@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,209 @@
1
+ /**
2
+ * Device/persona delegation — UACP/1.0 protocol extension (carrier side).
3
+ *
4
+ * A long-lived "persona" UUAID (the principal) authorizes a short-lived
5
+ * device identity (the delegate) to act on its behalf for specific envelope
6
+ * kinds and a bounded time window. The persona signs a JCS-canonical
7
+ * delegation document; the device embeds that document in the envelope's
8
+ * `delegation` field and signs the whole envelope with ITS OWN key.
9
+ *
10
+ * delegation = {
11
+ * version: "uuaid-delegation/1",
12
+ * id: "del-...",
13
+ * principal: persona uuaid, principalPublicKey: hex,
14
+ * delegate: device uuaid, delegatePublicKey: hex,
15
+ * kinds: ["*"] or ["request","reply",...],
16
+ * notBefore, notAfter: ISO 8601,
17
+ * signature: { alg: "ed25519", publicKey, signature },
18
+ * }
19
+ * signature = ed25519(JCS(delegation minus .signature), principalSeed)
20
+ *
21
+ * Wire-compatible with @uuaid/pillar-client's delegation.ts (mintDelegation /
22
+ * verifyDelegation / sealWithDelegation / verifyDelegatedEnvelope): same
23
+ * document shape, same JCS canonicalization, same rejection reason strings,
24
+ * so a carrier and a pillar-client peer reach the same verdict on the same
25
+ * bytes.
26
+ *
27
+ * Envelope acceptance rules (envelope.mjs open() branches here when the
28
+ * optional `delegation` field is present; absent -> legacy behavior,
29
+ * unchanged):
30
+ * (a) envelope transport signature valid under the DELEGATE's pubkey and
31
+ * transportSignature.keyId === delegate uuaid;
32
+ * (b) delegation document signature valid under the PRINCIPAL's pubkey,
33
+ * principal === envelope.sender, delegate === signing key's uuaid;
34
+ * (c) local-id binding for BOTH identities:
35
+ * sha256(pubkey)[:16] (UUID-dash formatted) === uuaid local-id;
36
+ * (d) now within [notBefore, notAfter];
37
+ * (e) envelope.kind covered by the delegation `kinds` allowlist
38
+ * ("*" covers everything), and not a capability-only name such as
39
+ * "inbox" — those are poll capabilities, never deliverable kinds, and
40
+ * are refused even under "*" (see CAPABILITY_ONLY_KINDS).
41
+ * All five must pass; any failure rejects with
42
+ * `delegation-invalid:<reason>` (HTTP 403 at the carrier).
43
+ *
44
+ * Storage/mailbox attribution is unchanged: the envelope's `sender` IS the
45
+ * principal, so carriers key mailboxes, rate limits, and audits to the
46
+ * principal UUAID exactly as before.
47
+ */
48
+ import { randomBytes } from "node:crypto";
49
+ import { jcs } from "../identity/jcs.mjs";
50
+ import { Keychain } from "../identity/keychain.mjs";
51
+
52
+ export const DELEGATION_VERSION = "uuaid-delegation/1";
53
+
54
+ /**
55
+ * Mint a delegation document, signed by the PRINCIPAL keychain.
56
+ *
57
+ * The counterpart to verifyDelegationDoc below, and the .mjs twin of
58
+ * pillar-client's mintDelegation (delegation.ts): same field order, same
59
+ * id shape, same JCS canonicalization, same signature placement — so a
60
+ * document minted here verifies byte-identically in a browser, and vice
61
+ * versa. A divergence in either direction is a wire break, not a style
62
+ * difference.
63
+ *
64
+ * Nothing here knows what KIND of principal is signing. A phone, a CLI and
65
+ * a daemon mint the same bytes; the only requirement is custody of the
66
+ * persona seed. That is what lets a headless agent link a browser.
67
+ *
68
+ * CALLER BEWARE — ttlDays defaults to 90 to match pillar-client, but the
69
+ * desktop-link path enforces a 7-day ceiling of its own (D14, chat-web's
70
+ * withinLinkTtl) and DROPS a longer grant in silence. Pass ttlDays <= 7 for
71
+ * a browser link; see bin/pillar.mjs cmdDelegate, which refuses rather than
72
+ * let that failure be invisible.
73
+ */
74
+ export function mintDelegationDoc(keychain, opts = {}) {
75
+ const principal = keychain?._identity;
76
+ if (!principal?.publicKeyHex) throw new Error("mintDelegationDoc: principal identity not loaded");
77
+ const { delegate, delegatePublicKey } = opts;
78
+ if (typeof delegate !== "string" || !delegate.startsWith("uuaid:")) {
79
+ throw new Error("mintDelegationDoc: delegate uuaid required");
80
+ }
81
+ if (typeof delegatePublicKey !== "string" || !/^[0-9a-f]{64}$/i.test(delegatePublicKey)) {
82
+ throw new Error("mintDelegationDoc: delegatePublicKey must be 64 hex");
83
+ }
84
+ const kinds = Array.isArray(opts.kinds) && opts.kinds.length ? [...opts.kinds] : ["*"];
85
+ const start = opts.notBefore instanceof Date ? opts.notBefore : new Date();
86
+ const ttlDays = Number.isFinite(opts.ttlDays) ? opts.ttlDays : 90;
87
+ const end = new Date(start.getTime() + ttlDays * 24 * 60 * 60 * 1000);
88
+ const unsigned = {
89
+ version: DELEGATION_VERSION,
90
+ id: `del-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`,
91
+ principal: principal.uuaid,
92
+ principalPublicKey: principal.publicKeyHex,
93
+ delegate,
94
+ delegatePublicKey: delegatePublicKey.toLowerCase(),
95
+ kinds,
96
+ notBefore: start.toISOString(),
97
+ notAfter: end.toISOString(),
98
+ };
99
+ const signature = keychain.sign(jcs(unsigned));
100
+ return {
101
+ ...unsigned,
102
+ signature: {
103
+ alg: "ed25519",
104
+ publicKey: principal.publicKeyHex,
105
+ signature: Buffer.from(signature).toString("hex"),
106
+ },
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Extract the local-id of a UUAID with pillar-client parseUuaid semantics:
112
+ * exactly four colon-separated segments with the "uuaid" prefix.
113
+ * Returns null for anything else (never throws).
114
+ */
115
+ export function uuaidLocalId(uuaid) {
116
+ if (typeof uuaid !== "string") return null;
117
+ const parts = uuaid.split(":");
118
+ if (parts.length !== 4 || parts[0] !== "uuaid") return null;
119
+ return parts[3];
120
+ }
121
+
122
+ /** Self-authenticating identity check: sha256(pubkey)[:16] === uuaid local-id. */
123
+ export function uuaidBindingOk(uuaid, publicKeyHex) {
124
+ const localId = uuaidLocalId(uuaid);
125
+ if (localId === null) return false;
126
+ if (typeof publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return false;
127
+ return Keychain.localIdFromKey(Buffer.from(publicKeyHex, "hex")) === localId;
128
+ }
129
+
130
+ /**
131
+ * Verify a delegation document on its own: shape, identity bindings, time
132
+ * window, and the principal's signature over JCS(doc minus .signature).
133
+ *
134
+ * Reason strings intentionally mirror @uuaid/pillar-client verifyDelegation
135
+ * so tooling sees identical diagnostics on both implementations.
136
+ *
137
+ * Returns { ok: true } or { ok: false, reason }.
138
+ */
139
+ export function verifyDelegationDoc(d, now = new Date()) {
140
+ if (!d || typeof d !== "object" || Array.isArray(d)) return { ok: false, reason: "not-object" };
141
+ if (d.version !== DELEGATION_VERSION) return { ok: false, reason: `bad-version:${d.version}` };
142
+ if (!/^[0-9a-f]{64}$/i.test(d.principalPublicKey ?? "")) return { ok: false, reason: "bad-principal-pubkey" };
143
+ if (!/^[0-9a-f]{64}$/i.test(d.delegatePublicKey ?? "")) return { ok: false, reason: "bad-delegate-pubkey" };
144
+ if (!uuaidBindingOk(d.principal, d.principalPublicKey)) {
145
+ return { ok: false, reason: "principal-uuaid-binding" };
146
+ }
147
+ if (!uuaidBindingOk(d.delegate, d.delegatePublicKey)) {
148
+ return { ok: false, reason: "delegate-uuaid-binding" };
149
+ }
150
+ if (!Array.isArray(d.kinds) || d.kinds.length === 0 || d.kinds.some(k => typeof k !== "string")) {
151
+ return { ok: false, reason: "bad-kinds" };
152
+ }
153
+ const nb = Date.parse(d.notBefore);
154
+ const na = Date.parse(d.notAfter);
155
+ if (Number.isNaN(nb) || Number.isNaN(na)) return { ok: false, reason: "bad-time" };
156
+ if (na <= nb) return { ok: false, reason: "empty-window" };
157
+ const t = now instanceof Date ? now.getTime() : Number(now);
158
+ if (t < nb) return { ok: false, reason: "not-yet-valid" };
159
+ if (t > na) return { ok: false, reason: "expired" };
160
+
161
+ const sig = d.signature;
162
+ if (!sig || sig.alg !== "ed25519" || sig.publicKey !== d.principalPublicKey) {
163
+ return { ok: false, reason: "bad-signature-shape" };
164
+ }
165
+ if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) {
166
+ return { ok: false, reason: "bad-signature-shape" };
167
+ }
168
+ // The signature covers JCS of everything except .signature.
169
+ const { signature: _drop, ...unsigned } = d;
170
+ let canonical;
171
+ try { canonical = jcs(unsigned); } catch (_e) { return { ok: false, reason: "bad-canonical" }; }
172
+ let okSig = false;
173
+ try {
174
+ okSig = Keychain.verifyDetached(sig.publicKey, Buffer.from(canonical, "utf-8"), Buffer.from(sig.signature, "hex"));
175
+ } catch (_e) {
176
+ return { ok: false, reason: "verify-threw" };
177
+ }
178
+ if (!okSig) return { ok: false, reason: "bad-signature" };
179
+ return { ok: true };
180
+ }
181
+
182
+ /**
183
+ * Names that may appear in a delegation's `kinds` as CAPABILITIES but are not
184
+ * envelope kinds, and so must never be deliverable.
185
+ *
186
+ * `kinds` is a single namespace answering two different questions: "may this
187
+ * delegate SEND envelopes of kind K" (envelope.mjs) and "may this delegate
188
+ * POLL the principal's inbox" (carrier-server.mjs, capability "inbox"). The
189
+ * overlap means a token minted read-only as `kinds: ["inbox"]` also satisfies
190
+ * the send check for an envelope whose `kind` is literally "inbox" — verified
191
+ * against a live alpha.15 carrier, which returned 202 for exactly that forgery
192
+ * while correctly returning 403 kind-not-covered for message/request/reply.
193
+ *
194
+ * Nothing consumes `kind: "inbox"`, so this is latent rather than live. It
195
+ * stops being latent the moment a read-only poll token is issued to anyone,
196
+ * which is what the push relay is being rebuilt to use.
197
+ *
198
+ * Enforced on the send path in envelope.mjs. Deliberately NOT enforced inside
199
+ * delegationCoversKind: that helper serves both questions, and the capability
200
+ * lookup in carrier-server.mjs depends on "inbox" matching there.
201
+ */
202
+ export const CAPABILITY_ONLY_KINDS = new Set(["inbox"]);
203
+
204
+ /** Does this delegation cover envelopes of `kind`? "*" covers all. */
205
+ export function delegationCoversKind(d, kind) {
206
+ if (!Array.isArray(d?.kinds)) return false;
207
+ if (d.kinds.includes("*")) return true;
208
+ return d.kinds.includes(kind);
209
+ }
@@ -30,6 +30,7 @@ import { createHash, randomBytes } from "node:crypto";
30
30
  import { jcs } from "../identity/jcs.mjs";
31
31
  import { Keychain } from "../identity/keychain.mjs";
32
32
  import { sealPayload, openPayload, E2E_ALG } from "../crypto/e2e.mjs";
33
+ import { verifyDelegationDoc, delegationCoversKind, CAPABILITY_ONLY_KINDS } from "./delegation.mjs";
33
34
 
34
35
  export const ENVELOPE_VERSION = "uuaid-pillar-envelope/2";
35
36
  export { E2E_ALG };
@@ -92,6 +93,11 @@ export function seal(keychain, body) {
92
93
  * Verify an envelope's transport signature and structure. Does NOT decrypt.
93
94
  * Anyone (carriers, relays, auditors) can call this.
94
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.
95
101
  */
96
102
  export function open(envelope, { cryptoInventory } = {}) {
97
103
  if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return { ok: false, reason: "not-object" };
@@ -101,6 +107,9 @@ export function open(envelope, { cryptoInventory } = {}) {
101
107
  if (cryptoInventory && cryptoInventory[sig.alg] !== "active") {
102
108
  return { ok: false, reason: `alg-not-active:${sig.alg}` };
103
109
  }
110
+ if (envelope.delegation !== undefined) {
111
+ return openDelegated(envelope, { cryptoInventory });
112
+ }
104
113
  if (sig.keyId !== envelope.sender) return { ok: false, reason: "keyId-sender-mismatch" };
105
114
  if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
106
115
  if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
@@ -136,6 +145,78 @@ export function open(envelope, { cryptoInventory } = {}) {
136
145
  return { ok: true };
137
146
  }
138
147
 
148
+ /**
149
+ * Delegated path of open() — UACP/1.0 extension. Invoked when the envelope
150
+ * carries a `delegation` document; the legacy self-authenticating-sender rule
151
+ * is REPLACED by the five delegation checks (see delegation.mjs):
152
+ *
153
+ * (a) envelope signature valid under the DELEGATE's key,
154
+ * transportSignature.keyId === delegation.delegate;
155
+ * (b) delegation document signature valid under the PRINCIPAL's key,
156
+ * delegation.principal === envelope.sender,
157
+ * delegation.delegate === the signing key's uuaid;
158
+ * (c) local-id binding for BOTH identities (inside verifyDelegationDoc);
159
+ * (d) now within [notBefore, notAfter] (inside verifyDelegationDoc);
160
+ * (e) envelope.kind covered by delegation.kinds.
161
+ *
162
+ * Fail-open is not an option: any failure rejects with
163
+ * { ok: false, reason: "delegation-invalid:<reason>", code: 403 }. Pure
164
+ * wire-format failures (enc block, hex encodings) keep the legacy reasons and
165
+ * 400 status — they are not delegation verdicts.
166
+ *
167
+ * The `delegation` field is part of the SIGNED body (sealed inside the
168
+ * canonicalization by the delegate), so a carrier cannot strip or swap it
169
+ * without breaking the envelope signature.
170
+ */
171
+ function openDelegated(envelope, { cryptoInventory } = {}) {
172
+ const reject = (reason) => ({ ok: false, reason: `delegation-invalid:${reason}`, code: 403 });
173
+ const sig = envelope.transportSignature;
174
+ if (typeof sig.publicKey !== "string" || !/^[0-9a-f]{64}$/i.test(sig.publicKey)) return { ok: false, reason: "bad-publicKey" };
175
+ if (typeof sig.signature !== "string" || !/^[0-9a-f]{128}$/i.test(sig.signature)) return { ok: false, reason: "bad-signature-encoding" };
176
+ // Structural checks on the encryption block — same rules as the legacy path.
177
+ const enc = envelope.enc;
178
+ if (!enc || typeof enc !== "object") return { ok: false, reason: "missing-enc" };
179
+ if (enc.alg !== E2E_ALG) return { ok: false, reason: `enc-alg-not-supported:${enc.alg}` };
180
+ if (cryptoInventory && cryptoInventory[enc.alg] && cryptoInventory[enc.alg] !== "active") {
181
+ return { ok: false, reason: `enc-alg-not-active:${enc.alg}` };
182
+ }
183
+ if (typeof enc.epk !== "string" || !/^[0-9a-f]{64}$/i.test(enc.epk)) return { ok: false, reason: "bad-epk" };
184
+ if (typeof enc.iv !== "string" || !/^[0-9a-f]{24}$/i.test(enc.iv)) return { ok: false, reason: "bad-iv" };
185
+ if (typeof enc.ct !== "string" || !/^[0-9a-f]{32,}$/i.test(enc.ct)) return { ok: false, reason: "bad-ct" };
186
+
187
+ const d = envelope.delegation;
188
+ // (b-i) + (c) + (d): the delegation document itself — shape, both local-id
189
+ // bindings, time window, and the principal's signature over it.
190
+ const dv = verifyDelegationDoc(d);
191
+ if (!dv.ok) return reject(dv.reason);
192
+ // (b-ii) the persona this envelope acts as must be the delegation principal.
193
+ if (d.principal !== envelope.sender) return reject("sender-not-principal");
194
+ // (b-iii) the actual signer must be the named delegate, key and all.
195
+ if (sig.keyId !== d.delegate) return reject("signer-not-delegate");
196
+ if (sig.publicKey !== d.delegatePublicKey) return reject("signer-key-mismatch");
197
+ // (e) the delegation must cover this envelope kind.
198
+ //
199
+ // A capability name is checked FIRST and unconditionally — including under
200
+ // kinds:["*"] — because these names are not envelope kinds at all. Without
201
+ // this, a read-only kinds:["inbox"] poll token doubles as authority to send
202
+ // envelopes of kind "inbox" as the principal. See CAPABILITY_ONLY_KINDS.
203
+ if (CAPABILITY_ONLY_KINDS.has(envelope.kind)) return reject("kind-reserved-capability");
204
+ if (!delegationCoversKind(d, envelope.kind)) return reject("kind-not-covered");
205
+ // (a) the envelope signature under the delegate key, over the canonical
206
+ // body INCLUDING the delegation field (exactly what the delegate signed).
207
+ const { transportSignature: _sig, ...withoutSig } = envelope;
208
+ let canonical;
209
+ try { canonical = jcs(withoutSig); } catch (_e) { return { ok: false, reason: "bad-canonical" }; }
210
+ let okSig;
211
+ try {
212
+ okSig = Keychain.verifyDetached(sig.publicKey, Buffer.from(canonical, "utf-8"), Buffer.from(sig.signature, "hex"));
213
+ } catch (_e) {
214
+ return { ok: false, reason: "verify-threw" };
215
+ }
216
+ if (!okSig) return reject("bad-envelope-signature");
217
+ return { ok: true, delegated: true, actingFor: d.principal };
218
+ }
219
+
139
220
  /**
140
221
  * Decrypt an envelope's payload. Only the recipient can do this.
141
222
  * keychain must be the recipient's loaded keychain.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `uuaid://link/…` — desktop linking offer, principal side.
3
+ *
4
+ * A browser tab that is not yet paired mints its OWN device identity and
5
+ * publishes it as an offer:
6
+ *
7
+ * uuaid://link/<url-encoded-delegate-uuaid>?pk=<64hex>&nonce=<hex>&exp=<iso>
8
+ *
9
+ * A phone renders that string as a QR and scans it. Nothing about the offer
10
+ * requires a camera: the QR is a rendering of this string, not a format of
11
+ * its own, so any principal that can read the string can complete the
12
+ * handshake. That is what makes `pillar delegate --to-browser` possible for
13
+ * an agent, which has a persona seed but no phone.
14
+ *
15
+ * Structural parity with `apps/mobile/src/services/link-offer.ts` is
16
+ * deliberate and load-bearing — same regexes, same rejection points, same
17
+ * "null means ignore safely" contract — because the browser on the other end
18
+ * validates what the phone would have sent. Change one, change both.
19
+ *
20
+ * Parsing is not authorization: `parseLinkOffer` checks shape only. Liveness
21
+ * (`isLinkOfferLive`) and single-use are the caller's job.
22
+ */
23
+
24
+ export const LINK_OFFER_DEEPLINK_HOST = "link";
25
+
26
+ /**
27
+ * The kinds a desktop-link grant covers: exactly what the phone grants
28
+ * (apps/mobile pillar.ts LINK_GRANT_KINDS, plan D12). No "inbox": the desktop
29
+ * polls its OWN inbox and never reads the principal's, and no "*", which a
30
+ * carrier reads as covering "inbox" (carrier-server delegationCoversKind).
31
+ */
32
+ export const LINK_GRANT_KINDS = Object.freeze(["message", "request", "reply", "ack"]);
33
+
34
+ const UUAID_RE = /^uuaid:[a-z0-9-]+:[a-z0-9-]+:[a-z0-9][a-z0-9-]*$/i;
35
+ const PK_RE = /^[0-9a-f]{64}$/i;
36
+ const NONCE_RE = /^[0-9a-f]{8,64}$/i;
37
+
38
+ export function isValidLinkDelegate(delegate) {
39
+ return typeof delegate === "string" && UUAID_RE.test(delegate);
40
+ }
41
+
42
+ export function isValidLinkPublicKey(pk) {
43
+ return typeof pk === "string" && PK_RE.test(pk);
44
+ }
45
+
46
+ export function isValidLinkNonce(nonce) {
47
+ return typeof nonce === "string" && NONCE_RE.test(nonce);
48
+ }
49
+
50
+ /**
51
+ * Parse a `uuaid://link/…` offer. Structural validation only — shape and
52
+ * field formats, including that `exp` parses as a date. Does NOT check
53
+ * whether the offer has expired (see isLinkOfferLive). Returns null for
54
+ * anything malformed; callers must treat null as "ignore safely".
55
+ */
56
+ export function parseLinkOffer(raw) {
57
+ if (typeof raw !== "string") return null;
58
+ const trimmed = raw.trim();
59
+ if (!trimmed) return null;
60
+ let u;
61
+ try {
62
+ // URL/URLSearchParams can silently mangle malformed percent escapes
63
+ // rather than throwing, so validate the encoding across the whole link
64
+ // before trusting any field of it.
65
+ decodeURI(trimmed);
66
+ u = new URL(trimmed);
67
+ } catch {
68
+ return null;
69
+ }
70
+ if (u.protocol.toLowerCase() !== "uuaid:") return null;
71
+ if (u.hostname.toLowerCase() !== LINK_OFFER_DEEPLINK_HOST) return null;
72
+ let delegate;
73
+ try {
74
+ delegate = decodeURIComponent(u.pathname.replace(/^\/+/, ""));
75
+ } catch {
76
+ return null;
77
+ }
78
+ if (!isValidLinkDelegate(delegate)) return null;
79
+ const pk = (u.searchParams.get("pk") ?? "").trim().toLowerCase();
80
+ const nonce = (u.searchParams.get("nonce") ?? "").trim().toLowerCase();
81
+ const exp = (u.searchParams.get("exp") ?? "").trim();
82
+ if (!isValidLinkPublicKey(pk)) return null;
83
+ if (!isValidLinkNonce(nonce)) return null;
84
+ if (!exp || Number.isNaN(Date.parse(exp))) return null;
85
+ return { delegate, pk, nonce, exp };
86
+ }
87
+
88
+ /** Build the offer string (the form a browser publishes; here for tests). */
89
+ export function buildLinkOffer(offer) {
90
+ if (!isValidLinkDelegate(offer?.delegate)) throw new Error("link offer: invalid delegate uuaid");
91
+ if (!isValidLinkPublicKey(offer?.pk)) throw new Error("link offer: invalid pk (need 64 hex)");
92
+ if (!isValidLinkNonce(offer?.nonce)) throw new Error("link offer: invalid nonce");
93
+ if (Number.isNaN(Date.parse(offer?.exp))) throw new Error("link offer: invalid exp (need ISO date)");
94
+ const params = new URLSearchParams({
95
+ pk: offer.pk.toLowerCase(),
96
+ nonce: offer.nonce.toLowerCase(),
97
+ exp: offer.exp,
98
+ });
99
+ return `uuaid://${LINK_OFFER_DEEPLINK_HOST}/${encodeURIComponent(offer.delegate)}?${params.toString()}`;
100
+ }
101
+
102
+ /** True while `now` is strictly before the offer's exp instant. */
103
+ export function isLinkOfferLive(offer, now = Date.now()) {
104
+ const expMs = Date.parse(offer?.exp);
105
+ if (Number.isNaN(expMs)) return false;
106
+ return now < expMs;
107
+ }
@@ -0,0 +1,126 @@
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
+ "delegation-invalid": "mint a fresh delegation from the principal, then resend",
36
+ // Carrier, envelope shape and crypto (envelope.mjs open()): one fix for all
37
+ "not-object": RESEAL,
38
+ "bad-version": RESEAL,
39
+ "missing-or-bad-alg": RESEAL,
40
+ "alg-not-active": RESEAL,
41
+ "bad-publicKey": RESEAL,
42
+ "bad-signature-encoding": RESEAL,
43
+ "missing-enc": RESEAL,
44
+ "enc-alg-not-supported": RESEAL,
45
+ "enc-alg-not-active": RESEAL,
46
+ "bad-epk": RESEAL,
47
+ "bad-iv": RESEAL,
48
+ "bad-ct": RESEAL,
49
+ "bad-canonical": RESEAL,
50
+ "verify-threw": RESEAL,
51
+ // Carrier, GET /v1/inbox/<uuaid>
52
+ "missing-auth-headers": "send x-pillar-pubkey, x-pillar-ts and x-pillar-sig",
53
+ "bad-pubkey": "send x-pillar-pubkey as the raw Ed25519 key in 64 hex characters",
54
+ "bad-sig-encoding": "send x-pillar-sig as 128 hex characters",
55
+ "clock-skew": "fix your clock (NTP) to within 5 min of the carrier; retrying does not help",
56
+ "uuaid-pubkey-mismatch": "read the inbox of the UUAID your key derives, or send that UUAID's delegation in x-pillar-delegation",
57
+ "delegation-unparseable": "send x-pillar-delegation as the JSON delegation document",
58
+ "delegation-too-large": "send a delegation document under 8,192 bytes",
59
+ "delegation-principal-mismatch": "use a delegation whose principal is the inbox UUAID",
60
+ "delegation-delegate-key-mismatch": "sign with the key the delegation names as delegate",
61
+ "delegation-inbox-denied": "ask the principal for a delegation that covers inbox (or *)",
62
+ "busy": "retry after at least 2 s with jitter, or poll with wait=0 for a while",
63
+ // Carrier, any route
64
+ "not-found": "use POST /v1/envelopes or GET /v1/inbox/<uuaid>",
65
+ "not-a-seed": "do not list this carrier as a seed; it runs without a seed identity",
66
+ "internal-error": "retry later; if it repeats, tell the carrier operator",
67
+ // Client side: no server gave a code
68
+ "carrier-unreachable": "check the carrier URLs and your network, then retry",
69
+ "carrier-bad-response": "retry, or use another carrier; the carrier or its proxy answered without a reason",
70
+ "over-tier-limit": "send the payload as a blob (pillar-blob send, or Pillar.sendBlob)",
71
+ "blob-key-unproven": "use a uuaid:foundation:agent UUAID derived from this key (pillar init), or get a registry badge that names this key",
72
+ "sender-key-not-active": "ask the sender to send from a uuaid:foundation:agent UUAID derived from its own key, or to get a registry badge naming its key",
73
+ });
74
+
75
+ /**
76
+ * The fix for a carrier reason, or null for a reason this map does not know.
77
+ * An exact key wins; otherwise the reason's family (the part before the
78
+ * first ":" or space) is looked up, so `delegation-invalid:expired` and
79
+ * `bad-version 2` resolve to their family's fix. The result is always a
80
+ * value of REFUSAL_FIXES or null; nothing from `reason` is copied into it.
81
+ */
82
+ export function carrierFix(reason) {
83
+ if (typeof reason !== "string" || !reason) return null;
84
+ if (Object.hasOwn(REFUSAL_FIXES, reason)) return REFUSAL_FIXES[reason];
85
+ const family = reason.split(/[: ]/, 1)[0];
86
+ return family !== reason && Object.hasOwn(REFUSAL_FIXES, family) ? REFUSAL_FIXES[family] : null;
87
+ }
88
+
89
+ /**
90
+ * A carrier response body with its `fix` added. Pure addition: only a
91
+ * refusal (status >= 400) that names a `reason` or `error` and has no `fix`
92
+ * yet gains one; every other field, and every other body, is returned as is.
93
+ */
94
+ export function withCarrierFix(status, body) {
95
+ if (!(status >= 400) || !body || typeof body !== "object" || Array.isArray(body) || body.fix !== undefined) return body;
96
+ const fix = carrierFix(typeof body.reason === "string" ? body.reason : body.error);
97
+ return fix ? { ...body, fix } : body;
98
+ }
99
+
100
+ /**
101
+ * Terminal safety only: a line break or control character a remote sent
102
+ * becomes a space, so the output stays one line and cannot drive the
103
+ * terminal. Every other character is printed as received.
104
+ */
105
+ function oneLine(value) {
106
+ return String(value).replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
107
+ }
108
+
109
+ /**
110
+ * `<code>: <fix>` for an error that carries both (a carrier or broker
111
+ * refusal, or a client error that knows its next step), else null so the
112
+ * caller keeps its old output. A server's fix is printed as received.
113
+ */
114
+ export function refusalLine(error) {
115
+ const code = typeof error?.code === "string" ? oneLine(error.code) : "";
116
+ const fix = typeof error?.fix === "string" ? oneLine(error.fix) : "";
117
+ return code && fix ? `${code}: ${fix}` : null;
118
+ }
119
+
120
+ /** `{ code, fix }` of an error, for a result row, omitting what is absent. */
121
+ export function refusalFields(error) {
122
+ return {
123
+ ...(typeof error?.code === "string" && error.code ? { code: error.code } : {}),
124
+ ...(typeof error?.fix === "string" && error.fix ? { fix: error.fix } : {}),
125
+ };
126
+ }