1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[PM-16793] port credential generator service to providers (#14071)

* introduce extension service
* deprecate legacy forwarder types
* eliminate repeat algorithm emissions
* extend logging to preference management
* align forwarder ids with vendor ids
* fix duplicate policy emissions; debugging required logger enhancements

-----

Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
This commit is contained in:
✨ Audrey ✨
2025-05-27 09:51:14 -04:00
committed by GitHub
parent f4f659c52a
commit 97a591e738
140 changed files with 3720 additions and 4085 deletions

View File

@@ -60,6 +60,7 @@ const SomeProvider = {
} as LegacyEncryptorProvider,
state: SomeStateProvider,
log: disabledSemanticLoggerProvider,
now: Date.now,
} as UserStateSubjectDependencyProvider;
const SomeExtension: ExtensionMetadata = {

View File

@@ -0,0 +1,24 @@
import { Jsonify } from "type-fest";
import { deepFreeze } from "../util";
import { SemanticLogger } from "./semantic-logger.abstraction";
/** All disabled loggers emitted by this module are `===` to this logger. */
export const DISABLED_LOGGER: SemanticLogger = deepFreeze({
debug<T>(_content: Jsonify<T>, _message?: string): void {},
info<T>(_content: Jsonify<T>, _message?: string): void {},
warn<T>(_content: Jsonify<T>, _message?: string): void {},
error<T>(_content: Jsonify<T>, _message?: string): void {},
panic<T>(content: Jsonify<T>, message?: string): never {
if (typeof content === "string" && !message) {
throw new Error(content);
} else {
throw new Error(message);
}
},
});

View File

@@ -1,22 +0,0 @@
import { Jsonify } from "type-fest";
import { SemanticLogger } from "./semantic-logger.abstraction";
/** Disables semantic logs. Still panics. */
export class DisabledSemanticLogger implements SemanticLogger {
debug<T>(_content: Jsonify<T>, _message?: string): void {}
info<T>(_content: Jsonify<T>, _message?: string): void {}
warn<T>(_content: Jsonify<T>, _message?: string): void {}
error<T>(_content: Jsonify<T>, _message?: string): void {}
panic<T>(content: Jsonify<T>, message?: string): never {
if (typeof content === "string" && !message) {
throw new Error(content);
} else {
throw new Error(message);
}
}
}

View File

@@ -3,11 +3,10 @@ import { Jsonify } from "type-fest";
import { LogService } from "../../platform/abstractions/log.service";
import { DefaultSemanticLogger } from "./default-semantic-logger";
import { DisabledSemanticLogger } from "./disabled-semantic-logger";
import { DISABLED_LOGGER } from "./disabled-logger";
import { SemanticLogger } from "./semantic-logger.abstraction";
/** A type for injection of a log provider */
export type LogProvider = <Context>(context: Jsonify<Context>) => SemanticLogger;
import { LogProvider } from "./types";
import { warnLoggingEnabled } from "./util";
/** Instantiates a semantic logger that emits nothing when a message
* is logged.
@@ -18,38 +17,72 @@ export type LogProvider = <Context>(context: Jsonify<Context>) => SemanticLogger
export function disabledSemanticLoggerProvider<Context extends object>(
_context: Jsonify<Context>,
): SemanticLogger {
return new DisabledSemanticLogger();
return DISABLED_LOGGER;
}
/** Instantiates a semantic logger that emits logs to the console.
* @param context a static payload that is cloned when the logger
* logs a message. The `messages`, `level`, and `content` fields
* are reserved for use by loggers.
* @param settings specializes how the semantic logger functions.
* If this is omitted, the logger suppresses debug messages.
* @param logService writes semantic logs to the console
*/
export function consoleSemanticLoggerProvider<Context extends object>(
logger: LogService,
context: Jsonify<Context>,
): SemanticLogger {
return new DefaultSemanticLogger(logger, context);
export function consoleSemanticLoggerProvider(logService: LogService): LogProvider {
function provider<Context extends object>(context: Jsonify<Context>) {
const logger = new DefaultSemanticLogger(logService, context);
warnLoggingEnabled(logService, "consoleSemanticLoggerProvider", context);
return logger;
}
return provider;
}
/** Instantiates a semantic logger that emits logs to the console.
/** Instantiates a semantic logger that emits logs to the console when the
* context's `type` matches its values.
* @param logService writes semantic logs to the console
* @param types the values to match against
*/
export function enableLogForTypes(logService: LogService, types: string[]): LogProvider {
if (types.length) {
warnLoggingEnabled(logService, "enableLogForTypes", { types });
}
function provider<Context extends object>(context: Jsonify<Context>) {
const { type } = context as { type?: unknown };
if (typeof type === "string" && types.includes(type)) {
const logger = new DefaultSemanticLogger(logService, context);
warnLoggingEnabled(logService, "enableLogForTypes", {
targetType: type,
available: types,
loggerContext: context,
});
return logger;
} else {
return DISABLED_LOGGER;
}
}
return provider;
}
/** Instantiates a semantic logger that emits logs to the console when its enabled.
* @param enable logs are emitted when this is true
* @param logService writes semantic logs to the console
* @param context a static payload that is cloned when the logger
* logs a message. The `messages`, `level`, and `content` fields
* are reserved for use by loggers.
* @param settings specializes how the semantic logger functions.
* If this is omitted, the logger suppresses debug messages.
* logs a message.
*
* @remarks The `message`, `level`, `provider`, and `content` fields
* are reserved for use by the semantic logging system.
*/
export function ifEnabledSemanticLoggerProvider<Context extends object>(
enable: boolean,
logger: LogService,
logService: LogService,
context: Jsonify<Context>,
) {
if (enable) {
return consoleSemanticLoggerProvider(logger, context);
const logger = new DefaultSemanticLogger(logService, context);
warnLoggingEnabled(logService, "ifEnabledSemanticLoggerProvider", context);
return logger;
} else {
return disabledSemanticLoggerProvider(context);
return DISABLED_LOGGER;
}
}

View File

@@ -1,2 +1,4 @@
export * from "./factory";
export * from "./disabled-logger";
export { LogProvider } from "./types";
export { SemanticLogger } from "./semantic-logger.abstraction";

View File

@@ -0,0 +1,11 @@
import { Jsonify } from "type-fest";
import { SemanticLogger } from "./semantic-logger.abstraction";
/** Creates a semantic logger.
* @param context all logs emitted by the logger are extended with
* these fields.
* @remarks The `message`, `level`, `provider`, and `content` fields
* are reserved for use by the semantic logging system.
*/
export type LogProvider = <Context extends object>(context: Jsonify<Context>) => SemanticLogger;

View File

@@ -0,0 +1,12 @@
import { LogService } from "../../platform/abstractions/log.service";
// show our GRIT - these functions implement generalized logging
// controls and should return DISABLED_LOGGER in production.
export function warnLoggingEnabled(logService: LogService, method: string, context?: any) {
logService.warning({
method,
context,
provider: "tools/log",
message: "Semantic logging enabled. 🦟 Please report this bug if you see it 🦟",
});
}

View File

@@ -17,7 +17,7 @@ export class PrivateClassifier<Data> implements Classifier<Data, Record<string,
}
const secret = picked as Jsonify<Data>;
return { disclosed: {}, secret };
return { disclosed: null, secret };
}
declassify(_disclosed: Jsonify<Record<keyof Data, never>>, secret: Jsonify<Data>) {

View File

@@ -16,7 +16,7 @@ export class PublicClassifier<Data> implements Classifier<Data, Data, Record<str
}
const disclosed = picked as Jsonify<Data>;
return { disclosed, secret: "" };
return { disclosed, secret: null };
}
declassify(disclosed: Jsonify<Data>, _secret: Jsonify<Record<keyof Data, never>>) {

View File

@@ -0,0 +1,13 @@
import { Observable } from "rxjs";
/**
* Used to infer types from arguments to functions like {@link withLatestReady}.
* So that you can have `forkJoin([Observable<A>, PromiseLike<B>]): Observable<[A, B]>`
* et al.
* @remarks this type definition is derived from rxjs' {@link ObservableInputTuple}.
* The difference is it *only* works with observables, while the rx version works
* with any thing that can become an observable.
*/
export type ObservableTuple<T> = {
[K in keyof T]: Observable<T[K]>;
};

File diff suppressed because it is too large Load Diff

View File

@@ -20,8 +20,13 @@ import {
startWith,
pairwise,
MonoTypeOperatorFunction,
Cons,
scan,
filter,
} from "rxjs";
import { ObservableTuple } from "./rx.rxjs";
/** Returns its input. */
function identity(value: any): any {
return value;
@@ -164,26 +169,30 @@ export function ready<T>(watch$: Observable<any> | Observable<any>[]) {
);
}
export function withLatestReady<Source, Watch>(
watch$: Observable<Watch>,
): OperatorFunction<Source, [Source, Watch]> {
export function withLatestReady<Source, Watch extends readonly unknown[]>(
...watches$: [...ObservableTuple<Watch>]
): OperatorFunction<Source, Cons<Source, Watch>> {
return connect((source$) => {
// these subscriptions are safe because `source$` connects only after there
// is an external subscriber.
const source = new ReplaySubject<Source>(1);
source$.subscribe(source);
const watch = new ReplaySubject<Watch>(1);
watch$.subscribe(watch);
const watches = watches$.map((w) => {
const watch$ = new ReplaySubject<unknown>(1);
w.subscribe(watch$);
return watch$;
}) as [...ObservableTuple<Watch>];
// `concat` is subscribed immediately after it's returned, at which point
// `zip` blocks until all items in `watching$` are ready. If that occurs
// `zip` blocks until all items in `watches` are ready. If that occurs
// after `source$` is hot, then the replay subject sends the last-captured
// emission through immediately. Otherwise, `ready` waits for the next
// emission
return concat(zip(watch).pipe(first(), ignoreElements()), source).pipe(
withLatestFrom(watch),
// emission through immediately. Otherwise, `withLatestFrom` waits for the
// next emission
return concat(zip(watches).pipe(first(), ignoreElements()), source).pipe(
withLatestFrom(...watches),
takeUntil(anyComplete(source)),
);
) as Observable<Cons<Source, Watch>>;
});
}
@@ -238,3 +247,54 @@ export function pin<T>(options?: {
}),
);
}
/** maps a value to a result and keeps a cache of the mapping
* @param mapResult - maps the stream to a result; this function must return
* a value. It must not return null or undefined.
* @param options.size - the number of entries in the cache
* @param options.key - maps the source to a cache key
* @remarks this method is useful for optimization of expensive
* `mapResult` calls. It's also useful when an interned reference type
* is needed.
*/
export function memoizedMap<Source, Result extends NonNullable<any>>(
mapResult: (source: Source) => Result,
options?: { size?: number; key?: (source: Source) => unknown },
): OperatorFunction<Source, Result> {
return pipe(
// scan's accumulator contains the cache
scan(
([cache], source) => {
const key: unknown = options?.key?.(source) ?? source;
// cache hit?
let result = cache?.get(key);
if (result) {
return [cache, result] as const;
}
// cache miss
result = mapResult(source);
cache?.set(key, result);
// trim cache
const overage = cache.size - (options?.size ?? 1);
if (overage > 0) {
Array.from(cache?.keys() ?? [])
.slice(0, overage)
.forEach((k) => cache?.delete(k));
}
return [cache, result] as const;
},
// FIXME: upgrade to a least-recently-used cache
[new Map(), null] as [Map<unknown, Result>, Source | null],
),
// encapsulate cache
map(([, result]) => result),
// preserve `NonNullable` constraint on `Result`
filter((result): result is Result => !!result),
);
}

