@gryt/core 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,6 +69,73 @@ 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
- /** Why a pairing stopped. `approve:<error>` carries the extension's own error code. */
74
- 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" | `approve:${string}`;
75
+ /** Why a pairing stopped. `approve:<error>` carries the extension's own error code for anything
76
+ below that isn't broken out on its own (GRYT-1578; auth#46 has the full list). */
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"
80
+ /** The approve endpoint says this user_code already got its one answer. */
81
+ | "code_used"
82
+ /** The approve endpoint says the user_code isn't valid any more: unknown, expired, or not pending. */
83
+ | "code_expired"
84
+ /** The approve endpoint refuses because the account has something pending, like verifying an email. */
85
+ | "required_actions"
86
+ /** The approve endpoint refuses A's access token as too old; refresh it and try again. */
87
+ | "stale_token"
88
+ /** The device grant itself said no: A denied it, or the approve endpoint's refusal denied the code. */
89
+ | "access_denied"
90
+ /** The device grant's user_code ran out before anyone answered it. */
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;
@@ -116,10 +125,15 @@ export function createNewDevicePairing(options) {
116
125
  throw failure;
117
126
  if (polled.status === "ok")
118
127
  return checkIdToken(polled.tokens, account, session.keycloakNonce);
128
+ // RFC 8628's own two terminal errors, plus what auth#46 leaves behind when A's approve
129
+ // endpoint refuses the code: either way N's device grant sees it as denied.
130
+ if (polled.status === "denied")
131
+ throw new PairingEnded("access_denied");
132
+ if (polled.status === "expired")
133
+ throw new PairingEnded("expired_token");
119
134
  if (polled.status === "slow_down")
120
135
  interval += 5000;
121
- else if (polled.status !== "pending")
122
- break;
136
+ // Anything else is "pending": keep polling.
123
137
  }
124
138
  throw new PairingEnded("sign_in_failed");
125
139
  }
@@ -136,6 +150,13 @@ export function createNewDevicePairing(options) {
136
150
  const envelope = outcome;
137
151
  const tokens = envelope.account ? await signIn(envelope.account, envelope.from) : null;
138
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
+ }
139
160
  set({ phase: "joining", servers: envelope.servers, from: envelope.from });
140
161
  }
141
162
  catch (e) {
@@ -143,20 +164,43 @@ export function createNewDevicePairing(options) {
143
164
  await end(reasonFor(e));
144
165
  }
145
166
  }
167
+ /** Reads A's history messages until every chunk is in, or A closing is the finish. */
146
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()));
147
175
  try {
148
- // History from A lands here in a later version. Until then, A closing is the finish.
149
- while (await current.box.next(abort.signal))
150
- ;
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
+ }
151
184
  }
152
185
  catch (e) {
153
186
  if (abort.signal.aborted)
154
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
+ }
155
195
  const reason = reasonFor(e);
156
196
  if (reason !== "cancelled_by_other" && reason !== "expired")
157
197
  return end(reason);
198
+ receiver?.abandon();
158
199
  set({ phase: "done", from });
159
- current?.session?.close();
200
+ session?.close();
201
+ }
202
+ finally {
203
+ abort.signal.removeEventListener("abort", onAbort);
160
204
  }
161
205
  }
162
206
  return {
@@ -167,6 +211,13 @@ export function createNewDevicePairing(options) {
167
211
  listeners.add(listener);
168
212
  return () => void listeners.delete(listener);
169
213
  },
214
+ get history() {
215
+ return history;
216
+ },
217
+ subscribeHistory(listener) {
218
+ historyListeners.add(listener);
219
+ return () => void historyListeners.delete(listener);
220
+ },
170
221
  start() {
171
222
  if (state.phase !== "idle")
172
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
  }
@@ -0,0 +1 @@
1
+ export { FakeDeliveryService, MemoryMlsStore } from "./mls/deliveryService.fake.js";
@@ -0,0 +1,3 @@
1
+ /* For the apps' own tests: the in-memory delivery service and state store the driver's tests
2
+ use, so a session can run the real driver against it. Not for app code. */
3
+ export { FakeDeliveryService, MemoryMlsStore } from "./mls/deliveryService.fake.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.14.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": {
@@ -14,6 +14,10 @@
14
14
  ".": {
15
15
  "types": "./dist/index.d.ts",
16
16
  "default": "./dist/index.js"
17
+ },
18
+ "./testing": {
19
+ "types": "./dist/testing.d.ts",
20
+ "default": "./dist/testing.js"
17
21
  }
18
22
  },
19
23
  "files": [
@@ -35,7 +39,7 @@
35
39
  "access": "public"
36
40
  },
37
41
  "dependencies": {
38
- "@gryt/crypto": "0.8.0",
42
+ "@gryt/crypto": "0.9.0",
39
43
  "@noble/hashes": "^2.3.0"
40
44
  }
41
45
  }