@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
@@ -0,0 +1,119 @@
1
+ /**
2
+ * TransferVerifier — the VDA-1 `transfer` instrument (spec: docs/VDA-1-SPEC.md).
3
+ * "Did transfer X (from, to, amount, asset) really settle on rail R?" is
4
+ * answered from the RAIL'S OWN record (chain receipt + logs at depth), never
5
+ * from the claimant's story, and emitted as a signed, content-addressed
6
+ * VDA-1 envelope.
7
+ *
8
+ * Design carried from the uuaid/iaaso governance stack: content-addressed
9
+ * ids (sha256(jcs(claim))[:16]); Ed25519 over JCS (offline verification);
10
+ * fail-closed token registry; instrument-blind honesty (fleet SOP §4.12) —
11
+ * a rail structurally blind to a claim answers "instrument-blind", never a
12
+ * vacuous negative. Every envelope carries a mandatory instrument-coverage
13
+ * statement (spec §5.1).
14
+ *
15
+ * Envelopes emitted before VDA-1 (kind "transfer-attestation", claims
16
+ * without `kind`) remain verifiable via verifyAttestationSig/verifyEnvelope —
17
+ * accepted read-only, never emitted (spec §4 legacy note).
18
+ */
19
+ import { jcs } from "../identity/jcs.mjs";
20
+ import { Keychain } from "../identity/keychain.mjs";
21
+ import { railKeyOfTerms } from "./rails/index.mjs";
22
+ import { normalizeClaim, buildAttestation } from "../vda/core.mjs";
23
+
24
+ export const ATTESTATION_V = 1;
25
+
26
+ // NOTE: the old 16-hex `claimId` export was deleted (RedTeam round-2 rec 5)
27
+ // so a truncated join key cannot be resurrected. Legacy envelope ids are
28
+ // handled inside vda/core.mjs (legacyClaimId, read-only branch).
29
+
30
+ /** Offline signature check for VDA-1 AND legacy envelopes. */
31
+ export function verifyAttestationSig(publicKeyHex, attestation) {
32
+ try {
33
+ const { sig, ...rest } = attestation;
34
+ if (attestation.v !== ATTESTATION_V || typeof sig !== "string") return false;
35
+ return Keychain.verifyDetached(publicKeyHex, Buffer.from(jcs(rest), "utf-8"), Buffer.from(sig, "hex"));
36
+ } catch (_e) {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ export class TransferVerifier {
42
+ /**
43
+ * @param rails RailSet (rails may implement verifyTransfer; those
44
+ * that don't are instrument-blind for standalone claims)
45
+ * @param keychain loaded Keychain; attestations are signed by it.
46
+ * Omit for unsigned (local-only) verification.
47
+ * @param tokenRegistry optional fail-closed allowlist (chains + tokens)
48
+ */
49
+ constructor({ rails, keychain = null, tokenRegistry = null }) {
50
+ if (!rails) throw new Error("TransferVerifier: rails required");
51
+ this.rails = rails;
52
+ this.keychain = keychain;
53
+ this.tokenRegistry = tokenRegistry;
54
+ }
55
+
56
+ /**
57
+ * Verify one transfer claim and return a (signed) VDA-1 attestation.
58
+ * claim: { rail: "evm"|"zilligon", chainId?, txId, from?, to, amount, asset? }
59
+ * Amounts are decimal base-unit strings (wei / Gons) — spec §2.
60
+ */
61
+ async verify(claimRaw) {
62
+ const n = normalizeClaim({ ...claimRaw, kind: "transfer" });
63
+ const coverageRefused =
64
+ "Verifier refused before consulting any instrument; this attestation observes nothing about the event.";
65
+ if (!n.ok) {
66
+ const claim = normalizeClaim({ kind: "malformed", originalKind: "transfer" }).claim;
67
+ return buildAttestation({ claim, verdict: "unsupported", reason: n.reason, coverage: coverageRefused, keychain: this.keychain });
68
+ }
69
+ const claim = n.claim;
70
+ const finish = (verdict, extra = {}) => buildAttestation({ claim, verdict, keychain: this.keychain, ...extra });
71
+
72
+ const railKey = railKeyOfTerms({ v: 2, rail: claim.rail, chainId: claim.chainId });
73
+ const rail = railKey ? this.rails.get(railKey) : null;
74
+ if (!rail) return finish("unsupported", { reason: "rail-unsupported", coverage: coverageRefused });
75
+
76
+ // Fail-closed allowlist, same gate the escrow applies at the door.
77
+ if (this.tokenRegistry && claim.rail === "evm") {
78
+ if (!this.tokenRegistry.isChainApproved(claim.chainId)) {
79
+ return finish("unsupported", { reason: "chain-not-registered", coverage: coverageRefused });
80
+ }
81
+ if (claim.asset !== "native" && !this.tokenRegistry.isAssetApproved(claim.chainId, claim.asset)) {
82
+ return finish("unsupported", { reason: "token-not-registered", coverage: coverageRefused });
83
+ }
84
+ }
85
+
86
+ if (typeof rail.verifyTransfer !== "function") {
87
+ return finish("instrument-blind", {
88
+ reason: "rail-cannot-attest-standalone-claims",
89
+ coverage:
90
+ "Instrument: this rail sees only its own wallet's rows and cannot observe arbitrary third-party transfers; " +
91
+ "this verdict is explicitly NOT evidence the transfer did not settle.",
92
+ });
93
+ }
94
+
95
+ const coverage =
96
+ `Instrument: the ${claim.rail} rail's own record (receipt and event logs at confirmation depth) via a single ` +
97
+ "RPC endpoint; full sight of the transaction hash space on that chain, subject to that endpoint's honesty — " +
98
+ "use a quorum of independent verifiers to bound endpoint trust.";
99
+
100
+ let res;
101
+ try {
102
+ res = await rail.verifyTransfer({ txId: claim.txId, from: claim.from, to: claim.to, amount: claim.amount, asset: claim.asset });
103
+ } catch (e) {
104
+ return finish("unsupported", { reason: `instrument-error: ${String(e.message ?? e)}`, coverage });
105
+ }
106
+ if (res?.verified) {
107
+ return finish("verified", {
108
+ coverage,
109
+ // blockNumber as a string: spec §3 — quantities that can exceed 2^53
110
+ evidence: { blockNumber: String(res.blockNumber), depth: res.depth, from: res.from, to: res.to, amount: res.amount, asset: res.asset },
111
+ });
112
+ }
113
+ if (res?.reason === "unconfirmed") return finish("pending", { reason: "below-confirmation-depth", coverage });
114
+ if (res?.reason === "instrument-blind") {
115
+ return finish("instrument-blind", { reason: res.detail ?? "instrument-blind", coverage });
116
+ }
117
+ return finish("failed", { reason: res?.reason ?? "not-found", coverage });
118
+ }
119
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Zilligon ZGN rail client — the ONLY money surface of the blind escrow.
3
+ *
4
+ * VERIFIED live against zilligon.com (2026-08-21, read-only / OPTIONS /
5
+ * invalid-body probes — no money has ever moved):
6
+ * POST /api/v1/auth/token {apiKey, grantType:"api_key"}
7
+ * -> {success, data:{accessToken, expiresIn:900, scopes}}
8
+ * POST /api/v1/wallet/transfer {recipientHandle, amount} (JWT Bearer)
9
+ * The route EXISTS (OPTIONS -> 204 Allow: OPTIONS, POST) though it
10
+ * appears in NO docs. Validation, elicited with invalid bodies:
11
+ * recipientHandle required string; amount must be a positive number
12
+ * (strings rejected, non-integers NOT rejected at validation);
13
+ * unknown handle -> 404 RECIPIENT_NOT_FOUND; self-transfer -> 400
14
+ * SELF_TRANSFER; extra fields tolerated.
15
+ * LIVE-VERIFIED end-to-end 2026-08-21: the rewards-service sidecar is
16
+ * now DEPLOYED on the prod cluster (REWARDS_SERVICE_URL +
17
+ * INTERNAL_SERVICE_SECRET wired on the live task-def) and an
18
+ * owner-authorized 10-Gon transfer returned HTTP 200 with
19
+ * {transactionId, newBalance, recipient, amount}. transactionId is
20
+ * the Tip row's id, which equals BOTH wallet rows' sourceId — the
21
+ * response's txId is the cross-side link (verified on the live rows).
22
+ * Also verified live: recipient must be status "ACTIVE" or the route
23
+ * returns 404 RECIPIENT_NOT_FOUND — 46,162 of the agents are DORMANT
24
+ * and only 37 platform-wide can receive transfers at all.
25
+ * GET /api/token/wallet?handle=<h> public by handle, NO auth — but a
26
+ * browser User-Agent is REQUIRED (403 "Automated access not
27
+ * permitted" without one, 200 with). Last ~20 tx rows:
28
+ * -> {handle, reputation, wallet:{zgnBalance, zgnLocked, zgnStaked,
29
+ * transactions:[{type, amount, currency, status, source, date}]}}
30
+ *
31
+ * VERIFIED tx vocabulary (20 rows sampled across 3 wallets, 2026-08-21):
32
+ * every observed row is {type:"EARNING", currency:"GONS",
33
+ * status:"CONFIRMED", source:"CODE_FORGE", date:ISO}.
34
+ *
35
+ * TRANSFER ROW CONTRACT (verified LIVE in-container 2026-08-21 on the prod
36
+ * DB — matches services/rewards-service internal.ts:290-348): one Tip + two
37
+ * WalletTransaction rows in a single prisma.$transaction:
38
+ * sender row: type "TIP_SENT", amount NEGATIVE, fee set,
39
+ * receiver row: type "TIP_RECEIVED", amount net
40
+ * (amount - floor(amount*0.005)), fee unset -> 0,
41
+ * both rows: currency "GONS", sourceType "transfer" (public API projects
42
+ * `source: "transfer"`), sourceId = tip.id (the ONLY link between the two
43
+ * sides — and the id the transfer API returns as transactionId), status
44
+ * "CONFIRMED", balanceAfter set. A row's own Prisma cuid `id` is NOT the
45
+ * transactionId.
46
+ * The public wallet API (/api/token/wallet) historically projected ONLY
47
+ * {type, amount, currency, status, source, date}; an additive projection fix
48
+ * (Zilligon-Lead, 2026-08-21) adds {id, sourceId, fee, balanceAfter}.
49
+ * Consequence for the matchers:
50
+ * - "source" on a transfer row is the literal string "transfer", NOT a
51
+ * handle — source-binding is only enforced when the field actually
52
+ * looks like a handle ("@..."), otherwise the row stays unbound.
53
+ * - TIP_SENT rows carry NEGATIVE amounts — the payout matcher compares
54
+ * magnitudes.
55
+ * - A buyer's claimed txId (the transfer response's transactionId) equals
56
+ * row.sourceId, NOT row.id — the matchers accept a claimed txId against
57
+ * EITHER, bind on an explicit sourceId claim, and HARD-REJECT a row
58
+ * whose sourceId disagrees with the claim (no silent amount+window
59
+ * fallthrough). Pre-fix rows (no id/sourceId in the projection) still
60
+ * degrade to amount+window attestation — the documented caveat for the
61
+ * IAASO ratification doc.
62
+ *
63
+ * Fee (verified in source: config.ts:52 tipFeePercent 0.005 + internal.ts:288
64
+ * fee = floor(amount * 0.005)): every transfer below 200 Gons is FEE-FREE;
65
+ * the receiver nets exactly `amount`. 200 Gons is the first amount that
66
+ * pays 1. The escrow fee math in terms.mjs mirrors this exactly.
67
+ */
68
+
69
+ const BROWSER_UA = "Mozilla/5.0"; // the exact UA verified to pass the wallet-view gate
70
+
71
+ /**
72
+ * Verified background-noise tx types — never a payment/payout signal.
73
+ * TIP_SENT / TIP_RECEIVED (the transfer-created rows) are NOT noise — they
74
+ * are the exchange rows the matchers hunt for.
75
+ */
76
+ export const TX_NOISE_TYPES = ["earning"];
77
+
78
+ export class ZilligonClient {
79
+ constructor({ baseUrl = "https://zilligon.com", apiKey, handle }) {
80
+ if (!apiKey) throw new Error("ZilligonClient: apiKey required");
81
+ if (!handle) throw new Error("ZilligonClient: handle required (escrow's own @handle)");
82
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
83
+ this.apiKey = apiKey;
84
+ this.handle = handle;
85
+ this._token = null; // { accessToken, expiresAt }
86
+ }
87
+
88
+ async getToken() {
89
+ if (this._token && this._token.expiresAt > Date.now() + 30_000) return this._token.accessToken;
90
+ const res = await fetch(`${this.baseUrl}/api/v1/auth/token`, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({ apiKey: this.apiKey, grantType: "api_key" }),
94
+ });
95
+ if (!res.ok) throw new Error(`zilligon auth/token: HTTP ${res.status}`);
96
+ const json = await res.json();
97
+ if (json.success !== true || !json.data?.accessToken) {
98
+ throw new Error(`zilligon auth/token rejected: ${json.error || json.message || "unknown"}`);
99
+ }
100
+ this._token = {
101
+ accessToken: json.data.accessToken,
102
+ expiresAt: Date.now() + (json.data.expiresIn ?? 900) * 1000,
103
+ };
104
+ return this._token.accessToken;
105
+ }
106
+
107
+ async _authed(method, path, body) {
108
+ const token = await this.getToken();
109
+ const res = await fetch(`${this.baseUrl}${path}`, {
110
+ method,
111
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
112
+ body: body === undefined ? undefined : JSON.stringify(body),
113
+ });
114
+ const json = await res.json().catch(() => ({}));
115
+ if (!res.ok || json.success === false) {
116
+ throw new Error(`zilligon ${path}: HTTP ${res.status} ${json.error || json.message || ""}`.trim());
117
+ }
118
+ return json;
119
+ }
120
+
121
+ /**
122
+ * Transfer Gons to a handle. The body carries ONLY the verified fields
123
+ * {recipientHandle, amount}. Local guards mirror the rail's verified
124
+ * rejection set (self-transfer) plus an integer guard stricter than the
125
+ * rail's validation (which accepts any positive number — Gons are whole).
126
+ */
127
+ async transfer({ recipientHandle, amount }) {
128
+ if (typeof amount !== "number" || !Number.isInteger(amount) || amount < 1) {
129
+ throw new Error(`transfer: amount must be an integer >= 1 Gons, got ${amount}`);
130
+ }
131
+ if (recipientHandle === this.handle) throw new Error("transfer: self-transfer rejected by rail");
132
+ return this._authed("POST", "/api/v1/wallet/transfer", { recipientHandle, amount });
133
+ }
134
+
135
+ /** Public wallet view for a handle — needs a browser User-Agent (verified). */
136
+ async getWallet(handle = this.handle) {
137
+ const res = await fetch(`${this.baseUrl}/api/token/wallet?handle=${encodeURIComponent(handle)}`, {
138
+ headers: { "User-Agent": BROWSER_UA },
139
+ });
140
+ if (!res.ok) throw new Error(`zilligon wallet?handle=: HTTP ${res.status}`);
141
+ return res.json();
142
+ }
143
+
144
+ /**
145
+ * Attest the buyer's payment from THIS client's wallet: poll until a
146
+ * matching non-noise row appears. Returns the row, or null once attempts
147
+ * are exhausted.
148
+ */
149
+ async findPayment({ buyerHandle, expectedAmount, txId, sourceId, since, attempts = 6, delayMs = 5000 }) {
150
+ for (let i = 0; i < attempts; i++) {
151
+ let view;
152
+ try {
153
+ view = await this.getWallet(this.handle);
154
+ } catch (_e) {
155
+ view = null; // transient rail hiccup — retry
156
+ }
157
+ const hit = view ? findPaymentInWallet(view, { buyerHandle, expectedAmount, txId, sourceId, since }) : null;
158
+ if (hit) return hit;
159
+ await new Promise((r) => setTimeout(r, delayMs));
160
+ }
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /** Extract a transaction id from any shape the rail might return. */
166
+ export function txIdOf(tx) {
167
+ return tx?.txId ?? tx?.id ?? tx?.transactionId ?? tx?._id ?? null;
168
+ }
169
+
170
+ const asNum = (v) => {
171
+ const n = Number(v);
172
+ return Number.isFinite(n) ? n : null;
173
+ };
174
+
175
+ /**
176
+ * Handle-like source/destination fields ("@handle") bind a row to a
177
+ * counterparty. Transfer rows carry the literal type string ("transfer") in
178
+ * `source` — NOT a handle — so binding must only fire on handle-shaped values.
179
+ */
180
+ const looksLikeHandle = (s) => typeof s === "string" && s.startsWith("@");
181
+
182
+ /** A row is a candidate payment/payout iff its type is not verified noise. */
183
+ function isExchangeRow(tx) {
184
+ const t = String(tx?.type ?? "").toLowerCase();
185
+ if (!t) return true; // unknown vocabulary — can't classify as noise
186
+ return !TX_NOISE_TYPES.includes(t);
187
+ }
188
+
189
+ /** ISO-string window check; rows that can't be compared are NOT filtered. */
190
+ function afterSince(dateStr, sinceStr) {
191
+ const d = Date.parse(dateStr);
192
+ const s = Date.parse(sinceStr);
193
+ if (Number.isNaN(d) || Number.isNaN(s)) return true;
194
+ return d >= s;
195
+ }
196
+
197
+ /**
198
+ * Pure matcher — shared by the live client and the integration-test mock so
199
+ * the mock exercises the SAME attestation logic. A payment hit needs:
200
+ * 1. a non-noise row (only EARNING rows are proven noise; transfer rows
201
+ * are TIP_SENT/TIP_RECEIVED — non-noise, see header),
202
+ * 2. amount === expectedAmount Gons exactly,
203
+ * 3. date >= since, when a window is given,
204
+ * 4. counterparty binding, strongest first (verified live 2026-08-21: the
205
+ * transfer response's transactionId equals BOTH rows' sourceId, NOT a
206
+ * row's own id — so a claimed txId matches against EITHER):
207
+ * a. claimed txId === row.id OR row.sourceId -> hit;
208
+ * b. explicit sourceId claim === row.sourceId -> hit;
209
+ * c. row.sourceId present but DISAGREEING with a claim -> the row is
210
+ * someone else's transfer: hard-skip, never amount+window
211
+ * fallthrough;
212
+ * 5. and — when the row carries a handle-SHAPED source field ("@...") and
213
+ * buyerHandle is known — that source equals the buyer. A non-handle
214
+ * source (the literal "transfer" on rail rows) with no id/sourceId
215
+ * (pre-projection-fix rows) leaves the row UNBOUND: it attests by
216
+ * amount+window alone.
217
+ */
218
+ export function findPaymentInWallet(walletView, { buyerHandle, expectedAmount, txId, sourceId, since }) {
219
+ const wallet = walletView?.wallet ?? walletView ?? {};
220
+ const txs = Array.isArray(wallet.transactions) ? wallet.transactions : [];
221
+ for (const tx of txs) {
222
+ if (typeof tx !== "object" || tx === null) continue;
223
+ if (!isExchangeRow(tx)) continue;
224
+ if (asNum(tx.amount) !== expectedAmount) continue;
225
+ if (since && !afterSince(tx.date, since)) continue;
226
+ const source = tx.source ?? tx.from ?? tx.senderHandle ?? null;
227
+ const id = txIdOf(tx);
228
+ const rowSourceId = tx.sourceId ?? null;
229
+ // (a) claimed txId matches row id or row sourceId — the strongest link
230
+ // (the transfer API's transactionId IS the rows' sourceId).
231
+ if (txId && ((id && String(id) === String(txId)) || (rowSourceId && String(rowSourceId) === String(txId)))) return tx;
232
+ // (b) explicit sourceId claim matches the row's.
233
+ if (sourceId && rowSourceId && String(rowSourceId) === String(sourceId)) return tx;
234
+ // (c) the row carries a sourceId that conflicts with our claims — it is
235
+ // someone else's transfer: hard-skip.
236
+ if (rowSourceId && ((txId && String(rowSourceId) !== String(txId)) || (sourceId && String(rowSourceId) !== String(sourceId)))) continue;
237
+ if (buyerHandle && looksLikeHandle(source) && source !== buyerHandle) continue; // bound and mismatched
238
+ return tx;
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * Matcher for the escrow's own payout: a non-noise row of the exact amount
245
+ * (magnitude — the escrow's sender-side rows are TIP_SENT with NEGATIVE
246
+ * amounts, see header). dest-binding only fires on handle-shaped fields;
247
+ * an explicit sourceId claim binds hard (match -> hit, disagreement -> skip).
248
+ */
249
+ export function findPayoutInWallet(walletView, { sellerHandle, amount, sourceId, since }) {
250
+ const wallet = walletView?.wallet ?? walletView ?? {};
251
+ const txs = Array.isArray(wallet.transactions) ? wallet.transactions : [];
252
+ for (const tx of txs) {
253
+ if (typeof tx !== "object" || tx === null) continue;
254
+ if (!isExchangeRow(tx)) continue;
255
+ const mag = asNum(tx.amount);
256
+ if (mag === null || Math.abs(mag) !== amount) continue; // TIP_SENT rows are negative (see header)
257
+ if (since && !afterSince(tx.date, since)) continue;
258
+ const rowSourceId = tx.sourceId ?? null;
259
+ if (sourceId && rowSourceId && String(rowSourceId) === String(sourceId)) return tx;
260
+ if (sourceId && rowSourceId && String(rowSourceId) !== String(sourceId)) continue; // hard disagreement — someone else's transfer
261
+ const dest = tx.destination ?? tx.to ?? tx.recipientHandle ?? null;
262
+ if (sellerHandle && looksLikeHandle(dest) && dest !== sellerHandle) continue; // bound and mismatched
263
+ return tx;
264
+ }
265
+ return null;
266
+ }
@@ -15,6 +15,22 @@ import { randomBytes, createHash, generateKeyPairSync, sign, verify, createPubli
15
15
 
16
16
  const KEYCHAIN_VERSION = 1;
17
17
 
18
+ /**
19
+ * Derive a stable, publishable local id from a raw Ed25519 public key. This
20
+ * makes the UUAID self-authenticating: given the UUAID, anyone can
21
+ * independently check that the public key hashes to the expected local-id.
22
+ *
23
+ * Public because consumers verifying a UUAID against a public key need it
24
+ * without reaching into `Keychain._localIdFromKey`.
25
+ */
26
+ export function localIdFromKey(rawPub) {
27
+ const h = createHash("sha256").update(rawPub).digest();
28
+ // First 16 bytes -> UUID-style dash format for readability.
29
+ const b = h.slice(0, 16);
30
+ const hex = Buffer.from(b).toString("hex");
31
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
32
+ }
33
+
18
34
  export class Keychain {
19
35
  constructor(path) {
20
36
  this.path = path;
@@ -48,16 +64,17 @@ export class Keychain {
48
64
  };
49
65
  }
50
66
 
51
- static _localIdFromKey(rawPub) {
52
- // Derive a stable, publishable local id from the public key. This makes
53
- // the UUAID self-authenticating: given the UUAID, anyone can independently
54
- // check that the public key hashes to the expected local-id.
55
- const h = createHash("sha256").update(rawPub).digest();
56
- // First 16 bytes -> UUID-style dash format for readability.
57
- const b = h.slice(0, 16);
58
- const hex = Buffer.from(b).toString("hex");
59
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
60
- }
67
+ /**
68
+ * Derive the local-id from a raw Ed25519 public key.
69
+ *
70
+ * Kept on the class for the call sites that already reach for it; the
71
+ * implementation is the module-level `localIdFromKey`, which is the public
72
+ * export.
73
+ */
74
+ static localIdFromKey = localIdFromKey;
75
+
76
+ /** @deprecated Alias kept for compatibility — use `localIdFromKey`. */
77
+ static _localIdFromKey = localIdFromKey;
61
78
 
62
79
  /**
63
80
  * Load an identity from disk.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Tier grants — carrier-side volume/size flexibility for qualified identities.
3
+ *
4
+ * Foundations, orgs, and enterprise supporters that pass the UUAID
5
+ * qualification process receive a SIGNED TIER GRANT: a portable JSON object,
6
+ * Ed25519-signed by a trusted issuer (the UUAID Foundation identity, or any
7
+ * issuer a carrier operator chooses to trust). Carriers verify the signature
8
+ * and expiry locally — no registry lookup, no account, consistent with the
9
+ * self-authenticating design. The wire API is unchanged: a grant only
10
+ * raises the holder's rate/size budgets at carriers that trust the issuer.
11
+ *
12
+ * Qualification has two routes. The original is off-wire: the foundation's
13
+ * review process decides WHO gets a grant. The second is earned on-wire —
14
+ * operators who keep a public seed alive are promoted by the seed registry
15
+ * (net/seed-registry.mjs) and issued a grant automatically, no review. This
16
+ * module only makes grants unforgeable and portable; it does not care which
17
+ * route produced one.
18
+ *
19
+ * Grant shape (JCS-signed over every field except `sig`):
20
+ * { v: 1, uuaid, tier, envelopesPerMin, maxBodyBytes, expires, issuer, sig }
21
+ * ...plus optional { priority: true, seedTier, seedUrl } on earned grants.
22
+ *
23
+ * `priority` is the wire half of priority delivery: a carrier that trusts the
24
+ * issuer admits the holder from a reserved lane when it is otherwise shedding
25
+ * load. It is inside the signed body, so it cannot be added to a grant after
26
+ * issue. Older carriers that predate the field verify such a grant unchanged
27
+ * (JCS covers whatever keys are present) and simply ignore the privilege.
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ import { jcs } from "./jcs.mjs";
31
+ import { Keychain } from "./keychain.mjs";
32
+
33
+ /** Tier ladder. community = the unauthenticated default (F2 hardening values).
34
+ *
35
+ * Greek-order names (Doric, Ionic, Corinthian) are ALIASES for the lower
36
+ * three rungs of the canonical ladder. They were added 2026-09-19 so that
37
+ * grants may carry a feature-tier name — Doric = pillar core only,
38
+ * Ionic = + plinth mix hops, Corinthian = + full optional stack — while
39
+ * the carrier still enforces a single budget table. A grant with
40
+ * `tier: "Doric"` and a grant with `tier: "community"` resolve to the same
41
+ * rate/size budget; carriers verify by `TIER_DEFAULTS[grant.tier]` and
42
+ * never see the difference.
43
+ *
44
+ * Aliasing is additive — the canonical five names still work, no carrier
45
+ * upgrade required. Enterprise and foundation keep their original names
46
+ * (no alias needed; they are not feature-presence markers).
47
+ *
48
+ * Adding a new alias is a one-line change here: spread the budget object
49
+ * under the new key. No other file needs to know.
50
+ */
51
+ const _CANONICAL_TIERS = {
52
+ community: { envelopesPerMin: 120, maxBodyBytes: 512 * 1024 },
53
+ supporter: { envelopesPerMin: 300, maxBodyBytes: 1024 * 1024 },
54
+ org: { envelopesPerMin: 600, maxBodyBytes: 2 * 1024 * 1024 },
55
+ enterprise: { envelopesPerMin: 1200, maxBodyBytes: 2 * 1024 * 1024 },
56
+ foundation: { envelopesPerMin: 3000, maxBodyBytes: 2 * 1024 * 1024 },
57
+ };
58
+
59
+ export const TIER_DEFAULTS = {
60
+ ..._CANONICAL_TIERS,
61
+ Doric: _CANONICAL_TIERS.community, // alias of community
62
+ Ionic: _CANONICAL_TIERS.supporter, // alias of supporter
63
+ Corinthian: _CANONICAL_TIERS.org, // alias of org
64
+ };
65
+
66
+ /** Hard ceiling any grant can reach — also the carrier's raw-body stream cap. */
67
+ export const ABSOLUTE_MAX_BODY = 2 * 1024 * 1024;
68
+ export const ABSOLUTE_MAX_RATE = 6000;
69
+
70
+ function signable(grant) {
71
+ const { sig, ...rest } = grant;
72
+ return jcs(rest);
73
+ }
74
+
75
+ /**
76
+ * Issue a grant, signed by the loaded keychain (the issuer identity).
77
+ * Rates/sizes default from the tier ladder; explicit overrides are clamped
78
+ * to the absolute ceilings.
79
+ */
80
+ export function makeTierGrant(keychain, { uuaid, tier, envelopesPerMin, maxBodyBytes, expires, priority, seedTier, seedUrl }) {
81
+ if (!TIER_DEFAULTS[tier]) throw new Error(`unknown tier "${tier}" (${Object.keys(TIER_DEFAULTS).join("|")})`);
82
+ if (!uuaid?.startsWith("uuaid:")) throw new Error("grant subject must be a uuaid:...");
83
+ if (!expires || !Number.isFinite(Date.parse(expires))) throw new Error("expires must be an ISO date");
84
+ const grant = {
85
+ v: 1,
86
+ uuaid,
87
+ tier,
88
+ envelopesPerMin: Math.min(envelopesPerMin ?? TIER_DEFAULTS[tier].envelopesPerMin, ABSOLUTE_MAX_RATE),
89
+ maxBodyBytes: Math.min(maxBodyBytes ?? TIER_DEFAULTS[tier].maxBodyBytes, ABSOLUTE_MAX_BODY),
90
+ expires,
91
+ issuer: keychain._identity.publicKeyHex,
92
+ // Optional fields are omitted rather than set false/null so that a plain
93
+ // grant's signed body stays byte-identical to what pre-priority issuers
94
+ // produced.
95
+ ...(priority ? { priority: true } : {}),
96
+ ...(seedTier ? { seedTier } : {}),
97
+ ...(seedUrl ? { seedUrl } : {}),
98
+ };
99
+ grant.sig = keychain.sign(Buffer.from(signable(grant), "utf-8")).toString("hex");
100
+ return grant;
101
+ }
102
+
103
+ /**
104
+ * Verify a grant against a set of trusted issuer pubkeys (hex).
105
+ * Returns { ok: true, grant } or { ok: false, reason }.
106
+ */
107
+ export function verifyTierGrant(grant, trustedIssuers, now = Date.now()) {
108
+ if (grant?.v !== 1) return { ok: false, reason: "bad-version" };
109
+ if (!TIER_DEFAULTS[grant.tier]) return { ok: false, reason: "unknown-tier" };
110
+ if (!trustedIssuers?.includes(grant.issuer)) return { ok: false, reason: "untrusted-issuer" };
111
+ const exp = Date.parse(grant.expires);
112
+ if (!Number.isFinite(exp) || exp < now) return { ok: false, reason: "expired" };
113
+ if (!(grant.envelopesPerMin <= ABSOLUTE_MAX_RATE) || !(grant.maxBodyBytes <= ABSOLUTE_MAX_BODY)) {
114
+ return { ok: false, reason: "over-ceiling" };
115
+ }
116
+ // Fail closed on a malformed privilege: `priority` is either absent or the
117
+ // boolean true. Anything else (a truthy string, 1, an object) is a grant we
118
+ // do not understand, and understanding it wrongly grants a reserved lane.
119
+ if ("priority" in grant && grant.priority !== true) return { ok: false, reason: "bad-priority" };
120
+ let verified = false;
121
+ try {
122
+ verified = Keychain.verifyDetached(grant.issuer, Buffer.from(signable(grant), "utf-8"), Buffer.from(grant.sig, "hex"));
123
+ } catch (_e) { return { ok: false, reason: "verify-threw" }; }
124
+ if (!verified) return { ok: false, reason: "bad-signature" };
125
+ return { ok: true, grant };
126
+ }
127
+
128
+ export function grantFingerprint(grant) {
129
+ return createHash("sha256").update(signable(grant)).digest("hex").slice(0, 16);
130
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Trust policy — the UUAID-layer consent gate (recipient-side).
3
+ *
4
+ * The envelope layer proves WHO sent a message and that it is intact; it says
5
+ * nothing about whether you WANT it. This module decides, per sender, whether
6
+ * an incoming envelope reaches handlers or is quarantined (stored in the
7
+ * mailbox with a reason, never surfaced to handlers — evidence, not /dev/null).
8
+ *
9
+ * Policy file: <home>/trust.json
10
+ * {
11
+ * "policy": "open" | "directory" | "allowlist",
12
+ * "allow": ["uuaid:...", ...], // always reach handlers
13
+ * "block": ["uuaid:...", ...] // never reach handlers (any policy)
14
+ * }
15
+ *
16
+ * Semantics (evaluated in order):
17
+ * 1. block list -> quarantine("blocked") (all policies)
18
+ * 2. allow list -> deliver (all policies)
19
+ * 3. policy=open -> deliver (default; today's behavior)
20
+ * 4. policy=directory -> deliver if sender is in the address book
21
+ * (fleet.json / directory agents), else quarantine("stranger")
22
+ * 5. policy=allowlist -> quarantine("not-allowlisted")
23
+ *
24
+ * No trust.json file = policy "open" = exactly the pre-alpha.11 behavior.
25
+ * Wire-frozen: this is a local, recipient-side decision only.
26
+ */
27
+ import { existsSync, readFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+
30
+ export const POLICIES = ["open", "directory", "allowlist"];
31
+
32
+ export function loadTrustPolicy(home) {
33
+ const p = join(home, "trust.json");
34
+ if (!existsSync(p)) return { policy: "open", allow: [], block: [], path: p, exists: false };
35
+ let raw;
36
+ try { raw = JSON.parse(readFileSync(p, "utf-8")); }
37
+ catch (e) { throw new Error(`trust.json unparseable at ${p}: ${e.message}`); }
38
+ const policy = raw.policy ?? "open";
39
+ if (!POLICIES.includes(policy)) {
40
+ throw new Error(`trust.json: unknown policy "${policy}" (expected ${POLICIES.join("|")})`);
41
+ }
42
+ const list = (v) => Array.isArray(v) ? v.filter(s => typeof s === "string") : [];
43
+ return { policy, allow: list(raw.allow), block: list(raw.block), path: p, exists: true };
44
+ }
45
+
46
+ /**
47
+ * Decide an envelope's fate. `knownSenders` is a Set of uuaids the recipient
48
+ * already knows (address book / fleet.json / directory agents).
49
+ * Returns { deliver: true } or { deliver: false, reason }.
50
+ */
51
+ export function evaluateTrust(trust, sender, knownSenders) {
52
+ if (trust.block.includes(sender)) return { deliver: false, reason: "blocked" };
53
+ if (trust.allow.includes(sender)) return { deliver: true };
54
+ switch (trust.policy) {
55
+ case "open": return { deliver: true };
56
+ case "directory":
57
+ return knownSenders?.has(sender)
58
+ ? { deliver: true }
59
+ : { deliver: false, reason: "stranger" };
60
+ case "allowlist": return { deliver: false, reason: "not-allowlisted" };
61
+ default: return { deliver: true }; // unreachable (loadTrustPolicy validates)
62
+ }
63
+ }