@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
@@ -27,10 +27,11 @@
27
27
  * Carrier-only by default (outbound HTTPS only — runs on any machine or
28
28
  * webserver; no inbound ports, no libp2p). Pass --libp2p to also listen.
29
29
  *
30
- * pillar-responder --home ~/.pillar-fleet/zz-master-responder \
31
- * --reply-text "zz-master is away — left a message" \
30
+ * pillar-responder --home ~/.pillar-responder \
31
+ * --reply-text "my-agent 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.
package/bin/pillar.mjs CHANGED
@@ -9,11 +9,14 @@
9
9
  * pillar recv [--home ~/.pillar] [--follow] listen for envelopes and print them
10
10
  * pillar inbox [--home ~/.pillar] [--unread] [--notify CMD] [--mark-read]
11
11
  * one-shot: pull pending mail, print, exit
12
+ * pillar trust [set|allow|block|unlist|quarantine] recipient consent policy
13
+ * pillar tier [issue|install|verify] signed volume/size tier grants
12
14
  * pillar peers list currently connected peers
13
15
  *
14
16
  * All commands respect --home to point at an alternate pillar directory,
15
17
  * useful for running multiple pillars on one machine (Zestro dogfood etc.).
16
18
  */
19
+ import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
17
20
  import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
18
21
  import { join, dirname } from "node:path";
19
22
  import { homedir } from "node:os";
@@ -21,6 +24,10 @@ import { argv, exit } from "node:process";
21
24
  import { Keychain } from "../src/identity/keychain.mjs";
22
25
  import { Pillar } from "../src/pillar.mjs";
23
26
  import { loadFleetAddressBook } from "../src/address-book.mjs";
27
+ import { loadTrustPolicy, POLICIES } from "../src/identity/trust.mjs";
28
+ import { parseLinkOffer, isLinkOfferLive, LINK_GRANT_KINDS } from "../src/net/link-offer.mjs";
29
+ import { mintDelegationDoc } from "../src/net/delegation.mjs";
30
+ import { refusalLine } from "../src/net/refusals.mjs";
24
31
 
