@gryt/core 0.32.0 → 0.33.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/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mls/dmDriver.js +23 -14
- package/dist/pairing/approver.d.ts +5 -0
- package/dist/pairing/approver.js +32 -10
- package/dist/pairing/historyReceiver.d.ts +3 -5
- package/dist/pairing/historyReceiver.js +6 -10
- package/dist/pairing/historySender.d.ts +3 -5
- package/dist/pairing/historySender.js +8 -8
- package/dist/pairing/historyStore.d.ts +20 -0
- package/dist/pairing/historyStore.js +9 -0
- package/dist/pairing/newDevice.d.ts +6 -1
- package/dist/pairing/newDevice.js +21 -5
- package/dist/pairing/serverHistory.d.ts +26 -0
- package/dist/pairing/serverHistory.js +65 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -22,4 +22,6 @@ export type { DmSealingMode, MlsAddOwnDeviceOptions, MlsClaimedKeyPackage, MlsDe
|
|
|
22
22
|
export { createApproverPairing, type ApproverOptions, type ApproverPairing, type ApproverState, } from "./pairing/approver.js";
|
|
23
23
|
export { createNewDevicePairing, PAIRING_APPROVAL_MS, type NewDeviceOptions, type NewDevicePairing, type NewDeviceState, } from "./pairing/newDevice.js";
|
|
24
24
|
export { createPairingRelay, PairingRelayError, type PairingRelay } from "./pairing/relayClient.js";
|
|
25
|
+
export { chooseHistoryServer, historyStoreError, openServerHistory, serverHistoryStore, type HistoryFetch, type ServerAccess, } from "./pairing/serverHistory.js";
|
|
26
|
+
export { HistoryStoreError, type HistoryChunkStore, type HistoryStoreChooser, type HistoryStoreOpener, } from "./pairing/historyStore.js";
|
|
25
27
|
export type { DeviceAuthorization, DeviceTokenPoll, HistoryArchive, HistoryConversation, HistoryCursor, HistoryNotedMessage, HistoryProgress, HistorySink, OwnDeviceAdder, PairedServerDevice, PairingClock, PairingDeviceInfo, PairingEndReason, PairingFetch, PairingOidc, PairingStorage, PairingTokens, } from "./pairing/interfaces.js";
|
package/dist/index.js
CHANGED
|
@@ -23,3 +23,5 @@ export { importId, retryAfterMs, retryLeftOut, runDiscordImport, } from "./impor
|
|
|
23
23
|
export { createApproverPairing, } from "./pairing/approver.js";
|
|
24
24
|
export { createNewDevicePairing, PAIRING_APPROVAL_MS, } from "./pairing/newDevice.js";
|
|
25
25
|
export { createPairingRelay, PairingRelayError } from "./pairing/relayClient.js";
|
|
26
|
+
export { chooseHistoryServer, historyStoreError, openServerHistory, serverHistoryStore, } from "./pairing/serverHistory.js";
|
|
27
|
+
export { HistoryStoreError, } from "./pairing/historyStore.js";
|
package/dist/mls/dmDriver.js
CHANGED
|
@@ -2,6 +2,7 @@ import { addMlsMembers, asIdentityScope, base64UrlDecode, createMlsGroup, decode
|
|
|
2
2
|
/** Devices per person per server. Must match the server's MLS_MAX_DEVICES, or extra devices are left out quietly. */
|
|
3
3
|
const MLS_MAX_DEVICES_PER_PERSON = 100;
|
|
4
4
|
const COMMIT_ATTEMPTS = 5;
|
|
5
|
+
const OWN_DEVICE_ADDS_AT_ONCE = 4;
|
|
5
6
|
/** socket.io closes the connection on a packet with more than 10 binary parts (GRYT-1522). */
|
|
6
7
|
const MAX_BINARY_PARTS = 10;
|
|
7
8
|
const LOG_PAGE = MAX_BINARY_PARTS;
|
|
@@ -1072,21 +1073,29 @@ export function createMlsDmDriver(options) {
|
|
|
1072
1073
|
const ids = (await store.listGroups()).map((r) => r.conversationId);
|
|
1073
1074
|
const first = (options.order ?? []).filter((id) => ids.includes(id));
|
|
1074
1075
|
const ordered = [...new Set([...first, ...ids])];
|
|
1075
|
-
const results =
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1076
|
+
const results = new Array(ordered.length);
|
|
1077
|
+
let done = 0;
|
|
1078
|
+
let next = 0;
|
|
1079
|
+
// Each group is its own commit, so a few go at once; `order` still decides which start first.
|
|
1080
|
+
const worker = async () => {
|
|
1081
|
+
while (next < ordered.length) {
|
|
1082
|
+
const i = next++;
|
|
1083
|
+
const conversationId = ordered[i];
|
|
1084
|
+
let result;
|
|
1085
|
+
try {
|
|
1086
|
+
result = await run(conversationId, () => addOwnDeviceTo(conversationId, deviceId, ownPersonKey));
|
|
1087
|
+
}
|
|
1088
|
+
catch (e) {
|
|
1089
|
+
if (e instanceof MlsDriverError && e.code === "not_own_device")
|
|
1090
|
+
throw e;
|
|
1091
|
+
const groupId = (await store.loadGroup(conversationId))?.groupId ?? "";
|
|
1092
|
+
result = { conversationId, groupId, outcome: "failed", add: null, error: e instanceof MlsDriverError ? e.code : "error" };
|
|
1093
|
+
}
|
|
1094
|
+
results[i] = result;
|
|
1095
|
+
options.onProgress?.({ done: ++done, total: ordered.length, result });
|
|
1086
1096
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
}
|
|
1097
|
+
};
|
|
1098
|
+
await Promise.all(Array.from({ length: Math.min(OWN_DEVICE_ADDS_AT_ONCE, ordered.length) }, worker));
|
|
1090
1099
|
return results;
|
|
1091
1100
|
}
|
|
1092
1101
|
return {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type PairingEmoji, type PairingEnvelope } from "@gryt/crypto";
|
|
2
2
|
import type { MlsOwnDeviceAdd } from "../mls/interfaces.js";
|
|
3
|
+
import type { HistoryStoreChooser } from "./historyStore.js";
|
|
3
4
|
import type { HistoryArchive, HistoryNotedMessage, HistoryProgress, OwnDeviceAdder, PairingClock, PairingDeviceInfo, PairingEndReason, PairingFetch } from "./interfaces.js";
|
|
4
5
|
import type { PairingRelay } from "./relayClient.js";
|
|
5
6
|
export type ApproverState = {
|
|
@@ -59,6 +60,8 @@ export interface ApproverOptions {
|
|
|
59
60
|
approvalMs?: number;
|
|
60
61
|
/** A's archive. Without it, no history goes across. */
|
|
61
62
|
history?: HistoryArchive;
|
|
63
|
+
/** Picks the server that holds the history. Without one, the history stays on A. */
|
|
64
|
+
historyStore?: HistoryStoreChooser;
|
|
62
65
|
/** How long after the adds A keeps collecting old-epoch messages for the last tail. */
|
|
63
66
|
lateWindowMs?: number;
|
|
64
67
|
}
|
|
@@ -67,6 +70,8 @@ export interface ApproverPairing {
|
|
|
67
70
|
subscribe(listener: (state: ApproverState) => void): () => void;
|
|
68
71
|
/** Null until Approve, or with no archive. */
|
|
69
72
|
readonly history: HistoryProgress | null;
|
|
73
|
+
/** True once Approve found no server to hold the history, so it stays on A. */
|
|
74
|
+
readonly historyLeftBehind: boolean;
|
|
70
75
|
subscribeHistory(listener: (progress: HistoryProgress) => void): () => void;
|
|
71
76
|
/** Every MLS message A archives while this runs, received or sent, for the tail. */
|
|
72
77
|
noteMessage(message: HistoryNotedMessage): void;
|
package/dist/pairing/approver.js
CHANGED
|
@@ -30,6 +30,7 @@ export function createApproverPairing(options) {
|
|
|
30
30
|
let confirming = null;
|
|
31
31
|
let deadline = 0;
|
|
32
32
|
let history = null;
|
|
33
|
+
let historyLeftBehind = false;
|
|
33
34
|
const historyListeners = new Set();
|
|
34
35
|
const tail = createTailRecorder();
|
|
35
36
|
const setHistory = (progress) => {
|
|
@@ -142,16 +143,30 @@ export function createApproverPairing(options) {
|
|
|
142
143
|
}
|
|
143
144
|
async function addEverywhere(ready) {
|
|
144
145
|
const added = {};
|
|
145
|
-
|
|
146
|
+
// Servers go at once, so progress is the sum over them.
|
|
147
|
+
const counts = new Map();
|
|
148
|
+
const report = (host) => {
|
|
149
|
+
let done = 0;
|
|
150
|
+
let total = 0;
|
|
151
|
+
for (const c of counts.values()) {
|
|
152
|
+
done += c.done;
|
|
153
|
+
total += c.total;
|
|
154
|
+
}
|
|
155
|
+
set({ phase: "adding", device: confirmed().device, host, done, total });
|
|
156
|
+
};
|
|
157
|
+
await Promise.all(ready.map(async ({ host, deviceId }) => {
|
|
146
158
|
const driver = devices(host);
|
|
147
159
|
if (!driver)
|
|
148
|
-
|
|
160
|
+
return;
|
|
149
161
|
for (let tries = 0;; tries++) {
|
|
150
162
|
try {
|
|
151
163
|
added[host] = await driver.addOwnDevice(deviceId, {
|
|
152
|
-
onProgress: ({ done, total }) =>
|
|
164
|
+
onProgress: ({ done, total }) => {
|
|
165
|
+
counts.set(host, { done, total });
|
|
166
|
+
report(host);
|
|
167
|
+
},
|
|
153
168
|
});
|
|
154
|
-
|
|
169
|
+
return;
|
|
155
170
|
}
|
|
156
171
|
catch (e) {
|
|
157
172
|
// The server lists N's device once its KeyPackages are in, which can trail N's "ready".
|
|
@@ -160,7 +175,7 @@ export function createApproverPairing(options) {
|
|
|
160
175
|
await clock.sleep(2000, abort.signal);
|
|
161
176
|
}
|
|
162
177
|
}
|
|
163
|
-
}
|
|
178
|
+
}));
|
|
164
179
|
return added;
|
|
165
180
|
}
|
|
166
181
|
/** Each group's cursor on every server in the envelope: the snapshot covers up to there. */
|
|
@@ -173,10 +188,10 @@ export function createApproverPairing(options) {
|
|
|
173
188
|
}
|
|
174
189
|
return snap;
|
|
175
190
|
}
|
|
176
|
-
function startHistory(archive) {
|
|
191
|
+
function startHistory(archive, store) {
|
|
177
192
|
const { id, token, session } = confirmed();
|
|
178
193
|
return createHistorySender({
|
|
179
|
-
|
|
194
|
+
store, archive, clock, signal: abort.signal,
|
|
180
195
|
post: (message) => relay.post(id, token, { type: "sealed", body: sealJson(session, message) }),
|
|
181
196
|
onProgress: setHistory,
|
|
182
197
|
});
|
|
@@ -187,13 +202,17 @@ export function createApproverPairing(options) {
|
|
|
187
202
|
let sender = null;
|
|
188
203
|
let snap = new Map();
|
|
189
204
|
let sealed = envelope;
|
|
190
|
-
|
|
205
|
+
const held = options.history && options.historyStore ? await options.historyStore(envelope.servers).catch(() => null) : null;
|
|
206
|
+
if (options.history && held) {
|
|
191
207
|
// Recording starts before the positions are read, so nothing lands between the two unseen.
|
|
192
208
|
tail.start();
|
|
193
209
|
snap = await positions(envelope);
|
|
194
|
-
sender = startHistory(options.history);
|
|
210
|
+
sender = startHistory(options.history, held.store);
|
|
195
211
|
const chunks = await sender.forEnvelope(Math.min(clock.now() + ENVELOPE_BUDGET_MS, deadline - 10_000));
|
|
196
|
-
sealed = { ...envelope, history: { key: sender.key, manifest: { v: 1, chunks } } };
|
|
212
|
+
sealed = { ...envelope, history: { key: sender.key, manifest: { v: 1, chunks }, host: held.host, transfer: held.transfer } };
|
|
213
|
+
}
|
|
214
|
+
else if (options.history && envelope.servers.length > 0) {
|
|
215
|
+
historyLeftBehind = true;
|
|
197
216
|
}
|
|
198
217
|
await relay.post(id, token, { type: "sealed", body: base64Url(session.seal(encodePairingEnvelope(sealed))) });
|
|
199
218
|
if (envelope.account && accessToken) {
|
|
@@ -237,6 +256,9 @@ export function createApproverPairing(options) {
|
|
|
237
256
|
get history() {
|
|
238
257
|
return history;
|
|
239
258
|
},
|
|
259
|
+
get historyLeftBehind() {
|
|
260
|
+
return historyLeftBehind;
|
|
261
|
+
},
|
|
240
262
|
subscribeHistory(listener) {
|
|
241
263
|
historyListeners.add(listener);
|
|
242
264
|
return () => void historyListeners.delete(listener);
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { type HistoryChunkEntry } from "@gryt/crypto";
|
|
2
2
|
import type { HistoryProgress, HistorySink, PairingClock } from "./interfaces.js";
|
|
3
|
-
import {
|
|
3
|
+
import type { HistoryChunkStore } from "./historyStore.js";
|
|
4
4
|
export interface HistoryReceiverOptions {
|
|
5
|
-
|
|
6
|
-
id: string;
|
|
7
|
-
token: string;
|
|
5
|
+
store: HistoryChunkStore;
|
|
8
6
|
key: Uint8Array;
|
|
9
7
|
sink: HistorySink;
|
|
10
8
|
clock: PairingClock;
|
|
@@ -13,7 +11,7 @@ export interface HistoryReceiverOptions {
|
|
|
13
11
|
}
|
|
14
12
|
/** N: fetches each listed chunk newest first, checks it against its entry, and stores each message once. */
|
|
15
13
|
export declare function createHistoryReceiver(options: HistoryReceiverOptions): {
|
|
16
|
-
done: Promise<
|
|
14
|
+
done: Promise<undefined>;
|
|
17
15
|
progress: () => {
|
|
18
16
|
messages: number;
|
|
19
17
|
total: number | null;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { openHistoryChunk, parseHistoryChunkEntry } from "@gryt/crypto";
|
|
2
2
|
import { PairingEnded } from "./channel.js";
|
|
3
3
|
import { emptyProgress } from "./historySender.js";
|
|
4
|
-
import {
|
|
4
|
+
import { HistoryStoreError } from "./historyStore.js";
|
|
5
5
|
const MAX_NETWORK_RETRIES = 5;
|
|
6
6
|
const messageKey = (r) => JSON.stringify([r.scope, r.conversationId, r.messageId]);
|
|
7
7
|
/** N: fetches each listed chunk newest first, checks it against its entry, and stores each message once. */
|
|
8
8
|
export function createHistoryReceiver(options) {
|
|
9
|
-
const {
|
|
9
|
+
const { store, key, sink, clock, signal } = options;
|
|
10
10
|
const progress = emptyProgress();
|
|
11
11
|
const pending = new Map();
|
|
12
12
|
const slots = new Set();
|
|
@@ -36,14 +36,12 @@ export function createHistoryReceiver(options) {
|
|
|
36
36
|
async function fetchChunk(n) {
|
|
37
37
|
for (let failures = 0;;) {
|
|
38
38
|
try {
|
|
39
|
-
return await
|
|
39
|
+
return await store.get(n, signal);
|
|
40
40
|
}
|
|
41
41
|
catch (e) {
|
|
42
|
-
if (e instanceof
|
|
42
|
+
if (e instanceof HistoryStoreError && e.code === "missing")
|
|
43
43
|
return "missing";
|
|
44
|
-
if (e instanceof
|
|
45
|
-
return "gone";
|
|
46
|
-
if (!(e instanceof PairingRelayError && e.code === "network") || ++failures > MAX_NETWORK_RETRIES)
|
|
44
|
+
if (!(e instanceof HistoryStoreError && e.code === "network") || ++failures > MAX_NETWORK_RETRIES)
|
|
47
45
|
throw e;
|
|
48
46
|
await clock.sleep(2000, signal);
|
|
49
47
|
}
|
|
@@ -77,8 +75,6 @@ export function createHistoryReceiver(options) {
|
|
|
77
75
|
}
|
|
78
76
|
const [n, entry] = item;
|
|
79
77
|
const sealed = await fetchChunk(n);
|
|
80
|
-
if (sealed === "gone")
|
|
81
|
-
return abandon();
|
|
82
78
|
pending.delete(n);
|
|
83
79
|
if (sealed === "missing") {
|
|
84
80
|
progress.missing++;
|
|
@@ -103,7 +99,7 @@ export function createHistoryReceiver(options) {
|
|
|
103
99
|
progress.messages += fresh.length;
|
|
104
100
|
progress.oldest = Math.min(progress.oldest ?? entry.first, entry.first);
|
|
105
101
|
report();
|
|
106
|
-
|
|
102
|
+
store.delete(n).catch(() => undefined);
|
|
107
103
|
}
|
|
108
104
|
}
|
|
109
105
|
signal.addEventListener("abort", nudge, { once: true });
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type HistoryChunkEntry, type HistoryRecord } from "@gryt/crypto";
|
|
2
2
|
import type { MlsOwnDeviceAdd } from "../mls/interfaces.js";
|
|
3
3
|
import type { HistoryArchive, HistoryNotedMessage, HistoryProgress, PairingClock } from "./interfaces.js";
|
|
4
|
-
import {
|
|
5
|
-
/** The
|
|
4
|
+
import type { HistoryChunkStore } from "./historyStore.js";
|
|
5
|
+
/** The server's 256 MiB per user, less room for the tail. */
|
|
6
6
|
export declare const HISTORY_SNAPSHOT_MAX_BYTES: number;
|
|
7
7
|
/** Entries in the envelope's manifest and in each later message, which keep under 256 and 64 KiB. */
|
|
8
8
|
export declare const HISTORY_ENVELOPE_ENTRIES = 400;
|
|
@@ -21,9 +21,7 @@ export interface HistoryMessage {
|
|
|
21
21
|
}
|
|
22
22
|
export declare const emptyProgress: () => HistoryProgress;
|
|
23
23
|
export interface HistorySenderOptions {
|
|
24
|
-
|
|
25
|
-
id: string;
|
|
26
|
-
token: string;
|
|
24
|
+
store: HistoryChunkStore;
|
|
27
25
|
archive: HistoryArchive;
|
|
28
26
|
clock: PairingClock;
|
|
29
27
|
signal: AbortSignal;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { generateHistoryKey, planHistoryChunks, sealHistoryChunk, } from "@gryt/crypto";
|
|
2
2
|
import { archiveByDay } from "./historySnapshot.js";
|
|
3
|
-
import {
|
|
4
|
-
/** The
|
|
3
|
+
import { HistoryStoreError } from "./historyStore.js";
|
|
4
|
+
/** The server's 256 MiB per user, less room for the tail. */
|
|
5
5
|
export const HISTORY_SNAPSHOT_MAX_BYTES = 248 * 1024 * 1024;
|
|
6
6
|
/** Entries in the envelope's manifest and in each later message, which keep under 256 and 64 KiB. */
|
|
7
7
|
export const HISTORY_ENVELOPE_ENTRIES = 400;
|
|
@@ -14,7 +14,7 @@ export const emptyProgress = () => ({
|
|
|
14
14
|
});
|
|
15
15
|
/** A: seals the archive into chunks newest first, uploads each, then lists it to N. */
|
|
16
16
|
export function createHistorySender(options) {
|
|
17
|
-
const {
|
|
17
|
+
const { store, clock, signal } = options;
|
|
18
18
|
const key = generateHistoryKey();
|
|
19
19
|
const maxBytes = options.maxBytes ?? HISTORY_SNAPSHOT_MAX_BYTES;
|
|
20
20
|
const progress = emptyProgress();
|
|
@@ -30,16 +30,16 @@ export function createHistorySender(options) {
|
|
|
30
30
|
async function put(n, sealed) {
|
|
31
31
|
for (let failures = 0;;) {
|
|
32
32
|
try {
|
|
33
|
-
return await
|
|
33
|
+
return await store.put(n, sealed, signal);
|
|
34
34
|
}
|
|
35
35
|
catch (e) {
|
|
36
|
-
if (!(e instanceof
|
|
36
|
+
if (!(e instanceof HistoryStoreError && e.code === "network") || ++failures > MAX_NETWORK_RETRIES)
|
|
37
37
|
throw e;
|
|
38
38
|
await clock.sleep(2000, signal);
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
-
/** False once the cap or the
|
|
42
|
+
/** False once the cap or the store says no more. */
|
|
43
43
|
async function upload(records, capped) {
|
|
44
44
|
for (const chunk of planHistoryChunks(records)) {
|
|
45
45
|
if (signal.aborted)
|
|
@@ -52,8 +52,8 @@ export function createHistorySender(options) {
|
|
|
52
52
|
await put(n, sealed);
|
|
53
53
|
}
|
|
54
54
|
catch (e) {
|
|
55
|
-
// The
|
|
56
|
-
if (capped && e instanceof
|
|
55
|
+
// The server's own caps: whatever didn't fit stays behind, the same as our own cap.
|
|
56
|
+
if (capped && e instanceof HistoryStoreError && e.code === "full")
|
|
57
57
|
return false;
|
|
58
58
|
throw e;
|
|
59
59
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PairingServer } from "@gryt/crypto";
|
|
2
|
+
/** Where A leaves sealed history chunks and N picks them up: one of the user's own servers, never Gryt's relay (GRYT-2055). */
|
|
3
|
+
export interface HistoryChunkStore {
|
|
4
|
+
put(n: number, sealed: Uint8Array, signal?: AbortSignal): Promise<void>;
|
|
5
|
+
get(n: number, signal?: AbortSignal): Promise<Uint8Array>;
|
|
6
|
+
delete(n: number): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/** "network" is retried, "full" ends the snapshot early, "missing" is a chunk the store doesn't have. */
|
|
9
|
+
export declare class HistoryStoreError extends Error {
|
|
10
|
+
readonly code: "network" | "full" | "missing" | "refused";
|
|
11
|
+
constructor(code: HistoryStoreError["code"], message?: string);
|
|
12
|
+
}
|
|
13
|
+
/** A's choice of a server both devices are on that will hold the history, or null when none will. */
|
|
14
|
+
export type HistoryStoreChooser = (servers: PairingServer[]) => Promise<{
|
|
15
|
+
host: string;
|
|
16
|
+
transfer: string;
|
|
17
|
+
store: HistoryChunkStore;
|
|
18
|
+
} | null>;
|
|
19
|
+
/** N's way into the store the envelope names. */
|
|
20
|
+
export type HistoryStoreOpener = (host: string, transfer: string) => HistoryChunkStore;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** "network" is retried, "full" ends the snapshot early, "missing" is a chunk the store doesn't have. */
|
|
2
|
+
export class HistoryStoreError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message = `History store: ${code}`) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "HistoryStoreError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type PairingEmoji, type PairingServer } from "@gryt/crypto";
|
|
2
|
+
import type { HistoryStoreOpener } from "./historyStore.js";
|
|
2
3
|
import type { HistoryProgress, HistorySink, PairedServerDevice, PairingClock, PairingDeviceInfo, PairingEndReason, PairingOidc, PairingStorage } from "./interfaces.js";
|
|
3
4
|
import type { PairingRelay } from "./relayClient.js";
|
|
4
5
|
export type NewDeviceState = {
|
|
@@ -46,14 +47,18 @@ export interface NewDeviceOptions {
|
|
|
46
47
|
/** Only for a server with its own auth server: goes in the QR for A to check against its own. */
|
|
47
48
|
relayOrigin?: string;
|
|
48
49
|
approvalMs?: number;
|
|
49
|
-
/** N's archive. Without it, history A sends is left
|
|
50
|
+
/** N's archive. Without it, history A sends is left where A put it. */
|
|
50
51
|
history?: HistorySink;
|
|
52
|
+
/** Opens the server the envelope names for the history. Without it, no history comes across. */
|
|
53
|
+
historyStore?: HistoryStoreOpener;
|
|
51
54
|
}
|
|
52
55
|
export interface NewDevicePairing {
|
|
53
56
|
readonly state: NewDeviceState;
|
|
54
57
|
subscribe(listener: (state: NewDeviceState) => void): () => void;
|
|
55
58
|
/** Null until the envelope brings a history key, or with no sink. */
|
|
56
59
|
readonly history: HistoryProgress | null;
|
|
60
|
+
/** True when A had servers but sent no history, so the old messages stay on A. */
|
|
61
|
+
readonly historyLeftBehind: boolean;
|
|
57
62
|
subscribeHistory(listener: (progress: HistoryProgress) => void): () => void;
|
|
58
63
|
start(): void;
|
|
59
64
|
/** In `joining`: N's device on each server, once its KeyPackages are up. */
|
|
@@ -16,6 +16,7 @@ export function createNewDevicePairing(options) {
|
|
|
16
16
|
let current = null;
|
|
17
17
|
let receiver = null;
|
|
18
18
|
let history = null;
|
|
19
|
+
let historyLeftBehind = false;
|
|
19
20
|
const historyListeners = new Set();
|
|
20
21
|
const setHistory = (progress) => {
|
|
21
22
|
history = progress;
|
|
@@ -156,12 +157,17 @@ export function createNewDevicePairing(options) {
|
|
|
156
157
|
const envelope = outcome;
|
|
157
158
|
const tokens = envelope.account ? await signIn(envelope.account, envelope.from) : null;
|
|
158
159
|
await storage.commit(envelope, tokens);
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
const held = envelope.history;
|
|
161
|
+
// History only ever comes from a server the envelope names; an older A's relay copy is left there.
|
|
162
|
+
if (held?.host && held.transfer && options.history && options.historyStore) {
|
|
161
163
|
receiver = createHistoryReceiver({
|
|
162
|
-
|
|
164
|
+
store: options.historyStore(held.host, held.transfer),
|
|
165
|
+
key: held.key, sink: options.history, clock, signal: abort.signal, onProgress: setHistory,
|
|
163
166
|
});
|
|
164
|
-
receiver.listManifest(
|
|
167
|
+
receiver.listManifest(held.manifest.chunks);
|
|
168
|
+
}
|
|
169
|
+
else if (envelope.servers.length > 0) {
|
|
170
|
+
historyLeftBehind = true;
|
|
165
171
|
}
|
|
166
172
|
set({ phase: "joining", servers: envelope.servers, from: envelope.from });
|
|
167
173
|
}
|
|
@@ -220,6 +226,9 @@ export function createNewDevicePairing(options) {
|
|
|
220
226
|
get history() {
|
|
221
227
|
return history;
|
|
222
228
|
},
|
|
229
|
+
get historyLeftBehind() {
|
|
230
|
+
return historyLeftBehind;
|
|
231
|
+
},
|
|
223
232
|
subscribeHistory(listener) {
|
|
224
233
|
historyListeners.add(listener);
|
|
225
234
|
return () => void historyListeners.delete(listener);
|
|
@@ -234,7 +243,14 @@ export function createNewDevicePairing(options) {
|
|
|
234
243
|
throw new Error("Not waiting to join.");
|
|
235
244
|
const { from } = state;
|
|
236
245
|
const body = sealJson(current.session, { t: "ready", devices: devices.map(({ host, deviceId }) => ({ host, deviceId })) });
|
|
237
|
-
|
|
246
|
+
try {
|
|
247
|
+
await relay.post(current.id, current.token, { type: "sealed", body });
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
// A closed before N got here: say so, rather than sit on "joining" with nothing listening.
|
|
251
|
+
await end(reasonFor(e));
|
|
252
|
+
throw e;
|
|
253
|
+
}
|
|
238
254
|
set({ phase: "linked", from });
|
|
239
255
|
void linked(from);
|
|
240
256
|
},
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { HistoryChunkStore, HistoryStoreChooser, HistoryStoreError as StoreError, HistoryStoreOpener } from "./historyStore.js";
|
|
2
|
+
import type { PairingClock } from "./interfaces.js";
|
|
3
|
+
/** `fetch`, cut down to what the store uses. A body here is bytes, which PairingFetch can't send. */
|
|
4
|
+
export type HistoryFetch = (url: string, init: {
|
|
5
|
+
method: string;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
body?: Uint8Array;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}) => Promise<{
|
|
10
|
+
status: number;
|
|
11
|
+
json(): Promise<{
|
|
12
|
+
linkHistory?: boolean;
|
|
13
|
+
} | null>;
|
|
14
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
15
|
+
}>;
|
|
16
|
+
/** How this device reaches a server: its address and, once it has joined, its token. */
|
|
17
|
+
export interface ServerAccess {
|
|
18
|
+
base(host: string): string;
|
|
19
|
+
token(host: string): Promise<string | null> | string | null;
|
|
20
|
+
}
|
|
21
|
+
export declare function historyStoreError(status: number): StoreError;
|
|
22
|
+
export declare function serverHistoryStore(access: ServerAccess, host: string, transfer: string, fetch: HistoryFetch, clock?: PairingClock): HistoryChunkStore;
|
|
23
|
+
/** The first server this device is signed in to that says it holds linked devices' history. */
|
|
24
|
+
export declare function chooseHistoryServer(access: ServerAccess, fetch: HistoryFetch, clock?: PairingClock): HistoryStoreChooser;
|
|
25
|
+
/** The new device's side: the server the envelope names. */
|
|
26
|
+
export declare function openServerHistory(access: ServerAccess, fetch: HistoryFetch, clock?: PairingClock): HistoryStoreOpener;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { base64Url } from "@gryt/crypto";
|
|
2
|
+
import { randomBytes } from "@noble/hashes/utils.js";
|
|
3
|
+
import { HistoryStoreError } from "./historyStore.js";
|
|
4
|
+
import { systemClock } from "./channel.js";
|
|
5
|
+
/** The new device only gets a token once it has joined, which can trail the envelope. */
|
|
6
|
+
const TOKEN_WAIT_MS = 90_000;
|
|
7
|
+
export function historyStoreError(status) {
|
|
8
|
+
if (status === 404)
|
|
9
|
+
return new HistoryStoreError("missing");
|
|
10
|
+
if (status === 413 || status === 429)
|
|
11
|
+
return new HistoryStoreError("full");
|
|
12
|
+
// A stale token gets refreshed by the connection, and a server error may pass: both are worth another try.
|
|
13
|
+
if (status === 401 || (status >= 500 && status !== 503))
|
|
14
|
+
return new HistoryStoreError("network");
|
|
15
|
+
return new HistoryStoreError("refused");
|
|
16
|
+
}
|
|
17
|
+
export function serverHistoryStore(access, host, transfer, fetch, clock = systemClock) {
|
|
18
|
+
async function tokenFor(signal) {
|
|
19
|
+
for (const start = clock.now();; await clock.sleep(1000, signal)) {
|
|
20
|
+
const token = await access.token(host);
|
|
21
|
+
if (token)
|
|
22
|
+
return token;
|
|
23
|
+
if (clock.now() - start > TOKEN_WAIT_MS)
|
|
24
|
+
throw new HistoryStoreError("refused", `No session on ${host} to fetch history from.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function call(method, n, signal, body) {
|
|
28
|
+
const headers = { authorization: `Bearer ${await tokenFor(signal)}` };
|
|
29
|
+
if (body)
|
|
30
|
+
headers["content-type"] = "application/octet-stream";
|
|
31
|
+
const url = `${access.base(host)}/api/link-history/${transfer}/${n}`;
|
|
32
|
+
const res = await fetch(url, { method, headers, body, signal }).catch(() => {
|
|
33
|
+
throw new HistoryStoreError("network");
|
|
34
|
+
});
|
|
35
|
+
if (res.status < 200 || res.status > 299)
|
|
36
|
+
throw historyStoreError(res.status);
|
|
37
|
+
return res;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
put: async (n, sealed, signal) => void (await call("PUT", n, signal, sealed)),
|
|
41
|
+
get: async (n, signal) => new Uint8Array(await (await call("GET", n, signal)).arrayBuffer()),
|
|
42
|
+
delete: async (n) => void (await call("DELETE", n)),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** The first server this device is signed in to that says it holds linked devices' history. */
|
|
46
|
+
export function chooseHistoryServer(access, fetch, clock = systemClock) {
|
|
47
|
+
return async (servers) => {
|
|
48
|
+
for (const { host } of servers) {
|
|
49
|
+
if (!(await access.token(host)))
|
|
50
|
+
continue;
|
|
51
|
+
const info = await fetch(`${access.base(host)}/info`, { method: "GET" })
|
|
52
|
+
.then((r) => (r.status === 200 ? r.json() : null))
|
|
53
|
+
.catch(() => null);
|
|
54
|
+
if (info?.linkHistory !== true)
|
|
55
|
+
continue;
|
|
56
|
+
const transfer = base64Url(randomBytes(18));
|
|
57
|
+
return { host, transfer, store: serverHistoryStore(access, host, transfer, fetch, clock) };
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** The new device's side: the server the envelope names. */
|
|
63
|
+
export function openServerHistory(access, fetch, clock = systemClock) {
|
|
64
|
+
return (host, transfer) => serverHistoryStore(access, host, transfer, fetch, clock);
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gryt/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.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": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"access": "public"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@gryt/crypto": "0.
|
|
46
|
+
"@gryt/crypto": "0.14.0",
|
|
47
47
|
"@noble/hashes": "^2.3.0"
|
|
48
48
|
}
|
|
49
49
|
}
|