@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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Receipts — the recipient's own signed word that an envelope arrived.
3
+ *
4
+ * A carrier receipt (`seq`, `sha`) proves a CARRIER stored the envelope. It
5
+ * says nothing about the recipient. These are the two recipient-signed
6
+ * signals the Interaction Convention already defines, which the mobile app
7
+ * sends and honours, now spoken by this package too:
8
+ *
9
+ * delivery ack kind "ack", payload { thread, inReplyTo: <envelope id> }
10
+ * read receipt kind "ack", payload { v: 1, type: "pillar-read-receipt",
11
+ * upTo: <envelope id>, thread }
12
+ *
13
+ * Both are ordinary sealed envelopes, so each is signed by the recipient's
14
+ * key and verified by open() before anything here sees it. What they prove
15
+ * and what they do not is spelled out in INTERACTION-CONVENTION.md: a receipt
16
+ * is evidence about a MAILBOX (the endpoint holding the key stored or opened
17
+ * the envelope), never about an understanding. Labels in this package say
18
+ * "acknowledged" and "read receipt", never "delivered to the agent".
19
+ *
20
+ * Sending either is opt-in (config.json `receipts`), and only ever to a
21
+ * contact: acking a stranger would confirm the address is live.
22
+ */
23
+ export const READ_RECEIPT_TYPE = "pillar-read-receipt";
24
+ export const MAX_READ_RECEIPTS_PER_RUN = 64;
25
+
26
+ /** Thread root of an envelope: the payload's `thread`, else the envelope itself. */
27
+ export function threadOf(envelope, payload) {
28
+ const t = payload && typeof payload === "object" ? payload.thread : undefined;
29
+ return typeof t === "string" && t.length > 0 ? t : envelope.id;
30
+ }
31
+
32
+ export function isReadReceipt(payload) {
33
+ return !!payload && typeof payload === "object" && !Array.isArray(payload) &&
34
+ payload.type === READ_RECEIPT_TYPE &&
35
+ typeof payload.upTo === "string" && payload.upTo.length > 0 &&
36
+ typeof payload.thread === "string" && payload.thread.length > 0;
37
+ }
38
+
39
+ /** A plain delivery ack for one received envelope (convention rule 2). */
40
+ export function deliveryAck(envelope, payload) {
41
+ return { kind: "ack", payload: { thread: threadOf(envelope, payload), inReplyTo: envelope.id } };
42
+ }
43
+
44
+ export function readReceipt({ thread, upTo }) {
45
+ return { kind: "ack", payload: { v: 1, type: READ_RECEIPT_TYPE, upTo, thread } };
46
+ }
47
+
48
+ /**
49
+ * What a peeked envelope says about our send `envelopeId` to `recipient`:
50
+ * "delivered", "read", or null. A delivery ack answers it by `inReplyTo`
51
+ * only: the ack of a FOLLOW-UP carries the same `thread` and is not about
52
+ * this row. A read receipt covers it by `upTo` or `thread`, and counts only
53
+ * while read receipts are on (`read: false` ignores them, as applyReceipt does).
54
+ */
55
+ export function receiptFor(view, envelopeId, recipient, { read = true } = {}) {
56
+ if (!view || view.kind !== "ack" || view.sender !== recipient) return null;
57
+ const p = view.payload;
58
+ if (isReadReceipt(p)) return read && (p.upTo === envelopeId || p.thread === envelopeId) ? "read" : null;
59
+ return p && typeof p === "object" && p.inReplyTo === envelopeId ? "delivered" : null;
60
+ }
61
+
62
+ /**
63
+ * The read receipts that cover a batch of inbound messages being marked
64
+ * read: one per (sender, conversation), citing the latest envelope in it.
65
+ * A group message carries no `thread`, so its receipt names that envelope as
66
+ * both thread and upTo; the sender applies it to everything earlier it sealed
67
+ * to us in that group. Receipts and our own notes are never receipted.
68
+ *
69
+ * `views`: [{ id, sender, kind, payload, groupId? }] in arrival order.
70
+ */
71
+ export function planReadReceipts(views, { self, max = MAX_READ_RECEIPTS_PER_RUN } = {}) {
72
+ const latest = new Map(); // key -> { to, thread, upTo }
73
+ for (const v of views) {
74
+ if (!v || v.kind === "ack" || v.sender === self) continue;
75
+ if (v.groupId) {
76
+ latest.set(`g|${v.sender}|${v.groupId}`, { to: v.sender, thread: v.id, upTo: v.id });
77
+ } else {
78
+ const thread = threadOf(v, v.payload);
79
+ latest.set(`t|${v.sender}|${thread}`, { to: v.sender, thread, upTo: v.id });
80
+ }
81
+ }
82
+ const all = [...latest.values()];
83
+ return { receipts: all.slice(-max), dropped: Math.max(0, all.length - max) };
84
+ }
85
+
86
+ /**
87
+ * Apply an inbound ack to the outbox. Only ever to rows we sent TO the
88
+ * ack's verified sender: nobody else can acknowledge them.
89
+ *
90
+ * Returns { kind: "ack" | "read" | null, marked }.
91
+ */
92
+ export function applyReceipt(mailbox, envelope, payload, { applyRead = true } = {}) {
93
+ if (envelope.kind !== "ack" || !payload || typeof payload !== "object") return { kind: null, marked: 0 };
94
+ const from = envelope.sender;
95
+ if (isReadReceipt(payload)) {
96
+ if (!applyRead) return { kind: "read", marked: 0, ignored: "read-receipts-off" };
97
+ const rows = mailbox.outboundTo(from);
98
+ const cited = rows.find((r) => r.envelope_id === payload.thread);
99
+ let candidates;
100
+ let cutoffRow;
101
+ if (cited?.group_id) {
102
+ // Group: everything earlier we sealed to this member in that group.
103
+ candidates = rows.filter((r) => r.group_id === cited.group_id);
104
+ cutoffRow = rows.find((r) => r.envelope_id === payload.upTo) ?? cited;
105
+ } else {
106
+ candidates = rows.filter((r) => r.thread === payload.thread || r.envelope_id === payload.thread);
107
+ cutoffRow = rows.find((r) => r.envelope_id === payload.upTo);
108
+ }
109
+ // An unknown anchor still covers the whole conversation: the receipt is
110
+ // authenticated from its counterparty, and the rows are only ever ours
111
+ // to THAT counterparty, so an over-mark is their own mail marked read.
112
+ const cutoff = cutoffRow?.created_at;
113
+ const ids = candidates.filter((r) => !cutoff || r.created_at <= cutoff).map((r) => r.envelope_id);
114
+ const marked = ids.length ? mailbox.markOutboundRead(ids, { at: envelope.createdAt }) : 0;
115
+ return { kind: "read", marked };
116
+ }
117
+ if (typeof payload.inReplyTo === "string" && payload.inReplyTo) {
118
+ const ok = mailbox.markOutboundAcked(payload.inReplyTo, { from, ackId: envelope.id, at: envelope.createdAt });
119
+ return { kind: "ack", marked: ok ? 1 : 0 };
120
+ }
121
+ return { kind: null, marked: 0 };
122
+ }
123
+
124
+ /** True when an inbound ack/reply answers something we sent to its sender. */
125
+ export function answersOurSend(mailbox, envelope, payload) {
126
+ if (envelope.kind !== "ack" && envelope.kind !== "reply") return false;
127
+ const sentTo = (id) => {
128
+ if (typeof id !== "string" || !id) return false;
129
+ const row = mailbox.getOutbound(id);
130
+ return !!row && row.recipient === envelope.sender;
131
+ };
132
+ if (sentTo(payload?.inReplyTo)) return true;
133
+ return envelope.kind === "ack" && isReadReceipt(payload) && (sentTo(payload.thread) || sentTo(payload.upTo));
134
+ }
135
+
136
+ /** Human status line for one outbox row; wording follows the convention's limits. */
137
+ export function describeOutboundStatus(row) {
138
+ const parts = [];
139
+ if (row.status === "delivered") parts.push(`accepted by carrier ${row.delivered_at ?? ""}`.trim());
140
+ else parts.push("not yet accepted by any carrier");
141
+ if (row.recipient_ack_at) parts.push(`acknowledged by the recipient's key ${row.recipient_ack_at}`);
142
+ if (row.recipient_read_at) parts.push(`read receipt ${row.recipient_read_at}`);
143
+ if (!row.recipient_ack_at && !row.recipient_read_at && row.kind !== "ack") parts.push("no recipient receipt yet");
144
+ return parts.join("; ");
145
+ }