@uuaid/agent-commons 0.0.0 → 0.2.0-alpha.4

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 (78) hide show
  1. package/CONFORMANCE.md +26 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +22 -0
  4. package/PROVENANCE.json +24 -0
  5. package/README.md +404 -5
  6. package/bin/agent-commons.mjs +76 -0
  7. package/examples/Dockerfile +12 -0
  8. package/examples/agent.mjs +17 -0
  9. package/examples/global-policy.json +9 -0
  10. package/examples/run-cohort.mjs +68 -0
  11. package/package.json +31 -8
  12. package/profiles/agentnet.chat.json +11 -0
  13. package/profiles/local.json +11 -0
  14. package/profiles/zilligon.com.json +11 -0
  15. package/profiles/zilligons.com.json +11 -0
  16. package/src/carrier.mjs +84 -0
  17. package/src/channels.d.mts +75 -0
  18. package/src/channels.mjs +158 -0
  19. package/src/cohort.mjs +61 -0
  20. package/src/collaboration.mjs +875 -0
  21. package/src/config.mjs +40 -0
  22. package/src/continuity.d.mts +24 -0
  23. package/src/continuity.mjs +601 -0
  24. package/src/governance-scope.d.mts +46 -0
  25. package/src/governance-scope.mjs +139 -0
  26. package/src/governance.mjs +355 -0
  27. package/src/index.d.ts +111 -0
  28. package/src/index.mjs +15 -0
  29. package/src/loop.mjs +71 -0
  30. package/src/offline.d.mts +7 -0
  31. package/src/offline.mjs +29 -0
  32. package/src/pillar.mjs +59 -0
  33. package/src/profiles.d.mts +12 -0
  34. package/src/profiles.mjs +92 -0
  35. package/src/relay/auth.mjs +294 -0
  36. package/src/relay/board.mjs +223 -0
  37. package/src/relay/claims.mjs +25 -0
  38. package/src/relay/cli.mjs +49 -0
  39. package/src/relay/client.mjs +437 -0
  40. package/src/relay/docs/reconnect-v1.md +27 -0
  41. package/src/relay/docs/working-rules-v1.md +42 -0
  42. package/src/relay/enroll.mjs +232 -0
  43. package/src/relay/export-cli.mjs +47 -0
  44. package/src/relay/governance.mjs +1328 -0
  45. package/src/relay/mailbox.mjs +103 -0
  46. package/src/relay/records.mjs +146 -0
  47. package/src/relay/render.mjs +38 -0
  48. package/src/relay/roster.mjs +134 -0
  49. package/src/relay/routes.mjs +87 -0
  50. package/src/relay/schema.mjs +1081 -0
  51. package/src/relay/seed.mjs +89 -0
  52. package/src/relay/serve.mjs +116 -0
  53. package/src/relay/server.mjs +633 -0
  54. package/src/relay/signing.mjs +693 -0
  55. package/src/relay/test-mode.mjs +73 -0
  56. package/src/runtime.mjs +359 -0
  57. package/src/spaces/manifest.mjs +245 -0
  58. package/src/spaces/pillar-group.mjs +12 -0
  59. package/src/spaces/policy.mjs +229 -0
  60. package/src/spaces/store.mjs +325 -0
  61. package/src/store.mjs +73 -0
  62. package/src/transport.d.mts +76 -0
  63. package/src/transport.mjs +267 -0
  64. package/src/trust.mjs +104 -0
  65. package/src/uuaid-namespace.d.mts +6 -0
  66. package/src/uuaid-namespace.mjs +48 -0
  67. package/src/vendor/pillar/blob/blob.mjs +508 -0
  68. package/src/vendor/pillar/crypto/e2e.mjs +97 -0
  69. package/src/vendor/pillar/groups-v2.mjs +1108 -0
  70. package/src/vendor/pillar/groups.mjs +175 -0
  71. package/src/vendor/pillar/identity/jcs.mjs +34 -0
  72. package/src/vendor/pillar/identity/keychain.mjs +241 -0
  73. package/src/vendor/pillar/identity/tier.mjs +130 -0
  74. package/src/vendor/pillar/net/carrier-client.mjs +219 -0
  75. package/src/vendor/pillar/net/delegation.mjs +209 -0
  76. package/src/vendor/pillar/net/envelope.mjs +258 -0
  77. package/src/vendor/pillar/net/refusals.mjs +129 -0
  78. package/src/vendor/pillar/net/seed-tier.mjs +185 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Envelope v2 — the wire format for pillar-to-pillar messages.