25
32
  function parseArgs(argv) {
26
33
  const args = { _: [] };
@@ -60,6 +67,19 @@ function loadPillar(args) {
60
67
  const carriers = args.carrier
61
68
  ? [args.carrier]
62
69
  : (process.env.PILLAR_CARRIERS ? process.env.PILLAR_CARRIERS.split(",") : (cfg.carriers ?? []));
70
+ // Trust gate: --policy overrides trust.json's policy for this invocation.
71
+ const trust = loadTrustPolicy(home);
72
+ if (args.policy) {
73
+ if (!POLICIES.includes(args.policy)) {
74
+ console.error(`--policy must be one of ${POLICIES.join("|")}`);
75
+ exit(2);
76
+ }
77
+ trust.policy = args.policy;
78
+ }
79
+ // Tier grant: <home>/tier-grant.json (issued via `pillar tier issue`) is
80
+ // attached to carrier delivers — raises budgets at carriers trusting the issuer.
81
+ const tgPath = join(home, "tier-grant.json");
82
+ const tierGrant = existsSync(tgPath) ? JSON.parse(readFileSync(tgPath, "utf-8")) : null;
63
83
  return new Pillar({
64
84
  keychain: kc,
65
85
  mailboxPath: mbPath,
@@ -69,6 +89,8 @@ function loadPillar(args) {
69
89
  dhtClientMode: cfg.dhtClientMode ?? true,
70
90
  carriers,
71
91
  enableLibp2p: !(args["carrier-only"] || cfg.carrierOnly === true),
92
+ trust,
93
+ tierGrant,
72
94
  });
73
95
  }
74
96
 
@@ -247,6 +269,121 @@ async function cmdSend(args) {
247
269
  }
248
270
  }
249
271
 
272
+
273
+ // ── delegate ──────────────────────────────────────────────────────────────────
274
+ // The desktop-link ceiling enforced by chat-web (browser-chat-session.ts,
275
+ // LINK_TTL_MS). A grant with a longer window verifies fine and is then DROPPED
276
+ // by the browser without a word, so this side refuses it loudly instead.
277
+ const LINK_TTL_DAYS_MAX = 7;
278
+
279
+ /**
280
+ * pillar delegate --to-browser '<uuaid://link/...>'
281
+ *
282
+ * Pair a browser tab to THIS pillar identity. The tab publishes an offer
283
+ * carrying its own freshly minted device key; this mints a delegation naming
284
+ * that key as delegate and this identity as principal, and sends it as a
285
+ * `_link` grant to the tab's inbox.
286
+ *
287
+ * The persona seed never leaves this machine — only a signed, time-boxed
288
+ * document does. Identical in every respect to what a phone sends: the
289
+ * handshake authenticates the PRINCIPAL (the envelope sender must equal the
290
+ * delegation principal), and never the kind of device the principal runs on.
291
+ * That is what lets a headless agent hold a desktop.
292
+ */
293
+ async function cmdDelegate(args) {
294
+ const offerRaw = typeof args["to-browser"] === "string" ? args["to-browser"] : args._[1];
295
+ if (!offerRaw || args["to-browser"] === true) {
296
+ console.error("usage: pillar delegate --to-browser '<uuaid://link/...>' [--ttl-days N] [--name NAME]");
297
+ console.error(" Paste the offer string the browser shows under its QR code.");
298
+ exit(2);
299
+ }
300
+
301
+ const offer = parseLinkOffer(offerRaw);
302
+ if (!offer) {
303
+ console.error("not a valid link offer. Expected: uuaid://link/<delegate-uuaid>?pk=<64hex>&nonce=<hex>&exp=<iso>");
304
+ exit(2);
305
+ }
306
+ if (!isLinkOfferLive(offer)) {
307
+ console.error(`offer expired at ${offer.exp} — reload the browser tab for a fresh one`);
308
+ exit(1);
309
+ }
310
+
311
+ const ttlDays = args["ttl-days"] !== undefined ? Number(args["ttl-days"]) : LINK_TTL_DAYS_MAX;
312
+ if (!Number.isFinite(ttlDays) || ttlDays <= 0) {
313
+ console.error("--ttl-days must be a positive number");
314
+ exit(2);
315
+ }
316
+ // Refuse rather than mint a grant the far side discards in silence.
317
+ if (ttlDays > LINK_TTL_DAYS_MAX) {
318
+ console.error(`--ttl-days ${ttlDays} exceeds the ${LINK_TTL_DAYS_MAX}-day desktop-link ceiling.`);
319
+ console.error("A longer grant is a VALID document that the browser drops without reporting why.");
320
+ exit(2);
321
+ }
322
+
323
+ const p = loadPillar(args);
324
+ await p.start();
325
+ try {
326
+ const me = p.keychain._identity;
327
+ const delegation = mintDelegationDoc(p.keychain, {
328
+ delegate: offer.delegate,
329
+ delegatePublicKey: offer.pk,
330
+ kinds: [...LINK_GRANT_KINDS], // never "*": that would let the desktop read this identity's inbox
331
+ ttlDays,
332
+ });
333
+
334
+ // The snapshot's persona MUST equal the delegation principal exactly — the
335
+ // browser rejects the whole grant on any mismatch, treating a disagreeing
336
+ // snapshot as evidence that neither half can be trusted.
337
+ // fleet.json is { fleet: { "<role>": { uuaid, publicKey, ... } } } — keyed by
338
+ // ROLE, which is also the human-readable name the desktop shows. A row
339
+ // without a publicKey cannot be sealed to, so it is not a contact.
340
+ const contacts = [];
341
+ const fp = fleetPath(args);
342
+ if (existsSync(fp)) {
343
+ let fleet = {};
344
+ try {
345
+ fleet = JSON.parse(readFileSync(fp, "utf-8")).fleet ?? {};
346
+ } catch {
347
+ console.error(`warning: ${fp} is not readable JSON — sending 0 contacts`);
348
+ }
349
+ for (const [role, entry] of Object.entries(fleet)) {
350
+ if (typeof entry?.uuaid !== "string" || typeof entry?.publicKey !== "string") continue;
351
+ contacts.push({ uuaid: entry.uuaid, publicKey: entry.publicKey, name: String(role) });
352
+ }
353
+ }
354
+
355
+ const payload = {
356
+ _link: { v: 1, op: "grant", nonce: offer.nonce },
357
+ delegation,
358
+ snapshot: {
359
+ persona: { uuaid: me.uuaid, publicKey: me.publicKeyHex },
360
+ displayName: typeof args.name === "string" ? args.name : null,
361
+ contacts,
362
+ },
363
+ };
364
+
365
+ const { envelope } = await p.send({
366
+ recipient: offer.delegate,
367
+ kind: "message",
368
+ payload,
369
+ recipientPublicKey: offer.pk,
370
+ resolveTimeoutMs: 5000,
371
+ });
372
+
373
+ console.log(`linked ${offer.delegate}`);
374
+ console.log(` principal: ${me.uuaid}`);
375
+ console.log(` window: ${delegation.notBefore} -> ${delegation.notAfter} (${ttlDays}d)`);
376
+ console.log(` contacts: ${contacts.length}`);
377
+ console.log(` envelope: ${envelope.id}`);
378
+ console.log("");
379
+ console.log("The tab pairs when it polls. If it does not, the grant was refused in");
380
+ console.log("silence — check the offer is unexpired and unclaimed, and that the tab");
381
+ console.log("still shows the same QR (a reload rotates the nonce and voids this grant).");
382
+ } finally {
383
+ await p.stop();
384
+ }
385
+ }
386
+
250
387
  async function cmdSendGroup(args) {
251
388
  const [groupName, kind, jsonStr] = args._.slice(1);
252
389
  if (!groupName || !kind) {
@@ -268,7 +405,7 @@ async function cmdSendGroup(args) {
268
405
  let failed = 0;
269
406
  for (const r of results) {
270
407
  if (r.ok) console.log(` ✓ ${r.member} via ${r.via} ${r.envelopeId}`);
271
- else { failed++; console.error(` ✗ ${r.member} ${r.error}`); }
408
+ else { failed++; console.error(` ✗ ${r.member} ${refusalLine(r) ?? r.error}`); }
272
409
  }
273
410
  console.log(`group "${groupName}": ${results.length - failed}/${results.length} delivered`);
274
411
  if (failed) exit(1);
@@ -377,7 +514,7 @@ async function cmdRecv(args) {
377
514
  await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "error", reason: e.message.slice(0, 300), text: `zestro bridge error: ${e.message.slice(0, 300)}` } });
378
515
  }
379
516
  }
