@uuaid/pillar 0.2.0-alpha.9 → 2.0.2

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.
Files changed (45) hide show
  1. package/HOSTING-LIMITS.md +432 -0
  2. package/INTERACTION-CONVENTION.md +222 -1
  3. package/README.md +358 -17
  4. package/bin/carrier.mjs +7 -1
  5. package/bin/pillar-ask.mjs +1 -0
  6. package/bin/pillar-blob.mjs +139 -0
  7. package/bin/pillar-directory.mjs +232 -0
  8. package/bin/pillar-escrow.mjs +675 -0
  9. package/bin/pillar-responder.mjs +49 -3
  10. package/bin/pillar-seed.mjs +113 -13
  11. package/bin/pillar.mjs +299 -7
  12. package/package.json +30 -7
  13. package/src/blob/blob.mjs +510 -0
  14. package/src/blob/broker.mjs +129 -0
  15. package/src/blob/http.mjs +334 -0
  16. package/src/blob/store.mjs +55 -0
  17. package/src/escrow/box.mjs +43 -0
  18. package/src/escrow/escrow-service.mjs +538 -0
  19. package/src/escrow/rails/evm-rail.mjs +427 -0
  20. package/src/escrow/rails/index.mjs +41 -0
  21. package/src/escrow/rails/zilligon-rail.mjs +73 -0
  22. package/src/escrow/terms.mjs +368 -0
  23. package/src/escrow/token-registry.mjs +231 -0
  24. package/src/escrow/verify.mjs +119 -0
  25. package/src/escrow/zilligon.mjs +266 -0
  26. package/src/identity/keychain.mjs +27 -10
  27. package/src/identity/tier.mjs +130 -0
  28. package/src/identity/trust.mjs +63 -0
  29. package/src/index.mjs +28 -2
  30. package/src/mailbox/mailbox.mjs +33 -2
  31. package/src/net/carrier-client.mjs +93 -14
  32. package/src/net/carrier-server.mjs +212 -15
  33. package/src/net/delegation.mjs +209 -0
  34. package/src/net/envelope.mjs +81 -0
  35. package/src/net/link-offer.mjs +107 -0
  36. package/src/net/refusals.mjs +126 -0
  37. package/src/net/seed-registry.mjs +467 -0
  38. package/src/net/seed-tier.mjs +185 -0
  39. package/src/node-check.mjs +32 -0
  40. package/src/observability.mjs +141 -0
  41. package/src/pillar.mjs +180 -17
  42. package/src/vda/core.mjs +290 -0
  43. package/src/vda/directory.mjs +87 -0
  44. package/src/vda/instruments.mjs +246 -0
  45. package/src/vda/shims.mjs +77 -0
package/src/index.mjs CHANGED
@@ -1,18 +1,44 @@
1
1
  /**
2
2
  * @uuaid/pillar — v0.2.0-alpha.0
3
3
  *
4
- * IAASO-3050 reference: every agent runs its own PDP pillar, discovers peers
4
+ * Every agent runs its own PDP pillar, discovers peers
5
5
  * via a Kademlia DHT, exchanges cryptographically signed messages over libp2p.
6
6
  *
7
7
  * Entry point. Not intended for import in browsers today — browser build
8
8
  * lives at @uuaid/pillar-browser (later).
9
9
  */
10
- export { Keychain } from "./identity/keychain.mjs";
10
+ export { Keychain, localIdFromKey } from "./identity/keychain.mjs";
11
11
  export { jcs } from "./identity/jcs.mjs";
12
12
  export { seal, open, decrypt, envelopeSha, keychainSeed, ENVELOPE_VERSION, E2E_ALG } from "./net/envelope.mjs";
13
+ export { DELEGATION_VERSION, verifyDelegationDoc, delegationCoversKind } from "./net/delegation.mjs";
13
14
  export { sealPayload, openPayload, edPubToX25519, edSeedToX25519 } from "./crypto/e2e.mjs";
14
15
  export { Mailbox } from "./mailbox/mailbox.mjs";
15
16
  export { Pillar } from "./pillar.mjs";
16
17
  export { createTransport, PILLAR_PROTOCOL, uuaidToDhtKey, peerIdFromEd25519Hex } from "./net/transport.mjs";
17
18
  export { CarrierClient } from "./net/carrier-client.mjs";
18
19
  export { CarrierServer } from "./net/carrier-server.mjs";
