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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +2 -0
  2. package/dist/commerce/alerts.d.ts +37 -0
  3. package/dist/commerce/alerts.js +127 -0
  4. package/dist/commerce/buyer-store.d.ts +8 -0
  5. package/dist/commerce/buyer.d.ts +12 -3
  6. package/dist/commerce/buyer.js +151 -7
  7. package/dist/commerce/card.js +2 -2
  8. package/dist/commerce/cli.js +74 -9
  9. package/dist/commerce/client-runtime.d.ts +1 -1
  10. package/dist/commerce/config.d.ts +5 -1
  11. package/dist/commerce/config.js +37 -1
  12. package/dist/commerce/configure.d.ts +3 -0
  13. package/dist/commerce/configure.js +158 -0
  14. package/dist/commerce/discovery.d.ts +23 -0
  15. package/dist/commerce/discovery.js +162 -0
  16. package/dist/commerce/envar-runtime.d.ts +3 -1
  17. package/dist/commerce/envar-runtime.js +28 -1
  18. package/dist/commerce/envar.d.ts +5 -0
  19. package/dist/commerce/envar.js +13 -0
  20. package/dist/commerce/index.d.ts +1 -0
  21. package/dist/commerce/index.js +1 -0
  22. package/dist/commerce/management.js +3 -1
  23. package/dist/commerce/mpp-client.d.ts +11 -0
  24. package/dist/commerce/mpp-client.js +5 -0
  25. package/dist/commerce/mpp-runtime.d.ts +1 -0
  26. package/dist/commerce/mpp-runtime.js +1 -0
  27. package/dist/commerce/native-skills.d.ts +31 -0
  28. package/dist/commerce/native-skills.js +174 -0
  29. package/dist/commerce/operations.d.ts +22 -0
  30. package/dist/commerce/operations.js +39 -0
  31. package/dist/commerce/peer-proxy.js +11 -5
  32. package/dist/commerce/pricing.js +2 -2
  33. package/dist/commerce/runtime.d.ts +2 -0
  34. package/dist/commerce/runtime.js +2 -0
  35. package/dist/commerce/server.d.ts +4 -1
  36. package/dist/commerce/server.js +40 -9
  37. package/dist/commerce/setup.d.ts +18 -0
  38. package/dist/commerce/setup.js +344 -0
  39. package/dist/commerce/skill-gate.d.ts +54 -0
  40. package/dist/commerce/skill-gate.js +153 -0
  41. package/dist/commerce/store.d.ts +2 -0
  42. package/dist/commerce/store.js +7 -0
  43. package/dist/commerce/stripe-issuer.d.ts +41 -0
  44. package/dist/commerce/stripe-issuer.js +351 -0
  45. package/dist/commerce/stripe-provider.d.ts +2 -0
  46. package/dist/commerce/stripe-provider.js +57 -22
  47. package/dist/commerce/types.d.ts +27 -2
  48. package/dist/commerce/x402.d.ts +1 -0
  49. package/dist/commerce/x402.js +8 -1
  50. package/examples/hermes-setup-probe.py +105 -0
  51. package/examples/hermes-skill-inventory.py +107 -0
  52. package/examples/native-skill-runner.py +197 -0
  53. package/package.json +1 -1
  54. package/schemas/buyer-policy.schema.json +105 -0
  55. package/schemas/envarpay.config.schema.json +22 -15
