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

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.
@@ -108,7 +108,7 @@ export class CommerceBuyer {
108
108
  this.fetchImpl = options.fetchImpl ?? fetch;
109
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))
110
110
  throw new CommerceError('invalid_signer', 'x402 peers require an EVM wallet and independent receipt verifier');
111
- if (this.policy.peers.some(p => p.protocol === 'mpp') && (!options.mpp || !options.mpp.payer || !/^pm_[A-Za-z0-9]+$/.test(options.mpp.paymentMethod) || !['test', 'live'].includes(options.mpp.mode) || typeof options.mpp.createToken !== 'function' || typeof options.mpp.verifyReceipt !== 'function'))
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.paymentMethod === 'pm_card_visa')) || !['test', 'live'].includes(options.mpp.mode) || typeof options.mpp.createToken !== 'function' || typeof options.mpp.verifyReceipt !== 'function'))
112
112
  throw new CommerceError('mpp_provider_missing', 'MPP peers require explicit token creation and independent provider verification');
113
113
  for (const peer of this.policy.peers)
114
114
  if (!(peer.mode === 'standard-a2a' && peer.authentication === 'none') && (!options.peerTokens[peer.id] || options.peerTokens[peer.id].length < 16))
@@ -123,15 +123,35 @@ async function run() {
123
123
  throw new CommerceError('buyer_key', 'Signer file must contain one 32-byte 0x-prefixed private key');
124
124
  signer = privateKeyToAccount(key);
125
125
  }
126
- if (needsMpp && !secrets.mppAdapterModule)
127
- throw new CommerceError('mpp_adapter_module', 'MPP peers require an explicit local adapter module for authorized funding and receipt verification');
128
- const mpp = needsMpp ? await loadMppAdapter(secrets.mppAdapterModule) : undefined;
126
+ if (secrets.mppAdapterModule && secrets.mppStripe)
127
+ throw new CommerceError('mpp_configuration', 'Choose the built-in Stripe issuer or one explicit adapter module');
128
+ if (needsMpp && !secrets.mppAdapterModule && !secrets.mppStripe)
129
+ throw new CommerceError('mpp_configuration', 'MPP peers require a configured Stripe issuer or explicit authorized funding adapter');
130
+ const buyerVault = secrets.vaultKeyFile ? new CredentialVault(join(dirname(values.state), 'buyer-authorizations'), privateFile(secrets.vaultKeyFile)) : undefined;
131
+ let mpp;
132
+ if (needsMpp && secrets.mppStripe) {
133
+ const configured = secrets.mppStripe;
134
+ if (!configured.secretKeyFile || !configured.sellers || typeof configured.sellers !== 'object' || Array.isArray(configured.sellers))
135
+ throw new CommerceError('mpp_configuration', 'Stripe issuer and seller read credentials must use private key files');
136
+ const { createStripeMppBuyer } = await import('./stripe-issuer.js');
137
+ const adapter = createStripeMppBuyer({
138
+ payer: configured.payer, mode: configured.mode, issuerAccountId: configured.issuerAccountId,
139
+ secretKey: privateFile(configured.secretKeyFile).toString('utf8').trim(), paymentMethod: configured.paymentMethod,
140
+ sellers: Object.fromEntries(Object.entries(configured.sellers).map(([id, seller]) => [id, { accountId: seller.accountId, secretKey: privateFile(seller.secretKeyFile).toString('utf8').trim() }])),
141
+ ...(configured.issuance ? { issuance: configured.issuance } : {}), ...(configured.returnUrl ? { returnUrl: configured.returnUrl } : {}),
142
+ }, { statePath: values.state + '.stripe-issuer.sqlite3', vault: buyerVault });
143
+ closers.push(() => adapter.close());
144
+ await adapter.assertReady();
145
+ mpp = adapter;
146
+ }
147
+ else if (needsMpp)
148
+ mpp = await loadMppAdapter(secrets.mppAdapterModule);
129
149
  const { BuyerStore } = await import('./buyer-store.js');
130
150
  const { CommerceBuyer } = await import('./buyer.js');
131
151
  const { BuyerManagement, listenBuyerManagement } = await import('./management.js');
132
152
  const store = new BuyerStore(values.state);
133
153
  closers.push(() => store.close());
