@uuaid/pillar 0.2.0-alpha.8 → 2.0.1

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 +371 -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 +47 -1
  10. package/bin/pillar-seed.mjs +113 -13
  11. package/bin/pillar.mjs +345 -4
  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 +41 -0
  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 +206 -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
@@ -31,6 +31,7 @@
31
31
  * --reply-text "zz-master is away — left a message" \
32
32
  * --forward-to uuaid:foundation:agent:… --forward-pubkey <hex>
33
33
  */
34
+ import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
34
35
  import { existsSync, readFileSync, appendFileSync } from "node:fs";
35
36
  import { join } from "node:path";
36
37
  import { homedir } from "node:os";
@@ -38,11 +39,16 @@ import { argv, exit } from "node:process";
38
39
  import { spawn } from "node:child_process";
39
40
  import { Keychain } from "../src/identity/keychain.mjs";
40
41
  import { Pillar } from "../src/pillar.mjs";
42
+ import { loadTrustPolicy, evaluateTrust, POLICIES } from "../src/identity/trust.mjs";
43
+ import { loadFleetAddressBook } from "../src/address-book.mjs";
41
44
 
42
45
  const DEFAULT_REPLY = "pillar-responder: request acked — owner will respond through the normal channel";
46
+ const STRANGER_REPLY = "pillar-responder: request received. This responder runs its command hook only for enrolled senders; ask the operator to `pillar trust allow` your uuaid.";
43
47
  const MAX_REPLY_BYTES = 8192;
44
48
  const HOOK_TIMEOUT_MS = 60_000;
45
49
  const REPLAY_WINDOW_MS = 24 * 3600_000;
50
+ const SENDER_REPLIES_PER_HOUR = 30; // amplification cap: max hook/reply cycles per sender
51
+ const RATE_WINDOW_MS = 3600_000;
46
52
 