20
+ // Seeds: the ladder that turns sustained relay service into earned tier
21
+ // grants + priority delivery, and the registry that observes and publishes it.
22
+ export {
23
+ SEED_TIERS, SEED_TIER_NAMES, GRANT_TTL_DAYS, DEMOTE_AFTER_FAILURES,
24
+ seedTier, seedTierAtLeast, uptimeOf, evaluateSeedTier,
25
+ } from "./net/seed-tier.mjs";
26
+ export { SeedRegistry, checkSeedUrl, verifySeedInfo, PROBE_WINDOW } from "./net/seed-registry.mjs";
27
+ // Blob transport (client/core half): oversized payloads ride an ephemeral
28
+ // encrypted object store; only a <=4KiB signed ref travels in the envelope.
29
+ export {
30
+ BLOB_REF_V, BLOB_KIND, MAX_BLOB_REF_BYTES, MAX_INLINE_BODY_BYTES,
31
+ MAX_BLOB_OBJECT_BYTES, MAX_BLOB_PLAINTEXT_BYTES,
32
+ DEFAULT_BLOB_TTL_MS, DEFAULT_DIRECT_THRESHOLD_BYTES,
33
+ BlobError, BlobSizeError, BlobExpiryError, BlobNotFoundError, BlobUploadError,
34
+ BlobFetchError, BlobIntegrityError, BlobRefError, BlobBrokerError, BlobSenderKeyError,
35
+ blobAad, encryptBlob, decryptBlob, contentHash, mkObjectName,
36
+ signBlobRef, verifyBlobRefSig, blobRefMetadata, blobRefSecret, refSerializedBytes,
37
+ makeBlobRef, validateBlobRef, envelopeBodyBytes, preflightBodyBytes, chooseTransport,
38
+ prepareBlobSend, fetchAndDecryptBlob, prepareBrokerBlobSend, fetchBrokerBlob,
39
+ SELF_CERTIFIED_KEY_ID, isSelfCertifiedUuaid, selfCertifiedUuaidFor, senderSelfCertified,
40
+ } from "./blob/blob.mjs";
41
+ export { MemoryBlobStore, FsBlobStore } from "./blob/store.mjs";
42
+ export { PillarBlobHttpClient, PILLAR_BLOB_AUDIENCE } from "./blob/http.mjs";
43
+ // Refusals in agent language: carrier reason -> one-line fix, `<code>: <fix>`.
44
+ export { REFUSAL_FIXES, carrierFix, withCarrierFix, refusalLine, refusalFields } from "./net/refusals.mjs";
@@ -69,6 +69,12 @@ export class Mailbox {
69
69
  this.db.pragma("journal_mode = WAL");
70
70
  this.db.pragma("synchronous = NORMAL");
71
71
  this.db.exec(SCHEMA);
72
+ // v2 migration: quarantine marker (trust-policy gate). Nullable text =
73
+ // reason; null = normal mail. Chain hashes are unaffected (metadata only).
74
+ const cols = this.db.pragma("table_info(inbox)").map(c => c.name);
75
+ if (!cols.includes("quarantined")) {
76
+ this.db.exec("alter table inbox add column quarantined text");
77
+ }
72
78
  }
73
79
 
74
80
  close() { try { this.db.close(); } catch (_e) {} }
@@ -140,6 +146,21 @@ export class Mailbox {
140
146
  })();
141
147
  }
142
148
 
