From e1b5b83723f92bf242f583bbb2caa8f5bfd457ac Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 9 Nov 2023 17:06:42 -0500 Subject: [PATCH] Add State Provider Framework (#6640) * Add StateDefinition Add a class for encapsulation information about state this will often be for a domain but creations of this will exist outside of a specific domain, hence just the name State. * Add KeyDefinition This adds a type that extends state definition into another sub-key and forces creators to define the data that will be stored and how to read the data that they expect to be stored. * Add key-builders helper functions Adds to function to help building keys for both keys scoped to a specific user and for keys scoped to global storage. Co-authored-by: Matt Gibson * Add updates$ stream to existing storageServices Original commit by Matt: 823d9546fe059da23ce3353782b4f4134bb36262 Co-authored-by: Matt Gibson * Add fromChromeEvent helper Create a helper that creats an Observable from a chrome event and removes the listener when the subscription is completed. * Implement `updates$` property for chrome storage Use fromChromeEvent to create an observable from chrome event and map that into our expected shape. * Add GlobalState Abstractions * Add UserState Abstractions * Add Default Implementations of User/Global state Co-authored-by: Matt Gibson * Add Barrel File for state Co-authored-by: Matt Gibson * Fix ChromeStorageServices * Rework fromChromeEvent Rework fromChromeEvent so we have to lie to TS less and remove unneeded generics. I did this by caring less about the function and more about the parameters only. Co-authored-by: Matt Gibson * Fix UserStateProvider Test * Add Inner Mock & Assert Calls * Update Tests to use new keys Use different key format * Prefer returns over mutations in update * Update Tests * Address PR Feedback * Be stricter with userId parameter * Add Better Way To Determine if it was a remove * Fix Web & Browser Storage Services * Fix Desktop & CLI Storage Services * Fix Test Storage Service * Use createKey Helper * Prefer implement to extending * Determine storage location in providers * Export default providers publicly * Fix user state tests * Name tests * Fix CLI * Prefer Implement In Chrome Storage * Remove Secure Storage Option Also throw an exception for subscribes to the secure storage observable. * Update apps/browser/src/platform/browser/from-chrome-event.ts Co-authored-by: Oscar Hinton * Enforce state module barrel file * Fix Linting Error * Allow state module import from other modules * Globally Unregister fromChromeEvent Listeners Changed fromChromeEvent to add its listeners through the BrowserApi, so that they will be unregistered when safari closes. * Test default global state * Use Proper Casing in Parameter * Address Feedback * Update libs/common/src/platform/state/key-definition.ts Co-authored-by: Oscar Hinton * Add `buildCacheKey` Method * Fix lint errors * Add Comment Co-authored-by: Oscar Hinton * Use Generic in callback parameter * Refactor Out DerivedStateDefinition * Persist Listener Return Type * Add Ticket Link --------- Co-authored-by: Matt Gibson Co-authored-by: Matt Gibson Co-authored-by: Oscar Hinton --- .eslintrc.json | 29 ++- .../src/platform/browser/browser-api.ts | 80 +++--- .../browser/from-chrome-event.spec.ts | 103 ++++++++ .../src/platform/browser/from-chrome-event.ts | 39 +++ .../abstract-chrome-storage-api.service.ts | 42 +++- .../services/browser-local-storage.service.ts | 4 +- .../browser-memory-storage.service.ts | 4 +- .../local-backed-session-storage.service.ts | 11 +- .../browser-fido2-user-interface.service.ts | 3 + .../services/lowdb-storage.service.ts | 17 +- .../node-env-secure-storage.service.ts | 15 +- ...lectron-renderer-secure-storage.service.ts | 12 +- .../electron-renderer-storage.service.ts | 23 +- .../services/electron-storage.service.ts | 13 +- apps/web/src/app/core/html-storage.service.ts | 14 +- libs/common/spec/fake-storage.service.ts | 60 +++++ libs/common/spec/utils.ts | 16 +- .../platform/abstractions/storage.service.ts | 15 ++ .../services/memory-storage.service.ts | 17 +- .../src/platform/state/derived-user-state.ts | 5 + .../platform/state/global-state.provider.ts | 13 + .../common/src/platform/state/global-state.ts | 20 ++ .../implementations/default-derived-state.ts | 23 ++ .../default-global-state.provider.ts | 46 ++++ .../default-global-state.spec.ts | 98 ++++++++ .../implementations/default-global-state.ts | 60 +++++ .../default-user-state.provider.ts | 55 ++++ .../default-user-state.spec.ts | 236 ++++++++++++++++++ .../implementations/default-user-state.ts | 152 +++++++++++ libs/common/src/platform/state/index.ts | 3 + .../src/platform/state/key-definition.ts | 102 ++++++++ .../src/platform/state/state-definition.ts | 13 + .../src/platform/state/user-state.provider.ts | 13 + libs/common/src/platform/state/user-state.ts | 41 +++ libs/node/jest.config.js | 1 + libs/shared/test.environment.ts | 22 ++ 36 files changed, 1352 insertions(+), 68 deletions(-) create mode 100644 apps/browser/src/platform/browser/from-chrome-event.spec.ts create mode 100644 apps/browser/src/platform/browser/from-chrome-event.ts create mode 100644 libs/common/spec/fake-storage.service.ts create mode 100644 libs/common/src/platform/state/derived-user-state.ts create mode 100644 libs/common/src/platform/state/global-state.provider.ts create mode 100644 libs/common/src/platform/state/global-state.ts create mode 100644 libs/common/src/platform/state/implementations/default-derived-state.ts create mode 100644 libs/common/src/platform/state/implementations/default-global-state.provider.ts create mode 100644 libs/common/src/platform/state/implementations/default-global-state.spec.ts create mode 100644 libs/common/src/platform/state/implementations/default-global-state.ts create mode 100644 libs/common/src/platform/state/implementations/default-user-state.provider.ts create mode 100644 libs/common/src/platform/state/implementations/default-user-state.spec.ts create mode 100644 libs/common/src/platform/state/implementations/default-user-state.ts create mode 100644 libs/common/src/platform/state/index.ts create mode 100644 libs/common/src/platform/state/key-definition.ts create mode 100644 libs/common/src/platform/state/state-definition.ts create mode 100644 libs/common/src/platform/state/user-state.provider.ts create mode 100644 libs/common/src/platform/state/user-state.ts create mode 100644 libs/shared/test.environment.ts diff --git a/.eslintrc.json b/.eslintrc.json index 2dab917901..5662ac5281 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -100,6 +100,19 @@ "./libs/importer/**/*", "./libs/exporter/**/*" ] + }, + { + // avoid import of unexported state objects + "target": [ + "!(libs)/**/*", + "libs/!(common)/**/*", + "libs/common/!(src)/**/*", + "libs/common/src/!(platform)/**/*", + "libs/common/src/platform/!(state)/**/*" + ], + "from": ["./libs/common/src/platform/state/**/*"], + // allow module index import + "except": ["**/state/index.ts"] } ] } @@ -179,17 +192,23 @@ }, { "files": ["apps/browser/src/**/*.ts", "libs/**/*.ts"], - "excludedFiles": "apps/browser/src/autofill/{content,notification}/**/*.ts", + "excludedFiles": [ + "apps/browser/src/autofill/{content,notification}/**/*.ts", + "apps/browser/src/**/background/**/*.ts", // It's okay to have long lived listeners in the background + "apps/browser/src/platform/background.ts" + ], "rules": { "no-restricted-syntax": [ "error", { - "message": "Using addListener in the browser popup produces a memory leak in Safari, use `BrowserApi.messageListener` instead", - "selector": "CallExpression > [object.object.object.name='chrome'][object.object.property.name='runtime'][object.property.name='onMessage'][property.name='addListener']" + "message": "Using addListener in the browser popup produces a memory leak in Safari, use `BrowserApi.addListener` instead", + // This selector covers events like chrome.storage.onChange & chrome.runtime.onMessage + "selector": "CallExpression > [object.object.object.name='chrome'][property.name='addListener']" }, { - "message": "Using addListener in the browser popup produces a memory leak in Safari, use `BrowserApi.storageChangeListener` instead", - "selector": "CallExpression > [object.object.object.name='chrome'][object.object.property.name='storage'][object.property.name='onChanged'][property.name='addListener']" + "message": "Using addListener in the browser popup produces a memory leak in Safari, use `BrowserApi.addListener` instead", + // This selector covers events like chrome.storage.local.onChange + "selector": "CallExpression > [object.object.object.object.name='chrome'][property.name='addListener']" } ] } diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index 64efbfdc57..472bf8cc3d 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -193,6 +193,9 @@ export class BrowserApi { } static async onWindowCreated(callback: (win: chrome.windows.Window) => any) { + // FIXME: Make sure that is does not cause a memory leak in Safari or use BrowserApi.AddListener + // and test that it doesn't break. + // eslint-disable-next-line no-restricted-syntax return chrome.windows.onCreated.addListener(callback); } @@ -220,8 +223,10 @@ export class BrowserApi { // Keep track of all the events registered in a Safari popup so we can remove // them when the popup gets unloaded, otherwise we cause a memory leak - private static registeredMessageListeners: any[] = []; - private static registeredStorageChangeListeners: any[] = []; + private static trackedChromeEventListeners: [ + event: chrome.events.Event<(...args: unknown[]) => unknown>, + callback: (...args: unknown[]) => unknown + ][] = []; static messageListener( name: string, @@ -231,13 +236,7 @@ export class BrowserApi { sendResponse: any ) => boolean | void ) { - // eslint-disable-next-line no-restricted-syntax - chrome.runtime.onMessage.addListener(callback); - - if (BrowserApi.isSafariApi && !BrowserApi.isBackgroundPage(window)) { - BrowserApi.registeredMessageListeners.push(callback); - BrowserApi.setupUnloadListeners(); - } + BrowserApi.addListener(chrome.runtime.onMessage, callback); } static messageListener$() { @@ -246,44 +245,67 @@ export class BrowserApi { subscriber.next(message); }; - BrowserApi.messageListener("message", handler); + BrowserApi.addListener(chrome.runtime.onMessage, handler); - return () => { - chrome.runtime.onMessage.removeListener(handler); - - if (BrowserApi.isSafariApi) { - const index = BrowserApi.registeredMessageListeners.indexOf(handler); - if (index !== -1) { - BrowserApi.registeredMessageListeners.splice(index, 1); - } - } - }; + return () => BrowserApi.removeListener(chrome.runtime.onMessage, handler); }); } static storageChangeListener( callback: Parameters[0] ) { - // eslint-disable-next-line no-restricted-syntax - chrome.storage.onChanged.addListener(callback); + BrowserApi.addListener(chrome.storage.onChanged, callback); + } + + /** + * Adds a callback to the given chrome event in a cross-browser platform manner. + * + * **Important:** All event listeners in the browser extension popup context must + * use this instead of the native APIs to handle unsubscribing from Safari properly. + * + * @param event - The event in which to add the listener to. + * @param callback - The callback you want registered onto the event. + */ + static addListener unknown>( + event: chrome.events.Event, + callback: T + ) { + event.addListener(callback); if (BrowserApi.isSafariApi && !BrowserApi.isBackgroundPage(window)) { - BrowserApi.registeredStorageChangeListeners.push(callback); + BrowserApi.trackedChromeEventListeners.push([event, callback]); BrowserApi.setupUnloadListeners(); } } + /** + * Removes a callback from the given chrome event in a cross-browser platform manner. + * @param event - The event in which to remove the listener from. + * @param callback - The callback you want removed from the event. + */ + static removeListener unknown>( + event: chrome.events.Event, + callback: T + ) { + event.removeListener(callback); + + if (BrowserApi.isSafariApi && !BrowserApi.isBackgroundPage(window)) { + const index = BrowserApi.trackedChromeEventListeners.findIndex(([_event, eventListener]) => { + return eventListener == callback; + }); + if (index !== -1) { + BrowserApi.trackedChromeEventListeners.splice(index, 1); + } + } + } + // Setup the event to destroy all the listeners when the popup gets unloaded in Safari, otherwise we get a memory leak private static setupUnloadListeners() { // The MDN recommend using 'visibilitychange' but that event is fired any time the popup window is obscured as well // 'pagehide' works just like 'unload' but is compatible with the back/forward cache, so we prefer using that one window.onpagehide = () => { - for (const callback of BrowserApi.registeredMessageListeners) { - chrome.runtime.onMessage.removeListener(callback); - } - - for (const callback of BrowserApi.registeredStorageChangeListeners) { - chrome.storage.onChanged.removeListener(callback); + for (const [event, callback] of BrowserApi.trackedChromeEventListeners) { + event.removeListener(callback); } }; } diff --git a/apps/browser/src/platform/browser/from-chrome-event.spec.ts b/apps/browser/src/platform/browser/from-chrome-event.spec.ts new file mode 100644 index 0000000000..36a0e73b75 --- /dev/null +++ b/apps/browser/src/platform/browser/from-chrome-event.spec.ts @@ -0,0 +1,103 @@ +import { fromChromeEvent } from "./from-chrome-event"; + +describe("fromChromeEvent", () => { + class FakeEvent implements chrome.events.Event<(arg1: string, arg2: number) => void> { + listenerWasAdded: boolean; + listenerWasRemoved: boolean; + activeListeners: ((arg1: string, arg2: number) => void)[] = []; + + addListener(callback: (arg1: string, arg2: number) => void): void { + this.listenerWasAdded = true; + this.activeListeners.push(callback); + } + getRules(callback: (rules: chrome.events.Rule[]) => void): void; + getRules(ruleIdentifiers: string[], callback: (rules: chrome.events.Rule[]) => void): void; + getRules(ruleIdentifiers: unknown, callback?: unknown): void { + throw new Error("Method not implemented."); + } + hasListener(callback: (arg1: string, arg2: number) => void): boolean { + throw new Error("Method not implemented."); + } + removeRules(ruleIdentifiers?: string[], callback?: () => void): void; + removeRules(callback?: () => void): void; + removeRules(ruleIdentifiers?: unknown, callback?: unknown): void { + throw new Error("Method not implemented."); + } + addRules(rules: chrome.events.Rule[], callback?: (rules: chrome.events.Rule[]) => void): void { + throw new Error("Method not implemented."); + } + removeListener(callback: (arg1: string, arg2: number) => void): void { + const index = this.activeListeners.findIndex((c) => c == callback); + if (index === -1) { + throw new Error("No registered callback."); + } + + this.listenerWasRemoved = true; + this.activeListeners.splice(index, 1); + } + hasListeners(): boolean { + throw new Error("Method not implemented."); + } + + fireEvent(arg1: string, arg2: number) { + this.activeListeners.forEach((listener) => { + listener(arg1, arg2); + }); + } + } + + let event: FakeEvent; + + beforeEach(() => { + event = new FakeEvent(); + }); + + it("should never call addListener when never subscribed to", () => { + fromChromeEvent(event); + expect(event.listenerWasAdded).toBeFalsy(); + }); + + it("should add a listener when subscribed to.", () => { + const eventObservable = fromChromeEvent(event); + eventObservable.subscribe(); + expect(event.listenerWasAdded).toBeTruthy(); + expect(event.activeListeners).toHaveLength(1); + }); + + it("should call remove listener when the created subscription is unsubscribed", () => { + const eventObservable = fromChromeEvent(event); + const subscription = eventObservable.subscribe(); + subscription.unsubscribe(); + expect(event.listenerWasAdded).toBeTruthy(); + expect(event.listenerWasRemoved).toBeTruthy(); + expect(event.activeListeners).toHaveLength(0); + }); + + it("should fire each callback given to subscribe", () => { + const eventObservable = fromChromeEvent(event); + + let subscription1Called = false; + let subscription2Called = false; + + const subscription1 = eventObservable.subscribe(([arg1, arg2]) => { + expect(arg1).toBe("Hi!"); + expect(arg2).toBe(2); + subscription1Called = true; + }); + + const subscription2 = eventObservable.subscribe(([arg1, arg2]) => { + expect(arg1).toBe("Hi!"); + expect(arg2).toBe(2); + subscription2Called = true; + }); + + event.fireEvent("Hi!", 2); + + subscription1.unsubscribe(); + subscription2.unsubscribe(); + + expect(event.activeListeners).toHaveLength(0); + expect(subscription1Called).toBeTruthy(); + expect(subscription2Called).toBeTruthy(); + }); +}); diff --git a/apps/browser/src/platform/browser/from-chrome-event.ts b/apps/browser/src/platform/browser/from-chrome-event.ts new file mode 100644 index 0000000000..5217d8e702 --- /dev/null +++ b/apps/browser/src/platform/browser/from-chrome-event.ts @@ -0,0 +1,39 @@ +import { Observable } from "rxjs"; + +import { BrowserApi } from "./browser-api"; + +/** + * Converts a Chrome event to an Observable stream. + * + * @typeParam T - The type of the event arguments. + * @param event - The Chrome event to convert. + * @returns An Observable stream of the event arguments. + * + * @remarks + * This function creates an Observable stream that listens to a Chrome event and emits its arguments + * whenever the event is triggered. If the event throws an error, the Observable will emit an error + * notification with the error message. + * + * @example + * ```typescript + * const onMessage = fromChromeEvent(chrome.runtime.onMessage); + * onMessage.subscribe((message) => console.log('Received message:', message)); + * ``` + */ +export function fromChromeEvent( + event: chrome.events.Event<(...args: T) => void> +): Observable { + return new Observable((subscriber) => { + const handler = (...args: T) => { + if (chrome.runtime.lastError) { + subscriber.error(chrome.runtime.lastError); + return; + } + + subscriber.next(args); + }; + + BrowserApi.addListener(event, handler); + return () => BrowserApi.removeListener(event, handler); + }); +} diff --git a/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts b/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts index 5e9c14fd3c..7d6cf1390f 100644 --- a/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts +++ b/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts @@ -1,7 +1,39 @@ -import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { Observable, mergeMap } from "rxjs"; + +import { + AbstractStorageService, + StorageUpdate, + StorageUpdateType, +} from "@bitwarden/common/platform/abstractions/storage.service"; + +import { fromChromeEvent } from "../../browser/from-chrome-event"; export default abstract class AbstractChromeStorageService implements AbstractStorageService { - protected abstract chromeStorageApi: chrome.storage.StorageArea; + constructor(protected chromeStorageApi: chrome.storage.StorageArea) {} + + get updates$(): Observable { + return fromChromeEvent(this.chromeStorageApi.onChanged).pipe( + mergeMap(([changes]) => { + return Object.entries(changes).map(([key, change]) => { + // The `newValue` property isn't on the StorageChange object + // when the change was from a remove. Similarly a check of the `oldValue` + // could be used to tell if the operation was the first creation of this key + // but we currently do not differentiate that. + // Ref: https://developer.chrome.com/docs/extensions/reference/storage/#type-StorageChange + // Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/StorageChange + const updateType: StorageUpdateType = "newValue" in change ? "save" : "remove"; + + return { + key: key, + // For removes this property will not exist but then it will just be + // undefined which is fine. + value: change.newValue, + updateType: updateType, + }; + }); + }) + ); + } async get(key: string): Promise { return new Promise((resolve) => { @@ -22,11 +54,7 @@ export default abstract class AbstractChromeStorageService implements AbstractSt async save(key: string, obj: any): Promise { if (obj == null) { // Fix safari not liking null in set - return new Promise((resolve) => { - this.chromeStorageApi.remove(key, () => { - resolve(); - }); - }); + return this.remove(key); } if (obj instanceof Set) { diff --git a/apps/browser/src/platform/services/browser-local-storage.service.ts b/apps/browser/src/platform/services/browser-local-storage.service.ts index 8be8127d54..2efd03a046 100644 --- a/apps/browser/src/platform/services/browser-local-storage.service.ts +++ b/apps/browser/src/platform/services/browser-local-storage.service.ts @@ -1,5 +1,7 @@ import AbstractChromeStorageService from "./abstractions/abstract-chrome-storage-api.service"; export default class BrowserLocalStorageService extends AbstractChromeStorageService { - protected chromeStorageApi = chrome.storage.local; + constructor() { + super(chrome.storage.local); + } } diff --git a/apps/browser/src/platform/services/browser-memory-storage.service.ts b/apps/browser/src/platform/services/browser-memory-storage.service.ts index cdefbe4581..f824a1df0d 100644 --- a/apps/browser/src/platform/services/browser-memory-storage.service.ts +++ b/apps/browser/src/platform/services/browser-memory-storage.service.ts @@ -1,5 +1,7 @@ import AbstractChromeStorageService from "./abstractions/abstract-chrome-storage-api.service"; export default class BrowserMemoryStorageService extends AbstractChromeStorageService { - protected chromeStorageApi = chrome.storage.session; + constructor() { + super(chrome.storage.session); + } } diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.ts index 6c44adabcc..188a9854c5 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.ts @@ -1,7 +1,11 @@ +import { Subject } from "rxjs"; import { Jsonify } from "type-fest"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; -import { AbstractMemoryStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { + AbstractMemoryStorageService, + StorageUpdate, +} from "@bitwarden/common/platform/abstractions/storage.service"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { MemoryStorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; @@ -22,6 +26,7 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi private cache = new Map(); private localStorage = new BrowserLocalStorageService(); private sessionStorage = new BrowserMemoryStorageService(); + private updatesSubject = new Subject(); constructor( private encryptService: EncryptService, @@ -30,6 +35,10 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi super(); } + get updates$() { + return this.updatesSubject.asObservable(); + } + async get(key: string, options?: MemoryStorageOptions): Promise { if (this.cache.has(key)) { return this.cache.get(key) as T; diff --git a/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts b/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts index 71aae01c54..e03c430009 100644 --- a/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts @@ -219,6 +219,9 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi }); this.windowClosed$ = fromEventPattern( + // FIXME: Make sure that is does not cause a memory leak in Safari or use BrowserApi.AddListener + // and test that it doesn't break. Tracking Ticket: https://bitwarden.atlassian.net/browse/PM-4735 + // eslint-disable-next-line no-restricted-syntax (handler: any) => chrome.windows.onRemoved.addListener(handler), (handler: any) => chrome.windows.onRemoved.removeListener(handler) ); diff --git a/apps/cli/src/platform/services/lowdb-storage.service.ts b/apps/cli/src/platform/services/lowdb-storage.service.ts index bd5895907c..e80e94c043 100644 --- a/apps/cli/src/platform/services/lowdb-storage.service.ts +++ b/apps/cli/src/platform/services/lowdb-storage.service.ts @@ -5,10 +5,14 @@ import * as lowdb from "lowdb"; import * as FileSync from "lowdb/adapters/FileSync"; import * as lock from "proper-lockfile"; import { OperationOptions } from "retry"; +import { Subject } from "rxjs"; import { NodeUtils } from "@bitwarden/common/misc/nodeUtils"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { + AbstractStorageService, + StorageUpdate, +} from "@bitwarden/common/platform/abstractions/storage.service"; import { sequentialize } from "@bitwarden/common/platform/misc/sequentialize"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -24,6 +28,7 @@ export class LowdbStorageService implements AbstractStorageService { private db: lowdb.LowdbSync; private defaults: any; private ready = false; + private updatesSubject = new Subject(); constructor( protected logService: LogService, @@ -102,6 +107,10 @@ export class LowdbStorageService implements AbstractStorageService { this.ready = true; } + get updates$() { + return this.updatesSubject.asObservable(); + } + async get(key: string): Promise { await this.waitForReady(); return this.lockDbFile(() => { @@ -119,21 +128,23 @@ export class LowdbStorageService implements AbstractStorageService { return this.get(key).then((v) => v != null); } - async save(key: string, obj: any): Promise { + async save(key: string, obj: any): Promise { await this.waitForReady(); return this.lockDbFile(() => { this.readForNoCache(); this.db.set(key, obj).write(); + this.updatesSubject.next({ key, value: obj, updateType: "save" }); this.logService.debug(`Successfully wrote ${key} to db`); return; }); } - async remove(key: string): Promise { + async remove(key: string): Promise { await this.waitForReady(); return this.lockDbFile(() => { this.readForNoCache(); this.db.unset(key).write(); + this.updatesSubject.next({ key, value: null, updateType: "remove" }); this.logService.debug(`Successfully removed ${key} from db`); return; }); diff --git a/apps/cli/src/platform/services/node-env-secure-storage.service.ts b/apps/cli/src/platform/services/node-env-secure-storage.service.ts index 491ec32cb6..364491469e 100644 --- a/apps/cli/src/platform/services/node-env-secure-storage.service.ts +++ b/apps/cli/src/platform/services/node-env-secure-storage.service.ts @@ -1,3 +1,5 @@ +import { throwError } from "rxjs"; + import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; @@ -12,6 +14,12 @@ export class NodeEnvSecureStorageService implements AbstractStorageService { private cryptoService: () => CryptoService ) {} + get updates$() { + return throwError( + () => new Error("Secure storage implementations cannot have their updates subscribed to.") + ); + } + async get(key: string): Promise { const value = await this.storageService.get(this.makeProtectedStorageKey(key)); if (value == null) { @@ -25,7 +33,7 @@ export class NodeEnvSecureStorageService implements AbstractStorageService { return (await this.get(key)) != null; } - async save(key: string, obj: any): Promise { + async save(key: string, obj: any): Promise { if (obj == null) { return this.remove(key); } @@ -37,8 +45,9 @@ export class NodeEnvSecureStorageService implements AbstractStorageService { await this.storageService.save(this.makeProtectedStorageKey(key), protectedObj); } - remove(key: string): Promise { - return this.storageService.remove(this.makeProtectedStorageKey(key)); + async remove(key: string): Promise { + await this.storageService.remove(this.makeProtectedStorageKey(key)); + return; } private async encrypt(plainValue: string): Promise { diff --git a/apps/desktop/src/platform/services/electron-renderer-secure-storage.service.ts b/apps/desktop/src/platform/services/electron-renderer-secure-storage.service.ts index d0ee76b419..8d6b51cf7d 100644 --- a/apps/desktop/src/platform/services/electron-renderer-secure-storage.service.ts +++ b/apps/desktop/src/platform/services/electron-renderer-secure-storage.service.ts @@ -1,7 +1,15 @@ +import { throwError } from "rxjs"; + import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; export class ElectronRendererSecureStorageService implements AbstractStorageService { + get updates$() { + return throwError( + () => new Error("Secure storage implementations cannot have their updates subscribed to.") + ); + } + async get(key: string, options?: StorageOptions): Promise { const val = await ipc.platform.passwords.get(key, options?.keySuffix ?? ""); return val != null ? (JSON.parse(val) as T) : null; @@ -12,11 +20,11 @@ export class ElectronRendererSecureStorageService implements AbstractStorageServ return !!val; } - async save(key: string, obj: any, options?: StorageOptions): Promise { + async save(key: string, obj: T, options?: StorageOptions): Promise { await ipc.platform.passwords.set(key, options?.keySuffix ?? "", JSON.stringify(obj)); } - async remove(key: string, options?: StorageOptions): Promise { + async remove(key: string, options?: StorageOptions): Promise { await ipc.platform.passwords.delete(key, options?.keySuffix ?? ""); } } diff --git a/apps/desktop/src/platform/services/electron-renderer-storage.service.ts b/apps/desktop/src/platform/services/electron-renderer-storage.service.ts index 0060a11858..8bb9ff7217 100644 --- a/apps/desktop/src/platform/services/electron-renderer-storage.service.ts +++ b/apps/desktop/src/platform/services/electron-renderer-storage.service.ts @@ -1,6 +1,17 @@ -import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { Subject } from "rxjs"; + +import { + AbstractStorageService, + StorageUpdate, +} from "@bitwarden/common/platform/abstractions/storage.service"; export class ElectronRendererStorageService implements AbstractStorageService { + private updatesSubject = new Subject(); + + get updates$() { + return this.updatesSubject.asObservable(); + } + get(key: string): Promise { return ipc.platform.storage.get(key); } @@ -9,11 +20,13 @@ export class ElectronRendererStorageService implements AbstractStorageService { return ipc.platform.storage.has(key); } - save(key: string, obj: any): Promise { - return ipc.platform.storage.save(key, obj); + async save(key: string, obj: T): Promise { + await ipc.platform.storage.save(key, obj); + this.updatesSubject.next({ key, value: obj, updateType: "save" }); } - remove(key: string): Promise { - return ipc.platform.storage.remove(key); + async remove(key: string): Promise { + await ipc.platform.storage.remove(key); + this.updatesSubject.next({ key, value: null, updateType: "remove" }); } } diff --git a/apps/desktop/src/platform/services/electron-storage.service.ts b/apps/desktop/src/platform/services/electron-storage.service.ts index 51fb9cfe9c..40be8260dc 100644 --- a/apps/desktop/src/platform/services/electron-storage.service.ts +++ b/apps/desktop/src/platform/services/electron-storage.service.ts @@ -1,9 +1,13 @@ import * as fs from "fs"; import { ipcMain } from "electron"; +import { Subject } from "rxjs"; import { NodeUtils } from "@bitwarden/common/misc/nodeUtils"; -import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { + AbstractStorageService, + StorageUpdate, +} from "@bitwarden/common/platform/abstractions/storage.service"; // See: https://github.com/sindresorhus/electron-store/blob/main/index.d.ts interface ElectronStoreOptions { @@ -35,6 +39,7 @@ type Options = BaseOptions<"get"> | BaseOptions<"has"> | SaveOptions | BaseOptio export class ElectronStorageService implements AbstractStorageService { private store: ElectronStore; + private updatesSubject = new Subject(); constructor(dir: string, defaults = {}) { if (!fs.existsSync(dir)) { @@ -60,6 +65,10 @@ export class ElectronStorageService implements AbstractStorageService { }); } + get updates$() { + return this.updatesSubject.asObservable(); + } + get(key: string): Promise { const val = this.store.get(key) as T; return Promise.resolve(val != null ? val : null); @@ -75,11 +84,13 @@ export class ElectronStorageService implements AbstractStorageService { obj = Array.from(obj); } this.store.set(key, obj); + this.updatesSubject.next({ key, value: obj, updateType: "save" }); return Promise.resolve(); } remove(key: string): Promise { this.store.delete(key); + this.updatesSubject.next({ key, value: null, updateType: "remove" }); return Promise.resolve(); } } diff --git a/apps/web/src/app/core/html-storage.service.ts b/apps/web/src/app/core/html-storage.service.ts index 370f475a50..ef839fb4d9 100644 --- a/apps/web/src/app/core/html-storage.service.ts +++ b/apps/web/src/app/core/html-storage.service.ts @@ -1,15 +1,25 @@ import { Injectable } from "@angular/core"; +import { Subject } from "rxjs"; import { HtmlStorageLocation } from "@bitwarden/common/enums"; -import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { + AbstractStorageService, + StorageUpdate, +} from "@bitwarden/common/platform/abstractions/storage.service"; import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; @Injectable() export class HtmlStorageService implements AbstractStorageService { + private updatesSubject = new Subject(); + get defaultOptions(): StorageOptions { return { htmlStorageLocation: HtmlStorageLocation.Session }; } + get updates$() { + return this.updatesSubject.asObservable(); + } + get(key: string, options: StorageOptions = this.defaultOptions): Promise { let json: string = null; switch (options.htmlStorageLocation) { @@ -52,6 +62,7 @@ export class HtmlStorageService implements AbstractStorageService { window.sessionStorage.setItem(key, json); break; } + this.updatesSubject.next({ key, value: obj, updateType: "save" }); return Promise.resolve(); } @@ -65,6 +76,7 @@ export class HtmlStorageService implements AbstractStorageService { window.sessionStorage.removeItem(key); break; } + this.updatesSubject.next({ key, value: null, updateType: "remove" }); return Promise.resolve(); } } diff --git a/libs/common/spec/fake-storage.service.ts b/libs/common/spec/fake-storage.service.ts new file mode 100644 index 0000000000..281df01533 --- /dev/null +++ b/libs/common/spec/fake-storage.service.ts @@ -0,0 +1,60 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { Subject } from "rxjs"; + +import { + AbstractStorageService, + StorageUpdate, +} from "../src/platform/abstractions/storage.service"; +import { StorageOptions } from "../src/platform/models/domain/storage-options"; + +export class FakeStorageService implements AbstractStorageService { + private store: Record; + private updatesSubject = new Subject(); + + /** + * Returns a mock of a {@see AbstractStorageService} for asserting the expected + * amount of calls. It is not recommended to use this to mock implementations as + * they are not respected. + */ + mock: MockProxy; + + constructor(initial?: Record) { + this.store = initial ?? {}; + this.mock = mock(); + } + + /** + * Updates the internal store for this fake implementation, this bypasses any mock calls + * or updates to the {@link updates$} observable. + * @param store + */ + internalUpdateStore(store: Record) { + this.store = store; + } + + get updates$() { + return this.updatesSubject.asObservable(); + } + + get(key: string, options?: StorageOptions): Promise { + this.mock.get(key, options); + const value = this.store[key] as T; + return Promise.resolve(value); + } + has(key: string, options?: StorageOptions): Promise { + this.mock.has(key, options); + return Promise.resolve(this.store[key] != null); + } + save(key: string, obj: T, options?: StorageOptions): Promise { + this.mock.save(key, options); + this.store[key] = obj; + this.updatesSubject.next({ key: key, value: obj, updateType: "save" }); + return Promise.resolve(); + } + remove(key: string, options?: StorageOptions): Promise { + this.mock.remove(key, options); + delete this.store[key]; + this.updatesSubject.next({ key: key, value: undefined, updateType: "remove" }); + return Promise.resolve(); + } +} diff --git a/libs/common/spec/utils.ts b/libs/common/spec/utils.ts index 8d6f892031..91d2033da8 100644 --- a/libs/common/spec/utils.ts +++ b/libs/common/spec/utils.ts @@ -69,12 +69,18 @@ export function trackEmissions(observable: Observable): T[] { case "boolean": emissions.push(value); break; - case "object": - emissions.push({ ...value }); - break; - default: - emissions.push(JSON.parse(JSON.stringify(value))); + default: { + emissions.push(clone(value)); + } } }); return emissions; } + +function clone(value: any): any { + if (global.structuredClone != undefined) { + return structuredClone(value); + } else { + return JSON.parse(JSON.stringify(value)); + } +} diff --git a/libs/common/src/platform/abstractions/storage.service.ts b/libs/common/src/platform/abstractions/storage.service.ts index cdd731f9d6..8beac2c1c1 100644 --- a/libs/common/src/platform/abstractions/storage.service.ts +++ b/libs/common/src/platform/abstractions/storage.service.ts @@ -1,6 +1,21 @@ +import { Observable } from "rxjs"; + import { MemoryStorageOptions, StorageOptions } from "../models/domain/storage-options"; +export type StorageUpdateType = "save" | "remove"; +export type StorageUpdate = { + key: string; + value?: unknown; + updateType: StorageUpdateType; +}; + export abstract class AbstractStorageService { + /** + * Provides an {@link Observable} that represents a stream of updates that + * have happened in this storage service or in the storage this service provides + * an interface to. + */ + abstract get updates$(): Observable; abstract get(key: string, options?: StorageOptions): Promise; abstract has(key: string, options?: StorageOptions): Promise; abstract save(key: string, obj: T, options?: StorageOptions): Promise; diff --git a/libs/common/src/platform/services/memory-storage.service.ts b/libs/common/src/platform/services/memory-storage.service.ts index b4d65c0ffa..abfc0b1597 100644 --- a/libs/common/src/platform/services/memory-storage.service.ts +++ b/libs/common/src/platform/services/memory-storage.service.ts @@ -1,7 +1,14 @@ -import { AbstractMemoryStorageService } from "../abstractions/storage.service"; +import { Subject } from "rxjs"; + +import { AbstractMemoryStorageService, StorageUpdate } from "../abstractions/storage.service"; export class MemoryStorageService extends AbstractMemoryStorageService { - private store = new Map(); + private store = new Map(); + private updatesSubject = new Subject(); + + get updates$() { + return this.updatesSubject.asObservable(); + } get(key: string): Promise { if (this.store.has(key)) { @@ -15,16 +22,18 @@ export class MemoryStorageService extends AbstractMemoryStorageService { return (await this.get(key)) != null; } - save(key: string, obj: any): Promise { + save(key: string, obj: T): Promise { if (obj == null) { return this.remove(key); } this.store.set(key, obj); + this.updatesSubject.next({ key, value: obj, updateType: "save" }); return Promise.resolve(); } - remove(key: string): Promise { + remove(key: string): Promise { this.store.delete(key); + this.updatesSubject.next({ key, value: null, updateType: "remove" }); return Promise.resolve(); } diff --git a/libs/common/src/platform/state/derived-user-state.ts b/libs/common/src/platform/state/derived-user-state.ts new file mode 100644 index 0000000000..89e0b6ec76 --- /dev/null +++ b/libs/common/src/platform/state/derived-user-state.ts @@ -0,0 +1,5 @@ +import { Observable } from "rxjs"; + +export interface DerivedUserState { + state$: Observable; +} diff --git a/libs/common/src/platform/state/global-state.provider.ts b/libs/common/src/platform/state/global-state.provider.ts new file mode 100644 index 0000000000..7c791b6b4d --- /dev/null +++ b/libs/common/src/platform/state/global-state.provider.ts @@ -0,0 +1,13 @@ +import { GlobalState } from "./global-state"; +import { KeyDefinition } from "./key-definition"; + +/** + * A provider for geting an implementation of global state scoped to the given key. + */ +export abstract class GlobalStateProvider { + /** + * Gets a {@link GlobalState} scoped to the given {@link KeyDefinition} + * @param keyDefinition - The {@link KeyDefinition} for which you want the state for. + */ + get: (keyDefinition: KeyDefinition) => GlobalState; +} diff --git a/libs/common/src/platform/state/global-state.ts b/libs/common/src/platform/state/global-state.ts new file mode 100644 index 0000000000..3d330668de --- /dev/null +++ b/libs/common/src/platform/state/global-state.ts @@ -0,0 +1,20 @@ +import { Observable } from "rxjs"; + +/** + * A helper object for interacting with state that is scoped to a specific domain + * but is not scoped to a user. This is application wide storage. + */ +export interface GlobalState { + /** + * Method for allowing you to manipulate state in an additive way. + * @param configureState callback for how you want manipulate this section of state + * @returns A promise that must be awaited before your next action to ensure the update has been written to state. + */ + update: (configureState: (state: T) => T) => Promise; + + /** + * An observable stream of this state, the first emission of this will be the current state on disk + * and subsequent updates will be from an update to that state. + */ + state$: Observable; +} diff --git a/libs/common/src/platform/state/implementations/default-derived-state.ts b/libs/common/src/platform/state/implementations/default-derived-state.ts new file mode 100644 index 0000000000..1d822c3d29 --- /dev/null +++ b/libs/common/src/platform/state/implementations/default-derived-state.ts @@ -0,0 +1,23 @@ +import { Observable, switchMap } from "rxjs"; + +import { EncryptService } from "../../abstractions/encrypt.service"; +import { DerivedUserState } from "../derived-user-state"; +import { Converter, DeriveContext, UserState } from "../user-state"; + +export class DefaultDerivedUserState implements DerivedUserState { + state$: Observable; + + constructor( + private converter: Converter, + private encryptService: EncryptService, + private userState: UserState + ) { + this.state$ = userState.state$.pipe( + switchMap(async (from) => { + // TODO: How do I get the key? + const convertedData = await this.converter(from, new DeriveContext(null, encryptService)); + return convertedData; + }) + ); + } +} diff --git a/libs/common/src/platform/state/implementations/default-global-state.provider.ts b/libs/common/src/platform/state/implementations/default-global-state.provider.ts new file mode 100644 index 0000000000..a00615ddef --- /dev/null +++ b/libs/common/src/platform/state/implementations/default-global-state.provider.ts @@ -0,0 +1,46 @@ +import { + AbstractMemoryStorageService, + AbstractStorageService, +} from "../../abstractions/storage.service"; +import { GlobalState } from "../global-state"; +import { GlobalStateProvider } from "../global-state.provider"; +import { KeyDefinition } from "../key-definition"; +import { StorageLocation } from "../state-definition"; + +import { DefaultGlobalState } from "./default-global-state"; + +export class DefaultGlobalStateProvider implements GlobalStateProvider { + private globalStateCache: Record> = {}; + + constructor( + private memoryStorage: AbstractMemoryStorageService, + private diskStorage: AbstractStorageService + ) {} + + get(keyDefinition: KeyDefinition): GlobalState { + const cacheKey = keyDefinition.buildCacheKey(); + const existingGlobalState = this.globalStateCache[cacheKey]; + if (existingGlobalState != null) { + // The cast into the actual generic is safe because of rules around key definitions + // being unique. + return existingGlobalState as DefaultGlobalState; + } + + const newGlobalState = new DefaultGlobalState( + keyDefinition, + this.getLocation(keyDefinition.stateDefinition.storageLocation) + ); + + this.globalStateCache[cacheKey] = newGlobalState; + return newGlobalState; + } + + private getLocation(location: StorageLocation) { + switch (location) { + case "disk": + return this.diskStorage; + case "memory": + return this.memoryStorage; + } + } +} diff --git a/libs/common/src/platform/state/implementations/default-global-state.spec.ts b/libs/common/src/platform/state/implementations/default-global-state.spec.ts new file mode 100644 index 0000000000..86dc7e1670 --- /dev/null +++ b/libs/common/src/platform/state/implementations/default-global-state.spec.ts @@ -0,0 +1,98 @@ +/** + * need to update test environment so trackEmissions works appropriately + * @jest-environment ../shared/test.environment.ts + */ + +import { Jsonify } from "type-fest"; + +import { trackEmissions } from "../../../../spec"; +import { FakeStorageService } from "../../../../spec/fake-storage.service"; +import { KeyDefinition, globalKeyBuilder } from "../key-definition"; +import { StateDefinition } from "../state-definition"; + +import { DefaultGlobalState } from "./default-global-state"; + +class TestState { + date: Date; + + static fromJSON(jsonState: Jsonify) { + if (jsonState == null) { + return null; + } + + return Object.assign(new TestState(), jsonState, { + date: new Date(jsonState.date), + }); + } +} + +const testStateDefinition = new StateDefinition("fake", "disk"); + +const testKeyDefinition = new KeyDefinition( + testStateDefinition, + "fake", + TestState.fromJSON +); +const globalKey = globalKeyBuilder(testKeyDefinition); + +describe("DefaultGlobalState", () => { + let diskStorageService: FakeStorageService; + let globalState: DefaultGlobalState; + + beforeEach(() => { + diskStorageService = new FakeStorageService(); + globalState = new DefaultGlobalState(testKeyDefinition, diskStorageService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should emit when storage updates", async () => { + const emissions = trackEmissions(globalState.state$); + const newData = { date: new Date() }; + await diskStorageService.save(globalKey, newData); + + expect(emissions).toEqual([ + null, // Initial value + newData, + // JSON.parse(JSON.stringify(newData)), // This is due to the way `trackEmissions` clones + ]); + }); + + it("should not emit when update key does not match", async () => { + const emissions = trackEmissions(globalState.state$); + const newData = { date: new Date() }; + await diskStorageService.save("wrong_key", newData); + + expect(emissions).toEqual( + expect.arrayContaining([ + null, // Initial value + ]) + ); + }); + + it("should save on update", async () => { + const newData = { date: new Date() }; + const result = await globalState.update((state) => { + return newData; + }); + + expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1); + expect(result).toEqual(newData); + }); + + it("should emit once per update", async () => { + const emissions = trackEmissions(globalState.state$); + const newData = { date: new Date() }; + + await globalState.update((state) => { + return newData; + }); + + expect(emissions).toEqual([ + null, // Initial value + newData, + ]); + }); +}); diff --git a/libs/common/src/platform/state/implementations/default-global-state.ts b/libs/common/src/platform/state/implementations/default-global-state.ts new file mode 100644 index 0000000000..a7f6576426 --- /dev/null +++ b/libs/common/src/platform/state/implementations/default-global-state.ts @@ -0,0 +1,60 @@ +import { BehaviorSubject, Observable, defer, filter, map, shareReplay, tap } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { AbstractStorageService } from "../../abstractions/storage.service"; +import { GlobalState } from "../global-state"; +import { KeyDefinition, globalKeyBuilder } from "../key-definition"; + +export class DefaultGlobalState implements GlobalState { + private storageKey: string; + private seededPromise: Promise; + + protected stateSubject: BehaviorSubject = new BehaviorSubject(null); + + state$: Observable; + + constructor( + private keyDefinition: KeyDefinition, + private chosenLocation: AbstractStorageService + ) { + this.storageKey = globalKeyBuilder(this.keyDefinition); + + this.seededPromise = this.chosenLocation.get>(this.storageKey).then((data) => { + const serializedData = this.keyDefinition.deserializer(data); + this.stateSubject.next(serializedData); + }); + + const storageUpdates$ = this.chosenLocation.updates$.pipe( + filter((update) => update.key === this.storageKey), + map((update) => { + return this.keyDefinition.deserializer(update.value as Jsonify); + }), + shareReplay({ bufferSize: 1, refCount: false }) + ); + + this.state$ = defer(() => { + const storageUpdateSubscription = storageUpdates$.subscribe((value) => { + this.stateSubject.next(value); + }); + + return this.stateSubject.pipe( + tap({ + complete: () => storageUpdateSubscription.unsubscribe(), + }) + ); + }); + } + + async update(configureState: (state: T) => T): Promise { + await this.seededPromise; + const currentState = this.stateSubject.getValue(); + const newState = configureState(currentState); + await this.chosenLocation.save(this.storageKey, newState); + return newState; + } + + async getFromState(): Promise { + const data = await this.chosenLocation.get>(this.storageKey); + return this.keyDefinition.deserializer(data); + } +} diff --git a/libs/common/src/platform/state/implementations/default-user-state.provider.ts b/libs/common/src/platform/state/implementations/default-user-state.provider.ts new file mode 100644 index 0000000000..c50e07cc07 --- /dev/null +++ b/libs/common/src/platform/state/implementations/default-user-state.provider.ts @@ -0,0 +1,55 @@ +import { AccountService } from "../../../auth/abstractions/account.service"; +import { EncryptService } from "../../abstractions/encrypt.service"; +import { + AbstractMemoryStorageService, + AbstractStorageService, +} from "../../abstractions/storage.service"; +import { KeyDefinition } from "../key-definition"; +import { StorageLocation } from "../state-definition"; +import { UserState } from "../user-state"; +import { UserStateProvider } from "../user-state.provider"; + +import { DefaultUserState } from "./default-user-state"; + +export class DefaultUserStateProvider implements UserStateProvider { + private userStateCache: Record> = {}; + + constructor( + protected accountService: AccountService, + protected encryptService: EncryptService, + protected memoryStorage: AbstractMemoryStorageService, + protected diskStorage: AbstractStorageService + ) {} + + get(keyDefinition: KeyDefinition): UserState { + const cacheKey = keyDefinition.buildCacheKey(); + const existingUserState = this.userStateCache[cacheKey]; + if (existingUserState != null) { + // I have to cast out of the unknown generic but this should be safe if rules + // around domain token are made + return existingUserState as DefaultUserState; + } + + const newUserState = this.buildUserState(keyDefinition); + this.userStateCache[cacheKey] = newUserState; + return newUserState; + } + + protected buildUserState(keyDefinition: KeyDefinition): UserState { + return new DefaultUserState( + keyDefinition, + this.accountService, + this.encryptService, + this.getLocation(keyDefinition.stateDefinition.storageLocation) + ); + } + + private getLocation(location: StorageLocation) { + switch (location) { + case "disk": + return this.diskStorage; + case "memory": + return this.memoryStorage; + } + } +} diff --git a/libs/common/src/platform/state/implementations/default-user-state.spec.ts b/libs/common/src/platform/state/implementations/default-user-state.spec.ts new file mode 100644 index 0000000000..e1ab3c1a62 --- /dev/null +++ b/libs/common/src/platform/state/implementations/default-user-state.spec.ts @@ -0,0 +1,236 @@ +import { any, mock } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom, timeout } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { trackEmissions } from "../../../../spec"; +import { FakeStorageService } from "../../../../spec/fake-storage.service"; +import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { UserId } from "../../../types/guid"; +import { KeyDefinition } from "../key-definition"; +import { StateDefinition } from "../state-definition"; + +import { DefaultUserState } from "./default-user-state"; + +class TestState { + date: Date; + array: string[]; + + static fromJSON(jsonState: Jsonify) { + if (jsonState == null) { + return null; + } + + return Object.assign(new TestState(), jsonState, { + date: new Date(jsonState.date), + }); + } +} + +const testStateDefinition = new StateDefinition("fake", "disk"); + +const testKeyDefinition = new KeyDefinition( + testStateDefinition, + "fake", + TestState.fromJSON +); + +describe("DefaultUserState", () => { + const accountService = mock(); + let diskStorageService: FakeStorageService; + let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>; + let userState: DefaultUserState; + + beforeEach(() => { + activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(undefined); + accountService.activeAccount$ = activeAccountSubject; + + diskStorageService = new FakeStorageService(); + userState = new DefaultUserState( + testKeyDefinition, + accountService, + null, // Not testing anything with encrypt service + diskStorageService + ); + }); + + const changeActiveUser = async (id: string) => { + const userId = id != null ? `00000000-0000-1000-a000-00000000000${id}` : undefined; + activeAccountSubject.next({ + id: userId as UserId, + email: `test${id}@example.com`, + name: `Test User ${id}`, + status: AuthenticationStatus.Unlocked, + }); + await new Promise((resolve) => setTimeout(resolve, 1)); + }; + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("emits updates for each user switch and update", async () => { + diskStorageService.internalUpdateStore({ + "user_00000000-0000-1000-a000-000000000001_fake_fake": { + date: "2022-09-21T13:14:17.648Z", + array: ["value1", "value2"], + } as Jsonify, + "user_00000000-0000-1000-a000-000000000002_fake_fake": { + date: "2021-09-21T13:14:17.648Z", + array: ["user2_value"], + }, + }); + + const emissions = trackEmissions(userState.state$); + + // User signs in + changeActiveUser("1"); + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Service does an update + await userState.update((state) => { + state.array.push("value3"); + state.date = new Date(2023, 0); + return state; + }); + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Emulate an account switch + await changeActiveUser("2"); + + expect(emissions).toHaveLength(3); + // Gotten starter user data + expect(emissions[0]).toBeTruthy(); + expect(emissions[0].array).toHaveLength(2); + + // Gotten emission for the update call + expect(emissions[1]).toBeTruthy(); + expect(emissions[1].array).toHaveLength(3); + expect(new Date(emissions[1].date).getUTCFullYear()).toBe(2023); + + // The second users data + expect(emissions[2]).toBeTruthy(); + expect(emissions[2].array).toHaveLength(1); + expect(new Date(emissions[2].date).getUTCFullYear()).toBe(2021); + + // Should only be called twice to get state, once for each user + expect(diskStorageService.mock.get).toHaveBeenCalledTimes(2); + expect(diskStorageService.mock.get).toHaveBeenNthCalledWith( + 1, + "user_00000000-0000-1000-a000-000000000001_fake_fake", + any() + ); + expect(diskStorageService.mock.get).toHaveBeenNthCalledWith( + 2, + "user_00000000-0000-1000-a000-000000000002_fake_fake", + any() + ); + + // Should only have saved data for the first user + expect(diskStorageService.mock.save).toHaveBeenCalledTimes(1); + expect(diskStorageService.mock.save).toHaveBeenNthCalledWith( + 1, + "user_00000000-0000-1000-a000-000000000001_fake_fake", + any() + ); + }); + + it("will not emit any value if there isn't an active user", async () => { + let resolvedValue: TestState | undefined = undefined; + let rejectedError: Error | undefined = undefined; + + const promise = firstValueFrom(userState.state$.pipe(timeout(20))) + .then((value) => { + resolvedValue = value; + }) + .catch((err) => { + rejectedError = err; + }); + await promise; + + expect(diskStorageService.mock.get).not.toHaveBeenCalled(); + + expect(resolvedValue).toBe(undefined); + expect(rejectedError).toBeTruthy(); + expect(rejectedError.message).toBe("Timeout has occurred"); + }); + + it("will emit value for a new active user after subscription started", async () => { + let resolvedValue: TestState | undefined = undefined; + let rejectedError: Error | undefined = undefined; + + diskStorageService.internalUpdateStore({ + "user_00000000-0000-1000-a000-000000000001_fake_fake": { + date: "2020-09-21T13:14:17.648Z", + array: ["testValue"], + } as Jsonify, + }); + + const promise = firstValueFrom(userState.state$.pipe(timeout(20))) + .then((value) => { + resolvedValue = value; + }) + .catch((err) => { + rejectedError = err; + }); + await changeActiveUser("1"); + await promise; + + expect(diskStorageService.mock.get).toHaveBeenCalledTimes(1); + + expect(resolvedValue).toBeTruthy(); + expect(resolvedValue.array).toHaveLength(1); + expect(resolvedValue.date.getUTCFullYear()).toBe(2020); + expect(rejectedError).toBeFalsy(); + }); + + it("should not emit a previous users value if that user is no longer active", async () => { + diskStorageService.internalUpdateStore({ + "user_00000000-0000-1000-a000-000000000001_fake_fake": { + date: "2020-09-21T13:14:17.648Z", + array: ["value"], + } as Jsonify, + "user_00000000-0000-1000-a000-000000000002_fake_fake": { + date: "2020-09-21T13:14:17.648Z", + array: [], + } as Jsonify, + }); + + // This starts one subscription on the observable for tracking emissions throughout + // the whole test. + const emissions = trackEmissions(userState.state$); + + // Change to a user with data + await changeActiveUser("1"); + + // This should always return a value right await + const value = await firstValueFrom(userState.state$); + expect(value).toBeTruthy(); + + // Make it such that there is no active user + await changeActiveUser(undefined); + + let resolvedValue: TestState | undefined = undefined; + let rejectedError: Error | undefined = undefined; + + // Even if the observable has previously emitted a value it shouldn't have + // a value for the user subscribing to it because there isn't an active user + // to get data for. + await firstValueFrom(userState.state$.pipe(timeout(20))) + .then((value) => { + resolvedValue = value; + }) + .catch((err) => { + rejectedError = err; + }); + + expect(resolvedValue).toBeFalsy(); + expect(rejectedError).toBeTruthy(); + expect(rejectedError.message).toBe("Timeout has occurred"); + + // We need to figure out if something should be emitted + // when there becomes no active user, if we don't want that to emit + // this value is correct. + expect(emissions).toHaveLength(2); + }); +}); diff --git a/libs/common/src/platform/state/implementations/default-user-state.ts b/libs/common/src/platform/state/implementations/default-user-state.ts new file mode 100644 index 0000000000..10d1329d70 --- /dev/null +++ b/libs/common/src/platform/state/implementations/default-user-state.ts @@ -0,0 +1,152 @@ +import { + Observable, + BehaviorSubject, + map, + shareReplay, + switchMap, + tap, + defer, + firstValueFrom, + combineLatestWith, + filter, +} from "rxjs"; +import { Jsonify } from "type-fest"; + +import { AccountService } from "../../../auth/abstractions/account.service"; +import { UserId } from "../../../types/guid"; +import { EncryptService } from "../../abstractions/encrypt.service"; +import { AbstractStorageService } from "../../abstractions/storage.service"; +import { DerivedUserState } from "../derived-user-state"; +import { KeyDefinition, userKeyBuilder } from "../key-definition"; +import { Converter, UserState } from "../user-state"; + +import { DefaultDerivedUserState } from "./default-derived-state"; + +const FAKE_DEFAULT = Symbol("fakeDefault"); + +export class DefaultUserState implements UserState { + private formattedKey$: Observable; + + protected stateSubject: BehaviorSubject = new BehaviorSubject< + T | typeof FAKE_DEFAULT + >(FAKE_DEFAULT); + private stateSubject$ = this.stateSubject.asObservable(); + + state$: Observable; + + constructor( + protected keyDefinition: KeyDefinition, + private accountService: AccountService, + private encryptService: EncryptService, + private chosenStorageLocation: AbstractStorageService + ) { + this.formattedKey$ = this.accountService.activeAccount$.pipe( + map((account) => + account != null && account.id != null + ? userKeyBuilder(account.id, this.keyDefinition) + : null + ), + shareReplay({ bufferSize: 1, refCount: false }) + ); + + const activeAccountData$ = this.formattedKey$.pipe( + switchMap(async (key) => { + if (key == null) { + return FAKE_DEFAULT; + } + const jsonData = await this.chosenStorageLocation.get>(key); + const data = keyDefinition.deserializer(jsonData); + return data; + }), + // Share the execution + shareReplay({ refCount: false, bufferSize: 1 }) + ); + + const storageUpdates$ = this.chosenStorageLocation.updates$.pipe( + combineLatestWith(this.formattedKey$), + filter(([update, key]) => key !== null && update.key === key), + map(([update]) => { + return keyDefinition.deserializer(update.value as Jsonify); + }) + ); + + // Whomever subscribes to this data, should be notified of updated data + // if someone calls my update() method, or the active user changes. + this.state$ = defer(() => { + const accountChangeSubscription = activeAccountData$.subscribe((data) => { + this.stateSubject.next(data); + }); + const storageUpdateSubscription = storageUpdates$.subscribe((data) => { + this.stateSubject.next(data); + }); + + return this.stateSubject$.pipe( + tap({ + complete: () => { + accountChangeSubscription.unsubscribe(); + storageUpdateSubscription.unsubscribe(); + }, + }) + ); + }) + // I fake the generic here because I am filtering out the other union type + // and this makes it so that typescript understands the true type + .pipe(filter((value) => value != FAKE_DEFAULT)); + } + + async update(configureState: (state: T) => T): Promise { + const key = await this.createKey(); + const currentState = await this.getGuaranteedState(key); + const newState = configureState(currentState); + await this.saveToStorage(key, newState); + return newState; + } + + async updateFor(userId: UserId, configureState: (state: T) => T): Promise { + if (userId == null) { + throw new Error("Attempting to update user state, but no userId has been supplied."); + } + + const key = userKeyBuilder(userId, this.keyDefinition); + const currentStore = await this.chosenStorageLocation.get>(key); + const currentState = this.keyDefinition.deserializer(currentStore); + const newState = configureState(currentState); + await this.saveToStorage(key, newState); + + return newState; + } + + async getFromState(): Promise { + const key = await this.createKey(); + const data = await this.chosenStorageLocation.get>(key); + return this.keyDefinition.deserializer(data); + } + + createDerived(converter: Converter): DerivedUserState { + return new DefaultDerivedUserState(converter, this.encryptService, this); + } + + protected async createKey(): Promise { + const formattedKey = await firstValueFrom(this.formattedKey$); + if (formattedKey == null) { + throw new Error("Cannot create a key while there is no active user."); + } + return formattedKey; + } + + protected async getGuaranteedState(key: string) { + const currentValue = this.stateSubject.getValue(); + return currentValue === FAKE_DEFAULT ? await this.seedInitial(key) : currentValue; + } + + private async seedInitial(key: string): Promise { + const data = await this.chosenStorageLocation.get>(key); + const serializedData = this.keyDefinition.deserializer(data); + this.stateSubject.next(serializedData); + return serializedData; + } + + protected saveToStorage(key: string, data: T): Promise { + return this.chosenStorageLocation.save(key, data); + } +} diff --git a/libs/common/src/platform/state/index.ts b/libs/common/src/platform/state/index.ts new file mode 100644 index 0000000000..92f2ed2dbf --- /dev/null +++ b/libs/common/src/platform/state/index.ts @@ -0,0 +1,3 @@ +export { DerivedUserState } from "./derived-user-state"; +export { DefaultGlobalStateProvider } from "./implementations/default-global-state.provider"; +export { DefaultUserStateProvider } from "./implementations/default-user-state.provider"; diff --git a/libs/common/src/platform/state/key-definition.ts b/libs/common/src/platform/state/key-definition.ts new file mode 100644 index 0000000000..91dafcb5e9 --- /dev/null +++ b/libs/common/src/platform/state/key-definition.ts @@ -0,0 +1,102 @@ +import { Jsonify, Opaque } from "type-fest"; + +import { UserId } from "../../types/guid"; +import { Utils } from "../misc/utils"; + +import { StateDefinition } from "./state-definition"; + +/** + * KeyDefinitions describe the precise location to store data for a given piece of state. + * The StateDefinition is used to describe the domain of the state, and the KeyDefinition + * sub-divides that domain into specific keys. + */ +export class KeyDefinition { + /** + * Creates a new instance of a KeyDefinition + * @param stateDefinition The state definition for which this key belongs to. + * @param key The name of the key, this should be unique per domain + * @param deserializer A function to use to safely convert your type from json to your expected type. + */ + constructor( + readonly stateDefinition: StateDefinition, + readonly key: string, + readonly deserializer: (jsonValue: Jsonify) => T + ) {} + + /** + * Creates a {@link KeyDefinition} for state that is an array. + * @param stateDefinition The state definition to be added to the KeyDefinition + * @param key The key to be added to the KeyDefinition + * @param deserializer The deserializer for the element of the array in your state. + * @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each + * element of an array **unless that array is null in which case it will return an empty list.** + */ + static array( + stateDefinition: StateDefinition, + key: string, + deserializer: (jsonValue: Jsonify) => T + ) { + return new KeyDefinition(stateDefinition, key, (jsonValue) => { + return jsonValue?.map((v) => deserializer(v)) ?? []; + }); + } + + /** + * Creates a {@link KeyDefinition} for state that is a record. + * @param stateDefinition The state definition to be added to the KeyDefinition + * @param key The key to be added to the KeyDefinition + * @param deserializer The deserializer for the value part of a record. + * @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each + * value in a record and returns every key as a string **unless that record is null in which case it will return an record.** + */ + static record( + stateDefinition: StateDefinition, + key: string, + deserializer: (jsonValue: Jsonify) => T + ) { + return new KeyDefinition>(stateDefinition, key, (jsonValue) => { + const output: Record = {}; + + if (jsonValue == null) { + return output; + } + + for (const key in jsonValue) { + output[key] = deserializer((jsonValue as Record>)[key]); + } + return output; + }); + } + + /** + * + * @returns + */ + buildCacheKey(): string { + return `${this.stateDefinition.storageLocation}_${this.stateDefinition.name}_${this.key}`; + } +} + +export type StorageKey = Opaque; + +/** + * Creates a {@link StorageKey} that points to the data at the given key definition for the specified user. + * @param userId The userId of the user you want the key to be for. + * @param keyDefinition The key definition of which data the key should point to. + * @returns A key that is ready to be used in a storage service to get data. + */ +export function userKeyBuilder(userId: UserId, keyDefinition: KeyDefinition): StorageKey { + if (!Utils.isGuid(userId)) { + throw new Error("You cannot build a user key without a valid UserId"); + } + return `user_${userId}_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey; +} + +/** + * Creates a {@link StorageKey} + * @param keyDefinition The key definition of which data the key should point to. + * @returns A key that is ready to be used in a storage service to get data. + */ +export function globalKeyBuilder(keyDefinition: KeyDefinition): StorageKey { + return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey; +} diff --git a/libs/common/src/platform/state/state-definition.ts b/libs/common/src/platform/state/state-definition.ts new file mode 100644 index 0000000000..8bc766b534 --- /dev/null +++ b/libs/common/src/platform/state/state-definition.ts @@ -0,0 +1,13 @@ +export type StorageLocation = "disk" | "memory"; + +/** + * Defines the base location and instruction of where this state is expected to be located. + */ +export class StateDefinition { + /** + * Creates a new instance of {@link StateDefinition}, the creation of which is owned by the platform team. + * @param name The name of the state, this needs to be unique from all other {@link StateDefinition}'s. + * @param storageLocation The location of where this state should be stored. + */ + constructor(readonly name: string, readonly storageLocation: StorageLocation) {} +} diff --git a/libs/common/src/platform/state/user-state.provider.ts b/libs/common/src/platform/state/user-state.provider.ts new file mode 100644 index 0000000000..2e20bc4e51 --- /dev/null +++ b/libs/common/src/platform/state/user-state.provider.ts @@ -0,0 +1,13 @@ +import { KeyDefinition } from "./key-definition"; +import { UserState } from "./user-state"; + +/** + * A provider for getting an implementation of user scoped state for the given key. + */ +export abstract class UserStateProvider { + /** + * Gets a {@link GlobalState} scoped to the given {@link KeyDefinition} + * @param keyDefinition - The {@link KeyDefinition} for which you want the user state for. + */ + get: (keyDefinition: KeyDefinition) => UserState; +} diff --git a/libs/common/src/platform/state/user-state.ts b/libs/common/src/platform/state/user-state.ts new file mode 100644 index 0000000000..82113e37ae --- /dev/null +++ b/libs/common/src/platform/state/user-state.ts @@ -0,0 +1,41 @@ +import { Observable } from "rxjs"; + +import { UserId } from "../../types/guid"; +import { EncryptService } from "../abstractions/encrypt.service"; +import { UserKey } from "../models/domain/symmetric-crypto-key"; + +import { DerivedUserState } from "."; + +export class DeriveContext { + constructor(readonly activeUserKey: UserKey, readonly encryptService: EncryptService) {} +} + +export type Converter = (data: TFrom, context: DeriveContext) => Promise; + +/** + * A helper object for interacting with state that is scoped to a specific user. + */ +export interface UserState { + readonly state$: Observable; + readonly getFromState: () => Promise; + /** + * Updates backing stores for the active user. + * @param configureState function that takes the current state and returns the new state + * @returns The new state + */ + readonly update: (configureState: (state: T) => T) => Promise; + /** + * Updates backing stores for the given userId, which may or may not be active. + * @param userId the UserId to target the update for + * @param configureState function that takes the current state for the targeted user and returns the new state + * @returns The new state + */ + readonly updateFor: (userId: UserId, configureState: (state: T) => T) => Promise; + + /** + * Creates a derives state from the current state. Derived states are always tied to the active user. + * @param converter + * @returns + */ + createDerived: (converter: Converter) => DerivedUserState; +} diff --git a/libs/node/jest.config.js b/libs/node/jest.config.js index fd7d580fda..dc98adf8dd 100644 --- a/libs/node/jest.config.js +++ b/libs/node/jest.config.js @@ -8,6 +8,7 @@ const sharedConfig = require("../shared/jest.config.ts"); module.exports = { ...sharedConfig, preset: "ts-jest", + testEnvironment: "node", setupFilesAfterEnv: ["/test.setup.ts"], moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { prefix: "/", diff --git a/libs/shared/test.environment.ts b/libs/shared/test.environment.ts new file mode 100644 index 0000000000..404303d1aa --- /dev/null +++ b/libs/shared/test.environment.ts @@ -0,0 +1,22 @@ +import JSDOMEnvironment from "jest-environment-jsdom"; + +/** + * https://github.com/jsdom/jsdom/issues/3363#issuecomment-1467894943 + * Adds nodes structuredClone implementation to the global object of jsdom. + * use by either adding this file to the testEnvironment property of jest config + * or by adding the following to the top spec file: + * + * ``` + * /** + * * @jest-environment ../shared/test.environment.ts + * *\/ + * ``` + */ +export default class FixJSDOMEnvironment extends JSDOMEnvironment { + constructor(...args: ConstructorParameters) { + super(...args); + + // FIXME https://github.com/jsdom/jsdom/issues/3363 + this.global.structuredClone = structuredClone; + } +}