@@ -0,0 +1,153 @@
1
+ import { AgentCard, TaskState } from '@a2a-js/sdk';
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+ import { CommerceError } from './types.js';
4
+ import { validateUrl } from './config.js';
5
+ /** The Agent-owned adapter may expose one private, authenticated A2A Card per skill. */
6
+ export function createSkillCard(descriptor, interfaceUrl) {
7
+ const card = validateUrl(descriptor.cardUrl, true), endpoint = validateUrl(interfaceUrl, true);
8
+ if (card.origin !== endpoint.origin || !/^[a-z][a-z0-9-]{0,63}$/.test(descriptor.name) ||
9
+ !/^[0-9a-f]{64}$/.test(descriptor.digest))
10
+ throw new CommerceError('invalid_skill_card', 'Skill Card needs one exact named skill, pinned digest and same-origin A2A interface');
11
+ return AgentCard.fromJSON({
12
+ name: descriptor.name, description: `Agent Skill: ${descriptor.name}`, version: descriptor.digest,
13
+ supportedInterfaces: [{ url: endpoint.href, protocolBinding: 'JSONRPC', protocolVersion: '1.0' }],
14
+ capabilities: { streaming: false, pushNotifications: false, extensions: [{
15
+ uri: 'urn:envarpay:skill-gate:1', required: false,
16
+ description: 'The owner-operated adapter invokes this exact installed skill and gates other paid skills',
17
+ params: { skillName: descriptor.name, skillDigest: descriptor.digest, access: descriptor.access },
18
+ }] },
19
+ defaultInputModes: ['application/json'], defaultOutputModes: ['application/json', 'text/plain'],
20
+ skills: [{ id: descriptor.name, name: descriptor.name, description: `Invoke ${descriptor.name}`, tags: [descriptor.name] }],
21
+ securitySchemes: { bearer: { httpAuthSecurityScheme: { scheme: 'bearer' } } },
22
+ securityRequirements: [{ schemes: { bearer: { list: [] } } }],
23
+ });
24
+ }
25
+ /** In-process authority passed only after the seller has confirmed payment or a free offer. */
26
+ export class SkillGrant {
27
+ skillName;
28
+ skillDigest;
29
+ orderId;
30
+ caller;
31
+ messageId;
32
+ inputDigest;
33
+ #freeSkills;
34
+ constructor(order, descriptor, freeSkills, messageId = order.messageId) {
35
+ if (!['confirmed', 'not_required'].includes(order.paymentState))
36
+ throw new CommerceError('payment_required', 'An unpaid order cannot authorize a skill');
37
+ this.skillName = descriptor.name;
38
+ this.skillDigest = descriptor.digest;
39
+ this.orderId = order.id;
40
+ this.caller = order.caller;
41
+ this.messageId = messageId;
42
+ this.inputDigest = order.inputDigest;
43
+ this.#freeSkills = new Set(freeSkills);
44
+ Object.freeze(this);
45
+ }
46
+ /** A purchased task may use its own skill and declared free skills, never another paid skill. */
47
+ require(skillName) {
48
+ if (skillName !== this.skillName && !this.#freeSkills.has(skillName))
49
+ throw new CommerceError('skill_payment_required', 'Purchase the other paid skill as a separate service');
50
+ }
51
+ }
52
+ /** Reference in-process adapter: every nested skill call passes the same purchase gate. */
53
+ export function createSkillRegistry(handlers) {
54
+ const byName = new Map();
55
+ for (const handler of handlers) {
56
+ const { name, digest, cardUrl } = handler.descriptor;
57
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(name) || !/^[0-9a-f]{64}$/.test(digest) || !cardUrl || byName.has(name))
58
+ throw new CommerceError('invalid_skill_registry', 'Skill names and pinned versions must be unique');
59
+ byName.set(name, { ...handler, descriptor: Object.freeze({ ...handler.descriptor }) });
60
+ }
61
+ const select = (name, grant) => {
62
+ grant.require(name);
63
+ const handler = byName.get(name);
64
+ if (!handler)
65
+ throw new CommerceError('skill_unavailable', 'The requested skill is not installed');
66
+ return handler;
67
+ };
68
+ const dispatch = (name, input, invocation, depth) => {
69
+ if (depth > 8)
70
+ throw new CommerceError('skill_depth', 'Nested skill calls exceeded the supported limit');
71
+ const handler = select(name, invocation.grant);
72
+ return handler.send(input, { ...invocation, callSkill: async (child, childInput) => dispatch(child, childInput, invocation, depth + 1) });
73
+ };
74
+ return {
75
+ list: () => [...byName.values()].map(h => ({ ...h.descriptor })),
76
+ send: (name, input, invocation) => dispatch(name, input, invocation, 0),
77
+ getTask: (name, taskId, invocation) => select(name, invocation.grant).getTask(taskId, invocation),
78
+ };
79
+ }
80
+ const TERMINAL = new Set([
81
+ TaskState.TASK_STATE_COMPLETED, TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_CANCELED,
82
+ TaskState.TASK_STATE_REJECTED, TaskState.TASK_STATE_INPUT_REQUIRED, TaskState.TASK_STATE_AUTH_REQUIRED,
83
+ ]);
84
+ /** External buyer/seller communication remains ordinary A2A; only local skill dispatch changes. */
85
+ export function skillScopedExecutor(runtime, signal) {
86
+ function descriptor(service) {
87
+ const execution = service.execution;
88
+ if (execution.type !== 'skill' || service.id !== service.name)
89
+ throw new CommerceError('skill_name_mismatch', 'The service must use an exact skill name');
90
+ const matches = runtime.list().filter(s => s.name === service.id && s.digest === execution.skillDigest);
91
+ if (matches.length !== 1 || matches[0].cardUrl !== execution.cardUrl)
92
+ throw new CommerceError('skill_unavailable', 'The named installed skill or its pinned version is unavailable');
93
+ if (matches[0].access === 'free' && service.offers.some(offer => offer.pricing.kind !== 'free'))
94
+ throw new CommerceError('free_skill_price', 'A declared free helper cannot have paid offers');
95
+ return matches[0];
96
+ }
97
+ function grant(order, service, messageId = order.messageId) {
98
+ const selected = descriptor(service);
99
+ const free = new Set(runtime.list().filter(s => s.access === 'free').map(s => s.name));
100
+ return new SkillGrant(order, selected, free, messageId);
101
+ }
102
+ function checked(task, expectedId) {
103
+ if (!task || typeof task.id !== 'string' || !task.id || !task.status || (expectedId && task.id !== expectedId))
104
+ throw new CommerceError('invalid_agent_response', 'The skill runtime returned no stable A2A Task');
105
+ return task;
106
+ }
107
+ async function* observe(initial, order, service, authority) {
108
+ let task = checked(initial);
109
+ const deadline = Date.now() + service.contract.targetDurationSeconds * 1000;
110
+ for (;;) {
111
+ yield task;
112
+ if (TERMINAL.has(task.status?.state ?? 0))
113
+ return;
114
+ if (Date.now() >= deadline)
115
+ throw new CommerceError('execution_pending', 'Read the original Task to continue observing execution');
116
+ await sleep(1000, undefined, { signal });
117
+ task = checked(await runtime.getTask(service.id, task.id, {
118
+ orderId: order.id, messageId: authority.messageId, grant: authority,
119
+ }), task.id);
120
+ }
121
+ }
122
+ const execute = async function* (order, service) {
123
+ const authority = grant(order, service);
124
+ authority.require(service.id);
125
+ const task = await runtime.send(service.id, order.input, {
126
+ orderId: order.id, messageId: order.messageId, grant: authority,
127
+ });
128
+ yield* observe(task, order, service, authority);
129
+ };
130
+ execute.continue = async function* (order, service, continuation, remote) {
131
+ if (remote.origin !== service.execution.cardUrl)
132
+ throw new CommerceError('skill_origin_changed', 'The original skill Task belongs to another execution entry');
133
+ const authority = grant(order, service, continuation.messageId);
134
+ const task = checked(await runtime.send(service.id, continuation.input, {
135
+ orderId: order.id, messageId: continuation.messageId, taskId: remote.taskId,
136
+ contextId: remote.contextId, grant: authority,
137
+ }), remote.taskId);
138
+ yield* observe(task, order, service, authority);
139
+ };
140
+ execute.recover = async (order, service, remote) => {
141
+ if (remote.origin !== service.execution.cardUrl)
142
+ throw new CommerceError('skill_origin_changed', 'The original skill Task belongs to another execution entry');
143
+ // Reading an original Task must survive a removed/upgraded installed package.
144
+ if (service.execution.type !== 'skill')
145
+ throw new CommerceError('skill_required', 'Expected a skill Task');
146
+ const authority = new SkillGrant(order, { name: service.id, digest: service.execution.skillDigest, cardUrl: service.execution.cardUrl, access: 'paid' }, new Set());
147
+ return checked(await runtime.getTask(service.id, remote.taskId, {
148
+ orderId: order.id, messageId: order.messageId, grant: authority,
149
+ }), remote.taskId);
150
+ };
151
+ execute.assertSkill = descriptor;
152
+ return execute;
153
+ }
@@ -36,6 +36,8 @@ export declare class CommerceStore implements TaskStore {
36
36
  constructor(path: string);
37
37
  private migrateV1;
38
38
  close(): void;
39
+ /** Local readiness only. External settlement is independently verified per purchase. */
40
+ assertReady(): void;
39
41
  registerCatalog(config: CommerceConfig): void;
40
42
  serviceRevision(id: string): Service | undefined;
41
43
  catalogHistory(): {
@@ -134,6 +134,13 @@ export class CommerceStore {
134
134
  this.ownerDb.close();
135
135
  }
136
136
  }
137
+ /** Local readiness only. External settlement is independently verified per purchase. */
138
+ assertReady() {
139
+ const row = this.db.prepare('SELECT version FROM commerce_meta').get();
140
+ if (row?.version !== 2)
141
+ throw new CommerceError('store_unavailable', 'Commerce ledger is unavailable');
142
+ this.db.prepare('SELECT id FROM commerce_orders LIMIT 1').get();
143
+ }
137
144
  registerCatalog(config) {
138
145
  this.transaction(() => {
139
146
  for (const service of config.services) {
@@ -0,0 +1,41 @@
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
+ /** Issuer's Stripe.js key; optional until customer authentication is needed. */
16
+ publishableKey?: string;
17
+ /** Previously owner-authorized PaymentMethod; this adapter never collects card data. */
18
+ paymentMethod: string;
19
+ sellers: Readonly<Record<string, StripeSellerReadBinding>>;
20
+ returnUrl?: string;
21
+ /** Real Stripe test infrastructure, selected explicitly; never a live fallback. */
22
+ issuance?: 'issued-token' | 'test-helper';
23
+ }
24
+ export interface StripeMppBuyerRuntime {
25
+ statePath: string;
26
+ vault: CredentialVault;
27
+ fetchImpl?: typeof fetch;
28
+ now?: () => number;
29
+ }
30
+ export interface StripeAuthenticationAction {
31
+ type: 'use_stripe_sdk';
32
+ hashedValue: string;
33
+ }
34
+ export interface StripeMppBuyerAdapter extends MppBuyerOptions {
35
+ assertReady(): Promise<void>;
36
+ close(): void;
37
+ /** Private owner-only API. Pass this value directly to Stripe.js, never logs. */
38
+ authenticationAction(operationId: string): Promise<StripeAuthenticationAction | undefined>;
39
+ }
40
+ /** Official SPT issuer with exact scope and original-ID-only recovery. No automatic funding. */
41
+ export declare function createStripeMppBuyer(input: StripeMppBuyerConfig, runtime: StripeMppBuyerRuntime): StripeMppBuyerAdapter;
@@ -0,0 +1,351 @@
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
+ import { isStripeTestPaymentMethod } from './mpp-client.js';
9
+ /** Buyer and seller use the same public preview, independently of mppx internals. */
10
+ export const STRIPE_ISSUER_API_VERSION = STRIPE_MPP_API_VERSION;
11
+ const ACCOUNT = /^acct_[A-Za-z0-9]{1,128}$/;
12
+ const TOKEN = /^spt_[A-Za-z0-9_]{1,200}$/;
13
+ const PAYMENT_METHOD = /^pm_[A-Za-z0-9]{1,128}$/;
14
+ const OPERATION = /^[A-Za-z0-9_-]{1,200}$/;
15
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
16
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
17
+ /** Durable private references only; exact requests and tokens live in CredentialVault. */
18
+ class IssuerLedger {
19
+ db;
20
+ owner;
21
+ closed = false;
22
+ constructor(path) {
23
+ if (typeof path !== 'string' || !path.trim() || path === ':memory:')
24
+ throw new CommerceError('stripe_issuer_state', 'SPT issuance requires a durable private ledger');
25
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
26
+ this.owner = new DatabaseSync(path + '.owner.sqlite3');
27
+ chmodSync(path + '.owner.sqlite3', 0o600);
28
+ try {
29
+ this.owner.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE');
30
+ }
31
+ catch {
32
+ this.owner.close();
33
+ throw new CommerceError('store_locked', 'Stripe issuer ledger already has an active process');
34
+ }
35
+ try {
36
+ this.db = new DatabaseSync(path);
37
+ chmodSync(path, 0o600);
38
+ this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
39
+ CREATE TABLE IF NOT EXISTS stripe_issuer_meta(version INTEGER NOT NULL);
40
+ INSERT INTO stripe_issuer_meta SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM stripe_issuer_meta);
41
+ CREATE TABLE IF NOT EXISTS stripe_issuer_operations(
42
+ operation_id TEXT PRIMARY KEY, idempotency_key TEXT NOT NULL UNIQUE, purchase_id TEXT NOT NULL UNIQUE,
43
+ fingerprint TEXT NOT NULL, request_ref TEXT NOT NULL, token_ref TEXT,
44
+ created_at INTEGER NOT NULL
45
+ );`);
46
+ if (this.db.prepare('SELECT version FROM stripe_issuer_meta').get()?.version !== 1)
47
+ throw new CommerceError('store_version', 'Unsupported Stripe issuer ledger version');
48
+ }
49
+ catch (error) {
50
+ this.db?.close();
51
+ this.owner.close();
52
+ throw error;
53
+ }
54
+ }
55
+ get(id) {
56
+ const row = this.db.prepare('SELECT * FROM stripe_issuer_operations WHERE operation_id=?').get(id);
57
+ 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;
58
+ }
59
+ insert(operation, fingerprint, requestRef, now) {
60
+ try {
61
+ this.db.prepare('INSERT INTO stripe_issuer_operations VALUES(?,?,?,?,?,NULL,?)').run(operation.operationId, operation.idempotencyKey, operation.purchaseId, fingerprint, requestRef, now);
62
+ }
63
+ catch {
64
+ throw new CommerceError('stripe_spt_operation_conflict', 'Purchase or idempotency identity already has an original issuer operation');
65
+ }
66
+ return this.get(operation.operationId);
67
+ }
68
+ token(id, ref) { this.db.prepare('UPDATE stripe_issuer_operations SET token_ref=? WHERE operation_id=? AND token_ref IS NULL').run(ref, id); }
69
+ close() {
70
+ if (this.closed)
71
+ return;
72
+ this.closed = true;
73
+ this.db.close();
74
+ this.owner?.exec('ROLLBACK');
75
+ this.owner?.close();
76
+ }
77
+ }
78
+ /** Official SPT issuer with exact scope and original-ID-only recovery. No automatic funding. */
79
+ export function createStripeMppBuyer(input, runtime) {
80
+ const config = structuredClone(input), issuance = config.issuance ?? 'issued-token';
81
+ const keyValid = (key) => typeof key === 'string' && new RegExp(`^(?:sk|rk)_${config.mode}_[A-Za-z0-9]{1,256}$`).test(key);
82
+ if (!['test', 'live'].includes(config.mode) || !['issued-token', 'test-helper'].includes(issuance) || !ACCOUNT.test(config.issuerAccountId) || !keyValid(config.secretKey) ||
83
+ typeof config.payer !== 'string' || !config.payer || config.payer.length > 200 || !config.sellers || !Object.keys(config.sellers).length ||
84
+ !(PAYMENT_METHOD.test(config.paymentMethod) || (issuance === 'test-helper' && isStripeTestPaymentMethod(config.paymentMethod)))) {
85
+ throw new CommerceError('stripe_issuer_configuration', 'Explicit Stripe issuer, funding method, seller read bindings and mode are required');
86
+ }
87
+ for (const [profile, seller] of Object.entries(config.sellers)) {
88
+ if (!isStripeProfileId(profile) || (config.mode === 'live' && profile.startsWith('profile_test_')) || !ACCOUNT.test(seller.accountId) || !keyValid(seller.secretKey))
89
+ throw new CommerceError('stripe_issuer_configuration', 'Seller profile, account and read credential must match the explicit mode');
90
+ }
91
+ if (issuance === 'test-helper' && (config.mode !== 'test' || !isStripeTestPaymentMethod(config.paymentMethod) || Object.values(config.sellers).some(s => s.accountId !== config.issuerAccountId))) {
92
+ throw new CommerceError('stripe_test_helper_configuration', 'Test helper requires an explicitly supported test card and the same configured test seller account');
93
+ }
94
+ if (config.publishableKey !== undefined && (typeof config.publishableKey !== 'string' || !new RegExp(`^pk_${config.mode}_[A-Za-z0-9]{1,256}$`).test(config.publishableKey))) {
95
+ throw new CommerceError('stripe_publishable_key', 'Stripe.js requires an issuer publishable key matching the configured mode');
96
+ }
97
+ if (config.returnUrl !== undefined) {
98
+ let url;
99
+ try {
100
+ url = new URL(config.returnUrl);
101
+ }
102
+ catch {
103
+ throw new CommerceError('stripe_return_url', 'Stripe return URL must be an owner-configured HTTPS URL');
104
+ }
105
+ if (url.protocol !== 'https:' || url.username || url.password || config.returnUrl.length > 2048)
106
+ throw new CommerceError('stripe_return_url', 'Stripe return URL must be an owner-configured HTTPS URL');
107
+ }
108
+ const ledger = new IssuerLedger(runtime.statePath), vault = runtime.vault;
109
+ const fetchImpl = runtime.fetchImpl ?? fetch, now = runtime.now ?? Date.now;
110
+ const inFlight = new Set();
111
+ async function call(secret, path, body, idempotencyKey) {
112
+ const headers = new Headers({ Authorization: `Bearer ${secret}`, 'Stripe-Version': STRIPE_ISSUER_API_VERSION });
113
+ if (body !== undefined)
114
+ headers.set('Content-Type', 'application/x-www-form-urlencoded');
115
+ if (idempotencyKey)
116
+ headers.set('Idempotency-Key', idempotencyKey);
117
+ let response;
118
+ try {
119
+ response = await fetchImpl(`https://api.stripe.com${path}`, { method: body === undefined ? 'GET' : 'POST', headers, ...(body === undefined ? {} : { body }), redirect: 'error', signal: AbortSignal.timeout(20000) });
120
+ }
121
+ catch {
122
+ throw new CommerceError('stripe_transport_unknown', 'Stripe request outcome is unknown; retain the original operation');
123
+ }
124
+ const reader = response.body?.getReader();
125
+ let size = 0;
126
+ const chunks = [];
127
+ try {
128
+ if (reader)
129
+ for (;;) {
130
+ const { value, done } = await reader.read();
131
+ if (done)
132
+ break;
133
+ size += value.byteLength;
134
+ if (size > 1024 * 1024) {
135
+ await reader.cancel();
136
+ throw new CommerceError('stripe_response_size', 'Stripe response exceeds limit');
137
+ }
138
+ chunks.push(value);
139
+ }
140
+ }
141
+ catch (error) {
142
+ if (error instanceof CommerceError && error.code === 'stripe_response_size')
143
+ throw error;
144
+ throw new CommerceError('stripe_transport_unknown', 'Stripe response was interrupted; retain the original operation');
145
+ }
146
+ if (!response.ok)
147
+ throw new CommerceError(response.status === 429 ? 'stripe_rate_limit' : 'stripe_request_failed', `Stripe request failed (HTTP ${response.status})`);
148
+ let value;
149
+ try {
150
+ value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
151
+ }
152
+ catch {
153
+ throw new CommerceError('stripe_response', 'Stripe returned invalid JSON');
154
+ }
155
+ if (!value || typeof value !== 'object' || Array.isArray(value))
156
+ throw new CommerceError('stripe_response', 'Stripe returned an invalid object');
157
+ return value;
158
+ }
159
+ async function account(secret, id) {
160
+ const result = await call(secret, '/v1/account');
161
+ if (result.id !== id || result.object !== 'account')
162
+ throw new CommerceError('stripe_account_mismatch', 'Stripe credential does not prove the expected account');
163
+ return result;
164
+ }
165
+ async function sellerReady(profile) {
166
+ const seller = config.sellers[profile];
167
+ if (!Object.hasOwn(config.sellers, profile) || !seller)
168
+ throw new CommerceError('stripe_seller_binding', 'Seller is not in the local read-verification allowlist');
169
+ const result = await account(seller.secretKey, seller.accountId);
170
+ if (config.mode === 'live' && (result.charges_enabled !== true || object(result.capabilities).card_payments !== 'active'))
171
+ throw new CommerceError('stripe_charges_disabled', 'Live seller card payments are not enabled');
172
+ const ownProfile = await call(seller.secretKey, '/v2/network/business_profiles/me');
173
+ // Require the documented mode as well as the credential-scoped /me identity.
174
+ if (ownProfile.id !== profile || ownProfile.object !== 'v2.network.business_profile' || ownProfile.livemode !== (config.mode === 'live'))
175
+ throw new CommerceError('stripe_profile_mismatch', 'Seller credential does not prove the expected profile and mode');
176
+ return seller;
177
+ }
178
+ function requestFor(operation) {
179
+ const quote = operation.quote, challenge = operation.challenge;
180
+ const details = object(challenge?.request?.methodDetails), metadata = object(operation.metadata);
181
+ const expiry = Math.floor(Date.parse(quote?.expiresAt) / 1000);
182
+ if (!OPERATION.test(operation.operationId) || operation.idempotencyKey !== operation.operationId || !UUID.test(operation.purchaseId) ||
183
+ operation.mode !== config.mode || operation.paymentMethod !== config.paymentMethod || operation.currency !== 'usd' || !/^[1-9][0-9]{1,7}$/.test(operation.amount) ||
184
+ BigInt(operation.amount) < 50n || BigInt(operation.amount) > 99999999n || operation.amount !== quote.amount || quote.currency !== 'usd' || quote.payer !== config.payer ||
185
+ operation.networkId !== quote.recipient || !isStripeProfileId(operation.networkId) || !Object.hasOwn(config.sellers, operation.networkId) || !Number.isSafeInteger(expiry) || operation.expiresAt !== expiry ||
186
+ challenge?.method !== 'stripe' || challenge.intent !== 'charge' || challenge.header !== 'Payment-Authorization' || challenge.expires !== quote.expiresAt ||
187
+ challenge.request.amount !== quote.amount || challenge.request.currency !== 'usd' || challenge.request.recipient !== quote.recipient || challenge.request.externalId !== quote.quoteId ||
188
+ details.networkId !== quote.recipient || digest(details.metadata ?? {}) !== digest(metadata) ||
189
+ metadata.envarpay_quote !== quote.quoteId || metadata.envarpay_terms !== quote.termsDigest || typeof metadata.envarpay_order !== 'string' || !UUID.test(metadata.envarpay_order) ||
190
+ typeof quote.quoteId !== 'string' || quote.quoteId.length > 500 || typeof quote.termsDigest !== 'string' || quote.termsDigest.length > 500 ||
191
+ Challenge.serialize(challenge).length > 32768)
192
+ throw new CommerceError('stripe_spt_binding', 'SPT request differs from the frozen purchase or approved funding scope');
193
+ const body = new URLSearchParams({ payment_method: operation.paymentMethod,
194
+ 'usage_limits[currency]': 'usd', 'usage_limits[max_amount]': operation.amount, 'usage_limits[expires_at]': String(expiry),
195
+ 'shared_metadata[envarpay_operation]': operation.operationId, 'shared_metadata[envarpay_purchase]': operation.purchaseId,
196
+ 'shared_metadata[envarpay_order]': metadata.envarpay_order, 'shared_metadata[envarpay_quote]': quote.quoteId, 'shared_metadata[envarpay_terms]': quote.termsDigest,
197
+ });
198
+ if (issuance === 'issued-token') {
199
+ body.set('seller_details[network_business_profile]', operation.networkId);
200
+ if (config.returnUrl)
201
+ body.set('return_url', config.returnUrl);
202
+ }
203
+ return { operation: structuredClone(operation), body: body.toString(), binding: { issuerAccountId: config.issuerAccountId, sellerAccountId: config.sellers[operation.networkId].accountId, issuance, ...(config.returnUrl ? { returnUrl: config.returnUrl } : {}) } };
204
+ }
205
+ function saved(row) {
206
+ const original = vault.get(row.requestRef);
207
+ if (row.operationId !== original.operation?.operationId || digest(original) !== row.fingerprint || digest(requestFor(original.operation)) !== row.fingerprint)
208
+ throw new CommerceError('stripe_spt_recovery_binding', 'Original issuer scope changed or failed integrity validation');
209
+ return original;
210
+ }
211
+ function tokenId(row) {
212
+ if (!row.tokenRef)
213
+ throw new CommerceError('stripe_spt_recovery_required', 'Original SPT ID is unknown; do not issue a replacement token');
214
+ const value = vault.get(row.tokenRef);
215
+ if (value.operationId !== row.operationId || value.fingerprint !== row.fingerprint || !TOKEN.test(value.id))
216
+ throw new CommerceError('stripe_spt_recovery_binding', 'Original encrypted SPT binding is invalid');
217
+ return value.id;
218
+ }
219
+ function validateToken(value, original, expectedId) {
220
+ const operation = original.operation, limits = object(value.usage_limits), meta = object(value.shared_metadata);
221
+ if (value.id !== expectedId || value.object !== (issuance === 'issued-token' ? 'shared_payment.issued_token' : 'shared_payment.granted_token') ||
222
+ value.livemode !== (config.mode === 'live') || limits.currency !== 'usd' || !Number.isSafeInteger(limits.max_amount) || String(limits.max_amount) !== operation.amount || limits.expires_at !== operation.expiresAt ||
223
+ 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 ||
224
+ (issuance === 'issued-token' && (value.payment_method !== operation.paymentMethod || object(value.seller_details).network_business_profile !== operation.networkId)) ||
225
+ (issuance === 'test-helper' && object(value.payment_method_details).type !== 'card'))
226
+ throw new CommerceError('stripe_spt_response_binding', 'Stripe token does not match the original scope and mode');
227
+ }
228
+ function usable(value, original) {
229
+ if (value.status === 'requires_action')
230
+ throw new CommerceError('stripe_spt_requires_action', 'Original SPT requires customer authentication through Stripe.js');
231
+ const captured = object(object(value.usage_details).amount_captured);
232
+ if ((issuance === 'issued-token' && value.status !== 'active') || value.deactivated_at != null || value.deactivated_reason != null ||
233
+ captured.value !== 0 || captured.currency !== 'usd' || original.operation.expiresAt <= Math.floor(now() / 1000))
234
+ throw new CommerceError('stripe_spt_unusable', 'Original SPT is expired, consumed, inactive or not proven unused');
235
+ return String(value.id);
236
+ }
237
+ async function retrieve(row) {
238
+ const original = saved(row), id = tokenId(row);
239
+ await account(config.secretKey, config.issuerAccountId);
240
+ // Test helpers use the seller account itself; issued tokens use the issuer account.
241
+ const value = await call(config.secretKey, `/v1/shared_payment/${issuance === 'issued-token' ? 'issued' : 'granted'}_tokens/${id}`);
242
+ validateToken(value, original, id);
243
+ return { original, value };
244
+ }
245
+ async function exclusive(id, fn) {
246
+ if (inFlight.has(id))
247
+ throw new CommerceError('stripe_spt_operation_busy', 'Original SPT operation is already in progress');
248
+ inFlight.add(id);
249
+ try {
250
+ return await fn();
251
+ }
252
+ finally {
253
+ inFlight.delete(id);
254
+ }
255
+ }
256
+ const adapter = {
257
+ payer: config.payer, mode: config.mode, paymentMethod: config.paymentMethod, issuance,
258
+ async assertReady() {
259
+ await account(config.secretKey, config.issuerAccountId);
260
+ for (const profile of Object.keys(config.sellers))
261
+ await sellerReady(profile);
262
+ },
263
+ close() { ledger.close(); },
264
+ async createToken(operation) {
265
+ return exclusive(operation.operationId, async () => {
266
+ const original = requestFor(operation), fingerprint = digest(original), prior = ledger.get(operation.operationId);
267
+ if (prior) {
268
+ if (prior.fingerprint !== fingerprint)
269
+ throw new CommerceError('stripe_spt_recovery_binding', 'Operation ID is already bound to another SPT request');
270
+ const result = await retrieve(prior);
271
+ return usable(result.value, result.original);
272
+ }
273
+ if (operation.expiresAt <= Math.floor(now() / 1000) || operation.expiresAt > Math.floor(now() / 1000) + 3600)
274
+ throw new CommerceError('stripe_spt_expiry', 'SPT expiry must be within the original one-hour quote window');
275
+ await account(config.secretKey, config.issuerAccountId);
276
+ await sellerReady(operation.networkId);
277
+ if (operation.expiresAt <= Math.floor(now() / 1000))
278
+ throw new CommerceError('stripe_spt_expiry', 'Original quote expired during issuer readiness checks');
279
+ // No await between durable insertion and starting the one permitted POST.
280
+ const row = ledger.insert(operation, fingerprint, vault.put(original), now());
281
+ 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);
282
+ if (typeof value.id !== 'string' || !TOKEN.test(value.id))
283
+ throw new CommerceError('stripe_spt_response_binding', 'Stripe did not return a valid original SPT ID');
284
+ // Save the ID even if the remaining response needs independent reconciliation.
285
+ ledger.token(row.operationId, vault.put({ operationId: row.operationId, fingerprint, id: value.id }));
286
+ validateToken(value, original, value.id);
287
+ return usable(value, original);
288
+ });
289
+ },
290
+ async recoverToken(operation) {
291
+ return exclusive(operation.operationId, async () => {
292
+ const row = ledger.get(operation.operationId);
293
+ if (!row)
294
+ throw new CommerceError('stripe_spt_recovery_required', 'Original issuer operation is absent; do not issue a replacement token');
295
+ if (digest(requestFor(operation)) !== row.fingerprint)
296
+ throw new CommerceError('stripe_spt_recovery_binding', 'Recovery differs from the original SPT request');
297
+ const result = await retrieve(row);
298
+ return usable(result.value, result.original);
299
+ });
300
+ },
301
+ async authenticationAction(operationId) {
302
+ if (!OPERATION.test(operationId))
303
+ throw new CommerceError('stripe_spt_recovery_required', 'Original issuer operation is required');
304
+ const row = ledger.get(operationId);
305
+ if (!row)
306
+ throw new CommerceError('stripe_spt_recovery_required', 'Original issuer operation is absent');
307
+ const { value } = await retrieve(row);
308
+ if (value.status !== 'requires_action')
309
+ return undefined;
310
+ const action = object(value.next_action), sdk = object(action.use_stripe_sdk);
311
+ if (action.type !== 'use_stripe_sdk' || typeof sdk.value !== 'string' || !sdk.value || sdk.value.length > 16384)
312
+ throw new CommerceError('stripe_spt_action_unsupported', 'Original SPT requires customer action not supported by this Stripe.js bridge');
313
+ return { type: 'use_stripe_sdk', hashedValue: sdk.value };
314
+ },
315
+ async getAuthentication(context) {
316
+ if (!OPERATION.test(context.operationId))
317
+ throw new CommerceError('stripe_spt_recovery_required', 'Original issuer operation is required');
318
+ const row = ledger.get(context.operationId);
319
+ if (!row)
320
+ throw new CommerceError('stripe_spt_recovery_required', 'Original issuer operation is absent');
321
+ const original = saved(row), op = original.operation;
322
+ if (context.purchaseId !== op.purchaseId || context.mode !== config.mode || digest(context.quote) !== digest(op.quote)) {
323
+ throw new CommerceError('stripe_spt_recovery_binding', 'Authentication differs from the original purchase');
324
+ }
325
+ const action = await adapter.authenticationAction(context.operationId);
326
+ if (!action)
327
+ return undefined;
328
+ if (!config.publishableKey)
329
+ throw new CommerceError('stripe_publishable_key_required', 'Configure the issuer publishable key to authenticate this original payment');
330
+ return { ...action, publishableKey: config.publishableKey };
331
+ },
332
+ async verifyReceipt(receipt, context) {
333
+ const row = ledger.get(context.operationId);
334
+ if (!row?.tokenRef)
335
+ return false;
336
+ const original = saved(row), op = original.operation, seller = config.sellers[op.networkId];
337
+ if (context.purchaseId !== op.purchaseId || context.mode !== config.mode || digest(context.quote) !== digest(op.quote) ||
338
+ receipt.method !== 'stripe' || receipt.status !== 'success' || !/^pi_[A-Za-z0-9]{1,128}$/.test(receipt.reference) ||
339
+ receipt.externalId !== op.quote.quoteId || receipt.amount !== op.amount || receipt.currency !== 'usd' || receipt.merchant !== op.networkId ||
340
+ receipt.accountId !== seller.accountId || receipt.livemode !== (config.mode === 'live'))
341
+ return false;
342
+ await sellerReady(op.networkId);
343
+ const intent = await call(seller.secretKey, `/v1/payment_intents/${receipt.reference}`);
344
+ const metadata = object(intent.metadata);
345
+ return intent.id === receipt.reference && intent.object === 'payment_intent' && intent.status === 'succeeded' && intent.livemode === (config.mode === 'live') &&
346
+ Number.isSafeInteger(intent.amount) && String(intent.amount) === op.amount && Number.isSafeInteger(intent.amount_received) && String(intent.amount_received) === op.amount &&
347
+ intent.currency === 'usd' && metadata.envarpay_order === op.metadata.envarpay_order && metadata.envarpay_quote === op.quote.quoteId && metadata.envarpay_terms === op.quote.termsDigest;
348
+ },
349
+ };
350
+ return adapter;
351
+ }
@@ -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;