149
+ /** True if we sent this envelope (trust gate: is a receipt answering OUR send?). */
150
+ hasOutbound(envelopeId) {
151
+ return !!this.db.prepare("select 1 from outbox where envelope_id = ?").get(envelopeId);
152
+ }
153
+
154
+ /** Full outbox row by envelope id (VDA message-delivery instrument). */
155
+ getOutbound(envelopeId) {
156
+ return this.db.prepare("select * from outbox where envelope_id = ?").get(envelopeId) ?? null;
157
+ }
158
+
159
+ /** Full inbox row by envelope id (VDA message-delivery instrument). */
160
+ getInbound(envelopeId) {
161
+ return this.db.prepare("select * from inbox where envelope_id = ?").get(envelopeId) ?? null;
162
+ }
163
+
143
164
  markOutboundDelivered(envelopeId, ackSha256) {
144
165
  this.db.prepare("update outbox set status = 'delivered', delivered_at = ?, ack_sha256 = ? where envelope_id = ?")
145
166
  .run(new Date().toISOString(), ackSha256 ?? null, envelopeId);
@@ -155,9 +176,19 @@ export class Mailbox {
155
176
  return this.db.prepare("select * from inbox where seq > ? order by seq asc limit ?").all(seq, limit);
156
177
  }
157
178
 
158
- /** Envelopes never shown to the operator (read_at null). Oldest first. */
179
+ /** Envelopes never shown to the operator (read_at null, not quarantined). Oldest first. */
159
180
  inboxUnread(limit = 100) {
160
- return this.db.prepare("select * from inbox where read_at is null order by seq asc limit ?").all(limit);
181
+ return this.db.prepare("select * from inbox where read_at is null and quarantined is null order by seq asc limit ?").all(limit);
182
+ }
183
+
184
+ /** Trust-gated envelopes: stored as evidence, never surfaced to handlers. */
185
+ inboxQuarantined(limit = 100) {
186
+ return this.db.prepare("select * from inbox where quarantined is not null order by seq asc limit ?").all(limit);
187
+ }
188
+
189
+ markQuarantined(envelopeId, reason) {
190
+ this.db.prepare("update inbox set quarantined = ? where envelope_id = ?")
191
+ .run(String(reason).slice(0, 100), envelopeId);
161
192
  }
162
193
 
163
194
  markInboxRead(envelopeId) {
@@ -5,17 +5,51 @@
5
5
  * This is the universal transport: works from sandboxes, NATed laptops,
6
6
  * serverless, anywhere. No listening socket required.
7
7
  */
8
+ import { envelopeBodyBytes, preflightBodyBytes } from "../blob/blob.mjs";
9
+ import { seedTier } from "./seed-tier.mjs";
10
+ import { carrierFix, REFUSAL_FIXES } from "./refusals.mjs";
11
+
12
+ /**
13
+ * Rank carriers by how proven they are, best first. Unknown carriers rank as
14
+ * `probation` — the primary carrier from a directory has no seed tier and
15
+ * should not be assumed better than a seed that earned one, nor worse.
16
+ * Array.prototype.sort is stable, so a client given no tier information at
17
+ * all keeps its list in exactly the order it was constructed with.
18
+ */
19
+ function byTier(tiers) {
20
+ return (a, b) => seedTier(tiers[a] ?? "probation").listRank - seedTier(tiers[b] ?? "probation").listRank;
21
+ }
8
22
 
9
23
  export class CarrierClient {
10
24
  /**
11
25
  * @param {{keychain: import('../identity/keychain.mjs').Keychain, carriers: string[]}} opts
12
26
  * carriers: base URLs, e.g. ["https://pillar.uuaid.org"]
13
27
  */
14
- constructor({ keychain, carriers }) {
28
+ /**
29
+ * @param {object} opts
30
+ * carrierTiers optional { "<url>": "<seed tier>" } from a directory's
31
+ * seeds[]. Drives which carrier's receipt is authoritative
32
+ * and which one is polled first — see `deliver`.
33
+ */
34
+ constructor({ keychain, carriers, tierGrant = null, carrierTiers = {} }) {
15
35
  if (!keychain?._identity) throw new Error("CarrierClient needs a loaded keychain");
16
36
  if (!carriers?.length) throw new Error("CarrierClient needs at least one carrier URL");
17
37
  this.keychain = keychain;
18
- this.carriers = carriers.map(u => u.replace(/\/+$/, ""));
38
+ // Normalise tier keys the same way as carrier URLs so a trailing slash on
39
+ // one side does not silently drop a seed to `probation`.
40
+ this.carrierTiers = Object.fromEntries(
41
+ Object.entries(carrierTiers).map(([u, t]) => [String(u).replace(/\/+$/, ""), t]),
42
+ );
43
+ this.carriers = carriers.map(u => u.replace(/\/+$/, "")).sort(byTier(this.carrierTiers));
44
+ // Tier label for the local preflight: a signed grant raises it; default
45
+ // stays community (matches the carrier's unauthenticated tier).
46
+ this.tier = tierGrant?.tier ?? "community";
47
+ this.maxBodyBytes = Number.isInteger(tierGrant?.maxBodyBytes) ? tierGrant.maxBodyBytes : null;
48
+ // Optional signed tier grant (tier.mjs) — sent as a header on deliver so
49
+ // qualifying senders get raised budgets at carriers that trust the issuer.
50
+ this.tierGrantB64 = tierGrant
51
+ ? Buffer.from(JSON.stringify(tierGrant), "utf-8").toString("base64")
52
+ : null;
19
53
  this._stopPolling = false;
20
54
  }
21
55
 
@@ -30,25 +64,56 @@ export class CarrierClient {
30
64
  * envelope.id and re-fires no handlers.
31
65
  *
32
66
  * Resolves once every carrier has answered. Succeeds if at least one
33
- * accepted; throws if every carrier rejects. Returns the first-in-list
34
- * accepting receipt as the primary ({ carrier, seq, sha, duplicate }) plus
35
- * `all` with every carrier's outcome ({ carrier, ok, seq?, sha?,
36
- * duplicate?, error? }).
67
+ * accepted; throws if every carrier rejects. Returns the receipt from the
68
+ * most PROVEN accepting carrier as the primary ({ carrier, seq, sha,
69
+ * duplicate }) plus `all` with every carrier's outcome ({ carrier, ok,
70
+ * seq?, sha?, duplicate?, error?, status?, reason?, fix? }).
71
+ *
72
+ * When every carrier refuses, the thrown error carries `code` (the most
73
+ * proven carrier's `reason`, else carrier-unreachable / carrier-bad-response),
74
+ * `fix`, `status`, `carrier` and `refusals` (= `all`).
75
+ *
76
+ * Which receipt is primary matters because callers quote its seq back as a
77
+ * cursor and cite its carrier in receipts: it should name a seed that will
78
+ * still be there. With no tier information every carrier ranks equally and
79
+ * the first-in-list receipt wins, exactly as before.
37
80
  */
38
81
  async deliver(envelope, { timeoutMs = 10000 } = {}) {
82
+ // Preflight against the carrier limits BEFORE any network call: the
83
+ // tier inline limit first, then the 2 MiB direct hard cap — no tier
84
+ // grant can raise the hard cap. Typed failure, not a carrier 413.
85
+ const pf = preflightBodyBytes(envelopeBodyBytes(envelope), { tier: this.tier, maxBodyBytes: this.maxBodyBytes });
86
+ if (!pf.ok) {
87
+ const e = new Error(`carrier preflight: body ${pf.bytes}B exceeds ${pf.source} limit ${pf.limit}B — use the blob transport`);
88
+ e.code = pf.source === "direct-cap" ? "blob-too-large" : "over-tier-limit";
89
+ e.limit = pf.limit; e.source = pf.source;
90
+ e.fix = REFUSAL_FIXES["over-tier-limit"];
91
+ throw e;
92
+ }
39
93
  const attempt = async (base) => {
40
94
  try {
41
95
  const res = await fetch(`${base}/v1/envelopes`, {
42
96
  method: "POST",
43
- headers: { "content-type": "application/json" },
97
+ headers: {
98
+ "content-type": "application/json",
99
+ ...(this.tierGrantB64 ? { "x-pillar-tier-grant": this.tierGrantB64 } : {}),
100
+ },
44
101
  body: JSON.stringify(envelope),
45
102
  signal: AbortSignal.timeout(timeoutMs),
46
103
  });
47
- const body = await res.json();
104
+ const raw = await res.text();
105
+ let body = {};
106
+ try { body = raw ? JSON.parse(raw) : {}; } catch (_error) {}
48
107
  if (res.status === 202 && body.accepted) {
49
108
  return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate };
50
109
  }
51
- return { carrier: base, ok: false, error: `${res.status} ${body.reason ?? body.error ?? ""}` };
110
+ const reason = typeof body.reason === "string" && body.reason ? body.reason
111
+ : typeof body.error === "string" && body.error ? body.error : null;
112
+ return {
113
+ carrier: base, ok: false, status: res.status,
114
+ error: `${res.status} ${body.reason ?? body.error ?? (raw ? "non-json-response" : "empty-response")}`,
115
+ ...(reason ? { reason, fix: (typeof body.fix === "string" && body.fix) || carrierFix(reason) } : {}),
116
+ };
52
117
  } catch (e) {
53
118
  return { carrier: base, ok: false, error: e.message };
54
119
  }
@@ -57,9 +122,17 @@ export class CarrierClient {
57
122
  rs.map(r => r.status === "fulfilled" ? r.value : { carrier: "?", ok: false, error: String(r.reason) }));
58
123
  const accepted = all.filter(r => r.ok);
59
124
  if (!accepted.length) {
60
- throw new Error(`carrier deliver failed on all carriers: ${all.map(r => `${r.carrier}: ${r.error}`).join("; ")}`);
125
+ const e = new Error(`carrier deliver failed on all carriers: ${all.map(r => `${r.carrier}: ${r.error}`).join("; ")}`);
126
+ // `all` is in rank order, so this is the most proven carrier's answer.
127
+ const primary = all.find(r => r.reason) ?? all.find(r => r.status) ?? null;
128
+ e.code = primary?.reason ?? (primary ? "carrier-bad-response" : "carrier-unreachable");
129
+ e.fix = primary?.fix ?? carrierFix(e.code);
130
+ if (primary) { e.status = primary.status; e.carrier = primary.carrier; }
131
+ e.refusals = all;
132
+ throw e;
61
133
  }
62
- const { carrier, seq, sha, duplicate } = accepted[0];
134
+ const ranked = accepted.slice().sort((a, b) => byTier(this.carrierTiers)(a.carrier, b.carrier));
135
+ const { carrier, seq, sha, duplicate } = ranked[0];
63
136
  return { carrier, seq, sha, duplicate, all };
64
137
  }
65
138
 
@@ -81,7 +154,11 @@ export class CarrierClient {
81
154
  });
82
155
  if (res.status !== 200) {
83
156
  const body = await res.json().catch(() => ({}));
84
- throw new Error(`inbox fetch ${base}: ${res.status} ${body.error ?? ""}`);
157
+ const e = new Error(`inbox fetch ${base}: ${res.status} ${body.error ?? ""}`);
158
+ e.code = typeof body.error === "string" && body.error ? body.error : "carrier-bad-response";
159
+ e.fix = (typeof body.fix === "string" && body.fix) || carrierFix(e.code);
160
+ e.status = res.status; e.carrier = base;
161
+ throw e;
85
162
  }
86
163
  return res.json(); // { envelopes: [{seq, envelope}], now }
87
164
  }
