-
-
-
{{ secret.name }}
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.html
index e4990f0111a..03dc17b98d5 100644
--- a/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.html
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.html
@@ -9,4 +9,5 @@
(restoreSecretsEvent)="openRestoreSecret($event)"
[secrets]="secrets$ | async"
[trash]="true"
+ (copySecretUuidEvent)="copySecretUuid($event)"
>
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts
index 83f510ed569..e92a01ed279 100644
--- a/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts
@@ -2,10 +2,13 @@ import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { combineLatestWith, Observable, startWith, switchMap } from "rxjs";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { SecretListView } from "../models/view/secret-list.view";
import { SecretService } from "../secrets/secret.service";
+import { SecretsListComponent } from "../shared/secrets-list.component";
import {
SecretHardDeleteDialogComponent,
@@ -28,6 +31,8 @@ export class TrashComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private secretService: SecretService,
+ private platformUtilsService: PlatformUtilsService,
+ private i18nService: I18nService,
private dialogService: DialogService
) {}
@@ -65,4 +70,8 @@ export class TrashComponent implements OnInit {
},
});
}
+
+ copySecretUuid(id: string) {
+ SecretsListComponent.copySecretUuid(id, this.platformUtilsService, this.i18nService);
+ }
}
From 17a4edcda19c60322066a13567057e3c98298a47 Mon Sep 17 00:00:00 2001
From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
Date: Mon, 28 Aug 2023 11:10:35 -0500
Subject: [PATCH 05/27] increase secret value max length limit (#6030)
---
.../secrets-manager/secrets/dialog/secret-dialog.component.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts
index c6a807f7395..426542823f9 100644
--- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts
@@ -40,7 +40,7 @@ export class SecretDialogComponent implements OnInit {
validators: [Validators.required, Validators.maxLength(500), BitValidators.trimValidator],
updateOn: "submit",
}),
- value: new FormControl("", [Validators.required, Validators.maxLength(3500)]),
+ value: new FormControl("", [Validators.required, Validators.maxLength(25000)]),
notes: new FormControl("", {
validators: [Validators.maxLength(7000), BitValidators.trimValidator],
updateOn: "submit",
From b5c4149bc8f12ca5c07d38858943f07fb01a0221 Mon Sep 17 00:00:00 2001
From: Vince Grassia <593223+vgrassia@users.noreply.github.com>
Date: Mon, 28 Aug 2023 15:31:09 -0400
Subject: [PATCH 06/27] CLI/Release Desktop Workflow - Update runner to Ubuntu
latest (#6129)
---
.github/workflows/build-cli.yml | 8 ++++----
.github/workflows/release-desktop.yml | 4 ++--
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml
index da076303508..4d19c7e7cce 100644
--- a/.github/workflows/build-cli.yml
+++ b/.github/workflows/build-cli.yml
@@ -35,7 +35,7 @@ defaults:
jobs:
cloc:
name: CLOC
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-22.04
steps:
- name: Checkout repo
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
@@ -51,7 +51,7 @@ jobs:
setup:
name: Setup
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-22.04
outputs:
package_version: ${{ steps.retrieve-version.outputs.package_version }}
steps:
@@ -69,7 +69,7 @@ jobs:
name: Build CLI ${{ matrix.os }}
strategy:
matrix:
- os: [ubuntu-20.04, macos-11]
+ os: [ubuntu-22.04, macos-11]
runs-on: ${{ matrix.os }}
needs:
- setup
@@ -368,7 +368,7 @@ jobs:
check-failures:
name: Check for failures
if: always()
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-22.04
needs:
- cloc
- setup
diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml
index c564c1c7254..97c91776520 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/release-desktop.yml
@@ -47,7 +47,7 @@ defaults:
jobs:
setup:
name: Setup
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-22.04
outputs:
release-version: ${{ steps.version.outputs.version }}
release-channel: ${{ steps.release-channel.outputs.channel }}
@@ -247,7 +247,7 @@ jobs:
snap:
name: Deploy Snap
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-22.04
needs: setup
if: inputs.snap_publish
env:
From 3cc0e5f59a7004cd25d9ed19ce92ca1e33b91975 Mon Sep 17 00:00:00 2001
From: Vince Grassia <593223+vgrassia@users.noreply.github.com>
Date: Mon, 28 Aug 2023 16:12:40 -0400
Subject: [PATCH 07/27] Fix Release CLI workflow (#6130)
---
.github/workflows/release-cli.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml
index eaaeafd62c8..9ff812bf305 100644
--- a/.github/workflows/release-cli.yml
+++ b/.github/workflows/release-cli.yml
@@ -182,6 +182,8 @@ jobs:
- name: Publish Snap & logout
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
+ env:
+ SNAPCRAFT_STORE_CREDENTIALS: ${{ steps.retrieve-secrets.outputs.snapcraft-store-token }}
run: |
snapcraft push bw_${{ env._PKG_VERSION }}_amd64.snap --release stable
snapcraft logout
From fd119f08ec667841cdde66a74518dffa157340b1 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 28 Aug 2023 16:35:34 -0400
Subject: [PATCH 08/27] Bumped desktop version to 2023.8.3 (#6131)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
---
apps/desktop/package.json | 2 +-
apps/desktop/src/package-lock.json | 4 ++--
apps/desktop/src/package.json | 2 +-
package-lock.json | 2 +-
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 83a3aedde21..82c77156c12 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
- "version": "2023.8.2",
+ "version": "2023.8.3",
"keywords": [
"bitwarden",
"password",
diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json
index f91ee186705..54af2baea1e 100644
--- a/apps/desktop/src/package-lock.json
+++ b/apps/desktop/src/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@bitwarden/desktop",
- "version": "2023.8.2",
+ "version": "2023.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@bitwarden/desktop",
- "version": "2023.8.2",
+ "version": "2023.8.3",
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/desktop-native": "file:../desktop_native"
diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json
index 2853ab64649..b171046bcef 100644
--- a/apps/desktop/src/package.json
+++ b/apps/desktop/src/package.json
@@ -2,7 +2,7 @@
"name": "@bitwarden/desktop",
"productName": "Bitwarden",
"description": "A secure and free password manager for all of your devices.",
- "version": "2023.8.2",
+ "version": "2023.8.3",
"author": "Bitwarden Inc. (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",
diff --git a/package-lock.json b/package-lock.json
index 2c955a3ec02..0b364d58ed7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -231,7 +231,7 @@
},
"apps/desktop": {
"name": "@bitwarden/desktop",
- "version": "2023.8.2",
+ "version": "2023.8.3",
"hasInstallScript": true,
"license": "GPL-3.0"
},
From 42193aecb83c11eaddad7c25c5739f964febb255 Mon Sep 17 00:00:00 2001
From: Jonathan Prusik
Date: Tue, 29 Aug 2023 09:10:16 -0400
Subject: [PATCH 09/27] [PM-1407] Improve iframe sandbox detection (#5976)
* improve iframe sandbox detection
* code cleanup
Co-authored-by: Cesar Gonzalez
* update autofill v1 logic as well
---------
Co-authored-by: Cesar Gonzalez
---
apps/browser/src/autofill/content/autofill.js | 12 ++++++++++--
apps/browser/src/autofill/content/autofillv2.ts | 10 +++++++++-
2 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/apps/browser/src/autofill/content/autofill.js b/apps/browser/src/autofill/content/autofill.js
index f6db33af97d..1833c09e159 100644
--- a/apps/browser/src/autofill/content/autofill.js
+++ b/apps/browser/src/autofill/content/autofill.js
@@ -768,8 +768,16 @@
// Detect if within an iframe, and the iframe is sandboxed
function isSandboxed() {
- // self.origin is 'null' if inside a frame with sandboxed csp or iframe tag
- return self.origin == null || self.origin === 'null';
+ // self.origin is 'null' if inside a frame with sandboxed csp or iframe tag
+ if (String(self.origin).toLowerCase() === "null") {
+ return true;
+ }
+
+ if (window.frameElement?.hasAttribute("sandbox")) {
+ return true;
+ }
+
+ return location.hostname === "";
}
function doFill(fillScript) {
diff --git a/apps/browser/src/autofill/content/autofillv2.ts b/apps/browser/src/autofill/content/autofillv2.ts
index 8bf16ff879c..65813b3afe6 100644
--- a/apps/browser/src/autofill/content/autofillv2.ts
+++ b/apps/browser/src/autofill/content/autofillv2.ts
@@ -849,7 +849,15 @@ function fill(document: Document, fillScript: AutofillScript) {
// Detect if within an iframe, and the iframe is sandboxed
function isSandboxed() {
// self.origin is 'null' if inside a frame with sandboxed csp or iframe tag
- return self.origin == null || self.origin === "null";
+ if (String(self.origin).toLowerCase() === "null") {
+ return true;
+ }
+
+ if (window.frameElement?.hasAttribute("sandbox")) {
+ return true;
+ }
+
+ return location.hostname === "";
}
function doFill(fillScript: AutofillScript) {
From 6bf559b9321201ba78692e13ffed00e8ed7b79fb Mon Sep 17 00:00:00 2001
From: Opeyemi
Date: Tue, 29 Aug 2023 16:00:28 +0100
Subject: [PATCH 10/27] UPDATE: fix all warnings in Clients workflow (#6137)
---
.github/workflows/brew-bump-cli.yml | 2 +-
.github/workflows/brew-bump-desktop.yml | 2 +-
.github/workflows/release-desktop.yml | 2 +-
.github/workflows/version-auto-bump.yml | 2 --
.github/workflows/version-bump.yml | 3 ---
5 files changed, 3 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/brew-bump-cli.yml b/.github/workflows/brew-bump-cli.yml
index 5bee549032b..477c9ace582 100644
--- a/.github/workflows/brew-bump-cli.yml
+++ b/.github/workflows/brew-bump-cli.yml
@@ -38,4 +38,4 @@ jobs:
formula: bitwarden-cli
tag: ${{ github.ref }}
revision: ${{ github.sha }}
- force: false
+ force: true
diff --git a/.github/workflows/brew-bump-desktop.yml b/.github/workflows/brew-bump-desktop.yml
index f8bf833dcfa..0a5c3947161 100644
--- a/.github/workflows/brew-bump-desktop.yml
+++ b/.github/workflows/brew-bump-desktop.yml
@@ -38,5 +38,5 @@ jobs:
cask: bitwarden
tag: ${{ github.ref }}
revision: ${{ github.sha }}
- force: false
+ force: true
dryrun: true
diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml
index 97c91776520..99119725a3e 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/release-desktop.yml
@@ -185,7 +185,7 @@ jobs:
--endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com
- name: Get checksum files
- uses: bitwarden/gh-actions/get-checksum@67ab95d7a466bcefdedf3f93cbc10bcff436edfe
+ uses: bitwarden/gh-actions/get-checksum@82cfceb235b308c2eb63923824e61d8350d280db
with:
packages_dir: "apps/desktop/artifacts"
file_path: "apps/desktop/artifacts/sha256-checksums.txt"
diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml
index 2c13ec05b38..857099db511 100644
--- a/.github/workflows/version-auto-bump.yml
+++ b/.github/workflows/version-auto-bump.yml
@@ -42,8 +42,6 @@ jobs:
name: Bump version to ${{ needs.setup.outputs.version_number }}
needs: setup
uses: ./.github/workflows/version-bump.yml
- secrets:
- AZURE_PROD_KV_CREDENTIALS: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
with:
version_number: ${{ needs.setup.outputs.version_number }}
client: "Desktop"
diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml
index 6ea1198784a..420ef456ec0 100644
--- a/.github/workflows/version-bump.yml
+++ b/.github/workflows/version-bump.yml
@@ -26,9 +26,6 @@ on:
client:
required: true
type: string
- secrets:
- AZURE_PROD_KV_CREDENTIALS:
- required: true
defaults:
run:
From c8c314dd35b8e832bab9e36a79ca6f2e411ab5eb Mon Sep 17 00:00:00 2001
From: Danielle Flinn <43477473+danielleflinn@users.noreply.github.com>
Date: Tue, 29 Aug 2023 13:42:56 -0700
Subject: [PATCH 11/27] [PM-2866] - Update color variables for better contrast
(#6078)
* Update variables.scss
* update toast text color to have better WCAG contrast
* added toastcolor variables
* Update window.main.ts
* Tweaked styles
* darkened backgroundAlt2 and button background
* lightened button border
* lightened button backgroundColor
* Update window.main.ts
* updated brand colors and added toastTextColor variable
* lightened solarize danger variable
to meet WCAG contrast with dark text
* updated browser solarize variable to match tw-theme.css
---
apps/browser/src/popup/scss/variables.scss | 10 +++++-----
apps/desktop/src/scss/variables.scss | 8 ++++----
apps/web/src/scss/variables.scss | 6 +++---
libs/components/src/tw-theme.css | 2 +-
libs/components/src/variables.scss | 8 ++++----
5 files changed, 17 insertions(+), 17 deletions(-)
diff --git a/apps/browser/src/popup/scss/variables.scss b/apps/browser/src/popup/scss/variables.scss
index 05843a9b351..2cdc49cd9ef 100644
--- a/apps/browser/src/popup/scss/variables.scss
+++ b/apps/browser/src/popup/scss/variables.scss
@@ -24,10 +24,10 @@ $gray-light: #777;
$text-muted: $gray-light;
$brand-primary: #175ddc;
-$brand-danger: #dd4b39;
-$brand-success: #00a65a;
+$brand-danger: #c83522;
+$brand-success: #017e45;
$brand-info: #555555;
-$brand-warning: #bf7e16;
+$brand-warning: #8b6609;
$brand-primary-accent: #1252a3;
$background-color: #f0f0f0;
@@ -237,7 +237,7 @@ $themes: (
passwordCountText: $nord5,
calloutBorderColor: $nord0,
calloutBackgroundColor: $nord2,
- toastTextColor: #ffffff,
+ toastTextColor: #000000,
svgSuffix: "-dark.svg",
transparentColor: rgba(0, 0, 0, 0),
dateInputColorScheme: dark,
@@ -299,7 +299,7 @@ $themes: (
passwordCountText: $solarizedDarkBase2,
calloutBorderColor: $solarizedDarkBase03,
calloutBackgroundColor: $solarizedDarkBase01,
- toastTextColor: #ffffff,
+ toastTextColor: #000000,
svgSuffix: "-solarized.svg",
transparentColor: rgba(0, 0, 0, 0),
dateInputColorScheme: dark,
diff --git a/apps/desktop/src/scss/variables.scss b/apps/desktop/src/scss/variables.scss
index b99881134d5..3ad4c0f0754 100644
--- a/apps/desktop/src/scss/variables.scss
+++ b/apps/desktop/src/scss/variables.scss
@@ -20,10 +20,10 @@ $gray-light: #777;
$text-muted: $gray-light;
$brand-primary: #175ddc;
-$brand-danger: #dd4b39;
-$brand-success: #00a65a;
+$brand-danger: #c83522;
+$brand-success: #017e45;
$brand-info: #555555;
-$brand-warning: #bf7e16;
+$brand-warning: #8b6609;
$brand-primary-accent: #1252a3;
$background-color: white;
@@ -211,7 +211,7 @@ $themes: (
passwordCountText: $nord5,
calloutBorderColor: $nord1,
calloutBackgroundColor: $nord2,
- toastTextColor: #ffffff,
+ toastTextColor: #000000,
accountSwitcherBackgroundColor: $nord0,
accountSwitcherTextColor: $nord5,
svgSuffix: "-dark.svg",
diff --git a/apps/web/src/scss/variables.scss b/apps/web/src/scss/variables.scss
index 222c43fed19..719f403e385 100644
--- a/apps/web/src/scss/variables.scss
+++ b/apps/web/src/scss/variables.scss
@@ -4,10 +4,10 @@ $primary: #175ddc;
$primary-accent: #1252a3;
$secondary: #ced4da;
$secondary-alt: #1a3b66;
-$success: #00a65a;
+$success: #017e45;
$info: #555555;
-$warning: #bf7e16;
-$danger: #dd4b39;
+$warning: #8b6609;
+$danger: #c83522;
$white: #ffffff;
// Bootstrap Variable Overrides
diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css
index 420b2cfd483..1ff2064fdbd 100644
--- a/libs/components/src/tw-theme.css
+++ b/libs/components/src/tw-theme.css
@@ -151,7 +151,7 @@
--color-text-main: 253 246 227;
--color-text-muted: 147 161 161;
- --color-text-contrast: 0 43 54;
+ --color-text-contrast: 0 0 0;
--color-text-alt2: 255 255 255;
--color-text-code: 240 141 199;
diff --git a/libs/components/src/variables.scss b/libs/components/src/variables.scss
index 1abfd645fd2..88e3cba5c3c 100644
--- a/libs/components/src/variables.scss
+++ b/libs/components/src/variables.scss
@@ -4,10 +4,10 @@ $primary: #175ddc;
$primary-accent: #1252a3;
$secondary: #ced4da;
$secondary-alt: #1a3b66;
-$success: #00a65a;
+$success: #017e45;
$info: #555555;
-$warning: #bf7e16;
-$danger: #dd4b39;
+$warning: #8b6609;
+$danger: #c83522;
$white: #ffffff;
// Bootstrap Variable Overrides
@@ -85,7 +85,7 @@ $mfaTypes: 0, 2, 3, 4, 6;
// Theme Variables
// Light
-$lightDangerHover: #c43421;
+$lightDangerHover: #c83522;
$lightInputColor: #465057;
$lightInputPlaceholderColor: #b6b8b8;
From 3930189c4e90408eff4a2f60499e842e5011c982 Mon Sep 17 00:00:00 2001
From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>
Date: Tue, 29 Aug 2023 17:08:02 -0400
Subject: [PATCH 12/27] PM-3632 - Vault Timeout Input comp (all clients) -
resolve bug where logic which was supposed to initialize the custom vault
timeout timeframe if coming from a previously selected numeric timeout was
not working properly. (#6102)
---
.../components/settings/vault-timeout-input.component.ts | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/libs/angular/src/components/settings/vault-timeout-input.component.ts b/libs/angular/src/components/settings/vault-timeout-input.component.ts
index b0dec5affb7..e01a01fdd1d 100644
--- a/libs/angular/src/components/settings/vault-timeout-input.component.ts
+++ b/libs/angular/src/components/settings/vault-timeout-input.component.ts
@@ -76,14 +76,17 @@ export class VaultTimeoutInputComponent
}
});
- // Assign the previous value to the custom fields
+ // Assign the current value to the custom fields
+ // so that if the user goes from a numeric value to custom
+ // we can initialize the custom fields with the current value
+ // ex: user picks 5 min, goes to custom, we want to show 0 hr, 5 min in the custom fields
this.form.controls.vaultTimeout.valueChanges
.pipe(
filter((value) => value !== VaultTimeoutInputComponent.CUSTOM_VALUE),
takeUntil(this.destroy$)
)
- .subscribe((_) => {
- const current = Math.max(this.form.value.vaultTimeout, 0);
+ .subscribe((value) => {
+ const current = Math.max(value, 0);
this.form.patchValue({
custom: {
hours: Math.floor(current / 60),
From 9288367bc8925581c0620c94be9ccc94eee220cb Mon Sep 17 00:00:00 2001
From: Vince Grassia <593223+vgrassia@users.noreply.github.com>
Date: Wed, 30 Aug 2023 10:46:10 -0400
Subject: [PATCH 13/27] Fix logic in workflow (#6147)
---
.github/workflows/release-desktop.yml | 40 +++++++++++++--------------
1 file changed, 20 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml
index 99119725a3e..22b76f46408 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/release-desktop.yml
@@ -56,7 +56,7 @@ jobs:
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- name: Branch check
- if: ${{ github.event.inputs.release_type != 'Dry Run' }}
+ if: ${{ inputs.release_type != 'Dry Run' }}
run: |
if [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc-desktop" ]]; then
echo "==================================="
@@ -69,7 +69,7 @@ jobs:
id: version
uses: bitwarden/gh-actions/release-version-check@67ab95d7a466bcefdedf3f93cbc10bcff436edfe
with:
- release-type: ${{ github.event.inputs.release_type }}
+ release-type: ${{ inputs.release_type }}
project-type: ts
file: apps/desktop/src/package.json
monorepo: true
@@ -93,7 +93,7 @@ jobs:
esac
- name: Create GitHub deployment
- if: ${{ github.event.inputs.release_type != 'Dry Run' }}
+ if: ${{ inputs.release_type != 'Dry Run' }}
uses: chrnorm/deployment-action@d42cde7132fcec920de534fffc3be83794335c00 # v2.0.5
id: deployment
with:
@@ -122,7 +122,7 @@ jobs:
cf-prod-account"
- name: Download all artifacts
- if: ${{ github.event.inputs.release_type != 'Dry Run' }}
+ if: ${{ inputs.release_type != 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe
with:
workflow: build-desktop.yml
@@ -131,7 +131,7 @@ jobs:
path: apps/desktop/artifacts
- name: Dry Run - Download all artifacts
- if: ${{ github.event.inputs.release_type == 'Dry Run' }}
+ if: ${{ inputs.release_type == 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe
with:
workflow: build-desktop.yml
@@ -146,17 +146,17 @@ jobs:
run: mv Bitwarden-${{ env.PKG_VERSION }}-universal.pkg Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive
- name: Set staged rollout percentage
- if: ${{ github.event.inputs.electron_publish }}
+ if: ${{ inputs.electron_publish == 'true' }}
env:
RELEASE_CHANNEL: ${{ steps.release-channel.outputs.channel }}
- ROLLOUT_PCT: ${{ github.event.inputs.rollout_percentage }}
+ ROLLOUT_PCT: ${{ inputs.rollout_percentage }}
run: |
echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}.yml
echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}-linux.yml
echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}-mac.yml
- name: Publish artifacts to S3
- if: ${{ github.event.inputs.release_type != 'Dry Run' && github.event.inputs.electron_publish }}
+ if: ${{ inputs.release_type != 'Dry Run' && inputs.electron_publish == 'true' }}
env:
AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }}
AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.aws-electron-access-key }}
@@ -170,7 +170,7 @@ jobs:
--quiet
- name: Publish artifacts to R2
- if: ${{ github.event.inputs.release_type != 'Dry Run' && github.event.inputs.electron_publish }}
+ if: ${{ inputs.release_type != 'Dry Run' && inputs.electron_publish == 'true' }}
env:
AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.r2-electron-access-id }}
AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.r2-electron-access-key }}
@@ -192,7 +192,7 @@ jobs:
- name: Create Release
uses: ncipollo/release-action@a2e71bdd4e7dab70ca26a852f29600c98b33153e # v1.12.0
- if: ${{ steps.release-channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' && inputs.github_release }}
+ if: ${{ steps.release-channel.outputs.channel == 'latest' && inputs.release_type != 'Dry Run' && inputs.github_release == 'true' }}
env:
PKG_VERSION: ${{ steps.version.outputs.version }}
RELEASE_CHANNEL: ${{ steps.release-channel.outputs.channel }}
@@ -230,7 +230,7 @@ jobs:
draft: true
- name: Update deployment status to Success
- if: ${{ github.event.inputs.release_type != 'Dry Run' && success() }}
+ if: ${{ inputs.release_type != 'Dry Run' && success() }}
uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1
with:
token: '${{ secrets.GITHUB_TOKEN }}'
@@ -238,7 +238,7 @@ jobs:
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
- name: Update deployment status to Failure
- if: ${{ github.event.inputs.release_type != 'Dry Run' && failure() }}
+ if: ${{ inputs.release_type != 'Dry Run' && failure() }}
uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1
with:
token: '${{ secrets.GITHUB_TOKEN }}'
@@ -249,7 +249,7 @@ jobs:
name: Deploy Snap
runs-on: ubuntu-22.04
needs: setup
- if: inputs.snap_publish
+ if: ${{ inputs.snap_publish == 'true' }}
env:
_PKG_VERSION: ${{ needs.setup.outputs.release-version }}
steps:
@@ -278,7 +278,7 @@ jobs:
working-directory: apps/desktop
- name: Download Snap artifact
- if: ${{ github.event.inputs.release_type != 'Dry Run' }}
+ if: ${{ inputs.release_type != 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe
with:
workflow: build-desktop.yml
@@ -288,7 +288,7 @@ jobs:
path: apps/desktop/dist
- name: Dry Run - Download Snap artifact
- if: ${{ github.event.inputs.release_type == 'Dry Run' }}
+ if: ${{ inputs.release_type == 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe
with:
workflow: build-desktop.yml
@@ -298,7 +298,7 @@ jobs:
path: apps/desktop/dist
- name: Deploy to Snap Store
- if: ${{ github.event.inputs.release_type != 'Dry Run' }}
+ if: ${{ inputs.release_type != 'Dry Run' }}
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ steps.retrieve-secrets.outputs.snapcraft-store-token }}
run: |
@@ -310,7 +310,7 @@ jobs:
name: Deploy Choco
runs-on: windows-2019
needs: setup
- if: inputs.choco_publish
+ if: ${{ inputs.choco_publish == 'true' }}
env:
_PKG_VERSION: ${{ needs.setup.outputs.release-version }}
steps:
@@ -346,7 +346,7 @@ jobs:
working-directory: apps/desktop
- name: Download choco artifact
- if: ${{ github.event.inputs.release_type != 'Dry Run' }}
+ if: ${{ inputs.release_type != 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe
with:
workflow: build-desktop.yml
@@ -356,7 +356,7 @@ jobs:
path: apps/desktop/dist
- name: Dry Run - Download choco artifact
- if: ${{ github.event.inputs.release_type == 'Dry Run' }}
+ if: ${{ inputs.release_type == 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe
with:
workflow: build-desktop.yml
@@ -366,7 +366,7 @@ jobs:
path: apps/desktop/dist
- name: Push to Chocolatey
- if: ${{ github.event.inputs.release_type != 'Dry Run' }}
+ if: ${{ inputs.release_type != 'Dry Run' }}
shell: pwsh
run: choco push --source=https://push.chocolatey.org/
working-directory: apps/desktop/dist
From b444eed0b5b61fb66eb89d1c90ee0ded89a3f6d9 Mon Sep 17 00:00:00 2001
From: Cesar Gonzalez
Date: Wed, 30 Aug 2023 10:18:20 -0500
Subject: [PATCH 14/27] [PM-3589] Context Menu No Longer Shows Autofill Ciphers
(#6085)
* [PM-3589] Context Menu No Longer Shows Autofill Ciphers
* [PM-3589] Ensuring that passwordless users can also access ciphers that require reprompt
* [PM-3589] Fixing jest test
* [PM-3589] Fixing issue where context menu autofill does not allow filling when passwordless setup is in place
---
.../cipher-context-menu-handler.spec.ts | 57 +------------------
.../browser/cipher-context-menu-handler.ts | 14 +----
.../context-menu-clicked-handler.spec.ts | 5 +-
.../browser/context-menu-clicked-handler.ts | 21 +++++--
.../browser/src/background/main.background.ts | 6 +-
.../popup/components/vault/view.component.ts | 10 +---
6 files changed, 32 insertions(+), 81 deletions(-)
diff --git a/apps/browser/src/autofill/browser/cipher-context-menu-handler.spec.ts b/apps/browser/src/autofill/browser/cipher-context-menu-handler.spec.ts
index dbe391ce4ab..d7cac8d44b2 100644
--- a/apps/browser/src/autofill/browser/cipher-context-menu-handler.spec.ts
+++ b/apps/browser/src/autofill/browser/cipher-context-menu-handler.spec.ts
@@ -1,7 +1,6 @@
import { mock, MockProxy } from "jest-mock-extended";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
-import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
@@ -14,7 +13,6 @@ describe("CipherContextMenuHandler", () => {
let mainContextMenuHandler: MockProxy;
let authService: MockProxy;
let cipherService: MockProxy;
- let userVerificationService: MockProxy;
let sut: CipherContextMenuHandler;
@@ -22,17 +20,10 @@ describe("CipherContextMenuHandler", () => {
mainContextMenuHandler = mock();
authService = mock();
cipherService = mock();
- userVerificationService = mock();
- userVerificationService.hasMasterPassword.mockResolvedValue(true);
jest.spyOn(MainContextMenuHandler, "removeAll").mockResolvedValue();
- sut = new CipherContextMenuHandler(
- mainContextMenuHandler,
- authService,
- cipherService,
- userVerificationService
- );
+ sut = new CipherContextMenuHandler(mainContextMenuHandler, authService, cipherService);
});
afterEach(() => jest.resetAllMocks());
@@ -78,7 +69,7 @@ describe("CipherContextMenuHandler", () => {
expect(mainContextMenuHandler.noLogins).toHaveBeenCalledTimes(1);
});
- it("only adds valid ciphers", async () => {
+ it("only adds login ciphers including ciphers that require reprompt", async () => {
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
mainContextMenuHandler.init.mockResolvedValue(true);
@@ -90,47 +81,6 @@ describe("CipherContextMenuHandler", () => {
name: "Test Cipher",
login: { username: "Test Username" },
};
-
- cipherService.getAllDecryptedForUrl.mockResolvedValue([
- null, // invalid cipher
- undefined, // invalid cipher
- { type: CipherType.Card }, // invalid cipher
- { type: CipherType.Login, reprompt: CipherRepromptType.Password }, // invalid cipher
- realCipher, // valid cipher
- ] as any[]);
-
- await sut.update("https://test.com");
-
- expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1);
-
- expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com");
-
- expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledTimes(2);
-
- expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledWith(
- "Test Cipher (Test Username)",
- "5",
- "https://test.com",
- realCipher
- );
- });
-
- it("adds ciphers with master password reprompt if the user does not have a master password", async () => {
- authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
-
- // User does not have a master password, or has one but hasn't logged in with it (key connector user or TDE user)
- userVerificationService.hasMasterPasswordAndMasterKeyHash.mockResolvedValue(false);
-
- mainContextMenuHandler.init.mockResolvedValue(true);
-
- const realCipher = {
- id: "5",
- type: CipherType.Login,
- reprompt: CipherRepromptType.None,
- name: "Test Cipher",
- login: { username: "Test Username" },
- };
-
const repromptCipher = {
id: "6",
type: CipherType.Login,
@@ -143,8 +93,8 @@ describe("CipherContextMenuHandler", () => {
null, // invalid cipher
undefined, // invalid cipher
{ type: CipherType.Card }, // invalid cipher
- repromptCipher, // valid cipher
realCipher, // valid cipher
+ repromptCipher,
] as any[]);
await sut.update("https://test.com");
@@ -153,7 +103,6 @@ describe("CipherContextMenuHandler", () => {
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com");
- // Should call this twice, once for each valid cipher
expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledTimes(2);
expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledWith(
diff --git a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts
index 1d1be8f8386..fe6479aae51 100644
--- a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts
+++ b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts
@@ -1,5 +1,4 @@
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
-import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -12,7 +11,6 @@ import {
authServiceFactory,
AuthServiceInitOptions,
} from "../../auth/background/service-factories/auth-service.factory";
-import { userVerificationServiceFactory } from "../../auth/background/service-factories/user-verification-service.factory";
import { Account } from "../../models/account";
import { CachedServices } from "../../platform/background/service-factories/factory-options";
import { BrowserApi } from "../../platform/browser/browser-api";
@@ -39,8 +37,7 @@ export class CipherContextMenuHandler {
constructor(
private mainContextMenuHandler: MainContextMenuHandler,
private authService: AuthService,
- private cipherService: CipherService,
- private userVerificationService: UserVerificationService
+ private cipherService: CipherService
) {}
static async create(cachedServices: CachedServices) {
@@ -79,8 +76,7 @@ export class CipherContextMenuHandler {
return new CipherContextMenuHandler(
await MainContextMenuHandler.mv3Create(cachedServices),
await authServiceFactory(cachedServices, serviceOptions),
- await cipherServiceFactory(cachedServices, serviceOptions),
- await userVerificationServiceFactory(cachedServices, serviceOptions)
+ await cipherServiceFactory(cachedServices, serviceOptions)
);
}
@@ -180,11 +176,7 @@ export class CipherContextMenuHandler {
}
private async updateForCipher(url: string, cipher: CipherView) {
- if (
- cipher == null ||
- cipher.type !== CipherType.Login ||
- (await this.userVerificationService.hasMasterPasswordAndMasterKeyHash())
- ) {
+ if (cipher == null || cipher.type !== CipherType.Login) {
return;
}
diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts
index a9dbcbaacc5..021d15df89e 100644
--- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts
+++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts
@@ -3,6 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
+import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
@@ -63,6 +64,7 @@ describe("ContextMenuClickedHandler", () => {
let cipherService: MockProxy;
let totpService: MockProxy;
let eventCollectionService: MockProxy;
+ let userVerificationService: MockProxy;
let sut: ContextMenuClickedHandler;
@@ -82,7 +84,8 @@ describe("ContextMenuClickedHandler", () => {
authService,
cipherService,
totpService,
- eventCollectionService
+ eventCollectionService,
+ userVerificationService
);
});
diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts
index 38e605abe70..9a14ea06da0 100644
--- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts
+++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts
@@ -1,6 +1,7 @@
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
+import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EventType } from "@bitwarden/common/enums";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
@@ -14,6 +15,7 @@ import {
AuthServiceInitOptions,
} from "../../auth/background/service-factories/auth-service.factory";
import { totpServiceFactory } from "../../auth/background/service-factories/totp-service.factory";
+import { userVerificationServiceFactory } from "../../auth/background/service-factories/user-verification-service.factory";
import LockedVaultPendingNotificationsItem from "../../background/models/lockedVaultPendingNotificationsItem";
import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory";
import { Account } from "../../models/account";
@@ -56,7 +58,8 @@ export class ContextMenuClickedHandler {
private authService: AuthService,
private cipherService: CipherService,
private totpService: TotpService,
- private eventCollectionService: EventCollectionService
+ private eventCollectionService: EventCollectionService,
+ private userVerificationService: UserVerificationService
) {}
static async mv3Create(cachedServices: CachedServices) {
@@ -109,7 +112,8 @@ export class ContextMenuClickedHandler {
await authServiceFactory(cachedServices, serviceOptions),
await cipherServiceFactory(cachedServices, serviceOptions),
await totpServiceFactory(cachedServices, serviceOptions),
- await eventCollectionServiceFactory(cachedServices, serviceOptions)
+ await eventCollectionServiceFactory(cachedServices, serviceOptions),
+ await userVerificationServiceFactory(cachedServices, serviceOptions)
);
}
@@ -204,7 +208,7 @@ export class ContextMenuClickedHandler {
return;
}
- if (cipher.reprompt !== CipherRepromptType.None) {
+ if (await this.isPasswordRepromptRequired(cipher)) {
await BrowserApi.tabSendMessageData(tab, "passwordReprompt", {
cipherId: cipher.id,
action: AUTOFILL_ID,
@@ -218,7 +222,7 @@ export class ContextMenuClickedHandler {
this.copyToClipboard({ text: cipher.login.username, tab: tab });
break;
case COPY_PASSWORD_ID:
- if (cipher.reprompt !== CipherRepromptType.None) {
+ if (await this.isPasswordRepromptRequired(cipher)) {
await BrowserApi.tabSendMessageData(tab, "passwordReprompt", {
cipherId: cipher.id,
action: COPY_PASSWORD_ID,
@@ -230,7 +234,7 @@ export class ContextMenuClickedHandler {
break;
case COPY_VERIFICATIONCODE_ID:
- if (cipher.reprompt !== CipherRepromptType.None) {
+ if (await this.isPasswordRepromptRequired(cipher)) {
await BrowserApi.tabSendMessageData(tab, "passwordReprompt", {
cipherId: cipher.id,
action: COPY_VERIFICATIONCODE_ID,
@@ -246,6 +250,13 @@ export class ContextMenuClickedHandler {
}
}
+ private async isPasswordRepromptRequired(cipher: CipherView): Promise {
+ return (
+ cipher.reprompt === CipherRepromptType.Password &&
+ (await this.userVerificationService.hasMasterPasswordAndMasterKeyHash())
+ );
+ }
+
private async getIdentifier(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) {
return new Promise((resolve, reject) => {
BrowserApi.sendTabsMessage(
diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts
index 617acc2bf78..c65a8697631 100644
--- a/apps/browser/src/background/main.background.ts
+++ b/apps/browser/src/background/main.background.ts
@@ -635,7 +635,8 @@ export default class MainBackground {
this.authService,
this.cipherService,
this.totpService,
- this.eventCollectionService
+ this.eventCollectionService,
+ this.userVerificationService
);
this.contextMenusBackground = new ContextMenusBackground(contextMenuClickedHandler);
@@ -670,8 +671,7 @@ export default class MainBackground {
this.cipherContextMenuHandler = new CipherContextMenuHandler(
this.mainContextMenuHandler,
this.authService,
- this.cipherService,
- this.userVerificationService
+ this.cipherService
);
}
}
diff --git a/apps/browser/src/vault/popup/components/vault/view.component.ts b/apps/browser/src/vault/popup/components/vault/view.component.ts
index a70a11475ca..8f45547737a 100644
--- a/apps/browser/src/vault/popup/components/vault/view.component.ts
+++ b/apps/browser/src/vault/popup/components/vault/view.component.ts
@@ -170,8 +170,8 @@ export class ViewComponent extends BaseViewComponent {
switch (this.loadAction) {
case AUTOFILL_ID:
- this.fillCipher();
- return;
+ await this.fillCipher();
+ break;
case COPY_USERNAME_ID:
await this.copy(this.cipher.login.username, "username", "Username");
break;
@@ -186,7 +186,7 @@ export class ViewComponent extends BaseViewComponent {
}
if (this.inPopout && this.loadAction) {
- this.close();
+ setTimeout(() => this.close(), 1000);
}
}
@@ -238,10 +238,6 @@ export class ViewComponent extends BaseViewComponent {
const didAutofill = await this.doAutofill();
if (didAutofill) {
this.platformUtilsService.showToast("success", null, this.i18nService.t("autoFillSuccess"));
-
- if (this.inPopout) {
- this.close();
- }
}
}
From 3340af8084514ffb1edc300a2dae8d7af95798b1 Mon Sep 17 00:00:00 2001
From: Matt Gibson
Date: Wed, 30 Aug 2023 12:57:20 -0500
Subject: [PATCH 15/27] PM-3585 Improve state migrations (#5009)
* WIP: safer state migrations
Co-authored-by: Justin Baur
* Add min version check and remove old migrations
Co-authored-by: Oscar Hinton
* Add rollback and version checking
* Add state version move migration
* Expand tests and improve typing for Migrations
* Remove StateMigration Service
* Rewrite version 5 and 6 migrations
* Add all but initial migration to supported migrations
* Handle stateVersion location in migrator update versions
* Move to unique migrations directory
* Disallow imports outside of state-migrations
* Lint and test fixes
* Do not run migrations if we cannot determine state
* Fix desktop background StateService build
* Document Migration builder class
* Add debug logging to migrations
* Comment on migrator overrides
* Use specific property names
* `npm run prettier` :robot:
* Insert new migration
* Set stateVersion when creating new globals object
* PR comments
* Fix migrate imports
* Move migration building into `migrate` function
* Export current version from migration definitions
* Move file version concerns to migrator
* Update migrate spec to reflect new version requirements
* Fix import paths
* Prefer unique state data
* Remove unnecessary async
* Prefer to not use `any`
---------
Co-authored-by: Justin Baur
Co-authored-by: Oscar Hinton
---
.../browser/cipher-context-menu-handler.ts | 3 -
.../browser/context-menu-clicked-handler.ts | 3 -
.../browser/main-context-menu-handler.ts | 3 -
.../browser/src/background/main.background.ts | 8 -
.../state-migration-service.factory.ts | 40 --
.../state-service.factory.ts | 8 +-
.../platform/listeners/on-command-listener.ts | 6 -
.../platform/listeners/on-install-listener.ts | 3 -
.../src/platform/listeners/update-badge.ts | 3 -
.../services/browser-state.service.spec.ts | 4 -
.../services/browser-state.service.ts | 3 -
.../src/popup/services/services.module.ts | 18 +-
apps/cli/src/bw.ts | 9 -
.../src/app/services/services.module.ts | 2 -
apps/desktop/src/main.ts | 1 -
apps/web/src/app/core/core.module.ts | 7 -
.../src/app/core/state-migration.service.ts | 13 -
apps/web/src/app/core/state/state.service.ts | 3 -
.../src/services/jslib-services.module.ts | 8 -
libs/common/src/enums/index.ts | 1 -
libs/common/src/enums/state-version.enum.ts | 10 -
.../abstractions/state-migration.service.ts | 4 -
.../platform/abstractions/state.service.ts | 2 -
.../platform/models/domain/global-state.ts | 3 +-
.../services/state-migration.service.spec.ts | 216 -------
.../services/state-migration.service.ts | 587 ------------------
.../src/platform/services/state.service.ts | 24 +-
.../src/state-migrations/.eslintrc.json | 24 +
libs/common/src/state-migrations/index.ts | 1 +
.../src/state-migrations/migrate.spec.ts | 67 ++
libs/common/src/state-migrations/migrate.ts | 60 ++
.../migration-builder.spec.ts | 117 ++++
.../src/state-migrations/migration-builder.ts | 106 ++++
.../state-migrations/migration-helper.spec.ts | 84 +++
.../src/state-migrations/migration-helper.ts | 37 ++
.../migrations/3-fix-premium.spec.ts | 111 ++++
.../migrations/3-fix-premium.ts | 48 ++
.../4-remove-ever-been-unlocked.spec.ts | 75 +++
.../migrations/4-remove-ever-been-unlocked.ts | 32 +
.../5-add-key-type-to-org-keys.spec.ts | 141 +++++
.../migrations/5-add-key-type-to-org-keys.ts | 67 ++
.../6-remove-legacy-etm-key.spec.ts | 80 +++
.../migrations/6-remove-legacy-etm-key.ts | 32 +
...e-biometric-auto-prompt-to-account.spec.ts | 102 +++
...7-move-biometric-auto-prompt-to-account.ts | 45 ++
.../migrations/8-move-state-version.spec.ts | 90 +++
.../migrations/8-move-state-version.ts | 37 ++
.../migrations/min-version.spec.ts | 29 +
.../migrations/min-version.ts | 26 +
.../src/state-migrations/migrator.spec.ts | 75 +++
libs/common/src/state-migrations/migrator.ts | 40 ++
51 files changed, 1538 insertions(+), 980 deletions(-)
delete mode 100644 apps/browser/src/platform/background/service-factories/state-migration-service.factory.ts
delete mode 100644 apps/web/src/app/core/state-migration.service.ts
delete mode 100644 libs/common/src/enums/state-version.enum.ts
delete mode 100644 libs/common/src/platform/abstractions/state-migration.service.ts
delete mode 100644 libs/common/src/platform/services/state-migration.service.spec.ts
delete mode 100644 libs/common/src/platform/services/state-migration.service.ts
create mode 100644 libs/common/src/state-migrations/.eslintrc.json
create mode 100644 libs/common/src/state-migrations/index.ts
create mode 100644 libs/common/src/state-migrations/migrate.spec.ts
create mode 100644 libs/common/src/state-migrations/migrate.ts
create mode 100644 libs/common/src/state-migrations/migration-builder.spec.ts
create mode 100644 libs/common/src/state-migrations/migration-builder.ts
create mode 100644 libs/common/src/state-migrations/migration-helper.spec.ts
create mode 100644 libs/common/src/state-migrations/migration-helper.ts
create mode 100644 libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts
create mode 100644 libs/common/src/state-migrations/migrations/3-fix-premium.ts
create mode 100644 libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts
create mode 100644 libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts
create mode 100644 libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts
create mode 100644 libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts
create mode 100644 libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts
create mode 100644 libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts
create mode 100644 libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts
create mode 100644 libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts
create mode 100644 libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts
create mode 100644 libs/common/src/state-migrations/migrations/8-move-state-version.ts
create mode 100644 libs/common/src/state-migrations/migrations/min-version.spec.ts
create mode 100644 libs/common/src/state-migrations/migrations/min-version.ts
create mode 100644 libs/common/src/state-migrations/migrator.spec.ts
create mode 100644 libs/common/src/state-migrations/migrator.ts
diff --git a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts
index fe6479aae51..6140db260f5 100644
--- a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts
+++ b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts
@@ -66,9 +66,6 @@ export class CipherContextMenuHandler {
clipboardWriteCallback: NOT_IMPLEMENTED,
win: self,
},
- stateMigrationServiceOptions: {
- stateFactory: stateFactory,
- },
stateServiceOptions: {
stateFactory: stateFactory,
},
diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts
index 9a14ea06da0..a6bff50a195 100644
--- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts
+++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts
@@ -88,9 +88,6 @@ export class ContextMenuClickedHandler {
clipboardWriteCallback: NOT_IMPLEMENTED,
win: self,
},
- stateMigrationServiceOptions: {
- stateFactory: stateFactory,
- },
stateServiceOptions: {
stateFactory: stateFactory,
},
diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts
index 9b16aa266db..b9af3dd191f 100644
--- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts
+++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts
@@ -79,9 +79,6 @@ export class MainContextMenuHandler {
logServiceOptions: {
isDev: false,
},
- stateMigrationServiceOptions: {
- stateFactory: stateFactory,
- },
stateServiceOptions: {
stateFactory: stateFactory,
},
diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts
index c65a8697631..31e81c198f6 100644
--- a/apps/browser/src/background/main.background.ts
+++ b/apps/browser/src/background/main.background.ts
@@ -59,7 +59,6 @@ import { EncryptServiceImplementation } from "@bitwarden/common/platform/service
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
-import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service";
import { SystemService } from "@bitwarden/common/platform/services/system.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
@@ -177,7 +176,6 @@ export default class MainBackground {
searchService: SearchServiceAbstraction;
notificationsService: NotificationsServiceAbstraction;
stateService: StateServiceAbstraction;
- stateMigrationService: StateMigrationService;
systemService: SystemServiceAbstraction;
eventCollectionService: EventCollectionServiceAbstraction;
eventUploadService: EventUploadServiceAbstraction;
@@ -262,17 +260,11 @@ export default class MainBackground {
new KeyGenerationService(this.cryptoFunctionService)
)
: new MemoryStorageService();
- this.stateMigrationService = new StateMigrationService(
- this.storageService,
- this.secureStorageService,
- new StateFactory(GlobalState, Account)
- );
this.stateService = new BrowserStateService(
this.storageService,
this.secureStorageService,
this.memoryStorageService,
this.logService,
- this.stateMigrationService,
new StateFactory(GlobalState, Account)
);
this.platformUtilsService = new BrowserPlatformUtilsService(
diff --git a/apps/browser/src/platform/background/service-factories/state-migration-service.factory.ts b/apps/browser/src/platform/background/service-factories/state-migration-service.factory.ts
deleted file mode 100644
index 8d4ee969583..00000000000
--- a/apps/browser/src/platform/background/service-factories/state-migration-service.factory.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
-import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
-import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service";
-
-import { Account } from "../../../models/account";
-
-import { CachedServices, factory, FactoryOptions } from "./factory-options";
-import {
- diskStorageServiceFactory,
- DiskStorageServiceInitOptions,
- secureStorageServiceFactory,
- SecureStorageServiceInitOptions,
-} from "./storage-service.factory";
-
-type StateMigrationServiceFactoryOptions = FactoryOptions & {
- stateMigrationServiceOptions: {
- stateFactory: StateFactory;
- };
-};
-
-export type StateMigrationServiceInitOptions = StateMigrationServiceFactoryOptions &
- DiskStorageServiceInitOptions &
- SecureStorageServiceInitOptions;
-
-export function stateMigrationServiceFactory(
- cache: { stateMigrationService?: StateMigrationService } & CachedServices,
- opts: StateMigrationServiceInitOptions
-): Promise {
- return factory(
- cache,
- "stateMigrationService",
- opts,
- async () =>
- new StateMigrationService(
- await diskStorageServiceFactory(cache, opts),
- await secureStorageServiceFactory(cache, opts),
- opts.stateMigrationServiceOptions.stateFactory
- )
- );
-}
diff --git a/apps/browser/src/platform/background/service-factories/state-service.factory.ts b/apps/browser/src/platform/background/service-factories/state-service.factory.ts
index f926d428890..7d3aaf9b6f3 100644
--- a/apps/browser/src/platform/background/service-factories/state-service.factory.ts
+++ b/apps/browser/src/platform/background/service-factories/state-service.factory.ts
@@ -6,10 +6,6 @@ import { BrowserStateService } from "../../services/browser-state.service";
import { CachedServices, factory, FactoryOptions } from "./factory-options";
import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory";
-import {
- stateMigrationServiceFactory,
- StateMigrationServiceInitOptions,
-} from "./state-migration-service.factory";
import {
diskStorageServiceFactory,
secureStorageServiceFactory,
@@ -30,8 +26,7 @@ export type StateServiceInitOptions = StateServiceFactoryOptions &
DiskStorageServiceInitOptions &
SecureStorageServiceInitOptions &
MemoryStorageServiceInitOptions &
- LogServiceInitOptions &
- StateMigrationServiceInitOptions;
+ LogServiceInitOptions;
export async function stateServiceFactory(
cache: { stateService?: BrowserStateService } & CachedServices,
@@ -47,7 +42,6 @@ export async function stateServiceFactory(
await secureStorageServiceFactory(cache, opts),
await memoryStorageServiceFactory(cache, opts),
await logServiceFactory(cache, opts),
- await stateMigrationServiceFactory(cache, opts),
opts.stateServiceOptions.stateFactory,
opts.stateServiceOptions.useAccountCache
)
diff --git a/apps/browser/src/platform/listeners/on-command-listener.ts b/apps/browser/src/platform/listeners/on-command-listener.ts
index 65af31e173c..0e2cf03828d 100644
--- a/apps/browser/src/platform/listeners/on-command-listener.ts
+++ b/apps/browser/src/platform/listeners/on-command-listener.ts
@@ -47,9 +47,6 @@ const doAutoFillLogin = async (tab: chrome.tabs.Tab): Promise => {
stateServiceOptions: {
stateFactory: new StateFactory(GlobalState, Account),
},
- stateMigrationServiceOptions: {
- stateFactory: new StateFactory(GlobalState, Account),
- },
apiServiceOptions: {
logoutCallback: () => Promise.resolve(),
},
@@ -94,9 +91,6 @@ const doGeneratePasswordToClipboard = async (tab: chrome.tabs.Tab): Promise Promise.resolve(),
win: self,
},
- stateMigrationServiceOptions: {
- stateFactory: stateFactory,
- },
stateServiceOptions: {
stateFactory: stateFactory,
},
diff --git a/apps/browser/src/platform/listeners/on-install-listener.ts b/apps/browser/src/platform/listeners/on-install-listener.ts
index 480e811fd26..0394941e283 100644
--- a/apps/browser/src/platform/listeners/on-install-listener.ts
+++ b/apps/browser/src/platform/listeners/on-install-listener.ts
@@ -23,9 +23,6 @@ export async function onInstallListener(details: chrome.runtime.InstalledDetails
stateServiceOptions: {
stateFactory: new StateFactory(GlobalState, Account),
},
- stateMigrationServiceOptions: {
- stateFactory: new StateFactory(GlobalState, Account),
- },
};
const environmentService = await environmentServiceFactory(cache, opts);
diff --git a/apps/browser/src/platform/listeners/update-badge.ts b/apps/browser/src/platform/listeners/update-badge.ts
index 89b620ad6fe..1b692eb9b97 100644
--- a/apps/browser/src/platform/listeners/update-badge.ts
+++ b/apps/browser/src/platform/listeners/update-badge.ts
@@ -272,9 +272,6 @@ export class UpdateBadge {
stateServiceOptions: {
stateFactory: new StateFactory(GlobalState, Account),
},
- stateMigrationServiceOptions: {
- stateFactory: new StateFactory(GlobalState, Account),
- },
apiServiceOptions: {
logoutCallback: () => Promise.reject("not implemented"),
},
diff --git a/apps/browser/src/platform/services/browser-state.service.spec.ts b/apps/browser/src/platform/services/browser-state.service.spec.ts
index d6bb83f7fb5..0712416172c 100644
--- a/apps/browser/src/platform/services/browser-state.service.spec.ts
+++ b/apps/browser/src/platform/services/browser-state.service.spec.ts
@@ -8,7 +8,6 @@ import {
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { State } from "@bitwarden/common/platform/models/domain/state";
-import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
@@ -26,7 +25,6 @@ describe("Browser State Service", () => {
let secureStorageService: MockProxy;
let diskStorageService: MockProxy;
let logService: MockProxy;
- let stateMigrationService: MockProxy;
let stateFactory: MockProxy>;
let useAccountCache: boolean;
@@ -39,7 +37,6 @@ describe("Browser State Service", () => {
secureStorageService = mock();
diskStorageService = mock();
logService = mock();
- stateMigrationService = mock();
stateFactory = mock();
// turn off account cache for tests
useAccountCache = false;
@@ -64,7 +61,6 @@ describe("Browser State Service", () => {
secureStorageService,
memoryStorageService,
logService,
- stateMigrationService,
stateFactory,
useAccountCache
);
diff --git a/apps/browser/src/platform/services/browser-state.service.ts b/apps/browser/src/platform/services/browser-state.service.ts
index 34fa1a1d0f3..5e356e7fbe8 100644
--- a/apps/browser/src/platform/services/browser-state.service.ts
+++ b/apps/browser/src/platform/services/browser-state.service.ts
@@ -1,7 +1,6 @@
import { BehaviorSubject } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
-import { StateMigrationService } from "@bitwarden/common/platform/abstractions/state-migration.service";
import {
AbstractStorageService,
AbstractMemoryStorageService,
@@ -41,7 +40,6 @@ export class BrowserStateService
secureStorageService: AbstractStorageService,
memoryStorageService: AbstractMemoryStorageService,
logService: LogService,
- stateMigrationService: StateMigrationService,
stateFactory: StateFactory,
useAccountCache = true
) {
@@ -50,7 +48,6 @@ export class BrowserStateService
secureStorageService,
memoryStorageService,
logService,
- stateMigrationService,
stateFactory,
useAccountCache
);
diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts
index 191e2c78060..261f6abe37d 100644
--- a/apps/browser/src/popup/services/services.module.ts
+++ b/apps/browser/src/popup/services/services.module.ts
@@ -47,7 +47,6 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
-import { StateMigrationService } from "@bitwarden/common/platform/abstractions/state-migration.service";
import {
StateService as BaseStateServiceAbstraction,
StateService,
@@ -442,36 +441,23 @@ function getBgService(service: keyof MainBackground) {
provide: MEMORY_STORAGE,
useFactory: getBgService("memoryStorageService"),
},
- {
- provide: StateMigrationService,
- useFactory: getBgService("stateMigrationService"),
- deps: [],
- },
{
provide: StateServiceAbstraction,
useFactory: (
storageService: AbstractStorageService,
secureStorageService: AbstractStorageService,
memoryStorageService: AbstractMemoryStorageService,
- logService: LogServiceAbstraction,
- stateMigrationService: StateMigrationService
+ logService: LogServiceAbstraction
) => {
return new BrowserStateService(
storageService,
secureStorageService,
memoryStorageService,
logService,
- stateMigrationService,
new StateFactory(GlobalState, Account)
);
},
- deps: [
- AbstractStorageService,
- SECURE_STORAGE,
- MEMORY_STORAGE,
- LogServiceAbstraction,
- StateMigrationService,
- ],
+ deps: [AbstractStorageService, SECURE_STORAGE, MEMORY_STORAGE, LogServiceAbstraction],
},
{
provide: UsernameGenerationServiceAbstraction,
diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts
index 1bcaa1a2acf..42ba158ee72 100644
--- a/apps/cli/src/bw.ts
+++ b/apps/cli/src/bw.ts
@@ -37,7 +37,6 @@ import { EnvironmentService } from "@bitwarden/common/platform/services/environm
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service";
-import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service";
import { StateService } from "@bitwarden/common/platform/services/state.service";
import { AuditService } from "@bitwarden/common/services/audit.service";
import { OrganizationUserServiceImplementation } from "@bitwarden/common/services/organization-user/organization-user.service.implementation";
@@ -136,7 +135,6 @@ export class Main {
keyConnectorService: KeyConnectorService;
userVerificationService: UserVerificationService;
stateService: StateService;
- stateMigrationService: StateMigrationService;
organizationService: OrganizationService;
providerService: ProviderService;
twoFactorService: TwoFactorService;
@@ -188,18 +186,11 @@ export class Main {
this.memoryStorageService = new MemoryStorageService();
- this.stateMigrationService = new StateMigrationService(
- this.storageService,
- this.secureStorageService,
- new StateFactory(GlobalState, Account)
- );
-
this.stateService = new StateService(
this.storageService,
this.secureStorageService,
this.memoryStorageService,
this.logService,
- this.stateMigrationService,
new StateFactory(GlobalState, Account)
);
diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts
index ded0366dc16..42208077c33 100644
--- a/apps/desktop/src/app/services/services.module.ts
+++ b/apps/desktop/src/app/services/services.module.ts
@@ -28,7 +28,6 @@ import {
} from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
-import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/platform/abstractions/state-migration.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service";
@@ -134,7 +133,6 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
SECURE_STORAGE,
MEMORY_STORAGE,
LogService,
- StateMigrationServiceAbstraction,
STATE_FACTORY,
STATE_SERVICE_USE_CACHE,
],
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index 9f15d0d24d9..5107d31b1c5 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -90,7 +90,6 @@ export class Main {
null,
this.memoryStorageService,
this.logService,
- null,
new StateFactory(GlobalState, Account),
false // Do not use disk caching because this will get out of sync with the renderer service
);
diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts
index 03f20ad2955..b2e44d7e3db 100644
--- a/apps/web/src/app/core/core.module.ts
+++ b/apps/web/src/app/core/core.module.ts
@@ -17,7 +17,6 @@ import { FileDownloadService } from "@bitwarden/common/platform/abstractions/fil
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
-import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/platform/abstractions/state-migration.service";
import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
@@ -27,7 +26,6 @@ import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from "@
import { PolicyListService } from "../admin-console/core/policy-list.service";
import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service";
-import { StateMigrationService } from "../core/state-migration.service";
import { CollectionAdminService } from "../vault/core/collection-admin.service";
import { PasswordRepromptService } from "../vault/core/password-reprompt.service";
@@ -84,11 +82,6 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service";
},
{ provide: MessagingServiceAbstraction, useClass: BroadcasterMessagingService },
{ provide: ModalServiceAbstraction, useClass: ModalService },
- {
- provide: StateMigrationServiceAbstraction,
- useClass: StateMigrationService,
- deps: [AbstractStorageService, SECURE_STORAGE, STATE_FACTORY],
- },
StateService,
{
provide: BaseStateServiceAbstraction,
diff --git a/apps/web/src/app/core/state-migration.service.ts b/apps/web/src/app/core/state-migration.service.ts
deleted file mode 100644
index c1d6e2ded5d..00000000000
--- a/apps/web/src/app/core/state-migration.service.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { StateMigrationService as BaseStateMigrationService } from "@bitwarden/common/platform/services/state-migration.service";
-
-import { Account } from "./state/account";
-import { GlobalState } from "./state/global-state";
-
-export class StateMigrationService extends BaseStateMigrationService {
- protected async migrationStateFrom1To2(): Promise {
- await super.migrateStateFrom1To2();
- const globals = (await this.get("global")) ?? this.stateFactory.createGlobal(null);
- globals.rememberEmail = (await this.get("rememberEmail")) ?? globals.rememberEmail;
- await this.set("global", globals);
- }
-}
diff --git a/apps/web/src/app/core/state/state.service.ts b/apps/web/src/app/core/state/state.service.ts
index 60f09ceae36..c95077bfbcc 100644
--- a/apps/web/src/app/core/state/state.service.ts
+++ b/apps/web/src/app/core/state/state.service.ts
@@ -7,7 +7,6 @@ import {
STATE_SERVICE_USE_CACHE,
} from "@bitwarden/angular/services/injection-tokens";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
-import { StateMigrationService } from "@bitwarden/common/platform/abstractions/state-migration.service";
import {
AbstractMemoryStorageService,
AbstractStorageService,
@@ -30,7 +29,6 @@ export class StateService extends BaseStateService {
@Inject(SECURE_STORAGE) secureStorageService: AbstractStorageService,
@Inject(MEMORY_STORAGE) memoryStorageService: AbstractMemoryStorageService,
logService: LogService,
- stateMigrationService: StateMigrationService,
@Inject(STATE_FACTORY) stateFactory: StateFactory,
@Inject(STATE_SERVICE_USE_CACHE) useAccountCache = true
) {
@@ -39,7 +37,6 @@ export class StateService extends BaseStateService {
secureStorageService,
memoryStorageService,
logService,
- stateMigrationService,
stateFactory,
useAccountCache
);
diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts
index 14b26ca43da..df64c25c914 100644
--- a/libs/angular/src/services/jslib-services.module.ts
+++ b/libs/angular/src/services/jslib-services.module.ts
@@ -77,7 +77,6 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
-import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/platform/abstractions/state-migration.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -94,7 +93,6 @@ import { EncryptServiceImplementation } from "@bitwarden/common/platform/service
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
-import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service";
import { StateService } from "@bitwarden/common/platform/services/state.service";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
@@ -480,16 +478,10 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
SECURE_STORAGE,
MEMORY_STORAGE,
LogService,
- StateMigrationServiceAbstraction,
STATE_FACTORY,
STATE_SERVICE_USE_CACHE,
],
},
- {
- provide: StateMigrationServiceAbstraction,
- useClass: StateMigrationService,
- deps: [AbstractStorageService, SECURE_STORAGE, STATE_FACTORY],
- },
{
provide: VaultExportServiceAbstraction,
useClass: VaultExportService,
diff --git a/libs/common/src/enums/index.ts b/libs/common/src/enums/index.ts
index 87a688b856e..b62b3ecfa81 100644
--- a/libs/common/src/enums/index.ts
+++ b/libs/common/src/enums/index.ts
@@ -18,7 +18,6 @@ export * from "./notification-type.enum";
export * from "./product-type.enum";
export * from "./provider-type.enum";
export * from "./secure-note-type.enum";
-export * from "./state-version.enum";
export * from "./storage-location.enum";
export * from "./theme-type.enum";
export * from "./uri-match-type.enum";
diff --git a/libs/common/src/enums/state-version.enum.ts b/libs/common/src/enums/state-version.enum.ts
deleted file mode 100644
index 927ce3a1105..00000000000
--- a/libs/common/src/enums/state-version.enum.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export enum StateVersion {
- One = 1, // Original flat key/value pair store
- Two = 2, // Move to a typed State object
- Three = 3, // Fix migration of users' premium status
- Four = 4, // Fix 'Never Lock' option by removing stale data
- Five = 5, // Migrate to new storage of encrypted organization keys
- Six = 6, // Delete account.keys.legacyEtmKey property
- Seven = 7, // Remove global desktop auto prompt setting, move to account
- Latest = Seven,
-}
diff --git a/libs/common/src/platform/abstractions/state-migration.service.ts b/libs/common/src/platform/abstractions/state-migration.service.ts
deleted file mode 100644
index f16777a159f..00000000000
--- a/libs/common/src/platform/abstractions/state-migration.service.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export abstract class StateMigrationService {
- needsMigration: () => Promise;
- migrate: () => Promise;
-}
diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts
index 4a2b515b74a..82813718de3 100644
--- a/libs/common/src/platform/abstractions/state.service.ts
+++ b/libs/common/src/platform/abstractions/state.service.ts
@@ -495,8 +495,6 @@ export abstract class StateService {
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise;
getApproveLoginRequests: (options?: StorageOptions) => Promise;
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise;
- getStateVersion: () => Promise;
- setStateVersion: (value: number) => Promise;
getWindow: () => Promise;
setWindow: (value: WindowState) => Promise;
/**
diff --git a/libs/common/src/platform/models/domain/global-state.ts b/libs/common/src/platform/models/domain/global-state.ts
index dfe3c6c417f..30ad32124cf 100644
--- a/libs/common/src/platform/models/domain/global-state.ts
+++ b/libs/common/src/platform/models/domain/global-state.ts
@@ -1,5 +1,5 @@
import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls";
-import { StateVersion, ThemeType } from "../../../enums";
+import { ThemeType } from "../../../enums";
import { WindowState } from "../../../models/domain/window-state";
export class GlobalState {
@@ -25,7 +25,6 @@ export class GlobalState {
enableBiometrics?: boolean;
biometricText?: string;
noAutoPromptBiometricsText?: string;
- stateVersion: StateVersion = StateVersion.One;
environmentUrls: EnvironmentUrls = new EnvironmentUrls();
enableTray?: boolean;
enableMinimizeToTray?: boolean;
diff --git a/libs/common/src/platform/services/state-migration.service.spec.ts b/libs/common/src/platform/services/state-migration.service.spec.ts
deleted file mode 100644
index 7bbd19106d5..00000000000
--- a/libs/common/src/platform/services/state-migration.service.spec.ts
+++ /dev/null
@@ -1,216 +0,0 @@
-// eslint-disable-next-line no-restricted-imports
-import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
-import { MockProxy, any, mock } from "jest-mock-extended";
-
-import { StateVersion } from "../../enums";
-import { AbstractStorageService } from "../abstractions/storage.service";
-import { StateFactory } from "../factories/state-factory";
-import { Account } from "../models/domain/account";
-import { GlobalState } from "../models/domain/global-state";
-
-import { StateMigrationService } from "./state-migration.service";
-
-const userId = "USER_ID";
-
-// Note: each test calls the private migration method for that migration,
-// so that we don't accidentally run all following migrations as well
-
-describe("State Migration Service", () => {
- let storageService: MockProxy;
- let secureStorageService: SubstituteOf;
- let stateFactory: SubstituteOf;
-
- let stateMigrationService: StateMigrationService;
-
- beforeEach(() => {
- storageService = mock();
- secureStorageService = Substitute.for();
- stateFactory = Substitute.for();
-
- stateMigrationService = new StateMigrationService(
- storageService,
- secureStorageService,
- stateFactory
- );
- });
-
- afterEach(() => {
- jest.resetAllMocks();
- });
-
- describe("StateVersion 3 to 4 migration", () => {
- beforeEach(() => {
- const globalVersion3: Partial = {
- stateVersion: StateVersion.Three,
- };
-
- storageService.get.calledWith("global", any()).mockResolvedValue(globalVersion3);
- storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]);
- });
-
- it("clears everBeenUnlocked", async () => {
- const accountVersion3: Account = {
- profile: {
- apiKeyClientId: null,
- convertAccountToKeyConnector: null,
- email: "EMAIL",
- emailVerified: true,
- everBeenUnlocked: true,
- hasPremiumPersonally: false,
- kdfIterations: 100000,
- kdfType: 0,
- keyHash: "KEY_HASH",
- lastSync: "LAST_SYNC",
- userId: userId,
- usesKeyConnector: false,
- forcePasswordResetReason: null,
- },
- };
-
- const expectedAccountVersion4: Account = {
- profile: {
- ...accountVersion3.profile,
- },
- };
- delete expectedAccountVersion4.profile.everBeenUnlocked;
-
- storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion3);
-
- await (stateMigrationService as any).migrateStateFrom3To4();
-
- expect(storageService.save).toHaveBeenCalledTimes(2);
- expect(storageService.save).toHaveBeenCalledWith(userId, expectedAccountVersion4, any());
- });
-
- it("updates StateVersion number", async () => {
- await (stateMigrationService as any).migrateStateFrom3To4();
-
- expect(storageService.save).toHaveBeenCalledWith(
- "global",
- { stateVersion: StateVersion.Four },
- any()
- );
- expect(storageService.save).toHaveBeenCalledTimes(1);
- });
- });
-
- describe("StateVersion 4 to 5 migration", () => {
- it("migrates organization keys to new format", async () => {
- const accountVersion4 = new Account({
- keys: {
- organizationKeys: {
- encrypted: {
- orgOneId: "orgOneEncKey",
- orgTwoId: "orgTwoEncKey",
- orgThreeId: "orgThreeEncKey",
- },
- },
- },
- } as any);
-
- const expectedAccount = new Account({
- keys: {
- organizationKeys: {
- encrypted: {
- orgOneId: {
- type: "organization",
- key: "orgOneEncKey",
- },
- orgTwoId: {
- type: "organization",
- key: "orgTwoEncKey",
- },
- orgThreeId: {
- type: "organization",
- key: "orgThreeEncKey",
- },
- },
- } as any,
- } as any,
- });
-
- const migratedAccount = await (stateMigrationService as any).migrateAccountFrom4To5(
- accountVersion4
- );
-
- expect(migratedAccount).toEqual(expectedAccount);
- });
- });
-
- describe("StateVersion 5 to 6 migration", () => {
- it("deletes account.keys.legacyEtmKey value", async () => {
- const accountVersion5 = new Account({
- keys: {
- legacyEtmKey: "legacy key",
- },
- } as any);
-
- const migratedAccount = await (stateMigrationService as any).migrateAccountFrom5To6(
- accountVersion5
- );
-
- expect(migratedAccount.keys.legacyEtmKey).toBeUndefined();
- });
- });
-
- describe("StateVersion 6 to 7 migration", () => {
- it("should delete global.noAutoPromptBiometrics value", async () => {
- storageService.get
- .calledWith("global", any())
- .mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true });
- storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([]);
-
- await stateMigrationService.migrate();
-
- expect(storageService.save).toHaveBeenCalledWith(
- "global",
- {
- stateVersion: StateVersion.Seven,
- },
- any()
- );
- });
-
- it("should call migrateStateFrom6To7 on each account", async () => {
- const accountVersion6 = new Account({
- otherStuff: "other stuff",
- } as any);
-
- storageService.get
- .calledWith("global", any())
- .mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true });
- storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]);
- storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion6);
-
- const migrateSpy = jest.fn();
- (stateMigrationService as any).migrateAccountFrom6To7 = migrateSpy;
-
- await stateMigrationService.migrate();
-
- expect(migrateSpy).toHaveBeenCalledWith(true, accountVersion6);
- });
-
- it("should update account.settings.disableAutoBiometricsPrompt value if global is no prompt", async () => {
- const result = await (stateMigrationService as any).migrateAccountFrom6To7(true, {
- otherStuff: "other stuff",
- });
-
- expect(result).toEqual({
- otherStuff: "other stuff",
- settings: {
- disableAutoBiometricsPrompt: true,
- },
- });
- });
-
- it("should not update account.settings.disableAutoBiometricsPrompt value if global auto prompt is enabled", async () => {
- const result = await (stateMigrationService as any).migrateAccountFrom6To7(false, {
- otherStuff: "other stuff",
- });
-
- expect(result).toEqual({
- otherStuff: "other stuff",
- });
- });
- });
-});
diff --git a/libs/common/src/platform/services/state-migration.service.ts b/libs/common/src/platform/services/state-migration.service.ts
deleted file mode 100644
index 234d1b2bff8..00000000000
--- a/libs/common/src/platform/services/state-migration.service.ts
+++ /dev/null
@@ -1,587 +0,0 @@
-import { OrganizationData } from "../../admin-console/models/data/organization.data";
-import { PolicyData } from "../../admin-console/models/data/policy.data";
-import { ProviderData } from "../../admin-console/models/data/provider.data";
-import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
-import { TokenService } from "../../auth/services/token.service";
-import { StateVersion, ThemeType, KdfType, HtmlStorageLocation } from "../../enums";
-import { EventData } from "../../models/data/event.data";
-import { GeneratedPasswordHistory } from "../../tools/generator/password";
-import { SendData } from "../../tools/send/models/data/send.data";
-import { CipherData } from "../../vault/models/data/cipher.data";
-import { CollectionData } from "../../vault/models/data/collection.data";
-import { FolderData } from "../../vault/models/data/folder.data";
-import { AbstractStorageService } from "../abstractions/storage.service";
-import { StateFactory } from "../factories/state-factory";
-import {
- Account,
- AccountSettings,
- EncryptionPair,
- AccountSettingsSettings,
-} from "../models/domain/account";
-import { EncString } from "../models/domain/enc-string";
-import { GlobalState } from "../models/domain/global-state";
-import { StorageOptions } from "../models/domain/storage-options";
-
-// Originally (before January 2022) storage was handled as a flat key/value pair store.
-// With the move to a typed object for state storage these keys should no longer be in use anywhere outside of this migration.
-const v1Keys: { [key: string]: string } = {
- accessToken: "accessToken",
- alwaysShowDock: "alwaysShowDock",
- autoConfirmFingerprints: "autoConfirmFingerprints",
- autoFillOnPageLoadDefault: "autoFillOnPageLoadDefault",
- biometricAwaitingAcceptance: "biometricAwaitingAcceptance",
- biometricFingerprintValidated: "biometricFingerprintValidated",
- biometricText: "biometricText",
- biometricUnlock: "biometric",
- clearClipboard: "clearClipboardKey",
- clientId: "apikey_clientId",
- clientSecret: "apikey_clientSecret",
- collapsedGroupings: "collapsedGroupings",
- convertAccountToKeyConnector: "convertAccountToKeyConnector",
- defaultUriMatch: "defaultUriMatch",
- disableAddLoginNotification: "disableAddLoginNotification",
- disableAutoBiometricsPrompt: "noAutoPromptBiometrics",
- disableAutoTotpCopy: "disableAutoTotpCopy",
- disableBadgeCounter: "disableBadgeCounter",
- disableChangedPasswordNotification: "disableChangedPasswordNotification",
- disableContextMenuItem: "disableContextMenuItem",
- disableFavicon: "disableFavicon",
- disableGa: "disableGa",
- dontShowCardsCurrentTab: "dontShowCardsCurrentTab",
- dontShowIdentitiesCurrentTab: "dontShowIdentitiesCurrentTab",
- emailVerified: "emailVerified",
- enableAlwaysOnTop: "enableAlwaysOnTopKey",
- enableAutoFillOnPageLoad: "enableAutoFillOnPageLoad",
- enableBiometric: "enabledBiometric",
- enableBrowserIntegration: "enableBrowserIntegration",
- enableBrowserIntegrationFingerprint: "enableBrowserIntegrationFingerprint",
- enableCloseToTray: "enableCloseToTray",
- enableFullWidth: "enableFullWidth",
- enableMinimizeToTray: "enableMinimizeToTray",
- enableStartToTray: "enableStartToTrayKey",
- enableTray: "enableTray",
- encKey: "encKey", // Generated Symmetric Key
- encOrgKeys: "encOrgKeys",
- encPrivate: "encPrivateKey",
- encProviderKeys: "encProviderKeys",
- entityId: "entityId",
- entityType: "entityType",
- environmentUrls: "environmentUrls",
- equivalentDomains: "equivalentDomains",
- eventCollection: "eventCollection",
- forcePasswordReset: "forcePasswordReset",
- history: "generatedPasswordHistory",
- installedVersion: "installedVersion",
- kdf: "kdf",
- kdfIterations: "kdfIterations",
- key: "key", // Master Key
- keyHash: "keyHash",
- lastActive: "lastActive",
- localData: "sitesLocalData",
- locale: "locale",
- mainWindowSize: "mainWindowSize",
- minimizeOnCopyToClipboard: "minimizeOnCopyToClipboardKey",
- neverDomains: "neverDomains",
- noAutoPromptBiometricsText: "noAutoPromptBiometricsText",
- openAtLogin: "openAtLogin",
- passwordGenerationOptions: "passwordGenerationOptions",
- pinProtected: "pinProtectedKey",
- protectedPin: "protectedPin",
- refreshToken: "refreshToken",
- ssoCodeVerifier: "ssoCodeVerifier",
- ssoIdentifier: "ssoOrgIdentifier",
- ssoState: "ssoState",
- stamp: "securityStamp",
- theme: "theme",
- userEmail: "userEmail",
- userId: "userId",
- usesConnector: "usesKeyConnector",
- vaultTimeoutAction: "vaultTimeoutAction",
- vaultTimeout: "lockOption",
- rememberedEmail: "rememberedEmail",
-};
-
-const v1KeyPrefixes: { [key: string]: string } = {
- ciphers: "ciphers_",
- collections: "collections_",
- folders: "folders_",
- lastSync: "lastSync_",
- policies: "policies_",
- twoFactorToken: "twoFactorToken_",
- organizations: "organizations_",
- providers: "providers_",
- sends: "sends_",
- settings: "settings_",
-};
-
-const keys = {
- global: "global",
- authenticatedAccounts: "authenticatedAccounts",
- activeUserId: "activeUserId",
- tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication
- accountActivity: "accountActivity",
-};
-
-const partialKeys = {
- autoKey: "_masterkey_auto",
- biometricKey: "_masterkey_biometric",
- masterKey: "_masterkey",
-};
-
-export class StateMigrationService<
- TGlobalState extends GlobalState = GlobalState,
- TAccount extends Account = Account
-> {
- constructor(
- protected storageService: AbstractStorageService,
- protected secureStorageService: AbstractStorageService,
- protected stateFactory: StateFactory
- ) {}
-
- async needsMigration(): Promise {
- const currentStateVersion = await this.getCurrentStateVersion();
- return currentStateVersion == null || currentStateVersion < StateVersion.Latest;
- }
-
- async migrate(): Promise {
- let currentStateVersion = await this.getCurrentStateVersion();
- while (currentStateVersion < StateVersion.Latest) {
- switch (currentStateVersion) {
- case StateVersion.One:
- await this.migrateStateFrom1To2();
- break;
- case StateVersion.Two:
- await this.migrateStateFrom2To3();
- break;
- case StateVersion.Three:
- await this.migrateStateFrom3To4();
- break;
- case StateVersion.Four: {
- const authenticatedAccounts = await this.getAuthenticatedAccounts();
- for (const account of authenticatedAccounts) {
- const migratedAccount = await this.migrateAccountFrom4To5(account);
- await this.set(account.profile.userId, migratedAccount);
- }
- await this.setCurrentStateVersion(StateVersion.Five);
- break;
- }
- case StateVersion.Five: {
- const authenticatedAccounts = await this.getAuthenticatedAccounts();
- for (const account of authenticatedAccounts) {
- const migratedAccount = await this.migrateAccountFrom5To6(account);
- await this.set(account.profile.userId, migratedAccount);
- }
- await this.setCurrentStateVersion(StateVersion.Six);
- break;
- }
- case StateVersion.Six: {
- const authenticatedAccounts = await this.getAuthenticatedAccounts();
- const globals = (await this.getGlobals()) as any;
- for (const account of authenticatedAccounts) {
- const migratedAccount = await this.migrateAccountFrom6To7(
- globals?.noAutoPromptBiometrics,
- account
- );
- await this.set(account.profile.userId, migratedAccount);
- }
- if (globals) {
- delete globals.noAutoPromptBiometrics;
- }
- await this.set(keys.global, globals);
- await this.setCurrentStateVersion(StateVersion.Seven);
- }
- }
-
- currentStateVersion += 1;
- }
- }
-
- protected async migrateStateFrom1To2(): Promise {
- const clearV1Keys = async (clearingUserId?: string) => {
- for (const key in v1Keys) {
- if (key == null) {
- continue;
- }
- await this.set(v1Keys[key], null);
- }
- if (clearingUserId != null) {
- for (const keyPrefix in v1KeyPrefixes) {
- if (keyPrefix == null) {
- continue;
- }
- await this.set(v1KeyPrefixes[keyPrefix] + userId, null);
- }
- }
- };
-
- // Some processes, like biometrics, may have already defined a value before migrations are run.
- // We don't want to null out those values if they don't exist in the old storage scheme (like for new installs)
- // So, the OOO for migration is that we:
- // 1. Check for an existing storage value from the old storage structure OR
- // 2. Check for a value already set by processes that run before migration OR
- // 3. Assign the default value
- const globals: any =
- (await this.get(keys.global)) ?? this.stateFactory.createGlobal(null);
- globals.stateVersion = StateVersion.Two;
- globals.environmentUrls =
- (await this.get(v1Keys.environmentUrls)) ?? globals.environmentUrls;
- globals.locale = (await this.get(v1Keys.locale)) ?? globals.locale;
- globals.noAutoPromptBiometrics =
- (await this.get(v1Keys.disableAutoBiometricsPrompt)) ??
- globals.noAutoPromptBiometrics;
- globals.noAutoPromptBiometricsText =
- (await this.get(v1Keys.noAutoPromptBiometricsText)) ??
- globals.noAutoPromptBiometricsText;
- globals.ssoCodeVerifier =
- (await this.get(v1Keys.ssoCodeVerifier)) ?? globals.ssoCodeVerifier;
- globals.ssoOrganizationIdentifier =
- (await this.get(v1Keys.ssoIdentifier)) ?? globals.ssoOrganizationIdentifier;
- globals.ssoState = (await this.get(v1Keys.ssoState)) ?? globals.ssoState;
- globals.rememberedEmail =
- (await this.get(v1Keys.rememberedEmail)) ?? globals.rememberedEmail;
- globals.theme = (await this.get(v1Keys.theme)) ?? globals.theme;
- globals.vaultTimeout = (await this.get(v1Keys.vaultTimeout)) ?? globals.vaultTimeout;
- globals.vaultTimeoutAction =
- (await this.get(v1Keys.vaultTimeoutAction)) ?? globals.vaultTimeoutAction;
- globals.window = (await this.get(v1Keys.mainWindowSize)) ?? globals.window;
- globals.enableTray = (await this.get(v1Keys.enableTray)) ?? globals.enableTray;
- globals.enableMinimizeToTray =
- (await this.get(v1Keys.enableMinimizeToTray)) ?? globals.enableMinimizeToTray;
- globals.enableCloseToTray =
- (await this.get(v1Keys.enableCloseToTray)) ?? globals.enableCloseToTray;
- globals.enableStartToTray =
- (await this.get(v1Keys.enableStartToTray)) ?? globals.enableStartToTray;
- globals.openAtLogin = (await this.get(v1Keys.openAtLogin)) ?? globals.openAtLogin;
- globals.alwaysShowDock =
- (await this.get(v1Keys.alwaysShowDock)) ?? globals.alwaysShowDock;
- globals.enableBrowserIntegration =
- (await this.get(v1Keys.enableBrowserIntegration)) ??
- globals.enableBrowserIntegration;
- globals.enableBrowserIntegrationFingerprint =
- (await this.get(v1Keys.enableBrowserIntegrationFingerprint)) ??
- globals.enableBrowserIntegrationFingerprint;
-
- const userId =
- (await this.get(v1Keys.userId)) ?? (await this.get(v1Keys.entityId));
-
- const defaultAccount = this.stateFactory.createAccount(null);
- const accountSettings: AccountSettings = {
- autoConfirmFingerPrints:
- (await this.get(v1Keys.autoConfirmFingerprints)) ??
- defaultAccount.settings.autoConfirmFingerPrints,
- autoFillOnPageLoadDefault:
- (await this.get(v1Keys.autoFillOnPageLoadDefault)) ??
- defaultAccount.settings.autoFillOnPageLoadDefault,
- biometricUnlock:
- (await this.get(v1Keys.biometricUnlock)) ??
- defaultAccount.settings.biometricUnlock,
- clearClipboard:
- (await this.get(v1Keys.clearClipboard)) ?? defaultAccount.settings.clearClipboard,
- defaultUriMatch:
- (await this.get(v1Keys.defaultUriMatch)) ?? defaultAccount.settings.defaultUriMatch,
- disableAddLoginNotification:
- (await this.get(v1Keys.disableAddLoginNotification)) ??
- defaultAccount.settings.disableAddLoginNotification,
- disableAutoBiometricsPrompt:
- (await this.get(v1Keys.disableAutoBiometricsPrompt)) ??
- defaultAccount.settings.disableAutoBiometricsPrompt,
- disableAutoTotpCopy:
- (await this.get(v1Keys.disableAutoTotpCopy)) ??
- defaultAccount.settings.disableAutoTotpCopy,
- disableBadgeCounter:
- (await this.get(v1Keys.disableBadgeCounter)) ??
- defaultAccount.settings.disableBadgeCounter,
- disableChangedPasswordNotification:
- (await this.get(v1Keys.disableChangedPasswordNotification)) ??
- defaultAccount.settings.disableChangedPasswordNotification,
- disableContextMenuItem:
- (await this.get(v1Keys.disableContextMenuItem)) ??
- defaultAccount.settings.disableContextMenuItem,
- disableGa: (await this.get(v1Keys.disableGa)) ?? defaultAccount.settings.disableGa,
- dontShowCardsCurrentTab:
- (await this.get(v1Keys.dontShowCardsCurrentTab)) ??
- defaultAccount.settings.dontShowCardsCurrentTab,
- dontShowIdentitiesCurrentTab:
- (await this.get(v1Keys.dontShowIdentitiesCurrentTab)) ??
- defaultAccount.settings.dontShowIdentitiesCurrentTab,
- enableAlwaysOnTop:
- (await this.get(v1Keys.enableAlwaysOnTop)) ??
- defaultAccount.settings.enableAlwaysOnTop,
- enableAutoFillOnPageLoad:
- (await this.get(v1Keys.enableAutoFillOnPageLoad)) ??
- defaultAccount.settings.enableAutoFillOnPageLoad,
- enableBiometric:
- (await this.get(v1Keys.enableBiometric)) ??
- defaultAccount.settings.enableBiometric,
- enableFullWidth:
- (await this.get(v1Keys.enableFullWidth)) ??
- defaultAccount.settings.enableFullWidth,
- environmentUrls: globals.environmentUrls ?? defaultAccount.settings.environmentUrls,
- equivalentDomains:
- (await this.get(v1Keys.equivalentDomains)) ??
- defaultAccount.settings.equivalentDomains,
- minimizeOnCopyToClipboard:
- (await this.get(v1Keys.minimizeOnCopyToClipboard)) ??
- defaultAccount.settings.minimizeOnCopyToClipboard,
- neverDomains:
- (await this.get(v1Keys.neverDomains)) ?? defaultAccount.settings.neverDomains,
- passwordGenerationOptions:
- (await this.get(v1Keys.passwordGenerationOptions)) ??
- defaultAccount.settings.passwordGenerationOptions,
- pinProtected: Object.assign(new EncryptionPair(), {
- decrypted: null,
- encrypted: await this.get(v1Keys.pinProtected),
- }),
- protectedPin: await this.get(v1Keys.protectedPin),
- settings:
- userId == null
- ? null
- : await this.get(v1KeyPrefixes.settings + userId),
- vaultTimeout:
- (await this.get(v1Keys.vaultTimeout)) ?? defaultAccount.settings.vaultTimeout,
- vaultTimeoutAction:
- (await this.get(v1Keys.vaultTimeoutAction)) ??
- defaultAccount.settings.vaultTimeoutAction,
- };
-
- // (userId == null) = no logged in user (so no known userId) and we need to temporarily store account specific settings in state to migrate on first auth
- // (userId != null) = we have a currently authed user (so known userId) with encrypted data and other key settings we can move, no need to temporarily store account settings
- if (userId == null) {
- await this.set(keys.tempAccountSettings, accountSettings);
- await this.set(keys.global, globals);
- await this.set(keys.authenticatedAccounts, []);
- await this.set(keys.activeUserId, null);
- await clearV1Keys();
- return;
- }
-
- globals.twoFactorToken = await this.get(v1KeyPrefixes.twoFactorToken + userId);
- await this.set(keys.global, globals);
- await this.set(userId, {
- data: {
- addEditCipherInfo: null,
- ciphers: {
- decrypted: null,
- encrypted: await this.get<{ [id: string]: CipherData }>(v1KeyPrefixes.ciphers + userId),
- },
- collapsedGroupings: null,
- collections: {
- decrypted: null,
- encrypted: await this.get<{ [id: string]: CollectionData }>(
- v1KeyPrefixes.collections + userId
- ),
- },
- eventCollection: await this.get(v1Keys.eventCollection),
- folders: {
- decrypted: null,
- encrypted: await this.get<{ [id: string]: FolderData }>(v1KeyPrefixes.folders + userId),
- },
- localData: null,
- organizations: await this.get<{ [id: string]: OrganizationData }>(
- v1KeyPrefixes.organizations + userId
- ),
- passwordGenerationHistory: {
- decrypted: null,
- encrypted: await this.get(v1Keys.history),
- },
- policies: {
- decrypted: null,
- encrypted: await this.get<{ [id: string]: PolicyData }>(v1KeyPrefixes.policies + userId),
- },
- providers: await this.get<{ [id: string]: ProviderData }>(v1KeyPrefixes.providers + userId),
- sends: {
- decrypted: null,
- encrypted: await this.get<{ [id: string]: SendData }>(v1KeyPrefixes.sends + userId),
- },
- },
- keys: {
- apiKeyClientSecret: await this.get(v1Keys.clientSecret),
- cryptoMasterKey: null,
- cryptoMasterKeyAuto: null,
- cryptoMasterKeyB64: null,
- cryptoMasterKeyBiometric: null,
- cryptoSymmetricKey: {
- encrypted: await this.get(v1Keys.encKey),
- decrypted: null,
- },
- legacyEtmKey: null,
- organizationKeys: {
- decrypted: null,
- encrypted: await this.get(v1Keys.encOrgKeys),
- },
- privateKey: {
- decrypted: null,
- encrypted: await this.get(v1Keys.encPrivate),
- },
- providerKeys: {
- decrypted: null,
- encrypted: await this.get(v1Keys.encProviderKeys),
- },
- publicKey: null,
- },
- profile: {
- apiKeyClientId: await this.get(v1Keys.clientId),
- authenticationStatus: null,
- convertAccountToKeyConnector: await this.get(v1Keys.convertAccountToKeyConnector),
- email: await this.get(v1Keys.userEmail),
- emailVerified: await this.get(v1Keys.emailVerified),
- entityId: null,
- entityType: null,
- everBeenUnlocked: null,
- forcePasswordReset: null,
- hasPremiumPersonally: null,
- kdfIterations: await this.get(v1Keys.kdfIterations),
- kdfType: await this.get(v1Keys.kdf),
- keyHash: await this.get(v1Keys.keyHash),
- lastSync: null,
- userId: userId,
- usesKeyConnector: null,
- },
- settings: accountSettings,
- tokens: {
- accessToken: await this.get(v1Keys.accessToken),
- decodedToken: null,
- refreshToken: await this.get(v1Keys.refreshToken),
- securityStamp: null,
- },
- });
-
- await this.set(keys.authenticatedAccounts, [userId]);
- await this.set(keys.activeUserId, userId);
-
- const accountActivity: { [userId: string]: number } = {
- [userId]: await this.get(v1Keys.lastActive),
- };
- accountActivity[userId] = await this.get(v1Keys.lastActive);
- await this.set(keys.accountActivity, accountActivity);
-
- await clearV1Keys(userId);
-
- if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "biometric" })) {
- await this.secureStorageService.save(
- `${userId}${partialKeys.biometricKey}`,
- await this.secureStorageService.get(v1Keys.key, { keySuffix: "biometric" }),
- { keySuffix: "biometric" }
- );
- await this.secureStorageService.remove(v1Keys.key, { keySuffix: "biometric" });
- }
-
- if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "auto" })) {
- await this.secureStorageService.save(
- `${userId}${partialKeys.autoKey}`,
- await this.secureStorageService.get(v1Keys.key, { keySuffix: "auto" }),
- { keySuffix: "auto" }
- );
- await this.secureStorageService.remove(v1Keys.key, { keySuffix: "auto" });
- }
-
- if (await this.secureStorageService.has(v1Keys.key)) {
- await this.secureStorageService.save(
- `${userId}${partialKeys.masterKey}`,
- await this.secureStorageService.get(v1Keys.key)
- );
- await this.secureStorageService.remove(v1Keys.key);
- }
- }
-
- protected async migrateStateFrom2To3(): Promise {
- const authenticatedUserIds = await this.get(keys.authenticatedAccounts);
- await Promise.all(
- authenticatedUserIds.map(async (userId) => {
- const account = await this.get(userId);
- if (
- account?.profile?.hasPremiumPersonally === null &&
- account.tokens?.accessToken != null
- ) {
- const decodedToken = await TokenService.decodeToken(account.tokens.accessToken);
- account.profile.hasPremiumPersonally = decodedToken.premium;
- await this.set(userId, account);
- }
- })
- );
-
- const globals = await this.getGlobals();
- globals.stateVersion = StateVersion.Three;
- await this.set(keys.global, globals);
- }
-
- protected async migrateStateFrom3To4(): Promise {
- const authenticatedUserIds = await this.get(keys.authenticatedAccounts);
- await Promise.all(
- authenticatedUserIds.map(async (userId) => {
- const account = await this.get(userId);
- if (account?.profile?.everBeenUnlocked != null) {
- delete account.profile.everBeenUnlocked;
- return this.set(userId, account);
- }
- })
- );
-
- const globals = await this.getGlobals();
- globals.stateVersion = StateVersion.Four;
- await this.set(keys.global, globals);
- }
-
- protected async migrateAccountFrom4To5(account: TAccount): Promise {
- const encryptedOrgKeys = account.keys?.organizationKeys?.encrypted;
- if (encryptedOrgKeys != null) {
- for (const [orgId, encKey] of Object.entries(encryptedOrgKeys)) {
- encryptedOrgKeys[orgId] = {
- type: "organization",
- key: encKey as unknown as string, // Account v4 does not reflect the current account model so we have to cast
- };
- }
- }
-
- return account;
- }
-
- protected async migrateAccountFrom5To6(account: TAccount): Promise {
- delete (account as any).keys?.legacyEtmKey;
- return account;
- }
-
- protected async migrateAccountFrom6To7(
- globalSetting: boolean,
- account: TAccount
- ): Promise {
- if (globalSetting) {
- account.settings = Object.assign({}, account.settings, { disableAutoBiometricsPrompt: true });
- }
- return account;
- }
-
- protected get options(): StorageOptions {
- return { htmlStorageLocation: HtmlStorageLocation.Local };
- }
-
- protected get(key: string): Promise {
- return this.storageService.get(key, this.options);
- }
-
- protected set(key: string, value: any): Promise {
- if (value == null) {
- return this.storageService.remove(key, this.options);
- }
- return this.storageService.save(key, value, this.options);
- }
-
- protected async getGlobals(): Promise {
- return await this.get(keys.global);
- }
-
- protected async getCurrentStateVersion(): Promise {
- return (await this.getGlobals())?.stateVersion ?? StateVersion.One;
- }
-
- protected async setCurrentStateVersion(newVersion: StateVersion): Promise {
- const globals = await this.getGlobals();
- globals.stateVersion = newVersion;
- await this.set(keys.global, globals);
- }
-
- protected async getAuthenticatedAccounts(): Promise {
- const authenticatedUserIds = await this.get(keys.authenticatedAccounts);
- return Promise.all(authenticatedUserIds.map((id) => this.get(id)));
- }
-}
diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts
index 14ba4abfd39..5fdf40e8458 100644
--- a/libs/common/src/platform/services/state.service.ts
+++ b/libs/common/src/platform/services/state.service.ts
@@ -21,6 +21,7 @@ import {
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { EventData } from "../../models/data/event.data";
import { WindowState } from "../../models/domain/window-state";
+import { migrate } from "../../state-migrations";
import { GeneratedPasswordHistory } from "../../tools/generator/password";
import { SendData } from "../../tools/send/models/data/send.data";
import { SendView } from "../../tools/send/models/view/send.view";
@@ -32,7 +33,6 @@ import { CipherView } from "../../vault/models/view/cipher.view";
import { CollectionView } from "../../vault/models/view/collection.view";
import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info";
import { LogService } from "../abstractions/log.service";
-import { StateMigrationService } from "../abstractions/state-migration.service";
import { StateService as StateServiceAbstraction } from "../abstractions/state.service";
import {
AbstractMemoryStorageService,
@@ -61,6 +61,7 @@ import {
const keys = {
state: "state",
+ stateVersion: "stateVersion",
global: "global",
authenticatedAccounts: "authenticatedAccounts",
activeUserId: "activeUserId",
@@ -106,7 +107,6 @@ export class StateService<
protected secureStorageService: AbstractStorageService,
protected memoryStorageService: AbstractMemoryStorageService,
protected logService: LogService,
- protected stateMigrationService: StateMigrationService,
protected stateFactory: StateFactory,
protected useAccountCache: boolean = true
) {
@@ -133,9 +133,7 @@ export class StateService<
return;
}
- if (await this.stateMigrationService.needsMigration()) {
- await this.stateMigrationService.migrate();
- }
+ await migrate(this.storageService, this.logService);
await this.state().then(async (state) => {
if (state == null) {
@@ -2724,16 +2722,6 @@ export class StateService<
);
}
- async getStateVersion(): Promise {
- return (await this.getGlobals(await this.defaultOnDiskLocalOptions())).stateVersion ?? 1;
- }
-
- async setStateVersion(value: number): Promise {
- const globals = await this.getGlobals(await this.defaultOnDiskOptions());
- globals.stateVersion = value;
- await this.saveGlobals(globals, await this.defaultOnDiskOptions());
- }
-
async getWindow(): Promise {
const globals = await this.getGlobals(await this.defaultOnDiskOptions());
return globals?.window != null && Object.keys(globals.window).length > 0
@@ -2838,7 +2826,11 @@ export class StateService<
globals = await this.getGlobalsFromDisk(options);
}
- return globals ?? this.createGlobals();
+ if (globals == null) {
+ globals = this.createGlobals();
+ }
+
+ return globals;
}
protected async saveGlobals(globals: TGlobalState, options: StorageOptions) {
diff --git a/libs/common/src/state-migrations/.eslintrc.json b/libs/common/src/state-migrations/.eslintrc.json
new file mode 100644
index 00000000000..4b66f0a32fa
--- /dev/null
+++ b/libs/common/src/state-migrations/.eslintrc.json
@@ -0,0 +1,24 @@
+{
+ "overrides": [
+ {
+ "files": ["*"],
+ "rules": {
+ "import/no-restricted-paths": [
+ "error",
+ {
+ "basePath": "libs/common/src/state-migrations",
+ "zones": [
+ {
+ "target": "./",
+ "from": "../",
+ // Relative to from, not basePath
+ "except": ["state-migrations"],
+ "message": "State migrations should rarely import from the greater codebase. If you need to import from another location, take into account the likelihood of change in that code and consider copying to the migration instead."
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/libs/common/src/state-migrations/index.ts b/libs/common/src/state-migrations/index.ts
new file mode 100644
index 00000000000..c883b1ca811
--- /dev/null
+++ b/libs/common/src/state-migrations/index.ts
@@ -0,0 +1 @@
+export { migrate, CURRENT_VERSION } from "./migrate";
diff --git a/libs/common/src/state-migrations/migrate.spec.ts b/libs/common/src/state-migrations/migrate.spec.ts
new file mode 100644
index 00000000000..ade3d261f69
--- /dev/null
+++ b/libs/common/src/state-migrations/migrate.spec.ts
@@ -0,0 +1,67 @@
+import { mock, MockProxy } from "jest-mock-extended";
+
+// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
+import { LogService } from "../platform/abstractions/log.service";
+// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
+import { AbstractStorageService } from "../platform/abstractions/storage.service";
+
+import { CURRENT_VERSION, currentVersion, migrate } from "./migrate";
+import { MigrationBuilder } from "./migration-builder";
+
+jest.mock("./migration-builder", () => {
+ return {
+ MigrationBuilder: {
+ create: jest.fn().mockReturnThis(),
+ },
+ };
+});
+
+describe("migrate", () => {
+ it("should not run migrations if state is empty", async () => {
+ const storage = mock();
+ const logService = mock();
+ storage.get.mockReturnValueOnce(null);
+ await migrate(storage, logService);
+ expect(MigrationBuilder.create).not.toHaveBeenCalled();
+ });
+
+ it("should set to current version if state is empty", async () => {
+ const storage = mock();
+ const logService = mock();
+ storage.get.mockReturnValueOnce(null);
+ await migrate(storage, logService);
+ expect(storage.save).toHaveBeenCalledWith("stateVersion", CURRENT_VERSION);
+ });
+});
+
+describe("currentVersion", () => {
+ let storage: MockProxy;
+ let logService: MockProxy;
+
+ beforeEach(() => {
+ storage = mock();
+ logService = mock();
+ });
+
+ it("should return -1 if no version", async () => {
+ storage.get.mockReturnValueOnce(null);
+ expect(await currentVersion(storage, logService)).toEqual(-1);
+ });
+
+ it("should return version", async () => {
+ storage.get.calledWith("stateVersion").mockReturnValueOnce(1 as any);
+ expect(await currentVersion(storage, logService)).toEqual(1);
+ });
+
+ it("should return version from global", async () => {
+ storage.get.calledWith("stateVersion").mockReturnValueOnce(null);
+ storage.get.calledWith("global").mockReturnValueOnce({ stateVersion: 1 } as any);
+ expect(await currentVersion(storage, logService)).toEqual(1);
+ });
+
+ it("should prefer root version to global", async () => {
+ storage.get.calledWith("stateVersion").mockReturnValue(1 as any);
+ storage.get.calledWith("global").mockReturnValue({ stateVersion: 2 } as any);
+ expect(await currentVersion(storage, logService)).toEqual(1);
+ });
+});
diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts
new file mode 100644
index 00000000000..483c4f2e8eb
--- /dev/null
+++ b/libs/common/src/state-migrations/migrate.ts
@@ -0,0 +1,60 @@
+// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
+import { LogService } from "../platform/abstractions/log.service";
+// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
+import { AbstractStorageService } from "../platform/abstractions/storage.service";
+
+import { MigrationBuilder } from "./migration-builder";
+import { MigrationHelper } from "./migration-helper";
+import { FixPremiumMigrator } from "./migrations/3-fix-premium";
+import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
+import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
+import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
+import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
+import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
+import { MinVersionMigrator } from "./migrations/min-version";
+
+export const MIN_VERSION = 2;
+export const CURRENT_VERSION = 8;
+export type MinVersion = typeof MIN_VERSION;
+
+export async function migrate(
+ storageService: AbstractStorageService,
+ logService: LogService
+): Promise {
+ const migrationHelper = new MigrationHelper(
+ await currentVersion(storageService, logService),
+ storageService,
+ logService
+ );
+ if (migrationHelper.currentVersion < 0) {
+ // Cannot determine state, assuming empty so we don't repeatedly apply a migration.
+ await storageService.save("stateVersion", CURRENT_VERSION);
+ return;
+ }
+ MigrationBuilder.create()
+ .with(MinVersionMigrator)
+ .with(FixPremiumMigrator, 2, 3)
+ .with(RemoveEverBeenUnlockedMigrator, 3, 4)
+ .with(AddKeyTypeToOrgKeysMigrator, 4, 5)
+ .with(RemoveLegacyEtmKeyMigrator, 5, 6)
+ .with(MoveBiometricAutoPromptToAccount, 6, 7)
+ .with(MoveStateVersionMigrator, 7, CURRENT_VERSION)
+ .migrate(migrationHelper);
+}
+
+export async function currentVersion(
+ storageService: AbstractStorageService,
+ logService: LogService
+) {
+ let state = await storageService.get("stateVersion");
+ if (state == null) {
+ // Pre v8
+ state = (await storageService.get<{ stateVersion: number }>("global"))?.stateVersion;
+ }
+ if (state == null) {
+ logService.info("No state version found, assuming empty state.");
+ return -1;
+ }
+ logService.info(`State version: ${state}`);
+ return state;
+}
diff --git a/libs/common/src/state-migrations/migration-builder.spec.ts b/libs/common/src/state-migrations/migration-builder.spec.ts
new file mode 100644
index 00000000000..fa53544f133
--- /dev/null
+++ b/libs/common/src/state-migrations/migration-builder.spec.ts
@@ -0,0 +1,117 @@
+import { mock } from "jest-mock-extended";
+
+import { MigrationBuilder } from "./migration-builder";
+import { MigrationHelper } from "./migration-helper";
+import { Migrator } from "./migrator";
+
+describe("MigrationBuilder", () => {
+ class TestMigrator extends Migrator<0, 1> {
+ async migrate(helper: MigrationHelper): Promise {
+ await helper.set("test", "test");
+ return;
+ }
+
+ async rollback(helper: MigrationHelper): Promise {
+ await helper.set("test", "rollback");
+ return;
+ }
+ }
+
+ let sut: MigrationBuilder;
+
+ beforeEach(() => {
+ sut = MigrationBuilder.create();
+ });
+
+ class TestBadMigrator extends Migrator<1, 0> {
+ async migrate(helper: MigrationHelper): Promise {
+ await helper.set("test", "test");
+ }
+
+ async rollback(helper: MigrationHelper): Promise {
+ await helper.set("test", "rollback");
+ }
+ }
+
+ it("should throw if instantiated incorrectly", () => {
+ expect(() => MigrationBuilder.create().with(TestMigrator, null, null)).toThrow();
+ expect(() =>
+ MigrationBuilder.create().with(TestMigrator, 0, 1).with(TestBadMigrator, 1, 0)
+ ).toThrow();
+ });
+
+ it("should be able to create a new MigrationBuilder", () => {
+ expect(sut).toBeInstanceOf(MigrationBuilder);
+ });
+
+ it("should be able to add a migrator", () => {
+ const newBuilder = sut.with(TestMigrator, 0, 1);
+ const migrations = newBuilder["migrations"];
+ expect(migrations.length).toBe(1);
+ expect(migrations[0]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "up" });
+ });
+
+ it("should be able to add a rollback", () => {
+ const newBuilder = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0);
+ const migrations = newBuilder["migrations"];
+ expect(migrations.length).toBe(2);
+ expect(migrations[1]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "down" });
+ });
+
+ describe("migrate", () => {
+ let migrator: TestMigrator;
+ let rollback_migrator: TestMigrator;
+
+ beforeEach(() => {
+ sut = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0);
+ migrator = (sut as any).migrations[0].migrator;
+ rollback_migrator = (sut as any).migrations[1].migrator;
+ });
+
+ it("should migrate", async () => {
+ const helper = new MigrationHelper(0, mock(), mock());
+ const spy = jest.spyOn(migrator, "migrate");
+ await sut.migrate(helper);
+ expect(spy).toBeCalledWith(helper);
+ });
+
+ it("should rollback", async () => {
+ const helper = new MigrationHelper(1, mock(), mock());
+ const spy = jest.spyOn(rollback_migrator, "rollback");
+ await sut.migrate(helper);
+ expect(spy).toBeCalledWith(helper);
+ });
+
+ it("should update version on migrate", async () => {
+ const helper = new MigrationHelper(0, mock(), mock());
+ const spy = jest.spyOn(migrator, "updateVersion");
+ await sut.migrate(helper);
+ expect(spy).toBeCalledWith(helper, "up");
+ });
+
+ it("should update version on rollback", async () => {
+ const helper = new MigrationHelper(1, mock(), mock());
+ const spy = jest.spyOn(rollback_migrator, "updateVersion");
+ await sut.migrate(helper);
+ expect(spy).toBeCalledWith(helper, "down");
+ });
+
+ it("should not run the migrator if the current version does not match the from version", async () => {
+ const helper = new MigrationHelper(3, mock(), mock());
+ const migrate = jest.spyOn(migrator, "migrate");
+ const rollback = jest.spyOn(rollback_migrator, "rollback");
+ await sut.migrate(helper);
+ expect(migrate).not.toBeCalled();
+ expect(rollback).not.toBeCalled();
+ });
+
+ it("should not update version if the current version does not match the from version", async () => {
+ const helper = new MigrationHelper(3, mock(), mock());
+ const migrate = jest.spyOn(migrator, "updateVersion");
+ const rollback = jest.spyOn(rollback_migrator, "updateVersion");
+ await sut.migrate(helper);
+ expect(migrate).not.toBeCalled();
+ expect(rollback).not.toBeCalled();
+ });
+ });
+});
diff --git a/libs/common/src/state-migrations/migration-builder.ts b/libs/common/src/state-migrations/migration-builder.ts
new file mode 100644
index 00000000000..776295a6b8f
--- /dev/null
+++ b/libs/common/src/state-migrations/migration-builder.ts
@@ -0,0 +1,106 @@
+import { MigrationHelper } from "./migration-helper";
+import { Direction, Migrator, VersionFrom, VersionTo } from "./migrator";
+
+export class MigrationBuilder {
+ /** Create a new MigrationBuilder with an empty buffer of migrations to perform.
+ *
+ * Add migrations to the buffer with {@link with} and {@link rollback}.
+ * @returns A new MigrationBuilder.
+ */
+ static create(): MigrationBuilder<0> {
+ return new MigrationBuilder([]);
+ }
+
+ private constructor(
+ private migrations: readonly { migrator: Migrator; direction: Direction }[]
+ ) {}
+
+ /** Add a migrator to the MigrationBuilder. Types are updated such that the chained MigrationBuilder must currently be
+ * at state version equal to the from version of the migrator. Return as MigrationBuilder where TTo is the to
+ * version of the migrator, so that the next migrator can be chained.
+ *
+ * @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is
+ * required to instantiate version numbers unless a default constructor is defined.
+ * @returns A new MigrationBuilder with the to version of the migrator as the current version.
+ */
+ with<
+ TMigrator extends Migrator,
+ TFrom extends VersionFrom & TCurrent,
+ TTo extends VersionTo
+ >(
+ ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo]
+ ): MigrationBuilder {
+ return this.addMigrator(migrate, "up");
+ }
+
+ /** Add a migrator to rollback on the MigrationBuilder's list of migrations. As with {@link with}, types of
+ * MigrationBuilder and Migrator must align. However, this time the migration is reversed so TCurrent of the
+ * MigrationBuilder must be equal to the to version of the migrator. Return as MigrationBuilder where TFrom
+ * is the from version of the migrator, so that the next migrator can be chained.
+ *
+ * @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is
+ * required to instantiate version numbers unless a default constructor is defined.
+ * @returns A new MigrationBuilder with the from version of the migrator as the current version.
+ */
+ rollback<
+ TMigrator extends Migrator,
+ TFrom extends VersionFrom,
+ TTo extends VersionTo & TCurrent
+ >(
+ ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TTo, TFrom]
+ ): MigrationBuilder {
+ if (migrate.length === 3) {
+ migrate = [migrate[0], migrate[2], migrate[1]];
+ }
+ return this.addMigrator(migrate, "down");
+ }
+
+ /** Execute the migrations as defined in the MigrationBuilder's migrator buffer */
+ migrate(helper: MigrationHelper): Promise {
+ return this.migrations.reduce(
+ (promise, migrator) =>
+ promise.then(async () => {
+ await this.runMigrator(migrator.migrator, helper, migrator.direction);
+ }),
+ Promise.resolve()
+ );
+ }
+
+ private addMigrator<
+ TMigrator extends Migrator,
+ TFrom extends VersionFrom & TCurrent,
+ TTo extends VersionTo
+ >(
+ migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo],
+ direction: Direction = "up"
+ ) {
+ const newMigration =
+ migrate.length === 1
+ ? { migrator: new migrate[0](), direction }
+ : { migrator: new migrate[0](migrate[1], migrate[2]), direction };
+
+ return new MigrationBuilder([...this.migrations, newMigration]);
+ }
+
+ private async runMigrator(
+ migrator: Migrator,
+ helper: MigrationHelper,
+ direction: Direction
+ ): Promise {
+ const shouldMigrate = await migrator.shouldMigrate(helper, direction);
+ helper.info(
+ `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) should migrate: ${shouldMigrate} - ${direction}`
+ );
+ if (shouldMigrate) {
+ const method = direction === "up" ? migrator.migrate : migrator.rollback;
+ await method(helper);
+ helper.info(
+ `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) migrated - ${direction}`
+ );
+ await migrator.updateVersion(helper, direction);
+ helper.info(
+ `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) updated version - ${direction}`
+ );
+ }
+ }
+}
diff --git a/libs/common/src/state-migrations/migration-helper.spec.ts b/libs/common/src/state-migrations/migration-helper.spec.ts
new file mode 100644
index 00000000000..5b8a0f2eb4f
--- /dev/null
+++ b/libs/common/src/state-migrations/migration-helper.spec.ts
@@ -0,0 +1,84 @@
+import { MockProxy, mock } from "jest-mock-extended";
+
+// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
+import { LogService } from "../platform/abstractions/log.service";
+// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
+import { AbstractStorageService } from "../platform/abstractions/storage.service";
+
+import { MigrationHelper } from "./migration-helper";
+
+const exampleJSON = {
+ authenticatedAccounts: [
+ "c493ed01-4e08-4e88-abc7-332f380ca760",
+ "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
+ ],
+ "c493ed01-4e08-4e88-abc7-332f380ca760": {
+ otherStuff: "otherStuff1",
+ },
+ "23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
+ otherStuff: "otherStuff2",
+ },
+};
+
+describe("RemoveLegacyEtmKeyMigrator", () => {
+ let storage: MockProxy;
+ let logService: MockProxy;
+ let sut: MigrationHelper;
+
+ beforeEach(() => {
+ logService = mock();
+ storage = mock();
+ storage.get.mockImplementation((key) => (exampleJSON as any)[key]);
+
+ sut = new MigrationHelper(0, storage, logService);
+ });
+
+ describe("get", () => {
+ it("should delegate to storage.get", async () => {
+ await sut.get("key");
+ expect(storage.get).toHaveBeenCalledWith("key");
+ });
+ });
+
+ describe("set", () => {
+ it("should delegate to storage.save", async () => {
+ await sut.set("key", "value");
+ expect(storage.save).toHaveBeenCalledWith("key", "value");
+ });
+ });
+
+ describe("getAccounts", () => {
+ it("should return all accounts", async () => {
+ const accounts = await sut.getAccounts();
+ expect(accounts).toEqual([
+ { userId: "c493ed01-4e08-4e88-abc7-332f380ca760", account: { otherStuff: "otherStuff1" } },
+ { userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9", account: { otherStuff: "otherStuff2" } },
+ ]);
+ });
+
+ it("should handle missing authenticatedAccounts", async () => {
+ storage.get.mockImplementation((key) =>
+ key === "authenticatedAccounts" ? undefined : (exampleJSON as any)[key]
+ );
+ const accounts = await sut.getAccounts();
+ expect(accounts).toEqual([]);
+ });
+ });
+});
+
+/** Helper to create well-mocked migration helpers in migration tests */
+export function mockMigrationHelper(storageJson: any): MockProxy {
+ const logService: MockProxy = mock();
+ const storage: MockProxy = mock();
+ storage.get.mockImplementation((key) => (storageJson as any)[key]);
+ storage.save.mockImplementation(async (key, value) => {
+ (storageJson as any)[key] = value;
+ });
+ const helper = new MigrationHelper(0, storage, logService);
+
+ const mockHelper = mock();
+ mockHelper.get.mockImplementation((key) => helper.get(key));
+ mockHelper.set.mockImplementation((key, value) => helper.set(key, value));
+ mockHelper.getAccounts.mockImplementation(() => helper.getAccounts());
+ return mockHelper;
+}
diff --git a/libs/common/src/state-migrations/migration-helper.ts b/libs/common/src/state-migrations/migration-helper.ts
new file mode 100644
index 00000000000..a185aa69a99
--- /dev/null
+++ b/libs/common/src/state-migrations/migration-helper.ts
@@ -0,0 +1,37 @@
+// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
+import { LogService } from "../platform/abstractions/log.service";
+// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
+import { AbstractStorageService } from "../platform/abstractions/storage.service";
+
+export class MigrationHelper {
+ constructor(
+ public currentVersion: number,
+ private storageService: AbstractStorageService,
+ public logService: LogService
+ ) {}
+
+ get(key: string): Promise {
+ return this.storageService.get(key);
+ }
+
+ set(key: string, value: T): Promise {
+ this.logService.info(`Setting ${key}`);
+ return this.storageService.save(key, value);
+ }
+
+ info(message: string): void {
+ this.logService.info(message);
+ }
+
+ async getAccounts(): Promise<
+ { userId: string; account: ExpectedAccountType }[]
+ > {
+ const userIds = (await this.get("authenticatedAccounts")) ?? [];
+ return Promise.all(
+ userIds.map(async (userId) => ({
+ userId,
+ account: await this.get(userId),
+ }))
+ );
+ }
+}
diff --git a/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts b/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts
new file mode 100644
index 00000000000..1ef910d4569
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts
@@ -0,0 +1,111 @@
+import { MockProxy } from "jest-mock-extended";
+
+// eslint-disable-next-line import/no-restricted-paths -- Used for testing migration, which requires import
+import { TokenService } from "../../auth/services/token.service";
+import { MigrationHelper } from "../migration-helper";
+import { mockMigrationHelper } from "../migration-helper.spec";
+
+import { FixPremiumMigrator } from "./3-fix-premium";
+
+function migrateExampleJSON() {
+ return {
+ global: {
+ stateVersion: 2,
+ otherStuff: "otherStuff1",
+ },
+ authenticatedAccounts: [
+ "c493ed01-4e08-4e88-abc7-332f380ca760",
+ "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
+ ],
+ "c493ed01-4e08-4e88-abc7-332f380ca760": {
+ profile: {
+ otherStuff: "otherStuff2",
+ hasPremiumPersonally: null as boolean,
+ },
+ tokens: {
+ otherStuff: "otherStuff3",
+ accessToken: "accessToken",
+ },
+ otherStuff: "otherStuff4",
+ },
+ "23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
+ profile: {
+ otherStuff: "otherStuff5",
+ hasPremiumPersonally: true,
+ },
+ tokens: {
+ otherStuff: "otherStuff6",
+ accessToken: "accessToken",
+ },
+ otherStuff: "otherStuff7",
+ },
+ otherStuff: "otherStuff8",
+ };
+}
+
+jest.mock("../../auth/services/token.service", () => ({
+ TokenService: {
+ decodeToken: jest.fn(),
+ },
+}));
+
+describe("FixPremiumMigrator", () => {
+ let helper: MockProxy;
+ let sut: FixPremiumMigrator;
+ const decodeTokenSpy = TokenService.decodeToken as jest.Mock;
+
+ beforeEach(() => {
+ helper = mockMigrationHelper(migrateExampleJSON());
+ sut = new FixPremiumMigrator(2, 3);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe("migrate", () => {
+ it("should migrate hasPremiumPersonally", async () => {
+ decodeTokenSpy.mockResolvedValueOnce({ premium: true });
+ await sut.migrate(helper);
+
+ expect(helper.set).toHaveBeenCalledTimes(1);
+ expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
+ profile: {
+ otherStuff: "otherStuff2",
+ hasPremiumPersonally: true,
+ },
+ tokens: {
+ otherStuff: "otherStuff3",
+ accessToken: "accessToken",
+ },
+ otherStuff: "otherStuff4",
+ });
+ });
+
+ it("should not migrate if decode throws", async () => {
+ decodeTokenSpy.mockRejectedValueOnce(new Error("test"));
+ await sut.migrate(helper);
+
+ expect(helper.set).not.toHaveBeenCalled();
+ });
+
+ it("should not migrate if decode returns null", async () => {
+ decodeTokenSpy.mockResolvedValueOnce(null);
+ await sut.migrate(helper);
+
+ expect(helper.set).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("updateVersion", () => {
+ it("should update version", async () => {
+ await sut.updateVersion(helper, "up");
+
+ expect(helper.set).toHaveBeenCalledTimes(1);
+ expect(helper.set).toHaveBeenCalledWith("global", {
+ stateVersion: 3,
+ otherStuff: "otherStuff1",
+ });
+ });
+ });
+});
diff --git a/libs/common/src/state-migrations/migrations/3-fix-premium.ts b/libs/common/src/state-migrations/migrations/3-fix-premium.ts
new file mode 100644
index 00000000000..b6c69a99168
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/3-fix-premium.ts
@@ -0,0 +1,48 @@
+// eslint-disable-next-line import/no-restricted-paths -- Used for token decoding, which are valid for days. We want the latest
+import { TokenService } from "../../auth/services/token.service";
+import { MigrationHelper } from "../migration-helper";
+import { Migrator, IRREVERSIBLE, Direction } from "../migrator";
+
+type ExpectedAccountType = {
+ profile?: { hasPremiumPersonally?: boolean };
+ tokens?: { accessToken?: string };
+};
+
+export class FixPremiumMigrator extends Migrator<2, 3> {
+ async migrate(helper: MigrationHelper): Promise {
+ const accounts = await helper.getAccounts();
+
+ async function fixPremium(userId: string, account: ExpectedAccountType) {
+ if (account?.profile?.hasPremiumPersonally === null && account.tokens?.accessToken != null) {
+ let decodedToken: { premium: boolean };
+ try {
+ decodedToken = await TokenService.decodeToken(account.tokens.accessToken);
+ } catch {
+ return;
+ }
+
+ if (decodedToken?.premium == null) {
+ return;
+ }
+
+ account.profile.hasPremiumPersonally = decodedToken?.premium;
+ return helper.set(userId, account);
+ }
+ }
+
+ await Promise.all(accounts.map(({ userId, account }) => fixPremium(userId, account)));
+ }
+
+ rollback(helper: MigrationHelper): Promise {
+ throw IRREVERSIBLE;
+ }
+
+ // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
+ // it is nested inside a global object.
+ override async updateVersion(helper: MigrationHelper, direction: Direction): Promise {
+ const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
+ helper.currentVersion = endVersion;
+ const global: Record = (await helper.get("global")) || {};
+ await helper.set("global", { ...global, stateVersion: endVersion });
+ }
+}
diff --git a/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts b/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts
new file mode 100644
index 00000000000..1701762118d
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts
@@ -0,0 +1,75 @@
+import { MockProxy } from "jest-mock-extended";
+
+import { MigrationHelper } from "../migration-helper";
+import { mockMigrationHelper } from "../migration-helper.spec";
+
+import { RemoveEverBeenUnlockedMigrator } from "./4-remove-ever-been-unlocked";
+
+function migrateExampleJSON() {
+ return {
+ global: {
+ stateVersion: 3,
+ otherStuff: "otherStuff1",
+ },
+ authenticatedAccounts: [
+ "c493ed01-4e08-4e88-abc7-332f380ca760",
+ "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
+ ],
+ "c493ed01-4e08-4e88-abc7-332f380ca760": {
+ profile: {
+ otherStuff: "otherStuff2",
+ everBeenUnlocked: true,
+ },
+ otherStuff: "otherStuff3",
+ },
+ "23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
+ profile: {
+ otherStuff: "otherStuff4",
+ everBeenUnlocked: false,
+ },
+ otherStuff: "otherStuff5",
+ },
+ otherStuff: "otherStuff6",
+ };
+}
+
+describe("RemoveEverBeenUnlockedMigrator", () => {
+ let helper: MockProxy;
+ let sut: RemoveEverBeenUnlockedMigrator;
+
+ beforeEach(() => {
+ helper = mockMigrationHelper(migrateExampleJSON());
+ sut = new RemoveEverBeenUnlockedMigrator(3, 4);
+ });
+
+ describe("migrate", () => {
+ it("should remove everBeenUnlocked from profile", async () => {
+ await sut.migrate(helper);
+ expect(helper.set).toHaveBeenCalledTimes(2);
+ expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
+ profile: {
+ otherStuff: "otherStuff2",
+ },
+ otherStuff: "otherStuff3",
+ });
+ expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
+ profile: {
+ otherStuff: "otherStuff4",
+ },
+ otherStuff: "otherStuff5",
+ });
+ });
+ });
+
+ describe("updateVersion", () => {
+ it("should update version up", async () => {
+ await sut.updateVersion(helper, "up");
+
+ expect(helper.set).toHaveBeenCalledTimes(1);
+ expect(helper.set).toHaveBeenCalledWith("global", {
+ stateVersion: 4,
+ otherStuff: "otherStuff1",
+ });
+ });
+ });
+});
diff --git a/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts b/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts
new file mode 100644
index 00000000000..cfa45958d06
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts
@@ -0,0 +1,32 @@
+import { MigrationHelper } from "../migration-helper";
+import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
+
+type ExpectedAccountType = { profile?: { everBeenUnlocked?: boolean } };
+
+export class RemoveEverBeenUnlockedMigrator extends Migrator<3, 4> {
+ async migrate(helper: MigrationHelper): Promise {
+ const accounts = await helper.getAccounts();
+
+ async function removeEverBeenUnlocked(userId: string, account: ExpectedAccountType) {
+ if (account?.profile?.everBeenUnlocked != null) {
+ delete account.profile.everBeenUnlocked;
+ return helper.set(userId, account);
+ }
+ }
+
+ Promise.all(accounts.map(({ userId, account }) => removeEverBeenUnlocked(userId, account)));
+ }
+
+ rollback(helper: MigrationHelper): Promise {
+ throw IRREVERSIBLE;
+ }
+
+ // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
+ // it is nested inside a global object.
+ override async updateVersion(helper: MigrationHelper, direction: Direction): Promise {
+ const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
+ helper.currentVersion = endVersion;
+ const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
+ await helper.set("global", { ...global, stateVersion: endVersion });
+ }
+}
diff --git a/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts b/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts
new file mode 100644
index 00000000000..028a0b879b1
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts
@@ -0,0 +1,141 @@
+import { MockProxy } from "jest-mock-extended";
+
+import { MigrationHelper } from "../migration-helper";
+import { mockMigrationHelper } from "../migration-helper.spec";
+
+import { AddKeyTypeToOrgKeysMigrator } from "./5-add-key-type-to-org-keys";
+
+function migrateExampleJSON() {
+ return {
+ global: {
+ stateVersion: 4,
+ otherStuff: "otherStuff1",
+ },
+ authenticatedAccounts: [
+ "c493ed01-4e08-4e88-abc7-332f380ca760",
+ "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
+ ],
+ "c493ed01-4e08-4e88-abc7-332f380ca760": {
+ keys: {
+ organizationKeys: {
+ encrypted: {
+ orgOneId: "orgOneEncKey",
+ orgTwoId: "orgTwoEncKey",
+ },
+ },
+ otherStuff: "otherStuff2",
+ },
+ otherStuff: "otherStuff3",
+ },
+ };
+}
+
+function rollbackExampleJSON() {
+ return {
+ global: {
+ stateVersion: 5,
+ otherStuff: "otherStuff1",
+ },
+ authenticatedAccounts: [
+ "c493ed01-4e08-4e88-abc7-332f380ca760",
+ "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
+ ],
+ "c493ed01-4e08-4e88-abc7-332f380ca760": {
+ keys: {
+ organizationKeys: {
+ encrypted: {
+ orgOneId: {
+ type: "organization",
+ key: "orgOneEncKey",
+ },
+ orgTwoId: {
+ type: "organization",
+ key: "orgTwoEncKey",
+ },
+ },
+ },
+ otherStuff: "otherStuff2",
+ },
+ otherStuff: "otherStuff3",
+ },
+ };
+}
+
+describe("AddKeyTypeToOrgKeysMigrator", () => {
+ let helper: MockProxy;
+ let sut: AddKeyTypeToOrgKeysMigrator;
+
+ describe("migrate", () => {
+ beforeEach(() => {
+ helper = mockMigrationHelper(migrateExampleJSON());
+ sut = new AddKeyTypeToOrgKeysMigrator(4, 5);
+ });
+
+ it("should add organization type to organization keys", async () => {
+ await sut.migrate(helper);
+
+ expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
+ keys: {
+ organizationKeys: {
+ encrypted: {
+ orgOneId: {
+ type: "organization",
+ key: "orgOneEncKey",
+ },
+ orgTwoId: {
+ type: "organization",
+ key: "orgTwoEncKey",
+ },
+ },
+ },
+ otherStuff: "otherStuff2",
+ },
+ otherStuff: "otherStuff3",
+ });
+ });
+
+ it("should update version", async () => {
+ await sut.updateVersion(helper, "up");
+
+ expect(helper.set).toHaveBeenCalledTimes(1);
+ expect(helper.set).toHaveBeenCalledWith("global", {
+ stateVersion: 5,
+ otherStuff: "otherStuff1",
+ });
+ });
+ });
+
+ describe("rollback", () => {
+ beforeEach(() => {
+ helper = mockMigrationHelper(rollbackExampleJSON());
+ sut = new AddKeyTypeToOrgKeysMigrator(4, 5);
+ });
+
+ it("should remove type from orgainzation keys", async () => {
+ await sut.rollback(helper);
+
+ expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
+ keys: {
+ organizationKeys: {
+ encrypted: {
+ orgOneId: "orgOneEncKey",
+ orgTwoId: "orgTwoEncKey",
+ },
+ },
+ otherStuff: "otherStuff2",
+ },
+ otherStuff: "otherStuff3",
+ });
+ });
+
+ it("should update version down", async () => {
+ await sut.updateVersion(helper, "down");
+
+ expect(helper.set).toHaveBeenCalledTimes(1);
+ expect(helper.set).toHaveBeenCalledWith("global", {
+ stateVersion: 4,
+ otherStuff: "otherStuff1",
+ });
+ });
+ });
+});
diff --git a/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts b/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts
new file mode 100644
index 00000000000..ab1550c52e3
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts
@@ -0,0 +1,67 @@
+import { MigrationHelper } from "../migration-helper";
+import { Direction, Migrator } from "../migrator";
+
+type ExpectedAccountType = { keys?: { organizationKeys?: { encrypted: Record } } };
+type NewAccountType = {
+ keys?: {
+ organizationKeys?: { encrypted: Record };
+ };
+};
+
+export class AddKeyTypeToOrgKeysMigrator extends Migrator<4, 5> {
+ async migrate(helper: MigrationHelper): Promise {
+ const accounts = await helper.getAccounts();
+
+ async function updateOrgKey(userId: string, account: ExpectedAccountType) {
+ const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted;
+ if (encryptedOrgKeys == null) {
+ return;
+ }
+
+ const newOrgKeys: Record = {};
+
+ Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => {
+ newOrgKeys[orgId] = {
+ type: "organization",
+ key: encKey,
+ };
+ });
+ (account as any).keys.organizationKeys.encrypted = newOrgKeys;
+
+ await helper.set(userId, account);
+ }
+
+ Promise.all(accounts.map(({ userId, account }) => updateOrgKey(userId, account)));
+ }
+
+ async rollback(helper: MigrationHelper): Promise {
+ const accounts = await helper.getAccounts();
+
+ async function updateOrgKey(userId: string, account: NewAccountType) {
+ const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted;
+ if (encryptedOrgKeys == null) {
+ return;
+ }
+
+ const newOrgKeys: Record = {};
+
+ Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => {
+ newOrgKeys[orgId] = encKey.key;
+ });
+ (account as any).keys.organizationKeys.encrypted = newOrgKeys;
+
+ await helper.set(userId, account);
+ }
+
+ Promise.all(accounts.map(async ({ userId, account }) => updateOrgKey(userId, account)));
+ }
+
+ // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
+ // it is nested inside a global object.
+ override async updateVersion(helper: MigrationHelper, direction: Direction): Promise {
+ const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
+ helper.currentVersion = endVersion;
+ const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
+ await helper.set("global", { ...global, stateVersion: endVersion });
+ }
+}
diff --git a/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts b/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts
new file mode 100644
index 00000000000..bc7b862f6cf
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts
@@ -0,0 +1,80 @@
+import { MockProxy } from "jest-mock-extended";
+
+import { MigrationHelper } from "../migration-helper";
+import { mockMigrationHelper } from "../migration-helper.spec";
+
+import { RemoveLegacyEtmKeyMigrator } from "./6-remove-legacy-etm-key";
+
+function exampleJSON() {
+ return {
+ global: {
+ stateVersion: 5,
+ otherStuff: "otherStuff1",
+ },
+ authenticatedAccounts: [
+ "c493ed01-4e08-4e88-abc7-332f380ca760",
+ "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
+ "fd005ea6-a16a-45ef-ba4a-a194269bfd73",
+ ],
+ "c493ed01-4e08-4e88-abc7-332f380ca760": {
+ keys: {
+ legacyEtmKey: "legacyEtmKey",
+ otherStuff: "otherStuff2",
+ },
+ otherStuff: "otherStuff3",
+ },
+ "23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
+ keys: {
+ legacyEtmKey: "legacyEtmKey",
+ otherStuff: "otherStuff4",
+ },
+ otherStuff: "otherStuff5",
+ },
+ };
+}
+
+describe("RemoveLegacyEtmKeyMigrator", () => {
+ let helper: MockProxy;
+ let sut: RemoveLegacyEtmKeyMigrator;
+
+ beforeEach(() => {
+ helper = mockMigrationHelper(exampleJSON());
+ sut = new RemoveLegacyEtmKeyMigrator(5, 6);
+ });
+
+ describe("migrate", () => {
+ it("should remove legacyEtmKey from all accounts", async () => {
+ await sut.migrate(helper);
+ expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
+ keys: {
+ otherStuff: "otherStuff2",
+ },
+ otherStuff: "otherStuff3",
+ });
+ expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
+ keys: {
+ otherStuff: "otherStuff4",
+ },
+ otherStuff: "otherStuff5",
+ });
+ });
+ });
+
+ describe("rollback", () => {
+ it("should throw", async () => {
+ await expect(sut.rollback(helper)).rejects.toThrow();
+ });
+ });
+
+ describe("updateVersion", () => {
+ it("should update version up", async () => {
+ await sut.updateVersion(helper, "up");
+
+ expect(helper.set).toHaveBeenCalledTimes(1);
+ expect(helper.set).toHaveBeenCalledWith("global", {
+ stateVersion: 6,
+ otherStuff: "otherStuff1",
+ });
+ });
+ });
+});
diff --git a/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts b/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts
new file mode 100644
index 00000000000..2a06916ea33
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts
@@ -0,0 +1,32 @@
+import { MigrationHelper } from "../migration-helper";
+import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
+
+type ExpectedAccountType = { keys?: { legacyEtmKey?: string } };
+
+export class RemoveLegacyEtmKeyMigrator extends Migrator<5, 6> {
+ async migrate(helper: MigrationHelper): Promise {
+ const accounts = await helper.getAccounts();
+
+ async function updateAccount(userId: string, account: ExpectedAccountType) {
+ if (account?.keys?.legacyEtmKey) {
+ delete account.keys.legacyEtmKey;
+ await helper.set(userId, account);
+ }
+ }
+
+ await Promise.all(accounts.map(({ userId, account }) => updateAccount(userId, account)));
+ }
+
+ async rollback(helper: MigrationHelper): Promise {
+ throw IRREVERSIBLE;
+ }
+
+ // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
+ // it is nested inside a global object.
+ override async updateVersion(helper: MigrationHelper, direction: Direction): Promise {
+ const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
+ helper.currentVersion = endVersion;
+ const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
+ await helper.set("global", { ...global, stateVersion: endVersion });
+ }
+}
diff --git a/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts b/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts
new file mode 100644
index 00000000000..fe73f8a9bc4
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts
@@ -0,0 +1,102 @@
+import { MockProxy, any, matches } from "jest-mock-extended";
+
+import { MigrationHelper } from "../migration-helper";
+import { mockMigrationHelper } from "../migration-helper.spec";
+
+import { MoveBiometricAutoPromptToAccount } from "./7-move-biometric-auto-prompt-to-account";
+
+function exampleJSON() {
+ return {
+ global: {
+ stateVersion: 6,
+ noAutoPromptBiometrics: true,
+ otherStuff: "otherStuff1",
+ },
+ authenticatedAccounts: [
+ "c493ed01-4e08-4e88-abc7-332f380ca760",
+ "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
+ "fd005ea6-a16a-45ef-ba4a-a194269bfd73",
+ ],
+ "c493ed01-4e08-4e88-abc7-332f380ca760": {
+ settings: {
+ otherStuff: "otherStuff2",
+ },
+ otherStuff: "otherStuff3",
+ },
+ "23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
+ settings: {
+ otherStuff: "otherStuff4",
+ },
+ otherStuff: "otherStuff5",
+ },
+ };
+}
+
+describe("RemoveLegacyEtmKeyMigrator", () => {
+ let helper: MockProxy;
+ let sut: MoveBiometricAutoPromptToAccount;
+
+ beforeEach(() => {
+ helper = mockMigrationHelper(exampleJSON());
+ sut = new MoveBiometricAutoPromptToAccount(6, 7);
+ });
+
+ describe("migrate", () => {
+ it("should remove noAutoPromptBiometrics from global", async () => {
+ await sut.migrate(helper);
+ expect(helper.set).toHaveBeenCalledWith("global", {
+ otherStuff: "otherStuff1",
+ stateVersion: 6,
+ });
+ });
+
+ it("should set disableAutoBiometricsPrompt to true on all accounts", async () => {
+ await sut.migrate(helper);
+ expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
+ settings: {
+ disableAutoBiometricsPrompt: true,
+ otherStuff: "otherStuff2",
+ },
+ otherStuff: "otherStuff3",
+ });
+ expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
+ settings: {
+ disableAutoBiometricsPrompt: true,
+ otherStuff: "otherStuff4",
+ },
+ otherStuff: "otherStuff5",
+ });
+ });
+
+ it("should not set disableAutoBiometricsPrompt to true on accounts if noAutoPromptBiometrics is false", async () => {
+ const json = exampleJSON();
+ json.global.noAutoPromptBiometrics = false;
+ helper = mockMigrationHelper(json);
+ await sut.migrate(helper);
+ expect(helper.set).not.toHaveBeenCalledWith(
+ matches((s) => s != "global"),
+ any()
+ );
+ });
+ });
+
+ describe("rollback", () => {
+ it("should throw", async () => {
+ await expect(sut.rollback(helper)).rejects.toThrow();
+ });
+ });
+
+ describe("updateVersion", () => {
+ it("should update version up", async () => {
+ await sut.updateVersion(helper, "up");
+
+ expect(helper.set).toHaveBeenCalledTimes(1);
+ expect(helper.set).toHaveBeenCalledWith(
+ "global",
+ Object.assign({}, exampleJSON().global, {
+ stateVersion: 7,
+ })
+ );
+ });
+ });
+});
diff --git a/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts b/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts
new file mode 100644
index 00000000000..0ac065d60c1
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts
@@ -0,0 +1,45 @@
+import { MigrationHelper } from "../migration-helper";
+import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
+
+type ExpectedAccountType = { settings?: { disableAutoBiometricsPrompt?: boolean } };
+
+export class MoveBiometricAutoPromptToAccount extends Migrator<6, 7> {
+ async migrate(helper: MigrationHelper): Promise {
+ const global = await helper.get<{ noAutoPromptBiometrics?: boolean }>("global");
+ const noAutoPromptBiometrics = global?.noAutoPromptBiometrics ?? false;
+
+ const accounts = await helper.getAccounts();
+ async function updateAccount(userId: string, account: ExpectedAccountType) {
+ if (account == null) {
+ return;
+ }
+
+ if (noAutoPromptBiometrics) {
+ account.settings = Object.assign(account?.settings ?? {}, {
+ disableAutoBiometricsPrompt: true,
+ });
+ await helper.set(userId, account);
+ }
+ }
+
+ delete global.noAutoPromptBiometrics;
+
+ await Promise.all([
+ ...accounts.map(({ userId, account }) => updateAccount(userId, account)),
+ helper.set("global", global),
+ ]);
+ }
+
+ async rollback(helper: MigrationHelper): Promise {
+ throw IRREVERSIBLE;
+ }
+
+ // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
+ // it is nested inside a global object.
+ override async updateVersion(helper: MigrationHelper, direction: Direction): Promise {
+ const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
+ helper.currentVersion = endVersion;
+ const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
+ await helper.set("global", { ...global, stateVersion: endVersion });
+ }
+}
diff --git a/libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts b/libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts
new file mode 100644
index 00000000000..8c84fd642ea
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts
@@ -0,0 +1,90 @@
+import { MockProxy } from "jest-mock-extended";
+
+import { MigrationHelper } from "../migration-helper";
+import { mockMigrationHelper } from "../migration-helper.spec";
+
+import { MoveStateVersionMigrator } from "./8-move-state-version";
+
+function migrateExampleJSON() {
+ return {
+ global: {
+ stateVersion: 6,
+ otherStuff: "otherStuff1",
+ },
+ otherStuff: "otherStuff2",
+ };
+}
+
+function rollbackExampleJSON() {
+ return {
+ global: {
+ otherStuff: "otherStuff1",
+ },
+ stateVersion: 7,
+ otherStuff: "otherStuff2",
+ };
+}
+
+describe("moveStateVersion", () => {
+ let helper: MockProxy;
+ let sut: MoveStateVersionMigrator;
+
+ describe("migrate", () => {
+ beforeEach(() => {
+ helper = mockMigrationHelper(migrateExampleJSON());
+ sut = new MoveStateVersionMigrator(7, 8);
+ });
+
+ it("should move state version to root", async () => {
+ await sut.migrate(helper);
+ expect(helper.set).toHaveBeenCalledWith("stateVersion", 6);
+ });
+
+ it("should remove state version from global", async () => {
+ await sut.migrate(helper);
+ expect(helper.set).toHaveBeenCalledWith("global", {
+ otherStuff: "otherStuff1",
+ });
+ });
+
+ it("should throw if state version not found", async () => {
+ helper.get.mockReturnValue({ otherStuff: "otherStuff1" } as any);
+ await expect(sut.migrate(helper)).rejects.toThrow(
+ "Migration failed, state version not found"
+ );
+ });
+
+ it("should update version up", async () => {
+ await sut.updateVersion(helper, "up");
+
+ expect(helper.set).toHaveBeenCalledTimes(1);
+ expect(helper.set).toHaveBeenCalledWith("stateVersion", 8);
+ });
+ });
+
+ describe("rollback", () => {
+ beforeEach(() => {
+ helper = mockMigrationHelper(rollbackExampleJSON());
+ sut = new MoveStateVersionMigrator(7, 8);
+ });
+
+ it("should move state version to global", async () => {
+ await sut.rollback(helper);
+ expect(helper.set).toHaveBeenCalledWith("global", {
+ stateVersion: 7,
+ otherStuff: "otherStuff1",
+ });
+ expect(helper.set).toHaveBeenCalledWith("stateVersion", undefined);
+ });
+
+ it("should update version down", async () => {
+ await sut.updateVersion(helper, "down");
+
+ expect(helper.set).toHaveBeenCalledTimes(1);
+ expect(helper.set).toHaveBeenCalledWith("global", {
+ stateVersion: 7,
+ otherStuff: "otherStuff1",
+ });
+ });
+ });
+});
diff --git a/libs/common/src/state-migrations/migrations/8-move-state-version.ts b/libs/common/src/state-migrations/migrations/8-move-state-version.ts
new file mode 100644
index 00000000000..cbcdf423843
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/8-move-state-version.ts
@@ -0,0 +1,37 @@
+import { JsonObject } from "type-fest";
+
+import { MigrationHelper } from "../migration-helper";
+import { Direction, Migrator } from "../migrator";
+
+export class MoveStateVersionMigrator extends Migrator<7, 8> {
+ async migrate(helper: MigrationHelper): Promise {
+ const global = await helper.get<{ stateVersion: number }>("global");
+ if (global.stateVersion) {
+ await helper.set("stateVersion", global.stateVersion);
+ delete global.stateVersion;
+ await helper.set("global", global);
+ } else {
+ throw new Error("Migration failed, state version not found");
+ }
+ }
+
+ async rollback(helper: MigrationHelper): Promise {
+ const version = await helper.get("stateVersion");
+ const global = await helper.get("global");
+ await helper.set("global", { ...global, stateVersion: version });
+ await helper.set("stateVersion", undefined);
+ }
+
+ // Override is necessary because default implementation assumes `stateVersion` at the root, but this migration moves
+ // it from a `global` object to root.This makes for unique rollback versioning.
+ override async updateVersion(helper: MigrationHelper, direction: Direction): Promise {
+ const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
+ helper.currentVersion = endVersion;
+ if (direction === "up") {
+ await helper.set("stateVersion", endVersion);
+ } else {
+ const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
+ await helper.set("global", { ...global, stateVersion: endVersion });
+ }
+ }
+}
diff --git a/libs/common/src/state-migrations/migrations/min-version.spec.ts b/libs/common/src/state-migrations/migrations/min-version.spec.ts
new file mode 100644
index 00000000000..26e106c19a9
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/min-version.spec.ts
@@ -0,0 +1,29 @@
+import { MockProxy } from "jest-mock-extended";
+
+import { MIN_VERSION } from "../migrate";
+import { MigrationHelper } from "../migration-helper";
+import { mockMigrationHelper } from "../migration-helper.spec";
+
+import { MinVersionMigrator } from "./min-version";
+
+describe("MinVersionMigrator", () => {
+ let helper: MockProxy;
+ let sut: MinVersionMigrator;
+
+ beforeEach(() => {
+ helper = mockMigrationHelper(null);
+ sut = new MinVersionMigrator();
+ });
+
+ describe("shouldMigrate", () => {
+ it("should return true if current version is less than min version", async () => {
+ helper.currentVersion = MIN_VERSION - 1;
+ expect(await sut.shouldMigrate(helper)).toBe(true);
+ });
+
+ it("should return false if current version is greater than min version", async () => {
+ helper.currentVersion = MIN_VERSION + 1;
+ expect(await sut.shouldMigrate(helper)).toBe(false);
+ });
+ });
+});
diff --git a/libs/common/src/state-migrations/migrations/min-version.ts b/libs/common/src/state-migrations/migrations/min-version.ts
new file mode 100644
index 00000000000..a417cc51a3c
--- /dev/null
+++ b/libs/common/src/state-migrations/migrations/min-version.ts
@@ -0,0 +1,26 @@
+import { MinVersion, MIN_VERSION } from "../migrate";
+import { MigrationHelper } from "../migration-helper";
+import { IRREVERSIBLE, Migrator } from "../migrator";
+
+export function minVersionError(current: number) {
+ return `Your local data is too old to be migrated. Your current state version is ${current}, but minimum version is ${MIN_VERSION}.`;
+}
+
+export class MinVersionMigrator extends Migrator<0, MinVersion> {
+ constructor() {
+ super(0, MIN_VERSION);
+ }
+
+ // Overrides the default implementation to catch any version that may be passed in.
+ override shouldMigrate(helper: MigrationHelper): Promise {
+ return Promise.resolve(helper.currentVersion < MIN_VERSION);
+ }
+ async migrate(helper: MigrationHelper): Promise {
+ if (helper.currentVersion < MIN_VERSION) {
+ throw new Error(minVersionError(helper.currentVersion));
+ }
+ }
+ async rollback(helper: MigrationHelper): Promise {
+ throw IRREVERSIBLE;
+ }
+}
diff --git a/libs/common/src/state-migrations/migrator.spec.ts b/libs/common/src/state-migrations/migrator.spec.ts
new file mode 100644
index 00000000000..3abaa277273
--- /dev/null
+++ b/libs/common/src/state-migrations/migrator.spec.ts
@@ -0,0 +1,75 @@
+import { mock, MockProxy } from "jest-mock-extended";
+
+// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
+import { LogService } from "../platform/abstractions/log.service";
+// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
+import { AbstractStorageService } from "../platform/abstractions/storage.service";
+
+import { MigrationHelper } from "./migration-helper";
+import { Migrator } from "./migrator";
+
+describe("migrator default methods", () => {
+ class TestMigrator extends Migrator<0, 1> {
+ async migrate(helper: MigrationHelper): Promise {
+ await helper.set("test", "test");
+ }
+ async rollback(helper: MigrationHelper): Promise {
+ await helper.set("test", "rollback");
+ }
+ }
+
+ let storage: MockProxy;
+ let logService: MockProxy;
+ let helper: MigrationHelper;
+ let sut: TestMigrator;
+
+ beforeEach(() => {
+ storage = mock();
+ logService = mock();
+ helper = new MigrationHelper(0, storage, logService);
+ sut = new TestMigrator(0, 1);
+ });
+
+ describe("shouldMigrate", () => {
+ describe("up", () => {
+ it("should return true if the current version equals the from version", async () => {
+ expect(await sut.shouldMigrate(helper, "up")).toBe(true);
+ });
+
+ it("should return false if the current version does not equal the from version", async () => {
+ helper.currentVersion = 1;
+ expect(await sut.shouldMigrate(helper, "up")).toBe(false);
+ });
+ });
+
+ describe("down", () => {
+ it("should return true if the current version equals the to version", async () => {
+ helper.currentVersion = 1;
+ expect(await sut.shouldMigrate(helper, "down")).toBe(true);
+ });
+
+ it("should return false if the current version does not equal the to version", async () => {
+ expect(await sut.shouldMigrate(helper, "down")).toBe(false);
+ });
+ });
+ });
+
+ describe("updateVersion", () => {
+ describe("up", () => {
+ it("should update the version", async () => {
+ await sut.updateVersion(helper, "up");
+ expect(storage.save).toBeCalledWith("stateVersion", 1);
+ expect(helper.currentVersion).toBe(1);
+ });
+ });
+
+ describe("down", () => {
+ it("should update the version", async () => {
+ helper.currentVersion = 1;
+ await sut.updateVersion(helper, "down");
+ expect(storage.save).toBeCalledWith("stateVersion", 0);
+ expect(helper.currentVersion).toBe(0);
+ });
+ });
+ });
+});
diff --git a/libs/common/src/state-migrations/migrator.ts b/libs/common/src/state-migrations/migrator.ts
new file mode 100644
index 00000000000..aba81372d49
--- /dev/null
+++ b/libs/common/src/state-migrations/migrator.ts
@@ -0,0 +1,40 @@
+import { NonNegativeInteger } from "type-fest";
+
+import { MigrationHelper } from "./migration-helper";
+
+export const IRREVERSIBLE = new Error("Irreversible migration");
+
+export type VersionFrom = T extends Migrator
+ ? TFrom extends NonNegativeInteger
+ ? TFrom
+ : never
+ : never;
+export type VersionTo = T extends Migrator
+ ? TTo extends NonNegativeInteger |