@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
@@ -0,0 +1,467 @@
1
+ /**
2
+ * Seed registry — permissionless enrollment and earned promotion for seeds.
3
+ *
4
+ * THE PROBLEM THIS EXISTS TO FIX
5
+ *
6
+ * `pillar-seed` has always been able to run a carrier. What it could not do
7
+ * was JOIN: the seed list agents bootstrap from was a static directory.json
8
+ * edited by hand on one operator's host. A stranger who stood up a perfect
9
+ * seed had no way in and no reason to bother, so the mesh had one seed — the
10
+ * one belonging to whoever owned the file. A store-and-forward network whose
11
+ * survivability argument is "many independent seeds" cannot be run that way.
12
+ *
13
+ * The registry replaces the file with a process:
14
+ *
15
+ * 1. ENROLL the operator announces a URL. The registry fetches
16
+ * <url>/v1/seed-info and checks the signature over it. That
17
+ * proves the announcer controls both the URL and the key —
18
+ * no account, no review, no operator in the loop.
19
+ * 2. OBSERVE the registry probes the seed on a schedule: liveness, and
20
+ * a real envelope round-tripped through it. Only what the
21
+ * registry measures itself can promote a seed.
22
+ * 3. PROMOTE thresholds crossed (net/seed-tier.mjs) raise the rung and
23
+ * issue the operator a signed tier grant — the "extra".
24
+ * 4. PUBLISH directory.json is rendered from this table, ordered by
25
+ * rung, so agents bootstrap onto proven seeds first.
26
+ *
27
+ * WHAT A SEED OPERATOR IS ACTUALLY BUYING
28
+ *
29
+ * Grants are issued to the operator's own identity, so the reward for
30
+ * carrying other agents' traffic is that the operator's OWN traffic gets
31
+ * raised budgets and a reserved admission lane at every carrier trusting
32
+ * this issuer. The incentive points the right way: the cheapest route to
33
+ * priority is to become infrastructure for everyone else.
34
+ */
35
+ import { createHash } from "node:crypto";
36
+ import { isIP } from "node:net";
37
+ import Database from "better-sqlite3";
38
+ import { jcs } from "../identity/jcs.mjs";
39
+ import { Keychain } from "../identity/keychain.mjs";
40
+ import { makeTierGrant } from "../identity/tier.mjs";
41
+ import { seal } from "./envelope.mjs";
42
+ import { evaluateSeedTier, seedTier, GRANT_TTL_DAYS, DEMOTE_AFTER_FAILURES } from "./seed-tier.mjs";
43
+
44
+ const PROBE_TIMEOUT_MS = 10_000;
45
+ const ROUND_TRIP_WAIT_S = 5;
46
+ /** Keep the observation window bounded so uptime reflects recent behaviour. */
47
+ export const PROBE_WINDOW = 1000;
48
+
49
+ /**
50
+ * Hostnames that must never be probed from a public registry. Enrollment
51
+ * takes a URL from an unauthenticated stranger and makes the registry fetch
52
+ * it, which is a server-side request forgery primitive unless it is fenced.
53
+ * Tests pass allowPrivateUrls to reach a loopback seed.
54
+ */
55
+ function isPrivateHost(host) {
56
+ const h = host.toLowerCase();
57
+ if (h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".internal")) return true;
58
+ if (isIP(h) === 4) {
59
+ const [a, b] = h.split(".").map(Number);
60
+ if (a === 10 || a === 127 || a === 0) return true;
61
+ if (a === 172 && b >= 16 && b <= 31) return true;
62
+ if (a === 192 && b === 168) return true;
63
+ if (a === 169 && b === 254) return true; // link-local / cloud metadata
64
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
65
+ return false;
66
+ }
67
+ if (isIP(h) === 6) {
68
+ return h === "::1" || h === "::" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80");
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /** Normalise + fence a candidate seed URL. Returns { ok, url } or { ok:false, reason }. */
74
+ export function checkSeedUrl(raw, { allowPrivateUrls = false } = {}) {
75
+ let u;
76
+ try { u = new URL(String(raw)); } catch (_e) { return { ok: false, reason: "bad-url" }; }
77
+ if (u.protocol !== "https:" && !(allowPrivateUrls && u.protocol === "http:")) {
78
+ return { ok: false, reason: "https-required" };
79
+ }
80
+ if (u.username || u.password) return { ok: false, reason: "credentials-in-url" };
81
+ if (u.search || u.hash) return { ok: false, reason: "query-in-url" };
82
+ if (!allowPrivateUrls && isPrivateHost(u.hostname)) return { ok: false, reason: "private-address" };
83
+ const url = `${u.protocol}//${u.host}${u.pathname}`.replace(/\/+$/, "");
84
+ return { ok: true, url };
85
+ }
86
+
87
+ /** Verify a /v1/seed-info document: signature, key/uuaid binding, URL match. */
88
+ export function verifySeedInfo(doc, expectedUrl) {
89
+ if (doc?.v !== 1) return { ok: false, reason: "bad-version" };
90
+ if (typeof doc.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(doc.publicKey)) {
91
+ return { ok: false, reason: "bad-public-key" };
92
+ }
93
+ if (typeof doc.uuaid !== "string" || !doc.uuaid.startsWith("uuaid:")) return { ok: false, reason: "bad-uuaid" };
94
+ // The seed's UUAID must be derived from the key that signed — the same
95
+ // self-authenticating rule the carrier applies to inbox reads.
96
+ const localId = Keychain._localIdFromKey(Buffer.from(doc.publicKey, "hex"));
97
+ if (doc.uuaid.split(":")[3] !== localId) return { ok: false, reason: "uuaid-key-mismatch" };
98
+ if (typeof doc.sig !== "string" || !/^[0-9a-f]{128}$/i.test(doc.sig)) return { ok: false, reason: "bad-sig-encoding" };
99
+ // The URL is inside the signed body, so a third party cannot enroll a URL
100
+ // it does not operate by replaying someone else's seed-info.
101
+ if (expectedUrl && doc.url !== expectedUrl) return { ok: false, reason: "url-mismatch" };
102
+ const { sig, ...rest } = doc;
103
+ let verified = false;
104
+ try { verified = Keychain.verifyDetached(doc.publicKey, Buffer.from(jcs(rest), "utf-8"), Buffer.from(sig, "hex")); }
105
+ catch (_e) { return { ok: false, reason: "verify-threw" }; }
106
+ if (!verified) return { ok: false, reason: "bad-signature" };
107
+ return { ok: true, doc };
108
+ }
109
+
110
+ export class SeedRegistry {
111
+ /**
112
+ * @param {object} opts
113
+ * keychain loaded Keychain — signs tier grants AND owns the probe
114
+ * identity whose inbox the round-trip test uses.
115
+ * carrierUrl the primary carrier published as directory.carrier
116
+ * dbPath SQLite path
117
+ * protocol directory.protocol string
118
+ * fetchImpl injectable fetch (tests)
119
+ * allowPrivateUrls permit loopback seeds (tests / local networks)
120
+ */
121
+ constructor({ keychain, carrierUrl, dbPath = "seed-registry.db", protocol = "uacp/1.0-draft", fetchImpl = globalThis.fetch, allowPrivateUrls = false } = {}) {
122
+ if (!keychain?._identity) throw new Error("SeedRegistry needs a loaded keychain");
123
+ this.keychain = keychain;
124
+ this.carrierUrl = carrierUrl ? String(carrierUrl).replace(/\/+$/, "") : null;
125
+ this.protocol = protocol;
126
+ this.fetch = fetchImpl;
127
+ this.allowPrivateUrls = allowPrivateUrls;
128
+ this.db = new Database(dbPath);
129
+ this.db.pragma("journal_mode = WAL");
130
+ this.db.exec(`
131
+ create table if not exists seeds (
132
+ uuaid text primary key,
133
+ url text not null unique,
134
+ public_key text not null,
135
+ operator text,
136
+ region text,
137
+ enrolled_at text not null,
138
+ tier text not null default 'probation',
139
+ probes_total integer not null default 0,
140
+ probes_passed integer not null default 0,
141
+ rt_total integer not null default 0,
142
+ rt_passed integer not null default 0,
143
+ consecutive_failures integer not null default 0,
144
+ last_probe_at text,
145
+ last_pass_at text,
146
+ claimed text,
147
+ promoted_at text,
148
+ -- The last PROBE_WINDOW outcomes, oldest first, as '1'/'0'. Storing
149
+ -- the outcomes rather than a running ratio is what makes the window
150
+ -- actually slide: probes_total/probes_passed are derived from it.
151
+ probe_log text not null default ''
152
+ );
153
+ create index if not exists idx_seeds_tier on seeds(tier);
154
+ `);
155
+ // Idempotent migration for registries created before probe_log existed.
156
+ const cols = this.db.pragma("table_info(seeds)").map(c => c.name);
157
+ if (!cols.includes("probe_log")) {
158
+ this.db.exec("alter table seeds add column probe_log text not null default ''");
159
+ }
160
+ }
161
+
162
+ close() { if (this.db.open) this.db.close(); }
163
+
164
+ get uuaid() { return this.keychain._identity.uuaid; }
165
+ get issuerKey() { return this.keychain._identity.publicKeyHex; }
166
+
167
+ /** Row -> the record shape evaluateSeedTier expects. */
168
+ static _record(row) {
169
+ return {
170
+ enrolledAt: row.enrolled_at,
171
+ probes: { total: row.probes_total, passed: row.probes_passed },
172
+ roundTrips: { total: row.rt_total, passed: row.rt_passed },
173
+ consecutiveFailures: row.consecutive_failures,
174
+ };
175
+ }
176
+
177
+ get(uuaid) { return this.db.prepare("select * from seeds where uuaid = ?").get(uuaid); }
178
+ all() { return this.db.prepare("select * from seeds").all(); }
179
+
180
+ /**
181
+ * Enroll a seed by announced URL. Permissionless: the only gate is that
182
+ * <url>/v1/seed-info answers with a document signed by the key whose UUAID
183
+ * it claims, naming this exact URL.
184
+ *
185
+ * Re-announcing an already-enrolled URL is a no-op that returns the current
186
+ * standing rather than an error, so an operator restarting a seed does not
187
+ * have to care whether it enrolled before.
188
+ */
189
+ async enroll({ url, operator = null, region = null, now = Date.now() }) {
190
+ const checked = checkSeedUrl(url, { allowPrivateUrls: this.allowPrivateUrls });
191
+ if (!checked.ok) return { ok: false, reason: checked.reason };
192
+
193
+ let doc;
194
+ try {
195
+ const res = await this.fetch(`${checked.url}/v1/seed-info`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
196
+ if (!res.ok) return { ok: false, reason: `seed-info-${res.status}` };
197
+ doc = await res.json();
198
+ } catch (e) {
199
+ return { ok: false, reason: `seed-unreachable: ${e.message}` };
200
+ }
201
+ const v = verifySeedInfo(doc, checked.url);
202
+ if (!v.ok) return { ok: false, reason: `seed-info-invalid:${v.reason}` };
203
+
204
+ const existing = this.get(doc.uuaid);
205
+ if (existing) {
206
+ if (existing.url !== checked.url) return { ok: false, reason: "uuaid-already-enrolled-at-other-url" };
207
+ return { ok: true, already: true, ...this.status(doc.uuaid) };
208
+ }
209
+ // A URL can only ever belong to one identity: otherwise a second key
210
+ // could enroll the same host and inherit its observed history.
211
+ const urlTaken = this.db.prepare("select uuaid from seeds where url = ?").get(checked.url);
212
+ if (urlTaken) return { ok: false, reason: "url-already-enrolled" };
213
+
214
+ this.db.prepare(`
215
+ insert into seeds (uuaid, url, public_key, operator, region, enrolled_at, tier, claimed)
216
+ values (?,?,?,?,?,?,'probation',?)
217
+ `).run(doc.uuaid, checked.url, doc.publicKey.toLowerCase(), operator, region,
218
+ new Date(now).toISOString(), JSON.stringify(doc.stats ?? {}));
219
+ return { ok: true, already: false, ...this.status(doc.uuaid) };
220
+ }
221
+
222
+ /**
223
+ * Record one health-probe outcome into an exact sliding window of the last
224
+ * PROBE_WINDOW probes.
225
+ *
226
+ * The obvious cheap alternatives both misreport a seed that has been up a
227
+ * long time and then degrades. Capping the counters freezes the ratio, so
228
+ * 1000/1000 stays 100% through any number of later failures. Decaying them
229
+ * multiplicatively has integer fixed points — floor(950 * 0.999) + 1 is
230
+ * 950 — so a recovered seed can never climb back. Keeping the outcomes
231
+ * themselves costs ~1 KiB per seed and is simply correct in both
232
+ * directions.
233
+ */
234
+ recordProbe(uuaid, passed, now = Date.now()) {
235
+ const row = this.get(uuaid);
236
+ if (!row) return;
237
+ const log = ((row.probe_log ?? "") + (passed ? "1" : "0")).slice(-PROBE_WINDOW);
238
+ const total = log.length;
239
+ let pass = 0;
240
+ for (let i = 0; i < log.length; i++) if (log[i] === "1") pass++;
241
+ const iso = new Date(now).toISOString();
242
+ this.db.prepare(`
243
+ update seeds set
244
+ probe_log = ?,
245
+ probes_total = ?,
246
+ probes_passed = ?,
247
+ consecutive_failures = case when ? then 0 else consecutive_failures + 1 end,
248
+ last_probe_at = ?,
249
+ last_pass_at = case when ? then ? else last_pass_at end
250
+ where uuaid = ?
251
+ `).run(log, total, pass, passed ? 1 : 0, iso, passed ? 1 : 0, iso, uuaid);
252
+ }
253
+
254
+ /** Record one relay round-trip outcome. */
255
+ recordRoundTrip(uuaid, passed) {
256
+ this.db.prepare(`
257
+ update seeds set rt_total = rt_total + 1,
258
+ rt_passed = case when ? then rt_passed + 1 else rt_passed end
259
+ where uuaid = ?
260
+ `).run(passed ? 1 : 0, uuaid);
261
+ }
262
+
263
+ /** GET <url>/v1/health — the liveness half of an observation. */
264
+ async healthProbe(row) {
265
+ try {
266
+ const res = await this.fetch(`${row.url}/v1/health`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
267
+ if (!res.ok) return false;
268
+ const body = await res.json();
269
+ return body?.ok === true;
270
+ } catch (_e) { return false; }
271
+ }
272
+
273
+ /**
274
+ * Push a real signed envelope THROUGH the seed and read it back out.
275
+ *
276
+ * This is the load-bearing measurement: a seed passes only by doing the
277
+ * exact job it exists for — accept a signed envelope, store it, and serve
278
+ * it to an authenticated poller. Uptime alone would promote a host that
279
+ * answers /v1/health and drops mail.
280
+ */
281
+ async roundTripProbe(row, { now = Date.now() } = {}) {
282
+ const me = this.keychain._identity;
283
+ let envelope;
284
+ try {
285
+ envelope = seal(this.keychain, {
286
+ recipient: me.uuaid,
287
+ recipientPublicKey: me.publicKeyHex,
288
+ kind: "seed-probe",
289
+ payload: { probe: true, seed: row.uuaid, at: new Date(now).toISOString() },
290
+ });
291
+ } catch (e) { return { ok: false, reason: `seal-failed: ${e.message}` }; }
292
+
293
+ try {
294
+ const post = await this.fetch(`${row.url}/v1/envelopes`, {
295
+ method: "POST",
296
+ headers: { "content-type": "application/json" },
297
+ body: JSON.stringify(envelope),
298
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
299
+ });
300
+ if (post.status !== 202) return { ok: false, reason: `deliver-${post.status}` };
301
+ const receipt = await post.json();
302
+ if (!receipt?.accepted) return { ok: false, reason: "deliver-not-accepted" };
303
+
304
+ // Poll from just below the receipt seq so the probe envelope is in
305
+ // range regardless of how much unrelated traffic the seed carries.
306
+ const since = Math.max(0, Number(receipt.seq) - 1);
307
+ const path = `GET /v1/inbox/${me.uuaid}?since=${since}`;
308
+ const ts = String(Date.now());
309
+ const sig = this.keychain.sign(Buffer.from(`${path}\n${ts}`, "utf-8")).toString("hex");
310
+ const res = await this.fetch(
311
+ `${row.url}/v1/inbox/${encodeURIComponent(me.uuaid)}?since=${since}&wait=${ROUND_TRIP_WAIT_S}`,
312
+ {
313
+ headers: { "x-pillar-pubkey": me.publicKeyHex, "x-pillar-ts": ts, "x-pillar-sig": sig },
314
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS + ROUND_TRIP_WAIT_S * 1000),
315
+ },
316
+ );
317
+ if (res.status !== 200) return { ok: false, reason: `inbox-${res.status}` };
318
+ const body = await res.json();
319
+ const found = (body?.envelopes ?? []).some(e => e?.envelope?.id === envelope.id);
320
+ return found ? { ok: true } : { ok: false, reason: "envelope-not-returned" };
321
+ } catch (e) {
322
+ return { ok: false, reason: `probe-threw: ${e.message}` };
323
+ }
324
+ }
325
+
326
+ /**
327
+ * One full observation of one seed: liveness, then (only if live) a relay
328
+ * round trip. A dead seed is not charged a failed round-trip on top of a
329
+ * failed health probe — one outage, one mark.
330
+ */
331
+ async observe(row, { now = Date.now() } = {}) {
332
+ const alive = await this.healthProbe(row);
333
+ this.recordProbe(row.uuaid, alive, now);
334
+ let rt = null;
335
+ if (alive) {
336
+ rt = await this.roundTripProbe(row, { now });
337
+ this.recordRoundTrip(row.uuaid, rt.ok);
338
+ }
339
+ const evaluated = this.evaluate(row.uuaid, now);
340
+ return { uuaid: row.uuaid, url: row.url, alive, roundTrip: rt, ...evaluated };
341
+ }
342
+
343
+ /** Observe every enrolled seed. Sequential: probes are cheap and ordering keeps logs readable. */
344
+ async observeAll({ now = Date.now() } = {}) {
345
+ const out = [];
346
+ for (const row of this.all()) out.push(await this.observe(row, { now }));
347
+ return out;
348
+ }
349
+
350
+ /**
351
+ * Recompute a seed's rung from observations and persist it. Returns the
352
+ * evaluation plus whether the rung moved, so callers can log promotions
353
+ * without diffing.
354
+ */
355
+ evaluate(uuaid, now = Date.now()) {
356
+ const row = this.get(uuaid);
357
+ if (!row) return { ok: false, reason: "unknown-seed" };
358
+ const before = row.tier;
359
+ const ev = evaluateSeedTier(SeedRegistry._record(row), now);
360
+ if (ev.tier !== before) {
361
+ this.db.prepare("update seeds set tier = ?, promoted_at = ? where uuaid = ?")
362
+ .run(ev.tier, new Date(now).toISOString(), uuaid);
363
+ }
364
+ return { ok: true, ...ev, previousTier: before, changed: ev.tier !== before };
365
+ }
366
+
367
+ /**
368
+ * The earned grant for a seed's current rung, or null on `probation`.
369
+ *
370
+ * Issued fresh each call with a GRANT_TTL_DAYS expiry. That TTL is the
371
+ * whole revocation story: a seed that stops serving stops being re-issued,
372
+ * and its last grant dies on its own within the window.
373
+ */
374
+ grantFor(uuaid, now = Date.now()) {
375
+ const row = this.get(uuaid);
376
+ if (!row) return null;
377
+ const rung = seedTier(row.tier);
378
+ if (!rung.grant) return null;
379
+ return makeTierGrant(this.keychain, {
380
+ uuaid: row.uuaid,
381
+ tier: rung.grant,
382
+ expires: new Date(now + GRANT_TTL_DAYS * 86_400_000).toISOString(),
383
+ priority: rung.priority,
384
+ seedTier: rung.name,
385
+ seedUrl: row.url,
386
+ });
387
+ }
388
+
389
+ /** Operator-facing standing: rung, what it earned, and what is left to earn. */
390
+ status(uuaid, now = Date.now()) {
391
+ const row = this.get(uuaid);
392
+ if (!row) return { ok: false, reason: "unknown-seed" };
393
+ const ev = evaluateSeedTier(SeedRegistry._record(row), now);
394
+ const rung = seedTier(ev.tier);
395
+ return {
396
+ ok: true,
397
+ uuaid: row.uuaid,
398
+ url: row.url,
399
+ operator: row.operator,
400
+ region: row.region,
401
+ enrolledAt: row.enrolled_at,
402
+ tier: ev.tier,
403
+ blurb: rung.blurb,
404
+ priority: rung.priority,
405
+ grantTier: rung.grant,
406
+ demoted: ev.demoted,
407
+ next: ev.next,
408
+ missing: ev.missing,
409
+ observed: {
410
+ probes: { total: row.probes_total, passed: row.probes_passed },
411
+ roundTrips: { total: row.rt_total, passed: row.rt_passed },
412
+ uptime: Number(ev.uptime.toFixed(4)),
413
+ consecutiveFailures: row.consecutive_failures,
414
+ lastProbeAt: row.last_probe_at,
415
+ lastPassAt: row.last_pass_at,
416
+ },
417
+ };
418
+ }
419
+
420
+ /**
421
+ * Render directory.json.
422
+ *
423
+ * Seeds are ordered by rung (proven first) and demoted seeds are dropped
424
+ * entirely: the directory is what a fresh agent bootstraps its carrier list
425
+ * from, and handing it a host that failed its last three probes is how you
426
+ * get a first-run experience of silent message loss.
427
+ */
428
+ directory({ agents = [], now = Date.now(), includeProbation = true } = {}) {
429
+ const rows = this.all()
430
+ .map(r => ({ r, ev: evaluateSeedTier(SeedRegistry._record(r), now) }))
431
+ .filter(({ ev }) => !ev.demoted)
432
+ .filter(({ ev }) => includeProbation || ev.tier !== "probation")
433
+ .sort((a, b) => {
434
+ const d = seedTier(a.ev.tier).listRank - seedTier(b.ev.tier).listRank;
435
+ return d !== 0 ? d : a.r.enrolled_at.localeCompare(b.r.enrolled_at);
436
+ });
437
+ return {
438
+ protocol: this.protocol,
439
+ carrier: this.carrierUrl,
440
+ updated: new Date(now).toISOString(),
441
+ issuer: this.issuerKey,
442
+ agents,
443
+ seeds: rows.map(({ r, ev }) => ({
444
+ url: r.url,
445
+ operator: r.operator ?? undefined,
446
+ region: r.region ?? undefined,
447
+ since: r.enrolled_at.slice(0, 10),
448
+ // Published so a client can rank carriers without asking the
449
+ // registry a second question (see CarrierClient carrierTiers).
450
+ tier: ev.tier,
451
+ priority: seedTier(ev.tier).priority,
452
+ // String decimal, not a float: directory.json has to survive JCS
453
+ // canonicalization (directoryEtag, and any future signed directory),
454
+ // and JCS admits no non-integer number.
455
+ uptime: ev.uptime.toFixed(4),
456
+ })),
457
+ };
458
+ }
459
+
460
+ /** Stable id for a directory render — lets a publisher skip no-op writes. */
461
+ static directoryEtag(dir) {
462
+ const { updated, ...rest } = dir;
463
+ return createHash("sha256").update(jcs(rest)).digest("hex").slice(0, 16);
464
+ }
465
+ }
466
+
467
+ export { DEMOTE_AFTER_FAILURES };
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Seed tiers — what a seed operator EARNS by staying up.
3
+ *
4
+ * A seed is just a carrier server with a public URL (see bin/pillar-seed.mjs).
5
+ * Running one costs money and attention, and until now it bought the operator
6
+ * nothing: the only reward was altruism, which is why the mesh had exactly one
7
+ * seed. This module defines the ladder that turns sustained service into
8
+ * concrete privileges.
9
+ *
10
+ * WHAT COUNTS AS SERVICE (and why it is not self-reported)
11
+ *
12
+ * A seed cannot promote itself by claiming numbers. Every threshold below is
13
+ * measured from observations the REGISTRAR makes against the seed:
14
+ *
15
+ * - health probes GET /v1/health on a schedule. Unfakeable: the seed
16
+ * either answers within the timeout or it does not.
17
+ * - round trips the registrar seals a real envelope to its own probe
18
+ * identity, POSTs it THROUGH the seed, then polls the
19
+ * seed's inbox for it. Passing means the seed genuinely
20
+ * stored and served a signed envelope — the exact job a
21
+ * seed exists to do. "A few exchanges" is this counter.
22
+ *
23
+ * The seed's own counters (envelopes relayed, distinct peers) are collected
24
+ * for display via /v1/seed-info but never gate a promotion, because a seed
25
+ * operator controls them.
26
+ *
27
+ * REVOCATION IS EXPIRY
28
+ *
29
+ * A promotion issues a short-TTL tier grant (GRANT_TTL_DAYS). Continued
30
+ * service renews it on the next evaluation; a seed that goes dark simply
31
+ * stops being renewed and its grant lapses. There is no revocation list to
32
+ * distribute, and no way for a stale grant to outlive the service that
33
+ * earned it by more than one TTL.
34
+ */
35
+
36
+ const DAY = 86_400_000;
37
+
38
+ /** How long an earned grant lives before it must be re-earned. */
39
+ export const GRANT_TTL_DAYS = 14;
40
+
41
+ /**
42
+ * Consecutive failed probes that knock a seed back to `probation`.
43
+ * Deliberately small: a seed that cannot answer three probes in a row is not
44
+ * one an agent should be told to depend on.
45
+ */
46
+ export const DEMOTE_AFTER_FAILURES = 3;
47
+
48
+ /**
49
+ * The ladder, lowest first. Each rung is cumulative — a seed holds the
50
+ * HIGHEST rung whose every threshold it meets.
51
+ *
52
+ * `grant` names the row of TIER_DEFAULTS (identity/tier.mjs) the operator's
53
+ * own identity is granted while it holds this rung. That is the "extra": the
54
+ * budgets that previously required off-wire foundation review are earned
55
+ * automatically by keeping a seed alive.
56
+ *
57
+ * `priority` marks the rungs whose holders get the reserved admission lane at
58
+ * every carrier that trusts the issuer (see carrier-server.mjs).
59
+ */
60
+ export const SEED_TIERS = [
61
+ {
62
+ name: "probation",
63
+ minAgeMs: 0,
64
+ minProbes: 0,
65
+ minUptime: 0,
66
+ minRoundTrips: 0,
67
+ grant: null,
68
+ priority: false,
69
+ /** Sort key for directory.json — lower is listed first. */
70
+ listRank: 3,
71
+ blurb: "enrolled; still proving it stays up",
72
+ },
73
+ {
74
+ name: "steady",
75
+ minAgeMs: DAY,
76
+ minProbes: 20,
77
+ minUptime: 0.95,
78
+ minRoundTrips: 10,
79
+ grant: "supporter",
80
+ priority: false,
81
+ listRank: 2,
82
+ blurb: "up a day, relaying real traffic",
83
+ },
84
+ {
85
+ name: "durable",
86
+ minAgeMs: 7 * DAY,
87
+ minProbes: 150,
88
+ minUptime: 0.99,
89
+ minRoundTrips: 100,
90
+ grant: "org",
91
+ priority: true,
92
+ listRank: 1,
93
+ blurb: "a week of near-perfect uptime",
94
+ },
95
+ {
96
+ name: "anchor",
97
+ minAgeMs: 30 * DAY,
98
+ minProbes: 700,
99
+ minUptime: 0.995,
100
+ minRoundTrips: 1000,
101
+ grant: "enterprise",
102
+ priority: true,
103
+ listRank: 0,
104
+ blurb: "a month; the mesh can be pinned to it",
105
+ },
106
+ ];
107
+
108
+ export const SEED_TIER_NAMES = SEED_TIERS.map(t => t.name);
109
+
110
+ /** Look a rung up by name. Unknown names resolve to `probation` (fail low). */
111
+ export function seedTier(name) {
112
+ return SEED_TIERS.find(t => t.name === name) ?? SEED_TIERS[0];
113
+ }
114
+
115
+ /** True when `a` sits at or above `b` on the ladder. */
116
+ export function seedTierAtLeast(a, b) {
117
+ return SEED_TIERS.findIndex(t => t.name === a) >= SEED_TIERS.findIndex(t => t.name === b);
118
+ }
119
+
120
+ /**
121
+ * Observed uptime as a fraction, or 0 when nothing has been observed yet.
122
+ * Kept explicit so "no probes" can never read as "100% uptime".
123
+ */
124
+ export function uptimeOf(record) {
125
+ const total = record?.probes?.total ?? 0;
126
+ if (total <= 0) return 0;
127
+ return (record.probes.passed ?? 0) / total;
128
+ }
129
+
130
+ /**
131
+ * Decide a seed's rung from the registrar's observation record.
132
+ *
133
+ * @param {object} record { enrolledAt, probes:{total,passed}, roundTrips:{passed},
134
+ * consecutiveFailures }
135
+ * @param {number} now epoch ms (injectable for tests)
136
+ * @returns {{ tier: string, demoted: boolean, next: string|null, missing: string[],
137
+ * ageMs: number, uptime: number, roundTrips: number }}
138
+ * `missing` names the thresholds blocking the NEXT rung, so an
139
+ * operator can be told what is actually left to do rather than just
140
+ * "not yet".
141
+ */
142
+ export function evaluateSeedTier(record, now = Date.now()) {
143
+ const enrolledMs = Date.parse(record?.enrolledAt ?? "");
144
+ const ageMs = Number.isFinite(enrolledMs) ? Math.max(0, now - enrolledMs) : 0;
145
+ const uptime = uptimeOf(record);
146
+ const probes = record?.probes?.total ?? 0;
147
+ const roundTrips = record?.roundTrips?.passed ?? 0;
148
+ const failures = record?.consecutiveFailures ?? 0;
149
+
150
+ // A currently-failing seed is not a seed anyone should be routed to, no
151
+ // matter how good its history looks.
152
+ if (failures >= DEMOTE_AFTER_FAILURES) {
153
+ return {
154
+ tier: "probation",
155
+ demoted: true,
156
+ next: null,
157
+ missing: [`answer a health probe (${failures} consecutive failures)`],
158
+ ageMs, uptime, roundTrips,
159
+ };
160
+ }
161
+
162
+ const meets = (t) =>
163
+ ageMs >= t.minAgeMs &&
164
+ probes >= t.minProbes &&
165
+ uptime >= t.minUptime &&
166
+ roundTrips >= t.minRoundTrips;
167
+
168
+ let held = SEED_TIERS[0];
169
+ for (const t of SEED_TIERS) {
170
+ if (meets(t)) held = t; else break;
171
+ }
172
+ const nextRung = SEED_TIERS[SEED_TIERS.indexOf(held) + 1] ?? null;
173
+
174
+ const missing = [];
175
+ if (nextRung) {
176
+ if (ageMs < nextRung.minAgeMs) {
177
+ missing.push(`${Math.ceil((nextRung.minAgeMs - ageMs) / DAY)}d more enrolled`);
178
+ }
179
+ if (probes < nextRung.minProbes) missing.push(`${nextRung.minProbes - probes} more health probes`);
180
+ if (uptime < nextRung.minUptime) missing.push(`uptime ${(uptime * 100).toFixed(1)}% < ${(nextRung.minUptime * 100).toFixed(1)}%`);
181
+ if (roundTrips < nextRung.minRoundTrips) missing.push(`${nextRung.minRoundTrips - roundTrips} more relay round-trips`);
182
+ }
183
+
184
+ return { tier: held.name, demoted: false, next: nextRung?.name ?? null, missing, ageMs, uptime, roundTrips };
185
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Node.js version gate for the pillar command-line tools.
3
+ *
4
+ * Every bin imports this module FIRST, so it runs before any dependency does.
5
+ * Measured 2026-09-24 with the published alpha.16: on Node 20 and 21 the
6
+ * libp2p stack calls Promise.withResolvers (first shipped unflagged in Node
7
+ * 22.0.0) deep inside its peer store, so `pillar id` died with "TypeError:
8
+ * Promise.withResolvers is not a function" instead of saying what was wrong.
9
+ * package.json `engines` states the same floor, but npm only warns about
10
+ * engines; this turns the warning into a clear refusal.
11
+ */
12
+ import { basename } from "node:path";
13
+
14
+ export const MIN_NODE_MAJOR = 22;
15
+
16
+ /**
17
+ * Returns a human-readable reason this Node.js version cannot run pillar, or
18
+ * null when it can. An unparseable version passes: this gate exists to explain
19
+ * a known failure, not to guess about builds it cannot read.
20
+ */
21
+ export function nodeVersionProblem(version = process.versions.node, tool = "pillar") {
22
+ const major = Number.parseInt(String(version).split(".")[0], 10);
23
+ if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR) return null;
24
+ return `${tool} needs Node.js ${MIN_NODE_MAJOR} or newer (found v${version}). ` +
25
+ `Install the current LTS from https://nodejs.org and run it again.`;
26
+ }
27
+
28
+ const problem = nodeVersionProblem(process.versions.node, basename(process.argv[1] ?? "pillar").replace(/\.mjs$/, ""));
29
+ if (problem) {
30
+ process.stderr.write(`${problem}\n`);
31
+ process.exit(1);
32
+ }