@gryt/core 0.15.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.
package/README.md CHANGED
@@ -71,7 +71,16 @@ goes missing **and** when one appears that nobody listed.
71
71
  extension, and adds the new device to every DM. If the extension isn't there,
72
72
  it hands back Keycloak's own device page to open instead. The app passes in
73
73
  `fetch`, a clock, storage and the OIDC calls through
74
- `src/pairing/interfaces.ts`. History isn't in it yet. It uses
74
+ `src/pairing/interfaces.ts`. When the app passes its archive, history goes
75
+ across too. On Approve, A notes where each group's log stands, reads the
76
+ archive newest day first through `HistoryArchive`, and seals and uploads it
77
+ in chunks. The envelope carries the history key and the first chunks, and
78
+ later sealed messages list the rest. After the adds, A sends the tail: what
79
+ it decrypted between the snapshot and each add, plus old-epoch messages that
80
+ land after it, which the app reports with `noteMessage`. N fetches the chunks
81
+ newest first, checks each against its manifest entry, and hands each message
82
+ to `HistorySink` once. Both sides report progress through
83
+ `subscribeHistory`. It uses
75
84
  `@noble/hashes` for the PKCE challenge, which `@gryt/crypto` already pulls in.
76
85
 
77
86
  ## What deliberately isn't