@@ -89,6 +166,8 @@ export class CarrierClient {
89
166
  /**
90
167
  * Long-poll every carrier in parallel forever, invoking onEnvelope for each
91
168
  * new envelope. Tracks per-carrier cursor. Returns a stop() function.
169
+ * onError(error, { carrier }) — the carrier context is passed so callers
170
+ * can attribute failures per carrier in metrics.
92
171
  */
93
172
  startPolling({ onEnvelope, cursors = {}, waitS = 25, onError }) {
94
173
  this._stopPolling = false;
@@ -100,10 +179,10 @@ export class CarrierClient {
100
179
  for (const { seq, envelope } of r.envelopes) {
101
180
  state.cursors[base] = Math.max(state.cursors[base] ?? 0, seq);
102
181
  try { await onEnvelope(envelope, { carrier: base, seq }); }
103
- catch (e) { onError?.(new Error(`onEnvelope threw: ${e.message}`)); }
182
+ catch (e) { onError?.(new Error(`onEnvelope threw: ${e.message}`), { carrier: base }); }
104
183
  }
105
184
  } catch (e) {
106
- onError?.(e);
185
+ onError?.(e, { carrier: base });
107
186
  // Back off on error so a dead carrier doesn't spin the CPU.
108
187
  await new Promise(r2 => setTimeout(r2, 2000));
109
188
  }
@@ -18,14 +18,33 @@
18
18
  * x-pillar-pubkey: <ed25519 pubkey hex>
19
19
  * x-pillar-ts: <unix ms> (rejected if clock skew > 5 min)
20
20
  * x-pillar-sig: hex(sign("GET /v1/inbox/<uuaid>?since=<seq>" + "\n" + ts))
21
+ * x-pillar-delegation: (optional) JSON delegation document letting a
22
+ * delegate key read <uuaid>'s inbox; must be signed by <uuaid> and
23
+ * cover kind "inbox" (or "*")
21
24
  * - carrier derives UUAID from pubkey; must equal <uuaid> (self-authenticating,
22
- * zero registration)
25
+ * zero registration) unless a valid inbox delegation is presented
23
26
  * - long-polls up to `wait` seconds (max 30)
24
27
  * -> 200 { envelopes: [{seq, envelope}], now }
25
28
  *
26
29
  * GET /v1/health
27
30
  * -> 200 { ok, uptime, stored }
28
31
  *
32
+ * GET /v1/seed-info
33
+ * -> 200 { v, url, uuaid, publicKey, startedAt, firstSeen, stats, now, sig }
34
+ * Present only when the server was constructed with a keychain + publicUrl
35
+ * (i.e. it is running as a public seed, not a private relay). The
36
+ * signature binds this URL to the operator's key, which is what the seed
37
+ * registry checks at enrollment before listing the URL as a seed.
38
+ *
39
+ * PRIORITY ADMISSION
40
+ *
41
+ * Load shedding is not uniform. A sender presenting a valid tier grant with
42
+ * `priority: true` — issued to durable seed operators by the seed registry —
43
+ * is admitted from reserved headroom that community traffic cannot touch:
44
+ * the last slice of the store, its own per-IP budget, and reserved long-poll
45
+ * slots. Under no load the two lanes are indistinguishable; the privilege
46
+ * only becomes visible at the moment the carrier starts saying no.
47
+ *
29
48
  * Durability: SQLite (WAL). Envelopes are retained until TTL (default 14 days).
30
49
  * The carrier CANNOT read payloads it relays beyond what the envelope exposes,
31
50
  * and CANNOT forge envelopes (no keys). It can only: deliver, delay, or drop —
@@ -36,10 +55,16 @@ import { createHash } from "node:crypto";
36
55
  import Database from "better-sqlite3";
37
56
  import { open as openEnvelope } from "./envelope.mjs";
38
57
  import { Keychain } from "../identity/keychain.mjs";
58
+ import { jcs } from "../identity/jcs.mjs";
59
+ import { verifyTierGrant } from "../identity/tier.mjs";
60
+ import { verifyDelegationDoc, delegationCoversKind } from "./delegation.mjs";
61
+ import { withCarrierFix } from "./refusals.mjs"; // additive `fix` on refusals; reason/error/status unchanged
39
62
 
40
63
  const MAX_WAIT_S = 30;
41
- const MAX_BODY = 512 * 1024; // 512 KiB per envelope
64
+ const MAX_BODY = 512 * 1024; // 512 KiB per envelope (community tier)
65
+ const MAX_BODY_STREAM = 2 * 1024 * 1024 + 8192; // absolute read cap: max tier body + grant/header slack
42
66
  const MAX_SKEW_MS = 5 * 60 * 1000;
67
+ const MAX_DELEGATION_HEADER = 8192; // x-pillar-delegation size cap (bytes)
43
68
  // Hardening (RedTeam F2/F3):
44
69
  const MAX_ENVELOPE_AGE_MS = 24 * 3600_000; // F3: ingest freshness — reject skewed createdAt
45
70
  const RATE_WINDOW_MS = 60_000; // F2: sliding window for all rate limits
@@ -49,9 +74,16 @@ const MAX_INVALID_PER_MIN = 60; // F2: per client-IP budget for rejec
49
74
  const MAX_CONCURRENT_WAITS = 64; // F2: inbox long-poll concurrency cap
50
75
  const MAX_DB_BYTES = 256 * 1024 * 1024; // F2: SQLite size backpressure (503 when full)
51
76
  const RATE_MAP_HARD_CAP = 10_000; // F2: bound memory under key spray
77
+ // Priority lanes. Each is a RESERVE carved out of an existing cap, never an
78
+ // increase of it: community traffic sheds earlier so that priority traffic
79
+ // still fits inside the same absolute ceiling. A carrier with no trusted tier
80
+ // issuers configured never resolves a priority grant, so these are inert.
81
+ const COMMUNITY_DB_BYTES = Math.floor(MAX_DB_BYTES * 0.85); // last 15% of the store is reserved
82
+ const COMMUNITY_CONCURRENT_WAITS = 48; // of MAX_CONCURRENT_WAITS
83
+ const MAX_PRIORITY_GOOD_PER_MIN = 600; // priority senders bill to their own per-IP bucket
52
84
 
53
85
  export class CarrierServer {
54
- constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory } = {}) {
86
+ constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory, trustedTierIssuers = [], keychain = null, publicUrl = null } = {}) {
55
87
  this.db = new Database(dbPath);
56
88
  this.db.pragma("journal_mode = WAL");
57
89
  this.db.exec(`
@@ -65,7 +97,16 @@ export class CarrierServer {
65
97
  created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
66
98
  );
67
99
  create index if not exists idx_envelopes_recipient on envelopes(recipient, seq);
100
+ -- Lifetime service counters. Kept OUTSIDE the envelopes table because
101
+ -- vacuum() deletes envelopes at TTL, and a seed's service history must
102
+ -- survive its own garbage collection.
103
+ create table if not exists seed_stats (
104
+ k text primary key,
105
+ v text not null
106
+ );
68
107
  `);
108
+ this.db.prepare("insert or ignore into seed_stats (k, v) values ('first_seen', ?)")
109
+ .run(new Date().toISOString());
69
110
  this.ttlDays = ttlDays;
70
111
  this.cryptoInventory = cryptoInventory ?? { ed25519: "active" };
71
112
  this.startedAt = Date.now();
@@ -73,6 +114,94 @@ export class CarrierServer {
73
114
  this.rateMap = new Map(); // F2: "<kind>:<key>" -> [ms timestamps]
74
115
  this.activeWaits = 0; // F2: long-polls currently sleeping
75
116
  this._closed = false;
117
+ // Tier grants: issuer pubkeys (hex) this carrier honors. Empty = every
118
+ // sender gets community-tier budgets (pre-tier behavior, default).
119
+ this.trustedTierIssuers = trustedTierIssuers;
120
+ this._grantCache = new Map(); // sender uuaid -> { grant, cachedAt }
121
+ // Seed identity. Optional: a private relay runs without one and simply
122
+ // does not expose /v1/seed-info.
123
+ this.keychain = keychain;
124
+ this.publicUrl = publicUrl ? String(publicUrl).replace(/\/+$/, "") : null;
125
+ }
126
+
127
+ /** Read a lifetime counter. */
128
+ _stat(k, fallback = 0) {
129
+ const row = this.db.prepare("select v from seed_stats where k = ?").get(k);
130
+ if (row === undefined) return fallback;
131
+ const n = Number(row.v);
132
+ return Number.isFinite(n) ? n : row.v;
133
+ }
134
+
135
+ /** Bump a lifetime counter by n (creating it at 0 first). */
136
+ _bump(k, n = 1) {
137
+ this.db.prepare(
138
+ "insert into seed_stats (k, v) values (?, ?) on conflict(k) do update set v = cast(cast(v as integer) + ? as text)"
139
+ ).run(k, String(n), n);
140
+ }
141
+
142
+ /**
143
+ * The signed service claim served at /v1/seed-info.
144
+ *
145
+ * These counters are the seed's OWN account of its work and are therefore
146
+ * not evidence: an operator can inflate them freely. The registry records
147
+ * them for display and promotes strictly on what it observes itself (see
148
+ * net/seed-tier.mjs). What the signature does prove is the thing enrollment
149
+ * actually needs — that whoever controls this URL also controls this key.
150
+ */
151
+ seedInfo() {
152
+ if (!this.keychain?._identity || !this.publicUrl) return null;
153
+ const doc = {
154
+ v: 1,
155
+ url: this.publicUrl,
156
+ uuaid: this.keychain._identity.uuaid,
157
+ publicKey: this.keychain._identity.publicKeyHex,
158
+ startedAt: new Date(this.startedAt).toISOString(),
159
+ firstSeen: this.db.prepare("select v from seed_stats where k = 'first_seen'").get()?.v ?? null,
160
+ stats: {
161
+ relayed: this._stat("relayed"),
162
+ served: this._stat("served"),
163
+ stored: this.db.prepare("select count(*) as n from envelopes").get().n,
164
+ },
165
+ now: new Date().toISOString(),
166
+ };
167
+ doc.sig = this.keychain.sign(Buffer.from(jcs(doc), "utf-8")).toString("hex");
168
+ return doc;
169
+ }
170
+
171
+ /**
172
+ * Tier resolution for a POST: verify an optional x-pillar-tier-grant header
173
+ * (base64 JSON, issued by makeTierGrant). A valid grant FOR THE CLAIMED
174
+ * SENDER raises that sender's rate/size budget. Invalid/foreign grants are
175
+ * ignored (fall back to community) — never a hard reject, so a bad grant
176
+ * cannot break plain delivery.
177
+ */
178
+ _resolveTier(req, sender) {
179
+ const fallback = { tier: "community", envelopesPerMin: MAX_ENVELOPES_PER_MIN, maxBodyBytes: MAX_BODY, priority: false };
180
+ if (!this.trustedTierIssuers.length) return fallback;
181
+ const hdr = req?.headers?.["x-pillar-tier-grant"];
182
+ if (!hdr) return fallback;
183
+ let grant;
184
+ try { grant = JSON.parse(Buffer.from(String(hdr), "base64").toString("utf-8")); }
185
+ catch (_e) { return fallback; }
186
+ if (grant?.uuaid !== sender) return fallback; // grant must name the claimed sender
187
+ const cached = this._grantCache.get(sender);
188
+ if (cached && cached.sig === grant.sig && Date.now() - cached.at < 60_000) return cached.resolved;
189
+ const v = verifyTierGrant(grant, this.trustedTierIssuers);
190
+ const resolved = v.ok
191
+ ? {
192
+ tier: grant.tier,
193
+ envelopesPerMin: grant.envelopesPerMin,
194
+ maxBodyBytes: grant.maxBodyBytes,
195
+ // Only a grant the issuer actually signed as priority opens the
196
+ // reserved lane. verifyTierGrant has already rejected any grant
197
+ // whose `priority` is present but not exactly true.
198
+ priority: grant.priority === true,
199
+ seedTier: grant.seedTier ?? null,
200
+ }
201
+ : fallback;
202
+ if (this._grantCache.size > 4096) this._grantCache.clear(); // bounded
203
+ this._grantCache.set(sender, { sig: grant.sig, at: Date.now(), resolved });
204
+ return resolved;
76
205
  }
77
206
 
78
207
  /** F2: best-effort client identity, nginx-aware (XFF set by proxy). */
@@ -117,7 +246,7 @@ export class CarrierServer {
117
246
  // No-op on a dead socket: close() destroys connections, and a long-poll
118
247
  // handler whose timer fires afterwards must not throw on write.
119
248
  if (res.destroyed || res.writableEnded) return;
120
- const body = JSON.stringify(obj);
249
+ const body = JSON.stringify(withCarrierFix(code, obj));
121
250
  res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
122
251
  res.end(body);
123
252
  }
@@ -127,7 +256,7 @@ export class CarrierServer {
127
256
  let size = 0; const chunks = [];
128
257
  req.on("data", (c) => {
129
258
  size += c.length;
130
- if (size > MAX_BODY) { reject(new Error("body too large")); req.destroy(); return; }
259
+ if (size > MAX_BODY_STREAM) { reject(new Error("body too large")); req.destroy(); return; }
131
260
  chunks.push(c);
132
261
  });
133
262
  req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
@@ -135,25 +264,35 @@ export class CarrierServer {
135
264
  });
136
265
  }