3
+ *
4
+ * ENCRYPTION IS FUNDAMENTAL. There is no plaintext payload mode. Every
5
+ * envelope's payload is sealed to the recipient's key before the envelope
6
+ * is signed. Anyone can VERIFY an envelope (transport signature is public);
7
+ * only the recipient can READ it.
8
+ *
9
+ * Envelope v2 = {
10
+ * version: "uuaid-pillar-envelope/2",
11
+ * id: "env-...",
12
+ * sender: "uuaid:...",
13
+ * recipient: "uuaid:...",
14
+ * kind: "message" | "work-order" | ... (routing hint, NOT secret)
15
+ * createdAt: ISO 8601,
16
+ * enc: { alg: "x25519-hkdf-sha256-aes256gcm", epk, iv, ct }, // sealed payload
17
+ * transportSignature: { alg: "ed25519", keyId, publicKey, signature, created },
18
+ * }
19
+ *
20
+ * The transport signature covers everything (including the ciphertext), so
21
+ * tamper detection works WITHOUT decrypting — carriers verify-then-store.
22
+ * The GCM AAD binds the ciphertext to the envelope id, so a valid ciphertext
23
+ * cannot be transplanted into a different envelope even by the sender.
24
+ *
25
+ * seal() enforces identity binding: recipient UUAID local-id must equal
26
+ * sha256(recipientPublicKey) — you cannot encrypt to a key that doesn't own
27
+ * the UUAID you're addressing.
28
+ */
29
+ import { createHash, randomBytes } from "node:crypto";
30
+ import { jcs } from "../identity/jcs.mjs";
31
+ import { Keychain, isStrictEd25519PublicKey } from "../identity/keychain.mjs";
32
+ import { sealPayload, openPayload, E2E_ALG } from "../crypto/e2e.mjs";
33
+ import { verifyDelegationDoc, delegationCoversKind, CAPABILITY_ONLY_KINDS } from "./delegation.mjs";
34
+
35
+ export const ENVELOPE_VERSION = "uuaid-pillar-envelope/2";
36
+ export { E2E_ALG };
37
+
38
+ /**
39
+ * Build, encrypt, and sign an envelope.
40
+ *
41
+ * keychain: a loaded Keychain (has .sign()).
42
+ * body: {
43
+ * recipient: "uuaid:..." REQUIRED
44
+ * recipientPublicKey: ed25519 pubkey hex REQUIRED (E2E is not optional)
45
+ * kind, payload, id, createdAt optional
46
+ * }
47
+ */
48
+ export function seal(keychain, body) {
49
+ const identity = keychain._identity;
50
+ if (!identity) throw new Error("keychain not loaded");
51
+ const sender = body.sender ?? identity.uuaid;
52
+ if (sender !== identity.uuaid) {
53
+ throw new Error(`envelope.sender ${sender} does not match keychain identity ${identity.uuaid}`);
54
+ }
55
+ if (!body.recipient || typeof body.recipient !== "string") {
56
+ throw new Error("seal: recipient required");
57
+ }
58
+ if (!body.recipientPublicKey || !/^[0-9a-f]{64}$/i.test(body.recipientPublicKey)) {
59
+ throw new Error("seal: recipientPublicKey (ed25519 hex) is REQUIRED — encryption is not optional");
60
+ }
61
+ // Identity binding: the key must own the UUAID.
62
+ const expectedLocalId = Keychain._localIdFromKey(Buffer.from(body.recipientPublicKey, "hex"));
63
+ const recipientLocalId = body.recipient.split(":")[3];
64
+ if (recipientLocalId !== expectedLocalId) {
65
+ throw new Error(`seal: recipientPublicKey does not derive recipient UUAID (key owns ...${expectedLocalId.slice(0, 13)}, envelope addressed to ...${String(recipientLocalId).slice(0, 13)})`);
66
+ }
67
+
68
+ const id = body.id ?? mkEnvelopeId();
69
+ const enc = sealPayload(body.recipientPublicKey, body.payload ?? {}, { aad: id });
70
+
71
+ const envelope = {
72
+ version: ENVELOPE_VERSION,
73
+ id,
74
+ sender,
75
+ recipient: body.recipient,
76
+ kind: body.kind ?? "message",
77
+ enc,
78
+ createdAt: body.createdAt ?? new Date().toISOString(),
79
+ };
80
+ const canonical = jcs(envelope);
81
+ const sigBytes = keychain.sign(Buffer.from(canonical, "utf-8"));
82
+ envelope.transportSignature = {
83
+ alg: "ed25519",
84
+ keyId: identity.uuaid,
85
+ publicKey: identity.publicKeyHex,
86
+ signature: sigBytes.toString("hex"),
87
+ created: envelope.createdAt,
88
+ };
89
+ return envelope;
90
+ }
91
+
92
+ /**
93
+ * Verify an envelope's transport signature and structure. Does NOT decrypt.
94
+ * Anyone (carriers, relays, auditors) can call this.
95
+ * Returns { ok: true } or { ok: false, reason }.
96
+ *
97
+ * DELEGATED ENVELOPES (UACP/1.0 ext): when the optional `delegation` field is
98
+ * present, the self-authenticating-sender rule is replaced by the delegation
99
+ * chain — see openDelegated() below and src/net/delegation.mjs. When the
100
+ * field is ABSENT the checks below are byte-for-byte the legacy behavior.
101
+ */
102
+ export function open(envelope, { cryptoInventory } = {}) {
103
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return { ok: false, reason: "not-object" };
104
+ if (envelope.version !== ENVELOPE_VERSION) return { ok: false, reason: `bad-version ${envelope.version}` };
105
+ const sig = envelope.transportSignature;
106
+ if (!sig || typeof sig !== "object" || sig.alg !== "ed25519") return { ok: false, reason: "missing-or-bad-alg" };
107
+ if (cryptoInventory && cryptoInventory[sig.alg] !== "active") {
108
+ return { ok: false, reason: `alg-not-active:${sig.alg}` };
109
+ }
110
+ if (envelope.delegation !== undefined) {
111
+ return openDelegated(envelope, { cryptoInventory });
112
+ }
113
+ if (sig.keyId !== envelope.sender) return { ok: false, reason: "keyId-sender-mismatch" };
114
+ if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
115
+ if (!isStrictEd25519PublicKey(sig.publicKey)) return { ok: false, reason: "weak-publicKey" }; // @rule envelope.open-strict-key
116
+ if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
117
+ // Structural checks on the encryption block — an envelope without one is invalid.
118
+ const enc = envelope.enc;
119
+ if (!enc || typeof enc !== "object") return { ok: false, reason: "missing-enc" };
120
+ if (enc.alg !== E2E_ALG) return { ok: false, reason: `enc-alg-not-supported:${enc.alg}` };
121
+ if (cryptoInventory && cryptoInventory[enc.alg] && cryptoInventory[enc.alg] !== "active") {
122
+ return { ok: false, reason: `enc-alg-not-active:${enc.alg}` };
123
+ }
124
+ if (typeof enc.epk !== "string" || !/^[0-9a-f]{64}$/i.test(enc.epk)) return { ok: false, reason: "bad-epk" };
125
+ if (typeof enc.iv !== "string" || !/^[0-9a-f]{24}$/i.test(enc.iv)) return { ok: false, reason: "bad-iv" };
126
+ if (typeof enc.ct !== "string" || !/^[0-9a-f]{32,}$/i.test(enc.ct)) return { ok: false, reason: "bad-ct" };
127
+ // Verify the signature over the envelope WITHOUT the signature block.
128
+ const { transportSignature: _sig, ...withoutSig } = envelope;
129
+ let canonical;
130
+ try { canonical = jcs(withoutSig); } catch (_e) { return { ok: false, reason: "bad-canonical" }; }
131
+ let okSig;
132
+ try {
133
+ okSig = Keychain.verifyDetached(sig.publicKey, Buffer.from(canonical, "utf-8"), Buffer.from(sig.signature, "hex"));
134
+ } catch (_e) {
135
+ return { ok: false, reason: "verify-threw" };
136
+ }
137
+ if (!okSig) return { ok: false, reason: "bad-signature" };
138
+ // Sender binding (self-authenticating identity): the sender UUAID's
139
+ // local-id MUST derive from the signing key — the sender-side mirror of
140
+ // seal()'s recipient binding. Without this, anyone can sign an envelope
141
+ // with their own key while claiming another agent's sender UUAID.
142
+ const expectedSenderLocalId = Keychain._localIdFromKey(Buffer.from(sig.publicKey, "hex"));
143
+ if (expectedSenderLocalId !== String(envelope.sender).split(":")[3]) {
144
+ return { ok: false, reason: "publicKey-sender-mismatch" };
145
+ }
146
+ return { ok: true };
147
+ }
148
+
149
+ /**
150
+ * Delegated path of open() — UACP/1.0 extension. Invoked when the envelope
151
+ * carries a `delegation` document; the legacy self-authenticating-sender rule
152
+ * is REPLACED by the five delegation checks (see delegation.mjs):
153
+ *
154
+ * (a) envelope signature valid under the DELEGATE's key,
155
+ * transportSignature.keyId === delegation.delegate;
156
+ * (b) delegation document signature valid under the PRINCIPAL's key,
157
+ * delegation.principal === envelope.sender,
158
+ * delegation.delegate === the signing key's uuaid;
159
+ * (c) local-id binding for BOTH identities (inside verifyDelegationDoc);
160
+ * (d) now within [notBefore, notAfter] (inside verifyDelegationDoc);
161
+ * (e) envelope.kind covered by delegation.kinds.
162
+ *
163
+ * Fail-open is not an option: any failure rejects with
164
+ * { ok: false, reason: "delegation-invalid:<reason>", code: 403 }. Pure
165
+ * wire-format failures (enc block, hex encodings) keep the legacy reasons and
166
+ * 400 status — they are not delegation verdicts.
167
+ *
168
+ * The `delegation` field is part of the SIGNED body (sealed inside the
169
+ * canonicalization by the delegate), so a carrier cannot strip or swap it
170
+ * without breaking the envelope signature.
171
+ */
172
+ function openDelegated(envelope, { cryptoInventory } = {}) {
173
+ const reject = (reason) => ({ ok: false, reason: `delegation-invalid:${reason}`, code: 403 });
174
+ const sig = envelope.transportSignature;
175
+ if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
176
+ if (!isStrictEd25519PublicKey(sig.publicKey)) return { ok: false, reason: "weak-publicKey" }; // @rule envelope.open-delegated-strict-key
177
+ if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
178
+ // Structural checks on the encryption block — same rules as the legacy path.
179
+ const enc = envelope.enc;
180
+ if (!enc || typeof enc !== "object") return { ok: false, reason: "missing-enc" };
181
+ if (enc.alg !== E2E_ALG) return { ok: false, reason: `enc-alg-not-supported:${enc.alg}` };
182
+ if (cryptoInventory && cryptoInventory[enc.alg] && cryptoInventory[enc.alg] !== "active") {
183
+ return { ok: false, reason: `enc-alg-not-active:${enc.alg}` };
184
+ }
185
+ if (typeof enc.epk !== "string" || !/^[0-9a-f]{64}$/i.test(enc.epk)) return { ok: false, reason: "bad-epk" };
186
+ if (typeof enc.iv !== "string" || !/^[0-9a-f]{24}$/i.test(enc.iv)) return { ok: false, reason: "bad-iv" };
187
+ if (typeof enc.ct !== "string" || !/^[0-9a-f]{32,}$/i.test(enc.ct)) return { ok: false, reason: "bad-ct" };
188
+
189
+ const d = envelope.delegation;
190
+ // (b-i) + (c) + (d): the delegation document itself — shape, both local-id
191
+ // bindings, time window, and the principal's signature over it.
192
+ const dv = verifyDelegationDoc(d);
193
+ if (!dv.ok) return reject(dv.reason);
194
+ // (b-ii) the persona this envelope acts as must be the delegation principal.
195
+ if (d.principal !== envelope.sender) return reject("sender-not-principal");
196
+ // (b-iii) the actual signer must be the named delegate, key and all.
197
+ if (sig.keyId !== d.delegate) return reject("signer-not-delegate");
198
+ if (sig.publicKey !== d.delegatePublicKey) return reject("signer-key-mismatch");
199
+ // (e) the delegation must cover this envelope kind.
200
+ //
201
+ // A capability name is checked FIRST and unconditionally — including under
202
+ // kinds:["*"] — because these names are not envelope kinds at all. Without
203
+ // this, a read-only kinds:["inbox"] poll token doubles as authority to send
204
+ // envelopes of kind "inbox" as the principal. See CAPABILITY_ONLY_KINDS.
205
+ if (CAPABILITY_ONLY_KINDS.has(envelope.kind)) return reject("kind-reserved-capability");
206
+ if (!delegationCoversKind(d, envelope.kind)) return reject("kind-not-covered");
207
+ // (a) the envelope signature under the delegate key, over the canonical
208
+ // body INCLUDING the delegation field (exactly what the delegate signed).
209
+ const { transportSignature: _sig, ...withoutSig } = envelope;
210
+ let canonical;
211
+ try { canonical = jcs(withoutSig); } catch (_e) { return { ok: false, reason: "bad-canonical" }; }
212
+ let okSig;
213
+ try {
214
+ okSig = Keychain.verifyDetached(sig.publicKey, Buffer.from(canonical, "utf-8"), Buffer.from(sig.signature, "hex"));
215
+ } catch (_e) {
216
+ return { ok: false, reason: "verify-threw" };
217
+ }
218
+ if (!okSig) return reject("bad-envelope-signature");
219
+ return { ok: true, delegated: true, actingFor: d.principal };
220
+ }
221
+
222
+ /**
223
+ * Decrypt an envelope's payload. Only the recipient can do this.
224
+ * keychain must be the recipient's loaded keychain.
225
+ * Throws on wrong recipient, wrong key, tamper, or AAD mismatch.
226
+ */
227
+ export function decrypt(keychain, envelope) {
228
+ const identity = keychain._identity;
229
+ if (!identity) throw new Error("decrypt: keychain not loaded");
230
+ if (envelope.recipient !== identity.uuaid) {
231
+ throw new Error(`decrypt: envelope addressed to ${envelope.recipient}, not us (${identity.uuaid})`);
232
+ }
233
+ const seed = keychainSeed(keychain);
234
+ return openPayload(seed, envelope.enc, { aad: envelope.id });
235
+ }
236
+
237
+ /** Extract the 32-byte Ed25519 seed from a loaded keychain. */
238
+ export function keychainSeed(keychain) {
239
+ const der = keychain._identity.privateKey.export({ type: "pkcs8", format: "der" });
240
+ return Buffer.from(der.subarray(der.length - 32));
241
+ }
242
+
243
+ /**
244
+ * Envelope id: 16 random bytes hex + a small timestamp prefix for
245
+ * human-readable sort. Not sensitive; not secret. Used as GCM AAD.
246
+ */
247
+ export function mkEnvelopeId() {
248
+ const now = Date.now().toString(36);
249
+ const rnd = randomBytes(8).toString("hex");
250
+ return `env-${now}-${rnd}`;
251
+ }
252
+
253
+ /**
254
+ * Content-hash of an envelope, computable by anyone.
255
+ */
256
+ export function envelopeSha(envelope) {
257
+ return createHash("sha256").update(jcs(envelope)).digest("hex");
258
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Refusals in agent language: every refusal is a stable `code` plus one
3
+ * imperative `fix` line, and the CLIs print exactly `<code>: <fix>`.
4
+ *
5
+ * The carrier's codes are the `reason` (refused send) or `error` (refused
6
+ * inbox read) values it has always sent (carrier-server.mjs, envelope.mjs,
7
+ * delegation.mjs). Those values and their status codes never change here;
8
+ * the carrier only ADDS a `fix` field, and a client talking to an older
9
+ * carrier looks the fix up in this same map. The blob broker sends its own
10
+ * `code` + `fix`, which is printed as received.
11
+ *
12
+ * CLOSED AND STATIC. Every fix this package produces is a value of
13
+ * REFUSAL_FIXES: a fixed string, never built from request data, a remote
14
+ * reason or an exception message. A parameterised reason
15
+ * (`delegation-invalid:<why>`, `bad-version <v>`, `alg-not-active:<alg>`)
16
+ * resolves by its family key, so the parameter never reaches the fix.
17
+ *
18
+ * Documented, with statuses, in HOSTING-LIMITS.md "Refusal codes".
19
+ */
20
+
21
+ const RESEAL = "re-seal the envelope with seal() from @uuaid/pillar or @uuaid/pillar-client; this one does not verify";
22
+
23
+ /** code -> one-line fix. Frozen: add, never rename, never interpolate. */
24
+ export const REFUSAL_FIXES = Object.freeze({
25
+ // Carrier, POST /v1/envelopes
26
+ "rate-limited": "back off: budgets refill over a sliding 60 s, so retry after 5 s, doubling to 60 s",
27
+ "body-too-large": "send the payload as a blob (pillar-blob send); maxBodyBytes is your tier's inline limit",
28
+ "stale-envelope": "seal a new envelope and check your clock; createdAt must be within 24 h of carrier time",
29
+ "carrier-full": "use your other carriers and do not retry this one soon; its store is full",
30
+ "bad-recipient": "address the envelope to a full UUAID (uuaid:<namespace>:<type>:<id>)",
31
+ "bad-json": "POST the envelope as one JSON object",
32
+ "keyId-sender-mismatch": "sign with transportSignature.keyId equal to the sender UUAID, or send under a delegation",
33
+ "publicKey-sender-mismatch": "send from the UUAID your signing key derives, or attach the principal's delegation",
34
+ "bad-signature": "sign again with this identity's own key over the exact bytes; the signature does not verify",
35
+ "weak-publicKey": "use a real Ed25519 identity key; this key is a small-order or non-canonical point that can sign anything, so no signature under it counts",
36
+ "delegation-invalid": "mint a fresh delegation from the principal, then resend",
37
+ "envelope-id-conflict": "seal a new envelope with a fresh id; this carrier already holds a different envelope under this id",
38
+ // Carrier, envelope shape and crypto (envelope.mjs open()): one fix for all
39
+ "not-object": RESEAL,
40
+ "bad-version": RESEAL,
41
+ "missing-or-bad-alg": RESEAL,
42
+ "alg-not-active": RESEAL,
43
+ "bad-publicKey": RESEAL,
44
+ "bad-signature-encoding": RESEAL,
45
+ "missing-enc": RESEAL,
46
+ "enc-alg-not-supported": RESEAL,
47
+ "enc-alg-not-active": RESEAL,
48
+ "bad-epk": RESEAL,
49
+ "bad-iv": RESEAL,
50
+ "bad-ct": RESEAL,
51
+ "bad-canonical": RESEAL,
52
+ "verify-threw": RESEAL,
53
+ // Carrier, GET /v1/inbox/<uuaid>
54
+ "missing-auth-headers": "send x-pillar-pubkey, x-pillar-ts and x-pillar-sig",
55
+ "bad-pubkey": "send x-pillar-pubkey as the raw Ed25519 key in 64 hex characters",
56
+ "bad-sig-encoding": "send x-pillar-sig as 128 hex characters",
57
+ "clock-skew": "fix your clock (NTP) to within 5 min of the carrier; retrying does not help",
58
+ "uuaid-pubkey-mismatch": "read the inbox of the UUAID your key derives, or send that UUAID's delegation in x-pillar-delegation",
59
+ "delegation-unparseable": "send x-pillar-delegation as the JSON delegation document",
60
+ "delegation-too-large": "send a delegation document under 8,192 bytes",
61
+ "delegation-principal-mismatch": "use a delegation whose principal is the inbox UUAID",
62
+ "delegation-delegate-key-mismatch": "sign with the key the delegation names as delegate",
63
+ "delegation-inbox-denied": "ask the principal for a delegation that covers inbox (or *)",
64
+ "busy": "retry after at least 2 s with jitter, or poll with wait=0 for a while",
65
+ // Carrier, any route
66
+ "not-found": "use POST /v1/envelopes or GET /v1/inbox/<uuaid>",
67
+ "not-a-seed": "do not list this carrier as a seed; it runs without a seed identity",
68
+ "internal-error": "retry later; if it repeats, tell the carrier operator",
69
+ // Client side: no server gave a code
70
+ "carrier-unreachable": "check the carrier URLs and your network, then retry",
71
+ "carrier-bad-response": "retry, or use another carrier; the carrier or its proxy answered without a reason",
72
+ "over-tier-limit": "send the payload as a blob (pillar-blob send, or Pillar.sendBlob)",
73
+ "blob-key-unproven": "use a self-certifying agent UUAID derived from this key (pillar init), or get a registry badge that names this key",
74
+ "sender-key-not-active": "ask the sender to send from a self-certifying agent UUAID derived from its own key, or to get a registry badge naming its key",
75
+ "group-v2-use-v2-send": "send to this pillar-group/2 group with pillar group send <group>; v1 group sends (send-group, sendGroup) do not reach it",
76
+ });
77
+
78
+ /**
79
+ * The fix for a carrier reason, or null for a reason this map does not know.
80
+ * An exact key wins; otherwise the reason's family (the part before the
81
+ * first ":" or space) is looked up, so `delegation-invalid:expired` and
82
+ * `bad-version 2` resolve to their family's fix. The result is always a
83
+ * value of REFUSAL_FIXES or null; nothing from `reason` is copied into it.
84
+ */
85
+ export function carrierFix(reason) {
86
+ if (typeof reason !== "string" || !reason) return null;
87
+ if (Object.hasOwn(REFUSAL_FIXES, reason)) return REFUSAL_FIXES[reason];
88
+ const family = reason.split(/[: ]/, 1)[0];
89
+ return family !== reason && Object.hasOwn(REFUSAL_FIXES, family) ? REFUSAL_FIXES[family] : null;
90
+ }
91
+
92
+ /**
93
+ * A carrier response body with its `fix` added. Pure addition: only a
94
+ * refusal (status >= 400) that names a `reason` or `error` and has no `fix`
95
+ * yet gains one; every other field, and every other body, is returned as is.
96
+ */
97
+ export function withCarrierFix(status, body) {
98
+ if (!(status >= 400) || !body || typeof body !== "object" || Array.isArray(body) || body.fix !== undefined) return body;
99
+ const fix = carrierFix(typeof body.reason === "string" ? body.reason : body.error);
100
+ return fix ? { ...body, fix } : body;
101
+ }
102
+
103
+ /**
104
+ * Terminal safety only: a line break or control character a remote sent
105
+ * becomes a space, so the output stays one line and cannot drive the
106
+ * terminal. Every other character is printed as received.
107
+ */
108
+ function oneLine(value) {
109
+ return String(value).replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
110
+ }
111
+
112
+ /**
113
+ * `<code>: <fix>` for an error that carries both (a carrier or broker
114
+ * refusal, or a client error that knows its next step), else null so the
115
+ * caller keeps its old output. A server's fix is printed as received.
116
+ */
117
+ export function refusalLine(error) {
118
+ const code = typeof error?.code === "string" ? oneLine(error.code) : "";
119
+ const fix = typeof error?.fix === "string" ? oneLine(error.fix) : "";
120
+ return code && fix ? `${code}: ${fix}` : null;
121
+ }
122
+
123
+ /** `{ code, fix }` of an error, for a result row, omitting what is absent. */
124
+ export function refusalFields(error) {
125
+ return {
126
+ ...(typeof error?.code === "string" && error.code ? { code: error.code } : {}),
127
+ ...(typeof error?.fix === "string" && error.fix ? { fix: error.fix } : {}),
128
+ };
129
+ }
@@ -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
+ }