@uuaid/pillar 2.0.3 → 2.3.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/HOSTING-LIMITS.md +33 -14
- package/INTERACTION-CONVENTION.md +10 -6
- package/README.md +118 -12
- package/bin/carrier.mjs +51 -2
- package/bin/pillar-directory.mjs +2 -2
- package/bin/pillar-escrow.mjs +2 -2
- package/bin/pillar-group-v2.mjs +17 -0
- package/bin/pillar-seed.mjs +11 -4
- package/bin/pillar.mjs +727 -73
- package/package.json +2 -1
- package/src/address-book.mjs +135 -10
- package/src/blob/blob.mjs +12 -14
- package/src/blob/http.mjs +4 -4
- package/src/crypto/e2e.mjs +3 -2
- package/src/group-v2-cli.mjs +231 -0
- package/src/groups-v2.mjs +1108 -0
- package/src/groups.mjs +175 -0
- package/src/identity/jcs.mjs +20 -8
- package/src/identity/keychain.mjs +51 -2
- package/src/index.mjs +2 -2
- package/src/mailbox/mailbox.mjs +296 -12
- package/src/net/carrier-client.mjs +29 -7
- package/src/net/carrier-receipt.mjs +77 -0
- package/src/net/carrier-server.mjs +175 -20
- package/src/net/refusals.mjs +4 -2
- package/src/pillar.mjs +416 -35
- package/src/readers.mjs +120 -0
- package/src/receipts.mjs +145 -0
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
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
339
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
346
|
-
|
|
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
|
|
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: {
|
|
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({
|
|
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
|
|
619
|
-
//
|
|
620
|
-
// (kind alone is spoofable
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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); }
|
package/src/readers.mjs
ADDED
|
@@ -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
|
+
}
|