@uuaid/pillar 0.2.0-alpha.11 → 0.2.0-alpha.13

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.
@@ -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.
package/src/index.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  * Entry point. Not intended for import in browsers today — browser build
8
8
  * lives at @uuaid/pillar-browser (later).
9
9
  */
10
- export { Keychain } from "./identity/keychain.mjs";
10
+ export { Keychain, localIdFromKey } from "./identity/keychain.mjs";
11
11
  export { jcs } from "./identity/jcs.mjs";
12
12
  export { seal, open, decrypt, envelopeSha, keychainSeed, ENVELOPE_VERSION, E2E_ALG } from "./net/envelope.mjs";
13
13
  export { sealPayload, openPayload, edPubToX25519, edSeedToX25519 } from "./crypto/e2e.mjs";
@@ -151,6 +151,16 @@ export class Mailbox {
151
151
  return !!this.db.prepare("select 1 from outbox where envelope_id = ?").get(envelopeId);
152
152
  }
153
153
 
154
+ /** Full outbox row by envelope id (VDA message-delivery instrument). */
155
+ getOutbound(envelopeId) {
156
+ return this.db.prepare("select * from outbox where envelope_id = ?").get(envelopeId) ?? null;
157
+ }
158
+
159
+ /** Full inbox row by envelope id (VDA message-delivery instrument). */
160
+ getInbound(envelopeId) {
161
+ return this.db.prepare("select * from inbox where envelope_id = ?").get(envelopeId) ?? null;
162
+ }
163
+
154
164
  markOutboundDelivered(envelopeId, ackSha256) {
155
165
  this.db.prepare("update outbox set status = 'delivered', delivered_at = ?, ack_sha256 = ? where envelope_id = ?")
156
166
  .run(new Date().toISOString(), ackSha256 ?? null, envelopeId);
@@ -97,6 +97,8 @@ export class CarrierClient {
97
97
  /**
98
98
  * Long-poll every carrier in parallel forever, invoking onEnvelope for each
99
99
  * new envelope. Tracks per-carrier cursor. Returns a stop() function.
100
+ * onError(error, { carrier }) — the carrier context is passed so callers
101
+ * can attribute failures per carrier in metrics.
100
102
  */
101
103
  startPolling({ onEnvelope, cursors = {}, waitS = 25, onError }) {
102
104
  this._stopPolling = false;
@@ -108,10 +110,10 @@ export class CarrierClient {
108
110
  for (const { seq, envelope } of r.envelopes) {
109
111
  state.cursors[base] = Math.max(state.cursors[base] ?? 0, seq);
110
112
  try { await onEnvelope(envelope, { carrier: base, seq }); }
111
- catch (e) { onError?.(new Error(`onEnvelope threw: ${e.message}`)); }
113
+ catch (e) { onError?.(new Error(`onEnvelope threw: ${e.message}`), { carrier: base }); }
112
114
  }
113
115
  } catch (e) {
114
- onError?.(e);
116
+ onError?.(e, { carrier: base });
115
117
  // Back off on error so a dead carrier doesn't spin the CPU.
116
118
  await new Promise(r2 => setTimeout(r2, 2000));
117
119
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Observability — metrics/analytics for the Pillar communication system.
3
+ *
4
+ * Zero-dependency, in-memory counters / gauges / histograms with an optional
5
+ * append-only JSONL sink (same pattern as the escrow journal). Every Pillar
6
+ * and EscrowService owns one instance; when none is passed in, a default is
7
+ * created internally so instrumentation sites never null-check.
8
+ *
9
+ * inc("sent.via.carrier") counters — labels compose into dotted
10
+ * keys at the CALL SITE, e.g.
11
+ * `inc(\`received.kind.${kind}\`)`
12
+ * setGauge("escrow.offers.open", 3) gauges — last write wins
13
+ * observe("send.latencyMs", ms) histograms — fixed latency buckets
14
+ *
15
+ * Sink: journal({event, ...}) appends an event line; flush() appends a full
16
+ * state snapshot. An external analytics pipeline can tail the file without
17
+ * a live process. summary() renders the one-glance human view.
18
+ *
19
+ * Keys are sanitized (lowercased, non-alphanumerics folded to "-") so
20
+ * attacker-influenced strings (envelope kinds, error reasons) can never
21
+ * fabricate keys or escape the dotted namespace.
22
+ */
23
+ import { appendFileSync, mkdirSync } from "node:fs";
24
+ import { dirname } from "node:path";
25
+
26
+ // Fixed latency buckets (ms) — covers fast local sends (<10 ms) up to slow
27
+ // carrier round-trips (>10 s). Values above the top bucket still count into
28
+ // n/sum/max; percentiles interpolate within the top bucket's upper bound.
29
+ export const LATENCY_BUCKETS = [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
30
+
31
+ const MAX_KEY = 64;
32
+
33
+ export function slugKey(key) {
34
+ return String(key)
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9.-]+/g, "-")
37
+ .replace(/^-+|-+$/g, "") // a trailing "!!!" run must not leave a dangling dash
38
+ .slice(0, MAX_KEY);
39
+ }
40
+
41
+ function newHistogram() {
42
+ return { n: 0, sum: 0, min: Infinity, max: -Infinity, buckets: new Array(LATENCY_BUCKETS.length).fill(0) };
43
+ }
44
+
45
+ function percentile(h, p) {
46
+ if (h.n === 0) return 0;
47
+ const target = h.n * p;
48
+ let acc = 0;
49
+ let lower = 0;
50
+ for (let i = 0; i < LATENCY_BUCKETS.length; i++) {
51
+ acc += h.buckets[i];
52
+ if (acc >= target) {
53
+ const upper = LATENCY_BUCKETS[i];
54
+ const inBucket = h.buckets[i];
55
+ const frac = inBucket === 0 ? 0 : (target - (acc - inBucket)) / inBucket;
56
+ return Math.round(lower + frac * (upper - lower));
57
+ }
58
+ lower = LATENCY_BUCKETS[i];
59
+ }
60
+ return h.max;
61
+ }
62
+
63
+ export class Observability {
64
+ constructor({ sinkPath = null } = {}) {
65
+ this.sinkPath = sinkPath;
66
+ this.counters = new Map();
67
+ this.gauges = new Map();
68
+ this.histograms = new Map();
69
+ this.startedAt = new Date().toISOString();
70
+ }
71
+
72
+ /* ------------------------------------------------ write paths -------- */
73
+
74
+ inc(key, n = 1) {
75
+ const k = slugKey(key);
76
+ this.counters.set(k, (this.counters.get(k) ?? 0) + n);
77
+ return this.counters.get(k);
78
+ }
79
+
80
+ setGauge(key, value) {
81
+ this.gauges.set(slugKey(key), value);
82
+ }
83
+
84
+ observe(key, ms) {
85
+ const k = slugKey(key);
86
+ let h = this.histograms.get(k);
87
+ if (!h) { h = newHistogram(); this.histograms.set(k, h); }
88
+ const v = Number(ms);
89
+ if (!Number.isFinite(v)) return;
90
+ h.n += 1;
91
+ h.sum += v;
92
+ if (v < h.min) h.min = v;
93
+ if (v > h.max) h.max = v;
94
+ const idx = LATENCY_BUCKETS.findIndex((b) => v <= b);
95
+ h.buckets[idx < 0 ? LATENCY_BUCKETS.length - 1 : idx] += 1;
96
+ }
97
+
98
+ /** Append an event line to the sink (no-op without a sinkPath). */
99
+ journal({ event, ...fields }) {
100
+ if (!this.sinkPath) return;
101
+ mkdirSync(dirname(this.sinkPath), { recursive: true });
102
+ appendFileSync(this.sinkPath, JSON.stringify({ at: new Date().toISOString(), event, ...fields }) + "\n");
103
+ }
104
+
105
+ /** Append a full state snapshot line to the sink (no-op without a sinkPath). */
106
+ flush() {
107
+ if (!this.sinkPath) return;
108
+ mkdirSync(dirname(this.sinkPath), { recursive: true });
109
+ appendFileSync(this.sinkPath, JSON.stringify({ type: "snapshot", ...this.snapshot() }) + "\n");
110
+ }
111
+
112
+ /* ------------------------------------------------ read paths --------- */
113
+
114
+ snapshot() {
115
+ const histograms = {};
116
+ for (const [k, h] of this.histograms) {
117
+ histograms[k] = { n: h.n, sum: h.sum, min: h.min, max: h.max, p50: percentile(h, 0.5), p95: percentile(h, 0.95) };
118
+ }
119
+ return {
120
+ at: new Date().toISOString(),
121
+ startedAt: this.startedAt,
122
+ counters: Object.fromEntries(this.counters),
123
+ gauges: Object.fromEntries(this.gauges),
124
+ histograms,
125
+ };
126
+ }
127
+
128
+ /** One-glance human view for CLI / dashboards. */
129
+ summary() {
130
+ const s = this.snapshot();
131
+ const lines = [
132
+ `pillar metrics (since ${s.startedAt}) — ${this.counters.size} counters, ${this.gauges.size} gauges, ${this.histograms.size} histograms`,
133
+ ];
134
+ for (const [k, v] of Object.entries(s.counters).sort()) lines.push(` ${k.padEnd(48)} ${v}`);
135
+ for (const [k, v] of Object.entries(s.gauges).sort()) lines.push(` ${k.padEnd(48)} = ${v}`);
136
+ for (const [k, h] of Object.entries(s.histograms).sort()) {
137
+ lines.push(` ${k.padEnd(48)} n=${h.n} min=${h.min}ms p50=${h.p50}ms p95=${h.p95}ms max=${h.max}ms`);
138
+ }
139
+ return lines.join("\n");
140
+ }
141
+ }