137
266
 
138
- _handlePost(envelope, ip = "unknown") {
267
+ _handlePost(envelope, ip = "unknown", req = null, rawBytes = 0) {
139
268
  const verdict = openEnvelope(envelope, { cryptoInventory: this.cryptoInventory });
140
269
  if (!verdict.ok) {
141
270
  // F2: cap rejected sends per client (flood the verifier → 429).
142
271
  if (!this._rateAllow(`bad:${ip}`, MAX_INVALID_PER_MIN)) {
143
272
  return { code: 429, body: { accepted: false, reason: "rate-limited" } };
144
273
  }
145
- return { code: 400, body: { accepted: false, reason: verdict.reason } };
274
+ return { code: verdict.code ?? 400, body: { accepted: false, reason: verdict.reason } };
146
275
  }
147
276
  if (typeof envelope.recipient !== "string" || !envelope.recipient.startsWith("uuaid:")) {
148
277
  return { code: 400, body: { accepted: false, reason: "bad-recipient" } };
149
278
  }
279
+ // Tier: a verified grant raises this sender's budgets; default community.
280
+ const tier = this._resolveTier(req, envelope.sender);
281
+ if (rawBytes > tier.maxBodyBytes) {
282
+ return { code: 413, body: { accepted: false, reason: "body-too-large", maxBodyBytes: tier.maxBodyBytes, tier: tier.tier } };
283
+ }
150
284
  // F2: per-sender ingest budget — a sender uuaid cannot flood its carrier.
151
- if (!this._rateAllow(`s:${envelope.sender}`, MAX_ENVELOPES_PER_MIN)) {
285
+ if (!this._rateAllow(`s:${envelope.sender}`, tier.envelopesPerMin)) {
152
286
  return { code: 429, body: { accepted: false, reason: "rate-limited" } };
153
287
  }
154
288
  // F2: per-client budget on ACCEPTED sends — closes the unlimited
155
289
  // fresh-keypair spray (each new key gets a fresh sender budget).
156
- if (!this._rateAllow(`g:${ip}`, MAX_GOOD_PER_MIN)) {
290
+ // Priority senders bill to a SEPARATE bucket: the point of the lane is
291
+ // that a noisy neighbour behind the same egress IP (or NAT, or CI
292
+ // runner) cannot consume the budget a durable seed operator earned.
293
+ const ipKey = tier.priority ? `p:${ip}` : `g:${ip}`;
294
+ const ipLimit = tier.priority ? MAX_PRIORITY_GOOD_PER_MIN : MAX_GOOD_PER_MIN;
295
+ if (!this._rateAllow(ipKey, ipLimit)) {
157
296
  return { code: 429, body: { accepted: false, reason: "rate-limited" } };
158
297
  }
159
298
  // F3: freshness — reject envelopes whose createdAt is missing or skewed
@@ -163,10 +302,13 @@ export class CarrierServer {
163
302
  return { code: 400, body: { accepted: false, reason: "stale-envelope" } };
164
303
  }
165
304
  // F2: backpressure — refuse ingest when the store is near-full instead of
166
- // failing at disk-full mid-write.
305
+ // failing at disk-full mid-write. Community traffic sheds at 85% so the
306
+ // remaining slice stays available to priority senders; the absolute cap
307
+ // is identical for both, so the reserve cannot push the store past it.
167
308
  const pages = this.db.pragma("page_count", { simple: true });
168
309
  const pageBytes = this.db.pragma("page_size", { simple: true });
169
- if (pages * pageBytes > MAX_DB_BYTES) {
310
+ const storeCap = tier.priority ? MAX_DB_BYTES : COMMUNITY_DB_BYTES;
311
+ if (pages * pageBytes > storeCap) {
170
312
  return { code: 503, body: { accepted: false, reason: "carrier-full" } };
171
313
  }
172
314
  const sha = createHash("sha256").update(JSON.stringify(envelope)).digest("hex");
@@ -174,6 +316,7 @@ export class CarrierServer {
174
316
  const r = this.db.prepare(
175
317
  "insert into envelopes (envelope_id, recipient, sender, sha, body) values (?,?,?,?,?)"
176
318
  ).run(envelope.id, envelope.recipient, envelope.sender, sha, JSON.stringify(envelope));
319
+ this._bump("relayed");
177
320
  return { code: 202, body: { accepted: true, seq: Number(r.lastInsertRowid), sha } };
178
321
  } catch (e) {
179
322
  if (/UNIQUE constraint/.test(e.message)) {
@@ -198,7 +341,17 @@ export class CarrierServer {
198
341
  // Self-authenticating: UUAID must derive from the presented pubkey.
199
342
  const localId = Keychain._localIdFromKey(Buffer.from(pubkey, "hex"));
200
343
  const derived = `uuaid:foundation:agent:${localId}`;
201
- if (derived !== uuaid) return { ok: false, reason: "uuaid-pubkey-mismatch" };
344
+ if (derived !== uuaid) {
345
+ // A delegate key may read the principal's inbox only by presenting the
346
+ // principal-signed delegation document that names it. Without the
347
+ // header this stays the legacy mismatch.
348
+ const raw = req.headers["x-pillar-delegation"];
349
+ if (raw === undefined) return { ok: false, reason: "uuaid-pubkey-mismatch" };
350
+ const delegated = this._verifyInboxDelegation(raw, uuaid, pubkey);
351
+ if (!delegated.ok) return delegated;
352
+ }
353
+ // Either the principal's own key or an authorized delegate key: in both
354
+ // cases it must have signed THIS request.
202
355
  const message = Buffer.from(`${sincePath}\n${ts}`, "utf-8");
203
356
  let verified = false;
204
357
  try { verified = Keychain.verifyDetached(pubkey, message, Buffer.from(sig, "hex")); }
@@ -207,6 +360,37 @@ export class CarrierServer {
207
360
  return { ok: true };
208
361
  }
209
362
 
363
+ /**
364
+ * Authorize a delegate to poll `uuaid`'s inbox from an `x-pillar-delegation`
365
+ * header. Fails closed on every error and never logs the document.
366
+ *
367
+ * Reasons (all surface as HTTP 401):
368
+ * delegation-too-large header exceeds MAX_DELEGATION_HEADER
369
+ * delegation-unparseable not a string / not JSON
370
+ * delegation-invalid:<reason> verifyDelegationDoc verdict (expired,
371
+ * bad-signature, *-uuaid-binding, ...)
372
+ * delegation-principal-mismatch doc principal is not the path uuaid
373
+ * delegation-delegate-key-mismatch doc names a different delegate key
374
+ * delegation-inbox-denied kinds cover neither "inbox" nor "*"
375
+ */
376
+ _verifyInboxDelegation(raw, uuaid, pubkey) {
377
+ if (typeof raw !== "string") return { ok: false, reason: "delegation-unparseable" };
378
+ if (Buffer.byteLength(raw, "utf-8") > MAX_DELEGATION_HEADER) {
379
+ return { ok: false, reason: "delegation-too-large" };
380
+ }
381
+ let doc;
382
+ try { doc = JSON.parse(raw); }
383
+ catch (_e) { return { ok: false, reason: "delegation-unparseable" }; }
384
+ const verdict = verifyDelegationDoc(doc);
385
+ if (!verdict.ok) return { ok: false, reason: `delegation-invalid:${verdict.reason}` };
386
+ if (doc.principal !== uuaid) return { ok: false, reason: "delegation-principal-mismatch" };
387
+ if (doc.delegatePublicKey.toLowerCase() !== pubkey.toLowerCase()) {
388
+ return { ok: false, reason: "delegation-delegate-key-mismatch" };
389
+ }
390
+ if (!delegationCoversKind(doc, "inbox")) return { ok: false, reason: "delegation-inbox-denied" };
391
+ return { ok: true };
392
+ }
393
+
210
394
  async _handleInbox(req, res, uuaid, url) {
211
395
  const since = Number(url.searchParams.get("since") ?? 0);
212
396
  const wait = Math.min(Number(url.searchParams.get("wait") ?? 0), MAX_WAIT_S);
@@ -215,8 +399,13 @@ export class CarrierServer {
215
399
  if (!auth.ok) return this._json(res, 401, { error: auth.reason });
216
400
 
217
401
  // F2: cap concurrent long-polls so sleeping connections cannot pin the
218
- // event loop / fd table.
219
- if (wait > 0 && this.activeWaits >= MAX_CONCURRENT_WAITS) {
402
+ // event loop / fd table. The grant is resolved against the PRINCIPAL
403
+ // whose inbox this is (the path uuaid), not the key that signed — a
404
+ // delegate polling for a priority principal inherits the reserved slots,
405
+ // which is the same authority it already has to read the mail.
406
+ const tier = this._resolveTier(req, uuaid);
407
+ const waitCap = tier.priority ? MAX_CONCURRENT_WAITS : COMMUNITY_CONCURRENT_WAITS;
408
+ if (wait > 0 && this.activeWaits >= waitCap) {
220
409
  return this._json(res, 503, { error: "busy" });
221
410
  }
222
411
  if (wait > 0) this.activeWaits++;
@@ -237,6 +426,7 @@ export class CarrierServer {
237
426
  await new Promise(r => setTimeout(r, 400));
238
427
  rows = fetchRows();
239
428
  }
429
+ if (rows.length) this._bump("served", rows.length);
240
430
  this._json(res, 200, {
241
431
  envelopes: rows.map(r => ({ seq: r.seq, envelope: JSON.parse(r.body) })),
242
432
  now: Date.now(),
@@ -255,7 +445,7 @@ export class CarrierServer {
255
445
  let envelope;
256
446
  try { envelope = JSON.parse(raw); }
257
447
  catch { return this._json(res, 400, { accepted: false, reason: "bad-json" }); }
258
- const { code, body } = this._handlePost(envelope, this._clientIp(req));
448
+ const { code, body } = this._handlePost(envelope, this._clientIp(req), req, Buffer.byteLength(raw));
259
449
  return this._json(res, code, body);
260
450
  }
261
451
  const inboxMatch = /^\/v1\/inbox\/([^/]+)$/.exec(url.pathname);
@@ -264,6 +454,13 @@ export class CarrierServer {
264
454
  if (!uuaid.startsWith("uuaid:")) return this._json(res, 404, { error: "not-found" });
265
455
  return await this._handleInbox(req, res, uuaid, url);
266
456
  }
457
+ if (req.method === "GET" && url.pathname === "/v1/seed-info") {
458
+ const info = this.seedInfo();
459
+ // A relay with no seed identity is not a seed and must not look
460
+ // like one to the registry.
461
+ if (!info) return this._json(res, 404, { error: "not-a-seed" });
462
+ return this._json(res, 200, info);
463
+ }
267
464
  if (req.method === "GET" && url.pathname === "/v1/health") {
268
465
  const stored = this.db.prepare("select count(*) as n from envelopes").get().n;
269
466
  return this._json(res, 200, { ok: true, uptimeMs: Date.now() - this.startedAt, stored });