mycomesh-consumer 0.1.52 → 0.2.0

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.
@@ -1,355 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { dirname } from "node:path";
4
- import { BlockList, isIP } from "node:net";
5
- import { secp256k1 } from "@noble/curves/secp256k1";
6
- import { keccak_256 } from "@noble/hashes/sha3.js";
7
-
8
- export const RELAY_ADMISSION_SCHEMA = "mycomesh.relay-admission.v1";
9
- export const RELAY_ANNOUNCEMENT_SCHEMA = "mycomesh.relay-announcement.v1";
10
- export const RELAY_DIRECTORY_SCHEMA = "mycomesh.relay-directory.v1";
11
- const CACHE_SCHEMA = "mycomesh.relay-discovery-cache.v1";
12
- const MAX_RECORDS = 64;
13
- const MAX_DIRECTORY_BYTES = 1024 * 1024;
14
- const MAX_ANNOUNCEMENT_BYTES = 16 * 1024;
15
- const BINDINGS = ["network_id", "channel_id", "chain_id", "settlement_contract", "protocol_version",
16
- "host", "provider_port", "public_url", "provider_tls", "payment_address", "attestation_address"];
17
- const fileRevision = (stat) => `${stat.dev}:${stat.ino}:${stat.mtimeNs}:${stat.size}`;
18
-
19
- const privateAddresses = new BlockList();
20
- for (const [address, prefix] of [
21
- ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
22
- ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
23
- ["192.168.0.0", 16], ["192.88.99.0", 24], ["198.18.0.0", 15], ["198.51.100.0", 24],
24
- ["203.0.113.0", 24], ["224.0.0.0", 3],
25
- ]) privateAddresses.addSubnet(address, prefix, "ipv4");
26
- for (const [address, prefix] of [["2001::", 23], ["2001:db8::", 32], ["2002::", 16],
27
- ["3fff::", 20], ["3ffe::", 16]]) privateAddresses.addSubnet(address, prefix, "ipv6");
28
- const publicV6 = new BlockList();
29
- publicV6.addSubnet("2000::", 3, "ipv6");
30
-
31
- export function isPublicDiscoveryAddress(address) {
32
- const family = isIP(address);
33
- return family === 4 ? !privateAddresses.check(address, "ipv4")
34
- : family === 6 && publicV6.check(address, "ipv6") && !privateAddresses.check(address, "ipv6");
35
- }
36
-
37
- function loopback(address) {
38
- return address === "::1" || (isIP(address) === 4 && address.startsWith("127."));
39
- }
40
-
41
- export function discoveryUrl(value, { local = false, bridge = false } = {}) {
42
- if (typeof value !== "string" || value.length > 512 || /[^\x21-\x7e]|\\/.test(value)) throw new Error("Invalid discovery URL");
43
- const match = /^(https?):\/\/(\[[^\]]+\]|[^/:]+)(?::([0-9]+))?$/.exec(value);
44
- if (!match) throw new Error("Invalid discovery URL origin");
45
- const url = new URL(value);
46
- const hostname = url.hostname.replace(/^\[|\]$/g, "");
47
- if ((url.protocol !== "https:" && !(local && loopback(hostname) && url.protocol === "http:"))
48
- || url.username || url.password || url.search || url.hash || !["", "/"].includes(url.pathname)) {
49
- throw new Error("Discovery requires HTTPS origin URLs without credentials, query, or fragment");
50
- }
51
- if ((isIP(hostname) && (!(isPublicDiscoveryAddress(hostname) || (local && loopback(hostname)))
52
- || match[2] !== url.hostname)) || (!bridge && !isIP(hostname))) {
53
- throw new Error("Discovery endpoint is not public");
54
- }
55
- if (bridge && !isIP(hostname) && !/^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/.test(match[2])) throw new Error("Invalid bootstrap hostname");
56
- if (url.port === "0") throw new Error("Invalid discovery port");
57
- if (value !== url.origin) throw new Error("Noncanonical discovery URL");
58
- return value;
59
- }
60
-
61
- function exactFields(value, fields, label) {
62
- if (!value || typeof value !== "object" || Array.isArray(value)
63
- || Object.keys(value).length !== fields.length || fields.some((field) => !Object.hasOwn(value, field))) {
64
- throw new Error(`Invalid ${label} fields`);
65
- }
66
- }
67
-
68
- function address(value, label) {
69
- if (typeof value !== "string" || !/^0x[0-9a-f]{40}$/.test(value) || /^0x0{40}$/.test(value)) {
70
- throw new Error(`Invalid ${label}`);
71
- }
72
- return value;
73
- }
74
-
75
- function integer(value, label, min = 0, max = Number.MAX_SAFE_INTEGER) {
76
- if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`Invalid ${label}`);
77
- return value;
78
- }
79
-
80
- export function discoveryCanonical(value, depth = 0) {
81
- if (depth > 12) throw new Error("Discovery JSON is too deeply nested");
82
- if (typeof value === "string") {
83
- if (/[^\x20-\x7e]/.test(value)) throw new Error("Discovery strings must be printable ASCII");
84
- return JSON.stringify(value);
85
- }
86
- if (typeof value === "number") return String(integer(value, "discovery integer"));
87
- if (typeof value === "boolean") return JSON.stringify(value);
88
- if (Array.isArray(value)) return `[${value.map((item) => discoveryCanonical(item, depth + 1)).join(",")}]`;
89
- if (value && typeof value === "object") return `{${Object.keys(value).sort()
90
- .map((key) => `${discoveryCanonical(key, depth + 1)}:${discoveryCanonical(value[key], depth + 1)}`).join(",")}}`;
91
- throw new Error("Invalid discovery value");
92
- }
93
-
94
- export function discoveryDigest(schema, unsigned) {
95
- return keccak_256(Buffer.from(`${schema}\n${discoveryCanonical(unsigned)}`, "utf8"));
96
- }
97
-
98
- function recoverSigner(digest, value) {
99
- if (typeof value !== "string" || !/^0x[0-9a-f]{130}$/.test(value)) throw new Error("Invalid discovery signature encoding");
100
- const bytes = Buffer.from(value.slice(2), "hex");
101
- if (bytes[64] !== 27 && bytes[64] !== 28) throw new Error("Invalid discovery signature recovery");
102
- const signature = secp256k1.Signature.fromCompact(bytes.subarray(0, 64)).addRecoveryBit(bytes[64] - 27);
103
- if (signature.hasHighS()) throw new Error("Discovery signature must be low-s");
104
- const publicKey = signature.recoverPublicKey(digest).toRawBytes(false).subarray(1);
105
- return `0x${Buffer.from(keccak_256(publicKey).subarray(-20)).toString("hex")}`;
106
- }
107
-
108
- export function parseDiscoveryConfig(manifest, network) {
109
- if (manifest.relay_discovery === undefined) return null;
110
- const value = manifest.relay_discovery;
111
- if (!value || typeof value !== "object" || Array.isArray(value)
112
- || Object.keys(value).some((key) => !["authorities", "threshold", "refresh_seconds", "timeout_seconds"].includes(key))) {
113
- throw new Error("Invalid relay_discovery policy");
114
- }
115
- if (!Array.isArray(value.authorities) || !value.authorities.length || value.authorities.length > 16) throw new Error("Discovery authorities required");
116
- const authorities = value.authorities.map((item) => address(item, "discovery authority"));
117
- if (new Set(authorities).size !== authorities.length) throw new Error("Duplicate discovery authorities");
118
- const threshold = integer(value.threshold, "discovery threshold", Math.floor(authorities.length / 2) + 1, authorities.length);
119
- const refreshSeconds = integer(value.refresh_seconds ?? 30, "discovery refresh", 1, 120);
120
- const timeoutSeconds = integer(value.timeout_seconds ?? 3, "discovery timeout", 1, 10);
121
- for (const name of ["network_id", "channel_id"]) {
122
- if (typeof manifest[name] !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(manifest[name])) throw new Error(`Discovery requires ${name}`);
123
- }
124
- if (!["local", "testnet", "open"].includes(manifest.network_profile)) throw new Error("Invalid discovery network profile");
125
- const local = manifest.network_profile === "local";
126
- if (!Array.isArray(manifest.bridge_urls) || !manifest.bridge_urls.length || manifest.bridge_urls.length > 8) throw new Error("Discovery requires one to eight trusted Bridges");
127
- const bridgeUrls = manifest.bridge_urls.map((url) => discoveryUrl(url, { local, bridge: true }));
128
- if (new Set(bridgeUrls).size !== bridgeUrls.length) throw new Error("Duplicate discovery bootstrap Bridge");
129
- return { authorities, threshold, refreshMs: refreshSeconds * 1000, timeoutMs: timeoutSeconds * 1000,
130
- local, bridgeUrls, network_id: manifest.network_id, channel_id: manifest.channel_id,
131
- chain_id: integer(network.chain_id, "discovery chain", 1),
132
- settlement_contract: address(network.settlement_contract, "discovery settlement"),
133
- protocol_version: integer(network.protocol_version, "discovery protocol", 8, 10) };
134
- }
135
-
136
- function verifyBinding(record, config) {
137
- for (const key of ["network_id", "channel_id", "chain_id", "settlement_contract", "protocol_version"]) {
138
- if (record[key] !== config[key]) throw new Error(`Discovery ${key} mismatch`);
139
- }
140
- address(record.payment_address, "Relay payment address");
141
- address(record.attestation_address, "Relay attestation address");
142
- integer(record.provider_port, "Relay provider port", 1, 65535);
143
- if (typeof record.provider_tls !== "boolean" || (!config.local && !record.provider_tls)) throw new Error("Discovery Provider transport requires TLS");
144
- if (discoveryUrl(record.public_url, { local: config.local }) !== record.public_url) throw new Error("Noncanonical discovery URL");
145
- const host = new URL(record.public_url).hostname.replace(/^\[|\]$/g, "");
146
- if (record.host !== host) throw new Error("Relay host must match its public URL IP");
147
- }
148
-
149
- export function verifyRelayAnnouncement(record, config, { now = Math.floor(Date.now() / 1000), allowExpired = false } = {}) {
150
- exactFields(record, [...BINDINGS, "schema", "sequence", "issued_at", "expires_at", "admission", "signature"], "Relay announcement");
151
- if (record.schema !== RELAY_ANNOUNCEMENT_SCHEMA || Buffer.byteLength(discoveryCanonical(record)) > MAX_ANNOUNCEMENT_BYTES) throw new Error("Invalid Relay announcement schema or size");
152
- verifyBinding(record, config);
153
- integer(record.sequence, "Relay sequence", 1);
154
- integer(record.issued_at, "Relay issued_at");
155
- integer(record.expires_at, "Relay expires_at", 1);
156
- if (record.issued_at > now + 30 || record.expires_at <= record.issued_at || record.expires_at - record.issued_at > 300
157
- || (!allowExpired && record.expires_at <= now)) throw new Error("Relay announcement expired or invalid lifetime");
158
- const admission = record.admission;
159
- exactFields(admission, [...BINDINGS, "schema", "expires_at", "signatures"], "Relay admission");
160
- if (admission.schema !== RELAY_ADMISSION_SCHEMA) throw new Error("Invalid Relay admission schema");
161
- for (const key of BINDINGS) if (admission[key] !== record[key]) throw new Error("Relay admission binding mismatch");
162
- integer(admission.expires_at, "admission expiry", 1);
163
- if (admission.expires_at < record.expires_at || admission.expires_at > now + 366 * 86400
164
- || (!allowExpired && admission.expires_at <= now)) throw new Error("Relay admission expired or invalid lifetime");
165
- if (!Array.isArray(admission.signatures) || !admission.signatures.length || admission.signatures.length > 16) throw new Error("Invalid admission signatures");
166
- const { signatures, ...unsignedAdmission } = admission;
167
- const admissionDigest = discoveryDigest(RELAY_ADMISSION_SCHEMA, unsignedAdmission);
168
- const signers = new Set();
169
- for (const signed of signatures) {
170
- exactFields(signed, ["signer", "signature"], "admission signature");
171
- const signer = address(signed.signer, "admission signer");
172
- if (!config.authorities.includes(signer) || signers.has(signer)
173
- || recoverSigner(admissionDigest, signed.signature) !== signer) throw new Error("Invalid or untrusted admission signature");
174
- signers.add(signer);
175
- }
176
- if (signers.size < config.threshold) throw new Error("Relay admission threshold not met");
177
- const { signature, ...unsigned } = record;
178
- if (recoverSigner(discoveryDigest(RELAY_ANNOUNCEMENT_SCHEMA, unsigned), signature) !== record.attestation_address) {
179
- throw new Error("Relay announcement signature mismatch");
180
- }
181
- return JSON.parse(JSON.stringify(record));
182
- }
183
-
184
- async function fetchDiscoveryJson(url, { dispatcher, timeoutMs, maxBytes }) {
185
- const controller = new AbortController();
186
- const timer = setTimeout(() => controller.abort(), timeoutMs);
187
- timer.unref?.();
188
- try {
189
- const response = await fetch(url, { redirect: "error", headers: { accept: "application/json" }, dispatcher, signal: controller.signal });
190
- if (!response.ok) { await response.body?.cancel(); throw new Error(`Discovery HTTP ${response.status}`); }
191
- if (Number(response.headers.get("content-length")) > maxBytes) { await response.body?.cancel(); throw new Error("Discovery response exceeds size limit"); }
192
- let length = 0;
193
- const chunks = [];
194
- for await (const chunk of response.body || []) {
195
- length += chunk.byteLength;
196
- if (length > maxBytes) { controller.abort(); throw new Error("Discovery response exceeds size limit"); }
197
- chunks.push(Buffer.from(chunk));
198
- }
199
- return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, length)));
200
- } finally { clearTimeout(timer); }
201
- }
202
-
203
- export class ConsumerRelayDiscovery {
204
- constructor({ config, cachePath, dispatcher, now = () => Math.floor(Date.now() / 1000) }) {
205
- this.config = config;
206
- this.cachePath = cachePath;
207
- this.dispatcher = dispatcher;
208
- this.now = now;
209
- this.records = new Map();
210
- this.refreshing = null;
211
- this.lastRefresh = -Infinity;
212
- this.lastError = null;
213
- if (cachePath && existsSync(cachePath)) {
214
- try {
215
- this.records = this.readCache();
216
- } catch (error) {
217
- // Corrupt or incompatible persistent anti-replay state cannot silently
218
- // become an empty cache and admit older signed announcements again.
219
- this.records.clear();
220
- this.lastError = `Discovery cache rejected: ${error.message}`;
221
- this.blocked = true;
222
- }
223
- }
224
- }
225
-
226
- readCache() {
227
- const records = new Map();
228
- if (!this.cachePath || !existsSync(this.cachePath)) return records;
229
- const descriptor = openSync(this.cachePath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
230
- let cached, revision;
231
- try {
232
- const stat = fstatSync(descriptor, { bigint: true });
233
- if (!stat.isFile() || stat.size > BigInt(MAX_DIRECTORY_BYTES)) throw new Error("Invalid discovery cache file");
234
- revision = fileRevision(stat);
235
- cached = JSON.parse(readFileSync(descriptor, "utf8"));
236
- } finally { closeSync(descriptor); }
237
- exactFields(cached, ["schema", "records"], "discovery cache");
238
- if (cached.schema !== CACHE_SCHEMA || !Array.isArray(cached.records) || cached.records.length > MAX_RECORDS) throw new Error("Invalid discovery cache");
239
- for (const candidate of cached.records) {
240
- const record = verifyRelayAnnouncement(candidate, this.config, { now: this.now(), allowExpired: true });
241
- if (records.has(record.attestation_address)) throw new Error("Duplicate discovery cache identity");
242
- records.set(record.attestation_address, record);
243
- }
244
- this.cacheRevision = revision;
245
- return records;
246
- }
247
-
248
- persist() {
249
- if (!this.cachePath) return;
250
- const temporary = `${this.cachePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
251
- try {
252
- writeFileSync(temporary, JSON.stringify({ schema: CACHE_SCHEMA, records: [...this.records.values()] }), { flag: "wx", mode: 0o600 });
253
- const descriptor = openSync(temporary, "r");
254
- try { fsyncSync(descriptor); } finally { closeSync(descriptor); }
255
- renameSync(temporary, this.cachePath);
256
- chmodSync(this.cachePath, 0o600);
257
- if (process.platform !== "win32") {
258
- const directory = openSync(dirname(this.cachePath), "r");
259
- try { fsyncSync(directory); } finally { closeSync(directory); }
260
- }
261
- this.cacheRevision = fileRevision(lstatSync(this.cachePath, { bigint: true }));
262
- } finally { try { unlinkSync(temporary); } catch {} }
263
- }
264
-
265
- accept(candidate) {
266
- if (this.blocked) throw new Error(this.lastError);
267
- const record = verifyRelayAnnouncement(candidate, this.config, { now: this.now() });
268
- let lock;
269
- if (this.cachePath) {
270
- try {
271
- lock = openSync(`${this.cachePath}.lock`, "wx", 0o600);
272
- writeFileSync(lock, String(process.pid));
273
- } catch (error) {
274
- if (lock !== undefined) { closeSync(lock); try { unlinkSync(`${this.cachePath}.lock`); } catch {} }
275
- throw new Error("Discovery cache is locked; cached routes and static fallbacks remain available");
276
- }
277
- }
278
- try {
279
- if (this.cachePath && existsSync(this.cachePath)
280
- && fileRevision(lstatSync(this.cachePath, { bigint: true })) !== this.cacheRevision) {
281
- const persisted = this.readCache();
282
- const merged = new Map(this.records);
283
- for (const [identity, latest] of persisted) {
284
- const previous = merged.get(identity);
285
- if (!previous || previous.sequence < latest.sequence) merged.set(identity, latest);
286
- else if (previous.sequence === latest.sequence && discoveryCanonical(previous) !== discoveryCanonical(latest)) throw new Error("Conflicting discovery cache identity");
287
- }
288
- if (merged.size > MAX_RECORDS) throw new Error("Relay discovery identity capacity reached");
289
- this.records = merged;
290
- }
291
- return this.acceptVerified(record);
292
- } finally {
293
- if (lock !== undefined) { closeSync(lock); unlinkSync(`${this.cachePath}.lock`); }
294
- }
295
- }
296
-
297
- acceptVerified(record) {
298
- const identity = record.attestation_address;
299
- const previous = this.records.get(identity);
300
- if (previous && (record.sequence < previous.sequence || (record.sequence === previous.sequence
301
- && discoveryCanonical(record) !== discoveryCanonical(previous)))) throw new Error("Relay announcement sequence rollback or equivocation");
302
- if (previous?.sequence === record.sequence) return previous;
303
- if (!previous && this.records.size >= MAX_RECORDS) throw new Error("Relay discovery identity capacity reached");
304
- this.records.set(identity, record);
305
- try { this.persist(); }
306
- catch (error) { if (previous) this.records.set(identity, previous); else this.records.delete(identity); throw error; }
307
- return record;
308
- }
309
-
310
- active() {
311
- const now = this.now();
312
- return [...this.records.values()].filter((record) => record.expires_at > now && record.admission.expires_at > now);
313
- }
314
-
315
- recordFor(url) {
316
- const matches = this.active().filter((record) => record.public_url === url);
317
- if (matches.length > 1) throw new Error("Conflicting Relay identities share one discovery endpoint");
318
- return matches[0] || null;
319
- }
320
-
321
- async refresh({ force = false } = {}) {
322
- if (this.blocked) return this.active();
323
- if (this.refreshing) return this.refreshing;
324
- if (!force && Date.now() - this.lastRefresh < this.config.refreshMs) return this.active();
325
- this.lastRefresh = Date.now();
326
- this.lastError = null;
327
- this.refreshing = (async () => {
328
- const results = await Promise.allSettled(this.config.bridgeUrls.map(async (url) => {
329
- const directory = await fetchDiscoveryJson(`${url}/relays`, { dispatcher: this.dispatcher,
330
- timeoutMs: this.config.timeoutMs, maxBytes: MAX_DIRECTORY_BYTES });
331
- if (directory?.schema !== RELAY_DIRECTORY_SCHEMA || !Array.isArray(directory.relays) || directory.relays.length > MAX_RECORDS) throw new Error("Invalid Relay directory");
332
- let accepted = 0;
333
- for (const candidate of directory.relays) {
334
- try { this.accept(candidate); accepted += 1; } catch (error) { this.lastError = error.message; }
335
- }
336
- return accepted;
337
- }));
338
- if (results.every((result) => result.status === "rejected")) this.lastError = "All Bridge discovery queries failed; retained unexpired cached routes";
339
- return this.active();
340
- })();
341
- try { return await this.refreshing; } finally { this.refreshing = null; }
342
- }
343
-
344
- async probe(url) {
345
- const candidate = this.recordFor(url);
346
- if (!candidate) throw new Error("Relay discovery announcement expired or unavailable");
347
- const payload = await fetchDiscoveryJson(`${url}/relay-announcement`, { dispatcher: this.dispatcher,
348
- timeoutMs: this.config.timeoutMs, maxBytes: MAX_ANNOUNCEMENT_BYTES });
349
- const record = verifyRelayAnnouncement(payload, this.config, { now: this.now() });
350
- if (BINDINGS.some((field) => record[field] !== candidate[field]) || record.sequence < candidate.sequence) {
351
- throw new Error("Relay endpoint proof does not match its discovered announcement");
352
- }
353
- return this.accept(record);
354
- }
355
- }
@@ -1,287 +0,0 @@
1
- import {
2
- chmodSync,
3
- closeSync,
4
- constants,
5
- existsSync,
6
- fchmodSync,
7
- fstatSync,
8
- fsyncSync,
9
- lstatSync,
10
- mkdirSync,
11
- openSync,
12
- readFileSync,
13
- readSync,
14
- writeSync,
15
- } from "node:fs";
16
- import { homedir } from "node:os";
17
- import { dirname, join, resolve } from "node:path";
18
-
19
- const SCHEMA = "mycomesh.consumer.history.v1";
20
- const MAX_LINE_BYTES = 16 * 1024;
21
- const ADDRESS = /^0x[0-9a-f]{40}$/i;
22
- const BYTES32 = /^0x[0-9a-f]{64}$/i;
23
- const SECRET = /myco_(?:sk|local|share)_[a-z0-9_-]+|\bbearer\s+\S+|0x[0-9a-f]{128,}/i;
24
- const V9_TERMINAL = new Set(["released", "refunded", "dismissed", "timed_out"]);
25
- const STATUSES = new Set(["dispatching", "outcome_unknown", "not_dispatched", "queued", "pending", "submitted", "broadcast_unknown", "confirmed", "failed", "rejected", "escrowed", "disputed", ...V9_TERMINAL]);
26
- const NUMERIC_FIELDS = ["input_tokens", "output_tokens", "actual_fee_units", "updated_at", "confirmed_at", "block_number", "confirmations", "authorization_deadline", "jury_reference_timestamp", "settlement_release_at", "settlement_checked_at", "dispute_updated_at", "dispute_confirmed_at"];
27
- const DISPUTE_STAGES = new Set(["planned", "wallet_prompted", "submitted", "uncertain", "failed", "confirmed"]);
28
-
29
- function address(value, label) {
30
- if (typeof value !== "string" || !ADDRESS.test(value)) throw new TypeError(`invalid ${label}`);
31
- return value.toLowerCase();
32
- }
33
-
34
- function number(value) {
35
- if (typeof value === "boolean" || value === null || value === "" || value === undefined) return undefined;
36
- const parsed = Number(value);
37
- return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
38
- }
39
-
40
- function text(value, maximum = 256) {
41
- if (typeof value !== "string" || value.length > maximum || SECRET.test(value) || /[\u0000-\u001f]/.test(value)) return undefined;
42
- return value;
43
- }
44
-
45
- function publicUrl(value) {
46
- const raw = text(value, 2048);
47
- if (!raw) return undefined;
48
- try {
49
- const url = new URL(raw);
50
- if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.search || url.hash) return undefined;
51
- return raw;
52
- } catch {
53
- return undefined;
54
- }
55
- }
56
-
57
- const recordKey = record => `${record.capacity_channel_id || ""}:${record.request_id}`;
58
-
59
- function stable(record) {
60
- return JSON.stringify(Object.fromEntries(Object.keys(record).sort().map((key) => [key, record[key]])));
61
- }
62
-
63
- function privateDirectory(path) {
64
- if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error("history directory must not be a symbolic link");
65
- mkdirSync(path, { recursive: true, mode: 0o700 });
66
- chmodSync(path, 0o700);
67
- }
68
-
69
- function appendPrivate(path, entry) {
70
- const directory = dirname(path);
71
- if (!existsSync(directory)) privateDirectory(directory);
72
- else if (!lstatSync(directory).isDirectory()) throw new Error("history parent must be a regular non-symlink directory");
73
- // A single O_APPEND write keeps concurrent writers from sharing a seek offset.
74
- // Readers also deduplicate: repeated imports by two processes are harmless.
75
- const fd = openSync(path, constants.O_RDWR | constants.O_CREAT | constants.O_APPEND | (constants.O_NOFOLLOW || 0), 0o600);
76
- try {
77
- const info = fstatSync(fd);
78
- if (!info.isFile()) throw new Error("history must be a regular file");
79
- fchmodSync(fd, 0o600);
80
- let prefix = "";
81
- if (info.size) {
82
- const last = Buffer.alloc(1);
83
- readSync(fd, last, 0, 1, info.size - 1);
84
- if (last[0] !== 10) prefix = "\n";
85
- }
86
- const line = Buffer.from(`${prefix}${JSON.stringify(entry)}\n`, "utf8");
87
- if (writeSync(fd, line) !== line.length) throw new Error("incomplete history append");
88
- fsyncSync(fd);
89
- } finally {
90
- closeSync(fd);
91
- }
92
- }
93
-
94
- function merge(previous, next) {
95
- if (!previous) return next;
96
- let winner;
97
- if (V9_TERMINAL.has(previous.status) && !V9_TERMINAL.has(next.status)) winner = previous;
98
- else if (V9_TERMINAL.has(next.status) && !V9_TERMINAL.has(previous.status)) winner = next;
99
- else if (["escrowed", "disputed"].includes(previous.status) && ["dispatching", "outcome_unknown", "not_dispatched", "queued", "pending", "submitted", "broadcast_unknown", "failed", "rejected"].includes(next.status)) winner = previous;
100
- else if (["escrowed", "disputed"].includes(next.status) && ["dispatching", "outcome_unknown", "not_dispatched", "queued", "pending", "submitted", "broadcast_unknown", "failed", "rejected"].includes(previous.status)) winner = next;
101
- else if (previous.status === "confirmed" && next.status !== "confirmed") winner = previous;
102
- else if (next.status === "confirmed" && previous.status !== "confirmed") winner = next;
103
- else if (["failed", "rejected"].includes(previous.status) && !["failed", "rejected"].includes(next.status)) winner = previous;
104
- else if (["failed", "rejected"].includes(next.status) && !["failed", "rejected"].includes(previous.status)) winner = next;
105
- else {
106
- const previousTime = previous.updated_at ?? previous.timestamp;
107
- const nextTime = next.updated_at ?? next.timestamp;
108
- winner = nextTime >= previousTime ? next : previous;
109
- }
110
- const other = winner === next ? previous : next;
111
- const result = { ...other, ...winner, accepted: previous.accepted || next.accepted || winner.status === "confirmed" };
112
- // Never discard locally verified token detail merely because a chain-only
113
- // status update has no token enrichment yet.
114
- for (const name of ["input_tokens", "output_tokens"]) {
115
- if (result[name] == null && other[name] != null) result[name] = other[name];
116
- }
117
- const timestamps = [previous.timestamp, next.timestamp].filter((value) => value > 0);
118
- if (timestamps.length) result.timestamp = Math.min(...timestamps);
119
- if (previous.confirmations !== undefined || next.confirmations !== undefined) {
120
- result.confirmations = Math.max(previous.confirmations || 0, next.confirmations || 0);
121
- }
122
- return result;
123
- }
124
-
125
- /**
126
- * Same-device metadata journal, partitioned by chain + contract + payment key.
127
- * This class never grants API or dashboard access: the caller's wallet gate
128
- * must still guard history presentation and any chain-confirmation sync.
129
- * Only localPath and this exact shared scope are read; no directory scanning.
130
- */
131
- export class ConsumerHistoryLedger {
132
- constructor({ localPath, sharedDir, chainId, contract, keyAddress }) {
133
- if (typeof localPath !== "string" || !localPath) throw new TypeError("localPath is required");
134
- this.chainId = number(chainId);
135
- if (!this.chainId) throw new TypeError("invalid chainId");
136
- this.contract = address(contract, "contract");
137
- this.keyAddress = address(keyAddress, "keyAddress");
138
- this.localPath = resolve(localPath);
139
- this.sharedDir = resolve(sharedDir || process.env.MYCOMESH_CONSUMER_HISTORY_DIR || join(homedir(), ".mycomesh", "consumer-history"));
140
- this.scopeDirectory = join(this.sharedDir, String(this.chainId), this.contract, this.keyAddress);
141
- this.sharedPath = join(this.scopeDirectory, "receipts.jsonl");
142
- this.lastDiagnostics = { skippedLocalLines: 0, skippedSharedLines: 0, importedEntries: 0 };
143
- }
144
-
145
- normalize(raw, origin) {
146
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
147
- const requestId = typeof raw.request_id === "string" ? raw.request_id.toLowerCase() : "";
148
- if (!BYTES32.test(requestId)) return null;
149
- const claims = [raw.key_address, raw.payment_key_address, raw.key].filter((value) => value !== undefined);
150
- if (claims.some((value) => typeof value !== "string" || !ADDRESS.test(value) || value.toLowerCase() !== this.keyAddress)) return null;
151
- if (raw.chain_id !== undefined && number(raw.chain_id) !== this.chainId) return null;
152
- for (const claimed of [raw.settlement_contract, raw.contract]) {
153
- if (claimed !== undefined && (typeof claimed !== "string" || !ADDRESS.test(claimed) || claimed.toLowerCase() !== this.contract)) return null;
154
- }
155
- const settlementKey = typeof raw.settlement_key === "string" ? raw.settlement_key.toLowerCase() : "";
156
- const legacyScope = /^v[89]:(0x[0-9a-f]{40}):(0x[0-9a-f]{64})$/.exec(settlementKey);
157
- if (legacyScope && (legacyScope[1] !== this.keyAddress || legacyScope[2] !== requestId)) return null;
158
- if (settlementKey && !legacyScope && !BYTES32.test(settlementKey)) return null;
159
- if (origin === "local" && !claims.length && !legacyScope) return null;
160
- if (origin === "shared" && (!claims.length || raw.chain_id === undefined || raw.settlement_contract === undefined)) return null;
161
- if (raw.capacity_channel_id !== undefined && (typeof raw.capacity_channel_id !== "string" || !BYTES32.test(raw.capacity_channel_id))) return null;
162
- const status = STATUSES.has(raw.status) ? raw.status : "pending";
163
- const record = {
164
- schema: SCHEMA,
165
- chain_id: this.chainId,
166
- settlement_contract: this.contract,
167
- key_address: this.keyAddress,
168
- request_id: requestId,
169
- settlement_key: settlementKey || `v8:${this.keyAddress}:${requestId}`,
170
- timestamp: number(raw.timestamp) ?? (origin === "append" ? Math.floor(Date.now() / 1000) : 0),
171
- status,
172
- accepted: raw.accepted === true || status === "confirmed",
173
- source: text(raw.source, 80) || (origin === "local" && raw.schema !== SCHEMA ? "legacy-local" : "consumer"),
174
- };
175
- if (raw.capacity_channel_id) record.capacity_channel_id = raw.capacity_channel_id.toLowerCase();
176
- if (["provider-signed", "receipt-only", "failed"].includes(raw.content_verification)) record.content_verification = raw.content_verification;
177
- for (const name of ["owner", "provider", "provider_signer", "relay"]) {
178
- if (typeof raw[name] === "string" && ADDRESS.test(raw[name])) record[name] = raw[name].toLowerCase();
179
- }
180
- if (typeof raw.response_hash === "string" && BYTES32.test(raw.response_hash)) record.response_hash = raw.response_hash.toLowerCase();
181
- if (typeof raw.request_hash === "string" && BYTES32.test(raw.request_hash)) record.request_hash = raw.request_hash.toLowerCase();
182
- const juryFields = [raw.jury_evidence_hash, raw.jury_report_id, raw.jury_reporter,
183
- raw.jury_origin_relay_public_key, raw.jury_reference_signature,
184
- raw.jury_reference_timestamp];
185
- if (juryFields.every((value) => value !== undefined)
186
- && BYTES32.test(raw.jury_evidence_hash)
187
- && BYTES32.test(raw.jury_report_id)
188
- && ADDRESS.test(raw.jury_reporter)
189
- && typeof raw.jury_origin_relay_public_key === "string"
190
- && /^[0-9a-f]{64}$/.test(raw.jury_origin_relay_public_key)
191
- && typeof raw.jury_reference_signature === "string"
192
- && /^[0-9a-f]{128}$/.test(raw.jury_reference_signature)
193
- && number(raw.jury_reference_timestamp) !== undefined) {
194
- record.jury_evidence_hash = raw.jury_evidence_hash.toLowerCase();
195
- record.jury_report_id = raw.jury_report_id.toLowerCase();
196
- record.jury_reporter = raw.jury_reporter.toLowerCase();
197
- record.jury_origin_relay_public_key = raw.jury_origin_relay_public_key;
198
- record.jury_reference_signature = raw.jury_reference_signature;
199
- }
200
- if (DISPUTE_STAGES.has(raw.dispute_stage)) record.dispute_stage = raw.dispute_stage;
201
- if (["approval", "open_dispute"].includes(raw.dispute_tx_kind)) record.dispute_tx_kind = raw.dispute_tx_kind;
202
- for (const name of ["dispute_tx_hash", "dispute_approval_tx_hash"]) {
203
- if (typeof raw[name] === "string" && BYTES32.test(raw[name])) record[name] = raw[name].toLowerCase();
204
- }
205
- if (typeof raw.dispute_bond_units === "string" && /^(?:0|[1-9][0-9]*)$/.test(raw.dispute_bond_units)) {
206
- record.dispute_bond_units = raw.dispute_bond_units;
207
- }
208
- const disputeError = text(raw.dispute_error, 512);
209
- if (disputeError !== undefined) record.dispute_error = disputeError;
210
- if (number(raw.max_fee_units) !== undefined) record.max_fee_units = number(raw.max_fee_units);
211
- for (const name of ["endpoint", "model", "route_model", "session_id", "error_code"]) {
212
- const value = text(raw[name]);
213
- if (value !== undefined) record[name] = value;
214
- }
215
- const relayUrl = publicUrl(raw.relay_url);
216
- if (relayUrl) record.relay_url = relayUrl;
217
- const txHash = raw.tx_hash ?? raw.transaction_hash;
218
- if (typeof txHash === "string" && BYTES32.test(txHash)) record.tx_hash = txHash.toLowerCase();
219
- for (const name of NUMERIC_FIELDS) {
220
- const value = number(raw[name]);
221
- if (value !== undefined) record[name] = value;
222
- }
223
- if (record.actual_fee_units === undefined && number(raw.actual_fee) !== undefined) record.actual_fee_units = number(raw.actual_fee);
224
- // All unknown fields (including prompts, payloads, private keys, API keys,
225
- // signatures and nested authorizations) are deliberately not copied.
226
- return record;
227
- }
228
-
229
- read(path, origin) {
230
- if (!existsSync(path)) return [];
231
- if (!lstatSync(path).isFile()) throw new Error("history must be a regular non-symlink file");
232
- const rows = [];
233
- for (const line of readFileSync(path, "utf8").split("\n")) {
234
- if (!line.trim()) continue;
235
- let record;
236
- try {
237
- record = Buffer.byteLength(line, "utf8") <= MAX_LINE_BYTES ? this.normalize(JSON.parse(line), origin) : null;
238
- } catch {
239
- record = null;
240
- }
241
- if (record) rows.push(record);
242
- else this.lastDiagnostics[origin === "local" ? "skippedLocalLines" : "skippedSharedLines"] += 1;
243
- }
244
- return rows;
245
- }
246
-
247
- prepareShared() {
248
- privateDirectory(this.sharedDir);
249
- privateDirectory(join(this.sharedDir, String(this.chainId)));
250
- privateDirectory(join(this.sharedDir, String(this.chainId), this.contract));
251
- privateDirectory(this.scopeDirectory);
252
- }
253
-
254
- history(limit = 100) {
255
- if (!Number.isSafeInteger(limit)) throw new TypeError("history limit must be an integer");
256
- this.lastDiagnostics = { skippedLocalLines: 0, skippedSharedLines: 0, importedEntries: 0 };
257
- const shared = new Map();
258
- for (const record of this.read(this.sharedPath, "shared")) shared.set(recordKey(record), merge(shared.get(recordKey(record)), record));
259
- const local = new Map();
260
- for (const record of this.read(this.localPath, "local")) local.set(recordKey(record), merge(local.get(recordKey(record)), record));
261
- const merged = new Map(shared);
262
- for (const record of local.values()) {
263
- const next = merge(shared.get(recordKey(record)), record);
264
- merged.set(recordKey(record), next);
265
- if (!shared.has(recordKey(record)) || stable(next) !== stable(shared.get(recordKey(record)))) {
266
- this.prepareShared();
267
- appendPrivate(this.sharedPath, next);
268
- this.lastDiagnostics.importedEntries += 1;
269
- }
270
- }
271
- const rows = [...merged.values()].sort((left, right) => right.timestamp - left.timestamp || left.request_id.localeCompare(right.request_id));
272
- return limit <= 0 ? rows : rows.slice(0, Math.min(limit, 500));
273
- }
274
-
275
- append(entry) {
276
- const record = this.normalize(entry, "append");
277
- if (!record) throw new TypeError("history entry has an invalid request ID or conflicting scope");
278
- const bytes = Buffer.byteLength(JSON.stringify(record), "utf8");
279
- if (bytes > MAX_LINE_BYTES) throw new TypeError("history entry is too large");
280
- this.prepareShared();
281
- appendPrivate(this.localPath, record);
282
- if (this.sharedPath !== this.localPath) appendPrivate(this.sharedPath, record);
283
- return record;
284
- }
285
- }
286
-
287
- export { ConsumerHistoryLedger as ConsumerHistory };