134
- const buyer = new CommerceBuyer({ policy, store, vault: secrets.vaultKeyFile ? new CredentialVault(join(dirname(values.state), 'buyer-authorizations'), privateFile(secrets.vaultKeyFile)) : undefined,
154
+ const buyer = new CommerceBuyer({ policy, store, vault: buyerVault,
135
155
  signer, mpp, peerTokens: secrets.peerTokens, peerEndpoints: secrets.peerEndpoints,
136
156
  ...(needsEvm ? { verifyReceipt: evmReceiptVerifier(secrets.rpcUrls), ...evmSettlementRecovery(secrets.rpcUrls) } : {}) });
137
157
  closers.push(() => buyer.stop());
@@ -2,6 +2,8 @@ import { type BuyerPolicy, type CommerceConfig, type PaymentProfile } from './ty
2
2
  export declare function atomic(value: string): bigint;
3
3
  export declare function validateUrl(value: string, allowPrivate?: boolean): URL;
4
4
  export declare function currencyOf(profile: PaymentProfile): string;
5
+ /** Stripe documents sandbox Business Profile IDs as profile_test_<opaque id>. */
6
+ export declare function isStripeProfileId(value: unknown): value is string;
5
7
  export declare function digest(value: unknown): string;
6
8
  export declare function loadCommerceConfig(value: unknown): CommerceConfig;
7
9
  export declare function loadBuyerPolicy(value: unknown): BuyerPolicy;
@@ -45,6 +45,10 @@ export function validateUrl(value, allowPrivate = false) {
45
45
  export function currencyOf(profile) {
46
46
  return profile.adapter === 'x402' ? `${profile.network}/erc20:${profile.asset.toLowerCase()}` : profile.currency;
47
47
  }
48
+ /** Stripe documents sandbox Business Profile IDs as profile_test_<opaque id>. */
49
+ export function isStripeProfileId(value) {
50
+ return typeof value === 'string' && /^profile_(?:test_)?[A-Za-z0-9]{1,120}$/.test(value);
51
+ }
48
52
  function assertJson(value) {
49
53
  if (typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))
50
54
  throw new CommerceError('unsafe_json_number', 'Use strings for integers outside the safe JSON number range');
@@ -172,7 +176,7 @@ export function loadBuyerPolicy(value) {
172
176
  throw new CommerceError('invalid_recipient', 'x402 peers require a nonzero EVM recipient and USDC asset');
173
177
  peer.recipient = peer.recipient.toLowerCase();
174
178
  }
175
- else if (peer.currency !== 'usd' || !/^profile_[A-Za-z0-9]+$/.test(peer.recipient))
179
+ else if (peer.currency !== 'usd' || !isStripeProfileId(peer.recipient))
176
180
  throw new CommerceError('invalid_recipient', 'MPP peers require USD and a verified Stripe merchant profile');
177
181
  const limit = limits.get(peer.currency);
178
182
  if (limit === undefined || atomic(peer.maxPerPurchase) > limit)
@@ -1,3 +1,4 @@
1
1
  /** Opt-in MPP server adapter. Account eligibility is checked at startup. */
2
2
  export * from './mpp.js';
3
3
  export * from './stripe-provider.js';
4
+ export * from './stripe-issuer.js';
@@ -1,3 +1,4 @@
1
1
  /** Opt-in MPP server adapter. Account eligibility is checked at startup. */
2
2
  export * from './mpp.js';
3
3
  export * from './stripe-provider.js';
4
+ export * from './stripe-issuer.js';
@@ -1,7 +1,7 @@
1
1
  import { timingSafeEqual } from 'node:crypto';
2
2
  import { AgentCard, Role, Task, TaskState, SendMessageRequest } from '@a2a-js/sdk';
3
3
  import { DefaultRequestHandler, JsonRpcTransportHandler, ServerCallContext } from '@a2a-js/sdk/server';
4
- import { RequestMalformedError, TaskNotFoundError, UnsupportedOperationError } from '@a2a-js/sdk/errors';
4
+ import { ContentTypeNotSupportedError, RequestMalformedError, TaskNotFoundError, UnsupportedOperationError, VersionNotSupportedError } from '@a2a-js/sdk/errors';
5
5
  import { Ajv2020 } from 'ajv/dist/2020.js';
6
6
  import addFormatsImport from 'ajv-formats';
7
7
  import { createOfferCard, offerPath } from './card.js';
@@ -267,8 +267,6 @@ export class CommerceServer {
267
267
  const caller = await this.options.authenticate(request);
268
268
  if (!caller)
269
269
  return Response.json({ error: 'authentication_required' }, { status: 401, headers: { 'WWW-Authenticate': 'Bearer', 'Cache-Control': 'no-store' } });
270
- if (request.headers.get('A2A-Version') !== '1.0')
271
- return Response.json({ error: 'A2A-Version 1.0 required' }, { status: 400 });
272
270
  if (Number(request.headers.get('Content-Length') ?? 0) > 1024 * 1024)
273
271
  return Response.json({ error: 'request_too_large' }, { status: 413 });
274
272
  let size = 0;
@@ -291,12 +289,18 @@ export class CommerceServer {
291
289
  let requestId = null;
292
290
  try {
293
291
  const raw = JSON.parse(body);
294
- if (raw.method === 'SendMessage' && this.workerFailed)
295
- return Response.json({ error: 'worker_unavailable' }, { status: 503, headers: { 'Cache-Control': 'no-store' } });
296
292
  if (!raw || Array.isArray(raw) || typeof raw !== 'object')
297
293
  throw new RequestMalformedError('A single JSONRPC object is required');
298
294
  requestId = raw.id ?? null;
299
295
  const params = raw.params;
296
+ // Parse only the bounded envelope to preserve its request ID. Reject these
297
+ // transport conditions before any quote, payment gate or Task dispatch.
298
+ if (request.headers.get('A2A-Version') !== '1.0')
299
+ throw new VersionNotSupportedError('This service supports A2A-Version 1.0');
300
+ if (request.headers.get('Content-Type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json')
301
+ throw new ContentTypeNotSupportedError('Send application/json to this JSON-RPC endpoint');
302
+ if (raw.method === 'SendMessage' && this.workerFailed)
303
+ return Response.json({ error: 'worker_unavailable' }, { status: 503, headers: { 'Cache-Control': 'no-store' } });
300
304
  if (params && typeof params === 'object' && 'tenant' in params && params.tenant !== route.tenant)
301
305
  return Response.json({ jsonrpc: '2.0', id: raw.id ?? null, error: { code: -32602, message: 'Invalid tenant' } }, { status: 200 });
302
306
  let paymentHeaders = {};
@@ -329,7 +333,7 @@ export class CommerceServer {
329
333
  }
330
334
  catch (error) {
331
335
  const mapped = error instanceof CommerceError ? { code: -32000, message: error.message, data: { code: error.code } } : JsonRpcTransportHandler.mapToJSONRPCError(error);
332
- return Response.json({ jsonrpc: '2.0', id: requestId, error: mapped }, { headers: { 'Cache-Control': 'no-store' } });
336
+ return Response.json({ jsonrpc: '2.0', id: requestId, error: mapped }, { headers: { 'A2A-Version': '1.0', 'Cache-Control': 'no-store' } });
333
337
  }
334
338
  }
335
339
  async continueTask(params, input, service, offerId, context) {
@@ -0,0 +1,39 @@
1
+ import { CredentialVault } from './vault.js';
2
+ import type { MppBuyerOptions } from './mpp-client.js';
3
+ /** Buyer and seller use the same public preview, independently of mppx internals. */
4
+ export declare const STRIPE_ISSUER_API_VERSION = "2026-09-30.preview";
5
+ export interface StripeSellerReadBinding {
6
+ accountId: string;
7
+ /** Account-scoped read credential for account, profile and PaymentIntent reads. */
8
+ secretKey: string;
9
+ }
10
+ export interface StripeMppBuyerConfig {
11
+ payer: string;
12
+ mode: 'test' | 'live';
13
+ issuerAccountId: string;
14
+ secretKey: string;
15
+ /** Previously owner-authorized PaymentMethod; this adapter never collects card data. */
16
+ paymentMethod: string;
17
+ sellers: Readonly<Record<string, StripeSellerReadBinding>>;
18
+ returnUrl?: string;
19
+ /** Real Stripe test infrastructure, selected explicitly; never a live fallback. */
20
+ issuance?: 'issued-token' | 'test-helper';
21
+ }
22
+ export interface StripeMppBuyerRuntime {
23
+ statePath: string;
24
+ vault: CredentialVault;
25
+ fetchImpl?: typeof fetch;
26
+ now?: () => number;
27
+ }
28
+ export interface StripeAuthenticationAction {
29
+ type: 'use_stripe_sdk';
30
+ hashedValue: string;
31
+ }
32
+ export interface StripeMppBuyerAdapter extends MppBuyerOptions {
33
+ assertReady(): Promise<void>;
34
+ close(): void;
35
+ /** Private owner-only API. Pass this value directly to Stripe.js, never logs. */
36
+ authenticationAction(operationId: string): Promise<StripeAuthenticationAction | undefined>;
37
+ }
38
+ /** Official SPT issuer with exact scope and original-ID-only recovery. No automatic funding. */
39
+ export declare function createStripeMppBuyer(input: StripeMppBuyerConfig, runtime: StripeMppBuyerRuntime): StripeMppBuyerAdapter;
@@ -0,0 +1,330 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { chmodSync, mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { Challenge } from 'mppx';
5
+ import { digest, isStripeProfileId } from './config.js';
6
+ import { CommerceError } from './types.js';
7
+ import { STRIPE_MPP_API_VERSION } from './stripe-provider.js';
8
+ /** Buyer and seller use the same public preview, independently of mppx internals. */
9
+ export const STRIPE_ISSUER_API_VERSION = STRIPE_MPP_API_VERSION;
10
+ const ACCOUNT = /^acct_[A-Za-z0-9]{1,128}$/;
11
+ const TOKEN = /^spt_[A-Za-z0-9_]{1,200}$/;
12
+ const PAYMENT_METHOD = /^pm_[A-Za-z0-9]{1,128}$/;
13
+ const OPERATION = /^[A-Za-z0-9_-]{1,200}$/;
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
15
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
16
+ /** Durable private references only; exact requests and tokens live in CredentialVault. */
17
+ class IssuerLedger {
18
+ db;
19
+ owner;
20
+ closed = false;
21
+ constructor(path) {
22
+ if (typeof path !== 'string' || !path.trim() || path === ':memory:')
23
+ throw new CommerceError('stripe_issuer_state', 'SPT issuance requires a durable private ledger');
24
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
25
+ this.owner = new DatabaseSync(path + '.owner.sqlite3');
26
+ chmodSync(path + '.owner.sqlite3', 0o600);
27
+ try {
28
+ this.owner.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE');
29
+ }
30
+ catch {
31
+ this.owner.close();
32
+ throw new CommerceError('store_locked', 'Stripe issuer ledger already has an active process');
33
+ }
34
+ try {
35
+ this.db = new DatabaseSync(path);
36
+ chmodSync(path, 0o600);
37
+ this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
38
+ CREATE TABLE IF NOT EXISTS stripe_issuer_meta(version INTEGER NOT NULL);
39
+ INSERT INTO stripe_issuer_meta SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM stripe_issuer_meta);
40
+ CREATE TABLE IF NOT EXISTS stripe_issuer_operations(
41
+ operation_id TEXT PRIMARY KEY, idempotency_key TEXT NOT NULL UNIQUE, purchase_id TEXT NOT NULL UNIQUE,
42
+ fingerprint TEXT NOT NULL, request_ref TEXT NOT NULL, token_ref TEXT,
43
+ created_at INTEGER NOT NULL
44
+ );`);
45
+ if (this.db.prepare('SELECT version FROM stripe_issuer_meta').get()?.version !== 1)
46
+ throw new CommerceError('store_version', 'Unsupported Stripe issuer ledger version');
47
+ }
48
+ catch (error) {
49
+ this.db?.close();
50
+ this.owner.close();
51
+ throw error;
52
+ }
53
+ }
54
+ get(id) {
55
+ const row = this.db.prepare('SELECT * FROM stripe_issuer_operations WHERE operation_id=?').get(id);
56
+ return row ? { operationId: String(row.operation_id), fingerprint: String(row.fingerprint), requestRef: String(row.request_ref), ...(row.token_ref ? { tokenRef: String(row.token_ref) } : {}) } : undefined;
57
+ }
58
+ insert(operation, fingerprint, requestRef, now) {
59
+ try {
60
+ this.db.prepare('INSERT INTO stripe_issuer_operations VALUES(?,?,?,?,?,NULL,?)').run(operation.operationId, operation.idempotencyKey, operation.purchaseId, fingerprint, requestRef, now);
61
+ }
62
+ catch {
63
+ throw new CommerceError('stripe_spt_operation_conflict', 'Purchase or idempotency identity already has an original issuer operation');
64
+ }
65
+ return this.get(operation.operationId);
66
+ }
67
+ token(id, ref) { this.db.prepare('UPDATE stripe_issuer_operations SET token_ref=? WHERE operation_id=? AND token_ref IS NULL').run(ref, id); }
68
+ close() {
69
+ if (this.closed)
70
+ return;
71
+ this.closed = true;
72
+ this.db.close();
73
+ this.owner?.exec('ROLLBACK');
74
+ this.owner?.close();
75
+ }
76
+ }
77
+ /** Official SPT issuer with exact scope and original-ID-only recovery. No automatic funding. */
78
+ export function createStripeMppBuyer(input, runtime) {
79
+ const config = structuredClone(input), issuance = config.issuance ?? 'issued-token';
80
+ const keyValid = (key) => typeof key === 'string' && new RegExp(`^(?:sk|rk)_${config.mode}_[A-Za-z0-9]{1,256}$`).test(key);
81
+ if (!['test', 'live'].includes(config.mode) || !['issued-token', 'test-helper'].includes(issuance) || !ACCOUNT.test(config.issuerAccountId) || !keyValid(config.secretKey) ||
82
+ typeof config.payer !== 'string' || !config.payer || config.payer.length > 200 || !config.sellers || !Object.keys(config.sellers).length ||
83
+ !(PAYMENT_METHOD.test(config.paymentMethod) || (issuance === 'test-helper' && config.paymentMethod === 'pm_card_visa'))) {
84
+ throw new CommerceError('stripe_issuer_configuration', 'Explicit Stripe issuer, funding method, seller read bindings and mode are required');
85
+ }
86
+ for (const [profile, seller] of Object.entries(config.sellers)) {
87
+ if (!isStripeProfileId(profile) || (config.mode === 'live' && profile.startsWith('profile_test_')) || !ACCOUNT.test(seller.accountId) || !keyValid(seller.secretKey))
88
+ throw new CommerceError('stripe_issuer_configuration', 'Seller profile, account and read credential must match the explicit mode');
89
+ }
90
+ if (issuance === 'test-helper' && (config.mode !== 'test' || config.paymentMethod !== 'pm_card_visa' || Object.values(config.sellers).some(s => s.accountId !== config.issuerAccountId))) {
91
+ throw new CommerceError('stripe_test_helper_configuration', 'Test helper requires pm_card_visa and the same explicitly configured test seller account');
92
+ }
93
+ if (config.returnUrl !== undefined) {
94
+ let url;
95
+ try {
96
+ url = new URL(config.returnUrl);
97
+ }
98
+ catch {
99
+ throw new CommerceError('stripe_return_url', 'Stripe return URL must be an owner-configured HTTPS URL');
100
+ }
101
+ if (url.protocol !== 'https:' || url.username || url.password || config.returnUrl.length > 2048)
102
+ throw new CommerceError('stripe_return_url', 'Stripe return URL must be an owner-configured HTTPS URL');
103
+ }
104
+ const ledger = new IssuerLedger(runtime.statePath), vault = runtime.vault;
105
+ const fetchImpl = runtime.fetchImpl ?? fetch, now = runtime.now ?? Date.now;
106
+ const inFlight = new Set();
107
+ async function call(secret, path, body, idempotencyKey) {
108
+ const headers = new Headers({ Authorization: `Bearer ${secret}`, 'Stripe-Version': STRIPE_ISSUER_API_VERSION });
109
+ if (body !== undefined)
110
+ headers.set('Content-Type', 'application/x-www-form-urlencoded');
111
+ if (idempotencyKey)
112
+ headers.set('Idempotency-Key', idempotencyKey);
113
+ let response;
114
+ try {
115
+ response = await fetchImpl(`https://api.stripe.com${path}`, { method: body === undefined ? 'GET' : 'POST', headers, ...(body === undefined ? {} : { body }), redirect: 'error', signal: AbortSignal.timeout(20000) });
116
+ }
117
+ catch {
118
+ throw new CommerceError('stripe_transport_unknown', 'Stripe request outcome is unknown; retain the original operation');
119
+ }
120
+ const reader = response.body?.getReader();
121
+ let size = 0;
122
+ const chunks = [];
123
+ try {
124
+ if (reader)
125
+ for (;;) {
126
+ const { value, done } = await reader.read();
127
+ if (done)
128
+ break;
129
+ size += value.byteLength;
130
+ if (size > 1024 * 1024) {
131
+ await reader.cancel();
132
+ throw new CommerceError('stripe_response_size', 'Stripe response exceeds limit');
133
+ }
134
+ chunks.push(value);
135
+ }
136
+ }
137
+ catch (error) {
138
+ if (error instanceof CommerceError && error.code === 'stripe_response_size')
139
+ throw error;
140
+ throw new CommerceError('stripe_transport_unknown', 'Stripe response was interrupted; retain the original operation');
141
+ }
142
+ if (!response.ok)
143
+ throw new CommerceError(response.status === 429 ? 'stripe_rate_limit' : 'stripe_request_failed', `Stripe request failed (HTTP ${response.status})`);
144
+ let value;
145
+ try {
146
+ value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
147
+ }
148
+ catch {
149
+ throw new CommerceError('stripe_response', 'Stripe returned invalid JSON');
150
+ }
151
+ if (!value || typeof value !== 'object' || Array.isArray(value))
152
+ throw new CommerceError('stripe_response', 'Stripe returned an invalid object');
153
+ return value;
154
+ }
155
+ async function account(secret, id) {
156
+ const result = await call(secret, '/v1/account');
157
+ if (result.id !== id || result.object !== 'account')
158
+ throw new CommerceError('stripe_account_mismatch', 'Stripe credential does not prove the expected account');
159
+ return result;
160
+ }
161
+ async function sellerReady(profile) {
162
+ const seller = config.sellers[profile];
163
+ if (!Object.hasOwn(config.sellers, profile) || !seller)
164
+ throw new CommerceError('stripe_seller_binding', 'Seller is not in the local read-verification allowlist');
165
+ const result = await account(seller.secretKey, seller.accountId);
166
+ if (config.mode === 'live' && (result.charges_enabled !== true || object(result.capabilities).card_payments !== 'active'))
167
+ throw new CommerceError('stripe_charges_disabled', 'Live seller card payments are not enabled');
168
+ const ownProfile = await call(seller.secretKey, '/v2/network/business_profiles/me');
169
+ // Require the documented mode as well as the credential-scoped /me identity.
170
+ if (ownProfile.id !== profile || ownProfile.object !== 'v2.network.business_profile' || ownProfile.livemode !== (config.mode === 'live'))
171
+ throw new CommerceError('stripe_profile_mismatch', 'Seller credential does not prove the expected profile and mode');
172
+ return seller;
173
+ }
174
+ function requestFor(operation) {
175
+ const quote = operation.quote, challenge = operation.challenge;
176
+ const details = object(challenge?.request?.methodDetails), metadata = object(operation.metadata);
177
+ const expiry = Math.floor(Date.parse(quote?.expiresAt) / 1000);
178
+ if (!OPERATION.test(operation.operationId) || operation.idempotencyKey !== operation.operationId || !UUID.test(operation.purchaseId) ||
179
+ operation.mode !== config.mode || operation.paymentMethod !== config.paymentMethod || operation.currency !== 'usd' || !/^[1-9][0-9]{1,7}$/.test(operation.amount) ||
180
+ BigInt(operation.amount) < 50n || BigInt(operation.amount) > 99999999n || operation.amount !== quote.amount || quote.currency !== 'usd' || quote.payer !== config.payer ||
181
+ operation.networkId !== quote.recipient || !isStripeProfileId(operation.networkId) || !Object.hasOwn(config.sellers, operation.networkId) || !Number.isSafeInteger(expiry) || operation.expiresAt !== expiry ||
182
+ challenge?.method !== 'stripe' || challenge.intent !== 'charge' || challenge.header !== 'Payment-Authorization' || challenge.expires !== quote.expiresAt ||
183
+ challenge.request.amount !== quote.amount || challenge.request.currency !== 'usd' || challenge.request.recipient !== quote.recipient || challenge.request.externalId !== quote.quoteId ||
184
+ details.networkId !== quote.recipient || digest(details.metadata ?? {}) !== digest(metadata) ||
185
+ metadata.envarpay_quote !== quote.quoteId || metadata.envarpay_terms !== quote.termsDigest || typeof metadata.envarpay_order !== 'string' || !UUID.test(metadata.envarpay_order) ||
186
+ typeof quote.quoteId !== 'string' || quote.quoteId.length > 500 || typeof quote.termsDigest !== 'string' || quote.termsDigest.length > 500 ||
187
+ Challenge.serialize(challenge).length > 32768)
188
+ throw new CommerceError('stripe_spt_binding', 'SPT request differs from the frozen purchase or approved funding scope');
189
+ const body = new URLSearchParams({ payment_method: operation.paymentMethod,
190
+ 'usage_limits[currency]': 'usd', 'usage_limits[max_amount]': operation.amount, 'usage_limits[expires_at]': String(expiry),
191
+ 'shared_metadata[envarpay_operation]': operation.operationId, 'shared_metadata[envarpay_purchase]': operation.purchaseId,
192
+ 'shared_metadata[envarpay_order]': metadata.envarpay_order, 'shared_metadata[envarpay_quote]': quote.quoteId, 'shared_metadata[envarpay_terms]': quote.termsDigest,
193
+ });
194
+ if (issuance === 'issued-token') {
195
+ body.set('seller_details[network_business_profile]', operation.networkId);
196
+ if (config.returnUrl)
197
+ body.set('return_url', config.returnUrl);
198
+ }
199
+ return { operation: structuredClone(operation), body: body.toString(), binding: { issuerAccountId: config.issuerAccountId, sellerAccountId: config.sellers[operation.networkId].accountId, issuance, ...(config.returnUrl ? { returnUrl: config.returnUrl } : {}) } };
200
+ }
201
+ function saved(row) {
202
+ const original = vault.get(row.requestRef);
203
+ if (row.operationId !== original.operation?.operationId || digest(original) !== row.fingerprint || digest(requestFor(original.operation)) !== row.fingerprint)
204
+ throw new CommerceError('stripe_spt_recovery_binding', 'Original issuer scope changed or failed integrity validation');
205
+ return original;
206
+ }
207
+ function tokenId(row) {
208
+ if (!row.tokenRef)
209
+ throw new CommerceError('stripe_spt_recovery_required', 'Original SPT ID is unknown; do not issue a replacement token');
210
+ const value = vault.get(row.tokenRef);
211
+ if (value.operationId !== row.operationId || value.fingerprint !== row.fingerprint || !TOKEN.test(value.id))
212
+ throw new CommerceError('stripe_spt_recovery_binding', 'Original encrypted SPT binding is invalid');
213
+ return value.id;
214
+ }
215
+ function validateToken(value, original, expectedId) {
216
+ const operation = original.operation, limits = object(value.usage_limits), meta = object(value.shared_metadata);
217
+ if (value.id !== expectedId || value.object !== (issuance === 'issued-token' ? 'shared_payment.issued_token' : 'shared_payment.granted_token') ||
218
+ value.livemode !== (config.mode === 'live') || limits.currency !== 'usd' || !Number.isSafeInteger(limits.max_amount) || String(limits.max_amount) !== operation.amount || limits.expires_at !== operation.expiresAt ||
219
+ meta.envarpay_operation !== operation.operationId || meta.envarpay_purchase !== operation.purchaseId || meta.envarpay_order !== operation.metadata.envarpay_order || meta.envarpay_quote !== operation.quote.quoteId || meta.envarpay_terms !== operation.quote.termsDigest ||
220
+ (issuance === 'issued-token' && (value.payment_method !== operation.paymentMethod || object(value.seller_details).network_business_profile !== operation.networkId)) ||
221
+ (issuance === 'test-helper' && object(value.payment_method_details).type !== 'card'))
222
+ throw new CommerceError('stripe_spt_response_binding', 'Stripe token does not match the original scope and mode');
223
+ }
224
+ function usable(value, original) {
225
+ if (value.status === 'requires_action')
226
+ throw new CommerceError('stripe_spt_requires_action', 'Original SPT requires customer authentication through Stripe.js');
227
+ const captured = object(object(value.usage_details).amount_captured);
228
+ if ((issuance === 'issued-token' && value.status !== 'active') || value.deactivated_at != null || value.deactivated_reason != null ||
229
+ captured.value !== 0 || captured.currency !== 'usd' || original.operation.expiresAt <= Math.floor(now() / 1000))
230
+ throw new CommerceError('stripe_spt_unusable', 'Original SPT is expired, consumed, inactive or not proven unused');
231
+ return String(value.id);
232
+ }
233
+ async function retrieve(row) {
234
+ const original = saved(row), id = tokenId(row);
235
+ await account(config.secretKey, config.issuerAccountId);
236
+ // Test helpers use the seller account itself; issued tokens use the issuer account.
237
+ const value = await call(config.secretKey, `/v1/shared_payment/${issuance === 'issued-token' ? 'issued' : 'granted'}_tokens/${id}`);
238
+ validateToken(value, original, id);
239
+ return { original, value };
240
+ }
241
+ async function exclusive(id, fn) {
242
+ if (inFlight.has(id))
243
+ throw new CommerceError('stripe_spt_operation_busy', 'Original SPT operation is already in progress');
244
+ inFlight.add(id);
245
+ try {
246
+ return await fn();
247
+ }
248
+ finally {
249
+ inFlight.delete(id);
250
+ }
251
+ }
252
+ const adapter = {
253
+ payer: config.payer, mode: config.mode, paymentMethod: config.paymentMethod,
254
+ async assertReady() {
255
+ await account(config.secretKey, config.issuerAccountId);
256
+ for (const profile of Object.keys(config.sellers))
257
+ await sellerReady(profile);
258
+ },
259
+ close() { ledger.close(); },
260
+ async createToken(operation) {
261
+ return exclusive(operation.operationId, async () => {
262
+ const original = requestFor(operation), fingerprint = digest(original), prior = ledger.get(operation.operationId);
263
+ if (prior) {
264
+ if (prior.fingerprint !== fingerprint)
265
+ throw new CommerceError('stripe_spt_recovery_binding', 'Operation ID is already bound to another SPT request');
266
+ const result = await retrieve(prior);
267
+ return usable(result.value, result.original);
268
+ }
269
+ if (operation.expiresAt <= Math.floor(now() / 1000) || operation.expiresAt > Math.floor(now() / 1000) + 3600)
270
+ throw new CommerceError('stripe_spt_expiry', 'SPT expiry must be within the original one-hour quote window');
271
+ await account(config.secretKey, config.issuerAccountId);
272
+ await sellerReady(operation.networkId);
273
+ if (operation.expiresAt <= Math.floor(now() / 1000))
274
+ throw new CommerceError('stripe_spt_expiry', 'Original quote expired during issuer readiness checks');
275
+ // No await between durable insertion and starting the one permitted POST.
276
+ const row = ledger.insert(operation, fingerprint, vault.put(original), now());
277
+ const value = await call(config.secretKey, issuance === 'issued-token' ? '/v1/shared_payment/issued_tokens' : '/v1/test_helpers/shared_payment/granted_tokens', original.body, operation.idempotencyKey);
278
+ if (typeof value.id !== 'string' || !TOKEN.test(value.id))
279
+ throw new CommerceError('stripe_spt_response_binding', 'Stripe did not return a valid original SPT ID');
280
+ // Save the ID even if the remaining response needs independent reconciliation.
281
+ ledger.token(row.operationId, vault.put({ operationId: row.operationId, fingerprint, id: value.id }));
282
+ validateToken(value, original, value.id);
283
+ return usable(value, original);
284
+ });
285
+ },
286
+ async recoverToken(operation) {
287
+ return exclusive(operation.operationId, async () => {
288
+ const row = ledger.get(operation.operationId);
289
+ if (!row)
290
+ throw new CommerceError('stripe_spt_recovery_required', 'Original issuer operation is absent; do not issue a replacement token');
291
+ if (digest(requestFor(operation)) !== row.fingerprint)
292
+ throw new CommerceError('stripe_spt_recovery_binding', 'Recovery differs from the original SPT request');
293
+ const result = await retrieve(row);
294
+ return usable(result.value, result.original);
295
+ });
296
+ },
297
+ async authenticationAction(operationId) {
298
+ if (!OPERATION.test(operationId))
299
+ throw new CommerceError('stripe_spt_recovery_required', 'Original issuer operation is required');
300
+ const row = ledger.get(operationId);
301
+ if (!row)
302
+ throw new CommerceError('stripe_spt_recovery_required', 'Original issuer operation is absent');
303
+ const { value } = await retrieve(row);
304
+ if (value.status !== 'requires_action')
305
+ return undefined;
306
+ const action = object(value.next_action), sdk = object(action.use_stripe_sdk);
307
+ if (action.type !== 'use_stripe_sdk' || typeof sdk.value !== 'string' || !sdk.value || sdk.value.length > 16384)
308
+ throw new CommerceError('stripe_spt_action_unsupported', 'Original SPT requires customer action not supported by this Stripe.js bridge');
309
+ return { type: 'use_stripe_sdk', hashedValue: sdk.value };
310
+ },
311
+ async verifyReceipt(receipt, context) {
312
+ const row = ledger.get(context.operationId);
313
+ if (!row?.tokenRef)
314
+ return false;
315
+ const original = saved(row), op = original.operation, seller = config.sellers[op.networkId];
316
+ if (context.purchaseId !== op.purchaseId || context.mode !== config.mode || digest(context.quote) !== digest(op.quote) ||
317
+ receipt.method !== 'stripe' || receipt.status !== 'success' || !/^pi_[A-Za-z0-9]{1,128}$/.test(receipt.reference) ||
318
+ receipt.externalId !== op.quote.quoteId || receipt.amount !== op.amount || receipt.currency !== 'usd' || receipt.merchant !== op.networkId ||
319
+ receipt.accountId !== seller.accountId || receipt.livemode !== (config.mode === 'live'))
320
+ return false;
321
+ await sellerReady(op.networkId);
322
+ const intent = await call(seller.secretKey, `/v1/payment_intents/${receipt.reference}`);
323
+ const metadata = object(intent.metadata);
324
+ return intent.id === receipt.reference && intent.object === 'payment_intent' && intent.status === 'succeeded' && intent.livemode === (config.mode === 'live') &&
325
+ Number.isSafeInteger(intent.amount) && String(intent.amount) === op.amount && Number.isSafeInteger(intent.amount_received) && String(intent.amount_received) === op.amount &&
326
+ intent.currency === 'usd' && metadata.envarpay_order === op.metadata.envarpay_order && metadata.envarpay_quote === op.quote.quoteId && metadata.envarpay_terms === op.quote.termsDigest;
327
+ },
328
+ };
329
+ return adapter;
330
+ }
@@ -5,5 +5,7 @@ export interface StripeProviderConfig {
5
5
  merchantProfile: string;
6
6
  mode: 'test' | 'live';
7
7
  }
8
+ /** Public Stripe Business Profiles and Shared Payment Tokens contract. */
9
+ export declare const STRIPE_MPP_API_VERSION = "2026-09-30.preview";
8
10
  /** Stripe REST transport used by the official mppx Stripe method. No SPT creation. */
9
11
  export declare function stripeProvider(config: StripeProviderConfig, fetchImpl?: typeof fetch): StripeProvider;
@@ -1,8 +1,12 @@
1
1
  import { CommerceError } from './types.js';
2
+ import { isStripeProfileId } from './config.js';
3
+ /** Public Stripe Business Profiles and Shared Payment Tokens contract. */
4
+ export const STRIPE_MPP_API_VERSION = '2026-09-30.preview';
2
5
  /** Stripe REST transport used by the official mppx Stripe method. No SPT creation. */
3
6
  export function stripeProvider(config, fetchImpl = fetch) {
4
- if (!/^acct_[A-Za-z0-9]+$/.test(config.accountId) || !/^profile_[A-Za-z0-9]+$/.test(config.merchantProfile) ||
5
- !config.secretKey.startsWith(config.mode === 'live' ? 'sk_live_' : 'sk_test_')) {
7
+ if (!['test', 'live'].includes(config.mode) || !/^acct_[A-Za-z0-9]+$/.test(config.accountId) || !isStripeProfileId(config.merchantProfile) ||
8
+ !new RegExp(`^(?:sk|rk)_${config.mode}_[A-Za-z0-9]{1,256}$`).test(config.secretKey) ||
9
+ (config.mode === 'live' && config.merchantProfile.startsWith('profile_test_'))) {
6
10
  throw new CommerceError('stripe_configuration', 'Explicit matching Stripe account, profile and test/live credentials are required');
7
11
  }
8
12
  const call = async (path, method = 'GET', params, options) => {
@@ -26,30 +30,45 @@ export function stripeProvider(config, fetchImpl = fetch) {
26
30
  for (const [key, value] of Object.entries(params ?? {}))
27
31
  add(key, value);
28
32
  const headers = new Headers({ Authorization: `Bearer ${config.secretKey}` });
29
- if (options?.apiVersion)
30
- headers.set('Stripe-Version', options.apiVersion);
33
+ headers.set('Stripe-Version', STRIPE_MPP_API_VERSION);
31
34
  if (options?.idempotencyKey)
32
35
  headers.set('Idempotency-Key', options.idempotencyKey);
33
36
  if (method !== 'GET')
34
37
  headers.set('Content-Type', 'application/x-www-form-urlencoded');
35
- const response = await fetchImpl(`https://api.stripe.com${path}${method === 'GET' && body.size ? '?' + body.toString() : ''}`, {
36
- method, headers, ...(method === 'GET' ? {} : { body }), redirect: 'error', signal: AbortSignal.timeout(20000),
37
- });
38
+ const url = `https://api.stripe.com${path}${method === 'GET' && body.size ? '?' + body.toString() : ''}`;
39
+ let response;
40
+ try {
41
+ response = await fetchImpl(url, {
42
+ method, headers, ...(method === 'GET' ? {} : { body }), redirect: 'error', signal: AbortSignal.timeout(20000),
43
+ });
44
+ }
45
+ catch {
46
+ throw new CommerceError('stripe_transport_unknown', 'Stripe request outcome is unknown; preserve the original payment');
47
+ }
48
+ if (response.url && response.url !== url)
49
+ throw new CommerceError('stripe_redirect', 'Stripe response escaped the fixed provider request');
38
50
  const reader = response.body?.getReader();
39
51
  let size = 0;
40
52
  const chunks = [];
41
- if (reader)
42
- for (;;) {
43
- const { value, done } = await reader.read();
44
- if (done)
45
- break;
46
- size += value.byteLength;
47
- if (size > 1024 * 1024) {
48
- await reader.cancel();
49
- throw new CommerceError('stripe_response_size', 'Stripe response exceeds limit');
53
+ try {
54
+ if (reader)
55
+ for (;;) {
56
+ const { value, done } = await reader.read();
57
+ if (done)
58
+ break;
59
+ size += value.byteLength;
60
+ if (size > 1024 * 1024) {
61
+ await reader.cancel();
62
+ throw new CommerceError('stripe_response_size', 'Stripe response exceeds limit');
63
+ }
64
+ chunks.push(value);
50
65
  }
51
- chunks.push(value);
52
- }
66
+ }
67
+ catch (error) {
68
+ if (error instanceof CommerceError)
69
+ throw error;
70
+ throw new CommerceError('stripe_transport_unknown', 'Stripe response was interrupted; preserve the original payment');
71
+ }
53
72
  let result;
54
73
  try {
55
74
  result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
@@ -73,12 +92,28 @@ export function stripeProvider(config, fetchImpl = fetch) {
73
92
  accountId: config.accountId, merchantProfile: config.merchantProfile, mode: config.mode,
74
93
  async assertReady() {
75
94
  const account = await call('/v1/account');
76
- if (account.id !== config.accountId)
95
+ if (account.object !== 'account' || account.id !== config.accountId)
77
96
  throw new CommerceError('stripe_account_mismatch', 'Stripe key belongs to another account');
78
- if (config.mode === 'live' && account.charges_enabled !== true)
79
- throw new CommerceError('stripe_charges_disabled', 'Live merchant is not enabled for charges');
97
+ if (config.mode === 'live' && (account.charges_enabled !== true || account.capabilities?.card_payments !== 'active'))
98
+ throw new CommerceError('stripe_charges_disabled', 'Live merchant is not enabled for card charges');
99
+ const profile = await call('/v2/network/business_profiles/me');
100
+ // /me binds the profile to this authenticated merchant; the documented
101
+ // livemode field must also prove the requested test/live boundary.
102
+ if (profile.object !== 'v2.network.business_profile' || profile.id !== config.merchantProfile ||
103
+ profile.livemode !== (config.mode === 'live'))
104
+ throw new CommerceError('stripe_profile_mismatch', 'Stripe profile does not match this merchant and mode');
105
+ },
106
+ async create(params, options) {
107
+ if (!Number.isSafeInteger(params.amount) || Number(params.amount) < 50 || Number(params.amount) > 99999999 || params.currency !== 'usd' || params.confirm !== true ||
108
+ typeof params.shared_payment_granted_token !== 'string' || !/^spt_[A-Za-z0-9_]+$/.test(params.shared_payment_granted_token) ||
109
+ params.payment_method_data !== undefined || params.payment_method !== undefined || typeof options.idempotencyKey !== 'string' || !options.idempotencyKey || options.idempotencyKey.length > 255) {
110
+ throw new CommerceError('stripe_request', 'MPP Stripe charge requires a bound USD amount, original SPT and idempotency key');
111
+ }
112
+ // mppx 0.13.1 supplies the older SDK argument. The pinned public Stripe API
113
+ // consumes SPTs under payment_method_data; preserve the same original token.
114
+ const { shared_payment_granted_token: token, ...rest } = params;
115
+ return validateIntent(await call('/v1/payment_intents', 'POST', { ...rest, payment_method_data: { shared_payment_granted_token: token } }, options));
80
116
  },
81
- async create(params, options) { return validateIntent(await call('/v1/payment_intents', 'POST', params, options)); },
82
117
  async retrieve(id) {
83
118
  if (!/^pi_[A-Za-z0-9]+$/.test(id))
84
119
  throw new CommerceError('stripe_reference', 'Invalid original PaymentIntent');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envarai/envarpay",
3
- "version": "0.2.0-alpha.2",
3
+ "version": "0.2.0-alpha.4",
4
4
  "description": "Independent A2A service commerce, budgeted buyers and x402/MPP payments",
5
5
  "type": "module",
6
6
  "license": "MIT",