@uuaid/pillar 0.2.0-alpha.10 → 0.2.0-alpha.12
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 +54 -1
- package/bin/carrier.mjs +6 -1
- package/bin/pillar-escrow.mjs +674 -0
- package/bin/pillar-responder.mjs +46 -1
- package/bin/pillar.mjs +164 -2
- package/package.json +13 -3
- package/src/escrow/box.mjs +43 -0
- package/src/escrow/escrow-service.mjs +538 -0
- package/src/escrow/rails/evm-rail.mjs +427 -0
- package/src/escrow/rails/index.mjs +41 -0
- package/src/escrow/rails/zilligon-rail.mjs +73 -0
- package/src/escrow/terms.mjs +368 -0
- package/src/escrow/token-registry.mjs +231 -0
- package/src/escrow/verify.mjs +119 -0
- package/src/escrow/zilligon.mjs +266 -0
- package/src/identity/tier.mjs +85 -0
- package/src/identity/trust.mjs +63 -0
- package/src/mailbox/mailbox.mjs +33 -2
- package/src/net/carrier-client.mjs +14 -4
- package/src/net/carrier-server.mjs +44 -6
- package/src/observability.mjs +141 -0
- package/src/pillar.mjs +80 -11
- package/src/vda/core.mjs +290 -0
- package/src/vda/directory.mjs +87 -0
- package/src/vda/instruments.mjs +246 -0
- package/src/vda/shims.mjs +77 -0
|
@@ -36,9 +36,11 @@ import { createHash } from "node:crypto";
|
|
|
36
36
|
import Database from "better-sqlite3";
|
|
37
37
|
import { open as openEnvelope } from "./envelope.mjs";
|
|
38
38
|
import { Keychain } from "../identity/keychain.mjs";
|
|
39
|
+
import { verifyTierGrant } from "../identity/tier.mjs";
|
|
39
40
|
|
|
40
41
|
const MAX_WAIT_S = 30;
|
|
41
|
-
const MAX_BODY = 512 * 1024; // 512 KiB per envelope
|
|
42
|
+
const MAX_BODY = 512 * 1024; // 512 KiB per envelope (community tier)
|
|
43
|
+
const MAX_BODY_STREAM = 2 * 1024 * 1024 + 8192; // absolute read cap: max tier body + grant/header slack
|
|
42
44
|
const MAX_SKEW_MS = 5 * 60 * 1000;
|
|
43
45
|
// Hardening (RedTeam F2/F3):
|
|
44
46
|
const MAX_ENVELOPE_AGE_MS = 24 * 3600_000; // F3: ingest freshness — reject skewed createdAt
|
|
@@ -51,7 +53,7 @@ const MAX_DB_BYTES = 256 * 1024 * 1024; // F2: SQLite size backpressure (503
|
|
|
51
53
|
const RATE_MAP_HARD_CAP = 10_000; // F2: bound memory under key spray
|
|
52
54
|
|
|
53
55
|
export class CarrierServer {
|
|
54
|
-
constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory } = {}) {
|
|
56
|
+
constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory, trustedTierIssuers = [] } = {}) {
|
|
55
57
|
this.db = new Database(dbPath);
|
|
56
58
|
this.db.pragma("journal_mode = WAL");
|
|
57
59
|
this.db.exec(`
|
|
@@ -73,6 +75,37 @@ export class CarrierServer {
|
|
|
73
75
|
this.rateMap = new Map(); // F2: "<kind>:<key>" -> [ms timestamps]
|
|
74
76
|
this.activeWaits = 0; // F2: long-polls currently sleeping
|
|
75
77
|
this._closed = false;
|
|
78
|
+
// Tier grants: issuer pubkeys (hex) this carrier honors. Empty = every
|
|
79
|
+
// sender gets community-tier budgets (pre-tier behavior, default).
|
|
80
|
+
this.trustedTierIssuers = trustedTierIssuers;
|
|
81
|
+
this._grantCache = new Map(); // sender uuaid -> { grant, cachedAt }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Tier resolution for a POST: verify an optional x-pillar-tier-grant header
|
|
86
|
+
* (base64 JSON, issued by makeTierGrant). A valid grant FOR THE CLAIMED
|
|
87
|
+
* SENDER raises that sender's rate/size budget. Invalid/foreign grants are
|
|
88
|
+
* ignored (fall back to community) — never a hard reject, so a bad grant
|
|
89
|
+
* cannot break plain delivery.
|
|
90
|
+
*/
|
|
91
|
+
_resolveTier(req, sender) {
|
|
92
|
+
const fallback = { tier: "community", envelopesPerMin: MAX_ENVELOPES_PER_MIN, maxBodyBytes: MAX_BODY };
|
|
93
|
+
if (!this.trustedTierIssuers.length) return fallback;
|
|
94
|
+
const hdr = req?.headers?.["x-pillar-tier-grant"];
|
|
95
|
+
if (!hdr) return fallback;
|
|
96
|
+
let grant;
|
|
97
|
+
try { grant = JSON.parse(Buffer.from(String(hdr), "base64").toString("utf-8")); }
|
|
98
|
+
catch (_e) { return fallback; }
|
|
99
|
+
if (grant?.uuaid !== sender) return fallback; // grant must name the claimed sender
|
|
100
|
+
const cached = this._grantCache.get(sender);
|
|
101
|
+
if (cached && cached.sig === grant.sig && Date.now() - cached.at < 60_000) return cached.resolved;
|
|
102
|
+
const v = verifyTierGrant(grant, this.trustedTierIssuers);
|
|
103
|
+
const resolved = v.ok
|
|
104
|
+
? { tier: grant.tier, envelopesPerMin: grant.envelopesPerMin, maxBodyBytes: grant.maxBodyBytes }
|
|
105
|
+
: fallback;
|
|
106
|
+
if (this._grantCache.size > 4096) this._grantCache.clear(); // bounded
|
|
107
|
+
this._grantCache.set(sender, { sig: grant.sig, at: Date.now(), resolved });
|
|
108
|
+
return resolved;
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
/** F2: best-effort client identity, nginx-aware (XFF set by proxy). */
|
|
@@ -127,7 +160,7 @@ export class CarrierServer {
|
|
|
127
160
|
let size = 0; const chunks = [];
|
|
128
161
|
req.on("data", (c) => {
|
|
129
162
|
size += c.length;
|
|
130
|
-
if (size >
|
|
163
|
+
if (size > MAX_BODY_STREAM) { reject(new Error("body too large")); req.destroy(); return; }
|
|
131
164
|
chunks.push(c);
|
|
132
165
|
});
|
|
133
166
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
@@ -135,7 +168,7 @@ export class CarrierServer {
|
|
|
135
168
|
});
|
|
136
169
|
}
|
|
137
170
|
|
|
138
|
-
_handlePost(envelope, ip = "unknown") {
|
|
171
|
+
_handlePost(envelope, ip = "unknown", req = null, rawBytes = 0) {
|
|
139
172
|
const verdict = openEnvelope(envelope, { cryptoInventory: this.cryptoInventory });
|
|
140
173
|
if (!verdict.ok) {
|
|
141
174
|
// F2: cap rejected sends per client (flood the verifier → 429).
|
|
@@ -147,8 +180,13 @@ export class CarrierServer {
|
|
|
147
180
|
if (typeof envelope.recipient !== "string" || !envelope.recipient.startsWith("uuaid:")) {
|
|
148
181
|
return { code: 400, body: { accepted: false, reason: "bad-recipient" } };
|
|
149
182
|
}
|
|
183
|
+
// Tier: a verified grant raises this sender's budgets; default community.
|
|
184
|
+
const tier = this._resolveTier(req, envelope.sender);
|
|
185
|
+
if (rawBytes > tier.maxBodyBytes) {
|
|
186
|
+
return { code: 413, body: { accepted: false, reason: "body-too-large", maxBodyBytes: tier.maxBodyBytes, tier: tier.tier } };
|
|
187
|
+
}
|
|
150
188
|
// F2: per-sender ingest budget — a sender uuaid cannot flood its carrier.
|
|
151
|
-
if (!this._rateAllow(`s:${envelope.sender}`,
|
|
189
|
+
if (!this._rateAllow(`s:${envelope.sender}`, tier.envelopesPerMin)) {
|
|
152
190
|
return { code: 429, body: { accepted: false, reason: "rate-limited" } };
|
|
153
191
|
}
|
|
154
192
|
// F2: per-client budget on ACCEPTED sends — closes the unlimited
|
|
@@ -255,7 +293,7 @@ export class CarrierServer {
|
|
|
255
293
|
let envelope;
|
|
256
294
|
try { envelope = JSON.parse(raw); }
|
|
257
295
|
catch { return this._json(res, 400, { accepted: false, reason: "bad-json" }); }
|
|
258
|
-
const { code, body } = this._handlePost(envelope, this._clientIp(req));
|
|
296
|
+
const { code, body } = this._handlePost(envelope, this._clientIp(req), req, Buffer.byteLength(raw));
|
|
259
297
|
return this._json(res, code, body);
|
|
260
298
|
}
|
|
261
299
|
const inboxMatch = /^\/v1\/inbox\/([^/]+)$/.exec(url.pathname);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observability — metrics/analytics for the Pillar communication system.
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency, in-memory counters / gauges / histograms with an optional
|
|
5
|
+
* append-only JSONL sink (same pattern as the escrow journal). Every Pillar
|
|
6
|
+
* and EscrowService owns one instance; when none is passed in, a default is
|
|
7
|
+
* created internally so instrumentation sites never null-check.
|
|
8
|
+
*
|
|
9
|
+
* inc("sent.via.carrier") counters — labels compose into dotted
|
|
10
|
+
* keys at the CALL SITE, e.g.
|
|
11
|
+
* `inc(\`received.kind.${kind}\`)`
|
|
12
|
+
* setGauge("escrow.offers.open", 3) gauges — last write wins
|
|
13
|
+
* observe("send.latencyMs", ms) histograms — fixed latency buckets
|
|
14
|
+
*
|
|
15
|
+
* Sink: journal({event, ...}) appends an event line; flush() appends a full
|
|
16
|
+
* state snapshot. An external analytics pipeline can tail the file without
|
|
17
|
+
* a live process. summary() renders the one-glance human view.
|
|
18
|
+
*
|
|
19
|
+
* Keys are sanitized (lowercased, non-alphanumerics folded to "-") so
|
|
20
|
+
* attacker-influenced strings (envelope kinds, error reasons) can never
|
|
21
|
+
* fabricate keys or escape the dotted namespace.
|
|
22
|
+
*/
|
|
23
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
24
|
+
import { dirname } from "node:path";
|
|
25
|
+
|
|
26
|
+
// Fixed latency buckets (ms) — covers fast local sends (<10 ms) up to slow
|
|
27
|
+
// carrier round-trips (>10 s). Values above the top bucket still count into
|
|
28
|
+
// n/sum/max; percentiles interpolate within the top bucket's upper bound.
|
|
29
|
+
export const LATENCY_BUCKETS = [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
|
|
30
|
+
|
|
31
|
+
const MAX_KEY = 64;
|
|
32
|
+
|
|
33
|
+
export function slugKey(key) {
|
|
34
|
+
return String(key)
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9.-]+/g, "-")
|
|
37
|
+
.replace(/^-+|-+$/g, "") // a trailing "!!!" run must not leave a dangling dash
|
|
38
|
+
.slice(0, MAX_KEY);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function newHistogram() {
|
|
42
|
+
return { n: 0, sum: 0, min: Infinity, max: -Infinity, buckets: new Array(LATENCY_BUCKETS.length).fill(0) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function percentile(h, p) {
|
|
46
|
+
if (h.n === 0) return 0;
|
|
47
|
+
const target = h.n * p;
|
|
48
|
+
let acc = 0;
|
|
49
|
+
let lower = 0;
|
|
50
|
+
for (let i = 0; i < LATENCY_BUCKETS.length; i++) {
|
|
51
|
+
acc += h.buckets[i];
|
|
52
|
+
if (acc >= target) {
|
|
53
|
+
const upper = LATENCY_BUCKETS[i];
|
|
54
|
+
const inBucket = h.buckets[i];
|
|
55
|
+
const frac = inBucket === 0 ? 0 : (target - (acc - inBucket)) / inBucket;
|
|
56
|
+
return Math.round(lower + frac * (upper - lower));
|
|
57
|
+
}
|
|
58
|
+
lower = LATENCY_BUCKETS[i];
|
|
59
|
+
}
|
|
60
|
+
return h.max;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class Observability {
|
|
64
|
+
constructor({ sinkPath = null } = {}) {
|
|
65
|
+
this.sinkPath = sinkPath;
|
|
66
|
+
this.counters = new Map();
|
|
67
|
+
this.gauges = new Map();
|
|
68
|
+
this.histograms = new Map();
|
|
69
|
+
this.startedAt = new Date().toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ------------------------------------------------ write paths -------- */
|
|
73
|
+
|
|
74
|
+
inc(key, n = 1) {
|
|
75
|
+
const k = slugKey(key);
|
|
76
|
+
this.counters.set(k, (this.counters.get(k) ?? 0) + n);
|
|
77
|
+
return this.counters.get(k);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setGauge(key, value) {
|
|
81
|
+
this.gauges.set(slugKey(key), value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
observe(key, ms) {
|
|
85
|
+
const k = slugKey(key);
|
|
86
|
+
let h = this.histograms.get(k);
|
|
87
|
+
if (!h) { h = newHistogram(); this.histograms.set(k, h); }
|
|
88
|
+
const v = Number(ms);
|
|
89
|
+
if (!Number.isFinite(v)) return;
|
|
90
|
+
h.n += 1;
|
|
91
|
+
h.sum += v;
|
|
92
|
+
if (v < h.min) h.min = v;
|
|
93
|
+
if (v > h.max) h.max = v;
|
|
94
|
+
const idx = LATENCY_BUCKETS.findIndex((b) => v <= b);
|
|
95
|
+
h.buckets[idx < 0 ? LATENCY_BUCKETS.length - 1 : idx] += 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Append an event line to the sink (no-op without a sinkPath). */
|
|
99
|
+
journal({ event, ...fields }) {
|
|
100
|
+
if (!this.sinkPath) return;
|
|
101
|
+
mkdirSync(dirname(this.sinkPath), { recursive: true });
|
|
102
|
+
appendFileSync(this.sinkPath, JSON.stringify({ at: new Date().toISOString(), event, ...fields }) + "\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Append a full state snapshot line to the sink (no-op without a sinkPath). */
|
|
106
|
+
flush() {
|
|
107
|
+
if (!this.sinkPath) return;
|
|
108
|
+
mkdirSync(dirname(this.sinkPath), { recursive: true });
|
|
109
|
+
appendFileSync(this.sinkPath, JSON.stringify({ type: "snapshot", ...this.snapshot() }) + "\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ------------------------------------------------ read paths --------- */
|
|
113
|
+
|
|
114
|
+
snapshot() {
|
|
115
|
+
const histograms = {};
|
|
116
|
+
for (const [k, h] of this.histograms) {
|
|
117
|
+
histograms[k] = { n: h.n, sum: h.sum, min: h.min, max: h.max, p50: percentile(h, 0.5), p95: percentile(h, 0.95) };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
at: new Date().toISOString(),
|
|
121
|
+
startedAt: this.startedAt,
|
|
122
|
+
counters: Object.fromEntries(this.counters),
|
|
123
|
+
gauges: Object.fromEntries(this.gauges),
|
|
124
|
+
histograms,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One-glance human view for CLI / dashboards. */
|
|
129
|
+
summary() {
|
|
130
|
+
const s = this.snapshot();
|
|
131
|
+
const lines = [
|
|
132
|
+
`pillar metrics (since ${s.startedAt}) — ${this.counters.size} counters, ${this.gauges.size} gauges, ${this.histograms.size} histograms`,
|
|
133
|
+
];
|
|
134
|
+
for (const [k, v] of Object.entries(s.counters).sort()) lines.push(` ${k.padEnd(48)} ${v}`);
|
|
135
|
+
for (const [k, v] of Object.entries(s.gauges).sort()) lines.push(` ${k.padEnd(48)} = ${v}`);
|
|
136
|
+
for (const [k, h] of Object.entries(s.histograms).sort()) {
|
|
137
|
+
lines.push(` ${k.padEnd(48)} n=${h.n} min=${h.min}ms p50=${h.p50}ms p95=${h.p95}ms max=${h.max}ms`);
|
|
138
|
+
}
|
|
139
|
+
return lines.join("\n");
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/pillar.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { createTransport, PILLAR_PROTOCOL, uuaidToDhtKey, peerIdFromEd25519Hex }
|
|
|
22
22
|
import { seal, open, decrypt, envelopeSha, ENVELOPE_VERSION } from "./net/envelope.mjs";
|
|
23
23
|
import { Mailbox } from "./mailbox/mailbox.mjs";
|
|
24
24
|
import { CarrierClient } from "./net/carrier-client.mjs";
|
|
25
|
+
import { Observability } from "./observability.mjs";
|
|
25
26
|
|
|
26
27
|
const VALUE_STORE_PREFIX = "/uuaid/1.0";
|
|
27
28
|
|
|
@@ -37,6 +38,9 @@ export class Pillar {
|
|
|
37
38
|
carriers = [],
|
|
38
39
|
enableLibp2p = true,
|
|
39
40
|
cryptoInventory = { ed25519: "active", "ml-dsa-65": "reserved", "slh-dsa-128s": "reserved" },
|
|
41
|
+
trust = null, // { policy, allow, block } from loadTrustPolicy(); null = open
|
|
42
|
+
tierGrant = null, // signed grant object (tier.mjs) sent with carrier delivers
|
|
43
|
+
metrics = null, // Observability instance; a default is created internally
|
|
40
44
|
}) {
|
|
41
45
|
if (!keychain?._identity) throw new Error("Pillar: keychain must be loaded before construction");
|
|
42
46
|
this.keychain = keychain;
|
|
@@ -47,11 +51,13 @@ export class Pillar {
|
|
|
47
51
|
this.dhtClientMode = dhtClientMode;
|
|
48
52
|
this.dhtPeerInfoMapper = dhtPeerInfoMapper;
|
|
49
53
|
this.carriers = carriers;
|
|
50
|
-
this.carrierClient = carriers.length ? new CarrierClient({ keychain, carriers }) : null;
|
|
54
|
+
this.carrierClient = carriers.length ? new CarrierClient({ keychain, carriers, tierGrant }) : null;
|
|
51
55
|
this._carrierPoller = null;
|
|
52
56
|
this.enableLibp2p = enableLibp2p;
|
|
53
57
|
this.cryptoInventory = cryptoInventory;
|
|
54
58
|
this.node = null;
|
|
59
|
+
this.trust = trust;
|
|
60
|
+
this.metrics = metrics ?? new Observability();
|
|
55
61
|
this._envelopeHandlers = new Set();
|
|
56
62
|
this._openStreams = new Map(); // peerIdString -> pushable
|
|
57
63
|
}
|
|
@@ -235,7 +241,11 @@ export class Pillar {
|
|
|
235
241
|
* once at fleet-up time.
|
|
236
242
|
*/
|
|
237
243
|
async send({ recipient, recipientPublicKey, kind = "message", payload = {}, resolveTimeoutMs = 5000 }) {
|
|
238
|
-
if (!this.node && !this.carrierClient)
|
|
244
|
+
if (!this.node && !this.carrierClient) {
|
|
245
|
+
this.metrics.inc("send.error.not-started");
|
|
246
|
+
throw new Error("pillar not started");
|
|
247
|
+
}
|
|
248
|
+
const t0 = Date.now();
|
|
239
249
|
|
|
240
250
|
const book = this.addressBook?.get(recipient);
|
|
241
251
|
|
|
@@ -251,6 +261,7 @@ export class Pillar {
|
|
|
251
261
|
} catch (_e) { /* fall through to the error below */ }
|
|
252
262
|
}
|
|
253
263
|
if (!pubkey) {
|
|
264
|
+
this.metrics.inc("send.error.no-public-key");
|
|
254
265
|
throw new Error(`send: no public key known for ${recipient} — encryption is mandatory; register the peer (address book / fleet.json) or pass recipientPublicKey`);
|
|
255
266
|
}
|
|
256
267
|
|
|
@@ -260,9 +271,15 @@ export class Pillar {
|
|
|
260
271
|
// 1. Address book path: direct libp2p dial (lowest latency, no third party).
|
|
261
272
|
if (this.node && book?.peerId && book?.multiaddr) {
|
|
262
273
|
try {
|
|
263
|
-
|
|
274
|
+
const delivery = await this._deliverToPeer(envelope, book.peerId, book.multiaddr);
|
|
275
|
+
this.metrics.inc("send.via.direct");
|
|
276
|
+
this.metrics.observe("send.latencyMs.via.direct", Date.now() - t0);
|
|
277
|
+
return { envelope, persisted, via: "direct", delivery };
|
|
264
278
|
} catch (e) {
|
|
265
|
-
if (!this.carrierClient)
|
|
279
|
+
if (!this.carrierClient) {
|
|
280
|
+
this.metrics.inc("send.error.direct-delivery-failed");
|
|
281
|
+
throw e;
|
|
282
|
+
} // else fall through to carrier
|
|
266
283
|
}
|
|
267
284
|
}
|
|
268
285
|
|
|
@@ -270,16 +287,27 @@ export class Pillar {
|
|
|
270
287
|
if (this.carrierClient) {
|
|
271
288
|
const receipt = await this.carrierClient.deliver(envelope);
|
|
272
289
|
this.mailbox.markOutboundDelivered(envelope.id, envelopeSha(envelope));
|
|
290
|
+
this.metrics.inc("send.via.carrier");
|
|
291
|
+
this.metrics.observe("send.latencyMs.via.carrier", Date.now() - t0);
|
|
273
292
|
return { envelope, persisted, via: "carrier", delivery: receipt };
|
|
274
293
|
}
|
|
275
294
|
|
|
276
295
|
// 3. DHT path.
|
|
277
|
-
if (!this.node)
|
|
296
|
+
if (!this.node) {
|
|
297
|
+
this.metrics.inc("send.error.no-route");
|
|
298
|
+
throw new Error(`send: cannot resolve ${recipient}: no carrier configured and libp2p not started`);
|
|
299
|
+
}
|
|
278
300
|
if (!dhtAdvert) {
|
|
279
301
|
try { dhtAdvert = await this.resolveUuaid(recipient, { timeoutMs: resolveTimeoutMs }); }
|
|
280
|
-
catch (e) {
|
|
302
|
+
catch (e) {
|
|
303
|
+
this.metrics.inc("send.error.resolve-failed");
|
|
304
|
+
throw new Error(`send: cannot resolve ${recipient}: ${e.message}`);
|
|
305
|
+
}
|
|
281
306
|
}
|
|
282
|
-
|
|
307
|
+
const delivery = await this._deliverToPeer(envelope, dhtAdvert.peerId, dhtAdvert.addrs?.[0]);
|
|
308
|
+
this.metrics.inc("send.via.dht");
|
|
309
|
+
this.metrics.observe("send.latencyMs.via.dht", Date.now() - t0);
|
|
310
|
+
return { envelope, persisted, via: "dht", delivery };
|
|
283
311
|
}
|
|
284
312
|
|
|
285
313
|
/**
|
|
@@ -309,6 +337,9 @@ export class Pillar {
|
|
|
309
337
|
results.push({ member, ok: false, error: e.message });
|
|
310
338
|
}
|
|
311
339
|
}
|
|
340
|
+
this.metrics.inc("sendGroup");
|
|
341
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
342
|
+
if (failed) this.metrics.inc("sendGroup.failed", failed);
|
|
312
343
|
return { group, results };
|
|
313
344
|
}
|
|
314
345
|
|
|
@@ -328,8 +359,11 @@ export class Pillar {
|
|
|
328
359
|
onEnvelope: async (envelope, { carrier, seq }) => {
|
|
329
360
|
await this._receive(JSON.stringify(envelope), null);
|
|
330
361
|
this.mailbox.setCarrierCursor?.(carrier, seq);
|
|
362
|
+
this.metrics.inc(`carrier.poll.${carrier}`);
|
|
363
|
+
},
|
|
364
|
+
onError: (_e, meta) => {
|
|
365
|
+
this.metrics.inc(`carrier.poll.error.${meta?.carrier ?? "unknown"}`);
|
|
331
366
|
},
|
|
332
|
-
onError: (_e) => { /* transient poll errors are expected; backoff is in client */ },
|
|
333
367
|
});
|
|
334
368
|
return this._carrierPoller;
|
|
335
369
|
}
|
|
@@ -358,6 +392,7 @@ export class Pillar {
|
|
|
358
392
|
this.mailbox.setCarrierCursor?.(base, seq);
|
|
359
393
|
since = Math.max(since, seq);
|
|
360
394
|
total++;
|
|
395
|
+
this.metrics.inc(`carrier.drain.${base}`);
|
|
361
396
|
}
|
|
362
397
|
}
|
|
363
398
|
}
|
|
@@ -451,15 +486,20 @@ export class Pillar {
|
|
|
451
486
|
async _receive(rawLine, connection) {
|
|
452
487
|
let envelope;
|
|
453
488
|
try { envelope = JSON.parse(rawLine); }
|
|
454
|
-
catch (_e) {
|
|
489
|
+
catch (_e) {
|
|
490
|
+
this.metrics.inc("received.unparsable");
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
455
493
|
const verdict = open(envelope, { cryptoInventory: this.cryptoInventory });
|
|
456
494
|
if (!verdict.ok) {
|
|
495
|
+
this.metrics.inc(`received.rejected.${verdict.reason}`);
|
|
457
496
|
// eslint-disable-next-line no-console
|
|
458
497
|
console.warn("pillar: rejected envelope from", connection?.remotePeer?.toString(), verdict.reason);
|
|
459
498
|
return;
|
|
460
499
|
}
|
|
461
500
|
if (envelope.recipient !== this.uuaid) {
|
|
462
501
|
// Not for us. Store-and-forward would go here in v0.3.
|
|
502
|
+
this.metrics.inc("received.wrong-recipient");
|
|
463
503
|
return;
|
|
464
504
|
}
|
|
465
505
|
// E2E: decrypt the sealed payload. Ciphertext-at-rest: the mailbox stores
|
|
@@ -468,16 +508,45 @@ export class Pillar {
|
|
|
468
508
|
try {
|
|
469
509
|
payload = decrypt(this.keychain, envelope);
|
|
470
510
|
} catch (e) {
|
|
511
|
+
this.metrics.inc("received.decrypt-failed");
|
|
471
512
|
// eslint-disable-next-line no-console
|
|
472
513
|
console.warn("pillar: envelope decrypt failed (dropping)", envelope.id, e.message);
|
|
473
514
|
return;
|
|
474
515
|
}
|
|
475
516
|
const persisted = this.mailbox.appendInbound(envelope);
|
|
476
|
-
if (persisted.duplicate)
|
|
517
|
+
if (persisted.duplicate) {
|
|
518
|
+
this.metrics.inc("received.duplicate");
|
|
519
|
+
return; // replay: do not re-fire handlers
|
|
520
|
+
}
|
|
521
|
+
this.metrics.inc(`received.kind.${envelope.kind}`);
|
|
522
|
+
// Trust gate (recipient consent): quarantine-not-drop. The envelope is
|
|
523
|
+
// already persisted (chain-hashed evidence); a gated sender just never
|
|
524
|
+
// reaches handlers. An ack/reply passes the gate ONLY if its inReplyTo
|
|
525
|
+
// names an envelope in OUR outbox — a genuine receipt for our own send.
|
|
526
|
+
// (kind alone is spoofable; the outbox check is not.)
|
|
527
|
+
if (this.trust) {
|
|
528
|
+
const isReceipt = (envelope.kind === "ack" || envelope.kind === "reply") &&
|
|
529
|
+
typeof payload?.inReplyTo === "string" && this.mailbox.hasOutbound(payload.inReplyTo);
|
|
530
|
+
if (!isReceipt) {
|
|
531
|
+
const { evaluateTrust } = await import("./identity/trust.mjs");
|
|
532
|
+
const known = new Set(this.addressBook?.keys() ?? []);
|
|
533
|
+
const verdict = evaluateTrust(this.trust, envelope.sender, known);
|
|
534
|
+
if (!verdict.deliver) {
|
|
535
|
+
this.mailbox.markQuarantined(envelope.id, verdict.reason);
|
|
536
|
+
this.metrics.inc(`received.quarantined.${verdict.reason}`);
|
|
537
|
+
// eslint-disable-next-line no-console
|
|
538
|
+
console.warn(`pillar: quarantined ${envelope.id} from ${envelope.sender} (${verdict.reason})`);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
477
543
|
const view = { ...envelope, payload };
|
|
478
544
|
for (const h of this._envelopeHandlers) {
|
|
479
545
|
try { await h(view, persisted); }
|
|
480
|
-
catch (e) {
|
|
546
|
+
catch (e) {
|
|
547
|
+
this.metrics.inc("received.handler-error");
|
|
548
|
+
console.error("pillar: envelope handler threw", e);
|
|
549
|
+
}
|
|
481
550
|
}
|
|
482
551
|
}
|
|
483
552
|
}
|