@tavrn/e2ee 0.0.0-stage → 0.2.2

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/node.ts ADDED
@@ -0,0 +1,3 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /** Node.js-only parts of @tavrn/e2ee (file system access). Import from `@tavrn/e2ee/node`. */
3
+ export { FileKeyStore, STATE_FILE, StateDirInUseError } from "./storage/file";
@@ -0,0 +1,141 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Device signatures on plaintext messages (FEDERATION.md §10.3): the envelope fields the signature
4
+ * covers, the nonce inside the `signature` value and how a verification result is shown. Signing and
5
+ * verifying themselves run in the MLS session manager (device key, attested devices).
6
+ */
7
+ import { b64urlDecode, b64urlEncode, jcs, type Attachment } from "@tavrn/sdk";
8
+
9
+ /** One attachment as covered by the signature. */
10
+ export interface SignedAttachment {
11
+ id: string;
12
+ size: number;
13
+ sha256: string;
14
+ }
15
+
16
+ /** The envelope fields covered by the signature, in the JSON form `@tavrn/crypto` expects. */
17
+ export interface SignedFields {
18
+ /** Qualified conversation id `<node domain>/<ulid>`. */
19
+ conversation: string;
20
+ author: string;
21
+ device_id: string;
22
+ reply_to: string | null;
23
+ thread_id: string | null;
24
+ mentions_everyone: boolean;
25
+ mentions: string[];
26
+ attachments: SignedAttachment[];
27
+ }
28
+
29
+ /** The parts of an envelope needed to check its signature. */
30
+ export interface PlainEnvelope {
31
+ id: string;
32
+ author: string;
33
+ device_id?: string | null;
34
+ reply_to_id?: string | null;
35
+ thread_id?: string | null;
36
+ mentions?: string[];
37
+ mentions_everyone?: boolean;
38
+ attachments?: Pick<Attachment, "id" | "size" | "sha256">[];
39
+ content: unknown;
40
+ signature?: string | null;
41
+ }
42
+
43
+ /** Signed attachment entries in envelope order (the node lists attachments sorted by id). */
44
+ export function signedAttachments(list: Pick<Attachment, "id" | "size" | "sha256">[]): SignedAttachment[] {
45
+ return [...list].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map((a) => ({ id: a.id, size: a.size, sha256: a.sha256 }));
46
+ }
47
+
48
+ /** The covered fields of a received envelope; null for messages without a device (system messages). */
49
+ export function envelopeFields(conversation: string, m: PlainEnvelope): SignedFields | null {
50
+ if (!m.device_id) return null;
51
+ return {
52
+ conversation,
53
+ author: m.author,
54
+ device_id: m.device_id,
55
+ reply_to: m.reply_to_id ?? null,
56
+ thread_id: m.thread_id ?? null,
57
+ mentions_everyone: !!m.mentions_everyone,
58
+ mentions: m.mentions ?? [],
59
+ // Envelope order as received: the signer used the same order (sorted by id).
60
+ attachments: (m.attachments ?? []).map((a) => ({ id: a.id, size: a.size, sha256: a.sha256 })),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * SHA-256 (base64url) of the signed input of a plaintext message (covered fields as JSON, canonical content) with
66
+ * its signature: the key of a verification result. A collision-resistant hash, so a result is only ever reused for the
67
+ * identical signed input.
68
+ */
69
+ export async function signedInputDigest(fieldsJson: string, contentJcs: string, signature: string): Promise<string> {
70
+ const input = new TextEncoder().encode(`${fieldsJson}|${contentJcs}|${signature}`);
71
+ return b64urlEncode(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", input)));
72
+ }
73
+
74
+ /**
75
+ * The values of an envelope a verification result depends on: a cheap check that an envelope object passed again was
76
+ * not changed in place (a store that applies an update onto the same object) before its earlier result is reused. The
77
+ * covered fields and the content's top level are compared by value; content with nested objects or arrays (an embed,
78
+ * a GIF) is compared by its canonical form, so a change anywhere inside it is noticed.
79
+ */
80
+ export function envelopeSnapshot(m: PlainEnvelope): unknown[] {
81
+ const out: unknown[] = [m.id, m.author, m.device_id, m.reply_to_id, m.thread_id, m.mentions_everyone, m.signature, m.mentions, m.attachments, m.content];
82
+ for (const x of m.mentions ?? []) out.push(x);
83
+ for (const a of m.attachments ?? []) out.push(a, a.id, a.size, a.sha256);
84
+ const c = m.content;
85
+ if (!c || typeof c !== "object") return out;
86
+ const values = Object.entries(c);
87
+ if (values.some(([, v]) => v !== null && typeof v === "object")) {
88
+ try {
89
+ out.push(jcs(c));
90
+ } catch {
91
+ // No canonical form: never equal to an earlier snapshot.
92
+ out.push(Symbol("unserializable"));
93
+ }
94
+ } else for (const [k, v] of values) out.push(k, v);
95
+ return out;
96
+ }
97
+
98
+ /** Whether two {@link envelopeSnapshot}s are equal (element by element, `Object.is`). */
99
+ export function sameSnapshot(a: unknown[], b: unknown[]): boolean {
100
+ return a.length === b.length && a.every((x, i) => Object.is(x, b[i]));
101
+ }
102
+
103
+ /** The nonce (base64url) inside a `signature` value, or null if it is not 80 bytes of base64url. */
104
+ export function signatureNonce(signature: string): string | null {
105
+ if (signature.length !== 107 || !/^[A-Za-z0-9_-]+$/.test(signature)) return null;
106
+ try {
107
+ const bytes = b64urlDecode(signature);
108
+ return bytes.length === 80 ? b64urlEncode(bytes.slice(0, 16)) : null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Result of checking a plaintext message:
116
+ * - `valid`: signed by an attested device of the author;
117
+ * - `unsigned`: no signature (older clients, bots, API accounts without a device key);
118
+ * - `unverifiable`: signed, but the device key is unknown here (e.g. users of other nodes) or the
119
+ * input could not be checked;
120
+ * - `invalid`: the signature does not match: the node or someone else changed the message;
121
+ * - `replay`: the nonce was already used by another message of the conversation.
122
+ */
123
+ export type PlainStatus = "valid" | "unsigned" | "unverifiable" | "invalid" | "replay";
124
+
125
+ /**
126
+ * How a message with this status is shown: normally (`null`), with a small "unverified" marker, or
127
+ * with its content hidden behind "could not be verified" (FEDERATION.md §10.3). Unknown status
128
+ * (not checked yet, or this device cannot check: locked) shows nothing.
129
+ */
130
+ export function plainMarker(status: PlainStatus | undefined): null | "unverified" | "hidden" {
131
+ switch (status) {
132
+ case "unsigned":
133
+ case "unverifiable":
134
+ return "unverified";
135
+ case "invalid":
136
+ case "replay":
137
+ return "hidden";
138
+ default:
139
+ return null;
140
+ }
141
+ }
package/src/poll.ts ADDED
@@ -0,0 +1,434 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Polls and date polls (PROTOCOL.md §7g): the poll content (`{ type: "poll", kind: "choice" | "date", … }`), the
4
+ * vote content (`{ type: "poll.vote", choices | answers, voted_at, ctx: { poll, poll_hash } }`), the poll hash
5
+ * (lowercase hex SHA-256 of the RFC 8785 form of the poll without its `ctx`), encrypting, signing and checking vote
6
+ * records and the tally.
7
+ *
8
+ * A poll is an ordinary message; its votes are records next to it (`api.polls`), one per member. Encrypted votes are
9
+ * content format 1 under the conversation's current epoch, signed by the voting device ({@link encryptPollVote},
10
+ * {@link MlsManager.decryptPollVote}); plaintext votes carry the plaintext signature of FEDERATION.md §10.3 with empty
11
+ * reply and thread, no mentions and no attachments ({@link signPlainPollVote}). {@link checkPollVote} turns a record
12
+ * into a countable vote or the reason it does not count; {@link tallyPoll} counts at most one vote per member and
13
+ * leaves out stale, late and unverifiable votes.
14
+ */
15
+ import { jcs, type PollVote } from "@tavrn/sdk";
16
+ import type { DecryptFailure, MlsManager } from "./mls";
17
+ import type { PlainStatus } from "./plaintext";
18
+
19
+ export const POLL_TYPE = "poll";
20
+ export const POLL_VOTE_TYPE = "poll.vote";
21
+ /** Limits of PROTOCOL.md §7g. */
22
+ export const POLL_LIMITS = {
23
+ question: 300,
24
+ label: 100,
25
+ minOptions: 2,
26
+ maxOptions: 20,
27
+ minSlots: 1,
28
+ maxSlots: 60,
29
+ id: 32,
30
+ /** Bytes of a vote record's content (ciphertext or plaintext JSON). */
31
+ voteBytes: 4096,
32
+ } as const;
33
+
34
+ export type PollKind = "choice" | "date";
35
+ export type PollResultsVisible = "always" | "after_vote" | "after_close";
36
+ export type PollVotesVisible = "public" | "hidden_until_close";
37
+ export type PollAnswer = "yes" | "maybe" | "no";
38
+
39
+ export interface PollOption {
40
+ id: string;
41
+ label: string;
42
+ }
43
+
44
+ export interface PollSlot {
45
+ id: string;
46
+ /** RFC 3339 with offset. */
47
+ start: string;
48
+ end?: string;
49
+ all_day?: boolean;
50
+ }
51
+
52
+ interface PollBase {
53
+ question: string;
54
+ results_visible: PollResultsVisible;
55
+ votes_visible: PollVotesVisible;
56
+ }
57
+
58
+ export interface ChoicePoll extends PollBase {
59
+ kind: "choice";
60
+ options: PollOption[];
61
+ max_choices: number;
62
+ }
63
+
64
+ export interface DatePoll extends PollBase {
65
+ kind: "date";
66
+ slots: PollSlot[];
67
+ }
68
+
69
+ /** A checked poll (the fields of PROTOCOL.md §7g; unknown fields of the content are not kept here). */
70
+ export type Poll = ChoicePoll | DatePoll;
71
+
72
+ /** The vote content (inside the record). */
73
+ export interface PollVoteContent {
74
+ type: "poll.vote";
75
+ choices?: string[];
76
+ answers?: Record<string, PollAnswer>;
77
+ voted_at: string;
78
+ ctx: { poll: string; poll_hash: string };
79
+ }
80
+
81
+ const ID = /^[A-Za-z0-9_-]{1,32}$/;
82
+ /** RFC 3339 date-time with seconds and an offset (the same pattern the node checks). */
83
+ const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
84
+ const HASH = /^[0-9a-f]{64}$/;
85
+ const RESULTS = new Set<string>(["always", "after_vote", "after_close"]);
86
+ const VOTES = new Set<string>(["public", "hidden_until_close"]);
87
+ const ANSWERS = new Set<string>(["yes", "maybe", "no"]);
88
+
89
+ const chars = (s: string) => Array.from(s).length;
90
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
91
+
92
+ /** An RFC 3339 date-time with an offset (never a floating local time), as polls and votes use them. */
93
+ export function isPollTime(v: unknown): v is string {
94
+ return typeof v === "string" && RFC3339.test(v) && Number.isFinite(Date.parse(v));
95
+ }
96
+
97
+ function text(v: unknown, max: number): string | null {
98
+ if (typeof v !== "string") return null;
99
+ const t = v.trim();
100
+ return t && chars(t) <= max ? t : null;
101
+ }
102
+
103
+ function uniqueIds(list: { id: string }[]): boolean {
104
+ return new Set(list.map((x) => x.id)).size === list.length;
105
+ }
106
+
107
+ /**
108
+ * Checks a (decrypted or plaintext) content object against PROTOCOL.md §7g; null when it is no valid poll (clients
109
+ * show it like an unknown type). `ctx` and unknown fields are ignored.
110
+ */
111
+ export function parsePoll(content: unknown): Poll | null {
112
+ if (!isObject(content) || content.type !== POLL_TYPE) return null;
113
+ const question = text(content.question, POLL_LIMITS.question);
114
+ if (!question || typeof content.results_visible !== "string" || !RESULTS.has(content.results_visible)) return null;
115
+ if (typeof content.votes_visible !== "string" || !VOTES.has(content.votes_visible)) return null;
116
+ const base = { question, results_visible: content.results_visible as PollResultsVisible, votes_visible: content.votes_visible as PollVotesVisible };
117
+ if (content.kind === "choice") {
118
+ const raw = content.options;
119
+ if (!Array.isArray(raw) || raw.length < POLL_LIMITS.minOptions || raw.length > POLL_LIMITS.maxOptions) return null;
120
+ const options: PollOption[] = [];
121
+ for (const o of raw) {
122
+ if (!isObject(o) || typeof o.id !== "string" || !ID.test(o.id)) return null;
123
+ const label = text(o.label, POLL_LIMITS.label);
124
+ if (!label) return null;
125
+ options.push({ id: o.id, label });
126
+ }
127
+ const max = content.max_choices;
128
+ if (!uniqueIds(options) || typeof max !== "number" || !Number.isInteger(max) || max < 1 || max > options.length) return null;
129
+ return { kind: "choice", ...base, options, max_choices: max };
130
+ }
131
+ if (content.kind === "date") {
132
+ const raw = content.slots;
133
+ if (!Array.isArray(raw) || raw.length < POLL_LIMITS.minSlots || raw.length > POLL_LIMITS.maxSlots) return null;
134
+ const slots: PollSlot[] = [];
135
+ for (const s of raw) {
136
+ if (!isObject(s) || typeof s.id !== "string" || !ID.test(s.id) || !isPollTime(s.start)) return null;
137
+ const slot: PollSlot = { id: s.id, start: s.start };
138
+ if (s.end !== undefined && s.end !== null) {
139
+ if (!isPollTime(s.end) || Date.parse(s.end) <= Date.parse(s.start)) return null;
140
+ slot.end = s.end;
141
+ }
142
+ if (s.all_day !== undefined && s.all_day !== null) {
143
+ if (typeof s.all_day !== "boolean") return null;
144
+ if (s.all_day) slot.all_day = true;
145
+ }
146
+ slots.push(slot);
147
+ }
148
+ if (!uniqueIds(slots)) return null;
149
+ return { kind: "date", ...base, slots };
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /** What a poll composer collects; {@link pollContent} turns it into the content object. */
155
+ export type PollInput =
156
+ | { kind: "choice"; question: string; options: (string | PollOption)[]; maxChoices?: number; resultsVisible?: PollResultsVisible; votesVisible?: PollVotesVisible }
157
+ | { kind: "date"; question: string; slots: (PollSlot | { start: string; end?: string; allDay?: boolean })[]; resultsVisible?: PollResultsVisible; votesVisible?: PollVotesVisible };
158
+
159
+ /**
160
+ * The content object of a new poll (without `ctx`: the encrypting layer adds the message context). Options given as
161
+ * plain labels get the ids `o1`, `o2`, …, slots without an id `s1`, `s2`, …. Throws RangeError when the result breaks
162
+ * PROTOCOL.md §7g.
163
+ */
164
+ export function pollContent(input: PollInput): Record<string, unknown> {
165
+ const common = { type: POLL_TYPE, question: input.question.trim(), results_visible: input.resultsVisible ?? "always", votes_visible: input.votesVisible ?? "public" };
166
+ let content: Record<string, unknown>;
167
+ if (input.kind === "choice") {
168
+ const options = input.options.map((o, i) => (typeof o === "string" ? { id: `o${i + 1}`, label: o.trim() } : { id: o.id, label: o.label.trim() }));
169
+ content = { ...common, kind: "choice", options, max_choices: input.maxChoices ?? 1 };
170
+ } else {
171
+ const slots = input.slots.map((s, i) => {
172
+ const out: Record<string, unknown> = { id: "id" in s && typeof s.id === "string" ? s.id : `s${i + 1}`, start: s.start };
173
+ if (s.end) out.end = s.end;
174
+ if (("all_day" in s && s.all_day) || ("allDay" in s && s.allDay)) out.all_day = true;
175
+ return out;
176
+ });
177
+ content = { ...common, kind: "date", slots };
178
+ }
179
+ if (!parsePoll(content)) throw new RangeError("invalid poll (PROTOCOL.md §7g)");
180
+ return content;
181
+ }
182
+
183
+ function hex(bytes: ArrayBuffer): string {
184
+ return Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
185
+ }
186
+
187
+ /**
188
+ * `poll_hash`: lowercase hex SHA-256 of the JCS form of the poll content as the client holds it (decrypted, or the
189
+ * plaintext object), without its `ctx` member. Unknown fields are part of the hash.
190
+ */
191
+ export async function pollHash(content: Record<string, unknown>): Promise<string> {
192
+ const { ctx: _ctx, ...rest } = content;
193
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(jcs(rest)));
194
+ return hex(digest);
195
+ }
196
+
197
+ /** The voter's choice: option ids of a choice poll, or answers per slot of a date poll. */
198
+ export type PollSelection = { choices: string[] } | { answers: Record<string, PollAnswer> };
199
+
200
+ /** Does the selection fit the poll (known ids, 1 to `max_choices` distinct choices, at least one answer)? */
201
+ export function selectionFits(poll: Poll, sel: { choices?: unknown; answers?: unknown }): boolean {
202
+ if (poll.kind === "choice") {
203
+ if (sel.answers !== undefined || !Array.isArray(sel.choices)) return false;
204
+ const ids = new Set(poll.options.map((o) => o.id));
205
+ const c = sel.choices as unknown[];
206
+ return c.length >= 1 && c.length <= poll.max_choices && new Set(c).size === c.length && c.every((x) => typeof x === "string" && ids.has(x));
207
+ }
208
+ if (sel.choices !== undefined || !isObject(sel.answers)) return false;
209
+ const ids = new Set(poll.slots.map((s) => s.id));
210
+ const entries = Object.entries(sel.answers);
211
+ return entries.length >= 1 && entries.every(([k, v]) => ids.has(k) && typeof v === "string" && ANSWERS.has(v));
212
+ }
213
+
214
+ /**
215
+ * The vote content for a poll message: `messageId` and `hash` ({@link pollHash}) bind it to exactly this poll. Throws
216
+ * RangeError when the selection does not fit the poll.
217
+ */
218
+ export function pollVoteContent(poll: Poll, messageId: string, hash: string, selection: PollSelection, now: Date = new Date()): PollVoteContent {
219
+ if (!selectionFits(poll, selection)) throw new RangeError("the selection does not fit the poll");
220
+ if (!HASH.test(hash)) throw new RangeError("poll_hash must be lowercase hex SHA-256");
221
+ const vote: PollVoteContent = { type: POLL_VOTE_TYPE, voted_at: now.toISOString().replace(/\.\d{3}Z$/, "Z"), ctx: { poll: messageId, poll_hash: hash } };
222
+ if ("choices" in selection) vote.choices = [...selection.choices];
223
+ else vote.answers = { ...selection.answers };
224
+ return vote;
225
+ }
226
+
227
+ /** Why a vote record is not counted (besides the decryption and signature failures). */
228
+ export type VoteRejection =
229
+ /** Cast on another version of the poll (an edited poll): shown as "voted on an earlier version". */
230
+ | "stale"
231
+ /** Bound to another poll (`ctx.poll`), or a malformed `ctx`. */
232
+ | "envelope_mismatch"
233
+ /** Not a vote, or the choices do not fit the poll. */
234
+ | "invalid";
235
+
236
+ /** A record as a countable vote, or why it does not count. */
237
+ export type VoteCheck =
238
+ | { ok: true; vote: PollVoteContent; selection: PollSelection; unverified?: true }
239
+ | { ok: false; reason: VoteRejection | DecryptFailure | "bad_signature" | "replay" };
240
+
241
+ /**
242
+ * Checks a decrypted (or plaintext) vote content against the poll the client renders (PROTOCOL.md §7g "Receivers",
243
+ * steps 2 and 3).
244
+ */
245
+ export function parsePollVote(content: unknown, poll: Poll, messageId: string, hash: string): VoteCheck {
246
+ if (!isObject(content) || content.type !== POLL_VOTE_TYPE || !isPollTime(content.voted_at)) return { ok: false, reason: "invalid" };
247
+ const ctx = content.ctx;
248
+ if (!isObject(ctx) || Object.keys(ctx).length !== 2 || typeof ctx.poll !== "string" || typeof ctx.poll_hash !== "string") return { ok: false, reason: "envelope_mismatch" };
249
+ if (ctx.poll !== messageId) return { ok: false, reason: "envelope_mismatch" };
250
+ if (ctx.poll_hash !== hash) return { ok: false, reason: "stale" };
251
+ if (!selectionFits(poll, content)) return { ok: false, reason: "invalid" };
252
+ const selection: PollSelection = poll.kind === "choice" ? { choices: [...(content.choices as string[])] } : { answers: { ...(content.answers as Record<string, PollAnswer>) } };
253
+ return { ok: true, vote: content as unknown as PollVoteContent, selection };
254
+ }
255
+
256
+ /** Encrypts a vote under the conversation's current epoch: the body of `PUT …/poll/vote` (`content`, `epoch`). */
257
+ export async function encryptPollVote(mls: Pick<MlsManager, "encrypt">, conversationId: string, vote: PollVoteContent): Promise<{ content: string; epoch: number }> {
258
+ return mls.encrypt(conversationId, vote as unknown as Record<string, unknown>);
259
+ }
260
+
261
+ /**
262
+ * The plaintext signature of a vote in an unencrypted conversation (FEDERATION.md §10.3; `conversation` qualified):
263
+ * empty reply and thread, no `@everyone`, no mentions, no attachments. Null when this device cannot sign.
264
+ */
265
+ export function signPlainPollVote(mls: Pick<MlsManager, "signPlaintext">, conversation: string, vote: PollVoteContent): string | null {
266
+ return mls.signPlaintext(conversation, { reply_to: null, thread_id: null, mentions_everyone: false, mentions: [], attachments: [] }, vote as unknown as Record<string, unknown>);
267
+ }
268
+
269
+ /**
270
+ * The id under which a voter's records are decrypted and replay-checked: one per voter, not per poll, so a vote the
271
+ * node serves under another poll fails on its signed `ctx` (`envelope_mismatch`) without spoiling the genuine one.
272
+ */
273
+ export function pollVoteRecordId(record: Pick<PollVote, "user">): string {
274
+ return `poll-vote:${record.user}`;
275
+ }
276
+
277
+ /**
278
+ * Checks a vote record (PROTOCOL.md §7g "Receivers", steps 1–3): encrypted content is decrypted and verified against
279
+ * the voter's device ({@link MlsManager.decryptPollVote}), plaintext content's signature is checked (unsigned or
280
+ * unverifiable votes count, marked `unverified`; a wrong signature or a replay does not), then the vote is checked
281
+ * against the poll. `conversation` is the qualified id `<node>/<id>`.
282
+ */
283
+ export async function checkPollVote(
284
+ mls: Pick<MlsManager, "decryptPollVote" | "verifyPlaintext">,
285
+ conversation: string,
286
+ record: PollVote,
287
+ poll: Poll,
288
+ hash: string,
289
+ ): Promise<VoteCheck> {
290
+ if (typeof record.content === "string") {
291
+ const d = await mls.decryptPollVote(conversation, record);
292
+ if (!d.ok) return d;
293
+ return parsePollVote(d.content, poll, record.message_id, hash);
294
+ }
295
+ if (!isObject(record.content)) return { ok: false, reason: "invalid" };
296
+ if (new TextEncoder().encode(JSON.stringify(record.content)).length > POLL_LIMITS.voteBytes) return { ok: false, reason: "invalid" };
297
+ let status: PlainStatus;
298
+ try {
299
+ status = await mls.verifyPlaintext(conversation, { id: pollVoteRecordId(record), author: record.user, device_id: record.device_id ?? null, reply_to_id: null, thread_id: null, mentions: [], mentions_everyone: false, attachments: [], content: record.content, signature: record.signature ?? null });
300
+ } catch {
301
+ status = "unverifiable";
302
+ }
303
+ if (status === "invalid") return { ok: false, reason: "bad_signature" };
304
+ if (status === "replay") return { ok: false, reason: "replay" };
305
+ const r = parsePollVote(record.content, poll, record.message_id, hash);
306
+ return r.ok && status !== "valid" ? { ...r, unverified: true } : r;
307
+ }
308
+
309
+ /** A record with its check, as {@link tallyPoll} takes it. */
310
+ export interface CheckedVote {
311
+ user: string;
312
+ updated_at: string;
313
+ check: VoteCheck;
314
+ }
315
+
316
+ export interface OptionTally {
317
+ id: string;
318
+ label: string;
319
+ count: number;
320
+ /** Addresses of the voters (for `votes_visible: "public"`; the client hides them while votes are hidden). */
321
+ voters: string[];
322
+ }
323
+
324
+ export interface SlotTally {
325
+ id: string;
326
+ yes: number;
327
+ maybe: number;
328
+ no: number;
329
+ voters: { yes: string[]; maybe: string[]; no: string[] };
330
+ /** `2 × yes + maybe`: the ranking of the best slots. */
331
+ score: number;
332
+ }
333
+
334
+ export interface PollTally {
335
+ kind: PollKind;
336
+ /** `poll_closes_at` has passed. */
337
+ closed: boolean;
338
+ /** Members whose vote counts. */
339
+ voters: number;
340
+ /** Members with a vote record (counted or not): the voter count of a poll the client cannot read. */
341
+ records: number;
342
+ /** Not counted: cast on an earlier version of the poll. */
343
+ stale: number;
344
+ /** Not counted: changed after the close time. */
345
+ late: number;
346
+ /** Not counted: not decryptable or verifiable, malformed, bound to another poll or not fitting the poll. */
347
+ rejected: number;
348
+ /** Counted votes that carry no checkable signature (plaintext conversations). */
349
+ unverified: number;
350
+ options: OptionTally[];
351
+ slots: SlotTally[];
352
+ /** Choice polls: the option ids with the most votes; date polls: the slot ids with the best score (empty without votes). */
353
+ best: string[];
354
+ /** The counted selection per voter (the viewer's own vote included). */
355
+ byUser: Map<string, PollSelection>;
356
+ }
357
+
358
+ /**
359
+ * Counts the votes of a poll (PROTOCOL.md §7g "Receivers"): per member only the newest record counts (the node keeps
360
+ * one; several are tolerated), and only when it checked out, matches this version of the poll and was not changed
361
+ * after `closesAt`. `now` decides `closed`.
362
+ */
363
+ export function tallyPoll(poll: Poll, votes: CheckedVote[], opts: { closesAt?: string | null; now?: Date } = {}): PollTally {
364
+ const closesAt = opts.closesAt ? Date.parse(opts.closesAt) : NaN;
365
+ const now = (opts.now ?? new Date()).getTime();
366
+ const newest = new Map<string, CheckedVote>();
367
+ for (const v of votes) {
368
+ const seen = newest.get(v.user);
369
+ if (!seen || Date.parse(v.updated_at) > Date.parse(seen.updated_at)) newest.set(v.user, v);
370
+ }
371
+ const tally: PollTally = {
372
+ kind: poll.kind,
373
+ closed: Number.isFinite(closesAt) && now >= closesAt,
374
+ voters: 0,
375
+ records: newest.size,
376
+ stale: 0,
377
+ late: 0,
378
+ rejected: 0,
379
+ unverified: 0,
380
+ options: poll.kind === "choice" ? poll.options.map((o) => ({ id: o.id, label: o.label, count: 0, voters: [] })) : [],
381
+ slots: poll.kind === "date" ? poll.slots.map((s) => ({ id: s.id, yes: 0, maybe: 0, no: 0, voters: { yes: [], maybe: [], no: [] }, score: 0 })) : [],
382
+ best: [],
383
+ byUser: new Map(),
384
+ };
385
+ for (const [user, v] of [...newest].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
386
+ if (!v.check.ok) {
387
+ if (v.check.reason === "stale") tally.stale++;
388
+ else tally.rejected++;
389
+ continue;
390
+ }
391
+ if (Number.isFinite(closesAt) && Date.parse(v.updated_at) > closesAt) {
392
+ tally.late++;
393
+ continue;
394
+ }
395
+ const sel = v.check.selection;
396
+ tally.voters++;
397
+ if (v.check.unverified) tally.unverified++;
398
+ tally.byUser.set(user, sel);
399
+ if ("choices" in sel) {
400
+ for (const id of sel.choices) {
401
+ const o = tally.options.find((x) => x.id === id);
402
+ if (o) {
403
+ o.count++;
404
+ o.voters.push(user);
405
+ }
406
+ }
407
+ } else {
408
+ for (const [id, a] of Object.entries(sel.answers)) {
409
+ const s = tally.slots.find((x) => x.id === id);
410
+ if (!s) continue;
411
+ s[a]++;
412
+ s.voters[a].push(user);
413
+ }
414
+ }
415
+ }
416
+ if (poll.kind === "choice") {
417
+ const top = Math.max(0, ...tally.options.map((o) => o.count));
418
+ tally.best = top > 0 ? tally.options.filter((o) => o.count === top).map((o) => o.id) : [];
419
+ } else {
420
+ for (const s of tally.slots) s.score = 2 * s.yes + s.maybe;
421
+ const top = Math.max(0, ...tally.slots.map((s) => s.score));
422
+ tally.best = top > 0 ? tally.slots.filter((s) => s.score === top).map((s) => s.id) : [];
423
+ }
424
+ return tally;
425
+ }
426
+
427
+ /**
428
+ * Whether the viewer may see the results now (`results_visible`, cooperative): always, after their own vote, or after
429
+ * the close. And whether the voters' names may be shown (`votes_visible`).
430
+ */
431
+ export function pollVisibility(poll: Poll, tally: Pick<PollTally, "closed">, hasVoted: boolean): { results: boolean; voters: boolean } {
432
+ const results = poll.results_visible === "always" || (poll.results_visible === "after_vote" && (hasVoted || tally.closed)) || tally.closed;
433
+ return { results, voters: results && (poll.votes_visible === "public" || tally.closed) };
434
+ }
package/src/sealed.ts ADDED
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Write-only webhooks in encrypted channels (APPLICATIONS.md §6.1a, ENCRYPTION.md §5b): the stored form of a
4
+ * sealed webhook message and the checks of the payload inside the box. The box itself is opened by
5
+ * `Client.openSealed` with the inbox key derived from the epoch's content key ({@link MlsManager.decrypt}).
6
+ */
7
+ import { withValidEmbeds } from "./embeds";
8
+
9
+ /** Who sealed the message: the posting service itself, or the node (which then saw the text once). */
10
+ export type SealedBy = "sender" | "node";
11
+
12
+ /** The `content` of a sealed webhook message: `{ type: "webhook.sealed", epoch, sealed, sealed_by }`. */
13
+ export interface SealedContent {
14
+ epoch: number;
15
+ /** `crypto_box_seal` output, base64url. */
16
+ sealed: string;
17
+ sealedBy: SealedBy;
18
+ }
19
+
20
+ /** Longest text body (PROTOCOL.md §7, the node's limit for plaintext messages). */
21
+ const MAX_BODY = 4000;
22
+ const B64URL = /^[A-Za-z0-9_-]+$/;
23
+
24
+ /** The sealed form of a message `content`, or null for anything else (ordinary ciphertext, plaintext). */
25
+ export function sealedContent(content: unknown): SealedContent | null {
26
+ if (!content || typeof content !== "object" || Array.isArray(content)) return null;
27
+ const c = content as Record<string, unknown>;
28
+ if (c.type !== "webhook.sealed") return null;
29
+ if (typeof c.epoch !== "number" || !Number.isSafeInteger(c.epoch) || c.epoch < 0) return null;
30
+ if (typeof c.sealed !== "string" || !B64URL.test(c.sealed)) return null;
31
+ return { epoch: c.epoch, sealed: c.sealed, sealedBy: c.sealed_by === "node" ? "node" : "sender" };
32
+ }
33
+
34
+ /** The node's rules for a webhook `username` (APPLICATIONS.md §6.1): 1–32 characters, no `@ # :`, no controls. */
35
+ export function validWebhookName(name: unknown): name is string {
36
+ if (typeof name !== "string") return false;
37
+ const n = name.trim();
38
+ const len = Array.from(n).length;
39
+ if (len === 0 || len > 32 || /[@#:]/.test(n) || /[\u0000-\u001f\u007f​-‏‪-‮⁦-⁩]/.test(n)) return false;
40
+ return !["everyone", "here"].includes(n.toLowerCase());
41
+ }
42
+
43
+ /**
44
+ * The payload inside a sealed box, checked like a webhook's plaintext payload: `{ content: { type: "text", body,
45
+ * embeds? }, username? }`. Only these fields survive (no attachments, GIFs or previews: webhooks have none);
46
+ * invalid embeds are dropped, an invalid username is ignored. Null when the payload is not a text payload.
47
+ */
48
+ export function webhookPayload(json: string): { content: Record<string, unknown>; username?: string } | null {
49
+ let parsed: unknown;
50
+ try {
51
+ parsed = JSON.parse(json);
52
+ } catch {
53
+ return null;
54
+ }
55
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
56
+ const p = parsed as Record<string, unknown>;
57
+ const c = p.content as Record<string, unknown> | null | undefined;
58
+ if (!c || typeof c !== "object" || Array.isArray(c) || c.type !== "text" || typeof c.body !== "string") return null;
59
+ if (Array.from(c.body).length > MAX_BODY) return null;
60
+ const content = withValidEmbeds(Array.isArray(c.embeds) ? { type: "text", body: c.body, embeds: c.embeds } : { type: "text", body: c.body });
61
+ const hasEmbeds = Array.isArray(content.embeds) && content.embeds.length > 0;
62
+ if (!c.body.trim() && !hasEmbeds) return null;
63
+ return validWebhookName(p.username) ? { content, username: p.username.trim() } : { content };
64
+ }