1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 09:43:23 +00:00

[PM-8280] email forwarders (#11563)

* forwarder lookup and generation support
* localize algorithm names and descriptions in the credential generator service
* add encryption support to UserStateSubject
* move generic rx utilities to common
* move icon button labels to generator configurations
This commit is contained in:
✨ Audrey ✨
2024-10-23 12:11:42 -04:00
committed by GitHub
parent e67577cc39
commit eff9a423da
45 changed files with 3403 additions and 1005 deletions

View File

@@ -17,3 +17,9 @@ export type ClassifiedFormat<Id, Disclosed> = {
*/
readonly disclosed: Jsonify<Disclosed>;
};
export function isClassifiedFormat<Id, Disclosed>(
value: any,
): value is ClassifiedFormat<Id, Disclosed> {
return "id" in value && "secret" in value && "disclosed" in value;
}

View File

@@ -1,4 +1,11 @@
import { Constraints, StateConstraints } from "../types";
import { BehaviorSubject, Observable } from "rxjs";
import {
Constraints,
DynamicStateConstraints,
StateConstraints,
SubjectConstraints,
} from "../types";
// The constraints type shares the properties of the state,
// but never has any members
@@ -9,16 +16,31 @@ const EMPTY_CONSTRAINTS = new Proxy<any>(Object.freeze({}), {
});
/** A constraint that does nothing. */
export class IdentityConstraint<State extends object> implements StateConstraints<State> {
export class IdentityConstraint<State extends object>
implements StateConstraints<State>, DynamicStateConstraints<State>
{
/** Instantiate the identity constraint */
constructor() {}
readonly constraints: Readonly<Constraints<State>> = EMPTY_CONSTRAINTS;
calibrate() {
return this;
}
adjust(state: State) {
return state;
}
fix(state: State) {
return state;
}
}
/** Emits a constraint that does not alter the input state. */
export function unconstrained$<State extends object>(): Observable<SubjectConstraints<State>> {
const identity = new IdentityConstraint<State>();
const constraints$ = new BehaviorSubject(identity);
return constraints$;
}

View File

@@ -0,0 +1,53 @@
import { UserKeyDefinition, UserKeyDefinitionOptions } from "../../platform/state";
// eslint-disable-next-line -- `StateDefinition` used as a type
import type { StateDefinition } from "../../platform/state/state-definition";
import { ClassifiedFormat } from "./classified-format";
import { Classifier } from "./classifier";
/** A key for storing JavaScript objects (`{ an: "example" }`)
* in a UserStateSubject.
*/
// FIXME: promote to class: `ObjectConfiguration<State, Secret, Disclosed>`.
// The class receives `encryptor`, `prepareNext`, `adjust`, and `fix`
// From `UserStateSubject`. `UserStateSubject` keeps `classify` and
// `declassify`. The class should also include serialization
// facilities (to be used in place of JSON.parse/stringify) in it's
// options. Also allow swap between "classifier" and "classification"; the
// latter is a list of properties/arguments to the specific classifier in-use.
export type ObjectKey<State, Secret = State, Disclosed = Record<string, never>> = {
target: "object";
key: string;
state: StateDefinition;
classifier: Classifier<State, Disclosed, Secret>;
format: "plain" | "classified";
options: UserKeyDefinitionOptions<State>;
};
export function isObjectKey(key: any): key is ObjectKey<unknown> {
return key.target === "object" && "format" in key && "classifier" in key;
}
export function toUserKeyDefinition<State, Secret, Disclosed>(
key: ObjectKey<State, Secret, Disclosed>,
) {
if (key.format === "plain") {
const plain = new UserKeyDefinition<State>(key.state, key.key, key.options);
return plain;
} else if (key.format === "classified") {
const classified = new UserKeyDefinition<ClassifiedFormat<void, Disclosed>>(
key.state,
key.key,
{
cleanupDelayMs: key.options.cleanupDelayMs,
deserializer: (jsonValue) => jsonValue as ClassifiedFormat<void, Disclosed>,
clearOn: key.options.clearOn,
},
);
return classified;
} else {
throw new Error(`unknown format: ${key.format}`);
}
}

View File

@@ -1,6 +1,6 @@
import { Observable } from "rxjs";
import { DynamicStateConstraints, StateConstraints } from "../types";
import { DynamicStateConstraints, StateConstraints, SubjectConstraints } from "../types";
/** A pattern for types that depend upon a dynamic set of constraints.
*
@@ -10,12 +10,12 @@ import { DynamicStateConstraints, StateConstraints } from "../types";
* last-emitted constraints. If `constraints$` completes, the consumer should
* continue using the last-emitted constraints.
*/
export type StateConstraintsDependency<State> = {
export type SubjectConstraintsDependency<State> = {
/** A stream that emits constraints when subscribed and when the
* constraints change. The stream should not emit `null` or
* `undefined`.
*/
constraints$: Observable<StateConstraints<State> | DynamicStateConstraints<State>>;
constraints$: Observable<SubjectConstraints<State>>;
};
/** Returns `true` if the input constraint is a `DynamicStateConstraints<T>`.

View File

@@ -1,15 +1,23 @@
import { Simplify } from "type-fest";
import { RequireExactlyOne, Simplify } from "type-fest";
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
import {
Dependencies,
SingleUserDependency,
SingleUserEncryptorDependency,
WhenDependency,
} from "../dependencies";
import { StateConstraintsDependency } from "./state-constraints-dependency";
import { SubjectConstraintsDependency } from "./state-constraints-dependency";
/** dependencies accepted by the user state subject */
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
SingleUserDependency &
RequireExactlyOne<
SingleUserDependency & SingleUserEncryptorDependency,
"singleUserEncryptor$" | "singleUserId$"
> &
Partial<WhenDependency> &
Partial<Dependencies<Dependency>> &
Partial<StateConstraintsDependency<State>> & {
Partial<SubjectConstraintsDependency<State>> & {
/** Compute the next stored value. If this is not set, values
* provided to `next` unconditionally override state.
* @param current the value stored in state

View File

@@ -1,14 +1,50 @@
import { BehaviorSubject, of, Subject } from "rxjs";
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec";
import { UserBound } from "../dependencies";
import { PrivateClassifier } from "../private-classifier";
import { StateConstraints } from "../types";
import { ClassifiedFormat } from "./classified-format";
import { ObjectKey } from "./object-key";
import { UserEncryptor } from "./user-encryptor.abstraction";
import { UserStateSubject } from "./user-state-subject";
const SomeUser = "some user" as UserId;
type TestType = { foo: string };
const SomeKey = new UserKeyDefinition<TestType>(GENERATOR_DISK, "TestKey", {
deserializer: (d) => d as TestType,
clearOn: [],
});
const SomeObjectKey = {
target: "object",
key: "TestObjectKey",
state: GENERATOR_DISK,
classifier: new PrivateClassifier(),
format: "classified",
options: {
deserializer: (d) => d as TestType,
clearOn: ["logout"],
},
} satisfies ObjectKey<TestType>;
const SomeEncryptor: UserEncryptor = {
userId: SomeUser,
encrypt(secret) {
const tmp: any = secret;
return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any);
},
decrypt(secret) {
const tmp: any = JSON.parse(secret.encryptedString);
return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any);
},
};
function fooMaxLength(maxLength: number): StateConstraints<TestType> {
return Object.freeze({
@@ -43,7 +79,11 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next);
const when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
const subject = new UserStateSubject(SomeKey, () => state, {
singleUserId$,
nextValue,
when$,
});
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
subject.next({ foo: "next" });
@@ -65,7 +105,11 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next);
const when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
const subject = new UserStateSubject(SomeKey, () => state, {
singleUserId$,
nextValue,
when$,
});
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
subject.next({ foo: "next" });
@@ -79,11 +123,35 @@ describe("UserStateSubject", () => {
expect(nextValue).toHaveBeenCalledTimes(1);
});
it("ignores repeated singleUserEncryptor$ emissions", async () => {
// this test looks for `nextValue` because a subscription isn't necessary for
// the subject to update
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const nextValue = jest.fn((_, next) => next);
const singleUserEncryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor: null });
const subject = new UserStateSubject(SomeKey, () => state, {
nextValue,
singleUserEncryptor$,
});
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
subject.next({ foo: "next" });
await awaitAsync();
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
await awaitAsync();
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
await awaitAsync();
expect(nextValue).toHaveBeenCalledTimes(1);
});
it("waits for constraints$", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(3));
@@ -91,13 +159,28 @@ describe("UserStateSubject", () => {
expect(initResult).toEqual({ foo: "ini" });
});
it("waits for singleUserEncryptor$", async () => {
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
SomeUser,
{ id: null, secret: '{"foo":"init"}', disclosed: {} },
);
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
const tracker = new ObservableTracker(subject);
singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor });
const [initResult] = await tracker.pauseUntilReceived(1);
expect(initResult).toEqual({ foo: "decrypt(init)" });
});
});
describe("next", () => {
it("emits the next value", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const expected: TestType = { foo: "next" };
let actual: TestType = null;
@@ -114,7 +197,7 @@ describe("UserStateSubject", () => {
const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let actual: TestType = null;
subject.subscribe((value) => {
@@ -132,7 +215,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const shouldUpdate = jest.fn(() => true);
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate });
const nextVal: TestType = { foo: "next" };
subject.next(nextVal);
@@ -147,7 +230,7 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser);
const shouldUpdate = jest.fn(() => true);
const dependencyValue = { bar: "dependency" };
const subject = new UserStateSubject(state, {
const subject = new UserStateSubject(SomeKey, () => state, {
singleUserId$,
shouldUpdate,
dependencies$: of(dependencyValue),
@@ -165,7 +248,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const shouldUpdate = jest.fn(() => true);
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate });
const expected: TestType = { foo: "next" };
let actual: TestType = null;
@@ -183,7 +266,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const shouldUpdate = jest.fn(() => false);
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate });
subject.next({ foo: "next" });
await awaitAsync();
@@ -200,7 +283,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next);
const subject = new UserStateSubject(state, { singleUserId$, nextValue });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, nextValue });
const nextVal: TestType = { foo: "next" };
subject.next(nextVal);
@@ -215,7 +298,7 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next);
const dependencyValue = { bar: "dependency" };
const subject = new UserStateSubject(state, {
const subject = new UserStateSubject(SomeKey, () => state, {
singleUserId$,
nextValue,
dependencies$: of(dependencyValue),
@@ -236,7 +319,11 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next);
const when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
const subject = new UserStateSubject(SomeKey, () => state, {
singleUserId$,
nextValue,
when$,
});
const nextVal: TestType = { foo: "next" };
subject.next(nextVal);
@@ -253,7 +340,11 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser);
const nextValue = jest.fn((_, next) => next);
const when$ = new BehaviorSubject(false);
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
const subject = new UserStateSubject(SomeKey, () => state, {
singleUserId$,
nextValue,
when$,
});
const nextVal: TestType = { foo: "next" };
subject.next(nextVal);
@@ -265,42 +356,52 @@ describe("UserStateSubject", () => {
expect(nextValue).toHaveBeenCalled();
});
it("waits to evaluate nextValue until singleUserId$ emits", async () => {
// this test looks for `nextValue` because a subscription isn't necessary for
it("waits to evaluate `UserState.update` until singleUserId$ emits", async () => {
// this test looks for `nextMock` because a subscription isn't necessary for
// the subject to update.
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new Subject<UserId>();
const nextValue = jest.fn((_, next) => next);
const subject = new UserStateSubject(state, { singleUserId$, nextValue });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
// precondition: subject doesn't update after `next`
const nextVal: TestType = { foo: "next" };
subject.next(nextVal);
await awaitAsync();
expect(nextValue).not.toHaveBeenCalled();
expect(state.nextMock).not.toHaveBeenCalled();
singleUserId$.next(SomeUser);
await awaitAsync();
expect(nextValue).toHaveBeenCalled();
expect(state.nextMock).toHaveBeenCalledWith({ foo: "next" });
});
it("applies constraints$ on init", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
it("waits to evaluate `UserState.update` until singleUserEncryptor$ emits", async () => {
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
SomeUser,
{ id: null, secret: '{"foo":"init"}', disclosed: null },
);
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
const [result] = await tracker.pauseUntilReceived(1);
// precondition: subject doesn't update after `next`
const nextVal: TestType = { foo: "next" };
subject.next(nextVal);
await awaitAsync();
expect(state.nextMock).not.toHaveBeenCalled();
expect(result).toEqual({ foo: "in" });
singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor });
await awaitAsync();
const encrypted = { foo: "encrypt(next)" };
expect(state.nextMock).toHaveBeenCalledWith({ id: null, secret: encrypted, disclosed: null });
});
it("applies dynamic constraints", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission();
@@ -311,24 +412,11 @@ describe("UserStateSubject", () => {
expect(actual).toEqual({ foo: "" });
});
it("applies constraints$ on constraints$ emission", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(1));
const [, result] = await tracker.pauseUntilReceived(2);
expect(result).toEqual({ foo: "i" });
});
it("applies constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
subject.next({ foo: "next" });
@@ -341,7 +429,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(3));
@@ -355,13 +443,17 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const results: any[] = [];
subject.subscribe((r) => {
results.push(r);
});
subject.next({ foo: "next" });
constraints$.next(fooMaxLength(3));
await awaitAsync();
// `init` is also waiting and is processed before `next`
const [, nextResult] = await tracker.pauseUntilReceived(2);
const [, nextResult] = results;
expect(nextResult).toEqual({ foo: "nex" });
});
@@ -370,7 +462,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(3));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.error({ some: "error" });
@@ -384,7 +476,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(3));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.complete();
@@ -399,7 +491,7 @@ describe("UserStateSubject", () => {
it("emits errors", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const expected: TestType = { foo: "error" };
let actual: TestType = null;
@@ -418,7 +510,7 @@ describe("UserStateSubject", () => {
const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let actual: TestType = null;
subject.subscribe({
@@ -437,7 +529,7 @@ describe("UserStateSubject", () => {
const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let shouldNotRun = false;
subject.subscribe({
@@ -457,7 +549,7 @@ describe("UserStateSubject", () => {
it("emits completes", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let actual = false;
subject.subscribe({
@@ -475,7 +567,7 @@ describe("UserStateSubject", () => {
const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let shouldNotRun = false;
subject.subscribe({
@@ -496,7 +588,7 @@ describe("UserStateSubject", () => {
const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let timesRun = 0;
subject.subscribe({
@@ -513,11 +605,36 @@ describe("UserStateSubject", () => {
});
describe("subscribe", () => {
it("applies constraints$ on init", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
const [result] = await tracker.pauseUntilReceived(1);
expect(result).toEqual({ foo: "in" });
});
it("applies constraints$ on constraints$ emission", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(1));
const [, result] = await tracker.pauseUntilReceived(2);
expect(result).toEqual({ foo: "i" });
});
it("completes when singleUserId$ completes", async () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
let actual = false;
subject.subscribe({
@@ -531,12 +648,32 @@ describe("UserStateSubject", () => {
expect(actual).toBeTruthy();
});
it("completes when singleUserId$ completes", async () => {
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
SomeUser,
{ id: null, secret: '{"foo":"init"}', disclosed: null },
);
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
let actual = false;
subject.subscribe({
complete: () => {
actual = true;
},
});
singleUserEncryptor$.complete();
await awaitAsync();
expect(actual).toBeTruthy();
});
it("completes when when$ completes", async () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(state, { singleUserId$, when$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ });
let actual = false;
subject.subscribe({
@@ -557,7 +694,7 @@ describe("UserStateSubject", () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const errorUserId = "error" as UserId;
let error = false;
@@ -572,11 +709,32 @@ describe("UserStateSubject", () => {
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId });
});
it("errors when singleUserEncryptor$ changes", async () => {
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
SomeUser,
{ id: null, secret: '{"foo":"init"}', disclosed: null },
);
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
const errorUserId = "error" as UserId;
let error = false;
subject.subscribe({
error: (e: unknown) => {
error = e as any;
},
});
singleUserEncryptor$.next({ userId: errorUserId, encryptor: SomeEncryptor });
await awaitAsync();
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId });
});
it("errors when singleUserId$ errors", async () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const expected = { error: "description" };
let actual = false;
@@ -591,12 +749,31 @@ describe("UserStateSubject", () => {
expect(actual).toEqual(expected);
});
it("errors when singleUserEncryptor$ errors", async () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
const subject = new UserStateSubject(SomeKey, () => state, { singleUserEncryptor$ });
const expected = { error: "description" };
let actual = false;
subject.subscribe({
error: (e: unknown) => {
actual = e as any;
},
});
singleUserEncryptor$.error(expected);
await awaitAsync();
expect(actual).toEqual(expected);
});
it("errors when when$ errors", async () => {
const initialValue: TestType = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
const singleUserId$ = new BehaviorSubject(SomeUser);
const when$ = new BehaviorSubject(true);
const subject = new UserStateSubject(state, { singleUserId$, when$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ });
const expected = { error: "description" };
let actual = false;
@@ -616,7 +793,7 @@ describe("UserStateSubject", () => {
it("returns the userId to which the subject is bound", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new Subject<UserId>();
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
expect(subject.userId).toEqual(SomeUser);
});
@@ -626,7 +803,7 @@ describe("UserStateSubject", () => {
it("emits the next value with an empty constraint", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission();
@@ -642,7 +819,7 @@ describe("UserStateSubject", () => {
const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
const tracker = new ObservableTracker(subject.withConstraints$);
subject.complete();
@@ -657,7 +834,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(1);
const emission = tracker.expectEmission();
@@ -673,7 +850,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission();
@@ -690,7 +867,7 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(2);
const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const emission = tracker.expectEmission();
@@ -705,7 +882,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(3);
constraints$.next(expected);
@@ -722,7 +899,7 @@ describe("UserStateSubject", () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(3);
@@ -740,7 +917,7 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(3);
const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
constraints$.error({ some: "error" });
@@ -756,7 +933,7 @@ describe("UserStateSubject", () => {
const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(3);
const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
constraints$.complete();

View File

@@ -5,15 +5,10 @@ import {
ReplaySubject,
filter,
map,
Subject,
takeUntil,
pairwise,
combineLatest,
distinctUntilChanged,
BehaviorSubject,
race,
ignoreElements,
endWith,
startWith,
Observable,
Subscription,
@@ -22,16 +17,32 @@ import {
combineLatestWith,
catchError,
EMPTY,
concatMap,
OperatorFunction,
pipe,
first,
withLatestFrom,
scan,
skip,
} from "rxjs";
import { SingleUserState } from "@bitwarden/common/platform/state";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SingleUserState, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { WithConstraints } from "../types";
import { UserBound } from "../dependencies";
import { anyComplete, ready, withLatestReady } from "../rx";
import { Constraints, SubjectConstraints, WithConstraints } from "../types";
import { IdentityConstraint } from "./identity-state-constraint";
import { ClassifiedFormat, isClassifiedFormat } from "./classified-format";
import { unconstrained$ } from "./identity-state-constraint";
import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key";
import { isDynamic } from "./state-constraints-dependency";
import { UserEncryptor } from "./user-encryptor.abstraction";
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
type Constrained<State> = { constraints: Readonly<Constraints<State>>; state: State };
/**
* Adapt a state provider to an rxjs subject.
*
@@ -44,14 +55,20 @@ import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"
* @template State the state stored by the subject
* @template Dependencies use-specific dependencies provided by the user.
*/
export class UserStateSubject<State extends object, Dependencies = null>
export class UserStateSubject<
State extends object,
Secret = State,
Disclosed = never,
Dependencies = null,
>
extends Observable<State>
implements SubjectLike<State>
{
/**
* Instantiates the user state subject
* @param state the backing store of the subject
* @param dependencies tailor the subject's behavior for a particular
* Instantiates the user state subject bound to a persistent backing store
* @param key identifies the persistent backing store
* @param getState creates a persistent backing store using a key
* @param context tailor the subject's behavior for a particular
* purpose.
* @param dependencies.when$ blocks updates to the state subject until
* this becomes true. When this occurs, only the last-received update
@@ -61,93 +78,306 @@ export class UserStateSubject<State extends object, Dependencies = null>
* is available.
*/
constructor(
private state: SingleUserState<State>,
private dependencies: UserStateSubjectDependencies<State, Dependencies>,
private key: UserKeyDefinition<State> | ObjectKey<State, Secret, Disclosed>,
getState: (key: UserKeyDefinition<unknown>) => SingleUserState<unknown>,
private context: UserStateSubjectDependencies<State, Dependencies>,
) {
super();
if (isObjectKey(this.key)) {
// classification and encryption only supported with `ObjectKey`
this.objectKey = this.key;
this.stateKey = toUserKeyDefinition(this.key);
this.state = getState(this.stateKey);
} else {
// raw state access granted with `UserKeyDefinition`
this.objectKey = null;
this.stateKey = this.key as UserKeyDefinition<State>;
this.state = getState(this.stateKey);
}
// normalize dependencies
const when$ = (this.dependencies.when$ ?? new BehaviorSubject(true)).pipe(
distinctUntilChanged(),
);
const userIdAvailable$ = this.dependencies.singleUserId$.pipe(
startWith(state.userId),
pairwise(),
map(([expectedUserId, actualUserId]) => {
if (expectedUserId === actualUserId) {
return true;
} else {
throw { expectedUserId, actualUserId };
}
}),
distinctUntilChanged(),
);
const constraints$ = (
this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint<State>())
).pipe(
// FIXME: this should probably log that an error occurred
catchError(() => EMPTY),
);
const when$ = (this.context.when$ ?? new BehaviorSubject(true)).pipe(distinctUntilChanged());
// normalize input in case this `UserStateSubject` is not the only
// observer of the backing store
const input$ = combineLatest([this.input, constraints$]).pipe(
map(([input, constraints]) => {
const calibration = isDynamic(constraints) ? constraints.calibrate(input) : constraints;
const state = calibration.adjust(input);
return state;
}),
);
// manage dependencies through replay subjects since `UserStateSubject`
// reads them in multiple places
const encryptor$ = new ReplaySubject<UserEncryptor>(1);
const { singleUserId$, singleUserEncryptor$ } = this.context;
this.encryptor(singleUserEncryptor$ ?? singleUserId$).subscribe(encryptor$);
// when the output subscription completes, its last-emitted value
// loops around to the input for finalization
const finalize$ = this.pipe(
last(),
combineLatestWith(constraints$),
map(([output, constraints]) => {
const calibration = isDynamic(constraints) ? constraints.calibrate(output) : constraints;
const state = calibration.fix(output);
return state;
}),
);
const updates$ = concat(input$, finalize$);
const constraints$ = new ReplaySubject<SubjectConstraints<State>>(1);
(this.context.constraints$ ?? unconstrained$<State>())
.pipe(
// FIXME: this should probably log that an error occurred
catchError(() => EMPTY),
)
.subscribe(constraints$);
// observe completion
const whenComplete$ = when$.pipe(ignoreElements(), endWith(true));
const inputComplete$ = this.input.pipe(ignoreElements(), endWith(true));
const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true));
const completion$ = race(whenComplete$, inputComplete$, userIdComplete$);
const dependencies$ = new ReplaySubject<Dependencies>(1);
if (this.context.dependencies$) {
this.context.dependencies$.subscribe(dependencies$);
} else {
dependencies$.next(null);
}
// wire output before input so that output normalizes the current state
// before any `next` value is processed
this.outputSubscription = this.state.state$
.pipe(
combineLatestWith(constraints$),
map(([rawState, constraints]) => {
const calibration = isDynamic(constraints)
? constraints.calibrate(rawState)
: constraints;
const state = calibration.adjust(rawState);
return {
constraints: calibration.constraints,
state,
};
}),
)
.pipe(this.declassify(encryptor$), this.adjust(combineLatestWith(constraints$)))
.subscribe(this.output);
this.inputSubscription = combineLatest([updates$, when$, userIdAvailable$])
const last$ = new ReplaySubject<State>(1);
this.output
.pipe(
filter(([_, when]) => when),
map(([state]) => state),
takeUntil(completion$),
last(),
map((o) => o.state),
)
.subscribe(last$);
// the update stream simulates the stateProvider's "shouldUpdate"
// functionality & applies policy
const updates$ = concat(
this.input.pipe(
this.when(when$),
this.adjust(withLatestReady(constraints$)),
this.prepareUpdate(this, dependencies$),
),
// when the output subscription completes, its last-emitted value
// loops around to the input for finalization
last$.pipe(this.fix(constraints$), this.prepareUpdate(last$, dependencies$)),
);
// classification/encryption bound to the input subscription's lifetime
// to ensure that `fix` has access to the encryptor key
//
// FIXME: this should probably timeout when a lock occurs
this.inputSubscription = updates$
.pipe(this.classify(encryptor$), takeUntil(anyComplete([when$, this.input, encryptor$])))
.subscribe({
next: (r) => this.onNext(r),
next: (state) => this.onNext(state),
error: (e: unknown) => this.onError(e),
complete: () => this.onComplete(),
});
}
private stateKey: UserKeyDefinition<unknown>;
private objectKey: ObjectKey<State, Secret, Disclosed>;
private encryptor(
singleUserEncryptor$: Observable<UserBound<"encryptor", UserEncryptor> | UserId>,
): Observable<UserEncryptor> {
return singleUserEncryptor$.pipe(
// normalize inputs
map((maybe): UserBound<"encryptor", UserEncryptor> => {
if (typeof maybe === "object" && "encryptor" in maybe) {
return maybe;
} else if (typeof maybe === "string") {
return { encryptor: null, userId: maybe as UserId };
} else {
throw new Error(`Invalid encryptor input received for ${this.key.key}.`);
}
}),
// fail the stream if the state desyncs from the bound userId
startWith({ userId: this.state.userId, encryptor: null } as UserBound<
"encryptor",
UserEncryptor
>),
pairwise(),
map(([expected, actual]) => {
if (expected.userId === actual.userId) {
return actual;
} else {
throw {
expectedUserId: expected.userId,
actualUserId: actual.userId,
};
}
}),
// reduce emissions to when encryptor changes
distinctUntilChanged(),
map(({ encryptor }) => encryptor),
);
}
private when(when$: Observable<boolean>): OperatorFunction<State, State> {
return pipe(
combineLatestWith(when$.pipe(distinctUntilChanged())),
filter(([_, when]) => !!when),
map(([input]) => input),
);
}
private prepareUpdate(
init$: Observable<State>,
dependencies$: Observable<Dependencies>,
): OperatorFunction<Constrained<State>, State> {
return (input$) =>
concat(
// `init$` becomes the accumulator for `scan`
init$.pipe(
first(),
map((init) => [init, null] as const),
),
input$.pipe(
map((constrained) => constrained.state),
withLatestFrom(dependencies$),
),
).pipe(
// scan only emits values that can cause updates
scan(([prev], [pending, dependencies]) => {
const shouldUpdate = this.context.shouldUpdate?.(prev, pending, dependencies) ?? true;
if (shouldUpdate) {
// actual update
const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending;
return [next, dependencies];
} else {
// false update
return [prev, null];
}
}),
// the first emission primes `scan`s aggregator
skip(1),
map(([state]) => state),
// clean up false updates
distinctUntilChanged(),
);
}
private adjust(
withConstraints: OperatorFunction<State, [State, SubjectConstraints<State>]>,
): OperatorFunction<State, Constrained<State>> {
return pipe(
// how constraints are blended with incoming emissions varies:
// * `output` needs to emit when constraints update
// * `input` needs to wait until a message flows through the pipe
withConstraints,
map(([loadedState, constraints]) => {
// bypass nulls
if (!loadedState) {
return {
constraints: {} as Constraints<State>,
state: null,
} satisfies Constrained<State>;
}
const calibration = isDynamic(constraints)
? constraints.calibrate(loadedState)
: constraints;
const adjusted = calibration.adjust(loadedState);
return {
constraints: calibration.constraints,
state: adjusted,
};
}),
);
}
private fix(
constraints$: Observable<SubjectConstraints<State>>,
): OperatorFunction<State, Constrained<State>> {
return pipe(
combineLatestWith(constraints$),
map(([loadedState, constraints]) => {
const calibration = isDynamic(constraints)
? constraints.calibrate(loadedState)
: constraints;
const fixed = calibration.fix(loadedState);
return {
constraints: calibration.constraints,
state: fixed,
};
}),
);
}
private declassify(encryptor$: Observable<UserEncryptor>): OperatorFunction<unknown, State> {
// short-circuit if they key lacks encryption support
if (!this.objectKey || this.objectKey.format === "plain") {
return (input$) => input$ as Observable<State>;
}
// if the key supports encryption, enable encryptor support
if (this.objectKey && this.objectKey.format === "classified") {
return pipe(
combineLatestWith(encryptor$),
concatMap(async ([input, encryptor]) => {
// pass through null values
if (input === null || input === undefined) {
return null;
}
// fail fast if the format is incorrect
if (!isClassifiedFormat(input)) {
throw new Error(`Cannot declassify ${this.key.key}; unknown format.`);
}
// decrypt classified data
const { secret, disclosed } = input;
const encrypted = EncString.fromJSON(secret);
const decryptedSecret = await encryptor.decrypt<Secret>(encrypted);
// assemble into proper state
const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret);
const state = this.objectKey.options.deserializer(declassified);
return state;
}),
);
}
throw new Error(`unknown serialization format: ${this.objectKey.format}`);
}
private classify(encryptor$: Observable<UserEncryptor>): OperatorFunction<State, unknown> {
// short-circuit if they key lacks encryption support; `encryptor` is
// readied to preserve `dependencies.singleUserId$` emission contract
if (!this.objectKey || this.objectKey.format === "plain") {
return pipe(
ready(encryptor$),
map((input) => input as unknown),
);
}
// if the key supports encryption, enable encryptor support
if (this.objectKey && this.objectKey.format === "classified") {
return pipe(
withLatestReady(encryptor$),
concatMap(async ([input, encryptor]) => {
// fail fast if there's no value
if (input === null || input === undefined) {
return null;
}
// split data by classification level
const serialized = JSON.parse(JSON.stringify(input));
const classified = this.objectKey.classifier.classify(serialized);
// protect data
const encrypted = await encryptor.encrypt(classified.secret);
const secret = JSON.parse(JSON.stringify(encrypted));
// wrap result in classified format envelope for storage
const envelope = {
id: null as void,
secret,
disclosed: classified.disclosed,
} satisfies ClassifiedFormat<void, Disclosed>;
// deliberate type erasure; the type is restored during `declassify`
return envelope as unknown;
}),
);
}
// FIXME: add "encrypted" format --> key contains encryption logic
// CONSIDER: should "classified format" algorithm be embedded in subject keys...?
throw new Error(`unknown serialization format: ${this.objectKey.format}`);
}
/** The userId to which the subject is bound.
*/
get userId() {
@@ -177,7 +407,8 @@ export class UserStateSubject<State extends object, Dependencies = null>
// using subjects to ensure the right semantics are followed;
// if greater efficiency becomes desirable, consider implementing
// `SubjectLike` directly
private input = new Subject<State>();
private input = new ReplaySubject<State>(1);
private state: SingleUserState<unknown>;
private readonly output = new ReplaySubject<WithConstraints<State>>(1);
/** A stream containing settings and their last-applied constraints. */
@@ -188,25 +419,8 @@ export class UserStateSubject<State extends object, Dependencies = null>
private inputSubscription: Unsubscribable;
private outputSubscription: Unsubscribable;
private onNext(value: State) {
const nextValue = this.dependencies.nextValue ?? ((_: State, next: State) => next);
const shouldUpdate = this.dependencies.shouldUpdate ?? ((_: State) => true);
this.state
.update(
(state, dependencies) => {
const next = nextValue(state, value, dependencies);
return next;
},
{
shouldUpdate(current, dependencies) {
const update = shouldUpdate(current, value, dependencies);
return update;
},
combineLatestWith: this.dependencies.dependencies$,
},
)
.catch((e: any) => this.onError(e));
private onNext(value: unknown) {
this.state.update(() => value).catch((e: any) => this.onError(e));
}
private onError(value: any) {
@@ -232,8 +446,8 @@ export class UserStateSubject<State extends object, Dependencies = null>
private dispose() {
if (!this.isDisposed) {
// clean up internal subscriptions
this.inputSubscription.unsubscribe();
this.outputSubscription.unsubscribe();
this.inputSubscription?.unsubscribe();
this.outputSubscription?.unsubscribe();
this.inputSubscription = null;
this.outputSubscription = null;