@envarai/envarpay 0.1.0-alpha.3 → 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 (58) hide show
  1. package/README.md +56 -146
  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/examples/buyer-policy.json +18 -0
  53. package/examples/envar-integration-policy.json +18 -0
  54. package/examples/seller.json +52 -0
  55. package/package.json +75 -10
  56. package/schemas/buyer-policy.schema.json +148 -0
  57. package/schemas/envarpay.config.schema.json +376 -0
  58. package/schemas/quote.schema.json +91 -0
@@ -0,0 +1,60 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { Ajv2020 } from 'ajv/dist/2020.js';
3
+ import addFormatsImport from 'ajv-formats';
4
+ import { atomic, currencyOf, digest } from './config.js';
5
+ import { CommerceError } from './types.js';
6
+ const addFormats = addFormatsImport;
7
+ export function buildQuote(config, args) {
8
+ if (!args.caller || !args.messageId || args.messageId.length > 128)
9
+ throw new CommerceError('invalid_identity', 'Authenticated caller and stable message ID are required');
10
+ const service = config.services.find(s => s.id === args.serviceId);
11
+ const offer = service?.offers.find(o => o.id === args.offerId);
12
+ if (!service || !offer)
13
+ throw new CommerceError('offer_not_found', 'Service offer not found');
14
+ const ajv = new Ajv2020({ strict: true });
15
+ addFormats(ajv);
16
+ const validate = ajv.compile(service.contract.inputSchema);
17
+ if (!validate(args.input))
18
+ throw new CommerceError('invalid_input', ajv.errorsText(validate.errors));
19
+ let amount = 0n;
20
+ if (offer.pricing.kind === 'fixed')
21
+ amount = atomic(offer.pricing.amount);
22
+ if (offer.pricing.kind === 'quantity') {
23
+ let current = args.input;
24
+ for (const part of offer.pricing.quantity.pointer.slice(1).split('/')) {
25
+ const key = part.replace(/~1/g, '/').replace(/~0/g, '~');
26
+ current = current && typeof current === 'object' && Object.hasOwn(current, key) ? current[key] : undefined;
27
+ }
28
+ const q = offer.pricing.quantity;
29
+ if (!Array.isArray(current) || current.length < q.min || current.length > q.max)
30
+ throw new CommerceError('invalid_quantity', 'Input quantity outside the purchased range');
31
+ amount = atomic(offer.pricing.unitAmount) * BigInt(current.length);
32
+ }
33
+ if (amount >= 2n ** 256n)
34
+ throw new CommerceError('invalid_amount', 'Quoted amount is too large');
35
+ const profile = offer.paymentProfile ? config.paymentProfiles[offer.paymentProfile] : null;
36
+ const recipient = profile?.adapter === 'x402' ? profile.payTo : profile ? args.stripeRecipient : null;
37
+ if (profile?.adapter === 'mpp' && !recipient)
38
+ throw new CommerceError('merchant_not_ready', 'Resolve and verify the Stripe merchant before quoting');
39
+ const now = args.now ?? new Date();
40
+ const ttl = args.ttlSeconds ?? 600;
41
+ if (!Number.isInteger(ttl) || ttl < 1 || ttl > 3600 || !Number.isFinite(now.getTime()))
42
+ throw new CommerceError('invalid_expiry', 'Quote lifetime must be 1–3600 seconds');
43
+ const quote = {
44
+ quoteVersion: 1, quoteId: randomUUID(), caller: args.caller, serviceId: service.id,
45
+ serviceRevision: service.revision, offerId: offer.id, messageId: args.messageId,
46
+ inputDigest: digest(args.input), termsDigest: digest({ contract: service.contract, offer, profile }),
47
+ issuedAt: now.toISOString(), expiresAt: new Date(now.getTime() + ttl * 1000).toISOString(),
48
+ amount: amount.toString(), currency: profile ? currencyOf(profile) : null,
49
+ recipient: recipient ?? null, paymentProfile: profile ? structuredClone(profile) : null,
50
+ };
51
+ if (quote.paymentProfile)
52
+ Object.freeze(quote.paymentProfile);
53
+ return Object.freeze(quote);
54
+ }
55
+ export function assertQuoteRequest(quote, caller, messageId, input, now = new Date()) {
56
+ if (quote.caller !== caller || quote.messageId !== messageId || quote.inputDigest !== digest(input))
57
+ throw new CommerceError('quote_mismatch', 'Request differs from the frozen quote');
58
+ if (!Number.isFinite(now.getTime()) || !Number.isFinite(Date.parse(quote.expiresAt)) || now.getTime() >= Date.parse(quote.expiresAt))
59
+ throw new CommerceError('quote_expired', 'Review a new quote before payment');
60
+ }
@@ -0,0 +1,8 @@
1
+ /** Node.js service APIs. Keep node:sqlite out of the portable contracts entry. */
2
+ export * from './store.js';
3
+ export * from './server.js';
4
+ export * from './upstream.js';
5
+ export * from './http.js';
6
+ export * from './x402.js';
7
+ export * from './vault.js';
8
+ export * from './chain.js';
@@ -0,0 +1,8 @@
1
+ /** Node.js service APIs. Keep node:sqlite out of the portable contracts entry. */
2
+ export * from './store.js';
3
+ export * from './server.js';
4
+ export * from './upstream.js';
5
+ export * from './http.js';
6
+ export * from './x402.js';
7
+ export * from './vault.js';
8
+ export * from './chain.js';
@@ -0,0 +1,58 @@
1
+ import { Task, SendMessageRequest } from '@a2a-js/sdk';
2
+ import { ServerCallContext } from '@a2a-js/sdk/server';
3
+ import { CommerceStore, type OrderRecord, type ContinuationRecord, type RemoteTaskReference } from './store.js';
4
+ import { type CommerceConfig, type Input, type Service } from './types.js';
5
+ export interface ExecuteOrder {
6
+ (order: OrderRecord, service: Service): AsyncIterable<Task>;
7
+ continue?: (order: OrderRecord, service: Service, continuation: ContinuationRecord, remote: RemoteTaskReference) => AsyncIterable<Task>;
8
+ recover?: (order: OrderRecord, service: Service, remote: RemoteTaskReference) => Promise<Task>;
9
+ remoteInterface?: (task: Task) => string | undefined;
10
+ }
11
+ export interface CommerceServerOptions {
12
+ config: CommerceConfig;
13
+ origin: string;
14
+ store: CommerceStore;
15
+ authenticate: (request: Request) => Promise<string | null>;
16
+ execute: ExecuteOrder;
17
+ paymentGate?: {
18
+ handle(request: Request, order: OrderRecord): Promise<{
19
+ response: Response;
20
+ } | {
21
+ headers: Record<string, string>;
22
+ }>;
23
+ };
24
+ stripeRecipientFor?: (accountRef: string) => string | undefined;
25
+ ownershipChallenges?: Readonly<Record<string, string>>;
26
+ onError?: (code: string, orderId?: string) => void;
27
+ }
28
+ export declare function bearerAuthenticator(tokens: Readonly<Record<string, string>>): (request: Request) => Promise<string | null>;
29
+ /** HTTP handler + one durable worker. Recovery only reads the original upstream Task. */
30
+ export declare class CommerceServer {
31
+ private options;
32
+ private currentConfig;
33
+ get config(): CommerceConfig;
34
+ readonly store: CommerceStore;
35
+ private routes;
36
+ private running;
37
+ private kickPending;
38
+ private stopped;
39
+ private waiters;
40
+ private activeOrders;
41
+ private recoveries;
42
+ constructor(options: CommerceServerOptions);
43
+ /** Atomically replace active routes while retaining immutable history and original Tasks. */
44
+ applyConfig(value: CommerceConfig): void;
45
+ private activeRevision;
46
+ private context;
47
+ quote(service: Service, offerId: string, caller: string, messageId: string, input: Input): Readonly<import("./types.js").PriceQuote>;
48
+ handle(request: Request): Promise<Response>;
49
+ continueTask(params: SendMessageRequest, input: Input, service: Service, offerId: string, context: ServerCallContext): Promise<Task>;
50
+ recoverTask(taskId: string, context: ServerCallContext): Promise<Task>;
51
+ private project;
52
+ schedule(): void;
53
+ private drain;
54
+ waitForTask(taskId: string, context: ServerCallContext): Promise<Task>;
55
+ private wake;
56
+ stop(): void;
57
+ get isRunning(): boolean;
58
+ }
@@ -0,0 +1,485 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { AgentCard, Role, Task, TaskState, SendMessageRequest } from '@a2a-js/sdk';
3
+ import { DefaultRequestHandler, JsonRpcTransportHandler, ServerCallContext } from '@a2a-js/sdk/server';
4
+ import { RequestMalformedError, TaskNotFoundError, UnsupportedOperationError } from '@a2a-js/sdk/errors';
5
+ import { Ajv2020 } from 'ajv/dist/2020.js';
6
+ import addFormatsImport from 'ajv-formats';
7
+ import { createOfferCard, offerPath } from './card.js';
8
+ import { digest, loadCommerceConfig } from './config.js';
9
+ import { buildQuote } from './pricing.js';
10
+ import { CommerceError } from './types.js';
11
+ const TERMINAL = [TaskState.TASK_STATE_COMPLETED, TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_CANCELED, TaskState.TASK_STATE_REJECTED];
12
+ const WAITING = [TaskState.TASK_STATE_INPUT_REQUIRED, TaskState.TASK_STATE_AUTH_REQUIRED];
13
+ const addFormats = addFormatsImport;
14
+ export function bearerAuthenticator(tokens) {
15
+ const entries = Object.entries(tokens);
16
+ if (!entries.length || entries.some(([token, caller]) => token.length < 32 || !caller))
17
+ throw new CommerceError('invalid_credentials', 'Configure strong distinct caller tokens');
18
+ return async (request) => {
19
+ const candidate = request.headers.get('Authorization')?.replace(/^Bearer /, '') ?? '';
20
+ const value = Buffer.from(candidate);
21
+ let found = null;
22
+ for (const [token, caller] of entries) {
23
+ const expected = Buffer.from(token);
24
+ if (value.length === expected.length && timingSafeEqual(value, expected))
25
+ found = caller;
26
+ }
27
+ return found;
28
+ };
29
+ }
30
+ function structuredInput(params) {
31
+ const m = params.message;
32
+ if (!m || m.role !== Role.ROLE_USER || !m.messageId || m.messageId.length > 128 || m.parts.length !== 1 || m.referenceTaskIds.length || (!m.taskId && m.contextId))
33
+ throw new RequestMalformedError('Commerce requires one user data part and a stable messageId; continuations need the original Task ID');
34
+ const p = m.parts[0].content;
35
+ if (p?.$case !== 'data' || !p.value || typeof p.value !== 'object' || Array.isArray(p.value))
36
+ throw new RequestMalformedError('Provide the service JSON input in one data part');
37
+ return p.value;
38
+ }
39
+ function validateBeforePayment(raw) {
40
+ if (raw.jsonrpc !== '2.0' || !['string', 'number'].includes(typeof raw.id) || raw.id === '' || (typeof raw.id === 'number' && !Number.isSafeInteger(raw.id)))
41
+ throw new RequestMalformedError('Valid JSONRPC request ID required');
42
+ const params = raw.params;
43
+ const message = params?.message;
44
+ if (!message || typeof message.messageId !== 'string' || message.role !== 'ROLE_USER' || !Array.isArray(message.parts))
45
+ throw new RequestMalformedError('A valid user Message is required');
46
+ if (message.taskId !== undefined && (typeof message.taskId !== 'string' || !message.taskId))
47
+ throw new RequestMalformedError('Task ID must be a nonempty string');
48
+ if (message.contextId !== undefined && (typeof message.contextId !== 'string' || !message.contextId))
49
+ throw new RequestMalformedError('Context ID must be a nonempty string');
50
+ if (message.parts.length !== 1 || !message.parts[0] || typeof message.parts[0] !== 'object' || Object.keys(message.parts[0]).some(x => !['data', 'mediaType'].includes(x)))
51
+ throw new RequestMalformedError('Commerce accepts exactly one structured data part');
52
+ if (params?.metadata || message.metadata || message.extensions)
53
+ throw new UnsupportedOperationError('Unrecognized execution metadata is not accepted by this service');
54
+ const configuration = params?.configuration;
55
+ if (configuration) {
56
+ if (typeof configuration !== 'object' || Array.isArray(configuration) || Object.keys(configuration).some(k => !['returnImmediately', 'acceptedOutputModes', 'historyLength'].includes(k)))
57
+ throw new UnsupportedOperationError('Unsupported request configuration');
58
+ if (configuration.returnImmediately !== undefined && typeof configuration.returnImmediately !== 'boolean')
59
+ throw new RequestMalformedError('returnImmediately must be boolean');
60
+ if (configuration.historyLength !== undefined && (!Number.isInteger(configuration.historyLength) || configuration.historyLength < 0))
61
+ throw new RequestMalformedError('historyLength must be a nonnegative integer');
62
+ if (configuration.acceptedOutputModes !== undefined && (!Array.isArray(configuration.acceptedOutputModes) || configuration.acceptedOutputModes.some(x => typeof x !== 'string')))
63
+ throw new RequestMalformedError('acceptedOutputModes must be strings');
64
+ }
65
+ }
66
+ /** Clarifications may only fill explicitly declared absent fields of the initial contract. */
67
+ function clarification(service, offerId, current, patch) {
68
+ let added = 0;
69
+ const offer = service.offers.find(o => o.id === offerId);
70
+ const quantity = offer.pricing.kind === 'quantity' ? offer.pricing.quantity.pointer : null;
71
+ const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
72
+ const merge = (before, changes, schema, path) => {
73
+ const result = structuredClone(before);
74
+ for (const [key, value] of Object.entries(changes)) {
75
+ if (['__proto__', 'prototype', 'constructor'].includes(key))
76
+ throw new CommerceError('invalid_clarification', 'Reserved input property');
77
+ const pointer = path + '/' + key.replace(/~/g, '~0').replace(/\//g, '~1');
78
+ const properties = schema.properties;
79
+ if (Object.hasOwn(before, key)) {
80
+ if (digest(before[key]) === digest(value))
81
+ continue;
82
+ if (object(before[key]) && object(value) && properties?.[key]) {
83
+ result[key] = merge(before[key], value, properties[key], pointer);
84
+ continue;
85
+ }
86
+ throw new CommerceError('scope_changed', 'Previously supplied input cannot change during the purchased task');
87
+ }
88
+ if (!properties || !Object.hasOwn(properties, key))
89
+ throw new CommerceError('scope_changed', 'Clarification must fill an explicitly declared input field');
90
+ if (quantity && (pointer === quantity || pointer.startsWith(quantity + '/') || quantity.startsWith(pointer + '/')))
91
+ throw new CommerceError('price_changed', 'Priced quantity cannot change during clarification');
92
+ result[key] = value;
93
+ added++;
94
+ }
95
+ return result;
96
+ };
97
+ const merged = merge(current, patch, service.contract.inputSchema, '');
98
+ if (!added)
99
+ throw new CommerceError('empty_clarification', 'Add a previously absent clarification field');
100
+ const ajv = new Ajv2020({ strict: true });
101
+ addFormats(ajv);
102
+ if (!ajv.compile(service.contract.inputSchema)(merged))
103
+ throw new CommerceError('invalid_clarification', 'Clarified input does not match the original service schema');
104
+ return merged;
105
+ }
106
+ class OrderHandler extends DefaultRequestHandler {
107
+ host;
108
+ service;
109
+ offerId;
110
+ active;
111
+ constructor(host, service, offerId, card, store, active) {
112
+ const executor = { execute: async () => { throw new UnsupportedOperationError('Use commerce order dispatch'); }, cancelTask: async () => { throw new UnsupportedOperationError('Runtime cancellation is not available'); } };
113
+ super(card, store, executor);
114
+ this.host = host;
115
+ this.service = service;
116
+ this.offerId = offerId;
117
+ this.active = active;
118
+ }
119
+ async sendMessage(params, context) {
120
+ if (params.configuration?.taskPushNotificationConfig)
121
+ throw new UnsupportedOperationError('Push notifications are not supported');
122
+ const caller = context.user?.isAuthenticated ? context.user.userName : undefined;
123
+ if (!caller)
124
+ throw new RequestMalformedError('Authenticated buyer required');
125
+ const input = structuredInput(params);
126
+ if (params.message.taskId)
127
+ return this.host.continueTask(params, input, this.service, this.offerId, context);
128
+ const prior = this.host.store.findOrder(caller, `${this.service.id}:${this.service.revision}`, params.message.messageId);
129
+ if (prior) {
130
+ if (prior.inputDigest !== digest(input) || prior.offerId !== this.offerId)
131
+ throw new RequestMalformedError('Message ID already identifies a different purchase');
132
+ const original = await this.host.store.load(prior.taskId, context);
133
+ if (original)
134
+ return original;
135
+ }
136
+ if (!this.active)
137
+ throw new UnsupportedOperationError('This service revision is retired; existing Tasks remain readable');
138
+ const order = this.host.store.ensureQuote(this.host.quote(this.service, this.offerId, caller, params.message.messageId, input), input);
139
+ if (order.paymentState === 'confirmed') {
140
+ const task = await this.host.store.load(order.taskId, context);
141
+ if (!task)
142
+ throw new CommerceError('task_recovery_required', 'Paid task record is incomplete');
143
+ this.host.schedule();
144
+ return task;
145
+ }
146
+ if (order.paymentState !== 'not_required')
147
+ throw new UnsupportedOperationError('Payment transport must be enabled before this paid offer can execute');
148
+ const task = this.host.store.enqueueFree(order.id, caller);
149
+ this.host.schedule();
150
+ return params.configuration?.returnImmediately === true ? task : this.host.waitForTask(order.taskId, context);
151
+ }
152
+ async getTask(params, context) {
153
+ const task = await this.host.recoverTask(params.id, context);
154
+ if (params.historyLength !== undefined) {
155
+ if (params.historyLength < 0)
156
+ throw new RequestMalformedError('historyLength must be nonnegative');
157
+ task.history = params.historyLength === 0 ? [] : task.history.slice(-params.historyLength);
158
+ }
159
+ return task;
160
+ }
161
+ async *sendMessageStream() { throw new UnsupportedOperationError('Streaming is not available on this commerce entry'); }
162
+ async *resubscribe() { throw new UnsupportedOperationError('Task subscriptions are not available on this commerce entry'); }
163
+ }
164
+ /** HTTP handler + one durable worker. Recovery only reads the original upstream Task. */
165
+ export class CommerceServer {
166
+ options;
167
+ currentConfig;
168
+ get config() { return structuredClone(this.currentConfig); }
169
+ store;
170
+ routes = new Map();
171
+ running = false;
172
+ kickPending = false;
173
+ stopped = false;
174
+ waiters = new Map();
175
+ activeOrders = new Set();
176
+ recoveries = new Map();
177
+ constructor(options) {
178
+ this.options = options;
179
+ this.currentConfig = loadCommerceConfig(options.config);
180
+ this.store = options.store;
181
+ this.applyConfig(this.currentConfig);
182
+ this.store.recoverInterruptedDispatches();
183
+ this.schedule();
184
+ }
185
+ /** Atomically replace active routes while retaining immutable history and original Tasks. */
186
+ applyConfig(value) {
187
+ if (this.stopped)
188
+ throw new CommerceError('server_draining', 'Cannot apply configuration while stopping');
189
+ const config = loadCommerceConfig(value);
190
+ if (config.agent.id !== this.currentConfig.agent.id)
191
+ throw new CommerceError('config_agent_changed', 'Runtime Agent identity cannot change');
192
+ const history = this.store.catalogHistory();
193
+ for (const service of config.services) {
194
+ const maximum = Math.max(0, ...history.filter(h => h.service.id === service.id).map(h => h.service.revision));
195
+ if (service.revision < maximum)
196
+ throw new CommerceError('stale_revision', 'An older service revision cannot become active');
197
+ }
198
+ const snapshots = new Map(history.map(entry => [`${entry.service.id}:${entry.service.revision}`, entry]));
199
+ for (const service of config.services) {
200
+ const profiles = Object.fromEntries(service.offers.filter(o => o.paymentProfile).map(o => [o.paymentProfile, config.paymentProfiles[o.paymentProfile]]));
201
+ const key = `${service.id}:${service.revision}`, prior = snapshots.get(key);
202
+ if (prior && digest(prior) !== digest({ service, profiles }))
203
+ throw new CommerceError('immutable_revision', 'Service revision terms are immutable');
204
+ snapshots.set(key, { service, profiles });
205
+ }
206
+ const routes = new Map();
207
+ for (const entry of snapshots.values())
208
+ for (const offer of entry.service.offers) {
209
+ const service = entry.service, historical = { ...config, paymentProfiles: entry.profiles, services: [service] };
210
+ const card = createOfferCard(historical, service.id, offer.id, this.options.origin);
211
+ const active = config.services.some(s => s.id === service.id && s.revision === service.revision);
212
+ routes.set(offerPath(service.id, service.revision, offer.id), { card, rpc: new JsonRpcTransportHandler(new OrderHandler(this, service, offer.id, card, this.store, active)), tenant: `${service.id}:${service.revision}`, service, offerId: offer.id, active });
213
+ }
214
+ this.store.registerCatalog(config);
215
+ this.currentConfig = config;
216
+ this.routes = routes;
217
+ }
218
+ activeRevision(service) { return this.currentConfig.services.some(s => s.id === service.id && s.revision === service.revision); }
219
+ context(caller, tenant) {
220
+ return new ServerCallContext({ user: { isAuthenticated: true, userName: caller }, tenant, requestedVersion: '1.0' });
221
+ }
222
+ quote(service, offerId, caller, messageId, input) {
223
+ if (!this.activeRevision(service))
224
+ throw new CommerceError('retired_revision', 'This service revision is retired; recover original Tasks');
225
+ const frozen = this.store.catalogHistory().find(entry => entry.service.id === service.id && entry.service.revision === service.revision);
226
+ if (!frozen)
227
+ throw new CommerceError('unknown_revision', 'Service revision is not registered');
228
+ const config = { ...this.currentConfig, paymentProfiles: frozen.profiles, services: [frozen.service] }, offer = frozen.service.offers.find(o => o.id === offerId);
229
+ const profile = offer.paymentProfile ? frozen.profiles[offer.paymentProfile] : undefined;
230
+ return buildQuote(config, { serviceId: service.id, offerId, caller, messageId, input, ...(profile?.adapter === 'mpp' ? { stripeRecipient: this.options.stripeRecipientFor?.(profile.accountRef) } : {}) });
231
+ }
232
+ async handle(request) {
233
+ const url = new URL(request.url);
234
+ if (url.origin !== new URL(this.options.origin).origin)
235
+ return Response.json({ error: 'invalid_host' }, { status: 421 });
236
+ if (request.method === 'GET' && ['/healthz', '/readyz'].includes(url.pathname))
237
+ return Response.json({ status: this.stopped ? 'draining' : 'ready' }, { status: this.stopped ? 503 : 200, headers: { 'Cache-Control': 'no-store' } });
238
+ const proof = /^\/\.well-known\/envar\/([0-9a-f-]{36})$/.exec(url.pathname);
239
+ if (request.method === 'GET' && proof) {
240
+ const challenge = this.options.ownershipChallenges?.[proof[1]];
241
+ if (!challenge)
242
+ return Response.json({ error: 'not_found' }, { status: 404 });
243
+ return Response.json({ agent_id: proof[1], challenge }, { headers: { 'Cache-Control': 'no-store' } });
244
+ }
245
+ const base = url.pathname.replace(/\/(agent-card\.json|a2a)$/, '');
246
+ const route = this.routes.get(base);
247
+ if (!route)
248
+ return Response.json({ error: 'not_found' }, { status: 404 });
249
+ if (request.method === 'GET' && url.pathname.endsWith('/agent-card.json'))
250
+ return Response.json(AgentCard.toJSON(route.card), { headers: { 'Cache-Control': 'public, max-age=60' } });
251
+ if (request.method !== 'POST' || !url.pathname.endsWith('/a2a'))
252
+ return Response.json({ error: 'method_not_allowed' }, { status: 405 });
253
+ if (this.stopped)
254
+ return Response.json({ error: 'server_draining' }, { status: 503, headers: { 'Retry-After': '5' } });
255
+ const caller = await this.options.authenticate(request);
256
+ if (!caller)
257
+ return Response.json({ error: 'authentication_required' }, { status: 401, headers: { 'WWW-Authenticate': 'Bearer', 'Cache-Control': 'no-store' } });
258
+ if (request.headers.get('A2A-Version') !== '1.0')
259
+ return Response.json({ error: 'A2A-Version 1.0 required' }, { status: 400 });
260
+ if (Number(request.headers.get('Content-Length') ?? 0) > 1024 * 1024)
261
+ return Response.json({ error: 'request_too_large' }, { status: 413 });
262
+ let size = 0;
263
+ const chunks = [];
264
+ if (request.body) {
265
+ const reader = request.body.getReader();
266
+ while (true) {
267
+ const { value, done } = await reader.read();
268
+ if (done)
269
+ break;
270
+ size += value.byteLength;
271
+ if (size > 1024 * 1024) {
272
+ await reader.cancel();
273
+ return Response.json({ error: 'request_too_large' }, { status: 413 });
274
+ }
275
+ chunks.push(value);
276
+ }
277
+ }
278
+ const body = Buffer.concat(chunks).toString('utf8');
279
+ let requestId = null;
280
+ try {
281
+ const raw = JSON.parse(body);
282
+ if (!raw || Array.isArray(raw) || typeof raw !== 'object')
283
+ throw new RequestMalformedError('A single JSONRPC object is required');
284
+ requestId = raw.id ?? null;
285
+ const params = raw.params;
286
+ if (params && typeof params === 'object' && 'tenant' in params && params.tenant !== route.tenant)
287
+ return Response.json({ jsonrpc: '2.0', id: raw.id ?? null, error: { code: -32602, message: 'Invalid tenant' } }, { status: 200 });
288
+ let paymentHeaders = {};
289
+ if (raw.method === 'SendMessage') {
290
+ validateBeforePayment(raw);
291
+ const message = SendMessageRequest.fromJSON(params);
292
+ const input = structuredInput(message);
293
+ // A taskId does not grant access: the handler verifies the original owner,
294
+ // service/offer, confirmed payment and waiting state before any continuation.
295
+ if (this.options.paymentGate && !message.message.taskId) {
296
+ const prior = this.store.findOrder(caller, route.tenant, message.message.messageId);
297
+ if (prior && (prior.inputDigest !== digest(input) || prior.offerId !== route.offerId))
298
+ throw new RequestMalformedError('Purchase request conflicts with the original');
299
+ if (!prior && !this.activeRevision(route.service))
300
+ throw new UnsupportedOperationError('This service revision is retired');
301
+ const order = prior ?? this.store.ensureQuote(this.quote(route.service, route.offerId, caller, message.message.messageId, input), input);
302
+ if (order.paymentState !== 'not_required') {
303
+ const gate = await this.options.paymentGate.handle(new Request(request.url, { method: 'POST', headers: request.headers, body }), order);
304
+ if ('response' in gate)
305
+ return gate.response;
306
+ paymentHeaders = gate.headers;
307
+ this.schedule();
308
+ }
309
+ }
310
+ }
311
+ const result = await route.rpc.handle(body, this.context(caller, route.tenant));
312
+ if (Symbol.asyncIterator in result)
313
+ return Response.json({ jsonrpc: '2.0', id: raw.id ?? null, error: { code: -32004, message: 'Streaming not available' } }, { status: 200 });
314
+ return Response.json(result, { headers: { 'A2A-Version': '1.0', 'Cache-Control': 'no-store', ...paymentHeaders } });
315
+ }
316
+ catch (error) {
317
+ const mapped = error instanceof CommerceError ? { code: -32000, message: error.message, data: { code: error.code } } : JsonRpcTransportHandler.mapToJSONRPCError(error);
318
+ return Response.json({ jsonrpc: '2.0', id: requestId, error: mapped }, { headers: { 'Cache-Control': 'no-store' } });
319
+ }
320
+ }
321
+ async continueTask(params, input, service, offerId, context) {
322
+ const m = params.message, caller = context.user.userName;
323
+ const order = this.store.orderForTask(m.taskId, caller, context.tenant ?? '');
324
+ const original = order ? await this.store.load(order.taskId, context) : undefined;
325
+ if (!order || !original || order.offerId !== offerId)
326
+ throw new TaskNotFoundError('Original Task not found on this offer');
327
+ if (m.contextId && m.contextId !== original.contextId)
328
+ throw new RequestMalformedError('Context does not match the original Task');
329
+ const existing = this.store.continuationFor(order.id, m.messageId);
330
+ if (existing) {
331
+ if (existing.requestDigest !== digest(input))
332
+ throw new RequestMalformedError('Clarification message ID already identifies different input');
333
+ return original;
334
+ }
335
+ if (!this.options.execute.continue)
336
+ throw new UnsupportedOperationError('This executor does not support scoped Task continuation');
337
+ if (this.activeOrders.has(order.id) || this.recoveries.has(order.id))
338
+ throw new CommerceError('task_busy', 'Wait for the current Task observation to finish');
339
+ if (!this.store.remoteTask(order.id, caller))
340
+ throw new CommerceError('task_recovery_required', 'No original remote Task is available');
341
+ const merged = clarification(service, offerId, this.store.clarificationInput(order.id), input);
342
+ this.store.enqueueContinuation(order.id, caller, m.messageId, input, merged);
343
+ this.schedule();
344
+ return params.configuration?.returnImmediately === true ? (await this.store.load(order.taskId, context)) : this.waitForTask(order.taskId, context);
345
+ }
346
+ async recoverTask(taskId, context) {
347
+ const original = await this.store.load(taskId, context);
348
+ if (!original)
349
+ throw new TaskNotFoundError('Task not found');
350
+ const order = this.store.orderForTask(taskId, context.user.userName, context.tenant ?? '');
351
+ if (!order || TERMINAL.includes(original.status?.state ?? 0) || this.activeOrders.has(order.id) || order.executionState === 'queued' || !this.options.execute.recover)
352
+ return original;
353
+ const remote = this.store.remoteTask(order.id, order.caller), service = this.store.serviceRevision(order.serviceRevision);
354
+ if (!remote || !service)
355
+ return original;
356
+ let pending = this.recoveries.get(order.id);
357
+ if (!pending) {
358
+ pending = (async () => {
359
+ try {
360
+ const task = await this.options.execute.recover(order, service, remote);
361
+ this.store.recordRemoteTask(order.id, remote.origin, task.id, task.contextId, this.options.execute.remoteInterface?.(task) ?? remote.interfaceUrl);
362
+ this.store.acknowledgeRecoveredContinuations(order.id, task);
363
+ const projected = this.project(task, original, order);
364
+ await this.store.save(projected, context);
365
+ this.wake(taskId);
366
+ }
367
+ catch (error) {
368
+ this.options.onError?.(error instanceof CommerceError ? error.code : 'recovery_unavailable', order.id);
369
+ }
370
+ })().finally(() => this.recoveries.delete(order.id));
371
+ this.recoveries.set(order.id, pending);
372
+ }
373
+ await pending;
374
+ return (await this.store.load(taskId, context));
375
+ }
376
+ project(remote, original, order) {
377
+ const projected = Task.fromJSON({ ...Task.toJSON(remote), id: order.taskId, contextId: original.contextId });
378
+ for (const m of [...projected.history, ...(projected.status?.message ? [projected.status.message] : [])]) {
379
+ if (m.taskId === remote.id)
380
+ m.taskId = order.taskId;
381
+ if (m.contextId === remote.contextId)
382
+ m.contextId = original.contextId;
383
+ }
384
+ const metadata = { ...projected.metadata };
385
+ delete metadata.recoveryRequired;
386
+ delete metadata.executionState;
387
+ delete metadata.clarificationPending;
388
+ if (this.store.hasUnresolvedContinuation(order.id)) {
389
+ metadata.recoveryRequired = true;
390
+ metadata.clarificationPending = true;
391
+ }
392
+ projected.metadata = metadata;
393
+ return projected;
394
+ }
395
+ schedule() {
396
+ if (this.stopped)
397
+ return;
398
+ if (this.running) {
399
+ this.kickPending = true;
400
+ return;
401
+ }
402
+ this.running = true;
403
+ void this.drain().catch(() => this.options.onError?.('worker_failure')).finally(() => { this.running = false; if (this.kickPending) {
404
+ this.kickPending = false;
405
+ this.schedule();
406
+ } });
407
+ }
408
+ async drain() {
409
+ while (!this.stopped) {
410
+ const continued = this.store.claimContinuation();
411
+ const order = continued?.order ?? this.store.claimReady();
412
+ if (!order)
413
+ return;
414
+ const service = this.store.serviceRevision(order.serviceRevision), context = this.context(order.caller, order.serviceRevision);
415
+ const original = await this.store.load(order.taskId, context);
416
+ if (!service || !original) {
417
+ if (continued)
418
+ this.store.unknownContinuation(continued.continuation.id);
419
+ this.store.markExecutionUnknown(order.id);
420
+ this.wake(order.taskId);
421
+ continue;
422
+ }
423
+ this.activeOrders.add(order.id);
424
+ try {
425
+ const reference = this.store.remoteTask(order.id, order.caller);
426
+ if (continued && (!reference || !this.options.execute.continue))
427
+ throw new CommerceError('continuation_unavailable', 'Original continuation executor is unavailable');
428
+ const stream = continued ? this.options.execute.continue(order, service, continued.continuation, reference) : this.options.execute(order, service);
429
+ let observed = false;
430
+ for await (const remote of stream) {
431
+ if (!remote.id || !remote.status)
432
+ throw new CommerceError('invalid_agent_response', 'Runtime returned an invalid Task');
433
+ this.store.recordRemoteTask(order.id, service.execution.cardUrl, remote.id, remote.contextId, this.options.execute.remoteInterface?.(remote));
434
+ if (continued)
435
+ this.store.acceptContinuation(continued.continuation.id);
436
+ await this.store.save(this.project(remote, original, order), context);
437
+ this.wake(order.taskId);
438
+ if (TERMINAL.includes(remote.status.state) || WAITING.includes(remote.status.state)) {
439
+ observed = true;
440
+ break;
441
+ }
442
+ }
443
+ if (!observed) {
444
+ this.store.markExecutionUnknown(order.id);
445
+ this.wake(order.taskId);
446
+ }
447
+ }
448
+ catch (error) {
449
+ if (continued)
450
+ this.store.unknownContinuation(continued.continuation.id);
451
+ this.store.markExecutionUnknown(order.id);
452
+ this.options.onError?.(error instanceof CommerceError ? error.code : 'execution_unknown', order.id);
453
+ this.wake(order.taskId);
454
+ }
455
+ finally {
456
+ this.activeOrders.delete(order.id);
457
+ }
458
+ }
459
+ }
460
+ async waitForTask(taskId, context) {
461
+ for (;;) {
462
+ const task = await this.store.load(taskId, context);
463
+ if (!task)
464
+ throw new TaskNotFoundError('Task not found');
465
+ if (task.metadata?.recoveryRequired)
466
+ throw new CommerceError('execution_unknown', `Execution requires reconciliation; retrieve original Task ${taskId}`);
467
+ if (TERMINAL.includes(task.status?.state ?? 0) || WAITING.includes(task.status?.state ?? 0))
468
+ return task;
469
+ if (this.stopped)
470
+ throw new CommerceError('server_draining', 'Retrieve the original Task after restart');
471
+ await new Promise((resolve, reject) => {
472
+ const callbacks = this.waiters.get(taskId) ?? new Set();
473
+ const done = () => { clearTimeout(timer); callbacks.delete(done); resolve(); };
474
+ const timer = setTimeout(() => { callbacks.delete(done); reject(new CommerceError('execution_pending', 'Use GetTask with the original Task ID')); }, 30000);
475
+ callbacks.add(done);
476
+ this.waiters.set(taskId, callbacks);
477
+ });
478
+ }
479
+ }
480
+ wake(taskId) { for (const callback of this.waiters.get(taskId) ?? [])
481
+ callback(); this.waiters.delete(taskId); }
482
+ stop() { this.stopped = true; for (const id of this.waiters.keys())
483
+ this.wake(id); }
484
+ get isRunning() { return this.running || this.recoveries.size > 0; }
485
+ }