47
53
  function parseArgs(argv) {
48
54
  const args = { _: [] };
@@ -162,6 +168,26 @@ if (!carriers.length && !enableLibp2p) {
162
168
 
163
169
  const replyText = args["reply-text"] ?? rcfg.replyText ?? DEFAULT_REPLY;
164
170
  const hookCmd = args.command ?? rcfg.command ?? null;
171
+ // Trust gate. Default policy for the COMMAND HOOK is "directory": strangers
172
+ // still get ack + a fixed reply (universality preserved — first contact
173
+ // works), but attacker-controlled JSON only reaches the hook process from
174
+ // senders the operator knows. --hook-policy open restores old behavior.
175
+ const trust = loadTrustPolicy(home);
176
+ const hookPolicy = args["hook-policy"] ?? rcfg.hookPolicy ?? "directory";
177
+ if (!POLICIES.includes(hookPolicy)) {
178
+ console.error(`--hook-policy must be one of ${POLICIES.join("|")}`);
179
+ exit(2);
180
+ }
181
+ const senderRate = new Map(); // sender uuaid -> [ms timestamps]
182
+ function senderAllowed(sender) {
183
+ const now = Date.now();
184
+ let arr = senderRate.get(sender);
185
+ if (!arr) { arr = []; senderRate.set(sender, arr); }
186
+ while (arr.length && now - arr[0] > RATE_WINDOW_MS) arr.shift();
187
+ if (arr.length >= SENDER_REPLIES_PER_HOUR) return false;
188
+ arr.push(now);
189
+ return true;
190
+ }
165
191
  const hookTimeoutMs = Number(args["timeout-ms"] ?? rcfg.timeoutMs ?? HOOK_TIMEOUT_MS);
166
192
  const hookMaxBytes = Number(args["max-reply-bytes"] ?? MAX_REPLY_BYTES);
167
193
  const fwd = args["forward-to"] || rcfg.forwardTo?.uuaid
@@ -187,10 +213,14 @@ const p = new Pillar({
187
213
 
188
214
  await p.start();
189
215
 
216
+ // Address book feeds the "directory" hook policy (known senders = fleet.json).
217
+ const abCount = loadFleetAddressBook(join(home, "fleet.json"), p);
218
+
190
219
  console.log(`pillar-responder up — ${p.uuaid}`);
191
220
  console.log(` pubkey: ${p.publicKeyHex}`);
192
221
  console.log(` carriers: ${carriers.length ? carriers.join(", ") : "(none — libp2p only)"}`);
193
222
  console.log(` reply: ${hookCmd ? `hook ${hookCmd}` : "builtin text"}`);
223
+ if (hookCmd) console.log(` hook gate: policy=${hookPolicy}, known senders=${abCount}, cap=${SENDER_REPLIES_PER_HOUR}/h per sender`);
194
224
  if (fwd) console.log(` forwardTo: ${fwd.uuaid}`);
195
225
 
196
226
  // Replay journaled-but-unfinished requests first (at-least-once).
@@ -215,7 +245,21 @@ p.onEnvelope(async (env) => {
215
245
  const thread = env.payload?.thread ?? env.id;
216
246
  let status = "ok";
217
247
  let text = replyText;
218
- if (hookCmd) {
248
+ // Amplification cap: over-budget senders get ack only, no reply/hook.
249
+ if (!senderAllowed(env.sender)) {
250
+ await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "ack", payload: { inReplyTo: env.id } });
251
+ journalDone(home, env.id, { text: "(rate-capped: ack only)", status: "capped" });
252
+ console.log(` ${env.sender.slice(-8)} request ${env.id.slice(0, 16)}… → ack only (rate cap)`);
253
+ return;
254
+ }
255
+ // Hook gate: strangers get the fixed reply, never the command hook.
256
+ const gate = evaluateTrust({ ...trust, policy: hookPolicy }, env.sender, new Set(p.addressBook?.keys() ?? []));
257
+ if (gate.reason === "blocked") {
258
+ journalDone(home, env.id, { text: "(blocked sender: dropped)", status: "blocked" });
259
+ console.log(` ${env.sender.slice(-8)} request ${env.id.slice(0, 16)}… → dropped (blocked)`);
260
+ return;
261
+ }
262
+ if (hookCmd && gate.deliver) {
219
263
  const r = await runHook(hookCmd, {
220
264
  id: env.id,
221
265
  sender: env.sender,
@@ -229,6 +273,8 @@ p.onEnvelope(async (env) => {
229
273
  status = "error";
230
274
  text = r.reason;
231
275
  }
276
+ } else if (hookCmd && !gate.deliver) {
277
+ text = STRANGER_REPLY; // stranger: fixed reply, hook never sees the payload
232
278
  }
233
279
  await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "ack", payload: { inReplyTo: env.id } });
234
280
  const replyPayload = { thread, inReplyTo: env.id, status, text };
@@ -4,6 +4,8 @@
4
4
  * the p2p store-and-forward mesh, like a LimeWire/BitTorrent seed peer.
5
5
  *
6
6
  * pillar-seed --port 8799 --db /opt/pillar-seed/seed.db
7
+ * pillar-seed --public-url https://seed.example.org --announce https://pillar.uuaid.org
8
+ * pillar-seed --status --announce https://pillar.uuaid.org
7
9
  *
8
10
  * A seed IS a carrier server with a public URL — nothing else. Agents
9
11
  * anywhere list your URL in their carriers[] and then:
@@ -13,18 +15,36 @@
13
15
  * Minimal ongoing requirements: one process, one SQLite file, outbound-free
14
16
  * (all traffic comes TO you). That is the whole contract.
15
17
  *
16
- * Defaults to loopback (RedTeam F5). To become a real seed, put it behind a
17
- * TLS-terminating proxy (nginx/Caddy) and advertise the PUBLIC https:// URL —
18
- * never the loopback one — as a seeds[] entry in a pillar directory.json:
18
+ * JOINING
19
+ *
20
+ * Announce yourself to a registry with --announce and you are enrolled: the
21
+ * registry fetches /v1/seed-info from the URL you gave, checks that it is
22
+ * signed by your seed key, and lists you. No account, no review, no waiting
23
+ * on whoever owns the directory file.
24
+ *
25
+ * WHAT YOU EARN
26
+ *
27
+ * The registry then probes you — liveness plus a real envelope round-tripped
28
+ * through your seed — and promotes you as the evidence accumulates:
29
+ *
30
+ * probation -> steady (a day) -> durable (a week) -> anchor (a month)
19
31
  *
20
- * { "url": "https://<your-host>", "operator": "<you>", "region": "<region>", "since": "<ISO date>" }
32
+ * From `steady` up, your OWN identity is issued a signed tier grant: raised
33
+ * send budgets at every carrier that trusts the registry's issuer key. From
34
+ * `durable` up that grant also carries priority, which reserves you
35
+ * admission capacity at those carriers when they are shedding load. Run
36
+ * --status to see your rung and exactly what is left to reach the next one.
21
37
  *
22
- * Anyone running `pillar init --directory <directory-url>` then picks you up
23
- * automatically as one of their carriers. You can also be someone's ENTIRE
24
- * carrier set (`--carriers https://<your-host>`) — two agents can talk with
25
- * only your seed between them, no official carrier involved.
38
+ * Defaults to loopback (RedTeam F5). To become a real seed, put it behind a
39
+ * TLS-terminating proxy (nginx/Caddy) and pass the PUBLIC https:// URL as
40
+ * --public-url — never the loopback one.
26
41
  */
42
+ import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
43
+ import { mkdirSync } from "node:fs";
44
+ import { homedir } from "node:os";
45
+ import { join } from "node:path";
27
46
  import { CarrierServer } from "../src/net/carrier-server.mjs";
47
+ import { Keychain } from "../src/identity/keychain.mjs";
28
48
 
29
49
  function parseArgs(argv) {
30
50
  const args = {};
@@ -39,16 +59,68 @@ function parseArgs(argv) {
39
59
  }
40
60
 
41
61
  const args = parseArgs(process.argv.slice(2));
62
+ const home = args.home ?? process.env.SEED_HOME ?? join(homedir(), ".pillar-seed");
42
63
  const port = Number(args.port ?? process.env.SEED_PORT ?? 8799);
43
64
  const host = args.host ?? process.env.SEED_HOST ?? "127.0.0.1";
44
65
  const dbPath = args.db ?? process.env.SEED_DB ?? "pillar-seed.db";
45
66
  const ttlDays = Number(args.ttl ?? process.env.SEED_TTL_DAYS ?? 14);
67
+ const publicUrl = args["public-url"] ?? process.env.SEED_PUBLIC_URL ?? null;
68
+ const registryUrl = typeof args.announce === "string" ? args.announce.replace(/\/+$/, "") : null;
69
+ const operator = args.operator ?? process.env.SEED_OPERATOR ?? null;
70
+ const region = args.region ?? process.env.SEED_REGION ?? null;
71
+
72
+ /**
73
+ * The seed's identity. Distinct from any agent keychain on the box: it names
74
+ * the SEED, is what /v1/seed-info is signed with, and is the subject of any
75
+ * tier grant the seed earns.
76
+ */
77
+ mkdirSync(home, { recursive: true });
78
+ const keychain = new Keychain(join(home, "seed-keychain.json"));
79
+ if (!keychain.exists()) {
80
+ keychain.save(Keychain.generate());
81
+ console.log(`minted seed identity -> ${join(home, "seed-keychain.json")}`);
82
+ }
83
+ keychain.load();
46
84
 
47
- const server = new CarrierServer({ dbPath, ttlDays });
85
+ /** Ask a registry where we stand and print the ladder position. */
86
+ async function printStanding(registry, uuaid) {
87
+ try {
88
+ const res = await fetch(`${registry}/v1/seeds/${encodeURIComponent(uuaid)}`);
89
+ if (res.status === 404) {
90
+ console.log(` standing: not enrolled at ${registry} — run with --announce ${registry}`);
91
+ return;
92
+ }
93
+ if (!res.ok) { console.log(` standing: registry ${res.status}`); return; }
94
+ const s = await res.json();
95
+ console.log(` tier: ${s.tier}${s.demoted ? " (DEMOTED — failing probes)" : ""} — ${s.blurb}`);
96
+ console.log(` earned: ${s.grantTier ? `${s.grantTier} grant${s.priority ? " + priority delivery" : ""}` : "nothing yet"}`);
97
+ const o = s.observed ?? {};
98
+ console.log(` observed: ${o.probes?.passed ?? 0}/${o.probes?.total ?? 0} probes (${((o.uptime ?? 0) * 100).toFixed(1)}% uptime), ${o.roundTrips?.passed ?? 0} relay round-trips`);
99
+ if (s.next) console.log(` next: ${s.next} — needs ${s.missing.join(", ")}`);
100
+ else if (!s.demoted) console.log(` next: top of the ladder`);
101
+ } catch (e) {
102
+ console.log(` standing: unreachable — ${e.message}`);
103
+ }
104
+ }
105
+
106
+ // --status is a query, not a server: report and exit.
107
+ if (args.status) {
108
+ if (!registryUrl) {
109
+ console.error("--status needs --announce <registry-url> to know which registry to ask");
110
+ process.exit(1);
111
+ }
112
+ console.log(`seed ${keychain._identity.uuaid}`);
113
+ await printStanding(registryUrl, keychain._identity.uuaid);
114
+ process.exit(0);
115
+ }
116
+
117
+ const server = new CarrierServer({ dbPath, ttlDays, keychain, publicUrl });
48
118
  const addr = await server.listen(port, host);
49
119
  console.log(`pillar-seed listening on http://${addr.address}:${addr.port}`);
120
+ console.log(` uuaid: ${keychain._identity.uuaid}`);
50
121
  console.log(` db: ${dbPath}`);
51
122
  console.log(` ttl: ${ttlDays} days`);
123
+ if (publicUrl) console.log(` public url: ${publicUrl}`);
52
124
 
53
125
  // Boot self-check on the address we actually serve — verify before claim.
54
126
  try {
@@ -60,11 +132,39 @@ try {
60
132
  console.log(` health: unreachable — ${e.message}`);
61
133
  }
62
134
 
63
- if (host === "127.0.0.1") {
135
+ if (registryUrl) {
136
+ if (!publicUrl) {
137
+ console.log(`\n --announce needs --public-url <https://your-host>: the registry enrolls a URL,`);
138
+ console.log(` and it must be one it can reach and that you can prove you operate.`);
139
+ } else {
140
+ console.log(`\n announcing ${publicUrl} to ${registryUrl} ...`);
141
+ try {
142
+ const res = await fetch(`${registryUrl}/v1/seeds`, {
143
+ method: "POST",
144
+ headers: { "content-type": "application/json" },
145
+ body: JSON.stringify({ url: publicUrl, operator, region }),
146
+ });
147
+ const body = await res.json().catch(() => ({}));
148
+ if (res.ok && body.ok) {
149
+ console.log(` enrolled${body.already ? " (already)" : ""} as ${body.uuaid}`);
150
+ await printStanding(registryUrl, keychain._identity.uuaid);
151
+ } else {
152
+ console.log(` enrollment refused: ${body.reason ?? res.status}`);
153
+ if (String(body.reason ?? "").startsWith("seed-unreachable")) {
154
+ console.log(` (the registry could not fetch ${publicUrl}/v1/seed-info — check DNS, TLS, and your proxy)`);
155
+ }
156
+ }
157
+ } catch (e) {
158
+ console.log(` enrollment failed: ${e.message}`);
159
+ }
160
+ }
161
+ } else if (host === "127.0.0.1") {
64
162
  console.log(`\n loopback seed — reachable only from this machine.`);
65
- console.log(` To join the mesh as a real seed: TLS-terminate a public URL and list it:`);
66
- console.log(` { "url": "https://<your-host>", "operator": "<you>", "region": "<region>", "since": "${new Date().toISOString().slice(0, 10)}" }`);
67
- console.log(` in the seeds[] array of your pillar directory.json.`);
163
+ console.log(` To join the mesh as a real seed: TLS-terminate a public URL, then`);
164
+ console.log(` pillar-seed --host 0.0.0.0 --public-url https://<your-host> \\`);
165
+ console.log(` --announce https://pillar.uuaid.org --operator <you> --region <region>`);
166
+ console.log(` Enrollment is permissionless: the registry verifies the URL against your`);
167
+ console.log(` seed key and lists you. Staying up is what earns the rest.`);
68
168
  }
69
169
 
70
170
  // Vacuum expired envelopes hourly.