@uuaid/pillar 0.2.0-alpha.13 → 0.2.0-alpha.17
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/INTERACTION-CONVENTION.md +192 -0
- package/README.md +276 -12
- package/bin/carrier.mjs +1 -0
- package/bin/pillar-ask.mjs +1 -0
- package/bin/pillar-blob.mjs +113 -0
- package/bin/pillar-directory.mjs +232 -0
- package/bin/pillar-escrow.mjs +1 -0
- package/bin/pillar-responder.mjs +1 -0
- package/bin/pillar-seed.mjs +113 -13
- package/bin/pillar.mjs +7 -2
- package/package.json +26 -15
- package/src/blob/blob.mjs +426 -0
- package/src/blob/broker.mjs +129 -0
- package/src/blob/http.mjs +196 -0
- package/src/blob/store.mjs +55 -0
- package/src/identity/tier.mjs +24 -3
- package/src/index.mjs +24 -1
- package/src/net/carrier-client.mjs +55 -9
- package/src/net/carrier-server.mjs +169 -11
- package/src/net/delegation.mjs +152 -0
- package/src/net/envelope.mjs +81 -0
- package/src/net/seed-registry.mjs +467 -0
- package/src/net/seed-tier.mjs +185 -0
- package/src/node-check.mjs +32 -0
- package/src/pillar.mjs +96 -3
|
@@ -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
|
+
}
|