380
- } else if (bridge) {
517
+ } else if (bridge && env.kind !== "ack" && env.kind !== "reply") {
381
518
  try {
382
519
  const result = await bridge.sendCommand({ command: JSON.stringify({ kind: env.kind, payload: env.payload, sender: env.sender }) });
383
520
  console.log(` → zestro result: ${JSON.stringify(result).slice(0, 200)}`);
@@ -390,6 +527,10 @@ async function cmdRecv(args) {
390
527
  console.error(` → zestro bridge error: ${e.message}`);
391
528
  }
392
529
  }
530
+ } else if (bridge) {
531
+ // ack/reply are receipts for OUR earlier sends — bridging them would
532
+ // inject a Zestro agent turn per receipt. Print-only (line above).
533
+ console.log(` → zestro: not bridged (kind=${env.kind} is a receipt)`);
393
534
  }
394
535
  });
395
536
 
@@ -410,9 +551,16 @@ async function cmdInbox(args) {
410
551
  const fresh = [];
411
552
  p.onEnvelope((env) => { fresh.push(env); });
412
553
  await p.start();
554
+ // Address book BEFORE the drain: the trust "directory" policy needs known
555
+ // senders loaded, or every fresh envelope counts as a stranger.
556
+ loadFleetAddressBook(fleetPath(args), p);
413
557
  const pulled = await p.drainCarrierInbox();
558
+ // fromPublicKey: the sender's verified Ed25519 key (transportSignature —
559
+ // open() binds it to `from`). Lets a drain-and-reply consumer (the fleet
560
+ // courier's auto-ack, a bot) seal back to a sender who is NOT in fleet.json
561
+ // via `pillar send … --to-pubkey <fromPublicKey>`. Additive field.
414
562
  for (const env of fresh) {
415
- console.log(JSON.stringify({ new: true, id: env.id, from: env.sender, kind: env.kind, at: env.createdAt, payload: env.payload }));
563
+ console.log(JSON.stringify({ new: true, id: env.id, from: env.sender, fromPublicKey: env.transportSignature?.publicKey ?? null, kind: env.kind, at: env.createdAt, payload: env.payload }));
416
564
  }
417
565
  // Backlog: rows stored earlier (e.g. by a recv daemon) but never surfaced.
418
566
  const { decrypt } = await import("../src/net/envelope.mjs");
@@ -423,10 +571,11 @@ async function cmdInbox(args) {
423
571
  const env = JSON.parse(row.envelope_json);
424
572
  let payload = null;
425
573
  try { payload = decrypt(p.keychain, env); } catch (_e) { payload = { error: "decrypt failed" }; }
426
- console.log(JSON.stringify({ new: false, id: env.id, from: env.sender, kind: env.kind, at: env.createdAt, payload }));
574
+ console.log(JSON.stringify({ new: false, id: env.id, from: env.sender, fromPublicKey: env.transportSignature?.publicKey ?? null, kind: env.kind, at: env.createdAt, payload }));
427
575
  }
428
576
  }
