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

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 (59) hide show
  1. package/README.md +56 -145
  2. package/dist/commerce/buyer-store.d.ts +93 -0
  3. package/dist/commerce/buyer-store.js +168 -0
  4. package/dist/commerce/buyer.d.ts +129 -0
  5. package/dist/commerce/buyer.js +631 -0
  6. package/dist/commerce/card.d.ts +5 -0
  7. package/dist/commerce/card.js +29 -0
  8. package/dist/commerce/chain.d.ts +24 -0
  9. package/dist/commerce/chain.js +146 -0
  10. package/dist/commerce/cli.d.ts +2 -0
  11. package/dist/commerce/cli.js +268 -0
  12. package/dist/commerce/client-runtime.d.ts +8 -0
  13. package/dist/commerce/client-runtime.js +7 -0
  14. package/dist/commerce/config.d.ts +7 -0
  15. package/dist/commerce/config.js +164 -0
  16. package/dist/commerce/envar-runtime.d.ts +46 -0
  17. package/dist/commerce/envar-runtime.js +256 -0
  18. package/dist/commerce/envar.d.ts +126 -0
  19. package/dist/commerce/envar.js +647 -0
  20. package/dist/commerce/http.d.ts +2 -0
  21. package/dist/commerce/http.js +34 -0
  22. package/dist/commerce/index.d.ts +4 -0
  23. package/dist/commerce/index.js +4 -0
  24. package/dist/commerce/management.d.ts +14 -0
  25. package/dist/commerce/management.js +138 -0
  26. package/dist/commerce/mpp-client.d.ts +59 -0
  27. package/dist/commerce/mpp-client.js +24 -0
  28. package/dist/commerce/mpp-runtime.d.ts +3 -0
  29. package/dist/commerce/mpp-runtime.js +3 -0
  30. package/dist/commerce/mpp.d.ts +53 -0
  31. package/dist/commerce/mpp.js +163 -0
  32. package/dist/commerce/peer-proxy.d.ts +44 -0
  33. package/dist/commerce/peer-proxy.js +667 -0
  34. package/dist/commerce/pricing.d.ts +12 -0
  35. package/dist/commerce/pricing.js +60 -0
  36. package/dist/commerce/runtime.d.ts +8 -0
  37. package/dist/commerce/runtime.js +8 -0
  38. package/dist/commerce/server.d.ts +58 -0
  39. package/dist/commerce/server.js +485 -0
  40. package/dist/commerce/store.d.ts +86 -0
  41. package/dist/commerce/store.js +466 -0
  42. package/dist/commerce/stripe-provider.d.ts +9 -0
  43. package/dist/commerce/stripe-provider.js +97 -0
  44. package/dist/commerce/types.d.ts +113 -0
  45. package/dist/commerce/types.js +8 -0
  46. package/dist/commerce/upstream.d.ts +6 -0
  47. package/dist/commerce/upstream.js +103 -0
  48. package/dist/commerce/vault.d.ts +8 -0
  49. package/dist/commerce/vault.js +55 -0
  50. package/dist/commerce/x402.d.ts +37 -0
  51. package/dist/commerce/x402.js +174 -0
  52. package/dist/index.js +3 -1
  53. package/examples/buyer-policy.json +18 -0
  54. package/examples/envar-integration-policy.json +18 -0
  55. package/examples/seller.json +52 -0
  56. package/package.json +75 -10
  57. package/schemas/buyer-policy.schema.json +148 -0
  58. package/schemas/envarpay.config.schema.json +376 -0
  59. package/schemas/quote.schema.json +91 -0