package/dist/index.d.ts CHANGED
@@ -18,4 +18,4 @@ export type { DmSealingMode, MlsAddOwnDeviceOptions, MlsClaimedKeyPackage, MlsDe
18
18
  export { createApproverPairing, type ApproverOptions, type ApproverPairing, type ApproverState, } from "./pairing/approver.js";
19
19
  export { createNewDevicePairing, PAIRING_APPROVAL_MS, type NewDeviceOptions, type NewDevicePairing, type NewDeviceState, } from "./pairing/newDevice.js";
20
20
  export { createPairingRelay, PairingRelayError, type PairingRelay } from "./pairing/relayClient.js";
21
- export type { DeviceAuthorization, DeviceTokenPoll, OwnDeviceAdder, PairedServerDevice, PairingClock, PairingDeviceInfo, PairingEndReason, PairingFetch, PairingOidc, PairingStorage, PairingTokens, } from "./pairing/interfaces.js";
21
+ export type { DeviceAuthorization, DeviceTokenPoll, HistoryArchive, HistoryConversation, HistoryCursor, HistoryNotedMessage, HistoryProgress, HistorySink, OwnDeviceAdder, PairedServerDevice, PairingClock, PairingDeviceInfo, PairingEndReason, PairingFetch, PairingOidc, PairingStorage, PairingTokens, } from "./pairing/interfaces.js";
@@ -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,11 @@
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;
5
9
  /** auth#46's codes that get a named reason. Anything else it can answer still falls
6
10
  through to the generic `approve:<code>`. */
7
11
  const APPROVE_ERROR_REASONS = {
@@ -24,6 +28,15 @@ export function createApproverPairing(options) {
24
28
  let current = null;
25
29
  let device = null;
26
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
+ };
27
40
  const set = (next) => {
28
41
  if (state.phase === "ended" || state.phase === "done")
29
42
  return;
@@ -86,7 +99,7 @@ export function createApproverPairing(options) {
86
99
  const hello = openJson(current.session, await box.next(abort.signal, clock.now() + approvalMs), "hello");
87
100
  const text = (v) => (typeof v === "string" ? v.slice(0, 100) : "");
88
101
  device = { name: text(hello.name), app: text(hello.app), platform: text(hello.platform) };
89
- const deadline = clock.now() + approvalMs;
102
+ deadline = clock.now() + approvalMs;
90
103
  set({ phase: "confirming", device, location: claimed.location, yourLocation: claimed.yourLocation, emoji: current.session.emoji, deadline });
91
104
  // N sends nothing more until A approves, so a message or a close here means something's off.
92
105
  confirming = new AbortController();
@@ -144,9 +157,37 @@ export function createApproverPairing(options) {
144
157
  }
145
158
  return added;
146
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
+ }
147
178
  async function approve(envelope, accessToken) {
148
179
  const { id, token, box, session } = current;
149
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
+ }
150
191
  await relay.post(id, token, { type: "sealed", body: base64Url(session.seal(encodePairingEnvelope(envelope))) });
151
192
  if (envelope.account && accessToken) {
152
193
  const message = await box.next(abort.signal, clock.now() + approvalMs);
@@ -163,10 +204,20 @@ export function createApproverPairing(options) {
163
204
  const ready = openJson(session, await box.next(abort.signal), "ready");
164
205
  const list = Array.isArray(ready.devices) ? ready.devices : [];
165
206
  const named = list.filter((d) => typeof d?.host === "string" && typeof d?.deviceId === "string");
207
+ sender?.startPaging();
166
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.
167
219
  set({ phase: "done", device: device, added });
168
220
  session.close();
169
- await closeQuietly();
170
221
  }
171
222
  return {
172
223
  get state() {
@@ -176,6 +227,14 @@ export function createApproverPairing(options) {
176
227
  listeners.add(listener);
177
228
  return () => void listeners.delete(listener);
178
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),
179
238
  claim(input) {
180
239
  if (state.phase !== "idle")
181
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
+ };
@@ -0,0 +1,182 @@
1
+ import { generateHistoryKey, planHistoryChunks, sealHistoryChunk, } from "@gryt/crypto";
2
+ import { archiveByDay } from "./historySnapshot.js";
3
+ import { PairingRelayError } from "./relayClient.js";
4
+ /** The relay's 256 MiB per session, less room for the tail. */
5
+ export const HISTORY_SNAPSHOT_MAX_BYTES = 248 * 1024 * 1024;
6
+ /** Entries in the envelope's manifest and in each later message, which keep under 256 and 64 KiB. */
7
+ export const HISTORY_ENVELOPE_ENTRIES = 400;
8
+ export const HISTORY_PAGE_ENTRIES = 180;
9
+ /** The relay allows 64 messages a session, both sides together; N and the envelope use five. */
10
+ const HISTORY_MAX_CHUNKS = HISTORY_ENVELOPE_ENTRIES + 40 * HISTORY_PAGE_ENTRIES;
11
+ const MAX_NETWORK_RETRIES = 5;
12
+ export const emptyProgress = () => ({
13
+ messages: 0, total: null, chunks: 0, listed: 0, missing: 0, refused: 0, truncated: false, oldest: null, complete: false,
14
+ });
15
+ /** A: seals the archive into chunks newest first, uploads each, then lists it to N. */
16
+ export function createHistorySender(options) {
17
+ const { relay, id, token, clock, signal } = options;
18
+ const key = generateHistoryKey();
19
+ const maxBytes = options.maxBytes ?? HISTORY_SNAPSHOT_MAX_BYTES;
20
+ const progress = emptyProgress();
21
+ /** Uploaded, not yet listed. */
22
+ const unlisted = [];
23
+ let slot = 0;
24
+ let bytes = 0;
25
+ let paging = false;
26
+ let walked = false;
27
+ let posting = Promise.resolve();
28
+ let finished = false;
29
+ const report = () => options.onProgress({ ...progress });
30
+ async function put(n, sealed) {
31
+ for (let failures = 0;;) {
32
+ try {
33
+ return await relay.putChunk(id, token, n, sealed, signal);
34
+ }
35
+ catch (e) {
36
+ if (!(e instanceof PairingRelayError && e.code === "network") || ++failures > MAX_NETWORK_RETRIES)
37
+ throw e;
38
+ await clock.sleep(2000, signal);
39
+ }
40
+ }
41
+ }
42
+ /** False once the cap or the relay says no more. */
43
+ async function upload(records, capped) {
44
+ for (const chunk of planHistoryChunks(records)) {
45
+ if (signal.aborted)
46
+ throw new Error("History transfer cancelled.");
47
+ const { entry, sealed } = sealHistoryChunk(key, chunk);
48
+ if (capped && (bytes + sealed.length > maxBytes || slot >= HISTORY_MAX_CHUNKS))
49
+ return false;
50
+ const n = slot++;
51
+ try {
52
+ await put(n, sealed);
53
+ }
54
+ catch (e) {
55
+ // The relay's own caps: whatever didn't fit stays behind, the same as our own cap.
56
+ if (capped && e instanceof PairingRelayError && (e.status === 507 || e.code === "busy"))
57
+ return false;
58
+ throw e;
59
+ }
60
+ bytes += sealed.length;
61
+ unlisted.push({ n, ...entry });
62
+ progress.chunks++;
63
+ progress.messages += entry.count;
64
+ progress.oldest = Math.min(progress.oldest ?? entry.first, entry.first);
65
+ report();
66
+ }
67
+ return true;
68
+ }
69
+ async function walk() {
70
+ const conversations = await options.archive.conversations();
71
+ if (conversations.every((c) => typeof c.count === "number")) {
72
+ progress.total = conversations.reduce((sum, c) => sum + (c.count ?? 0), 0);
73
+ report();
74
+ }
75
+ for await (const day of archiveByDay(options.archive)) {
76
+ if (!(await upload(day, true))) {
77
+ progress.truncated = true;
78
+ break;
79
+ }
80
+ if (paging)
81
+ void flush(false, false);
82
+ }
83
+ walked = true;
84
+ if (paging)
85
+ void flush(false);
86
+ }
87
+ const snapshot = walk();
88
+ snapshot.catch(() => undefined);
89
+ /** Lists what's uploaded, a page per message. `partial` false keeps back a page that isn't full. */
90
+ function flush(final, partial = true) {
91
+ const pages = [];
92
+ while (unlisted.length >= (partial ? 1 : HISTORY_PAGE_ENTRIES))
93
+ pages.push(unlisted.splice(0, HISTORY_PAGE_ENTRIES));
94
+ if (final && pages.length === 0)
95
+ pages.push([]);
96
+ pages.forEach((chunks, i) => {
97
+ const message = { t: "history", chunks };
98
+ if (progress.total !== null)
99
+ message.total = progress.total;
100
+ if (progress.truncated)
101
+ message.truncated = true;
102
+ if (final && i === pages.length - 1)
103
+ message.final = true;
104
+ posting = posting.then(async () => {
105
+ await options.post(message);
106
+ progress.listed += chunks.length;
107
+ if (message.final)
108
+ progress.complete = true;
109
+ report();
110
+ });
111
+ });
112
+ posting.catch(() => undefined);
113
+ return posting;
114
+ }
115
+ return {
116
+ key,
117
+ progress: () => ({ ...progress }),
118
+ /** Waits until `until` or the end of the snapshot, whichever is first, and hands over what's up. */
119
+ async forEnvelope(until) {
120
+ const wait = new AbortController();
121
+ const stop = () => wait.abort();
122
+ signal.addEventListener("abort", stop, { once: true });
123
+ try {
124
+ await Promise.race([snapshot, clock.sleep(Math.max(0, until - clock.now()), wait.signal).catch(() => undefined)]);
125
+ }
126
+ finally {
127
+ wait.abort();
128
+ signal.removeEventListener("abort", stop);
129
+ }
130
+ const listed = unlisted.splice(0, HISTORY_ENVELOPE_ENTRIES);
131
+ progress.listed += listed.length;
132
+ report();
133
+ return listed.map(({ n: _n, ...entry }) => entry);
134
+ },
135
+ /** After N's "ready": list what's waiting, and keep listing as pages fill. */
136
+ startPaging() {
137
+ paging = true;
138
+ void flush(false, walked);
139
+ },
140
+ /** The tail: records past the snapshot. `final` waits for the snapshot and says that's all. */
141
+ async send(records, final) {
142
+ if (finished)
143
+ throw new Error("This history transfer has finished.");
144
+ if (final) {
145
+ await snapshot;
146
+ finished = true;
147
+ }
148
+ if (records.length > 0)
149
+ await upload(records, false);
150
+ await flush(final);
151
+ },
152
+ };
153
+ }
154
+ /**
155
+ * A's record of MLS messages it archived during the transfer. The tail is every one past the
156
+ * snapshot that N can't read: before N's add, or sent in an older epoch that landed after it.
157
+ */
158
+ export function createTailRecorder() {
159
+ let on = false;
160
+ let noted = [];
161
+ return {
162
+ start: () => void (on = true),
163
+ note(message) {
164
+ if (on)
165
+ noted.push(message);
166
+ },
167
+ /** Everything noted since the last call that N needs. `snap` and `added` are per host. */
168
+ take(snap, added) {
169
+ const out = [];
170
+ for (const m of noted) {
171
+ if (m.seq <= (snap.get(m.host)?.get(m.conversationId) ?? -1))
172
+ continue;
173
+ const add = added[m.host]?.find((a) => a.conversationId === m.conversationId)?.add ?? null;
174
+ if (add && m.seq >= add.seq && m.epoch >= add.epoch)
175
+ continue;
176
+ out.push(m.record);
177
+ }
178
+ noted = [];
179
+ return out;
180
+ },
181
+ };
182
+ }
@@ -0,0 +1,7 @@
1
+ import type { HistoryRecord } from "@gryt/crypto";
2
+ import type { HistoryArchive, HistoryCursor } from "./interfaces.js";
3
+ export declare const dayOf: (sentAt: number) => number;
4
+ /** Negative when `a` is newer: by time, then by message id, the same order the archive pages in. */
5
+ export declare function newerFirst(a: HistoryCursor, b: HistoryCursor): number;
6
+ /** A's archive one UTC day at a time, newest day first across every conversation and server. */
7
+ export declare function archiveByDay(archive: HistoryArchive, pageSize?: number): AsyncGenerator<HistoryRecord[]>;
@@ -0,0 +1,45 @@
1
+ const DAY_MS = 24 * 60 * 60 * 1000;
2
+ export const dayOf = (sentAt) => Math.floor(sentAt / DAY_MS);
3
+ /** Negative when `a` is newer: by time, then by message id, the same order the archive pages in. */
4
+ export function newerFirst(a, b) {
5
+ return b.sentAt - a.sentAt || (a.messageId < b.messageId ? 1 : a.messageId > b.messageId ? -1 : 0);
6
+ }
7
+ /** A's archive one UTC day at a time, newest day first across every conversation and server. */
8
+ export async function* archiveByDay(archive, pageSize = 500) {
9
+ const walks = (await archive.conversations()).map((c) => ({ ...c, buffer: [], exhausted: false }));
10
+ const fill = async (w) => {
11
+ if (w.buffer.length > 0 || w.exhausted)
12
+ return;
13
+ const page = await archive.page(w.scope, w.conversationId, { before: w.before, limit: pageSize });
14
+ // Only what's older than the cursor and in this conversation, so a sloppy page can't loop forever.
15
+ const fresh = page
16
+ .filter((r) => r.scope === w.scope && r.conversationId === w.conversationId)
17
+ .filter((r) => !w.before || newerFirst(r, w.before) > 0)
18
+ .sort(newerFirst);
19
+ w.buffer = fresh;
20
+ w.before = fresh.at(-1) ?? w.before;
21
+ w.exhausted = fresh.length === 0 || page.length < pageSize;
22
+ };
23
+ for (;;) {
24
+ await Promise.all(walks.map(fill));
25
+ const heads = walks.filter((w) => w.buffer.length > 0);
26
+ if (heads.length === 0)
27
+ return;
28
+ const day = Math.max(...heads.map((w) => dayOf(w.buffer[0].sentAt)));
29
+ const out = [];
30
+ for (const w of heads) {
31
+ for (;;) {
32
+ let i = 0;
33
+ while (i < w.buffer.length && dayOf(w.buffer[i].sentAt) === day)
34
+ out.push(w.buffer[i++]);
35
+ w.buffer = w.buffer.slice(i);
36
+ if (w.buffer.length > 0)
37
+ break;
38
+ await fill(w);
39
+ if (w.buffer.length === 0)
40
+ break;
41
+ }
42
+ }
43
+ yield out;
44
+ }
45
+ }
@@ -1,5 +1,5 @@
1
- import type { PairingEnvelope } from "@gryt/crypto";
2
- import type { MlsAddOwnDeviceOptions, MlsOwnDeviceAdd } from "../mls/interfaces.js";
1
+ import type { HistoryRecord, PairingEnvelope } from "@gryt/crypto";
2
+ import type { MlsAddOwnDeviceOptions, MlsGroupPosition, MlsOwnDeviceAdd } from "../mls/interfaces.js";
3
3
  /** `fetch`, cut down to what pairing uses. The browser's, Node's and React Native's all fit. */
4
4
  export type PairingFetch = (url: string, init: {
5
5
  method: string;
@@ -69,10 +69,14 @@ export interface PairedServerDevice {
69
69
  /** A's DM driver on one server, or undefined when A isn't on it. */
70
70
  export type OwnDeviceAdder = (host: string) => {
71
71
  addOwnDevice(deviceId: string, options?: MlsAddOwnDeviceOptions): Promise<MlsOwnDeviceAdd[]>;
72
+ /** Where the history snapshot stops in each group; the tail starts after it. */
73
+ groupPositions(): Promise<MlsGroupPosition[]>;
72
74
  } | undefined;
73
75
  /** Why a pairing stopped. `approve:<error>` carries the extension's own error code for anything
74
76
  below that isn't broken out on its own (GRYT-1578; auth#46 has the full list). */
75
77
  export type PairingEndReason = "cancelled" | "cancelled_by_other" | "mismatch" | "timed_out" | "expired" | "already_claimed" | "unknown_code" | "wrong_relay" | "newer_version" | "not_pairing" | "rate_limited" | "tampered" | "wrong_account" | "sign_in_failed" | "relay_error"
78
+ /** N's archive refused the history it was handed. Keys and sign-in are in place. */
79
+ | "history_failed"
76
80
  /** The approve endpoint says this user_code already got its one answer. */
77
81
  | "code_used"
78
82
  /** The approve endpoint says the user_code isn't valid any more: unknown, expired, or not pending. */
@@ -85,3 +89,53 @@ export type PairingEndReason = "cancelled" | "cancelled_by_other" | "mismatch" |
85
89
  | "access_denied"
86
90
  /** The device grant's user_code ran out before anyone answered it. */
87
91
  | "expired_token" | `approve:${string}`;
92
+ /** A conversation in A's archive. `count` feeds the progress total, so leave it off rather than guess. */
93
+ export interface HistoryConversation {
94
+ scope: string;
95
+ conversationId: string;
96
+ count?: number;
97
+ }
98
+ /** Where a page of the archive stops: the oldest record in it. */
99
+ export interface HistoryCursor {
100
+ sentAt: number;
101
+ messageId: string;
102
+ }
103
+ /** A's archive, read for the history snapshot. Records are @gryt/crypto's HistoryRecord. */
104
+ export interface HistoryArchive {
105
+ conversations(): Promise<HistoryConversation[]>;
106
+ /** Up to `limit` records sent before `before` (by sentAt, then messageId), in any order. */
107
+ page(scope: string, conversationId: string, options: {
108
+ before?: HistoryCursor;
109
+ limit: number;
110
+ }): Promise<HistoryRecord[]>;
111
+ }
112
+ /** N's archive. Core hands it each message id once; a record for an id N already holds replaces it. */
113
+ export interface HistorySink {
114
+ put(records: HistoryRecord[]): Promise<void>;
115
+ }
116
+ /** How a history transfer is going, on either side. */
117
+ export interface HistoryProgress {
118
+ /** A: messages uploaded and listed. N: messages stored. */
119
+ messages: number;
120
+ /** A: from the archive's counts. N: what A said it would send. Null when nobody knows yet. */
121
+ total: number | null;
122
+ /** Chunks uploaded (A) or fetched and stored (N), out of `listed`. */
123
+ chunks: number;
124
+ listed: number;
125
+ /** N only: chunks the relay no longer has, and chunks that failed their manifest check. */
126
+ missing: number;
127
+ refused: number;
128
+ /** The relay's cap stopped the snapshot early; the oldest messages stayed behind. */
129
+ truncated: boolean;
130
+ /** The oldest message time sent so far, so N can say how far back it has. */
131
+ oldest: number | null;
132
+ complete: boolean;
133
+ }
134
+ /** A message A archived from MLS, received or sent, with where it sits in its group's log. */
135
+ export interface HistoryNotedMessage {
136
+ host: string;
137
+ conversationId: string;
138
+ seq: number;
139
+ epoch: number;
140
+ record: HistoryRecord;
141
+ }
@@ -1,5 +1,5 @@
1
1
  import { type PairingEmoji, type PairingServer } from "@gryt/crypto";
2
- import type { PairedServerDevice, PairingClock, PairingDeviceInfo, PairingEndReason, PairingOidc, PairingStorage } from "./interfaces.js";
2
+ import type { HistoryProgress, HistorySink, PairedServerDevice, PairingClock, PairingDeviceInfo, PairingEndReason, PairingOidc, PairingStorage } from "./interfaces.js";
3
3
  import type { PairingRelay } from "./relayClient.js";
4
4
  export type NewDeviceState = {
5
5
  phase: "idle";
@@ -46,10 +46,15 @@ export interface NewDeviceOptions {
46
46
  /** Only for a server with its own auth server: goes in the QR for A to check against its own. */
47
47
  relayOrigin?: string;
48
48
  approvalMs?: number;
49
+ /** N's archive. Without it, history A sends is left on the relay. */
50
+ history?: HistorySink;
49
51
  }
50
52
  export interface NewDevicePairing {
51
53
  readonly state: NewDeviceState;
52
54
  subscribe(listener: (state: NewDeviceState) => void): () => void;
55
+ /** Null until the envelope brings a history key, or with no sink. */
56
+ readonly history: HistoryProgress | null;
57
+ subscribeHistory(listener: (progress: HistoryProgress) => void): () => void;
53
58
  start(): void;
54
59
  /** In `joining`: N's device on each server, once its KeyPackages are up. */
55
60
  ready(devices: PairedServerDevice[]): Promise<void>;
@@ -1,7 +1,8 @@
1
1
  import { base64Url, base64UrlDecode, createPairingKey, decodePairingEnvelope, decodePairingSessionId, formatPairingCode, formatPairingQr, pairingCommitment, startNewDeviceSession, } from "@gryt/crypto";
2
2
  import { sha256 } from "@noble/hashes/sha2.js";
3
3
  import { randomBytes } from "@noble/hashes/utils.js";
4
- import { mailbox, PairingEnded, reasonFor, sealJson, systemClock } from "./channel.js";
4
+ import { mailbox, openJson, PairingEnded, reasonFor, sealJson, systemClock } from "./channel.js";
5
+ import { createHistoryReceiver } from "./historyReceiver.js";
5
6
  export const PAIRING_APPROVAL_MS = 60_000;
6
7
  const PAIRING_OIDC_SCOPE = "openid profile email offline_access";
7
8
  /** N: shows the QR and code, checks the emoji, opens the envelope, signs in, then says ready. */
@@ -13,6 +14,14 @@ export function createNewDevicePairing(options) {
13
14
  const abort = new AbortController();
14
15
  let state = { phase: "idle" };
15
16
  let current = null;
17
+ let receiver = null;
18
+ let history = null;
19
+ const historyListeners = new Set();
20
+ const setHistory = (progress) => {
21
+ history = progress;
22
+ for (const l of historyListeners)
23
+ l(progress);
24
+ };
16
25
  const set = (next) => {
17
26
  if (state.phase === "ended" || state.phase === "done")
18
27
  return;
@@ -141,6 +150,13 @@ export function createNewDevicePairing(options) {
141
150
  const envelope = outcome;
142
151
  const tokens = envelope.account ? await signIn(envelope.account, envelope.from) : null;
143
152
  await storage.commit(envelope, tokens);
153
+ if (envelope.history && options.history) {
154
+ const { id, token } = current;
155
+ receiver = createHistoryReceiver({
156
+ relay, id, token, key: envelope.history.key, sink: options.history, clock, signal: abort.signal, onProgress: setHistory,
157
+ });
158
+ receiver.listManifest(envelope.history.manifest.chunks);
159
+ }
144
160
  set({ phase: "joining", servers: envelope.servers, from: envelope.from });
145
161
  }
146
162
  catch (e) {
@@ -148,20 +164,43 @@ export function createNewDevicePairing(options) {
148
164
  await end(reasonFor(e));
149
165
  }
150
166
  }
167
+ /** Reads A's history messages until every chunk is in, or A closing is the finish. */
151
168
  async function linked(from) {
169
+ const { box, session } = current;
170
+ const stop = new AbortController();
171
+ const onAbort = () => stop.abort();
172
+ abort.signal.addEventListener("abort", onAbort, { once: true });
173
+ let failed = false;
174
+ receiver?.done.then(onAbort, () => ((failed = true), onAbort()));
152
175
  try {
153
- // History from A lands here in a later version. Until then, A closing is the finish.
154
- while (await current.box.next(abort.signal))
155
- ;
176
+ for (;;) {
177
+ const body = openJson(session, await box.next(stop.signal), "history");
178
+ if (receiver)
179
+ receiver.list(body);
180
+ // No sink here: nothing to fetch, so A's last message is the finish.
181
+ else if (body.final === true)
182
+ throw (stop.abort(), new Error("finished"));
183
+ }
156
184
  }
157
185
  catch (e) {
158
186
  if (abort.signal.aborted)
159
187
  return;
188
+ if (failed)
189
+ return end("history_failed");
190
+ if (stop.signal.aborted) {
191
+ set({ phase: "done", from });
192
+ session?.close();
193
+ return void (await closeQuietly());
194
+ }
160
195
  const reason = reasonFor(e);
161
196
  if (reason !== "cancelled_by_other" && reason !== "expired")
162
197
  return end(reason);
198
+ receiver?.abandon();
163
199
  set({ phase: "done", from });
164
- current?.session?.close();
200
+ session?.close();
201
+ }
202
+ finally {
203
+ abort.signal.removeEventListener("abort", onAbort);
165
204
  }
166
205
  }
167
206
  return {
@@ -172,6 +211,13 @@ export function createNewDevicePairing(options) {
172
211
  listeners.add(listener);
173
212
  return () => void listeners.delete(listener);
174
213
  },
214
+ get history() {
215
+ return history;
216
+ },
217
+ subscribeHistory(listener) {
218
+ historyListeners.add(listener);
219
+ return () => void historyListeners.delete(listener);
220
+ },
175
221
  start() {
176
222
  if (state.phase !== "idle")
177
223
  throw new Error("This pairing has started already.");
@@ -40,6 +40,13 @@ export interface PairingRelay {
40
40
  /** The other side's messages after `after`, waiting up to `waitSeconds`. Asking past one acks it. */
41
41
  poll(id: string, token: string, after: number, waitSeconds: number, signal?: AbortSignal): Promise<RelayMessage[]>;
42
42
  close(id: string, token: string): Promise<void>;
43
+ /** A: one sealed history chunk into slot `n`. */
44
+ putChunk(id: string, token: string, n: number, sealed: Uint8Array, signal?: AbortSignal): Promise<void>;
45
+ /** N: the chunk in slot `n`. A 404 means the relay doesn't have it. */
46
+ getChunk(id: string, token: string, n: number, signal?: AbortSignal): Promise<Uint8Array>;
47
+ /** N: done with slot `n`. */
48
+ deleteChunk(id: string, token: string, n: number): Promise<void>;
43
49
  }
44
- /** The relay's HTTP API as auth#45 built it, under `{origin}/api/v1/pairing`. */
50
+ /** The relay's HTTP API as auth#45 built it, under `{origin}/api/v1/pairing`. The chunk
51
+ calls follow docs/pairing-design.md; the relay doesn't have them yet (GRYT-1591). */
45
52
  export declare function createPairingRelay(origin: string, fetch: PairingFetch): PairingRelay;
@@ -1,3 +1,4 @@
1
+ import { base64Url, base64UrlDecode } from "@gryt/crypto";
1
2
  /** A refusal from the relay: its status and the `error` it sent, or "network" when nothing came back. */
2
3
  export class PairingRelayError extends Error {
3
4
  status;
@@ -9,7 +10,8 @@ export class PairingRelayError extends Error {
9
10
  this.code = code;
10
11
  }
11
12
  }
12
- /** The relay's HTTP API as auth#45 built it, under `{origin}/api/v1/pairing`. */
13
+ /** The relay's HTTP API as auth#45 built it, under `{origin}/api/v1/pairing`. The chunk
14
+ calls follow docs/pairing-design.md; the relay doesn't have them yet (GRYT-1591). */
13
15
  export function createPairingRelay(origin, fetch) {
14
16
  const base = `${origin.replace(/\/+$/, "")}/api/v1/pairing/sessions`;
15
17
  async function call(method, path, body, token, signal) {
@@ -44,5 +46,18 @@ export function createPairingRelay(origin, fetch) {
44
46
  return (Array.isArray(res.messages) ? res.messages : []);
45
47
  },
46
48
  close: async (id, token) => void (await call("DELETE", path(id), undefined, token)),
49
+ putChunk: async (id, token, n, sealed, signal) => void (await call("PUT", `${path(id)}/chunks/${n}`, { body: base64Url(sealed) }, token, signal)),
50
+ getChunk: async (id, token, n, signal) => {
51
+ const res = await call("GET", `${path(id)}/chunks/${n}`, undefined, token, signal);
52
+ if (typeof res.body !== "string")
53
+ throw new PairingRelayError(200, "invalid_body");
54
+ try {
55
+ return base64UrlDecode(res.body);
56
+ }
57
+ catch {
58
+ throw new PairingRelayError(200, "invalid_body");
59
+ }
60
+ },
61
+ deleteChunk: async (id, token, n) => void (await call("DELETE", `${path(id)}/chunks/${n}`, undefined, token)),
47
62
  };
48
63
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.15.0",
3
+ "version": "0.16.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": {