@uuaid/pillar 2.0.3 → 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.
package/src/pillar.mjs CHANGED
@@ -22,7 +22,17 @@ import { createTransport, PILLAR_PROTOCOL, uuaidToDhtKey, peerIdFromEd25519Hex }
22
22
  import { seal, open, decrypt, envelopeSha, mkEnvelopeId, ENVELOPE_VERSION } from "./net/envelope.mjs";
23
23
  import { Mailbox } from "./mailbox/mailbox.mjs";
24
24
  import { CarrierClient } from "./net/carrier-client.mjs";
25
+ import { REFUSAL_FIXES } from "./net/refusals.mjs";
25
26
  import { Observability } from "./observability.mjs";
27
+ import { evaluateTrust } from "./identity/trust.mjs";
28
+ import { keyDerivesUuaid } from "./identity/keychain.mjs";
29
+ import {
30
+ extractGroupTag, buildGroupTag, absorbGroupTag, newGroupId, newGroupMsgId, normalizeMembers, isGroupId,
31
+ } from "./groups.mjs";
32
+ import { applyReceipt, answersOurSend, deliveryAck, planReadReceipts, readReceipt } from "./receipts.mjs";
33
+ import { GroupReceiver, RosterStore, isGroupIdV2, adminOf, buildGroupTag as buildGroupTagV2, manifestHash as manifestHashOf, loadGroupForCommand } from "./groups-v2.mjs";
34
+ import { randomBytes } from "node:crypto";
35
+ import { dirname } from "node:path";
26
36
  import {
27
37
  BLOB_KIND, MAX_INLINE_BODY_BYTES, BlobError, BlobSizeError, envelopeBodyBytes, preflightBodyBytes,
28
38
  prepareBlobSend, fetchAndDecryptBlob, prepareBrokerBlobSend, fetchBrokerBlob,
@@ -49,6 +59,9 @@ export class Pillar {
49
59
  blobBroker = null, // challenge broker for blob reserve/finalize/redeem
50
60
  blobClient = null, // PillarBlobHttpClient for the production S3 broker path
51
61
  senderKeyCheck = null, // registry gate for senders not derived from their key: (uuaid, pubkeyHex) => status
62
+ reader = null, // reader key recorded on every row this process stores (src/readers.mjs)
63
+ receipts = null, // { delivered, read }: send signed acks / read receipts (receipts.mjs); null = neither
64
+ groupsV2Home = null, // where groups-v2/ lives (pillar-group/2 store); default: the mailbox's directory
52
65
  }) {
53
66
  if (!keychain?._identity) throw new Error("Pillar: keychain must be loaded before construction");
54
67
  this.keychain = keychain;
@@ -72,7 +85,17 @@ export class Pillar {
72
85
  this.blobTier = tierGrant?.tier ?? "community";
73
86
  this.blobMaxBodyBytes = Number.isInteger(tierGrant?.maxBodyBytes) ? tierGrant.maxBodyBytes : null;
74
87
  this.senderKeyCheck = senderKeyCheck;
88
+ this.reader = reader;
89
+ this.receipts = { delivered: !!receipts?.delivered, read: !!receipts?.read };
75
90
  this._envelopeHandlers = new Set();
91
+ // pillar-group/2. Starting the receiver re-syncs every stored group's
92
+ // mailbox row from the verified store. Its `sends` are NOT executed: no
93
+ // send path reads the v2 store until that policy is decided.
94
+ this.groupsV2 = new GroupReceiver({
95
+ self: { uuaid: keychain._identity.uuaid, key: keychain._identity.publicKeyHex },
96
+ store: new RosterStore(groupsV2Home ?? dirname(mailboxPath)),
97
+ mailbox: this.mailbox,
98
+ });
76
99
  this._openStreams = new Map(); // peerIdString -> pushable
77
100
  }
78
101
 
@@ -219,11 +242,7 @@ export class Pillar {
219
242
  const providerPubKey = provider.id.publicKey;
220
243
  if (!providerPubKey) continue; // shouldn't happen for Ed25519 PeerIds but be safe
221
244
  const publicKeyHex = Buffer.from(providerPubKey.raw).toString("hex");
222
- // Recompute the UUAID that this key would mint.
223
- const { Keychain } = await import("./identity/keychain.mjs");
224
- const localId = Keychain._localIdFromKey(Buffer.from(publicKeyHex, "hex"));
225
- const expectedUuaid = `uuaid:foundation:agent:${localId}`;
226
- if (expectedUuaid !== uuaid) continue; // provider is squatting on someone else's key
245
+ if (!keyDerivesUuaid(publicKeyHex, uuaid, "agent")) continue; // provider is squatting on someone else's key
227
246
  // Remember addrs so future dials succeed even if the provider drops off DHT.
228
247
  if (provider.multiaddrs?.length) {
229
248
  try { await this.node.peerStore.patch(provider.id, { multiaddrs: provider.multiaddrs }); } catch (_e) {}
@@ -285,7 +304,14 @@ export class Pillar {
285
304
  this.metrics.inc("send.error.direct-cap");
286
305
  throw new BlobSizeError(`envelope ${hardCap.bytes}B exceeds the ${MAX_INLINE_BODY_BYTES}B hard cap`, { size: hardCap.bytes, max: MAX_INLINE_BODY_BYTES });
287
306
  }
288
- const persisted = this.mailbox.appendOutbound(envelope);
307
+ // Receipt matching needs the thread and group of this row, and the sealed
308
+ // envelope cannot tell us later: we cannot decrypt our own outbox.
309
+ const groupTag = extractGroupTag(payload);
310
+ const persisted = this.mailbox.appendOutbound(envelope, {
311
+ thread: typeof payload?.thread === "string" && payload.thread ? payload.thread : null,
312
+ groupId: groupTag && !groupTag.derivedId ? groupTag.id : null,
313
+ groupMsgId: groupTag?.msgId ?? null,
314
+ });
289
315
 
290
316
  // 1. Address book path: direct libp2p dial (lowest latency, no third party).
291
317
  if (this.node && book?.peerId && book?.multiaddr) {
@@ -335,23 +361,51 @@ export class Pillar {
335
361
  * There is deliberately NO group key: each member gets their own sealed
336
362
  * envelope (same guarantees as 1:1 send), so membership changes never
337
363
  * require rekeying and the carrier still can't read anything. The group
338
- * context travels INSIDE the sealed payload as `_group: { name, members }`
339
- * so every recipient can reply to the whole group without extra lookups.
364
+ * travels INSIDE the sealed payload as a `pillar-group` v1 tag (groups.mjs,
365
+ * INTERACTION-CONVENTION.md): a stable `id`, the roster, one `msgId` shared
366
+ * by every copy, the `op`, and binding-checkable sealing `keys`, so every
367
+ * member files the message under one thread and can answer the whole group.
368
+ *
369
+ * `groupId` names a group from this home's roster (mailbox `groups`). Without
370
+ * one, the group is found by name + identical roster, or created, so repeat
371
+ * sends to the same named roster stay one thread.
340
372
  *
341
373
  * Failures are isolated: one undeliverable member never blocks the rest.
342
- * Returns { group, results: [{ member, ok, via?, envelopeId?, error?, code?, fix? }] }.
374
+ * Refused (code group-v2-use-v2-send) for a pillar-group/2 group.
375
+ * Returns { group, groupId, msgId, results: [{ member, ok, via?, envelopeId?, error?, code?, fix? }] }.
343
376
  */
344
- async sendGroup({ group, members, kind = "message", payload = {}, resolveTimeoutMs = 5000 }) {
345
- if (!Array.isArray(members) || members.length === 0) {
346
- throw new Error(`sendGroup: group "${group}" has no members`);
377
+ async sendGroup({ group, groupId = null, members, kind = "message", payload = {}, op = "message", resolveTimeoutMs = 5000 }) {
378
+ let g = groupId ? this.mailbox.getGroup(groupId) : null;
379
+ if (groupId && !g) throw new Error(`sendGroup: no group ${groupId} in this home`);
380
+ if (!g) {
381
+ const roster = normalizeMembers([...(Array.isArray(members) ? members : []), this.uuaid]);
382
+ if (roster.length < 2) throw new Error(`sendGroup: group "${group}" has no members`);
383
+ const sorted = [...roster].sort().join(",");
384
+ g = this.mailbox.listGroups().find((x) => x.name === group && [...x.members].sort().join(",") === sorted && !x.left)
385
+ ?? this.mailbox.putGroup({ id: newGroupId(), name: String(group ?? "Group"), members: roster, keys: {}, createdBy: this.uuaid });
386
+ }
387
+ // A pillar-group/2 group (held, or its row marked by the v2 sync) takes no v1 send,
388
+ // whichever way it was reached (id, name + roster, a groups file): sendGroupV2 sends it.
389
+ if (isGroupIdV2(g.id) && (this.groupsV2.store.has(g.id) || this.mailbox.isGroupV2Marked(g.id))) throw Object.assign(new Error(`sendGroup: ${g.id} is a pillar-group/2 group and takes no v1 send`), { code: "group-v2-use-v2-send", fix: REFUSAL_FIXES["group-v2-use-v2-send"] }); // @rule P-v1-send-refuses-v2
390
+ if (g.pending) throw new Error(`sendGroup: group ${g.id} is a pending invitation — accept it first (pillar group accept ${g.id})`);
391
+ if (g.left && op !== "leave") throw new Error(`sendGroup: you left group ${g.id}`);
392
+ // Sealing hints for the members: every key we know that really owns its uuaid.
393
+ const keys = { [this.uuaid]: this.publicKeyHex };
394
+ for (const m of g.members) {
395
+ const k = this.addressBook?.get(m)?.publicKey ?? g.keys?.[m];
396
+ if (k) keys[m] = k;
347
397
  }
348
- const others = members.filter(m => m !== this.uuaid);
398
+ const msgId = newGroupMsgId();
349
399
  // _group goes last so a payload can never override the group it travels in.
350
- const wrapped = { ...payload, _group: { name: group, members } };
400
+ const wrapped = { ...payload, _group: buildGroupTag(g, { msgId, keys, op }) };
401
+ const others = g.members.filter(m => m !== this.uuaid);
351
402
  const results = [];
352
403
  for (const member of others) {
353
404
  try {
354
- const r = await this.send({ recipient: member, kind, payload: wrapped, resolveTimeoutMs });
405
+ const r = await this.send({
406
+ recipient: member, recipientPublicKey: this.addressBook?.get(member)?.publicKey ?? g.keys?.[member],
407
+ kind, payload: wrapped, resolveTimeoutMs,
408
+ });
355
409
  results.push({ member, ok: true, via: r.via, envelopeId: r.envelope.id });
356
410
  } catch (e) {
357
411
  results.push({ member, ok: false, error: e.message, ...(typeof e.code === "string" ? { code: e.code } : {}), ...(typeof e.fix === "string" ? { fix: e.fix } : {}) });
@@ -360,7 +414,44 @@ export class Pillar {
360
414
  this.metrics.inc("sendGroup");
361
415
  const failed = results.filter((r) => !r.ok).length;
362
416
  if (failed) this.metrics.inc("sendGroup.failed", failed);
363
- return { group, results };
417
+ return { group: g.name, groupId: g.id, msgId, results };
418
+ }
419
+
420
+ /**
421
+ * pillar-group/2 send. Recipients come from the mailbox `groups` row (the
422
+ * verified head minus anyone leaving, which is what an egress filter
423
+ * reads), never from the v2 store; the store gives only the tag's `mv`.
424
+ * message, leave.request every member in the row except self
425
+ * accept.request, rename.request the admin only
426
+ * Refused for a group this home does not hold, a frozen or cleared row, and
427
+ * any op but accept.request from a member who is only invited.
428
+ * Returns { group, groupId, msgId, results } like sendGroup.
429
+ */
430
+ async sendGroupV2({ groupId, op = "message", payload = {}, kind = "message", resolveTimeoutMs = 5000 }) {
431
+ if (!["message", "leave.request", "accept.request", "rename.request"].includes(op)) throw new Error(`sendGroupV2: op ${op} is not sent from here`);
432
+ const rec = loadGroupForCommand(this.groupsV2.store, groupId); // throws for a group it does not hold, or one that no longer verifies
433
+ const row = this.mailbox.getGroup(groupId);
434
+ const refuse = (why) => { const e = new Error(`sendGroupV2: ${groupId}: ${why}`); e.code = "group-v2-refused"; throw e; };
435
+ if (!row || row.left) refuse("its mailbox row is cleared (frozen, removed, or never written)"); // @rule P-send-row-live
436
+ const head = rec.chain[rec.chain.length - 1];
437
+ const me = head.members.find((r) => r.uuaid === this.uuaid);
438
+ const admin = adminOf(head).uuaid;
439
+ if (me?.status !== "active" && !(me?.status === "invited" && op === "accept.request")) refuse(`this member is ${me?.status ?? "not in the roster"}`); // @rule P-send-active-only
440
+ let to = row.members.filter((m) => m !== this.uuaid);
441
+ if (op === "accept.request" || op === "rename.request") to = to.filter((m) => m === admin);
442
+ const tag = buildGroupTagV2(rec, { op });
443
+ const wrapped = { text: "", ...payload, _group: tag };
444
+ const results = [];
445
+ for (const member of to) {
446
+ try {
447
+ const r = await this.send({ recipient: member, recipientPublicKey: this.addressBook?.get(member)?.publicKey ?? row.keys?.[member], kind, payload: wrapped, resolveTimeoutMs });
448
+ results.push({ member, ok: true, via: r.via, envelopeId: r.envelope.id });
449
+ } catch (e) {
450
+ results.push({ member, ok: false, error: e.message, ...(typeof e.code === "string" ? { code: e.code } : {}), ...(typeof e.fix === "string" ? { fix: e.fix } : {}) });
451
+ }
452
+ }
453
+ this.metrics.inc("sendGroupV2");
454
+ return { group: head.name, groupId, msgId: tag.msgId, results };
364
455
  }
365
456
 
366
457
  /**
@@ -577,6 +668,273 @@ export class Pillar {
577
668
  }
578
669
  }
579
670
 
671
+ /**
672
+ * Judge a pillar-group/2 message (src/groups-v2.mjs) and file its inbox row.
673
+ * Returns true when the row is delivered to handlers now.
674
+ *
675
+ * accepted / invitation / recovered filed under the group, delivered
676
+ * warning (v1 line in a v2 group) delivered, never filed under the group
677
+ * held quarantined `group-v2:held` until released
678
+ * anything else quarantined `group-v2:<reason>` (kept as evidence)
679
+ *
680
+ * Held messages released by this step are filed and delivered, or
681
+ * re-quarantined with their reason. The receiver's `sends` are counted and
682
+ * dropped: nothing leaves this process from here.
683
+ */
684
+ async _receiveGroupV2(envelope, payload) {
685
+ const env = { from: envelope.sender, transportKey: envelope.transportSignature?.publicKey, payload, ref: envelope.id };
686
+ if (envelope.delegation) env.delegation = { rootKey: envelope.delegation.principalPublicKey };
687
+ const r = this.groupsV2.receive(env, Date.now());
688
+ this.metrics.inc(`received.group-v2.${r.verdict}`);
689
+ const groupId = payload._group.id;
690
+ if (r.sends.length) {
691
+ const keyed = r.sends.map((s) => ({ ...s, key: this._groupV2KeyFor(groupId, s.to, envelope) }));
692
+ let ids = [];
693
+ ids = this.mailbox.enqueueGroupV2Sends(groupId, keyed, { newId: () => `gq-${randomBytes(12).toString("hex")}` }); // @rule Q-enqueue
694
+ this.metrics.inc("group-v2.sends.queued", ids.length);
695
+ if (ids.length < r.sends.length) this.metrics.inc("group-v2.sends.queue-full", r.sends.length - ids.length);
696
+ }
697
+ for (const x of r.released) await this._settleHeldGroupV2(x.ref, x.verdict, x.reason, groupId);
698
+ for (const x of r.dropped) if (x.ref) this.mailbox.markQuarantined(x.ref, `group-v2:${x.reason}`);
699
+ if (["accepted", "invitation", "recovered"].includes(r.verdict)) {
700
+ this.mailbox.setInboundGroup(envelope.id, groupId);
701
+ return true;
702
+ }
703
+ if (r.verdict === "warning") return true;
704
+ // Verdict "v1" (a v1 tag for a group the store does not hold) reaches here only
705
+ // for a marked row whose store file is gone: no verified chain, nothing to file under.
706
+ this.mailbox.markQuarantined(envelope.id, `group-v2:${r.verdict === "v1" ? "no-chain" : (r.reason ?? r.verdict)}`);
707
+ return false;
708
+ }
709
+
710
+ /**
711
+ * The key to seal a queued send to `uuaid`: its row in the verified chain
712
+ * (any version, so a removed member and an invitee resolve too); else, for
713
+ * the sender of the envelope that caused it, its verified key (the
714
+ * delegation's root key for a delegated envelope); else the address book.
715
+ */
716
+ _groupV2KeyFor(groupId, uuaid, envelope) {
717
+ if (this.groupsV2.store.has(groupId)) {
718
+ try {
719
+ const rec = this.groupsV2.store.load(groupId);
720
+ for (let i = rec.chain.length - 1; i >= 0; i--) {
721
+ const row = rec.chain[i].members.find((r) => r.uuaid === uuaid);
722
+ if (row) return row.key;
723
+ }
724
+ } catch { /* an unreadable record: fall through */ }
725
+ }
726
+ if (envelope && uuaid === envelope.sender) return envelope.delegation?.principalPublicKey ?? envelope.transportSignature?.publicKey ?? null;
727
+ return this.addressBook?.get(uuaid)?.publicKey ?? null;
728
+ }
729
+
730
+ /**
731
+ * Send what the v2 receiver queued, for one group or all. This is the only
732
+ * path that sends a queued row; nothing calls it on receive. Each row is
733
+ * sealed to its recipient alone. Final states: `sent` (with the envelope
734
+ * id) or `dropped` (with a reason). A failed attempt stays `queued`.
735
+ * A frozen group sends only its equivocation proof.
736
+ * Returns [{ id, groupId, to, op, state, envelopeId?, reason? }].
737
+ */
738
+ async syncGroupV2({ groupId = null, resolveTimeoutMs = 5000 } = {}) {
739
+ const ZERO = "0".repeat(64);
740
+ const out = [];
741
+ for (const q of this.mailbox.queuedGroupV2Sends(groupId)) {
742
+ const done = (state, extra = {}) => { this.mailbox.markGroupV2Send(q.id, state, extra); out.push({ id: q.id, groupId: q.group_id, to: q.recipient, op: q.op, state, ...extra }); };
743
+ let rec = null;
744
+ if (this.groupsV2.store.has(q.group_id)) {
745
+ try { rec = this.groupsV2.store.load(q.group_id); } catch (e) { done("dropped", { reason: `store: ${e.message}` }); continue; }
746
+ }
747
+ if (rec?.frozen && q.op !== "equivocation") { done("dropped", { reason: "frozen" }); continue; } // @rule Q-sync-frozen-only-proof
748
+ const msgId = `gm-${randomBytes(16).toString("hex")}`;
749
+ const tag = rec
750
+ ? buildGroupTagV2(rec, { op: q.op, msgId })
751
+ : { v: 2, id: q.group_id, name: "", members: [], msgId, op: q.op, mv: { version: 0, hash: ZERO } };
752
+ let text;
753
+ if (q.op === "manifest.request") {
754
+ tag.want = { from: q.want_from, to: q.want_to };
755
+ text = `asks for group manifests ${q.want_from}..${q.want_to ?? "newest"}`;
756
+ } else if (q.op === "manifest") {
757
+ const m = rec?.chain.find((x) => manifestHashOf(x) === q.manifest_hash);
758
+ if (!m) { done("dropped", { reason: "manifest-not-held" }); continue; }
759
+ tag.manifest = m;
760
+ text = `group manifest v${m.version} (${m.transition.action})`;
761
+ } else if (q.op === "equivocation") {
762
+ const m = rec?.frozen?.manifests.find((x) => manifestHashOf(x) === q.manifest_hash);
763
+ if (!m) { done("dropped", { reason: "proof-not-held" }); continue; }
764
+ tag.manifest = m;
765
+ tag.conflictsWith = q.conflicts_with;
766
+ text = `the group admin signed two versions ${m.version}: this group is frozen`;
767
+ } else { done("dropped", { reason: `op ${q.op} is not queued` }); continue; }
768
+ const key = q.recipient_key ?? this.addressBook?.get(q.recipient)?.publicKey;
769
+ if (!key) { done("dropped", { reason: "no key for the recipient" }); continue; }
770
+ try {
771
+ const r = await this.send({ recipient: q.recipient, recipientPublicKey: key, kind: "message", payload: { text, _group: tag }, resolveTimeoutMs });
772
+ done("sent", { envelopeId: r.envelope.id }); // @rule Q-sync-marks-sent
773
+ } catch (e) {
774
+ this.mailbox.markGroupV2Send(q.id, "queued", { reason: e.message });
775
+ out.push({ id: q.id, groupId: q.group_id, to: q.recipient, op: q.op, state: "queued", reason: e.message });
776
+ }
777
+ }
778
+ this.metrics.inc("group-v2.sync", out.length);
779
+ return out;
780
+ }
781
+
782
+ async _settleHeldGroupV2(envelopeId, verdict, reason, groupId) {
783
+ if (!envelopeId) return;
784
+ if (verdict !== "accepted") { this.mailbox.markQuarantined(envelopeId, `group-v2:${reason ?? verdict}`); return; }
785
+ this.mailbox.clearQuarantine(envelopeId);
786
+ this.mailbox.setInboundGroup(envelopeId, groupId);
787
+ const row = this.mailbox.getInbound(envelopeId);
788
+ if (!row) return;
789
+ const envelope = JSON.parse(row.envelope_json);
790
+ let payload;
791
+ try { payload = decrypt(this.keychain, envelope); } catch { return; }
792
+ const view = { ...envelope, payload };
793
+ for (const h of this._envelopeHandlers) {
794
+ try { await h(view, { duplicate: false, envelopeId, released: true }); }
795
+ catch (e) { this.metrics.inc("received.handler-error"); console.error("pillar: envelope handler threw", e); }
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Would the trust gate deliver this (already verified, decrypted) envelope?
801
+ * Shared by the receive path and the no-side-effect peek. { deliver, reason? }
802
+ */
803
+ _trustVerdict(envelope, payload) {
804
+ if (!this.trust) return { deliver: true };
805
+ if (answersOurSend(this.mailbox, envelope, payload)) return { deliver: true };
806
+ return evaluateTrust(this.trust, envelope.sender, new Set(this.addressBook?.keys() ?? []));
807
+ }
808
+
809
+ /** A peer we may send delivery signals to: never a stranger (no liveness oracle). */
810
+ _isReceiptPeer(uuaid, tag) {
811
+ if (this.addressBook?.get(uuaid)?.publicKey) return true;
812
+ if (this.trust?.allow?.includes(uuaid)) return true;
813
+ if (tag) {
814
+ const g = this.mailbox.getGroup(tag.id);
815
+ if (g && !g.pending && !g.left && g.members.includes(uuaid)) return true;
816
+ }
817
+ return false;
818
+ }
819
+
820
+ _sealKeyFor(uuaid, envelope = null, tag = null) {
821
+ return this.addressBook?.get(uuaid)?.publicKey
822
+ ?? (tag ? this.mailbox.getGroup(tag.id)?.keys?.[uuaid] : undefined)
823
+ // The verified transport key: open() already bound it to the sender
824
+ // (for a delegated envelope it is the device key, and seal() refuses it).
825
+ ?? envelope?.transportSignature?.publicKey;
826
+ }
827
+
828
+ async _autoAck(envelope, payload, tag) {
829
+ if (envelope.sender === this.uuaid || !this._isReceiptPeer(envelope.sender, tag)) return;
830
+ try {
831
+ const ack = deliveryAck(envelope, payload);
832
+ await this.send({ recipient: envelope.sender, recipientPublicKey: this._sealKeyFor(envelope.sender, envelope, tag), ...ack });
833
+ this.metrics.inc("receipts.ack.sent");
834
+ } catch (e) {
835
+ this.metrics.inc("receipts.ack.failed");
836
+ // eslint-disable-next-line no-console
837
+ console.warn(`pillar: could not ack ${envelope.id} to ${envelope.sender}: ${e.message}`);
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Send read receipts for inbound messages the caller has just marked read.
843
+ * One receipt per (sender, conversation), only to receipt peers, only when
844
+ * read receipts are on. `views`: [{ id, sender, kind, payload, groupId? }].
845
+ * Returns { sent, skipped, dropped }.
846
+ */
847
+ async sendReadReceipts(views) {
848
+ if (!this.receipts.read) return { sent: 0, skipped: 0, dropped: 0 };
849
+ const { receipts, dropped } = planReadReceipts(views, { self: this.uuaid });
850
+ let sent = 0, skipped = 0;
851
+ for (const r of receipts) {
852
+ const source = views.find((v) => v.id === r.upTo);
853
+ const tag = source?.groupId ? { id: source.groupId } : null;
854
+ if (!this._isReceiptPeer(r.to, tag)) { skipped++; continue; }
855
+ try {
856
+ await this.send({ recipient: r.to, recipientPublicKey: this._sealKeyFor(r.to, source, tag), ...readReceipt(r) });
857
+ sent++;
858
+ } catch (e) {
859
+ skipped++;
860
+ // eslint-disable-next-line no-console
861
+ console.warn(`pillar: could not send a read receipt to ${r.to}: ${e.message}`);
862
+ }
863
+ }
864
+ this.metrics.inc("receipts.read.sent", sent);
865
+ return { sent, skipped, dropped };
866
+ }
867
+
868
+ /**
869
+ * Look at the carriers WITHOUT storing anything or moving a cursor: long-poll
870
+ * every carrier from the stored cursor and resolve with the verified,
871
+ * decrypted envelopes that would reach handlers (trust gate applied;
872
+ * quarantine-bound, foreign, undecryptable and already-stored envelopes are
873
+ * passed over) as soon as at least one matches, or when the deadline passes.
874
+ *
875
+ * This is the wake signal: a process can sleep on it at zero CPU and hand
876
+ * the mail to whoever reads the home next, which still sees it as new.
877
+ *
878
+ * match(view) optional filter; only matching views count and are returned
879
+ * stopWhen() optional; checked after every round (e.g. "already acked")
880
+ * deadlineMs absolute epoch ms; null = wait for ever
881
+ *
882
+ * Throws the last carrier error when every carrier refuses outright.
883
+ */
884
+ async peekCarrierInbox({ match = null, stopWhen = null, deadlineMs = null, waitS = 25 } = {}) {
885
+ if (!this.carrierClient) throw new Error("peek: no carriers configured");
886
+ const cursors = this.mailbox.getCarrierCursors?.() ?? {};
887
+ const found = new Map();
888
+ const ctl = new AbortController();
889
+ let stop = false;
890
+ const dead = new Map(); // carrier -> error
891
+ const FATAL = new Set(["uuaid-pubkey-mismatch", "clock-skew", "bad-pubkey", "bad-signature", "missing-auth-headers"]);
892
+ const loop = async (base) => {
893
+ // drainCarrierInbox keys cursors by the configured URL, the polling loop
894
+ // by the normalised one; start past whichever is further along.
895
+ let since = Math.max(cursors[base] ?? 0, cursors[String(base).replace(/\/+$/, "")] ?? 0);
896
+ let asked = false; // every carrier is asked once, even at timeout 0
897
+ while (!stop) {
898
+ if (stopWhen?.()) { stop = true; break; }
899
+ const left = deadlineMs == null ? Infinity : deadlineMs - Date.now();
900
+ if (left <= 0 && asked) break;
901
+ asked = true;
902
+ const w = Math.max(0, Math.min(waitS, Math.floor(left / 1000)));
903
+ let r;
904
+ try {
905
+ r = await this.carrierClient.fetchInbox(base, { since, waitS: w, timeoutMs: (w + 15) * 1000, signal: ctl.signal });
906
+ } catch (e) {
907
+ if (stop || ctl.signal.aborted) break;
908
+ if (FATAL.has(e.code)) { dead.set(base, e); break; }
909
+ dead.set(base, e);
910
+ await new Promise((res) => setTimeout(res, Math.min(2000, Math.max(0, left))));
911
+ continue;
912
+ }
913
+ dead.delete(base);
914
+ for (const { seq, envelope } of r.envelopes) {
915
+ since = Math.max(since, seq);
916
+ if (found.has(envelope.id) || this.mailbox.getInbound(envelope.id)) continue;
917
+ const v = open(envelope, { cryptoInventory: this.cryptoInventory });
918
+ if (!v.ok || envelope.recipient !== this.uuaid) continue;
919
+ let payload;
920
+ try { payload = decrypt(this.keychain, envelope); } catch { continue; }
921
+ if (!this._trustVerdict(envelope, payload).deliver) continue;
922
+ const view = { ...envelope, payload };
923
+ if (match && !match(view)) continue;
924
+ found.set(envelope.id, view);
925
+ }
926
+ if (found.size) { stop = true; ctl.abort(); }
927
+ else if (w === 0 && !r.envelopes.length) {
928
+ // Nothing and no time left to long-poll: the deadline governs.
929
+ if (deadlineMs != null && Date.now() >= deadlineMs) break;
930
+ }
931
+ }
932
+ };
933
+ await Promise.all(this.carriers.map((base) => loop(base)));
934
+ if (!found.size && dead.size === this.carriers.length) throw [...dead.values()].at(-1);
935
+ return [...found.values()];
936
+ }
937
+
580
938
  async _receive(rawLine, connection) {
581
939
  let envelope;
582
940
  try { envelope = JSON.parse(rawLine); }
@@ -607,7 +965,7 @@ export class Pillar {
607
965
  console.warn("pillar: envelope decrypt failed (dropping)", envelope.id, e.message);
608
966
  return;
609
967
  }
610
- const persisted = this.mailbox.appendInbound(envelope);
968
+ const persisted = this.mailbox.appendInbound(envelope, { pulledBy: this.reader });
611
969
  if (persisted.duplicate) {
612
970
  this.metrics.inc("received.duplicate");
613
971
  return; // replay: do not re-fire handlers
@@ -615,25 +973,48 @@ export class Pillar {
615
973
  this.metrics.inc(`received.kind.${envelope.kind}`);
616
974
  // Trust gate (recipient consent): quarantine-not-drop. The envelope is
617
975
  // already persisted (chain-hashed evidence); a gated sender just never
618
- // reaches handlers. An ack/reply passes the gate ONLY if its inReplyTo
619
- // names an envelope in OUR outbox — a genuine receipt for our own send.
620
- // (kind alone is spoofable; the outbox check is not.)
621
- if (this.trust) {
622
- const isReceipt = (envelope.kind === "ack" || envelope.kind === "reply") &&
623
- typeof payload?.inReplyTo === "string" && this.mailbox.hasOutbound(payload.inReplyTo);
624
- if (!isReceipt) {
625
- const { evaluateTrust } = await import("./identity/trust.mjs");
626
- const known = new Set(this.addressBook?.keys() ?? []);
627
- const verdict = evaluateTrust(this.trust, envelope.sender, known);
628
- if (!verdict.deliver) {
629
- this.mailbox.markQuarantined(envelope.id, verdict.reason);
630
- this.metrics.inc(`received.quarantined.${verdict.reason}`);
631
- // eslint-disable-next-line no-console
632
- console.warn(`pillar: quarantined ${envelope.id} from ${envelope.sender} (${verdict.reason})`);
633
- return;
634
- }
635
- }
976
+ // reaches handlers. An ack/reply (or a read receipt) passes the gate ONLY
977
+ // when it answers an envelope in OUR outbox that was sent TO ITS SENDER —
978
+ // a genuine receipt for our own send. (kind alone is spoofable, and so is
979
+ // an envelope id: ids are visible to every carrier. Sent-to-this-sender
980
+ // is not.)
981
+ const verdict2 = this._trustVerdict(envelope, payload);
982
+ if (!verdict2.deliver) {
983
+ this.mailbox.markQuarantined(envelope.id, verdict2.reason);
984
+ this.metrics.inc(`received.quarantined.${verdict2.reason}`);
985
+ // eslint-disable-next-line no-console
986
+ console.warn(`pillar: quarantined ${envelope.id} from ${envelope.sender} (${verdict2.reason})`);
987
+ return;
988
+ }
989
+ // The recipient's own signed word about something we sent.
990
+ if (envelope.kind === "ack") {
991
+ const r = applyReceipt(this.mailbox, envelope, payload, { applyRead: this.receipts.read });
992
+ if (r.kind) this.metrics.inc(`received.receipt.${r.kind}${r.marked ? "" : ".unmatched"}`);
993
+ }
994
+ // Group threads: fold the roster, and file the row under its group only
995
+ // when its sender is a member (before this tag, for a `leave`; after it,
996
+ // for the tag that created the group). A blocked sender reaches here
997
+ // through the receipt pass-through above, and never touches a roster.
998
+ // pillar-group/2 first: a v2 tag, or a v1 tag naming a group the v2 store
999
+ // holds or whose row the v2 sync marked, never reaches the v1 roster
1000
+ // (spec §5.0, §7). An unmarked row is v1's, whatever its id's shape.
1001
+ const g = payload?._group;
1002
+ let route = null;
1003
+ if (g && typeof g === "object" && !Array.isArray(g) && !this.trust?.block?.includes(envelope.sender)) {
1004
+ if (g.v === 2) route = "v2"; // @rule P-route-v2-tag
1005
+ if (route === null && isGroupIdV2(g.id) && this.groupsV2.store.has(g.id)) route = "v2"; // @rule P-route-held-id
1006
+ if (route === null && isGroupIdV2(g.id) && this.mailbox.isGroupV2Marked(g.id)) route = "v2"; // @rule P-route-marked-id
1007
+ }
1008
+ if (route === "v2" && !(await this._receiveGroupV2(envelope, payload))) return;
1009
+ const tag = route === "v2" ? null : extractGroupTag(payload);
1010
+ if (tag && !this.trust?.block?.includes(envelope.sender)) {
1011
+ const fromContact = !!this.addressBook?.get(envelope.sender) || !!this.trust?.allow?.includes(envelope.sender);
1012
+ const before = this.mailbox.getGroup(tag.id);
1013
+ const after = absorbGroupTag(this.mailbox, tag, { sender: envelope.sender, self: this.uuaid, text: payload?.text ?? "", fromContact });
1014
+ if ((before ?? after)?.members.includes(envelope.sender)) this.mailbox.setInboundGroup(envelope.id, tag.id);
1015
+ else this.metrics.inc("received.group.non-member");
636
1016
  }
1017
+ if (this.receipts.delivered && envelope.kind !== "ack") await this._autoAck(envelope, payload, tag);
637
1018
  const view = { ...envelope, payload };
638
1019
  for (const h of this._envelopeHandlers) {
639
1020
  try { await h(view, persisted); }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Readers — more than one consumer of one pillar home.
3
+ *
4
+ * A home's mailbox.db holds ONE carrier cursor per carrier: fetching is
5
+ * idempotent on envelope id, so it does not matter which process pulls. What
6
+ * used to matter is who SEES a message: `pillar inbox` printed only what that
7
+ * run itself pulled, so when two readers shared a home, whichever ran first
8
+ * was the only one that saw the mail as new (and with --mark-read, the other
9
+ * never saw it at all).
10
+ *
11
+ * Two fixes live here:
12
+ *
13
+ * 1. Named readers (`--reader NAME` or PILLAR_READER). Each has its own
14
+ * cursor over the LOCAL inbox table, so every reader sees every row after
15
+ * its cursor no matter which process stored it.
16
+ *
17
+ * 2. Warnings for everyone else. A reader without a name gets an implicit
18
+ * key per (host, uid, working directory), so its output stays exactly
19
+ * what it always was, but it is told on stderr when another reader
20
+ * pulled its mail, and when a live `pillar recv` is consuming the home.
21
+ */
22
+ import { createHash } from "node:crypto";
23
+ import { hostname, userInfo } from "node:os";
24
+
25
+ export const READER_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
26
+ export const IMPLICIT_PREFIX = "ctx:";
27
+ export const LEASE_TTL_MS = 90_000;
28
+ export const LEASE_HEARTBEAT_MS = 30_000;
29
+
30
+ function uid() {
31
+ try { return String(userInfo().uid); } catch { return "?"; }
32
+ }
33
+
34
+ /** The calling process, as a reader row records it. */
35
+ export function processContext({ command } = {}) {
36
+ return {
37
+ host: hostname(),
38
+ pid: process.pid,
39
+ cwd: process.cwd(),
40
+ command: command ?? null,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * The reader key for this invocation. A name is validated; without one the
46
+ * key is derived from (host, uid, cwd), so repeated runs of the same cron job
47
+ * or agent are one reader, and a second agent working elsewhere is another.
48
+ */
49
+ export function readerKey(name, ctx = processContext()) {
50
+ if (name !== undefined && name !== null && name !== true && name !== "") {
51
+ const n = String(name);
52
+ if (!READER_NAME_RE.test(n)) {
53
+ const e = new Error(`reader name "${n}" is not allowed: use 1-64 letters, digits, ".", "_" or "-", starting with a letter or digit`);
54
+ e.code = "bad-reader-name";
55
+ throw e;
56
+ }
57
+ return { key: n, named: true };
58
+ }
59
+ const h = createHash("sha256").update(`${ctx.host}|${uid()}|${ctx.cwd}`).digest("hex").slice(0, 8);
60
+ return { key: `${IMPLICIT_PREFIX}${h}`, named: false };
61
+ }
62
+
63
+ function pidAlive(pid) {
64
+ try { process.kill(pid, 0); return true; }
65
+ catch (e) { return e.code === "EPERM"; } // exists, just not ours to signal
66
+ }
67
+
68
+ /**
69
+ * Leases held by OTHER live processes. On this host a pid that no longer
70
+ * exists is dead whatever its lease says (a crashed recv never released it);
71
+ * on another host the lease's own expiry is all there is to go on.
72
+ */
73
+ export function otherLiveLeases(mailbox, ctx = processContext()) {
74
+ return mailbox.unexpiredLeases().filter((l) => {
75
+ if (l.host === ctx.host && l.pid === ctx.pid) return false;
76
+ if (l.host === ctx.host) return pidAlive(l.pid);
77
+ return true;
78
+ });
79
+ }
80
+
81
+ /** "pillar recv (pid 4242 on mac, /work/bot, since 2026-09-28T…)" */
82
+ export function describeProcess(row) {
83
+ const where = [row.pid != null ? `pid ${row.pid}` : null, row.host ? `on ${row.host}` : null, row.cwd ?? null]
84
+ .filter(Boolean).join(", ");
85
+ const when = row.started_at ? `since ${row.started_at}` : row.last_seen ? `last seen ${row.last_seen}` : null;
86
+ return `${row.command ?? "a pillar process"} (${[where, when].filter(Boolean).join(", ")})`;
87
+ }
88
+
89
+ /** The reader a row was pulled by, for a warning line. */
90
+ export function describeReader(mailbox, key) {
91
+ if (!key) return "a process that did not record itself (pillar older than this one, or the library)";
92
+ const r = mailbox.getReader(key);
93
+ const label = key.startsWith(IMPLICIT_PREFIX) ? "an unnamed reader" : `reader "${key}"`;
94
+ return r ? `${label}: ${describeProcess(r)}` : label;
95
+ }
96
+
97
+ /**
98
+ * Where a brand-new named reader starts: just before the oldest row nobody
99
+ * has marked read, so it sees everything still unread and nothing older;
100
+ * at the tip when everything has been read.
101
+ */
102
+ export function initialCursor(mailbox) {
103
+ const row = mailbox.db.prepare("select min(seq) as s from inbox where read_at is null and quarantined is null").get();
104
+ return row?.s ? row.s - 1 : mailbox.inboxTipSeq();
105
+ }
106
+
107
+ /**
108
+ * Hand a named reader's rows to `handle` in order, advancing the cursor past
109
+ * each row whether or not its handler threw: at most once, exactly like an
110
+ * unnamed recv's handlers. A handler that fails because the carrier is down
111
+ * (its reply cannot go out) would otherwise hold the cursor, and the next
112
+ * tick would forward the same request again, once a second.
113
+ */
114
+ export async function handleInOrder(rows, handle, { advance, onError }) {
115
+ for (const row of rows) {
116
+ try { await handle(row); }
117
+ catch (e) { onError(row, e); }
118
+ advance(row);
119
+ }
120
+ }