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 <MGibson1@users.noreply.github.com>

* Add updates$ stream to existing storageServices

Original commit by Matt: 823d9546fe
Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>

* 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 <MGibson1@users.noreply.github.com>

* Add Barrel File for state

Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>

* 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 <MGibson1@users.noreply.github.com>

* 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 <Hinton@users.noreply.github.com>

* 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 <Hinton@users.noreply.github.com>

* Add `buildCacheKey` Method

* Fix lint errors

* Add Comment

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* Use Generic in callback parameter

* Refactor Out DerivedStateDefinition

* Persist Listener Return Type

* Add Ticket Link

---------

Co-authored-by: Matt Gibson <MGibson1@users.noreply.github.com>
Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
This commit is contained in:
Justin Baur 2023-11-09 17:06:42 -05:00 committed by GitHub
parent 801141f90e
commit e1b5b83723
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1352 additions and 68 deletions

View File

@ -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']"
}
]
}

View File

@ -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<typeof chrome.storage.onChanged.addListener>[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<T extends (...args: readonly unknown[]) => unknown>(
event: chrome.events.Event<T>,
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<T extends (...args: readonly unknown[]) => unknown>(
event: chrome.events.Event<T>,
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);
}
};
}

View File

@ -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();
});
});

View File

@ -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<T extends unknown[]>(
event: chrome.events.Event<(...args: T) => void>
): Observable<T> {
return new Observable<T>((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);
});
}

View File

