@envarai/envarpay 0.2.0-alpha.17 → 0.2.0-alpha.18

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.
@@ -0,0 +1,244 @@
1
+ import { ExternalAgentOAuth } from "./external-oauth.js";
2
+ import { createServer } from "node:http";
3
+ import { Readable } from "node:stream";
4
+ import { join, dirname, resolve } from "node:path";
5
+ import { readFileSync, lstatSync, chmodSync, mkdirSync, writeFileSync, } from "node:fs";
6
+ import { randomBytes } from "node:crypto";
7
+ import { CommerceStore } from "./store.js";
8
+ import { CommerceServer, bearerAuthenticator } from "./server.js";
9
+ import { loadCommerceConfig } from "./config.js";
10
+ import { ExternalAgentExecutor } from "./external-agent.js";
11
+ import { ExternalAgentEvents, publicWebhook, } from "./external-events.js";
12
+ import { ExternalAgentMcp, externalAgentSkill } from "./external-mcp.js";
13
+ import { CredentialVault } from "./vault.js";
14
+ import { CommerceError } from "./types.js";
15
+ export function setupExternalAgent(directory, origin) {
16
+ directory = resolve(directory);
17
+ const u = new URL(origin);
18
+ if (u.protocol !== "https:" || u.origin !== origin)
19
+ throw new CommerceError("external_origin", "Provide the exact public HTTPS origin");
20
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
21
+ const config = {
22
+ configVersion: 1,
23
+ agent: { id: "external-agent", name: "External Agent" },
24
+ paymentProfiles: {},
25
+ services: [
26
+ {
27
+ id: "text-answer",
28
+ name: "Bounded text answer",
29
+ revision: 1,
30
+ execution: { type: "a2a", cardUrl: origin + "/agent-card.json" },
31
+ contract: {
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: {
35
+ request: { type: "string", minLength: 1, maxLength: 2000 },
36
+ },
37
+ required: ["request"],
38
+ additionalProperties: false,
39
+ },
40
+ deliverables: ["external-agent-result.txt"],
41
+ targetDurationSeconds: 600,
42
+ includedRevisions: 0,
43
+ },
44
+ offers: [
45
+ {
46
+ id: "free",
47
+ pricing: { kind: "free" },
48
+ collection: { kind: "none" },
49
+ },
50
+ ],
51
+ },
52
+ ],
53
+ };
54
+ loadCommerceConfig(config);
55
+ const agentToken = randomBytes(32).toString("base64url"), buyerToken = randomBytes(32).toString("base64url");
56
+ const credentials = {
57
+ principal: "external-agent",
58
+ services: ["text-answer"],
59
+ agentTokens: { [agentToken]: "external-agent" },
60
+ callers: { [buyerToken]: "owner" },
61
+ vaultKeyFile: join(directory, "events.key"),
62
+ };
63
+ for (const [name, value] of Object.entries({
64
+ "seller.json": JSON.stringify(config, null, 2),
65
+ "credentials.json": JSON.stringify(credentials, null, 2),
66
+ "events.key": randomBytes(32),
67
+ "agent.token": agentToken,
68
+ "pairing.key": randomBytes(32).toString("base64url"),
69
+ "buyer.token": buyerToken,
70
+ "SKILL.md": externalAgentSkill,
71
+ "connection.json": JSON.stringify({
72
+ url: origin + "/services/text-answer/v1/offers/free/agent-card.json",
73
+ authToken: buyerToken,
74
+ mcpUrl: origin + "/mcp",
75
+ }),
76
+ }))
77
+ writeFileSync(join(directory, name), value, { mode: 0o600, flag: "wx" });
78
+ return {
79
+ directory,
80
+ cardUrl: origin + "/services/text-answer/v1/offers/free/agent-card.json",
81
+ mcpUrl: origin + "/mcp",
82
+ credentialsFile: join(directory, "credentials.json"),
83
+ paymentsEnabled: false,
84
+ };
85
+ }
86
+ export function serveExternalAgent(options) {
87
+ const privateBytes = (path) => {
88
+ const s = lstatSync(path);
89
+ if (!s.isFile() || s.isSymbolicLink() || s.mode & 0o077 || s.size > 65536)
90
+ throw new CommerceError("external_private_file", "Use private regular config/credential files");
91
+ return readFileSync(path);
92
+ };
93
+ const privateJson = (path) => JSON.parse(privateBytes(path).toString("utf8"));
94
+ const config = loadCommerceConfig(privateJson(options.configFile)), secret = privateJson(options.credentialsFile);
95
+ if (config.services.some((s) => s.execution.type !== "a2a" ||
96
+ s.offers.some((o) => o.pricing.kind !== "free")) ||
97
+ Object.keys(config.paymentProfiles).length)
98
+ throw new CommerceError("external_free_only", "External setup currently supports explicit free task acceptance only");
99
+ if (Object.keys(secret.agentTokens).some((token) => Object.hasOwn(secret.callers, token)))
100
+ throw new CommerceError("external_token_scope", "External Agent tokens must differ from buyer credentials");
101
+ const store = new CommerceStore(options.stateFile), executor = new ExternalAgentExecutor(store, secret.principal, secret.services), vault = new CredentialVault(join(dirname(options.stateFile), "event-secrets"), privateBytes(secret.vaultKeyFile));
102
+ const pairingFile = join(dirname(options.credentialsFile), "pairing.key"), oauth = new ExternalAgentOAuth(options.stateFile + ".oauth.sqlite3", options.origin, secret.principal, privateBytes(pairingFile).toString("utf8").trim());
103
+ const agentAuth = bearerAuthenticator(secret.agentTokens);
104
+ const eventsPath = options.stateFile + ".events.sqlite3", events = new ExternalAgentEvents(eventsPath, executor, vault, (url, body, headers) => publicWebhook(url, body, headers, options.callbackLookup));
105
+ chmodSync(eventsPath, 0o600);
106
+ const mcp = new ExternalAgentMcp(executor, events, async (r) => oauth.authenticate(r) ?? (await agentAuth(r)));
107
+ const server = new CommerceServer({
108
+ config,
109
+ store,
110
+ origin: options.origin,
111
+ authenticate: bearerAuthenticator(secret.callers),
112
+ execute: executor.execute,
113
+ });
114
+ const expected = new URL(options.origin).host;
115
+ async function externalA2a(request) {
116
+ const path = new URL(request.url).pathname;
117
+ let normalized = request;
118
+ if (request.method === "POST" && path.endsWith("/a2a")) {
119
+ const rawText = await request.text();
120
+ if (Buffer.byteLength(rawText) > 65536)
121
+ return Response.json({ error: "request_too_large" }, { status: 413 });
122
+ let raw;
123
+ try {
124
+ raw = JSON.parse(rawText);
125
+ }
126
+ catch {
127
+ return Response.json({ error: "invalid_json" }, { status: 400 });
128
+ }
129
+ if (raw.method === "SendMessage") {
130
+ const message = raw.params?.message;
131
+ if (message?.parts?.length === 1 &&
132
+ typeof message.parts[0]?.text === "string" &&
133
+ Object.keys(message.parts[0]).every((k) => ["text", "mediaType"].includes(k))) {
134
+ const text = message.parts[0].text;
135
+ // This opt-in external service advertises a bounded plain-text request.
136
+ // A JSON-text request with exactly that schema is also accepted.
137
+ let input = { request: text };
138
+ try {
139
+ const parsed = JSON.parse(text);
140
+ if (parsed &&
141
+ !Array.isArray(parsed) &&
142
+ Object.keys(parsed).length === 1 &&
143
+ typeof parsed.request === "string")
144
+ input = parsed;
145
+ }
146
+ catch { }
147
+ message.parts = [{ data: input }];
148
+ }
149
+ raw.params.configuration = {
150
+ ...raw.params.configuration,
151
+ returnImmediately: true,
152
+ };
153
+ }
154
+ normalized = new Request(request.url, {
155
+ method: "POST",
156
+ headers: request.headers,
157
+ body: JSON.stringify(raw),
158
+ });
159
+ }
160
+ const response = await server.handle(normalized);
161
+ if (response.status !== 200)
162
+ return response;
163
+ const value = await response.json();
164
+ if (request.method === "GET" && path.endsWith("/agent-card.json")) {
165
+ value.defaultInputModes = ["application/json", "text/plain"];
166
+ }
167
+ // Internally the completed dispatch queue waits for an external claim.
168
+ // A2A buyers see WORKING and can poll until this original Task completes.
169
+ for (const task of [value.result?.task, value.result]) {
170
+ if (task?.status?.state === "TASK_STATE_INPUT_REQUIRED" &&
171
+ task.metadata?.externalAgent) {
172
+ task.status.state = "TASK_STATE_WORKING";
173
+ delete task.status.message;
174
+ }
175
+ }
176
+ return Response.json(value, {
177
+ status: response.status,
178
+ headers: response.headers,
179
+ });
180
+ }
181
+ const http = createServer(async (req, res) => {
182
+ try {
183
+ if (req.headers.host !== expected) {
184
+ res.writeHead(421);
185
+ res.end();
186
+ return;
187
+ }
188
+ const headers = new Headers();
189
+ for (const [k, v] of Object.entries(req.headers))
190
+ if (v !== undefined)
191
+ headers.set(k, Array.isArray(v) ? v.join(",") : v);
192
+ const request = new Request(new URL(req.url ?? "/", options.origin), {
193
+ method: req.method,
194
+ headers,
195
+ ...(!["GET", "HEAD"].includes(req.method ?? "GET")
196
+ ? { body: Readable.toWeb(req), duplex: "half" }
197
+ : {}),
198
+ });
199
+ let response = await oauth.handle(request);
200
+ if (!response)
201
+ response =
202
+ new URL(request.url).pathname === "/mcp"
203
+ ? await mcp.handle(request)
204
+ : await externalA2a(request);
205
+ if (response.status === 401 && new URL(request.url).pathname === "/mcp")
206
+ response = new Response(response.body, {
207
+ status: 401,
208
+ headers: {
209
+ ...Object.fromEntries(response.headers),
210
+ "WWW-Authenticate": oauth.challenge(),
211
+ },
212
+ });
213
+ res.writeHead(response.status, Object.fromEntries(response.headers));
214
+ if (response.body)
215
+ Readable.fromWeb(response.body).pipe(res);
216
+ else
217
+ res.end();
218
+ }
219
+ catch {
220
+ if (!res.headersSent)
221
+ res.writeHead(500);
222
+ res.end('{"error":"request_failed"}');
223
+ }
224
+ });
225
+ http.requestTimeout = 30000;
226
+ http.headersTimeout = 15000;
227
+ http.listen(options.port, options.host);
228
+ events.start();
229
+ return {
230
+ http,
231
+ store,
232
+ events,
233
+ server,
234
+ close: async () => {
235
+ oauth.close();
236
+ await events.close();
237
+ server.stop();
238
+ await new Promise((resolve) => http.close(() => resolve()));
239
+ while (server.isRunning)
240
+ await new Promise((r) => setTimeout(r, 10));
241
+ store.close();
242
+ },
243
+ };
244
+ }
@@ -8,3 +8,4 @@ export * from './http.js';
8
8
  export * from './x402.js';
