@uuaid/pillar 2.0.2 → 2.2.0

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.
@@ -69,12 +69,99 @@ export class Mailbox {
69
69
  this.db.pragma("journal_mode = WAL");
70
70
  this.db.pragma("synchronous = NORMAL");
71
71
  this.db.exec(SCHEMA);
72
+ // Several processes open one home at once (the courier's `inbox` beside a
73
+ // daemon's `recv`). A check-then-ALTER outside a lock lets the loser throw
74
+ // "duplicate column name", so every migration runs in ONE immediate
75
+ // transaction that re-reads table_info inside it: the second opener waits
76
+ // on the write lock (busy timeout) and then finds nothing left to add.
77
+ this.db.transaction(() => this._migrate()).immediate();
78
+ }
79
+
80
+ _migrate() {
72
81
  // v2 migration: quarantine marker (trust-policy gate). Nullable text =
73
82
  // reason; null = normal mail. Chain hashes are unaffected (metadata only).
74
83
  const cols = this.db.pragma("table_info(inbox)").map(c => c.name);
75
84
  if (!cols.includes("quarantined")) {
76
85
  this.db.exec("alter table inbox add column quarantined text");
77
86
  }
87
+ // v3 migration. Every column is metadata beside the chain (the chain
88
+ // hashes envelope_sha256 only), so none of it can alter an audit.
89
+ // inbox.pulled_by the reader key of the process that stored the row;
90
+ // lets a reader see that SOMEONE ELSE pulled its mail.
91
+ // inbox.group_id the pillar-group id the sealed payload carried.
92
+ // outbox.kind/thread/group_id what a receipt is matched against: the
93
+ // sender cannot decrypt its own sealed outbox rows.
94
+ // outbox.recipient_* the recipient's own signed ack / read receipt.
95
+ // `status = 'delivered'` keeps meaning what it always
96
+ // meant: a CARRIER accepted the envelope.
97
+ const addCols = (table, specs) => {
98
+ const have = new Set(this.db.pragma(`table_info(${table})`).map(c => c.name));
99
+ for (const [name, type] of specs) {
100
+ if (!have.has(name)) this.db.exec(`alter table ${table} add column ${name} ${type}`);
101
+ }
102
+ };
103
+ addCols("inbox", [["pulled_by", "text"], ["group_id", "text"]]);
104
+ addCols("outbox", [
105
+ ["kind", "text"], ["thread", "text"], ["group_id", "text"], ["group_msg_id", "text"],
106
+ ["recipient_ack_at", "text"], ["recipient_ack_id", "text"], ["recipient_read_at", "text"],
107
+ ]);
108
+ this.db.exec(`
109
+ create table if not exists readers (
110
+ name text primary key,
111
+ cursor integer not null default 0,
112
+ pid integer,
113
+ host text,
114
+ cwd text,
115
+ command text,
116
+ last_seen text
117
+ );
118
+ create table if not exists reader_leases (
119
+ reader text not null,
120
+ host text not null,
121
+ pid integer not null,
122
+ cwd text,
123
+ command text,
124
+ started_at text not null,
125
+ lease_until text not null,
126
+ primary key (reader, host, pid)
127
+ );
128
+ create table if not exists groups (
129
+ id text primary key,
130
+ name text not null,
131
+ members text not null,
132
+ keys text,
133
+ created_by text,
134
+ created_at text not null,
135
+ updated_at text not null,
136
+ pending integer not null default 0,
137
+ left_at text
138
+ );
139
+ create table if not exists group_v2_sendq (
140
+ id text primary key,
141
+ group_id text not null,
142
+ recipient text not null,
143
+ recipient_key text,
144
+ op text not null,
145
+ mv_version integer not null,
146
+ want_from integer,
147
+ want_to integer,
148
+ manifest_hash text,
149
+ conflicts_with text,
150
+ queued_at text not null,
151
+ state text not null default 'queued',
152
+ state_at text,
153
+ envelope_id text,
154
+ reason text
155
+ );
156
+ create index if not exists group_v2_sendq_state on group_v2_sendq (state, group_id);
157
+ create index if not exists inbox_group on inbox (group_id);
158
+ create index if not exists outbox_thread on outbox (recipient, thread);
159
+ `);
160
+ // groups.v2_synced_at: set when the pillar-group/2 sync writes the row (the
161
+ // verified head minus leavers). A marked row is a v2 group's for good: v1 tags
162
+ // and `pillar group` verbs naming it go to v2 (Pillar._receive, groupCommandRoute),
163
+ // and with its v2 store file gone it is cleared, not handed back to v1.
164
+ addCols("groups", [["v2_synced_at", "text"]]);
78
165
  }
