@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.
@@ -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 > MAX_BODY) { reject(new Error("body too large")); req.destroy(); return; }
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}`, MAX_ENVELOPES_PER_MIN)) {
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) throw new Error("pillar not started");
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
- return { envelope, persisted, via: "direct", delivery: await this._deliverToPeer(envelope, book.peerId, book.multiaddr) };
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) throw e; // else fall through to carrier
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) throw new Error(`send: cannot resolve ${recipient}: no carrier configured and libp2p not started`);
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) { throw new Error(`send: cannot resolve ${recipient}: ${e.message}`); }
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
- return { envelope, persisted, via: "dht", delivery: await this._deliverToPeer(envelope, dhtAdvert.peerId, dhtAdvert.addrs?.[0]) };
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) { return; }
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) return; // replay: do not re-fire handlers
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) { console.error("pillar: envelope handler threw", 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
  }