@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.
- package/README.md +56 -146
- package/dist/commerce/buyer-store.d.ts +93 -0
- package/dist/commerce/buyer-store.js +168 -0
- package/dist/commerce/buyer.d.ts +129 -0
- package/dist/commerce/buyer.js +631 -0
- package/dist/commerce/card.d.ts +5 -0
- package/dist/commerce/card.js +29 -0
- package/dist/commerce/chain.d.ts +24 -0
- package/dist/commerce/chain.js +146 -0
- package/dist/commerce/cli.d.ts +2 -0
- package/dist/commerce/cli.js +268 -0
- package/dist/commerce/client-runtime.d.ts +8 -0
- package/dist/commerce/client-runtime.js +7 -0
- package/dist/commerce/config.d.ts +7 -0
- package/dist/commerce/config.js +164 -0
- package/dist/commerce/envar-runtime.d.ts +46 -0
- package/dist/commerce/envar-runtime.js +256 -0
- package/dist/commerce/envar.d.ts +126 -0
- package/dist/commerce/envar.js +647 -0
- package/dist/commerce/http.d.ts +2 -0
- package/dist/commerce/http.js +34 -0
- package/dist/commerce/index.d.ts +4 -0
- package/dist/commerce/index.js +4 -0
- package/dist/commerce/management.d.ts +14 -0
- package/dist/commerce/management.js +138 -0
- package/dist/commerce/mpp-client.d.ts +59 -0
- package/dist/commerce/mpp-client.js +24 -0
- package/dist/commerce/mpp-runtime.d.ts +3 -0
- package/dist/commerce/mpp-runtime.js +3 -0
- package/dist/commerce/mpp.d.ts +53 -0
- package/dist/commerce/mpp.js +163 -0
- package/dist/commerce/peer-proxy.d.ts +44 -0
- package/dist/commerce/peer-proxy.js +667 -0
- package/dist/commerce/pricing.d.ts +12 -0
- package/dist/commerce/pricing.js +60 -0
- package/dist/commerce/runtime.d.ts +8 -0
- package/dist/commerce/runtime.js +8 -0
- package/dist/commerce/server.d.ts +58 -0
- package/dist/commerce/server.js +485 -0
- package/dist/commerce/store.d.ts +86 -0
- package/dist/commerce/store.js +466 -0
- package/dist/commerce/stripe-provider.d.ts +9 -0
- package/dist/commerce/stripe-provider.js +97 -0
- package/dist/commerce/types.d.ts +113 -0
- package/dist/commerce/types.js +8 -0
- package/dist/commerce/upstream.d.ts +6 -0
- package/dist/commerce/upstream.js +103 -0
- package/dist/commerce/vault.d.ts +8 -0
- package/dist/commerce/vault.js +55 -0
- package/dist/commerce/x402.d.ts +37 -0
- package/dist/commerce/x402.js +174 -0
- package/examples/buyer-policy.json +18 -0
- package/examples/envar-integration-policy.json +18 -0
- package/examples/seller.json +52 -0
- package/package.json +75 -10
- package/schemas/buyer-policy.schema.json +148 -0
- package/schemas/envarpay.config.schema.json +376 -0
- package/schemas/quote.schema.json +91 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/** Optional directory integration; independent A2A/payment paths never import this module. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { atomic, currencyOf, digest, loadCommerceConfig } from "./config.js";
|
|
7
|
+
import { CommerceError, } from "./types.js";
|
|
8
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, HASH = /^0x[0-9a-f]{64}$/i, ADDRESS = /^0x[0-9a-f]{40}$/i, ID = /^[a-z][a-z0-9-]{0,63}$/;
|
|
9
|
+
const STATUS_KINDS = [
|
|
10
|
+
"seller_started",
|
|
11
|
+
"seller_completed",
|
|
12
|
+
"seller_failed",
|
|
13
|
+
"buyer_received",
|
|
14
|
+
"buyer_failed",
|
|
15
|
+
"execution_unknown",
|
|
16
|
+
];
|
|
17
|
+
function fail(code, message) {
|
|
18
|
+
throw new CommerceError(code, message);
|
|
19
|
+
}
|
|
20
|
+
function object(value) {
|
|
21
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
function uuid(value) {
|
|
24
|
+
if (typeof value !== "string" || !UUID.test(value))
|
|
25
|
+
fail("envar_identifier", "Use a UUID platform identifier");
|
|
26
|
+
}
|
|
27
|
+
function origin(value, allowHttp = false) {
|
|
28
|
+
let url;
|
|
29
|
+
try {
|
|
30
|
+
url = new URL(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return fail("envar_origin", "Configure an absolute allowed origin");
|
|
34
|
+
}
|
|
35
|
+
if (!["https:", ...(allowHttp ? ["http:"] : [])].includes(url.protocol) ||
|
|
36
|
+
url.username ||
|
|
37
|
+
url.password ||
|
|
38
|
+
url.pathname !== "/" ||
|
|
39
|
+
url.search ||
|
|
40
|
+
url.hash)
|
|
41
|
+
fail("envar_origin", "Use an exact origin without credentials, path, query or fragment");
|
|
42
|
+
return url.origin;
|
|
43
|
+
}
|
|
44
|
+
function secureDirectory(path) {
|
|
45
|
+
const directory = resolve(path);
|
|
46
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
47
|
+
const stat = lstatSync(directory);
|
|
48
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0)
|
|
49
|
+
fail("envar_private_directory", "Use a real owner-only integration directory (0700)");
|
|
50
|
+
return directory;
|
|
51
|
+
}
|
|
52
|
+
function secureFile(path) {
|
|
53
|
+
if (!existsSync(path))
|
|
54
|
+
return;
|
|
55
|
+
const stat = lstatSync(path);
|
|
56
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0)
|
|
57
|
+
fail("envar_private_file", "Integration files must be owner-only regular files");
|
|
58
|
+
}
|
|
59
|
+
function atomicJson(path, value) {
|
|
60
|
+
secureFile(path);
|
|
61
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(temporary, JSON.stringify(value, null, 2) + "\n", {
|
|
64
|
+
flag: "wx",
|
|
65
|
+
mode: 0o600,
|
|
66
|
+
flush: true,
|
|
67
|
+
});
|
|
68
|
+
renameSync(temporary, path);
|
|
69
|
+
const fd = openSync(resolve(path, ".."), "r");
|
|
70
|
+
try {
|
|
71
|
+
fsyncSync(fd);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
closeSync(fd);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
if (existsSync(temporary))
|
|
79
|
+
unlinkSync(temporary);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Same frozen fingerprint as CommerceStore.registerCatalog; callers must read actual persisted state. */
|
|
83
|
+
export function envarServiceProofs(config) {
|
|
84
|
+
return config.services.map((service) => ({
|
|
85
|
+
id: service.id,
|
|
86
|
+
revision: service.revision,
|
|
87
|
+
digest: digest({
|
|
88
|
+
service,
|
|
89
|
+
profiles: Object.fromEntries(service.offers
|
|
90
|
+
.filter((o) => o.paymentProfile)
|
|
91
|
+
.map((o) => [
|
|
92
|
+
o.paymentProfile,
|
|
93
|
+
config.paymentProfiles[o.paymentProfile],
|
|
94
|
+
])),
|
|
95
|
+
}),
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
export class EnvarIntegration {
|
|
99
|
+
options;
|
|
100
|
+
sourceInstance;
|
|
101
|
+
policy;
|
|
102
|
+
db;
|
|
103
|
+
lock;
|
|
104
|
+
configDirectory;
|
|
105
|
+
fetcher;
|
|
106
|
+
now;
|
|
107
|
+
timeoutMs;
|
|
108
|
+
limitBytes;
|
|
109
|
+
flushing = false;
|
|
110
|
+
applying = false;
|
|
111
|
+
closed = false;
|
|
112
|
+
constructor(options) {
|
|
113
|
+
this.options = options;
|
|
114
|
+
if (options.policy.enabled !== true)
|
|
115
|
+
fail("envar_not_enabled", "Envar integration requires explicit local opt-in");
|
|
116
|
+
this.policy = structuredClone(options.policy);
|
|
117
|
+
this.policy.platformOrigin = origin(this.policy.platformOrigin);
|
|
118
|
+
uuid(this.policy.agentId);
|
|
119
|
+
if (typeof this.policy.acceptUpdates !== "boolean")
|
|
120
|
+
fail("envar_policy", "acceptUpdates must be an explicit local boolean");
|
|
121
|
+
if (!ID.test(this.policy.runtimeAgentId) ||
|
|
122
|
+
this.policy.allowedServices.some((id) => !ID.test(id)))
|
|
123
|
+
fail("envar_policy", "Configure valid local Agent and service IDs");
|
|
124
|
+
this.policy.allowedUpstreamOrigins = this.policy.allowedUpstreamOrigins.map((url) => origin(url, true));
|
|
125
|
+
this.policy.allowedX402 = this.policy.allowedX402.map((entry) => {
|
|
126
|
+
if (!/^eip155:[1-9][0-9]*$/.test(entry.network) ||
|
|
127
|
+
!ADDRESS.test(entry.asset) ||
|
|
128
|
+
!ADDRESS.test(entry.payTo))
|
|
129
|
+
fail("envar_policy", "Configure explicit receiving identities");
|
|
130
|
+
return {
|
|
131
|
+
...entry,
|
|
132
|
+
asset: entry.asset.toLowerCase(),
|
|
133
|
+
payTo: entry.payTo.toLowerCase(),
|
|
134
|
+
facilitatorOrigin: origin(entry.facilitatorOrigin),
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
if (this.policy.allowedMppAccounts.some((id) => !ID.test(id)))
|
|
138
|
+
fail("envar_policy", "Configure explicit merchant account references");
|
|
139
|
+
this.timeoutMs = options.timeoutMs ?? 10000;
|
|
140
|
+
this.limitBytes = options.responseLimitBytes ?? 1048576;
|
|
141
|
+
if (!Number.isInteger(this.timeoutMs) ||
|
|
142
|
+
this.timeoutMs < 100 ||
|
|
143
|
+
this.timeoutMs > 60000 ||
|
|
144
|
+
!Number.isInteger(this.limitBytes) ||
|
|
145
|
+
this.limitBytes < 1024 ||
|
|
146
|
+
this.limitBytes > 4194304)
|
|
147
|
+
fail("envar_limits", "Configure bounded HTTP time and response limits");
|
|
148
|
+
this.fetcher = options.fetch ?? fetch;
|
|
149
|
+
this.now = options.now ?? Date.now;
|
|
150
|
+
const directory = secureDirectory(options.stateDirectory);
|
|
151
|
+
this.configDirectory = secureDirectory(options.configDirectory);
|
|
152
|
+
const lockPath = join(directory, "owner.sqlite"), dbPath = join(directory, "integration.sqlite");
|
|
153
|
+
secureFile(lockPath);
|
|
154
|
+
secureFile(dbPath);
|
|
155
|
+
const lock = new DatabaseSync(lockPath);
|
|
156
|
+
chmodSync(lockPath, 0o600);
|
|
157
|
+
try {
|
|
158
|
+
lock.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE; CREATE TABLE IF NOT EXISTS owner (id INTEGER PRIMARY KEY);");
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
lock.close();
|
|
162
|
+
return fail("envar_integration_owned", "Another process already owns this integration state");
|
|
163
|
+
}
|
|
164
|
+
this.lock = lock;
|
|
165
|
+
let database;
|
|
166
|
+
try {
|
|
167
|
+
database = new DatabaseSync(dbPath);
|
|
168
|
+
chmodSync(dbPath, 0o600);
|
|
169
|
+
database.exec(`PRAGMA journal_mode=DELETE;PRAGMA synchronous=FULL;PRAGMA busy_timeout=5000;
|
|
170
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
|
171
|
+
CREATE TABLE IF NOT EXISTS revisions (id TEXT PRIMARY KEY,service_id TEXT NOT NULL,local_service TEXT NOT NULL,revision INTEGER NOT NULL,digest TEXT NOT NULL,config_json TEXT NOT NULL,state TEXT NOT NULL,UNIQUE(local_service,revision));
|
|
172
|
+
CREATE TABLE IF NOT EXISTS outbox (event_id TEXT PRIMARY KEY,dedupe_key TEXT UNIQUE NOT NULL,kind TEXT NOT NULL,payload_json TEXT NOT NULL,digest TEXT NOT NULL,state TEXT NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,next_attempt INTEGER NOT NULL DEFAULT 0,last_error TEXT NOT NULL DEFAULT '');`);
|
|
173
|
+
const binding = JSON.stringify({
|
|
174
|
+
platformOrigin: this.policy.platformOrigin,
|
|
175
|
+
agentId: this.policy.agentId,
|
|
176
|
+
runtimeAgentId: this.policy.runtimeAgentId,
|
|
177
|
+
});
|
|
178
|
+
const prior = database
|
|
179
|
+
.prepare("SELECT value FROM meta WHERE key=?")
|
|
180
|
+
.get("binding");
|
|
181
|
+
if (prior && prior.value !== binding)
|
|
182
|
+
fail("envar_state_binding", "This private state belongs to a different platform or Agent");
|
|
183
|
+
database
|
|
184
|
+
.prepare("INSERT OR IGNORE INTO meta VALUES (?,?)")
|
|
185
|
+
.run("binding", binding);
|
|
186
|
+
database
|
|
187
|
+
.prepare("INSERT OR IGNORE INTO meta VALUES (?,?)")
|
|
188
|
+
.run("source_instance", randomUUID());
|
|
189
|
+
this.sourceInstance = String(database
|
|
190
|
+
.prepare("SELECT value FROM meta WHERE key=?")
|
|
191
|
+
.get("source_instance").value);
|
|
192
|
+
this.db = database;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
database?.close();
|
|
196
|
+
lock.exec("ROLLBACK");
|
|
197
|
+
lock.close();
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
close() {
|
|
202
|
+
if (this.closed)
|
|
203
|
+
return;
|
|
204
|
+
if (this.applying || this.flushing)
|
|
205
|
+
fail("envar_busy", "Wait for current integration operations before closing");
|
|
206
|
+
this.db.close();
|
|
207
|
+
this.lock.exec("ROLLBACK");
|
|
208
|
+
this.lock.close();
|
|
209
|
+
this.closed = true;
|
|
210
|
+
}
|
|
211
|
+
open() {
|
|
212
|
+
if (this.closed)
|
|
213
|
+
fail("envar_closed", "Integration is closed");
|
|
214
|
+
}
|
|
215
|
+
async request(path, payload, eventId) {
|
|
216
|
+
this.open();
|
|
217
|
+
if (![
|
|
218
|
+
"/api/v1/agent-config/candidates",
|
|
219
|
+
"/api/v1/agent-config/acknowledgments",
|
|
220
|
+
"/api/v1/commerce-reports",
|
|
221
|
+
].includes(path))
|
|
222
|
+
fail("envar_path", "Unsupported platform integration operation");
|
|
223
|
+
const token = await this.options.token();
|
|
224
|
+
if (typeof token !== "string" ||
|
|
225
|
+
token.length < 32 ||
|
|
226
|
+
token.length > 4096 ||
|
|
227
|
+
/\s/.test(token))
|
|
228
|
+
fail("envar_token", "Use a dedicated Agent-scoped platform credential");
|
|
229
|
+
const url = this.policy.platformOrigin + path;
|
|
230
|
+
let response;
|
|
231
|
+
try {
|
|
232
|
+
response = await this.fetcher(url, {
|
|
233
|
+
method: payload === undefined ? "GET" : "POST",
|
|
234
|
+
headers: {
|
|
235
|
+
Accept: "application/json",
|
|
236
|
+
Authorization: `Bearer ${token}`,
|
|
237
|
+
...(payload === undefined
|
|
238
|
+
? {}
|
|
239
|
+
: {
|
|
240
|
+
"Content-Type": "application/json",
|
|
241
|
+
"Idempotency-Key": eventId,
|
|
242
|
+
}),
|
|
243
|
+
},
|
|
244
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
245
|
+
redirect: "error",
|
|
246
|
+
credentials: "omit",
|
|
247
|
+
cache: "no-store",
|
|
248
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return fail("envar_transport", "Platform request did not complete; retain the original integration event");
|
|
253
|
+
}
|
|
254
|
+
if (response.redirected ||
|
|
255
|
+
(response.url && response.url !== url) ||
|
|
256
|
+
(response.status >= 300 && response.status < 400)) {
|
|
257
|
+
await response.body?.cancel();
|
|
258
|
+
fail("envar_redirect", "Platform credentials must never follow redirects");
|
|
259
|
+
}
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
await response.body?.cancel();
|
|
262
|
+
return fail(`envar_http_${response.status}`, `Platform returned HTTP ${response.status}; retain the original event`);
|
|
263
|
+
}
|
|
264
|
+
if (Number(response.headers.get("content-length") ?? "0") > this.limitBytes) {
|
|
265
|
+
await response.body?.cancel();
|
|
266
|
+
return fail("envar_response_size", "Platform response exceeds the configured limit");
|
|
267
|
+
}
|
|
268
|
+
const chunks = [];
|
|
269
|
+
let size = 0;
|
|
270
|
+
const reader = response.body?.getReader();
|
|
271
|
+
if (reader) {
|
|
272
|
+
try {
|
|
273
|
+
while (true) {
|
|
274
|
+
const next = await reader.read();
|
|
275
|
+
if (next.done)
|
|
276
|
+
break;
|
|
277
|
+
size += next.value.byteLength;
|
|
278
|
+
if (size > this.limitBytes) {
|
|
279
|
+
await reader.cancel();
|
|
280
|
+
fail("envar_response_size", "Platform response exceeds the configured limit");
|
|
281
|
+
}
|
|
282
|
+
chunks.push(next.value);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
reader.releaseLock();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
let value;
|
|
290
|
+
try {
|
|
291
|
+
value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return fail("envar_response_json", "Platform returned invalid JSON");
|
|
295
|
+
}
|
|
296
|
+
if (!object(value))
|
|
297
|
+
fail("envar_response_json", "Platform response must be an object");
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
candidate(value) {
|
|
301
|
+
if (!this.policy.acceptUpdates)
|
|
302
|
+
fail("envar_updates_disabled", "Local owner has not enabled platform configuration updates");
|
|
303
|
+
if (!object(value) ||
|
|
304
|
+
typeof value.id !== "string" ||
|
|
305
|
+
typeof value.service_id !== "string" ||
|
|
306
|
+
typeof value.digest !== "string")
|
|
307
|
+
fail("envar_candidate", "Invalid service revision candidate");
|
|
308
|
+
uuid(value.id);
|
|
309
|
+
uuid(value.service_id);
|
|
310
|
+
const config = loadCommerceConfig(value.config);
|
|
311
|
+
if (config.agent.id !== this.policy.runtimeAgentId ||
|
|
312
|
+
config.services.length !== 1)
|
|
313
|
+
fail("envar_candidate_agent", "Candidate does not match the locally linked Agent");
|
|
314
|
+
const service = config.services[0];
|
|
315
|
+
if (service.revision !== value.revision ||
|
|
316
|
+
!this.policy.allowedServices.includes(service.id))
|
|
317
|
+
fail("envar_candidate_service", "Candidate service is outside the local update grant");
|
|
318
|
+
if (digest(config) !== value.digest ||
|
|
319
|
+
!object(value.publication) ||
|
|
320
|
+
value.publication.desired_digest !== value.digest ||
|
|
321
|
+
![
|
|
322
|
+
"awaiting_application",
|
|
323
|
+
"applied",
|
|
324
|
+
"probe_failed",
|
|
325
|
+
"published",
|
|
326
|
+
].includes(String(value.publication.state)))
|
|
327
|
+
fail("envar_candidate_digest", "Candidate and publication digests must match");
|
|
328
|
+
if (!this.policy.allowedUpstreamOrigins.includes(new URL(service.execution.cardUrl).origin))
|
|
329
|
+
fail("envar_upstream_denied", "Upstream origin is outside the local update grant");
|
|
330
|
+
for (const profile of Object.values(config.paymentProfiles)) {
|
|
331
|
+
if (profile.adapter === "x402") {
|
|
332
|
+
if (!this.policy.allowedX402.some((a) => a.network === profile.network &&
|
|
333
|
+
a.asset === profile.asset.toLowerCase() &&
|
|
334
|
+
a.payTo === profile.payTo.toLowerCase() &&
|
|
335
|
+
a.facilitatorOrigin === new URL(profile.facilitatorUrl).origin))
|
|
336
|
+
fail("envar_recipient_denied", "Receiving identity or facilitator is outside the local update grant");
|
|
337
|
+
}
|
|
338
|
+
else if (!this.policy.allowedMppAccounts.includes(profile.accountRef))
|
|
339
|
+
fail("envar_merchant_denied", "Merchant account is outside the local update grant");
|
|
340
|
+
}
|
|
341
|
+
const prior = this.db
|
|
342
|
+
.prepare("SELECT * FROM revisions WHERE id=? OR (local_service=? AND revision=?)")
|
|
343
|
+
.all(value.id, service.id, service.revision);
|
|
344
|
+
if (prior.some((row) => row.id !== value.id ||
|
|
345
|
+
row.service_id !== value.service_id ||
|
|
346
|
+
row.digest !== value.digest))
|
|
347
|
+
fail("envar_revision_conflict", "A historical service revision cannot change");
|
|
348
|
+
const mapped = this.db
|
|
349
|
+
.prepare("SELECT service_id FROM revisions WHERE local_service=? LIMIT 1")
|
|
350
|
+
.get(service.id);
|
|
351
|
+
if (mapped && mapped.service_id !== value.service_id)
|
|
352
|
+
fail("envar_service_mapping", "Local service is already mapped to a different platform service");
|
|
353
|
+
return {
|
|
354
|
+
id: value.id,
|
|
355
|
+
service_id: value.service_id,
|
|
356
|
+
revision: service.revision,
|
|
357
|
+
digest: value.digest,
|
|
358
|
+
config,
|
|
359
|
+
publication: {
|
|
360
|
+
state: String(value.publication.state),
|
|
361
|
+
desired_digest: String(value.publication.desired_digest),
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/** Latest locally acknowledged revisions; startup recovery never requires platform connectivity. */
|
|
366
|
+
appliedCandidates() {
|
|
367
|
+
this.open();
|
|
368
|
+
const rows = this.db
|
|
369
|
+
.prepare("SELECT r.* FROM revisions r WHERE r.state='applied' AND r.revision=(SELECT MAX(n.revision) FROM revisions n WHERE n.local_service=r.local_service AND n.state='applied') ORDER BY r.local_service")
|
|
370
|
+
.all();
|
|
371
|
+
return rows.map((row) => {
|
|
372
|
+
const config = loadCommerceConfig(JSON.parse(String(row.config_json)));
|
|
373
|
+
if (digest(config) !== row.digest ||
|
|
374
|
+
config.agent.id !== this.policy.runtimeAgentId)
|
|
375
|
+
fail("envar_config_integrity", "Saved applied candidate changed");
|
|
376
|
+
const path = join(this.configDirectory, `${config.services[0].id}-v${row.revision}-${row.digest}.json`);
|
|
377
|
+
secureFile(path);
|
|
378
|
+
if (!existsSync(path) ||
|
|
379
|
+
digest(JSON.parse(readFileSync(path, "utf8"))) !== row.digest)
|
|
380
|
+
fail("envar_config_integrity", "Saved applied configuration file changed");
|
|
381
|
+
return {
|
|
382
|
+
id: String(row.id),
|
|
383
|
+
service_id: String(row.service_id),
|
|
384
|
+
revision: Number(row.revision),
|
|
385
|
+
digest: String(row.digest),
|
|
386
|
+
config,
|
|
387
|
+
publication: { state: "applied", desired_digest: String(row.digest) },
|
|
388
|
+
};
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/** Recover only pending applications already proven by the runtime's durable service catalog. */
|
|
392
|
+
pendingCandidates(registered) {
|
|
393
|
+
this.open();
|
|
394
|
+
const rows = this.db
|
|
395
|
+
.prepare("SELECT * FROM revisions WHERE state='pending'")
|
|
396
|
+
.all();
|
|
397
|
+
const results = [];
|
|
398
|
+
for (const row of rows) {
|
|
399
|
+
const config = loadCommerceConfig(JSON.parse(String(row.config_json)));
|
|
400
|
+
if (!envarServiceProofs(config).every((expected) => registered.some((actual) => digest(actual) === digest(expected))))
|
|
401
|
+
continue;
|
|
402
|
+
const candidate = this.candidate({
|
|
403
|
+
id: String(row.id),
|
|
404
|
+
service_id: String(row.service_id),
|
|
405
|
+
revision: Number(row.revision),
|
|
406
|
+
digest: String(row.digest),
|
|
407
|
+
config,
|
|
408
|
+
publication: {
|
|
409
|
+
state: "awaiting_application",
|
|
410
|
+
desired_digest: String(row.digest),
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
const path = join(this.configDirectory, `${config.services[0].id}-v${row.revision}-${row.digest}.json`);
|
|
414
|
+
secureFile(path);
|
|
415
|
+
if (!existsSync(path) ||
|
|
416
|
+
digest(JSON.parse(readFileSync(path, "utf8"))) !== row.digest)
|
|
417
|
+
fail("envar_config_integrity", "Pending applied configuration file changed");
|
|
418
|
+
results.push(candidate);
|
|
419
|
+
}
|
|
420
|
+
return results;
|
|
421
|
+
}
|
|
422
|
+
async pullCandidates() {
|
|
423
|
+
this.open();
|
|
424
|
+
if (!this.policy.acceptUpdates)
|
|
425
|
+
return [];
|
|
426
|
+
const response = await this.request("/api/v1/agent-config/candidates");
|
|
427
|
+
if (response.agent_id !== this.policy.agentId ||
|
|
428
|
+
!Array.isArray(response.results) ||
|
|
429
|
+
response.results.length > 256)
|
|
430
|
+
fail("envar_candidate_agent", "Platform credential must map to the configured Agent");
|
|
431
|
+
const latest = new Map(), seen = new Map();
|
|
432
|
+
for (const raw of response.results) {
|
|
433
|
+
const candidate = this.candidate(raw), service = candidate.config.services[0], key = `${service.id}:${service.revision}`;
|
|
434
|
+
if (seen.has(key) &&
|
|
435
|
+
seen.get(key) !== `${candidate.id}:${candidate.digest}`)
|
|
436
|
+
fail("envar_revision_conflict", "The same revision has conflicting candidate digests");
|
|
437
|
+
seen.set(key, `${candidate.id}:${candidate.digest}`);
|
|
438
|
+
const previous = latest.get(service.id);
|
|
439
|
+
if (previous && previous.service_id !== candidate.service_id)
|
|
440
|
+
fail("envar_service_mapping", "A local service cannot map to two platform services");
|
|
441
|
+
if (!previous || candidate.revision > previous.revision)
|
|
442
|
+
latest.set(service.id, candidate);
|
|
443
|
+
}
|
|
444
|
+
return [...latest.values()];
|
|
445
|
+
}
|
|
446
|
+
async applyCandidate(raw, apply) {
|
|
447
|
+
this.open();
|
|
448
|
+
if (this.applying)
|
|
449
|
+
fail("envar_busy", "Only one configuration application may run at a time");
|
|
450
|
+
const candidate = this.candidate(raw), service = candidate.config.services[0], previous = this.db
|
|
451
|
+
.prepare("SELECT state FROM revisions WHERE id=?")
|
|
452
|
+
.get(candidate.id);
|
|
453
|
+
const configPath = join(this.configDirectory, `${service.id}-v${service.revision}-${candidate.digest}.json`);
|
|
454
|
+
if (previous?.state === "applied") {
|
|
455
|
+
secureFile(configPath);
|
|
456
|
+
if (!existsSync(configPath) ||
|
|
457
|
+
digest(JSON.parse(readFileSync(configPath, "utf8"))) !==
|
|
458
|
+
candidate.digest)
|
|
459
|
+
fail("envar_config_integrity", "The applied configuration file is missing or changed");
|
|
460
|
+
return { ackEventId: this.enqueueAck(candidate), configPath };
|
|
461
|
+
}
|
|
462
|
+
const latest = this.db
|
|
463
|
+
.prepare("SELECT MAX(revision) AS revision FROM revisions WHERE local_service=? AND state='applied'")
|
|
464
|
+
.get(service.id);
|
|
465
|
+
if (Number(latest.revision ?? 0) > service.revision)
|
|
466
|
+
fail("envar_stale_revision", "An older revision cannot replace the active service");
|
|
467
|
+
this.db
|
|
468
|
+
.prepare("INSERT OR IGNORE INTO revisions VALUES (?,?,?,?,?,?,?)")
|
|
469
|
+
.run(candidate.id, candidate.service_id, service.id, service.revision, candidate.digest, JSON.stringify(candidate.config), "pending");
|
|
470
|
+
this.applying = true;
|
|
471
|
+
try {
|
|
472
|
+
if (existsSync(configPath)) {
|
|
473
|
+
secureFile(configPath);
|
|
474
|
+
if (digest(JSON.parse(readFileSync(configPath, "utf8"))) !==
|
|
475
|
+
candidate.digest)
|
|
476
|
+
fail("envar_config_integrity", "The saved candidate changed");
|
|
477
|
+
}
|
|
478
|
+
else
|
|
479
|
+
atomicJson(configPath, candidate.config);
|
|
480
|
+
const proof = await apply(structuredClone(candidate));
|
|
481
|
+
if (!proof ||
|
|
482
|
+
proof.digest !== candidate.digest ||
|
|
483
|
+
digest(proof.services) !== digest(envarServiceProofs(candidate.config)))
|
|
484
|
+
fail("envar_application_proof", "Active runtime and stored service revisions did not confirm the candidate");
|
|
485
|
+
atomicJson(join(this.configDirectory, `${service.id}-current.json`), candidate.config);
|
|
486
|
+
this.db
|
|
487
|
+
.prepare("UPDATE revisions SET state='applied' WHERE id=?")
|
|
488
|
+
.run(candidate.id);
|
|
489
|
+
return { ackEventId: this.enqueueAck(candidate), configPath };
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
this.applying = false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
enqueueAck(candidate) {
|
|
496
|
+
return this.enqueue(`ack:${candidate.id}:${candidate.digest}`, "ack", {
|
|
497
|
+
revision_id: candidate.id,
|
|
498
|
+
digest: candidate.digest,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
enqueue(key, kind, payload) {
|
|
502
|
+
this.open();
|
|
503
|
+
if (!key || key.length > 512 || /[\r\n\0]/.test(key))
|
|
504
|
+
fail("envar_event_key", "Use a stable bounded local event key");
|
|
505
|
+
const fingerprint = digest(payload), existing = this.db
|
|
506
|
+
.prepare("SELECT * FROM outbox WHERE dedupe_key=?")
|
|
507
|
+
.get(key);
|
|
508
|
+
if (existing) {
|
|
509
|
+
if (existing.kind !== kind || existing.digest !== fingerprint)
|
|
510
|
+
fail("envar_event_conflict", "An original event key cannot describe different facts");
|
|
511
|
+
return String(existing.event_id);
|
|
512
|
+
}
|
|
513
|
+
const id = randomUUID(), body = { ...payload, event_id: id, source_instance: this.sourceInstance };
|
|
514
|
+
this.db
|
|
515
|
+
.prepare("INSERT INTO outbox (event_id,dedupe_key,kind,payload_json,digest,state) VALUES (?,?,?,?,?,'pending')")
|
|
516
|
+
.run(id, key, kind, JSON.stringify(body), fingerprint);
|
|
517
|
+
return id;
|
|
518
|
+
}
|
|
519
|
+
enqueueStatus(dedupeKey, report) {
|
|
520
|
+
uuid(report.orderId);
|
|
521
|
+
if (!STATUS_KINDS.includes(report.kind))
|
|
522
|
+
fail("envar_report_kind", "Use an allowed status fact");
|
|
523
|
+
return this.enqueue(`report:${dedupeKey}`, "report", {
|
|
524
|
+
order_id: report.orderId,
|
|
525
|
+
kind: report.kind,
|
|
526
|
+
payload: {},
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
enqueueX402Payment(dedupeKey, report) {
|
|
530
|
+
uuid(report.orderId);
|
|
531
|
+
const { quote, payment: p } = report, profile = quote.paymentProfile;
|
|
532
|
+
if (!profile ||
|
|
533
|
+
profile.adapter !== "x402" ||
|
|
534
|
+
p.network !== profile.network ||
|
|
535
|
+
p.asset.toLowerCase() !== profile.asset.toLowerCase() ||
|
|
536
|
+
p.recipient.toLowerCase() !== profile.payTo.toLowerCase() ||
|
|
537
|
+
p.amount !== quote.amount ||
|
|
538
|
+
quote.currency !== currencyOf(profile) ||
|
|
539
|
+
quote.recipient?.toLowerCase() !== profile.payTo.toLowerCase() ||
|
|
540
|
+
!ADDRESS.test(p.payer) ||
|
|
541
|
+
!HASH.test(p.nonce) ||
|
|
542
|
+
!HASH.test(p.transaction))
|
|
543
|
+
fail("envar_payment_binding", "Report the original x402 transaction matching the frozen quote");
|
|
544
|
+
atomic(p.amount);
|
|
545
|
+
return this.enqueue(`report:${dedupeKey}`, "report", {
|
|
546
|
+
order_id: report.orderId,
|
|
547
|
+
kind: "payment_observed",
|
|
548
|
+
payload: {
|
|
549
|
+
protocol: "x402",
|
|
550
|
+
payment: {
|
|
551
|
+
network: p.network,
|
|
552
|
+
asset: p.asset.toLowerCase(),
|
|
553
|
+
payer: p.payer.toLowerCase(),
|
|
554
|
+
recipient: p.recipient.toLowerCase(),
|
|
555
|
+
amount: p.amount,
|
|
556
|
+
nonce: p.nonce.toLowerCase(),
|
|
557
|
+
transaction: p.transaction.toLowerCase(),
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
enqueueMppPayment(dedupeKey, report) {
|
|
563
|
+
uuid(report.orderId);
|
|
564
|
+
const { quote, payment: p } = report;
|
|
565
|
+
if (quote.paymentProfile?.adapter !== "mpp" ||
|
|
566
|
+
p.amount !== quote.amount ||
|
|
567
|
+
p.currency !== quote.currency ||
|
|
568
|
+
p.currency !== "usd" ||
|
|
569
|
+
!/^pi_[A-Za-z0-9]{1,240}$/.test(p.reference))
|
|
570
|
+
fail("envar_payment_binding", "Report the original Stripe PaymentIntent matching the frozen quote");
|
|
571
|
+
atomic(p.amount);
|
|
572
|
+
return this.enqueue(`report:${dedupeKey}`, "report", {
|
|
573
|
+
order_id: report.orderId,
|
|
574
|
+
kind: "payment_observed",
|
|
575
|
+
payload: {
|
|
576
|
+
protocol: "mpp",
|
|
577
|
+
payment: {
|
|
578
|
+
reference: p.reference,
|
|
579
|
+
amount: p.amount,
|
|
580
|
+
currency: p.currency,
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
queueStatus() {
|
|
586
|
+
this.open();
|
|
587
|
+
const count = (state) => Number(this.db
|
|
588
|
+
.prepare("SELECT COUNT(*) AS count FROM outbox WHERE state=?")
|
|
589
|
+
.get(state).count);
|
|
590
|
+
return {
|
|
591
|
+
pending: count("pending"),
|
|
592
|
+
sent: count("sent"),
|
|
593
|
+
errors: this.db
|
|
594
|
+
.prepare("SELECT event_id,last_error,attempts FROM outbox WHERE state='pending' AND last_error<>'' ORDER BY rowid LIMIT 100")
|
|
595
|
+
.all().map((r) => ({
|
|
596
|
+
eventId: String(r.event_id),
|
|
597
|
+
code: String(r.last_error),
|
|
598
|
+
attempts: Number(r.attempts),
|
|
599
|
+
})),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
async flush(options = {}) {
|
|
603
|
+
this.open();
|
|
604
|
+
if (this.flushing)
|
|
605
|
+
return { sent: 0, pending: this.queueStatus().pending };
|
|
606
|
+
const limit = options.limit ?? 16;
|
|
607
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
608
|
+
fail("envar_flush_limit", "Flush between one and 100 events");
|
|
609
|
+
this.flushing = true;
|
|
610
|
+
let sent = 0;
|
|
611
|
+
try {
|
|
612
|
+
const rows = this.db
|
|
613
|
+
.prepare("SELECT * FROM outbox WHERE state='pending' AND next_attempt<=? ORDER BY rowid LIMIT ?")
|
|
614
|
+
.all(this.now(), limit);
|
|
615
|
+
for (const row of rows) {
|
|
616
|
+
const body = JSON.parse(String(row.payload_json));
|
|
617
|
+
try {
|
|
618
|
+
const response = await this.request(row.kind === "ack"
|
|
619
|
+
? "/api/v1/agent-config/acknowledgments"
|
|
620
|
+
: "/api/v1/commerce-reports", body, String(row.event_id));
|
|
621
|
+
if (row.kind === "ack"
|
|
622
|
+
? response.id !== body.revision_id ||
|
|
623
|
+
response.digest !== body.digest ||
|
|
624
|
+
!object(response.publication) ||
|
|
625
|
+
response.publication.applied_digest !== body.digest
|
|
626
|
+
: response.accepted !== true)
|
|
627
|
+
fail("envar_event_ack", "Platform did not acknowledge the original event");
|
|
628
|
+
this.db
|
|
629
|
+
.prepare("UPDATE outbox SET state='sent',last_error='' WHERE event_id=?")
|
|
630
|
+
.run(row.event_id);
|
|
631
|
+
sent++;
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
const attempts = Number(row.attempts) + 1, code = error instanceof CommerceError ? error.code : "envar_transport";
|
|
635
|
+
this.db
|
|
636
|
+
.prepare("UPDATE outbox SET attempts=?,next_attempt=?,last_error=? WHERE event_id=?")
|
|
637
|
+
.run(attempts, this.now() +
|
|
638
|
+
Math.min(300000, 1000 * 2 ** Math.min(attempts - 1, 9)), code, row.event_id);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return { sent, pending: this.queueStatus().pending };
|
|
642
|
+
}
|
|
643
|
+
finally {
|
|
644
|
+
this.flushing = false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
|
+
export function listenCommerce(server, origin, host = '127.0.0.1', port = 4020) {
|
|
4
|
+
const http = createServer(async (req, res) => {
|
|
5
|
+
try {
|
|
6
|
+
const expectedHost = new URL(origin).host;
|
|
7
|
+
if (req.headers.host !== expectedHost) {
|
|
8
|
+
res.writeHead(421, { 'Content-Type': 'application/json' });
|
|
9
|
+
res.end('{"error":"invalid_host"}');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const headers = new Headers();
|
|
13
|
+
for (const [key, value] of Object.entries(req.headers))
|
|
14
|
+
if (value !== undefined)
|
|
15
|
+
headers.set(key, Array.isArray(value) ? value.join(',') : value);
|
|
16
|
+
const request = new Request(new URL(req.url ?? '/', origin), { method: req.method, headers, ...(req.method !== 'GET' && req.method !== 'HEAD' ? { body: Readable.toWeb(req), duplex: 'half' } : {}) });
|
|
17
|
+
const response = await server.handle(request);
|
|
18
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
19
|
+
if (response.body)
|
|
20
|
+
Readable.fromWeb(response.body).pipe(res);
|
|
21
|
+
else
|
|
22
|
+
res.end();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
if (!res.headersSent)
|
|
26
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
27
|
+
res.end('{"error":"request_failed"}');
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
http.requestTimeout = 30000;
|
|
31
|
+
http.headersTimeout = 15000;
|
|
32
|
+
http.listen(port, host);
|
|
33
|
+
return http;
|
|
34
|
+
}
|