@gryt/core 0.14.0 → 0.16.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.
@@ -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
+ /** The same cap as a message that isn't MLS: after this a send fails and the text goes back. */
13
+ const SEND_GIVE_UP_MS = 5 * 60_000;
14
+ /** A send with no answer goes again after this, or sooner if the connection comes back. */
15
+ const RESEND_AFTER_MS = 2_000;
12
16
  /** The server keeps Welcomes 30 days; a last-resort package is replaced with a week to go. */
13
17
  const WELCOME_KEEP_S = 30 * 86_400;
14
18
  const RENEW_BEFORE_S = 7 * 86_400;
@@ -26,6 +30,11 @@ export class MlsDriverError extends Error {
26
30
  function refused(what, r) {
27
31
  return new MlsDriverError("refused", `${what}: ${r.error} (${r.message})`, r);
28
32
  }
33
+ /** A refusal that says nothing about the request, only that the server couldn't be reached. */
34
+ function unreachable(r) {
35
+ const error = r instanceof MlsDriverError ? r.refusal?.error : r?.error;
36
+ return error === "offline" || error === "timeout" ? error : null;
37
+ }
29
38
  function toHex(bytes) {
30
39
  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
31
40
  }
@@ -107,6 +116,35 @@ export function createMlsDmDriver(options) {
107
116
  const reconciled = new Set();
108
117
  const seen = new Set();
109
118
  const welcomeWaiters = new Map();
119
+ const flights = new Set();
120
+ let waitingForServer = false;
121
+ let stopped = false;
122
+ const paused = new Set();
123
+ const giveUpMs = options.sendGiveUpMs ?? SEND_GIVE_UP_MS;
124
+ function setWaiting(next) {
125
+ if (waitingForServer === next)
126
+ return;
127
+ waitingForServer = next;
128
+ events.onWaiting?.(next);
129
+ }
130
+ /** Until `start()` has caught up again, or `ms`. Fails at the deadline or on `stop()`. */
131
+ function pause(deadline, ms) {
132
+ return new Promise((resolve, reject) => {
133
+ const left = deadline - Date.now();
134
+ if (stopped || left <= 0)
135
+ return reject(stopped ? stoppedError() : gaveUpError());
136
+ const done = (then) => {
137
+ clearTimeout(timer);
138
+ paused.delete(entry);
139
+ then();
140
+ };
141
+ const entry = { resume: () => done(resolve), fail: (e) => done(() => reject(e)) };
142
+ const timer = setTimeout(() => (Date.now() >= deadline ? entry.fail(gaveUpError()) : entry.resume()), Math.min(left, ms));
143
+ paused.add(entry);
144
+ });
145
+ }
146
+ const gaveUpError = () => new MlsDriverError("offline", "The server didn't answer in time, so the message wasn't sent.");
147
+ const stoppedError = () => new MlsDriverError("stopped", "This connection has closed.");
110
148
  async function ownDevice() {
111
149
  if (device)
112
150
  return device;
@@ -255,7 +293,14 @@ export function createMlsDmDriver(options) {
255
293
  }
256
294
  }
257
295
  }
258
- else if (!(e.senderServerUserId === self && e.senderDeviceId === d.deviceId) && e.epoch >= open.rec.joinedEpoch) {
296
+ else if (e.senderServerUserId === self && e.senderDeviceId === d.deviceId) {
297
+ // A send whose answer never came: the log says it landed, so it isn't sent again.
298
+ for (const f of flights) {
299
+ if (f.conversationId === open.rec.conversationId && f.seq === null && f.message && sameBytes(f.message, e.data))
300
+ f.seq = e.seq;
301
+ }
302
+ }
303
+ else if (e.epoch >= open.rec.joinedEpoch) {
259
304
  let plaintext = null;
260
305
  try {
261
306
  const r = await processMlsMessage(open.state, e.data);
@@ -569,12 +614,50 @@ export function createMlsDmDriver(options) {
569
614
  }
570
615
  if (!registered)
571
616
  throw new MlsDriverError("no_device", "This device isn't registered for MLS here. Call start() first.");
572
- await ensureOpen(conversationId);
617
+ if (stopped)
618
+ throw stoppedError();
619
+ const deadline = Date.now() + giveUpMs;
620
+ const flight = { conversationId, message: null, seq: null };
621
+ flights.add(flight);
622
+ try {
623
+ for (;;) {
624
+ try {
625
+ return await sendOnce(flight, peer, plaintext, attachmentIds);
626
+ }
627
+ catch (e) {
628
+ const why = unreachable(e);
629
+ if (!why || stopped)
630
+ throw e;
631
+ // Offline waits for the next start(). A timeout goes again soon, as the same bytes.
632
+ if (why === "offline")
633
+ setWaiting(true);
634
+ await pause(deadline, why === "offline" ? Infinity : RESEND_AFTER_MS);
635
+ }
636
+ }
637
+ }
638
+ finally {
639
+ flights.delete(flight);
640
+ }
641
+ }
642
+ /* Encrypted at most once, and only once the group is caught up. Every later attempt sends
643
+ the same bytes, which the server takes as a repeat rather than a second message. */
644
+ async function sendOnce(flight, peer, plaintext, attachmentIds) {
645
+ const { conversationId } = flight;
646
+ if (!flight.message)
647
+ await ensureOpen(conversationId);
573
648
  return run(conversationId, async () => {
574
649
  const d = await ownDevice();
575
650
  const open = await load(conversationId);
576
651
  if (!open || !(await catchUp(open)))
577
652
  throw new MlsDriverError("not_in_group", "This device isn't in that group.");
653
+ if (flight.seq !== null)
654
+ return { seq: flight.seq };
655
+ if (flight.message) {
656
+ const r = await transport.send({ conversationId, deviceId: d.deviceId, message: flight.message, attachmentIds });
657
+ if (!r.ok)
658
+ throw refused("Sending", r);
659
+ return { seq: r.seq };
660
+ }
578
661
  if (!reconciled.has(conversationId))
579
662
  await reconcile(open, peer, true);
580
663
  const people = await peopleIn(conversationId, open.state);
@@ -589,6 +672,7 @@ export function createMlsDmDriver(options) {
589
672
  open.state = out.state;
590
673
  // The generation is used up whether or not the send arrives, so the state is saved first.
591
674
  await save(open);
675
+ flight.message = out.message;
592
676
  const r = await transport.send({ conversationId, deviceId: d.deviceId, message: out.message, attachmentIds });
593
677
  if (!r.ok)
594
678
  throw refused("Sending", r);
@@ -806,6 +890,15 @@ export function createMlsDmDriver(options) {
806
890
  await catchUp(open);
807
891
  });
808
892
  }
893
+ // Caught up, so the sends that waited go now, on top of that.
894
+ setWaiting(false);
895
+ for (const p of [...paused])
896
+ p.resume();
897
+ },
898
+ stop() {
899
+ stopped = true;
900
+ for (const p of [...paused])
901
+ p.fail(stoppedError());
809
902
  },
810
903
  modeFor,
811
904
  send,
@@ -51,8 +51,8 @@ export interface MlsClaimedKeyPackage extends MlsDeviceRef {
51
51
  lastResort: boolean;
52
52
  }
53
53
  /**
54
- * One method per mls:* request. The app adds `accessToken`, emits, and resolves with the ack,
55
- * turning ArrayBuffers into Uint8Arrays. Pushes go to the driver's `handle*` methods instead.
54
+ * One method per mls:* request: add `accessToken`, emit, resolve with the ack. Pushes go to `handle*`.
55
+ * Disconnected, answer `offline` without emitting; with no ack in time, answer `timeout`.
56
56
  */
57
57
  export interface MlsTransport {
58
58
  publishKeyPackages(req: {
@@ -230,6 +230,8 @@ export interface MlsDmEvents {
230
230
  seq: number;
231
231
  reason: string;
232
232
  }): void;
233
+ /** Sends are held until the connection is back and `start()` has caught up, or not any more. */
234
+ onWaiting?(waiting: boolean): void;
233
235
  /** This device is out of the group: removed, or its state couldn't keep up. */
234
236
  onGroupLost?(info: {
235
237
  conversationId: string;
@@ -309,13 +311,18 @@ export interface MlsDmDriverOptions {
309
311
  capability: MlsServerCapability | null;
310
312
  /** The first time only: `createMlsDevice` from @gryt/crypto, so the seed stays in the app. */
311
313
  newDevice(): MlsDeviceRecord | Promise<MlsDeviceRecord>;
314
+ /** How long a send waits for the server before it fails with `offline`. Five minutes. */
315
+ sendGiveUpMs?: number;
312
316
  }
313
317
  /** One per server. Calls for one conversation run one at a time, in order. */
314
318
  export interface MlsDmDriver {
315
319
  /** On connect: register, top up KeyPackages, take waiting Welcomes, catch up every group. */
316
320
  start(): Promise<void>;
317
321
  modeFor(conversationId: string, peerServerUserId: string): Promise<DmSealingMode>;
318
- /** Opens the group if need be, adds or removes devices that changed, then sends. */
322
+ /**
323
+ * Opens the group if need be, adds or removes devices that changed, then sends. Offline, it
324
+ * waits for the next `start()` and encrypts after that. It goes out once, or fails `offline`.
325
+ */
319
326
  send(conversationId: string, peerServerUserId: string, plaintext: Uint8Array, options?: {
320
327
  attachmentIds?: readonly string[];
321
328
  }): Promise<{
@@ -337,4 +344,6 @@ export interface MlsDmDriver {
337
344
  * Throws `not_own_device` for a device the server doesn't list as yours, or not under your person key.
338
345
  */
339
346
  addOwnDevice(deviceId: string, options?: MlsAddOwnDeviceOptions): Promise<MlsOwnDeviceAdd[]>;
347
+ /** Sends still waiting fail with `stopped`, and no more are taken. For a session going away. */
348
+ stop(): void;
340
349
  }
@@ -1,6 +1,6 @@
1
1
  import { type PairingEmoji, type PairingEnvelope } from "@gryt/crypto";
2
2
  import type { MlsOwnDeviceAdd } from "../mls/interfaces.js";
3
- import type { OwnDeviceAdder, PairingClock, PairingDeviceInfo, PairingEndReason, PairingFetch } from "./interfaces.js";
3
+ import type { HistoryArchive, HistoryNotedMessage, HistoryProgress, OwnDeviceAdder, PairingClock, PairingDeviceInfo, PairingEndReason, PairingFetch } from "./interfaces.js";
4
4
  import type { PairingRelay } from "./relayClient.js";
5
5
  export type ApproverState = {
6
6
  phase: "idle";
@@ -34,6 +34,12 @@ export type ApproverState = {
34
34
  host: string;
35
35
  done: number;
36
36
  total: number;
37
+ }
38
+ /** N is in every conversation; the history's tail is still going. Progress is on `history`. */
39
+ | {
40
+ phase: "sending";
41
+ device: PairingDeviceInfo;
42
+ added: Record<string, MlsOwnDeviceAdd[]>;
37
43
  } | {
38
44
  phase: "done";
39
45
  device: PairingDeviceInfo;
@@ -51,10 +57,19 @@ export interface ApproverOptions {
51
57
  devices: OwnDeviceAdder;
52
58
  clock?: PairingClock;
53
59
  approvalMs?: number;
60
+ /** A's archive. Without it, no history goes across. */
61
+ history?: HistoryArchive;
62
+ /** How long after the adds A keeps collecting old-epoch messages for the last tail. */
63
+ lateWindowMs?: number;
54
64
  }
55
65
  export interface ApproverPairing {
56
66
  readonly state: ApproverState;
57
67
  subscribe(listener: (state: ApproverState) => void): () => void;
68
+ /** Null until Approve, or with no archive. */
69
+ readonly history: HistoryProgress | null;
70
+ subscribeHistory(listener: (progress: HistoryProgress) => void): () => void;
71
+ /** Every MLS message A archives while this runs, received or sent, for the tail. */
72
+ noteMessage(message: HistoryNotedMessage): void;
58
73
  /** A scanned QR, or a typed code. */
59
74
  claim(input: {
60
75
  qr: string;
@@ -1,7 +1,22 @@
1
1
  import { base64Url, base64UrlDecode, createPairingKey, decodePairingSessionId, encodePairingEnvelope, encodePairingSessionId, parsePairingCode, parsePairingQr, startApproverSession, } from "@gryt/crypto";
2
- import { mailbox, openJson, PairingEnded, reasonFor, systemClock } from "./channel.js";
2
+ import { mailbox, openJson, PairingEnded, reasonFor, sealJson, systemClock } from "./channel.js";
3
+ import { createHistorySender, createTailRecorder } from "./historySender.js";
3
4
  import { PAIRING_APPROVAL_MS } from "./newDevice.js";
4
5
  const NOT_LISTED_RETRIES = 5;
6
+ /** How long Approve may spend sealing history before the envelope goes, so it lands in N's window. */
7
+ const ENVELOPE_BUDGET_MS = 5_000;
8
+ const HISTORY_LATE_WINDOW_MS = 60_000;
9
+ /** auth#46's codes that get a named reason. Anything else it can answer still falls
10
+ through to the generic `approve:<code>`. */
11
+ const APPROVE_ERROR_REASONS = {
12
+ code_used: "code_used",
13
+ unknown_code: "code_expired",
14
+ expired_code: "code_expired",
15
+ code_not_pending: "code_expired",
16
+ required_actions: "required_actions",
17
+ stale_token: "stale_token",
18
+ rate_limited: "rate_limited",
19
+ };
5
20
  /** A: claims the session, shows N and the emoji, and on Approve seals the envelope, signs N in and adds it. */
6
21
  export function createApproverPairing(options) {
7
22
  const { relay, fetch, devices } = options;
@@ -13,6 +28,15 @@ export function createApproverPairing(options) {
13
28
  let current = null;
14
29
  let device = null;
15
30
  let confirming = null;
31
+ let deadline = 0;
32
+ let history = null;
33
+ const historyListeners = new Set();
34
+ const tail = createTailRecorder();
35
+ const setHistory = (progress) => {
36
+ history = progress;
37
+ for (const l of historyListeners)
38
+ l(progress);
39
+ };
16
40
  const set = (next) => {
17
41
  if (state.phase === "ended" || state.phase === "done")
18
42
  return;
@@ -75,7 +99,7 @@ export function createApproverPairing(options) {
75
99
  const hello = openJson(current.session, await box.next(abort.signal, clock.now() + approvalMs), "hello");
76
100
  const text = (v) => (typeof v === "string" ? v.slice(0, 100) : "");
77
101
  device = { name: text(hello.name), app: text(hello.app), platform: text(hello.platform) };
78
- const deadline = clock.now() + approvalMs;
102
+ deadline = clock.now() + approvalMs;
79
103
  set({ phase: "confirming", device, location: claimed.location, yourLocation: claimed.yourLocation, emoji: current.session.emoji, deadline });
80
104
  // N sends nothing more until A approves, so a message or a close here means something's off.
81
105
  confirming = new AbortController();
@@ -107,7 +131,8 @@ export function createApproverPairing(options) {
107
131
  if (res.status === 404)
108
132
  return false;
109
133
  const body = (await res.json().catch(() => null));
110
- throw new PairingEnded(`approve:${typeof body?.error === "string" ? body.error : res.status}`);
134
+ const code = typeof body?.error === "string" ? body.error : String(res.status);
135
+ throw new PairingEnded(APPROVE_ERROR_REASONS[code] ?? `approve:${code}`);
111
136
  }
112
137
  async function addEverywhere(ready) {
113
138
  const added = {};
@@ -132,9 +157,37 @@ export function createApproverPairing(options) {
132
157
  }
133
158
  return added;
134
159
  }
160
+ /** Each group's cursor on every server in the envelope: the snapshot covers up to there. */
161
+ async function positions(envelope) {
162
+ const snap = new Map();
163
+ for (const { host } of envelope.servers) {
164
+ const driver = devices(host);
165
+ if (driver)
166
+ snap.set(host, new Map((await driver.groupPositions()).map((p) => [p.conversationId, p.seq])));
167
+ }
168
+ return snap;
169
+ }
170
+ function startHistory(archive) {
171
+ const { id, token, session } = current;
172
+ return createHistorySender({
173
+ relay, id, token, archive, clock, signal: abort.signal,
174
+ post: (message) => relay.post(id, token, { type: "sealed", body: sealJson(session, message) }),
175
+ onProgress: setHistory,
176
+ });
177
+ }
135
178
  async function approve(envelope, accessToken) {
136
179
  const { id, token, box, session } = current;
137
180
  set({ phase: envelope.account ? "signing_in" : "waiting_ready", device: device });
181
+ let sender = null;
182
+ let snap = new Map();
183
+ if (options.history) {
184
+ // Recording starts before the positions are read, so nothing lands between the two unseen.
185
+ tail.start();
186
+ snap = await positions(envelope);
187
+ sender = startHistory(options.history);
188
+ 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 } } };
190
+ }
138
191
  await relay.post(id, token, { type: "sealed", body: base64Url(session.seal(encodePairingEnvelope(envelope))) });
139
192
  if (envelope.account && accessToken) {
140
193
  const message = await box.next(abort.signal, clock.now() + approvalMs);
@@ -151,10 +204,20 @@ export function createApproverPairing(options) {
151
204
  const ready = openJson(session, await box.next(abort.signal), "ready");
152
205
  const list = Array.isArray(ready.devices) ? ready.devices : [];
153
206
  const named = list.filter((d) => typeof d?.host === "string" && typeof d?.deviceId === "string");
207
+ sender?.startPaging();
154
208
  const added = await addEverywhere(named);
209
+ if (!sender) {
210
+ set({ phase: "done", device: device, added });
211
+ session.close();
212
+ return void (await closeQuietly());
213
+ }
214
+ set({ phase: "sending", device: device, added });
215
+ await sender.send(tail.take(snap, added), false);
216
+ await clock.sleep(options.lateWindowMs ?? HISTORY_LATE_WINDOW_MS, abort.signal);
217
+ await sender.send(tail.take(snap, added), true);
218
+ // N closes the session once it has every chunk; closing here would delete them.
155
219
  set({ phase: "done", device: device, added });
156
220
  session.close();
157
- await closeQuietly();
158
221
  }
159
222
  return {
160
223
  get state() {
@@ -164,6 +227,14 @@ export function createApproverPairing(options) {
164
227
  listeners.add(listener);
165
228
  return () => void listeners.delete(listener);
166
229
  },
230
+ get history() {
231
+ return history;
232
+ },
233
+ subscribeHistory(listener) {
234
+ historyListeners.add(listener);
235
+ return () => void historyListeners.delete(listener);
236
+ },
237
+ noteMessage: (message) => tail.note(message),
167
238
  claim(input) {
168
239
  if (state.phase !== "idle")
169
240
  throw new Error("This pairing has claimed a session already.");
@@ -10,7 +10,7 @@ export declare const systemClock: PairingClock;
10
10
  export declare function reasonFor(error: unknown): PairingEndReason;
11
11
  export declare function sealJson(session: PairingSession, value: Record<string, unknown>): string;
12
12
  /** The next sealed message as JSON with a `t`, or a throw that ends the pairing as tampered. */
13
- export declare function openJson(session: PairingSession, message: RelayMessage | null, t: string): Record<string, unknown>;
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. */
15
15
  export declare function mailbox(relay: PairingRelay, id: string, token: string, clock: PairingClock): {
16
16
  next(signal: AbortSignal, deadline?: number): Promise<RelayMessage | null>;
@@ -41,7 +41,8 @@ export function openJson(session, message, t) {
41
41
  if (message?.type !== "sealed" || !message.body)
42
42
  throw new Error();
43
43
  const value = JSON.parse(new TextDecoder().decode(session.open(base64UrlDecode(message.body))));
44
- if (value && typeof value === "object" && value.t === t)
44
+ const got = value && typeof value === "object" ? value.t : undefined;
45
+ if (typeof got === "string" && (t === undefined || got === t))
45
46
  return value;
46
47
  }
47
48
  catch {
@@ -0,0 +1,34 @@
1
+ import { type HistoryChunkEntry } from "@gryt/crypto";
2
+ import type { HistoryProgress, HistorySink, PairingClock } from "./interfaces.js";
3
+ import { type PairingRelay } from "./relayClient.ts.js";
4
+ export interface HistoryReceiverOptions {
5
+ relay: PairingRelay;
6
+ id: string;
7
+ token: string;
8
+ key: Uint8Array;
9
+ sink: HistorySink;
10
+ clock: PairingClock;
11
+ signal: AbortSignal;
12
+ onProgress(progress: HistoryProgress): void;
13
+ }
14
+ /** N: fetches each listed chunk newest first, checks it against its entry, and stores each message once. */
15
+ export declare function createHistoryReceiver(options: HistoryReceiverOptions): {
16
+ done: Promise<void>;
17
+ progress: () => {
18
+ messages: number;
19
+ total: number | null;
20
+ chunks: number;
21
+ listed: number;
22
+ missing: number;
23
+ refused: number;
24
+ truncated: boolean;
25
+ oldest: number | null;
26
+ complete: boolean;
27
+ };
28
+ /** The envelope's manifest: slots 0 onwards, in order. */
29
+ listManifest(chunks: readonly HistoryChunkEntry[]): void;
30
+ /** One of A's history messages. Throws `tampered` for anything off. */
31
+ list(message: Record<string, unknown>): void;
32
+ /** The session is gone: whatever is still listed won't arrive. */
33
+ abandon: () => void;
34
+ };
@@ -0,0 +1,139 @@
1
+ import { openHistoryChunk, parseHistoryChunkEntry } from "@gryt/crypto";
2
+ import { PairingEnded } from "./channel.js";
3
+ import { emptyProgress } from "./historySender.js";
4
+ import { PairingRelayError } from "./relayClient.js";
5
+ const MAX_NETWORK_RETRIES = 5;
6
+ const messageKey = (r) => JSON.stringify([r.scope, r.conversationId, r.messageId]);
7
+ /** N: fetches each listed chunk newest first, checks it against its entry, and stores each message once. */
8
+ export function createHistoryReceiver(options) {
9
+ const { relay, id, token, key, sink, clock, signal } = options;
10
+ const progress = emptyProgress();
11
+ const pending = new Map();
12
+ const slots = new Set();
13
+ const chunkIds = new Set();
14
+ const stored = new Set();
15
+ let final = false;
16
+ let stopped = false;
17
+ let wake = null;
18
+ const report = () => options.onProgress({ ...progress });
19
+ const nudge = () => (wake?.(), (wake = null));
20
+ function add(n, value) {
21
+ let entry;
22
+ try {
23
+ entry = parseHistoryChunkEntry(value);
24
+ }
25
+ catch {
26
+ throw new PairingEnded("tampered");
27
+ }
28
+ // A slot or chunk listed twice is either a replay or a bug; neither gets a second look.
29
+ if (!Number.isSafeInteger(n) || n < 0 || slots.has(n) || chunkIds.has(entry.id))
30
+ throw new PairingEnded("tampered");
31
+ slots.add(n);
32
+ chunkIds.add(entry.id);
33
+ pending.set(n, entry);
34
+ progress.listed++;
35
+ }
36
+ async function fetchChunk(n) {
37
+ for (let failures = 0;;) {
38
+ try {
39
+ return await relay.getChunk(id, token, n, signal);
40
+ }
41
+ catch (e) {
42
+ if (e instanceof PairingRelayError && e.status === 404)
43
+ return "missing";
44
+ if (e instanceof PairingRelayError && e.status === 410)
45
+ return "gone";
46
+ if (!(e instanceof PairingRelayError && e.code === "network") || ++failures > MAX_NETWORK_RETRIES)
47
+ throw e;
48
+ await clock.sleep(2000, signal);
49
+ }
50
+ }
51
+ }
52
+ /** The newest listed chunk, so recent conversations fill in first. */
53
+ function next() {
54
+ let best = null;
55
+ for (const item of pending)
56
+ if (!best || item[1].last > best[1].last)
57
+ best = item;
58
+ return best;
59
+ }
60
+ function abandon() {
61
+ progress.missing += pending.size;
62
+ pending.clear();
63
+ stopped = true;
64
+ report();
65
+ nudge();
66
+ }
67
+ async function run() {
68
+ for (;;) {
69
+ if (signal.aborted || stopped)
70
+ return;
71
+ const item = next();
72
+ if (!item) {
73
+ if (final)
74
+ return void ((progress.complete = true), report());
75
+ await new Promise((resolve) => (wake = resolve));
76
+ continue;
77
+ }
78
+ const [n, entry] = item;
79
+ const sealed = await fetchChunk(n);
80
+ if (sealed === "gone")
81
+ return abandon();
82
+ pending.delete(n);
83
+ if (sealed === "missing") {
84
+ progress.missing++;
85
+ report();
86
+ continue;
87
+ }
88
+ let records;
89
+ try {
90
+ records = openHistoryChunk(key, entry, sealed);
91
+ }
92
+ catch {
93
+ progress.refused++;
94
+ report();
95
+ continue;
96
+ }
97
+ const fresh = records.filter((r) => !stored.has(messageKey(r)));
98
+ if (fresh.length > 0)
99
+ await sink.put(fresh);
100
+ for (const r of fresh)
101
+ stored.add(messageKey(r));
102
+ progress.chunks++;
103
+ progress.messages += fresh.length;
104
+ progress.oldest = Math.min(progress.oldest ?? entry.first, entry.first);
105
+ report();
106
+ relay.deleteChunk(id, token, n).catch(() => undefined);
107
+ }
108
+ }
109
+ signal.addEventListener("abort", nudge, { once: true });
110
+ const done = run();
111
+ done.catch(() => undefined);
112
+ return {
113
+ done,
114
+ progress: () => ({ ...progress }),
115
+ /** The envelope's manifest: slots 0 onwards, in order. */
116
+ listManifest(chunks) {
117
+ chunks.forEach((entry, n) => add(n, entry));
118
+ report();
119
+ nudge();
120
+ },
121
+ /** One of A's history messages. Throws `tampered` for anything off. */
122
+ list(message) {
123
+ if (final || !Array.isArray(message.chunks))
124
+ throw new PairingEnded("tampered");
125
+ for (const chunk of message.chunks)
126
+ add(chunk?.n, chunk);
127
+ if (Number.isSafeInteger(message.total))
128
+ progress.total = message.total;
129
+ if (message.truncated === true)
130
+ progress.truncated = true;
131
+ if (message.final === true)
132
+ final = true;
133
+ report();
134
+ nudge();
135
+ },
136
+ /** The session is gone: whatever is still listed won't arrive. */
137
+ abandon,
138
+ };
139
+ }
@@ -0,0 +1,65 @@
1
+ import { type HistoryChunkEntry, type HistoryRecord } from "@gryt/crypto";
2
+ import type { MlsOwnDeviceAdd } from "../mls/interfaces.js";
3
+ import type { HistoryArchive, HistoryNotedMessage, HistoryProgress, PairingClock } from "./interfaces.js";
4
+ import { type PairingRelay } from "./relayClient.ts.js";
5
+ /** The relay's 256 MiB per session, less room for the tail. */
6
+ export declare const HISTORY_SNAPSHOT_MAX_BYTES: number;
7
+ /** Entries in the envelope's manifest and in each later message, which keep under 256 and 64 KiB. */
8
+ export declare const HISTORY_ENVELOPE_ENTRIES = 400;
9
+ export declare const HISTORY_PAGE_ENTRIES = 180;
10
+ /** One chunk as a history message lists it: its relay slot and its manifest entry. */
11
+ export interface ListedChunk extends HistoryChunkEntry {
12
+ n: number;
13
+ }
14
+ /** What A posts after the envelope, sealed. `final` says nothing more is coming. */
15
+ export interface HistoryMessage {
16
+ t: "history";
17
+ chunks: ListedChunk[];
18
+ total?: number;
19
+ truncated?: boolean;
20
+ final?: boolean;
21
+ }
22
+ export declare const emptyProgress: () => HistoryProgress;
23
+ export interface HistorySenderOptions {
24
+ relay: PairingRelay;
25
+ id: string;
26
+ token: string;
27
+ archive: HistoryArchive;
28
+ clock: PairingClock;
29
+ signal: AbortSignal;
30
+ /** Seals and posts one message. The sender calls it one at a time, in order. */
31
+ post(message: HistoryMessage): Promise<void>;
32
+ onProgress(progress: HistoryProgress): void;
33
+ maxBytes?: number;
34
+ }
35
+ /** A: seals the archive into chunks newest first, uploads each, then lists it to N. */
36
+ export declare function createHistorySender(options: HistorySenderOptions): {
37
+ key: Uint8Array<ArrayBufferLike>;
38
+ progress: () => {
39
+ messages: number;
40
+ total: number | null;
41
+ chunks: number;
42
+ listed: number;
43
+ missing: number;
44
+ refused: number;
45
+ truncated: boolean;
46
+ oldest: number | null;
47
+ complete: boolean;
48
+ };
49
+ /** Waits until `until` or the end of the snapshot, whichever is first, and hands over what's up. */
50
+ forEnvelope(until: number): Promise<HistoryChunkEntry[]>;
51
+ /** After N's "ready": list what's waiting, and keep listing as pages fill. */
52
+ startPaging(): void;
53
+ /** The tail: records past the snapshot. `final` waits for the snapshot and says that's all. */
54
+ send(records: HistoryRecord[], final: boolean): Promise<void>;
55
+ };
56
+ /**
57
+ * A's record of MLS messages it archived during the transfer. The tail is every one past the
58
+ * snapshot that N can't read: before N's add, or sent in an older epoch that landed after it.
59
+ */
60
+ export declare function createTailRecorder(): {
61
+ start: () => undefined;
62
+ note(message: HistoryNotedMessage): void;
63
+ /** Everything noted since the last call that N needs. `snap` and `added` are per host. */
64
+ take(snap: Map<string, Map<string, number>>, added: Record<string, MlsOwnDeviceAdd[]>): HistoryRecord[];
65
+ };