@gryt/core 0.10.0 → 0.12.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/README.md CHANGED
@@ -51,8 +51,13 @@ goes missing **and** when one appears that nobody listed.
51
51
  topped up, opens a DM group, joins from a Welcome, sends, and reads the group
52
52
  log in order. Each app hands it the socket, the storage and the pins through
53
53
  the interfaces in `src/mls/interfaces.ts`. `mlsPinsFromPeerPins` builds the
54
- pins from the peer pins `@gryt/crypto` already keeps. It's the one module with
55
- a dependency, `@gryt/crypto`, pinned exactly the way the apps pin it.
54
+ pins from the peer pins `@gryt/crypto` already keeps.
55
+ `encodeMlsDmContent` and `decodeMlsDmContent` write and read what goes inside
56
+ each message, so both apps use the same JSON: a message, an edit, a delete or
57
+ a reaction. `readMlsDmContent` also says when something came from a newer app,
58
+ so it can be skipped quietly rather than counted as broken. `applyMlsReaction` puts a reaction on a message in the archive,
59
+ since the server never sees one. It's the one module with a
60
+ dependency, `@gryt/crypto`, pinned exactly the way the apps pin it.
56
61
 
57
62
  ## What deliberately isn't
58
63
 
package/dist/index.d.ts CHANGED
@@ -12,4 +12,6 @@ export { describeFolderRules, folderFollowNote, folderPhrase, } from "./permissi
12
12
  export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_URL, TERMS_VERSION, } from "./terms/termsAgreement.js";
13
13
  export { createMlsDmDriver, MlsDriverError, type MlsDriverErrorCode } from "./mls/dmDriver.js";
14
14
  export { mlsPinsFromPeerPins } from "./mls/pins.js";
15
+ export { decodeMlsDmContent, encodeMlsDmContent, MLS_DM_CONTENT_VERSION, type MlsDmContent, readMlsDmContent, } from "./mls/content.js";
16
+ export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
15
17
  export type { DmSealingMode, MlsClaimedKeyPackage, MlsDecryptedMessage, MlsDeviceRecord, MlsDeviceRef, MlsDmDriver, MlsDmDriverOptions, MlsDmEvents, MlsGroupRecord, MlsGroupView, MlsKeyPackageRecord, MlsLogEntry, MlsPins, MlsRefusal, MlsReply, MlsServerCapability, MlsStateStore, MlsTransport, MlsWelcomeDelivery, } from "./mls/interfaces.js";
package/dist/index.js CHANGED
@@ -14,3 +14,5 @@ export { describeFolderRules, folderFollowNote, folderPhrase, } from "./permissi
14
14
  export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_URL, TERMS_VERSION, } from "./terms/termsAgreement.js";
15
15
  export { createMlsDmDriver, MlsDriverError } from "./mls/dmDriver.js";
16
16
  export { mlsPinsFromPeerPins } from "./mls/pins.js";
