@gryt/core 0.32.1 → 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 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;
@@ -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
- relay, id, token, archive, clock, signal: abort.signal,
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
- if (options.history) {
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) {
@@ -251,6 +256,9 @@ export function createApproverPairing(options) {
251
256
  get history() {
252
257
  return history;
253
258
  },
259
+ get historyLeftBehind() {
260
+ return historyLeftBehind;
261
+ },
254
262
  subscribeHistory(listener) {
255
263
  historyListeners.add(listener);
256
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 { type PairingRelay } from "./relayClient.ts.js";
3
+ import type { HistoryChunkStore } from "./historyStore.js";
4
4
  export interface HistoryReceiverOptions {
5
- relay: PairingRelay;
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<void>;
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 { PairingRelayError } from "./relayClient.js";
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 { relay, id, token, key, sink, clock, signal } = options;
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 relay.getChunk(id, token, n, signal);
39
+ return await store.get(n, signal);
40
40
  }
41
41
  catch (e) {
42
- if (e instanceof PairingRelayError && e.status === 404)
42
+ if (e instanceof HistoryStoreError && e.code === "missing")
43
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)
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
- relay.deleteChunk(id, token, n).catch(() => undefined);
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 { type PairingRelay } from "./relayClient.ts.js";
5
- /** The relay's 256 MiB per session, less room for the tail. */
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
- relay: PairingRelay;
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 { PairingRelayError } from "./relayClient.js";
4
- /** The relay's 256 MiB per session, less room for the tail. */
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 { relay, id, token, clock, signal } = options;
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 relay.putChunk(id, token, n, sealed, signal);
33
+ return await store.put(n, sealed, signal);
34
34
  }
35
35
  catch (e) {
36
- if (!(e instanceof PairingRelayError && e.code === "network") || ++failures > MAX_NETWORK_RETRIES)
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 relay says no more. */
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 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"))
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 on the relay. */
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
- if (envelope.history && options.history) {
160
- const { id, token } = paired();
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
- relay, id, token, key: envelope.history.key, sink: options.history, clock, signal: abort.signal, onProgress: setHistory,
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(envelope.history.manifest.chunks);
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);
@@ -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.32.1",
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.13.0",
46
+ "@gryt/crypto": "0.14.0",
47
47
  "@noble/hashes": "^2.3.0"
48
48
  }
49
49
  }