@@ -0,0 +1,4 @@
1
+ export * from './types.js';
2
+ export * from './config.js';
3
+ export * from './pricing.js';
4
+ export * from './card.js';
@@ -0,0 +1,4 @@
1
+ export * from './types.js';
2
+ export * from './config.js';
3
+ export * from './pricing.js';
4
+ export * from './card.js';
@@ -0,0 +1,14 @@
1
+ import { CommerceBuyer } from './buyer.js';
2
+ export interface BuyerManagementOptions {
3
+ buyer: CommerceBuyer;
4
+ authenticate: (request: Request) => Promise<string | null>;
5
+ origin: string;
6
+ }
7
+ /** Private API: authentication binds every purchase to its caller. No CORS or public wallet proxy. */
8
+ export declare class BuyerManagement {
9
+ private readonly options;
10
+ readonly origin: string;
11
+ constructor(options: BuyerManagementOptions);
12
+ handle(request: Request): Promise<Response>;
13
+ }
14
+ export declare function listenBuyerManagement(management: BuyerManagement, origin?: string, host?: string, port?: number): import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>;
@@ -0,0 +1,138 @@
1
+ import { createServer } from 'node:http';
2
+ import { Readable } from 'node:stream';
3
+ import { CommerceError } from './types.js';
4
+ import { validateUrl } from './config.js';
5
+ const loopback = (host) => ['localhost', '127.0.0.1', '[::1]', '::1'].includes(host);
6
+ const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
7
+ async function jsonBody(request) {
8
+ if (!request.headers.get('Content-Type')?.toLowerCase().startsWith('application/json'))
9
+ throw new CommerceError('invalid_content_type', 'Send application/json');
10
+ let size = 0;
11
+ const chunks = [];
12
+ if (request.body) {
13
+ const reader = request.body.getReader();
14
+ for (;;) {
15
+ const { value, done } = await reader.read();
16
+ if (done)
17
+ break;
18
+ size += value.byteLength;
19
+ if (size > 1024 * 1024) {
20
+ await reader.cancel();
21
+ throw new CommerceError('request_too_large', 'Management request exceeds 1 MiB');
22
+ }
23
+ chunks.push(value);
24
+ }
25
+ }
26
+ let value;
27
+ try {
28
+ value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
29
+ }
30
+ catch {
31
+ throw new CommerceError('invalid_json', 'Valid JSON object required');
32
+ }
33
+ if (!value || typeof value !== 'object' || Array.isArray(value))
34
+ throw new CommerceError('invalid_json', 'Valid JSON object required');
35
+ return value;
36
+ }
37
+ function exactKeys(value, expected) {
38
+ if (Object.keys(value).some(key => !expected.includes(key)) || expected.some(key => !Object.hasOwn(value, key)))
39
+ throw new CommerceError('invalid_request', 'Request fields do not match this management operation');
40
+ }
41
+ /** Private API: authentication binds every purchase to its caller. No CORS or public wallet proxy. */
42
+ export class BuyerManagement {
43
+ options;
44
+ origin;
45
+ constructor(options) {
46
+ this.options = options;
47
+ const url = validateUrl(options.origin, true);
48
+ if (url.pathname !== '/' || url.search || (!loopback(url.hostname) && url.protocol !== 'https:'))
49
+ throw new CommerceError('management_origin', 'Use a loopback origin or an explicitly configured HTTPS management origin');
50
+ this.origin = url.origin;
51
+ }
52
+ async handle(request) {
53
+ const url = new URL(request.url);
54
+ if (url.origin !== this.origin || (request.headers.get('Host') && request.headers.get('Host') !== url.host))
55
+ return Response.json({ error: 'invalid_host' }, { status: 421, headers });
56
+ if (request.headers.get('Origin') && request.headers.get('Origin') !== this.origin)
57
+ return Response.json({ error: 'origin_not_allowed' }, { status: 403, headers });
58
+ if (request.method === 'OPTIONS')
59
+ return Response.json({ error: 'method_not_allowed' }, { status: 405, headers });
60
+ try {
61
+ const caller = await this.options.authenticate(request);
62
+ if (!caller)
63
+ return Response.json({ error: 'authentication_required' }, { status: 401, headers: { ...headers, 'WWW-Authenticate': 'Bearer' } });
64
+ if (url.search)
65
+ throw new CommerceError('invalid_request', 'Management query parameters are not supported');
66
+ if (request.method === 'GET' && url.pathname === '/management/v1/policy')
67
+ return Response.json(this.options.buyer.policySnapshot(), { headers });
68
+ if (request.method === 'POST' && url.pathname === '/management/v1/purchases/preview') {
69
+ const body = await jsonBody(request);
70
+ exactKeys(body, ['cardUrl', 'messageId', 'input', 'offerId']);
71
+ return Response.json(await this.options.buyer.preview(caller, body), { headers });
72
+ }
73
+ if (request.method === 'POST' && url.pathname === '/management/v1/purchases/confirm') {
74
+ const body = await jsonBody(request);
75
+ exactKeys(body, ['previewId', 'quoteToken', 'messageId']);
76
+ if (['previewId', 'quoteToken', 'messageId'].some(key => typeof body[key] !== 'string'))
77
+ throw new CommerceError('invalid_request', 'Confirmation identifiers must be strings');
78
+ return Response.json(await this.options.buyer.confirm(caller, body), { headers });
79
+ }
80
+ const match = /^\/management\/v1\/purchases\/([0-9a-f-]{36})(\/(?:recover|continue))?$/.exec(url.pathname);
81
+ if (match && request.method === 'GET' && !match[2])
82
+ return Response.json(this.options.buyer.get(caller, match[1]), { headers });
83
+ if (match && request.method === 'POST' && match[2] === '/continue') {
84
+ const body = await jsonBody(request);
85
+ exactKeys(body, ['messageId', 'input']);
86
+ if (typeof body.messageId !== 'string')
87
+ throw new CommerceError('invalid_request', 'Continuation message ID must be a string');
88
+ return Response.json(await this.options.buyer.continue(caller, match[1], body), { headers });
89
+ }
90
+ if (match && request.method === 'POST' && match[2] === '/recover') {
91
+ if (request.body) {
92
+ const body = await jsonBody(request);
93
+ exactKeys(body, []);
94
+ }
95
+ return Response.json(await this.options.buyer.recover(caller, match[1]), { headers });
96
+ }
97
+ return Response.json({ error: 'not_found' }, { status: 404, headers });
98
+ }
99
+ catch (error) {
100
+ const code = error instanceof CommerceError ? error.code : 'request_failed';
101
+ const status = code === 'purchase_not_found' ? 404 : code === 'request_too_large' ? 413 : ['purchase_conflict', 'quote_expired', 'approval_mismatch', 'budget_exceeded', 'payments_disabled'].includes(code) ? 409 : 400;
102
+ return Response.json({ error: code }, { status, headers });
103
+ }
104
+ }
105
+ }
106
+ export function listenBuyerManagement(management, origin = management.origin, host = '127.0.0.1', port = 4021) {
107
+ if (new URL(origin).origin !== management.origin || (!loopback(host) && new URL(origin).protocol !== 'https:'))
108
+ throw new CommerceError('management_bind', 'Remote management requires an explicit HTTPS reverse proxy origin');
109
+ const server = createServer(async (req, res) => {
110
+ try {
111
+ if (req.headers.host !== new URL(origin).host) {
112
+ res.writeHead(421, headers);
113
+ res.end('{"error":"invalid_host"}');
114
+ return;
115
+ }
116
+ const incoming = new Headers();
117
+ for (const [key, value] of Object.entries(req.headers))
118
+ if (value !== undefined)
119
+ incoming.set(key, Array.isArray(value) ? value.join(',') : value);
120
+ const request = new Request(new URL(req.url ?? '/', origin), { method: req.method, headers: incoming, ...(req.method !== 'GET' && req.method !== 'HEAD' ? { body: Readable.toWeb(req), duplex: 'half' } : {}) });
121
+ const response = await management.handle(request);
122
+ res.writeHead(response.status, Object.fromEntries(response.headers));
123
+ if (response.body)
124
+ Readable.fromWeb(response.body).pipe(res);
125
+ else
126
+ res.end();
127
+ }
128
+ catch {
129
+ if (!res.headersSent)
130
+ res.writeHead(500, headers);
131
+ res.end('{"error":"request_failed"}');
132
+ }
133
+ });
134
+ server.requestTimeout = 30000;
135
+ server.headersTimeout = 15000;
136
+ server.listen(port, host);
137
+ return server;
138
+ }
@@ -0,0 +1,59 @@
1
+ import { Challenge, Receipt } from 'mppx';
2
+ import { charge } from 'mppx/stripe/client';
3
+ import type { BuyerQuote } from './buyer-store.js';
4
+ export type MppReceipt = Receipt.Receipt & {
5
+ amount: string;
6
+ currency: 'usd';
7
+ merchant: string;
8
+ accountId?: string;
9
+ livemode: boolean;
10
+ };
11
+ export interface MppTokenOperation {
12
+ operationId: string;
13
+ idempotencyKey: string;
14
+ purchaseId: string;
15
+ quote: BuyerQuote;
16
+ mode: 'test' | 'live';
17
+ amount: string;
18
+ currency: string;
19
+ networkId: string;
20
+ expiresAt: number;
21
+ paymentMethod: string;
22
+ metadata?: Record<string, string>;
23
+ challenge: Challenge.Challenge;
24
+ }
25
+ export interface MppVerificationContext {
26
+ purchaseId: string;
27
+ quote: BuyerQuote;
28
+ mode: 'test' | 'live';
29
+ operationId: string;
30
+ }
31
+ export interface MppBuyerOptions {
32
+ /** Stable private buyer/PSP identity; never inferred from seller metadata. */
33
+ payer: string;
34
+ mode: 'test' | 'live';
35
+ paymentMethod: string;
36
+ createToken: (operation: MppTokenOperation) => Promise<string>;
37
+ /** Read the original operation only; never create another SPT on absence. */
38
+ recoverToken?: (operation: MppTokenOperation) => Promise<string | undefined>;
39
+ /** Independently read provider state and match account/mode/merchant/amount/order. */
40
+ verifyReceipt: (receipt: MppReceipt, context: MppVerificationContext) => Promise<boolean>;
41
+ }
42
+ export interface SavedMppAuthorization {
43
+ protocol: 'mpp';
44
+ credential: string;
45
+ challenge: Challenge.Challenge;
46
+ fingerprint: string;
47
+ operationId: string;
48
+ }
49
+ export interface SavedMppToken {
50
+ protocol: 'mpp-token';
51
+ token: string;
52
+ fingerprint: string;
53
+ operationId: string;
54
+ }
55
+ /** Native SDK preparation only; no fetch polyfill or automatic payment retry. */
56
+ export declare function prepareMppCredential(challenge: Challenge.Challenge, paymentMethod: string, createToken: (parameters: charge.OnChallengeParameters) => Promise<string>): Promise<string>;
57
+ export declare function validateMppCredential(value: string, challenge: Challenge.Challenge): void;
58
+ export declare function assertMppDigest(challenge: Challenge.Challenge, body: string): void;
59
+ export declare function publicMppReceipt(value: MppReceipt): MppReceipt;
@@ -0,0 +1,24 @@
1
+ import { Challenge, Credential } from 'mppx';
2
+ import { Mppx } from 'mppx/client';
3
+ import { charge } from 'mppx/stripe/client';
4
+ import { createHash } from 'node:crypto';
5
+ import { CommerceError } from './types.js';
6
+ /** Native SDK preparation only; no fetch polyfill or automatic payment retry. */
7
+ export async function prepareMppCredential(challenge, paymentMethod, createToken) {
8
+ const client = Mppx.create({ polyfill: false, maxPaymentRetries: 0, methods: [charge({ paymentMethod, createToken })] });
9
+ const payment = await client.preparePayment(new Response(null, { status: 402, headers: { 'WWW-Authenticate': Challenge.serialize(challenge) } }));
10
+ return payment.createCredential();
11
+ }
12
+ export function validateMppCredential(value, challenge) {
13
+ const credential = Credential.deserialize(value);
14
+ const payload = credential.payload;
15
+ if (Challenge.serialize(credential.challenge) !== Challenge.serialize(challenge) || payload.externalId !== challenge.request.externalId || typeof payload.spt !== 'string' || !/^spt_[A-Za-z0-9_]+$/.test(payload.spt))
16
+ throw new CommerceError('mpp_credential_binding', 'Native MPP credential differs from the original challenge');
17
+ }
18
+ export function assertMppDigest(challenge, body) {
19
+ if (challenge.digest && challenge.digest !== `sha-256=:${createHash('sha256').update(body).digest('base64')}:`)
20
+ throw new CommerceError('mpp_request_digest', 'MPP challenge is bound to different request bytes');
21
+ }
22
+ export function publicMppReceipt(value) {
23
+ return { method: value.method, status: value.status, reference: value.reference, timestamp: value.timestamp, ...(value.externalId ? { externalId: value.externalId } : {}), amount: value.amount, currency: value.currency, merchant: value.merchant, livemode: value.livemode, ...(value.accountId ? { accountId: value.accountId } : {}) };
24
+ }
@@ -0,0 +1,3 @@
1
+ /** Opt-in MPP server adapter. Account eligibility is checked at startup. */
2
+ export * from './mpp.js';
3
+ export * from './stripe-provider.js';
@@ -0,0 +1,3 @@
1
+ /** Opt-in MPP server adapter. Account eligibility is checked at startup. */
2
+ export * from './mpp.js';
3
+ export * from './stripe-provider.js';
@@ -0,0 +1,53 @@
1
+ import { CommerceStore, type OrderRecord } from './store.js';
2
+ import { CredentialVault } from './vault.js';
3
+ import type { GateResult } from './x402.js';
4
+ export interface StripeIntent {
5
+ id: string;
6
+ status: string;
7
+ amount: number;
8
+ amount_received?: number;
9
+ currency: string;
10
+ livemode: boolean;
11
+ metadata: Record<string, string>;
12
+ lastResponse?: {
13
+ headers?: Record<string, string>;
14
+ };
15
+ }
16
+ export interface StripeProvider {
17
+ accountId: string;
18
+ merchantProfile: string;
19
+ mode: 'test' | 'live';
20
+ assertReady(): Promise<void>;
21
+ create(params: Record<string, unknown>, options: {
22
+ idempotencyKey: string;
23
+ apiVersion: string;
24
+ }): Promise<StripeIntent>;
25
+ retrieve(id: string): Promise<StripeIntent>;
26
+ findOriginal(orderId: string): Promise<StripeIntent | undefined>;
27
+ }
28
+ export interface MppGateOptions {
29
+ store: CommerceStore;
30
+ vault: CredentialVault;
31
+ origin: string;
32
+ hmacSecret: string;
33
+ providers: Readonly<Record<string, StripeProvider>>;
34
+ }
35
+ /** Native MPP Stripe charge; only a verified PSP success may queue a task. */
36
+ export declare class MppGate {
37
+ private options;
38
+ constructor(options: MppGateOptions);
39
+ merchantRecipient(accountRef: string): string | undefined;
40
+ check(): Promise<void>;
41
+ private provider;
42
+ private optionsFor;
43
+ private runtime;
44
+ private bound;
45
+ private verified;
46
+ private receipt;
47
+ private headers;
48
+ recover(orderId: string, caller: string): Promise<{
49
+ state: string;
50
+ taskId: string;
51
+ }>;
52
+ handle(request: Request, order: OrderRecord): Promise<GateResult>;
53
+ }
@@ -0,0 +1,163 @@
1
+ import { Mppx } from 'mppx/server/core';
2
+ import { charge } from 'mppx/stripe/server';
3
+ import { Challenge, Credential, Receipt } from 'mppx';
4
+ import { CommerceError } from './types.js';
5
+ import { digest } from './config.js';
6
+ /** Native MPP Stripe charge; only a verified PSP success may queue a task. */
7
+ export class MppGate {
8
+ options;
9
+ constructor(options) {
10
+ this.options = options;
11
+ if (Buffer.byteLength(options.hmacSecret) < 32)
12
+ throw new CommerceError('mpp_hmac_key', 'MPP challenge key must contain at least 32 bytes');
13
+ }
14
+ merchantRecipient(accountRef) { return this.options.providers[accountRef]?.merchantProfile; }
15
+ async check() {
16
+ for (const provider of Object.values(this.options.providers))
17
+ await provider.assertReady();
18
+ }
19
+ provider(order) {
20
+ const profile = order.quote.paymentProfile;
21
+ if (profile?.adapter !== 'mpp')
22
+ throw new CommerceError('payment_method_unavailable', 'Not an MPP offer');
23
+ const provider = this.options.providers[profile.accountRef];
24
+ if (!provider || provider.merchantProfile !== order.quote.recipient)
25
+ throw new CommerceError('merchant_not_ready', 'Configured merchant must match original quote');
26
+ return provider;
27
+ }
28
+ optionsFor(order, provider) {
29
+ const amount = BigInt(order.quote.amount);
30
+ if (amount < 50n || amount > 99999999n)
31
+ throw new CommerceError('stripe_amount', 'Stripe USD purchase must be 50–99999999 cents');
32
+ return {
33
+ amount: `${amount / 100n}.${(amount % 100n).toString().padStart(2, '0')}`,
34
+ currency: 'usd', decimals: 2, externalId: order.quote.quoteId,
35
+ recipient: provider.merchantProfile, networkId: provider.merchantProfile,
36
+ expires: order.quote.expiresAt,
37
+ scope: `service:${order.serviceRevision}/offer:${order.offerId}`,
38
+ meta: {
39
+ caller: digest(order.caller), inputDigest: order.inputDigest, termsDigest: order.quote.termsDigest,
40
+ messageId: order.messageId, quoteId: order.quote.quoteId, serviceId: order.quote.serviceId, serviceRevision: String(order.quote.serviceRevision),
41
+ offerId: order.offerId, amount: order.quote.amount, currency: 'usd', recipient: provider.merchantProfile, expiresAt: order.quote.expiresAt,
42
+ },
43
+ metadata: { envarpay_order: order.id, envarpay_quote: order.quote.quoteId, envarpay_terms: order.quote.termsDigest },
44
+ };
45
+ }
46
+ runtime(create) {
47
+ return Mppx.create({
48
+ methods: [charge({ client: { paymentIntents: { create } }, paymentMethodTypes: ['card'] })],
49
+ realm: new URL(this.options.origin).host, secretKey: this.options.hmacSecret, requiresAuth: true,
50
+ });
51
+ }
52
+ bound(intent, order, provider) {
53
+ return intent.livemode === (provider.mode === 'live') && Number.isSafeInteger(intent.amount) && String(intent.amount) === order.quote.amount &&
54
+ intent.currency === 'usd' && intent.metadata?.envarpay_order === order.id && intent.metadata.envarpay_quote === order.quote.quoteId && intent.metadata.envarpay_terms === order.quote.termsDigest;
55
+ }
56
+ verified(intent, order, provider) {
57
+ return this.bound(intent, order, provider) && intent.status === 'succeeded' && intent.livemode === (provider.mode === 'live') &&
58
+ Number.isSafeInteger(intent.amount) && String(intent.amount) === order.quote.amount &&
59
+ Number.isSafeInteger(intent.amount_received) && String(intent.amount_received) === order.quote.amount &&
60
+ intent.currency === 'usd' && intent.metadata?.envarpay_order === order.id &&
61
+ intent.metadata.envarpay_quote === order.quote.quoteId && intent.metadata.envarpay_terms === order.quote.termsDigest;
62
+ }
63
+ receipt(intent, order, provider) {
64
+ return {
65
+ method: 'stripe', status: 'success', reference: intent.id, externalId: order.quote.quoteId,
66
+ timestamp: new Date().toISOString(), amount: order.quote.amount, currency: 'usd',
67
+ merchant: provider.merchantProfile, accountId: provider.accountId, livemode: intent.livemode,
68
+ };
69
+ }
70
+ headers(receipt) {
71
+ return { headers: { 'Payment-Receipt': Receipt.serialize(receipt) } };
72
+ }
73
+ async recover(orderId, caller) {
74
+ const order = this.options.store.getOrder(orderId, caller);
75
+ if (!order)
76
+ throw new CommerceError('order_not_found', 'Original order not found');
77
+ const attempt = this.options.store.paymentForOrder(order.id);
78
+ if (!attempt)
79
+ throw new CommerceError('attempt_not_found', 'No original MPP attempt');
80
+ if (attempt.state === 'confirmed' || attempt.state === 'rejected')
81
+ return { state: attempt.state, taskId: order.taskId };
82
+ const provider = this.provider(order);
83
+ const saved = this.options.vault.get(attempt.credentialRef);
84
+ if (saved.quoteId !== order.quote.quoteId || saved.accountId !== provider.accountId || saved.mode !== provider.mode)
85
+ throw new CommerceError('recovery_conflict', 'Original provider binding changed');
86
+ // Read only: Stripe idempotency cache expiry must never cause a second charge.
87
+ const reference = attempt.receipt?.reference;
88
+ const intent = typeof reference === 'string' && /^pi_[A-Za-z0-9]+$/.test(reference)
89
+ ? await provider.retrieve(reference) : await provider.findOriginal(order.id);
90
+ if (!intent)
91
+ return { state: 'unknown', taskId: order.taskId };
92
+ if (this.verified(intent, order, provider)) {
93
+ this.options.store.recordSettlement(attempt.id, 'confirmed', this.receipt(intent, order, provider));
94
+ return { state: 'confirmed', taskId: order.taskId };
95
+ }
96
+ // An action-required or declined intent might still change later. Only a
97
+ // canceled PSP intent is definitive non-payment; do not release on timeout.
98
+ const canceled = this.bound(intent, order, provider) && intent.status === 'canceled' && intent.amount_received === 0;
99
+ const state = canceled ? 'rejected' : 'unknown';
100
+ this.options.store.recordSettlement(attempt.id, state, { reference: intent.id, providerState: intent.status, livemode: intent.livemode });
101
+ return { state, taskId: order.taskId };
102
+ }
103
+ async handle(request, order) {
104
+ const provider = this.provider(order);
105
+ const prior = this.options.store.paymentForOrder(order.id);
106
+ if (order.paymentState === 'confirmed' && prior?.receipt)
107
+ return this.headers(prior.receipt);
108
+ if (['settling', 'unknown'].includes(order.paymentState)) {
109
+ const recovered = await this.recover(order.id, order.caller);
110
+ if (recovered.state === 'confirmed')
111
+ return this.headers(this.options.store.paymentForOrder(order.id).receipt);
112
+ return { response: Response.json({ error: 'payment_reconciliation_required', state: recovered.state }, { status: 503 }) };
113
+ }
114
+ if (Date.now() >= Date.parse(order.quote.expiresAt))
115
+ return { response: Response.json({ error: 'quote_expired' }, { status: 409 }) };
116
+ await provider.assertReady();
117
+ const runtime = this.runtime(provider.create.bind(provider));
118
+ const options = this.optionsFor(order, provider);
119
+ let challenge = this.options.store.paymentChallenge(order.id);
120
+ if (!challenge)
121
+ challenge = this.options.store.freezePaymentChallenge(order.id, await runtime.challenge.stripe.charge(options));
122
+ const unpaid = () => ({ response: new Response('{}', { status: 402, headers: {
123
+ 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'WWW-Authenticate': Challenge.serialize(challenge),
124
+ } }) });
125
+ const header = request.headers.get('Payment-Authorization');
126
+ if (!header)
127
+ return unpaid();
128
+ if (header.length > 32768)
129
+ throw new CommerceError('payment_payload_size', 'Payment credential exceeds limit');
130
+ let credential;
131
+ try {
132
+ credential = Credential.deserialize(header);
133
+ }
134
+ catch {
135
+ throw new CommerceError('invalid_payment', 'Malformed MPP credential');
136
+ }
137
+ // Equality against a server-persisted native challenge complements the SDK HMAC.
138
+ if (Challenge.serialize(credential.challenge) !== Challenge.serialize(challenge) || !Challenge.verify(credential.challenge, { secretKey: this.options.hmacSecret }) ||
139
+ credential.payload.externalId !== order.quote.quoteId || !/^spt_[A-Za-z0-9_]+$/.test(credential.payload.spt)) {
140
+ throw new CommerceError('quote_mismatch', 'MPP credential does not match the original order');
141
+ }
142
+ const saved = { quoteId: order.quote.quoteId, accountId: provider.accountId, mode: provider.mode, credential };
143
+ const ref = this.options.vault.put(saved);
144
+ const attempt = this.options.store.reservePayment(order.id, order.caller, digest({ protocol: 'mpp', spt: credential.payload.spt }), ref);
145
+ const wrapped = this.runtime(async (params, requestOptions) => {
146
+ const intent = await provider.create(params, requestOptions);
147
+ // Preserve original PI even if SDK reports requires_action or replay.
148
+ this.options.store.recordSettlement(attempt, 'unknown', { reference: intent.id, providerState: intent.status, livemode: intent.livemode });
149
+ return intent;
150
+ });
151
+ try {
152
+ await wrapped.broadcastCredential(credential, { realm: new URL(this.options.origin).host, request: options, scope: options.scope, meta: options.meta });
153
+ }
154
+ catch {
155
+ if (this.options.store.paymentForOrder(order.id)?.state === 'settling')
156
+ this.options.store.recordSettlement(attempt, 'unknown', {});
157
+ }
158
+ const recovered = await this.recover(order.id, order.caller);
159
+ if (recovered.state === 'confirmed')
160
+ return this.headers(this.options.store.paymentForOrder(order.id).receipt);
161
+ return { response: Response.json({ error: 'payment_not_confirmed', state: recovered.state }, { status: 503 }) };
162
+ }
163
+ }
@@ -0,0 +1,44 @@
1
+ import { AgentCard, Task, type SendMessageRequest } from "@a2a-js/sdk";
2
+ import { CommerceBuyer } from "./buyer.js";
3
+ export interface PeerProxyOptions {
4
+ buyer: CommerceBuyer;
5
+ buyerCaller: string;
6
+ peerId: string;
7
+ offerId: string;
8
+ origin: string;
9
+ stateDirectory: string;
10
+ tokens: Readonly<Record<string, string>>;
11
+ label?: string;
12
+ waitMilliseconds?: number;
13
+ /** Explicit opt-in for an isolated Docker/private HTTP network; public deployments require HTTPS. */
14
+ allowPrivateHttp?: boolean;
15
+ }
16
+ /** The proxy token is never a wallet-management token. One proxy is bound to one approved peer/offer. */
17
+ export declare class A2APeerProxy {
18
+ private readonly options;
19
+ readonly origin: string;
20
+ readonly card: AgentCard;
21
+ private readonly db;
22
+ private readonly lock;
23
+ private readonly rpc;
24
+ private readonly authenticate;
25
+ private readonly cardUrl;
26
+ private readonly waitMs;
27
+ private readonly active;
28
+ private stopped;
29
+ private closed;
30
+ constructor(options: PeerProxyOptions);
31
+ private row;
32
+ private phase;
33
+ private projection;
34
+ private exclusive;
35
+ private snapshot;
36
+ private wait;
37
+ send(params: SendMessageRequest, caller: string): Promise<Task>;
38
+ read(id: string, caller: string): Promise<Task>;
39
+ handle(request: Request): Promise<Response>;
40
+ assertListenHost(host: string): void;
41
+ stop(): Promise<void>;
42
+ close(): void;
43
+ }
44
+ export declare function listenPeerProxy(proxy: A2APeerProxy, host?: string, port?: number): import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>;