79
166
 
80
167
  close() { try { this.db.close(); } catch (_e) {} }
@@ -108,16 +195,17 @@ export class Mailbox {
108
195
  * and MUST include at least `id`, `recipient`, `createdAt`. Chain hash is computed
109
196
  * atomically with the insert; concurrent appends are serialized via the SQLite transaction.
110
197
  */
111
- appendOutbound(envelope) {
198
+ appendOutbound(envelope, { thread = null, groupId = null, groupMsgId = null } = {}) {
112
199
  return this.db.transaction(() => {
113
200
  const envSha = Mailbox.envelopeSha(envelope);
114
201
  const prev = this._getHead("outbox");
115
202
  const chainInput = prev == null ? CHAIN_SEED + envSha : prev + envSha;
116
203
  const chain = createHash("sha256").update(chainInput).digest("hex");
117
204
  this.db.prepare(`
118
- insert into outbox (envelope_id, envelope_sha256, recipient, created_at, envelope_json, chain_hash, status)
119
- values (?, ?, ?, ?, ?, ?, 'pending')
120
- `).run(envelope.id, envSha, envelope.recipient, envelope.createdAt, JSON.stringify(envelope), chain);
205
+ insert into outbox (envelope_id, envelope_sha256, recipient, created_at, envelope_json, chain_hash, status, kind, thread, group_id, group_msg_id)
206
+ values (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)
207
+ `).run(envelope.id, envSha, envelope.recipient, envelope.createdAt, JSON.stringify(envelope), chain,
208
+ envelope.kind ?? null, thread ?? envelope.id, groupId, groupMsgId);
121
209
  this._setHead("outbox", chain);
122
210
  return { envelopeId: envelope.id, envelopeSha256: envSha, chainHash: chain };
123
211
  })();
@@ -126,23 +214,24 @@ export class Mailbox {
126
214
  /**
127
215
  * Append an incoming envelope. Same shape. MUST be idempotent on envelope.id;
128
216
  * a duplicate delivery does NOT re-append and does NOT advance the chain.
217
+ * `pulledBy` is the reader key of the storing process (metadata only).
129
218
  */
130
- appendInbound(envelope) {
219
+ appendInbound(envelope, { pulledBy = null } = {}) {
131
220
  return this.db.transaction(() => {
132
- const existing = this.db.prepare("select envelope_id, envelope_sha256 from inbox where envelope_id = ?").get(envelope.id);
221
+ const existing = this.db.prepare("select seq, envelope_id, envelope_sha256 from inbox where envelope_id = ?").get(envelope.id);
133
222
  if (existing) {
134
- return { duplicate: true, envelopeId: envelope.id, envelopeSha256: existing.envelope_sha256 };
223
+ return { duplicate: true, seq: existing.seq, envelopeId: envelope.id, envelopeSha256: existing.envelope_sha256 };
135
224
  }
136
225
  const envSha = Mailbox.envelopeSha(envelope);
137
226
  const prev = this._getHead("inbox");
138
227
  const chainInput = prev == null ? CHAIN_SEED + envSha : prev + envSha;
139
228
  const chain = createHash("sha256").update(chainInput).digest("hex");
140
- this.db.prepare(`
141
- insert into inbox (envelope_id, envelope_sha256, sender, received_at, envelope_json, chain_hash)
142
- values (?, ?, ?, ?, ?, ?)
143
- `).run(envelope.id, envSha, envelope.sender ?? envelope.from ?? "unknown", new Date().toISOString(), JSON.stringify(envelope), chain);
229
+ const r = this.db.prepare(`
230
+ insert into inbox (envelope_id, envelope_sha256, sender, received_at, envelope_json, chain_hash, pulled_by)
231
+ values (?, ?, ?, ?, ?, ?, ?)
232
+ `).run(envelope.id, envSha, envelope.sender ?? envelope.from ?? "unknown", new Date().toISOString(), JSON.stringify(envelope), chain, pulledBy);
144
233
  this._setHead("inbox", chain);
145
- return { duplicate: false, envelopeId: envelope.id, envelopeSha256: envSha, chainHash: chain };
234
+ return { duplicate: false, seq: Number(r.lastInsertRowid), envelopeId: envelope.id, envelopeSha256: envSha, chainHash: chain };
146
235
  })();
147
236
  }
148
237
 
@@ -191,11 +280,206 @@ export class Mailbox {
191
280
  .run(String(reason).slice(0, 100), envelopeId);
192
281
  }
193
282
 
283
+ // ── pillar-group/2 send queue: what the v2 receiver asked to send ─────────
284
+ // Nothing is sent from here. A row leaves only through an explicit command
285
+ // (Pillar.syncGroupV2), and an egress filter can read every queued recipient
286
+ // with: select recipient from group_v2_sendq where state = 'queued'.
287
+
288
+ static GROUP_V2_SENDQ_MAX = 1000;
289
+
290
+ /** Queue sends ({ to, key?, op, mvVersion, want?, manifestHash?, conflictsWith? }). Returns the ids queued. */
291
+ enqueueGroupV2Sends(groupId, sends, { now = new Date().toISOString(), newId }) {
292
+ const ids = [];
293
+ this.db.transaction(() => {
294
+ let queued = this.db.prepare("select count(*) as n from group_v2_sendq where state = 'queued'").get().n;
295
+ const ins = this.db.prepare(`insert into group_v2_sendq (id, group_id, recipient, recipient_key, op, mv_version, want_from, want_to, manifest_hash, conflicts_with, queued_at)
296
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
297
+ for (const s of sends) {
298
+ if (queued >= Mailbox.GROUP_V2_SENDQ_MAX) break; // bounded: the caller counts what did not fit
299
+ const id = newId();
300
+ ins.run(id, groupId, s.to, s.key ?? null, s.op, s.mvVersion, s.want?.from ?? null, s.want ? s.want.to : null,
301
+ s.manifestHash ?? null, s.conflictsWith ?? null, now);
302
+ ids.push(id);
303
+ queued++;
304
+ }
305
+ }).immediate();
306
+ return ids;
307
+ }
308
+
309
+ /** Queued rows, oldest first; one group or all. */
310
+ queuedGroupV2Sends(groupId = null) {
311
+ return groupId
312
+ ? this.db.prepare("select * from group_v2_sendq where state = 'queued' and group_id = ? order by queued_at, rowid").all(groupId)
313
+ : this.db.prepare("select * from group_v2_sendq where state = 'queued' order by queued_at, rowid").all();
314
+ }
315
+
316
+ /** state: sent | dropped (final), or queued with a reason (a failed attempt). */
317
+ markGroupV2Send(id, state, { envelopeId = null, reason = null } = {}) {
318
+ this.db.prepare("update group_v2_sendq set state = ?, state_at = ?, envelope_id = coalesce(?, envelope_id), reason = ? where id = ?")
319
+ .run(state, new Date().toISOString(), envelopeId, reason ? String(reason).slice(0, 200) : null, id);
320
+ }
321
+
322
+ clearQuarantine(envelopeId) {
323
+ this.db.prepare("update inbox set quarantined = null where envelope_id = ?").run(envelopeId);
324
+ }
325
+
194
326
  markInboxRead(envelopeId) {
195
327
  this.db.prepare("update inbox set read_at = ? where envelope_id = ? and read_at is null")
196
328
  .run(new Date().toISOString(), envelopeId);
197
329
  }
198
330
 
331
+ setInboundGroup(envelopeId, groupId) {
332
+ this.db.prepare("update inbox set group_id = ? where envelope_id = ?").run(groupId, envelopeId);
333
+ }
334
+
335
+ inboxTipSeq() {
336
+ return this.db.prepare("select coalesce(max(seq), 0) as n from inbox").get().n;
337
+ }
338
+
339
+ /** Non-quarantined rows after `seq`, oldest first — what a reader has not seen. */
340
+ inboxAfter(seq = 0, limit = 500) {
341
+ return this.db.prepare("select * from inbox where seq > ? and quarantined is null order by seq asc limit ?").all(seq, limit);
342
+ }
343
+
344
+ // ── Readers ────────────────────────────────────────────────────────────────
345
+ // A reader is one consumer of this home's inbox, with its OWN cursor over
346
+ // the local inbox table. The carrier cursor stays shared (carrier_cursors):
347
+ // fetching is idempotent on envelope id, so whoever pulls, every reader
348
+ // still sees the row after its own cursor.
349
+
350
+ getReader(name) {
351
+ return this.db.prepare("select * from readers where name = ?").get(name) ?? null;
352
+ }
353
+
354
+ listReaders() {
355
+ return this.db.prepare("select * from readers order by name").all();
356
+ }
357
+
358
+ /** Create or refresh a reader's last-run details; never moves its cursor. */
359
+ touchReader(name, { pid = null, host = null, cwd = null, command = null, cursor = 0 } = {}) {
360
+ this.db.prepare(`
361
+ insert into readers (name, cursor, pid, host, cwd, command, last_seen)
362
+ values (?, ?, ?, ?, ?, ?, ?)
363
+ on conflict(name) do update set pid = excluded.pid, host = excluded.host, cwd = excluded.cwd,
364
+ command = excluded.command, last_seen = excluded.last_seen
365
+ `).run(name, cursor, pid, host, cwd, command, new Date().toISOString());
366
+ }
367
+
368
+ setReaderCursor(name, seq) {
369
+ this.db.prepare("update readers set cursor = max(cursor, ?) where name = ?").run(seq, name);
370
+ }
371
+
372
+ /** A long-running consumer (pillar recv) holds a lease; renew it on a heartbeat. */
373
+ holdLease(reader, { host, pid, cwd = null, command = null, ttlMs = 90_000 }) {
374
+ const now = new Date();
375
+ this.db.prepare(`
376
+ insert into reader_leases (reader, host, pid, cwd, command, started_at, lease_until)
377
+ values (?, ?, ?, ?, ?, ?, ?)
378
+ on conflict(reader, host, pid) do update set lease_until = excluded.lease_until
379
+ `).run(reader, host, pid, cwd, command, now.toISOString(), new Date(now.getTime() + ttlMs).toISOString());
380
+ }
381
+
382
+ releaseLease(reader, { host, pid }) {
383
+ this.db.prepare("delete from reader_leases where reader = ? and host = ? and pid = ?").run(reader, host, pid);
384
+ }
385
+
386
+ /** Leases not yet expired. The caller decides which are really alive (pid check). */
387
+ unexpiredLeases() {
388
+ this.db.prepare("delete from reader_leases where lease_until < ?").run(new Date(Date.now() - 3600_000).toISOString());
389
+ return this.db.prepare("select * from reader_leases where lease_until >= ? order by started_at").all(new Date().toISOString());
390
+ }
391
+
392
+ // ── Receipts (the recipient's own signed ack / read receipt) ──────────────
393
+
394
+ /** Record a recipient-signed ack. Only for a row actually sent TO `from`. */
395
+ markOutboundAcked(envelopeId, { from, ackId = null, at = null }) {
396
+ const r = this.db.prepare(`
397
+ update outbox set recipient_ack_at = coalesce(recipient_ack_at, ?), recipient_ack_id = coalesce(recipient_ack_id, ?)
398
+ where envelope_id = ? and recipient = ?
399
+ `).run(at ?? new Date().toISOString(), ackId, envelopeId, from);
400
+ return r.changes > 0;
401
+ }
402
+
403
+ /** Mark rows read (read implies acked: a missing ack is backfilled). */
404
+ markOutboundRead(envelopeIds, { at = null } = {}) {
405
+ const ts = at ?? new Date().toISOString();
406
+ const stmt = this.db.prepare(`
407
+ update outbox set recipient_read_at = coalesce(recipient_read_at, ?), recipient_ack_at = coalesce(recipient_ack_at, ?)
408
+ where envelope_id = ?
409
+ `);
410
+ let n = 0;
411
+ this.db.transaction(() => { for (const id of envelopeIds) n += stmt.run(ts, ts, id).changes; })();
412
+ return n;
413
+ }
414
+
415
+ /** Outbound rows to one recipient, oldest first (receipt matching). */
416
+ outboundTo(recipient) {
417
+ return this.db.prepare("select * from outbox where recipient = ? order by seq asc").all(recipient);
418
+ }
419
+
420
+ outboxRecent(limit = 20) {
421
+ return this.db.prepare("select * from outbox order by seq desc limit ?").all(limit);
422
+ }
423
+
424
+ // ── Groups (pillar-group v1 rosters, INTERACTION-CONVENTION.md) ───────────
425
+
426
+ _groupRow(row) {
427
+ if (!row) return null;
428
+ return {
429
+ id: row.id,
430
+ name: row.name,
431
+ members: JSON.parse(row.members),
432
+ keys: row.keys ? JSON.parse(row.keys) : {},
433
+ createdBy: row.created_by,
434
+ createdAt: row.created_at,
435
+ updatedAt: row.updated_at,
436
+ pending: !!row.pending,
437
+ left: !!row.left_at,
438
+ };
439
+ }
440
+
441
+ getGroup(id) {
442
+ return this._groupRow(this.db.prepare("select * from groups where id = ?").get(id));
443
+ }
444
+
445
+ listGroups() {
446
+ return this.db.prepare("select * from groups order by updated_at desc").all().map(r => this._groupRow(r));
447
+ }
448
+
449
+ /** Mark a groups row as written by the pillar-group/2 sync (see _migrate). The row must exist. */
450
+ markGroupV2(id, at = new Date().toISOString()) {
451
+ this.db.prepare("update groups set v2_synced_at = ? where id = ?").run(at, id);
452
+ }
453
+
454
+ isGroupV2Marked(id) {
455
+ return !!this.db.prepare("select v2_synced_at from groups where id = ?").get(id)?.v2_synced_at;
456
+ }
457
+
458
+ /** Ids of every row the v2 sync has marked. */
459
+ groupV2MarkedIds() {
460
+ return this.db.prepare("select id from groups where v2_synced_at is not null order by id").all().map(r => r.id);
461
+ }
462
+
463
+ putGroup(g) {
464
+ const now = new Date().toISOString();
465
+ this.db.prepare(`
466
+ insert into groups (id, name, members, keys, created_by, created_at, updated_at, pending, left_at)
467
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?)
468
+ on conflict(id) do update set name = excluded.name, members = excluded.members, keys = excluded.keys,
469
+ updated_at = excluded.updated_at, pending = excluded.pending, left_at = excluded.left_at
470
+ `).run(g.id, g.name, JSON.stringify(g.members), JSON.stringify(g.keys ?? {}), g.createdBy ?? null,
471
+ g.createdAt ?? now, now, g.pending ? 1 : 0, g.left ? (g.leftAt ?? now) : null);
472
+ return this.getGroup(g.id);
473
+ }
474
+
475
+ groupInbound(groupId, limit = 50) {
476
+ return this.db.prepare("select * from inbox where group_id = ? and quarantined is null order by seq desc limit ?").all(groupId, limit).reverse();
477
+ }
478
+
479
+ groupOutbound(groupId, limit = 200) {
480
+ return this.db.prepare("select * from outbox where group_id = ? order by seq desc limit ?").all(groupId, limit).reverse();
481
+ }
482
+
199
483
  /**
200
484
  * Recompute the outbox chain from scratch and compare to stored chain_hash column.
201
485
  * Returns { ok, badSeq? }. Used by adversarial tests + operator-driven audits.
@@ -20,6 +20,21 @@ function byTier(tiers) {
20
20
  return (a, b) => seedTier(tiers[a] ?? "probation").listRank - seedTier(tiers[b] ?? "probation").listRank;
21
21
  }
22
22
 
23
+ /**
24
+ * The carrier's signed acceptance receipt (uuaid.carrier-receipt.v1), kept as
25
+ * the carrier sent it, plus this client's own clock when the answer arrived
26
+ * (design §12 rule 3: record received-at beside the carrier's acceptedAt,
27
+ * which is only the carrier's claim). NOT verified here: verify against the
28
+ * pinned key for this carrier and the exact envelope posted. {} when the
29
+ * carrier sent no receipt (an older or keyless carrier): that never fails a
30
+ * delivery, the outcome is simply unreceipted.
31
+ */
32
+ function keptReceipt(body) {
33
+ const r = body?.receipt;
34
+ if (!r || typeof r !== "object" || Array.isArray(r) || typeof body.receiptSig !== "string") return {};
35
+ return { receipt: r, receiptSig: body.receiptSig, receivedAt: new Date().toISOString() }; // @rule client.keeps-receipt
36
+ }
37
+
23
38
  export class CarrierClient {
24
39
  /**
25
40
  * @param {{keychain: import('../identity/keychain.mjs').Keychain, carriers: string[]}} opts
@@ -66,8 +81,13 @@ export class CarrierClient {
66
81
  * Resolves once every carrier has answered. Succeeds if at least one
67
82
  * accepted; throws if every carrier rejects. Returns the receipt from the
68
83
  * most PROVEN accepting carrier as the primary ({ carrier, seq, sha,
69
- * duplicate }) plus `all` with every carrier's outcome ({ carrier, ok,
70
- * seq?, sha?, duplicate?, error?, status?, reason?, fix? }).
84
+ * duplicate, receipt?, receiptSig?, receivedAt? }) plus `all` with every
85
+ * carrier's outcome ({ carrier, ok, seq?, sha?, duplicate?, receipt?,
86
+ * receiptSig?, receivedAt?, error?, status?, reason?, fix? }).
87
+ *
88
+ * `receipt` / `receiptSig` are present only when that carrier signed one
89
+ * (uuaid.carrier-receipt.v1); they are unverified carrier output, and
90
+ * `receivedAt` is this client's clock, not the carrier's.
71
91
  *
72
92
  * When every carrier refuses, the thrown error carries `code` (the most
73
93
  * proven carrier's `reason`, else carrier-unreachable / carrier-bad-response),
@@ -105,7 +125,7 @@ export class CarrierClient {
105
125
  let body = {};
106
126
  try { body = raw ? JSON.parse(raw) : {}; } catch (_error) {}
107
127
  if (res.status === 202 && body.accepted) {
108
- return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate };
128
+ return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate, ...keptReceipt(body) };
109
129
  }
110
130
  const reason = typeof body.reason === "string" && body.reason ? body.reason
111
131
  : typeof body.error === "string" && body.error ? body.error : null;
@@ -132,15 +152,15 @@ export class CarrierClient {
132
152
  throw e;
133
153
  }
134
154
  const ranked = accepted.slice().sort((a, b) => byTier(this.carrierTiers)(a.carrier, b.carrier));
135
- const { carrier, seq, sha, duplicate } = ranked[0];
136
- return { carrier, seq, sha, duplicate, all };
155
+ const { carrier, seq, sha, duplicate, receipt, receiptSig, receivedAt } = ranked[0];
156
+ return { carrier, seq, sha, duplicate, ...(receipt ? { receipt, receiptSig, receivedAt } : {}), all };
137
157
  }
138
158
 
139
159
  /**
140
160
  * Fetch envelopes addressed to us with seq > since from a specific carrier.
141
161
  * Signed request; carrier verifies our key before serving.
142
162
  */
143
- async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000 } = {}) {
163
+ async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000, signal = null } = {}) {
144
164
  const path = `GET /v1/inbox/${this.uuaid}?since=${since}`;
145
165
  const ts = String(Date.now());
146
166
  const sig = this.keychain.sign(Buffer.from(`${path}\n${ts}`, "utf-8")).toString("hex");
@@ -150,7 +170,9 @@ export class CarrierClient {
150
170
  "x-pillar-ts": ts,
151
171
  "x-pillar-sig": sig,
152
172
  },
153
- signal: AbortSignal.timeout(timeoutMs),
173
+ // `signal` lets a caller cancel a long-poll early (peekCarrierInbox stops
174
+ // the other carriers once one has mail).
175
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
154
176
  });
