From 04ed114e0ef2845527535778284142daa7ef35ed Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 26 May 2025 00:30:52 +0200 Subject: [PATCH 01/30] [BEEEP/PM-8492] Add autostart for flatpak (#12016) * Add autostart for flatpak via ashpd * Fix clippy errors * Cargo fmt * Fix clippy --- apps/desktop/desktop_native/Cargo.lock | 1 + apps/desktop/desktop_native/Cargo.toml | 1 + apps/desktop/desktop_native/core/Cargo.toml | 1 + .../core/src/autostart/linux.rs | 21 ++++++++++ .../desktop_native/core/src/autostart/mod.rs | 5 +++ .../core/src/autostart/unimplemented.rs | 5 +++ apps/desktop/desktop_native/core/src/lib.rs | 1 + apps/desktop/desktop_native/napi/index.d.ts | 3 ++ apps/desktop/desktop_native/napi/src/lib.rs | 10 +++++ apps/desktop/src/main/messaging.main.ts | 39 ++++++++++++------- 10 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/desktop_native/core/src/autostart/linux.rs create mode 100644 apps/desktop/desktop_native/core/src/autostart/mod.rs create mode 100644 apps/desktop/desktop_native/core/src/autostart/unimplemented.rs diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index e5d90446ddc..34819a3981b 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -863,6 +863,7 @@ dependencies = [ "anyhow", "arboard", "argon2", + "ashpd", "base64", "bitwarden-russh", "byteorder", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 71da53c867d..c4a2ed98e70 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -13,6 +13,7 @@ aes = "=0.8.4" anyhow = "=1.0.94" arboard = { version = "=3.5.0", default-features = false } argon2 = "=0.5.3" +ashpd = "=0.11.0" base64 = "=0.22.1" bindgen = "=0.71.1" bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "3d48f140fd506412d186203238993163a8c4e536" } diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index b71081aaa1f..7cd67dbad6a 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -85,6 +85,7 @@ desktop_objc = { path = "../objc" } [target.'cfg(target_os = "linux")'.dependencies] oo7 = { workspace = true } libc = { workspace = true } +ashpd = { workspace = true } zbus = { workspace = true, optional = true } zbus_polkit = { workspace = true, optional = true } diff --git a/apps/desktop/desktop_native/core/src/autostart/linux.rs b/apps/desktop/desktop_native/core/src/autostart/linux.rs new file mode 100644 index 00000000000..1fd02a6ea5d --- /dev/null +++ b/apps/desktop/desktop_native/core/src/autostart/linux.rs @@ -0,0 +1,21 @@ +use anyhow::Result; +use ashpd::desktop::background::Background; + +pub async fn set_autostart(autostart: bool, params: Vec) -> Result<()> { + let request = if params.is_empty() { + Background::request().auto_start(autostart) + } else { + Background::request().command(params).auto_start(autostart) + }; + + match request.send().await.and_then(|r| r.response()) { + Ok(response) => { + println!("[ASHPD] Autostart enabled: {:?}", response); + Ok(()) + } + Err(err) => { + println!("[ASHPD] Error enabling autostart: {}", err); + Err(anyhow::anyhow!("error enabling autostart {}", err)) + } + } +} diff --git a/apps/desktop/desktop_native/core/src/autostart/mod.rs b/apps/desktop/desktop_native/core/src/autostart/mod.rs new file mode 100644 index 00000000000..78e27eb433e --- /dev/null +++ b/apps/desktop/desktop_native/core/src/autostart/mod.rs @@ -0,0 +1,5 @@ +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "windows", path = "unimplemented.rs")] +#[cfg_attr(target_os = "macos", path = "unimplemented.rs")] +mod autostart_impl; +pub use autostart_impl::*; diff --git a/apps/desktop/desktop_native/core/src/autostart/unimplemented.rs b/apps/desktop/desktop_native/core/src/autostart/unimplemented.rs new file mode 100644 index 00000000000..14f567bdc65 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/autostart/unimplemented.rs @@ -0,0 +1,5 @@ +use anyhow::Result; + +pub async fn set_autostart(_autostart: bool, _params: Vec) -> Result<()> { + unimplemented!(); +} diff --git a/apps/desktop/desktop_native/core/src/lib.rs b/apps/desktop/desktop_native/core/src/lib.rs index 0a16ee65be3..a72ec04e9c2 100644 --- a/apps/desktop/desktop_native/core/src/lib.rs +++ b/apps/desktop/desktop_native/core/src/lib.rs @@ -1,4 +1,5 @@ pub mod autofill; +pub mod autostart; pub mod biometric; pub mod clipboard; pub mod crypto; diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 952f2571c5d..b3c6f715e98 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -111,6 +111,9 @@ export declare namespace ipc { send(message: string): number } } +export declare namespace autostart { + export function setAutostart(autostart: boolean, params: Array): Promise +} export declare namespace autofill { export function runCommand(value: string): Promise export const enum UserVerification { diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 37796ef6f59..079872a3b03 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -477,6 +477,16 @@ pub mod ipc { } } +#[napi] +pub mod autostart { + #[napi] + pub async fn set_autostart(autostart: bool, params: Vec) -> napi::Result<()> { + desktop_core::autostart::set_autostart(autostart, params) + .await + .map_err(|e| napi::Error::from_reason(format!("Error setting autostart - {e} - {e:?}"))) + } +} + #[napi] pub mod autofill { use desktop_core::ipc::server::{Message, MessageType}; diff --git a/apps/desktop/src/main/messaging.main.ts b/apps/desktop/src/main/messaging.main.ts index bb4063d64fd..556fa293108 100644 --- a/apps/desktop/src/main/messaging.main.ts +++ b/apps/desktop/src/main/messaging.main.ts @@ -6,8 +6,11 @@ import * as path from "path"; import { app, ipcMain } from "electron"; import { firstValueFrom } from "rxjs"; +import { autostart } from "@bitwarden/desktop-napi"; + import { Main } from "../main"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; +import { isFlatpak } from "../utils"; import { MenuUpdateRequest } from "./menu/menu.updater"; @@ -122,20 +125,24 @@ export class MessagingMain { private addOpenAtLogin() { if (process.platform === "linux") { - const data = `[Desktop Entry] -Type=Application -Version=${app.getVersion()} -Name=Bitwarden -Comment=Bitwarden startup script -Exec=${app.getPath("exe")} -StartupNotify=false -Terminal=false`; + if (isFlatpak()) { + autostart.setAutostart(true, []).catch((e) => {}); + } else { + const data = `[Desktop Entry] + Type=Application + Version=${app.getVersion()} + Name=Bitwarden + Comment=Bitwarden startup script + Exec=${app.getPath("exe")} + StartupNotify=false + Terminal=false`; - const dir = path.dirname(this.linuxStartupFile()); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir); + const dir = path.dirname(this.linuxStartupFile()); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir); + } + fs.writeFileSync(this.linuxStartupFile(), data); } - fs.writeFileSync(this.linuxStartupFile(), data); } else { app.setLoginItemSettings({ openAtLogin: true }); } @@ -143,8 +150,12 @@ Terminal=false`; private removeOpenAtLogin() { if (process.platform === "linux") { - if (fs.existsSync(this.linuxStartupFile())) { - fs.unlinkSync(this.linuxStartupFile()); + if (isFlatpak()) { + autostart.setAutostart(false, []).catch((e) => {}); + } else { + if (fs.existsSync(this.linuxStartupFile())) { + fs.unlinkSync(this.linuxStartupFile()); + } } } else { app.setLoginItemSettings({ openAtLogin: false }); From 56b565a47ebaf6fc49a021a86127f42d554aa7d3 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 26 May 2025 03:21:30 -0400 Subject: [PATCH 02/30] Add lowdb to ignored dependencies (#14907) * Added lowdb to ignored dependencies * Changed to allowedVersions instead of ignoring for more context. --- .github/renovate.json5 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 0ebc2c210a2..1b84ccdab01 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -405,6 +405,12 @@ commitMessagePrefix: "[deps] KM:", reviewers: ["team:team-key-management-dev"], }, + { + // Any versions of lowdb above 1.0.0 are not compatible with CommonJS. + matchPackageNames: ["lowdb"], + allowedVersions: "1.0.0", + description: "Higher versions of lowdb are not compatible with CommonJS", + }, ], ignoreDeps: ["@types/koa-bodyparser", "bootstrap", "node-ipc", "@bitwarden/sdk-internal"], } From 745ab219467fbe31ce0e05308ff3232c0067e1e1 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 26 May 2025 14:38:02 +0200 Subject: [PATCH 03/30] Remove unused imports in browser and desktop (#14875) Removes unused imports from browser and desktop. These were missed in #14795. --- .../autofill/popup/fido2/fido2-cipher-row.component.ts | 4 ---- .../autofill/popup/settings/notifications.component.ts | 2 -- .../src/platform/popup/layout/popup-layout.stories.ts | 3 --- .../popup/generator/credential-generator.component.ts | 2 -- .../send-v2/send-created/send-created.component.ts | 3 +-- .../open-attachments/open-attachments.component.ts | 10 ++-------- .../autofill-vault-list-items.component.ts | 9 +-------- .../vault-list-items-container.component.ts | 1 - .../popup/settings/download-bitwarden.component.ts | 2 -- .../trash-list-items-container.component.ts | 1 - .../popup/settings/vault-settings-v2.component.ts | 2 -- .../src/platform/components/approve-ssh-request.ts | 2 -- 12 files changed, 4 insertions(+), 37 deletions(-) diff --git a/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts b/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts index 98e73d2174c..02df3ffe9b3 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts @@ -10,8 +10,6 @@ import { ButtonModule, IconButtonModule, ItemModule, - SectionComponent, - SectionHeaderComponent, TypographyModule, } from "@bitwarden/components"; @@ -27,8 +25,6 @@ import { IconButtonModule, ItemModule, JslibModule, - SectionComponent, - SectionHeaderComponent, TypographyModule, ], }) diff --git a/apps/browser/src/autofill/popup/settings/notifications.component.ts b/apps/browser/src/autofill/popup/settings/notifications.component.ts index be447e3f885..476b601c4e5 100644 --- a/apps/browser/src/autofill/popup/settings/notifications.component.ts +++ b/apps/browser/src/autofill/popup/settings/notifications.component.ts @@ -18,7 +18,6 @@ import { } from "@bitwarden/components"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; -import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; @@ -31,7 +30,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co RouterModule, PopupPageComponent, PopupHeaderComponent, - PopupFooterComponent, PopOutComponent, ItemModule, CardComponent, diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts index f20049f6cde..48940f5fa10 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -185,7 +185,6 @@ class MockVaultPageComponent {} PopupPageComponent, PopupHeaderComponent, MockAddButtonComponent, - MockPopoutButtonComponent, MockCurrentAccountComponent, VaultComponent, ], @@ -290,9 +289,7 @@ class MockSettingsPageComponent {} PopupHeaderComponent, PopupFooterComponent, ButtonModule, - MockAddButtonComponent, MockPopoutButtonComponent, - MockCurrentAccountComponent, VaultComponent, IconButtonModule, ], diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.ts b/apps/browser/src/tools/popup/generator/credential-generator.component.ts index 9c1af07efdd..a2ef4be6620 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.ts +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.ts @@ -7,7 +7,6 @@ import { GeneratorModule } from "@bitwarden/generator-components"; import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; -import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; @@ -22,7 +21,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co PopOutComponent, PopupHeaderComponent, PopupPageComponent, - PopupFooterComponent, RouterModule, ItemModule, ], diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts index 7191040ac6f..7680e05dd5b 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ActivatedRoute, Router, RouterLink, RouterModule } from "@angular/router"; +import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -31,7 +31,6 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page PopOutComponent, PopupHeaderComponent, PopupPageComponent, - RouterLink, RouterModule, PopupFooterComponent, IconModule, diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts index 9189ea51313..d44b54bcb96 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts @@ -18,13 +18,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { - BadgeModule, - CardComponent, - ItemModule, - ToastService, - TypographyModule, -} from "@bitwarden/components"; +import { BadgeModule, ItemModule, ToastService, TypographyModule } from "@bitwarden/components"; import BrowserPopupUtils from "../../../../../../platform/popup/browser-popup-utils"; import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.service"; @@ -33,7 +27,7 @@ import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/f standalone: true, selector: "app-open-attachments", templateUrl: "./open-attachments.component.html", - imports: [BadgeModule, CommonModule, ItemModule, JslibModule, TypographyModule, CardComponent], + imports: [BadgeModule, CommonModule, ItemModule, JslibModule, TypographyModule], }) export class OpenAttachmentsComponent implements OnInit { /** Cipher `id` */ diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts index 72d51776f7b..bdc0d7ae5bc 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -6,12 +6,7 @@ import { combineLatest, map, Observable } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { - IconButtonModule, - SectionComponent, - SectionHeaderComponent, - TypographyModule, -} from "@bitwarden/components"; +import { IconButtonModule, TypographyModule } from "@bitwarden/components"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; @@ -23,11 +18,9 @@ import { VaultListItemsContainerComponent } from "../vault-list-items-container/ standalone: true, imports: [ CommonModule, - SectionComponent, TypographyModule, VaultListItemsContainerComponent, JslibModule, - SectionHeaderComponent, IconButtonModule, ], selector: "app-autofill-vault-list-items", diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index 6df1bdf8ae5..073d49333b5 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -74,7 +74,6 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options ScrollingModule, DisclosureComponent, DisclosureTriggerForDirective, - DecryptionFailureDialogComponent, ], selector: "app-vault-list-items-container", templateUrl: "vault-list-items-container.component.html", diff --git a/apps/browser/src/vault/popup/settings/download-bitwarden.component.ts b/apps/browser/src/vault/popup/settings/download-bitwarden.component.ts index fa7efa87bda..0287b7d504f 100644 --- a/apps/browser/src/vault/popup/settings/download-bitwarden.component.ts +++ b/apps/browser/src/vault/popup/settings/download-bitwarden.component.ts @@ -9,7 +9,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { CardComponent, LinkModule, TypographyModule } from "@bitwarden/components"; -import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; @@ -26,7 +25,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co PopOutComponent, CardComponent, TypographyModule, - CurrentAccountComponent, LinkModule, ], }) diff --git a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts index cbfc89bf922..b4899e12eda 100644 --- a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts @@ -49,7 +49,6 @@ import { PopupCipherView } from "../../views/popup-cipher.view"; IconButtonModule, OrgIconDirective, TypographyModule, - DecryptionFailureDialogComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts index c969f0436df..049e853d2cd 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts @@ -10,7 +10,6 @@ import { ItemModule, ToastOptions, ToastService } from "@bitwarden/components"; import { BrowserApi } from "../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; -import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; @@ -22,7 +21,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co JslibModule, RouterModule, PopupPageComponent, - PopupFooterComponent, PopupHeaderComponent, PopOutComponent, ItemModule, diff --git a/apps/desktop/src/platform/components/approve-ssh-request.ts b/apps/desktop/src/platform/components/approve-ssh-request.ts index c6c7388ecf1..515fd94ecd6 100644 --- a/apps/desktop/src/platform/components/approve-ssh-request.ts +++ b/apps/desktop/src/platform/components/approve-ssh-request.ts @@ -13,7 +13,6 @@ import { IconButtonModule, DialogService, } from "@bitwarden/components"; -import { CipherFormGeneratorComponent } from "@bitwarden/vault"; export interface ApproveSshRequestParams { cipherName: string; @@ -30,7 +29,6 @@ export interface ApproveSshRequestParams { DialogModule, CommonModule, JslibModule, - CipherFormGeneratorComponent, ButtonModule, IconButtonModule, ReactiveFormsModule, From 43c963032db7f8a92fc97e24f7044af22f5751cf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 May 2025 16:25:30 +0200 Subject: [PATCH 04/30] [deps] Architecture: Update lint-staged to v16 (#14925) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Oscar Hinton --- package-lock.json | 160 ++++++---------------------------------------- package.json | 2 +- 2 files changed, 22 insertions(+), 140 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2e2623f89cc..88302755623 100644 --- a/package-lock.json +++ b/package-lock.json @@ -153,7 +153,7 @@ "jest-mock-extended": "3.0.7", "jest-preset-angular": "14.1.1", "json5": "2.2.3", - "lint-staged": "15.5.1", + "lint-staged": "16.0.0", "mini-css-extract-plugin": "2.9.2", "nx": "20.8.0", "postcss": "8.5.3", @@ -25970,28 +25970,28 @@ } }, "node_modules/lint-staged": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.1.tgz", - "integrity": "sha512-6m7u8mue4Xn6wK6gZvSCQwBvMBR36xfY24nF5bMTf2MHDYG6S3yhJuOgdYVw99hsjyDt2d4z168b3naI8+NWtQ==", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.0.0.tgz", + "integrity": "sha512-sUCprePs6/rbx4vKC60Hez6X10HPkpDJaGcy3D1NdwR7g1RcNkWL8q9mJMreOqmHBTs+1sNFp+wOiX9fr+hoOQ==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^5.4.1", "commander": "^13.1.0", "debug": "^4.4.0", - "execa": "^8.0.1", "lilconfig": "^3.1.3", - "listr2": "^8.2.5", + "listr2": "^8.3.3", "micromatch": "^4.0.8", + "nano-spawn": "^1.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", - "yaml": "^2.7.0" + "yaml": "^2.7.1" }, "bin": { "lint-staged": "bin/lint-staged.js" }, "engines": { - "node": ">=18.12.0" + "node": ">=20.18" }, "funding": { "url": "https://opencollective.com/lint-staged" @@ -26077,53 +26077,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lint-staged/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/lint-staged/node_modules/is-fullwidth-code-point": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", @@ -26137,19 +26090,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lint-staged/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lint-staged/node_modules/listr2": { "version": "8.3.3", "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", @@ -26168,64 +26108,6 @@ "node": ">=18.0.0" } }, - "node_modules/lint-staged/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lint-staged/node_modules/slice-ansi": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", @@ -26277,19 +26159,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/lint-staged/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lint-staged/node_modules/wrap-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", @@ -28632,6 +28501,19 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nano-spawn": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz", + "integrity": "sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", diff --git a/package.json b/package.json index ce6adf3009d..8a8e80bd632 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "jest-mock-extended": "3.0.7", "jest-preset-angular": "14.1.1", "json5": "2.2.3", - "lint-staged": "15.5.1", + "lint-staged": "16.0.0", "mini-css-extract-plugin": "2.9.2", "nx": "20.8.0", "postcss": "8.5.3", From beb00a206b7b48f4f053b394b06160b5dd2c3f2c Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 26 May 2025 17:02:28 +0200 Subject: [PATCH 05/30] Add UUID helpers to the SDK (#14939) * Add UUID helpers to the SDK * Address review feedback --- .../platform/abstractions/sdk/sdk.service.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/libs/common/src/platform/abstractions/sdk/sdk.service.ts b/libs/common/src/platform/abstractions/sdk/sdk.service.ts index 3adf3291bbf..07dfb2aa0df 100644 --- a/libs/common/src/platform/abstractions/sdk/sdk.service.ts +++ b/libs/common/src/platform/abstractions/sdk/sdk.service.ts @@ -1,9 +1,10 @@ import { Observable } from "rxjs"; -import { BitwardenClient } from "@bitwarden/sdk-internal"; +import { BitwardenClient, Uuid } from "@bitwarden/sdk-internal"; import { UserId } from "../../../types/guid"; import { Rc } from "../../misc/reference-counting/rc"; +import { Utils } from "../../misc/utils"; export class UserNotLoggedInError extends Error { constructor(userId: UserId) { @@ -11,6 +12,30 @@ export class UserNotLoggedInError extends Error { } } +export class InvalidUuid extends Error { + constructor(uuid: string) { + super(`Invalid UUID: ${uuid}`); + } +} + +/** + * Converts a string to UUID. Will throw an error if the UUID is non valid. + */ +export function asUuid(uuid: string): T { + if (Utils.isGuid(uuid)) { + return uuid as T; + } + + throw new InvalidUuid(uuid); +} + +/** + * Converts a UUID to the string representation. + */ +export function uuidToString(uuid: T): string { + return uuid as unknown as string; +} + export abstract class SdkService { /** * Retrieve the version of the SDK. From a6e2012087f1dc8e49c4b2d70a2f04a6a8ddd37c Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 27 May 2025 10:03:54 +0200 Subject: [PATCH 06/30] [PM-21600] Migrate account and security to standalone (#14762) Migrates account and security settings components to standalone and removing them from the `LooseComponentsModule`. --- .../settings/account/account.component.ts | 15 ++++++-- .../account/change-avatar-dialog.component.ts | 7 +++- .../account/change-email.component.spec.ts | 3 +- .../account/change-email.component.ts | 5 ++- .../settings/account/danger-zone.component.ts | 4 +-- .../account/deauthorize-sessions.component.ts | 7 ++-- .../delete-account-dialog.component.ts | 6 +++- .../settings/account/profile.component.ts | 7 +++- .../account/selectable-avatar.component.ts | 6 +++- .../settings/security/api-key.component.ts | 7 ++-- .../security/security-keys.component.ts | 7 ++-- .../settings/security/security.component.ts | 7 ++-- .../src/app/shared/loose-components.module.ts | 35 ------------------- 13 files changed, 62 insertions(+), 54 deletions(-) diff --git a/apps/web/src/app/auth/settings/account/account.component.ts b/apps/web/src/app/auth/settings/account/account.component.ts index cfc01f17674..c06df56e386 100644 --- a/apps/web/src/app/auth/settings/account/account.component.ts +++ b/apps/web/src/app/auth/settings/account/account.component.ts @@ -8,16 +8,27 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogService } from "@bitwarden/components"; +import { HeaderModule } from "../../../layouts/header/header.module"; +import { SharedModule } from "../../../shared"; import { PurgeVaultComponent } from "../../../vault/settings/purge-vault.component"; +import { ChangeEmailComponent } from "./change-email.component"; +import { DangerZoneComponent } from "./danger-zone.component"; import { DeauthorizeSessionsComponent } from "./deauthorize-sessions.component"; import { DeleteAccountDialogComponent } from "./delete-account-dialog.component"; +import { ProfileComponent } from "./profile.component"; import { SetAccountVerifyDevicesDialogComponent } from "./set-account-verify-devices-dialog.component"; @Component({ - selector: "app-account", templateUrl: "account.component.html", - standalone: false, + standalone: true, + imports: [ + SharedModule, + HeaderModule, + ProfileComponent, + ChangeEmailComponent, + DangerZoneComponent, + ], }) export class AccountComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); diff --git a/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts index 5d71333c0de..80fdb20954f 100644 --- a/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts +++ b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts @@ -24,6 +24,10 @@ import { ToastService, } from "@bitwarden/components"; +import { SharedModule } from "../../../shared"; + +import { SelectableAvatarComponent } from "./selectable-avatar.component"; + type ChangeAvatarDialogData = { profile: ProfileResponse; }; @@ -31,7 +35,8 @@ type ChangeAvatarDialogData = { @Component({ templateUrl: "change-avatar-dialog.component.html", encapsulation: ViewEncapsulation.None, - standalone: false, + standalone: true, + imports: [SharedModule, SelectableAvatarComponent], }) export class ChangeAvatarDialogComponent implements OnInit, OnDestroy { profile: ProfileResponse; diff --git a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts index 838a50b5c2e..f5c0733e5b0 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts +++ b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts @@ -33,8 +33,7 @@ describe("ChangeEmailComponent", () => { accountService = mockAccountServiceWith("UserId" as UserId); await TestBed.configureTestingModule({ - declarations: [ChangeEmailComponent], - imports: [ReactiveFormsModule, SharedModule], + imports: [ReactiveFormsModule, SharedModule, ChangeEmailComponent], providers: [ { provide: AccountService, useValue: accountService }, { provide: ApiService, useValue: apiService }, diff --git a/apps/web/src/app/auth/settings/account/change-email.component.ts b/apps/web/src/app/auth/settings/account/change-email.component.ts index c86c8c2f4f7..98f704d6044 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.ts +++ b/apps/web/src/app/auth/settings/account/change-email.component.ts @@ -14,10 +14,13 @@ import { UserId } from "@bitwarden/common/types/guid"; import { ToastService } from "@bitwarden/components"; import { KdfConfigService, KeyService } from "@bitwarden/key-management"; +import { SharedModule } from "../../../shared"; + @Component({ selector: "app-change-email", templateUrl: "change-email.component.html", - standalone: false, + standalone: true, + imports: [SharedModule], }) export class ChangeEmailComponent implements OnInit { tokenSent = false; diff --git a/apps/web/src/app/auth/settings/account/danger-zone.component.ts b/apps/web/src/app/auth/settings/account/danger-zone.component.ts index 91f22c7d08f..e07b6e6b8db 100644 --- a/apps/web/src/app/auth/settings/account/danger-zone.component.ts +++ b/apps/web/src/app/auth/settings/account/danger-zone.component.ts @@ -3,8 +3,8 @@ import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; -import { JslibModule } from "@bitwarden/angular/jslib.module"; import { TypographyModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; /** * Component for the Danger Zone section of the Account/Organization Settings page. @@ -13,6 +13,6 @@ import { TypographyModule } from "@bitwarden/components"; selector: "app-danger-zone", templateUrl: "danger-zone.component.html", standalone: true, - imports: [TypographyModule, JslibModule, CommonModule], + imports: [CommonModule, TypographyModule, I18nPipe], }) export class DangerZoneComponent {} diff --git a/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts b/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts index da4d2dce9d7..a48e968ab3e 100644 --- a/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts +++ b/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts @@ -1,6 +1,7 @@ import { Component } from "@angular/core"; import { FormBuilder } from "@angular/forms"; +import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { Verification } from "@bitwarden/common/auth/types/verification"; @@ -9,10 +10,12 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { SharedModule } from "../../../shared"; + @Component({ - selector: "app-deauthorize-sessions", templateUrl: "deauthorize-sessions.component.html", - standalone: false, + standalone: true, + imports: [SharedModule, UserVerificationFormInputComponent], }) export class DeauthorizeSessionsComponent { deauthForm = this.formBuilder.group({ diff --git a/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts b/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts index 8a3575af5ba..0fc2276b779 100644 --- a/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts +++ b/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts @@ -3,15 +3,19 @@ import { Component } from "@angular/core"; import { FormBuilder } from "@angular/forms"; +import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular"; import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; import { Verification } from "@bitwarden/common/auth/types/verification"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { SharedModule } from "../../../shared"; + @Component({ templateUrl: "delete-account-dialog.component.html", - standalone: false, + standalone: true, + imports: [SharedModule, UserVerificationFormInputComponent], }) export class DeleteAccountDialogComponent { deleteForm = this.formBuilder.group({ diff --git a/apps/web/src/app/auth/settings/account/profile.component.ts b/apps/web/src/app/auth/settings/account/profile.component.ts index dc3997f58bb..a33efd742aa 100644 --- a/apps/web/src/app/auth/settings/account/profile.component.ts +++ b/apps/web/src/app/auth/settings/account/profile.component.ts @@ -14,12 +14,17 @@ import { ProfileResponse } from "@bitwarden/common/models/response/profile.respo import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { DynamicAvatarComponent } from "../../../components/dynamic-avatar.component"; +import { SharedModule } from "../../../shared"; +import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component"; + import { ChangeAvatarDialogComponent } from "./change-avatar-dialog.component"; @Component({ selector: "app-profile", templateUrl: "profile.component.html", - standalone: false, + standalone: true, + imports: [SharedModule, DynamicAvatarComponent, AccountFingerprintComponent], }) export class ProfileComponent implements OnInit, OnDestroy { loading = true; diff --git a/apps/web/src/app/auth/settings/account/selectable-avatar.component.ts b/apps/web/src/app/auth/settings/account/selectable-avatar.component.ts index 33c307882c5..a53e3990090 100644 --- a/apps/web/src/app/auth/settings/account/selectable-avatar.component.ts +++ b/apps/web/src/app/auth/settings/account/selectable-avatar.component.ts @@ -1,7 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { NgClass } from "@angular/common"; import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { AvatarModule } from "@bitwarden/components"; + @Component({ selector: "selectable-avatar", template: ` `, - standalone: false, + standalone: true, + imports: [NgClass, AvatarModule], }) export class SelectableAvatarComponent { @Input() id: string; diff --git a/apps/web/src/app/auth/settings/security/api-key.component.ts b/apps/web/src/app/auth/settings/security/api-key.component.ts index 4f87c082881..5e61b4b4584 100644 --- a/apps/web/src/app/auth/settings/security/api-key.component.ts +++ b/apps/web/src/app/auth/settings/security/api-key.component.ts @@ -3,12 +3,15 @@ import { Component, Inject } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; +import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "@bitwarden/common/auth/models/response/api-key.response"; import { Verification } from "@bitwarden/common/auth/types/verification"; import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components"; +import { SharedModule } from "../../../shared"; + export type ApiKeyDialogData = { keyType: string; isRotation?: boolean; @@ -21,9 +24,9 @@ export type ApiKeyDialogData = { apiKeyDescription: string; }; @Component({ - selector: "app-api-key", templateUrl: "api-key.component.html", - standalone: false, + standalone: true, + imports: [SharedModule, UserVerificationFormInputComponent], }) export class ApiKeyComponent { clientId: string; diff --git a/apps/web/src/app/auth/settings/security/security-keys.component.ts b/apps/web/src/app/auth/settings/security/security-keys.component.ts index 98e743f57dc..6d33193cdde 100644 --- a/apps/web/src/app/auth/settings/security/security-keys.component.ts +++ b/apps/web/src/app/auth/settings/security/security-keys.component.ts @@ -8,12 +8,15 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { DialogService } from "@bitwarden/components"; +import { SharedModule } from "../../../shared"; + import { ApiKeyComponent } from "./api-key.component"; +import { ChangeKdfModule } from "./change-kdf/change-kdf.module"; @Component({ - selector: "app-security-keys", templateUrl: "security-keys.component.html", - standalone: false, + standalone: true, + imports: [SharedModule, ChangeKdfModule], }) export class SecurityKeysComponent implements OnInit { showChangeKdf = true; diff --git a/apps/web/src/app/auth/settings/security/security.component.ts b/apps/web/src/app/auth/settings/security/security.component.ts index 41b1af17abb..95733d693e2 100644 --- a/apps/web/src/app/auth/settings/security/security.component.ts +++ b/apps/web/src/app/auth/settings/security/security.component.ts @@ -4,10 +4,13 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { HeaderModule } from "../../../layouts/header/header.module"; +import { SharedModule } from "../../../shared"; + @Component({ - selector: "app-security", templateUrl: "security.component.html", - standalone: false, + standalone: true, + imports: [SharedModule, HeaderModule], }) export class SecurityComponent implements OnInit { showChangePassword = true; diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index e59633ee499..44323614f17 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -15,23 +15,12 @@ import { AcceptFamilySponsorshipComponent } from "../admin-console/organizations import { RecoverDeleteComponent } from "../auth/recover-delete.component"; import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component"; import { SetPasswordComponent } from "../auth/set-password.component"; -import { AccountComponent } from "../auth/settings/account/account.component"; -import { ChangeAvatarDialogComponent } from "../auth/settings/account/change-avatar-dialog.component"; -import { ChangeEmailComponent } from "../auth/settings/account/change-email.component"; import { DangerZoneComponent } from "../auth/settings/account/danger-zone.component"; -import { DeauthorizeSessionsComponent } from "../auth/settings/account/deauthorize-sessions.component"; -import { DeleteAccountDialogComponent } from "../auth/settings/account/delete-account-dialog.component"; -import { ProfileComponent } from "../auth/settings/account/profile.component"; -import { SelectableAvatarComponent } from "../auth/settings/account/selectable-avatar.component"; import { EmergencyAccessConfirmComponent } from "../auth/settings/emergency-access/confirm/emergency-access-confirm.component"; import { EmergencyAccessAddEditComponent } from "../auth/settings/emergency-access/emergency-access-add-edit.component"; import { EmergencyAccessComponent } from "../auth/settings/emergency-access/emergency-access.component"; import { EmergencyAccessTakeoverComponent } from "../auth/settings/emergency-access/takeover/emergency-access-takeover.component"; import { EmergencyAccessViewComponent } from "../auth/settings/emergency-access/view/emergency-access-view.component"; -import { ApiKeyComponent } from "../auth/settings/security/api-key.component"; -import { ChangeKdfModule } from "../auth/settings/security/change-kdf/change-kdf.module"; -import { SecurityKeysComponent } from "../auth/settings/security/security-keys.component"; -import { SecurityComponent } from "../auth/settings/security/security.component"; import { UserVerificationModule } from "../auth/shared/components/user-verification"; import { UpdatePasswordComponent } from "../auth/update-password.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; @@ -39,7 +28,6 @@ import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component" import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.component"; import { SponsoredFamiliesComponent } from "../billing/settings/sponsored-families.component"; import { SponsoringOrgRowComponent } from "../billing/settings/sponsoring-org-row.component"; -import { DynamicAvatarComponent } from "../components/dynamic-avatar.component"; // eslint-disable-next-line no-restricted-imports -- Temporarily disabled until DIRT refactors these out of this module import { ExposedPasswordsReportComponent as OrgExposedPasswordsReportComponent } from "../dirt/reports/pages/organizations/exposed-passwords-report.component"; // eslint-disable-next-line no-restricted-imports -- Temporarily disabled until DIRT refactors these out of this module @@ -68,8 +56,6 @@ import { SharedModule } from "./shared.module"; imports: [ SharedModule, UserVerificationModule, - ChangeKdfModule, - DynamicAvatarComponent, AccountFingerprintComponent, OrganizationBadgeModule, PipesModule, @@ -85,11 +71,6 @@ import { SharedModule } from "./shared.module"; ], declarations: [ AcceptFamilySponsorshipComponent, - AccountComponent, - ApiKeyComponent, - ChangeEmailComponent, - DeauthorizeSessionsComponent, - DeleteAccountDialogComponent, EmergencyAccessAddEditComponent, EmergencyAccessComponent, EmergencyAccessConfirmComponent, @@ -104,15 +85,10 @@ import { SharedModule } from "./shared.module"; OrgUserConfirmComponent, OrgWeakPasswordsReportComponent, PremiumBadgeComponent, - ProfileComponent, - ChangeAvatarDialogComponent, PurgeVaultComponent, RecoverDeleteComponent, RecoverTwoFactorComponent, RemovePasswordComponent, - SecurityComponent, - SecurityKeysComponent, - SelectableAvatarComponent, SetPasswordComponent, SponsoredFamiliesComponent, FreeBitwardenFamiliesComponent, @@ -125,12 +101,6 @@ import { SharedModule } from "./shared.module"; exports: [ UserVerificationModule, PremiumBadgeComponent, - AccountComponent, - ApiKeyComponent, - ChangeEmailComponent, - DeauthorizeSessionsComponent, - DeleteAccountDialogComponent, - DynamicAvatarComponent, EmergencyAccessAddEditComponent, EmergencyAccessComponent, EmergencyAccessConfirmComponent, @@ -146,15 +116,10 @@ import { SharedModule } from "./shared.module"; OrgUserConfirmComponent, OrgWeakPasswordsReportComponent, PremiumBadgeComponent, - ProfileComponent, - ChangeAvatarDialogComponent, PurgeVaultComponent, RecoverDeleteComponent, RecoverTwoFactorComponent, RemovePasswordComponent, - SecurityComponent, - SecurityKeysComponent, - SelectableAvatarComponent, SetPasswordComponent, SponsoredFamiliesComponent, FreeBitwardenFamiliesComponent, From 45f2104fd8905ff28fe4c6fe570cdf59ae9cb1d3 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Tue, 27 May 2025 14:31:27 +0200 Subject: [PATCH 07/30] fix: broken SDK interface (#14959) --- .../src/platform/services/sdk/default-sdk.service.ts | 12 +++++++++++- package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index 7027b5134a0..8e84642fb99 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -152,7 +152,15 @@ export class DefaultSdkService implements SdkService { const settings = this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient(settings); - await this.initializeClient(client, account, kdfParams, privateKey, userKey, orgKeys); + await this.initializeClient( + userId, + client, + account, + kdfParams, + privateKey, + userKey, + orgKeys, + ); return client; }; @@ -182,6 +190,7 @@ export class DefaultSdkService implements SdkService { } private async initializeClient( + userId: UserId, client: BitwardenClient, account: AccountInfo, kdfParams: KdfConfig, @@ -190,6 +199,7 @@ export class DefaultSdkService implements SdkService { orgKeys: Record | null, ) { await client.crypto().initialize_user_crypto({ + userId, email: account.email, method: { decryptedKey: { decrypted_user_key: userKey.keyB64 } }, kdfParams: diff --git a/package-lock.json b/package-lock.json index 88302755623..ff21acbc20b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@angular/platform-browser": "18.2.13", "@angular/platform-browser-dynamic": "18.2.13", "@angular/router": "18.2.13", - "@bitwarden/sdk-internal": "0.2.0-main.168", + "@bitwarden/sdk-internal": "0.2.0-main.177", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "3.1.0", @@ -4803,9 +4803,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.168", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.168.tgz", - "integrity": "sha512-NU10oqw+GI9oHrh8/i/IC8/7oaYmswqC2E/0Zc56xC3jY7uNgFZgpae7JhyMU6UxzrAjiEqdmGnm+AGWFiPG8w==", + "version": "0.2.0-main.177", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.177.tgz", + "integrity": "sha512-2fp/g0WJDPPrIqrU88QrwoJsZTzoi7S7eCf+Qq0/8x3ImqQyoYJEdHdz06YHjUdS0CzucPrwTo5zJ/ZvcLNOmQ==", "license": "GPL-3.0" }, "node_modules/@bitwarden/send-ui": { diff --git a/package.json b/package.json index 8a8e80bd632..d27d919fb16 100644 --- a/package.json +++ b/package.json @@ -159,7 +159,7 @@ "@angular/platform-browser": "18.2.13", "@angular/platform-browser-dynamic": "18.2.13", "@angular/router": "18.2.13", - "@bitwarden/sdk-internal": "0.2.0-main.168", + "@bitwarden/sdk-internal": "0.2.0-main.177", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "3.1.0", From 888e2031a702f3e12c61b666aa87fa8fc62d3cd4 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 27 May 2025 08:24:53 -0500 Subject: [PATCH 08/30] [PM-21090] Vault - Repeated Syncs (#14740) * move `fullSync` contents to private methods in prep to storing the respective promise * store in-flight sync so multiple calls to the sync service are avoided * Revert "store in-flight sync so multiple calls to the sync service are avoided" This reverts commit 233c8e9d4b92d448f63e157b01c168d840c7e531. * Revert "move `fullSync` contents to private methods in prep to storing the respective promise" This reverts commit 3f686ac6a4b41e332180478f269d9fde85f562a1. * store inflight API calls for sync service - This avoids duplicate network requests in a relatively short amount of time but still allows consumers to call `fullSync` if needed * add debug log for duplicate sync --- .../sync/default-sync.service.spec.ts | 65 +++++++++++++++---- .../src/platform/sync/default-sync.service.ts | 32 ++++++++- 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index 29543672dbe..fc6b9481bd5 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -130,23 +130,23 @@ describe("DefaultSyncService", () => { const user1 = "user1" as UserId; + const emptySyncResponse = new SyncResponse({ + profile: { + id: user1, + }, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }); + describe("fullSync", () => { beforeEach(() => { accountService.activeAccount$ = of({ id: user1 } as Account); Matrix.autoMockMethod(authService.authStatusFor$, () => of(AuthenticationStatus.Unlocked)); - apiService.getSync.mockResolvedValue( - new SyncResponse({ - profile: { - id: user1, - }, - folders: [], - collections: [], - ciphers: [], - sends: [], - domains: [], - policies: [], - }), - ); + apiService.getSync.mockResolvedValue(emptySyncResponse); Matrix.autoMockMethod(userDecryptionOptionsService.userDecryptionOptionsById$, () => of({ hasMasterPassword: true } satisfies UserDecryptionOptions), ); @@ -201,5 +201,44 @@ describe("DefaultSyncService", () => { expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); expect(apiService.getSync).toHaveBeenCalledTimes(1); }); + + describe("in-flight syncs", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("does not call getSync when one is already in progress", async () => { + const fullSyncPromises = [sut.fullSync(true), sut.fullSync(false), sut.fullSync(false)]; + + jest.advanceTimersByTime(100); + + await Promise.all(fullSyncPromises); + + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does not call refreshIdentityToken when one is already in progress", async () => { + const fullSyncPromises = [sut.fullSync(true), sut.fullSync(false), sut.fullSync(false)]; + + jest.advanceTimersByTime(100); + + await Promise.all(fullSyncPromises); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + }); + + it("resets the in-flight properties when the complete", async () => { + const fullSyncPromises = [sut.fullSync(true), sut.fullSync(true)]; + + await Promise.all(fullSyncPromises); + + expect(sut["inFlightApiCalls"].refreshToken).toBeNull(); + expect(sut["inFlightApiCalls"].sync).toBeNull(); + }); + }); }); }); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 6e1a8f28443..47ac3784c33 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -58,11 +58,21 @@ import { MessageSender } from "../messaging"; import { StateProvider } from "../state"; import { CoreSyncService } from "./core-sync.service"; +import { SyncResponse } from "./sync.response"; import { SyncOptions } from "./sync.service"; export class DefaultSyncService extends CoreSyncService { syncInProgress = false; + /** The promises associated with any in-flight api calls. */ + private inFlightApiCalls: { + refreshToken: Promise | null; + sync: Promise | null; + } = { + refreshToken: null, + sync: null, + }; + constructor( private masterPasswordService: InternalMasterPasswordServiceAbstraction, accountService: AccountService, @@ -141,9 +151,24 @@ export class DefaultSyncService extends CoreSyncService { try { if (!skipTokenRefresh) { - await this.apiService.refreshIdentityToken(); + // Store the promise so multiple calls to refresh the token are not made + if (this.inFlightApiCalls.refreshToken === null) { + this.inFlightApiCalls.refreshToken = this.apiService.refreshIdentityToken(); + } + + await this.inFlightApiCalls.refreshToken; } - const response = await this.apiService.getSync(); + + // Store the promise so multiple calls to sync are not made + if (this.inFlightApiCalls.sync === null) { + this.inFlightApiCalls.sync = this.apiService.getSync(); + } else { + this.logService.debug( + "Sync: Sync network call already in progress, returning existing promise", + ); + } + + const response = await this.inFlightApiCalls.sync; await this.syncProfile(response.profile); await this.syncFolders(response.folders, response.profile.id); @@ -162,6 +187,9 @@ export class DefaultSyncService extends CoreSyncService { } else { return this.syncCompleted(false, userId); } + } finally { + this.inFlightApiCalls.refreshToken = null; + this.inFlightApiCalls.sync = null; } } From f4f659c52a0703d9f1e868185925185799f34b5e Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 27 May 2025 15:38:33 +0200 Subject: [PATCH 09/30] Increase allowed memory for chromatic (#14964) --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d27d919fb16..f63db41e1f3 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "lint:dep-ownership": "tsc --project ./scripts/tsconfig.json && node ./scripts/dist/dep-ownership.js", "docs:json": "compodoc -p ./tsconfig.json -e json -d . --disableRoutesGraph", "storybook": "ng run components:storybook", - "build-storybook": "ng run components:build-storybook", - "build-storybook:ci": "ng run components:build-storybook --webpack-stats-json", + "build-storybook": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" ng run components:build-storybook", + "build-storybook:ci": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" ng run components:build-storybook --webpack-stats-json", "test-stories": "test-storybook --url http://localhost:6006", "test-stories:watch": "test-stories --watch", "postinstall": "patch-package" From 97a591e738b3d062cdf8b75efd8b1ccef8934547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 27 May 2025 09:51:14 -0400 Subject: [PATCH 10/30] [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> --- apps/browser/src/_locales/en/messages.json | 3 + apps/desktop/src/locales/en/messages.json | 39 +- .../policies/password-generator.component.ts | 26 +- apps/web/src/locales/en/messages.json | 27 +- .../tools/extension/extension.service.spec.ts | 1 + libs/common/src/tools/log/disabled-logger.ts | 24 + .../src/tools/log/disabled-semantic-logger.ts | 22 - libs/common/src/tools/log/factory.ts | 79 +- libs/common/src/tools/log/index.ts | 2 + libs/common/src/tools/log/types.ts | 11 + libs/common/src/tools/log/util.ts | 12 + libs/common/src/tools/private-classifier.ts | 2 +- libs/common/src/tools/public-classifier.ts | 2 +- libs/common/src/tools/rx.rxjs.ts | 13 + libs/common/src/tools/rx.spec.ts | 1504 +++++++++-------- libs/common/src/tools/rx.ts | 82 +- .../user-state-subject-dependency-provider.ts | 5 + .../tools/state/user-state-subject.spec.ts | 1 + .../src/tools/state/user-state-subject.ts | 7 +- libs/common/src/tools/types.ts | 5 + libs/common/src/tools/util.ts | 16 +- .../src/components/export.component.ts | 6 +- .../src/catchall-settings.component.ts | 19 +- .../credential-generator-history.component.ts | 19 +- .../src/credential-generator.component.html | 10 +- .../src/credential-generator.component.ts | 300 ++-- .../src/forwarder-settings.component.ts | 52 +- .../src/generator-services.module.ts | 144 +- .../src/passphrase-settings.component.ts | 88 +- .../src/password-generator.component.html | 5 +- .../src/password-generator.component.ts | 142 +- .../src/password-settings.component.ts | 86 +- .../src/subaddress-settings.component.ts | 20 +- .../src/username-generator.component.html | 6 +- .../src/username-generator.component.ts | 286 ++-- .../src/username-settings.component.ts | 19 +- libs/tools/generator/components/src/util.ts | 103 +- ...redential-generator-service.abstraction.ts | 104 ++ .../generator.service.abstraction.ts | 1 + .../generator/core/src/abstractions/index.ts | 1 + .../core/src/data/default-addy-io-options.ts | 8 - .../data/default-credential-preferences.ts | 18 - .../src/data/default-duck-duck-go-options.ts | 6 - .../core/src/data/default-fastmail-options.ts | 8 - .../src/data/default-firefox-relay-options.ts | 6 - .../src/data/default-forward-email-options.ts | 7 - .../src/data/default-simple-login-options.ts | 7 - .../core/src/data/generator-types.ts | 15 - .../generator/core/src/data/generators.ts | 422 ----- libs/tools/generator/core/src/data/index.ts | 12 - .../tools/generator/core/src/data/policies.ts | 23 - .../core/src/data/username-digits.ts | 4 - .../core/src/engine/email-randomizer.spec.ts | 21 +- .../core/src/engine/email-randomizer.ts | 5 +- .../generator/core/src/engine/forwarder.ts | 4 +- .../src/engine/password-randomizer.spec.ts | 11 +- .../core/src/engine/password-randomizer.ts | 5 +- .../src/engine/username-randomizer.spec.ts | 8 +- libs/tools/generator/core/src/index.ts | 23 +- .../generator/core/src/integration/addy-io.ts | 3 +- .../core/src/integration/duck-duck-go.ts | 3 +- .../core/src/integration/fastmail.ts | 3 +- .../core/src/integration/firefox-relay.ts | 3 +- .../core/src/integration/forward-email.ts | 3 +- .../core/src/integration/simple-login.ts | 3 +- .../core/src/metadata/algorithm-metadata.ts | 10 +- .../core/src/metadata/email/catchall.spec.ts | 3 +- .../core/src/metadata/email/catchall.ts | 9 +- .../core/src/metadata/email/forwarder.ts | 17 +- .../src/metadata/email/plus-address.spec.ts | 3 +- .../core/src/metadata/email/plus-address.ts | 9 +- .../core/src/metadata/generator-metadata.ts | 3 +- .../generator/core/src/metadata/index.ts | 42 +- .../metadata/password/eff-word-list.spec.ts | 3 +- .../src/metadata/password/eff-word-list.ts | 11 +- .../metadata/password/random-password.spec.ts | 3 +- .../src/metadata/password/random-password.ts | 9 +- .../metadata/username/eff-word-list.spec.ts | 3 +- .../src/metadata/username/eff-word-list.ts | 9 +- .../tools/generator/core/src/metadata/util.ts | 8 +- .../available-algorithms-constraint.ts | 76 + .../available-algorithms-policy.spec.ts | 18 +- .../policies/available-algorithms-policy.ts | 43 +- ...ynamic-password-policy-constraints.spec.ts | 50 +- .../generator/core/src/policies/index.ts | 2 + ...phrase-generator-options-evaluator.spec.ts | 70 +- .../passphrase-least-privilege.spec.ts | 20 +- .../passphrase-policy-constraints.spec.ts | 20 +- ...ssword-generator-options-evaluator.spec.ts | 126 +- .../policies/password-least-privilege.spec.ts | 24 +- .../credential-generator-providers.ts | 14 + .../providers/credential-preferences.spec.ts | 105 ++ .../src/providers/credential-preferences.ts | 28 + .../generator-dependency-provider.ts | 12 + .../generator-metadata-provider.spec.ts | 5 +- .../generator-metadata-provider.ts | 56 +- .../generator-profile-provider.spec.ts | 1 + .../generator-profile-provider.ts | 6 +- .../generator/core/src/providers/index.ts | 4 + libs/tools/generator/core/src/rx.ts | 14 - .../credential-generator.service.spec.ts | 1050 ------------ .../services/credential-generator.service.ts | 296 ---- .../services/credential-preferences.spec.ts | 53 - .../src/services/credential-preferences.ts | 30 - ...fault-credential-generator.service.spec.ts | 356 ++++ .../default-credential-generator.service.ts | 219 +++ .../default-generator.service.spec.ts | 12 +- .../generator/core/src/services/index.ts | 2 +- .../eff-username-generator-strategy.ts | 7 +- .../passphrase-generator-strategy.spec.ts | 31 +- .../passphrase-generator-strategy.ts | 14 +- .../password-generator-strategy.spec.ts | 53 +- .../strategies/password-generator-strategy.ts | 18 +- .../core/src/types/algorithm-info.ts | 50 + .../credential-generator-configuration.ts | 153 -- .../core/src/types/credential-preference.ts | 10 + .../core/src/types/forwarder-options.ts | 65 +- .../core/src/types/generate-request.ts | 11 +- .../src/types/generated-credential.spec.ts | 20 +- .../core/src/types/generated-credential.ts | 8 +- .../core/src/types/generator-type.ts | 83 - libs/tools/generator/core/src/types/index.ts | 16 +- .../core/src/types/policy-configuration.ts | 13 - libs/tools/generator/core/src/util.spec.ts | 90 +- libs/tools/generator/core/src/util.ts | 33 +- libs/tools/generator/core/tsconfig.json | 6 +- .../history/src/generated-credential.spec.ts | 22 +- .../history/src/generated-credential.ts | 4 +- .../src/generator-history.abstraction.ts | 4 +- .../local-generator-history.service.spec.ts | 54 +- .../src/local-generator-history.service.ts | 9 +- .../legacy/src}/forwarders.ts | 16 +- ...legacy-password-generation.service.spec.ts | 89 +- ...legacy-username-generation.service.spec.ts | 103 +- .../src/legacy-username-generation.service.ts | 19 +- .../generator-navigation-evaluator.spec.ts | 4 +- .../src/generator-navigation-evaluator.ts | 4 +- .../src/generator-navigation-policy.ts | 4 +- .../navigation/src/generator-navigation.ts | 7 +- .../options/send-options.component.ts | 6 +- 140 files changed, 3720 insertions(+), 4085 deletions(-) create mode 100644 libs/common/src/tools/log/disabled-logger.ts delete mode 100644 libs/common/src/tools/log/disabled-semantic-logger.ts create mode 100644 libs/common/src/tools/log/types.ts create mode 100644 libs/common/src/tools/log/util.ts create mode 100644 libs/common/src/tools/rx.rxjs.ts create mode 100644 libs/tools/generator/core/src/abstractions/credential-generator-service.abstraction.ts delete mode 100644 libs/tools/generator/core/src/data/default-addy-io-options.ts delete mode 100644 libs/tools/generator/core/src/data/default-credential-preferences.ts delete mode 100644 libs/tools/generator/core/src/data/default-duck-duck-go-options.ts delete mode 100644 libs/tools/generator/core/src/data/default-fastmail-options.ts delete mode 100644 libs/tools/generator/core/src/data/default-firefox-relay-options.ts delete mode 100644 libs/tools/generator/core/src/data/default-forward-email-options.ts delete mode 100644 libs/tools/generator/core/src/data/default-simple-login-options.ts delete mode 100644 libs/tools/generator/core/src/data/generator-types.ts delete mode 100644 libs/tools/generator/core/src/data/generators.ts delete mode 100644 libs/tools/generator/core/src/data/policies.ts delete mode 100644 libs/tools/generator/core/src/data/username-digits.ts create mode 100644 libs/tools/generator/core/src/policies/available-algorithms-constraint.ts create mode 100644 libs/tools/generator/core/src/providers/credential-generator-providers.ts create mode 100644 libs/tools/generator/core/src/providers/credential-preferences.spec.ts create mode 100644 libs/tools/generator/core/src/providers/credential-preferences.ts create mode 100644 libs/tools/generator/core/src/providers/generator-dependency-provider.ts rename libs/tools/generator/core/src/{services => providers}/generator-metadata-provider.spec.ts (98%) rename libs/tools/generator/core/src/{services => providers}/generator-metadata-provider.ts (87%) rename libs/tools/generator/core/src/{services => providers}/generator-profile-provider.spec.ts (99%) rename libs/tools/generator/core/src/{services => providers}/generator-profile-provider.ts (94%) create mode 100644 libs/tools/generator/core/src/providers/index.ts delete mode 100644 libs/tools/generator/core/src/services/credential-generator.service.spec.ts delete mode 100644 libs/tools/generator/core/src/services/credential-generator.service.ts delete mode 100644 libs/tools/generator/core/src/services/credential-preferences.spec.ts delete mode 100644 libs/tools/generator/core/src/services/credential-preferences.ts create mode 100644 libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts create mode 100644 libs/tools/generator/core/src/services/default-credential-generator.service.ts create mode 100644 libs/tools/generator/core/src/types/algorithm-info.ts delete mode 100644 libs/tools/generator/core/src/types/credential-generator-configuration.ts create mode 100644 libs/tools/generator/core/src/types/credential-preference.ts delete mode 100644 libs/tools/generator/core/src/types/generator-type.ts rename libs/tools/generator/{core/src/data => extensions/legacy/src}/forwarders.ts (73%) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 4775d1f7af0..feb5a7706f3 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2204,6 +2204,9 @@ "useThisPassword": { "message": "Use this password" }, + "useThisPassphrase": { + "message": "Use this passphrase" + }, "useThisUsername": { "message": "Use this username" }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 631f7d571f6..9d668d464ae 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -351,12 +351,6 @@ "other": { "message": "Other" }, - "generatePassword": { - "message": "Generate password" - }, - "generatePassphrase": { - "message": "Generate passphrase" - }, "type": { "message": "Type" }, @@ -2633,6 +2627,24 @@ "usernameGenerator": { "message": "Username generator" }, + "generatePassword": { + "message": "Generate password" + }, + "generatePassphrase": { + "message": "Generate passphrase" + }, + "passwordGenerated": { + "message": "Password generated" + }, + "passphraseGenerated": { + "message": "Passphrase generated" + }, + "usernameGenerated": { + "message": "Username generated" + }, + "emailGenerated": { + "message": "Email generated" + }, "spinboxBoundariesHint": { "message": "Value must be between $MIN$ and $MAX$.", "description": "Explains spin box minimum and maximum values to the user", @@ -2686,6 +2698,15 @@ "useThisEmail": { "message": "Use this email" }, + "useThisPassword": { + "message": "Use this password" + }, + "useThisPassphrase": { + "message": "Use this passphrase" + }, + "useThisUsername": { + "message": "Use this username" + }, "random": { "message": "Random" }, @@ -3051,12 +3072,6 @@ "weakAndBreachedMasterPasswordDesc": { "message": "Weak password identified and found in a data breach. Use a strong and unique password to protect your account. Are you sure you want to use this password?" }, - "useThisPassword": { - "message": "Use this password" - }, - "useThisUsername": { - "message": "Use this username" - }, "checkForBreaches": { "message": "Check known data breaches for this password" }, diff --git a/apps/web/src/app/admin-console/organizations/policies/password-generator.component.ts b/apps/web/src/app/admin-console/organizations/policies/password-generator.component.ts index f11b14aea38..26f87f333eb 100644 --- a/apps/web/src/app/admin-console/organizations/policies/password-generator.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/password-generator.component.ts @@ -7,7 +7,7 @@ import { BehaviorSubject, map } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Generators } from "@bitwarden/generator-core"; +import { BuiltIn, Profile } from "@bitwarden/generator-core"; import { BasePolicy, BasePolicyComponent } from "./base-policy.component"; @@ -26,14 +26,22 @@ export class PasswordGeneratorPolicy extends BasePolicy { export class PasswordGeneratorPolicyComponent extends BasePolicyComponent { // these properties forward the application default settings to the UI // for HTML attribute bindings - protected readonly minLengthMin = Generators.password.settings.constraints.length.min; - protected readonly minLengthMax = Generators.password.settings.constraints.length.max; - protected readonly minNumbersMin = Generators.password.settings.constraints.minNumber.min; - protected readonly minNumbersMax = Generators.password.settings.constraints.minNumber.max; - protected readonly minSpecialMin = Generators.password.settings.constraints.minSpecial.min; - protected readonly minSpecialMax = Generators.password.settings.constraints.minSpecial.max; - protected readonly minNumberWordsMin = Generators.passphrase.settings.constraints.numWords.min; - protected readonly minNumberWordsMax = Generators.passphrase.settings.constraints.numWords.max; + protected readonly minLengthMin = + BuiltIn.password.profiles[Profile.account].constraints.default.length.min; + protected readonly minLengthMax = + BuiltIn.password.profiles[Profile.account].constraints.default.length.max; + protected readonly minNumbersMin = + BuiltIn.password.profiles[Profile.account].constraints.default.minNumber.min; + protected readonly minNumbersMax = + BuiltIn.password.profiles[Profile.account].constraints.default.minNumber.max; + protected readonly minSpecialMin = + BuiltIn.password.profiles[Profile.account].constraints.default.minSpecial.min; + protected readonly minSpecialMax = + BuiltIn.password.profiles[Profile.account].constraints.default.minSpecial.max; + protected readonly minNumberWordsMin = + BuiltIn.passphrase.profiles[Profile.account].constraints.default.numWords.min; + protected readonly minNumberWordsMax = + BuiltIn.passphrase.profiles[Profile.account].constraints.default.numWords.max; data = this.formBuilder.group({ overridePasswordType: [null], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3c17baabbb9..a170612fab2 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -547,12 +547,6 @@ "message": "Toggle collapse", "description": "Toggling an expand/collapse state." }, - "generatePassword": { - "message": "Generate password" - }, - "generatePassphrase": { - "message": "Generate passphrase" - }, "checkPassword": { "message": "Check if password has been exposed." }, @@ -6825,6 +6819,24 @@ "generateEmail": { "message": "Generate email" }, + "generatePassword": { + "message": "Generate password" + }, + "generatePassphrase": { + "message": "Generate passphrase" + }, + "passwordGenerated": { + "message": "Password generated" + }, + "passphraseGenerated": { + "message": "Passphrase generated" + }, + "usernameGenerated": { + "message": "Username generated" + }, + "emailGenerated": { + "message": "Email generated" + }, "spinboxBoundariesHint": { "message": "Value must be between $MIN$ and $MAX$.", "description": "Explains spin box minimum and maximum values to the user", @@ -6888,6 +6900,9 @@ "useThisPassword": { "message": "Use this password" }, + "useThisPassphrase": { + "message": "Use this passphrase" + }, "useThisUsername": { "message": "Use this username" }, diff --git a/libs/common/src/tools/extension/extension.service.spec.ts b/libs/common/src/tools/extension/extension.service.spec.ts index dad5684b523..9959488feca 100644 --- a/libs/common/src/tools/extension/extension.service.spec.ts +++ b/libs/common/src/tools/extension/extension.service.spec.ts @@ -60,6 +60,7 @@ const SomeProvider = { } as LegacyEncryptorProvider, state: SomeStateProvider, log: disabledSemanticLoggerProvider, + now: Date.now, } as UserStateSubjectDependencyProvider; const SomeExtension: ExtensionMetadata = { diff --git a/libs/common/src/tools/log/disabled-logger.ts b/libs/common/src/tools/log/disabled-logger.ts new file mode 100644 index 00000000000..53feb0c8b39 --- /dev/null +++ b/libs/common/src/tools/log/disabled-logger.ts @@ -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(_content: Jsonify, _message?: string): void {}, + + info(_content: Jsonify, _message?: string): void {}, + + warn(_content: Jsonify, _message?: string): void {}, + + error(_content: Jsonify, _message?: string): void {}, + + panic(content: Jsonify, message?: string): never { + if (typeof content === "string" && !message) { + throw new Error(content); + } else { + throw new Error(message); + } + }, +}); diff --git a/libs/common/src/tools/log/disabled-semantic-logger.ts b/libs/common/src/tools/log/disabled-semantic-logger.ts deleted file mode 100644 index 21ea48bbe51..00000000000 --- a/libs/common/src/tools/log/disabled-semantic-logger.ts +++ /dev/null @@ -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(_content: Jsonify, _message?: string): void {} - - info(_content: Jsonify, _message?: string): void {} - - warn(_content: Jsonify, _message?: string): void {} - - error(_content: Jsonify, _message?: string): void {} - - panic(content: Jsonify, message?: string): never { - if (typeof content === "string" && !message) { - throw new Error(content); - } else { - throw new Error(message); - } - } -} diff --git a/libs/common/src/tools/log/factory.ts b/libs/common/src/tools/log/factory.ts index f8abc4d2240..5f0f2d74e91 100644 --- a/libs/common/src/tools/log/factory.ts +++ b/libs/common/src/tools/log/factory.ts @@ -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: Jsonify) => 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: Jsonify) => SemanticLogger export function disabledSemanticLoggerProvider( _context: Jsonify, ): 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( - logger: LogService, - context: Jsonify, -): SemanticLogger { - return new DefaultSemanticLogger(logger, context); +export function consoleSemanticLoggerProvider(logService: LogService): LogProvider { + function provider(context: Jsonify) { + 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: Jsonify) { + 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( enable: boolean, - logger: LogService, + logService: LogService, context: Jsonify, ) { 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; } } diff --git a/libs/common/src/tools/log/index.ts b/libs/common/src/tools/log/index.ts index 22444d23e27..1c93a6ce63f 100644 --- a/libs/common/src/tools/log/index.ts +++ b/libs/common/src/tools/log/index.ts @@ -1,2 +1,4 @@ export * from "./factory"; +export * from "./disabled-logger"; +export { LogProvider } from "./types"; export { SemanticLogger } from "./semantic-logger.abstraction"; diff --git a/libs/common/src/tools/log/types.ts b/libs/common/src/tools/log/types.ts new file mode 100644 index 00000000000..ca887af4225 --- /dev/null +++ b/libs/common/src/tools/log/types.ts @@ -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: Jsonify) => SemanticLogger; diff --git a/libs/common/src/tools/log/util.ts b/libs/common/src/tools/log/util.ts new file mode 100644 index 00000000000..cf1c39230e1 --- /dev/null +++ b/libs/common/src/tools/log/util.ts @@ -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 🦟", + }); +} diff --git a/libs/common/src/tools/private-classifier.ts b/libs/common/src/tools/private-classifier.ts index e2406d314c0..58244ae9906 100644 --- a/libs/common/src/tools/private-classifier.ts +++ b/libs/common/src/tools/private-classifier.ts @@ -17,7 +17,7 @@ export class PrivateClassifier implements Classifier; - return { disclosed: {}, secret }; + return { disclosed: null, secret }; } declassify(_disclosed: Jsonify>, secret: Jsonify) { diff --git a/libs/common/src/tools/public-classifier.ts b/libs/common/src/tools/public-classifier.ts index 136bee555ac..e036ebd1c42 100644 --- a/libs/common/src/tools/public-classifier.ts +++ b/libs/common/src/tools/public-classifier.ts @@ -16,7 +16,7 @@ export class PublicClassifier implements Classifier; - return { disclosed, secret: "" }; + return { disclosed, secret: null }; } declassify(disclosed: Jsonify, _secret: Jsonify>) { diff --git a/libs/common/src/tools/rx.rxjs.ts b/libs/common/src/tools/rx.rxjs.ts new file mode 100644 index 00000000000..7c11d658f19 --- /dev/null +++ b/libs/common/src/tools/rx.rxjs.ts @@ -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, PromiseLike]): 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 = { + [K in keyof T]: Observable; +}; diff --git a/libs/common/src/tools/rx.spec.ts b/libs/common/src/tools/rx.spec.ts index ee1de1c9118..5177dcaa2a5 100644 --- a/libs/common/src/tools/rx.spec.ts +++ b/libs/common/src/tools/rx.spec.ts @@ -3,7 +3,7 @@ * @jest-environment ../../../../shared/test.environment.ts */ // @ts-strict-ignore this file explicitly tests what happens when types are ignored -import { of, firstValueFrom, Subject, tap, EmptyError } from "rxjs"; +import { of, firstValueFrom, Subject, tap, EmptyError, BehaviorSubject } from "rxjs"; import { awaitAsync, trackEmissions } from "../../spec"; @@ -16,733 +16,825 @@ import { reduceCollection, withLatestReady, pin, + memoizedMap, } from "./rx"; -describe("errorOnChange", () => { - it("emits a single value when the input emits only once", async () => { - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); +describe("tools rx utilites", () => { + describe("errorOnChange", () => { + it("emits a single value when the input emits only once", async () => { + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); - source$.next(1); + source$.next(1); - expect(results).toEqual([1]); + expect(results).toEqual([1]); + }); + + it("emits when the input emits", async () => { + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); + + source$.next(1); + source$.next(1); + + expect(results).toEqual([1, 1]); + }); + + it("errors when the input errors", async () => { + const source$ = new Subject(); + const expected = {}; + let error: any = null; + source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); + + source$.error(expected); + + expect(error).toBe(expected); + }); + + it("completes when the input completes", async () => { + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(errorOnChange()).subscribe({ complete: () => (complete = true) }); + + source$.complete(); + + expect(complete).toBe(true); + }); + + it("errors when the input changes", async () => { + const source$ = new Subject(); + let error: any = null; + source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); + + source$.next(1); + source$.next(2); + + expect(error).toEqual({ expectedValue: 1, actualValue: 2 }); + }); + + it("emits when the extracted value remains constant", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + const results: Foo[] = []; + source$.pipe(errorOnChange((v) => v.foo)).subscribe((v) => results.push(v)); + + source$.next({ foo: "bar" }); + source$.next({ foo: "bar" }); + + expect(results).toEqual([{ foo: "bar" }, { foo: "bar" }]); + }); + + it("errors when an extracted value changes", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + let error: any = null; + source$.pipe(errorOnChange((v) => v.foo)).subscribe({ error: (v: unknown) => (error = v) }); + + source$.next({ foo: "bar" }); + source$.next({ foo: "baz" }); + + expect(error).toEqual({ expectedValue: "bar", actualValue: "baz" }); + }); + + it("constructs an error when the extracted value changes", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + let error: any = null; + source$ + .pipe( + errorOnChange( + (v) => v.foo, + (expected, actual) => ({ expected, actual }), + ), + ) + .subscribe({ error: (v: unknown) => (error = v) }); + + source$.next({ foo: "bar" }); + source$.next({ foo: "baz" }); + + expect(error).toEqual({ expected: "bar", actual: "baz" }); + }); }); - it("emits when the input emits", async () => { - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); + describe("reduceCollection", () => { + it.each([[null], [undefined], [[]]])( + "should return the default value when the collection is %p", + async (value: number[]) => { + const reduce = (acc: number, value: number) => acc + value; + const source$ = of(value); - source$.next(1); - source$.next(1); + const result$ = source$.pipe(reduceCollection(reduce, 100)); + const result = await firstValueFrom(result$); - expect(results).toEqual([1, 1]); - }); + expect(result).toEqual(100); + }, + ); - it("errors when the input errors", async () => { - const source$ = new Subject(); - const expected = {}; - let error: any = null; - source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); - - source$.error(expected); - - expect(error).toBe(expected); - }); - - it("completes when the input completes", async () => { - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(errorOnChange()).subscribe({ complete: () => (complete = true) }); - - source$.complete(); - - expect(complete).toBe(true); - }); - - it("errors when the input changes", async () => { - const source$ = new Subject(); - let error: any = null; - source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); - - source$.next(1); - source$.next(2); - - expect(error).toEqual({ expectedValue: 1, actualValue: 2 }); - }); - - it("emits when the extracted value remains constant", async () => { - type Foo = { foo: string }; - const source$ = new Subject(); - const results: Foo[] = []; - source$.pipe(errorOnChange((v) => v.foo)).subscribe((v) => results.push(v)); - - source$.next({ foo: "bar" }); - source$.next({ foo: "bar" }); - - expect(results).toEqual([{ foo: "bar" }, { foo: "bar" }]); - }); - - it("errors when an extracted value changes", async () => { - type Foo = { foo: string }; - const source$ = new Subject(); - let error: any = null; - source$.pipe(errorOnChange((v) => v.foo)).subscribe({ error: (v: unknown) => (error = v) }); - - source$.next({ foo: "bar" }); - source$.next({ foo: "baz" }); - - expect(error).toEqual({ expectedValue: "bar", actualValue: "baz" }); - }); - - it("constructs an error when the extracted value changes", async () => { - type Foo = { foo: string }; - const source$ = new Subject(); - let error: any = null; - source$ - .pipe( - errorOnChange( - (v) => v.foo, - (expected, actual) => ({ expected, actual }), - ), - ) - .subscribe({ error: (v: unknown) => (error = v) }); - - source$.next({ foo: "bar" }); - source$.next({ foo: "baz" }); - - expect(error).toEqual({ expected: "bar", actual: "baz" }); - }); -}); - -describe("reduceCollection", () => { - it.each([[null], [undefined], [[]]])( - "should return the default value when the collection is %p", - async (value: number[]) => { + it("should reduce the collection to a single value", async () => { const reduce = (acc: number, value: number) => acc + value; - const source$ = of(value); + const source$ = of([1, 2, 3]); - const result$ = source$.pipe(reduceCollection(reduce, 100)); + const result$ = source$.pipe(reduceCollection(reduce, 0)); const result = await firstValueFrom(result$); - expect(result).toEqual(100); - }, - ); - - it("should reduce the collection to a single value", async () => { - const reduce = (acc: number, value: number) => acc + value; - const source$ = of([1, 2, 3]); - - const result$ = source$.pipe(reduceCollection(reduce, 0)); - const result = await firstValueFrom(result$); - - expect(result).toEqual(6); - }); -}); - -describe("distinctIfShallowMatch", () => { - it("emits a single value", async () => { - const source$ = of({ foo: true }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true }]); - }); - - it("emits different values", async () => { - const source$ = of({ foo: true }, { foo: false }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true }, { foo: false }]); - }); - - it("emits new keys", async () => { - const source$ = of({ foo: true }, { foo: true, bar: true }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true }, { foo: true, bar: true }]); - }); - - it("suppresses identical values", async () => { - const source$ = of({ foo: true }, { foo: true }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true }]); - }); - - it("suppresses removed keys", async () => { - const source$ = of({ foo: true, bar: true }, { foo: true }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true, bar: true }]); - }); -}); - -describe("anyComplete", () => { - it("emits true when its input completes", () => { - const input$ = new Subject(); - - const emissions: boolean[] = []; - anyComplete(input$).subscribe((e) => emissions.push(e)); - input$.complete(); - - expect(emissions).toEqual([true]); - }); - - it("completes when its input is already complete", () => { - const input = new Subject(); - input.complete(); - - let completed = false; - anyComplete(input).subscribe({ complete: () => (completed = true) }); - - expect(completed).toBe(true); - }); - - it("completes when any input completes", () => { - const input$ = new Subject(); - const completing$ = new Subject(); - - let completed = false; - anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) }); - completing$.complete(); - - expect(completed).toBe(true); - }); - - it("ignores emissions", () => { - const input$ = new Subject(); - - const emissions: boolean[] = []; - anyComplete(input$).subscribe((e) => emissions.push(e)); - input$.next(1); - input$.next(2); - input$.complete(); - - expect(emissions).toEqual([true]); - }); - - it("forwards errors", () => { - const input$ = new Subject(); - const expected = { some: "error" }; - - let error = null; - anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) }); - input$.error(expected); - - expect(error).toEqual(expected); - }); -}); - -describe("ready", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: ready$ should be cold - const ready$ = source$.pipe(ready(watch$)); - expect(connected).toBe(false); - - ready$.subscribe(); - - expect(connected).toBe(true); - }); - - it("suppresses source emissions until its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - watch$.next(); - - expect(results).toEqual([1]); - }); - - it("suppresses source emissions until all watches emit", () => { - const watchA$ = new Subject(); - const watchB$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready([watchA$, watchB$])); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // preconditions: no emissions - source$.next(1); - expect(results).toEqual([]); - watchA$.next(); - expect(results).toEqual([]); - - watchB$.next(); - - expect(results).toEqual([1]); - }); - - it("emits the last source emission when its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - source$.next(2); - watch$.next(); - - expect(results).toEqual([2]); - }); - - it("emits all source emissions after its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - source$.next(2); - - expect(results).toEqual([1, 2]); - }); - - it("ignores repeated watch emissions", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - watch$.next(); - source$.next(2); - watch$.next(); - - expect(results).toEqual([1, 2]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - let completed = false; - ready$.subscribe({ complete: () => (completed = true) }); - - source$.complete(); - - expect(completed).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch completes before emitting", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.complete(); - - expect(error).toBeInstanceOf(EmptyError); - }); -}); - -describe("withLatestReady", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: ready$ should be cold - const ready$ = source$.pipe(withLatestReady(watch$)); - expect(connected).toBe(false); - - ready$.subscribe(); - - expect(connected).toBe(true); - }); - - it("suppresses source emissions until its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const results: [number, string][] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - watch$.next("watch"); - - expect(results).toEqual([[1, "watch"]]); - }); - - it("emits the last source emission when its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const results: [number, string][] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - source$.next(2); - watch$.next("watch"); - - expect(results).toEqual([[2, "watch"]]); - }); - - it("emits all source emissions after its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const results: [number, string][] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next("watch"); - source$.next(1); - source$.next(2); - - expect(results).toEqual([ - [1, "watch"], - [2, "watch"], - ]); - }); - - it("appends the latest watch emission", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const results: [number, string][] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next("ignored"); - watch$.next("watch"); - source$.next(1); - watch$.next("ignored"); - watch$.next("watch"); - source$.next(2); - - expect(results).toEqual([ - [1, "watch"], - [2, "watch"], - ]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - let completed = false; - ready$.subscribe({ complete: () => (completed = true) }); - - source$.complete(); - - expect(completed).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch completes before emitting", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.complete(); - - expect(error).toBeInstanceOf(EmptyError); - }); -}); - -describe("on", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: on$ should be cold - const on$ = source$.pipe(on(watch$)); - expect(connected).toBeFalsy(); - - on$.subscribe(); - - expect(connected).toBeTruthy(); - }); - - it("suppresses source emissions until `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - // precondition: on$ should be cold - source$.next(1); - expect(results).toEqual([]); - - watch$.next(); - - expect(results).toEqual([1]); - }); - - it("repeats source emissions when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - source$.next(1); - - watch$.next(); - watch$.next(); - - expect(results).toEqual([1, 1]); - }); - - it("updates source emissions when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - source$.next(1); - watch$.next(); - source$.next(2); - watch$.next(); - - expect(results).toEqual([1, 2]); - }); - - it("emits a value when `on` emits before the source is ready", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - - expect(results).toEqual([1]); - }); - - it("ignores repeated `on` emissions before the source is ready", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - watch$.next(); - watch$.next(); - source$.next(1); - - expect(results).toEqual([1]); - }); - - it("emits only the latest source emission when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - source$.next(1); - - watch$.next(); - - source$.next(2); - source$.next(3); - watch$.next(); - - expect(results).toEqual([1, 3]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); - - source$.complete(); - - expect(complete).toBeTruthy(); - }); - - it("completes when its watch completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); - - watch$.complete(); - - expect(complete).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const expected = { some: "error" }; - let error = null; - source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const expected = { some: "error" }; - let error = null; - source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); -}); - -describe("pin", () => { - it("emits the first value", async () => { - const input = new Subject(); - const result: unknown[] = []; - - input.pipe(pin()).subscribe((v) => result.push(v)); - input.next(1); - - expect(result).toEqual([1]); - }); - - it("filters repeated emissions", async () => { - const input = new Subject(); - const result: unknown[] = []; - - input.pipe(pin({ distinct: (p, c) => p == c })).subscribe((v) => result.push(v)); - input.next(1); - input.next(1); - - expect(result).toEqual([1]); - }); - - it("errors if multiple emissions occur", async () => { - const input = new Subject(); - let error: any = null!; - - input.pipe(pin()).subscribe({ - error: (e: unknown) => { - error = e; - }, + expect(result).toEqual(6); }); - input.next(1); - input.next(1); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatch(/^unknown/); }); - it("names the pinned observables if multiple emissions occur", async () => { - const input = new Subject(); - let error: any = null!; + describe("distinctIfShallowMatch", () => { + it("emits a single value", async () => { + const source$ = of({ foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); - input.pipe(pin({ name: () => "example" })).subscribe({ - error: (e: unknown) => { - error = e; - }, + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }]); }); - input.next(1); - input.next(1); - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatch(/^example/); + it("emits different values", async () => { + const source$ = of({ foo: true }, { foo: false }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }, { foo: false }]); + }); + + it("emits new keys", async () => { + const source$ = of({ foo: true }, { foo: true, bar: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }, { foo: true, bar: true }]); + }); + + it("suppresses identical values", async () => { + const source$ = of({ foo: true }, { foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }]); + }); + + it("suppresses removed keys", async () => { + const source$ = of({ foo: true, bar: true }, { foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true, bar: true }]); + }); }); - it("errors if indistinct emissions occur", async () => { - const input = new Subject(); - let error: any = null!; + describe("anyComplete", () => { + it("emits true when its input completes", () => { + const input$ = new Subject(); - input - .pipe(pin({ distinct: (p, c) => p == c })) - .subscribe({ error: (e: unknown) => (error = e) }); - input.next(1); - input.next(2); + const emissions: boolean[] = []; + anyComplete(input$).subscribe((e) => emissions.push(e)); + input$.complete(); - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatch(/^unknown/); + expect(emissions).toEqual([true]); + }); + + it("completes when its input is already complete", () => { + const input = new Subject(); + input.complete(); + + let completed = false; + anyComplete(input).subscribe({ complete: () => (completed = true) }); + + expect(completed).toBe(true); + }); + + it("completes when any input completes", () => { + const input$ = new Subject(); + const completing$ = new Subject(); + + let completed = false; + anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) }); + completing$.complete(); + + expect(completed).toBe(true); + }); + + it("ignores emissions", () => { + const input$ = new Subject(); + + const emissions: boolean[] = []; + anyComplete(input$).subscribe((e) => emissions.push(e)); + input$.next(1); + input$.next(2); + input$.complete(); + + expect(emissions).toEqual([true]); + }); + + it("forwards errors", () => { + const input$ = new Subject(); + const expected = { some: "error" }; + + let error = null; + anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) }); + input$.error(expected); + + expect(error).toEqual(expected); + }); + }); + + describe("ready", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: ready$ should be cold + const ready$ = source$.pipe(ready(watch$)); + expect(connected).toBe(false); + + ready$.subscribe(); + + expect(connected).toBe(true); + }); + + it("suppresses source emissions until its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + watch$.next(); + + expect(results).toEqual([1]); + }); + + it("suppresses source emissions until all watches emit", () => { + const watchA$ = new Subject(); + const watchB$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready([watchA$, watchB$])); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // preconditions: no emissions + source$.next(1); + expect(results).toEqual([]); + watchA$.next(); + expect(results).toEqual([]); + + watchB$.next(); + + expect(results).toEqual([1]); + }); + + it("emits the last source emission when its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + source$.next(2); + watch$.next(); + + expect(results).toEqual([2]); + }); + + it("emits all source emissions after its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + source$.next(2); + + expect(results).toEqual([1, 2]); + }); + + it("ignores repeated watch emissions", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + watch$.next(); + source$.next(2); + watch$.next(); + + expect(results).toEqual([1, 2]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + let completed = false; + ready$.subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch completes before emitting", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.complete(); + + expect(error).toBeInstanceOf(EmptyError); + }); + }); + + describe("withLatestReady", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: ready$ should be cold + const ready$ = source$.pipe(withLatestReady(watch$)); + expect(connected).toBe(false); + + ready$.subscribe(); + + expect(connected).toBe(true); + }); + + it("suppresses source emissions until its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + watch$.next("watch"); + + expect(results).toEqual([[1, "watch"]]); + }); + + it("emits the last source emission when its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + source$.next(2); + watch$.next("watch"); + + expect(results).toEqual([[2, "watch"]]); + }); + + it("emits all source emissions after its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next("watch"); + source$.next(1); + source$.next(2); + + expect(results).toEqual([ + [1, "watch"], + [2, "watch"], + ]); + }); + + it("appends the latest watch emission", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next("ignored"); + watch$.next("watch"); + source$.next(1); + watch$.next("ignored"); + watch$.next("watch"); + source$.next(2); + + expect(results).toEqual([ + [1, "watch"], + [2, "watch"], + ]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + let completed = false; + ready$.subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch completes before emitting", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.complete(); + + expect(error).toBeInstanceOf(EmptyError); + }); + }); + + describe("on", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: on$ should be cold + const on$ = source$.pipe(on(watch$)); + expect(connected).toBeFalsy(); + + on$.subscribe(); + + expect(connected).toBeTruthy(); + }); + + it("suppresses source emissions until `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + // precondition: on$ should be cold + source$.next(1); + expect(results).toEqual([]); + + watch$.next(); + + expect(results).toEqual([1]); + }); + + it("repeats source emissions when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + source$.next(1); + + watch$.next(); + watch$.next(); + + expect(results).toEqual([1, 1]); + }); + + it("updates source emissions when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + source$.next(1); + watch$.next(); + source$.next(2); + watch$.next(); + + expect(results).toEqual([1, 2]); + }); + + it("emits a value when `on` emits before the source is ready", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("ignores repeated `on` emissions before the source is ready", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + watch$.next(); + watch$.next(); + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("emits only the latest source emission when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + source$.next(1); + + watch$.next(); + + source$.next(2); + source$.next(3); + watch$.next(); + + expect(results).toEqual([1, 3]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); + + source$.complete(); + + expect(complete).toBeTruthy(); + }); + + it("completes when its watch completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); + + watch$.complete(); + + expect(complete).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const expected = { some: "error" }; + let error = null; + source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const expected = { some: "error" }; + let error = null; + source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + }); + + describe("pin", () => { + it("emits the first value", async () => { + const input = new Subject(); + const result: unknown[] = []; + + input.pipe(pin()).subscribe((v) => result.push(v)); + input.next(1); + + expect(result).toEqual([1]); + }); + + it("filters repeated emissions", async () => { + const input = new Subject(); + const result: unknown[] = []; + + input.pipe(pin({ distinct: (p, c) => p == c })).subscribe((v) => result.push(v)); + input.next(1); + input.next(1); + + expect(result).toEqual([1]); + }); + + it("errors if multiple emissions occur", async () => { + const input = new Subject(); + let error: any = null!; + + input.pipe(pin()).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + input.next(1); + input.next(1); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/^unknown/); + }); + + it("names the pinned observables if multiple emissions occur", async () => { + const input = new Subject(); + let error: any = null!; + + input.pipe(pin({ name: () => "example" })).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + input.next(1); + input.next(1); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/^example/); + }); + + it("errors if indistinct emissions occur", async () => { + const input = new Subject(); + let error: any = null!; + + input + .pipe(pin({ distinct: (p, c) => p == c })) + .subscribe({ error: (e: unknown) => (error = e) }); + input.next(1); + input.next(2); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/^unknown/); + }); + }); + + describe("memoizedMap", () => { + it("maps a value", () => { + const source$ = new Subject(); + const result$ = new BehaviorSubject({}); + const expectedResult = {}; + source$.pipe(memoizedMap(() => expectedResult)).subscribe(result$); + + source$.next("foo"); + + expect(result$.value).toEqual(expectedResult); + }); + + it("caches a mapped result", () => { + const source$ = new Subject(); + const result$ = new BehaviorSubject({}); + const map = jest.fn(() => ({})); + source$.pipe(memoizedMap(map)).subscribe(result$); + + source$.next("foo"); + source$.next("foo"); + + expect(map).toHaveBeenCalledTimes(1); + }); + + it("caches the last mapped result", () => { + const source$ = new Subject(); + const result$ = new BehaviorSubject({}); + const map = jest.fn(() => ({})); + source$.pipe(memoizedMap(map)).subscribe(result$); + + source$.next("foo"); + source$.next("foo"); + source$.next("bar"); + source$.next("foo"); + + expect(map).toHaveBeenCalledTimes(3); + }); + + it("caches multiple mapped results", () => { + const source$ = new Subject(); + const result$ = new BehaviorSubject({}); + const map = jest.fn(() => ({})); + source$.pipe(memoizedMap(map, { size: 2 })).subscribe(result$); + + source$.next("foo"); + source$.next("bar"); + source$.next("foo"); + source$.next("bar"); + + expect(map).toHaveBeenCalledTimes(2); + }); + + it("caches a result by key", () => { + const source$ = new Subject<{ key: string }>(); + const result$ = new BehaviorSubject({}); + const map = jest.fn(() => ({})); + source$.pipe(memoizedMap(map, { key: (s) => s.key })).subscribe(result$); + + // the messages are not equal; the keys are + source$.next({ key: "foo" }); + source$.next({ key: "foo" }); + source$.next({ key: "bar" }); + source$.next({ key: "bar" }); + + expect(map).toHaveBeenCalledTimes(2); + }); + }); + + it("errors", () => { + const source$ = new Subject(); + let error: unknown = null; + source$.pipe(memoizedMap(() => {})).subscribe({ error: (e: unknown) => (error = e) }); + const expectedError = {}; + + source$.error(expectedError); + + expect(error).toEqual(expectedError); + }); + + it("completes", () => { + const source$ = new Subject(); + let completed = false; + source$.pipe(memoizedMap(() => {})).subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toEqual(true); }); }); diff --git a/libs/common/src/tools/rx.ts b/libs/common/src/tools/rx.ts index ea397135581..4c9e547d151 100644 --- a/libs/common/src/tools/rx.ts +++ b/libs/common/src/tools/rx.ts @@ -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(watch$: Observable | Observable[]) { ); } -export function withLatestReady( - watch$: Observable, -): OperatorFunction { +export function withLatestReady( + ...watches$: [...ObservableTuple] +): OperatorFunction> { return connect((source$) => { // these subscriptions are safe because `source$` connects only after there // is an external subscriber. const source = new ReplaySubject(1); source$.subscribe(source); - const watch = new ReplaySubject(1); - watch$.subscribe(watch); + + const watches = watches$.map((w) => { + const watch$ = new ReplaySubject(1); + w.subscribe(watch$); + return watch$; + }) as [...ObservableTuple]; // `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>; }); } @@ -238,3 +247,54 @@ export function pin(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>( + mapResult: (source: Source) => Result, + options?: { size?: number; key?: (source: Source) => unknown }, +): OperatorFunction { + 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, Source | null], + ), + + // encapsulate cache + map(([, result]) => result), + + // preserve `NonNullable` constraint on `Result` + filter((result): result is Result => !!result), + ); +} diff --git a/libs/common/src/tools/state/user-state-subject-dependency-provider.ts b/libs/common/src/tools/state/user-state-subject-dependency-provider.ts index 2763aac4830..89b6d7e7270 100644 --- a/libs/common/src/tools/state/user-state-subject-dependency-provider.ts +++ b/libs/common/src/tools/state/user-state-subject-dependency-provider.ts @@ -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: Jsonify) => 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; } diff --git a/libs/common/src/tools/state/user-state-subject.spec.ts b/libs/common/src/tools/state/user-state-subject.spec.ts index 20acff17877..a6d452d37fd 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -90,6 +90,7 @@ const SomeProvider = { } as LegacyEncryptorProvider, state: SomeStateProvider, log: disabledSemanticLoggerProvider, + now: () => 100, }; function fooMaxLength(maxLength: number): StateConstraints { diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index dd88ec2fb20..118b0069c84 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -477,7 +477,12 @@ export class UserStateSubject< * @returns the subscription */ subscribe(observer?: Partial> | ((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; diff --git a/libs/common/src/tools/types.ts b/libs/common/src/tools/types.ts index 83f451351c2..6123b1f1dea 100644 --- a/libs/common/src/tools/types.ts +++ b/libs/common/src/tools/types.ts @@ -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 */ diff --git a/libs/common/src/tools/util.ts b/libs/common/src/tools/util.ts index 9a3a14c1c83..0fcb5a972af 100644 --- a/libs/common/src/tools/util.ts +++ b/libs/common/src/tools/util.ts @@ -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(value: T): Readonly { 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; +} diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index 0512b56e20d..956cc611c2e 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -57,7 +57,7 @@ import { ToastService, } from "@bitwarden/components"; import { GeneratorServicesModule } from "@bitwarden/generator-components"; -import { CredentialGeneratorService, GenerateRequest, Generators } from "@bitwarden/generator-core"; +import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; import { ExportedVault, VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; import { EncryptedExportType } from "../enums/encrypted-export-type.enum"; @@ -248,7 +248,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { }), ); this.generatorService - .generate$(Generators.password, { on$: this.onGenerate$, account$ }) + .generate$({ on$: this.onGenerate$, account$ }) .pipe(takeUntil(this.destroy$)) .subscribe((generated) => { this.exportForm.patchValue({ @@ -379,7 +379,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { } generatePassword = async () => { - this.onGenerate$.next({ source: "export" }); + this.onGenerate$.next({ source: "export", type: Type.password }); }; submit = async () => { diff --git a/libs/tools/generator/components/src/catchall-settings.component.ts b/libs/tools/generator/components/src/catchall-settings.component.ts index 3bf586c5a29..a836b26f98b 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.ts +++ b/libs/tools/generator/components/src/catchall-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, EventEmitter, @@ -17,7 +15,7 @@ import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { CatchallGenerationOptions, CredentialGeneratorService, - Generators, + BuiltIn, } from "@bitwarden/generator-core"; /** Options group for catchall emails */ @@ -28,7 +26,6 @@ import { }) export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges { /** Instantiates the component - * @param accountService queries user availability * @param generatorService settings and policy logic * @param formBuilder reactive form controls */ @@ -37,24 +34,26 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges { private generatorService: CredentialGeneratorService, ) {} - /** Binds the component to a specific user's settings. + /** Binds the component to a specific user's settings.\ + * @remarks this is initialized to null but since it's a required input it'll + * never have that value in practice. */ @Input({ required: true }) - account: Account; + account!: Account; private account$ = new ReplaySubject(1); /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like * to receive live settings updates including the initial update, - * use `CredentialGeneratorService.settings$(...)` instead. + * use `CredentialGeneratorService.settings(...)` instead. */ @Output() readonly onUpdated = new EventEmitter(); /** The template's control bindings */ protected settings = this.formBuilder.group({ - catchallDomain: [Generators.catchall.settings.initial.catchallDomain], + catchallDomain: [""], }); async ngOnChanges(changes: SimpleChanges) { @@ -64,7 +63,7 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges { } async ngOnInit() { - const settings = await this.generatorService.settings(Generators.catchall, { + const settings = await this.generatorService.settings(BuiltIn.catchall, { account$: this.account$, }); @@ -79,7 +78,7 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges { this.saveSettings .pipe( withLatestFrom(this.settings.valueChanges), - map(([, settings]) => settings), + map(([, settings]) => settings as CatchallGenerationOptions), takeUntil(this.destroyed$), ) .subscribe(settings); diff --git a/libs/tools/generator/components/src/credential-generator-history.component.ts b/libs/tools/generator/components/src/credential-generator-history.component.ts index e7dadb3da71..76dfbaea867 100644 --- a/libs/tools/generator/components/src/credential-generator-history.component.ts +++ b/libs/tools/generator/components/src/credential-generator-history.component.ts @@ -6,6 +6,7 @@ import { BehaviorSubject, ReplaySubject, Subject, map, switchMap, takeUntil, tap import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SemanticLogger, @@ -19,10 +20,11 @@ import { ItemModule, NoItemsModule, } from "@bitwarden/components"; -import { CredentialGeneratorService } from "@bitwarden/generator-core"; +import { AlgorithmsByType, CredentialGeneratorService } from "@bitwarden/generator-core"; import { GeneratedCredential, GeneratorHistoryService } from "@bitwarden/generator-history"; import { GeneratorModule } from "./generator.module"; +import { translate } from "./util"; @Component({ standalone: true, @@ -45,6 +47,7 @@ export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, O constructor( private generatorService: CredentialGeneratorService, private history: GeneratorHistoryService, + private i18nService: I18nService, private logService: LogService, ) {} @@ -94,13 +97,19 @@ export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, O } protected getCopyText(credential: GeneratedCredential) { - const info = this.generatorService.algorithm(credential.category); - return info.copy; + // there isn't a way way to look up category metadata so + // bodge it by looking up algorithm metadata + const [id] = AlgorithmsByType[credential.category]; + const info = this.generatorService.algorithm(id); + return translate(info.i18nKeys.copyCredential, this.i18nService); } protected getGeneratedValueText(credential: GeneratedCredential) { - const info = this.generatorService.algorithm(credential.category); - return info.credentialType; + // there isn't a way way to look up category metadata so + // bodge it by looking up algorithm metadata + const [id] = AlgorithmsByType[credential.category]; + const info = this.generatorService.algorithm(id); + return translate(info.i18nKeys.credentialType, this.i18nService); } ngOnDestroy() { diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 24146968456..559554b7ddb 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -42,13 +42,13 @@ @@ -84,7 +84,7 @@ @@ -94,12 +94,12 @@ [forwarder]="forwarderId$ | async" /> diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 0b48b4cf0f7..78b803392df 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { LiveAnnouncer } from "@angular/cdk/a11y"; import { Component, @@ -24,15 +22,15 @@ import { map, ReplaySubject, Subject, - switchMap, takeUntil, + tap, withLatestFrom, } from "rxjs"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { SemanticLogger, disabledSemanticLoggerProvider, @@ -41,23 +39,25 @@ import { import { UserId } from "@bitwarden/common/types/guid"; import { ToastService, Option } from "@bitwarden/components"; import { - AlgorithmInfo, - CredentialAlgorithm, - CredentialCategory, + CredentialType, CredentialGeneratorService, GenerateRequest, GeneratedCredential, - Generators, - getForwarderConfiguration, - isEmailAlgorithm, - isForwarderIntegration, - isPasswordAlgorithm, + isForwarderExtensionId, isSameAlgorithm, + isEmailAlgorithm, isUsernameAlgorithm, - toCredentialGeneratorConfiguration, + isPasswordAlgorithm, + CredentialAlgorithm, + AlgorithmMetadata, + Algorithm, + AlgorithmsByType, + Type, } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; +import { translate } from "./util"; + // constants used to identify navigation selections that are not // generator algorithms const IDENTIFIER = "identifier"; @@ -84,11 +84,14 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro private ariaLive: LiveAnnouncer, ) {} + /** exports algorithm symbols to the template */ + protected readonly Algorithm = Algorithm; + /** Binds the component to a specific user's settings. When this input is not provided, * the form binds to the active user */ @Input() - account: Account | null; + account: Account | null = null; /** Send structured debug logs from the credential generator component * to the debugger console. @@ -127,7 +130,7 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro @Output() readonly onGenerated = new EventEmitter(); - protected root$ = new BehaviorSubject<{ nav: string }>({ + protected root$ = new BehaviorSubject<{ nav: string | null }>({ nav: null, }); @@ -141,11 +144,11 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro } protected username = this.formBuilder.group({ - nav: [null as string], + nav: [null as string | null], }); protected forwarder = this.formBuilder.group({ - nav: [null as string], + nav: [null as string | null], }); async ngOnInit() { @@ -154,33 +157,52 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro }); if (!this.account) { - this.account = await firstValueFrom(this.accountService.activeAccount$); - this.log.info( - { userId: this.account.id }, - "account not specified; using active account settings", - ); - this.account$.next(this.account); + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + this.log.panic("active account cannot be `null`."); + } + + this.log.info({ userId: account.id }, "account not specified; using active account settings"); + this.account$.next(account); } - this.generatorService - .algorithms$(["email", "username"], { account$: this.account$ }) + combineLatest([ + this.generatorService.algorithms$("email", { account$: this.account$ }), + this.generatorService.algorithms$("username", { account$: this.account$ }), + ]) .pipe( + map((algorithms) => algorithms.flat()), map((algorithms) => { - const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); + // construct options for username and email algorithms; replace forwarder + // entry with a virtual entry for drill-down + const usernames = algorithms.filter((a) => !isForwarderExtensionId(a.id)); + usernames.sort((a, b) => a.weight - b.weight); const usernameOptions = this.toOptions(usernames); - usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwardedEmail") }); + usernameOptions.splice(-1, 0, { + value: FORWARDER, + label: this.i18nService.t("forwardedEmail"), + }); - const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id)); + // construct options for forwarder algorithms; they get their own selection box + const forwarders = algorithms.filter((a) => isForwarderExtensionId(a.id)); + forwarders.sort((a, b) => a.weight - b.weight); const forwarderOptions = this.toOptions(forwarders); forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") }); return [usernameOptions, forwarderOptions] as const; }), + tap((algorithms) => + this.log.debug({ algorithms: algorithms as object }, "algorithms loaded"), + ), takeUntil(this.destroyed), ) .subscribe(([usernames, forwarders]) => { - this.usernameOptions$.next(usernames); - this.forwarderOptions$.next(forwarders); + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.usernameOptions$.next(usernames); + this.forwarderOptions$.next(forwarders); + }); }); this.generatorService @@ -195,9 +217,15 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro ) .subscribe(this.rootOptions$); - this.algorithm$ + this.maybeAlgorithm$ .pipe( - map((a) => a?.description), + map((a) => { + if (a?.i18nKeys?.description) { + return translate(a.i18nKeys.description, this.i18nService); + } else { + return ""; + } + }), takeUntil(this.destroyed), ) .subscribe((hint) => { @@ -208,9 +236,9 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro }); }); - this.algorithm$ + this.maybeAlgorithm$ .pipe( - map((a) => a?.category), + map((a) => a?.type), distinctUntilChanged(), takeUntil(this.destroyed), ) @@ -223,10 +251,12 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro }); // wire up the generator - this.algorithm$ + this.generatorService + .generate$({ + on$: this.generate$, + account$: this.account$, + }) .pipe( - filter((algorithm) => !!algorithm), - switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), catchError((error: unknown, generator) => { if (typeof error === "string") { this.toastService.showToast({ @@ -241,11 +271,14 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro // continue with origin stream return generator; }), - withLatestFrom(this.account$, this.algorithm$), + withLatestFrom(this.account$, this.maybeAlgorithm$), takeUntil(this.destroyed), ) .subscribe(([generated, account, algorithm]) => { - this.log.debug({ source: generated.source }, "credential generated"); + this.log.debug( + { source: generated.source ?? null, algorithm: algorithm?.id ?? null }, + "credential generated", + ); this.generatorHistoryService .track(account.id, generated.credential, generated.category, generated.generationDate) @@ -256,8 +289,8 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { - if (generated.source === this.USER_REQUEST) { - this.announce(algorithm.onGeneratedMessage); + if (algorithm && generated.source === this.USER_REQUEST) { + this.announce(translate(algorithm.i18nKeys.credentialGenerated, this.i18nService)); } this.generatedCredential$.next(generated); @@ -274,36 +307,46 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro this.root$ .pipe( - map( - (root): CascadeValue => - root.nav === IDENTIFIER - ? { nav: root.nav } - : { nav: root.nav, algorithm: JSON.parse(root.nav) }, - ), + map((root): CascadeValue => { + if (root.nav === IDENTIFIER) { + return { nav: root.nav }; + } else if (root.nav) { + return { nav: root.nav, algorithm: JSON.parse(root.nav) }; + } else { + return { nav: IDENTIFIER }; + } + }), takeUntil(this.destroyed), ) .subscribe(activeRoot$); this.username.valueChanges .pipe( - map( - (username): CascadeValue => - username.nav === FORWARDER - ? { nav: username.nav } - : { nav: username.nav, algorithm: JSON.parse(username.nav) }, - ), + map((username): CascadeValue => { + if (username.nav === FORWARDER) { + return { nav: username.nav }; + } else if (username.nav) { + return { nav: username.nav, algorithm: JSON.parse(username.nav) }; + } else { + const [algorithm] = AlgorithmsByType[Type.username]; + return { nav: JSON.stringify(algorithm), algorithm }; + } + }), takeUntil(this.destroyed), ) .subscribe(activeIdentifier$); this.forwarder.valueChanges .pipe( - map( - (forwarder): CascadeValue => - forwarder.nav === NONE_SELECTED - ? { nav: forwarder.nav } - : { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) }, - ), + map((forwarder): CascadeValue => { + if (forwarder.nav === NONE_SELECTED) { + return { nav: forwarder.nav }; + } else if (forwarder.nav) { + return { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) }; + } else { + return { nav: NONE_SELECTED }; + } + }), takeUntil(this.destroyed), ) .subscribe(activeForwarder$); @@ -314,7 +357,7 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro map(([root, username, forwarder]) => { const showForwarder = !root.algorithm && !username.algorithm; const forwarderId = - showForwarder && isForwarderIntegration(forwarder.algorithm) + showForwarder && forwarder.algorithm && isForwarderExtensionId(forwarder.algorithm) ? forwarder.algorithm.forwarder : null; return [showForwarder, forwarderId] as const; @@ -344,47 +387,51 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro return null; } }), - distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + distinctUntilChanged((prev, next) => { + if (prev === null || next === null) { + return false; + } else { + return isSameAlgorithm(prev.id, next.id); + } + }), takeUntil(this.destroyed), ) .subscribe((algorithm) => { - this.log.debug(algorithm, "algorithm selected"); + this.log.debug({ algorithm: algorithm?.id ?? null }, "algorithm selected"); // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { - this.algorithm$.next(algorithm); + this.maybeAlgorithm$.next(algorithm); }); }); // assume the last-selected generator algorithm is the user's preferred one const preferences = await this.generatorService.preferences({ account$: this.account$ }); this.algorithm$ - .pipe( - filter((algorithm) => !!algorithm), - withLatestFrom(preferences), - takeUntil(this.destroyed), - ) + .pipe(withLatestFrom(preferences), takeUntil(this.destroyed)) .subscribe(([algorithm, preference]) => { - function setPreference(category: CredentialCategory, log: SemanticLogger) { - const p = preference[category]; + function setPreference(type: CredentialType) { + const p = preference[type]; p.algorithm = algorithm.id; p.updated = new Date(); - - log.info({ algorithm, category }, "algorithm preferences updated"); } // `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference` if (isEmailAlgorithm(algorithm.id)) { - setPreference("email", this.log); + setPreference("email"); } else if (isUsernameAlgorithm(algorithm.id)) { - setPreference("username", this.log); + setPreference("username"); } else if (isPasswordAlgorithm(algorithm.id)) { - setPreference("password", this.log); + setPreference("password"); } else { return; } + this.log.info( + { algorithm: algorithm.id, type: algorithm.type }, + "algorithm preferences updated", + ); preferences.next(preference); }); @@ -392,10 +439,12 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro preferences .pipe( map(({ email, username, password }) => { - const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null; const usernamePref = email.updated > username.updated ? email : username; + const forwarderPref = isForwarderExtensionId(usernamePref.algorithm) + ? usernamePref + : null; - // inject drilldown flags + // inject drill-down flags const forwarderNav = !forwarderPref ? NONE_SELECTED : JSON.stringify(forwarderPref.algorithm); @@ -411,14 +460,14 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro selection: { nav: rootNav }, active: { nav: rootNav, - algorithm: rootNav === IDENTIFIER ? null : password.algorithm, + algorithm: rootNav === IDENTIFIER ? undefined : password.algorithm, } as CascadeValue, }, username: { selection: { nav: userNav }, active: { nav: userNav, - algorithm: forwarderPref ? null : usernamePref.algorithm, + algorithm: forwarderPref ? undefined : usernamePref.algorithm, }, }, forwarder: { @@ -435,6 +484,15 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro takeUntil(this.destroyed), ) .subscribe(({ root, username, forwarder }) => { + this.log.debug( + { + root: root.selection, + username: username.selection, + forwarder: forwarder.selection, + }, + "navigation updated", + ); + // update navigation; break subscription loop this.onRootChanged(root.selection); this.username.setValue(username.selection, { emitEvent: false }); @@ -448,16 +506,16 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro // automatically regenerate when the algorithm switches if the algorithm // allows it; otherwise set a placeholder - this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.maybeAlgorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { this.zone.run(() => { - if (!a || a.onlyOnRequest) { - this.log.debug("autogeneration disabled; clearing generated credential"); - this.generatedCredential$.next(null); - } else { + if (a?.capabilities?.autogenerate) { this.log.debug("autogeneration enabled"); this.generate("autogenerate").catch((e: unknown) => { this.log.error(e as object, "a failure occurred during autogeneration"); }); + } else { + this.log.debug("autogeneration disabled; clearing generated credential"); + this.generatedCredential$.next(undefined); } }); }); @@ -469,41 +527,6 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro this.ariaLive.announce(message).catch((e) => this.logService.error(e)); } - private typeToGenerator$(algorithm: CredentialAlgorithm) { - const dependencies = { - on$: this.generate$, - account$: this.account$, - }; - - this.log.debug({ algorithm }, "constructing generation stream"); - - switch (algorithm) { - case "catchall": - return this.generatorService.generate$(Generators.catchall, dependencies); - - case "subaddress": - return this.generatorService.generate$(Generators.subaddress, dependencies); - - case "username": - return this.generatorService.generate$(Generators.username, dependencies); - - case "password": - return this.generatorService.generate$(Generators.password, dependencies); - - case "passphrase": - return this.generatorService.generate$(Generators.passphrase, dependencies); - } - - if (isForwarderIntegration(algorithm)) { - const forwarder = getForwarderConfiguration(algorithm.forwarder); - const configuration = toCredentialGeneratorConfiguration(forwarder); - const generator = this.generatorService.generate$(configuration, dependencies); - return generator; - } - - this.log.panic({ algorithm }, `Invalid generator type: "${algorithm}"`); - } - /** Lists the top-level credential types supported by the component. * @remarks This is string-typed because angular doesn't support * structural equality for objects, which prevents `CredentialAlgorithm` @@ -519,15 +542,20 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro protected forwarderOptions$ = new BehaviorSubject[]>([]); /** Tracks the currently selected forwarder. */ - protected forwarderId$ = new BehaviorSubject(null); + protected forwarderId$ = new BehaviorSubject(null); /** Tracks forwarder control visibility */ protected showForwarder$ = new BehaviorSubject(false); /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + protected maybeAlgorithm$ = new ReplaySubject(1); - protected showAlgorithm$ = this.algorithm$.pipe( + /** tracks the last valid algorithm selection */ + protected algorithm$ = this.maybeAlgorithm$.pipe( + filter((algorithm): algorithm is AlgorithmMetadata => !!algorithm), + ); + + protected showAlgorithm$ = this.maybeAlgorithm$.pipe( combineLatestWith(this.showForwarder$), map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)), ); @@ -536,33 +564,32 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro * Emits the copy button aria-label respective of the selected credential type */ protected credentialTypeCopyLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ copy }) => copy), + map(({ i18nKeys: { copyCredential } }) => translate(copyCredential, this.i18nService)), ); /** * Emits the generate button aria-label respective of the selected credential type */ protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ generate }) => generate), + map(({ i18nKeys: { generateCredential } }) => translate(generateCredential, this.i18nService)), ); /** * Emits the copy credential toast respective of the selected credential type */ protected credentialTypeLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ credentialType }) => credentialType), + map(({ i18nKeys: { credentialType } }) => translate(credentialType, this.i18nService)), ); /** Emits hint key for the currently selected credential type */ - protected credentialTypeHint$ = new ReplaySubject(1); + protected credentialTypeHint$ = new ReplaySubject(1); /** tracks the currently selected credential category */ - protected category$ = new ReplaySubject(1); + protected category$ = new ReplaySubject(1); - private readonly generatedCredential$ = new BehaviorSubject(null); + private readonly generatedCredential$ = new BehaviorSubject( + undefined, + ); /** Emits the last generated value. */ protected readonly value$ = this.generatedCredential$.pipe( @@ -580,15 +607,20 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro * origin in the debugger. */ protected async generate(source: string) { - const request = { source, website: this.website }; + const algorithm = await firstValueFrom(this.algorithm$); + const request: GenerateRequest = { source, algorithm: algorithm.id }; + if (this.website) { + request.website = this.website; + } + this.log.debug(request, "generation requested"); this.generate$.next(request); } - private toOptions(algorithms: AlgorithmInfo[]) { + private toOptions(algorithms: AlgorithmMetadata[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: JSON.stringify(algorithm.id), - label: algorithm.name, + label: translate(algorithm.i18nKeys.name, this.i18nService), })); return options; diff --git a/libs/tools/generator/components/src/forwarder-settings.component.ts b/libs/tools/generator/components/src/forwarder-settings.component.ts index 689cc7e258c..c961cd5bb7a 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.ts +++ b/libs/tools/generator/components/src/forwarder-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, EventEmitter, @@ -14,13 +12,11 @@ import { FormBuilder } from "@angular/forms"; import { map, ReplaySubject, skip, Subject, switchAll, takeUntil, withLatestFrom } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { - CredentialGeneratorConfiguration, CredentialGeneratorService, - getForwarderConfiguration, - NoPolicy, - toCredentialGeneratorConfiguration, + ForwarderOptions, + GeneratorMetadata, } from "@bitwarden/generator-core"; const Controls = Object.freeze({ @@ -37,7 +33,6 @@ const Controls = Object.freeze({ }) export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Instantiates the component - * @param accountService queries user availability * @param generatorService settings and policy logic * @param formBuilder reactive form controls */ @@ -47,14 +42,16 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy ) {} /** Binds the component to a specific user's settings. + * @remarks this is initialized to null but since it's a required input it'll + * never have that value in practice. */ @Input({ required: true }) - account: Account; + account: Account = null!; protected account$ = new ReplaySubject(1); @Input({ required: true }) - forwarder: IntegrationId; + forwarder: VendorId = null!; /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like @@ -71,24 +68,19 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy [Controls.baseUrl]: [""], }); - private forwarderId$ = new ReplaySubject(1); + private vendor = new ReplaySubject(1); async ngOnInit() { - const forwarder$ = new ReplaySubject>(1); - this.forwarderId$ + const forwarder$ = new ReplaySubject>(1); + this.vendor .pipe( - map((id) => getForwarderConfiguration(id)), - // type erasure necessary because the configuration properties are - // determined dynamically at runtime - // FIXME: this can be eliminated by unifying the forwarder settings types; - // see `ForwarderConfiguration<...>` for details. - map((forwarder) => toCredentialGeneratorConfiguration(forwarder)), + map((vendor) => this.generatorService.forwarder(vendor)), takeUntil(this.destroyed$), ) .subscribe((forwarder) => { - this.displayDomain = forwarder.request.includes("domain"); - this.displayToken = forwarder.request.includes("token"); - this.displayBaseUrl = forwarder.request.includes("baseUrl"); + this.displayDomain = forwarder.capabilities.fields.includes("domain"); + this.displayToken = forwarder.capabilities.fields.includes("token"); + this.displayBaseUrl = forwarder.capabilities.fields.includes("baseUrl"); forwarder$.next(forwarder); }); @@ -107,10 +99,10 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy forwarder$.pipe(takeUntil(this.destroyed$)).subscribe((forwarder) => { for (const name in Controls) { const control = this.settings.get(name); - if (forwarder.request.includes(name as any)) { - control.enable({ emitEvent: false }); + if (forwarder.capabilities.fields.includes(name)) { + control?.enable({ emitEvent: false }); } else { - control.disable({ emitEvent: false }); + control?.disable({ emitEvent: false }); } } }); @@ -128,7 +120,7 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy this.saveSettings .pipe(withLatestFrom(this.settings.valueChanges, settings$), takeUntil(this.destroyed$)) .subscribe(([, value, settings]) => { - settings.next(value); + settings.next(value as ForwarderOptions); }); } @@ -140,7 +132,7 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy async ngOnChanges(changes: SimpleChanges) { this.refresh$.complete(); if ("forwarder" in changes) { - this.forwarderId$.next(this.forwarder); + this.vendor.next(this.forwarder); } if ("account" in changes) { @@ -148,9 +140,9 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy } } - protected displayDomain: boolean; - protected displayToken: boolean; - protected displayBaseUrl: boolean; + protected displayDomain: boolean = false; + protected displayToken: boolean = false; + protected displayBaseUrl: boolean = false; private readonly refresh$ = new Subject(); diff --git a/libs/tools/generator/components/src/generator-services.module.ts b/libs/tools/generator/components/src/generator-services.module.ts index 214abbd0ac2..3a7b771a25d 100644 --- a/libs/tools/generator/components/src/generator-services.module.ts +++ b/libs/tools/generator/components/src/generator-services.module.ts @@ -7,19 +7,40 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; -import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log"; +import { Site } from "@bitwarden/common/tools/extension"; +import { ExtensionRegistry } from "@bitwarden/common/tools/extension/extension-registry.abstraction"; +import { ExtensionService } from "@bitwarden/common/tools/extension/extension.service"; +import { DefaultFields, DefaultSites, Extension } from "@bitwarden/common/tools/extension/metadata"; +import { RuntimeExtensionRegistry } from "@bitwarden/common/tools/extension/runtime-extension-registry"; +import { VendorExtensions, Vendors } from "@bitwarden/common/tools/extension/vendor"; +import { RestClient } from "@bitwarden/common/tools/integration/rpc"; +import { + LogProvider, + disabledSemanticLoggerProvider, + enableLogForTypes, +} from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; import { + BuiltIn, createRandomizer, CredentialGeneratorService, Randomizer, + providers, + DefaultCredentialGeneratorService, } from "@bitwarden/generator-core"; import { KeyService } from "@bitwarden/key-management"; export const RANDOMIZER = new SafeInjectionToken("Randomizer"); +const GENERATOR_SERVICE_PROVIDER = new SafeInjectionToken( + "CredentialGeneratorProviders", +); +const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken("SystemServices"); /** Shared module containing generator component dependencies */ @NgModule({ @@ -35,6 +56,116 @@ export const RANDOMIZER = new SafeInjectionToken("Randomizer"); useClass: KeyServiceLegacyEncryptorProvider, deps: [EncryptService, KeyService], }), + safeProvider({ + provide: ExtensionRegistry, + useFactory: () => { + const registry = new RuntimeExtensionRegistry(DefaultSites, DefaultFields); + + registry.registerSite(Extension[Site.forwarder]); + for (const vendor of Vendors) { + registry.registerVendor(vendor); + } + for (const extension of VendorExtensions) { + registry.registerExtension(extension); + } + registry.setPermission({ all: true }, "default"); + + return registry; + }, + deps: [], + }), + safeProvider({ + provide: SYSTEM_SERVICE_PROVIDER, + useFactory: ( + encryptor: LegacyEncryptorProvider, + state: StateProvider, + policy: PolicyService, + registry: ExtensionRegistry, + logger: LogService, + environment: PlatformUtilsService, + ) => { + let log: LogProvider; + if (environment.isDev()) { + log = enableLogForTypes(logger, []); + } else { + log = disabledSemanticLoggerProvider; + } + + const extension = new ExtensionService(registry, { + encryptor, + state, + log, + now: Date.now, + }); + + return { + policy, + extension, + log, + }; + }, + deps: [ + LegacyEncryptorProvider, + StateProvider, + PolicyService, + ExtensionRegistry, + LogService, + PlatformUtilsService, + ], + }), + safeProvider({ + provide: GENERATOR_SERVICE_PROVIDER, + useFactory: ( + system: SystemServiceProvider, + random: Randomizer, + encryptor: LegacyEncryptorProvider, + state: StateProvider, + i18n: I18nService, + api: ApiService, + ) => { + const userStateDeps = { + encryptor, + state, + log: system.log, + now: Date.now, + } satisfies UserStateSubjectDependencyProvider; + + const metadata = new providers.GeneratorMetadataProvider( + userStateDeps, + system, + Object.values(BuiltIn), + ); + const profile = new providers.GeneratorProfileProvider(userStateDeps, system.policy); + + const generator: providers.GeneratorDependencyProvider = { + randomizer: random, + client: new RestClient(api, i18n), + i18nService: i18n, + }; + + const userState: UserStateSubjectDependencyProvider = { + encryptor, + state, + log: system.log, + now: Date.now, + }; + + return { + userState, + generator, + profile, + metadata, + } satisfies providers.CredentialGeneratorProviders; + }, + deps: [ + SYSTEM_SERVICE_PROVIDER, + RANDOMIZER, + LegacyEncryptorProvider, + StateProvider, + I18nService, + ApiService, + ], + }), safeProvider({ provide: UserStateSubjectDependencyProvider, useFactory: (encryptor: LegacyEncryptorProvider, state: StateProvider) => @@ -42,19 +173,14 @@ export const RANDOMIZER = new SafeInjectionToken("Randomizer"); encryptor, state, log: disabledSemanticLoggerProvider, + now: Date.now, }), deps: [LegacyEncryptorProvider, StateProvider], }), safeProvider({ provide: CredentialGeneratorService, - useClass: CredentialGeneratorService, - deps: [ - RANDOMIZER, - PolicyService, - ApiService, - I18nService, - UserStateSubjectDependencyProvider, - ], + useClass: DefaultCredentialGeneratorService, + deps: [GENERATOR_SERVICE_PROVIDER, SYSTEM_SERVICE_PROVIDER], }), ], }) diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index 405914977c5..b3525251392 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { OnInit, @@ -12,14 +10,20 @@ import { OnChanges, } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { skip, takeUntil, Subject, map, withLatestFrom, ReplaySubject } from "rxjs"; +import { skip, takeUntil, Subject, map, withLatestFrom, ReplaySubject, tap } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { + SemanticLogger, + disabledSemanticLoggerProvider, + ifEnabledSemanticLoggerProvider, +} from "@bitwarden/common/tools/log"; import { - Generators, CredentialGeneratorService, PassphraseGenerationOptions, + BuiltIn, } from "@bitwarden/generator-core"; const Controls = Object.freeze({ @@ -45,12 +49,26 @@ export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy private formBuilder: FormBuilder, private generatorService: CredentialGeneratorService, private i18nService: I18nService, + private logService: LogService, ) {} + /** Send structured debug logs from the credential generator component + * to the debugger console. + * + * @warning this may reveal sensitive information in plaintext. + */ + @Input() + debug: boolean = false; + + // this `log` initializer is overridden in `ngOnInit` + private log: SemanticLogger = disabledSemanticLoggerProvider({}); + /** Binds the component to a specific user's settings. + * @remarks this is initialized to null but since it's a required input it'll + * never have that value in practice. */ @Input({ required: true }) - account: Account; + account: Account = null!; protected account$ = new ReplaySubject(1); @@ -70,53 +88,66 @@ export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like * to receive live settings updates including the initial update, - * use `CredentialGeneratorService.settings$(...)` instead. + * use {@link CredentialGeneratorService.settings} instead. */ @Output() readonly onUpdated = new EventEmitter(); protected settings = this.formBuilder.group({ - [Controls.numWords]: [Generators.passphrase.settings.initial.numWords], - [Controls.wordSeparator]: [Generators.passphrase.settings.initial.wordSeparator], - [Controls.capitalize]: [Generators.passphrase.settings.initial.capitalize], - [Controls.includeNumber]: [Generators.passphrase.settings.initial.includeNumber], + [Controls.numWords]: [0], + [Controls.wordSeparator]: [""], + [Controls.capitalize]: [false], + [Controls.includeNumber]: [false], }); async ngOnInit() { - const settings = await this.generatorService.settings(Generators.passphrase, { + this.log = ifEnabledSemanticLoggerProvider(this.debug, this.logService, { + type: "PassphraseSettingsComponent", + }); + + const settings = await this.generatorService.settings(BuiltIn.passphrase, { account$: this.account$, }); // skips reactive event emissions to break a subscription cycle settings.withConstraints$ - .pipe(takeUntil(this.destroyed$)) + .pipe( + tap((content) => this.log.debug(content, "passphrase settings loaded with constraints")), + takeUntil(this.destroyed$), + ) .subscribe(({ state, constraints }) => { this.settings.patchValue(state, { emitEvent: false }); let boundariesHint = this.i18nService.t( "spinboxBoundariesHint", - constraints.numWords.min?.toString(), - constraints.numWords.max?.toString(), + constraints.numWords?.min?.toString(), + constraints.numWords?.max?.toString(), ); - if (state.numWords <= (constraints.numWords.recommendation ?? 0)) { + if ((state.numWords ?? 0) <= (constraints.numWords?.recommendation ?? 0)) { boundariesHint += this.i18nService.t( "passphraseNumWordsRecommendationHint", - constraints.numWords.recommendation?.toString(), + constraints.numWords?.recommendation?.toString(), ); } this.numWordsBoundariesHint.next(boundariesHint); }); // the first emission is the current value; subsequent emissions are updates - settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); + settings + .pipe( + skip(1), + tap((settings) => this.log.debug(settings, "passphrase settings onUpdate event")), + takeUntil(this.destroyed$), + ) + .subscribe(this.onUpdated); // explain policy & disable policy-overridden fields this.generatorService - .policy$(Generators.passphrase, { account$: this.account$ }) + .policy$(BuiltIn.passphrase, { account$: this.account$ }) .pipe(takeUntil(this.destroyed$)) .subscribe(({ constraints }) => { - this.wordSeparatorMaxLength = constraints.wordSeparator.maxLength; - this.policyInEffect = constraints.policyInEffect; + this.wordSeparatorMaxLength = constraints.wordSeparator?.maxLength ?? 0; + this.policyInEffect = constraints.policyInEffect ?? false; this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly); this.toggleEnabled(Controls.includeNumber, !constraints.includeNumber?.readonly); @@ -126,22 +157,25 @@ export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy this.saveSettings .pipe( withLatestFrom(this.settings.valueChanges), - map(([, settings]) => settings), + tap(([source, form]) => + this.log.debug({ source, form }, "save passphrase settings request"), + ), + map(([, settings]) => settings as PassphraseGenerationOptions), takeUntil(this.destroyed$), ) .subscribe(settings); } /** attribute binding for wordSeparator[maxlength] */ - protected wordSeparatorMaxLength: number; + protected wordSeparatorMaxLength: number = 0; private saveSettings = new Subject(); - save(site: string = "component api call") { - this.saveSettings.next(site); + save(source: string = "component api call") { + this.saveSettings.next(source); } /** display binding for enterprise policy notice */ - protected policyInEffect: boolean; + protected policyInEffect: boolean = false; private numWordsBoundariesHint = new ReplaySubject(1); @@ -150,9 +184,9 @@ export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) { if (enabled) { - this.settings.get(setting).enable({ emitEvent: false }); + this.settings.get(setting)?.enable({ emitEvent: false }); } else { - this.settings.get(setting).disable({ emitEvent: false }); + this.settings.get(setting)?.disable({ emitEvent: false }); } } diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index 41ed982a490..cc5bdba6062 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -3,6 +3,7 @@ class="tw-mb-4" [selected]="credentialType$ | async" (selectedChange)="onCredentialTypeChanged($event)" + *ngIf="showCredentialTypes$ | async" attr.aria-label="{{ 'type' | i18n }}" > @@ -38,14 +39,14 @@ (1); @@ -87,7 +94,11 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy async ngOnChanges(changes: SimpleChanges) { const account = changes?.account; - if (account?.previousValue?.id !== account?.currentValue?.id) { + if ( + account && + account.currentValue.id && + account.previousValue.id !== account.currentValue.id + ) { this.log.debug( { previousUserId: account?.previousValue?.id as UserId, @@ -95,15 +106,19 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy }, "account input change detected", ); - this.account$.next(this.account); + this.account$.next(account.currentValue.id); } } + @Input() + profile: GeneratorProfile = Profile.account; + /** Removes bottom margin, passed to downstream components */ - @Input({ transform: coerceBooleanProperty }) disableMargin = false; + @Input({ transform: coerceBooleanProperty }) + disableMargin = false; /** tracks the currently selected credential type */ - protected credentialType$ = new BehaviorSubject(null); + protected credentialType$ = new BehaviorSubject(null); /** Emits the last generated value. */ protected readonly value$ = new BehaviorSubject(""); @@ -119,9 +134,11 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy * origin in the debugger. */ protected async generate(source: string) { - this.log.debug({ source }, "generation requested"); + const algorithm = await firstValueFrom(this.algorithm$); + const request: GenerateRequest = { source, algorithm: algorithm.id, profile: this.profile }; - this.generate$.next({ source }); + this.log.debug(request, "generation requested"); + this.generate$.next(request); } /** Tracks changes to the selected credential type @@ -146,16 +163,17 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy async ngOnInit() { this.log = ifEnabledSemanticLoggerProvider(this.debug, this.logService, { - type: "UsernameGeneratorComponent", + type: "PasswordGeneratorComponent", }); if (!this.account) { - this.account = await firstValueFrom(this.accountService.activeAccount$); - this.log.info( - { userId: this.account.id }, - "account not specified; using active account settings", - ); - this.account$.next(this.account); + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + this.log.panic("active account cannot be `null`."); + } + + this.log.info({ userId: account.id }, "account not specified; using active account settings"); + this.account$.next(account); } this.generatorService @@ -167,10 +185,9 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy .subscribe(this.passwordOptions$); // wire up the generator - this.algorithm$ + this.generatorService + .generate$({ on$: this.generate$, account$: this.account$ }) .pipe( - filter((algorithm) => !!algorithm), - switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), catchError((error: unknown, generator) => { if (typeof error === "string") { this.toastService.showToast({ @@ -189,7 +206,7 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy takeUntil(this.destroyed), ) .subscribe(([generated, account, algorithm]) => { - this.log.debug({ source: generated.source }, "credential generated"); + this.log.debug({ source: generated.source ?? null }, "credential generated"); this.generatorHistoryService .track(account.id, generated.credential, generated.category, generated.generationDate) @@ -201,7 +218,7 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy // template bindings refresh immediately this.zone.run(() => { if (generated.source === this.USER_REQUEST) { - this.announce(algorithm.onGeneratedMessage); + this.announce(translate(algorithm.i18nKeys.credentialGenerated, this.i18nService)); } this.onGenerated.next(generated); @@ -219,10 +236,7 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy ) .subscribe(([algorithm, preference]) => { if (isPasswordAlgorithm(algorithm)) { - this.log.info( - { algorithm, category: CredentialCategories.password }, - "algorithm preferences updated", - ); + this.log.info({ algorithm, type: Type.password }, "algorithm preferences updated"); preference.password.algorithm = algorithm; preference.password.updated = new Date(); } else { @@ -236,11 +250,17 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy preferences .pipe( map(({ password }) => this.generatorService.algorithm(password.algorithm)), - distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + distinctUntilChanged((prev, next) => { + if (prev === null || next === null) { + return false; + } else { + return isSameAlgorithm(prev.id, next.id); + } + }), takeUntil(this.destroyed), ) .subscribe((algorithm) => { - this.log.debug(algorithm, "algorithm selected"); + this.log.debug({ algorithm: algorithm.id }, "algorithm selected"); // update navigation this.onCredentialTypeChanged(algorithm.id); @@ -248,22 +268,22 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { - this.algorithm$.next(algorithm); - this.onAlgorithm.next(algorithm); + this.maybeAlgorithm$.next(algorithm); + this.onAlgorithm.next(toAlgorithmInfo(algorithm, this.i18nService)); }); }); // generate on load unless the generator prohibits it - this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.maybeAlgorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { this.zone.run(() => { - if (!a || a.onlyOnRequest) { - this.log.debug("autogeneration disabled; clearing generated credential"); - this.value$.next("-"); - } else { + if (a?.capabilities?.autogenerate) { this.log.debug("autogeneration enabled"); this.generate("autogenerate").catch((e: unknown) => { this.log.error(e as object, "a failure occurred during autogeneration"); }); + } else { + this.log.debug("autogeneration disabled; clearing generated credential"); + this.value$.next("-"); } }); }); @@ -275,59 +295,45 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy this.ariaLive.announce(message).catch((e) => this.logService.error(e)); } - private typeToGenerator$(algorithm: CredentialAlgorithm) { - const dependencies = { - on$: this.generate$, - account$: this.account$, - }; - - this.log.debug({ algorithm }, "constructing generation stream"); - - switch (algorithm) { - case "password": - return this.generatorService.generate$(Generators.password, dependencies); - - case "passphrase": - return this.generatorService.generate$(Generators.passphrase, dependencies); - default: - this.log.panic({ algorithm }, `Invalid generator type: "${algorithm}"`); - } - } - /** Lists the credential types supported by the component. */ protected passwordOptions$ = new BehaviorSubject[]>([]); + /** Determines when the password/passphrase selector is visible. */ + protected showCredentialTypes$ = this.passwordOptions$.pipe(map((options) => options.length > 1)); + /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + protected maybeAlgorithm$ = new ReplaySubject(1); + + /** tracks the last valid algorithm selection */ + protected algorithm$ = this.maybeAlgorithm$.pipe( + filter((algorithm): algorithm is AlgorithmMetadata => !!algorithm), + ); /** * Emits the copy button aria-label respective of the selected credential type */ protected credentialTypeCopyLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ copy }) => copy), + map(({ i18nKeys: { copyCredential } }) => translate(copyCredential, this.i18nService)), ); /** * Emits the generate button aria-label respective of the selected credential type */ protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ generate }) => generate), + map(({ i18nKeys: { generateCredential } }) => translate(generateCredential, this.i18nService)), ); /** * Emits the copy credential toast respective of the selected credential type */ protected credentialTypeLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ credentialType }) => credentialType), + map(({ i18nKeys: { credentialType } }) => translate(credentialType, this.i18nService)), ); - private toOptions(algorithms: AlgorithmInfo[]) { + private toOptions(algorithms: AlgorithmMetadata[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: algorithm.id, - label: algorithm.name, + label: translate(algorithm.i18nKeys.name, this.i18nService), })); return options; diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts index 346e9549cd8..965ada38146 100644 --- a/libs/tools/generator/components/src/password-settings.component.ts +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { OnInit, @@ -17,11 +15,13 @@ import { takeUntil, Subject, map, filter, tap, skip, ReplaySubject, withLatestFr import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { - Generators, CredentialGeneratorService, PasswordGenerationOptions, + BuiltIn, } from "@bitwarden/generator-core"; +import { hasRangeOfValues } from "./util"; + const Controls = Object.freeze({ length: "length", uppercase: "uppercase", @@ -52,9 +52,11 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { ) {} /** Binds the component to a specific user's settings. + * @remarks this is initialized to null but since it's a required input it'll + * never have that value in practice. */ @Input({ required: true }) - account: Account; + account: Account = null!; protected account$ = new ReplaySubject(1); @@ -78,40 +80,40 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like * to receive live settings updates including the initial update, - * use `CredentialGeneratorService.settings$(...)` instead. + * use `CredentialGeneratorService.settings(...)` instead. */ @Output() readonly onUpdated = new EventEmitter(); protected settings = this.formBuilder.group({ - [Controls.length]: [Generators.password.settings.initial.length], - [Controls.uppercase]: [Generators.password.settings.initial.uppercase], - [Controls.lowercase]: [Generators.password.settings.initial.lowercase], - [Controls.number]: [Generators.password.settings.initial.number], - [Controls.special]: [Generators.password.settings.initial.special], - [Controls.minNumber]: [Generators.password.settings.initial.minNumber], - [Controls.minSpecial]: [Generators.password.settings.initial.minSpecial], - [Controls.avoidAmbiguous]: [!Generators.password.settings.initial.ambiguous], + [Controls.length]: [0], + [Controls.uppercase]: [false], + [Controls.lowercase]: [false], + [Controls.number]: [false], + [Controls.special]: [false], + [Controls.minNumber]: [0], + [Controls.minSpecial]: [0], + [Controls.avoidAmbiguous]: [false], }); private get numbers() { - return this.settings.get(Controls.number); + return this.settings.get(Controls.number)!; } private get special() { - return this.settings.get(Controls.special); + return this.settings.get(Controls.special)!; } private get minNumber() { - return this.settings.get(Controls.minNumber); + return this.settings.get(Controls.minNumber)!; } private get minSpecial() { - return this.settings.get(Controls.minSpecial); + return this.settings.get(Controls.minSpecial)!; } async ngOnInit() { - const settings = await this.generatorService.settings(Generators.password, { + const settings = await this.generatorService.settings(BuiltIn.password, { account$: this.account$, }); @@ -130,13 +132,13 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { .subscribe(([state, constraints]) => { let boundariesHint = this.i18nService.t( "spinboxBoundariesHint", - constraints.length.min?.toString(), - constraints.length.max?.toString(), + constraints.length?.min?.toString(), + constraints.length?.max?.toString(), ); - if (state.length <= (constraints.length.recommendation ?? 0)) { + if (state.length <= (constraints.length?.recommendation ?? 0)) { boundariesHint += this.i18nService.t( "passwordLengthRecommendationHint", - constraints.length.recommendation?.toString(), + constraints.length?.recommendation?.toString(), ); } this.lengthBoundariesHint.next(boundariesHint); @@ -147,19 +149,25 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { // explain policy & disable policy-overridden fields this.generatorService - .policy$(Generators.password, { account$: this.account$ }) + .policy$(BuiltIn.password, { account$: this.account$ }) .pipe(takeUntil(this.destroyed$)) .subscribe(({ constraints }) => { - this.policyInEffect = constraints.policyInEffect; + this.policyInEffect = constraints.policyInEffect ?? false; const toggles = [ - [Controls.length, constraints.length.min < constraints.length.max], + [Controls.length, hasRangeOfValues(constraints.length?.min, constraints.length?.max)], [Controls.uppercase, !constraints.uppercase?.readonly], [Controls.lowercase, !constraints.lowercase?.readonly], [Controls.number, !constraints.number?.readonly], [Controls.special, !constraints.special?.readonly], - [Controls.minNumber, constraints.minNumber.min < constraints.minNumber.max], - [Controls.minSpecial, constraints.minSpecial.min < constraints.minSpecial.max], + [ + Controls.minNumber, + hasRangeOfValues(constraints.minNumber?.min, constraints.minNumber?.max), + ], + [ + Controls.minSpecial, + hasRangeOfValues(constraints.minSpecial?.min, constraints.minSpecial?.max), + ], ] as [keyof typeof Controls, boolean][]; for (const [control, enabled] of toggles) { @@ -172,7 +180,7 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { let lastMinNumber = 1; this.numbers.valueChanges .pipe( - filter((checked) => !(checked && this.minNumber.value > 0)), + filter((checked) => !(checked && (this.minNumber.value ?? 0) > 0)), map((checked) => (checked ? lastMinNumber : 0)), takeUntil(this.destroyed$), ) @@ -180,8 +188,11 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { this.minNumber.valueChanges .pipe( - map((value) => [value, value > 0] as const), - tap(([value, checkNumbers]) => (lastMinNumber = checkNumbers ? value : lastMinNumber)), + map((value) => [value, (value ?? 0) > 0] as const), + tap( + ([value, checkNumbers]) => + (lastMinNumber = checkNumbers && value ? value : lastMinNumber), + ), takeUntil(this.destroyed$), ) .subscribe(([, checkNumbers]) => this.numbers.setValue(checkNumbers, { emitEvent: false })); @@ -189,7 +200,7 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { let lastMinSpecial = 1; this.special.valueChanges .pipe( - filter((checked) => !(checked && this.minSpecial.value > 0)), + filter((checked) => !(checked && (this.minSpecial.value ?? 0) > 0)), map((checked) => (checked ? lastMinSpecial : 0)), takeUntil(this.destroyed$), ) @@ -197,8 +208,11 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { this.minSpecial.valueChanges .pipe( - map((value) => [value, value > 0] as const), - tap(([value, checkSpecial]) => (lastMinSpecial = checkSpecial ? value : lastMinSpecial)), + map((value) => [value, (value ?? 0) > 0] as const), + tap( + ([value, checkSpecial]) => + (lastMinSpecial = checkSpecial && value ? value : lastMinSpecial), + ), takeUntil(this.destroyed$), ) .subscribe(([, checkSpecial]) => this.special.setValue(checkSpecial, { emitEvent: false })); @@ -230,7 +244,7 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { } /** display binding for enterprise policy notice */ - protected policyInEffect: boolean; + protected policyInEffect: boolean = false; private lengthBoundariesHint = new ReplaySubject(1); @@ -239,9 +253,9 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) { if (enabled) { - this.settings.get(setting).enable({ emitEvent: false }); + this.settings.get(setting)?.enable({ emitEvent: false }); } else { - this.settings.get(setting).disable({ emitEvent: false }); + this.settings.get(setting)?.disable({ emitEvent: false }); } } diff --git a/libs/tools/generator/components/src/subaddress-settings.component.ts b/libs/tools/generator/components/src/subaddress-settings.component.ts index b09ecc86f9e..27ed6d5f9f3 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.ts +++ b/libs/tools/generator/components/src/subaddress-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, EventEmitter, @@ -13,10 +11,10 @@ import { import { FormBuilder } from "@angular/forms"; import { map, ReplaySubject, skip, Subject, takeUntil, withLatestFrom } from "rxjs"; -import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { CredentialGeneratorService, - Generators, + BuiltIn, SubaddressGenerationOptions, } from "@bitwarden/generator-core"; @@ -28,20 +26,20 @@ import { }) export class SubaddressSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Instantiates the component - * @param accountService queries user availability * @param generatorService settings and policy logic * @param formBuilder reactive form controls */ constructor( private formBuilder: FormBuilder, private generatorService: CredentialGeneratorService, - private accountService: AccountService, ) {} /** Binds the component to a specific user's settings. + * @remarks this is initialized to null but since it's a required input it'll + * never have that value in practice. */ @Input({ required: true }) - account: Account; + account: Account = null!; protected account$ = new ReplaySubject(1); @@ -54,18 +52,18 @@ export class SubaddressSettingsComponent implements OnInit, OnChanges, OnDestroy /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like * to receive live settings updates including the initial update, - * use `CredentialGeneratorService.settings$(...)` instead. + * use `CredentialGeneratorService.settings(...)` instead. */ @Output() readonly onUpdated = new EventEmitter(); /** The template's control bindings */ protected settings = this.formBuilder.group({ - subaddressEmail: [Generators.subaddress.settings.initial.subaddressEmail], + subaddressEmail: [""], }); async ngOnInit() { - const settings = await this.generatorService.settings(Generators.subaddress, { + const settings = await this.generatorService.settings(BuiltIn.plusAddress, { account$: this.account$, }); @@ -79,7 +77,7 @@ export class SubaddressSettingsComponent implements OnInit, OnChanges, OnDestroy this.saveSettings .pipe( withLatestFrom(this.settings.valueChanges), - map(([, settings]) => settings), + map(([, settings]) => settings as SubaddressGenerationOptions), takeUntil(this.destroyed$), ) .subscribe(settings); diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index 533a7a0e543..51b998f1d56 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -59,7 +59,7 @@ @@ -69,12 +69,12 @@ [account]="account$ | async" /> diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index de48a9bd6b1..6227bcd3f7c 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { LiveAnnouncer } from "@angular/cdk/a11y"; import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { @@ -25,15 +23,15 @@ import { map, ReplaySubject, Subject, - switchMap, takeUntil, + tap, withLatestFrom, } from "rxjs"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { SemanticLogger, disabledSemanticLoggerProvider, @@ -43,21 +41,23 @@ import { UserId } from "@bitwarden/common/types/guid"; import { ToastService, Option } from "@bitwarden/components"; import { AlgorithmInfo, - CredentialAlgorithm, - CredentialCategories, CredentialGeneratorService, GenerateRequest, GeneratedCredential, - Generators, - getForwarderConfiguration, + isForwarderExtensionId, isEmailAlgorithm, - isForwarderIntegration, - isSameAlgorithm, isUsernameAlgorithm, - toCredentialGeneratorConfiguration, + isSameAlgorithm, + CredentialAlgorithm, + AlgorithmMetadata, + AlgorithmsByType, + Type, + Algorithm, } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; +import { toAlgorithmInfo, translate } from "./util"; + // constants used to identify navigation selections that are not // generator algorithms const FORWARDER = "forwarder"; @@ -89,11 +89,14 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy private ariaLive: LiveAnnouncer, ) {} + /** exports algorithm symbols to the template */ + protected readonly Algorithm = Algorithm; + /** Binds the component to a specific user's settings. When this input is not provided, * the form binds to the active user */ @Input() - account: Account | null; + account: Account | null = null; protected account$ = new ReplaySubject(1); @@ -110,7 +113,11 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy async ngOnChanges(changes: SimpleChanges) { const account = changes?.account; - if (account?.previousValue?.id !== account?.currentValue?.id) { + if ( + account && + account.currentValue.id && + account.previousValue.id !== account.currentValue.id + ) { this.log.debug( { previousUserId: account?.previousValue?.id as UserId, @@ -118,7 +125,7 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy }, "account input change detected", ); - this.account$.next(this.account); + this.account$.next(account.currentValue.id); } } @@ -134,18 +141,18 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy /** emits algorithm info when the selected algorithm changes */ @Output() - readonly onAlgorithm = new EventEmitter(); + readonly onAlgorithm = new EventEmitter(); /** Removes bottom margin from internal elements */ @Input({ transform: coerceBooleanProperty }) disableMargin = false; /** Tracks the selected generation algorithm */ protected username = this.formBuilder.group({ - nav: [null as string], + nav: [null as string | null], }); protected forwarder = this.formBuilder.group({ - nav: [null as string], + nav: [null as string | null], }); async ngOnInit() { @@ -154,38 +161,63 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy }); if (!this.account) { - this.account = await firstValueFrom(this.accountService.activeAccount$); - this.log.info( - { userId: this.account.id }, - "account not specified; using active account settings", - ); - this.account$.next(this.account); + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + this.log.panic("active account cannot be `null`."); + } + + this.log.info({ userId: account.id }, "account not specified; using active account settings"); + this.account$.next(account); } - this.generatorService - .algorithms$(["email", "username"], { account$: this.account$ }) + combineLatest([ + this.generatorService.algorithms$("email", { account$: this.account$ }), + this.generatorService.algorithms$("username", { account$: this.account$ }), + ]) .pipe( + map((algorithms) => algorithms.flat()), map((algorithms) => { - const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); + // construct options for username and email algorithms; replace forwarder + // entry with a virtual entry for drill-down + const usernames = algorithms.filter((a) => !isForwarderExtensionId(a.id)); + usernames.sort((a, b) => a.weight - b.weight); const usernameOptions = this.toOptions(usernames); - usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwardedEmail") }); + usernameOptions.splice(-1, 0, { + value: FORWARDER, + label: this.i18nService.t("forwardedEmail"), + }); - const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id)); + // construct options for forwarder algorithms; they get their own selection box + const forwarders = algorithms.filter((a) => isForwarderExtensionId(a.id)); + forwarders.sort((a, b) => a.weight - b.weight); const forwarderOptions = this.toOptions(forwarders); forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") }); return [usernameOptions, forwarderOptions] as const; }), + tap((algorithms) => + this.log.debug({ algorithms: algorithms as object }, "algorithms loaded"), + ), takeUntil(this.destroyed), ) .subscribe(([usernames, forwarders]) => { - this.typeOptions$.next(usernames); - this.forwarderOptions$.next(forwarders); + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.typeOptions$.next(usernames); + this.forwarderOptions$.next(forwarders); + }); }); - this.algorithm$ + this.maybeAlgorithm$ .pipe( - map((a) => a?.description), + map((a) => { + if (a?.i18nKeys?.description) { + return translate(a.i18nKeys.description, this.i18nService); + } else { + return ""; + } + }), takeUntil(this.destroyed), ) .subscribe((hint) => { @@ -197,10 +229,12 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy }); // wire up the generator - this.algorithm$ + this.generatorService + .generate$({ + on$: this.generate$, + account$: this.account$, + }) .pipe( - filter((algorithm) => !!algorithm), - switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), catchError((error: unknown, generator) => { if (typeof error === "string") { this.toastService.showToast({ @@ -215,11 +249,14 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy // continue with origin stream return generator; }), - withLatestFrom(this.account$, this.algorithm$), + withLatestFrom(this.account$, this.maybeAlgorithm$), takeUntil(this.destroyed), ) .subscribe(([generated, account, algorithm]) => { - this.log.debug({ source: generated.source }, "credential generated"); + this.log.debug( + { source: generated.source ?? null, algorithm: algorithm?.id ?? null }, + "credential generated", + ); this.generatorHistoryService .track(account.id, generated.credential, generated.category, generated.generationDate) @@ -230,12 +267,12 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { - if (generated.source === this.USER_REQUEST) { - this.announce(algorithm.onGeneratedMessage); + if (algorithm && generated.source === this.USER_REQUEST) { + this.announce(translate(algorithm.i18nKeys.credentialGenerated, this.i18nService)); } + this.generatedCredential$.next(generated); this.onGenerated.next(generated); - this.value$.next(generated.credential); }); }); @@ -248,24 +285,31 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy this.username.valueChanges .pipe( - map( - (username): CascadeValue => - username.nav === FORWARDER - ? { nav: username.nav } - : { nav: username.nav, algorithm: JSON.parse(username.nav) }, - ), + map((username): CascadeValue => { + if (username.nav === FORWARDER) { + return { nav: username.nav }; + } else if (username.nav) { + return { nav: username.nav, algorithm: JSON.parse(username.nav) }; + } else { + const [algorithm] = AlgorithmsByType[Type.username]; + return { nav: JSON.stringify(algorithm), algorithm }; + } + }), takeUntil(this.destroyed), ) .subscribe(activeIdentifier$); this.forwarder.valueChanges .pipe( - map( - (forwarder): CascadeValue => - forwarder.nav === NONE_SELECTED - ? { nav: forwarder.nav } - : { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) }, - ), + map((forwarder): CascadeValue => { + if (forwarder.nav === NONE_SELECTED) { + return { nav: forwarder.nav }; + } else if (forwarder.nav) { + return { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) }; + } else { + return { nav: NONE_SELECTED }; + } + }), takeUntil(this.destroyed), ) .subscribe(activeForwarder$); @@ -276,7 +320,7 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy map(([username, forwarder]) => { const showForwarder = !username.algorithm; const forwarderId = - showForwarder && isForwarderIntegration(forwarder.algorithm) + showForwarder && forwarder.algorithm && isForwarderExtensionId(forwarder.algorithm) ? forwarder.algorithm.forwarder : null; return [showForwarder, forwarderId] as const; @@ -306,57 +350,61 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy return null; } }), - distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + distinctUntilChanged((prev, next) => { + if (prev === null || next === null) { + return false; + } else { + return isSameAlgorithm(prev.id, next.id); + } + }), takeUntil(this.destroyed), ) .subscribe((algorithm) => { - this.log.debug(algorithm, "algorithm selected"); + this.log.debug({ algorithm: algorithm?.id ?? null }, "algorithm selected"); // update subjects within the angular zone so that the // template bindings refresh immediately this.zone.run(() => { - this.algorithm$.next(algorithm); - this.onAlgorithm.next(algorithm); + this.maybeAlgorithm$.next(algorithm); + if (algorithm) { + this.onAlgorithm.next(toAlgorithmInfo(algorithm, this.i18nService)); + } else { + this.onAlgorithm.next(null); + } }); }); // assume the last-visible generator algorithm is the user's preferred one const preferences = await this.generatorService.preferences({ account$: this.account$ }); this.algorithm$ - .pipe( - filter((algorithm) => !!algorithm), - withLatestFrom(preferences), - takeUntil(this.destroyed), - ) + .pipe(withLatestFrom(preferences), takeUntil(this.destroyed)) .subscribe(([algorithm, preference]) => { if (isEmailAlgorithm(algorithm.id)) { - this.log.info( - { algorithm, category: CredentialCategories.email }, - "algorithm preferences updated", - ); preference.email.algorithm = algorithm.id; preference.email.updated = new Date(); } else if (isUsernameAlgorithm(algorithm.id)) { - this.log.info( - { algorithm, category: CredentialCategories.username }, - "algorithm preferences updated", - ); preference.username.algorithm = algorithm.id; preference.username.updated = new Date(); } else { return; } + this.log.info( + { algorithm: algorithm.id, type: algorithm.type }, + "algorithm preferences updated", + ); preferences.next(preference); }); preferences .pipe( map(({ email, username }) => { - const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null; const usernamePref = email.updated > username.updated ? email : username; + const forwarderPref = isForwarderExtensionId(usernamePref.algorithm) + ? usernamePref + : null; - // inject drilldown flags + // inject drill-down flags const forwarderNav = !forwarderPref ? NONE_SELECTED : JSON.stringify(forwarderPref.algorithm); @@ -368,7 +416,7 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy selection: { nav: userNav }, active: { nav: userNav, - algorithm: forwarderPref ? null : usernamePref.algorithm, + algorithm: forwarderPref ? undefined : usernamePref.algorithm, }, }, forwarder: { @@ -385,6 +433,14 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy takeUntil(this.destroyed), ) .subscribe(({ username, forwarder }) => { + this.log.debug( + { + username: username.selection, + forwarder: forwarder.selection, + }, + "navigation updated", + ); + // update navigation; break subscription loop this.username.setValue(username.selection, { emitEvent: false }); this.forwarder.setValue(forwarder.selection, { emitEvent: false }); @@ -396,17 +452,16 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy // automatically regenerate when the algorithm switches if the algorithm // allows it; otherwise set a placeholder - this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.maybeAlgorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { this.zone.run(() => { - if (!a || a.onlyOnRequest) { - this.log.debug("autogeneration disabled; clearing generated credential"); - this.value$.next("-"); - } else { + if (a?.capabilities?.autogenerate) { this.log.debug("autogeneration enabled"); - this.generate("autogenerate").catch((e: unknown) => { this.log.error(e as object, "a failure occurred during autogeneration"); }); + } else { + this.log.debug("autogeneration disabled; clearing generated credential"); + this.generatedCredential$.next(undefined); } }); }); @@ -414,34 +469,6 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy this.log.debug("component initialized"); } - private typeToGenerator$(algorithm: CredentialAlgorithm) { - const dependencies = { - on$: this.generate$, - account$: this.account$, - }; - - this.log.debug({ algorithm }, "constructing generation stream"); - - switch (algorithm) { - case "catchall": - return this.generatorService.generate$(Generators.catchall, dependencies); - - case "subaddress": - return this.generatorService.generate$(Generators.subaddress, dependencies); - - case "username": - return this.generatorService.generate$(Generators.username, dependencies); - } - - if (isForwarderIntegration(algorithm)) { - const forwarder = getForwarderConfiguration(algorithm.forwarder); - const configuration = toCredentialGeneratorConfiguration(forwarder); - return this.generatorService.generate$(configuration, dependencies); - } - - this.log.panic({ algorithm }, `Invalid generator type: "${algorithm}"`); - } - private announce(message: string) { this.ariaLive.announce(message).catch((e) => this.logService.error(e)); } @@ -450,7 +477,7 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy protected typeOptions$ = new BehaviorSubject[]>([]); /** Tracks the currently selected forwarder. */ - protected forwarderId$ = new BehaviorSubject(null); + protected forwarderId$ = new BehaviorSubject(null); /** Lists the credential types supported by the component. */ protected forwarderOptions$ = new BehaviorSubject[]>([]); @@ -458,19 +485,30 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy /** Tracks forwarder control visibility */ protected showForwarder$ = new BehaviorSubject(false); - /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + /** tracks the currently selected algorithm; emits `null` when no algorithm selected */ + protected maybeAlgorithm$ = new ReplaySubject(1); + + /** tracks the last valid algorithm selection */ + protected algorithm$ = this.maybeAlgorithm$.pipe( + filter((algorithm): algorithm is AlgorithmMetadata => !!algorithm), + ); /** Emits hint key for the currently selected credential type */ protected credentialTypeHint$ = new ReplaySubject(1); + private readonly generatedCredential$ = new BehaviorSubject( + undefined, + ); + /** Emits the last generated value. */ - protected readonly value$ = new BehaviorSubject(""); + protected readonly value$ = this.generatedCredential$.pipe( + map((generated) => generated?.credential ?? "-"), + ); /** Emits when a new credential is requested */ private readonly generate$ = new Subject(); - protected showAlgorithm$ = this.algorithm$.pipe( + protected showAlgorithm$ = this.maybeAlgorithm$.pipe( combineLatestWith(this.showForwarder$), map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)), ); @@ -479,24 +517,21 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy * Emits the copy button aria-label respective of the selected credential type */ protected credentialTypeCopyLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ copy }) => copy), + map(({ i18nKeys: { copyCredential } }) => translate(copyCredential, this.i18nService)), ); /** * Emits the generate button aria-label respective of the selected credential type */ protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ generate }) => generate), + map(({ i18nKeys: { generateCredential } }) => translate(generateCredential, this.i18nService)), ); /** * Emits the copy credential toast respective of the selected credential type */ protected credentialTypeLabel$ = this.algorithm$.pipe( - filter((algorithm) => !!algorithm), - map(({ credentialType }) => credentialType), + map(({ i18nKeys: { credentialType } }) => translate(credentialType, this.i18nService)), ); /** Identifies generator requests that were requested by the user */ @@ -507,15 +542,20 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy * origin in the debugger. */ protected async generate(source: string) { - const request = { source, website: this.website }; + const algorithm = await firstValueFrom(this.algorithm$); + const request: GenerateRequest = { source, algorithm: algorithm.id }; + if (this.website) { + request.website = this.website; + } + this.log.debug(request, "generation requested"); this.generate$.next(request); } - private toOptions(algorithms: AlgorithmInfo[]) { + private toOptions(algorithms: AlgorithmMetadata[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: JSON.stringify(algorithm.id), - label: algorithm.name, + label: translate(algorithm.i18nKeys.name, this.i18nService), })); return options; @@ -528,9 +568,11 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy // finalize subjects this.generate$.complete(); - this.value$.complete(); + this.generatedCredential$.complete(); // finalize component bindings this.onGenerated.complete(); + + this.log.debug("component destroyed"); } } diff --git a/libs/tools/generator/components/src/username-settings.component.ts b/libs/tools/generator/components/src/username-settings.component.ts index ea3cfbd35fb..7a12957f906 100644 --- a/libs/tools/generator/components/src/username-settings.component.ts +++ b/libs/tools/generator/components/src/username-settings.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, EventEmitter, @@ -17,7 +15,7 @@ import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { CredentialGeneratorService, EffUsernameGenerationOptions, - Generators, + BuiltIn, } from "@bitwarden/generator-core"; /** Options group for usernames */ @@ -28,7 +26,6 @@ import { }) export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Instantiates the component - * @param accountService queries user availability * @param generatorService settings and policy logic * @param formBuilder reactive form controls */ @@ -38,9 +35,11 @@ export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy { ) {} /** Binds the component to a specific user's settings. + * @remarks this is initialized to null but since it's a required input it'll + * never have that value in practice. */ @Input({ required: true }) - account: Account; + account: Account = null!; protected account$ = new ReplaySubject(1); @@ -53,19 +52,19 @@ export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like * to receive live settings updates including the initial update, - * use `CredentialGeneratorService.settings$(...)` instead. + * use `CredentialGeneratorService.settings(...)` instead. */ @Output() readonly onUpdated = new EventEmitter(); /** The template's control bindings */ protected settings = this.formBuilder.group({ - wordCapitalize: [Generators.username.settings.initial.wordCapitalize], - wordIncludeNumber: [Generators.username.settings.initial.wordIncludeNumber], + wordCapitalize: [false], + wordIncludeNumber: [false], }); async ngOnInit() { - const settings = await this.generatorService.settings(Generators.username, { + const settings = await this.generatorService.settings(BuiltIn.effWordList, { account$: this.account$, }); @@ -79,7 +78,7 @@ export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy { this.saveSettings .pipe( withLatestFrom(this.settings.valueChanges), - map(([, settings]) => settings), + map(([, settings]) => settings as EffUsernameGenerationOptions), takeUntil(this.destroyed$), ) .subscribe(settings); diff --git a/libs/tools/generator/components/src/util.ts b/libs/tools/generator/components/src/util.ts index 95e55d816ce..4b0ce4383de 100644 --- a/libs/tools/generator/components/src/util.ts +++ b/libs/tools/generator/components/src/util.ts @@ -1,71 +1,46 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { ValidatorFn, Validators } from "@angular/forms"; -import { distinctUntilChanged, map, pairwise, pipe, skipWhile, startWith, takeWhile } from "rxjs"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { I18nKeyOrLiteral } from "@bitwarden/common/tools/types"; +import { isI18nKey } from "@bitwarden/common/tools/util"; +import { AlgorithmInfo, AlgorithmMetadata } from "@bitwarden/generator-core"; -import { AnyConstraint, Constraints } from "@bitwarden/common/tools/types"; -import { UserId } from "@bitwarden/common/types/guid"; -import { CredentialGeneratorConfiguration } from "@bitwarden/generator-core"; +/** Adapts {@link AlgorithmMetadata} to legacy {@link AlgorithmInfo} structure. */ +export function toAlgorithmInfo(metadata: AlgorithmMetadata, i18n: I18nService) { + const info: AlgorithmInfo = { + id: metadata.id, + type: metadata.type, + name: translate(metadata.i18nKeys.name, i18n), + generate: translate(metadata.i18nKeys.generateCredential, i18n), + onGeneratedMessage: translate(metadata.i18nKeys.credentialGenerated, i18n), + credentialType: translate(metadata.i18nKeys.credentialType, i18n), + copy: translate(metadata.i18nKeys.copyCredential, i18n), + useGeneratedValue: translate(metadata.i18nKeys.useCredential, i18n), + onlyOnRequest: !metadata.capabilities.autogenerate, + request: metadata.capabilities.fields, + }; -export function completeOnAccountSwitch() { - return pipe( - map(({ id }: { id: UserId | null }) => id), - skipWhile((id) => !id), - startWith(null as UserId), - pairwise(), - takeWhile(([prev, next]) => (prev ?? next) === next), - map(([_, id]) => id), - distinctUntilChanged(), - ); + if (metadata.i18nKeys.description) { + info.description = translate(metadata.i18nKeys.description, i18n); + } + + return info; } -export function toValidators( - target: keyof Settings, - configuration: CredentialGeneratorConfiguration, - policy?: Constraints, -) { - const validators: Array = []; - - // widen the types to avoid typecheck issues - const config: AnyConstraint = configuration.settings.constraints[target]; - const runtime: AnyConstraint = policy[target]; - - const required = getConstraint("required", config, runtime) ?? false; - if (required) { - validators.push(Validators.required); - } - - const maxLength = getConstraint("maxLength", config, runtime); - if (maxLength !== undefined) { - validators.push(Validators.maxLength(maxLength)); - } - - const minLength = getConstraint("minLength", config, runtime); - if (minLength !== undefined) { - validators.push(Validators.minLength(config.minLength)); - } - - const min = getConstraint("min", config, runtime); - if (min !== undefined) { - validators.push(Validators.min(min)); - } - - const max = getConstraint("max", config, runtime); - if (max !== undefined) { - validators.push(Validators.max(max)); - } - - return validators; +/** Translates an internationalization key + * @param key the key to translate + * @param i18n the service providing translations + * @returns the translated key; if the key is a literal the literal + * is returned instead. + */ +export function translate(key: I18nKeyOrLiteral, i18n: I18nService) { + return isI18nKey(key) ? i18n.t(key) : key.literal; } -function getConstraint( - key: Key, - config: AnyConstraint, - policy?: AnyConstraint, -) { - if (policy && key in policy) { - return policy[key] ?? config[key]; - } else if (config && key in config) { - return config[key]; - } +/** Returns true when min < max + * @param min the minimum value to check; when this is nullish it becomes 0. + * @param max the maximum value to check; when this is nullish it becomes +Infinity. + */ +export function hasRangeOfValues(min?: number, max?: number) { + const minimum = min ?? 0; + const maximum = max ?? Number.POSITIVE_INFINITY; + return minimum < maximum; } diff --git a/libs/tools/generator/core/src/abstractions/credential-generator-service.abstraction.ts b/libs/tools/generator/core/src/abstractions/credential-generator-service.abstraction.ts new file mode 100644 index 00000000000..74f78514594 --- /dev/null +++ b/libs/tools/generator/core/src/abstractions/credential-generator-service.abstraction.ts @@ -0,0 +1,104 @@ +import { Observable } from "rxjs"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BoundDependency, OnDependency } from "@bitwarden/common/tools/dependencies"; +import { VendorId } from "@bitwarden/common/tools/extension"; +import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; + +import { + CredentialAlgorithm, + GeneratorMetadata, + GeneratorProfile, + CredentialType, +} from "../metadata"; +import { AlgorithmMetadata } from "../metadata/algorithm-metadata"; +import { + CredentialPreference, + ForwarderOptions, + GeneratedCredential, + GenerateRequest, +} from "../types"; +import { GeneratorConstraints } from "../types/generator-constraints"; + +/** Generates credentials used in identity and/or authentication flows. + */ +export abstract class CredentialGeneratorService { + /** Generates a stream of credentials + * @param dependencies.on$ Required. A new credential is emitted when this emits. + */ + abstract generate$: ( + dependencies: OnDependency & BoundDependency<"account", Account>, + ) => Observable; + + /** Emits metadata for the set of algorithms available to a user. + * @param type the set of algorithms + * @param dependencies.account$ algorithms are filtered to only + * those matching the provided account's policy. + * @returns An observable that emits algorithm metadata. + */ + abstract algorithms$: ( + type: CredentialType, + dependencies: BoundDependency<"account", Account>, + ) => Observable; + + /** Lists metadata for a set of algorithms. + * @param type the type or types of algorithms + * @returns A list containing the requested metadata. + * @remarks this is a raw data interface. To apply rules such as algorithm availability, + * use {@link algorithms$} instead. + */ + abstract algorithms: (type: CredentialType | CredentialType[]) => AlgorithmMetadata[]; + + /** Look up the metadata for a specific generator algorithm + * @param id identifies the algorithm + * @returns the requested metadata, or `null` if the metadata wasn't found. + */ + abstract algorithm: (id: CredentialAlgorithm) => AlgorithmMetadata; + + /** Look up the forwarder metadata for a vendor. + * @param id identifies the vendor proving the forwarder + */ + abstract forwarder: (id: VendorId) => GeneratorMetadata; + + /** Get a subject bound to credential generator preferences. + * @param dependencies.account$ identifies the account to which the preferences are bound + * @returns a subject bound to the user's preferences + * @remarks Preferences determine which algorithms are used when generating a + * credential from a credential category (e.g. `PassX` or `Username`). Preferences + * should not be used to hold navigation history. Use {@link @bitwarden/generator-navigation} + * instead. + */ + abstract preferences: ( + dependencies: BoundDependency<"account", Account>, + ) => UserStateSubject; + + /** Get a subject bound to a specific user's settings. the subject enforces policy for the + * settings by automatically updating incorrect values to those allowed by policy. + * @param metadata determines which generator's settings are loaded + * @param dependencies.account$ identifies the account to which the settings are bound + * @param profile identifies the generator profile to load; when this is not specified + * the user's account profile is loaded. + * @returns a subject bound to the requested user's generator settings + * @remarks Generator metadata can be looked up using {@link BuiltIn} and {@link forwarder}. + */ + abstract settings: ( + metadata: Readonly>, + dependencies: BoundDependency<"account", Account>, + profile?: GeneratorProfile, + ) => UserStateSubject; + + /** Get the policy constraints for the provided configuration + * @param metadata determines which generator's policy is loaded + * @param dependencies.account$ determines which user's policy is loaded + * @param profile identifies the generator profile to load; when this is not specified + * the user's account profile is loaded. + * @returns an observable that emits the policy once `dependencies.account$` + * and the policy become available. + * @remarks Generator metadata can be looked up using {@link BuiltIn} and {@link forwarder}. + */ + abstract policy$: ( + metadata: Readonly>, + dependencies: BoundDependency<"account", Account>, + profile?: GeneratorProfile, + ) => Observable>; +} diff --git a/libs/tools/generator/core/src/abstractions/generator.service.abstraction.ts b/libs/tools/generator/core/src/abstractions/generator.service.abstraction.ts index 221c0b8b007..9c12dba44c7 100644 --- a/libs/tools/generator/core/src/abstractions/generator.service.abstraction.ts +++ b/libs/tools/generator/core/src/abstractions/generator.service.abstraction.ts @@ -9,6 +9,7 @@ import { PolicyEvaluator } from "./policy-evaluator.abstraction"; /** Generates credentials used for user authentication * @typeParam Options the credential generation configuration * @typeParam Policy the policy enforced by the generator + * @deprecated Use {@link CredentialGeneratorService} instead. */ export abstract class GeneratorService { /** An observable monitoring the options saved to disk. diff --git a/libs/tools/generator/core/src/abstractions/index.ts b/libs/tools/generator/core/src/abstractions/index.ts index 471ec89ea32..4f45f985ef2 100644 --- a/libs/tools/generator/core/src/abstractions/index.ts +++ b/libs/tools/generator/core/src/abstractions/index.ts @@ -1,3 +1,4 @@ +export { CredentialGeneratorService } from "./credential-generator-service.abstraction"; export { GeneratorService } from "./generator.service.abstraction"; export { GeneratorStrategy } from "./generator-strategy.abstraction"; export { PolicyEvaluator } from "./policy-evaluator.abstraction"; diff --git a/libs/tools/generator/core/src/data/default-addy-io-options.ts b/libs/tools/generator/core/src/data/default-addy-io-options.ts deleted file mode 100644 index 2ebeefff6a8..00000000000 --- a/libs/tools/generator/core/src/data/default-addy-io-options.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { EmailDomainOptions, SelfHostedApiOptions } from "../types"; - -export const DefaultAddyIoOptions: SelfHostedApiOptions & EmailDomainOptions = Object.freeze({ - website: null, - baseUrl: "https://app.addy.io", - token: "", - domain: "", -}); diff --git a/libs/tools/generator/core/src/data/default-credential-preferences.ts b/libs/tools/generator/core/src/data/default-credential-preferences.ts deleted file mode 100644 index c26d44b3b79..00000000000 --- a/libs/tools/generator/core/src/data/default-credential-preferences.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CredentialPreference } from "../types"; - -import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "./generator-types"; - -export const DefaultCredentialPreferences: CredentialPreference = Object.freeze({ - email: Object.freeze({ - algorithm: EmailAlgorithms[0], - updated: new Date(0), - }), - password: Object.freeze({ - algorithm: PasswordAlgorithms[0], - updated: new Date(0), - }), - username: Object.freeze({ - algorithm: UsernameAlgorithms[0], - updated: new Date(0), - }), -}); diff --git a/libs/tools/generator/core/src/data/default-duck-duck-go-options.ts b/libs/tools/generator/core/src/data/default-duck-duck-go-options.ts deleted file mode 100644 index c600e6e512a..00000000000 --- a/libs/tools/generator/core/src/data/default-duck-duck-go-options.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ApiOptions } from "../types"; - -export const DefaultDuckDuckGoOptions: ApiOptions = Object.freeze({ - website: null, - token: "", -}); diff --git a/libs/tools/generator/core/src/data/default-fastmail-options.ts b/libs/tools/generator/core/src/data/default-fastmail-options.ts deleted file mode 100644 index 18faefc4643..00000000000 --- a/libs/tools/generator/core/src/data/default-fastmail-options.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ApiOptions, EmailPrefixOptions } from "../types"; - -export const DefaultFastmailOptions: ApiOptions & EmailPrefixOptions = Object.freeze({ - website: "", - domain: "", - prefix: "", - token: "", -}); diff --git a/libs/tools/generator/core/src/data/default-firefox-relay-options.ts b/libs/tools/generator/core/src/data/default-firefox-relay-options.ts deleted file mode 100644 index 20433a3e12a..00000000000 --- a/libs/tools/generator/core/src/data/default-firefox-relay-options.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ApiOptions } from "../types"; - -export const DefaultFirefoxRelayOptions: ApiOptions = Object.freeze({ - website: null, - token: "", -}); diff --git a/libs/tools/generator/core/src/data/default-forward-email-options.ts b/libs/tools/generator/core/src/data/default-forward-email-options.ts deleted file mode 100644 index d5175534a05..00000000000 --- a/libs/tools/generator/core/src/data/default-forward-email-options.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ApiOptions, EmailDomainOptions } from "../types"; - -export const DefaultForwardEmailOptions: ApiOptions & EmailDomainOptions = Object.freeze({ - website: null, - token: "", - domain: "", -}); diff --git a/libs/tools/generator/core/src/data/default-simple-login-options.ts b/libs/tools/generator/core/src/data/default-simple-login-options.ts deleted file mode 100644 index 965b1222cd3..00000000000 --- a/libs/tools/generator/core/src/data/default-simple-login-options.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SelfHostedApiOptions } from "../types"; - -export const DefaultSimpleLoginOptions: SelfHostedApiOptions = Object.freeze({ - website: null, - baseUrl: "https://app.simplelogin.io", - token: "", -}); diff --git a/libs/tools/generator/core/src/data/generator-types.ts b/libs/tools/generator/core/src/data/generator-types.ts deleted file mode 100644 index e54ec34e497..00000000000 --- a/libs/tools/generator/core/src/data/generator-types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Types of passwords that may be generated by the credential generator */ -export const PasswordAlgorithms = Object.freeze(["password", "passphrase"] as const); - -/** Types of usernames that may be generated by the credential generator */ -export const UsernameAlgorithms = Object.freeze(["username"] as const); - -/** Types of email addresses that may be generated by the credential generator */ -export const EmailAlgorithms = Object.freeze(["catchall", "subaddress"] as const); - -/** All types of credentials that may be generated by the credential generator */ -export const CredentialAlgorithms = Object.freeze([ - ...PasswordAlgorithms, - ...UsernameAlgorithms, - ...EmailAlgorithms, -] as const); diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts deleted file mode 100644 index da87c60f1f4..00000000000 --- a/libs/tools/generator/core/src/data/generators.ts +++ /dev/null @@ -1,422 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; -import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; -import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; -import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; -import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; - -import { - EmailRandomizer, - ForwarderConfiguration, - PasswordRandomizer, - UsernameRandomizer, -} from "../engine"; -import { Forwarder } from "../engine/forwarder"; -import { - DefaultPolicyEvaluator, - DynamicPasswordPolicyConstraints, - PassphraseGeneratorOptionsEvaluator, - passphraseLeastPrivilege, - PassphrasePolicyConstraints, - PasswordGeneratorOptionsEvaluator, - passwordLeastPrivilege, -} from "../policies"; -import { CatchallConstraints } from "../policies/catchall-constraints"; -import { SubaddressConstraints } from "../policies/subaddress-constraints"; -import { - CatchallGenerationOptions, - CredentialGenerator, - CredentialGeneratorConfiguration, - EffUsernameGenerationOptions, - GeneratorDependencyProvider, - NoPolicy, - PassphraseGenerationOptions, - PassphraseGeneratorPolicy, - PasswordGenerationOptions, - PasswordGeneratorPolicy, - SubaddressGenerationOptions, -} from "../types"; - -import { DefaultCatchallOptions } from "./default-catchall-options"; -import { DefaultEffUsernameOptions } from "./default-eff-username-options"; -import { DefaultPassphraseBoundaries } from "./default-passphrase-boundaries"; -import { DefaultPassphraseGenerationOptions } from "./default-passphrase-generation-options"; -import { DefaultPasswordBoundaries } from "./default-password-boundaries"; -import { DefaultPasswordGenerationOptions } from "./default-password-generation-options"; -import { DefaultSubaddressOptions } from "./default-subaddress-generator-options"; - -const PASSPHRASE: CredentialGeneratorConfiguration< - PassphraseGenerationOptions, - PassphraseGeneratorPolicy -> = Object.freeze({ - id: "passphrase", - category: "password", - nameKey: "passphrase", - generateKey: "generatePassphrase", - onGeneratedMessageKey: "passphraseGenerated", - credentialTypeKey: "passphrase", - copyKey: "copyPassphrase", - useGeneratedValueKey: "useThisPassword", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new PasswordRandomizer(dependencies.randomizer); - }, - }, - settings: { - initial: DefaultPassphraseGenerationOptions, - constraints: { - numWords: { - min: DefaultPassphraseBoundaries.numWords.min, - max: DefaultPassphraseBoundaries.numWords.max, - recommendation: DefaultPassphraseGenerationOptions.numWords, - }, - wordSeparator: { maxLength: 1 }, - }, - account: { - key: "passphraseGeneratorSettings", - target: "object", - format: "plain", - classifier: new PublicClassifier([ - "numWords", - "wordSeparator", - "capitalize", - "includeNumber", - ]), - state: GENERATOR_DISK, - initial: DefaultPassphraseGenerationOptions, - options: { - deserializer: (value) => value, - clearOn: ["logout"], - }, - } satisfies ObjectKey, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: Object.freeze({ - minNumberWords: 0, - capitalize: false, - includeNumber: false, - }), - combine: passphraseLeastPrivilege, - createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), - toConstraints: (policy) => - new PassphrasePolicyConstraints(policy, PASSPHRASE.settings.constraints), - }, -}); - -const PASSWORD: CredentialGeneratorConfiguration< - PasswordGenerationOptions, - PasswordGeneratorPolicy -> = Object.freeze({ - id: "password", - category: "password", - nameKey: "password", - generateKey: "generatePassword", - onGeneratedMessageKey: "passwordGenerated", - credentialTypeKey: "password", - copyKey: "copyPassword", - useGeneratedValueKey: "useThisPassword", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new PasswordRandomizer(dependencies.randomizer); - }, - }, - settings: { - initial: DefaultPasswordGenerationOptions, - constraints: { - length: { - min: DefaultPasswordBoundaries.length.min, - max: DefaultPasswordBoundaries.length.max, - recommendation: DefaultPasswordGenerationOptions.length, - }, - minNumber: { - min: DefaultPasswordBoundaries.minDigits.min, - max: DefaultPasswordBoundaries.minDigits.max, - }, - minSpecial: { - min: DefaultPasswordBoundaries.minSpecialCharacters.min, - max: DefaultPasswordBoundaries.minSpecialCharacters.max, - }, - }, - account: { - key: "passwordGeneratorSettings", - target: "object", - format: "plain", - classifier: new PublicClassifier([ - "length", - "ambiguous", - "uppercase", - "minUppercase", - "lowercase", - "minLowercase", - "number", - "minNumber", - "special", - "minSpecial", - ]), - state: GENERATOR_DISK, - initial: DefaultPasswordGenerationOptions, - options: { - deserializer: (value) => value, - clearOn: ["logout"], - }, - } satisfies ObjectKey, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: Object.freeze({ - minLength: 0, - useUppercase: false, - useLowercase: false, - useNumbers: false, - numberCount: 0, - useSpecial: false, - specialCount: 0, - }), - combine: passwordLeastPrivilege, - createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), - toConstraints: (policy) => - new DynamicPasswordPolicyConstraints(policy, PASSWORD.settings.constraints), - }, -}); - -const USERNAME: CredentialGeneratorConfiguration = - Object.freeze({ - id: "username", - category: "username", - nameKey: "randomWord", - generateKey: "generateUsername", - onGeneratedMessageKey: "usernameGenerated", - credentialTypeKey: "username", - copyKey: "copyUsername", - useGeneratedValueKey: "useThisUsername", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new UsernameRandomizer(dependencies.randomizer); - }, - }, - settings: { - initial: DefaultEffUsernameOptions, - constraints: {}, - account: { - key: "effUsernameGeneratorSettings", - target: "object", - format: "plain", - classifier: new PublicClassifier([ - "wordCapitalize", - "wordIncludeNumber", - ]), - state: GENERATOR_DISK, - initial: DefaultEffUsernameOptions, - options: { - deserializer: (value) => value, - clearOn: ["logout"], - }, - } satisfies ObjectKey, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, - combine(_acc: NoPolicy, _policy: Policy) { - return {}; - }, - createEvaluator(_policy: NoPolicy) { - return new DefaultPolicyEvaluator(); - }, - toConstraints(_policy: NoPolicy) { - return new IdentityConstraint(); - }, - }, - }); - -const CATCHALL: CredentialGeneratorConfiguration = - Object.freeze({ - id: "catchall", - category: "email", - nameKey: "catchallEmail", - descriptionKey: "catchallEmailDesc", - generateKey: "generateEmail", - onGeneratedMessageKey: "emailGenerated", - credentialTypeKey: "email", - copyKey: "copyEmail", - useGeneratedValueKey: "useThisEmail", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new EmailRandomizer(dependencies.randomizer); - }, - }, - settings: { - initial: DefaultCatchallOptions, - constraints: { catchallDomain: { minLength: 1 } }, - account: { - key: "catchallGeneratorSettings", - target: "object", - format: "plain", - classifier: new PublicClassifier([ - "catchallType", - "catchallDomain", - ]), - state: GENERATOR_DISK, - initial: { - catchallType: "random", - catchallDomain: "", - }, - options: { - deserializer: (value) => value, - clearOn: ["logout"], - }, - } satisfies ObjectKey, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, - combine(_acc: NoPolicy, _policy: Policy) { - return {}; - }, - createEvaluator(_policy: NoPolicy) { - return new DefaultPolicyEvaluator(); - }, - toConstraints(_policy: NoPolicy, email: string) { - return new CatchallConstraints(email); - }, - }, - }); - -const SUBADDRESS: CredentialGeneratorConfiguration = - Object.freeze({ - id: "subaddress", - category: "email", - nameKey: "plusAddressedEmail", - descriptionKey: "plusAddressedEmailDesc", - generateKey: "generateEmail", - onGeneratedMessageKey: "emailGenerated", - credentialTypeKey: "email", - copyKey: "copyEmail", - useGeneratedValueKey: "useThisEmail", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new EmailRandomizer(dependencies.randomizer); - }, - }, - settings: { - initial: DefaultSubaddressOptions, - constraints: {}, - account: { - key: "subaddressGeneratorSettings", - target: "object", - format: "plain", - classifier: new PublicClassifier([ - "subaddressType", - "subaddressEmail", - ]), - state: GENERATOR_DISK, - initial: { - subaddressType: "random", - subaddressEmail: "", - }, - options: { - deserializer: (value) => value, - clearOn: ["logout"], - }, - } satisfies ObjectKey, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, - combine(_acc: NoPolicy, _policy: Policy) { - return {}; - }, - createEvaluator(_policy: NoPolicy) { - return new DefaultPolicyEvaluator(); - }, - toConstraints(_policy: NoPolicy, email: string) { - return new SubaddressConstraints(email); - }, - }, - }); - -export function toCredentialGeneratorConfiguration( - configuration: ForwarderConfiguration, -) { - const forwarder = Object.freeze({ - id: { forwarder: configuration.id }, - category: "email", - nameKey: configuration.name, - descriptionKey: "forwardedEmailDesc", - generateKey: "generateEmail", - onGeneratedMessageKey: "emailGenerated", - credentialTypeKey: "email", - copyKey: "copyEmail", - useGeneratedValueKey: "useThisEmail", - onlyOnRequest: true, - request: configuration.forwarder.request, - engine: { - create(dependencies: GeneratorDependencyProvider) { - // FIXME: figure out why `configuration` fails to typecheck - const config: any = configuration; - return new Forwarder(config, dependencies.client, dependencies.i18nService); - }, - }, - settings: { - initial: configuration.forwarder.defaultSettings, - constraints: configuration.forwarder.settingsConstraints, - account: configuration.forwarder.local.settings, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, - combine(_acc: NoPolicy, _policy: Policy) { - return {}; - }, - createEvaluator(_policy: NoPolicy) { - return new DefaultPolicyEvaluator(); - }, - toConstraints(_policy: NoPolicy) { - return new IdentityConstraint(); - }, - }, - } satisfies CredentialGeneratorConfiguration); - - return forwarder; -} - -/** Generator configurations */ -export const Generators = Object.freeze({ - /** Passphrase generator configuration */ - passphrase: PASSPHRASE, - - /** Password generator configuration */ - password: PASSWORD, - - /** Username generator configuration */ - username: USERNAME, - - /** Catchall email generator configuration */ - catchall: CATCHALL, - - /** Email subaddress generator configuration */ - subaddress: SUBADDRESS, -}); diff --git a/libs/tools/generator/core/src/data/index.ts b/libs/tools/generator/core/src/data/index.ts index 482703fd3c3..bcf57e98c9a 100644 --- a/libs/tools/generator/core/src/data/index.ts +++ b/libs/tools/generator/core/src/data/index.ts @@ -1,20 +1,8 @@ -export * from "./generators"; -export * from "./default-addy-io-options"; export * from "./default-catchall-options"; -export * from "./default-duck-duck-go-options"; -export * from "./default-fastmail-options"; -export * from "./default-forward-email-options"; export * from "./default-passphrase-boundaries"; export * from "./default-password-boundaries"; export * from "./default-eff-username-options"; -export * from "./default-firefox-relay-options"; export * from "./default-passphrase-generation-options"; export * from "./default-password-generation-options"; -export * from "./default-credential-preferences"; export * from "./default-subaddress-generator-options"; -export * from "./default-simple-login-options"; -export * from "./forwarders"; export * from "./integrations"; -export * from "./policies"; -export * from "./username-digits"; -export * from "./generator-types"; diff --git a/libs/tools/generator/core/src/data/policies.ts b/libs/tools/generator/core/src/data/policies.ts deleted file mode 100644 index 4e46718a395..00000000000 --- a/libs/tools/generator/core/src/data/policies.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - PassphraseGenerationOptions, - PassphraseGeneratorPolicy, - PasswordGenerationOptions, - PasswordGeneratorPolicy, - PolicyConfiguration, -} from "../types"; - -import { Generators } from "./generators"; - -/** Policy configurations - * @deprecated use Generator.*.policy instead - */ -export const Policies = Object.freeze({ - Passphrase: Generators.passphrase.policy, - Password: Generators.password.policy, -} satisfies { - /** Passphrase policy configuration */ - Passphrase: PolicyConfiguration; - - /** Password policy configuration */ - Password: PolicyConfiguration; -}); diff --git a/libs/tools/generator/core/src/data/username-digits.ts b/libs/tools/generator/core/src/data/username-digits.ts deleted file mode 100644 index 99ef15cf1ca..00000000000 --- a/libs/tools/generator/core/src/data/username-digits.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const UsernameDigits = Object.freeze({ - enabled: 4, - disabled: 0, -}); diff --git a/libs/tools/generator/core/src/engine/email-randomizer.spec.ts b/libs/tools/generator/core/src/engine/email-randomizer.spec.ts index fb953af1659..2ebe50d12d4 100644 --- a/libs/tools/generator/core/src/engine/email-randomizer.spec.ts +++ b/libs/tools/generator/core/src/engine/email-randomizer.spec.ts @@ -2,6 +2,8 @@ import { mock } from "jest-mock-extended"; import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { Algorithm, Type } from "../metadata"; + import { Randomizer } from "./abstractions"; import { EmailRandomizer } from "./email-randomizer"; @@ -41,7 +43,8 @@ describe("EmailRandomizer", () => { async (email) => { const emailRandomizer = new EmailRandomizer(randomizer); - const result = await emailRandomizer.randomAsciiSubaddress(email); + // this tests what happens when the type system is subverted + const result = await emailRandomizer.randomAsciiSubaddress(email!); expect(result).toEqual(""); }, @@ -100,7 +103,8 @@ describe("EmailRandomizer", () => { it.each([[null], [undefined], [""]])("returns null if the domain is %p", async (domain) => { const emailRandomizer = new EmailRandomizer(randomizer); - const result = await emailRandomizer.randomAsciiCatchall(domain); + // this tests what happens when the type system is subverted + const result = await emailRandomizer.randomAsciiCatchall(domain!); expect(result).toBeNull(); }); @@ -150,7 +154,8 @@ describe("EmailRandomizer", () => { it.each([[null], [undefined], [""]])("returns null if the domain is %p", async (domain) => { const emailRandomizer = new EmailRandomizer(randomizer); - const result = await emailRandomizer.randomWordsCatchall(domain); + // this tests what happens when the type system is subverted + const result = await emailRandomizer.randomWordsCatchall(domain!); expect(result).toBeNull(); }); @@ -214,32 +219,32 @@ describe("EmailRandomizer", () => { const email = new EmailRandomizer(randomizer); const result = await email.generate( - {}, + { algorithm: Algorithm.catchall }, { catchallDomain: "example.com", }, ); - expect(result.category).toEqual("catchall"); + expect(result.category).toEqual(Type.email); }); it("processes subaddress generation options", async () => { const email = new EmailRandomizer(randomizer); const result = await email.generate( - {}, + { algorithm: Algorithm.plusAddress }, { subaddressEmail: "foo@example.com", }, ); - expect(result.category).toEqual("subaddress"); + expect(result.category).toEqual(Type.email); }); it("throws when it cannot recognize the options type", async () => { const email = new EmailRandomizer(randomizer); - const result = email.generate({}, {}); + const result = email.generate({ algorithm: Algorithm.password }, {}); await expect(result).rejects.toBeInstanceOf(Error); }); diff --git a/libs/tools/generator/core/src/engine/email-randomizer.ts b/libs/tools/generator/core/src/engine/email-randomizer.ts index 0be95a975af..f673ba05fc0 100644 --- a/libs/tools/generator/core/src/engine/email-randomizer.ts +++ b/libs/tools/generator/core/src/engine/email-randomizer.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { Type } from "../metadata"; import { CatchallGenerationOptions, CredentialGenerator, @@ -128,7 +129,7 @@ export class EmailRandomizer return new GeneratedCredential( email, - "catchall", + Type.email, Date.now(), request.source, request.website, @@ -138,7 +139,7 @@ export class EmailRandomizer return new GeneratedCredential( email, - "subaddress", + Type.email, Date.now(), request.source, request.website, diff --git a/libs/tools/generator/core/src/engine/forwarder.ts b/libs/tools/generator/core/src/engine/forwarder.ts index 6c6e574e873..5f41e35d21d 100644 --- a/libs/tools/generator/core/src/engine/forwarder.ts +++ b/libs/tools/generator/core/src/engine/forwarder.ts @@ -8,6 +8,7 @@ import { } from "@bitwarden/common/tools/integration/rpc"; import { GenerationRequest } from "@bitwarden/common/tools/types"; +import { Type } from "../metadata"; import { CredentialGenerator, GeneratedCredential } from "../types"; import { AccountRequest, ForwarderConfiguration } from "./forwarder-configuration"; @@ -40,9 +41,8 @@ export class Forwarder implements CredentialGenerator { const create = this.createForwardingAddress(this.configuration, settings); const result = await this.client.fetchJson(create, requestOptions); - const id = { forwarder: this.configuration.id }; - return new GeneratedCredential(result, id, Date.now()); + return new GeneratedCredential(result, Type.email, Date.now()); } private createContext( diff --git a/libs/tools/generator/core/src/engine/password-randomizer.spec.ts b/libs/tools/generator/core/src/engine/password-randomizer.spec.ts index fca98855fd5..a36c4bb5352 100644 --- a/libs/tools/generator/core/src/engine/password-randomizer.spec.ts +++ b/libs/tools/generator/core/src/engine/password-randomizer.spec.ts @@ -3,6 +3,7 @@ import { mock } from "jest-mock-extended"; import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; import { Randomizer } from "../abstractions"; +import { Algorithm, Type } from "../metadata"; import { Ascii } from "./data"; import { PasswordRandomizer } from "./password-randomizer"; @@ -341,32 +342,32 @@ describe("PasswordRandomizer", () => { const password = new PasswordRandomizer(randomizer); const result = await password.generate( - {}, + { algorithm: Algorithm.password }, { length: 10, }, ); - expect(result.category).toEqual("password"); + expect(result.category).toEqual(Type.password); }); it("processes passphrase generation options", async () => { const password = new PasswordRandomizer(randomizer); const result = await password.generate( - {}, + { algorithm: Algorithm.passphrase }, { numWords: 10, }, ); - expect(result.category).toEqual("passphrase"); + expect(result.category).toEqual(Type.password); }); it("throws when it cannot recognize the options type", async () => { const password = new PasswordRandomizer(randomizer); - const result = password.generate({}, {}); + const result = password.generate({ algorithm: Algorithm.username }, {}); await expect(result).rejects.toBeInstanceOf(Error); }); diff --git a/libs/tools/generator/core/src/engine/password-randomizer.ts b/libs/tools/generator/core/src/engine/password-randomizer.ts index a9612d2fb45..dc61ee064e1 100644 --- a/libs/tools/generator/core/src/engine/password-randomizer.ts +++ b/libs/tools/generator/core/src/engine/password-randomizer.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { Type } from "../metadata"; import { CredentialGenerator, GenerateRequest, @@ -86,7 +87,7 @@ export class PasswordRandomizer return new GeneratedCredential( password, - "password", + Type.password, Date.now(), request.source, request.website, @@ -97,7 +98,7 @@ export class PasswordRandomizer return new GeneratedCredential( passphrase, - "passphrase", + Type.password, Date.now(), request.source, request.website, diff --git a/libs/tools/generator/core/src/engine/username-randomizer.spec.ts b/libs/tools/generator/core/src/engine/username-randomizer.spec.ts index 54d140e4469..be0650fe16e 100644 --- a/libs/tools/generator/core/src/engine/username-randomizer.spec.ts +++ b/libs/tools/generator/core/src/engine/username-randomizer.spec.ts @@ -2,6 +2,8 @@ import { mock } from "jest-mock-extended"; import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { Algorithm, Type } from "../metadata"; + import { Randomizer } from "./abstractions"; import { UsernameRandomizer } from "./username-randomizer"; @@ -108,19 +110,19 @@ describe("UsernameRandomizer", () => { const username = new UsernameRandomizer(randomizer); const result = await username.generate( - {}, + { algorithm: Algorithm.username }, { wordIncludeNumber: true, }, ); - expect(result.category).toEqual("username"); + expect(result.category).toEqual(Type.username); }); it("throws when it cannot recognize the options type", async () => { const username = new UsernameRandomizer(randomizer); - const result = username.generate({}, {}); + const result = username.generate({ algorithm: Algorithm.passphrase }, {}); await expect(result).rejects.toBeInstanceOf(Error); }); diff --git a/libs/tools/generator/core/src/index.ts b/libs/tools/generator/core/src/index.ts index 494d034b674..928e6786ff9 100644 --- a/libs/tools/generator/core/src/index.ts +++ b/libs/tools/generator/core/src/index.ts @@ -3,13 +3,34 @@ export * from "./abstractions"; export * from "./data"; export { createRandomizer } from "./factories"; export * from "./types"; -export { CredentialGeneratorService } from "./services"; +export { DefaultCredentialGeneratorService } from "./services"; +export { + CredentialType, + CredentialAlgorithm, + PasswordAlgorithm, + Algorithm, + BuiltIn, + Type, + Profile, + GeneratorMetadata, + GeneratorProfile, + AlgorithmMetadata, + AlgorithmsByType, +} from "./metadata"; +export { + isForwarderExtensionId, + isEmailAlgorithm, + isUsernameAlgorithm, + isPasswordAlgorithm, + isSameAlgorithm, +} from "./metadata/util"; // These internal interfacess are exposed for use by other generator modules // They are unstable and may change arbitrarily export * as engine from "./engine"; export * as integration from "./integration"; export * as policies from "./policies"; +export * as providers from "./providers"; export * as rx from "./rx"; export * as services from "./services"; export * as strategies from "./strategies"; diff --git a/libs/tools/generator/core/src/integration/addy-io.ts b/libs/tools/generator/core/src/integration/addy-io.ts index 93ffed3392a..bd1be0ee6ae 100644 --- a/libs/tools/generator/core/src/integration/addy-io.ts +++ b/libs/tools/generator/core/src/integration/addy-io.ts @@ -4,6 +4,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, @@ -101,7 +102,7 @@ const forwarder = Object.freeze({ export const AddyIo = Object.freeze({ // integration - id: "anonaddy" as IntegrationId & VendorId, + id: Vendor.addyio as IntegrationId & VendorId, name: "Addy.io", extends: ["forwarder"], diff --git a/libs/tools/generator/core/src/integration/duck-duck-go.ts b/libs/tools/generator/core/src/integration/duck-duck-go.ts index d2bd6173a14..b88a7f77a33 100644 --- a/libs/tools/generator/core/src/integration/duck-duck-go.ts +++ b/libs/tools/generator/core/src/integration/duck-duck-go.ts @@ -4,6 +4,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; @@ -90,7 +91,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const DuckDuckGo = Object.freeze({ - id: "duckduckgo" as IntegrationId & VendorId, + id: Vendor.duckduckgo as IntegrationId & VendorId, name: "DuckDuckGo", baseUrl: "https://quack.duckduckgo.com/api", selfHost: "never", diff --git a/libs/tools/generator/core/src/integration/fastmail.ts b/libs/tools/generator/core/src/integration/fastmail.ts index bfde1aa70f5..a540807666e 100644 --- a/libs/tools/generator/core/src/integration/fastmail.ts +++ b/libs/tools/generator/core/src/integration/fastmail.ts @@ -6,6 +6,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; @@ -160,7 +161,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const Fastmail = Object.freeze({ - id: "fastmail" as IntegrationId & VendorId, + id: Vendor.fastmail as IntegrationId & VendorId, name: "Fastmail", baseUrl: "https://api.fastmail.com", selfHost: "maybe", diff --git a/libs/tools/generator/core/src/integration/firefox-relay.ts b/libs/tools/generator/core/src/integration/firefox-relay.ts index f80de0c95dd..9fbd56aa6ed 100644 --- a/libs/tools/generator/core/src/integration/firefox-relay.ts +++ b/libs/tools/generator/core/src/integration/firefox-relay.ts @@ -4,6 +4,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; @@ -98,7 +99,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const FirefoxRelay = Object.freeze({ - id: "firefoxrelay" as IntegrationId & VendorId, + id: Vendor.mozilla as IntegrationId & VendorId, name: "Firefox Relay", baseUrl: "https://relay.firefox.com/api", selfHost: "never", diff --git a/libs/tools/generator/core/src/integration/forward-email.ts b/libs/tools/generator/core/src/integration/forward-email.ts index 34b4602b94b..b53fc4ffab6 100644 --- a/libs/tools/generator/core/src/integration/forward-email.ts +++ b/libs/tools/generator/core/src/integration/forward-email.ts @@ -4,6 +4,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; @@ -102,7 +103,7 @@ const forwarder = Object.freeze({ export const ForwardEmail = Object.freeze({ // integration metadata - id: "forwardemail" as IntegrationId & VendorId, + id: Vendor.forwardemail as IntegrationId & VendorId, name: "Forward Email", extends: ["forwarder"], diff --git a/libs/tools/generator/core/src/integration/simple-login.ts b/libs/tools/generator/core/src/integration/simple-login.ts index efbac69cec2..f3cc776d401 100644 --- a/libs/tools/generator/core/src/integration/simple-login.ts +++ b/libs/tools/generator/core/src/integration/simple-login.ts @@ -4,6 +4,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, @@ -104,7 +105,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const SimpleLogin = Object.freeze({ - id: "simplelogin" as IntegrationId & VendorId, + id: Vendor.simplelogin as IntegrationId & VendorId, name: "SimpleLogin", selfHost: "maybe", extends: ["forwarder"], diff --git a/libs/tools/generator/core/src/metadata/algorithm-metadata.ts b/libs/tools/generator/core/src/metadata/algorithm-metadata.ts index c07deef5535..8bffa630dd9 100644 --- a/libs/tools/generator/core/src/metadata/algorithm-metadata.ts +++ b/libs/tools/generator/core/src/metadata/algorithm-metadata.ts @@ -1,6 +1,6 @@ -import { CredentialAlgorithm, CredentialType } from "./type"; +import { I18nKeyOrLiteral } from "@bitwarden/common/tools/types"; -type I18nKeyOrLiteral = string | { literal: string }; +import { CredentialAlgorithm, CredentialType } from "./type"; /** Credential generator metadata common across credential generators */ export type AlgorithmMetadata = { @@ -14,7 +14,7 @@ export type AlgorithmMetadata = { id: CredentialAlgorithm; /** The kind of credential generated by this configuration */ - category: CredentialType; + type: CredentialType; /** Used to order credential algorithms for display purposes. * Items with lesser weights appear before entries with greater @@ -23,6 +23,10 @@ export type AlgorithmMetadata = { weight: number; /** Localization keys */ + // FIXME: in practice, keys like `credentialGenerated` all align + // with credential types and contain duplicate keys. Extract + // them into a "credential type metadata" type and integrate + // that type with the algorithm metadata instead. i18nKeys: { /** descriptive name of the algorithm */ name: I18nKeyOrLiteral; diff --git a/libs/tools/generator/core/src/metadata/email/catchall.spec.ts b/libs/tools/generator/core/src/metadata/email/catchall.spec.ts index d6cc1795e0b..1099a6d59ea 100644 --- a/libs/tools/generator/core/src/metadata/email/catchall.spec.ts +++ b/libs/tools/generator/core/src/metadata/email/catchall.spec.ts @@ -2,7 +2,8 @@ import { mock } from "jest-mock-extended"; import { EmailRandomizer } from "../../engine"; import { CatchallConstraints } from "../../policies/catchall-constraints"; -import { CatchallGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CatchallGenerationOptions } from "../../types"; import { Profile } from "../data"; import { CoreProfileMetadata } from "../profile-metadata"; import { isCoreProfile } from "../util"; diff --git a/libs/tools/generator/core/src/metadata/email/catchall.ts b/libs/tools/generator/core/src/metadata/email/catchall.ts index 0711e5c3719..991f6b18b73 100644 --- a/libs/tools/generator/core/src/metadata/email/catchall.ts +++ b/libs/tools/generator/core/src/metadata/email/catchall.ts @@ -4,17 +4,14 @@ import { deepFreeze } from "@bitwarden/common/tools/util"; import { EmailRandomizer } from "../../engine"; import { CatchallConstraints } from "../../policies/catchall-constraints"; -import { - CatchallGenerationOptions, - CredentialGenerator, - GeneratorDependencyProvider, -} from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CatchallGenerationOptions, CredentialGenerator } from "../../types"; import { Algorithm, Type, Profile } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; const catchall: GeneratorMetadata = deepFreeze({ id: Algorithm.catchall, - category: Type.email, + type: Type.email, weight: 210, i18nKeys: { name: "catchallEmail", diff --git a/libs/tools/generator/core/src/metadata/email/forwarder.ts b/libs/tools/generator/core/src/metadata/email/forwarder.ts index f4f150f33fa..1066f890ef8 100644 --- a/libs/tools/generator/core/src/metadata/email/forwarder.ts +++ b/libs/tools/generator/core/src/metadata/email/forwarder.ts @@ -1,19 +1,14 @@ import { ExtensionMetadata, ExtensionStorageKey } from "@bitwarden/common/tools/extension/type"; -import { SelfHostedApiSettings } from "@bitwarden/common/tools/integration/rpc"; import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; import { getForwarderConfiguration } from "../../data"; -import { EmailDomainSettings, EmailPrefixSettings } from "../../engine"; import { Forwarder } from "../../engine/forwarder"; -import { GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { ForwarderOptions } from "../../types"; import { Profile, Type } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; import { ForwarderProfileMetadata } from "../profile-metadata"; -// These options are used by all forwarders; each forwarder uses a different set, -// as defined by `GeneratorMetadata.capabilities.fields`. -type ForwarderOptions = Partial; - // update the extension metadata export function toForwarderMetadata( extension: ExtensionMetadata, @@ -28,7 +23,7 @@ export function toForwarderMetadata( const generator: GeneratorMetadata = { id: { forwarder: extension.product.vendor.id }, - category: Type.email, + type: Type.email, weight: 300, i18nKeys: { name, @@ -56,6 +51,12 @@ export function toForwarderMetadata( storage: { key: "forwarder", frame: 512, + initial: { + token: "", + baseUrl: "", + domain: "", + prefix: "", + }, options: { deserializer: (value) => value, clearOn: ["logout"], diff --git a/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts b/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts index 063cb71c23a..befc900ceab 100644 --- a/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts +++ b/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts @@ -2,7 +2,8 @@ import { mock } from "jest-mock-extended"; import { EmailRandomizer } from "../../engine"; import { SubaddressConstraints } from "../../policies/subaddress-constraints"; -import { SubaddressGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { SubaddressGenerationOptions } from "../../types"; import { Profile } from "../data"; import { CoreProfileMetadata } from "../profile-metadata"; import { isCoreProfile } from "../util"; diff --git a/libs/tools/generator/core/src/metadata/email/plus-address.ts b/libs/tools/generator/core/src/metadata/email/plus-address.ts index 0db0acd415c..940d6599442 100644 --- a/libs/tools/generator/core/src/metadata/email/plus-address.ts +++ b/libs/tools/generator/core/src/metadata/email/plus-address.ts @@ -4,17 +4,14 @@ import { deepFreeze } from "@bitwarden/common/tools/util"; import { EmailRandomizer } from "../../engine"; import { SubaddressConstraints } from "../../policies/subaddress-constraints"; -import { - CredentialGenerator, - GeneratorDependencyProvider, - SubaddressGenerationOptions, -} from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CredentialGenerator, SubaddressGenerationOptions } from "../../types"; import { Algorithm, Profile, Type } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; const plusAddress: GeneratorMetadata = deepFreeze({ id: Algorithm.plusAddress, - category: Type.email, + type: Type.email, weight: 200, i18nKeys: { name: "plusAddressedEmail", diff --git a/libs/tools/generator/core/src/metadata/generator-metadata.ts b/libs/tools/generator/core/src/metadata/generator-metadata.ts index 9296d30430e..704ce88b217 100644 --- a/libs/tools/generator/core/src/metadata/generator-metadata.ts +++ b/libs/tools/generator/core/src/metadata/generator-metadata.ts @@ -1,4 +1,5 @@ -import { CredentialGenerator, GeneratorDependencyProvider } from "../types"; +import { GeneratorDependencyProvider } from "../providers"; +import { CredentialGenerator } from "../types"; import { AlgorithmMetadata } from "./algorithm-metadata"; import { Profile } from "./data"; diff --git a/libs/tools/generator/core/src/metadata/index.ts b/libs/tools/generator/core/src/metadata/index.ts index d9437822270..17c02918705 100644 --- a/libs/tools/generator/core/src/metadata/index.ts +++ b/libs/tools/generator/core/src/metadata/index.ts @@ -3,7 +3,32 @@ import { AlgorithmsByType as AlgorithmsByTypeData, Type as TypeData, } from "./data"; +import catchall from "./email/catchall"; +import plusAddress from "./email/plus-address"; +import passphrase from "./password/eff-word-list"; +import password from "./password/random-password"; import { CredentialType, CredentialAlgorithm } from "./type"; +import effWordList from "./username/eff-word-list"; + +/** Credential generators hosted natively by the credential generator system. + * These are supplemented by generators from the {@link ExtensionService}. + */ +export const BuiltIn = Object.freeze({ + /** Catchall email address generator */ + catchall, + + /** plus-addressed email address generator */ + plusAddress, + + /** passphrase generator using the EFF word list */ + passphrase, + + /** password generator */ + password, + + /** username generator using the EFF word list */ + effWordList, +}); // `CredentialAlgorithm` is defined in terms of `ABT`; supplying // type information in the barrel file breaks a circular dependency. @@ -12,14 +37,29 @@ export const AlgorithmsByType: Record< CredentialType, ReadonlyArray > = AlgorithmsByTypeData; + +/** A list of all built-in algorithm identifiers + * @remarks this is useful when you need to filter invalid values + */ export const Algorithms: ReadonlyArray = Object.freeze( Object.values(AlgorithmData), ); + +/** A list of all built-in algorithm types + * @remarks this is useful when you need to filter invalid values + */ export const Types: ReadonlyArray = Object.freeze(Object.values(TypeData)); export { Profile, Type, Algorithm } from "./data"; export { toForwarderMetadata } from "./email/forwarder"; +export { AlgorithmMetadata } from "./algorithm-metadata"; export { GeneratorMetadata } from "./generator-metadata"; export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata"; -export { GeneratorProfile, CredentialAlgorithm, CredentialType } from "./type"; +export { + GeneratorProfile, + CredentialAlgorithm, + PasswordAlgorithm, + CredentialType, + ForwarderExtensionId, +} from "./type"; export { isForwarderProfile, toVendorId, isForwarderExtensionId } from "./util"; diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts index e02d63d3d59..0c0693af272 100644 --- a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts @@ -5,7 +5,8 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PasswordRandomizer } from "../../engine"; import { PassphrasePolicyConstraints } from "../../policies"; -import { PassphraseGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { PassphraseGenerationOptions } from "../../types"; import { Profile } from "../data"; import { CoreProfileMetadata } from "../profile-metadata"; import { isCoreProfile } from "../util"; diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.ts index fc86032bf6b..021112f7c89 100644 --- a/libs/tools/generator/core/src/metadata/password/eff-word-list.ts +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.ts @@ -5,17 +5,14 @@ import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { PasswordRandomizer } from "../../engine"; import { passphraseLeastPrivilege, PassphrasePolicyConstraints } from "../../policies"; -import { - CredentialGenerator, - GeneratorDependencyProvider, - PassphraseGenerationOptions, -} from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CredentialGenerator, PassphraseGenerationOptions } from "../../types"; import { Algorithm, Profile, Type } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; const passphrase: GeneratorMetadata = { id: Algorithm.passphrase, - category: Type.password, + type: Type.password, weight: 110, i18nKeys: { name: "passphrase", @@ -26,7 +23,7 @@ const passphrase: GeneratorMetadata = { useCredential: "useThisPassphrase", }, capabilities: { - autogenerate: false, + autogenerate: true, fields: [], }, engine: { diff --git a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts index 9e38c50ee2a..b22f3e9356d 100644 --- a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts @@ -5,7 +5,8 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PasswordRandomizer } from "../../engine"; import { DynamicPasswordPolicyConstraints } from "../../policies"; -import { PasswordGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { PasswordGenerationOptions } from "../../types"; import { Profile } from "../data"; import { CoreProfileMetadata } from "../profile-metadata"; import { isCoreProfile } from "../util"; diff --git a/libs/tools/generator/core/src/metadata/password/random-password.ts b/libs/tools/generator/core/src/metadata/password/random-password.ts index 693236b0967..e446f1962a5 100644 --- a/libs/tools/generator/core/src/metadata/password/random-password.ts +++ b/libs/tools/generator/core/src/metadata/password/random-password.ts @@ -5,17 +5,14 @@ import { deepFreeze } from "@bitwarden/common/tools/util"; import { PasswordRandomizer } from "../../engine"; import { DynamicPasswordPolicyConstraints, passwordLeastPrivilege } from "../../policies"; -import { - CredentialGenerator, - GeneratorDependencyProvider, - PasswordGeneratorSettings, -} from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CredentialGenerator, PasswordGeneratorSettings } from "../../types"; import { Algorithm, Profile, Type } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; const password: GeneratorMetadata = deepFreeze({ id: Algorithm.password, - category: Type.password, + type: Type.password, weight: 100, i18nKeys: { name: "password", diff --git a/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts index d47d5ec9fcb..beebb016504 100644 --- a/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts +++ b/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts @@ -3,7 +3,8 @@ import { mock } from "jest-mock-extended"; import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; import { UsernameRandomizer } from "../../engine"; -import { EffUsernameGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { EffUsernameGenerationOptions } from "../../types"; import { Profile } from "../data"; import { CoreProfileMetadata } from "../profile-metadata"; import { isCoreProfile } from "../util"; diff --git a/libs/tools/generator/core/src/metadata/username/eff-word-list.ts b/libs/tools/generator/core/src/metadata/username/eff-word-list.ts index 6373daf8ed5..2802eea2c08 100644 --- a/libs/tools/generator/core/src/metadata/username/eff-word-list.ts +++ b/libs/tools/generator/core/src/metadata/username/eff-word-list.ts @@ -4,17 +4,14 @@ import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state import { deepFreeze } from "@bitwarden/common/tools/util"; import { UsernameRandomizer } from "../../engine"; -import { - CredentialGenerator, - EffUsernameGenerationOptions, - GeneratorDependencyProvider, -} from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CredentialGenerator, EffUsernameGenerationOptions } from "../../types"; import { Algorithm, Profile, Type } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; const effWordList: GeneratorMetadata = deepFreeze({ id: Algorithm.username, - category: Type.username, + type: Type.username, weight: 400, i18nKeys: { name: "randomWord", diff --git a/libs/tools/generator/core/src/metadata/util.ts b/libs/tools/generator/core/src/metadata/util.ts index 86b2742e86d..a8e8879b57c 100644 --- a/libs/tools/generator/core/src/metadata/util.ts +++ b/libs/tools/generator/core/src/metadata/util.ts @@ -12,23 +12,23 @@ import { /** Returns true when the input algorithm is a password algorithm. */ export function isPasswordAlgorithm( - algorithm: CredentialAlgorithm, + algorithm: CredentialAlgorithm | null, ): algorithm is PasswordAlgorithm { return AlgorithmsByType.password.includes(algorithm as any); } /** Returns true when the input algorithm is a username algorithm. */ export function isUsernameAlgorithm( - algorithm: CredentialAlgorithm, + algorithm: CredentialAlgorithm | null, ): algorithm is UsernameAlgorithm { return AlgorithmsByType.username.includes(algorithm as any); } /** Returns true when the input algorithm is a forwarder integration. */ export function isForwarderExtensionId( - algorithm: CredentialAlgorithm, + algorithm: CredentialAlgorithm | null, ): algorithm is ForwarderExtensionId { - return algorithm && typeof algorithm === "object" && "forwarder" in algorithm; + return !!(algorithm && typeof algorithm === "object" && "forwarder" in algorithm); } /** Extract a `VendorId` from a `CredentialAlgorithm`. diff --git a/libs/tools/generator/core/src/policies/available-algorithms-constraint.ts b/libs/tools/generator/core/src/policies/available-algorithms-constraint.ts new file mode 100644 index 00000000000..1824581664b --- /dev/null +++ b/libs/tools/generator/core/src/policies/available-algorithms-constraint.ts @@ -0,0 +1,76 @@ +import { SemanticLogger } from "@bitwarden/common/tools/log"; +import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; +import { Constraints, StateConstraints } from "@bitwarden/common/tools/types"; + +import { CredentialAlgorithm, CredentialType } from "../metadata"; +import { CredentialPreference } from "../types"; +import { TypeRequest } from "../types/metadata-request"; + +export class AvailableAlgorithmsConstraint implements StateConstraints { + /** Well-known constraints of `State` */ + readonly constraints: Readonly> = {}; + + /** Creates a password policy constraints + * @param algorithms loads the algorithms for an algorithm type + * @param isAvailable returns `true` when `algorithm` is enabled by policy + * @param system provides logging facilities + */ + constructor( + readonly algorithms: (request: TypeRequest) => CredentialAlgorithm[], + readonly isAvailable: (algorithm: CredentialAlgorithm) => boolean, + readonly system: UserStateSubjectDependencyProvider, + ) { + this.log = system.log({ type: "AvailableAlgorithmsConstraint" }); + } + private readonly log: SemanticLogger; + + adjust(preferences: CredentialPreference): CredentialPreference { + const result: any = {}; + + const types = Object.keys(preferences) as CredentialType[]; + for (const t of types) { + result[t] = this.adjustPreference(t, preferences[t]); + } + + return result; + } + + private adjustPreference(type: CredentialType, preference: { algorithm: CredentialAlgorithm }) { + if (this.isAvailable(preference.algorithm)) { + this.log.debug({ preference, type }, "using preferred algorithm"); + + return preference; + } + + // choose a default - this algorithm is arbitrary, but stable. + const algorithms = type ? this.algorithms({ type: type }) : []; + const defaultAlgorithm = algorithms.find(this.isAvailable) ?? null; + + // adjust the preference + let adjustedPreference; + if (defaultAlgorithm) { + adjustedPreference = { + ...preference, + algorithm: defaultAlgorithm, + updated: this.system.now(), + }; + this.log.debug( + { preference, defaultAlgorithm, type }, + "preference not available; defaulting the algorithm", + ); + } else { + // FIXME: hard-code a fallback in category metadata + this.log.warn( + { preference, type }, + "preference not available and default algorithm not found; continuing with preference", + ); + adjustedPreference = preference; + } + + return adjustedPreference; + } + + fix(preferences: CredentialPreference): CredentialPreference { + return preferences; + } +} diff --git a/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts b/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts index 1ef0adc1af4..5f699974fba 100644 --- a/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts +++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts @@ -2,15 +2,15 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PolicyId } from "@bitwarden/common/types/guid"; -import { CredentialAlgorithms, PasswordAlgorithms } from "../data"; +import { Algorithm, Algorithms, AlgorithmsByType } from "../metadata"; import { availableAlgorithms } from "./available-algorithms-policy"; -describe("availableAlgorithmsPolicy", () => { +describe("availableAlgorithms_vNextPolicy", () => { it("returns all algorithms", () => { const result = availableAlgorithms([]); - for (const expected of CredentialAlgorithms) { + for (const expected of Algorithms) { expect(result).toContain(expected); } }); @@ -30,7 +30,7 @@ describe("availableAlgorithmsPolicy", () => { expect(result).toContain(override); - for (const expected of PasswordAlgorithms.filter((a) => a !== override)) { + for (const expected of AlgorithmsByType[Algorithm.password].filter((a) => a !== override)) { expect(result).not.toContain(expected); } }); @@ -50,7 +50,7 @@ describe("availableAlgorithmsPolicy", () => { expect(result).toContain(override); - for (const expected of PasswordAlgorithms.filter((a) => a !== override)) { + for (const expected of AlgorithmsByType[Algorithm.password].filter((a) => a !== override)) { expect(result).not.toContain(expected); } }); @@ -79,7 +79,7 @@ describe("availableAlgorithmsPolicy", () => { expect(result).toContain("password"); - for (const expected of PasswordAlgorithms.filter((a) => a !== "password")) { + for (const expected of AlgorithmsByType[Algorithm.password].filter((a) => a !== "password")) { expect(result).not.toContain(expected); } }); @@ -97,7 +97,7 @@ describe("availableAlgorithmsPolicy", () => { const result = availableAlgorithms([policy]); - for (const expected of CredentialAlgorithms) { + for (const expected of Algorithms) { expect(result).toContain(expected); } }); @@ -115,7 +115,7 @@ describe("availableAlgorithmsPolicy", () => { const result = availableAlgorithms([policy]); - for (const expected of CredentialAlgorithms) { + for (const expected of Algorithms) { expect(result).toContain(expected); } }); @@ -133,7 +133,7 @@ describe("availableAlgorithmsPolicy", () => { const result = availableAlgorithms([policy]); - for (const expected of CredentialAlgorithms) { + for (const expected of Algorithms) { expect(result).toContain(expected); } }); diff --git a/libs/tools/generator/core/src/policies/available-algorithms-policy.ts b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts index 0c44a1a0408..e63b648cf44 100644 --- a/libs/tools/generator/core/src/policies/available-algorithms-policy.ts +++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts @@ -1,57 +1,30 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { PolicyType } from "@bitwarden/common/admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models // implement ADR-0002 import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { - CredentialAlgorithm as LegacyAlgorithm, - EmailAlgorithms, - PasswordAlgorithms, - UsernameAlgorithms, -} from ".."; -import { CredentialAlgorithm } from "../metadata"; +import { AlgorithmsByType, CredentialAlgorithm, Type } from "../metadata"; /** Reduces policies to a set of available algorithms * @param policies the policies to reduce * @returns the resulting `AlgorithmAvailabilityPolicy` */ -export function availableAlgorithms(policies: Policy[]): LegacyAlgorithm[] { +export function availableAlgorithms(policies: Policy[]): CredentialAlgorithm[] { const overridePassword = policies .filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled) .reduce( (type, policy) => (type === "password" ? type : (policy.data.overridePasswordType ?? type)), - null as LegacyAlgorithm, + null as CredentialAlgorithm | null, ); - const policy: LegacyAlgorithm[] = [...EmailAlgorithms, ...UsernameAlgorithms]; + const policy: CredentialAlgorithm[] = [ + ...AlgorithmsByType[Type.email], + ...AlgorithmsByType[Type.username], + ]; if (overridePassword) { policy.push(overridePassword); } else { - policy.push(...PasswordAlgorithms); - } - - return policy; -} - -/** Reduces policies to a set of available algorithms - * @param policies the policies to reduce - * @returns the resulting `AlgorithmAvailabilityPolicy` - */ -export function availableAlgorithms_vNext(policies: Policy[]): CredentialAlgorithm[] { - const overridePassword = policies - .filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled) - .reduce( - (type, policy) => (type === "password" ? type : (policy.data.overridePasswordType ?? type)), - null as CredentialAlgorithm, - ); - - const policy: CredentialAlgorithm[] = [...EmailAlgorithms, ...UsernameAlgorithms]; - if (overridePassword) { - policy.push(overridePassword); - } else { - policy.push(...PasswordAlgorithms); + policy.push(...AlgorithmsByType[Type.password]); } return policy; diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts index c8ae02ef723..0bebb0825bf 100644 --- a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts +++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts @@ -1,15 +1,25 @@ import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; -import { Generators } from "../data"; +import { BuiltIn, Profile } from "../metadata"; import { PasswordGeneratorSettings } from "../types"; import { AtLeastOne, Zero } from "./constraints"; import { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints"; -const accoutSettings = Generators.password.settings.account as ObjectKey; -const defaultOptions = accoutSettings.initial; -const disabledPolicy = Generators.password.policy.disabledValue; -const someConstraints = Generators.password.settings.constraints; +// non-null assertions used because these are always-defined constants +const accoutSettings = BuiltIn.password.profiles[Profile.account]! + .storage as ObjectKey; +const defaultOptions = accoutSettings.initial!; +const disabledPolicy = { + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, +}; +const someConstraints = BuiltIn.password.profiles[Profile.account]!.constraints.default; describe("DynamicPasswordPolicyConstraints", () => { describe("constructor", () => { @@ -33,8 +43,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const { constraints } = new DynamicPasswordPolicyConstraints(policy, someConstraints); expect(constraints.policyInEffect).toBeTruthy(); - expect(constraints.lowercase.readonly).toEqual(true); - expect(constraints.lowercase.requiredValue).toEqual(true); + expect(constraints.lowercase?.readonly).toEqual(true); + expect(constraints.lowercase?.requiredValue).toEqual(true); expect(constraints.minLowercase).toEqual({ min: 1 }); }); @@ -43,8 +53,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const { constraints } = new DynamicPasswordPolicyConstraints(policy, someConstraints); expect(constraints.policyInEffect).toBeTruthy(); - expect(constraints.uppercase.readonly).toEqual(true); - expect(constraints.uppercase.requiredValue).toEqual(true); + expect(constraints.uppercase?.readonly).toEqual(true); + expect(constraints.uppercase?.requiredValue).toEqual(true); expect(constraints.minUppercase).toEqual({ min: 1 }); }); @@ -53,8 +63,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const { constraints } = new DynamicPasswordPolicyConstraints(policy, someConstraints); expect(constraints.policyInEffect).toBeTruthy(); - expect(constraints.number.readonly).toEqual(true); - expect(constraints.number.requiredValue).toEqual(true); + expect(constraints.number?.readonly).toEqual(true); + expect(constraints.number?.requiredValue).toEqual(true); expect(constraints.minNumber).toEqual({ min: 1, max: 9 }); }); @@ -63,8 +73,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const { constraints } = new DynamicPasswordPolicyConstraints(policy, someConstraints); expect(constraints.policyInEffect).toBeTruthy(); - expect(constraints.special.readonly).toEqual(true); - expect(constraints.special.requiredValue).toEqual(true); + expect(constraints.special?.readonly).toEqual(true); + expect(constraints.special?.requiredValue).toEqual(true); expect(constraints.minSpecial).toEqual({ min: 1, max: 9 }); }); @@ -73,8 +83,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const { constraints } = new DynamicPasswordPolicyConstraints(policy, someConstraints); expect(constraints.policyInEffect).toBeTruthy(); - expect(constraints.number.readonly).toEqual(true); - expect(constraints.number.requiredValue).toEqual(true); + expect(constraints.number?.readonly).toEqual(true); + expect(constraints.number?.requiredValue).toEqual(true); expect(constraints.minNumber).toEqual({ min: 2, max: 9 }); }); @@ -83,8 +93,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const { constraints } = new DynamicPasswordPolicyConstraints(policy, someConstraints); expect(constraints.policyInEffect).toBeTruthy(); - expect(constraints.special.readonly).toEqual(true); - expect(constraints.special.requiredValue).toEqual(true); + expect(constraints.special?.readonly).toEqual(true); + expect(constraints.special?.requiredValue).toEqual(true); expect(constraints.minSpecial).toEqual({ min: 2, max: 9 }); }); @@ -140,7 +150,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const dynamic = new DynamicPasswordPolicyConstraints( { ...disabledPolicy, - useLowercase, + // the `undefined` case is testing behavior when the type system is bypassed + useLowercase: useLowercase!, }, someConstraints, ); @@ -185,7 +196,8 @@ describe("DynamicPasswordPolicyConstraints", () => { const dynamic = new DynamicPasswordPolicyConstraints( { ...disabledPolicy, - useUppercase, + // the `undefined` case is testing behavior when the type system is bypassed + useUppercase: useUppercase!, }, someConstraints, ); diff --git a/libs/tools/generator/core/src/policies/index.ts b/libs/tools/generator/core/src/policies/index.ts index 0d05e702306..893f0402d38 100644 --- a/libs/tools/generator/core/src/policies/index.ts +++ b/libs/tools/generator/core/src/policies/index.ts @@ -5,3 +5,5 @@ export { PassphrasePolicyConstraints } from "./passphrase-policy-constraints"; export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; export { passphraseLeastPrivilege } from "./passphrase-least-privilege"; export { passwordLeastPrivilege } from "./password-least-privilege"; +export { AvailableAlgorithmsConstraint } from "./available-algorithms-constraint"; +export { availableAlgorithms } from "./available-algorithms-policy"; diff --git a/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts index 3b1eb799391..5fcf847c504 100644 --- a/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts +++ b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts @@ -1,12 +1,32 @@ -import { Policies, DefaultPassphraseBoundaries } from "../data"; -import { PassphraseGenerationOptions } from "../types"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { DefaultPassphraseBoundaries } from "../data"; +import { + PassphraseGenerationOptions, + PassphraseGeneratorPolicy, + PolicyConfiguration, +} from "../types"; import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; +import { passphraseLeastPrivilege } from "./passphrase-least-privilege"; -describe("Password generator options builder", () => { +const Passphrase: PolicyConfiguration = + deepFreeze({ + type: PolicyType.PasswordGenerator, + disabledValue: { + minNumberWords: 0, + capitalize: false, + includeNumber: false, + }, + combine: passphraseLeastPrivilege, + createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), + }); + +describe("Passphrase generator options builder", () => { describe("constructor()", () => { it("should set the policy object to a copy of the input policy", () => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.minNumberWords = 10; // arbitrary change for deep equality check const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -16,7 +36,7 @@ describe("Password generator options builder", () => { }); it("should set default boundaries when a default policy is used", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); expect(builder.numWords).toEqual(DefaultPassphraseBoundaries.numWords); @@ -25,7 +45,7 @@ describe("Password generator options builder", () => { it.each([1, 2])( "should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)", (minNumberWords) => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -37,7 +57,7 @@ describe("Password generator options builder", () => { it.each([8, 12, 18])( "should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words", (minNumberWords) => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -50,7 +70,7 @@ describe("Password generator options builder", () => { it.each([150, 300, 9000])( "should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries", (minNumberWords) => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -63,14 +83,14 @@ describe("Password generator options builder", () => { describe("policyInEffect", () => { it("should return false when the policy has no effect", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); expect(builder.policyInEffect).toEqual(false); }); it("should return true when the policy has a numWords greater than the default boundary", () => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.minNumberWords = DefaultPassphraseBoundaries.numWords.min + 1; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -78,7 +98,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has capitalize enabled", () => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.capitalize = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -86,7 +106,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has includeNumber enabled", () => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.includeNumber = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -98,7 +118,7 @@ describe("Password generator options builder", () => { // All tests should freeze the options to ensure they are not modified it("should set `capitalize` to `false` when the policy does not override it", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -108,7 +128,7 @@ describe("Password generator options builder", () => { }); it("should set `capitalize` to `true` when the policy overrides it", () => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.capitalize = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ capitalize: false }); @@ -119,7 +139,7 @@ describe("Password generator options builder", () => { }); it("should set `includeNumber` to false when the policy does not override it", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -129,7 +149,7 @@ describe("Password generator options builder", () => { }); it("should set `includeNumber` to true when the policy overrides it", () => { - const policy: any = Object.assign({}, Policies.Passphrase.disabledValue); + const policy: any = Object.assign({}, Passphrase.disabledValue); policy.includeNumber = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ includeNumber: false }); @@ -140,7 +160,7 @@ describe("Password generator options builder", () => { }); it("should set `numWords` to the minimum value when it isn't supplied", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -154,7 +174,7 @@ describe("Password generator options builder", () => { (numWords) => { expect(numWords).toBeLessThan(DefaultPassphraseBoundaries.numWords.min); - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ numWords }); @@ -170,7 +190,7 @@ describe("Password generator options builder", () => { expect(numWords).toBeGreaterThanOrEqual(DefaultPassphraseBoundaries.numWords.min); expect(numWords).toBeLessThanOrEqual(DefaultPassphraseBoundaries.numWords.max); - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ numWords }); @@ -185,7 +205,7 @@ describe("Password generator options builder", () => { (numWords) => { expect(numWords).toBeGreaterThan(DefaultPassphraseBoundaries.numWords.max); - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ numWords }); @@ -196,7 +216,7 @@ describe("Password generator options builder", () => { ); it("should preserve unknown properties", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", @@ -214,7 +234,7 @@ describe("Password generator options builder", () => { // All tests should freeze the options to ensure they are not modified it("should return the input options without altering them", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ wordSeparator: "%" }); @@ -224,7 +244,7 @@ describe("Password generator options builder", () => { }); it("should set `wordSeparator` to '-' when it isn't supplied and there is no policy override", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -234,7 +254,7 @@ describe("Password generator options builder", () => { }); it("should leave `wordSeparator` as the empty string '' when it is the empty string", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ wordSeparator: "" }); @@ -244,7 +264,7 @@ describe("Password generator options builder", () => { }); it("should preserve unknown properties", () => { - const policy = Object.assign({}, Policies.Passphrase.disabledValue); + const policy = Object.assign({}, Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", diff --git a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts index ecac3855987..0fbc1796e9e 100644 --- a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts +++ b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts @@ -4,8 +4,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PolicyId } from "@bitwarden/common/types/guid"; -import { Policies } from "../data"; - import { passphraseLeastPrivilege } from "./passphrase-least-privilege"; function createPolicy( @@ -22,21 +20,27 @@ function createPolicy( }); } +const disabledValue = Object.freeze({ + minNumberWords: 0, + capitalize: false, + includeNumber: false, +}); + describe("passphraseLeastPrivilege", () => { it("should return the accumulator when the policy type does not apply", () => { const policy = createPolicy({}, PolicyType.RequireSso); - const result = passphraseLeastPrivilege(Policies.Passphrase.disabledValue, policy); + const result = passphraseLeastPrivilege(disabledValue, policy); - expect(result).toEqual(Policies.Passphrase.disabledValue); + expect(result).toEqual(disabledValue); }); it("should return the accumulator when the policy is not enabled", () => { const policy = createPolicy({}, PolicyType.PasswordGenerator, false); - const result = passphraseLeastPrivilege(Policies.Passphrase.disabledValue, policy); + const result = passphraseLeastPrivilege(disabledValue, policy); - expect(result).toEqual(Policies.Passphrase.disabledValue); + expect(result).toEqual(disabledValue); }); it.each([ @@ -46,8 +50,8 @@ describe("passphraseLeastPrivilege", () => { ])("should take the %p from the policy", (input, value) => { const policy = createPolicy({ [input]: value }); - const result = passphraseLeastPrivilege(Policies.Passphrase.disabledValue, policy); + const result = passphraseLeastPrivilege(disabledValue, policy); - expect(result).toEqual({ ...Policies.Passphrase.disabledValue, [input]: value }); + expect(result).toEqual({ ...disabledValue, [input]: value }); }); }); diff --git a/libs/tools/generator/core/src/policies/passphrase-policy-constraints.spec.ts b/libs/tools/generator/core/src/policies/passphrase-policy-constraints.spec.ts index d6e0a5615dc..6306382c84e 100644 --- a/libs/tools/generator/core/src/policies/passphrase-policy-constraints.spec.ts +++ b/libs/tools/generator/core/src/policies/passphrase-policy-constraints.spec.ts @@ -1,4 +1,4 @@ -import { Generators } from "../data"; +import { BuiltIn, Profile } from "../metadata"; import { PassphrasePolicyConstraints } from "./passphrase-policy-constraints"; @@ -9,8 +9,12 @@ const SomeSettings = { wordSeparator: "-", }; -const disabledPolicy = Generators.passphrase.policy.disabledValue; -const someConstraints = Generators.passphrase.settings.constraints; +const disabledPolicy = { + minNumberWords: 0, + capitalize: false, + includeNumber: false, +}; +const someConstraints = BuiltIn.passphrase.profiles[Profile.account]!.constraints.default; describe("PassphrasePolicyConstraints", () => { describe("constructor", () => { @@ -61,7 +65,7 @@ describe("PassphrasePolicyConstraints", () => { expect(constraints.policyInEffect).toBeTruthy(); expect(constraints.numWords).toMatchObject({ min: 10, - max: someConstraints.numWords.max, + max: someConstraints.numWords?.max, }); }); }); @@ -84,8 +88,8 @@ describe("PassphrasePolicyConstraints", () => { }); it.each([ - [1, someConstraints.numWords.min, 3, someConstraints.numWords.max], - [21, someConstraints.numWords.min, 20, someConstraints.numWords.max], + [1, someConstraints.numWords?.min, 3, someConstraints.numWords?.max], + [21, someConstraints.numWords?.min, 20, someConstraints.numWords?.max], ])( `fits numWords (=%p) within the default bounds (%p <= %p <= %p)`, (value, _, expected, __) => { @@ -98,8 +102,8 @@ describe("PassphrasePolicyConstraints", () => { ); it.each([ - [1, 6, 6, someConstraints.numWords.max], - [21, 20, 20, someConstraints.numWords.max], + [1, 6, 6, someConstraints.numWords?.max], + [21, 20, 20, someConstraints.numWords?.max], ])( "fits numWords (=%p) within the policy bounds (%p <= %p <= %p)", (value, minNumberWords, expected, _) => { diff --git a/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts index 91334f91f85..a088f93d3fe 100644 --- a/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts +++ b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts @@ -1,14 +1,34 @@ -import { DefaultPasswordBoundaries, Policies } from "../data"; -import { PasswordGenerationOptions } from "../types"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { DefaultPasswordBoundaries } from "../data"; +import { PasswordGenerationOptions, PasswordGeneratorPolicy, PolicyConfiguration } from "../types"; import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; +import { passwordLeastPrivilege } from "./password-least-privilege"; + +const Password: PolicyConfiguration = + deepFreeze({ + type: PolicyType.PasswordGenerator, + disabledValue: { + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, + }, + combine: passwordLeastPrivilege, + createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), + }); describe("Password generator options builder", () => { const defaultOptions = Object.freeze({ minLength: 0 }); describe("constructor()", () => { it("should set the policy object to a copy of the input policy", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.minLength = 10; // arbitrary change for deep equality check const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -18,7 +38,7 @@ describe("Password generator options builder", () => { }); it("should set default boundaries when a default policy is used", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -32,7 +52,7 @@ describe("Password generator options builder", () => { (minLength) => { expect(minLength).toBeLessThan(DefaultPasswordBoundaries.length.min); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.minLength = minLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -47,7 +67,7 @@ describe("Password generator options builder", () => { expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.min); expect(expectedLength).toBeLessThanOrEqual(DefaultPasswordBoundaries.length.max); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.minLength = expectedLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -62,7 +82,7 @@ describe("Password generator options builder", () => { (expectedLength) => { expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.max); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.minLength = expectedLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -78,7 +98,7 @@ describe("Password generator options builder", () => { expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.min); expect(expectedMinDigits).toBeLessThanOrEqual(DefaultPasswordBoundaries.minDigits.max); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.numberCount = expectedMinDigits; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -93,7 +113,7 @@ describe("Password generator options builder", () => { (expectedMinDigits) => { expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.max); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.numberCount = expectedMinDigits; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -113,7 +133,7 @@ describe("Password generator options builder", () => { DefaultPasswordBoundaries.minSpecialCharacters.max, ); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.specialCount = expectedSpecialCharacters; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -132,7 +152,7 @@ describe("Password generator options builder", () => { DefaultPasswordBoundaries.minSpecialCharacters.max, ); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.specialCount = expectedSpecialCharacters; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -151,7 +171,7 @@ describe("Password generator options builder", () => { (expectedLength, numberCount, specialCount) => { expect(expectedLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min); - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.numberCount = numberCount; policy.specialCount = specialCount; @@ -164,14 +184,14 @@ describe("Password generator options builder", () => { describe("policyInEffect", () => { it("should return false when the policy has no effect", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(builder.policyInEffect).toEqual(false); }); it("should return true when the policy has a minlength greater than the default boundary", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.minLength = DefaultPasswordBoundaries.length.min + 1; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -179,7 +199,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has a number count greater than the default boundary", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.numberCount = DefaultPasswordBoundaries.minDigits.min + 1; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -187,7 +207,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has a special character count greater than the default boundary", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.specialCount = DefaultPasswordBoundaries.minSpecialCharacters.min + 1; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -195,7 +215,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has uppercase enabled", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useUppercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -203,7 +223,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has lowercase enabled", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useLowercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -211,7 +231,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has numbers enabled", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useNumbers = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -219,7 +239,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has special characters enabled", () => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useSpecial = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -237,7 +257,7 @@ describe("Password generator options builder", () => { ])( "should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'", (expectedUppercase, uppercase) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useUppercase = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, uppercase }); @@ -251,7 +271,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true", (uppercase) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useUppercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, uppercase }); @@ -269,7 +289,7 @@ describe("Password generator options builder", () => { ])( "should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'", (expectedLowercase, lowercase) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useLowercase = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, lowercase }); @@ -283,7 +303,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true", (lowercase) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useLowercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, lowercase }); @@ -301,7 +321,7 @@ describe("Password generator options builder", () => { ])( "should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'", (expectedNumber, number) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useNumbers = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number }); @@ -315,7 +335,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.number` (= %s) to true when `policy.useNumbers` is true", (number) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useNumbers = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number }); @@ -333,7 +353,7 @@ describe("Password generator options builder", () => { ])( "should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'", (expectedSpecial, special) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useSpecial = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special }); @@ -347,7 +367,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.special` (= %s) to true when `policy.useSpecial` is true", (special) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.useSpecial = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special }); @@ -361,7 +381,7 @@ describe("Password generator options builder", () => { it.each([1, 2, 3, 4])( "should set `options.length` (= %i) to the minimum it is less than the minimum length", (length) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(length).toBeLessThan(builder.length.min); @@ -376,7 +396,7 @@ describe("Password generator options builder", () => { it.each([5, 10, 50, 100, 128])( "should not change `options.length` (= %i) when it is within the boundaries", (length) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(length).toBeGreaterThanOrEqual(builder.length.min); expect(length).toBeLessThanOrEqual(builder.length.max); @@ -392,7 +412,7 @@ describe("Password generator options builder", () => { it.each([129, 500, 9000])( "should set `options.length` (= %i) to the maximum length when it is exceeded", (length) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(length).toBeGreaterThan(builder.length.max); @@ -414,7 +434,7 @@ describe("Password generator options builder", () => { ])( "should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0", (expectedNumber, minNumber) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, minNumber }); @@ -425,7 +445,7 @@ describe("Password generator options builder", () => { ); it("should set `options.minNumber` to the minimum value when `options.number` is true", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number: true }); @@ -435,7 +455,7 @@ describe("Password generator options builder", () => { }); it("should set `options.minNumber` to 0 when `options.number` is false", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number: false }); @@ -447,7 +467,7 @@ describe("Password generator options builder", () => { it.each([1, 2, 3, 4])( "should set `options.minNumber` (= %i) to the minimum it is less than the minimum number", (minNumber) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.numberCount = 5; // arbitrary value greater than minNumber expect(minNumber).toBeLessThan(policy.numberCount); @@ -463,7 +483,7 @@ describe("Password generator options builder", () => { it.each([1, 3, 5, 7, 9])( "should not change `options.minNumber` (= %i) when it is within the boundaries", (minNumber) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min); expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max); @@ -479,7 +499,7 @@ describe("Password generator options builder", () => { it.each([10, 20, 400])( "should set `options.minNumber` (= %i) to the maximum digit boundary when it is exceeded", (minNumber) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minNumber).toBeGreaterThan(builder.minDigits.max); @@ -501,7 +521,7 @@ describe("Password generator options builder", () => { ])( "should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0", (expectedSpecial, minSpecial) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, minSpecial }); @@ -512,7 +532,7 @@ describe("Password generator options builder", () => { ); it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special: true }); @@ -522,7 +542,7 @@ describe("Password generator options builder", () => { }); it("should set `options.minSpecial` to 0 when `options.special` is false", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special: false }); @@ -534,7 +554,7 @@ describe("Password generator options builder", () => { it.each([1, 2, 3, 4])( "should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters", (minSpecial) => { - const policy: any = Object.assign({}, Policies.Password.disabledValue); + const policy: any = Object.assign({}, Password.disabledValue); policy.specialCount = 5; // arbitrary value greater than minSpecial expect(minSpecial).toBeLessThan(policy.specialCount); @@ -550,7 +570,7 @@ describe("Password generator options builder", () => { it.each([1, 3, 5, 7, 9])( "should not change `options.minSpecial` (= %i) when it is within the boundaries", (minSpecial) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min); expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max); @@ -566,7 +586,7 @@ describe("Password generator options builder", () => { it.each([10, 20, 400])( "should set `options.minSpecial` (= %i) to the maximum special character boundary when it is exceeded", (minSpecial) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max); @@ -579,7 +599,7 @@ describe("Password generator options builder", () => { ); it("should preserve unknown properties", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", @@ -602,7 +622,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minLowercase === %i` when `options.lowercase` is %s", (expectedMinLowercase, lowercase) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ lowercase, ...defaultOptions }); @@ -618,7 +638,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minUppercase === %i` when `options.uppercase` is %s", (expectedMinUppercase, uppercase) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ uppercase, ...defaultOptions }); @@ -634,7 +654,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set", (expectedMinNumber, number) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ number, ...defaultOptions }); @@ -652,7 +672,7 @@ describe("Password generator options builder", () => { ])( "should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set", (expectedNumber, minNumber) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minNumber, ...defaultOptions }); @@ -668,7 +688,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set", (special, expectedMinSpecial) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ special, ...defaultOptions }); @@ -686,7 +706,7 @@ describe("Password generator options builder", () => { ])( "should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set", (minSpecial, expectedSpecial) => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minSpecial, ...defaultOptions }); @@ -707,7 +727,7 @@ describe("Password generator options builder", () => { const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial; expect(sumOfMinimums).toBeLessThan(DefaultPasswordBoundaries.length.min); - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minLowercase, @@ -732,7 +752,7 @@ describe("Password generator options builder", () => { (expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => { expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min); - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minLowercase, @@ -749,7 +769,7 @@ describe("Password generator options builder", () => { ); it("should preserve unknown properties", () => { - const policy = Object.assign({}, Policies.Password.disabledValue); + const policy = Object.assign({}, Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", diff --git a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts index 5d5430b8cad..7f8dce19b15 100644 --- a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts +++ b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts @@ -4,8 +4,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PolicyId } from "@bitwarden/common/types/guid"; -import { Policies } from "../data"; - import { passwordLeastPrivilege } from "./password-least-privilege"; function createPolicy( @@ -22,21 +20,31 @@ function createPolicy( }); } +const disabledValue = Object.freeze({ + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, +}); + describe("passwordLeastPrivilege", () => { it("should return the accumulator when the policy type does not apply", () => { const policy = createPolicy({}, PolicyType.RequireSso); - const result = passwordLeastPrivilege(Policies.Password.disabledValue, policy); + const result = passwordLeastPrivilege(disabledValue, policy); - expect(result).toEqual(Policies.Password.disabledValue); + expect(result).toEqual(disabledValue); }); it("should return the accumulator when the policy is not enabled", () => { const policy = createPolicy({}, PolicyType.PasswordGenerator, false); - const result = passwordLeastPrivilege(Policies.Password.disabledValue, policy); + const result = passwordLeastPrivilege(disabledValue, policy); - expect(result).toEqual(Policies.Password.disabledValue); + expect(result).toEqual(disabledValue); }); it.each([ @@ -50,8 +58,8 @@ describe("passwordLeastPrivilege", () => { ])("should take the %p from the policy", (input, value, expected) => { const policy = createPolicy({ [input]: value }); - const result = passwordLeastPrivilege(Policies.Password.disabledValue, policy); + const result = passwordLeastPrivilege(disabledValue, policy); - expect(result).toEqual({ ...Policies.Password.disabledValue, [expected]: value }); + expect(result).toEqual({ ...disabledValue, [expected]: value }); }); }); diff --git a/libs/tools/generator/core/src/providers/credential-generator-providers.ts b/libs/tools/generator/core/src/providers/credential-generator-providers.ts new file mode 100644 index 00000000000..1e1a8345f34 --- /dev/null +++ b/libs/tools/generator/core/src/providers/credential-generator-providers.ts @@ -0,0 +1,14 @@ +import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; + +import { GeneratorDependencyProvider } from "./generator-dependency-provider"; +import { GeneratorMetadataProvider } from "./generator-metadata-provider"; +import { GeneratorProfileProvider } from "./generator-profile-provider"; + +// FIXME: find a better way to manage common dependencies than smashing them all +// together into a mega-type. +export type CredentialGeneratorProviders = { + readonly userState: UserStateSubjectDependencyProvider; + readonly generator: GeneratorDependencyProvider; + readonly profile: GeneratorProfileProvider; + readonly metadata: GeneratorMetadataProvider; +}; diff --git a/libs/tools/generator/core/src/providers/credential-preferences.spec.ts b/libs/tools/generator/core/src/providers/credential-preferences.spec.ts new file mode 100644 index 00000000000..6fd747f3823 --- /dev/null +++ b/libs/tools/generator/core/src/providers/credential-preferences.spec.ts @@ -0,0 +1,105 @@ +import { AlgorithmsByType, Type } from "../metadata"; +import { CredentialPreference } from "../types"; + +import { PREFERENCES } from "./credential-preferences"; + +const SomeCredentialPreferences: CredentialPreference = Object.freeze({ + email: Object.freeze({ + algorithm: AlgorithmsByType[Type.email][0], + updated: new Date(0), + }), + password: Object.freeze({ + algorithm: AlgorithmsByType[Type.password][0], + updated: new Date(0), + }), + username: Object.freeze({ + algorithm: AlgorithmsByType[Type.username][0], + updated: new Date(0), + }), +}); + +describe("PREFERENCES", () => { + describe("deserializer", () => { + it.each([[null], [undefined]])("creates new preferences (= %p)", (value) => { + // this case tests what happens when the type system is bypassed + const result = PREFERENCES.deserializer(value!); + + expect(result).toMatchObject({ + email: { + algorithm: AlgorithmsByType[Type.email][0], + }, + password: { + algorithm: AlgorithmsByType[Type.password][0], + }, + username: { + algorithm: AlgorithmsByType[Type.username][0], + }, + }); + }); + + it("fills missing password preferences", () => { + const input: any = structuredClone(SomeCredentialPreferences); + delete input.password; + + const result = PREFERENCES.deserializer(input); + + expect(result).toMatchObject({ + password: { + algorithm: AlgorithmsByType[Type.password][0], + }, + }); + }); + + it("fills missing email preferences", () => { + const input: any = structuredClone(SomeCredentialPreferences); + delete input.email; + + const result = PREFERENCES.deserializer(input); + + expect(result).toMatchObject({ + email: { + algorithm: AlgorithmsByType[Type.email][0], + }, + }); + }); + + it("fills missing username preferences", () => { + const input: any = structuredClone(SomeCredentialPreferences); + delete input.username; + + const result = PREFERENCES.deserializer(input); + + expect(result).toMatchObject({ + username: { + algorithm: AlgorithmsByType[Type.username][0], + }, + }); + }); + + it("converts string fields to Dates", () => { + const input: any = structuredClone(SomeCredentialPreferences); + input.email.updated = "1970-01-01T00:00:00.100Z"; + input.password.updated = "1970-01-01T00:00:00.200Z"; + input.username.updated = "1970-01-01T00:00:00.300Z"; + + const result = PREFERENCES.deserializer(input); + + expect(result?.email.updated).toEqual(new Date(100)); + expect(result?.password.updated).toEqual(new Date(200)); + expect(result?.username.updated).toEqual(new Date(300)); + }); + + it("converts number fields to Dates", () => { + const input: any = structuredClone(SomeCredentialPreferences); + input.email.updated = 100; + input.password.updated = 200; + input.username.updated = 300; + + const result = PREFERENCES.deserializer(input); + + expect(result?.email.updated).toEqual(new Date(100)); + expect(result?.password.updated).toEqual(new Date(200)); + expect(result?.username.updated).toEqual(new Date(300)); + }); + }); +}); diff --git a/libs/tools/generator/core/src/providers/credential-preferences.ts b/libs/tools/generator/core/src/providers/credential-preferences.ts new file mode 100644 index 00000000000..5c6efd6008b --- /dev/null +++ b/libs/tools/generator/core/src/providers/credential-preferences.ts @@ -0,0 +1,28 @@ +import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; + +import { AlgorithmsByType, CredentialType } from "../metadata"; +import { CredentialPreference } from "../types"; + +/** plaintext password generation options */ +export const PREFERENCES = new UserKeyDefinition( + GENERATOR_DISK, + "credentialPreferences", + { + deserializer: (value) => { + const result = (value as any) ?? {}; + + for (const key in AlgorithmsByType) { + const type = key as CredentialType; + if (result[type]) { + result[type].updated = new Date(result[type].updated); + } else { + const [algorithm] = AlgorithmsByType[type]; + result[type] = { algorithm, updated: new Date() }; + } + } + + return result; + }, + clearOn: ["logout"], + }, +); diff --git a/libs/tools/generator/core/src/providers/generator-dependency-provider.ts b/libs/tools/generator/core/src/providers/generator-dependency-provider.ts new file mode 100644 index 00000000000..14942698cdb --- /dev/null +++ b/libs/tools/generator/core/src/providers/generator-dependency-provider.ts @@ -0,0 +1,12 @@ +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { RestClient } from "@bitwarden/common/tools/integration/rpc"; + +import { Randomizer } from "../abstractions"; + +export type GeneratorDependencyProvider = { + randomizer: Randomizer; + client: RestClient; + // FIXME: introduce `I18nKeyOrLiteral` into forwarder + // structures and remove this dependency + i18nService: I18nService; +}; diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts b/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts similarity index 98% rename from libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts rename to libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts index 37a987f88bc..71fced46fa6 100644 --- a/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts +++ b/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts @@ -75,6 +75,7 @@ const SystemProvider = { } as LegacyEncryptorProvider, state: SomeStateProvider, log: disabledSemanticLoggerProvider, + now: Date.now, } as UserStateSubjectDependencyProvider; const SomeSiteId: SiteId = Site.forwarder; @@ -415,14 +416,14 @@ describe("GeneratorMetadataProvider", () => { await expect(firstValueFrom(result)).resolves.toEqual(plusAddress.id); }); - it("emits undefined when the user's preference is unavailable and there is no metadata", async () => { + it("emits the original preference when the user's preference is unavailable and there is no metadata", async () => { SomePolicyService.policiesByType$.mockReturnValue(new BehaviorSubject([])); const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); const result = new ReplaySubject(1); provider.preference$(Type.email, { account$: SomeAccount$ }).subscribe(result); - await expect(firstValueFrom(result)).resolves.toBeUndefined(); + await expect(firstValueFrom(result)).resolves.toEqual(preferences[Type.email].algorithm); }); }); diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.ts b/libs/tools/generator/core/src/providers/generator-metadata-provider.ts similarity index 87% rename from libs/tools/generator/core/src/services/generator-metadata-provider.ts rename to libs/tools/generator/core/src/providers/generator-metadata-provider.ts index 161f7192c39..52901545023 100644 --- a/libs/tools/generator/core/src/services/generator-metadata-provider.ts +++ b/libs/tools/generator/core/src/providers/generator-metadata-provider.ts @@ -1,12 +1,4 @@ -import { - Observable, - combineLatestWith, - distinctUntilChanged, - map, - shareReplay, - switchMap, - takeUntil, -} from "rxjs"; +import { Observable, distinctUntilChanged, map, shareReplay, switchMap, takeUntil } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; @@ -14,7 +6,7 @@ import { BoundDependency } from "@bitwarden/common/tools/dependencies"; import { ExtensionSite } from "@bitwarden/common/tools/extension"; import { SemanticLogger } from "@bitwarden/common/tools/log"; import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; -import { anyComplete, pin } from "@bitwarden/common/tools/rx"; +import { anyComplete, memoizedMap, pin } from "@bitwarden/common/tools/rx"; import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; @@ -29,7 +21,7 @@ import { Algorithms, Types, } from "../metadata"; -import { availableAlgorithms_vNext } from "../policies/available-algorithms-policy"; +import { AvailableAlgorithmsConstraint, availableAlgorithms } from "../policies"; import { CredentialPreference } from "../types"; import { AlgorithmRequest, @@ -148,8 +140,15 @@ export class GeneratorMetadataProvider { const policies$ = this.application.policy .policiesByType$(PolicyType.PasswordGenerator, id) .pipe( - map((p) => availableAlgorithms_vNext(p).filter((a) => this._metadata.has(a))), - map((p) => new Set(p)), + map((p) => + availableAlgorithms(p) + .filter((a) => this._metadata.has(a)) + .sort(), + ), + // interning the set transformation lets `distinctUntilChanged()` eliminate + // repeating policy emissions using reference equality + memoizedMap((a) => new Set(a), { key: (a) => a.join(":") }), + distinctUntilChanged(), // complete policy emissions otherwise `switchMap` holds `available$` open indefinitely takeUntil(anyComplete(id$)), ); @@ -211,24 +210,7 @@ export class GeneratorMetadataProvider { const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true })); const algorithm$ = this.preferences({ account$ }).pipe( - combineLatestWith(this.isAvailable$({ account$ })), - map(([preferences, isAvailable]) => { - const algorithm: CredentialAlgorithm = preferences[type].algorithm; - if (isAvailable(algorithm)) { - return algorithm; - } - - const algorithms = type ? this.algorithms({ type: type }) : []; - // `?? null` because logging types must be `Jsonify` - const defaultAlgorithm = algorithms.find(isAvailable) ?? null; - this.log.debug( - { algorithm, defaultAlgorithm, credentialType: type }, - "preference not available; defaulting the generator algorithm", - ); - - // `?? undefined` so that interface is ADR-14 compliant - return defaultAlgorithm ?? undefined; - }), + map((preferences) => preferences[type].algorithm), distinctUntilChanged(), ); @@ -246,8 +228,16 @@ export class GeneratorMetadataProvider { preferences( dependencies: BoundDependency<"account", Account>, ): UserStateSubject { - // FIXME: enforce policy - const subject = new UserStateSubject(PREFERENCES, this.system, dependencies); + const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true })); + + const constraints$ = this.isAvailable$({ account$ }).pipe( + map( + (isAvailable) => + new AvailableAlgorithmsConstraint(this.algorithms.bind(this), isAvailable, this.system), + ), + ); + + const subject = new UserStateSubject(PREFERENCES, this.system, { account$, constraints$ }); return subject; } diff --git a/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts b/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts similarity index 99% rename from libs/tools/generator/core/src/services/generator-profile-provider.spec.ts rename to libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts index aeb1a648a14..1053834eca7 100644 --- a/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts +++ b/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts @@ -67,6 +67,7 @@ const dependencyProvider: UserStateSubjectDependencyProvider = { encryptor: encryptorProvider, state: stateProvider, log: disabledSemanticLoggerProvider, + now: Date.now, }; // settings storage location diff --git a/libs/tools/generator/core/src/services/generator-profile-provider.ts b/libs/tools/generator/core/src/providers/generator-profile-provider.ts similarity index 94% rename from libs/tools/generator/core/src/services/generator-profile-provider.ts rename to libs/tools/generator/core/src/providers/generator-profile-provider.ts index 7088e23d3fe..4117d1f2a78 100644 --- a/libs/tools/generator/core/src/services/generator-profile-provider.ts +++ b/libs/tools/generator/core/src/providers/generator-profile-provider.ts @@ -19,6 +19,7 @@ import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/stat import { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "../metadata"; import { GeneratorConstraints } from "../types/generator-constraints"; +import { equivalent } from "../util"; /** Surfaces contextual information to credential generators */ export class GeneratorProfileProvider { @@ -99,7 +100,10 @@ export class GeneratorProfileProvider { const constraints$ = policies$.pipe( map((policies) => profile.constraints.create(policies, context)), - tap(() => this.log.debug("constraints created")), + distinctUntilChanged((previous, next) => { + return equivalent(previous, next); + }), + tap((constraints) => this.log.debug(constraints as object, "constraints updated")), ); return constraints$; diff --git a/libs/tools/generator/core/src/providers/index.ts b/libs/tools/generator/core/src/providers/index.ts new file mode 100644 index 00000000000..bad56b746b6 --- /dev/null +++ b/libs/tools/generator/core/src/providers/index.ts @@ -0,0 +1,4 @@ +export { CredentialGeneratorProviders } from "./credential-generator-providers"; +export { GeneratorMetadataProvider } from "./generator-metadata-provider"; +export { GeneratorProfileProvider } from "./generator-profile-provider"; +export { GeneratorDependencyProvider } from "./generator-dependency-provider"; diff --git a/libs/tools/generator/core/src/rx.ts b/libs/tools/generator/core/src/rx.ts index 44d23ef1c5c..ab907b6455f 100644 --- a/libs/tools/generator/core/src/rx.ts +++ b/libs/tools/generator/core/src/rx.ts @@ -18,20 +18,6 @@ export function mapPolicyToEvaluator( ); } -/** Maps an administrative console policy to constraints using the provided configuration. - * @param configuration the configuration that constructs the constraints. - */ -export function mapPolicyToConstraints( - configuration: PolicyConfiguration, - email: string, -) { - return pipe( - reduceCollection(configuration.combine, configuration.disabledValue), - distinctIfShallowMatch(), - map((policy) => configuration.toConstraints(policy, email)), - ); -} - /** Constructs a method that maps a policy to the default (no-op) policy. */ export function newDefaultEvaluator() { return () => { diff --git a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts deleted file mode 100644 index 2bc8d514873..00000000000 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ /dev/null @@ -1,1050 +0,0 @@ -// FIXME: remove ts-strict-ignore once `FakeAccountService` implements ts strict support -// @ts-strict-ignore -import { mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, map, Subject } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; -import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; -import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction"; -import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log"; -import { StateConstraints } from "@bitwarden/common/tools/types"; -import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; - -import { - FakeStateProvider, - FakeAccountService, - awaitAsync, - ObservableTracker, -} from "../../../../../common/spec"; -import { Randomizer } from "../abstractions"; -import { Generators } from "../data"; -import { - CredentialGeneratorConfiguration, - GeneratedCredential, - GenerateRequest, - GeneratorConstraints, -} from "../types"; - -import { CredentialGeneratorService } from "./credential-generator.service"; - -// arbitrary settings types -type SomeSettings = { foo: string }; -type SomePolicy = { fooPolicy: boolean }; - -// settings storage location -const SettingsKey = new UserKeyDefinition(GENERATOR_DISK, "SomeSettings", { - deserializer: (value) => value, - clearOn: [], -}); - -// fake policies -const policyService = mock(); -const somePolicy = new Policy({ - data: { fooPolicy: true }, - type: PolicyType.PasswordGenerator, - id: "" as PolicyId, - organizationId: "" as OrganizationId, - enabled: true, -}); -const passwordOverridePolicy = new Policy({ - id: "" as PolicyId, - organizationId: "", - type: PolicyType.PasswordGenerator, - data: { - overridePasswordType: "password", - }, - enabled: true, -}); - -const passphraseOverridePolicy = new Policy({ - id: "" as PolicyId, - organizationId: "", - type: PolicyType.PasswordGenerator, - data: { - overridePasswordType: "passphrase", - }, - enabled: true, -}); - -const SomeTime = new Date(1); -const SomeAlgorithm = "passphrase"; -const SomeCategory = "password"; -const SomeNameKey = "passphraseKey"; -const SomeGenerateKey = "generateKey"; -const SomeCredentialTypeKey = "credentialTypeKey"; -const SomeOnGeneratedMessageKey = "onGeneratedMessageKey"; -const SomeCopyKey = "copyKey"; -const SomeUseGeneratedValueKey = "useGeneratedValueKey"; - -// fake the configuration -const SomeConfiguration: CredentialGeneratorConfiguration = { - id: SomeAlgorithm, - category: SomeCategory, - nameKey: SomeNameKey, - generateKey: SomeGenerateKey, - onGeneratedMessageKey: SomeOnGeneratedMessageKey, - credentialTypeKey: SomeCredentialTypeKey, - copyKey: SomeCopyKey, - useGeneratedValueKey: SomeUseGeneratedValueKey, - onlyOnRequest: false, - request: [], - engine: { - create: (_randomizer) => { - return { - generate: (request, settings) => { - const result = new GeneratedCredential( - settings.foo, - SomeAlgorithm, - SomeTime, - request.source, - request.website, - ); - return Promise.resolve(result); - }, - }; - }, - }, - settings: { - initial: { foo: "initial" }, - constraints: { foo: {} }, - account: SettingsKey, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: { - fooPolicy: false, - }, - combine: (acc, policy) => { - return { fooPolicy: acc.fooPolicy || policy.data.fooPolicy }; - }, - createEvaluator: () => { - throw new Error("this should never be called"); - }, - toConstraints: (policy) => { - if (policy.fooPolicy) { - return { - constraints: { - policyInEffect: true, - }, - calibrate(state: SomeSettings) { - return { - constraints: {}, - adjust(state: SomeSettings) { - return { foo: `adjusted(${state.foo})` }; - }, - fix(state: SomeSettings) { - return { foo: `fixed(${state.foo})` }; - }, - } satisfies StateConstraints; - }, - } satisfies GeneratorConstraints; - } else { - return { - constraints: { - policyInEffect: false, - }, - adjust(state: SomeSettings) { - return state; - }, - fix(state: SomeSettings) { - return state; - }, - } satisfies GeneratorConstraints; - } - }, - }, -}; - -// fake user information -const SomeUser = "SomeUser" as UserId; -const AnotherUser = "SomeOtherUser" as UserId; -const accounts = { - [SomeUser]: { - id: SomeUser, - name: "some user", - email: "some.user@example.com", - emailVerified: true, - }, - [AnotherUser]: { - id: AnotherUser, - name: "some other user", - email: "some.other.user@example.com", - emailVerified: true, - }, -}; -const accountService = new FakeAccountService(accounts); - -// fake state -const stateProvider = new FakeStateProvider(accountService); - -// fake randomizer -const randomizer = mock(); - -const i18nService = mock(); - -const apiService = mock(); - -const encryptor = mock(); -const encryptorProvider = mock({ - userEncryptor$(_, dependencies) { - return dependencies.singleUserId$.pipe(map((userId) => ({ userId, encryptor }))); - }, -}); - -const account$ = new BehaviorSubject(accounts[SomeUser]); - -const providers = { - encryptor: encryptorProvider, - state: stateProvider, - log: disabledSemanticLoggerProvider, -}; - -describe("CredentialGeneratorService", () => { - beforeEach(async () => { - await accountService.switchAccount(SomeUser); - policyService.policiesByType$.mockImplementation(() => new BehaviorSubject([]).asObservable()); - i18nService.t.mockImplementation((key: string) => key); - apiService.fetch.mockImplementation(() => Promise.resolve(mock())); - jest.clearAllMocks(); - }); - - describe("generate$", () => { - it("completes when `on$` completes", async () => { - await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new Subject(); - let complete = false; - - // confirm no emission during subscription - generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({ - complete: () => { - complete = true; - }, - }); - on$.complete(); - await awaitAsync(); - - expect(complete).toBeTruthy(); - }); - - it("includes request.source in the generated credential", async () => { - const settings = { foo: "value" }; - await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new BehaviorSubject({ source: "some source" }); - const generated = new ObservableTracker( - generator.generate$(SomeConfiguration, { on$, account$ }), - ); - - const result = await generated.expectEmission(); - - expect(result.source).toEqual("some source"); - }); - - it("includes request.website in the generated credential", async () => { - const settings = { foo: "value" }; - await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new BehaviorSubject({ website: "some website" }); - const generated = new ObservableTracker( - generator.generate$(SomeConfiguration, { on$, account$ }), - ); - - const result = await generated.expectEmission(); - - expect(result.website).toEqual("some website"); - }); - - // FIXME: test these when the fake state provider can create the required emissions - it.todo("errors when the settings error"); - it.todo("completes when the settings complete"); - - it("emits a generation for a specific user when `user$` supplied", async () => { - await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account$ = new BehaviorSubject(accounts[AnotherUser]).asObservable(); - const on$ = new Subject(); - const generated = new ObservableTracker( - generator.generate$(SomeConfiguration, { on$, account$ }), - ); - on$.next({}); - - const result = await generated.expectEmission(); - - expect(result).toEqual(new GeneratedCredential("another", SomeAlgorithm, SomeTime)); - }); - - it("errors when `user$` errors", async () => { - await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new Subject(); - const account$ = new BehaviorSubject(accounts[SomeUser]); - let error = null; - - generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({ - error: (e: unknown) => { - error = e; - }, - }); - account$.error({ some: "error" }); - await awaitAsync(); - - expect(error).toEqual({ some: "error" }); - }); - - it("completes when `user$` completes", async () => { - await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new Subject(); - const account$ = new BehaviorSubject(accounts[SomeUser]); - let completed = false; - - generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({ - complete: () => { - completed = true; - }, - }); - account$.complete(); - await awaitAsync(); - - expect(completed).toBeTruthy(); - }); - - it("emits a generation only when `on$` emits", async () => { - // This test breaks from arrange/act/assert because it is testing causality - await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new Subject(); - const results: any[] = []; - - // confirm no emission during subscription - const sub = generator - .generate$(SomeConfiguration, { on$, account$ }) - .subscribe((result) => results.push(result)); - await awaitAsync(); - expect(results.length).toEqual(0); - - // confirm forwarded emission - on$.next({}); - await awaitAsync(); - expect(results).toEqual([new GeneratedCredential("value", SomeAlgorithm, SomeTime)]); - - // confirm updating settings does not cause an emission - await stateProvider.setUserState(SettingsKey, { foo: "next" }, SomeUser); - await awaitAsync(); - expect(results.length).toBe(1); - - // confirm forwarded emission takes latest value - on$.next({}); - await awaitAsync(); - sub.unsubscribe(); - - expect(results).toEqual([ - new GeneratedCredential("value", SomeAlgorithm, SomeTime), - new GeneratedCredential("next", SomeAlgorithm, SomeTime), - ]); - }); - - it("errors when `on$` errors", async () => { - await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const on$ = new Subject(); - let error: any = null; - - // confirm no emission during subscription - generator.generate$(SomeConfiguration, { on$, account$ }).subscribe({ - error: (e: unknown) => { - error = e; - }, - }); - on$.error({ some: "error" }); - await awaitAsync(); - - expect(error).toEqual({ some: "error" }); - }); - - // FIXME: test these when the fake state provider can delay its first emission - it.todo("emits when settings$ become available if on$ is called before they're ready."); - }); - - describe("algorithms", () => { - it("outputs password generation metadata", () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = generator.algorithms("password"); - - expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy(); - - // this test shouldn't contain entries outside of the current category - expect(result.some((a) => a.id === Generators.username.id)).toBeFalsy(); - expect(result.some((a) => a.id === Generators.catchall.id)).toBeFalsy(); - }); - - it("outputs username generation metadata", () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = generator.algorithms("username"); - - expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); - - // this test shouldn't contain entries outside of the current category - expect(result.some((a) => a.id === Generators.catchall.id)).toBeFalsy(); - expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); - }); - - it("outputs email generation metadata", () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = generator.algorithms("email"); - - expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); - - // this test shouldn't contain entries outside of the current category - expect(result.some((a) => a.id === Generators.username.id)).toBeFalsy(); - expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); - }); - - it("combines metadata across categories", () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = generator.algorithms(["username", "email"]); - - expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); - - // this test shouldn't contain entries outside of the current categories - expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); - }); - }); - - describe("algorithms$", () => { - // these tests cannot use the observable tracker because they return - // data that cannot be cloned - it("returns password metadata", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.algorithms$("password", { account$ })); - - expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy(); - }); - - it("returns username metadata", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.algorithms$("username", { account$ })); - - expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); - }); - - it("returns email metadata", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.algorithms$("email", { account$ })); - - expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); - }); - - it("returns username and email metadata", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom( - generator.algorithms$(["username", "email"], { account$ }), - ); - - expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); - }); - - // Subsequent tests focus on passwords and passphrases as an example of policy - // awareness; they exercise the logic without being comprehensive - it("enforces the active user's policy", async () => { - const policy$ = new BehaviorSubject([passwordOverridePolicy]); - policyService.policiesByType$.mockReturnValue(policy$); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.algorithms$(["password"], { account$ })); - - expect(policyService.policiesByType$).toHaveBeenCalledWith( - PolicyType.PasswordGenerator, - SomeUser, - ); - expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); - expect(result.some((a) => a.id === Generators.passphrase.id)).toBeFalsy(); - }); - - it("follows changes to the active user", async () => { - const account$ = new BehaviorSubject(accounts[SomeUser]); - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passwordOverridePolicy]), - ); - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passphraseOverridePolicy]), - ); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const results: any = []; - const sub = generator.algorithms$("password", { account$ }).subscribe((r) => results.push(r)); - - account$.next(accounts[AnotherUser]); - await awaitAsync(); - sub.unsubscribe(); - - const [someResult, anotherResult] = results; - - expect(policyService.policiesByType$).toHaveBeenNthCalledWith( - 1, - PolicyType.PasswordGenerator, - SomeUser, - ); - expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); - expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); - - expect(policyService.policiesByType$).toHaveBeenNthCalledWith( - 2, - PolicyType.PasswordGenerator, - AnotherUser, - ); - expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy(); - expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy(); - }); - - it("reads an arbitrary user's settings", async () => { - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passwordOverridePolicy]), - ); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account$ = new BehaviorSubject(accounts[AnotherUser]).asObservable(); - - const result = await firstValueFrom(generator.algorithms$("password", { account$ })); - - expect(policyService.policiesByType$).toHaveBeenCalledWith( - PolicyType.PasswordGenerator, - AnotherUser, - ); - expect(result.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); - expect(result.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); - }); - - it("follows changes to the arbitrary user", async () => { - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passwordOverridePolicy]), - ); - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passphraseOverridePolicy]), - ); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - const results: any = []; - const sub = generator.algorithms$("password", { account$ }).subscribe((r) => results.push(r)); - - account.next(accounts[AnotherUser]); - await awaitAsync(); - sub.unsubscribe(); - - const [someResult, anotherResult] = results; - expect(policyService.policiesByType$).toHaveBeenCalledWith( - PolicyType.PasswordGenerator, - SomeUser, - ); - expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); - expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); - - expect(policyService.policiesByType$).toHaveBeenCalledWith( - PolicyType.PasswordGenerator, - AnotherUser, - ); - expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy(); - expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy(); - }); - - it("errors when the arbitrary user's stream errors", async () => { - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passwordOverridePolicy]), - ); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - let error = null; - - generator.algorithms$("password", { account$ }).subscribe({ - error: (e: unknown) => { - error = e; - }, - }); - account.error({ some: "error" }); - await awaitAsync(); - - expect(error).toEqual({ some: "error" }); - }); - - it("completes when the arbitrary user's stream completes", async () => { - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passwordOverridePolicy]), - ); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - let completed = false; - - generator.algorithms$("password", { account$ }).subscribe({ - complete: () => { - completed = true; - }, - }); - account.complete(); - await awaitAsync(); - - expect(completed).toBeTruthy(); - }); - - it("ignores repeated arbitrary user emissions", async () => { - policyService.policiesByType$.mockReturnValueOnce( - new BehaviorSubject([passwordOverridePolicy]), - ); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - let count = 0; - - const sub = generator.algorithms$("password", { account$ }).subscribe({ - next: () => { - count++; - }, - }); - await awaitAsync(); - account.next(accounts[SomeUser]); - await awaitAsync(); - account.next(accounts[SomeUser]); - await awaitAsync(); - sub.unsubscribe(); - - expect(count).toEqual(1); - }); - }); - - describe("settings$", () => { - it("defaults to the configuration's initial settings if settings aren't found", async () => { - await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ })); - - expect(result).toEqual(SomeConfiguration.settings.initial); - }); - - it("reads from the active user's configuration-defined storage", async () => { - const settings = { foo: "value" }; - await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ })); - - expect(result).toEqual(settings); - }); - - it("applies policy to the loaded settings", async () => { - const settings = { foo: "value" }; - await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const policy$ = new BehaviorSubject([somePolicy]); - policyService.policiesByType$.mockReturnValue(policy$); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - - const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ })); - - expect(result).toEqual({ foo: "adjusted(value)" }); - }); - - it("reads an arbitrary user's settings", async () => { - await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const anotherSettings = { foo: "another" }; - await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account$ = new BehaviorSubject(accounts[AnotherUser]).asObservable(); - - const result = await firstValueFrom(generator.settings$(SomeConfiguration, { account$ })); - - expect(result).toEqual(anotherSettings); - }); - - it("errors when the arbitrary user's stream errors", async () => { - await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - let error = null; - - generator.settings$(SomeConfiguration, { account$ }).subscribe({ - error: (e: unknown) => { - error = e; - }, - }); - account.error({ some: "error" }); - await awaitAsync(); - - expect(error).toEqual({ some: "error" }); - }); - - it("completes when the arbitrary user's stream completes", async () => { - await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - let completed = false; - - generator.settings$(SomeConfiguration, { account$ }).subscribe({ - complete: () => { - completed = true; - }, - }); - account.complete(); - await awaitAsync(); - - expect(completed).toBeTruthy(); - }); - }); - - describe("settings", () => { - it("writes to the user's state", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const subject = generator.settings(SomeConfiguration, { account$ }); - - subject.next({ foo: "next value" }); - await awaitAsync(); - const result = await firstValueFrom(stateProvider.getUserState$(SettingsKey, SomeUser)); - - expect(result).toEqual({ - foo: "next value", - }); - }); - }); - - describe("policy$", () => { - it("creates constraints without policy in effect when there is no policy", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); - - const result = await firstValueFrom(generator.policy$(SomeConfiguration, { account$ })); - - expect(result.constraints.policyInEffect).toBeFalsy(); - }); - - it("creates constraints with policy in effect when there is a policy", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); - const policy$ = new BehaviorSubject([somePolicy]); - policyService.policiesByType$.mockReturnValue(policy$); - - const result = await firstValueFrom(generator.policy$(SomeConfiguration, { account$ })); - - expect(result.constraints.policyInEffect).toBeTruthy(); - }); - - it("follows policy emissions", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - const somePolicySubject = new BehaviorSubject([somePolicy]); - policyService.policiesByType$.mockReturnValueOnce(somePolicySubject.asObservable()); - const emissions: GeneratorConstraints[] = []; - const sub = generator - .policy$(SomeConfiguration, { account$ }) - .subscribe((policy) => emissions.push(policy)); - - // swap the active policy for an inactive policy - somePolicySubject.next([]); - await awaitAsync(); - sub.unsubscribe(); - const [someResult, anotherResult] = emissions; - - expect(someResult.constraints.policyInEffect).toBeTruthy(); - expect(anotherResult.constraints.policyInEffect).toBeFalsy(); - }); - - it("follows user emissions", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable(); - const anotherPolicy$ = new BehaviorSubject([]).asObservable(); - policyService.policiesByType$ - .mockReturnValueOnce(somePolicy$) - .mockReturnValueOnce(anotherPolicy$); - const emissions: GeneratorConstraints[] = []; - const sub = generator - .policy$(SomeConfiguration, { account$ }) - .subscribe((policy) => emissions.push(policy)); - - // swapping the user invokes the return for `anotherPolicy$` - account.next(accounts[AnotherUser]); - await awaitAsync(); - sub.unsubscribe(); - const [someResult, anotherResult] = emissions; - - expect(someResult.constraints.policyInEffect).toBeTruthy(); - expect(anotherResult.constraints.policyInEffect).toBeFalsy(); - }); - - it("errors when the user errors", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - const expectedError = { some: "error" }; - - let actualError: any = null; - generator.policy$(SomeConfiguration, { account$ }).subscribe({ - error: (e: unknown) => { - actualError = e; - }, - }); - account.error(expectedError); - await awaitAsync(); - - expect(actualError).toEqual(expectedError); - }); - - it("completes when the user completes", async () => { - const generator = new CredentialGeneratorService( - randomizer, - policyService, - apiService, - i18nService, - providers, - ); - const account = new BehaviorSubject(accounts[SomeUser]); - const account$ = account.asObservable(); - - let completed = false; - generator.policy$(SomeConfiguration, { account$ }).subscribe({ - complete: () => { - completed = true; - }, - }); - account.complete(); - await awaitAsync(); - - expect(completed).toBeTruthy(); - }); - }); -}); diff --git a/libs/tools/generator/core/src/services/credential-generator.service.ts b/libs/tools/generator/core/src/services/credential-generator.service.ts deleted file mode 100644 index eacc2ca6fc5..00000000000 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ /dev/null @@ -1,296 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { concatMap, distinctUntilChanged, map, Observable, switchMap, takeUntil } from "rxjs"; -import { Simplify } from "type-fest"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { BoundDependency, OnDependency } from "@bitwarden/common/tools/dependencies"; -import { IntegrationMetadata } from "@bitwarden/common/tools/integration"; -import { RestClient } from "@bitwarden/common/tools/integration/rpc"; -import { anyComplete, withLatestReady } from "@bitwarden/common/tools/rx"; -import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; -import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; - -import { Randomizer } from "../abstractions"; -import { - Generators, - getForwarderConfiguration, - Integrations, - toCredentialGeneratorConfiguration, -} from "../data"; -import { availableAlgorithms } from "../policies/available-algorithms-policy"; -import { mapPolicyToConstraints } from "../rx"; -import { - CredentialAlgorithm, - CredentialCategories, - CredentialCategory, - AlgorithmInfo, - CredentialPreference, - isForwarderIntegration, - ForwarderIntegration, - GenerateRequest, -} from "../types"; -import { - CredentialGeneratorConfiguration as Configuration, - CredentialGeneratorInfo, - GeneratorDependencyProvider, -} from "../types/credential-generator-configuration"; -import { GeneratorConstraints } from "../types/generator-constraints"; - -import { PREFERENCES } from "./credential-preferences"; - -type Generate$Dependencies = Simplify< - OnDependency & BoundDependency<"account", Account> ->; - -export class CredentialGeneratorService { - constructor( - private readonly randomizer: Randomizer, - private readonly policyService: PolicyService, - private readonly apiService: ApiService, - private readonly i18nService: I18nService, - private readonly providers: UserStateSubjectDependencyProvider, - ) {} - - private getDependencyProvider(): GeneratorDependencyProvider { - return { - client: new RestClient(this.apiService, this.i18nService), - i18nService: this.i18nService, - randomizer: this.randomizer, - }; - } - - // FIXME: the rxjs methods of this service can be a lot more resilient if - // `Subjects` are introduced where sharing occurs - - /** Generates a stream of credentials - * @param configuration determines which generator's settings are loaded - * @param dependencies.on$ Required. A new credential is emitted when this emits. - */ - generate$( - configuration: Readonly>, - dependencies: Generate$Dependencies, - ) { - const engine = configuration.engine.create(this.getDependencyProvider()); - const settings$ = this.settings$(configuration, dependencies); - - // generation proper - const generate$ = dependencies.on$.pipe( - withLatestReady(settings$), - concatMap(([request, settings]) => engine.generate(request, settings)), - takeUntil(anyComplete([settings$])), - ); - - return generate$; - } - - /** Emits metadata concerning the provided generation algorithms - * @param category the category or categories of interest - * @param dependences.account$ algorithms are filtered to only - * those matching the provided account's policy. - * @returns An observable that emits algorithm metadata. - */ - algorithms$( - category: CredentialCategory, - dependencies: BoundDependency<"account", Account>, - ): Observable; - algorithms$( - category: CredentialCategory[], - dependencies: BoundDependency<"account", Account>, - ): Observable; - algorithms$( - category: CredentialCategory | CredentialCategory[], - dependencies: BoundDependency<"account", Account>, - ) { - // any cast required here because TypeScript fails to bind `category` - // to the union-typed overload of `algorithms`. - const algorithms = this.algorithms(category as any); - - // apply policy - const algorithms$ = dependencies.account$.pipe( - distinctUntilChanged(), - switchMap((account) => { - const policies$ = this.policyService - .policiesByType$(PolicyType.PasswordGenerator, account.id) - .pipe( - map((p) => new Set(availableAlgorithms(p))), - // complete policy emissions otherwise `switchMap` holds `algorithms$` open indefinitely - takeUntil(anyComplete(dependencies.account$)), - ); - return policies$; - }), - map((available) => { - const filtered = algorithms.filter( - (c) => isForwarderIntegration(c.id) || available.has(c.id), - ); - return filtered; - }), - ); - - return algorithms$; - } - - /** Lists metadata for the algorithms in a credential category - * @param category the category or categories of interest - * @returns A list containing the requested metadata. - */ - algorithms(category: CredentialCategory): AlgorithmInfo[]; - algorithms(category: CredentialCategory[]): AlgorithmInfo[]; - algorithms(category: CredentialCategory | CredentialCategory[]): AlgorithmInfo[] { - const categories: CredentialCategory[] = Array.isArray(category) ? category : [category]; - - const algorithms = categories - .flatMap((c) => CredentialCategories[c] as CredentialAlgorithm[]) - .map((id) => this.algorithm(id)) - .filter((info) => info !== null); - - const forwarders = Object.keys(Integrations) - .map((key: keyof typeof Integrations) => { - const forwarder: ForwarderIntegration = { forwarder: Integrations[key].id }; - return this.algorithm(forwarder); - }) - .filter((forwarder) => categories.includes(forwarder.category)); - - return algorithms.concat(forwarders); - } - - /** Look up the metadata for a specific generator algorithm - * @param id identifies the algorithm - * @returns the requested metadata, or `null` if the metadata wasn't found. - */ - algorithm(id: CredentialAlgorithm): AlgorithmInfo { - let generator: CredentialGeneratorInfo = null; - let integration: IntegrationMetadata = null; - - if (isForwarderIntegration(id)) { - const forwarderConfig = getForwarderConfiguration(id.forwarder); - integration = forwarderConfig; - - if (forwarderConfig) { - generator = toCredentialGeneratorConfiguration(forwarderConfig); - } - } else { - generator = Generators[id]; - } - - if (!generator) { - throw new Error(`Invalid credential algorithm: ${JSON.stringify(id)}`); - } - - const info: AlgorithmInfo = { - id: generator.id, - category: generator.category, - name: integration ? integration.name : this.i18nService.t(generator.nameKey), - generate: this.i18nService.t(generator.generateKey), - onGeneratedMessage: this.i18nService.t(generator.onGeneratedMessageKey), - credentialType: this.i18nService.t(generator.credentialTypeKey), - copy: this.i18nService.t(generator.copyKey), - useGeneratedValue: this.i18nService.t(generator.useGeneratedValueKey), - onlyOnRequest: generator.onlyOnRequest, - request: generator.request, - }; - - if (generator.descriptionKey) { - info.description = this.i18nService.t(generator.descriptionKey); - } - - return info; - } - - /** Get the settings for the provided configuration - * @param configuration determines which generator's settings are loaded - * @param dependencies.account$ identifies the account to which the settings are bound. - * @returns an observable that emits settings - * @remarks the observable enforces policies on the settings - */ - settings$( - configuration: Configuration, - dependencies: BoundDependency<"account", Account>, - ) { - const constraints$ = this.policy$(configuration, dependencies); - - const settings = new UserStateSubject(configuration.settings.account, this.providers, { - constraints$, - account$: dependencies.account$, - }); - - const settings$ = settings.pipe( - map((settings) => settings ?? structuredClone(configuration.settings.initial)), - ); - - return settings$; - } - - /** Get a subject bound to credential generator preferences. - * @param dependencies.account$ identifies the account to which the preferences are bound - * @returns a subject bound to the user's preferences - * @remarks Preferences determine which algorithms are used when generating a - * credential from a credential category (e.g. `PassX` or `Username`). Preferences - * should not be used to hold navigation history. Use @bitwarden/generator-navigation - * instead. - */ - preferences( - dependencies: BoundDependency<"account", Account>, - ): UserStateSubject { - // FIXME: enforce policy - const subject = new UserStateSubject(PREFERENCES, this.providers, dependencies); - - return subject; - } - - /** Get a subject bound to a specific user's settings - * @param configuration determines which generator's settings are loaded - * @param dependencies.account$ identifies the account to which the settings are bound - * @returns a subject bound to the requested user's generator settings - * @remarks the subject enforces policy for the settings - */ - settings( - configuration: Readonly>, - dependencies: BoundDependency<"account", Account>, - ) { - const constraints$ = this.policy$(configuration, dependencies); - - const subject = new UserStateSubject(configuration.settings.account, this.providers, { - constraints$, - account$: dependencies.account$, - }); - - return subject; - } - - /** Get the policy constraints for the provided configuration - * @param dependencies.account$ determines which user's policy is loaded - * @returns an observable that emits the policy once `dependencies.account$` - * and the policy become available. - */ - policy$( - configuration: Configuration, - dependencies: BoundDependency<"account", Account>, - ): Observable> { - const constraints$ = dependencies.account$.pipe( - map((account) => { - if (account.emailVerified) { - return { userId: account.id, email: account.email }; - } - - return { userId: account.id, email: null }; - }), - switchMap(({ userId, email }) => { - // complete policy emissions otherwise `switchMap` holds `policies$` open indefinitely - const policies$ = this.policyService - .policiesByType$(configuration.policy.type, userId) - .pipe( - mapPolicyToConstraints(configuration.policy, email), - takeUntil(anyComplete(dependencies.account$)), - ); - return policies$; - }), - ); - - return constraints$; - } -} diff --git a/libs/tools/generator/core/src/services/credential-preferences.spec.ts b/libs/tools/generator/core/src/services/credential-preferences.spec.ts deleted file mode 100644 index fc7c3e1bbc6..00000000000 --- a/libs/tools/generator/core/src/services/credential-preferences.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { DefaultCredentialPreferences } from "../data"; - -import { PREFERENCES } from "./credential-preferences"; - -describe("PREFERENCES", () => { - describe("deserializer", () => { - it.each([[null], [undefined]])("creates new preferences (= %p)", (value) => { - const result = PREFERENCES.deserializer(value); - - expect(result).toEqual(DefaultCredentialPreferences); - }); - - it("fills missing password preferences", () => { - const input = { ...DefaultCredentialPreferences }; - delete input.password; - - const result = PREFERENCES.deserializer(input as any); - - expect(result).toEqual(DefaultCredentialPreferences); - }); - - it("fills missing email preferences", () => { - const input = { ...DefaultCredentialPreferences }; - delete input.email; - - const result = PREFERENCES.deserializer(input as any); - - expect(result).toEqual(DefaultCredentialPreferences); - }); - - it("fills missing username preferences", () => { - const input = { ...DefaultCredentialPreferences }; - delete input.username; - - const result = PREFERENCES.deserializer(input as any); - - expect(result).toEqual(DefaultCredentialPreferences); - }); - - it("converts updated fields to Dates", () => { - const input = structuredClone(DefaultCredentialPreferences); - input.email.updated = "1970-01-01T00:00:00.100Z" as any; - input.password.updated = "1970-01-01T00:00:00.200Z" as any; - input.username.updated = "1970-01-01T00:00:00.300Z" as any; - - const result = PREFERENCES.deserializer(input as any); - - expect(result.email.updated).toEqual(new Date(100)); - expect(result.password.updated).toEqual(new Date(200)); - expect(result.username.updated).toEqual(new Date(300)); - }); - }); -}); diff --git a/libs/tools/generator/core/src/services/credential-preferences.ts b/libs/tools/generator/core/src/services/credential-preferences.ts deleted file mode 100644 index 3f6a6c1e1bd..00000000000 --- a/libs/tools/generator/core/src/services/credential-preferences.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; - -import { DefaultCredentialPreferences } from "../data"; -import { CredentialPreference } from "../types"; - -/** plaintext password generation options */ -export const PREFERENCES = new UserKeyDefinition( - GENERATOR_DISK, - "credentialPreferences", - { - deserializer: (value) => { - const result = (value as any) ?? {}; - - for (const key in DefaultCredentialPreferences) { - // bind `key` to `category` to transmute the type - const category: keyof typeof DefaultCredentialPreferences = key as any; - - const preference = result[category] ?? { ...DefaultCredentialPreferences[category] }; - if (typeof preference.updated === "string") { - preference.updated = new Date(preference.updated); - } - - result[category] = preference; - } - - return result; - }, - clearOn: ["logout"], - }, -); diff --git a/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts new file mode 100644 index 00000000000..81e7ae6ac63 --- /dev/null +++ b/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts @@ -0,0 +1,356 @@ +import { BehaviorSubject, Subject, firstValueFrom, of } from "rxjs"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; +import { Site, VendorId } from "@bitwarden/common/tools/extension"; +import { Bitwarden } from "@bitwarden/common/tools/extension/vendor/bitwarden"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; +import { SemanticLogger, ifEnabledSemanticLoggerProvider } from "@bitwarden/common/tools/log"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { awaitAsync } from "../../../../../common/spec"; +import { + Algorithm, + CredentialAlgorithm, + CredentialType, + ForwarderExtensionId, + GeneratorMetadata, + Profile, + Type, +} from "../metadata"; +import { CredentialGeneratorProviders } from "../providers"; +import { GenerateRequest, GeneratedCredential } from "../types"; + +import { DefaultCredentialGeneratorService } from "./default-credential-generator.service"; + +// Custom type for jest.fn() mocks to preserve their type +type JestMockFunction any> = jest.Mock, Parameters>; + +// two-level partial that preserves jest.fn() mock types +type MockTwoLevelPartial = { + [K in keyof T]?: T[K] extends object + ? { + [P in keyof T[K]]?: T[K][P] extends (...args: any) => any + ? JestMockFunction + : T[K][P]; + } + : T[K]; +}; + +describe("DefaultCredentialGeneratorService", () => { + let service: DefaultCredentialGeneratorService; + let providers: MockTwoLevelPartial; + let system: any; + let log: SemanticLogger; + let mockExtension: { settings: jest.Mock }; + let account: Account; + let createService: (overrides?: any) => DefaultCredentialGeneratorService; + + beforeEach(() => { + log = ifEnabledSemanticLoggerProvider(false, new ConsoleLogService(true), { + from: "DefaultCredentialGeneratorService tests", + }); + + mockExtension = { settings: jest.fn() }; + + // Use a hard-coded value for mockAccount + account = { + id: "test-account-id" as UserId, + emailVerified: true, + email: "test@example.com", + name: "Test User", + }; + + system = { + log: jest.fn().mockReturnValue(log), + extension: mockExtension, + }; + + providers = { + metadata: { + metadata: jest.fn(), + preference$: jest.fn(), + algorithms$: jest.fn(), + algorithms: jest.fn(), + preferences: jest.fn(), + }, + profile: { + settings: jest.fn(), + constraints$: jest.fn(), + }, + generator: {}, + }; + + // Creating the service instance with a cast to the expected type + createService = (overrides = {}) => { + // Force cast the incomplete providers to the required type + // similar to how the overrides are applied + const providersCast = providers as unknown as CredentialGeneratorProviders; + + const instance = new DefaultCredentialGeneratorService(providersCast, system); + Object.assign(instance, overrides); + return instance; + }; + + service = createService(); + }); + + describe("generate$", () => { + it("should generate credentials when provided a specific algorithm", async () => { + const mockEngine = { + generate: jest + .fn() + .mockReturnValue( + of( + new GeneratedCredential("generatedPassword", Type.password, Date.now(), "unit test"), + ), + ), + }; + const mockMetadata = { + id: Algorithm.password, + engine: { create: jest.fn().mockReturnValue(mockEngine) }, + } as unknown as GeneratorMetadata; + const mockSettings = new BehaviorSubject({ length: 12 }); + providers.metadata!.metadata = jest.fn().mockReturnValue(mockMetadata); + service = createService({ + settings: () => mockSettings as any, + }); + const on$ = new Subject(); + const account$ = new BehaviorSubject(account); + const result$ = new BehaviorSubject(null); + + service.generate$({ on$, account$ }).subscribe(result$); + on$.next({ algorithm: Algorithm.password }); + await awaitAsync(); + + expect(result$.value?.credential).toEqual("generatedPassword"); + expect(providers.metadata!.metadata).toHaveBeenCalledWith(Algorithm.password); + expect(mockMetadata.engine.create).toHaveBeenCalled(); + expect(mockEngine.generate).toHaveBeenCalled(); + }); + + it("should determine preferred algorithm from credential type and generate credentials", async () => { + const mockEngine = { + generate: jest + .fn() + .mockReturnValue( + of(new GeneratedCredential("generatedPassword", "password", Date.now(), "unit test")), + ), + }; + const mockMetadata = { + id: "testAlgorithm", + engine: { create: jest.fn().mockReturnValue(mockEngine) }, + } as unknown as GeneratorMetadata; + const mockSettings = new BehaviorSubject({ length: 12 }); + + providers.metadata!.preference$ = jest + .fn() + .mockReturnValue(of("testAlgorithm" as CredentialAlgorithm)); + providers.metadata!.metadata = jest.fn().mockReturnValue(mockMetadata); + service = createService({ + settings: () => mockSettings as any, + }); + + const on$ = new Subject(); + const account$ = new BehaviorSubject(account); + const result$ = new BehaviorSubject(null); + + service.generate$({ on$, account$ }).subscribe(result$); + on$.next({ type: Type.password }); + await awaitAsync(); + + expect(result$.value?.credential).toBe("generatedPassword"); + expect(result$.value?.category).toBe(Type.password); + expect(providers.metadata!.metadata).toHaveBeenCalledWith("testAlgorithm"); + }); + }); + + describe("algorithms$", () => { + it("should retrieve and map available algorithms for a credential type", async () => { + const mockAlgorithms = [Algorithm.password, Algorithm.passphrase] as CredentialAlgorithm[]; + const mockMetadata1 = { id: Algorithm.password } as GeneratorMetadata; + const mockMetadata2 = { id: Algorithm.passphrase } as GeneratorMetadata; + + providers.metadata!.algorithms$ = jest.fn().mockReturnValue(of(mockAlgorithms)); + providers.metadata!.metadata = jest + .fn() + .mockReturnValueOnce(mockMetadata1) + .mockReturnValueOnce(mockMetadata2); + + const result = await firstValueFrom( + service.algorithms$("password" as CredentialType, { account$: of(account) }), + ); + + expect(result).toEqual([mockMetadata1, mockMetadata2]); + }); + }); + + describe("algorithms", () => { + it("should list algorithm metadata for a single credential type", () => { + providers.metadata!.algorithms = jest + .fn() + .mockReturnValue([Algorithm.password, Algorithm.passphrase] as CredentialAlgorithm[]); + service = createService({ + algorithm: (id: CredentialAlgorithm) => ({ id }) as GeneratorMetadata, + }); + + const result = service.algorithms("password" as CredentialType); + + expect(result).toEqual([{ id: Algorithm.password }, { id: Algorithm.passphrase }]); + expect(providers.metadata!.algorithms).toHaveBeenCalledWith({ type: "password" }); + }); + + it("should list combined algorithm metadata for multiple credential types", () => { + providers.metadata!.algorithms = jest + .fn() + .mockReturnValueOnce([Algorithm.password] as CredentialAlgorithm[]) + .mockReturnValueOnce([Algorithm.username] as CredentialAlgorithm[]); + + service = createService({ + algorithm: (id: CredentialAlgorithm) => ({ id }) as GeneratorMetadata, + }); + + const result = service.algorithms(["password", "username"] as CredentialType[]); + + expect(result).toEqual([{ id: Algorithm.password }, { id: Algorithm.username }]); + expect(providers.metadata!.algorithms).toHaveBeenCalledWith({ type: "password" }); + expect(providers.metadata!.algorithms).toHaveBeenCalledWith({ type: "username" }); + }); + }); + + describe("algorithm", () => { + it("should retrieve metadata for a specific generator algorithm", () => { + const mockMetadata = { id: Algorithm.password } as GeneratorMetadata; + providers.metadata!.metadata = jest.fn().mockReturnValue(mockMetadata); + + const result = service.algorithm(Algorithm.password); + + expect(result).toBe(mockMetadata); + expect(providers.metadata!.metadata).toHaveBeenCalledWith(Algorithm.password); + }); + + it("should log a panic when algorithm ID is invalid", () => { + providers.metadata!.metadata = jest.fn().mockReturnValue(null); + + expect(() => service.algorithm("invalidAlgo" as CredentialAlgorithm)).toThrow( + "invalid credential algorithm", + ); + }); + }); + + describe("forwarder", () => { + it("should retrieve forwarder metadata for a specific vendor", () => { + const vendorId = Vendor.bitwarden; + const forwarderExtensionId: ForwarderExtensionId = { forwarder: vendorId }; + const mockMetadata = { + id: forwarderExtensionId, + type: "email" as CredentialType, + } as GeneratorMetadata; + + providers.metadata!.metadata = jest.fn().mockReturnValue(mockMetadata); + + const result = service.forwarder(vendorId); + + expect(result).toBe(mockMetadata); + expect(providers.metadata!.metadata).toHaveBeenCalledWith(forwarderExtensionId); + }); + + it("should log a panic when vendor ID is invalid", () => { + const invalidVendorId = "invalid-vendor" as VendorId; + providers.metadata!.metadata = jest.fn().mockReturnValue(null); + + expect(() => service.forwarder(invalidVendorId)).toThrow("invalid vendor"); + }); + }); + + describe("preferences", () => { + it("should retrieve credential preferences bound to the user's account", () => { + const mockPreferences = { defaultType: "password" }; + providers.metadata!.preferences = jest.fn().mockReturnValue(mockPreferences); + + const result = service.preferences({ account$: of(account) }); + + expect(result).toBe(mockPreferences); + }); + }); + + describe("settings", () => { + it("should load user settings for account-bound profiles", () => { + const mockSettings = { value: { length: 12 } }; + const mockMetadata = { + id: "test", + profiles: { + [Profile.account]: { id: "accountProfile" }, + }, + } as unknown as GeneratorMetadata; + + providers.profile!.settings = jest.fn().mockReturnValue(mockSettings); + + const result = service.settings(mockMetadata, { account$: of(account) }); + + expect(result).toBe(mockSettings); + }); + + it("should load user settings for extension-bound profiles", () => { + const mockSettings = new BehaviorSubject({ value: { length: 12 } }); + const vendorId = Vendor.bitwarden; + const forwarderProfile = { + id: { forwarder: Bitwarden.id }, + site: Site.forwarder, + type: "extension", + }; + const mockMetadata = { + id: { forwarder: vendorId } as ForwarderExtensionId, + profiles: { + [Profile.account]: forwarderProfile, + }, + } as unknown as GeneratorMetadata; + + mockExtension.settings.mockReturnValue(mockSettings); + + const result = service.settings(mockMetadata, { account$: of(account) }); + + expect(result).toBe(mockSettings); + }); + + it("should log a panic when profile metadata is not found", () => { + const mockMetadata = { + id: "test", + profiles: {}, + } as unknown as GeneratorMetadata; + + expect(() => service.settings(mockMetadata, { account$: of(account) })).toThrow( + "failed to load settings; profile metadata not found", + ); + }); + }); + + describe("policy$", () => { + it("should retrieve policy constraints for a specific profile", async () => { + const mockConstraints = { minLength: 8 }; + const mockMetadata = { + id: "test", + profiles: { + [Profile.account]: { id: "accountProfile" }, + }, + } as unknown as GeneratorMetadata; + + providers.profile!.constraints$ = jest.fn().mockReturnValue(of(mockConstraints)); + + const result = await firstValueFrom(service.policy$(mockMetadata, { account$: of(account) })); + + expect(result).toEqual(mockConstraints); + }); + + it("should log a panic when profile metadata is not found for policy retrieval", () => { + const mockMetadata = { + id: "test", + profiles: {}, + } as unknown as GeneratorMetadata; + + expect(() => service.policy$(mockMetadata, { account$: of(account) })).toThrow( + "failed to load policy; profile metadata not found", + ); + }); + }); +}); diff --git a/libs/tools/generator/core/src/services/default-credential-generator.service.ts b/libs/tools/generator/core/src/services/default-credential-generator.service.ts new file mode 100644 index 00000000000..453139d284c --- /dev/null +++ b/libs/tools/generator/core/src/services/default-credential-generator.service.ts @@ -0,0 +1,219 @@ +import { + ReplaySubject, + concatMap, + filter, + first, + map, + of, + share, + shareReplay, + switchAll, + switchMap, + takeUntil, + tap, + timer, + zip, +} from "rxjs"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BoundDependency, OnDependency } from "@bitwarden/common/tools/dependencies"; +import { VendorId } from "@bitwarden/common/tools/extension"; +import { SemanticLogger } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; +import { anyComplete, memoizedMap } from "@bitwarden/common/tools/rx"; +import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; + +import { CredentialGeneratorService } from "../abstractions"; +import { + CredentialAlgorithm, + Profile, + GeneratorMetadata, + GeneratorProfile, + isForwarderProfile, + toVendorId, + CredentialType, +} from "../metadata"; +import { CredentialGeneratorProviders } from "../providers"; +import { GenerateRequest } from "../types"; +import { isAlgorithmRequest, isTypeRequest } from "../types/metadata-request"; + +const ALGORITHM_CACHE_SIZE = 10; +const THREE_MINUTES = 3 * 60 * 1000; + +export class DefaultCredentialGeneratorService implements CredentialGeneratorService { + /** Instantiate the `DefaultCredentialGeneratorService`. + * @param provide application services required by the credential generator. + * @param system low-level services required by the credential generator. + */ + constructor( + private readonly provide: CredentialGeneratorProviders, + private readonly system: SystemServiceProvider, + ) { + this.log = system.log({ type: "DefaultCredentialGeneratorService" }); + } + + private readonly log: SemanticLogger; + + generate$(dependencies: OnDependency & BoundDependency<"account", Account>) { + const request$ = dependencies.on$.pipe(shareReplay({ refCount: true, bufferSize: 1 })); + const account$ = dependencies.account$.pipe(shareReplay({ refCount: true, bufferSize: 1 })); + + // load algorithm metadata + const metadata$ = request$.pipe( + switchMap((request) => { + if (isAlgorithmRequest(request)) { + return of(request.algorithm); + } else if (isTypeRequest(request)) { + return this.provide.metadata.preference$(request.type, { account$ }).pipe(first()); + } else { + this.log.panic(request, "algorithm or category required"); + } + }), + filter((algorithm): algorithm is CredentialAlgorithm => !!algorithm), + memoizedMap((algorithm) => this.provide.metadata.metadata(algorithm), { + size: ALGORITHM_CACHE_SIZE, + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + // load the active profile's settings + const settings$ = zip(request$, metadata$).pipe( + map( + ([request, metadata]) => + [{ ...request, profile: request.profile ?? Profile.account }, metadata] as const, + ), + memoizedMap( + ([request, metadata]) => { + const [profile, algorithm] = [request.profile, metadata.id]; + + // settings$ stays hot and buffers the most recent value in the cache + // for the next `request` + const settings$ = this.settings(metadata, { account$ }, profile).pipe( + tap(() => this.log.debug({ algorithm, profile }, "settings update received")), + share({ + connector: () => new ReplaySubject(1, THREE_MINUTES), + resetOnRefCountZero: () => timer(THREE_MINUTES), + }), + tap({ + subscribe: () => this.log.debug({ algorithm, profile }, "settings hot"), + complete: () => this.log.debug({ algorithm, profile }, "settings cold"), + }), + first(), + ); + + this.log.debug({ algorithm, profile }, "settings cached"); + return settings$; + }, + { key: ([request, metadata]) => `${metadata.id}:${request.profile}` }, + ), + switchAll(), + ); + + // load the algorithm's engine + const engine$ = metadata$.pipe( + memoizedMap( + (metadata) => { + const engine = metadata.engine.create(this.provide.generator); + + this.log.debug({ algorithm: metadata.id }, "engine cached"); + return engine; + }, + { size: ALGORITHM_CACHE_SIZE }, + ), + ); + + // generation proper + const generate$ = zip([request$, settings$, engine$]).pipe( + tap(([request]) => this.log.debug(request, "generating credential")), + concatMap(([request, settings, engine]) => engine.generate(request, settings)), + takeUntil(anyComplete([settings$])), + ); + + return generate$; + } + + algorithms$(type: CredentialType, dependencies: BoundDependency<"account", Account>) { + return this.provide.metadata + .algorithms$({ type }, dependencies) + .pipe(map((algorithms) => algorithms.map((a) => this.algorithm(a)))); + } + + algorithms(type: CredentialType | CredentialType[]) { + const types: CredentialType[] = Array.isArray(type) ? type : [type]; + const algorithms = types + .flatMap((type) => this.provide.metadata.algorithms({ type })) + .map((algorithm) => this.algorithm(algorithm)); + return algorithms; + } + + algorithm(id: CredentialAlgorithm) { + const metadata = this.provide.metadata.metadata(id); + if (!metadata) { + this.log.panic({ algorithm: id }, "invalid credential algorithm"); + } + + return metadata; + } + + forwarder(id: VendorId) { + const metadata = this.provide.metadata.metadata({ forwarder: id }); + if (!metadata) { + this.log.panic({ algorithm: id }, "invalid vendor"); + } + + return metadata; + } + + preferences(dependencies: BoundDependency<"account", Account>) { + return this.provide.metadata.preferences(dependencies); + } + + settings( + metadata: Readonly>, + dependencies: BoundDependency<"account", Account>, + profile: GeneratorProfile = Profile.account, + ) { + const activeProfile = metadata.profiles[profile]; + if (!activeProfile) { + this.log.panic( + { algorithm: metadata.id, profile }, + "failed to load settings; profile metadata not found", + ); + } + + let settings: UserStateSubject; + if (isForwarderProfile(activeProfile)) { + const vendor = toVendorId(metadata.id); + if (!vendor) { + this.log.panic( + { algorithm: metadata.id, profile }, + "failed to load extension profile; vendor not specified", + ); + } + + this.log.info({ profile, vendor, site: activeProfile.site }, "loading extension profile"); + settings = this.system.extension.settings(activeProfile, vendor, dependencies); + } else { + this.log.info({ profile, algorithm: metadata.id }, "loading generator profile"); + settings = this.provide.profile.settings(activeProfile, dependencies); + } + + return settings; + } + + policy$( + metadata: Readonly>, + dependencies: BoundDependency<"account", Account>, + profile: GeneratorProfile = Profile.account, + ) { + const activeProfile = metadata.profiles[profile]; + if (!activeProfile) { + this.log.panic( + { algorithm: metadata.id, profile }, + "failed to load policy; profile metadata not found", + ); + } + + return this.provide.profile.constraints$(activeProfile, dependencies); + } +} diff --git a/libs/tools/generator/core/src/services/default-generator.service.spec.ts b/libs/tools/generator/core/src/services/default-generator.service.spec.ts index eb9642a9417..a0f9342fa28 100644 --- a/libs/tools/generator/core/src/services/default-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/default-generator.service.spec.ts @@ -18,7 +18,7 @@ import { DefaultGeneratorService } from "./default-generator.service"; function mockPolicyService(config?: { state?: BehaviorSubject }) { const service = mock(); - const stateValue = config?.state ?? new BehaviorSubject([null]); + const stateValue = config?.state ?? new BehaviorSubject([]); service.policiesByType$.mockReturnValue(stateValue); return service; @@ -119,22 +119,22 @@ describe("Password generator service", () => { it("should update the evaluator when the password generator policy changes", async () => { // set up dependencies - const state = new BehaviorSubject([null]); + const state = new BehaviorSubject([]); const policy = mockPolicyService({ state }); const strategy = mockGeneratorStrategy(); const service = new DefaultGeneratorService(strategy, policy); // model responses for the observable update. The map is called multiple times, // and the array shift ensures reference equality is maintained. - const firstEvaluator = mock>(); - const secondEvaluator = mock>(); + const firstEvaluator: PolicyEvaluator = mock>(); + const secondEvaluator: PolicyEvaluator = mock>(); const evaluators = [firstEvaluator, secondEvaluator]; - strategy.toEvaluator.mockReturnValueOnce(pipe(map(() => evaluators.shift()))); + strategy.toEvaluator.mockReturnValueOnce(pipe(map(() => evaluators.shift()!))); // act const evaluator$ = service.evaluator$(SomeUser); const firstResult = await firstValueFrom(evaluator$); - state.next([null]); + state.next([]); const secondResult = await firstValueFrom(evaluator$); // assert diff --git a/libs/tools/generator/core/src/services/index.ts b/libs/tools/generator/core/src/services/index.ts index d7184f684ae..57094e28ddd 100644 --- a/libs/tools/generator/core/src/services/index.ts +++ b/libs/tools/generator/core/src/services/index.ts @@ -1,2 +1,2 @@ export { DefaultGeneratorService } from "./default-generator.service"; -export { CredentialGeneratorService } from "./credential-generator.service"; +export { DefaultCredentialGeneratorService } from "./default-credential-generator.service"; diff --git a/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts index 83ed3b0d14e..ebeacef81e8 100644 --- a/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts @@ -2,7 +2,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { StateProvider } from "@bitwarden/common/platform/state"; import { GeneratorStrategy } from "../abstractions"; -import { DefaultEffUsernameOptions, UsernameDigits } from "../data"; +import { DefaultEffUsernameOptions } from "../data"; import { UsernameRandomizer } from "../engine"; import { newDefaultEvaluator } from "../rx"; import { EffUsernameGenerationOptions, NoPolicy } from "../types"; @@ -10,6 +10,11 @@ import { observe$PerUserId, sharedStateByUserId } from "../util"; import { EFF_USERNAME_SETTINGS } from "./storage"; +const UsernameDigits = Object.freeze({ + enabled: 4, + disabled: 0, +}); + /** Strategy for creating usernames from the EFF wordlist */ export class EffUsernameGeneratorStrategy implements GeneratorStrategy diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts index 5abcea82493..ee521d753ae 100644 --- a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts @@ -8,7 +8,7 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { DefaultPassphraseGenerationOptions, Policies } from "../data"; +import { DefaultPassphraseGenerationOptions } from "../data"; import { PasswordRandomizer } from "../engine"; import { PassphraseGeneratorOptionsEvaluator } from "../policies"; @@ -20,7 +20,7 @@ const SomeUser = "some user" as UserId; describe("Passphrase generation strategy", () => { describe("toEvaluator()", () => { it("should map to the policy evaluator", async () => { - const strategy = new PassphraseGeneratorStrategy(null, null); + const strategy = new PassphraseGeneratorStrategy(null!, null!); const policy = mock({ type: PolicyType.PasswordGenerator, data: { @@ -44,13 +44,18 @@ describe("Passphrase generation strategy", () => { it.each([[[]], [null], [undefined]])( "should map `%p` to a disabled password policy evaluator", async (policies) => { - const strategy = new PassphraseGeneratorStrategy(null, null); + const strategy = new PassphraseGeneratorStrategy(null!, null!); - const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + // this case tests when the type system is subverted + const evaluator$ = of(policies!).pipe(strategy.toEvaluator()); const evaluator = await firstValueFrom(evaluator$); expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject(Policies.Passphrase.disabledValue); + expect(evaluator.policy).toMatchObject({ + minNumberWords: 0, + capitalize: false, + includeNumber: false, + }); }, ); }); @@ -58,7 +63,7 @@ describe("Passphrase generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const strategy = new PassphraseGeneratorStrategy(null, provider); + const strategy = new PassphraseGeneratorStrategy(null!, provider); strategy.durableState(SomeUser); @@ -68,7 +73,7 @@ describe("Passphrase generation strategy", () => { describe("defaults$", () => { it("should return the default subaddress options", async () => { - const strategy = new PassphraseGeneratorStrategy(null, null); + const strategy = new PassphraseGeneratorStrategy(null!, null!); const result = await firstValueFrom(strategy.defaults$(SomeUser)); @@ -78,7 +83,7 @@ describe("Passphrase generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const strategy = new PassphraseGeneratorStrategy(null, null); + const strategy = new PassphraseGeneratorStrategy(null!, null!); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); @@ -95,7 +100,7 @@ describe("Passphrase generation strategy", () => { }); it("should map options", async () => { - const strategy = new PassphraseGeneratorStrategy(randomizer, null); + const strategy = new PassphraseGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ numWords: 6, @@ -114,7 +119,7 @@ describe("Passphrase generation strategy", () => { }); it("should default numWords", async () => { - const strategy = new PassphraseGeneratorStrategy(randomizer, null); + const strategy = new PassphraseGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ capitalize: true, @@ -132,7 +137,7 @@ describe("Passphrase generation strategy", () => { }); it("should default capitalize", async () => { - const strategy = new PassphraseGeneratorStrategy(randomizer, null); + const strategy = new PassphraseGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ numWords: 6, @@ -150,7 +155,7 @@ describe("Passphrase generation strategy", () => { }); it("should default includeNumber", async () => { - const strategy = new PassphraseGeneratorStrategy(randomizer, null); + const strategy = new PassphraseGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ numWords: 6, @@ -168,7 +173,7 @@ describe("Passphrase generation strategy", () => { }); it("should default wordSeparator", async () => { - const strategy = new PassphraseGeneratorStrategy(randomizer, null); + const strategy = new PassphraseGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ numWords: 6, diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts index 759f065b2ff..374df84a5bd 100644 --- a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts @@ -4,8 +4,9 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { StateProvider } from "@bitwarden/common/platform/state"; import { GeneratorStrategy } from "../abstractions"; -import { DefaultPassphraseGenerationOptions, Policies } from "../data"; +import { DefaultPassphraseGenerationOptions } from "../data"; import { PasswordRandomizer } from "../engine"; +import { PassphraseGeneratorOptionsEvaluator, passphraseLeastPrivilege } from "../policies"; import { mapPolicyToEvaluator } from "../rx"; import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types"; import { observe$PerUserId, optionsToEffWordListRequest, sharedStateByUserId } from "../util"; @@ -30,7 +31,16 @@ export class PassphraseGeneratorStrategy defaults$ = observe$PerUserId(() => DefaultPassphraseGenerationOptions); readonly policy = PolicyType.PasswordGenerator; toEvaluator() { - return mapPolicyToEvaluator(Policies.Passphrase); + return mapPolicyToEvaluator({ + type: PolicyType.PasswordGenerator, + disabledValue: Object.freeze({ + minNumberWords: 0, + capitalize: false, + includeNumber: false, + }), + combine: passphraseLeastPrivilege, + createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), + }); } // algorithm diff --git a/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts index 928e0b0dc8b..94e7c16be28 100644 --- a/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts @@ -8,7 +8,7 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { DefaultPasswordGenerationOptions, Policies } from "../data"; +import { DefaultPasswordGenerationOptions } from "../data"; import { PasswordRandomizer } from "../engine"; import { PasswordGeneratorOptionsEvaluator } from "../policies"; @@ -20,7 +20,7 @@ const SomeUser = "some user" as UserId; describe("Password generation strategy", () => { describe("toEvaluator()", () => { it("should map to a password policy evaluator", async () => { - const strategy = new PasswordGeneratorStrategy(null, null); + const strategy = new PasswordGeneratorStrategy(null!, null!); const policy = mock({ type: PolicyType.PasswordGenerator, data: { @@ -52,13 +52,22 @@ describe("Password generation strategy", () => { it.each([[[]], [null], [undefined]])( "should map `%p` to a disabled password policy evaluator", async (policies) => { - const strategy = new PasswordGeneratorStrategy(null, null); + const strategy = new PasswordGeneratorStrategy(null!, null!); - const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + // this case tests when the type system is subverted + const evaluator$ = of(policies!).pipe(strategy.toEvaluator()); const evaluator = await firstValueFrom(evaluator$); expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject(Policies.Password.disabledValue); + expect(evaluator.policy).toMatchObject({ + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, + }); }, ); }); @@ -66,7 +75,7 @@ describe("Password generation strategy", () => { describe("durableState", () => { it("should use password settings key", () => { const provider = mock(); - const strategy = new PasswordGeneratorStrategy(null, provider); + const strategy = new PasswordGeneratorStrategy(null!, provider); strategy.durableState(SomeUser); @@ -76,7 +85,7 @@ describe("Password generation strategy", () => { describe("defaults$", () => { it("should return the default subaddress options", async () => { - const strategy = new PasswordGeneratorStrategy(null, null); + const strategy = new PasswordGeneratorStrategy(null!, null!); const result = await firstValueFrom(strategy.defaults$(SomeUser)); @@ -86,7 +95,7 @@ describe("Password generation strategy", () => { describe("policy", () => { it("should use password generator policy", () => { - const strategy = new PasswordGeneratorStrategy(null, null); + const strategy = new PasswordGeneratorStrategy(null!, null!); expect(strategy.policy).toBe(PolicyType.PasswordGenerator); }); @@ -103,7 +112,7 @@ describe("Password generation strategy", () => { }); it("should map options", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 20, @@ -130,7 +139,7 @@ describe("Password generation strategy", () => { }); it("should disable uppercase", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 3, @@ -157,7 +166,7 @@ describe("Password generation strategy", () => { }); it("should disable lowercase", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 3, @@ -184,7 +193,7 @@ describe("Password generation strategy", () => { }); it("should disable digits", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 3, @@ -211,7 +220,7 @@ describe("Password generation strategy", () => { }); it("should disable special", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 3, @@ -238,7 +247,7 @@ describe("Password generation strategy", () => { }); it("should override length with minimums", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 20, @@ -265,7 +274,7 @@ describe("Password generation strategy", () => { }); it("should default uppercase", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 2, @@ -291,7 +300,7 @@ describe("Password generation strategy", () => { }); it("should default lowercase", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, @@ -317,7 +326,7 @@ describe("Password generation strategy", () => { }); it("should default number", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, @@ -343,7 +352,7 @@ describe("Password generation strategy", () => { }); it("should default special", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, @@ -369,7 +378,7 @@ describe("Password generation strategy", () => { }); it("should default minUppercase", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, @@ -395,7 +404,7 @@ describe("Password generation strategy", () => { }); it("should default minLowercase", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, @@ -421,7 +430,7 @@ describe("Password generation strategy", () => { }); it("should default minNumber", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, @@ -447,7 +456,7 @@ describe("Password generation strategy", () => { }); it("should default minSpecial", async () => { - const strategy = new PasswordGeneratorStrategy(randomizer, null); + const strategy = new PasswordGeneratorStrategy(randomizer, null!); const result = await strategy.generate({ length: 0, diff --git a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts index 9ff8a3d88b0..1a5070901c2 100644 --- a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts @@ -2,8 +2,9 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { StateProvider } from "@bitwarden/common/platform/state"; import { GeneratorStrategy } from "../abstractions"; -import { Policies, DefaultPasswordGenerationOptions } from "../data"; +import { DefaultPasswordGenerationOptions } from "../data"; import { PasswordRandomizer } from "../engine"; +import { PasswordGeneratorOptionsEvaluator, passwordLeastPrivilege } from "../policies"; import { mapPolicyToEvaluator } from "../rx"; import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types"; import { observe$PerUserId, optionsToRandomAsciiRequest, sharedStateByUserId } from "../util"; @@ -27,7 +28,20 @@ export class PasswordGeneratorStrategy defaults$ = observe$PerUserId(() => DefaultPasswordGenerationOptions); readonly policy = PolicyType.PasswordGenerator; toEvaluator() { - return mapPolicyToEvaluator(Policies.Password); + return mapPolicyToEvaluator({ + type: PolicyType.PasswordGenerator, + disabledValue: { + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, + }, + combine: passwordLeastPrivilege, + createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), + }); } // algorithm diff --git a/libs/tools/generator/core/src/types/algorithm-info.ts b/libs/tools/generator/core/src/types/algorithm-info.ts new file mode 100644 index 00000000000..a3db03600b6 --- /dev/null +++ b/libs/tools/generator/core/src/types/algorithm-info.ts @@ -0,0 +1,50 @@ +import { CredentialAlgorithm, CredentialType } from "../metadata"; + +// FIXME: deprecate or delete `AlgorithmInfo` once a better translation +// strategy is identified. +export type AlgorithmInfo = { + /** Uniquely identifies the credential configuration + * @example + * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` + * // to pattern test whether the credential describes a forwarder algorithm + * const meta : CredentialGeneratorInfo = // ... + * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; + */ + id: CredentialAlgorithm; + + /** The kind of credential generated by this configuration */ + type: CredentialType; + + /** Localized algorithm name */ + name: string; + + /* Localized generate button label */ + generate: string; + + /** Localized "credential generated" informational message */ + onGeneratedMessage: string; + + /* Localized copy button label */ + copy: string; + + /* Localized dialog button label */ + useGeneratedValue: string; + + /* Localized generated value label */ + credentialType: string; + + /** Localized algorithm description */ + description?: string; + + /** When true, credential generation must be explicitly requested. + * @remarks this property is useful when credential generation + * carries side effects, such as configuring a service external + * to Bitwarden. + */ + onlyOnRequest: boolean; + + /** Well-known fields to display on the options panel or collect from the environment. + * @remarks: at present, this is only used by forwarders + */ + request: readonly string[]; +}; diff --git a/libs/tools/generator/core/src/types/credential-generator-configuration.ts b/libs/tools/generator/core/src/types/credential-generator-configuration.ts deleted file mode 100644 index 36b0f3046a9..00000000000 --- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { UserKeyDefinition } from "@bitwarden/common/platform/state"; -import { RestClient } from "@bitwarden/common/tools/integration/rpc"; -import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; -import { Constraints } from "@bitwarden/common/tools/types"; - -import { Randomizer } from "../abstractions"; -import { CredentialAlgorithm, CredentialCategory, PolicyConfiguration } from "../types"; - -import { CredentialGenerator } from "./credential-generator"; - -export type GeneratorDependencyProvider = { - randomizer: Randomizer; - client: RestClient; - i18nService: I18nService; -}; - -export type AlgorithmInfo = { - /** Uniquely identifies the credential configuration - * @example - * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` - * // to pattern test whether the credential describes a forwarder algorithm - * const meta : CredentialGeneratorInfo = // ... - * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; - */ - id: CredentialAlgorithm; - - /** The kind of credential generated by this configuration */ - category: CredentialCategory; - - /** Localized algorithm name */ - name: string; - - /* Localized generate button label */ - generate: string; - - /** Localized "credential generated" informational message */ - onGeneratedMessage: string; - - /* Localized copy button label */ - copy: string; - - /* Localized dialog button label */ - useGeneratedValue: string; - - /* Localized generated value label */ - credentialType: string; - - /** Localized algorithm description */ - description?: string; - - /** When true, credential generation must be explicitly requested. - * @remarks this property is useful when credential generation - * carries side effects, such as configuring a service external - * to Bitwarden. - */ - onlyOnRequest: boolean; - - /** Well-known fields to display on the options panel or collect from the environment. - * @remarks: at present, this is only used by forwarders - */ - request: readonly string[]; -}; - -/** Credential generator metadata common across credential generators */ -export type CredentialGeneratorInfo = { - /** Uniquely identifies the credential configuration - * @example - * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` - * // to pattern test whether the credential describes a forwarder algorithm - * const meta : CredentialGeneratorInfo = // ... - * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; - */ - id: CredentialAlgorithm; - - /** The kind of credential generated by this configuration */ - category: CredentialCategory; - - /** Localization key for the credential name */ - nameKey: string; - - /** Localization key for the credential description*/ - descriptionKey?: string; - - /** Localization key for the generate command label */ - generateKey: string; - - /** Localization key for the copy button label */ - copyKey: string; - - /** Localization key for the "credential generated" informational message */ - onGeneratedMessageKey: string; - - /** Localized "use generated credential" button label */ - useGeneratedValueKey: string; - - /** Localization key for describing the kind of credential generated - * by this generator. - */ - credentialTypeKey: string; - - /** When true, credential generation must be explicitly requested. - * @remarks this property is useful when credential generation - * carries side effects, such as configuring a service external - * to Bitwarden. - */ - onlyOnRequest: boolean; - - /** Well-known fields to display on the options panel or collect from the environment. - * @remarks: at present, this is only used by forwarders - */ - request: readonly string[]; -}; - -/** Credential generator metadata that relies upon typed setting and policy definitions. - * @example - * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` - * // to pattern test whether the credential describes a forwarder algorithm - * const meta : CredentialGeneratorInfo = // ... - * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; - */ -export type CredentialGeneratorConfiguration = CredentialGeneratorInfo & { - /** An algorithm that generates credentials when ran. */ - engine: { - /** Factory for the generator - */ - // FIXME: note that this erases the engine's type so that credentials are - // generated uniformly. This property needs to be maintained for - // the credential generator, but engine configurations should return - // the underlying type. `create` may be able to do double-duty w/ an - // engine definition if `CredentialGenerator` can be made covariant. - create: (randomizer: GeneratorDependencyProvider) => CredentialGenerator; - }; - /** Defines the stored parameters for credential generation */ - settings: { - /** value used when an account's settings haven't been initialized - * @deprecated use `ObjectKey.initial` for your desired storage property instead - */ - initial: Readonly>; - - /** Application-global constraints that apply to account settings */ - constraints: Constraints; - - /** storage location for account-global settings */ - account: UserKeyDefinition | ObjectKey; - - /** storage location for *plaintext* settings imports */ - import?: UserKeyDefinition | ObjectKey, Settings>; - }; - - /** defines how to construct policy for this settings instance */ - policy: PolicyConfiguration; -}; diff --git a/libs/tools/generator/core/src/types/credential-preference.ts b/libs/tools/generator/core/src/types/credential-preference.ts new file mode 100644 index 00000000000..957299e5c31 --- /dev/null +++ b/libs/tools/generator/core/src/types/credential-preference.ts @@ -0,0 +1,10 @@ +import { CredentialAlgorithm, CredentialType } from "../metadata"; + +/** The kind of credential to generate using a compound configuration. */ +// FIXME: extend the preferences to include a preferred forwarder +export type CredentialPreference = { + [Key in CredentialType]: { + algorithm: CredentialAlgorithm; + updated: Date; + }; +}; diff --git a/libs/tools/generator/core/src/types/forwarder-options.ts b/libs/tools/generator/core/src/types/forwarder-options.ts index 7ba04da99a8..06084261283 100644 --- a/libs/tools/generator/core/src/types/forwarder-options.ts +++ b/libs/tools/generator/core/src/types/forwarder-options.ts @@ -7,32 +7,65 @@ import { import { EmailDomainSettings, EmailPrefixSettings } from "../engine"; +// FIXME: this type alias is in place for legacy support purposes; +// when replacing the forwarder implementation, eliminate `ForwarderId` and +// `IntegrationId`. The proper type is `VendorId`. /** Identifiers for email forwarding services. * @remarks These are used to select forwarder-specific options. * The must be kept in sync with the forwarder implementations. */ export type ForwarderId = IntegrationId; -/** Metadata format for email forwarding services. */ -export type ForwarderMetadata = { - /** The unique identifier for the forwarder. */ - id: ForwarderId; - - /** The name of the service the forwarder queries. */ - name: string; - - /** Whether the forwarder is valid for self-hosted instances of Bitwarden. */ - validForSelfHosted: boolean; -}; - -/** Options common to all forwarder APIs */ +/** Options common to all forwarder APIs + * @deprecated use {@link ForwarderOptions} instead. + */ export type ApiOptions = ApiSettings & IntegrationRequest; -/** Api configuration for forwarders that support self-hosted installations. */ +/** Api configuration for forwarders that support self-hosted installations. + * @deprecated use {@link ForwarderOptions} instead. + */ export type SelfHostedApiOptions = SelfHostedApiSettings & IntegrationRequest; -/** Api configuration for forwarders that support custom domains. */ +/** Api configuration for forwarders that support custom domains. + * @deprecated use {@link ForwarderOptions} instead. + */ export type EmailDomainOptions = EmailDomainSettings; -/** Api configuration for forwarders that support custom email parts. */ +/** Api configuration for forwarders that support custom email parts. + * @deprecated use {@link ForwarderOptions} instead. + */ export type EmailPrefixOptions = EmailDomainSettings & EmailPrefixSettings; + +/** These options are used by all forwarders; each forwarder uses a different set, + * as defined by `GeneratorMetadata.capabilities.fields`. + */ +export type ForwarderOptions = Partial< + { + /** bearer token that authenticates bitwarden to the forwarder. + * This is required to issue an API request. + */ + token: string; + + /** The base URL of the forwarder's API. + * When this is undefined or empty, the forwarder's default production API is used. + */ + baseUrl: string; + + /** The domain part of the generated email address. + * @remarks The domain should be authorized by the forwarder before + * submitting a request through bitwarden. + * @example If the domain is `domain.io` and the generated username + * is `jd`, then the generated email address will be `jd@domain.io` + */ + domain: string; + + /** A prefix joined to the generated email address' username. + * @example If the prefix is `foo`, the generated username is `bar`, + * and the domain is `domain.io`, then the generated email address is + * `foobar@domain.io`. + */ + prefix: string; + } & EmailDomainSettings & + EmailPrefixSettings & + SelfHostedApiSettings +>; diff --git a/libs/tools/generator/core/src/types/generate-request.ts b/libs/tools/generator/core/src/types/generate-request.ts index c7d5bf9c41c..9dbaaee12f6 100644 --- a/libs/tools/generator/core/src/types/generate-request.ts +++ b/libs/tools/generator/core/src/types/generate-request.ts @@ -1,6 +1,15 @@ +import { RequireExactlyOne } from "type-fest"; + +import { CredentialType, GeneratorProfile, CredentialAlgorithm } from "../metadata"; + /** Contextual information about the application state when a generator is invoked. */ -export type GenerateRequest = { +export type GenerateRequest = RequireExactlyOne< + { type: CredentialType; algorithm: CredentialAlgorithm }, + "type" | "algorithm" +> & { + profile?: GeneratorProfile; + /** Traces the origin of the generation request. This parameter is * copied to the generated credential. * diff --git a/libs/tools/generator/core/src/types/generated-credential.spec.ts b/libs/tools/generator/core/src/types/generated-credential.spec.ts index 6a498282fe3..3d8d2c9bd4d 100644 --- a/libs/tools/generator/core/src/types/generated-credential.spec.ts +++ b/libs/tools/generator/core/src/types/generated-credential.spec.ts @@ -1,40 +1,42 @@ -import { CredentialAlgorithm, GeneratedCredential } from "."; +import { Type } from "../metadata"; + +import { GeneratedCredential } from "./generated-credential"; describe("GeneratedCredential", () => { describe("constructor", () => { it("assigns credential", () => { - const result = new GeneratedCredential("example", "passphrase", new Date(100)); + const result = new GeneratedCredential("example", Type.password, new Date(100)); expect(result.credential).toEqual("example"); }); it("assigns category", () => { - const result = new GeneratedCredential("example", "passphrase", new Date(100)); + const result = new GeneratedCredential("example", Type.password, new Date(100)); - expect(result.category).toEqual("passphrase"); + expect(result.category).toEqual(Type.password); }); it("passes through date parameters", () => { - const result = new GeneratedCredential("example", "password", new Date(100)); + const result = new GeneratedCredential("example", Type.password, new Date(100)); expect(result.generationDate).toEqual(new Date(100)); }); it("converts numeric dates to Dates", () => { - const result = new GeneratedCredential("example", "password", 100); + const result = new GeneratedCredential("example", Type.password, 100); expect(result.generationDate).toEqual(new Date(100)); }); }); it("toJSON converts from a credential into a JSON object", () => { - const credential = new GeneratedCredential("example", "password", new Date(100)); + const credential = new GeneratedCredential("example", Type.password, new Date(100)); const result = credential.toJSON(); expect(result).toEqual({ credential: "example", - category: "password" as CredentialAlgorithm, + category: Type.password, generationDate: 100, }); }); @@ -42,7 +44,7 @@ describe("GeneratedCredential", () => { it("fromJSON converts Json objects into credentials", () => { const jsonValue = { credential: "example", - category: "password" as CredentialAlgorithm, + category: Type.password, generationDate: 100, }; diff --git a/libs/tools/generator/core/src/types/generated-credential.ts b/libs/tools/generator/core/src/types/generated-credential.ts index 99b864b9fd8..695e3866920 100644 --- a/libs/tools/generator/core/src/types/generated-credential.ts +++ b/libs/tools/generator/core/src/types/generated-credential.ts @@ -1,13 +1,13 @@ import { Jsonify } from "type-fest"; -import { CredentialAlgorithm } from "./generator-type"; +import { CredentialType } from "../metadata"; /** A credential generation result */ export class GeneratedCredential { /** * Instantiates a generated credential * @param credential The value of the generated credential (e.g. a password) - * @param category The kind of credential + * @param category The type of credential * @param generationDate The date that the credential was generated. * Numeric values should are interpreted using {@link Date.valueOf} * semantics. @@ -16,7 +16,9 @@ export class GeneratedCredential { */ constructor( readonly credential: string, - readonly category: CredentialAlgorithm, + // FIXME: create a way to migrate the data stored in `category` to a new `type` + // field. The hard part: This requires the migration occur post-decryption. + readonly category: CredentialType, generationDate: Date | number, readonly source?: string, readonly website?: string, diff --git a/libs/tools/generator/core/src/types/generator-type.ts b/libs/tools/generator/core/src/types/generator-type.ts deleted file mode 100644 index c75e4329610..00000000000 --- a/libs/tools/generator/core/src/types/generator-type.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { VendorId } from "@bitwarden/common/tools/extension"; -import { IntegrationId } from "@bitwarden/common/tools/integration"; - -import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types"; -import { AlgorithmsByType, CredentialType } from "../metadata"; - -/** A type of password that may be generated by the credential generator. */ -export type PasswordAlgorithm = (typeof PasswordAlgorithms)[number]; - -/** A type of username that may be generated by the credential generator. */ -export type UsernameAlgorithm = (typeof UsernameAlgorithms)[number]; - -/** A type of email address that may be generated by the credential generator. */ -export type EmailAlgorithm = (typeof EmailAlgorithms)[number]; - -export type ForwarderIntegration = { forwarder: IntegrationId & VendorId }; - -/** Returns true when the input algorithm is a forwarder integration. */ -export function isForwarderIntegration( - algorithm: CredentialAlgorithm, -): algorithm is ForwarderIntegration { - return algorithm && typeof algorithm === "object" && "forwarder" in algorithm; -} - -export function isSameAlgorithm(lhs: CredentialAlgorithm, rhs: CredentialAlgorithm) { - if (lhs === rhs) { - return true; - } else if (isForwarderIntegration(lhs) && isForwarderIntegration(rhs)) { - return lhs.forwarder === rhs.forwarder; - } else { - return false; - } -} - -/** A type of credential that may be generated by the credential generator. */ -export type CredentialAlgorithm = - | PasswordAlgorithm - | UsernameAlgorithm - | EmailAlgorithm - | ForwarderIntegration; - -/** Compound credential types supported by the credential generator. */ -export const CredentialCategories = Object.freeze({ - /** Lists algorithms in the "password" credential category */ - password: PasswordAlgorithms as Readonly, - - /** Lists algorithms in the "username" credential category */ - username: UsernameAlgorithms as Readonly, - - /** Lists algorithms in the "email" credential category */ - email: EmailAlgorithms as Readonly<(EmailAlgorithm | ForwarderIntegration)[]>, -}); - -/** Returns true when the input algorithm is a password algorithm. */ -export function isPasswordAlgorithm( - algorithm: CredentialAlgorithm, -): algorithm is PasswordAlgorithm { - return PasswordAlgorithms.includes(algorithm as any); -} - -/** Returns true when the input algorithm is a username algorithm. */ -export function isUsernameAlgorithm( - algorithm: CredentialAlgorithm, -): algorithm is UsernameAlgorithm { - return UsernameAlgorithms.includes(algorithm as any); -} - -/** Returns true when the input algorithm is an email algorithm. */ -export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm { - return EmailAlgorithms.includes(algorithm as any) || isForwarderIntegration(algorithm); -} - -/** A type of compound credential that may be generated by the credential generator. */ -export type CredentialCategory = keyof typeof CredentialCategories; - -/** The kind of credential to generate using a compound configuration. */ -// FIXME: extend the preferences to include a preferred forwarder -export type CredentialPreference = { - [Key in CredentialType & CredentialCategory]: { - algorithm: CredentialAlgorithm & (typeof AlgorithmsByType)[Key][number]; - updated: Date; - }; -}; diff --git a/libs/tools/generator/core/src/types/index.ts b/libs/tools/generator/core/src/types/index.ts index 3e392257b0c..f6460342b09 100644 --- a/libs/tools/generator/core/src/types/index.ts +++ b/libs/tools/generator/core/src/types/index.ts @@ -1,16 +1,14 @@ -import { EmailAlgorithm, PasswordAlgorithm, UsernameAlgorithm } from "./generator-type"; - export * from "./boundary"; export * from "./catchall-generator-options"; export * from "./credential-generator"; -export * from "./credential-generator-configuration"; +export * from "./algorithm-info"; export * from "./eff-username-generator-options"; export * from "./forwarder-options"; export * from "./generate-request"; export * from "./generator-constraints"; export * from "./generated-credential"; export * from "./generator-options"; -export * from "./generator-type"; +export * from "./credential-preference"; export * from "./no-policy"; export * from "./passphrase-generation-options"; export * from "./passphrase-generator-policy"; @@ -19,13 +17,3 @@ export * from "./password-generator-policy"; export * from "./policy-configuration"; export * from "./subaddress-generator-options"; export * from "./word-options"; - -/** Provided for backwards compatibility only. - * @deprecated Use one of the Algorithm types instead. - */ -export type GeneratorType = PasswordAlgorithm | UsernameAlgorithm | EmailAlgorithm; - -/** Provided for backwards compatibility only. - * @deprecated Use one of the Algorithm types instead. - */ -export type PasswordType = PasswordAlgorithm; diff --git a/libs/tools/generator/core/src/types/policy-configuration.ts b/libs/tools/generator/core/src/types/policy-configuration.ts index 07ded886609..e4db437575a 100644 --- a/libs/tools/generator/core/src/types/policy-configuration.ts +++ b/libs/tools/generator/core/src/types/policy-configuration.ts @@ -3,8 +3,6 @@ import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/do import { PolicyEvaluator } from "../abstractions"; -import { GeneratorConstraints } from "./generator-constraints"; - /** Determines how to construct a password generator policy */ export type PolicyConfiguration = { type: PolicyType; @@ -22,15 +20,4 @@ export type PolicyConfiguration = { * Use `toConstraints` instead. */ createEvaluator: (policy: Policy) => PolicyEvaluator; - - /** Converts policy service data into actionable policy constraints. - * - * @param policy - the policy to map into policy constraints. - * @param email - the default email to extend. - * - * @remarks this version includes constraints needed for the reactive forms; - * it was introduced so that the constraints can be incrementally introduced - * as the new UI is built. - */ - toConstraints: (policy: Policy, email: string) => GeneratorConstraints; }; diff --git a/libs/tools/generator/core/src/util.spec.ts b/libs/tools/generator/core/src/util.spec.ts index 8ed95a9f268..5aeaeb3c67e 100644 --- a/libs/tools/generator/core/src/util.spec.ts +++ b/libs/tools/generator/core/src/util.spec.ts @@ -1,5 +1,6 @@ import { DefaultPassphraseGenerationOptions } from "./data"; -import { optionsToEffWordListRequest, optionsToRandomAsciiRequest, sum } from "./util"; +import { GeneratorConstraints, PassphraseGenerationOptions } from "./types"; +import { optionsToEffWordListRequest, optionsToRandomAsciiRequest, sum, equivalent } from "./util"; describe("sum", () => { it("returns 0 when the list is empty", () => { @@ -424,3 +425,90 @@ describe("optionsToEffWordListRequest", () => { }); }); }); + +describe("equivalent", () => { + // constructs a partial constraints object; only the properties compared + // by `equivalent` are included. + function createConstraints( + policyInEffect: boolean, + numWordsMin?: number, + capitalize?: boolean, + ): GeneratorConstraints { + return { + constraints: { + policyInEffect, + numWords: numWordsMin !== undefined ? { min: numWordsMin } : undefined, + capitalize: capitalize !== undefined ? { requiredValue: capitalize } : undefined, + }, + } as unknown as GeneratorConstraints; + } + + it("should return true for identical constraints", () => { + const lhs = createConstraints(false, 3, true); + const rhs = createConstraints(false, 3, true); + + expect(equivalent(lhs, rhs)).toBe(true); + }); + + it("should return false when policy effects differ", () => { + const lhs = createConstraints(true, 3); + const rhs = createConstraints(false, 3); + + expect(equivalent(lhs, rhs)).toBe(false); + }); + + it("should return false when constraint values differ", () => { + const lhs = createConstraints(false, 3); + const rhs = createConstraints(false, 4); + + expect(equivalent(lhs, rhs)).toBe(false); + }); + + it("should return false when one has additional constraints", () => { + const lhs = createConstraints(false, 3, true); + const rhs = createConstraints(false, 3); + + expect(equivalent(lhs, rhs)).toBe(false); + }); + + it("should handle undefined constraints", () => { + const lhs = createConstraints(false); + const rhs = createConstraints(false); + + expect(equivalent(lhs, rhs)).toBe(true); + }); + + it("should handle empty constraint objects", () => { + const lhs = { + constraints: { + policyInEffect: false, + numWords: {}, + }, + } as unknown as GeneratorConstraints; + const rhs = { + constraints: { + policyInEffect: false, + numWords: {}, + }, + } as unknown as GeneratorConstraints; + + expect(equivalent(lhs, rhs)).toBe(true); + }); + + it("should return false when inner constraint properties differ", () => { + const lhs = { + constraints: { + policyInEffect: false, + numWords: { min: 3, max: 5 }, + }, + } as any; + const rhs = { + constraints: { + policyInEffect: false, + numWords: { min: 3, max: 6 }, + }, + } as unknown as GeneratorConstraints; + + expect(equivalent(lhs, rhs)).toBe(false); + }); +}); diff --git a/libs/tools/generator/core/src/util.ts b/libs/tools/generator/core/src/util.ts index 4b6041ffeba..62f749faf56 100644 --- a/libs/tools/generator/core/src/util.ts +++ b/libs/tools/generator/core/src/util.ts @@ -14,7 +14,11 @@ import { DefaultPassphraseGenerationOptions, DefaultPasswordGenerationOptions, } from "./data"; -import { PassphraseGenerationOptions, PasswordGenerationOptions } from "./types"; +import { + PassphraseGenerationOptions, + PasswordGenerationOptions, + GeneratorConstraints, +} from "./types"; /** construct a method that outputs a copy of `defaultValue` as an observable. */ export function observe$PerUserId( @@ -135,3 +139,30 @@ export function optionsToEffWordListRequest(options: PassphraseGenerationOptions return request; } + +export function equivalent(lhs: GeneratorConstraints, rhs: GeneratorConstraints): boolean { + if (lhs.constraints.policyInEffect !== rhs.constraints.policyInEffect) { + return false; + } + + // safe because `Constraints` shares keys with `T` + const keys = Object.keys(lhs.constraints) as (keyof T)[]; + + // use `for` loop so that `equivalent` can return as soon as the constraints + // differ. Using `array.xyz` would evaluate the whole key list eagerly + for (const k of keys) { + if (!(k in rhs.constraints)) { + return false; + } + + const lhsConstraints: any = lhs.constraints[k] ?? {}; + const rhsConstraints: any = rhs.constraints[k] ?? {}; + + const innerKeys = Object.keys(lhsConstraints); + if (innerKeys.some((k) => lhsConstraints[k] !== rhsConstraints[k])) { + return false; + } + } + + return true; +} diff --git a/libs/tools/generator/core/tsconfig.json b/libs/tools/generator/core/tsconfig.json index a95b588686f..303f913ba23 100644 --- a/libs/tools/generator/core/tsconfig.json +++ b/libs/tools/generator/core/tsconfig.json @@ -8,10 +8,6 @@ "@bitwarden/key-management": ["../../../key-management/src"] } }, - "include": [ - "src", - "../extensions/src/history/generator-history.abstraction.ts", - "../extensions/src/navigation/generator-navigation.service.abstraction.ts" - ], + "include": ["src"], "exclude": ["node_modules", "dist"] } diff --git a/libs/tools/generator/extensions/history/src/generated-credential.spec.ts b/libs/tools/generator/extensions/history/src/generated-credential.spec.ts index 846f2ee96be..26a48cb83ea 100644 --- a/libs/tools/generator/extensions/history/src/generated-credential.spec.ts +++ b/libs/tools/generator/extensions/history/src/generated-credential.spec.ts @@ -1,40 +1,42 @@ -import { GeneratorCategory, GeneratedCredential } from "."; +import { Type } from "@bitwarden/generator-core"; + +import { GeneratedCredential } from "."; describe("GeneratedCredential", () => { describe("constructor", () => { it("assigns credential", () => { - const result = new GeneratedCredential("example", "passphrase", new Date(100)); + const result = new GeneratedCredential("example", Type.password, new Date(100)); expect(result.credential).toEqual("example"); }); it("assigns category", () => { - const result = new GeneratedCredential("example", "passphrase", new Date(100)); + const result = new GeneratedCredential("example", Type.password, new Date(100)); - expect(result.category).toEqual("passphrase"); + expect(result.category).toEqual(Type.password); }); it("passes through date parameters", () => { - const result = new GeneratedCredential("example", "password", new Date(100)); + const result = new GeneratedCredential("example", Type.password, new Date(100)); expect(result.generationDate).toEqual(new Date(100)); }); it("converts numeric dates to Dates", () => { - const result = new GeneratedCredential("example", "password", 100); + const result = new GeneratedCredential("example", Type.password, 100); expect(result.generationDate).toEqual(new Date(100)); }); }); it("toJSON converts from a credential into a JSON object", () => { - const credential = new GeneratedCredential("example", "password", new Date(100)); + const credential = new GeneratedCredential("example", Type.password, new Date(100)); const result = credential.toJSON(); expect(result).toEqual({ credential: "example", - category: "password" as GeneratorCategory, + category: Type.password, generationDate: 100, }); }); @@ -42,7 +44,7 @@ describe("GeneratedCredential", () => { it("fromJSON converts Json objects into credentials", () => { const jsonValue = { credential: "example", - category: "password" as GeneratorCategory, + category: Type.password, generationDate: 100, }; @@ -51,7 +53,7 @@ describe("GeneratedCredential", () => { expect(result).toBeInstanceOf(GeneratedCredential); expect(result).toEqual({ credential: "example", - category: "password", + category: Type.password, generationDate: new Date(100), }); }); diff --git a/libs/tools/generator/extensions/history/src/generated-credential.ts b/libs/tools/generator/extensions/history/src/generated-credential.ts index 32efb752258..bec9e5ac7ee 100644 --- a/libs/tools/generator/extensions/history/src/generated-credential.ts +++ b/libs/tools/generator/extensions/history/src/generated-credential.ts @@ -1,6 +1,6 @@ import { Jsonify } from "type-fest"; -import { CredentialAlgorithm } from "@bitwarden/generator-core"; +import { CredentialType } from "@bitwarden/generator-core"; /** A credential generation result */ export class GeneratedCredential { @@ -14,7 +14,7 @@ export class GeneratedCredential { */ constructor( readonly credential: string, - readonly category: CredentialAlgorithm, + readonly category: CredentialType, generationDate: Date | number, ) { if (typeof generationDate === "number") { diff --git a/libs/tools/generator/extensions/history/src/generator-history.abstraction.ts b/libs/tools/generator/extensions/history/src/generator-history.abstraction.ts index 3b8a0e05a9e..3e3d4002be4 100644 --- a/libs/tools/generator/extensions/history/src/generator-history.abstraction.ts +++ b/libs/tools/generator/extensions/history/src/generator-history.abstraction.ts @@ -3,7 +3,7 @@ import { Observable } from "rxjs"; import { UserId } from "@bitwarden/common/types/guid"; -import { CredentialAlgorithm } from "@bitwarden/generator-core"; +import { CredentialType } from "@bitwarden/generator-core"; import { GeneratedCredential } from "./generated-credential"; @@ -29,7 +29,7 @@ export abstract class GeneratorHistoryService { track: ( userId: UserId, credential: string, - category: CredentialAlgorithm, + category: CredentialType, date?: Date, ) => Promise; diff --git a/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts b/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts index 3621b2c24a9..caff4234386 100644 --- a/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts +++ b/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts @@ -7,6 +7,7 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; +import { Type } from "@bitwarden/generator-core"; import { KeyService } from "@bitwarden/key-management"; import { FakeStateProvider, awaitAsync, mockAccountServiceWith } from "../../../../../common/spec"; @@ -25,7 +26,8 @@ describe("LocalGeneratorHistoryService", () => { encryptService.encryptString.mockImplementation((p) => Promise.resolve(p as unknown as EncString), ); - encryptService.decryptString.mockImplementation((c) => Promise.resolve(c.encryptedString)); + // in the test environment `c.encryptedString` always has a value + encryptService.decryptString.mockImplementation((c) => Promise.resolve(c.encryptedString!)); keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey)); keyService.userKey$.mockImplementation(() => of(true as unknown as UserKey)); }); @@ -50,35 +52,35 @@ describe("LocalGeneratorHistoryService", () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", Type.password); await awaitAsync(); const [result] = await firstValueFrom(history.credentials$(SomeUser)); - expect(result).toMatchObject({ credential: "example", category: "password" }); + expect(result).toMatchObject({ credential: "example", category: Type.password }); }); it("stores a passphrase", async () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "passphrase"); + await history.track(SomeUser, "example", Type.password); await awaitAsync(); const [result] = await firstValueFrom(history.credentials$(SomeUser)); - expect(result).toMatchObject({ credential: "example", category: "passphrase" }); + expect(result).toMatchObject({ credential: "example", category: Type.password }); }); it("stores a specific date when one is provided", async () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password", new Date(100)); + await history.track(SomeUser, "example", Type.password, new Date(100)); await awaitAsync(); const [result] = await firstValueFrom(history.credentials$(SomeUser)); expect(result).toEqual({ credential: "example", - category: "password", + category: Type.password, generationDate: new Date(100), }); }); @@ -87,13 +89,13 @@ describe("LocalGeneratorHistoryService", () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); - await history.track(SomeUser, "example", "password"); - await history.track(SomeUser, "example", "passphrase"); + await history.track(SomeUser, "example", Type.password); + await history.track(SomeUser, "example", Type.password); + await history.track(SomeUser, "example", Type.password); await awaitAsync(); const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); - expect(firstResult).toMatchObject({ credential: "example", category: "password" }); + expect(firstResult).toMatchObject({ credential: "example", category: Type.password }); expect(secondResult).toBeUndefined(); }); @@ -101,13 +103,13 @@ describe("LocalGeneratorHistoryService", () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "secondResult", "password"); - await history.track(SomeUser, "firstResult", "password"); + await history.track(SomeUser, "secondResult", Type.password); + await history.track(SomeUser, "firstResult", Type.password); await awaitAsync(); const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); - expect(firstResult).toMatchObject({ credential: "firstResult", category: "password" }); - expect(secondResult).toMatchObject({ credential: "secondResult", category: "password" }); + expect(firstResult).toMatchObject({ credential: "firstResult", category: Type.password }); + expect(secondResult).toMatchObject({ credential: "secondResult", category: Type.password }); }); it("removes history items exceeding maxTotal configuration", async () => { @@ -116,12 +118,12 @@ describe("LocalGeneratorHistoryService", () => { maxTotal: 1, }); - await history.track(SomeUser, "removed result", "password"); - await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "removed result", Type.password); + await history.track(SomeUser, "example", Type.password); await awaitAsync(); const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); - expect(firstResult).toMatchObject({ credential: "example", category: "password" }); + expect(firstResult).toMatchObject({ credential: "example", category: Type.password }); expect(secondResult).toBeUndefined(); }); @@ -131,8 +133,8 @@ describe("LocalGeneratorHistoryService", () => { maxTotal: 1, }); - await history.track(SomeUser, "some user example", "password"); - await history.track(AnotherUser, "another user example", "password"); + await history.track(SomeUser, "some user example", Type.password); + await history.track(AnotherUser, "another user example", Type.password); await awaitAsync(); const [someFirstResult, someSecondResult] = await firstValueFrom( history.credentials$(SomeUser), @@ -143,12 +145,12 @@ describe("LocalGeneratorHistoryService", () => { expect(someFirstResult).toMatchObject({ credential: "some user example", - category: "password", + category: Type.password, }); expect(someSecondResult).toBeUndefined(); expect(anotherFirstResult).toMatchObject({ credential: "another user example", - category: "password", + category: Type.password, }); expect(anotherSecondResult).toBeUndefined(); }); @@ -167,7 +169,7 @@ describe("LocalGeneratorHistoryService", () => { it("returns null when the credential wasn't found", async () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", Type.password); const result = await history.take(SomeUser, "not found"); @@ -177,20 +179,20 @@ describe("LocalGeneratorHistoryService", () => { it("returns a matching credential", async () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", Type.password); const result = await history.take(SomeUser, "example"); expect(result).toMatchObject({ credential: "example", - category: "password", + category: Type.password, }); }); it("removes a matching credential", async () => { const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); - await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", Type.password); await history.take(SomeUser, "example"); await awaitAsync(); diff --git a/libs/tools/generator/extensions/history/src/local-generator-history.service.ts b/libs/tools/generator/extensions/history/src/local-generator-history.service.ts index cbfa55a184f..673319c1bfa 100644 --- a/libs/tools/generator/extensions/history/src/local-generator-history.service.ts +++ b/libs/tools/generator/extensions/history/src/local-generator-history.service.ts @@ -9,7 +9,7 @@ import { BufferedState } from "@bitwarden/common/tools/state/buffered-state"; import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer"; import { SecretState } from "@bitwarden/common/tools/state/secret-state"; import { UserId } from "@bitwarden/common/types/guid"; -import { CredentialAlgorithm } from "@bitwarden/generator-core"; +import { CredentialType } from "@bitwarden/generator-core"; import { KeyService } from "@bitwarden/key-management"; import { GeneratedCredential } from "./generated-credential"; @@ -36,12 +36,7 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { private _credentialStates = new Map>(); /** {@link GeneratorHistoryService.track} */ - track = async ( - userId: UserId, - credential: string, - category: CredentialAlgorithm, - date?: Date, - ) => { + track = async (userId: UserId, credential: string, category: CredentialType, date?: Date) => { const state = this.getCredentialState(userId); let result: GeneratedCredential = null; diff --git a/libs/tools/generator/core/src/data/forwarders.ts b/libs/tools/generator/extensions/legacy/src/forwarders.ts similarity index 73% rename from libs/tools/generator/core/src/data/forwarders.ts rename to libs/tools/generator/extensions/legacy/src/forwarders.ts index e833fbf41d3..cb926ac3f66 100644 --- a/libs/tools/generator/core/src/data/forwarders.ts +++ b/libs/tools/generator/extensions/legacy/src/forwarders.ts @@ -1,4 +1,18 @@ -import { ForwarderMetadata } from "../types"; +import { IntegrationId } from "@bitwarden/common/tools/integration"; + +export type ForwarderId = IntegrationId; + +/** Metadata format for email forwarding services. */ +export type ForwarderMetadata = { + /** The unique identifier for the forwarder. */ + id: ForwarderId; + + /** The name of the service the forwarder queries. */ + name: string; + + /** Whether the forwarder is valid for self-hosted instances of Bitwarden. */ + validForSelfHosted: boolean; +}; /** Metadata about an email forwarding service. * @remarks This is used to populate the forwarder selection list diff --git a/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.spec.ts b/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.spec.ts index d932d013199..f575cd3b619 100644 --- a/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.spec.ts +++ b/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.spec.ts @@ -1,13 +1,12 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; -import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { UserId } from "@bitwarden/common/types/guid"; import { GeneratorService, DefaultPassphraseGenerationOptions, DefaultPasswordGenerationOptions, - Policies, PassphraseGenerationOptions, PassphraseGeneratorPolicy, PasswordGenerationOptions, @@ -38,12 +37,17 @@ const PasswordGeneratorOptionsEvaluator = policies.PasswordGeneratorOptionsEvalu function createPassphraseGenerator( options: PassphraseGenerationOptions = {}, - policy: PassphraseGeneratorPolicy = Policies.Passphrase.disabledValue, + policy?: PassphraseGeneratorPolicy, ) { let savedOptions = options; const generator = mock>({ evaluator$(id: UserId) { - const evaluator = new PassphraseGeneratorOptionsEvaluator(policy); + const active = policy ?? { + minNumberWords: 0, + capitalize: false, + includeNumber: false, + }; + const evaluator = new PassphraseGeneratorOptionsEvaluator(active); return of(evaluator); }, options$(id: UserId) { @@ -63,12 +67,21 @@ function createPassphraseGenerator( function createPasswordGenerator( options: PasswordGenerationOptions = {}, - policy: PasswordGeneratorPolicy = Policies.Password.disabledValue, + policy?: PasswordGeneratorPolicy, ) { let savedOptions = options; const generator = mock>({ evaluator$(id: UserId) { - const evaluator = new PasswordGeneratorOptionsEvaluator(policy); + const active = policy ?? { + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, + }; + const evaluator = new PasswordGeneratorOptionsEvaluator(active); return of(evaluator); }, options$(id: UserId) { @@ -118,7 +131,13 @@ describe("LegacyPasswordGenerationService", () => { describe("generatePassword", () => { it("invokes the inner password generator to generate passwords", async () => { const innerPassword = createPasswordGenerator(); - const generator = new LegacyPasswordGenerationService(null, null, innerPassword, null, null); + const generator = new LegacyPasswordGenerationService( + null!, + null!, + innerPassword, + null!, + null!, + ); const options = { type: "password" } as PasswordGeneratorOptions; await generator.generatePassword(options); @@ -129,11 +148,11 @@ describe("LegacyPasswordGenerationService", () => { it("invokes the inner passphrase generator to generate passphrases", async () => { const innerPassphrase = createPassphraseGenerator(); const generator = new LegacyPasswordGenerationService( - null, - null, - null, + null!, + null!, + null!, innerPassphrase, - null, + null!, ); const options = { type: "passphrase" } as PasswordGeneratorOptions; @@ -147,11 +166,11 @@ describe("LegacyPasswordGenerationService", () => { it("invokes the inner passphrase generator", async () => { const innerPassphrase = createPassphraseGenerator(); const generator = new LegacyPasswordGenerationService( - null, - null, - null, + null!, + null!, + null!, innerPassphrase, - null, + null!, ); const options = {} as PasswordGeneratorOptions; @@ -185,7 +204,7 @@ describe("LegacyPasswordGenerationService", () => { const navigation = createNavigationGenerator({ type: "passphrase", username: "word", - forwarder: "simplelogin" as IntegrationId, + forwarder: "simplelogin" as VendorId, }); const accountService = mockAccountServiceWith(SomeUser); const generator = new LegacyPasswordGenerationService( @@ -193,7 +212,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const [result] = await generator.getOptions(); @@ -220,16 +239,16 @@ describe("LegacyPasswordGenerationService", () => { }); it("sets default options when an inner service lacks a value", async () => { - const innerPassword = createPasswordGenerator(null); - const innerPassphrase = createPassphraseGenerator(null); - const navigation = createNavigationGenerator(null); + const innerPassword = createPasswordGenerator(null!); + const innerPassphrase = createPassphraseGenerator(null!); + const navigation = createNavigationGenerator(null!); const accountService = mockAccountServiceWith(SomeUser); const generator = new LegacyPasswordGenerationService( accountService, navigation, innerPassword, innerPassphrase, - null, + null!, ); const [result] = await generator.getOptions(); @@ -277,7 +296,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const [, policy] = await generator.getOptions(); @@ -323,7 +342,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); @@ -363,7 +382,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); @@ -409,7 +428,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const [, policy] = await generator.enforcePasswordGeneratorPoliciesOnOptions({}); @@ -441,7 +460,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const options = { type: "password" as const, @@ -474,7 +493,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const options = { type: "passphrase" as const, @@ -496,7 +515,7 @@ describe("LegacyPasswordGenerationService", () => { const navigation = createNavigationGenerator({ type: "password", username: "forwarded", - forwarder: "firefoxrelay" as IntegrationId, + forwarder: "firefoxrelay" as VendorId, }); const accountService = mockAccountServiceWith(SomeUser); const generator = new LegacyPasswordGenerationService( @@ -504,7 +523,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, - null, + null!, ); const options = { type: "passphrase" as const, @@ -533,9 +552,9 @@ describe("LegacyPasswordGenerationService", () => { const accountService = mockAccountServiceWith(SomeUser); const generator = new LegacyPasswordGenerationService( accountService, - null, - null, - null, + null!, + null!, + null!, history, ); @@ -552,9 +571,9 @@ describe("LegacyPasswordGenerationService", () => { const accountService = mockAccountServiceWith(SomeUser); const generator = new LegacyPasswordGenerationService( accountService, - null, - null, - null, + null!, + null!, + null!, history, ); diff --git a/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.spec.ts b/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.spec.ts index 08ffce2eba5..5a4dce4f4a5 100644 --- a/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.spec.ts +++ b/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.spec.ts @@ -1,6 +1,15 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { AddyIo } from "@bitwarden/common/tools/extension/vendor/addyio"; +import { DuckDuckGo } from "@bitwarden/common/tools/extension/vendor/duckduckgo"; +import { Fastmail } from "@bitwarden/common/tools/extension/vendor/fastmail"; +import { ForwardEmail } from "@bitwarden/common/tools/extension/vendor/forwardemail"; +import { Mozilla } from "@bitwarden/common/tools/extension/vendor/mozilla"; +import { SimpleLogin } from "@bitwarden/common/tools/extension/vendor/simplelogin"; +import { IntegrationId } from "@bitwarden/common/tools/integration"; import { UserId } from "@bitwarden/common/types/guid"; import { ApiOptions, @@ -13,13 +22,6 @@ import { DefaultCatchallOptions, DefaultEffUsernameOptions, EffUsernameGenerationOptions, - DefaultAddyIoOptions, - DefaultDuckDuckGoOptions, - DefaultFastmailOptions, - DefaultFirefoxRelayOptions, - DefaultForwardEmailOptions, - DefaultSimpleLoginOptions, - Forwarders, DefaultSubaddressOptions, SubaddressGenerationOptions, policies, @@ -169,7 +171,7 @@ describe("LegacyUsernameGenerationService", () => { // set up an arbitrary forwarder for the username test; all forwarders tested in their own tests const options = { type: "forwarded", - forwardedService: Forwarders.AddyIo.id, + forwardedService: AddyIo.id, } as UsernameGeneratorOptions; const addyIo = createGenerator(null, null); addyIo.generate.mockResolvedValue("addyio@example.com"); @@ -249,7 +251,7 @@ describe("LegacyUsernameGenerationService", () => { describe("generateForwarded", () => { it("should generate a AddyIo username", async () => { const options = { - forwardedService: Forwarders.AddyIo.id, + forwardedService: AddyIo.id, forwardedAnonAddyApiToken: "token", forwardedAnonAddyBaseUrl: "https://example.com", forwardedAnonAddyDomain: "example.com", @@ -284,7 +286,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a DuckDuckGo username", async () => { const options = { - forwardedService: Forwarders.DuckDuckGo.id, + forwardedService: DuckDuckGo.id, forwardedDuckDuckGoToken: "token", website: "example.com", } as UsernameGeneratorOptions; @@ -315,7 +317,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a Fastmail username", async () => { const options = { - forwardedService: Forwarders.Fastmail.id, + forwardedService: Fastmail.id, forwardedFastmailApiToken: "token", website: "example.com", } as UsernameGeneratorOptions; @@ -346,7 +348,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a FirefoxRelay username", async () => { const options = { - forwardedService: Forwarders.FirefoxRelay.id, + forwardedService: Mozilla.id, forwardedFirefoxApiToken: "token", website: "example.com", } as UsernameGeneratorOptions; @@ -377,7 +379,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a ForwardEmail username", async () => { const options = { - forwardedService: Forwarders.ForwardEmail.id, + forwardedService: ForwardEmail.id, forwardedForwardEmailApiToken: "token", forwardedForwardEmailDomain: "example.com", website: "example.com", @@ -410,7 +412,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a SimpleLogin username", async () => { const options = { - forwardedService: Forwarders.SimpleLogin.id, + forwardedService: SimpleLogin.id, forwardedSimpleLoginApiKey: "token", forwardedSimpleLoginBaseUrl: "https://example.com", website: "example.com", @@ -449,7 +451,7 @@ describe("LegacyUsernameGenerationService", () => { const navigation = createNavigationGenerator({ type: "username", username: "catchall", - forwarder: Forwarders.AddyIo.id, + forwarder: AddyIo.id, }); const catchall = createGenerator( @@ -557,7 +559,7 @@ describe("LegacyUsernameGenerationService", () => { subaddressEmail: "foo@example.com", catchallType: "random", catchallDomain: "example.com", - forwardedService: Forwarders.AddyIo.id, + forwardedService: AddyIo.id, forwardedAnonAddyApiToken: "addyIoToken", forwardedAnonAddyDomain: "addyio.example.com", forwardedAnonAddyBaseUrl: "https://addyio.api.example.com", @@ -583,21 +585,36 @@ describe("LegacyUsernameGenerationService", () => { null, DefaultSubaddressOptions, ); - const addyIo = createGenerator( - null, - DefaultAddyIoOptions, - ); - const duckDuckGo = createGenerator(null, DefaultDuckDuckGoOptions); - const fastmail = createGenerator( - null, - DefaultFastmailOptions, - ); - const firefoxRelay = createGenerator(null, DefaultFirefoxRelayOptions); - const forwardEmail = createGenerator( - null, - DefaultForwardEmailOptions, - ); - const simpleLogin = createGenerator(null, DefaultSimpleLoginOptions); + const addyIo = createGenerator(null, { + website: null, + baseUrl: "https://app.addy.io", + token: "", + domain: "", + }); + const duckDuckGo = createGenerator(null, { + website: null, + token: "", + }); + const fastmail = createGenerator(null, { + website: "", + domain: "", + prefix: "", + token: "", + }); + const firefoxRelay = createGenerator(null, { + website: null, + token: "", + }); + const forwardEmail = createGenerator(null, { + website: null, + token: "", + domain: "", + }); + const simpleLogin = createGenerator(null, { + website: null, + baseUrl: "https://app.simplelogin.io", + token: "", + }); const generator = new LegacyUsernameGenerationService( account, @@ -624,16 +641,16 @@ describe("LegacyUsernameGenerationService", () => { subaddressType: DefaultSubaddressOptions.subaddressType, subaddressEmail: DefaultSubaddressOptions.subaddressEmail, forwardedService: DefaultGeneratorNavigation.forwarder, - forwardedAnonAddyApiToken: DefaultAddyIoOptions.token, - forwardedAnonAddyDomain: DefaultAddyIoOptions.domain, - forwardedAnonAddyBaseUrl: DefaultAddyIoOptions.baseUrl, - forwardedDuckDuckGoToken: DefaultDuckDuckGoOptions.token, - forwardedFastmailApiToken: DefaultFastmailOptions.token, - forwardedFirefoxApiToken: DefaultFirefoxRelayOptions.token, - forwardedForwardEmailApiToken: DefaultForwardEmailOptions.token, - forwardedForwardEmailDomain: DefaultForwardEmailOptions.domain, - forwardedSimpleLoginApiKey: DefaultSimpleLoginOptions.token, - forwardedSimpleLoginBaseUrl: DefaultSimpleLoginOptions.baseUrl, + forwardedAnonAddyApiToken: "", + forwardedAnonAddyDomain: "", + forwardedAnonAddyBaseUrl: "https://app.addy.io", + forwardedDuckDuckGoToken: "", + forwardedFastmailApiToken: "", + forwardedFirefoxApiToken: "", + forwardedForwardEmailApiToken: "", + forwardedForwardEmailDomain: "", + forwardedSimpleLoginApiKey: "", + forwardedSimpleLoginBaseUrl: "https://app.simplelogin.io", }); }); }); @@ -678,7 +695,7 @@ describe("LegacyUsernameGenerationService", () => { subaddressEmail: "foo@example.com", catchallType: "random", catchallDomain: "example.com", - forwardedService: Forwarders.AddyIo.id, + forwardedService: AddyIo.id as IntegrationId, forwardedAnonAddyApiToken: "addyIoToken", forwardedAnonAddyDomain: "addyio.example.com", forwardedAnonAddyBaseUrl: "https://addyio.api.example.com", @@ -697,7 +714,7 @@ describe("LegacyUsernameGenerationService", () => { expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, { type: "password", username: "catchall", - forwarder: Forwarders.AddyIo.id, + forwarder: AddyIo.id, }); expect(catchall.saveOptions).toHaveBeenCalledWith(SomeUser, { diff --git a/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.ts b/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.ts index c6e9118535f..48da3404cd6 100644 --- a/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.ts +++ b/libs/tools/generator/extensions/legacy/src/legacy-username-generation.service.ts @@ -3,6 +3,7 @@ import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { UserId } from "@bitwarden/common/types/guid"; import { @@ -14,13 +15,13 @@ import { GeneratorService, CatchallGenerationOptions, EffUsernameGenerationOptions, - Forwarders, SubaddressGenerationOptions, UsernameGeneratorType, ForwarderId, } from "@bitwarden/generator-core"; import { GeneratorNavigationService, GeneratorNavigation } from "@bitwarden/generator-navigation"; +import { Forwarders } from "./forwarders"; import { UsernameGeneratorOptions } from "./username-generation-options"; import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction"; @@ -89,12 +90,14 @@ export class LegacyUsernameGenerationService implements UsernameGenerationServic const stored = this.toStoredOptions(options); switch (options.forwardedService) { case Forwarders.AddyIo.id: + case Vendor.addyio: return this.addyIo.generate(stored.forwarders.addyIo); case Forwarders.DuckDuckGo.id: return this.duckDuckGo.generate(stored.forwarders.duckDuckGo); case Forwarders.Fastmail.id: return this.fastmail.generate(stored.forwarders.fastmail); case Forwarders.FirefoxRelay.id: + case Vendor.mozilla: return this.firefoxRelay.generate(stored.forwarders.firefoxRelay); case Forwarders.ForwardEmail.id: return this.forwardEmail.generate(stored.forwarders.forwardEmail); @@ -232,22 +235,24 @@ export class LegacyUsernameGenerationService implements UsernameGenerationServic options: MappedOptions, ) { switch (forwarder) { - case "anonaddy": + case Forwarders.AddyIo.id: + case Vendor.addyio: await this.addyIo.saveOptions(account, options.forwarders.addyIo); return true; - case "duckduckgo": + case Forwarders.DuckDuckGo.id: await this.duckDuckGo.saveOptions(account, options.forwarders.duckDuckGo); return true; - case "fastmail": + case Forwarders.Fastmail.id: await this.fastmail.saveOptions(account, options.forwarders.fastmail); return true; - case "firefoxrelay": + case Forwarders.FirefoxRelay.id: + case Vendor.mozilla: await this.firefoxRelay.saveOptions(account, options.forwarders.firefoxRelay); return true; - case "forwardemail": + case Forwarders.ForwardEmail.id: await this.forwardEmail.saveOptions(account, options.forwarders.forwardEmail); return true; - case "simplelogin": + case Forwarders.SimpleLogin.id: await this.simpleLogin.saveOptions(account, options.forwarders.simpleLogin); return true; default: diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.spec.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.spec.ts index 218121a3a75..82b9e29e91a 100644 --- a/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.spec.ts +++ b/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.spec.ts @@ -24,7 +24,7 @@ describe("GeneratorNavigationEvaluator", () => { describe("applyPolicy", () => { it("returns the input options when a policy is not in effect", () => { - const evaluator = new GeneratorNavigationEvaluator(null); + const evaluator = new GeneratorNavigationEvaluator(null!); const options = { type: "password" as const }; const result = evaluator.applyPolicy(options); @@ -54,7 +54,7 @@ describe("GeneratorNavigationEvaluator", () => { }); it("defaults options to the default generator navigation type when a policy is not in effect", () => { - const evaluator = new GeneratorNavigationEvaluator(null); + const evaluator = new GeneratorNavigationEvaluator(null!); const result = evaluator.sanitize({}); diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts index 2f891cdea03..5446c1f26ad 100644 --- a/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts +++ b/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { PasswordAlgorithms, PolicyEvaluator } from "@bitwarden/generator-core"; +import { AlgorithmsByType, PolicyEvaluator, Type } from "@bitwarden/generator-core"; import { DefaultGeneratorNavigation } from "./default-generator-navigation"; import { GeneratorNavigation } from "./generator-navigation"; @@ -19,7 +19,7 @@ export class GeneratorNavigationEvaluator /** {@link PolicyEvaluator.policyInEffect} */ get policyInEffect(): boolean { - return PasswordAlgorithms.includes(this.policy?.overridePasswordType); + return AlgorithmsByType[Type.password].includes(this.policy?.overridePasswordType); } /** Apply policy to the input options. diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.ts index cba1f91dad3..fc920a66e0d 100644 --- a/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.ts +++ b/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.ts @@ -4,14 +4,14 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models // implement ADR-0002 import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { PasswordType } from "@bitwarden/generator-core"; +import { PasswordAlgorithm } from "@bitwarden/generator-core"; /** Policy settings affecting password generator navigation */ export type GeneratorNavigationPolicy = { /** The type of generator that should be shown by default when opening * the password generator. */ - overridePasswordType?: PasswordType; + overridePasswordType?: PasswordAlgorithm; }; /** Reduces a policy into an accumulator by preferring the password generator diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation.ts index 5a35e57d7b4..08e5025244d 100644 --- a/libs/tools/generator/extensions/navigation/src/generator-navigation.ts +++ b/libs/tools/generator/extensions/navigation/src/generator-navigation.ts @@ -1,4 +1,5 @@ -import { GeneratorType, ForwarderId, UsernameGeneratorType } from "@bitwarden/generator-core"; +import { VendorId } from "@bitwarden/common/tools/extension"; +import { UsernameGeneratorType, CredentialAlgorithm } from "@bitwarden/generator-core"; /** Stores credential generator UI state. */ export type GeneratorNavigation = { @@ -6,11 +7,11 @@ export type GeneratorNavigation = { * @remarks The legacy generator only supports "password" and "passphrase". * The componentized generator supports all values. */ - type?: GeneratorType; + type?: CredentialAlgorithm; /** When `type === "username"`, this stores the username algorithm. */ username?: UsernameGeneratorType; /** When `username === "forwarded"`, this stores the forwarder implementation. */ - forwarder?: ForwarderId | ""; + forwarder?: VendorId | ""; }; diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts index 8d24fc4acee..30775aa8a83 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts @@ -28,7 +28,7 @@ import { ToastService, TypographyModule, } from "@bitwarden/components"; -import { CredentialGeneratorService, GenerateRequest, Generators } from "@bitwarden/generator-core"; +import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; @@ -122,12 +122,12 @@ export class SendOptionsComponent implements OnInit { } generatePassword = async () => { - const on$ = new BehaviorSubject({ source: "send" }); + const on$ = new BehaviorSubject({ source: "send", type: Type.password }); const account$ = this.accountService.activeAccount$.pipe( pin({ name: () => "send-options.component", distinct: (p, c) => p.id === c.id }), ); const generatedCredential = await firstValueFrom( - this.generatorService.generate$(Generators.password, { on$, account$ }), + this.generatorService.generate$({ on$, account$ }), ); this.sendOptionsForm.patchValue({ From b2d38249ba33416faa8c88ed361ae18cc330ff86 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Tue, 27 May 2025 16:14:36 +0200 Subject: [PATCH 11/30] Remove class user-select: The copy button contains all the information and is the preferred method (#14944) Co-authored-by: Daniel James Smith --- .../popup/settings/about-dialog/about-dialog.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.html b/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.html index 40dad4cde4b..af68959fe5d 100644 --- a/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.html +++ b/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.html @@ -6,7 +6,7 @@

© Bitwarden Inc. 2015-{{ year }}

-
+

{{ "version" | i18n }}: {{ version$ | async }}

SDK: {{ sdkVersion$ | async }}

From d1aa8422e0319ac3b33dd5d9365162ecfe3fa4e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 27 May 2025 11:09:50 -0400 Subject: [PATCH 12/30] align sdk generator types (#14967) --- .../core/src/metadata/password/sdk-eff-word-list.spec.ts | 3 ++- .../core/src/metadata/password/sdk-eff-word-list.ts | 9 +++------ .../src/metadata/password/sdk-random-password.spec.ts | 3 ++- .../core/src/metadata/password/sdk-random-password.ts | 9 +++------ 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.spec.ts index 03f1275f73c..29378b4cdd3 100644 --- a/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.spec.ts @@ -5,7 +5,8 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { SdkPasswordRandomizer } from "../../engine"; import { PassphrasePolicyConstraints } from "../../policies"; -import { PassphraseGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { PassphraseGenerationOptions } from "../../types"; import { Profile } from "../data"; import { CoreProfileMetadata } from "../profile-metadata"; import { isCoreProfile } from "../util"; diff --git a/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.ts b/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.ts index 802ef0ef068..8892016b609 100644 --- a/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.ts +++ b/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.ts @@ -6,17 +6,14 @@ import { BitwardenClient } from "@bitwarden/sdk-internal"; import { SdkPasswordRandomizer } from "../../engine"; import { passphraseLeastPrivilege, PassphrasePolicyConstraints } from "../../policies"; -import { - CredentialGenerator, - GeneratorDependencyProvider, - PassphraseGenerationOptions, -} from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CredentialGenerator, PassphraseGenerationOptions } from "../../types"; import { Algorithm, Profile, Type } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; const sdkPassphrase: GeneratorMetadata = { id: Algorithm.sdkPassphrase, - category: Type.password, + type: Type.password, weight: 130, i18nKeys: { name: "passphrase", diff --git a/libs/tools/generator/core/src/metadata/password/sdk-random-password.spec.ts b/libs/tools/generator/core/src/metadata/password/sdk-random-password.spec.ts index cec4704789f..1e9cf6dbd87 100644 --- a/libs/tools/generator/core/src/metadata/password/sdk-random-password.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/sdk-random-password.spec.ts @@ -5,7 +5,8 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { SdkPasswordRandomizer } from "../../engine"; import { DynamicPasswordPolicyConstraints } from "../../policies"; -import { PasswordGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { PasswordGenerationOptions } from "../../types"; import { Profile } from "../data"; import { CoreProfileMetadata } from "../profile-metadata"; import { isCoreProfile } from "../util"; diff --git a/libs/tools/generator/core/src/metadata/password/sdk-random-password.ts b/libs/tools/generator/core/src/metadata/password/sdk-random-password.ts index d4bb6263b1e..d6544fa115e 100644 --- a/libs/tools/generator/core/src/metadata/password/sdk-random-password.ts +++ b/libs/tools/generator/core/src/metadata/password/sdk-random-password.ts @@ -6,17 +6,14 @@ import { BitwardenClient } from "@bitwarden/sdk-internal"; import { SdkPasswordRandomizer } from "../../engine"; import { DynamicPasswordPolicyConstraints, passwordLeastPrivilege } from "../../policies"; -import { - CredentialGenerator, - GeneratorDependencyProvider, - PasswordGeneratorSettings, -} from "../../types"; +import { GeneratorDependencyProvider } from "../../providers"; +import { CredentialGenerator, PasswordGeneratorSettings } from "../../types"; import { Algorithm, Profile, Type } from "../data"; import { GeneratorMetadata } from "../generator-metadata"; const sdkPassword: GeneratorMetadata = deepFreeze({ id: Algorithm.sdkPassword, - category: Type.password, + type: Type.password, weight: 120, i18nKeys: { name: "password", From abb01d9038819f39e8937dec6e995b9db62f7e44 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 27 May 2025 11:14:10 -0400 Subject: [PATCH 13/30] ensure loginview properties have correct defaults when usding SDK decryption (#14948) --- libs/common/src/vault/models/view/login.view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index 41568f643d5..6bdc23f42b1 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -118,7 +118,7 @@ export class LoginView extends ItemView { const passwordRevisionDate = obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); - const uris = obj.uris?.map((uri) => LoginUriView.fromSdkLoginUriView(uri)); + const uris = obj.uris?.map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || []; return Object.assign(new LoginView(), obj, { passwordRevisionDate, From 0f9f6a1c5ddfccc2632011066375cb1b847a6a47 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 18:58:56 +0200 Subject: [PATCH 14/30] [deps] Tools: Update papaparse to v5.5.3 (#14966) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- apps/cli/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 4c8d2918ce1..eeebf4dad6f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -86,7 +86,7 @@ "node-fetch": "2.6.12", "node-forge": "1.3.1", "open": "8.4.2", - "papaparse": "5.5.2", + "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "tldts": "7.0.1", diff --git a/package-lock.json b/package-lock.json index ff21acbc20b..f0dd03b5e74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "node-forge": "1.3.1", "oidc-client-ts": "2.4.1", "open": "8.4.2", - "papaparse": "5.5.2", + "papaparse": "5.5.3", "patch-package": "8.0.0", "proper-lockfile": "4.1.2", "qrcode-parser": "2.1.3", @@ -111,7 +111,7 @@ "@types/node": "22.15.3", "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", - "@types/papaparse": "5.3.15", + "@types/papaparse": "5.3.16", "@types/proper-lockfile": "4.1.4", "@types/retry": "0.12.5", "@types/zxcvbn": "4.4.5", @@ -219,7 +219,7 @@ "node-fetch": "2.6.12", "node-forge": "1.3.1", "open": "8.4.2", - "papaparse": "5.5.2", + "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "tldts": "7.0.1", @@ -11939,9 +11939,9 @@ } }, "node_modules/@types/papaparse": { - "version": "5.3.15", - "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.15.tgz", - "integrity": "sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.16.tgz", + "integrity": "sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg==", "dev": true, "license": "MIT", "dependencies": { @@ -30738,9 +30738,9 @@ "license": "(MIT AND Zlib)" }, "node_modules/papaparse": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz", - "integrity": "sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", "license": "MIT" }, "node_modules/param-case": { diff --git a/package.json b/package.json index f63db41e1f3..fe7f69ff40d 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "@types/node": "22.15.3", "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", - "@types/papaparse": "5.3.15", + "@types/papaparse": "5.3.16", "@types/proper-lockfile": "4.1.4", "@types/retry": "0.12.5", "@types/zxcvbn": "4.4.5", @@ -194,7 +194,7 @@ "node-forge": "1.3.1", "oidc-client-ts": "2.4.1", "open": "8.4.2", - "papaparse": "5.5.2", + "papaparse": "5.5.3", "patch-package": "8.0.0", "proper-lockfile": "4.1.2", "qrcode-parser": "2.1.3", From 5f169af08e69c4b957df3e5c9aa6baef56cb4616 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 27 May 2025 10:03:29 -0700 Subject: [PATCH 15/30] [PM-21122] - Hide orgs in product switcher for single org policy users (#14803) * don't display orgs in account switcher for single org policy users * add comment * add test case * fix storybook * fix storybook again * use variable name instead of comment --- .../navigation-switcher.stories.ts | 7 +++++++ .../product-switcher/product-switcher.stories.ts | 7 +++++++ .../shared/product-switcher.service.spec.ts | 16 ++++++++++++++++ .../shared/product-switcher.service.ts | 16 ++++++++++++++-- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts index b1c1a0a906a..0ecec9d8944 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts @@ -5,6 +5,7 @@ import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; @@ -130,6 +131,12 @@ export default { return new I18nMockService(translations); }, }, + { + provide: PolicyService, + useValue: { + policyAppliesToUser$: () => of(false), + }, + }, ], }), applicationConfig({ diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index 4525105e579..0b7304a3657 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -5,6 +5,7 @@ import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; @@ -126,6 +127,12 @@ export default { }); }, }, + { + provide: PolicyService, + useValue: { + policyAppliesToUser$: () => of(false), + }, + }, ], }), applicationConfig({ diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts index a1ac434d590..4abd85d7991 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts @@ -7,6 +7,7 @@ import { Observable, firstValueFrom, of } from "rxjs"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; @@ -27,6 +28,7 @@ describe("ProductSwitcherService", () => { let accountService: FakeAccountService; let platformUtilsService: MockProxy; let activeRouteParams = convertToParamMap({ organizationId: "1234" }); + let singleOrgPolicyEnabled = false; const getLastSync = jest.fn().mockResolvedValue(new Date("2024-05-14")); const userId = Utils.newGuid() as UserId; @@ -77,6 +79,12 @@ describe("ProductSwitcherService", () => { provide: SyncService, useValue: { getLastSync }, }, + { + provide: PolicyService, + useValue: { + policyAppliesToUser$: () => of(singleOrgPolicyEnabled), + }, + }, ], }); }); @@ -184,6 +192,14 @@ describe("ProductSwitcherService", () => { expect(products.bento.find((p) => p.name === "Admin Console")).toBeDefined(); expect(products.other.find((p) => p.name === "Organizations")).toBeUndefined(); }); + + it("does not include Organizations when the user's single org policy is enabled", async () => { + singleOrgPolicyEnabled = true; + initiateService(); + const products = await firstValueFrom(service.products$); + + expect(products.other.find((p) => p.name === "Organizations")).not.toBeDefined(); + }); }); describe("Provider Portal", () => { diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts index ec0d2c2651c..53ec3b0840f 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts @@ -6,6 +6,7 @@ import { combineLatest, concatMap, filter, + firstValueFrom, map, Observable, ReplaySubject, @@ -18,10 +19,12 @@ import { canAccessOrgAdmin, OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { ProviderType } from "@bitwarden/common/admin-console/enums"; +import { PolicyType, ProviderType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; @@ -104,6 +107,7 @@ export class ProductSwitcherService { private syncService: SyncService, private accountService: AccountService, private platformUtilsService: PlatformUtilsService, + private policyService: PolicyService, ) { this.pollUntilSynced(); } @@ -235,7 +239,15 @@ export class ProductSwitcherService { if (acOrg) { bento.push(products.ac); } else { - other.push(products.orgs); + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getUserId), + ); + const userHasSingleOrgPolicy = await firstValueFrom( + this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, activeUserId), + ); + if (!userHasSingleOrgPolicy) { + other.push(products.orgs); + } } if (providers.length > 0) { From 677a435cadddf26dbca9461d78a0950c52360c5f Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 27 May 2025 10:05:58 -0700 Subject: [PATCH 16/30] prevent showing ssh key when cipher changes in desktop view (#14913) --- apps/desktop/src/vault/app/vault/view.component.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/vault/app/vault/view.component.ts b/apps/desktop/src/vault/app/vault/view.component.ts index 68bf8d6d1a3..33dfc600265 100644 --- a/apps/desktop/src/vault/app/vault/view.component.ts +++ b/apps/desktop/src/vault/app/vault/view.component.ts @@ -9,6 +9,7 @@ import { OnDestroy, OnInit, Output, + SimpleChanges, } from "@angular/core"; import { ViewComponent as BaseViewComponent } from "@bitwarden/angular/vault/components/view.component"; @@ -130,7 +131,7 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); } - async ngOnChanges() { + async ngOnChanges(changes: SimpleChanges) { if (this.cipher?.decryptionFailure) { DecryptionFailureDialogComponent.open(this.dialogService, { cipherIds: [this.cipherId as CipherId], @@ -138,6 +139,12 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro return; } this.passwordReprompted = this.masterPasswordAlreadyPrompted; + + if (changes["cipherId"]) { + if (changes["cipherId"].currentValue !== changes["cipherId"].previousValue) { + this.showPrivateKey = false; + } + } } viewHistory() { From 5423ab326898c77950172b4f8cdd852a7f12d1a5 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 27 May 2025 19:13:15 +0200 Subject: [PATCH 17/30] [PM-21934] Upgrade to eslint 9 (#14754) Upgrades to Eslint v9. Since this is a major version there were breaking changes, but since we've previously migrated to flat configs in #12806 those were minimal. --- .github/renovate.json5 | 2 + .../vault-timeout/vault-timeout.service.ts | 1 - .../popup/services/browser-router.service.ts | 2 + .../view-cache/popup-router-cache.service.ts | 2 + apps/cli/src/utils.ts | 1 - .../organization-reporting-routing.module.ts | 6 +- .../change-plan-dialog.component.ts | 2 + .../organization-payment-method.component.ts | 2 + .../shared/payment-method.component.ts | 2 + apps/web/src/app/core/router.service.ts | 3 + eslint.config.mjs | 6 +- .../device-trust.service.implementation.ts | 4 + libs/eslint/fix-jsdom.ts | 10 + libs/eslint/jest.config.js | 1 + libs/eslint/test.setup.mjs | 2 - libs/eslint/tsconfig.json | 3 +- .../dashlane/dashlane-csv-importer.ts | 2 +- package-lock.json | 2758 ++++++++++++----- package.json | 9 +- 19 files changed, 1961 insertions(+), 857 deletions(-) create mode 100644 libs/eslint/fix-jsdom.ts diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 1b84ccdab01..f30bc06e4a2 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -59,6 +59,7 @@ { matchPackageNames: [ "@angular-eslint/schematics", + "@eslint/compat", "@typescript-eslint/rule-tester", "@typescript-eslint/utils", "angular-eslint", @@ -81,6 +82,7 @@ { matchPackageNames: [ "@angular-eslint/schematics", + "@eslint/compat", "@typescript-eslint/rule-tester", "@typescript-eslint/utils", "angular-eslint", diff --git a/apps/browser/src/key-management/vault-timeout/vault-timeout.service.ts b/apps/browser/src/key-management/vault-timeout/vault-timeout.service.ts index 51f90fb98a6..c0de04b02d7 100644 --- a/apps/browser/src/key-management/vault-timeout/vault-timeout.service.ts +++ b/apps/browser/src/key-management/vault-timeout/vault-timeout.service.ts @@ -17,7 +17,6 @@ export default class VaultTimeoutService extends BaseVaultTimeoutService { // setIntervals. It works by calling the native extension which sleeps for 10s, // efficiently replicating setInterval. async checkSafari() { - // eslint-disable-next-line while (true) { try { await SafariApp.sendMessageToApp("sleep"); diff --git a/apps/browser/src/platform/popup/services/browser-router.service.ts b/apps/browser/src/platform/popup/services/browser-router.service.ts index 413bde5fcad..2d449b8a0f2 100644 --- a/apps/browser/src/platform/popup/services/browser-router.service.ts +++ b/apps/browser/src/platform/popup/services/browser-router.service.ts @@ -21,6 +21,8 @@ export class BrowserRouterService { child = child.firstChild; } + // TODO: Eslint upgrade. Please resolve this since the ?? does nothing + // eslint-disable-next-line no-constant-binary-expression const updateUrl = !child?.data?.doNotSaveUrl ?? true; if (updateUrl) { diff --git a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts index aa0c0854eff..5fc508ac2a6 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts @@ -62,6 +62,8 @@ export class PopupRouterCacheService { child = child.firstChild; } + // TODO: Eslint upgrade. Please resolve this since the ?? does nothing + // eslint-disable-next-line no-constant-binary-expression return !child?.data?.doNotSaveUrl ?? true; }), switchMap((event) => this.push(event.url)), diff --git a/apps/cli/src/utils.ts b/apps/cli/src/utils.ts index fadede9c71b..e321adbfd5e 100644 --- a/apps/cli/src/utils.ts +++ b/apps/cli/src/utils.ts @@ -161,7 +161,6 @@ export class CliUtils { process.stdin.setEncoding("utf8"); process.stdin.on("readable", () => { - // eslint-disable-next-line while (true) { const chunk = process.stdin.read(); if (chunk == null) { diff --git a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts index 23751653331..4c825b26bb2 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts @@ -9,12 +9,16 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +// eslint-disable-next-line no-restricted-imports import { ExposedPasswordsReportComponent } from "../../../dirt/reports/pages/organizations/exposed-passwords-report.component"; +// eslint-disable-next-line no-restricted-imports import { InactiveTwoFactorReportComponent } from "../../../dirt/reports/pages/organizations/inactive-two-factor-report.component"; +// eslint-disable-next-line no-restricted-imports import { ReusedPasswordsReportComponent } from "../../../dirt/reports/pages/organizations/reused-passwords-report.component"; +// eslint-disable-next-line no-restricted-imports import { UnsecuredWebsitesReportComponent } from "../../../dirt/reports/pages/organizations/unsecured-websites-report.component"; +// eslint-disable-next-line no-restricted-imports import { WeakPasswordsReportComponent } from "../../../dirt/reports/pages/organizations/weak-passwords-report.component"; -/* eslint no-restricted-imports: "error" */ import { isPaidOrgGuard } from "../guards/is-paid-org.guard"; import { organizationPermissionsGuard } from "../guards/org-permissions.guard"; import { organizationRedirectGuard } from "../guards/org-redirect.guard"; diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 9b6694a3bbe..a6e8670d944 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -607,6 +607,8 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return ( plan.PasswordManager.additionalStoragePricePerGb * + // TODO: Eslint upgrade. Please resolve this since the null check does nothing + // eslint-disable-next-line no-constant-binary-expression Math.abs(this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0 || 0) ); } diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts index c896ee6404c..fbd7453c712 100644 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts +++ b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts @@ -148,6 +148,8 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { paymentSource, ); } + // TODO: Eslint upgrade. Please resolve this since the ?? does nothing + // eslint-disable-next-line no-constant-binary-expression this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false; // If the flag `launchPaymentModalAutomatically` is set to true, // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. diff --git a/apps/web/src/app/billing/shared/payment-method.component.ts b/apps/web/src/app/billing/shared/payment-method.component.ts index 27c9caf7186..74793bccc01 100644 --- a/apps/web/src/app/billing/shared/payment-method.component.ts +++ b/apps/web/src/app/billing/shared/payment-method.component.ts @@ -143,6 +143,8 @@ export class PaymentMethodComponent implements OnInit, OnDestroy { [this.billing, this.sub] = await Promise.all([billingPromise, subPromise]); } + // TODO: Eslint upgrade. Please resolve this since the ?? does nothing + // eslint-disable-next-line no-constant-binary-expression this.isUnpaid = this.subscription?.status === "unpaid" ?? false; this.loading = false; // If the flag `launchPaymentModalAutomatically` is set to true, diff --git a/apps/web/src/app/core/router.service.ts b/apps/web/src/app/core/router.service.ts index ff0aea47b9a..603c171e95b 100644 --- a/apps/web/src/app/core/router.service.ts +++ b/apps/web/src/app/core/router.service.ts @@ -74,6 +74,9 @@ export class RouterService { const titleId: string = child?.snapshot?.data?.titleId; const rawTitle: string = child?.snapshot?.data?.title; + + // TODO: Eslint upgrade. Please resolve this since the ?? does nothing + // eslint-disable-next-line no-constant-binary-expression const updateUrl = !child?.snapshot?.data?.doNotSaveUrl ?? true; if (titleId != null || rawTitle != null) { diff --git a/eslint.config.mjs b/eslint.config.mjs index aa0b475da0b..5a4874457a0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,5 @@ // @ts-check - +import { fixupPluginRules } from "@eslint/compat"; import eslint from "@eslint/js"; import tseslint from "typescript-eslint"; import angular from "angular-eslint"; @@ -31,8 +31,8 @@ export default tseslint.config( reportUnusedDisableDirectives: "error", }, plugins: { - rxjs: rxjs, - "rxjs-angular": angularRxjs, + rxjs: fixupPluginRules(rxjs), + "rxjs-angular": fixupPluginRules(angularRxjs), "@bitwarden/platform": platformPlugins, }, languageOptions: { diff --git a/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts b/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts index 84ebf981f03..ecfeb10dcda 100644 --- a/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts +++ b/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts @@ -89,6 +89,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction { ) { this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe( map((options) => { + // TODO: Eslint upgrade. Please resolve this since the ?? does nothing + // eslint-disable-next-line no-constant-binary-expression return options?.trustedDeviceOption != null ?? false; }), ); @@ -97,6 +99,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction { supportsDeviceTrustByUserId$(userId: UserId): Observable { return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId).pipe( map((options) => { + // TODO: Eslint upgrade. Please resolve this since the ?? does nothing + // eslint-disable-next-line no-constant-binary-expression return options?.trustedDeviceOption != null ?? false; }), ); diff --git a/libs/eslint/fix-jsdom.ts b/libs/eslint/fix-jsdom.ts new file mode 100644 index 00000000000..f7c29a62723 --- /dev/null +++ b/libs/eslint/fix-jsdom.ts @@ -0,0 +1,10 @@ +import JSDOMEnvironment from "jest-environment-jsdom"; + +export default class FixJSDOMEnvironment extends JSDOMEnvironment { + constructor(...args: ConstructorParameters) { + super(...args); + + // FIXME https://github.com/jsdom/jsdom/issues/3363 + this.global.structuredClone = structuredClone; + } +} diff --git a/libs/eslint/jest.config.js b/libs/eslint/jest.config.js index 67436ff906e..5acadb023e4 100644 --- a/libs/eslint/jest.config.js +++ b/libs/eslint/jest.config.js @@ -3,6 +3,7 @@ const sharedConfig = require("../../libs/shared/jest.config.angular"); /** @type {import('jest').Config} */ module.exports = { ...sharedConfig, + testEnvironment: "./fix-jsdom.ts", testMatch: ["**/+(*.)+(spec).+(mjs)"], displayName: "libs/eslint tests", preset: "jest-preset-angular", diff --git a/libs/eslint/test.setup.mjs b/libs/eslint/test.setup.mjs index 45de6fcf3e1..19f35df9e7f 100644 --- a/libs/eslint/test.setup.mjs +++ b/libs/eslint/test.setup.mjs @@ -1,5 +1,3 @@ -/* eslint-disable no-undef */ - import { clearImmediate, setImmediate } from "node:timers"; Object.defineProperties(globalThis, { diff --git a/libs/eslint/tsconfig.json b/libs/eslint/tsconfig.json index eb2bb889d1a..bbf065886c4 100644 --- a/libs/eslint/tsconfig.json +++ b/libs/eslint/tsconfig.json @@ -1,5 +1,6 @@ { "extends": "../shared/tsconfig", "compilerOptions": {}, - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist"], + "files": ["empty.ts"] } diff --git a/libs/importer/src/importers/dashlane/dashlane-csv-importer.ts b/libs/importer/src/importers/dashlane/dashlane-csv-importer.ts index 5b282fb466c..2ead8c4bb21 100644 --- a/libs/importer/src/importers/dashlane/dashlane-csv-importer.ts +++ b/libs/importer/src/importers/dashlane/dashlane-csv-importer.ts @@ -92,7 +92,7 @@ export class DashlaneCsvImporter extends BaseImporter implements Importer { this.parseIdRecord(cipher, row); } - if ((rowKeys[0] === "type") != null && rowKeys[1] === "title") { + if (rowKeys[0] === "type" && rowKeys[1] === "title") { this.parsePersonalInformationRecord(cipher, row); } diff --git a/package-lock.json b/package-lock.json index f0dd03b5e74..691705cc280 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ "@compodoc/compodoc": "1.1.26", "@electron/notarize": "2.5.0", "@electron/rebuild": "3.7.2", + "@eslint/compat": "1.2.9", "@lit-labs/signals": "0.1.2", "@ngtools/webpack": "18.2.19", "@storybook/addon-a11y": "8.6.12", @@ -136,7 +137,7 @@ "electron-reload": "2.0.0-alpha.1", "electron-store": "8.2.0", "electron-updater": "6.6.4", - "eslint": "8.57.1", + "eslint": "9.26.0", "eslint-config-prettier": "10.1.2", "eslint-import-resolver-typescript": "4.3.4", "eslint-plugin-import": "2.31.0", @@ -398,14 +399,14 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1902.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.12.tgz", - "integrity": "sha512-LfUc7k84YL290hAxsG+FvjQpXugQXyw5aDzrQQB4iTYhBgaABu2aaNOU4eu3JH+F8NeXd2EBF/YMr2LDSkYlMw==", + "version": "0.1902.13", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.13.tgz", + "integrity": "sha512-ZMj+PjK22Ph2U8usG6L7LqEfvWlbaOvmiWXSrEt9YiC9QJt6rsumCkOgUIsmHQtucm/lK+9CMtyYdwH2fYycjg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@angular-devkit/core": "19.2.12", + "@angular-devkit/core": "19.2.13", "rxjs": "7.8.1" }, "engines": { @@ -762,6 +763,20 @@ "@types/send": "*" } }, + "node_modules/@angular-devkit/build-angular/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -828,6 +843,48 @@ "webpack": ">=5" } }, + "node_modules/@angular-devkit/build-angular/node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/@angular-devkit/build-angular/node_modules/browserslist": { "version": "4.24.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", @@ -899,6 +956,19 @@ "node": ">= 6" } }, + "node_modules/@angular-devkit/build-angular/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -906,6 +976,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@angular-devkit/build-angular/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@angular-devkit/build-angular/node_modules/copy-webpack-plugin": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", @@ -968,6 +1055,116 @@ "node": ">=4.0" } }, + "node_modules/@angular-devkit/build-angular/node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/build-angular/node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/build-angular/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -1003,6 +1200,19 @@ "node": ">= 14" } }, + "node_modules/@angular-devkit/build-angular/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/immutable": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", @@ -1010,6 +1220,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@angular-devkit/build-angular/node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/is-plain-obj": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", @@ -1076,6 +1296,62 @@ "dev": true, "license": "ISC" }, + "node_modules/@angular-devkit/build-angular/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/mini-css-extract-plugin": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.0.tgz", @@ -1097,6 +1373,16 @@ "webpack": "^5.0.0" } }, + "node_modules/@angular-devkit/build-angular/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/open": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", @@ -1133,6 +1419,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@angular-devkit/build-angular/node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@angular-devkit/build-angular/node_modules/postcss": { "version": "8.4.41", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", @@ -1162,6 +1455,38 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/@angular-devkit/build-angular/node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1263,6 +1588,88 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/build-angular/node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/webpack": { "version": "5.94.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", @@ -1578,9 +1985,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "19.2.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.12.tgz", - "integrity": "sha512-v5pdfZHZ8MTZozfpkhKoPFBpXQW+2GFbTfdyis8FBtevJWCbIsCR3xhodgI4jwzkSEAraN4oVtWvSytdNyBC6A==", + "version": "19.2.13", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.13.tgz", + "integrity": "sha512-iq73hE5Uvms1w3uMUSk4i4NDXDMQ863VAifX8LOTadhG6U0xISjNJ11763egVCxQmaKmg7zbG4rda88wHJATzA==", "dev": true, "license": "MIT", "peer": true, @@ -6577,17 +6984,97 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/@eslint/compat": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.2.9.tgz", + "integrity": "sha512-gCdSY54n7k+driCadyMNv8JSPzYLeDVM/ikZRtvtROBpRdFSkS8W9A82MqsaY7lZuwL0wiapgD0NT1xT0hyJsA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.10.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", + "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -6595,7 +7082,7 @@ "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6630,16 +7117,13 @@ } }, "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6675,27 +7159,38 @@ "node": "*" } }, - "node_modules/@eslint/eslintrc/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "version": "9.26.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz", + "integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==", "dev": true, "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", + "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.13.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@figspec/components": { @@ -6796,44 +7291,42 @@ "@hapi/hoek": "^9.0.0" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" }, "engines": { - "node": ">=10.10.0" + "node": ">=18.18.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "Apache-2.0", "engines": { - "node": "*" + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@humanwhocodes/module-importer": { @@ -6850,13 +7343,19 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@inquirer/checkbox": { "version": "2.5.0", @@ -8126,6 +8625,66 @@ } } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.0.tgz", + "integrity": "sha512-m//7RlINx1F3sz3KqwY1WWzVgTcYX52HYk4bJ1hkBXV3zccAEth+jRvG8DBRrdaQuRsPAJOx2MH3zaHNCKL7Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@msgpack/msgpack": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz", @@ -9897,6 +10456,16 @@ "encoding": "^0.1.13" } }, + "node_modules/@sigstore/sign/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@sigstore/sign/node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -10540,6 +11109,29 @@ } } }, + "node_modules/@storybook/builder-webpack5/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@storybook/builder-webpack5/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@storybook/builder-webpack5/node_modules/style-loader": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", @@ -10883,9 +11475,9 @@ } }, "node_modules/@swc/core": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.24.tgz", - "integrity": "sha512-MaQEIpfcEMzx3VWWopbofKJvaraqmL6HbLlw2bFZ7qYqYw3rkhM0cQVEgyzbHtTWwCwPMFZSC2DUbhlZgrMfLg==", + "version": "1.11.29", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.29.tgz", + "integrity": "sha512-g4mThMIpWbNhV8G2rWp5a5/Igv8/2UFRJx2yImrLGMgrDDYZIopqZ/z0jZxDgqNA1QDx93rpwNF7jGsxVWcMlA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -10901,16 +11493,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.11.24", - "@swc/core-darwin-x64": "1.11.24", - "@swc/core-linux-arm-gnueabihf": "1.11.24", - "@swc/core-linux-arm64-gnu": "1.11.24", - "@swc/core-linux-arm64-musl": "1.11.24", - "@swc/core-linux-x64-gnu": "1.11.24", - "@swc/core-linux-x64-musl": "1.11.24", - "@swc/core-win32-arm64-msvc": "1.11.24", - "@swc/core-win32-ia32-msvc": "1.11.24", - "@swc/core-win32-x64-msvc": "1.11.24" + "@swc/core-darwin-arm64": "1.11.29", + "@swc/core-darwin-x64": "1.11.29", + "@swc/core-linux-arm-gnueabihf": "1.11.29", + "@swc/core-linux-arm64-gnu": "1.11.29", + "@swc/core-linux-arm64-musl": "1.11.29", + "@swc/core-linux-x64-gnu": "1.11.29", + "@swc/core-linux-x64-musl": "1.11.29", + "@swc/core-win32-arm64-msvc": "1.11.29", + "@swc/core-win32-ia32-msvc": "1.11.29", + "@swc/core-win32-x64-msvc": "1.11.29" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -10922,9 +11514,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.24.tgz", - "integrity": "sha512-dhtVj0PC1APOF4fl5qT2neGjRLgHAAYfiVP8poJelhzhB/318bO+QCFWAiimcDoyMgpCXOhTp757gnoJJrheWA==", + "version": "1.11.29", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.29.tgz", + "integrity": "sha512-whsCX7URzbuS5aET58c75Dloby3Gtj/ITk2vc4WW6pSDQKSPDuONsIcZ7B2ng8oz0K6ttbi4p3H/PNPQLJ4maQ==", "cpu": [ "arm64" ], @@ -10939,9 +11531,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.24.tgz", - "integrity": "sha512-H/3cPs8uxcj2Fe3SoLlofN5JG6Ny5bl8DuZ6Yc2wr7gQFBmyBkbZEz+sPVgsID7IXuz7vTP95kMm1VL74SO5AQ==", + "version": "1.11.29", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.29.tgz", + "integrity": "sha512-S3eTo/KYFk+76cWJRgX30hylN5XkSmjYtCBnM4jPLYn7L6zWYEPajsFLmruQEiTEDUg0gBEWLMNyUeghtswouw==", "cpu": [ "x64" ], @@ -10956,9 +11548,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.24.tgz", - "integrity": "sha512-PHJgWEpCsLo/NGj+A2lXZ2mgGjsr96ULNW3+T3Bj2KTc8XtMUkE8tmY2Da20ItZOvPNC/69KroU7edyo1Flfbw==", + "version": "1.11.29", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.29.tgz", + "integrity": "sha512-o9gdshbzkUMG6azldHdmKklcfrcMx+a23d/2qHQHPDLUPAN+Trd+sDQUYArK5Fcm7TlpG4sczz95ghN0DMkM7g==", "cpu": [ "arm" ], @@ -10973,9 +11565,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.24.tgz", - "integrity": "sha512-C2FJb08+n5SD4CYWCTZx1uR88BN41ZieoHvI8A55hfVf2woT8+6ZiBzt74qW2g+ntZ535Jts5VwXAKdu41HpBg==", + "version": "1.11.29", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.29.tgz", + "integrity": "sha512-sLoaciOgUKQF1KX9T6hPGzvhOQaJn+3DHy4LOHeXhQqvBgr+7QcZ+hl4uixPKTzxk6hy6Hb0QOvQEdBAAR1gXw==", "cpu": [ "arm64" ], @@ -10990,9 +11582,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.24.tgz", - "integrity": "sha512-ypXLIdszRo0re7PNNaXN0+2lD454G8l9LPK/rbfRXnhLWDBPURxzKlLlU/YGd2zP98wPcVooMmegRSNOKfvErw==", + "version": "1.11.29", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.29.tgz", + "integrity": "sha512-PwjB10BC0N+Ce7RU/L23eYch6lXFHz7r3NFavIcwDNa/AAqywfxyxh13OeRy+P0cg7NDpWEETWspXeI4Ek8otw==", "cpu": [ "arm64" ], @@ -11007,9 +11599,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.24.tgz", - "integrity": "sha512-IM7d+STVZD48zxcgo69L0yYptfhaaE9cMZ+9OoMxirNafhKKXwoZuufol1+alEFKc+Wbwp+aUPe/DeWC/Lh3dg==", + "version": "1.11.29", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.29.tgz", + "integrity": "sha512-i62vBVoPaVe9A3mc6gJG07n0/e7FVeAvdD9uzZTtGLiuIfVfIBta8EMquzvf+POLycSk79Z6lRhGPZPJPYiQaA==", "cpu": [ "x64" ], @@ -11024,9 +11616,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.24.tgz", - "integrity": "sha512-DZByJaMVzSfjQKKQn3cqSeqwy6lpMaQDQQ4HPlch9FWtDx/dLcpdIhxssqZXcR2rhaQVIaRQsCqwV6orSDGAGw==", + "version": "1.11.29", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.29.tgz", + "integrity": "sha512-YER0XU1xqFdK0hKkfSVX1YIyCvMDI7K07GIpefPvcfyNGs38AXKhb2byySDjbVxkdl4dycaxxhRyhQ2gKSlsFQ==", "cpu": [ "x64" ], @@ -11041,9 +11633,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.24.tgz", - "integrity": "sha512-Q64Ytn23y9aVDKN5iryFi8mRgyHw3/kyjTjT4qFCa8AEb5sGUuSj//AUZ6c0J7hQKMHlg9do5Etvoe61V98/JQ==", + "version": "1.11.29", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.29.tgz", + "integrity": "sha512-po+WHw+k9g6FAg5IJ+sMwtA/fIUL3zPQ4m/uJgONBATCVnDDkyW6dBA49uHNVtSEvjvhuD8DVWdFP847YTcITw==", "cpu": [ "arm64" ], @@ -11058,9 +11650,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.24.tgz", - "integrity": "sha512-9pKLIisE/Hh2vJhGIPvSoTK4uBSPxNVyXHmOrtdDot4E1FUUI74Vi8tFdlwNbaj8/vusVnb8xPXsxF1uB0VgiQ==", + "version": "1.11.29", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.29.tgz", + "integrity": "sha512-h+NjOrbqdRBYr5ItmStmQt6x3tnhqgwbj9YxdGPepbTDamFv7vFnhZR0YfB3jz3UKJ8H3uGJ65Zw1VsC+xpFkg==", "cpu": [ "ia32" ], @@ -11075,9 +11667,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.24.tgz", - "integrity": "sha512-sybnXtOsdB+XvzVFlBVGgRHLqp3yRpHK7CrmpuDKszhj/QhmsaZzY/GHSeALlMtLup13M0gqbcQvsTNlAHTg3w==", + "version": "1.11.29", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.29.tgz", + "integrity": "sha512-Q8cs2BDV9wqDvqobkXOYdC+pLUSEpX/KvI0Dgfun1F+LzuLotRFuDhrvkU9ETJA6OnD2+Fn/ieHgloiKA/Mn/g==", "cpu": [ "x64" ], @@ -11505,9 +12097,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "dev": true, "license": "MIT" }, @@ -11827,9 +12419,9 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.16", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz", - "integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==", + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz", + "integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==", "dev": true, "license": "MIT" }, @@ -11928,6 +12520,29 @@ "node": ">= 6" } }, + "node_modules/@types/node-fetch/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@types/node-fetch/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@types/node-forge": { "version": "1.3.11", "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", @@ -12709,13 +13324,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.7.2.tgz", @@ -13524,13 +14132,14 @@ } }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { "node": ">= 0.6" @@ -15099,87 +15708,24 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "dev": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/body-parser/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, "node_modules/bonjour-service": { @@ -15713,6 +16259,27 @@ "node": ">= 6.0.0" } }, + "node_modules/cache-content-type/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cache-content-type/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -16357,6 +16924,76 @@ "node": ">=8.0.0" } }, + "node_modules/co-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/co-body/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/co-body/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/co-body/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/co-body/node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/co-body/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", @@ -16812,9 +17449,10 @@ "license": "MIT" }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -16839,9 +17477,9 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, "license": "MIT", "engines": { @@ -16849,11 +17487,14 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/cookies": { "version": "0.9.1", @@ -18008,16 +18649,16 @@ } }, "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, "engines": { - "node": ">=6.0.0" + "node": ">=0.10.0" } }, "node_modules/dom-accessibility-api": { @@ -18444,9 +19085,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.155", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", - "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", + "version": "1.5.157", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz", + "integrity": "sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w==", "dev": true, "license": "ISC" }, @@ -18497,9 +19138,9 @@ } }, "node_modules/electron/node_modules/@types/node": { - "version": "20.17.48", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.48.tgz", - "integrity": "sha512-KpSfKOHPsiSC4IkZeu2LsusFwExAIVGkhG1KkbaBMLwau0uMhj0fCrvyg9ddM2sAvd+gtiBJLir4LAw1MNMIaw==", + "version": "20.17.50", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.50.tgz", + "integrity": "sha512-Mxiq0ULv/zo1OzOhwPqOA13I81CV/W3nvd3ChtQZRT5Cwz3cr0FKo/wMSsbTqL3EXpaBAEQhva2B8ByRkOIh9A==", "dev": true, "license": "MIT", "dependencies": { @@ -18552,9 +19193,10 @@ } }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -18699,9 +19341,9 @@ } }, "node_modules/es-abstract": { - "version": "1.23.9", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", - "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "version": "1.23.10", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.10.tgz", + "integrity": "sha512-MtUbM072wlJNyeYAe0mhzrD+M6DIJa96CZAOBBrhDbgKnB4MApIKefcyAB1eOdYn8cUNZgvwBvEzdoAYsxgEIw==", "dev": true, "license": "MIT", "dependencies": { @@ -18709,18 +19351,18 @@ "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.0", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", @@ -18736,13 +19378,13 @@ "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.0", + "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.3", + "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.3", + "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", @@ -18755,7 +19397,7 @@ "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.18" + "which-typed-array": "^1.1.19" }, "engines": { "node": ">= 0.4" @@ -18990,60 +19632,66 @@ } }, "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "version": "9.26.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz", + "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.20.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.13.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.26.0", + "@eslint/plugin-kit": "^0.2.8", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", + "@humanwhocodes/retry": "^0.4.2", + "@modelcontextprotocol/sdk": "^1.8.0", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "zod": "^3.24.2" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-prettier": { @@ -19214,19 +19862,6 @@ "ms": "^2.1.1" } }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/eslint-plugin-import/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -19383,39 +20018,19 @@ "concat-map": "0.0.1" } }, - "node_modules/eslint/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, + "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -19446,32 +20061,32 @@ "node": "*" } }, - "node_modules/eslint/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -19607,6 +20222,16 @@ "node": ">=12.0.0" } }, + "node_modules/eventsource-parser": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.2.tgz", + "integrity": "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -19715,192 +20340,82 @@ "license": "Apache-2.0" }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "dev": true, "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">= 0.10.0" + "node": ">= 18" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/express/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, "node_modules/express/node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "dev": true, "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { "node": ">= 0.8" } }, - "node_modules/express/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/express/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/express/node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/express/node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/express/node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -20160,16 +20675,16 @@ } }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/filelist": { @@ -20236,6 +20751,16 @@ "ms": "2.0.0" } }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -20243,6 +20768,29 @@ "dev": true, "license": "MIT" }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/find-cache-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", @@ -20355,81 +20903,17 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flat-cache/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/flat-cache/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/flat-cache/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/flat-cache/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=16" } }, "node_modules/flatted": { @@ -20729,6 +21213,27 @@ "node": ">= 6" } }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -20769,12 +21274,13 @@ "license": "ISC" }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/from": { @@ -21145,9 +21651,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", - "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -21937,6 +22443,15 @@ "node": ">= 0.6" } }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/http-auth": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/http-auth/-/http-auth-4.1.9.tgz", @@ -22003,15 +22518,6 @@ "node": ">= 0.8" } }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/http-parser-js": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", @@ -22566,13 +23072,13 @@ } }, "node_modules/ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 0.10" } }, "node_modules/is-arguments": { @@ -22985,16 +23491,6 @@ "node": ">=8" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -23469,9 +23965,9 @@ } }, "node_modules/jackspeak": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz", - "integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -25643,6 +26139,49 @@ "node": ">=8.0.0" } }, + "node_modules/koa-bodyparser/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa-bodyparser/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa-bodyparser/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa-bodyparser/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/koa-compose": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", @@ -25678,6 +26217,49 @@ "streaming-json-stringify": "3" } }, + "node_modules/koa/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/koa/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/koa/node_modules/http-errors": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", @@ -25703,6 +26285,67 @@ "node": ">= 0.6" } }, + "node_modules/koa/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/launch-editor": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz", @@ -26967,6 +27610,16 @@ "node": ">=8" } }, + "node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/make-fetch-happen/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -27259,12 +27912,13 @@ } }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/memfs": { @@ -27291,11 +27945,14 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "dev": true, "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -27957,21 +28614,23 @@ } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dev": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { "node": ">= 0.6" @@ -28351,6 +29010,19 @@ "dev": true, "license": "MIT" }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/mrmime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", @@ -28404,6 +29076,7 @@ "version": "1.4.5-lts.2", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -28418,6 +29091,36 @@ "node": ">= 6.0.0" } }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/multer/node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -28430,6 +29133,19 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/multicast-dns": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", @@ -28582,9 +29298,10 @@ } }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -28966,6 +29683,16 @@ "encoding": "^0.1.13" } }, + "node_modules/node-gyp/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-gyp/node_modules/nopt": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", @@ -29462,6 +30189,16 @@ "encoding": "^0.1.13" } }, + "node_modules/npm-registry-fetch/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/npm-registry-fetch/node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -30202,9 +30939,9 @@ } }, "node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -31194,6 +31931,16 @@ "nice-napi": "^1.0.2" } }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", @@ -32052,16 +32799,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -32227,32 +32964,21 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "dev": true, "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.6.3", "unpipe": "1.0.0" }, "engines": { "node": ">= 0.8" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -33136,6 +33862,47 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -33545,72 +34312,6 @@ "node": ">= 18" } }, - "node_modules/send/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -33671,6 +34372,20 @@ "node": ">= 0.8.0" } }, + "node_modules/serve-index/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/serve-index/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -33714,6 +34429,29 @@ "dev": true, "license": "ISC" }, + "node_modules/serve-index/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/serve-index/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -33721,6 +34459,16 @@ "dev": true, "license": "MIT" }, + "node_modules/serve-index/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/serve-index/node_modules/setprototypeof": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", @@ -33728,118 +34476,30 @@ "dev": true, "license": "ISC" }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "dev": true, "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-static/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-static/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/serve-static/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/serve-static/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static/node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-static/node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" + "node": ">= 18" } }, "node_modules/set-blocking": { @@ -34610,12 +35270,12 @@ } }, "node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/steno": { @@ -35309,9 +35969,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", - "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", "dev": true, "license": "MIT", "dependencies": { @@ -35551,13 +36211,6 @@ "node": "*" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -36244,6 +36897,16 @@ "encoding": "^0.1.13" } }, + "node_modules/tuf-js/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/tuf-js/node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -36360,13 +37023,15 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { "node": ">= 0.6" @@ -38024,17 +38689,27 @@ "url": "https://github.com/sponsors/streamich" } }, - "node_modules/webpack-dev-middleware/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/webpack-dev-middleware/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", "dependencies": { - "ee-first": "1.1.1" + "mime-db": "1.52.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.6" } }, "node_modules/webpack-dev-server": { @@ -38120,6 +38795,45 @@ "@types/send": "*" } }, + "node_modules/webpack-dev-server/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/webpack-dev-server/node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -38145,6 +38859,53 @@ "fsevents": "~2.3.2" } }, + "node_modules/webpack-dev-server/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack-dev-server/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/webpack-dev-server/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack-dev-server/node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -38158,6 +38919,82 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/webpack-dev-server/node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server/node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webpack-dev-server/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/webpack-dev-server/node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -38196,6 +39033,29 @@ } } }, + "node_modules/webpack-dev-server/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webpack-dev-server/node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/webpack-dev-server/node_modules/is-plain-obj": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", @@ -38225,6 +39085,72 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/webpack-dev-server/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/webpack-dev-server/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/webpack-dev-server/node_modules/open": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", @@ -38244,6 +39170,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/webpack-dev-server/node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack-dev-server/node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -38257,6 +39190,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/webpack-dev-server/node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/webpack-dev-server/node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/webpack-dev-server/node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -38270,6 +39235,71 @@ "node": ">=8.10.0" } }, + "node_modules/webpack-dev-server/node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/webpack-dev-server/node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webpack-dev-server/node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/webpack-dev-server/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/webpack-hot-middleware": { "version": "2.26.1", "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.26.1.tgz", @@ -38346,13 +39376,6 @@ "dev": true, "license": "MIT" }, - "node_modules/webpack/node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/webpack/node_modules/browserslist": { "version": "4.24.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", @@ -38417,6 +39440,29 @@ "dev": true, "license": "MIT" }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -38982,6 +40028,26 @@ "node": "*" } }, + "node_modules/zod": { + "version": "3.25.23", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.23.tgz", + "integrity": "sha512-Od2bdMosahjSrSgJtakrwjMDb1zM1A3VIHCPGveZt/3/wlrTWBya2lmEh2OYe4OIu8mPTmmr0gnLHIWQXdtWBg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/zone.js": { "version": "0.14.10", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.10.tgz", diff --git a/package.json b/package.json index fe7f69ff40d..84164696f46 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@compodoc/compodoc": "1.1.26", "@electron/notarize": "2.5.0", "@electron/rebuild": "3.7.2", + "@eslint/compat": "1.2.9", "@lit-labs/signals": "0.1.2", "@ngtools/webpack": "18.2.19", "@storybook/addon-a11y": "8.6.12", @@ -102,7 +103,7 @@ "electron-reload": "2.0.0-alpha.1", "electron-store": "8.2.0", "electron-updater": "6.6.4", - "eslint": "8.57.1", + "eslint": "9.26.0", "eslint-config-prettier": "10.1.2", "eslint-import-resolver-typescript": "4.3.4", "eslint-plugin-import": "2.31.0", @@ -207,6 +208,12 @@ "zxcvbn": "4.4.2" }, "overrides": { + "eslint-plugin-rxjs": { + "eslint": "$eslint" + }, + "eslint-plugin-rxjs-angular": { + "eslint": "$eslint" + }, "tailwindcss": "$tailwindcss", "@storybook/angular": { "zone.js": "$zone.js" From 88bc7625218028cdea3d73663a00f8d24133ba6e Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Tue, 27 May 2025 14:51:40 -0400 Subject: [PATCH 18/30] PM-16645 (#14649) --- .../src/autofill/services/autofill.service.ts | 248 +----------------- libs/common/src/enums/feature-flag.enum.ts | 2 - 2 files changed, 1 insertion(+), 249 deletions(-) diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 525010bacc1..fdd881c2760 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -1579,252 +1579,6 @@ export default class AutofillService implements AutofillServiceInterface { return [expectedDateFormat, dateFormatPatterns]; } - /** - * Generates the autofill script for the specified page details and identify cipher item. - * @param {AutofillScript} fillScript - * @param {AutofillPageDetails} pageDetails - * @param {{[p: string]: AutofillField}} filledFields - * @param {GenerateFillScriptOptions} options - * @returns {AutofillScript} - * @private - */ - private async generateIdentityFillScript( - fillScript: AutofillScript, - pageDetails: AutofillPageDetails, - filledFields: { [id: string]: AutofillField }, - options: GenerateFillScriptOptions, - ): Promise { - if (await this.configService.getFeatureFlag(FeatureFlag.GenerateIdentityFillScriptRefactor)) { - return this._generateIdentityFillScript(fillScript, pageDetails, filledFields, options); - } - - if (!options.cipher.identity) { - return null; - } - - const fillFields: { [id: string]: AutofillField } = {}; - - pageDetails.fields.forEach((f) => { - if ( - AutofillService.isExcludedFieldType(f, AutoFillConstants.ExcludedAutofillTypes) || - ["current-password", "new-password"].includes(f.autoCompleteType) - ) { - return; - } - - for (let i = 0; i < IdentityAutoFillConstants.IdentityAttributes.length; i++) { - const attr = IdentityAutoFillConstants.IdentityAttributes[i]; - // eslint-disable-next-line - if (!f.hasOwnProperty(attr) || !f[attr] || !f.viewable) { - continue; - } - - // ref https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill - // ref https://developers.google.com/web/fundamentals/design-and-ux/input/forms/ - if ( - !fillFields.name && - AutofillService.isFieldMatch( - f[attr], - IdentityAutoFillConstants.FullNameFieldNames, - IdentityAutoFillConstants.FullNameFieldNameValues, - ) - ) { - fillFields.name = f; - break; - } else if ( - !fillFields.firstName && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.FirstnameFieldNames) - ) { - fillFields.firstName = f; - break; - } else if ( - !fillFields.middleName && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.MiddlenameFieldNames) - ) { - fillFields.middleName = f; - break; - } else if ( - !fillFields.lastName && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.LastnameFieldNames) - ) { - fillFields.lastName = f; - break; - } else if ( - !fillFields.title && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.TitleFieldNames) - ) { - fillFields.title = f; - break; - } else if ( - !fillFields.email && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.EmailFieldNames) - ) { - fillFields.email = f; - break; - } else if ( - !fillFields.address1 && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.Address1FieldNames) - ) { - fillFields.address1 = f; - break; - } else if ( - !fillFields.address2 && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.Address2FieldNames) - ) { - fillFields.address2 = f; - break; - } else if ( - !fillFields.address3 && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.Address3FieldNames) - ) { - fillFields.address3 = f; - break; - } else if ( - !fillFields.address && - AutofillService.isFieldMatch( - f[attr], - IdentityAutoFillConstants.AddressFieldNames, - IdentityAutoFillConstants.AddressFieldNameValues, - ) - ) { - fillFields.address = f; - break; - } else if ( - !fillFields.postalCode && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.PostalCodeFieldNames) - ) { - fillFields.postalCode = f; - break; - } else if ( - !fillFields.city && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.CityFieldNames) - ) { - fillFields.city = f; - break; - } else if ( - !fillFields.state && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.StateFieldNames) - ) { - fillFields.state = f; - break; - } else if ( - !fillFields.country && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.CountryFieldNames) - ) { - fillFields.country = f; - break; - } else if ( - !fillFields.phone && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.PhoneFieldNames) - ) { - fillFields.phone = f; - break; - } else if ( - !fillFields.username && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.UserNameFieldNames) - ) { - fillFields.username = f; - break; - } else if ( - !fillFields.company && - AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.CompanyFieldNames) - ) { - fillFields.company = f; - break; - } - } - }); - - const identity = options.cipher.identity; - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "title"); - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "firstName"); - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "middleName"); - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "lastName"); - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "address1"); - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "address2"); - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "address3"); - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "city"); - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "postalCode"); - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "company"); - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "email"); - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "phone"); - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "username"); - - let filledState = false; - if (fillFields.state && identity.state && identity.state.length > 2) { - const stateLower = identity.state.toLowerCase(); - const isoState = - IdentityAutoFillConstants.IsoStates[stateLower] || - IdentityAutoFillConstants.IsoProvinces[stateLower]; - if (isoState) { - filledState = true; - this.makeScriptActionWithValue(fillScript, isoState, fillFields.state, filledFields); - } - } - - if (!filledState) { - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "state"); - } - - let filledCountry = false; - if (fillFields.country && identity.country && identity.country.length > 2) { - const countryLower = identity.country.toLowerCase(); - const isoCountry = IdentityAutoFillConstants.IsoCountries[countryLower]; - if (isoCountry) { - filledCountry = true; - this.makeScriptActionWithValue(fillScript, isoCountry, fillFields.country, filledFields); - } - } - - if (!filledCountry) { - this.makeScriptAction(fillScript, identity, fillFields, filledFields, "country"); - } - - if (fillFields.name && (identity.firstName || identity.lastName)) { - let fullName = ""; - if (AutofillService.hasValue(identity.firstName)) { - fullName = identity.firstName; - } - if (AutofillService.hasValue(identity.middleName)) { - if (fullName !== "") { - fullName += " "; - } - fullName += identity.middleName; - } - if (AutofillService.hasValue(identity.lastName)) { - if (fullName !== "") { - fullName += " "; - } - fullName += identity.lastName; - } - - this.makeScriptActionWithValue(fillScript, fullName, fillFields.name, filledFields); - } - - if (fillFields.address && AutofillService.hasValue(identity.address1)) { - let address = ""; - if (AutofillService.hasValue(identity.address1)) { - address = identity.address1; - } - if (AutofillService.hasValue(identity.address2)) { - if (address !== "") { - address += ", "; - } - address += identity.address2; - } - if (AutofillService.hasValue(identity.address3)) { - if (address !== "") { - address += ", "; - } - address += identity.address3; - } - - this.makeScriptActionWithValue(fillScript, address, fillFields.address, filledFields); - } - - return fillScript; - } - /** * Generates the autofill script for the specified page details and identity cipher item. * @@ -1833,7 +1587,7 @@ export default class AutofillService implements AutofillServiceInterface { * @param filledFields - The fields that have already been filled, passed between method references * @param options - Contains data used to fill cipher items */ - private _generateIdentityFillScript( + private generateIdentityFillScript( fillScript: AutofillScript, pageDetails: AutofillPageDetails, filledFields: { [id: string]: AutofillField }, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index a5c5c615e70..8cd44f9d627 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -22,7 +22,6 @@ export enum FeatureFlag { BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain", DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2", EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill", - GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor", IdpAutoSubmitLogin = "idp-auto-submit-login", NotificationRefresh = "notification-refresh", UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection", @@ -87,7 +86,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE, [FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE, [FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE, - [FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE, [FeatureFlag.IdpAutoSubmitLogin]: FALSE, [FeatureFlag.NotificationRefresh]: FALSE, [FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE, From cb770f5cd3381b38d34d502fb26f07659cf02d49 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Tue, 27 May 2025 16:01:07 -0400 Subject: [PATCH 19/30] refactor(browser-platform-utils): Remove Deprecation and Fix Code (#14709) * refactor(browser-platform-utils): Remove Deprecation and Fix Code - Changed usages of firefox to private and moved the usages to the preferred public method and removed the deprecations. * fix(browser-platform-utils): Remove Deprecation and Fix Code - Tiny changes. * test(browser-platform-utils): Remove Deprecation and Fix Code - Fixed up test --- .../src/platform/listeners/update-badge.ts | 10 +++---- .../browser-platform-utils.service.spec.ts | 3 +- .../browser-platform-utils.service.ts | 30 ++++--------------- 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/apps/browser/src/platform/listeners/update-badge.ts b/apps/browser/src/platform/listeners/update-badge.ts index cd74b9ebf7d..c168ae44f3c 100644 --- a/apps/browser/src/platform/listeners/update-badge.ts +++ b/apps/browser/src/platform/listeners/update-badge.ts @@ -7,13 +7,13 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import MainBackground from "../../background/main.background"; import IconDetails from "../../vault/background/models/icon-details"; import { BrowserApi } from "../browser/browser-api"; -import { BrowserPlatformUtilsService } from "../services/platform-utils/browser-platform-utils.service"; export type BadgeOptions = { tab?: chrome.tabs.Tab; @@ -28,6 +28,7 @@ export class UpdateBadge { private badgeAction: typeof chrome.action | typeof chrome.browserAction; private sidebarAction: OperaSidebarAction | FirefoxSidebarAction; private win: Window & typeof globalThis; + private platformUtilsService: PlatformUtilsService; constructor(win: Window & typeof globalThis, services: MainBackground) { this.badgeAction = BrowserApi.getBrowserAction(); @@ -38,6 +39,7 @@ export class UpdateBadge { this.authService = services.authService; this.cipherService = services.cipherService; this.accountService = services.accountService; + this.platformUtilsService = services.platformUtilsService; } async run(opts?: { tabId?: number; windowId?: number }): Promise { @@ -129,7 +131,7 @@ export class UpdateBadge { 38: "/images/icon38" + iconSuffix + ".png", }, }; - if (windowId && BrowserPlatformUtilsService.isFirefox()) { + if (windowId && this.platformUtilsService.isFirefox()) { options.windowId = windowId; } @@ -204,9 +206,7 @@ export class UpdateBadge { } private get useSyncApiCalls() { - return ( - BrowserPlatformUtilsService.isFirefox() || BrowserPlatformUtilsService.isSafari(this.win) - ); + return this.platformUtilsService.isFirefox() || this.platformUtilsService.isSafari(); } private isOperaSidebar( diff --git a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts index 38166d10a08..f75e9cc29a5 100644 --- a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts @@ -126,12 +126,11 @@ describe("Browser Utils Service", () => { configurable: true, value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:58.0) Gecko/20100101 Firefox/58.0", }); - jest.spyOn(BrowserPlatformUtilsService, "isFirefox"); browserPlatformUtilsService.getDevice(); expect(browserPlatformUtilsService.getDevice()).toBe(DeviceType.FirefoxExtension); - expect(BrowserPlatformUtilsService.isFirefox).toHaveBeenCalledTimes(1); + expect(browserPlatformUtilsService.isFirefox()).toBe(true); }); }); diff --git a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts index 22708d8e425..4ae412fbda6 100644 --- a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts @@ -60,10 +60,7 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return ClientType.Browser; } - /** - * @deprecated Do not call this directly, use getDevice() instead - */ - static isFirefox(): boolean { + private static isFirefox(): boolean { return ( navigator.userAgent.indexOf(" Firefox/") !== -1 || navigator.userAgent.indexOf(" Gecko/") !== -1 @@ -74,9 +71,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return this.getDevice() === DeviceType.FirefoxExtension; } - /** - * @deprecated Do not call this directly, use getDevice() instead - */ private static isChrome(globalContext: Window | ServiceWorkerGlobalScope): boolean { return globalContext.chrome && navigator.userAgent.indexOf(" Chrome/") !== -1; } @@ -85,9 +79,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return this.getDevice() === DeviceType.ChromeExtension; } - /** - * @deprecated Do not call this directly, use getDevice() instead - */ private static isEdge(): boolean { return navigator.userAgent.indexOf(" Edg/") !== -1; } @@ -96,9 +87,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return this.getDevice() === DeviceType.EdgeExtension; } - /** - * @deprecated Do not call this directly, use getDevice() instead - */ private static isOpera(globalContext: Window | ServiceWorkerGlobalScope): boolean { return ( !!globalContext.opr?.addons || @@ -111,9 +99,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return this.getDevice() === DeviceType.OperaExtension; } - /** - * @deprecated Do not call this directly, use getDevice() instead - */ private static isVivaldi(): boolean { return navigator.userAgent.indexOf(" Vivaldi/") !== -1; } @@ -122,10 +107,7 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return this.getDevice() === DeviceType.VivaldiExtension; } - /** - * @deprecated Do not call this directly, use getDevice() instead - */ - static isSafari(globalContext: Window | ServiceWorkerGlobalScope): boolean { + private static isSafari(globalContext: Window | ServiceWorkerGlobalScope): boolean { // Opera masquerades as Safari, so make sure we're not there first return ( !BrowserPlatformUtilsService.isOpera(globalContext) && @@ -137,6 +119,10 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return navigator.userAgent.match("Version/([0-9.]*)")?.[1]; } + isSafari(): boolean { + return this.getDevice() === DeviceType.SafariExtension; + } + /** * Safari previous to version 16.1 had a bug which caused artifacts on hover in large extension popups. * https://bugs.webkit.org/show_bug.cgi?id=218704 @@ -151,10 +137,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return parts?.[0] < 16 || (parts?.[0] === 16 && parts?.[1] === 0); } - isSafari(): boolean { - return this.getDevice() === DeviceType.SafariExtension; - } - isIE(): boolean { return false; } From 50143a4b88dc170de92dd603b507545c334f2227 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 27 May 2025 16:50:39 -0400 Subject: [PATCH 20/30] pushes search text to a subject (#14880) use distinctUntilChanged to prevent duplicate filtering operations Run filtering outside angular zone to prevent change detection issues --- .../vault-search/vault-v2-search.component.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts index 32f5611f436..b68818454d1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts @@ -1,8 +1,8 @@ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, NgZone } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; -import { Subject, Subscription, debounceTime, filter } from "rxjs"; +import { Subject, Subscription, debounceTime, distinctUntilChanged, filter } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SearchModule } from "@bitwarden/components"; @@ -22,13 +22,16 @@ export class VaultV2SearchComponent { private searchText$ = new Subject(); - constructor(private vaultPopupItemsService: VaultPopupItemsService) { + constructor( + private vaultPopupItemsService: VaultPopupItemsService, + private ngZone: NgZone, + ) { this.subscribeToLatestSearchText(); this.subscribeToApplyFilter(); } onSearchTextChanged() { - this.vaultPopupItemsService.applyFilter(this.searchText); + this.searchText$.next(this.searchText); } subscribeToLatestSearchText(): Subscription { @@ -44,9 +47,13 @@ export class VaultV2SearchComponent { subscribeToApplyFilter(): Subscription { return this.searchText$ - .pipe(debounceTime(SearchTextDebounceInterval), takeUntilDestroyed()) + .pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed()) .subscribe((data) => { - this.vaultPopupItemsService.applyFilter(data); + this.ngZone.runOutsideAngular(() => { + this.ngZone.run(() => { + this.vaultPopupItemsService.applyFilter(data); + }); + }); }); } } From 4fcc4793bbfff10fd3d2a228be004d28ae5eb838 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 28 May 2025 09:41:56 +1000 Subject: [PATCH 21/30] Add additional jsdoc to policyservice (#14934) --- .../policy/policy.service.abstraction.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts b/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts index 68f9843c5bd..bf02872ed7c 100644 --- a/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts @@ -7,6 +7,9 @@ import { MasterPasswordPolicyOptions } from "../../models/domain/master-password import { Policy } from "../../models/domain/policy"; import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options"; +/** + * The primary service for retrieving and evaluating policies from sync data. + */ export abstract class PolicyService { /** * All policies for the provided user from sync data. @@ -24,7 +27,7 @@ export abstract class PolicyService { abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable; /** - * @returns true if a policy of the specified type applies to the specified user, otherwise false. + * @returns true if any policy of the specified type applies to the specified user, otherwise false. * A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner). * This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the * {@link Policy} objects and then filter by Policy.data. @@ -35,8 +38,12 @@ export abstract class PolicyService { /** * Combines all Master Password policies that apply to the user. + * If you are evaluating Master Password policies before the first sync has completed, + * you must supply your own `policies` value. + * @param userId The user against whom the policy needs to be enforced. + * @param policies The policies to be evaluated; if null or undefined, it will default to using policies from sync data. * @returns a set of options which represent the minimum Master Password settings that the user must - * comply with in order to comply with **all** Master Password policies. + * comply with in order to comply with **all** applicable Master Password policies. */ abstract masterPasswordPolicyOptions$: ( userId: UserId, @@ -62,7 +69,17 @@ export abstract class PolicyService { ) => [ResetPasswordPolicyOptions, boolean]; } +/** + * An "internal" extension of the `PolicyService` which allows the update of policy data in the local sync data. + * This does not update any policies on the server. + */ export abstract class InternalPolicyService extends PolicyService { + /** + * Upsert a policy in the local sync data. This does not update any policies on the server. + */ abstract upsert: (policy: PolicyData, userId: UserId) => Promise; + /** + * Replace a policy in the local sync data. This does not update any policies on the server. + */ abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise; } From d1fb37d696b98c64de0a291af868a56db99788cd Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Wed, 28 May 2025 15:00:30 +0200 Subject: [PATCH 22/30] [PM-17635] [PM-18601] Simplifying mocking and usage of the sdk (#14287) * feat: add our own custom deep mocker * feat: use new mock service in totp tests * feat: implement userClient mocking * chore: move mock files * feat: replace existing manual sdkService mocking * chore: rename to 'client' * chore: improve docs * feat: refactor sdkService to never return undefined BitwardenClient --- .../platform/abstractions/sdk/sdk.service.ts | 9 +- .../services/sdk/default-sdk.service.spec.ts | 8 +- .../services/sdk/default-sdk.service.ts | 5 +- .../src/platform/spec/mock-deep.spec.ts | 58 ++++ libs/common/src/platform/spec/mock-deep.ts | 271 ++++++++++++++++++ .../src/platform/spec/mock-sdk.service.ts | 81 ++++++ .../src/vault/services/totp.service.spec.ts | 31 +- .../src/services/import.service.spec.ts | 10 +- ...symmetric-key-regeneration.service.spec.ts | 25 +- 9 files changed, 444 insertions(+), 54 deletions(-) create mode 100644 libs/common/src/platform/spec/mock-deep.spec.ts create mode 100644 libs/common/src/platform/spec/mock-deep.ts create mode 100644 libs/common/src/platform/spec/mock-sdk.service.ts diff --git a/libs/common/src/platform/abstractions/sdk/sdk.service.ts b/libs/common/src/platform/abstractions/sdk/sdk.service.ts index 07dfb2aa0df..d629e4fe9fa 100644 --- a/libs/common/src/platform/abstractions/sdk/sdk.service.ts +++ b/libs/common/src/platform/abstractions/sdk/sdk.service.ts @@ -53,15 +53,18 @@ export abstract class SdkService { * This client can be used for operations that require a user context, such as retrieving ciphers * and operations involving crypto. It can also be used for operations that don't require a user context. * + * - If the user is not logged when the subscription is created, the observable will complete + * immediately with {@link UserNotLoggedInError}. + * - If the user is logged in, the observable will emit the client and complete whithout an error + * when the user logs out. + * * **WARNING:** Do not use `firstValueFrom(userClient$)`! Any operations on the client must be done within the observable. * The client will be destroyed when the observable is no longer subscribed to. * Please let platform know if you need a client that is not destroyed when the observable is no longer subscribed to. * * @param userId The user id for which to retrieve the client - * - * @throws {UserNotLoggedInError} If the user is not logged in */ - abstract userClient$(userId: UserId): Observable | undefined>; + abstract userClient$(userId: UserId): Observable>; /** * This method is used during/after an authentication procedure to set a new client for a specific user. diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts index 6531be58f05..70a08257471 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts @@ -132,15 +132,13 @@ describe("DefaultSdkService", () => { ); keyService.userKey$.calledWith(userId).mockReturnValue(userKey$); - const subject = new BehaviorSubject | undefined>(undefined); - service.userClient$(userId).subscribe(subject); - await new Promise(process.nextTick); + const userClientTracker = new ObservableTracker(service.userClient$(userId), false); + await userClientTracker.pauseUntilReceived(1); userKey$.next(undefined); - await new Promise(process.nextTick); + await userClientTracker.expectCompletion(); expect(mockClient.free).toHaveBeenCalledTimes(1); - expect(subject.value).toBe(undefined); }); }); diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index 8e84642fb99..6be89a4b376 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -71,7 +71,7 @@ export class DefaultSdkService implements SdkService { private userAgent: string | null = null, ) {} - userClient$(userId: UserId): Observable | undefined> { + userClient$(userId: UserId): Observable> { return this.sdkClientOverrides.pipe( takeWhile((clients) => clients[userId] !== UnsetClient, false), map((clients) => { @@ -88,6 +88,7 @@ export class DefaultSdkService implements SdkService { return this.internalClient$(userId); }), + takeWhile((client) => client !== undefined, false), throwIfEmpty(() => new UserNotLoggedInError(userId)), ); } @@ -112,7 +113,7 @@ export class DefaultSdkService implements SdkService { * @param userId The user id for which to create the client * @returns An observable that emits the client for the user */ - private internalClient$(userId: UserId): Observable | undefined> { + private internalClient$(userId: UserId): Observable> { const cached = this.sdkClientCache.get(userId); if (cached !== undefined) { return cached; diff --git a/libs/common/src/platform/spec/mock-deep.spec.ts b/libs/common/src/platform/spec/mock-deep.spec.ts new file mode 100644 index 00000000000..535e02c11dd --- /dev/null +++ b/libs/common/src/platform/spec/mock-deep.spec.ts @@ -0,0 +1,58 @@ +import { mockDeep } from "./mock-deep"; + +class ToBeMocked { + property = "value"; + + method() { + return "method"; + } + + sub() { + return new SubToBeMocked(); + } +} + +class SubToBeMocked { + subProperty = "subValue"; + + sub() { + return new SubSubToBeMocked(); + } +} + +class SubSubToBeMocked { + subSubProperty = "subSubValue"; +} + +describe("deepMock", () => { + it("can mock properties", () => { + const mock = mockDeep(); + mock.property.replaceProperty("mocked value"); + expect(mock.property).toBe("mocked value"); + }); + + it("can mock methods", () => { + const mock = mockDeep(); + mock.method.mockReturnValue("mocked method"); + expect(mock.method()).toBe("mocked method"); + }); + + it("can mock sub-properties", () => { + const mock = mockDeep(); + mock.sub.mockDeep().subProperty.replaceProperty("mocked sub value"); + expect(mock.sub().subProperty).toBe("mocked sub value"); + }); + + it("can mock sub-sub-properties", () => { + const mock = mockDeep(); + mock.sub.mockDeep().sub.mockDeep().subSubProperty.replaceProperty("mocked sub-sub value"); + expect(mock.sub().sub().subSubProperty).toBe("mocked sub-sub value"); + }); + + it("returns the same mock object when calling mockDeep multiple times", () => { + const mock = mockDeep(); + const subMock1 = mock.sub.mockDeep(); + const subMock2 = mock.sub.mockDeep(); + expect(subMock1).toBe(subMock2); + }); +}); diff --git a/libs/common/src/platform/spec/mock-deep.ts b/libs/common/src/platform/spec/mock-deep.ts new file mode 100644 index 00000000000..89ef9a25451 --- /dev/null +++ b/libs/common/src/platform/spec/mock-deep.ts @@ -0,0 +1,271 @@ +// This is a modification of the code found in https://github.com/marchaos/jest-mock-extended +// to better support deep mocking of objects. + +// MIT License + +// Copyright (c) 2019 Marc McIntyre + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { jest } from "@jest/globals"; +import { FunctionLike } from "jest-mock"; +import { calledWithFn, MatchersOrLiterals } from "jest-mock-extended"; +import { PartialDeep } from "type-fest"; + +type ProxiedProperty = string | number | symbol; + +export interface GlobalConfig { + // ignoreProps is required when we don't want to return anything for a mock (for example, when mocking a promise). + ignoreProps?: ProxiedProperty[]; +} + +const DEFAULT_CONFIG: GlobalConfig = { + ignoreProps: ["then"], +}; + +let GLOBAL_CONFIG = DEFAULT_CONFIG; + +export const JestMockExtended = { + DEFAULT_CONFIG, + configure: (config: GlobalConfig) => { + // Shallow merge so they can override anything they want. + GLOBAL_CONFIG = { ...DEFAULT_CONFIG, ...config }; + }, + resetConfig: () => { + GLOBAL_CONFIG = DEFAULT_CONFIG; + }, +}; + +export interface CalledWithMock extends jest.Mock { + calledWith: (...args: [...MatchersOrLiterals>]) => jest.Mock; +} + +export interface MockDeepMock { + mockDeep: () => DeepMockProxy; +} + +export interface ReplaceProperty { + /** + * mockDeep will by default return a jest.fn() for all properties, + * but this allows you to replace the property with a value. + * @param value The value to replace the property with. + */ + replaceProperty(value: T): void; +} + +export type _MockProxy = { + [K in keyof T]: T[K] extends FunctionLike ? T[K] & CalledWithMock : T[K]; +}; + +export type MockProxy = _MockProxy & T; + +export type _DeepMockProxy = { + // This supports deep mocks in the else branch + [K in keyof T]: T[K] extends (...args: infer A) => infer R + ? T[K] & CalledWithMock & MockDeepMock + : T[K] & ReplaceProperty & _DeepMockProxy; +}; + +// we intersect with T here instead of on the mapped type above to +// prevent immediate type resolution on a recursive type, this will +// help to improve performance for deeply nested recursive mocking +// at the same time, this intersection preserves private properties +export type DeepMockProxy = _DeepMockProxy & T; + +export type _DeepMockProxyWithFuncPropSupport = { + // This supports deep mocks in the else branch + [K in keyof T]: T[K] extends FunctionLike + ? CalledWithMock & DeepMockProxy + : DeepMockProxy; +}; + +export type DeepMockProxyWithFuncPropSupport = _DeepMockProxyWithFuncPropSupport & T; + +export interface MockOpts { + deep?: boolean; + fallbackMockImplementation?: (...args: any[]) => any; +} + +export const mockClear = (mock: MockProxy) => { + for (const key of Object.keys(mock)) { + if (mock[key] === null || mock[key] === undefined) { + continue; + } + + if (mock[key]._isMockObject) { + mockClear(mock[key]); + } + + if (mock[key]._isMockFunction) { + mock[key].mockClear(); + } + } + + // This is a catch for if they pass in a jest.fn() + if (!mock._isMockObject) { + return mock.mockClear(); + } +}; + +export const mockReset = (mock: MockProxy) => { + for (const key of Object.keys(mock)) { + if (mock[key] === null || mock[key] === undefined) { + continue; + } + + if (mock[key]._isMockObject) { + mockReset(mock[key]); + } + if (mock[key]._isMockFunction) { + mock[key].mockReset(); + } + } + + // This is a catch for if they pass in a jest.fn() + // Worst case, we will create a jest.fn() (since this is a proxy) + // below in the get and call mockReset on it + if (!mock._isMockObject) { + return mock.mockReset(); + } +}; + +export function mockDeep( + opts: { + funcPropSupport?: true; + fallbackMockImplementation?: MockOpts["fallbackMockImplementation"]; + }, + mockImplementation?: PartialDeep, +): DeepMockProxyWithFuncPropSupport; +export function mockDeep(mockImplementation?: PartialDeep): DeepMockProxy; +export function mockDeep(arg1: any, arg2?: any) { + const [opts, mockImplementation] = + typeof arg1 === "object" && + (typeof arg1.fallbackMockImplementation === "function" || arg1.funcPropSupport === true) + ? [arg1, arg2] + : [{}, arg1]; + return mock(mockImplementation, { + deep: true, + fallbackMockImplementation: opts.fallbackMockImplementation, + }); +} + +const overrideMockImp = (obj: PartialDeep, opts?: MockOpts) => { + const proxy = new Proxy>(obj, handler(opts)); + for (const name of Object.keys(obj)) { + if (typeof obj[name] === "object" && obj[name] !== null) { + proxy[name] = overrideMockImp(obj[name], opts); + } else { + proxy[name] = obj[name]; + } + } + + return proxy; +}; + +const handler = (opts?: MockOpts): ProxyHandler => ({ + ownKeys(target: MockProxy) { + return Reflect.ownKeys(target); + }, + + set: (obj: MockProxy, property: ProxiedProperty, value: any) => { + obj[property] = value; + return true; + }, + + get: (obj: MockProxy, property: ProxiedProperty) => { + const fn = calledWithFn({ fallbackMockImplementation: opts?.fallbackMockImplementation }); + + if (!(property in obj)) { + if (GLOBAL_CONFIG.ignoreProps?.includes(property)) { + return undefined; + } + // Jest's internal equality checking does some wierd stuff to check for iterable equality + if (property === Symbol.iterator) { + return obj[property]; + } + + if (property === "_deepMock") { + return obj[property]; + } + // So this calls check here is totally not ideal - jest internally does a + // check to see if this is a spy - which we want to say no to, but blindly returning + // an proxy for calls results in the spy check returning true. This is another reason + // why deep is opt in. + if (opts?.deep && property !== "calls") { + obj[property] = new Proxy>(fn, handler(opts)); + obj[property].replaceProperty = (value: T[K]) => { + obj[property] = value; + }; + obj[property].mockDeep = () => { + if (obj[property]._deepMock) { + return obj[property]._deepMock; + } + + const mock = mockDeep({ + fallbackMockImplementation: opts?.fallbackMockImplementation, + }); + (obj[property] as CalledWithMock).mockReturnValue(mock); + obj[property]._deepMock = mock; + return mock; + }; + obj[property]._isMockObject = true; + } else { + obj[property] = calledWithFn({ + fallbackMockImplementation: opts?.fallbackMockImplementation, + }); + } + } + + // @ts-expect-error Hack by author of jest-mock-extended + if (obj instanceof Date && typeof obj[property] === "function") { + // @ts-expect-error Hack by author of jest-mock-extended + return obj[property].bind(obj); + } + + return obj[property]; + }, +}); + +const mock = & T = MockProxy & T>( + mockImplementation: PartialDeep = {} as PartialDeep, + opts?: MockOpts, +): MockedReturn => { + // @ts-expect-error private + mockImplementation!._isMockObject = true; + return overrideMockImp(mockImplementation, opts); +}; + +export const mockFn = (): CalledWithMock & T => { + // @ts-expect-error Hack by author of jest-mock-extended + return calledWithFn(); +}; + +export const stub = (): T => { + return new Proxy({} as T, { + get: (obj, property: ProxiedProperty) => { + if (property in obj) { + // @ts-expect-error Hack by author of jest-mock-extended + return obj[property]; + } + return jest.fn(); + }, + }); +}; + +export default mock; diff --git a/libs/common/src/platform/spec/mock-sdk.service.ts b/libs/common/src/platform/spec/mock-sdk.service.ts new file mode 100644 index 00000000000..66a6ab3ec84 --- /dev/null +++ b/libs/common/src/platform/spec/mock-sdk.service.ts @@ -0,0 +1,81 @@ +import { + BehaviorSubject, + distinctUntilChanged, + map, + Observable, + takeWhile, + throwIfEmpty, +} from "rxjs"; + +import { BitwardenClient } from "@bitwarden/sdk-internal"; + +import { UserId } from "../../types/guid"; +import { SdkService, UserNotLoggedInError } from "../abstractions/sdk/sdk.service"; +import { Rc } from "../misc/reference-counting/rc"; + +import { DeepMockProxy, mockDeep } from "./mock-deep"; + +export class MockSdkService implements SdkService { + private userClients$ = new BehaviorSubject<{ + [userId: UserId]: Rc | undefined; + }>({}); + + private _client$ = new BehaviorSubject(mockDeep()); + client$ = this._client$.asObservable(); + + version$ = new BehaviorSubject("0.0.1-test").asObservable(); + + userClient$(userId: UserId): Observable> { + return this.userClients$.pipe( + takeWhile((clients) => clients[userId] !== undefined, false), + map((clients) => clients[userId] as Rc), + distinctUntilChanged(), + throwIfEmpty(() => new UserNotLoggedInError(userId)), + ); + } + + setClient(): void { + throw new Error("Not supported in mock service"); + } + + /** + * Returns the non-user scoped client mock. + * This is what is returned by the `client$` observable. + */ + get client(): DeepMockProxy { + return this._client$.value; + } + + readonly simulate = { + /** + * Simulates a user login, and returns a user-scoped mock for the user. + * This will be return by the `userClient$` observable. + * + * @param userId The userId to simulate login for. + * @returns A user-scoped mock for the user. + */ + userLogin: (userId: UserId) => { + const client = mockDeep(); + this.userClients$.next({ + ...this.userClients$.getValue(), + [userId]: new Rc(client), + }); + return client; + }, + + /** + * Simulates a user logout, and disposes the user-scoped mock for the user. + * This will remove the user-scoped mock from the `userClient$` observable. + * + * @param userId The userId to simulate logout for. + */ + userLogout: (userId: UserId) => { + const clients = this.userClients$.value; + clients[userId]?.markForDisposal(); + this.userClients$.next({ + ...clients, + [userId]: undefined, + }); + }, + }; +} diff --git a/libs/common/src/vault/services/totp.service.spec.ts b/libs/common/src/vault/services/totp.service.spec.ts index c653b4ce1db..4aca262d537 100644 --- a/libs/common/src/vault/services/totp.service.spec.ts +++ b/libs/common/src/vault/services/totp.service.spec.ts @@ -1,38 +1,27 @@ -import { mock } from "jest-mock-extended"; -import { of, take } from "rxjs"; +import { take } from "rxjs"; -import { BitwardenClient, TotpResponse } from "@bitwarden/sdk-internal"; +import { TotpResponse } from "@bitwarden/sdk-internal"; -import { SdkService } from "../../platform/abstractions/sdk/sdk.service"; +import { MockSdkService } from "../../platform/spec/mock-sdk.service"; import { TotpService } from "./totp.service"; describe("TotpService", () => { - let totpService: TotpService; - let generateTotpMock: jest.Mock; - - const sdkService = mock(); + let totpService!: TotpService; + let sdkService!: MockSdkService; beforeEach(() => { - generateTotpMock = jest - .fn() - .mockReturnValueOnce({ + sdkService = new MockSdkService(); + sdkService.client.vault + .mockDeep() + .totp.mockDeep() + .generate_totp.mockReturnValueOnce({ code: "123456", period: 30, }) .mockReturnValueOnce({ code: "654321", period: 30 }) .mockReturnValueOnce({ code: "567892", period: 30 }); - const mockBitwardenClient = { - vault: () => ({ - totp: () => ({ - generate_totp: generateTotpMock, - }), - }), - }; - - sdkService.client$ = of(mockBitwardenClient as unknown as BitwardenClient); - totpService = new TotpService(sdkService); // TOTP is time-based, so we need to mock the current time diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index 30309a3d9c2..6c8656f4c1d 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -1,5 +1,4 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { of } from "rxjs"; // 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 @@ -8,14 +7,13 @@ import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { MockSdkService } from "@bitwarden/common/platform/spec/mock-sdk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { KeyService } from "@bitwarden/key-management"; -import { BitwardenClient } from "@bitwarden/sdk-internal"; import { BitwardenPasswordProtectedImporter } from "../importers/bitwarden/bitwarden-password-protected-importer"; import { Importer } from "../importers/importer"; @@ -35,7 +33,7 @@ describe("ImportService", () => { let encryptService: MockProxy; let pinService: MockProxy; let accountService: MockProxy; - let sdkService: MockProxy; + let sdkService: MockSdkService; beforeEach(() => { cipherService = mock(); @@ -46,9 +44,7 @@ describe("ImportService", () => { keyService = mock(); encryptService = mock(); pinService = mock(); - const mockClient = mock(); - sdkService = mock(); - sdkService.client$ = of(mockClient, mockClient, mockClient); + sdkService = new MockSdkService(); importService = new ImportService( cipherService, diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts index 35cef914588..84d1dd7ad72 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts @@ -5,17 +5,17 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; +import { MockSdkService } from "@bitwarden/common/platform/spec/mock-sdk.service"; import { makeStaticByteArray, mockEnc } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; -import { BitwardenClient, VerifyAsymmetricKeysResponse } from "@bitwarden/sdk-internal"; +import { VerifyAsymmetricKeysResponse } from "@bitwarden/sdk-internal"; import { KeyService } from "../../abstractions/key.service"; import { UserAsymmetricKeysRegenerationApiService } from "../abstractions/user-asymmetric-key-regeneration-api.service"; @@ -24,24 +24,17 @@ import { DefaultUserAsymmetricKeysRegenerationService } from "./default-user-asy function setupVerificationResponse( mockVerificationResponse: VerifyAsymmetricKeysResponse, - sdkService: MockProxy, + sdkService: MockSdkService, ) { const mockKeyPairResponse = { userPublicKey: "userPublicKey", userKeyEncryptedPrivateKey: "userKeyEncryptedPrivateKey", }; - sdkService.client$ = of({ - crypto: () => ({ - verify_asymmetric_keys: jest.fn().mockReturnValue(mockVerificationResponse), - make_key_pair: jest.fn().mockReturnValue(mockKeyPairResponse), - }), - free: jest.fn(), - echo: jest.fn(), - version: jest.fn(), - throw: jest.fn(), - catch: jest.fn(), - } as unknown as BitwardenClient); + sdkService.client.crypto + .mockDeep() + .verify_asymmetric_keys.mockReturnValue(mockVerificationResponse); + sdkService.client.crypto.mockDeep().make_key_pair.mockReturnValue(mockKeyPairResponse); } function setupUserKeyValidation( @@ -74,7 +67,7 @@ describe("regenerateIfNeeded", () => { let cipherService: MockProxy; let userAsymmetricKeysRegenerationApiService: MockProxy; let logService: MockProxy; - let sdkService: MockProxy; + let sdkService: MockSdkService; let apiService: MockProxy; let configService: MockProxy; let encryptService: MockProxy; @@ -84,7 +77,7 @@ describe("regenerateIfNeeded", () => { cipherService = mock(); userAsymmetricKeysRegenerationApiService = mock(); logService = mock(); - sdkService = mock(); + sdkService = new MockSdkService(); apiService = mock(); configService = mock(); encryptService = mock(); From 4bf1a3b670961eedbcf6f86dba2ac9e280ace708 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 May 2025 07:16:46 -0700 Subject: [PATCH 23/30] [deps] Platform: Update Rust crate bytes to v1.10.1 (#14922) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 4 ++-- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 34819a3981b..28d64e4f504 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -498,9 +498,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "camino" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index c4a2ed98e70..1fce5a7c597 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -18,7 +18,7 @@ base64 = "=0.22.1" bindgen = "=0.71.1" bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "3d48f140fd506412d186203238993163a8c4e536" } byteorder = "=1.5.0" -bytes = "=1.9.0" +bytes = "=1.10.1" cbc = "=0.1.2" core-foundation = "=0.10.0" dirs = "=6.0.0" From 169fdd5883352acae4fcc976d4122da5e9ea0a29 Mon Sep 17 00:00:00 2001 From: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Date: Wed, 28 May 2025 10:56:44 -0400 Subject: [PATCH 24/30] [PM-20650] Feature flag addition to clients (#14824) * Feature flag addition to clients * Updating feature flag name --- libs/common/src/enums/feature-flag.enum.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 8cd44f9d627..43b36c5692f 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -59,6 +59,7 @@ export enum FeatureFlag { CipherKeyEncryption = "cipher-key-encryption", PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms", EndUserNotifications = "pm-10609-end-user-notifications", + RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -107,6 +108,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE, [FeatureFlag.EndUserNotifications]: FALSE, [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, + [FeatureFlag.RemoveCardItemTypePolicy]: FALSE, /* Auth */ [FeatureFlag.PM16117_ChangeExistingPasswordRefactor]: FALSE, From 6351fc0e69e46e91da8182bed7be1b15e66d19c4 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Wed, 28 May 2025 13:36:46 -0700 Subject: [PATCH 25/30] fix(tailwind): [Auth/PM-22140] Use Tailwind for Password Settings header (#14978) `PM16117_ChangeExistingPasswordRefactor` flag ON --- .../password-settings/password-settings.component.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.html b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.html index 94cf08b5871..fc6620762f9 100644 --- a/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.html +++ b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.html @@ -1,6 +1,4 @@ -
-

{{ "changeMasterPassword" | i18n }}

-
+

{{ "changeMasterPassword" | i18n }}

{{ "loggedOutWarning" | i18n }} From 798acc7cba58fed70e55190534d60be14653d333 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 29 May 2025 15:17:04 +0200 Subject: [PATCH 26/30] [PM-21884] Fix DuckDuckGo integration when SDK is enabled for decrypt (#14884) * Fix ddg integration when sdk is enabled for decryption * Fix comments --- .../duckduckgo-message-handler.service.ts | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/services/duckduckgo-message-handler.service.ts b/apps/desktop/src/services/duckduckgo-message-handler.service.ts index 6fb91231be1..7bddaba499c 100644 --- a/apps/desktop/src/services/duckduckgo-message-handler.service.ts +++ b/apps/desktop/src/services/duckduckgo-message-handler.service.ts @@ -188,13 +188,10 @@ export class DuckDuckGoMessageHandlerService { } try { - let decryptedResult = await this.encryptService.decryptString( + const decryptedResult = await this.decryptDuckDuckGoEncString( message.encryptedCommand as EncString, this.duckduckgoSharedSecret, ); - - decryptedResult = this.trimNullCharsFromMessage(decryptedResult); - return JSON.parse(decryptedResult); } catch { this.sendResponse({ @@ -237,7 +234,46 @@ export class DuckDuckGoMessageHandlerService { ipc.platform.nativeMessaging.sendReply(response); } - // Trim all null bytes padded at the end of messages. This happens with C encryption libraries. + /* + * Bitwarden type 2 (AES256-CBC-HMAC256) uses PKCS7 padding. + * DuckDuckGo does not use PKCS7 padding; and instead fills the last CBC block with null bytes. + * ref: https://github.com/duckduckgo/apple-browsers/blob/04d678b447869c3a640714718a466b36407db8b6/macOS/DuckDuckGo/PasswordManager/Bitwarden/Services/BWEncryption.m#L141 + * + * This is incompatible which means the default encryptService cannot be used to decrypt the message, + * a custom EncString decrypt operation is needed. + * + * This function also trims null characters that are a result of the null-padding from the end of the message. + */ + private async decryptDuckDuckGoEncString( + encString: EncString, + key: SymmetricCryptoKey, + ): Promise { + const fastParams = this.cryptoFunctionService.aesDecryptFastParameters( + encString.data, + encString.iv, + encString.mac, + key, + ); + + const computedMac = await this.cryptoFunctionService.hmacFast( + fastParams.macData, + fastParams.macKey, + "sha256", + ); + const macsEqual = await this.cryptoFunctionService.compareFast(fastParams.mac, computedMac); + if (!macsEqual) { + return null; + } + const decryptedPaddedString = await this.cryptoFunctionService.aesDecryptFast({ + mode: "cbc", + parameters: fastParams, + }); + return this.trimNullCharsFromMessage(decryptedPaddedString); + } + + // DuckDuckGo does not use PKCS7 padding, but instead leaves the values as null, + // so null characters need to be trimmed from the end of the message for the last + // CBC-block. private trimNullCharsFromMessage(message: string): string { const charNull = 0; const charRightCurlyBrace = 125; From b48356228c5ca1437f5ede48f34aa0c79e877d79 Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Thu, 29 May 2025 08:45:40 -0500 Subject: [PATCH 27/30] Update risk insights report to default an invalid uri to the original uri (#14800) --- .../reports/risk-insights/services/ciphers.mock.ts | 3 +++ .../services/risk-insights-report.service.spec.ts | 14 +++++++++----- .../services/risk-insights-report.service.ts | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/ciphers.mock.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/ciphers.mock.ts index ca5cdc35b8a..f697d24f208 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/ciphers.mock.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/ciphers.mock.ts @@ -27,6 +27,9 @@ export const mockCiphers: any[] = [ createLoginUriView("accounts.google.com"), createLoginUriView("https://www.google.com"), createLoginUriView("https://www.google.com/login"), + createLoginUriView("www.invalid@uri@.com"), + createLoginUriView("www.invaliduri!.com"), + createLoginUriView("this_is-not|a-valid-uri123@+"), ], }, edit: false, diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts index f9177bf1bf7..3aa624f1e59 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts @@ -50,7 +50,7 @@ describe("RiskInsightsReportService", () => { let testCase = testCaseResults[0]; expect(testCase).toBeTruthy(); expect(testCase.cipherMembers).toHaveLength(2); - expect(testCase.trimmedUris).toHaveLength(2); + expect(testCase.trimmedUris).toHaveLength(5); expect(testCase.weakPasswordDetail).toBeTruthy(); expect(testCase.exposedPasswordDetail).toBeTruthy(); expect(testCase.reusedPasswordCount).toEqual(2); @@ -69,12 +69,16 @@ describe("RiskInsightsReportService", () => { it("should generate the raw data + uri report correctly", async () => { const result = await firstValueFrom(service.generateRawDataUriReport$("orgId")); - expect(result).toHaveLength(8); + expect(result).toHaveLength(11); // Two ciphers that have google.com as their uri. There should be 2 results const googleResults = result.filter((x) => x.trimmedUri === "google.com"); expect(googleResults).toHaveLength(2); + // There is an invalid uri and it should not be trimmed + const invalidUriResults = result.filter((x) => x.trimmedUri === "this_is-not|a-valid-uri123@+"); + expect(invalidUriResults).toHaveLength(1); + // Verify the details for one of the googles matches the password health info // expected const firstGoogle = googleResults.filter( @@ -88,7 +92,7 @@ describe("RiskInsightsReportService", () => { it("should generate applications health report data correctly", async () => { const result = await firstValueFrom(service.generateApplicationsReport$("orgId")); - expect(result).toHaveLength(5); + expect(result).toHaveLength(8); // Two ciphers have google.com associated with them. The first cipher // has 2 members and the second has 4. However, the 2 members in the first @@ -132,7 +136,7 @@ describe("RiskInsightsReportService", () => { expect(reportSummary.totalMemberCount).toEqual(7); expect(reportSummary.totalAtRiskMemberCount).toEqual(6); - expect(reportSummary.totalApplicationCount).toEqual(5); - expect(reportSummary.totalAtRiskApplicationCount).toEqual(4); + expect(reportSummary.totalApplicationCount).toEqual(8); + expect(reportSummary.totalAtRiskApplicationCount).toEqual(7); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts index e4fece801b6..6fdab58115d 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts @@ -433,7 +433,7 @@ export class RiskInsightsReportService { const cipherUris: string[] = []; const uris = cipher.login?.uris ?? []; uris.map((u: { uri: string }) => { - const uri = Utils.getDomain(u.uri); + const uri = Utils.getDomain(u.uri) ?? u.uri; if (!cipherUris.includes(uri)) { cipherUris.push(uri); } From c48e4be14b546d53bf77d837df6d7b0bc737ead6 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Thu, 29 May 2025 16:05:28 +0200 Subject: [PATCH 28/30] Pin @types/lowdb to v1 (#14957) --- .github/renovate.json5 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index f30bc06e4a2..453e5e29c44 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -413,6 +413,12 @@ allowedVersions: "1.0.0", description: "Higher versions of lowdb are not compatible with CommonJS", }, + { + // Pin types as well since we are not upgrading past v1 (and also v2+ does not need separate types). + matchPackageNames: ["@types/lowdb"], + allowedVersions: "< 2.0.0", + description: "Higher versions of lowdb do not need separate types", + }, ], ignoreDeps: ["@types/koa-bodyparser", "bootstrap", "node-ipc", "@bitwarden/sdk-internal"], } From 0715597e8e059e22e232df376054a01baf6610c5 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 29 May 2025 15:06:07 +0100 Subject: [PATCH 29/30] [PM-21603]Invite Member sub text seat count does not account for sponsorships (#14954) * Resolve the membership count * Get the occupied Seat count from metadata --- .../admin-console/organizations/members/members.component.ts | 5 ++++- .../response/organization-billing-metadata.response.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index e5a94bc4b4f..4f453762b5d 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -110,8 +110,10 @@ export class MembersComponent extends BaseMembersComponent protected rowHeight = 69; protected rowHeightClass = `tw-h-[69px]`; + private organizationUsersCount = 0; + get occupiedSeatCount(): number { - return this.dataSource.activeUserCount; + return this.organizationUsersCount; } constructor( @@ -218,6 +220,7 @@ export class MembersComponent extends BaseMembersComponent ); this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone; + this.organizationUsersCount = billingMetadata.organizationOccupiedSeats; await this.load(); diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts index d30ad76a147..aa34c37bd1d 100644 --- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts +++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts @@ -11,6 +11,7 @@ export class OrganizationBillingMetadataResponse extends BaseResponse { invoiceCreatedDate: Date | null; subPeriodEndDate: Date | null; isSubscriptionCanceled: boolean; + organizationOccupiedSeats: number; constructor(response: any) { super(response); @@ -25,6 +26,7 @@ export class OrganizationBillingMetadataResponse extends BaseResponse { this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate")); this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate")); this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled"); + this.organizationOccupiedSeats = this.getResponseProperty("OrganizationOccupiedSeats"); } private parseDate(dateString: any): Date | null { From 058eb9a04be3b7d422694bfb4519a84d477aad25 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 29 May 2025 11:17:30 -0400 Subject: [PATCH 30/30] [PM-19127] - Nested Traverse Optimization (#14881) * Draft optimization of getNestedCollectionTree * Added feature flag to wrap nestedTraverse_vNext. added the old implementation back in for feature flagging. * Correction from CR * Copied tests over for the vNext method. --------- Co-authored-by: Thomas Rittson --- .../collections/utils/collection-utils.ts | 25 ++++++ .../collections/vault.component.ts | 19 ++++- .../vault/individual-vault/vault.component.ts | 15 +++- libs/common/src/enums/feature-flag.enum.ts | 2 + libs/common/src/vault/service-utils.spec.ts | 18 +++++ libs/common/src/vault/service-utils.ts | 81 ++++++++++++++++--- 6 files changed, 141 insertions(+), 19 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts b/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts index 95ae911bbf6..f19c3f64530 100644 --- a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts +++ b/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts @@ -37,6 +37,31 @@ export function getNestedCollectionTree( return nodes; } +export function getNestedCollectionTree_vNext( + collections: (CollectionView | CollectionAdminView)[], +): TreeNode[] { + if (!collections) { + return []; + } + + // Collections need to be cloned because ServiceUtils.nestedTraverse actively + // modifies the names of collections. + // These changes risk affecting collections store in StateService. + const clonedCollections = collections + .sort((a, b) => a.name.localeCompare(b.name)) + .map(cloneCollection); + + const nodes: TreeNode[] = []; + clonedCollections.forEach((collection) => { + const parts = + collection.name != null + ? collection.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) + : []; + ServiceUtils.nestedTraverse_vNext(nodes, 0, parts, collection, null, NestingDelimiter); + }); + return nodes; +} + export function getFlatCollectionTree( nodes: TreeNode[], ): CollectionAdminView[]; diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index a3b62838d6a..19373f193d9 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -125,7 +125,11 @@ import { BulkCollectionsDialogResult, } from "./bulk-collections-dialog"; import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component"; -import { getNestedCollectionTree, getFlatCollectionTree } from "./utils"; +import { + getNestedCollectionTree, + getFlatCollectionTree, + getNestedCollectionTree_vNext, +} from "./utils"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultHeaderComponent } from "./vault-header/vault-header.component"; @@ -420,9 +424,16 @@ export class VaultComponent implements OnInit, OnDestroy { }), ); - const nestedCollections$ = allCollections$.pipe( - map((collections) => getNestedCollectionTree(collections)), - shareReplay({ refCount: true, bufferSize: 1 }), + const nestedCollections$ = combineLatest([ + this.allCollectionsWithoutUnassigned$, + this.configService.getFeatureFlag$(FeatureFlag.OptimizeNestedTraverseTypescript), + ]).pipe( + map( + ([collections, shouldOptimize]) => + (shouldOptimize + ? getNestedCollectionTree_vNext(collections) + : getNestedCollectionTree(collections)) as TreeNode[], + ), ); const collections$ = combineLatest([ diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 6e751f600dc..0dfaa1ac589 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -49,7 +49,9 @@ import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { EventType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -82,6 +84,7 @@ import { import { getNestedCollectionTree, getFlatCollectionTree, + getNestedCollectionTree_vNext, } from "../../admin-console/organizations/collections"; import { CollectionDialogAction, @@ -270,6 +273,7 @@ export class VaultComponent implements OnInit, OnDestroy { private trialFlowService: TrialFlowService, private organizationBillingService: OrganizationBillingServiceAbstraction, private billingNotificationService: BillingNotificationService, + private configService: ConfigService, ) {} async ngOnInit() { @@ -326,8 +330,15 @@ export class VaultComponent implements OnInit, OnDestroy { const filter$ = this.routedVaultFilterService.filter$; const allCollections$ = this.collectionService.decryptedCollections$; - const nestedCollections$ = allCollections$.pipe( - map((collections) => getNestedCollectionTree(collections)), + const nestedCollections$ = combineLatest([ + allCollections$, + this.configService.getFeatureFlag$(FeatureFlag.OptimizeNestedTraverseTypescript), + ]).pipe( + map(([collections, shouldOptimize]) => + shouldOptimize + ? getNestedCollectionTree_vNext(collections) + : getNestedCollectionTree(collections), + ), ); this.searchText$ diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 43b36c5692f..696f7028159 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -13,6 +13,7 @@ export enum FeatureFlag { /* Admin Console Team */ LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission", SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions", + OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript", /* Auth */ PM16117_ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor", @@ -82,6 +83,7 @@ export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.LimitItemDeletion]: FALSE, [FeatureFlag.SeparateCustomRolePermissions]: FALSE, + [FeatureFlag.OptimizeNestedTraverseTypescript]: FALSE, /* Autofill */ [FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE, diff --git a/libs/common/src/vault/service-utils.spec.ts b/libs/common/src/vault/service-utils.spec.ts index db414da76d7..619d3d72ee6 100644 --- a/libs/common/src/vault/service-utils.spec.ts +++ b/libs/common/src/vault/service-utils.spec.ts @@ -36,6 +36,24 @@ describe("serviceUtils", () => { }); }); + describe("nestedTraverse_vNext", () => { + it("should traverse a tree and add a node at the correct position given a valid path", () => { + const nodeToBeAdded: FakeObject = { id: "1.2.1", name: "1.2.1" }; + const path = ["1", "1.2", "1.2.1"]; + + ServiceUtils.nestedTraverse_vNext(nodeTree, 0, path, nodeToBeAdded, null, "/"); + expect(nodeTree[0].children[1].children[0].node).toEqual(nodeToBeAdded); + }); + + it("should combine the path for missing nodes and use as the added node name given an invalid path", () => { + const nodeToBeAdded: FakeObject = { id: "blank", name: "blank" }; + const path = ["3", "3.1", "3.1.1"]; + + ServiceUtils.nestedTraverse_vNext(nodeTree, 0, path, nodeToBeAdded, null, "/"); + expect(nodeTree[2].children[0].node.name).toEqual("3.1/3.1.1"); + }); + }); + describe("getTreeNodeObject", () => { it("should return a matching node given a single tree branch and a valid id", () => { const id = "1.1.1"; diff --git a/libs/common/src/vault/service-utils.ts b/libs/common/src/vault/service-utils.ts index 5fbc550d6af..96ae406fae4 100644 --- a/libs/common/src/vault/service-utils.ts +++ b/libs/common/src/vault/service-utils.ts @@ -3,15 +3,6 @@ import { ITreeNodeObject, TreeNode } from "./models/domain/tree-node"; export class ServiceUtils { - /** - * Recursively adds a node to nodeTree - * @param {TreeNode[]} nodeTree - An array of TreeNodes that the node will be added to - * @param {number} partIndex - Index of the `parts` array that is being processed - * @param {string[]} parts - Array of strings that represent the path to the `obj` node - * @param {ITreeNodeObject} obj - The node to be added to the tree - * @param {ITreeNodeObject} parent - The parent node of the `obj` node - * @param {string} delimiter - The delimiter used to split the path string, will be used to combine the path for missing nodes - */ static nestedTraverse( nodeTree: TreeNode[], partIndex: number, @@ -70,11 +61,75 @@ export class ServiceUtils { } } + /** + * Recursively adds a node to nodeTree + * @param {TreeNode[]} nodeTree - An array of TreeNodes that the node will be added to + * @param {number} partIndex - Index of the `parts` array that is being processed + * @param {string[]} parts - Array of strings that represent the path to the `obj` node + * @param {ITreeNodeObject} obj - The node to be added to the tree + * @param {ITreeNodeObject} parent - The parent node of the `obj` node + * @param {string} delimiter - The delimiter used to split the path string, will be used to combine the path for missing nodes + */ + static nestedTraverse_vNext( + nodeTree: TreeNode[], + partIndex: number, + parts: string[], + obj: ITreeNodeObject, + parent: TreeNode | undefined, + delimiter: string, + ) { + if (parts.length <= partIndex) { + return; + } + + // 'end' indicates we've traversed as far as we can based on the object name + const end: boolean = partIndex === parts.length - 1; + const partName: string = parts[partIndex]; + + // If we're at the end, just add the node - it doesn't matter what else is here + if (end) { + nodeTree.push(new TreeNode(obj, parent, partName)); + return; + } + + // Get matching nodes at this level by name + // NOTE: this is effectively a loop so we only want to do it once + const matchingNodes = nodeTree.filter((n) => n.node.name === partName); + + // If there are no matching nodes... + if (matchingNodes.length === 0) { + // And we're not at the end of the path (because we didn't trigger the early return above), + // combine the current name with the next name. + // 1, *1.2, 1.2.1 becomes + // 1, *1.2/1.2.1 + const newPartName = partName + delimiter + parts[partIndex + 1]; + ServiceUtils.nestedTraverse_vNext( + nodeTree, + 0, + [newPartName, ...parts.slice(partIndex + 2)], + obj, + parent, + delimiter, + ); + } else { + // There is a node here with the same name, descend into it + ServiceUtils.nestedTraverse_vNext( + matchingNodes[0].children, + partIndex + 1, + parts, + obj, + matchingNodes[0], + delimiter, + ); + return; + } + } + /** * Searches a tree for a node with a matching `id` - * @param {TreeNode} nodeTree - A single TreeNode branch that will be searched + * @param {TreeNode} nodeTree - A single TreeNode branch that will be searched * @param {string} id - The id of the node to be found - * @returns {TreeNode} The node with a matching `id` + * @returns {TreeNode} The node with a matching `id` */ static getTreeNodeObject( nodeTree: TreeNode, @@ -96,9 +151,9 @@ export class ServiceUtils { /** * Searches an array of tree nodes for a node with a matching `id` - * @param {TreeNode} nodeTree - An array of TreeNode branches that will be searched + * @param {TreeNode} nodeTree - An array of TreeNode branches that will be searched * @param {string} id - The id of the node to be found - * @returns {TreeNode} The node with a matching `id` + * @returns {TreeNode} The node with a matching `id` */ static getTreeNodeObjectFromList( nodeTree: TreeNode[],