9
9
  export * from './vault.js';
10
10
  export * from './chain.js';
11
+ export * from './external-agent.js';
@@ -8,3 +8,4 @@ export * from './http.js';
8
8
  export * from './x402.js';
9
9
  export * from './vault.js';
10
10
  export * from './chain.js';
11
+ export * from './external-agent.js';
@@ -80,8 +80,18 @@ export declare class CommerceStore implements TaskStore {
80
80
  hasUnresolvedContinuation(orderId: string): boolean;
81
81
  acknowledgeRecoveredContinuations(orderId: string, task: Task): void;
82
82
  recoverInterruptedDispatches(): number;
83
+ prepareExternalTask(orderId: string, principal: string): void;
84
+ externalTasks(principal: string, services: readonly string[]): {
85
+ order: OrderRecord;
86
+ claimId: string;
87
+ }[];
88
+ claimExternalTask(taskId: string, principal: string, services: readonly string[]): {
89
+ order: OrderRecord;
90
+ claimId: string;
91
+ };
92
+ externalClaim(taskId: string, principal: string, claimId: string, services: readonly string[]): OrderRecord;
83
93
  private scope;
84
- save(task: Task, context: ServerCallContext): Promise<void>;
94
+ save(task: Task, context: ServerCallContext, expectedOriginal?: Task): Promise<void>;
85
95
  load(taskId: string, context: ServerCallContext): Promise<Task | undefined>;
86
96
  list(params: ListTasksRequest, context: ServerCallContext): Promise<ListTasksResponse>;
87
97
  fingerprint(order: OrderRecord): string;
@@ -97,7 +97,11 @@ export class CommerceStore {
97
97
  state TEXT NOT NULL CHECK(state IN ('ready','dispatching','done','unknown')), UNIQUE(order_id,message_id)
98
98
  );
99
99
  CREATE INDEX IF NOT EXISTS commerce_tasks_owner_tenant_updated ON commerce_tasks(owner,tenant,updated_at DESC,id);
100
- CREATE INDEX IF NOT EXISTS commerce_continuations_order_state ON commerce_continuations(order_id,state);`);
100
+ CREATE INDEX IF NOT EXISTS commerce_continuations_order_state ON commerce_continuations(order_id,state);
101
+ CREATE TABLE IF NOT EXISTS commerce_external_claims(
102
+ order_id TEXT PRIMARY KEY REFERENCES commerce_orders(id),
103
+ principal TEXT NOT NULL, claim_id TEXT NOT NULL DEFAULT ''
104
+ );`);
101
105
  }