17
+ export { decodeMlsDmContent, encodeMlsDmContent, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
18
+ export { applyMlsReaction, mlsReactionAction } from "./mls/reactions.js";
@@ -0,0 +1,35 @@
1
+ import { type SealedAttachmentKey } from "@gryt/crypto";
2
+ export type MlsDmContent = {
3
+ type: "message";
4
+ /** Made by the sender, a UUID. Edits and deletes name it. */
5
+ id: string;
6
+ text: string;
7
+ replyTo?: string;
8
+ /** Upload id to what `sealAttachment` returned; `openAttachment` takes it as `meta`. */
9
+ attachments?: Record<string, SealedAttachmentKey>;
10
+ } | {
11
+ type: "edit";
12
+ id: string;
13
+ text: string;
14
+ } | {
15
+ type: "delete";
16
+ id: string;
17
+ } | {
18
+ type: "reaction";
19
+ /** The message reacted to, the way edits and deletes name theirs. */
20
+ id: string;
21
+ /** A Unicode emoji, or a server emoji as `:name:`, the same string `chat:react` carries. */
22
+ emoji: string;
23
+ /** Explicit rather than a toggle, so a repeat or a reorder lands the same on every device. */
24
+ action: "add" | "remove";
25
+ };
26
+ export declare const MLS_DM_CONTENT_VERSION = 1;
27
+ /** The same bytes the phone writes: `v` first, then the content's fields in this order. */
28
+ export declare function encodeMlsDmContent(content: MlsDmContent): Uint8Array;
29
+ /** Null for anything this version can't read: a newer version, a new type, or junk. */
30
+ export declare function decodeMlsDmContent(bytes: Uint8Array): MlsDmContent | null;
31
+ /**
32
+ * Like `decodeMlsDmContent`, but tells content from a newer app ("newer", skip it quietly)
33
+ * apart from junk (null, which is worth counting). A known type with a bad field is junk.
34
+ */
35
+ export declare function readMlsDmContent(bytes: Uint8Array): MlsDmContent | "newer" | null;
@@ -0,0 +1,160 @@
1
+ import { base64UrlDecode } from "@gryt/crypto";
2
+ export const MLS_DM_CONTENT_VERSION = 1;
3
+ const MAX_ID = 64;
4
+ /** Well past the composer's limit, and under what one `mls:send` can carry. */
5
+ const MAX_TEXT = 32_000;
6
+ const MAX_ATTACHMENTS = 10;
7
+ const MAX_LABEL = 255;
8
+ const UPLOAD_ID = /^[A-Za-z0-9_-]{1,128}$/;
9
+ /** The server's own rule for an emoji's name, in `emojiShared.ts`. */
10
+ const CUSTOM_EMOJI = /^:[A-Za-z0-9_]{2,32}:$/;
11
+ /** The longest RGI emoji, a kiss with two skin tones, is ten code points. */
12
+ const MAX_EMOJI_CODE_POINTS = 12;
13
+ const EMOJI_PART = /^(?:\p{Extended_Pictographic}|\p{Emoji_Component}|\uFE0E)$/u;
14
+ const EMOJI_BASE = /^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|\u20E3)$/u;
15
+ const isId = (v) => typeof v === "string" && v.length > 0 && v.length <= MAX_ID;
16
+ const isText = (v) => typeof v === "string" && v.length <= MAX_TEXT;
17
+ const isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
18
+ const isLabel = (v) => typeof v === "string" && v.length <= MAX_LABEL;
19
+ /** One emoji's worth of emoji characters, so a peer can't put a sentence on a message as a reaction. */
20
+ function isReactionEmoji(v) {
21
+ if (typeof v !== "string")
22
+ return false;
23
+ if (CUSTOM_EMOJI.test(v))
24
+ return true;
25
+ const points = Array.from(v);
26
+ if (points.length === 0 || points.length > MAX_EMOJI_CODE_POINTS)
27
+ return false;
28
+ return points.every((p) => EMOJI_PART.test(p)) && points.some((p) => EMOJI_BASE.test(p));
29
+ }
30
+ function isObject(v) {
31
+ return typeof v === "object" && v !== null && !Array.isArray(v);
32
+ }
33
+ function decodesTo(v, bytes) {
34
+ if (typeof v !== "string")
35
+ return false;
36
+ try {
37
+ return base64UrlDecode(v).length === bytes;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ /** Only the fields a reader uses, each checked; null for anything off. */
44
+ function readAttachmentKey(v) {
45
+ if (!isObject(v) || !isId(v.id) || !decodesTo(v.key, 32) || !decodesTo(v.iv, 12))
46
+ return null;
47
+ const out = { id: v.id, key: v.key, iv: v.iv };
48
+ for (const field of ["name", "mime"]) {
49
+ if (v[field] === undefined)
50
+ continue;
51
+ if (!isLabel(v[field]))
52
+ return null;
53
+ out[field] = v[field];
54
+ }
55
+ for (const field of ["size", "width", "height"]) {
56
+ if (v[field] === undefined)
57
+ continue;
58
+ if (!isCount(v[field]))
59
+ return null;
60
+ out[field] = v[field];
61
+ }
62
+ return out;
63
+ }
64
+ function readAttachments(v) {
65
+ if (!isObject(v))
66
+ return null;
67
+ const entries = Object.entries(v);
68
+ if (entries.length === 0 || entries.length > MAX_ATTACHMENTS)
69
+ return null;
70
+ const out = {};
71
+ for (const [uploadId, meta] of entries) {
72
+ const key = readAttachmentKey(meta);
73
+ if (!UPLOAD_ID.test(uploadId) || !key)
74
+ return null;
75
+ out[uploadId] = key;
76
+ }
77
+ return out;
78
+ }
79
+ /** The same bytes the phone writes: `v` first, then the content's fields in this order. */
80
+ export function encodeMlsDmContent(content) {
81
+ let body;
82
+ if (content.type === "message") {
83
+ body = { type: content.type, id: content.id, text: content.text };
84
+ if (content.replyTo !== undefined)
85
+ body.replyTo = content.replyTo;
86
+ if (content.attachments && Object.keys(content.attachments).length > 0)
87
+ body.attachments = content.attachments;
88
+ }
89
+ else if (content.type === "edit") {
90
+ body = { type: content.type, id: content.id, text: content.text };
91
+ }
92
+ else if (content.type === "reaction") {
93
+ body = { type: content.type, id: content.id, emoji: content.emoji, action: content.action };
94
+ }
95
+ else {
96
+ body = { type: content.type, id: content.id };
97
+ }
98
+ const bytes = new TextEncoder().encode(JSON.stringify({ v: MLS_DM_CONTENT_VERSION, ...body }));
99
+ if (!decodeMlsDmContent(bytes))
100
+ throw new Error("That content wouldn't read back, so it isn't sent.");
101
+ return bytes;
102
+ }
103
+ /** Null for anything this version can't read: a newer version, a new type, or junk. */
104
+ export function decodeMlsDmContent(bytes) {
105
+ return fromRaw(parse(bytes));
106
+ }
107
+ /**
108
+ * Like `decodeMlsDmContent`, but tells content from a newer app ("newer", skip it quietly)
109
+ * apart from junk (null, which is worth counting). A known type with a bad field is junk.
110
+ */
111
+ export function readMlsDmContent(bytes) {
112
+ const raw = parse(bytes);
113
+ return fromRaw(raw) ?? (isNewer(raw) ? "newer" : null);
114
+ }
115
+ const KNOWN_TYPES = new Set(["message", "edit", "delete", "reaction"]);
116
+ const TYPE_NAME = /^[a-z][a-z0-9_]{0,31}$/;
117
+ /** A later version, or a type this one doesn't know that names its message the way ours do. */
118
+ function isNewer(raw) {
119
+ if (!isObject(raw) || typeof raw.type !== "string" || !TYPE_NAME.test(raw.type))
120
+ return false;
121
+ if (raw.v === MLS_DM_CONTENT_VERSION)
122
+ return !KNOWN_TYPES.has(raw.type) && isId(raw.id);
123
+ return typeof raw.v === "number" && Number.isSafeInteger(raw.v) && raw.v > MLS_DM_CONTENT_VERSION;
124
+ }
125
+ function parse(bytes) {
126
+ try {
127
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ function fromRaw(raw) {
134
+ if (!isObject(raw) || raw.v !== MLS_DM_CONTENT_VERSION || !isId(raw.id))
135
+ return null;
136
+ const { type, id, text } = raw;
137
+ if (type === "message" && isText(text)) {
138
+ const out = { type, id, text };
139
+ if (raw.replyTo !== undefined) {
140
+ if (!isId(raw.replyTo))
141
+ return null;
142
+ out.replyTo = raw.replyTo;
143
+ }
144
+ if (raw.attachments !== undefined) {
145
+ const attachments = readAttachments(raw.attachments);
146
+ if (!attachments)
147
+ return null;
148
+ out.attachments = attachments;
149
+ }
150
+ return out;
151
+ }
152
+ if (type === "edit" && isText(text))
153
+ return { type, id, text };
154
+ if (type === "delete")
155
+ return { type, id };
156
+ if (type === "reaction" && isReactionEmoji(raw.emoji) && (raw.action === "add" || raw.action === "remove")) {
157
+ return { type, id, emoji: raw.emoji, action: raw.action };
158
+ }
159
+ return null;
160
+ }
@@ -1,5 +1,5 @@
1
1
  import type { MlsDmDriver, MlsDmDriverOptions, MlsRefusal } from "./interfaces.js";
2
- export type MlsDriverErrorCode = "refused" | "no_device" | "waiting_for_welcome" | "not_in_group" | "unexpected_member" | "peer_unverified" | "commit_retries";
2
+ export type MlsDriverErrorCode = "refused" | "no_device" | "waiting_for_welcome" | "not_in_group" | "unexpected_member" | "peer_unverified" | "too_many_attachments" | "commit_retries";
3
3
  export declare class MlsDriverError extends Error {
4
4
  code: MlsDriverErrorCode;
5
5
  /** The server's refusal, when there was one. */
@@ -2,7 +2,12 @@ import { addMlsMembers, asIdentityScope, base64UrlDecode, createMlsGroup, decode
2
2
  /** Five devices per person per server (design, section 1). The server holds the same line. */
3
3
  const MLS_MAX_DEVICES_PER_PERSON = 5;
4
4
  const COMMIT_ATTEMPTS = 5;
5
- const LOG_PAGE = 200;
5
+ /** socket.io closes the connection on a packet with more than 10 binary parts (GRYT-1522). */
6
+ const MAX_BINARY_PARTS = 10;
7
+ const LOG_PAGE = MAX_BINARY_PARTS;
8
+ const PUBLISH_BATCH = MAX_BINARY_PARTS - 1;
9
+ /** The server's cap on files in one message. */
10
+ const MAX_ATTACHMENT_IDS = 10;
6
11
  const WELCOME_WAIT_MS = 10_000;
7
12
  /** The server keeps Welcomes 30 days; a last-resort package is replaced with a week to go. */
8
13
  const WELCOME_KEEP_S = 30 * 86_400;
@@ -131,14 +136,20 @@ export function createMlsDmDriver(options) {
131
136
  const last = needLastResort ? await make(true) : undefined;
132
137
  // The private halves are stored first, so a Welcome can never name one this device lost.
133
138
  await store.putKeyPackages(last ? [...fresh, last] : fresh);
134
- const r = await transport.publishKeyPackages({
135
- deviceId: d.deviceId,
136
- keyPackages: fresh.map((k) => k.keyPackage),
137
- lastResort: last?.keyPackage,
138
- });
139
- if (!r.ok)
140
- throw refused("Publishing KeyPackages", r);
141
- registered = true;
139
+ // Nine at a time, and the last-resort one rides with the last batch.
140
+ const all = fresh.map((k) => k.keyPackage);
141
+ for (let i = 0; i === 0 || i < all.length; i += PUBLISH_BATCH) {
142
+ const keyPackages = all.slice(i, i + PUBLISH_BATCH);
143
+ const final = i + PUBLISH_BATCH >= all.length;
144
+ const r = await transport.publishKeyPackages({
145
+ deviceId: d.deviceId,
146
+ keyPackages,
147
+ lastResort: final ? last?.keyPackage : undefined,
148
+ });
149
+ if (!r.ok)
150
+ throw refused("Publishing KeyPackages", r);
151
+ registered = true;
152
+ }
142
153
  }
143
154
  async function syncAndTopUp(forceLastResort = false) {
144
155
  const d = await ownDevice();
@@ -411,8 +422,7 @@ export function createMlsDmDriver(options) {
411
422
  async function ensureOpen(conversationId) {
412
423
  if ((await run(conversationId, () => openGroup(conversationId))) === "open")
413
424
  return;
414
- for (const w of await syncAndTopUp())
415
- await handleWelcome(w);
425
+ await takeWaitingWelcomes();
416
426
  if (!(await store.loadGroup(conversationId)))
417
427
  await waitForWelcome(conversationId);
418
428
  }
@@ -457,27 +467,56 @@ export function createMlsDmDriver(options) {
457
467
  await catchUp(open);
458
468
  return { result: "joined", usedLastResort: kp.lastResort };
459
469
  }
460
- async function handleWelcome(w) {
470
+ /** "retry" when it's kept for later, else whether it used a last-resort package. */
471
+ async function takeWelcome(w) {
461
472
  const d = await ownDevice();
462
473
  if (w.deviceId !== d.deviceId || !w.conversationId)
463
- return;
474
+ return "retry";
464
475
  const conversationId = w.conversationId;
465
476
  const { result, usedLastResort } = await run(conversationId, () => joinFrom(w));
466
477
  if (result === "retry")
467
- return;
478
+ return "retry";
468
479
  await transport.ackWelcomes({ deviceId: d.deviceId, welcomeIds: [w.welcomeId] });
469
480
  if (result !== "joined")
470
- return;
481
+ return "done";
471
482
  reconciled.delete(conversationId);
472
483
  events.onJoined?.({ conversationId, groupId: w.groupId });
473
484
  for (const done of welcomeWaiters.get(conversationId) ?? [])
474
485
  done();
475
486
  welcomeWaiters.delete(conversationId);
487
+ return usedLastResort ? "used_last_resort" : "done";
488
+ }
489
+ async function handleWelcome(w) {
476
490
  // One package was used up, and a used last-resort one gets replaced.
477
- await syncAndTopUp(usedLastResort);
491
+ const outcome = await takeWelcome(w);
492
+ if (outcome !== "retry")
493
+ await syncAndTopUp(outcome === "used_last_resort");
494
+ }
495
+ /* Sync until nothing waiting can be acted on: a server may hand out ten Welcomes a time,
496
+ and each one taken is acked, so the next sync brings the rest (GRYT-1528). */
497
+ async function takeWaitingWelcomes() {
498
+ let lastResortUsed = false;
499
+ for (let round = 0; round < 20; round++) {
500
+ const welcomes = await syncAndTopUp(lastResortUsed);
501
+ lastResortUsed = false;
502
+ let taken = 0;
503
+ for (const w of welcomes) {
504
+ const outcome = await takeWelcome(w);
505
+ if (outcome !== "retry")
506
+ taken += 1;
507
+ if (outcome === "used_last_resort")
508
+ lastResortUsed = true;
509
+ }
510
+ if (taken === 0)
511
+ return;
512
+ }
478
513
  }
479
514
  // ── What the app calls ──────────────────────────────────────────────
480
- async function send(conversationId, peer, plaintext) {
515
+ async function send(conversationId, peer, plaintext, options = {}) {
516
+ const attachmentIds = options.attachmentIds?.length ? [...options.attachmentIds] : undefined;
517
+ if (attachmentIds && attachmentIds.length > MAX_ATTACHMENT_IDS) {
518
+ throw new MlsDriverError("too_many_attachments", `At most ${MAX_ATTACHMENT_IDS} files go with one message.`);
519
+ }
481
520
  if (!registered)
482
521
  throw new MlsDriverError("no_device", "This device isn't registered for MLS here. Call start() first.");
483
522
  await ensureOpen(conversationId);
@@ -500,7 +539,7 @@ export function createMlsDmDriver(options) {
500
539
  open.state = out.state;
501
540
  // The generation is used up whether or not the send arrives, so the state is saved first.
502
541
  await save(open);
503
- const r = await transport.send({ conversationId, deviceId: d.deviceId, message: out.message });
542
+ const r = await transport.send({ conversationId, deviceId: d.deviceId, message: out.message, attachmentIds });
504
543
  if (!r.ok)
505
544
  throw refused("Sending", r);
506
545
  return { seq: r.seq };
@@ -559,8 +598,7 @@ export function createMlsDmDriver(options) {
559
598
  await ownDevice();
560
599
  if (!capability)
561
600
  return;
562
- for (const w of await syncAndTopUp())
563
- await handleWelcome(w);
601
+ await takeWaitingWelcomes();
564
602
  reconciled.clear();
565
603
  for (const rec of await store.listGroups()) {
566
604
  await run(rec.conversationId, async () => {
@@ -93,10 +93,12 @@ export interface MlsTransport {
93
93
  seq: number;
94
94
  epoch: number;
95
95
  }>>;
96
+ /** `attachmentIds` are the uploads the message carries, so the server can tie them to it. */
96
97
  send(req: {
97
98
  conversationId: string;
98
99
  deviceId: string;
99
100
  message: Uint8Array;
101
+ attachmentIds?: string[];
100
102
  }): Promise<MlsReply<{
101
103
  seq: number;
102
104
  }>>;
@@ -260,7 +262,9 @@ export interface MlsDmDriver {
260
262
  start(): Promise<void>;
261
263
  modeFor(conversationId: string, peerServerUserId: string): Promise<DmSealingMode>;
262
264
  /** Opens the group if need be, adds or removes devices that changed, then sends. */
263
- send(conversationId: string, peerServerUserId: string, plaintext: Uint8Array): Promise<{
265
+ send(conversationId: string, peerServerUserId: string, plaintext: Uint8Array, options?: {
266
+ attachmentIds?: readonly string[];
267
+ }): Promise<{
264
268
  seq: number;
265
269
  }>;
266
270
  handleMessage(entry: MlsLogEntry): Promise<void>;
@@ -0,0 +1,13 @@
1
+ export interface MlsReaction {
2
+ src: string;
3
+ amount: number;
4
+ users: string[];
5
+ }
6
+ /** Adding twice or removing what isn't there changes nothing, so replays and reorders are safe. */
7
+ export declare function applyMlsReaction(reactions: readonly MlsReaction[] | null | undefined, change: {
8
+ emoji: string;
9
+ userId: string;
10
+ action: "add" | "remove";
11
+ }): MlsReaction[];
12
+ /** What a tap on a reaction chip means for this person: take theirs off, or put it on. */
13
+ export declare function mlsReactionAction(reactions: readonly MlsReaction[] | null | undefined, emoji: string, userId: string): "add" | "remove";
@@ -0,0 +1,26 @@
1
+ /* Reactions on an MLS message live only in each device's archive. One reducer for both apps,
2
+ in the `{ src, amount, users }` shape the server sends for every other message. */
3
+ /** Adding twice or removing what isn't there changes nothing, so replays and reorders are safe. */
4
+ export function applyMlsReaction(reactions, change) {
5
+ const { emoji, userId, action } = change;
6
+ const list = (reactions ?? []).map((r) => ({ src: r.src, amount: r.users.length, users: [...r.users] }));
7
+ const existing = list.find((r) => r.src === emoji);
8
+ if (action === "add") {
9
+ if (!existing)
10
+ list.push({ src: emoji, amount: 1, users: [userId] });
11
+ else if (!existing.users.includes(userId)) {
12
+ existing.users.push(userId);
13
+ existing.amount = existing.users.length;
14
+ }
15
+ return list;
16
+ }
17
+ if (existing) {
18
+ existing.users = existing.users.filter((u) => u !== userId);
19
+ existing.amount = existing.users.length;
20
+ }
21
+ return list.filter((r) => r.amount > 0);
22
+ }
23
+ /** What a tap on a reaction chip means for this person: take theirs off, or put it on. */
24
+ export function mlsReactionAction(reactions, emoji, userId) {
25
+ return reactions?.some((r) => r.src === emoji && r.users.includes(userId)) ? "remove" : "add";
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Gryt's shared application logic: one implementation of the things the desktop and mobile apps both do.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {