@uuaid/pillar 0.2.0-alpha.16 → 0.2.0-alpha.18

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.
package/src/blob/http.mjs CHANGED
@@ -9,16 +9,22 @@ import {
9
9
  BlobNotFoundError,
10
10
  BlobSenderKeyError,
11
11
  BlobUploadError,
12
+ SELF_CERTIFIED_KEY_ID,
13
+ isSelfCertifiedUuaid,
14
+ senderSelfCertified,
12
15
  } from "./blob.mjs";
16
+ import { REFUSAL_FIXES } from "../net/refusals.mjs";
13
17
 
14
18
  export const PILLAR_BLOB_AUDIENCE = "uuaid:pillar-blob:v1";
19
+ export { SELF_CERTIFIED_KEY_ID };
20
+ const KEY_ID_TTL_MS = 300_000;
15
21
 
16
22
  function payloadHash(payload) {
17
23
  return `0x${createHash("sha256").update(jcs(payload)).digest("hex")}`;
18
24
  }
19
25
 
20
26
  function signedRequest(keychain, keyId, challenge, request) {
21
- if (!keyId) throw new BlobBrokerError("blob-broker-key-id-required", "a registry-bound presentation key id is required");
27
+ if (!keyId) throw new BlobBrokerError("blob-broker-key-id-required", "a presentation key id is required (registry badge key or self-certified:ed25519)");
22
28
  const payload = { v: 1, audience: PILLAR_BLOB_AUDIENCE, challenge, request };
23
29
  return {
24
30
  payload,
@@ -33,6 +39,25 @@ function signedRequest(keychain, keyId, challenge, request) {
33
39
  };
34
40
  }
35
41
 
42
+ /** `{ code, fix, body }` from a refusal body; empty for a non-JSON one (e.g. S3 XML). */
43
+ async function refusalOf(response) {
44
+ const body = await response.json().catch(() => null);
45
+ if (!body || typeof body !== "object" || Array.isArray(body)) return {};
46
+ return {
47
+ code: typeof body.code === "string" && body.code ? body.code : null,
48
+ fix: typeof body.fix === "string" && body.fix ? body.fix : null,
49
+ body,
50
+ };
51
+ }
52
+
53
+ /** Put the broker's code/fix on a typed error; its own code moves to legacyCode. */
54
+ function withRefusal(error, { code = null, fix = null, body = null } = {}) {
55
+ if (code && code !== error.code) { error.legacyCode = error.code; error.code = code; }
56
+ if (fix) error.fix = fix;
57
+ if (body) error.body = body;
58
+ return error;
59
+ }
60
+
36
61
  function safeBase(url) {
37
62
  const parsed = new URL(url);
38
63
  if (parsed.protocol !== "https:" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") {
@@ -41,17 +66,35 @@ function safeBase(url) {
41
66
  return parsed.href.replace(/\/+$/, "");
42
67
  }
43
68
 
44
- /** HTTP client for the UUAID broker. URLs are returned only to the live caller. */
69
+ /**
70
+ * HTTP client for the UUAID broker. URLs are returned only to the live caller.
71
+ *
72
+ * `keyId` is optional. Given, it is presented as is. Omitted, the client
73
+ * resolves one per identity (resolveKeyId): the registry badge's key when a
74
+ * badge names this exact key, else "self-certified:ed25519" when the UUAID is
75
+ * exactly the key's uuaid:foundation:agent UUAID, else a `blob-key-unproven`
76
+ * refusal.
77
+ */
78
+ /**
79
+ * The brokers this package talks to without being told, same list and order as
80
+ * @uuaid/pillar-client (hosts.ts BLOB_BROKERS). pillar-sfo runs broker 1.1.0,
81
+ * which also serves self-certified identities; api.uuaid.org serves registry
82
+ * badges only, so it is no longer the default (pass --broker to use it).
83
+ */
84
+ export const BLOB_BROKERS = Object.freeze(["https://pillar-sfo.uuaid.org/blob", "https://pillar-nyc.uuaid.org/blob"]);
85
+ export const DEFAULT_BLOB_BROKER = BLOB_BROKERS[0];
86
+
45
87
  export class PillarBlobHttpClient {
46
- constructor({ baseUrl = "https://api.uuaid.org", keychain, keyId, fetchImpl = globalThis.fetch, timeoutMs = 15_000 } = {}) {
88
+ constructor({ baseUrl = DEFAULT_BLOB_BROKER, keychain, keyId = null, fetchImpl = globalThis.fetch, timeoutMs = 15_000 } = {}) {
47
89
  if (!keychain?._identity) throw new BlobBrokerError("blob-broker-no-identity", "loaded keychain required");
48
90
  if (typeof fetchImpl !== "function") throw new BlobBrokerError("blob-broker-no-fetch", "fetch implementation required");
49
91
  this.baseUrl = safeBase(baseUrl);
50
92
  this.keychain = keychain;
51
- this.keyId = keyId;
93
+ this.keyId = keyId || null;
52
94
  this.fetch = fetchImpl;
53
95
  this.timeoutMs = timeoutMs;
54
96
  this._trust = null;
97
+ this._resolvedKeyId = null;
55
98
  }
56
99
 
57
100
  async _json(path, { method = "POST", body } = {}) {
@@ -68,15 +111,62 @@ export class PillarBlobHttpClient {
68
111
  }
69
112
  const parsed = await response.json().catch(() => ({}));
70
113
  if (!response.ok) {
71
- const code = response.status === 404 ? "blob-broker-not-found"
114
+ const legacyCode = response.status === 404 ? "blob-broker-not-found"
72
115
  : response.status === 410 ? "blob-broker-expired"
73
116
  : response.status === 409 ? "blob-broker-replay"
74
117
  : "blob-broker-rejected";
75
- throw new BlobBrokerError(code, parsed.error ?? `blob broker returned ${response.status}`, response.status);
118
+ // The broker's own code + fix win; an older broker sends neither.
119
+ const brokerCode = typeof parsed?.code === "string" && parsed.code ? parsed.code : null;
120
+ const fix = typeof parsed?.fix === "string" && parsed.fix ? parsed.fix : null;
121
+ throw new BlobBrokerError(brokerCode ?? legacyCode, parsed?.error ?? `blob broker returned ${response.status}`, response.status, {
122
+ fix,
123
+ legacyCode,
124
+ body: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null,
125
+ });
76
126
  }
77
127
  return parsed;
78
128
  }
79
129
 
130
+ /**
131
+ * The keyId this identity presents, strongest proof first:
132
+ * 1. the constructor's explicit `keyId`;
133
+ * 2. the registry badge's presentation keyId, when a fresh registry-signed
134
+ * badge for this UUAID names this exact public key (badge limits);
135
+ * 3. "self-certified:ed25519", when this UUAID is exactly
136
+ * `uuaid:foundation:agent:` + the key's local id (self-certified
137
+ * limits; the full string, as the broker compares it);
138
+ * else throws `blob-key-unproven`. A badge that names another key, a
139
+ * missing badge and an unreachable registry all fall through to 3.
140
+ * Cached for 5 minutes.
141
+ */
142
+ async resolveKeyId() {
143
+ if (this.keyId) return this.keyId;
144
+ if (this._resolvedKeyId && this._resolvedKeyId.until > Date.now()) return this._resolvedKeyId.value;
145
+ const { uuaid, publicKeyHex } = this.keychain._identity;
146
+ let value = null;
147
+ let badgeProblem = "no registry badge names this key";
148
+ try {
149
+ const presentation = await this._verifiedPresentationKey(uuaid);
150
+ if (presentation && presentation.publicKey.toLowerCase() === publicKeyHex.toLowerCase()
151
+ && typeof presentation.keyId === "string" && presentation.keyId) {
152
+ value = presentation.keyId;
153
+ }
154
+ } catch (error) {
155
+ badgeProblem = `registry badge lookup failed (${error.code ?? error.message})`;
156
+ }
157
+ if (!value && isSelfCertifiedUuaid(uuaid, publicKeyHex)) value = SELF_CERTIFIED_KEY_ID;
158
+ if (!value) {
159
+ throw new BlobBrokerError(
160
+ "blob-key-unproven",
161
+ `${uuaid} cannot sign broker requests: ${badgeProblem}, and it is not the uuaid:foundation:agent UUAID derived from its key`,
162
+ null,
163
+ { fix: REFUSAL_FIXES["blob-key-unproven"] },
164
+ );
165
+ }
166
+ this._resolvedKeyId = { value, until: Date.now() + KEY_ID_TTL_MS };
167
+ return value;
168
+ }
169
+
80
170
  challenge(action, objectId = null) {
81
171
  return this._json("/pillar/blobs/challenge", {
82
172
  body: {
@@ -87,9 +177,10 @@ export class PillarBlobHttpClient {
87
177
  }).then((body) => body.challenge);
88
178
  }
89
179
 
90
- reserve(challenge, { recipient, ciphertextSha256, ciphertextBytes, expiresAt }) {
180
+ async reserve(challenge, { recipient, ciphertextSha256, ciphertextBytes, expiresAt }) {
181
+ const keyId = await this.resolveKeyId();
91
182
  return this._json("/pillar/blobs/reserve", {
92
- body: signedRequest(this.keychain, this.keyId, challenge, {
183
+ body: signedRequest(this.keychain, keyId, challenge, {
93
184
  action: "reserve",
94
185
  agent_uuaid: this.keychain._identity.uuaid,
95
186
  recipient_uuaid: recipient,
@@ -100,36 +191,44 @@ export class PillarBlobHttpClient {
100
191
  });
101
192
  }
102
193
 
103
- finalize(objectId) {
104
- return this.challenge("finalize", objectId).then((challenge) => this._json("/pillar/blobs/finalize", {
105
- body: signedRequest(this.keychain, this.keyId, challenge, {
194
+ async finalize(objectId) {
195
+ const keyId = await this.resolveKeyId();
196
+ const challenge = await this.challenge("finalize", objectId);
197
+ return this._json("/pillar/blobs/finalize", {
198
+ body: signedRequest(this.keychain, keyId, challenge, {
106
199
  action: "finalize",
107
200
  agent_uuaid: this.keychain._identity.uuaid,
108
201
  }),
109
- }));
202
+ });
110
203
  }
111
204
 
112
- redeem(objectId) {
113
- return this.challenge("redeem", objectId).then((challenge) => this._json("/pillar/blobs/redeem", {
114
- body: signedRequest(this.keychain, this.keyId, challenge, {
205
+ async redeem(objectId) {
206
+ const keyId = await this.resolveKeyId();
207
+ const challenge = await this.challenge("redeem", objectId);
208
+ return this._json("/pillar/blobs/redeem", {
209
+ body: signedRequest(this.keychain, keyId, challenge, {
115
210
  action: "redeem",
116
211
  agent_uuaid: this.keychain._identity.uuaid,
117
212
  }),
118
- }));
213
+ });
119
214
  }
120
215
 
121
216
  async upload(ticket, ciphertext) {
217
+ let response;
122
218
  try {
123
- const response = await this.fetch(ticket.url, {
219
+ response = await this.fetch(ticket.url, {
124
220
  method: "PUT",
125
221
  headers: ticket.headers,
126
222
  body: ciphertext,
127
223
  signal: AbortSignal.timeout(this.timeoutMs),
128
224
  });
129
- if (!response.ok) throw new Error(`upload returned ${response.status}`);
130
225
  } catch (error) {
131
226
  throw new BlobUploadError(`temporary blob upload failed: ${error.message}`, error);
132
227
  }
228
+ if (!response.ok) {
229
+ const cause = new Error(`upload returned ${response.status}`);
230
+ throw withRefusal(new BlobUploadError(`temporary blob upload failed: ${cause.message}`, cause), await refusalOf(response));
231
+ }
133
232
  }
134
233
 
135
234
  async download(ticket) {
@@ -139,9 +238,12 @@ export class PillarBlobHttpClient {
139
238
  } catch (error) {
140
239
  throw new BlobFetchError(`temporary blob download failed: ${error.message}`, error);
141
240
  }
142
- if (response.status === 404) throw new BlobNotFoundError("temporary-object");
143
- if (response.status === 403) throw new BlobExpiryError("temporary blob download capability expired");
144
- if (!response.ok) throw new BlobFetchError(`temporary blob download returned ${response.status}`);
241
+ if (!response.ok) {
242
+ const error = response.status === 404 ? new BlobNotFoundError("temporary-object")
243
+ : response.status === 403 ? new BlobExpiryError("temporary blob download capability expired")
244
+ : new BlobFetchError(`temporary blob download returned ${response.status}`);
245
+ throw withRefusal(error, await refusalOf(response));
246
+ }
145
247
  return Buffer.from(await response.arrayBuffer());
146
248
  }
147
249
 
@@ -155,16 +257,28 @@ export class PillarBlobHttpClient {
155
257
  return value;
156
258
  }
157
259
 
158
- /** Fail closed unless a fresh registry-signed badge names this exact active key. */
159
- async assertRegistryActiveSender(sender, expectedPublicKey) {
260
+ /**
261
+ * The presentation key ({ alg, keyId, publicKey }) of a fresh badge for
262
+ * `uuaid` whose payload hash matches and whose ed25519 signature pins to a
263
+ * live key of the registry trust root; null when there is no badge (404)
264
+ * or it fails any of those checks. Other broker errors propagate.
265
+ */
266
+ async _verifiedPresentationKey(uuaid) {
267
+ let badge;
268
+ try {
269
+ badge = await this._json(`/iaaso/v1/badge/${encodeURIComponent(uuaid)}`, { method: "GET" });
270
+ } catch (error) {
271
+ if (error?.status === 404) return null;
272
+ throw error;
273
+ }
160
274
  const trust = await this._registryTrust();
161
- const badge = await this._json(`/iaaso/v1/badge/${encodeURIComponent(sender)}`, { method: "GET" });
162
275
  const presentation = badge?.payload?.subject?.presentationKey;
163
- const keyMatches = badge?.payload?.subject?.uuaid === sender
276
+ const subjectMatches = badge?.payload?.subject?.uuaid === uuaid
164
277
  && presentation?.alg === "ed25519"
165
- && presentation?.publicKey?.toLowerCase() === expectedPublicKey?.toLowerCase();
278
+ && typeof presentation?.publicKey === "string";
166
279
  const fresh = Number.isFinite(Date.parse(badge?.payload?.freshUntil)) && Date.parse(badge.payload.freshUntil) > Date.now();
167
- const hashMatches = badge?.payloadHash === payloadHash(badge?.payload);
280
+ let hashMatches = false;
281
+ try { hashMatches = badge?.payloadHash === payloadHash(badge?.payload); } catch (_error) { hashMatches = false; }
168
282
  const registrySignature = Array.isArray(badge?.signatures)
169
283
  ? badge.signatures.find((signature) => {
170
284
  if (signature.alg !== "ed25519") return false;
@@ -175,12 +289,36 @@ export class PillarBlobHttpClient {
175
289
  } catch (_error) { return false; }
176
290
  })
177
291
  : null;
178
- if (!keyMatches || !fresh || !hashMatches || !registrySignature) {
292
+ return subjectMatches && fresh && hashMatches && registrySignature ? presentation : null;
293
+ }
294
+
295
+ /**
296
+ * Fail closed unless a fresh registry-signed badge names this exact active
297
+ * key. The receive flows call this only for a sender whose UUAID does not
298
+ * derive from its signing key; see assertSenderKey.
299
+ */
300
+ async assertRegistryActiveSender(sender, expectedPublicKey) {
301
+ const presentation = await this._verifiedPresentationKey(sender);
302
+ const keyMatches = typeof expectedPublicKey === "string"
303
+ && presentation?.publicKey.toLowerCase() === expectedPublicKey.toLowerCase();
304
+ if (!keyMatches) {
179
305
  throw new BlobSenderKeyError("sender presentation key is not registry-active", sender, "inactive");
180
306
  }
181
307
  return "active";
182
308
  }
183
309
 
310
+ /**
311
+ * Recipient sender gate for a received blob-ref. "self-certified" when the
312
+ * ref's signature verifies and its sender is exactly that signing key's
313
+ * uuaid:foundation:agent UUAID (proven key holder, unverified identity);
314
+ * otherwise "active" when a registry badge names that key; otherwise
315
+ * throws BlobSenderKeyError (code sender-key-not-active, with a fix).
316
+ */
317
+ async assertSenderKey(ref) {
318
+ if (senderSelfCertified(ref)) return "self-certified";
319
+ return this.assertRegistryActiveSender(ref?.sender, ref?.sig?.publicKey);
320
+ }
321
+
184
322
  crossCheckRedemption(ref, brokerRecord) {
185
323
  if (
186
324
  brokerRecord?.object_id !== ref.object ||
@@ -30,8 +30,25 @@ import { createHash } from "node:crypto";
30
30
  import { jcs } from "./jcs.mjs";
31
31
  import { Keychain } from "./keychain.mjs";
32
32
 
33
- /** Tier ladder. community = the unauthenticated default (F2 hardening values). */
34
- export const TIER_DEFAULTS = {
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 = {
35
52
  community: { envelopesPerMin: 120, maxBodyBytes: 512 * 1024 },
36
53
  supporter: { envelopesPerMin: 300, maxBodyBytes: 1024 * 1024 },
37
54
  org: { envelopesPerMin: 600, maxBodyBytes: 2 * 1024 * 1024 },
@@ -39,6 +56,13 @@ export const TIER_DEFAULTS = {
39
56
  foundation: { envelopesPerMin: 3000, maxBodyBytes: 2 * 1024 * 1024 },
40
57
  };
41
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
+
42
66
  /** Hard ceiling any grant can reach — also the carrier's raw-body stream cap. */
43
67
  export const ABSOLUTE_MAX_BODY = 2 * 1024 * 1024;
44
68
  export const ABSOLUTE_MAX_RATE = 6000;
package/src/index.mjs CHANGED
@@ -36,6 +36,9 @@ export {
36
36
  signBlobRef, verifyBlobRefSig, blobRefMetadata, blobRefSecret, refSerializedBytes,
37
37
  makeBlobRef, validateBlobRef, envelopeBodyBytes, preflightBodyBytes, chooseTransport,
38
38
  prepareBlobSend, fetchAndDecryptBlob, prepareBrokerBlobSend, fetchBrokerBlob,
39
+ SELF_CERTIFIED_KEY_ID, isSelfCertifiedUuaid, selfCertifiedUuaidFor, senderSelfCertified,
39
40
  } from "./blob/blob.mjs";
40
41
  export { MemoryBlobStore, FsBlobStore } from "./blob/store.mjs";
41
42
  export { PillarBlobHttpClient, PILLAR_BLOB_AUDIENCE } from "./blob/http.mjs";
43
+ // Refusals in agent language: carrier reason -> one-line fix, `<code>: <fix>`.
44
+ export { REFUSAL_FIXES, carrierFix, withCarrierFix, refusalLine, refusalFields } from "./net/refusals.mjs";
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { envelopeBodyBytes, preflightBodyBytes } from "../blob/blob.mjs";
9
9
  import { seedTier } from "./seed-tier.mjs";
10
+ import { carrierFix, REFUSAL_FIXES } from "./refusals.mjs";
10
11
 
11
12
  /**
12
13
  * Rank carriers by how proven they are, best first. Unknown carriers rank as
@@ -66,7 +67,11 @@ export class CarrierClient {
66
67
  * accepted; throws if every carrier rejects. Returns the receipt from the
67
68
  * most PROVEN accepting carrier as the primary ({ carrier, seq, sha,
68
69
  * duplicate }) plus `all` with every carrier's outcome ({ carrier, ok,
69
- * seq?, sha?, duplicate?, error? }).
70
+ * seq?, sha?, duplicate?, error?, status?, reason?, fix? }).
71
+ *
72
+ * When every carrier refuses, the thrown error carries `code` (the most
73
+ * proven carrier's `reason`, else carrier-unreachable / carrier-bad-response),
74
+ * `fix`, `status`, `carrier` and `refusals` (= `all`).
70
75
  *
71
76
  * Which receipt is primary matters because callers quote its seq back as a
72
77
  * cursor and cite its carrier in receipts: it should name a seed that will
@@ -82,6 +87,7 @@ export class CarrierClient {
82
87
  const e = new Error(`carrier preflight: body ${pf.bytes}B exceeds ${pf.source} limit ${pf.limit}B — use the blob transport`);
83
88
  e.code = pf.source === "direct-cap" ? "blob-too-large" : "over-tier-limit";
84
89
  e.limit = pf.limit; e.source = pf.source;
90
+ e.fix = REFUSAL_FIXES["over-tier-limit"];
85
91
  throw e;
86
92
  }
87
93
  const attempt = async (base) => {
@@ -101,7 +107,13 @@ export class CarrierClient {
101
107
  if (res.status === 202 && body.accepted) {
102
108
  return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate };
103
109
  }
104
- return { carrier: base, ok: false, error: `${res.status} ${body.reason ?? body.error ?? (raw ? "non-json-response" : "empty-response")}` };
110
+ const reason = typeof body.reason === "string" && body.reason ? body.reason
111
+ : typeof body.error === "string" && body.error ? body.error : null;
112
+ return {
113
+ carrier: base, ok: false, status: res.status,
114
+ error: `${res.status} ${body.reason ?? body.error ?? (raw ? "non-json-response" : "empty-response")}`,
115
+ ...(reason ? { reason, fix: (typeof body.fix === "string" && body.fix) || carrierFix(reason) } : {}),
116
+ };
105
117
  } catch (e) {
106
118
  return { carrier: base, ok: false, error: e.message };
107
119
  }
@@ -110,7 +122,14 @@ export class CarrierClient {
110
122
  rs.map(r => r.status === "fulfilled" ? r.value : { carrier: "?", ok: false, error: String(r.reason) }));
111
123
  const accepted = all.filter(r => r.ok);
112
124
  if (!accepted.length) {
113
- throw new Error(`carrier deliver failed on all carriers: ${all.map(r => `${r.carrier}: ${r.error}`).join("; ")}`);
125
+ const e = new Error(`carrier deliver failed on all carriers: ${all.map(r => `${r.carrier}: ${r.error}`).join("; ")}`);
126
+ // `all` is in rank order, so this is the most proven carrier's answer.
127
+ const primary = all.find(r => r.reason) ?? all.find(r => r.status) ?? null;
128
+ e.code = primary?.reason ?? (primary ? "carrier-bad-response" : "carrier-unreachable");
129
+ e.fix = primary?.fix ?? carrierFix(e.code);
130
+ if (primary) { e.status = primary.status; e.carrier = primary.carrier; }
131
+ e.refusals = all;
132
+ throw e;
114
133
  }
115
134
  const ranked = accepted.slice().sort((a, b) => byTier(this.carrierTiers)(a.carrier, b.carrier));
116
135
  const { carrier, seq, sha, duplicate } = ranked[0];
@@ -135,7 +154,11 @@ export class CarrierClient {
135
154
  });
136
155
  if (res.status !== 200) {
137
156
  const body = await res.json().catch(() => ({}));
138
- throw new Error(`inbox fetch ${base}: ${res.status} ${body.error ?? ""}`);
157
+ const e = new Error(`inbox fetch ${base}: ${res.status} ${body.error ?? ""}`);
158
+ e.code = typeof body.error === "string" && body.error ? body.error : "carrier-bad-response";
159
+ e.fix = (typeof body.fix === "string" && body.fix) || carrierFix(e.code);
160
+ e.status = res.status; e.carrier = base;
161
+ throw e;
139
162
  }
140
163
  return res.json(); // { envelopes: [{seq, envelope}], now }
141
164
  }
@@ -58,6 +58,7 @@ import { Keychain } from "../identity/keychain.mjs";
58
58
  import { jcs } from "../identity/jcs.mjs";
59
59
  import { verifyTierGrant } from "../identity/tier.mjs";
60
60
  import { verifyDelegationDoc, delegationCoversKind } from "./delegation.mjs";
61
+ import { withCarrierFix } from "./refusals.mjs"; // additive `fix` on refusals; reason/error/status unchanged
61
62
 
62
63
  const MAX_WAIT_S = 30;
63
64
  const MAX_BODY = 512 * 1024; // 512 KiB per envelope (community tier)
@@ -245,7 +246,7 @@ export class CarrierServer {
245
246
  // No-op on a dead socket: close() destroys connections, and a long-poll
246
247
  // handler whose timer fires afterwards must not throw on write.
247
248
  if (res.destroyed || res.writableEnded) return;
248
- const body = JSON.stringify(obj);
249
+ const body = JSON.stringify(withCarrierFix(code, obj));
249
250
  res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
250
251
  res.end(body);
251
252
  }
@@ -45,11 +45,68 @@
45
45
  * principal, so carriers key mailboxes, rate limits, and audits to the
46
46
  * principal UUAID exactly as before.
47
47
  */
48
+ import { randomBytes } from "node:crypto";
48
49
  import { jcs } from "../identity/jcs.mjs";
49
50
  import { Keychain } from "../identity/keychain.mjs";
50
51
 
51
52
  export const DELEGATION_VERSION = "uuaid-delegation/1";
52
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
+
53
110
  /**
54
111
  * Extract the local-id of a UUAID with pillar-client parseUuaid semantics:
55
112
  * exactly four colon-separated segments with the "uuaid" prefix.
@@ -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
+ }