@ -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<StorageUpdate> {
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<T>(key: string): Promise<T> {
return new Promise((resolve) => {
@ -22,11 +54,7 @@ export default abstract class AbstractChromeStorageService implements AbstractSt
async save(key: string, obj: any): Promise<void> {
if (obj == null) {
// Fix safari not liking null in set
return new Promise<void>((resolve) => {
this.chromeStorageApi.remove(key, () => {
resolve();
});
});
return this.remove(key);
}
if (obj instanceof Set) {

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<string, unknown>();
private localStorage = new BrowserLocalStorageService();
private sessionStorage = new BrowserMemoryStorageService();
private updatesSubject = new Subject<StorageUpdate>();
constructor(
private encryptService: EncryptService,
@ -30,6 +35,10 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi
super();
}
get updates$() {
return this.updatesSubject.asObservable();
}
async get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
if (this.cache.has(key)) {
return this.cache.get(key) as T;

View File

@ -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)
);

View File

@ -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<any>;
private defaults: any;
private ready = false;
private updatesSubject = new Subject<StorageUpdate>();
constructor(
protected logService: LogService,
@ -102,6 +107,10 @@ export class LowdbStorageService implements AbstractStorageService {
this.ready = true;
}
get updates$() {
return this.updatesSubject.asObservable();
}
async get<T>(key: string): Promise<T> {
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<any> {
async save(key: string, obj: any): Promise<void> {
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<any> {
async remove(key: string): Promise<void> {
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;
});

View File

@ -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<T>(key: string): Promise<T> {
const value = await this.storageService.get<string>(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<any> {
async save(key: string, obj: any): Promise<void> {
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<any> {
return this.storageService.remove(this.makeProtectedStorageKey(key));
async remove(key: string): Promise<void> {
await this.storageService.remove(this.makeProtectedStorageKey(key));
return;
}
private async encrypt(plainValue: string): Promise<string> {

View File

@ -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<T>(key: string, options?: StorageOptions): Promise<T> {
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<any> {
async save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
await ipc.platform.passwords.set(key, options?.keySuffix ?? "", JSON.stringify(obj));
}
async remove(key: string, options?: StorageOptions): Promise<any> {
async remove(key: string, options?: StorageOptions): Promise<void> {
await ipc.platform.passwords.delete(key, options?.keySuffix ?? "");
}
}

View File

@ -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<StorageUpdate>();
get updates$() {
return this.updatesSubject.asObservable();
}
get<T>(key: string): Promise<T> {
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<any> {
return ipc.platform.storage.save(key, obj);
async save<T>(key: string, obj: T): Promise<void> {
await ipc.platform.storage.save(key, obj);
this.updatesSubject.next({ key, value: obj, updateType: "save" });
}
remove(key: string): Promise<any> {
return ipc.platform.storage.remove(key);
async remove(key: string): Promise<void> {
await ipc.platform.storage.remove(key);
this.updatesSubject.next({ key, value: null, updateType: "remove" });
}
}

View File

@ -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<StorageUpdate>();
constructor(dir: string, defaults = {}) {
if (!fs.existsSync(dir)) {
@ -60,6 +65,10 @@ export class ElectronStorageService implements AbstractStorageService {
});
}
get updates$() {
return this.updatesSubject.asObservable();
}
get<T>(key: string): Promise<T> {
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<void> {
this.store.delete(key);
this.updatesSubject.next({ key, value: null, updateType: "remove" });
return Promise.resolve();
}
}

View File

@ -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<StorageUpdate>();
get defaultOptions(): StorageOptions {
return { htmlStorageLocation: HtmlStorageLocation.Session };
}
get updates$() {
return this.updatesSubject.asObservable();
}
get<T>(key: string, options: StorageOptions = this.defaultOptions): Promise<T> {
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();
}
}

View File

@ -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<string, unknown>;
private updatesSubject = new Subject<StorageUpdate>();
/**
* 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<AbstractStorageService>;
constructor(initial?: Record<string, unknown>) {
this.store = initial ?? {};
this.mock = mock<AbstractStorageService>();
}
/**
* 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<string, unknown>) {
this.store = store;
}
get updates$() {
return this.updatesSubject.asObservable();
}
get<T>(key: string, options?: StorageOptions): Promise<T> {
this.mock.get(key, options);
const value = this.store[key] as T;
return Promise.resolve(value);
}
has(key: string, options?: StorageOptions): Promise<boolean> {
this.mock.has(key, options);
return Promise.resolve(this.store[key] != null);
}
save<T>(key: string, obj: T, options?: StorageOptions): Promise<void> {
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<void> {
this.mock.remove(key, options);
delete this.store[key];
this.updatesSubject.next({ key: key, value: undefined, updateType: "remove" });
return Promise.resolve();
}
}

View File

@ -69,12 +69,18 @@ export function trackEmissions<T>(observable: Observable<T>): 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));
}
}

View File

@ -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<StorageUpdate>;
abstract get<T>(key: string, options?: StorageOptions): Promise<T>;
abstract has(key: string, options?: StorageOptions): Promise<boolean>;
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;

View File

@ -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<string, any>();
private store = new Map<string, unknown>();
private updatesSubject = new Subject<StorageUpdate>();
get updates$() {
return this.updatesSubject.asObservable();
}
get<T>(key: string): Promise<T> {
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<any> {
save<T>(key: string, obj: T): Promise<void> {
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<any> {
remove(key: string): Promise<void> {
this.store.delete(key);
this.updatesSubject.next({ key, value: null, updateType: "remove" });
return Promise.resolve();
}

View File

@ -0,0 +1,5 @@
import { Observable } from "rxjs";
export interface DerivedUserState<T> {
state$: Observable<T>;
}

View File

@ -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: <T>(keyDefinition: KeyDefinition<T>) => GlobalState<T>;
}

View File

@ -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<T> {
/**
* 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<T>;
/**
* 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<T>;
}

View File

@ -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<TFrom, TTo> implements DerivedUserState<TTo> {
state$: Observable<TTo>;
constructor(
private converter: Converter<TFrom, TTo>,
private encryptService: EncryptService,
private userState: UserState<TFrom>
) {
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;
})
);
}
}

View File

@ -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<string, GlobalState<unknown>> = {};
constructor(
private memoryStorage: AbstractMemoryStorageService,
private diskStorage: AbstractStorageService
) {}
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
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<T>;
}
const newGlobalState = new DefaultGlobalState<T>(
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;
}
}
}

View File

@ -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<TestState>) {
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<TestState>(
testStateDefinition,
"fake",
TestState.fromJSON
);
const globalKey = globalKeyBuilder(testKeyDefinition);
describe("DefaultGlobalState", () => {
let diskStorageService: FakeStorageService;
let globalState: DefaultGlobalState<TestState>;
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,
]);
});
});

View File

@ -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<T> implements GlobalState<T> {
private storageKey: string;
private seededPromise: Promise<void>;
protected stateSubject: BehaviorSubject<T | null> = new BehaviorSubject<T | null>(null);
state$: Observable<T>;
constructor(
private keyDefinition: KeyDefinition<T>,
private chosenLocation: AbstractStorageService
) {
this.storageKey = globalKeyBuilder(this.keyDefinition);
this.seededPromise = this.chosenLocation.get<Jsonify<T>>(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<T>);
}),
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<T> {
await this.seededPromise;
const currentState = this.stateSubject.getValue();
const newState = configureState(currentState);
await this.chosenLocation.save(this.storageKey, newState);
return newState;
}
async getFromState(): Promise<T> {
const data = await this.chosenLocation.get<Jsonify<T>>(this.storageKey);
return this.keyDefinition.deserializer(data);
}
}

View File

@ -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<string, UserState<unknown>> = {};
constructor(
protected accountService: AccountService,
protected encryptService: EncryptService,
protected memoryStorage: AbstractMemoryStorageService,
protected diskStorage: AbstractStorageService
) {}
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
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<T>;
}
const newUserState = this.buildUserState(keyDefinition);
this.userStateCache[cacheKey] = newUserState;
return newUserState;
}
protected buildUserState<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
return new DefaultUserState<T>(
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;
}
}
}

View File

@ -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<TestState>) {
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<TestState>(
testStateDefinition,
"fake",
TestState.fromJSON
);
describe("DefaultUserState", () => {
const accountService = mock<AccountService>();
let diskStorageService: FakeStorageService;
let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>;
let userState: DefaultUserState<TestState>;
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<TestState>,
"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<void>((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<void>((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<TestState>,
});
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<TestState>,
"user_00000000-0000-1000-a000-000000000002_fake_fake": {
date: "2020-09-21T13:14:17.648Z",
array: [],
} as Jsonify<TestState>,
});
// 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);
});
});

View File

@ -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<T> implements UserState<T> {
private formattedKey$: Observable<string>;
protected stateSubject: BehaviorSubject<T | typeof FAKE_DEFAULT> = new BehaviorSubject<
T | typeof FAKE_DEFAULT
>(FAKE_DEFAULT);
private stateSubject$ = this.stateSubject.asObservable();
state$: Observable<T>;
constructor(
protected keyDefinition: KeyDefinition<T>,
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<Jsonify<T>>(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<T>);
})
);
// 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<T>((value) => value != FAKE_DEFAULT));
}
async update(configureState: (state: T) => T): Promise<T> {
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<T> {
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<Jsonify<T>>(key);
const currentState = this.keyDefinition.deserializer(currentStore);
const newState = configureState(currentState);
await this.saveToStorage(key, newState);
return newState;
}
async getFromState(): Promise<T> {
const key = await this.createKey();
const data = await this.chosenStorageLocation.get<Jsonify<T>>(key);
return this.keyDefinition.deserializer(data);
}
createDerived<TTo>(converter: Converter<T, TTo>): DerivedUserState<TTo> {
return new DefaultDerivedUserState<T, TTo>(converter, this.encryptService, this);
}
protected async createKey(): Promise<string> {
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<T> {
const data = await this.chosenStorageLocation.get<Jsonify<T>>(key);
const serializedData = this.keyDefinition.deserializer(data);
this.stateSubject.next(serializedData);
return serializedData;
}
protected saveToStorage(key: string, data: T): Promise<void> {
return this.chosenStorageLocation.save(key, data);
}
}

View File

@ -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";

View File

@ -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<T> {
/**
* 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>) => 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<T>(
stateDefinition: StateDefinition,
key: string,
deserializer: (jsonValue: Jsonify<T>) => T
) {
return new KeyDefinition<T[]>(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<T>(
stateDefinition: StateDefinition,
key: string,
deserializer: (jsonValue: Jsonify<T>) => T
) {
return new KeyDefinition<Record<string, T>>(stateDefinition, key, (jsonValue) => {
const output: Record<string, T> = {};
if (jsonValue == null) {
return output;
}
for (const key in jsonValue) {
output[key] = deserializer((jsonValue as Record<string, Jsonify<T>>)[key]);
}
return output;
});
}
/**
*
* @returns
*/
buildCacheKey(): string {
return `${this.stateDefinition.storageLocation}_${this.stateDefinition.name}_${this.key}`;
}
}
export type StorageKey = Opaque<string, "StorageKey">;
/**
* 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<unknown>): 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<unknown>): StorageKey {
return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey;
}

View File

@ -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) {}
}

View File

@ -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: <T>(keyDefinition: KeyDefinition<T>) => UserState<T>;
}

View File

@ -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<TFrom, TTo> = (data: TFrom, context: DeriveContext) => Promise<TTo>;
/**
* A helper object for interacting with state that is scoped to a specific user.
*/
export interface UserState<T> {
readonly state$: Observable<T>;
readonly getFromState: () => Promise<T>;
/**
* 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<T>;
/**
* 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<T>;
/**
* Creates a derives state from the current state. Derived states are always tied to the active user.
* @param converter
* @returns
*/
createDerived: <TTo>(converter: Converter<T, TTo>) => DerivedUserState<TTo>;
}

View File

@ -8,6 +8,7 @@ const sharedConfig = require("../shared/jest.config.ts");
module.exports = {
...sharedConfig,
preset: "ts-jest",
testEnvironment: "node",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/",

View File

@ -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<typeof JSDOMEnvironment>) {
super(...args);
// FIXME https://github.com/jsdom/jsdom/issues/3363
this.global.structuredClone = structuredClone;
}
}