@gryt/core 0.16.2 → 0.17.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.
@@ -264,6 +264,15 @@ export class FakeDeliveryService {
264
264
  this.welcomes = this.welcomes.filter((w) => !(w.serverUserId === me && w.deviceId === deviceId && welcomeIds.includes(w.welcomeId)));
265
265
  return { ok: true, deleted: before - this.welcomes.length };
266
266
  },
267
+ requestAdd: async ({ conversationId, deviceId }) => {
268
+ await tick();
269
+ if (!member(conversationId))
270
+ return fail("not_found", "No such conversation.");
271
+ if (!this.isDevice(me, deviceId))
272
+ return fail("unknown_device", "Publish KeyPackages from this device first.");
273
+ this.push(this.conversations.get(conversationId), (d) => d.handleAddRequest({ conversationId, serverUserId: me, deviceId }));
274
+ return { ok: true };
275
+ },
267
276
  };
268
277
  // socket.io-parser closes the connection on a packet with more than ten binary parts.
269
278
  const guarded = {};
@@ -296,8 +305,13 @@ export class FakeDeliveryService {
296
305
  return guarded;
297
306
  }
298
307
  checkParts(socket, event, value) {
299
- const count = (v) => v instanceof Uint8Array ? 1 : Array.isArray(v) ? v.reduce((n, x) => n + count(x), 0)
300
- : v && typeof v === "object" ? Object.values(v).reduce((n, x) => n + count(x), 0) : 0;
308
+ const count = (v) => {
309
+ if (v instanceof Uint8Array)
310
+ return 1;
311
+ if (v && typeof v === "object")
312
+ return Object.values(v).reduce((n, x) => n + count(x), 0);
313
+ return 0;
314
+ };
301
315
  const parts = count(value);
302
316
  this.stats.mostBinaryParts = Math.max(this.stats.mostBinaryParts, parts);
303
317
  if (parts > 10) {
@@ -9,6 +9,10 @@ const PUBLISH_BATCH = MAX_BINARY_PARTS - 1;
9
9
  /** The server's cap on files in one message. */
10
10
  const MAX_ATTACHMENT_IDS = 10;
11
11
  const WELCOME_WAIT_MS = 10_000;
12
+ /** How often a device out of a group asks to be added, unless the person asks. */
13
+ const ASK_AGAIN_MS = 60_000;
14
+ /** A message can beat the Welcome that comes with it, so a missing group is only news after this. */
15
+ const NOT_ADDED_GRACE_MS = 3_000;
12
16
  /** The same cap as a message that isn't MLS: after this a send fails and the text goes back. */
13
17
  const SEND_GIVE_UP_MS = 5 * 60_000;
14
18
  /** A send with no answer goes again after this, or sooner if the connection comes back. */
@@ -85,6 +89,7 @@ export function createMlsDmDriver(options) {
85
89
  };
86
90
  }
87
91
  const raw = options.transport;
92
+ const rawRequestAdd = raw.requestAdd?.bind(raw);
88
93
  const transport = {
89
94
  publishKeyPackages: stopOnRemoval((req) => raw.publishKeyPackages(req)),
90
95
  claimKeyPackages: stopOnRemoval((req) => raw.claimKeyPackages(req)),
@@ -96,6 +101,7 @@ export function createMlsDmDriver(options) {
96
101
  fetchLog: stopOnRemoval((req) => raw.fetchLog(req)),
97
102
  sync: stopOnRemoval((req) => raw.sync(req)),
98
103
  ackWelcomes: stopOnRemoval((req) => raw.ackWelcomes(req)),
104
+ ...(rawRequestAdd ? { requestAdd: stopOnRemoval(rawRequestAdd) } : {}),
99
105
  };
100
106
  const scope = asIdentityScope(options.scope);
101
107
  const trusts = new Map();
@@ -116,6 +122,11 @@ export function createMlsDmDriver(options) {
116
122
  const reconciled = new Set();
117
123
  const seen = new Set();
118
124
  const welcomeWaiters = new Map();
125
+ /** Conversations with a Welcome for this device that it couldn't join yet, most likely an unpinned member. */
126
+ const unjoinable = new Set();
127
+ const asked = new Map();
128
+ /** Groups this device was taken out of here. It doesn't ask its way back into those. */
129
+ const left = new Set();
119
130
  const flights = new Set();
120
131
  let waitingForServer = false;
121
132
  let stopped = false;
@@ -269,6 +280,7 @@ export function createMlsDmDriver(options) {
269
280
  const r = await processMlsMessage(open.state, e.data);
270
281
  if (r.kind === "handshake" && r.removed) {
271
282
  await store.deleteGroup(open.rec.conversationId);
283
+ left.add(open.rec.conversationId);
272
284
  events.onGroupLost?.({ conversationId: open.rec.conversationId, reason: "removed" });
273
285
  return false;
274
286
  }
@@ -514,12 +526,38 @@ export function createMlsDmDriver(options) {
514
526
  welcomeWaiters.set(conversationId, [...(welcomeWaiters.get(conversationId) ?? []), done]);
515
527
  });
516
528
  }
529
+ /* Only a member can add a device, so one that's out asks the members' apps to. An old
530
+ server doesn't answer, which is why nothing waits on it. */
531
+ async function askToBeAdded(conversationId, force = false) {
532
+ events.onGroupLost?.({ conversationId, reason: "not_added" });
533
+ if (!transport.requestAdd || (!force && Date.now() - (asked.get(conversationId) ?? 0) < ASK_AGAIN_MS))
534
+ return;
535
+ asked.set(conversationId, Date.now());
536
+ const r = await transport.requestAdd({ conversationId, deviceId: (await ownDevice()).deviceId });
537
+ if (!r.ok)
538
+ throw refused("Asking to be added", r);
539
+ }
540
+ function notAddedLater(conversationId) {
541
+ setTimeout(() => {
542
+ if (stopped || left.has(conversationId) || unjoinable.has(conversationId))
543
+ return;
544
+ void store
545
+ .loadGroup(conversationId)
546
+ .then((rec) => (rec ? undefined : askToBeAdded(conversationId)))
547
+ .catch(() => undefined);
548
+ }, NOT_ADDED_GRACE_MS);
549
+ }
517
550
  async function ensureOpen(conversationId) {
518
551
  if ((await run(conversationId, () => openGroup(conversationId))) === "open")
519
552
  return;
520
553
  await takeWaitingWelcomes();
521
- if (!(await store.loadGroup(conversationId)))
522
- await waitForWelcome(conversationId);
554
+ if (await store.loadGroup(conversationId))
555
+ return;
556
+ if (unjoinable.has(conversationId)) {
557
+ throw new MlsDriverError("peer_unverified", "This device was added, and a member's key isn't pinned here to join with.");
558
+ }
559
+ void askToBeAdded(conversationId).catch(() => undefined);
560
+ await waitForWelcome(conversationId);
523
561
  }
524
562
  /** "joined", "not_ours" (ack it and forget it), or "retry" (keep it for next time). */
525
563
  async function joinFrom(w) {
@@ -569,12 +607,16 @@ export function createMlsDmDriver(options) {
569
607
  return "retry";
570
608
  const conversationId = w.conversationId;
571
609
  const { result, usedLastResort } = await run(conversationId, () => joinFrom(w));
572
- if (result === "retry")
610
+ if (result === "retry") {
611
+ unjoinable.add(conversationId);
573
612
  return "retry";
613
+ }
574
614
  await transport.ackWelcomes({ deviceId: d.deviceId, welcomeIds: [w.welcomeId] });
575
615
  if (result !== "joined")
576
616
  return "done";
577
617
  reconciled.delete(conversationId);
618
+ unjoinable.delete(conversationId);
619
+ left.delete(conversationId);
578
620
  events.onJoined?.({ conversationId, groupId: w.groupId });
579
621
  for (const done of welcomeWaiters.get(conversationId) ?? [])
580
622
  done();
@@ -702,7 +744,13 @@ export function createMlsDmDriver(options) {
702
744
  async function handleMessage(entry) {
703
745
  await run(entry.conversationId, async () => {
704
746
  const open = await load(entry.conversationId);
705
- if (!open || entry.seq <= open.rec.cursor)
747
+ if (!open) {
748
+ // A message for a group this device isn't in: without this it would show nothing.
749
+ if (entry.kind === "application" && entry.senderDeviceId !== (await ownDevice()).deviceId)
750
+ notAddedLater(entry.conversationId);
751
+ return;
752
+ }
753
+ if (entry.seq <= open.rec.cursor)
706
754
  return;
707
755
  if (entry.seq === open.rec.cursor + 1 && entry.groupId === open.rec.groupId)
708
756
  await applyEntry(open, entry);
@@ -730,6 +778,20 @@ export function createMlsDmDriver(options) {
730
778
  });
731
779
  }
732
780
  }
781
+ /** Adds whatever is missing, as a send would. Who asked only says which group to look at. */
782
+ async function handleAddRequest(push) {
783
+ if (push.deviceId === (await ownDevice()).deviceId)
784
+ return;
785
+ await run(push.conversationId, async () => {
786
+ const open = await load(push.conversationId);
787
+ if (!open || !(await catchUp(open)))
788
+ return;
789
+ const peer = (await peopleIn(push.conversationId, open.state)).find((p) => !!p && p !== self);
790
+ if (!peer || (push.serverUserId !== self && push.serverUserId !== peer))
791
+ return;
792
+ await reconcile(open, peer, true);
793
+ });
794
+ }
733
795
  /** Names from certificates under your own person key: this device's, your leaves in each group,
734
796
  then the last-resort KeyPackage the server lists with each of your devices. */
735
797
  async function ownDeviceNames(d, listed) {
@@ -921,6 +983,8 @@ export function createMlsDmDriver(options) {
921
983
  handleMessage,
922
984
  handleWelcome,
923
985
  handleDevicesChanged,
986
+ handleAddRequest,
987
+ requestAdd: (conversationId) => askToBeAdded(conversationId, true),
924
988
  ownDevices,
925
989
  async removeOwnDevice(deviceId) {
926
990
  const r = await transport.removeDevice({ deviceId });
@@ -140,6 +140,12 @@ export interface MlsTransport {
140
140
  }): Promise<MlsReply<{
141
141
  deleted: number;
142
142
  }>>;
143
+ /** This device isn't in that conversation's group: the server asks the members' apps to add it.
144
+ Optional, since a server from before GRYT-1858 doesn't take it. */
145
+ requestAdd?(req: {
146
+ conversationId: string;
147
+ deviceId: string;
148
+ }): Promise<MlsReply<object>>;
143
149
  }
144
150
  /** `server:info.mls`. Absent means the server can't do MLS. */
145
151
  export interface MlsServerCapability {
@@ -237,7 +243,7 @@ export interface MlsDmEvents {
237
243
  /** This device is out of the group: removed, or its state couldn't keep up. */
238
244
  onGroupLost?(info: {
239
245
  conversationId: string;
240
- reason: "removed" | "out_of_sync" | "gap";
246
+ reason: "removed" | "out_of_sync" | "gap" | "not_added";
241
247
  }): void;
242
248
  onJoined?(info: {
243
249
  conversationId: string;
@@ -335,6 +341,14 @@ export interface MlsDmDriver {
335
341
  handleDevicesChanged(push: {
336
342
  serverUserId: string;
337
343
  }): Promise<void>;
344
+ /** `mls:add:requested`: a device of somebody in the conversation asked to be added. */
345
+ handleAddRequest(push: {
346
+ conversationId: string;
347
+ serverUserId: string;
348
+ deviceId: string;
349
+ }): Promise<void>;
350
+ /** Asks the other members' apps to add this device, as the driver does when it finds it's out. */
351
+ requestAdd(conversationId: string): Promise<void>;
338
352
  /** Oldest first, as the server lists them. */
339
353
  ownDevices(): Promise<MlsOwnDevice[]>;
340
354
  /** Your own devices only. Every group you share drops it on the next pass. */
@@ -54,6 +54,12 @@ export function createApproverPairing(options) {
54
54
  await closeQuietly();
55
55
  };
56
56
  const fail = (e) => (abort.signal.aborted ? undefined : end(reasonFor(e)));
57
+ // Only reached past "confirming", which claim() enters with all three set.
58
+ const confirmed = () => {
59
+ if (!current?.session || !device)
60
+ throw new Error("Nothing confirmed yet.");
61
+ return { ...current, session: current.session, device };
62
+ };
57
63
  async function claim(input) {
58
64
  let by;
59
65
  let scanned;
@@ -122,7 +128,7 @@ export function createApproverPairing(options) {
122
128
  const res = await fetch(`${issuer.replace(/\/+$/, "")}/gryt-pairing/approve`, {
123
129
  method: "POST",
124
130
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
125
- body: JSON.stringify({ user_code: userCode, binding: current.session.keycloakNonce }),
131
+ body: JSON.stringify({ user_code: userCode, binding: confirmed().session.keycloakNonce }),
126
132
  }).catch(() => {
127
133
  throw new PairingEnded("approve:network");
128
134
  });
@@ -143,7 +149,7 @@ export function createApproverPairing(options) {
143
149
  for (let tries = 0;; tries++) {
144
150
  try {
145
151
  added[host] = await driver.addOwnDevice(deviceId, {
146
- onProgress: ({ done, total }) => set({ phase: "adding", device: device, host, done, total }),
152
+ onProgress: ({ done, total }) => set({ phase: "adding", device: confirmed().device, host, done, total }),
147
153
  });
148
154
  break;
149
155
  }
@@ -168,7 +174,7 @@ export function createApproverPairing(options) {
168
174
  return snap;
169
175
  }
170
176
  function startHistory(archive) {
171
- const { id, token, session } = current;
177
+ const { id, token, session } = confirmed();
172
178
  return createHistorySender({
173
179
  relay, id, token, archive, clock, signal: abort.signal,
174
180
  post: (message) => relay.post(id, token, { type: "sealed", body: sealJson(session, message) }),
@@ -176,19 +182,20 @@ export function createApproverPairing(options) {
176
182
  });
177
183
  }
178
184
  async function approve(envelope, accessToken) {
179
- const { id, token, box, session } = current;
180
- set({ phase: envelope.account ? "signing_in" : "waiting_ready", device: device });
185
+ const { id, token, box, session, device } = confirmed();
186
+ set({ phase: envelope.account ? "signing_in" : "waiting_ready", device });
181
187
  let sender = null;
182
188
  let snap = new Map();
189
+ let sealed = envelope;
183
190
  if (options.history) {
184
191
  // Recording starts before the positions are read, so nothing lands between the two unseen.
185
192
  tail.start();
186
193
  snap = await positions(envelope);
187
194
  sender = startHistory(options.history);
188
195
  const chunks = await sender.forEnvelope(Math.min(clock.now() + ENVELOPE_BUDGET_MS, deadline - 10_000));
189
- envelope = { ...envelope, history: { key: sender.key, manifest: { v: 1, chunks } } };
196
+ sealed = { ...envelope, history: { key: sender.key, manifest: { v: 1, chunks } } };
190
197
  }
191
- await relay.post(id, token, { type: "sealed", body: base64Url(session.seal(encodePairingEnvelope(envelope))) });
198
+ await relay.post(id, token, { type: "sealed", body: base64Url(session.seal(encodePairingEnvelope(sealed))) });
192
199
  if (envelope.account && accessToken) {
193
200
  const message = await box.next(abort.signal, clock.now() + approvalMs);
194
201
  if (!message)
@@ -196,27 +203,27 @@ export function createApproverPairing(options) {
196
203
  const userCode = String(openJson(session, message, "user_code").userCode ?? "");
197
204
  if (!(await approveExtension(envelope.account.issuer, userCode, accessToken))) {
198
205
  const url = `${envelope.account.issuer.replace(/\/+$/, "")}/device?user_code=${encodeURIComponent(userCode)}`;
199
- set({ phase: "browser", device: device, url });
206
+ set({ phase: "browser", device, url });
200
207
  }
201
208
  }
202
209
  if (state.phase === "signing_in")
203
- set({ phase: "waiting_ready", device: device });
210
+ set({ phase: "waiting_ready", device });
204
211
  const ready = openJson(session, await box.next(abort.signal), "ready");
205
212
  const list = Array.isArray(ready.devices) ? ready.devices : [];
206
213
  const named = list.filter((d) => typeof d?.host === "string" && typeof d?.deviceId === "string");
207
214
  sender?.startPaging();
208
215
  const added = await addEverywhere(named);
209
216
  if (!sender) {
210
- set({ phase: "done", device: device, added });
217
+ set({ phase: "done", device, added });
211
218
  session.close();
212
219
  return void (await closeQuietly());
213
220
  }
214
- set({ phase: "sending", device: device, added });
221
+ set({ phase: "sending", device, added });
215
222
  await sender.send(tail.take(snap, added), false);
216
223
  await clock.sleep(options.lateWindowMs ?? HISTORY_LATE_WINDOW_MS, abort.signal);
217
224
  await sender.send(tail.take(snap, added), true);
218
225
  // N closes the session once it has every chunk; closing here would delete them.
219
- set({ phase: "done", device: device, added });
226
+ set({ phase: "done", device, added });
220
227
  session.close();
221
228
  }
222
229
  return {
@@ -8,7 +8,7 @@ export declare class PairingEnded extends Error {
8
8
  }
9
9
  export declare const systemClock: PairingClock;
10
10
  export declare function reasonFor(error: unknown): PairingEndReason;
11
- export declare function sealJson(session: PairingSession, value: Record<string, unknown>): string;
11
+ export declare function sealJson(session: PairingSession, value: object): string;
12
12
  /** The next sealed message as JSON with a `t`, or a throw that ends the pairing as tampered. */
13
13
  export declare function openJson(session: PairingSession, message: RelayMessage | null, t?: string): Record<string, unknown>;
14
14
  /** The other side's messages in order, long-polling the relay. `next` gives null once `deadline` passes. */
@@ -66,8 +66,10 @@ export function mailbox(relay, id, token, clock) {
66
66
  return null;
67
67
  try {
68
68
  for (const m of await relay.poll(id, token, after, Math.min(25, Math.ceil(left / 1000)), signal)) {
69
- if (m.seq > after)
70
- (queue.push(m), (after = m.seq));
69
+ if (m.seq > after) {
70
+ queue.push(m);
71
+ after = m.seq;
72
+ }
71
73
  }
72
74
  failures = 0;
73
75
  }
@@ -130,7 +130,7 @@ export function createHistorySender(options) {
130
130
  const listed = unlisted.splice(0, HISTORY_ENVELOPE_ENTRIES);
131
131
  progress.listed += listed.length;
132
132
  report();
133
- return listed.map(({ n: _n, ...entry }) => entry);
133
+ return listed.map(({ n, ...entry }) => entry);
134
134
  },
135
135
  /** After N's "ready": list what's waiting, and keep listing as pages fill. */
136
136
  startPaging() {
@@ -2,7 +2,11 @@ const DAY_MS = 24 * 60 * 60 * 1000;
2
2
  export const dayOf = (sentAt) => Math.floor(sentAt / DAY_MS);
3
3
  /** Negative when `a` is newer: by time, then by message id, the same order the archive pages in. */
4
4
  export function newerFirst(a, b) {
5
- return b.sentAt - a.sentAt || (a.messageId < b.messageId ? 1 : a.messageId > b.messageId ? -1 : 0);
5
+ if (a.sentAt !== b.sentAt)
6
+ return b.sentAt - a.sentAt;
7
+ if (a.messageId === b.messageId)
8
+ return 0;
9
+ return a.messageId < b.messageId ? 1 : -1;
6
10
  }
7
11
  /** A's archive one UTC day at a time, newest day first across every conversation and server. */
8
12
  export async function* archiveByDay(archive, pageSize = 500) {
@@ -90,8 +90,14 @@ export function createNewDevicePairing(options) {
90
90
  throw new PairingEnded(e instanceof Error && /newer version/.test(e.message) ? "newer_version" : "tampered");
91
91
  }
92
92
  }
93
+ // attempt() sets both before signIn, run's history step or linked can run.
94
+ const paired = () => {
95
+ if (!current?.session)
96
+ throw new Error("No pairing session yet.");
97
+ return { ...current, session: current.session };
98
+ };
93
99
  async function signIn(account, from) {
94
- const { id, token, box, session } = current;
100
+ const { id, token, box, session } = paired();
95
101
  set({ phase: "signing_in", username: account.username, from });
96
102
  const verifier = base64Url(randomBytes(32));
97
103
  const auth = await oidc.deviceAuthorization({
@@ -151,7 +157,7 @@ export function createNewDevicePairing(options) {
151
157
  const tokens = envelope.account ? await signIn(envelope.account, envelope.from) : null;
152
158
  await storage.commit(envelope, tokens);
153
159
  if (envelope.history && options.history) {
154
- const { id, token } = current;
160
+ const { id, token } = paired();
155
161
  receiver = createHistoryReceiver({
156
162
  relay, id, token, key: envelope.history.key, sink: options.history, clock, signal: abort.signal, onProgress: setHistory,
157
163
  });
@@ -166,7 +172,7 @@ export function createNewDevicePairing(options) {
166
172
  }
167
173
  /** Reads A's history messages until every chunk is in, or A closing is the finish. */
168
174
  async function linked(from) {
169
- const { box, session } = current;
175
+ const { box, session } = paired();
170
176
  const stop = new AbortController();
171
177
  const onAbort = () => stop.abort();
172
178
  abort.signal.addEventListener("abort", onAbort, { once: true });
@@ -45,7 +45,10 @@ export interface Report {
45
45
  };
46
46
  /** The tail of the app's own log, where it keeps one. */
47
47
  logs?: string[];
48
- extra?: Record<string, unknown>;
48
+ extra?: {
49
+ embeddedServer?: boolean;
50
+ embeddedServerVersion?: string;
51
+ };
49
52
  }
50
53
  /**
51
54
  * What an app knows about itself when somebody opens the form. Passed in rather than read
@@ -120,11 +120,7 @@ export function describeAttached(report) {
120
120
  ? `v${report.app.version}${report.app.build ? ` (${report.app.build})` : ""}`
121
121
  : undefined);
122
122
  add("Channel", report.app?.channel);
123
- add("System", device?.platform && device?.osVersion
124
- ? `${platformLabel(device.platform)} ${device.osVersion}`
125
- : device?.platform
126
- ? platformLabel(device.platform)
127
- : undefined);
123
+ add("System", device?.platform ? [platformLabel(device.platform), device.osVersion].filter(Boolean).join(" ") : undefined);
128
124
  add("Simulator", device?.isEmulator ? "yes" : undefined);
129
125
  add("Electron", report.runtime?.electronVersion);
130
126
  add("Chrome", report.runtime?.chromeVersion);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.16.2",
3
+ "version": "0.17.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": {
@@ -24,16 +24,20 @@
24
24
  "dist"
25
25
  ],
26
26
  "scripts": {
27
- "build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node scripts/add-esm-extensions.mjs",
28
- "typecheck": "tsc -p tsconfig.json --noEmit",
29
- "test:comment-length": "node scripts/check-comment-length.mjs",
27
+ "build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node scripts/add-esm-extensions.ts",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p scripts",
29
+ "lint": "eslint --flag unstable_native_nodejs_ts_config .",
30
+ "test:comment-length": "node scripts/check-comment-length.ts",
30
31
  "test": "node --test \"src/**/*.test.ts\"",
31
- "check-surface": "node scripts/check-public-surface.mjs",
32
+ "check-surface": "node scripts/check-public-surface.ts",
32
33
  "prepublishOnly": "npm run build && npm run test && npm run check-surface"
33
34
  },
34
35
  "devDependencies": {
36
+ "@eslint/js": "^9.39.5",
35
37
  "@types/node": "^20.19.33",
36
- "typescript": "^5.9.3"
38
+ "eslint": "^9.39.5",
39
+ "typescript": "^5.9.3",
40
+ "typescript-eslint": "^8.71.2"
37
41
  },
38
42
  "publishConfig": {
39
43
  "access": "public"