102
106
  migrateV1() {
103
107
  // Rebuild only the state constraint. Preserve original IDs, credentials and every payment row.
@@ -394,17 +398,47 @@ export class CommerceStore {
394
398
  return pending.length;
395
399
  });
396
400
  }
401
+ prepareExternalTask(orderId, principal) {
402
+ const order = this.order(this.db.prepare('SELECT * FROM commerce_orders WHERE id=?').get(orderId));
403
+ if (!order || !['confirmed', 'not_required'].includes(order.paymentState) || order.executionState !== 'dispatching')
404
+ throw new CommerceError('external_payment_required', 'Only a dispatched and payment-approved original order can be delegated');
405
+ const existing = this.db.prepare('SELECT principal FROM commerce_external_claims WHERE order_id=?').get(orderId);
406
+ if (existing && existing.principal !== principal)
407
+ throw new CommerceError('external_owner', 'Original execution belongs to another external agent');
408
+ this.db.prepare('INSERT OR IGNORE INTO commerce_external_claims(order_id,principal) VALUES (?,?)').run(orderId, principal);
409
+ }
410
+ externalTasks(principal, services) {
411
+ return this.db.prepare("SELECT o.*,c.claim_id FROM commerce_orders o JOIN commerce_external_claims c ON c.order_id=o.id WHERE c.principal=? AND o.payment_state IN ('confirmed','not_required') AND o.execution_state IN ('input_required','working') ORDER BY o.rowid LIMIT 100").all(principal).filter(r => services.includes(String(r.service_revision).split(':')[0])).map(r => ({ order: this.order(r), claimId: String(r.claim_id) }));
412
+ }
413
+ claimExternalTask(taskId, principal, services) {
414
+ return this.transaction(() => {
415
+ const row = this.db.prepare("SELECT o.*,c.claim_id FROM commerce_orders o JOIN commerce_external_claims c ON c.order_id=o.id WHERE o.task_id=? AND c.principal=? AND o.payment_state IN ('confirmed','not_required') AND o.execution_state IN ('input_required','working','completed','failed')").get(taskId, principal);
416
+ if (!row || !services.includes(String(row.service_revision).split(':')[0]))
417
+ throw new CommerceError('external_task', 'Original Task is outside this external agent scope');
418
+ const claimId = String(row.claim_id) || randomUUID();
419
+ this.db.prepare('UPDATE commerce_external_claims SET claim_id=? WHERE order_id=?').run(claimId, String(row.id));
420
+ return { order: this.order(row), claimId };
421
+ });
422
+ }
423
+ externalClaim(taskId, principal, claimId, services) {
424
+ const row = this.db.prepare("SELECT o.* FROM commerce_orders o JOIN commerce_external_claims c ON c.order_id=o.id WHERE o.task_id=? AND c.principal=? AND c.claim_id=? AND c.claim_id!='' AND o.payment_state IN ('confirmed','not_required')").get(taskId, principal, claimId);
425
+ if (!row || !services.includes(String(row.service_revision).split(':')[0]))
426
+ throw new CommerceError('external_claim', 'Claim does not identify an authorized original Task');
427
+ return this.order(row);
428
+ }
397
429
  scope(context) {
398
430
  if (!context.user?.isAuthenticated || !context.user.userName)
399
431
  throw new CommerceError('authentication_required', 'Authenticated task owner required');
400
432
  return { owner: context.user.userName, tenant: context.tenant ?? '' };
401
433
  }
