From 0f1ef7eca1f1ab3c5a1787b19a6caec13fb30dec Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 13 Oct 2021 10:48:25 +0200 Subject: anastasis-webui: finish backup flow --- packages/anastasis-webui/package.json | 1 + .../src/hooks/use-anastasis-reducer.ts | 185 +++++++- packages/anastasis-webui/src/routes/home/index.tsx | 518 +++++++++++++++++++-- packages/anastasis-webui/src/routes/home/style.css | 25 +- pnpm-lock.yaml | 51 +- 5 files changed, 692 insertions(+), 88 deletions(-) diff --git a/packages/anastasis-webui/package.json b/packages/anastasis-webui/package.json index ddbd9ef20..fe332be03 100644 --- a/packages/anastasis-webui/package.json +++ b/packages/anastasis-webui/package.json @@ -21,6 +21,7 @@ ] }, "dependencies": { + "@gnu-taler/taler-util": "workspace:^0.8.3", "preact": "^10.3.1", "preact-render-to-string": "^5.1.4", "preact-router": "^3.2.1" diff --git a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts index 30bab96d1..d578d1418 100644 --- a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts +++ b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts @@ -1,6 +1,58 @@ import { useState } from "preact/hooks"; -type ReducerState = any; +export type ReducerState = + | ReducerStateBackup + | ReducerStateRecovery + | ReducerStateError; + +export interface ReducerStateBackup { + recovery_state: undefined; + backup_state: BackupStates; + code: undefined; + continents: any; + countries: any; + authentication_providers: any; + authentication_methods?: AuthMethod[]; + required_attributes: any; + secret_name?: string; + policies?: { + methods: { + authentication_method: number; + provider: string; + }[]; + }[]; + success_details: { + [provider_url: string]: { + policy_version: number; + }; + }; + payments?: string[]; + policy_payment_requests?: { + payto: string; + provider: string; + }[]; +} + +export interface AuthMethod { + type: string; + instructions: string; + challenge: string; +} + +export interface ReducerStateRecovery { + backup_state: undefined; + recovery_state: RecoveryStates; + code: undefined; + + continents: any; + countries: any; +} + +export interface ReducerStateError { + backup_state: undefined; + recovery_state: undefined; + code: number; +} interface AnastasisState { reducerState: ReducerState | undefined; @@ -10,6 +62,13 @@ interface AnastasisState { export enum BackupStates { ContinentSelecting = "CONTINENT_SELECTING", CountrySelecting = "COUNTRY_SELECTING", + UserAttributesCollecting = "USER_ATTRIBUTES_COLLECTING", + AuthenticationsEditing = "AUTHENTICATIONS_EDITING", + PoliciesReviewing = "POLICIES_REVIEWING", + SecretEditing = "SECRET_EDITING", + TruthsPaying = "TRUTHS_PAYING", + PoliciesPaying = "POLICIES_PAYING", + BackupFinished = "BACKUP_FINISHED", } export enum RecoveryStates { @@ -49,20 +108,62 @@ async function reduceState( return resp.json(); } +export interface ReducerTransactionHandle { + transactionState: ReducerState; + transition(action: string, args: any): Promise; +} + export interface AnastasisReducerApi { - currentReducerState: ReducerState; + currentReducerState: ReducerState | undefined; currentError: any; + dismissError: () => void; startBackup: () => void; startRecover: () => void; + reset: () => void; back: () => void; transition(action: string, args: any): void; + /** + * Run multiple reducer steps in a transaction without + * affecting the UI-visible transition state in-between. + */ + runTransaction(f: (h: ReducerTransactionHandle) => Promise): void; +} + +function restoreState(): any { + let state: any; + try { + let s = localStorage.getItem("anastasisReducerState"); + if (s === "undefined") { + state = undefined; + } else if (s) { + console.log("restoring state from", s); + state = JSON.parse(s); + } + } catch (e) { + console.log(e); + } + return state ?? undefined; } export function useAnastasisReducer(): AnastasisReducerApi { - const [anastasisState, setAnastasisState] = useState({ - reducerState: undefined, - currentError: undefined, - }); + const [anastasisState, setAnastasisStateInternal] = useState( + () => ({ + reducerState: restoreState(), + currentError: undefined, + }), + ); + + const setAnastasisState = (newState: AnastasisState) => { + try { + localStorage.setItem( + "anastasisReducerState", + JSON.stringify(newState.reducerState), + ); + } catch (e) { + console.log(e); + } + setAnastasisStateInternal(newState); + }; async function doTransition(action: string, args: any) { console.log("reducing with", action, args); @@ -102,30 +203,74 @@ export function useAnastasisReducer(): AnastasisReducerApi { doTransition(action, args); }, back() { + const reducerState = anastasisState.reducerState; + if (!reducerState) { + return; + } if ( - anastasisState.reducerState.backup_state === - BackupStates.ContinentSelecting || - anastasisState.reducerState.recovery_state === - RecoveryStates.ContinentSelecting + reducerState.backup_state === BackupStates.ContinentSelecting || + reducerState.recovery_state === RecoveryStates.ContinentSelecting ) { setAnastasisState({ ...anastasisState, currentError: undefined, reducerState: undefined, }); - } else if ( - anastasisState.reducerState.backup_state === - BackupStates.CountrySelecting - ) { - doTransition("unselect_continent", {}); - } else if ( - anastasisState.reducerState.recovery_state === - RecoveryStates.CountrySelecting - ) { - doTransition("unselect_continent", {}); } else { doTransition("back", {}); } }, + dismissError() { + setAnastasisState({ ...anastasisState, currentError: undefined }); + }, + reset() { + setAnastasisState({ + ...anastasisState, + currentError: undefined, + reducerState: undefined, + }); + }, + runTransaction(f) { + async function run() { + const txHandle = new ReducerTxImpl(anastasisState.reducerState!); + try { + await f(txHandle); + } catch (e) { + console.log("exception during reducer transaction", e); + } + const s = txHandle.transactionState; + console.log("transaction finished, new state", s); + if (s.code !== undefined) { + setAnastasisState({ + ...anastasisState, + currentError: txHandle.transactionState, + }); + } else { + setAnastasisState({ + ...anastasisState, + reducerState: txHandle.transactionState, + currentError: undefined, + }); + } + } + run(); + }, }; } + +class ReducerTxImpl implements ReducerTransactionHandle { + constructor(public transactionState: ReducerState) {} + async transition(action: string, args: any): Promise { + console.log("making transition in transaction", action); + this.transactionState = await reduceState( + this.transactionState, + action, + args, + ); + // Abort transaction as soon as we transition into an error state. + if (this.transactionState.code !== undefined) { + throw Error("transition resulted in error"); + } + return this.transactionState; + } +} diff --git a/packages/anastasis-webui/src/routes/home/index.tsx b/packages/anastasis-webui/src/routes/home/index.tsx index ee3399503..f61897682 100644 --- a/packages/anastasis-webui/src/routes/home/index.tsx +++ b/packages/anastasis-webui/src/routes/home/index.tsx @@ -1,80 +1,290 @@ +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; import { FunctionalComponent, h } from "preact"; import { useState } from "preact/hooks"; import { AnastasisReducerApi, + AuthMethod, + BackupStates, + ReducerStateBackup, + ReducerStateRecovery, useAnastasisReducer, } from "../../hooks/use-anastasis-reducer"; import style from "./style.css"; +interface ContinentSelectionProps { + reducer: AnastasisReducerApi; + reducerState: ReducerStateBackup | ReducerStateRecovery; +} + +function isBackup(reducer: AnastasisReducerApi) { + return !!reducer.currentReducerState?.backup_state; +} + +function ContinentSelection(props: ContinentSelectionProps) { + const { reducer, reducerState } = props; + return ( +
+

{isBackup(reducer) ? "Backup" : "Recovery"}: Select Continent

+ +
+ {reducerState.continents.map((x: any) => { + const sel = (x: string) => + reducer.transition("select_continent", { continent: x }); + return ( + + ); + })} +
+
+ +
+
+ ); +} + +interface CountrySelectionProps { + reducer: AnastasisReducerApi; + reducerState: ReducerStateBackup | ReducerStateRecovery; +} + +function CountrySelection(props: CountrySelectionProps) { + const { reducer, reducerState } = props; + return ( +
+

Backup: Select Country

+ +
+ {reducerState.countries.map((x: any) => { + const sel = (x: any) => + reducer.transition("select_country", { + country_code: x.code, + currencies: [x.currency], + }); + return ( + + ); + })} +
+
+ +
+
+ ); +} + const Home: FunctionalComponent = () => { const reducer = useAnastasisReducer(); - if (!reducer.currentReducerState) { + const reducerState = reducer.currentReducerState; + if (!reducerState) { return (

Home

- - + +

); } console.log("state", reducer.currentReducerState); - if (reducer.currentReducerState.backup_state === "CONTINENT_SELECTING") { + + if (reducerState.backup_state === BackupStates.ContinentSelecting) { + return ; + } + if (reducerState.backup_state === BackupStates.CountrySelecting) { + return ; + } + if (reducerState.backup_state === BackupStates.UserAttributesCollecting) { + return ; + } + if (reducerState.backup_state === BackupStates.AuthenticationsEditing) { + return ( + + ); + } + + if (reducerState.backup_state === BackupStates.PoliciesReviewing) { + const backupState: ReducerStateBackup = reducerState; + const authMethods = backupState.authentication_methods!; return (
-

Backup: Select Continent

+

Backup: Review Recovery Policies

- {reducer.currentReducerState.continents.map((x: any) => { - const sel = (x: string) => - reducer.transition("select_continent", { continent: x }); + {backupState.policies?.map((p, i) => { + const policyName = p.methods + .map((x) => authMethods[x.authentication_method].type) + .join(" + "); return ( - +
+

+ Policy #{i + 1}: {policyName} +

+ Required Authentications: +
    + {p.methods.map((x) => { + const m = authMethods[x.authentication_method]; + return ( +
  • + {m.type} ({m.instructions}) at provider {x.provider} +
  • + ); + })} +
+
+ +
+
); })}
+
); } - if (reducer.currentReducerState.backup_state === "COUNTRY_SELECTING") { + + if (reducerState.backup_state === BackupStates.SecretEditing) { + const [secretName, setSecretName] = useState(""); + const [secretValue, setSecretValue] = useState(""); + const secretNext = () => { + reducer.runTransaction(async (tx) => { + await tx.transition("enter_secret_name", { + name: secretName, + }); + await tx.transition("enter_secret", { + secret: { + value: "EDJP6WK5EG50", + mime: "text/plain", + }, + expiration: { + t_ms: new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 5, + }, + }); + await tx.transition("next", {}); + }); + }; return (
-

Backup: Select Continent

+

Backup: Provide secret

- {reducer.currentReducerState.countries.map((x: any) => { - const sel = (x: any) => - reducer.transition("select_country", { - country_code: x.code, - currencies: [x.currency], - }); + +
+
+ +
+ or: +
+ +
+
+ + +
+
+ ); + } + + if (reducerState.backup_state === BackupStates.BackupFinished) { + const backupState: ReducerStateBackup = reducerState; + return ( +
+

Backup finished

+

+ Your backup of secret "{backupState.secret_name ?? "??"}" was + successful. +

+

The backup is stored by the following providers:

+
    + {Object.keys(backupState.success_details).map((x, i) => { + const sd = backupState.success_details[x]; return ( - +
  • + {x} (Policy version {sd.policy_version}) +
  • ); })} -
+ + + + ); + } + + if (reducerState.backup_state === BackupStates.TruthsPaying) { + const backupState: ReducerStateBackup = reducerState; + const payments = backupState.payments ?? []; + return ( +
+

Backup: Authentication Storage Payments

+

+ Some of the providers require a payment to store the encrypted + authentication information. +

+
    + {payments.map((x) => { + return
  • {x}
  • ; + })} +
+
); } - if ( - reducer.currentReducerState.backup_state === "USER_ATTRIBUTES_COLLECTING" - ) { - return ; - } - if (reducer.currentReducerState.backup_state === "AUTHENTICATIONS_EDITING") { - return ; + if (reducerState.backup_state === BackupStates.PoliciesPaying) { + const backupState: ReducerStateBackup = reducerState; + const payments = backupState.policy_payment_requests ?? []; + return ( +
+

Backup: Recovery Document Payments

+

+ Some of the providers require a payment to store the encrypted + recovery document. +

+
    + {payments.map((x) => { + return ( +
  • + {x.provider}: {x.payto} +
  • + ); + })} +
+
+ + +
+
+ ); } console.log("unknown state", reducer.currentReducerState); @@ -82,31 +292,232 @@ const Home: FunctionalComponent = () => {

Home

Bug: Unknown state.

+
); }; +interface AuthMethodSetupProps { + method: string; + addAuthMethod: (x: any) => void; + cancel: () => void; +} + +function AuthMethodSmsSetup(props: AuthMethodSetupProps) { + const [mobileNumber, setMobileNumber] = useState(""); + return ( +
+

Add {props.method} authentication

+
+

+ For SMS authentication, you need to provide a mobile number. When + recovering your secret, you will be asked to enter the code you + receive via SMS. +

+ +
+ + +
+
+
+ ); +} + +function AuthMethodQuestionSetup(props: AuthMethodSetupProps) { + const [questionText, setQuestionText] = useState(""); + const [answerText, setAnswerText] = useState(""); + return ( +
+

Add {props.method} authentication

+
+

+ For security question authentication, you need to provide a question + and its answer. When recovering your secret, you will be shown the + question and you will need to type the answer exactly as you typed it + here. +

+
+ +
+
+ +
+
+ + +
+
+
+ ); +} + +function AuthMethodNotImplemented(props: AuthMethodSetupProps) { + return ( +
+

Add {props.method} authentication

+
+

+ This auth method is not implemented yet, please choose another one. +

+ +
+
+ ); +} + export interface AuthenticationEditorProps { reducer: AnastasisReducerApi; + backupState: ReducerStateBackup; } function AuthenticationEditor(props: AuthenticationEditorProps) { - const { reducer } = props; - const providers = reducer.currentReducerState.authentication_providers; - const authAvailable = new Set(); + const [selectedMethod, setSelectedMethod] = useState( + undefined, + ); + const { reducer, backupState } = props; + const providers = backupState.authentication_providers; + const authAvailableSet = new Set(); for (const provKey of Object.keys(providers)) { const p = providers[provKey]; for (const meth of p.methods) { - authAvailable.add(meth.type); + authAvailableSet.add(meth.type); + } + } + if (selectedMethod) { + const cancel = () => setSelectedMethod(undefined); + const addMethod = (args: any) => { + reducer.transition("add_authentication", args); + setSelectedMethod(undefined); + }; + switch (selectedMethod) { + case "sms": + return ( + + ); + case "question": + return ( + + ); + default: + return ( + + ); } } + function MethodButton(props: { method: string; label: String }) { + return ( + + ); + } + const configuredAuthMethods: AuthMethod[] = + backupState.authentication_methods ?? []; + const haveMethodsConfigured = configuredAuthMethods.length; return (

Backup: Configure Authentication Methods

-

Auths available: {JSON.stringify(Array.from(authAvailable))}

- + +

Add authentication method

+
+ + + + + + +
+

Configured authentication methods

+ {haveMethodsConfigured ? ( + configuredAuthMethods.map((x, i) => { + return ( +

+ {x.type} ({x.instructions}){" "} + +

+ ); + }) + ) : ( +

No authentication methods configured yet.

+ )}
+
); @@ -114,19 +525,21 @@ function AuthenticationEditor(props: AuthenticationEditorProps) { export interface AttributeEntryProps { reducer: AnastasisReducerApi; + backupState: ReducerStateBackup; } function AttributeEntry(props: AttributeEntryProps) { - const reducer = props.reducer; + const { reducer, backupState } = props; const [attrs, setAttrs] = useState>({}); return (

Backup: Enter Basic User Attributes

- {reducer.currentReducerState.required_attributes.map((x: any) => { + {backupState.required_attributes.map((x: any, i: number) => { return ( setAttrs({ ...attrs, [x.name]: v })} spec={x} value={attrs[x.name]} @@ -134,23 +547,24 @@ function AttributeEntry(props: AttributeEntryProps) { ); })}
-
+
); } export interface AttributeEntryFieldProps { + isFirst: boolean; value: string; setValue: (newValue: string) => void; spec: any; @@ -161,6 +575,7 @@ function AttributeEntryField(props: AttributeEntryFieldProps) {
props.setValue((e as any).target.value)} @@ -179,7 +594,14 @@ interface ErrorBannerProps { function ErrorBanner(props: ErrorBannerProps) { const currentError = props.reducer.currentError; if (currentError) { - return
Error: {JSON.stringify(currentError)}
; + return ( +
+

Error: {JSON.stringify(currentError)}

+ +
+ ); } return null; } diff --git a/packages/anastasis-webui/src/routes/home/style.css b/packages/anastasis-webui/src/routes/home/style.css index f052d2546..c9f34e6c8 100644 --- a/packages/anastasis-webui/src/routes/home/style.css +++ b/packages/anastasis-webui/src/routes/home/style.css @@ -1,5 +1,24 @@ .home { - padding: 56px 20px; - min-height: 100%; - width: 100%; + padding: 56px 20px; + min-height: 100%; + width: 100%; +} + +.home div { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.policy { + padding: 0.5em; + border: 1px solid black; + border-radius: 0.5em; + border-radius: 0.5em; +} + +.home > #error { + padding: 0.5em; + border: 1px solid black; + background-color: rgb(228, 189, 197); + border-radius: 0.5em; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8f1fd547..fbd3c7e98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,7 @@ importers: packages/anastasis-webui: specifiers: + '@gnu-taler/taler-util': workspace:^0.8.3 '@types/enzyme': ^3.10.5 '@types/jest': ^26.0.8 '@typescript-eslint/eslint-plugin': ^2.25.0 @@ -44,6 +45,7 @@ importers: sirv-cli: ^1.0.0-next.3 typescript: ^3.7.5 dependencies: + '@gnu-taler/taler-util': link:../taler-util preact: 10.5.14 preact-render-to-string: 5.1.19_preact@10.5.14 preact-router: 3.2.1_preact@10.5.14 @@ -4599,7 +4601,7 @@ packages: dependencies: '@types/estree': 0.0.39 estree-walker: 1.0.1 - picomatch: 2.2.2 + picomatch: 2.3.0 rollup: 2.56.2 dev: true @@ -7681,7 +7683,7 @@ packages: /axios/0.21.1: resolution: {integrity: sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==} dependencies: - follow-redirects: 1.14.2 + follow-redirects: 1.14.2_debug@4.3.2 transitivePeerDependencies: - debug @@ -10771,18 +10773,18 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 dependencies: - array-includes: 3.1.2 + array-includes: 3.1.3 array.prototype.flatmap: 1.2.4 doctrine: 2.1.0 eslint: 6.8.0 has: 1.0.3 jsx-ast-utils: 3.2.0 - object.entries: 1.1.3 - object.fromentries: 2.0.3 - object.values: 1.1.2 + object.entries: 1.1.4 + object.fromentries: 2.0.4 + object.values: 1.1.4 prop-types: 15.7.2 - resolve: 1.19.0 - string.prototype.matchall: 4.0.3 + resolve: 1.20.0 + string.prototype.matchall: 4.0.5 dev: true /eslint-plugin-react/7.22.0_eslint@7.18.0: @@ -11444,7 +11446,7 @@ packages: readable-stream: 2.3.7 dev: true - /follow-redirects/1.14.2: + /follow-redirects/1.14.2_debug@4.3.2: resolution: {integrity: sha512-yLR6WaE2lbF0x4K2qE2p9PEXKLDjUjnR/xmjS3wHAYxtlsI9MLLBJUZirAHKzUZDGLxje7w/cXR49WOUo4rbsA==} engines: {node: '>=4.0'} peerDependencies: @@ -11452,6 +11454,8 @@ packages: peerDependenciesMeta: debug: optional: true + dependencies: + debug: 4.3.2_supports-color@6.1.0 /for-each/0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -12485,7 +12489,7 @@ packages: engines: {node: '>=8.0.0'} dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.14.2 + follow-redirects: 1.14.2_debug@4.3.2 requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -14131,7 +14135,7 @@ packages: resolution: {integrity: sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q==} engines: {node: '>=4.0'} dependencies: - array-includes: 3.1.2 + array-includes: 3.1.3 object.assign: 4.1.2 dev: true @@ -15962,11 +15966,11 @@ packages: - typescript dev: true - /pnp-webpack-plugin/1.7.0_typescript@4.3.5: + /pnp-webpack-plugin/1.7.0_typescript@4.4.3: resolution: {integrity: sha512-2Rb3vm+EXble/sMXNSu6eoBx8e79gKqhNq9F5ZWW6ERNCTE/Q0wQNne5541tE5vKjfM8hpNCYL+LGc1YTfI0dg==} engines: {node: '>=6'} dependencies: - ts-pnp: 1.2.0_typescript@4.3.5 + ts-pnp: 1.2.0_typescript@4.4.3 transitivePeerDependencies: - typescript dev: true @@ -16770,7 +16774,7 @@ packages: native-url: 0.3.4 optimize-css-assets-webpack-plugin: 6.0.1_webpack@4.46.0 ora: 5.4.1 - pnp-webpack-plugin: 1.7.0_typescript@4.3.5 + pnp-webpack-plugin: 1.7.0_typescript@4.4.3 postcss: 8.3.6 postcss-load-config: 3.1.0 postcss-loader: 4.3.0_postcss@8.3.6+webpack@4.46.0 @@ -16788,7 +16792,7 @@ packages: stack-trace: 0.0.10 style-loader: 2.0.0_webpack@4.46.0 terser-webpack-plugin: 4.2.3_webpack@4.46.0 - typescript: 4.3.5 + typescript: 4.4.3 update-notifier: 5.1.0 url-loader: 4.1.1_file-loader@6.2.0+webpack@4.46.0 validate-npm-package-name: 3.0.0 @@ -18039,11 +18043,11 @@ packages: peerDependencies: rollup: ^2.0.0 dependencies: - '@babel/code-frame': 7.12.13 + '@babel/code-frame': 7.14.5 jest-worker: 26.6.2 rollup: 2.56.2 serialize-javascript: 4.0.0 - terser: 5.4.0 + terser: 5.7.1 dev: true /rollup/2.37.1: @@ -19167,6 +19171,7 @@ packages: /svgo/1.3.2: resolution: {integrity: sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==} engines: {node: '>=4.0.0'} + deprecated: This SVGO version is no longer supported. Upgrade to v2.x.x. hasBin: true dependencies: chalk: 2.4.2 @@ -19588,6 +19593,18 @@ packages: typescript: 4.3.5 dev: true + /ts-pnp/1.2.0_typescript@4.4.3: + resolution: {integrity: sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==} + engines: {node: '>=6'} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 4.4.3 + dev: true + /tsconfig-paths/3.9.0: resolution: {integrity: sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==} dependencies: -- cgit v1.2.3