429
- console.log(`inbox: ${pulled} new, ${backlog.length} older unread${args.unread ? "" : " (--unread to print)"}`);
577
+ const qCount = (p.mailbox.inboxQuarantined?.(500) ?? []).length;
578
+ console.log(`inbox: ${fresh.length} new, ${backlog.length} older unread${args.unread ? "" : " (--unread to print)"}${qCount ? `, ${qCount} quarantined (pillar trust quarantine)` : ""}`);
430
579
  if (args["mark-read"]) {
431
580
  for (const env of fresh) p.mailbox.markInboxRead?.(env.id);
432
581
  if (args.unread) for (const row of backlog) p.mailbox.markInboxRead?.(row.envelope_id);
@@ -445,6 +594,144 @@ async function cmdInbox(args) {
445
594
  exit(0);
446
595
  }
447
596
 
597
+ async function cmdTrust(args) {
598
+ // pillar trust show policy + lists + quarantine count
599
+ // pillar trust set <open|directory|allowlist>
600
+ // pillar trust allow|block <uuaid> add to list (removes from the other)
601
+ // pillar trust unlist <uuaid> remove from both lists
602
+ // pillar trust quarantine [--release ID] list quarantined mail / release one
603
+ const home = homePath(args);
604
+ const tPath = join(home, "trust.json");
605
+ const t = loadTrustPolicy(home);
606
+ const sub = args._[1];
607
+ const save = () => writeFileSync(tPath, JSON.stringify({ policy: t.policy, allow: t.allow, block: t.block }, null, 2));
608
+ if (!sub) {
609
+ console.log(`policy: ${t.policy}${t.exists ? "" : " (no trust.json — default)"}`);
610
+ console.log(`allow (${t.allow.length}):`); for (const u of t.allow) console.log(` ${u}`);
611
+ console.log(`block (${t.block.length}):`); for (const u of t.block) console.log(` ${u}`);
612
+ const { Mailbox } = await import("../src/mailbox/mailbox.mjs");
613
+ const mb = new Mailbox(join(home, "mailbox.db"));
614
+ const q = mb.inboxQuarantined(500);
615
+ console.log(`quarantined: ${q.length} (pillar trust quarantine to list)`);
616
+ mb.close();
617
+ return;
618
+ }
619
+ if (sub === "set") {
620
+ const pol = args._[2];
621
+ if (!POLICIES.includes(pol)) { console.error(`usage: pillar trust set <${POLICIES.join("|")}>`); exit(2); }
622
+ t.policy = pol; save();
623
+ console.log(`policy: ${pol} (${tPath})`);
624
+ return;
625
+ }
626
+ if (sub === "allow" || sub === "block") {
627
+ const u = args._[2];
628
+ if (!u?.startsWith("uuaid:")) { console.error(`usage: pillar trust ${sub} <uuaid:...>`); exit(2); }
629
+ t.allow = t.allow.filter(x => x !== u);
630
+ t.block = t.block.filter(x => x !== u);
631
+ t[sub === "allow" ? "allow" : "block"].push(u);
632
+ save();
633
+ console.log(`${sub}: ${u}`);
634
+ return;
635
+ }
636
+ if (sub === "unlist") {
637
+ const u = args._[2];
638
+ t.allow = t.allow.filter(x => x !== u);
639
+ t.block = t.block.filter(x => x !== u);
640
+ save();
641
+ console.log(`unlisted: ${u}`);
642
+ return;
643
+ }
644
+ if (sub === "quarantine") {
645
+ const { Mailbox } = await import("../src/mailbox/mailbox.mjs");
646
+ const mb = new Mailbox(join(home, "mailbox.db"));
647
+ if (args.release) {
648
+ mb.db.prepare("update inbox set quarantined = null where envelope_id = ?").run(args.release);
649
+ console.log(`released: ${args.release} (now visible to pillar inbox --unread)`);
650
+ mb.close();
651
+ return;
652
+ }
653
+ for (const row of mb.inboxQuarantined(500)) {
654
+ console.log(JSON.stringify({ id: row.envelope_id, from: row.sender, reason: row.quarantined, at: row.received_at }));
655
+ }
656
+ mb.close();
657
+ return;
658
+ }
659
+ console.error("usage: pillar trust [set <policy>|allow <uuaid>|block <uuaid>|unlist <uuaid>|quarantine [--release ID]]");
660
+ exit(2);
661
+ }
662
+
663
+ async function cmdTier(args) {
664
+ // pillar tier show installed grant
665
+ // pillar tier issue <uuaid> <tier> [--expires ISO] [--rate N] [--max-body N] [--out FILE]
666
+ // sign a grant with THIS home's key (issuer)
667
+ // pillar tier install <grant.json> install a received grant into this home
668
+ // pillar tier verify <grant.json> --issuer HEX verify signature/expiry locally
669
+ const { makeTierGrant, verifyTierGrant, grantFingerprint, TIER_DEFAULTS } =
670
+ await import("../src/identity/tier.mjs");
671
+ const home = homePath(args);
672
+ const sub = args._[1];
673
+ if (!sub) {
674
+ const tgPath = join(home, "tier-grant.json");
675
+ if (!existsSync(tgPath)) { console.log("no tier grant installed (community tier)"); return; }
676
+ const g = JSON.parse(readFileSync(tgPath, "utf-8"));
677
+ console.log(`tier: ${g.tier} rate: ${g.envelopesPerMin}/min maxBody: ${g.maxBodyBytes}B`);
678
+ console.log(`subject: ${g.uuaid}`);
679
+ console.log(`issuer: ${g.issuer}`);
680
+ console.log(`expires: ${g.expires} fingerprint: ${grantFingerprint(g)}`);
681
+ return;
682
+ }
683
+ if (sub === "issue") {
684
+ const [, , uuaid, tier] = args._;
685
+ if (!uuaid || !TIER_DEFAULTS[tier]) {
686
+ console.error(`usage: pillar tier issue <uuaid:...> <${Object.keys(TIER_DEFAULTS).join("|")}> [--expires ISO] [--rate N] [--max-body BYTES] [--out FILE]`);
687
+ exit(2);
688
+ }
689
+ const kc = new Keychain(join(home, "keychain.json"));
690
+ kc.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
691
+ const expires = args.expires ?? new Date(Date.now() + 365 * 86400_000).toISOString();
692
+ const grant = makeTierGrant(kc, {
693
+ uuaid, tier, expires,
694
+ envelopesPerMin: args.rate ? Number(args.rate) : undefined,
695
+ maxBodyBytes: args["max-body"] ? Number(args["max-body"]) : undefined,
696
+ });
697
+ const out = typeof args.out === "string" ? args.out : `tier-grant-${grantFingerprint(grant)}.json`;
698
+ writeFileSync(out, JSON.stringify(grant, null, 2));
699
+ console.log(`issued ${tier} grant for ${uuaid}`);
700
+ console.log(` rate ${grant.envelopesPerMin}/min, maxBody ${grant.maxBodyBytes}B, expires ${expires}`);
701
+ console.log(` issuer ${grant.issuer}`);
702
+ console.log(` -> ${out} (send to the grantee; they run: pillar tier install ${out})`);
703
+ return;
704
+ }
705
+ if (sub === "install") {
706
+ const file = args._[2];
707
+ if (!file || !existsSync(file)) { console.error("usage: pillar tier install <grant.json>"); exit(2); }
708
+ const g = JSON.parse(readFileSync(file, "utf-8"));
709
+ const kc = new Keychain(join(home, "keychain.json"));
710
+ kc.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
711
+ if (g.uuaid !== kc._identity.uuaid) {
712
+ console.error(`grant subject ${g.uuaid} != this home's identity ${kc._identity.uuaid}`);
713
+ exit(1);
714
+ }
715
+ const v = verifyTierGrant(g, [g.issuer]); // structural check (sig by named issuer, expiry, ceilings)
716
+ if (!v.ok) { console.error(`grant invalid: ${v.reason}`); exit(1); }
717
+ writeFileSync(join(home, "tier-grant.json"), JSON.stringify(g, null, 2));
718
+ console.log(`installed ${g.tier} grant (fingerprint ${grantFingerprint(g)}) — sent with every carrier deliver`);
719
+ console.log(`note: budgets rise only at carriers that trust issuer ${g.issuer.slice(0, 16)}…`);
720
+ return;
721
+ }
722
+ if (sub === "verify") {
723
+ const file = args._[2];
724
+ const issuer = args.issuer;
725
+ if (!file || !existsSync(file) || !issuer) { console.error("usage: pillar tier verify <grant.json> --issuer HEX"); exit(2); }
726
+ const g = JSON.parse(readFileSync(file, "utf-8"));
727
+ const v = verifyTierGrant(g, [String(issuer)]);
728
+ console.log(v.ok ? `VALID: ${g.tier} for ${g.uuaid} until ${g.expires}` : `INVALID: ${v.reason}`);
729
+ exit(v.ok ? 0 : 1);
730
+ }
731
+ console.error("usage: pillar tier [issue|install|verify]");
732
+ exit(2);
733
+ }
734
+
448
735
  async function cmdPeers(args) {
449
736
  const p = loadPillar(args);
450
737
  await p.start();
@@ -465,11 +752,16 @@ async function main() {
465
752
  case "groups": return cmdGroups(args);
466
753
  case "recv": return cmdRecv(args);
467
754
  case "inbox": return cmdInbox(args);
755
+ case "trust": return cmdTrust(args);
756
+ case "tier": return cmdTier(args);
468
757
  case "peers": return cmdPeers(args);
758
+ case "delegate": return cmdDelegate(args);
469
759
  default:
470
- console.error(`usage: pillar <init|id|send|send-group|groups|recv|inbox|peers> [--home DIR]`);
760
+ console.error(`usage: pillar <init|id|send|send-group|groups|recv|inbox|trust|tier|peers|delegate> [--home DIR]`);
471
761
  exit(2);
472
762
  }
473
763
  }
474
764
 
475
- main().catch(e => { console.error(e); exit(1); });
765
+ // A carrier or broker refusal prints one line, `<code>: <fix>` (src/net/refusals.mjs);
766
+ // anything else keeps the full error.
767
+ main().catch(e => { console.error(refusalLine(e) ?? e); exit(1); });