402
- async save(task, context) {
434
+ async save(task, context, expectedOriginal) {
403
435
  const scope = this.scope(context);
404
436
  this.transaction(() => {
405
437
  const current = this.db.prepare('SELECT owner,tenant,task_json FROM commerce_tasks WHERE id=?').get(task.id);
406
438
  if (current && (current.owner !== scope.owner || current.tenant !== scope.tenant))
407
439
  throw new CommerceError('task_owner_conflict', 'Cannot replace another owner task');
440
+ if (expectedOriginal && current?.task_json !== JSON.stringify(Task.toJSON(expectedOriginal)))
441
+ throw new CommerceError('external_result_conflict', 'Original Task changed while preparing this result; read and recover it before retrying');
408
442
  const order = this.db.prepare('SELECT payment_state FROM commerce_orders WHERE task_id=?').get(task.id);
409
443
  if (order && !['confirmed', 'not_required'].includes(String(order.payment_state)))
410
444
  throw new CommerceError('payment_required', 'Cannot publish task execution before payment');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envarai/envarpay",
3
- "version": "0.2.0-alpha.17",
3
+ "version": "0.2.0-alpha.18",
4
4
  "description": "Independent A2A service commerce, budgeted buyers and x402/MPP payments",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -69,7 +69,8 @@
69
69
  "canonicalize": "2.1.0",
70
70
  "mppx": "0.13.1",
71
71
  "viem": "2.57.1",
72
- "yaml": "2.8.2"
72
+ "yaml": "2.8.2",
73
+ "zod": "4.6.5"
73
74
  },
74
75
  "devDependencies": {
75
76
  "@types/node": "^22.0.0",