This commit is contained in:
Bruno Windels 2021-10-26 18:47:46 +02:00
parent abb802b881
commit faf4ea6434
6 changed files with 326 additions and 52 deletions

View File

@ -29,6 +29,7 @@ export class SessionLoadViewModel extends ViewModel {
this._loading = false; this._loading = false;
this._error = null; this._error = null;
this.backUrl = this.urlCreator.urlForSegment("session", true); this.backUrl = this.urlCreator.urlForSegment("session", true);
this._dehydratedDevice = undefined;
} }
async start() { async start() {
@ -110,6 +111,10 @@ export class SessionLoadViewModel extends ViewModel {
// Statuses related to login are handled by respective login view models // Statuses related to login are handled by respective login view models
if (sc) { if (sc) {
switch (sc.loadStatus.get()) { switch (sc.loadStatus.get()) {
case LoadStatus.QueryAccount:
return `Querying account encryption setup…`;
case LoadStatus.SetupAccount:
return `Please enter your password to restore your encryption setup`;
case LoadStatus.SessionSetup: case LoadStatus.SessionSetup:
return `Setting up your encryption keys…`; return `Setting up your encryption keys…`;
case LoadStatus.Loading: case LoadStatus.Loading:
@ -136,4 +141,27 @@ export class SessionLoadViewModel extends ViewModel {
const logExport = await this.logger.export(); const logExport = await this.logger.export();
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`);
} }
get canSetupAccount() {
return this._sessionContainer.loadStatus === LoadStatus.SetupAccount;
}
get canDehydrateDevice() {
return this.canSetupAccount && !!this._sessionContainer.accountSetup.encryptedDehydratedDevice;
}
tryDecryptDehydratedDevice(password) {
const {encryptedDehydratedDevice} = this._sessionContainer.accountSetup;
if (encryptedDehydratedDevice) {
this._dehydratedDevice = encryptedDehydratedDevice.decrypt(password);
return !!this._dehydratedDevice;
}
return false;
}
finishAccountSetup() {
const dehydratedDevice = this._dehydratedDevice;
this._dehydratedDevice = undefined;
this._sessionContainer.accountSetup.finish(dehydratedDevice);
}
} }

View File

@ -248,23 +248,77 @@ export class Session {
async createIdentity(log) { async createIdentity(log) {
if (this._olm) { if (this._olm) {
if (!this._e2eeAccount) { if (!this._e2eeAccount) {
this._e2eeAccount = await E2EEAccount.create({ this._e2eeAccount = this._createNewAccount(this._sessionInfo.deviceId, this._storage);
hsApi: this._hsApi,
olm: this._olm,
pickleKey: PICKLE_KEY,
userId: this._sessionInfo.userId,
deviceId: this._sessionInfo.deviceId,
olmWorker: this._olmWorker,
storage: this._storage,
});
log.set("keys", this._e2eeAccount.identityKeys); log.set("keys", this._e2eeAccount.identityKeys);
this._setupEncryption(); this._setupEncryption();
} }
await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log); await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log);
await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, log)); await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, undefined, log));
} }
} }
/** @internal */
dehydrateIdentity(dehydratedDevice, log = null) {
this._platform.logger.wrapOrRun(log, "dehydrateIdentity", async log => {
log.set("deviceId", dehydratedDevice.deviceId);
if (!this._olm) {
log.set("no_olm", true);
return false;
}
if (dehydratedDevice.deviceId !== this.deviceId) {
log.set("wrong_device", true);
return false;
}
if (this._e2eeAccount) {
log.set("account_already_setup", true);
return false;
}
if (!await dehydratedDevice.claim(this._hsApi, log)) {
log.set("already_claimed", true);
return false;
}
this._e2eeAccount = await E2EEAccount.adoptDehydratedDevice({
dehydratedDevice,
hsApi: this._hsApi,
olm: this._olm,
pickleKey: PICKLE_KEY,
userId: this._sessionInfo.userId,
olmWorker: this._olmWorker,
deviceId,
storage,
});
log.set("keys", this._e2eeAccount.identityKeys);
this._setupEncryption();
return true;
});
}
_createNewAccount(deviceId, storage = undefined) {
// storage is optional and if omitted the account won't be persisted (useful for dehydrating devices)
return E2EEAccount.create({
hsApi: this._hsApi,
olm: this._olm,
pickleKey: PICKLE_KEY,
userId: this._sessionInfo.userId,
olmWorker: this._olmWorker,
deviceId,
storage,
});
}
setupDehydratedDevice(key, log = null) {
return this._platform.logger.wrapOrRun(log, "setupDehydratedDevice", async log => {
const dehydrationAccount = await this._createNewAccount("temp-device-id");
try {
const deviceId = await uploadAccountAsDehydratedDevice(
dehydrationAccount, this._hsApi, key, "Dehydrated device", log);
return deviceId;
} finally {
dehydrationAccount.dispose();
}
});
}
/** @internal */ /** @internal */
async load(log) { async load(log) {
const txn = await this._storage.readTxn([ const txn = await this._storage.readTxn([
@ -323,6 +377,7 @@ export class Session {
this._olmWorker?.dispose(); this._olmWorker?.dispose();
this._sessionBackup?.dispose(); this._sessionBackup?.dispose();
this._megolmDecryption.dispose(); this._megolmDecryption.dispose();
this._e2eeAccount?.dispose();
for (const room of this._rooms.values()) { for (const room of this._rooms.values()) {
room.dispose(); room.dispose();
} }
@ -517,7 +572,7 @@ export class Session {
if (!isCatchupSync) { if (!isCatchupSync) {
const needsToUploadOTKs = await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log); const needsToUploadOTKs = await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log);
if (needsToUploadOTKs) { if (needsToUploadOTKs) {
await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, log)); await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, undefined, log));
} }
} }
} }

View File

@ -34,6 +34,8 @@ export const LoadStatus = createEnum(
"NotLoading", "NotLoading",
"Login", "Login",
"LoginFailed", "LoginFailed",
"QueryAccount", // check for dehydrated device after login
"SetupAccount", // asked to restore from dehydrated device if present, call sc.accountSetup.finish() to progress to the next stage
"Loading", "Loading",
"SessionSetup", // upload e2ee keys, ... "SessionSetup", // upload e2ee keys, ...
"Migrating", //not used atm, but would fit here "Migrating", //not used atm, but would fit here
@ -85,7 +87,7 @@ export class SessionContainer {
if (!sessionInfo) { if (!sessionInfo) {
throw new Error("Invalid session id: " + sessionId); throw new Error("Invalid session id: " + sessionId);
} }
await this._loadSessionInfo(sessionInfo, false, log); await this._loadSessionInfo(sessionInfo, null, log);
log.set("status", this._status.get()); log.set("status", this._status.get());
} catch (err) { } catch (err) {
log.catch(err); log.catch(err);
@ -154,7 +156,6 @@ export class SessionContainer {
lastUsed: clock.now() lastUsed: clock.now()
}; };
log.set("id", sessionId); log.set("id", sessionId);
await this._platform.sessionInfoStorage.add(sessionInfo);
} catch (err) { } catch (err) {
this._error = err; this._error = err;
if (err.name === "HomeServerError") { if (err.name === "HomeServerError") {
@ -173,11 +174,16 @@ export class SessionContainer {
} }
return; return;
} }
const dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo);
if (dehydratedDevice) {
sessionInfo.deviceId = dehydratedDevice.deviceId;
}
await this._platform.sessionInfoStorage.add(sessionInfo);
// loading the session can only lead to // loading the session can only lead to
// LoadStatus.Error in case of an error, // LoadStatus.Error in case of an error,
// so separate try/catch // so separate try/catch
try { try {
await this._loadSessionInfo(sessionInfo, true, log); await this._loadSessionInfo(sessionInfo, dehydratedDevice, log);
log.set("status", this._status.get()); log.set("status", this._status.get());
} catch (err) { } catch (err) {
log.catch(err); log.catch(err);
@ -187,7 +193,7 @@ export class SessionContainer {
}); });
} }
async _loadSessionInfo(sessionInfo, isNewLogin, log) { async _loadSessionInfo(sessionInfo, dehydratedDevice, log) {
log.set("appVersion", this._platform.version); log.set("appVersion", this._platform.version);
const clock = this._platform.clock; const clock = this._platform.clock;
this._sessionStartedByReconnector = false; this._sessionStartedByReconnector = false;
@ -233,7 +239,9 @@ export class SessionContainer {
platform: this._platform, platform: this._platform,
}); });
await this._session.load(log); await this._session.load(log);
if (!this._session.hasIdentity) { if (dehydratedDevice) {
await log.wrap("dehydrateIdentity", log => await this._session.dehydrateIdentity(dehydratedDevice, log));
} else if (!this._session.hasIdentity) {
this._status.set(LoadStatus.SessionSetup); this._status.set(LoadStatus.SessionSetup);
await log.wrap("createIdentity", log => this._session.createIdentity(log)); await log.wrap("createIdentity", log => this._session.createIdentity(log));
} }
@ -300,6 +308,30 @@ export class SessionContainer {
} }
} }
async _inspectAccountAfterLogin(sessionInfo) {
this._status.set(LoadStatus.QueryAccount);
const hsApi = new HomeServerApi({
homeserver: sessionInfo.homeServer,
accessToken: sessionInfo.accessToken,
request: this._platform.request,
});
const olm = await this._olmPromise;
const encryptedDehydratedDevice = await getDehydratedDevice(hsApi, olm);
if (encryptedDehydratedDevice) {
let resolveStageFinish;
const promiseStageFinish = new Promise(r => resolveStageFinish = r);
this._accountSetup = new AccountSetup(encryptedDehydratedDevice, resolveStageFinish);
this._status.set(LoadStatus.SetupAccount);
await promiseStageFinish;
const dehydratedDevice = this._accountSetup?._dehydratedDevice;
this._accountSetup = null;
return dehydratedDevice;
}
}
get accountSetup() {
return this._accountSetup;
}
get loadStatus() { get loadStatus() {
return this._status; return this._status;
@ -378,3 +410,20 @@ export class SessionContainer {
this._loginFailure = null; this._loginFailure = null;
} }
} }
class AccountSetup {
constructor(encryptedDehydratedDevice, finishStage) {
this._encryptedDehydratedDevice = encryptedDehydratedDevice;
this._dehydratedDevice = undefined;
this._finishStage = finishStage;
}
get encryptedDehydratedDevice() {
return this._encryptedDehydratedDevice;
}
finish(dehydratedDevice) {
this._dehydratedDevice = dehydratedDevice;
this._finishStage();
}
}

View File

@ -22,6 +22,24 @@ const ACCOUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "olmAccount";
const DEVICE_KEY_FLAG_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "areDeviceKeysUploaded"; const DEVICE_KEY_FLAG_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "areDeviceKeysUploaded";
const SERVER_OTK_COUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "serverOTKCount"; const SERVER_OTK_COUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "serverOTKCount";
async function initiallyStoreAccount(account, pickleKey, areDeviceKeysUploaded, serverOTKCount, storage) {
const pickledAccount = account.pickle(pickleKey);
const txn = await storage.readWriteTxn([
storage.storeNames.session
]);
try {
// add will throw if the key already exists
// we would not want to overwrite olmAccount here
txn.session.add(ACCOUNT_SESSION_KEY, pickledAccount);
txn.session.add(DEVICE_KEY_FLAG_SESSION_KEY, areDeviceKeysUploaded);
txn.session.add(SERVER_OTK_COUNT_SESSION_KEY, serverOTKCount);
} catch (err) {
txn.abort();
throw err;
}
await txn.complete();
}
export class Account { export class Account {
static async load({olm, pickleKey, hsApi, userId, deviceId, olmWorker, txn}) { static async load({olm, pickleKey, hsApi, userId, deviceId, olmWorker, txn}) {
const pickledAccount = await txn.session.get(ACCOUNT_SESSION_KEY); const pickledAccount = await txn.session.get(ACCOUNT_SESSION_KEY);
@ -35,6 +53,21 @@ export class Account {
} }
} }
static async adoptDehydratedDevice({olm, dehydratedDevice, pickleKey, hsApi, userId, olmWorker, storage}) {
const account = dehydratedDevice.adoptUnpickledOlmAccount();
const areDeviceKeysUploaded = true;
const oneTimeKeys = JSON.parse(this._account.one_time_keys());
// only one algorithm supported by olm atm, so hardcode its name
const oneTimeKeysEntries = Object.entries(oneTimeKeys.curve25519);
const serverOTKCount = oneTimeKeysEntries.length;
await initiallyStoreAccount(account, pickleKey, areDeviceKeysUploaded, serverOTKCount, storage);
return new Account({
pickleKey, hsApi, account, userId,
deviceId: dehydratedDevice.deviceId,
areDeviceKeysUploaded, serverOTKCount, olm, olmWorker
});
}
static async create({olm, pickleKey, hsApi, userId, deviceId, olmWorker, storage}) { static async create({olm, pickleKey, hsApi, userId, deviceId, olmWorker, storage}) {
const account = new olm.Account(); const account = new olm.Account();
if (olmWorker) { if (olmWorker) {
@ -43,22 +76,9 @@ export class Account {
account.create(); account.create();
account.generate_one_time_keys(account.max_number_of_one_time_keys()); account.generate_one_time_keys(account.max_number_of_one_time_keys());
} }
const pickledAccount = account.pickle(pickleKey); if (storage) {
const areDeviceKeysUploaded = false; await initiallyStoreAccount(account, pickleKey, false, 0, storage);
const txn = await storage.readWriteTxn([
storage.storeNames.session
]);
try {
// add will throw if the key already exists
// we would not want to overwrite olmAccount here
txn.session.add(ACCOUNT_SESSION_KEY, pickledAccount);
txn.session.add(DEVICE_KEY_FLAG_SESSION_KEY, areDeviceKeysUploaded);
txn.session.add(SERVER_OTK_COUNT_SESSION_KEY, 0);
} catch (err) {
txn.abort();
throw err;
} }
await txn.complete();
return new Account({pickleKey, hsApi, account, userId, return new Account({pickleKey, hsApi, account, userId,
deviceId, areDeviceKeysUploaded, serverOTKCount: 0, olm, olmWorker}); deviceId, areDeviceKeysUploaded, serverOTKCount: 0, olm, olmWorker});
} }
@ -80,7 +100,7 @@ export class Account {
return this._identityKeys; return this._identityKeys;
} }
async uploadKeys(storage, log) { async uploadKeys(storage, dehydratedDeviceId, log) {
const oneTimeKeys = JSON.parse(this._account.one_time_keys()); const oneTimeKeys = JSON.parse(this._account.one_time_keys());
// only one algorithm supported by olm atm, so hardcode its name // only one algorithm supported by olm atm, so hardcode its name
const oneTimeKeysEntries = Object.entries(oneTimeKeys.curve25519); const oneTimeKeysEntries = Object.entries(oneTimeKeys.curve25519);
@ -95,7 +115,7 @@ export class Account {
log.set("otks", true); log.set("otks", true);
payload.one_time_keys = this._oneTimeKeysPayload(oneTimeKeysEntries); payload.one_time_keys = this._oneTimeKeysPayload(oneTimeKeysEntries);
} }
const response = await this._hsApi.uploadKeys(payload, {log}).response(); const response = await this._hsApi.uploadKeys(dehydratedDeviceId, payload, {log}).response();
this._serverOTKCount = response?.one_time_key_counts?.signed_curve25519; this._serverOTKCount = response?.one_time_key_counts?.signed_curve25519;
log.set("serverOTKCount", this._serverOTKCount); log.set("serverOTKCount", this._serverOTKCount);
// TODO: should we not modify this in the txn like we do elsewhere? // TODO: should we not modify this in the txn like we do elsewhere?
@ -105,12 +125,12 @@ export class Account {
await this._updateSessionStorage(storage, sessionStore => { await this._updateSessionStorage(storage, sessionStore => {
if (oneTimeKeysEntries.length) { if (oneTimeKeysEntries.length) {
this._account.mark_keys_as_published(); this._account.mark_keys_as_published();
sessionStore.set(ACCOUNT_SESSION_KEY, this._account.pickle(this._pickleKey)); sessionStore?.set(ACCOUNT_SESSION_KEY, this._account.pickle(this._pickleKey));
sessionStore.set(SERVER_OTK_COUNT_SESSION_KEY, this._serverOTKCount); sessionStore?.set(SERVER_OTK_COUNT_SESSION_KEY, this._serverOTKCount);
} }
if (!this._areDeviceKeysUploaded) { if (!this._areDeviceKeysUploaded) {
this._areDeviceKeysUploaded = true; this._areDeviceKeysUploaded = true;
sessionStore.set(DEVICE_KEY_FLAG_SESSION_KEY, this._areDeviceKeysUploaded); sessionStore?.set(DEVICE_KEY_FLAG_SESSION_KEY, this._areDeviceKeysUploaded);
} }
}); });
} }
@ -246,6 +266,7 @@ export class Account {
} }
async _updateSessionStorage(storage, callback) { async _updateSessionStorage(storage, callback) {
if (storage) {
const txn = await storage.readWriteTxn([ const txn = await storage.readWriteTxn([
storage.storeNames.session storage.storeNames.session
]); ]);
@ -256,6 +277,9 @@ export class Account {
throw err; throw err;
} }
await txn.complete(); await txn.complete();
} else {
await callback(undefined);
}
} }
signObject(obj) { signObject(obj) {
@ -273,4 +297,12 @@ export class Account {
obj.unsigned = unsigned; obj.unsigned = unsigned;
} }
} }
pickleWithKey(key) {
return this._account.pickle(key);
}
dispose() {
this._account.free();
}
} }

View File

@ -0,0 +1,88 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export const DEHYDRATION_LIBOLM_PICKLE_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle";
async function getDehydratedDevice(hsApi, olm) {
const response = await hsApi.getDehydratedDevice().response();
if (response.device_data.algorithm === DEHYDRATION_LIBOLM_PICKLE_ALGORITHM) {
return new DehydratedDevice(response, olm);
}
}
async function hasRemainingDevice(ownUserId, ownDeviceId, storage) {
}
async function uploadAccountAsDehydratedDevice(account, hsApi, key, deviceDisplayName, log) {
const response = await hsApi.createDehydratedDevice({
device_data: {
algorithm: DEHYDRATION_LIBOLM_PICKLE_ALGORITHM,
account: account.pickleWithKey(new Uint8Array(key)),
passphrase: {}
},
initial_device_display_name: deviceDisplayName
}).response();
const deviceId = response.device_id;
await account.uploadKeys(undefined, deviceId, log);
return deviceId;
}
class EncryptedDehydratedDevice {
constructor(dehydratedDevice, olm) {
this._dehydratedDevice = dehydratedDevice;
this._olm = olm;
}
decrypt(key) {
const account = new this._olm.Account();
try {
const pickledAccount = this._dehydratedDevice.device_data.account;
account.unpickle(key, pickledAccount);
return new DehydratedDevice(this._dehydratedDevice, account);
} catch (err) {
account.free();
return null;
}
}
}
class DehydratedDevice {
constructor(dehydratedDevice, account) {
this._dehydratedDevice = dehydratedDevice;
this._account = account;
}
claim(hsApi, log) {
try {
const response = await hsApi.claimDehydratedDevice(this.deviceId, {log}).response();
return response.success;
} catch (err) {
return false;
}
}
// make it clear that ownership is transfered upon calling this
adoptUnpickledOlmAccount() {
const account = this._account;
this._account = null;
return account;
}
get deviceId() {
this._dehydratedDevice.device_id;
}
}

View File

@ -18,6 +18,9 @@ limitations under the License.
import {encodeQueryParams, encodeBody} from "./common.js"; import {encodeQueryParams, encodeBody} from "./common.js";
import {HomeServerRequest} from "./HomeServerRequest.js"; import {HomeServerRequest} from "./HomeServerRequest.js";
const CS_R0_PREFIX = "/_matrix/client/r0";
const DEHYDRATION_PREFIX = "/unstable/org.matrix.msc2697.v2";
export class HomeServerApi { export class HomeServerApi {
constructor({homeserver, accessToken, request, reconnector}) { constructor({homeserver, accessToken, request, reconnector}) {
// store these both in a closure somehow so it's harder to get at in case of XSS? // store these both in a closure somehow so it's harder to get at in case of XSS?
@ -28,8 +31,8 @@ export class HomeServerApi {
this._reconnector = reconnector; this._reconnector = reconnector;
} }
_url(csPath) { _url(csPath, prefix) {
return `${this._homeserver}/_matrix/client/r0${csPath}`; return this._homeserver + prefix + csPath;
} }
_baseRequest(method, url, queryParams, body, options, accessToken) { _baseRequest(method, url, queryParams, body, options, accessToken) {
@ -92,15 +95,15 @@ export class HomeServerApi {
} }
_post(csPath, queryParams, body, options) { _post(csPath, queryParams, body, options) {
return this._authedRequest("POST", this._url(csPath), queryParams, body, options); return this._authedRequest("POST", this._url(csPath, options?.prefix || CS_R0_PREFIX), queryParams, body, options);
} }
_put(csPath, queryParams, body, options) { _put(csPath, queryParams, body, options) {
return this._authedRequest("PUT", this._url(csPath), queryParams, body, options); return this._authedRequest("PUT", this._url(csPath, options?.prefix || CS_R0_PREFIX), queryParams, body, options);
} }
_get(csPath, queryParams, body, options) { _get(csPath, queryParams, body, options) {
return this._authedRequest("GET", this._url(csPath), queryParams, body, options); return this._authedRequest("GET", this._url(csPath, options?.prefix || CS_R0_PREFIX), queryParams, body, options);
} }
sync(since, filter, timeout, options = null) { sync(since, filter, timeout, options = null) {
@ -170,8 +173,12 @@ export class HomeServerApi {
return this._unauthedRequest("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options); return this._unauthedRequest("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options);
} }
uploadKeys(payload, options = null) { uploadKeys(dehydratedDeviceId, payload, options = null) {
return this._post("/keys/upload", null, payload, options); let path = "/keys/upload";
if (dehydratedDeviceId) {
path = path + `/${encodeURIComponent(dehydratedDeviceId)}`;
}
return this._post(path, null, payload, options);
} }
queryKeys(queryRequest, options = null) { queryKeys(queryRequest, options = null) {
@ -229,6 +236,21 @@ export class HomeServerApi {
logout(options = null) { logout(options = null) {
return this._post(`/logout`, null, null, options); return this._post(`/logout`, null, null, options);
} }
getDehydratedDevice(options = {}) {
options.prefix = DEHYDRATION_PREFIX;
return this._get(`/dehydrated_device`, null, null, options);
}
createDehydratedDevice(payload, options = null) {
options.prefix = DEHYDRATION_PREFIX;
return this._put(`/dehydrated_device`, null, payload, options);
}
claimDehydratedDevice(deviceId) {
options.prefix = DEHYDRATION_PREFIX;
return this._post(`/dehydrated_device/claim`, null, {device_id: deviceId}, options);
}
} }
import {Request as MockRequest} from "../../mocks/Request.js"; import {Request as MockRequest} from "../../mocks/Request.js";