View File

@@ -15,4 +15,9 @@ export abstract class UserStateSubjectDependencyProvider {
// FIXME: remove `log` and inject the system provider into the USS instead
/** Provides semantic logging */
abstract log: <Context extends object>(_context: Jsonify<Context>) => SemanticLogger;
/** Get the system time as a number of seconds since the unix epoch
* @remarks this can be turned into a date using `new Date(provider.now())`
*/
abstract now: () => number;
}

View File

@@ -90,6 +90,7 @@ const SomeProvider = {
} as LegacyEncryptorProvider,
state: SomeStateProvider,
log: disabledSemanticLoggerProvider,
now: () => 100,
};
function fooMaxLength(maxLength: number): StateConstraints<TestType> {

View File

@@ -477,7 +477,12 @@ export class UserStateSubject<
* @returns the subscription
*/
subscribe(observer?: Partial<Observer<State>> | ((value: State) => void) | null): Subscription {
return this.output.pipe(map((wc) => wc.state)).subscribe(observer);
return this.output
.pipe(
map((wc) => wc.state),
distinctUntilChanged(),
)
.subscribe(observer);
}
// using subjects to ensure the right semantics are followed;

View File

@@ -2,6 +2,11 @@ import { Simplify } from "type-fest";
import { IntegrationId } from "./integration";
/** When this is a string, it contains the i18n key. When it is an object, the `literal` member
* contains text that should not be translated.
*/
export type I18nKeyOrLiteral = string | { literal: string };
/** Constraints that are shared by all primitive field types */
type PrimitiveConstraint = {
/** `true` indicates the field is required; otherwise the field is optional */

View File

@@ -1,3 +1,5 @@
import { I18nKeyOrLiteral } from "./types";
/** Recursively freeze an object's own keys
* @param value the value to freeze
* @returns `value`
@@ -10,10 +12,22 @@ export function deepFreeze<T extends object>(value: T): Readonly<T> {
for (const key of keys) {
const own = value[key];
if ((own && typeof own === "object") || typeof own === "function") {
if (own && typeof own === "object") {
deepFreeze(own);
}
}
return Object.freeze(value);
}
/** Type guard that returns `true` when the value is an i18n key. */
export function isI18nKey(value: I18nKeyOrLiteral): value is string {
return typeof value === "string";
}
/** Type guard that returns `true` when the value requires no translation.
* @remarks the literal value can be accessed using the `.literal` property.
*/
export function isLiteral(value: I18nKeyOrLiteral): value is { literal: string } {
return typeof value === "object" && "literal" in value;
}