1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 11:01:17 +00:00

Merge remote-tracking branch 'origin' into auth/pm-23620/auth-request-answering-service

This commit is contained in:
Patrick Pimentel
2025-08-26 17:09:00 -04:00
637 changed files with 13902 additions and 5327 deletions

View File

@@ -3,8 +3,9 @@
import {
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionRequest,
CollectionResponse,
CreateCollectionRequest,
UpdateCollectionRequest,
} from "@bitwarden/admin-console/common";
import { OrganizationConnectionType } from "../admin-console/enums";
@@ -270,12 +271,12 @@ export abstract class ApiService {
): Promise<ListResponse<CollectionAccessDetailsResponse>>;
abstract postCollection(
organizationId: string,
request: CollectionRequest,
request: CreateCollectionRequest,
): Promise<CollectionDetailsResponse>;
abstract putCollection(
organizationId: string,
id: string,
request: CollectionRequest,
request: UpdateCollectionRequest,
): Promise<CollectionDetailsResponse>;
abstract deleteCollection(organizationId: string, id: string): Promise<any>;
abstract deleteManyCollections(organizationId: string, collectionIds: string[]): Promise<any>;

View File

@@ -28,7 +28,6 @@ export enum FeatureFlag {
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
UseOrganizationWarningsService = "use-organization-warnings-service",
AllowTrialLengthZero = "pm-20322-allow-trial-length-0",
PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout",
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
@@ -51,10 +50,10 @@ export enum FeatureFlag {
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
CipherKeyEncryption = "cipher-key-encryption",
RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy",
PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -93,7 +92,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
[FeatureFlag.RemoveCardItemTypePolicy]: FALSE,
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
[FeatureFlag.PM19315EndUserActivationMvp]: FALSE,
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
/* Auth */
@@ -104,7 +102,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
[FeatureFlag.UseOrganizationWarningsService]: FALSE,
[FeatureFlag.AllowTrialLengthZero]: FALSE,
[FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE,
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
@@ -116,6 +113,7 @@ export const DefaultFeatureFlagValue = {
/* Platform */
[FeatureFlag.IpcChannelFramework]: FALSE,
[FeatureFlag.PushNotificationsWhenLocked]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -0,0 +1,44 @@
import { CipherExport } from "@bitwarden/common/models/export/cipher.export";
import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.export";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
describe("Cipher Export", () => {
describe("toView", () => {
it.each([[null], [undefined]])(
"should preserve existing date values when request dates are nullish (=%p)",
(nullishDate) => {
const existingView = new CipherView();
existingView.creationDate = new Date("2023-01-01T00:00:00Z");
existingView.revisionDate = new Date("2023-01-02T00:00:00Z");
existingView.deletedDate = new Date("2023-01-03T00:00:00Z");
const request = CipherExport.template();
request.type = CipherType.SecureNote;
request.secureNote = SecureNoteExport.template();
request.creationDate = nullishDate as any;
request.revisionDate = nullishDate as any;
request.deletedDate = nullishDate as any;
const resultView = CipherExport.toView(request, existingView);
expect(resultView.creationDate).toEqual(existingView.creationDate);
expect(resultView.revisionDate).toEqual(existingView.revisionDate);
expect(resultView.deletedDate).toEqual(existingView.deletedDate);
},
);
it("should set date values when request dates are provided", () => {
const request = CipherExport.template();
request.type = CipherType.SecureNote;
request.secureNote = SecureNoteExport.template();
request.creationDate = new Date("2023-01-01T00:00:00Z");
request.revisionDate = new Date("2023-01-02T00:00:00Z");
request.deletedDate = new Date("2023-01-03T00:00:00Z");
const resultView = CipherExport.toView(request);
expect(resultView.creationDate).toEqual(request.creationDate);
expect(resultView.revisionDate).toEqual(request.revisionDate);
expect(resultView.deletedDate).toEqual(request.deletedDate);
});
});
});

View File

@@ -81,9 +81,9 @@ export class CipherExport {
view.passwordHistory = req.passwordHistory.map((ph) => PasswordHistoryExport.toView(ph));
}
view.creationDate = req.creationDate ? new Date(req.creationDate) : null;
view.revisionDate = req.revisionDate ? new Date(req.revisionDate) : null;
view.deletedDate = req.deletedDate ? new Date(req.deletedDate) : null;
view.creationDate = req.creationDate ? new Date(req.creationDate) : view.creationDate;
view.revisionDate = req.revisionDate ? new Date(req.revisionDate) : view.revisionDate;
view.deletedDate = req.deletedDate ? new Date(req.deletedDate) : view.deletedDate;
return view;
}

View File

@@ -1,2 +0,0 @@
/** Temporary re-export. This should not be used for new imports */
export { KeyGenerationService } from "../../key-management/crypto/key-generation/key-generation.service";

View File

@@ -2,25 +2,136 @@ import { Observable, shareReplay } from "rxjs";
import { IpcClient, IncomingMessage, OutgoingMessage } from "@bitwarden/sdk-internal";
/**
* Entry point for inter-process communication (IPC).
*
* - {@link IpcService.init} should be called in the initialization phase of the client.
* - This service owns the underlying {@link IpcClient} lifecycle and starts it during initialization.
*
* ## Usage
*
* ### Publish / Subscribe
* There are 2 main ways of sending and receiving messages over IPC in TypeScript:
*
* #### 1. TypeScript only JSON-based messages
* This is the simplest form of IPC, where messages are sent as untyped JSON objects.
* This is useful for simple message passing without the need for Rust code.
*
* ```typescript
* // Send a message
* await ipcService.send(OutgoingMessage.new_json_payload({ my: "data" }, "BrowserBackground", "my-topic"));
*
* // Receive messages
* ipcService.messages$.subscribe((message: IncomingMessage) => {
* if (message.topic === "my-topic") {
* const data = incomingMessage.parse_payload_as_json();
* console.log("Received message:", data);
* }
* });
* ```
*
* #### 2. Rust compatible messages
* If you need to send messages that can also be handled by Rust code you can use typed Rust structs
* together with Rust functions to send and receive messages. For more information on typed structs
* refer to `TypedOutgoingMessage` and `TypedIncomingMessage` in the SDK.
*
* For examples on how to use the RPC framework with Rust see the section below.
*
* ### RPC (Request / Response)
* The RPC functionality is more complex than simple message passing and requires Rust code
* to send and receive calls. For this reason, the service also exposes the underlying
* {@link IpcClient} so it can be passed directly into Rust code.
*
* #### Rust code
* ```rust
* #[wasm_bindgen(js_name = ipcRegisterPingHandler)]
* pub async fn ipc_register_ping_handler(ipc_client: &JsIpcClient) {
* ipc_client
* .client
* // See Rust docs for more information on how to implement a handler
* .register_rpc_handler(PingHandler::new())
* .await;
* }
*
* #[wasm_bindgen(js_name = ipcRequestPing)]
* pub async fn ipc_request_ping(
* ipc_client: &JsIpcClient,
* destination: Endpoint,
* abort_signal: Option<AbortSignal>,
* ) -> Result<PingResponse, RequestError> {
* ipc_client
* .client
* .request(
* PingRequest,
* destination,
* abort_signal.map(|c| c.to_cancellation_token()),
* )
* .await
* }
* ```
*
* #### TypeScript code
* ```typescript
* import { IpcService } from "@bitwarden/common/platform/ipc";
* import { IpcClient, ipcRegisterPingHandler, ipcRequestPing } from "@bitwarden/sdk-internal";
*
* class MyService {
* constructor(private ipcService: IpcService) {}
*
* async init() {
* await ipcRegisterPingHandler(this.ipcService.client);
* }
*
* async ping(destination: Endpoint): Promise<PingResponse> {
* return await ipcRequestPing(this.ipcService.client, destination);
* }
* }
*/
export abstract class IpcService {
private _client?: IpcClient;
/**
* Access to the underlying {@link IpcClient} for advanced/Rust RPC usage.
*
* @throws If the service has not been initialized.
*/
get client(): IpcClient {
if (!this._client) {
throw new Error("IpcService not initialized");
throw new Error("IpcService not initialized. Call init() first.");
}
return this._client;
}
private _messages$?: Observable<IncomingMessage>;
protected get messages$(): Observable<IncomingMessage> {
/**
* Hot stream of {@link IncomingMessage} from the IPC layer.
*
* @remarks
* - Uses `shareReplay({ bufferSize: 0, refCount: true })`, so no events are replayed to late subscribers.
* Subscribe early if you must not miss messages.
*
* @throws If the service has not been initialized.
*/
get messages$(): Observable<IncomingMessage> {
if (!this._messages$) {
throw new Error("IpcService not initialized");
throw new Error("IpcService not initialized. Call init() first.");
}
return this._messages$;
}
/**
* Initializes the service and starts the IPC client.
*/
abstract init(): Promise<void>;
/**
* Wires the provided {@link IpcClient}, starts it, and sets up the message stream.
*
* - Starts the client via `client.start()`.
* - Subscribes to the client's receive loop and exposes it through {@link messages$}.
* - Implementations may override `init` but should call this helper exactly once.
*/
protected async initWithClient(client: IpcClient): Promise<void> {
this._client = client;
await this._client.start();
@@ -47,6 +158,12 @@ export abstract class IpcService {
}).pipe(shareReplay({ bufferSize: 0, refCount: true }));
}
/**
* Sends an {@link OutgoingMessage} over IPC.
*
* @param message The message to send.
* @throws If the service is not initialized or the underlying client fails to send.
*/
async send(message: OutgoingMessage) {
await this.client.send(message);
}

View File

@@ -5,7 +5,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, Subject
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "@bitwarden/auth/common";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { awaitAsync } from "../../../../spec";
import { Matrix } from "../../../../spec/matrix";
@@ -16,6 +16,7 @@ import { NotificationType } from "../../../enums";
import { NotificationResponse } from "../../../models/response/notification.response";
import { UserId } from "../../../types/guid";
import { AppIdService } from "../../abstractions/app-id.service";
import { ConfigService } from "../../abstractions/config/config.service";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { LogService } from "../../abstractions/log.service";
import { MessageSender } from "../../messaging";
@@ -71,6 +72,14 @@ describe("NotificationsService", () => {
authRequestAnsweringService = mock<AuthRequestAnsweringServiceAbstraction>();
configService = mock<ConfigService>();
// For these tests, use the active-user implementation (feature flag disabled)
configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => {
const flagValueByFlag: Partial<Record<FeatureFlag, boolean>> = {
[FeatureFlag.PushNotificationsWhenLocked]: true,
};
return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any;
});
activeAccount = new BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>(null);
accountService.activeAccount$ = activeAccount.asObservable();
@@ -235,10 +244,9 @@ describe("NotificationsService", () => {
});
it.each([
// Temporarily rolling back server notifications being connected while locked
// { initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked },
// { initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked },
// { initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked },
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked },
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked },
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked },
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Unlocked },
])(
"does not re-connect when the user transitions from $initialStatus to $updatedStatus",
@@ -263,11 +271,7 @@ describe("NotificationsService", () => {
},
);
it.each([
// Temporarily disabling server notifications connecting while in a locked state
// AuthenticationStatus.Locked,
AuthenticationStatus.Unlocked,
])(
it.each([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked])(
"connects when a user transitions from logged out to %s",
async (newStatus: AuthenticationStatus) => {
emitActiveUser(mockUser1);

View File

@@ -17,7 +17,6 @@ import {
import { LogoutReason } from "@bitwarden/auth/common";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
@@ -32,6 +31,7 @@ import {
import { UserId } from "../../../types/guid";
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
import { AppIdService } from "../../abstractions/app-id.service";
import { ConfigService } from "../../abstractions/config/config.service";
import { EnvironmentService } from "../../abstractions/environment.service";
import { LogService } from "../../abstractions/log.service";
import { MessagingService } from "../../abstractions/messaging.service";
@@ -138,14 +138,25 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
);
}
// This method name is a lie currently as we also have an access token
// when locked, this is eventually where we want to be but it increases load
// on signalR so we are rolling back until we can move the load of browser to
// web push.
private hasAccessToken$(userId: UserId) {
return this.authService.authStatusFor$(userId).pipe(
map((authStatus) => authStatus === AuthenticationStatus.Unlocked),
distinctUntilChanged(),
return this.configService.getFeatureFlag$(FeatureFlag.PushNotificationsWhenLocked).pipe(
switchMap((featureFlagEnabled) => {
if (featureFlagEnabled) {
return this.authService.authStatusFor$(userId).pipe(
map(
(authStatus) =>
authStatus === AuthenticationStatus.Locked ||
authStatus === AuthenticationStatus.Unlocked,
),
distinctUntilChanged(),
);
} else {
return this.authService.authStatusFor$(userId).pipe(
map((authStatus) => authStatus === AuthenticationStatus.Unlocked),
distinctUntilChanged(),
);
}
}),
);
}

View File

@@ -1,2 +0,0 @@
/** Temporary re-export. This should not be used for new imports */
export { DefaultKeyGenerationService as KeyGenerationService } from "../../key-management/crypto/key-generation/default-key-generation.service";

View File

@@ -0,0 +1,64 @@
import { firstValueFrom, map } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherRecordMapper } from "@bitwarden/common/vault/models/domain/cipher-sdk-mapper";
import { StateClient, Repository } from "@bitwarden/sdk-internal";
import { StateProvider, UserKeyDefinition } from "../../state";
export async function initializeState(
userId: UserId,
stateClient: StateClient,
stateProvider: StateProvider,
): Promise<void> {
await stateClient.register_cipher_repository(
new RepositoryRecord(userId, stateProvider, new CipherRecordMapper()),
);
}
export interface SdkRecordMapper<ClientType, SdkType> {
userKeyDefinition(): UserKeyDefinition<Record<string, ClientType>>;
toSdk(value: ClientType): SdkType;
fromSdk(value: SdkType): ClientType;
}
class RepositoryRecord<ClientType, SdkType> implements Repository<SdkType> {
constructor(
private userId: UserId,
private stateProvider: StateProvider,
private mapper: SdkRecordMapper<ClientType, SdkType>,
) {}
async get(id: string): Promise<SdkType | null> {
const prov = this.stateProvider.getUser(this.userId, this.mapper.userKeyDefinition());
const data = await firstValueFrom(prov.state$.pipe(map((data) => data ?? {})));
const element = data[id];
if (!element) {
return null;
}
return this.mapper.toSdk(element);
}
async list(): Promise<SdkType[]> {
const prov = this.stateProvider.getUser(this.userId, this.mapper.userKeyDefinition());
const elements = await firstValueFrom(prov.state$.pipe(map((data) => data ?? {})));
return Object.values(elements).map((element) => this.mapper.toSdk(element));
}
async set(id: string, value: SdkType): Promise<void> {
const prov = this.stateProvider.getUser(this.userId, this.mapper.userKeyDefinition());
const elements = await firstValueFrom(prov.state$.pipe(map((data) => data ?? {})));
elements[id] = this.mapper.fromSdk(value);
await prov.update(() => elements);
}
async remove(id: string): Promise<void> {
const prov = this.stateProvider.getUser(this.userId, this.mapper.userKeyDefinition());
const elements = await firstValueFrom(prov.state$.pipe(map((data) => data ?? {})));
if (!elements[id]) {
return;
}
delete elements[id];
await prov.update(() => elements);
}
}

View File

@@ -6,8 +6,13 @@ import { BehaviorSubject, firstValueFrom, of } from "rxjs";
import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { ObservableTracker } from "../../../../spec";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import {
FakeAccountService,
FakeStateProvider,
mockAccountServiceWith,
ObservableTracker,
} from "../../../../spec";
import { AccountInfo } from "../../../auth/abstractions/account.service";
import { EncryptedString } from "../../../key-management/crypto/models/enc-string";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
@@ -17,6 +22,7 @@ import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "../../abstractions/sdk/sdk-load.service";
import { UserNotLoggedInError } from "../../abstractions/sdk/sdk.service";
import { Rc } from "../../misc/reference-counting/rc";
import { Utils } from "../../misc/utils";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { DefaultSdkService } from "./default-sdk.service";
@@ -33,10 +39,11 @@ describe("DefaultSdkService", () => {
let sdkClientFactory!: MockProxy<SdkClientFactory>;
let environmentService!: MockProxy<EnvironmentService>;
let platformUtilsService!: MockProxy<PlatformUtilsService>;
let accountService!: MockProxy<AccountService>;
let kdfConfigService!: MockProxy<KdfConfigService>;
let keyService!: MockProxy<KeyService>;
let service!: DefaultSdkService;
let accountService!: FakeAccountService;
let fakeStateProvider!: FakeStateProvider;
beforeEach(async () => {
await new TestSdkLoadService().loadAndInit();
@@ -44,9 +51,11 @@ describe("DefaultSdkService", () => {
sdkClientFactory = mock<SdkClientFactory>();
environmentService = mock<EnvironmentService>();
platformUtilsService = mock<PlatformUtilsService>();
accountService = mock<AccountService>();
kdfConfigService = mock<KdfConfigService>();
keyService = mock<KeyService>();
const mockUserId = Utils.newGuid() as UserId;
accountService = mockAccountServiceWith(mockUserId);
fakeStateProvider = new FakeStateProvider(accountService);
// Can't use `of(mock<Environment>())` for some reason
environmentService.environment$ = new BehaviorSubject(mock<Environment>());
@@ -58,6 +67,7 @@ describe("DefaultSdkService", () => {
accountService,
kdfConfigService,
keyService,
fakeStateProvider,
);
});
@@ -219,5 +229,9 @@ describe("DefaultSdkService", () => {
function createMockClient(): MockProxy<BitwardenClient> {
const client = mock<BitwardenClient>();
client.crypto.mockReturnValue(mock());
client.platform.mockReturnValue({
state: jest.fn().mockReturnValue(mock()),
free: mock(),
});
return client;
}

View File

@@ -38,6 +38,9 @@ import { SdkLoadService } from "../../abstractions/sdk/sdk-load.service";
import { SdkService, UserNotLoggedInError } from "../../abstractions/sdk/sdk.service";
import { compareValues } from "../../misc/compare-values";
import { Rc } from "../../misc/reference-counting/rc";
import { StateProvider } from "../../state";
import { initializeState } from "./client-managed-state";
// A symbol that represents an overriden client that is explicitly set to undefined,
// blocking the creation of an internal client for that user.
@@ -81,6 +84,7 @@ export class DefaultSdkService implements SdkService {
private accountService: AccountService,
private kdfConfigService: KdfConfigService,
private keyService: KeyService,
private stateProvider: StateProvider,
private userAgent: string | null = null,
) {}
@@ -241,6 +245,9 @@ export class DefaultSdkService implements SdkService {
.map(([k, v]) => [k, v.key as UnsignedSharedKey]),
),
});
// Initialize the SDK managed database and the client managed repositories.
await initializeState(userId, client.platform().state(), this.stateProvider);
}
private toSettings(env: Environment): ClientSettings {

View File

@@ -1,11 +0,0 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/user-core";
export abstract class ActiveUserAccessor {
/**
* Returns a stream of the current active user for the application. The stream either emits the user id for that account
* or returns null if there is no current active user.
*/
abstract activeUserId$: Observable<UserId | null>;
}

View File

@@ -1 +0,0 @@
export { DeriveDefinition } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { DerivedStateProvider } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { DerivedState } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { GlobalStateProvider } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { GlobalState } from "@bitwarden/state";

View File

@@ -1,36 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, distinctUntilChanged } from "rxjs";
import { UserId } from "../../../types/guid";
import { ActiveUserAccessor } from "../active-user.accessor";
import { UserKeyDefinition } from "../user-key-definition";
import { ActiveUserState } from "../user-state";
import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider";
import { DefaultActiveUserState } from "./default-active-user-state";
export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
activeUserId$: Observable<UserId | undefined>;
constructor(
private readonly activeAccountAccessor: ActiveUserAccessor,
private readonly singleUserStateProvider: SingleUserStateProvider,
) {
this.activeUserId$ = this.activeAccountAccessor.activeUserId$.pipe(
// To avoid going to storage when we don't need to, only get updates when there is a true change.
distinctUntilChanged((a, b) => (a == null || b == null ? a == b : a === b)), // Treat null and undefined as equal
);
}
get<T>(keyDefinition: UserKeyDefinition<T>): ActiveUserState<T> {
// All other providers cache the creation of their corresponding `State` objects, this instance
// doesn't need to do that since it calls `SingleUserStateProvider` it will go through their caching
// layer, because of that, the creation of this instance is quite simple and not worth caching.
return new DefaultActiveUserState(
keyDefinition,
this.activeUserId$,
this.singleUserStateProvider,
);
}
}

View File

@@ -1 +0,0 @@
export { DefaultActiveUserState } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { DefaultDerivedStateProvider } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { DefaultDerivedState } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { DefaultGlobalStateProvider } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { DefaultGlobalState } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { DefaultSingleUserStateProvider } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { DefaultSingleUserState } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { DefaultStateProvider } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { InlineDerivedState, InlineDerivedStateProvider } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { StateBase } from "@bitwarden/state";

View File

@@ -1 +1,6 @@
import { StateUpdateOptions as RequiredStateUpdateOptions } from "@bitwarden/state";
export * from "@bitwarden/state";
export { ActiveUserAccessor } from "@bitwarden/state-internal";
export type StateUpdateOptions<T, TCombine> = Partial<RequiredStateUpdateOptions<T, TCombine>>;

View File

@@ -1 +0,0 @@
export { KeyDefinition, KeyDefinitionOptions } from "@bitwarden/state";

View File

@@ -1,4 +1 @@
export { StateDefinition } from "@bitwarden/state";
// To be removed once references are updated to point to @bitwarden/storage-core
export { StorageLocation, ClientLocations } from "@bitwarden/storage-core";

View File

@@ -1,6 +0,0 @@
export {
StateEventRegistrarService,
StateEventInfo,
STATE_LOCK_EVENT,
STATE_LOGOUT_EVENT,
} from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { StateEventRunnerService } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { StateProvider } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { SerializedMemoryStorageService as MemoryStorageService } from "@bitwarden/storage-core";

View File

@@ -1 +0,0 @@
export { UserKeyDefinition, UserKeyDefinitionOptions } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { ActiveUserStateProvider, SingleUserStateProvider } from "@bitwarden/state";

View File

@@ -1 +0,0 @@
export { ActiveUserState, SingleUserState, CombinedState } from "@bitwarden/state";

View File

@@ -7,8 +7,9 @@ import { firstValueFrom } from "rxjs";
import {
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionRequest,
CollectionResponse,
CreateCollectionRequest,
UpdateCollectionRequest,
} from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
@@ -727,7 +728,7 @@ export class ApiService implements ApiServiceAbstraction {
async postCollection(
organizationId: string,
request: CollectionRequest,
request: CreateCollectionRequest,
): Promise<CollectionDetailsResponse> {
const r = await this.send(
"POST",
@@ -742,7 +743,7 @@ export class ApiService implements ApiServiceAbstraction {
async putCollection(
organizationId: string,
id: string,
request: CollectionRequest,
request: UpdateCollectionRequest,
): Promise<CollectionDetailsResponse> {
const r = await this.send(
"PUT",

View File

@@ -9,7 +9,12 @@ export abstract class SearchService {
abstract indexedEntityId$(userId: UserId): Observable<IndexedEntityId | null>;
abstract clearIndex(userId: UserId): Promise<void>;
abstract isSearchable(userId: UserId, query: string): Promise<boolean>;
/**
* Checks if the query is long enough to be searchable.
* For short Lunr.js queries (starts with '>'), a valid search index must exist for the user.
*/
abstract isSearchable(userId: UserId, query: string | null): Promise<boolean>;
abstract indexCiphers(
userId: UserId,
ciphersToIndex: CipherView[],

View File

@@ -0,0 +1,22 @@
import { SdkRecordMapper } from "@bitwarden/common/platform/services/sdk/client-managed-state";
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
import { Cipher as SdkCipher } from "@bitwarden/sdk-internal";
import { ENCRYPTED_CIPHERS } from "../../services/key-state/ciphers.state";
import { CipherData } from "../data/cipher.data";
import { Cipher } from "./cipher";
export class CipherRecordMapper implements SdkRecordMapper<CipherData, SdkCipher> {
userKeyDefinition(): UserKeyDefinition<Record<string, CipherData>> {
return ENCRYPTED_CIPHERS;
}
toSdk(value: CipherData): SdkCipher {
return new Cipher(value).toSdkCipher();
}
fromSdk(value: SdkCipher): CipherData {
throw new Error("Cipher.fromSdk is not implemented yet");
}
}

View File

@@ -183,8 +183,7 @@ export class CipherService implements CipherServiceAbstraction {
]).pipe(
filter(([ciphers, _, keys]) => ciphers != null && keys != null), // Skip if ciphers haven't been loaded yor synced yet
switchMap(() => this.getAllDecrypted(userId)),
tap(async (decrypted) => {
await this.searchService.indexCiphers(userId, decrypted);
tap(() => {
this.messageSender.send("updateOverlayCiphers");
}),
);
@@ -216,7 +215,7 @@ export class CipherService implements CipherServiceAbstraction {
if (value == null) {
await this.searchService.clearIndex(userId);
} else {
await this.searchService.indexCiphers(userId, value);
void this.searchService.indexCiphers(userId, value);
}
}
}

View File

@@ -0,0 +1,97 @@
import { BehaviorSubject } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { FakeStateProvider, mockAccountServiceWith } from "../../../spec";
import { SearchService } from "./search.service";
describe("SearchService", () => {
let fakeStateProvider: FakeStateProvider;
let service: SearchService;
const userId = "user-id" as UserId;
const mockLogService = {
error: jest.fn(),
measure: jest.fn(),
};
const mockLocale$ = new BehaviorSubject<string>("en");
const mockI18nService = {
locale$: mockLocale$.asObservable(),
};
beforeEach(() => {
jest.clearAllMocks();
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
service = new SearchService(
mockLogService as unknown as LogService,
mockI18nService as unknown as I18nService,
fakeStateProvider,
);
});
describe("isSearchable", () => {
let mockIndex$: jest.Mock;
beforeEach(() => {
mockIndex$ = jest.fn();
service["index$"] = mockIndex$;
});
it("returns false if the query is empty", async () => {
const result = await service.isSearchable(userId, "");
expect(result).toBe(false);
// Ensure we do not call the expensive index$ method
expect(mockIndex$).not.toHaveBeenCalled();
});
it("returns false if the query is null", async () => {
const result = await service.isSearchable(userId, null as any);
expect(result).toBe(false);
// Ensure we do not call the expensive index$ method
expect(mockIndex$).not.toHaveBeenCalled();
});
it("return true if the query is longer than searchableMinLength", async () => {
service["searchableMinLength"] = 3;
const result = await service.isSearchable(userId, "test");
expect(result).toBe(true);
// Ensure we do not call the expensive index$ method
expect(mockIndex$).not.toHaveBeenCalled();
});
it("returns false if the query is shorter than searchableMinLength", async () => {
service["searchableMinLength"] = 5;
const result = await service.isSearchable(userId, "test");
expect(result).toBe(false);
// Ensure we do not call the expensive index$ method
expect(mockIndex$).not.toHaveBeenCalled();
});
it("returns false for short Lunr query with missing index", async () => {
mockIndex$.mockReturnValue(new BehaviorSubject(null));
service["searchableMinLength"] = 3;
const result = await service.isSearchable(userId, ">l");
expect(result).toBe(false);
expect(mockIndex$).toHaveBeenCalledWith(userId);
});
it("returns false for long Lunr query with missing index", async () => {
mockIndex$.mockReturnValue(new BehaviorSubject(null));
service["searchableMinLength"] = 3;
const result = await service.isSearchable(userId, ">longer");
expect(result).toBe(false);
expect(mockIndex$).toHaveBeenCalledWith(userId);
});
it("returns true for short Lunr query with index", async () => {
mockIndex$.mockReturnValue(new BehaviorSubject(true));
service["searchableMinLength"] = 3;
const result = await service.isSearchable(userId, ">l");
expect(result).toBe(true);
expect(mockIndex$).toHaveBeenCalledWith(userId);
});
});
});

View File

@@ -4,6 +4,8 @@ import * as lunr from "lunr";
import { Observable, firstValueFrom, map } from "rxjs";
import { Jsonify } from "type-fest";
import { perUserCache$ } from "@bitwarden/common/vault/utils/observable-utilities";
import { UriMatchStrategy } from "../../models/domain/domain-service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { LogService } from "../../platform/abstractions/log.service";
@@ -21,6 +23,9 @@ import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
// Time to wait before performing a search after the user stops typing.
export const SearchTextDebounceInterval = 200; // milliseconds
export type SerializedLunrIndex = {
version: string;
fields: string[];
@@ -101,11 +106,19 @@ export class SearchService implements SearchServiceAbstraction {
return this.stateProvider.getUser(userId, LUNR_SEARCH_INDEX);
}
private index$(userId: UserId): Observable<lunr.Index | null> {
private index$ = perUserCache$((userId: UserId) => {
return this.searchIndexState(userId).state$.pipe(
map((searchIndex) => (searchIndex ? lunr.Index.load(searchIndex) : null)),
map((searchIndex) => {
let index: lunr.Index | null = null;
if (searchIndex) {
const loadTime = performance.now();
index = lunr.Index.load(searchIndex);
this.logService.measure(loadTime, "Vault", "SearchService", "index load");
}
return index;
}),
);
}
});
private searchIndexEntityIdState(userId: UserId): SingleUserState<IndexedEntityId | null> {
return this.stateProvider.getUser(userId, LUNR_SEARCH_INDEXED_ENTITY_ID);
@@ -129,17 +142,22 @@ export class SearchService implements SearchServiceAbstraction {
await this.searchIsIndexingState(userId).update(() => null);
}
async isSearchable(userId: UserId, query: string): Promise<boolean> {
const time = performance.now();
async isSearchable(userId: UserId, query: string | null): Promise<boolean> {
query = SearchService.normalizeSearchQuery(query);
const index = await this.getIndexForSearch(userId);
const notSearchable =
query == null ||
(index == null && query.length < this.searchableMinLength) ||
(index != null && query.length < this.searchableMinLength && query.indexOf(">") !== 0);
this.logService.measure(time, "Vault", "SearchService", "isSearchable");
return !notSearchable;
// Nothing to search if the query is null
if (query == null || query === "") {
return false;
}
const isLunrQuery = query.indexOf(">") === 0;
if (isLunrQuery) {
// Lunr queries always require an index
return (await this.getIndexForSearch(userId)) != null;
}
// Regular queries only require a minimum length
return query.length >= this.searchableMinLength;
}
async indexCiphers(
@@ -205,6 +223,7 @@ export class SearchService implements SearchServiceAbstraction {
ciphers: C[],
): Promise<C[]> {
const results: C[] = [];
const searchStartTime = performance.now();
if (query != null) {
query = SearchService.normalizeSearchQuery(query.trim().toLowerCase());
}
@@ -236,7 +255,9 @@ export class SearchService implements SearchServiceAbstraction {
const index = await this.getIndexForSearch(userId);
if (index == null) {
// Fall back to basic search if index is not available
return this.searchCiphersBasic(ciphers, query);
const basicResults = this.searchCiphersBasic(ciphers, query);
this.logService.measure(searchStartTime, "Vault", "SearchService", "basic search complete");
return basicResults;
}
const ciphersMap = new Map<string, C>();
@@ -270,6 +291,7 @@ export class SearchService implements SearchServiceAbstraction {
}
});
}
this.logService.measure(searchStartTime, "Vault", "SearchService", "search complete");
return results;
}
@@ -404,11 +426,30 @@ export class SearchService implements SearchServiceAbstraction {
if (u.uri == null || u.uri === "") {
return;
}
if (u.hostname != null) {
uris.push(u.hostname);
return;
}
// Match ports
const portMatch = u.uri.match(/:(\d+)(?:[/?#]|$)/);
const port = portMatch?.[1];
let uri = u.uri;
if (u.hostname !== null) {
uris.push(u.hostname);
if (port) {
uris.push(`${u.hostname}:${port}`);
uris.push(port);
}
return;
} else {
const slash = uri.indexOf("/");
const hostPart = slash > -1 ? uri.substring(0, slash) : uri;
uris.push(hostPart);
if (port) {
uris.push(`${hostPart}`);
uris.push(port);
}
}
if (u.match !== UriMatchStrategy.RegularExpression) {
const protocolIndex = uri.indexOf("://");
if (protocolIndex > -1) {