155
177
  if (res.status !== 200) {
156
178
  const body = await res.json().catch(() => ({}));
@@ -0,0 +1,77 @@
1
+ /**
2
+ * uuaid.carrier-receipt.v1 — the carrier's signed acceptance receipt.
3
+ *
4
+ * Design: uuaid docs/designs/2026-10-18-carrier-acceptance-receipt.md (§2 wire,
5
+ * §12 binding rules). A carrier configured with a receipt key answers an
6
+ * accepted POST /v1/envelopes with `receipt` + `receiptSig`, where receiptSig
7
+ * is Ed25519 (hex) over the UTF-8 bytes of
8
+ *
9
+ * "uuaid.carrier-receipt.v1" + "\n" + JCS({ the 10 fields below })
10
+ *
11
+ * The fixed prefix is domain separation: a receipt can never be mistaken for
12
+ * the seed identity document (bare JCS, first byte "{"), an auth.v1 assertion
13
+ * or a directory listing, even when the same key signs them.
14
+ *
15
+ * A receipt is the carrier key holder's attributable claim of acceptance,
16
+ * sequence, time and a retention commitment. It does not prove delivery, the
17
+ * disk write or the clock (§12 rule 3).
18
+ *
19
+ * Verification lives in the clients (@uuaid/pillar-client verifyCarrierReceipt);
20
+ * this module is what the carrier signs, and is byte-identical to the
21
+ * pillar-client `carrierReceiptSigningString` (checked against the shared
22
+ * vectors in test/unit/carrier-receipt.test.mjs).
23
+ */
24
+ import { jcs } from "../identity/jcs.mjs";
25
+ import { SELF_CERTIFYING_NAMESPACES, localIdFromKey } from "../identity/keychain.mjs";
26
+
27
+ export const CARRIER_RECEIPT_TYPE = "uuaid.carrier-receipt.v1";
28
+
29
+ /** The exact receipt keys, in JCS (sorted) order. */
30
+ export const CARRIER_RECEIPT_FIELDS = Object.freeze([
31
+ "acceptedAt", "carrier", "duplicate", "envelopeId", "envelopeSha",
32
+ "recipient", "retainUntil", "sender", "seq", "v",
33
+ ]);
34
+
35
+ const ISO_MS_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
36
+ const HEX64 = /^[0-9a-f]{64}$/;
37
+ const AGENT_UUAID = new RegExp(`^uuaid:(${SELF_CERTIFYING_NAMESPACES.join("|")}):agent:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`);
38
+ const isParty = (s) => typeof s === "string" && s.length > "uuaid:".length && s.startsWith("uuaid:");
39
+
40
+ /** The exact string whose UTF-8 bytes the carrier signs. Only the 10 receipt keys are covered. */
41
+ export function carrierReceiptSigningString(fields) {
42
+ const body = {};
43
+ for (const k of CARRIER_RECEIPT_FIELDS) body[k] = fields[k];
44
+ return `${CARRIER_RECEIPT_TYPE}\n${jcs(body)}`;
45
+ }
46
+
47
+ /** The carrier uuaid a receipt key derives: uuaid:foundation:agent:<localIdFromKey(key)>. */
48
+ export function carrierUuaidFromKey(publicKeyHex) {
49
+ return `uuaid:foundation:agent:${localIdFromKey(Buffer.from(publicKeyHex, "hex"))}`;
50
+ }
51
+
52
+ function isIsoMs(s) {
53
+ return typeof s === "string" && ISO_MS_Z.test(s) && !Number.isNaN(Date.parse(s)) && new Date(s).toISOString() === s;
54
+ }
55
+
56
+ /**
57
+ * True when `r` is a receipt a v1 verifier can accept on shape alone: exactly
58
+ * the 10 keys, v 1, seq a safe integer >= 1, boolean duplicate, ISO-8601 UTC
59
+ * millisecond times with retainUntil after acceptedAt, a key-derived agent
60
+ * uuaid as carrier, a non-empty envelopeId, a 64-hex envelopeSha and uuaid
61
+ * parties: the same schema rules as the pillar-client verifier. The carrier
62
+ * checks this before signing, so it never signs a claim no client could verify.
63
+ */
64
+ export function carrierReceiptShapeOk(r) {
65
+ if (!r || typeof r !== "object" || Array.isArray(r)) return false;
66
+ const keys = Object.keys(r).sort();
67
+ if (keys.length !== CARRIER_RECEIPT_FIELDS.length || keys.some((k, i) => k !== CARRIER_RECEIPT_FIELDS[i])) return false;
68
+ if (r.v !== 1) return false;
69
+ if (!Number.isSafeInteger(r.seq) || r.seq < 1) return false;
70
+ if (typeof r.duplicate !== "boolean") return false;
71
+ if (!isIsoMs(r.acceptedAt) || !isIsoMs(r.retainUntil)) return false;
72
+ if (!(Date.parse(r.retainUntil) > Date.parse(r.acceptedAt))) return false;
73
+ if (typeof r.carrier !== "string" || !AGENT_UUAID.test(r.carrier)) return false;
74
+ if (typeof r.envelopeId !== "string" || !r.envelopeId) return false;
75
+ if (typeof r.envelopeSha !== "string" || !HEX64.test(r.envelopeSha)) return false;
76
+ return isParty(r.sender) && isParty(r.recipient);
77
+ }