@gryt/core 0.32.1 → 0.33.1
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/pairing/approver.d.ts +5 -0
- package/dist/pairing/approver.js +17 -6
- 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 +9 -1
- package/dist/pairing/newDevice.js +25 -4
- 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";
|
|
@@ -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) => {
|
|
@@ -187,10 +188,10 @@ export function createApproverPairing(options) {
|
|
|
187
188
|
}
|
|
188
189
|
return snap;
|
|
189
190
|
}
|
|
190
|
-
function startHistory(archive) {
|
|
191
|
+
function startHistory(archive, store) {
|
|
191
192
|
const { id, token, session } = confirmed();
|
|
192
193
|
return createHistorySender({
|
|
193
|
-
|
|
194
|
+
store, archive, clock, signal: abort.signal,
|
|
194
195
|
post: (message) => relay.post(id, token, { type: "sealed", body: sealJson(session, message) }),
|
|
195
196
|
onProgress: setHistory,
|
|
196
197
|
});
|
|
@@ -201,13 +202,17 @@ export function createApproverPairing(options) {
|
|
|
201
202
|
let sender = null;
|
|
202
203
|
let snap = new Map();
|
|
203
204
|
let sealed = envelope;
|
|
204
|
-
|
|
205
|
+
const held = options.history && options.historyStore ? await options.historyStore(envelope.servers).catch(() => null) : null;
|
|
206
|
+
if (options.history && held) {
|
|
205
207
|
// Recording starts before the positions are read, so nothing lands between the two unseen.
|
|
206
208
|
tail.start();
|
|
207
209
|
snap = await positions(envelope);
|
|
208
|
-
sender = startHistory(options.history);
|
|
210
|
+
sender = startHistory(options.history, held.store);
|
|
209
211
|
const chunks = await sender.forEnvelope(Math.min(clock.now() + ENVELOPE_BUDGET_MS, deadline - 10_000));
|
|
210
|
-
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;
|
|
211
216
|
}
|
|
212
217
|
await relay.post(id, token, { type: "sealed", body: base64Url(session.seal(encodePairingEnvelope(sealed))) });
|
|
213
218
|
if (envelope.account && accessToken) {
|
|
@@ -234,7 +239,10 @@ export function createApproverPairing(options) {
|
|
|
234
239
|
}
|
|
235
240
|
set({ phase: "sending", device, added });
|
|
236
241
|
await sender.send(tail.take(snap, added), false);
|
|
237
|
-
|
|
242
|
+
// Late old-epoch messages can only come from a group N was just added to.
|
|
243
|
+
if (Object.values(added).some((groups) => groups.length > 0)) {
|
|
244
|
+
await clock.sleep(options.lateWindowMs ?? HISTORY_LATE_WINDOW_MS, abort.signal);
|
|
245
|
+
}
|
|
238
246
|
await sender.send(tail.take(snap, added), true);
|
|
239
247
|
// N closes the session once it has every chunk; closing here would delete them.
|
|
240
248
|
set({ phase: "done", device, added });
|
|
@@ -251,6 +259,9 @@ export function createApproverPairing(options) {
|
|
|
251
259
|
get history() {
|
|
252
260
|
return history;
|
|
253
261
|
},
|
|
262
|
+
get historyLeftBehind() {
|
|
263
|
+
return historyLeftBehind;
|
|
264
|
+
},
|
|
254
265
|
subscribeHistory(listener) {
|
|
255
266
|
historyListeners.add(listener);
|
|
256
267
|
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,21 @@ 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;
|
|
54
|
+
/** This device's archive scope for a server. A's records are refiled under it, since the
|
|
55
|
+
desktop files by server key and the phone by host. */
|
|
56
|
+
localScope?: (host: string) => string;
|
|
51
57
|
}
|
|
52
58
|
export interface NewDevicePairing {
|
|
53
59
|
readonly state: NewDeviceState;
|
|
54
60
|
subscribe(listener: (state: NewDeviceState) => void): () => void;
|
|
55
61
|
/** Null until the envelope brings a history key, or with no sink. */
|
|
56
62
|
readonly history: HistoryProgress | null;
|
|
63
|
+
/** True when A had servers but sent no history, so the old messages stay on A. */
|
|
64
|
+
readonly historyLeftBehind: boolean;
|
|
57
65
|
subscribeHistory(listener: (progress: HistoryProgress) => void): () => void;
|
|
58
66
|
start(): void;
|
|
59
67
|
/** In `joining`: N's device on each server, once its KeyPackages are up. */
|
|
@@ -3,6 +3,18 @@ import { sha256 } from "@noble/hashes/sha2.js";
|
|
|
3
3
|
import { randomBytes } from "@noble/hashes/utils.js";
|
|
4
4
|
import { mailbox, openJson, PairingEnded, reasonFor, sealJson, systemClock } from "./channel.js";
|
|
5
5
|
import { createHistoryReceiver } from "./historyReceiver.js";
|
|
6
|
+
function refiled(sink, servers, localScope) {
|
|
7
|
+
if (!localScope)
|
|
8
|
+
return sink;
|
|
9
|
+
const hosts = new Map(servers.map((s) => [s.scope, s.host]));
|
|
10
|
+
// Asked per batch: the desktop's scope needs the server's key, pinned only once it has joined.
|
|
11
|
+
return {
|
|
12
|
+
put: (records) => sink.put(records.map((r) => {
|
|
13
|
+
const host = hosts.get(r.scope);
|
|
14
|
+
return host === undefined ? r : { ...r, scope: localScope(host) };
|
|
15
|
+
})),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
6
18
|
export const PAIRING_APPROVAL_MS = 60_000;
|
|
7
19
|
const PAIRING_OIDC_SCOPE = "openid profile email offline_access";
|
|
8
20
|
/** N: shows the QR and code, checks the emoji, opens the envelope, signs in, then says ready. */
|
|
@@ -16,6 +28,7 @@ export function createNewDevicePairing(options) {
|
|
|
16
28
|
let current = null;
|
|
17
29
|
let receiver = null;
|
|
18
30
|
let history = null;
|
|
31
|
+
let historyLeftBehind = false;
|
|
19
32
|
const historyListeners = new Set();
|
|
20
33
|
const setHistory = (progress) => {
|
|
21
34
|
history = progress;
|
|
@@ -156,12 +169,17 @@ export function createNewDevicePairing(options) {
|
|
|
156
169
|
const envelope = outcome;
|
|
157
170
|
const tokens = envelope.account ? await signIn(envelope.account, envelope.from) : null;
|
|
158
171
|
await storage.commit(envelope, tokens);
|
|
159
|
-
|
|
160
|
-
|
|
172
|
+
const held = envelope.history;
|
|
173
|
+
// History only ever comes from a server the envelope names; an older A's relay copy is left there.
|
|
174
|
+
if (held?.host && held.transfer && options.history && options.historyStore) {
|
|
161
175
|
receiver = createHistoryReceiver({
|
|
162
|
-
|
|
176
|
+
store: options.historyStore(held.host, held.transfer),
|
|
177
|
+
key: held.key, sink: refiled(options.history, envelope.servers, options.localScope), clock, signal: abort.signal, onProgress: setHistory,
|
|
163
178
|
});
|
|
164
|
-
receiver.listManifest(
|
|
179
|
+
receiver.listManifest(held.manifest.chunks);
|
|
180
|
+
}
|
|
181
|
+
else if (envelope.servers.length > 0) {
|
|
182
|
+
historyLeftBehind = true;
|
|
165
183
|
}
|
|
166
184
|
set({ phase: "joining", servers: envelope.servers, from: envelope.from });
|
|
167
185
|
}
|
|
@@ -220,6 +238,9 @@ export function createNewDevicePairing(options) {
|
|
|
220
238
|
get history() {
|
|
221
239
|
return history;
|
|
222
240
|
},
|
|
241
|
+
get historyLeftBehind() {
|
|
242
|
+
return historyLeftBehind;
|
|
243
|
+
},
|
|
223
244
|
subscribeHistory(listener) {
|
|
224
245
|
historyListeners.add(listener);
|
|
225
246
|
return () => void historyListeners.delete(listener);
|
|
@@ -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.1",
|
|
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
|
}
|