From c170b9fa034d8280d19c019458c9fd5621840d96 Mon Sep 17 00:00:00 2001 From: jocelyneholdbrook Date: Wed, 4 Feb 2026 14:52:30 +0000 Subject: [PATCH 1/2] feat(medcat-trainer): Clear app storage on start-up to prevent auth state conflicts --- medcat-trainer/webapp/frontend/src/main.ts | 4 + .../src/tests/utils/storage-cleanup.spec.ts | 200 ++++++++++++++++++ .../frontend/src/utils/storage-cleanup.ts | 50 +++++ 3 files changed, 254 insertions(+) create mode 100644 medcat-trainer/webapp/frontend/src/tests/utils/storage-cleanup.spec.ts create mode 100644 medcat-trainer/webapp/frontend/src/utils/storage-cleanup.ts diff --git a/medcat-trainer/webapp/frontend/src/main.ts b/medcat-trainer/webapp/frontend/src/main.ts index 713cb7c44..061c7bd63 100644 --- a/medcat-trainer/webapp/frontend/src/main.ts +++ b/medcat-trainer/webapp/frontend/src/main.ts @@ -24,6 +24,7 @@ import * as components from 'vuetify/components' import * as directives from 'vuetify/directives' import {authPlugin} from "./auth"; import { loadRuntimeConfig, isOidcEnabled } from './runtimeConfig'; +import { performStartupCleanup } from './utils/storage-cleanup'; const theme ={ dark: false, @@ -50,6 +51,9 @@ const vuetify = createVuetify({ async function bootstrap() { await loadRuntimeConfig(); + // Clear browser storage on startup to prevent auth state conflicts + performStartupCleanup(); + const app = createApp(App) app.config.globalProperties.$http = axios app.component("v-select", vSelect) diff --git a/medcat-trainer/webapp/frontend/src/tests/utils/storage-cleanup.spec.ts b/medcat-trainer/webapp/frontend/src/tests/utils/storage-cleanup.spec.ts new file mode 100644 index 000000000..fe7fa7b26 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/utils/storage-cleanup.spec.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { performStartupCleanup } from '@/utils/storage-cleanup' + +describe('storageCleanup', () => { + let consoleLogSpy: any + let localStorageMock: { [key: string]: any } + let sessionStorageMock: { [key: string]: any } + + beforeEach(() => { + // Mock console.log + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + // Mock localStorage with proper Object.keys() support + localStorageMock = {} + const localStorageProxy = new Proxy(localStorageMock, { + get(target, prop) { + if (prop === 'getItem') return (key: string) => target[key] || null + if (prop === 'setItem') return (key: string, value: string) => { target[key] = value } + if (prop === 'removeItem') return (key: string) => { delete target[key] } + if (prop === 'clear') return () => { Object.keys(target).forEach(k => delete target[k]) } + if (prop === 'key') return (index: number) => Object.keys(target)[index] || null + if (prop === 'length') return Object.keys(target).length + return target[prop as string] + }, + ownKeys(target) { + return Object.keys(target) + }, + getOwnPropertyDescriptor(target, prop) { + return { + enumerable: true, + configurable: true, + value: target[prop as string] + } + } + }) + vi.stubGlobal('localStorage', localStorageProxy) + + // Mock sessionStorage + sessionStorageMock = {} + const sessionStorageProxy = { + getItem: (key: string) => sessionStorageMock[key] || null, + setItem: (key: string, value: string) => { sessionStorageMock[key] = value }, + removeItem: (key: string) => { delete sessionStorageMock[key] }, + clear: () => { sessionStorageMock = {} }, + key: (index: number) => Object.keys(sessionStorageMock)[index] || null, + get length() { return Object.keys(sessionStorageMock).length } + } as Storage + vi.stubGlobal('sessionStorage', sessionStorageProxy) + + // Mock document.cookie + let cookieStore: string[] = [] + Object.defineProperty(document, 'cookie', { + get: () => cookieStore.join('; '), + set: (value: string) => { + if (value.includes('expires=Thu, 01 Jan 1970')) { + // Cookie deletion + const name = value.split('=')[0] + cookieStore = cookieStore.filter(c => !c.startsWith(name + '=')) + } else { + cookieStore.push(value) + } + }, + configurable: true + }) + + // Mock window.location and URL + delete (window as any).location + window.location = { + href: 'https://example.com/', + hostname: 'example.com' + } as any + + // Mock window.history.replaceState + window.history.replaceState = vi.fn() + }) + + afterEach(() => { + consoleLogSpy.mockRestore() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + describe('performStartupCleanup', () => { + it('should log startup cleanup message', () => { + performStartupCleanup() + expect(consoleLogSpy).toHaveBeenCalledWith('[StorageCleanup] Performing startup cleanup') + }) + + it('should clear application cookies', () => { + // Set some cookies + document.cookie = 'api-token=test123' + document.cookie = 'username=testuser' + document.cookie = 'admin=true' + document.cookie = '_oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDVaV05sTjJJeE1qUXdZVE0wTWpVNE1UYzBaVEJqWm1KaU1tWXdPR' + document.cookie = '_oauth2_proxy_1=mdlsjjsadfhHLFhBLGnbJlhB>j' + document.cookie = 'sessionid=6id701ipjww6rx0gumt0vvz1pnxpy12p' + document.cookie = 'AUTH_SESSION_ID=OTI4Mzk4NmUtZWJhNi' + document.cookie = 'KC_RESTART=eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4..' + document.cookie = 'KEYCLOAK_IDENTITY=eyJhbGciOiJIUzUxMiIsInR5cCI...' + document.cookie = 'KEYCLOAK_SESSION=-9rVzyOy1xEA4sktmgSvv8DriM3ZO4kv-zjrhjuYFkA' + + performStartupCleanup() + + // Cookies should be cleared (setting them with expired date) + // We can't easily verify the exact cookie string, but we can check the function runs + expect(consoleLogSpy).toHaveBeenCalled() + }) + + it('should remove Keycloak items from localStorage', () => { + // Add some localStorage items + localStorage.setItem('kc-callback-e5a2e61c-69c3-430f-b0ba-5ed3c74be34a', {"state":"e5a2e61c-69c3-430f-b0ba-5ed3c74be34a","nonce":"89152b76-263f-41f9-92af-688f5ccc6b37","redirectUri":"https%3A%2F%2Fmedcattrainer.sites.er.kcl.ac.uk%2Ftrain-annotations%2F1%2F1","loginOptions":{},"pkceCodeVerifier":"hiPqBzEFgSBsqggWDOpmZBgwoy12Snso9rPBms1BPUemxDaDVFU1iAbuDMInsmnbJpFYRrQJ3PBoM6fjJb2XNe3mvGNl5iAu","expires":1770212697181}) + localStorage.setItem('keycloak-random', 'instance-data') + localStorage.setItem('other-key', 'should-remain') + + expect(Object.keys(localStorageMock)).toHaveLength(3) + + performStartupCleanup() + + // Only 'other-key' should remain + expect(localStorageMock['other-key']).toBe('should-remain') + expect(localStorageMock['kc-callback-e5a2e61c-69c3-430f-b0ba-5ed3c74be34a']).toBeUndefined() + expect(localStorageMock['keycloak-instance']).toBeUndefined() + expect(Object.keys(localStorageMock)).toHaveLength(1) + }) + + it('should clear all sessionStorage', () => { + // Add some sessionStorage items + sessionStorage.setItem('session-key1', 'value1') + sessionStorage.setItem('session-key2', 'value2') + + expect(Object.keys(sessionStorageMock)).toHaveLength(2) + + performStartupCleanup() + + expect(Object.keys(sessionStorageMock)).toHaveLength(0) + }) + + it('should remove OAuth callback parameters from URL hash', () => { + window.location.href = 'https://medcattrainer.sites.er.kcl.ac.uk/#state=4d6f2da7-5b67-4a1b-b2eb-bc6fe7953206&session_state=6e6c31ef-5d5b-485d-8036-6e155508934f&iss=https%3A%2F%2Fcogstack-auth.sites.er.kcl.ac.uk%2Frealms%2Fcogstack&code=2b13e03c-1961-432a-b736-959c720685fa.6e6c31ef-5d5b-485d-8036-6e155508934f.5eca49a4-3f37-46ce-9570-65aed1ee33c6' + + performStartupCleanup() + + expect(window.history.replaceState).toHaveBeenCalledWith( + {}, + expect.any(String), + expect.stringContaining('https://medcattrainer.sites.er.kcl.ac.uk/') + ) + + // Verify the URL doesn't contain OAuth params + const callArgs = (window.history.replaceState as any).mock.calls[0][2] + expect(callArgs).not.toContain('state=') + expect(callArgs).not.toContain('session_state=') + expect(callArgs).not.toContain('iss=') + expect(callArgs).not.toContain('code=') + }) + + it('should not modify URL if no OAuth parameters present', () => { + window.location.href = 'https://example.com/#/dashboard' + + performStartupCleanup() + + expect(window.history.replaceState).not.toHaveBeenCalled() + }) + + it('should handle URL with only some OAuth parameters', () => { + window.location.href = 'https://example.com/#state=abc123&other=value' + + performStartupCleanup() + + expect(window.history.replaceState).toHaveBeenCalled() + + // Verify 'other' parameter is preserved + const callArgs = (window.history.replaceState as any).mock.calls[0][2] + expect(callArgs).toContain('other=value') + expect(callArgs).not.toContain('state=') + }) + + it('should handle empty hash after removing all OAuth parameters', () => { + window.location.href = 'https://example.com/#state=abc123&code=code123' + + performStartupCleanup() + + expect(window.history.replaceState).toHaveBeenCalled() + + const callArgs = (window.history.replaceState as any).mock.calls[0][2] + // Should just be the base URL without hash or with empty hash + expect(callArgs).toMatch(/https:\/\/example\.com\/#?$/) + }) + + it('should handle localStorage with no Keycloak items', () => { + localStorage.setItem('normal-key', 'normal-value') + + performStartupCleanup() + + // Normal key should remain untouched + expect(localStorageMock['normal-key']).toBe('normal-value') + expect(Object.keys(localStorageMock)).toHaveLength(1) + }) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/utils/storage-cleanup.ts b/medcat-trainer/webapp/frontend/src/utils/storage-cleanup.ts new file mode 100644 index 000000000..9d32ee146 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/utils/storage-cleanup.ts @@ -0,0 +1,50 @@ +/** + * Utility to clear application-specific browser storage on startup to prevent auth state conflicts + */ + +function clearAuthRelatedCookies() { + console.debug('[StorageCleanup] Clearing auth-related cookies') + const cookies = [ + 'api-token', 'username', 'admin', 'user-id', + 'sessionid', 'AUTH_SESSION_ID', 'KC_RESTART', 'KEYCLOAK_IDENTITY', 'KEYCLOAK_SESSION', + '_oauth2_proxy', '_oauth2_proxy_csrf', '_oauth2_proxy_1', '_oauth2_proxy_2' + ] + cookies.forEach(name => { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;` + }) +} + +function clearKeycloakDataFromLocalStorage() { + console.debug('[StorageCleanup] Clearing Keycloak data from localStorage') + Object.keys(localStorage).forEach(key => { + if (key.startsWith('kc') || key.includes('keycloak')) { + localStorage.removeItem(key) + } + }) +} + +function clearSessionStorage() { + console.debug('[StorageCleanup] Clearing sessionStorage') + sessionStorage.clear() +} + +function removeOAuthParamsFromUrl() { + console.debug('[StorageCleanup] Removing OAuth params from URL') + const url = new URL(window.location.href) + const hash = url.hash + + if (hash.includes('state=') || hash.includes('code=')) { + const hashParams = new URLSearchParams(hash.substring(1)) + ;['state', 'session_state', 'iss', 'code'].forEach(param => hashParams.delete(param)) + url.hash = hashParams.toString() ? hashParams.toString() : '' + window.history.replaceState({}, document.title, url.toString()) + } +} + +export function performStartupCleanup(): void { + console.log('[StorageCleanup] Performing startup cleanup') + clearAuthRelatedCookies(); + clearKeycloakDataFromLocalStorage(); + clearSessionStorage(); + removeOAuthParamsFromUrl(); +} From f26c4a6fc5df7a89a8fe51d46ee38191e80f8dee Mon Sep 17 00:00:00 2001 From: jocelyneholdbrook Date: Thu, 5 Feb 2026 14:49:45 +0000 Subject: [PATCH 2/2] feat(medcat-trainer): Remove callback URL fragments after successful login. --- medcat-trainer/webapp/frontend/src/main.ts | 13 ++-- .../webapp/frontend/src/router/index.ts | 60 ++++++++-------- .../src/tests/utils/storage-cleanup.spec.ts | 68 ------------------- .../frontend/src/utils/storage-cleanup.ts | 26 +------ .../webapp/frontend/tsconfig.vitest.json | 2 +- 5 files changed, 42 insertions(+), 127 deletions(-) diff --git a/medcat-trainer/webapp/frontend/src/main.ts b/medcat-trainer/webapp/frontend/src/main.ts index 061c7bd63..fd26d6c26 100644 --- a/medcat-trainer/webapp/frontend/src/main.ts +++ b/medcat-trainer/webapp/frontend/src/main.ts @@ -11,7 +11,7 @@ import '@/assets/main.css' import { createApp } from 'vue' import App from './App.vue' -import router from './router' +import { initialiseRouter } from './router' import axios from 'axios' import VueCookies from 'vue-cookies' import vSelect from 'vue-select' @@ -51,15 +51,11 @@ const vuetify = createVuetify({ async function bootstrap() { await loadRuntimeConfig(); - // Clear browser storage on startup to prevent auth state conflicts - performStartupCleanup(); - const app = createApp(App) app.config.globalProperties.$http = axios app.component("v-select", vSelect) app.component('vue-simple-context-menu', VueSimpleContextMenu) app.component('font-awesome-icon', FontAwesomeIcon) - app.use(router) app.use(VueCookies, { expires: '7d'}) app.use(vuetify); @@ -85,9 +81,16 @@ async function bootstrap() { } app.config.compilerOptions.whitespace = 'preserve' + + // Router is initialised and created after keycloak initialisation as workaround to URL fragments not being removed after successful login + // See: https://github.com/keycloak/keycloak/issues/14742#issuecomment-1663069438 + app.use(initialiseRouter()) app.mount('#app') } +// Clear app storage before the application is bootstrapped +// This prevents stale auth state from being used +performStartupCleanup(); bootstrap() .then(() => console.log('[Bootstrap] Application started successfully')) .catch(error => console.error('[Bootstrap] Failed to start application:', error)) diff --git a/medcat-trainer/webapp/frontend/src/router/index.ts b/medcat-trainer/webapp/frontend/src/router/index.ts index 15eb3f23b..9bec0118c 100644 --- a/medcat-trainer/webapp/frontend/src/router/index.ts +++ b/medcat-trainer/webapp/frontend/src/router/index.ts @@ -1,4 +1,4 @@ -import { createRouter, createWebHistory } from 'vue-router' +import {createRouter, createWebHistory} from 'vue-router' import Home from '../views/Home.vue' import TrainAnnotations from '../views/TrainAnnotations.vue' import Demo from '../views/Demo.vue' @@ -6,46 +6,48 @@ import Metrics from '../views/Metrics.vue' import MetricsHome from '../views/MetricsHome.vue' import ConceptDatabase from '../views/ConceptDatabase.vue' - -const router = createRouter({ - history: createWebHistory(import.meta.env.BASE_URL), - routes: [ +const initialiseRouter = () => { + return createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ { - path: '/train-annotations/:projectId/:docId?', - name: 'train-annotations', - component: TrainAnnotations, - props: true, - // query: true + path: '/train-annotations/:projectId/:docId?', + name: 'train-annotations', + component: TrainAnnotations, + props: true, + // query: true }, { - path: '/metrics-reports/', - name: 'metrics-reports', - component: MetricsHome, + path: '/metrics-reports/', + name: 'metrics-reports', + component: MetricsHome, }, { - path: '/metrics/:reportId/', - name: 'metrics', - component: Metrics, - props: router => ({reportId: parseInt(router.params.reportId)}) + path: '/metrics/:reportId/', + name: 'metrics', + component: Metrics, + props: router => ({reportId: parseInt(router.params.reportId)}) }, { - path: '/demo', - name: 'demo', - component: Demo + path: '/demo', + name: 'demo', + component: Demo }, { - path: '/model-explore', - name: 'model-explore', - component: ConceptDatabase + path: '/model-explore', + name: 'model-explore', + component: ConceptDatabase }, { - path: '/:pathMatch(.*)', - name: 'home', - component: Home + path: '/:pathMatch(.*)', + name: 'home', + component: Home } - ] -}) + ] + }) +} + -export default router +export {initialiseRouter} diff --git a/medcat-trainer/webapp/frontend/src/tests/utils/storage-cleanup.spec.ts b/medcat-trainer/webapp/frontend/src/tests/utils/storage-cleanup.spec.ts index fe7fa7b26..a45607b89 100644 --- a/medcat-trainer/webapp/frontend/src/tests/utils/storage-cleanup.spec.ts +++ b/medcat-trainer/webapp/frontend/src/tests/utils/storage-cleanup.spec.ts @@ -106,23 +106,6 @@ describe('storageCleanup', () => { expect(consoleLogSpy).toHaveBeenCalled() }) - it('should remove Keycloak items from localStorage', () => { - // Add some localStorage items - localStorage.setItem('kc-callback-e5a2e61c-69c3-430f-b0ba-5ed3c74be34a', {"state":"e5a2e61c-69c3-430f-b0ba-5ed3c74be34a","nonce":"89152b76-263f-41f9-92af-688f5ccc6b37","redirectUri":"https%3A%2F%2Fmedcattrainer.sites.er.kcl.ac.uk%2Ftrain-annotations%2F1%2F1","loginOptions":{},"pkceCodeVerifier":"hiPqBzEFgSBsqggWDOpmZBgwoy12Snso9rPBms1BPUemxDaDVFU1iAbuDMInsmnbJpFYRrQJ3PBoM6fjJb2XNe3mvGNl5iAu","expires":1770212697181}) - localStorage.setItem('keycloak-random', 'instance-data') - localStorage.setItem('other-key', 'should-remain') - - expect(Object.keys(localStorageMock)).toHaveLength(3) - - performStartupCleanup() - - // Only 'other-key' should remain - expect(localStorageMock['other-key']).toBe('should-remain') - expect(localStorageMock['kc-callback-e5a2e61c-69c3-430f-b0ba-5ed3c74be34a']).toBeUndefined() - expect(localStorageMock['keycloak-instance']).toBeUndefined() - expect(Object.keys(localStorageMock)).toHaveLength(1) - }) - it('should clear all sessionStorage', () => { // Add some sessionStorage items sessionStorage.setItem('session-key1', 'value1') @@ -135,57 +118,6 @@ describe('storageCleanup', () => { expect(Object.keys(sessionStorageMock)).toHaveLength(0) }) - it('should remove OAuth callback parameters from URL hash', () => { - window.location.href = 'https://medcattrainer.sites.er.kcl.ac.uk/#state=4d6f2da7-5b67-4a1b-b2eb-bc6fe7953206&session_state=6e6c31ef-5d5b-485d-8036-6e155508934f&iss=https%3A%2F%2Fcogstack-auth.sites.er.kcl.ac.uk%2Frealms%2Fcogstack&code=2b13e03c-1961-432a-b736-959c720685fa.6e6c31ef-5d5b-485d-8036-6e155508934f.5eca49a4-3f37-46ce-9570-65aed1ee33c6' - - performStartupCleanup() - - expect(window.history.replaceState).toHaveBeenCalledWith( - {}, - expect.any(String), - expect.stringContaining('https://medcattrainer.sites.er.kcl.ac.uk/') - ) - - // Verify the URL doesn't contain OAuth params - const callArgs = (window.history.replaceState as any).mock.calls[0][2] - expect(callArgs).not.toContain('state=') - expect(callArgs).not.toContain('session_state=') - expect(callArgs).not.toContain('iss=') - expect(callArgs).not.toContain('code=') - }) - - it('should not modify URL if no OAuth parameters present', () => { - window.location.href = 'https://example.com/#/dashboard' - - performStartupCleanup() - - expect(window.history.replaceState).not.toHaveBeenCalled() - }) - - it('should handle URL with only some OAuth parameters', () => { - window.location.href = 'https://example.com/#state=abc123&other=value' - - performStartupCleanup() - - expect(window.history.replaceState).toHaveBeenCalled() - - // Verify 'other' parameter is preserved - const callArgs = (window.history.replaceState as any).mock.calls[0][2] - expect(callArgs).toContain('other=value') - expect(callArgs).not.toContain('state=') - }) - - it('should handle empty hash after removing all OAuth parameters', () => { - window.location.href = 'https://example.com/#state=abc123&code=code123' - - performStartupCleanup() - - expect(window.history.replaceState).toHaveBeenCalled() - - const callArgs = (window.history.replaceState as any).mock.calls[0][2] - // Should just be the base URL without hash or with empty hash - expect(callArgs).toMatch(/https:\/\/example\.com\/#?$/) - }) it('should handle localStorage with no Keycloak items', () => { localStorage.setItem('normal-key', 'normal-value') diff --git a/medcat-trainer/webapp/frontend/src/utils/storage-cleanup.ts b/medcat-trainer/webapp/frontend/src/utils/storage-cleanup.ts index 9d32ee146..34e340ef6 100644 --- a/medcat-trainer/webapp/frontend/src/utils/storage-cleanup.ts +++ b/medcat-trainer/webapp/frontend/src/utils/storage-cleanup.ts @@ -4,9 +4,10 @@ function clearAuthRelatedCookies() { console.debug('[StorageCleanup] Clearing auth-related cookies') + // Omit keycloak cookies ( 'AUTH_SESSION_ID', 'KC_RESTART', 'KEYCLOAK_IDENTITY', 'KEYCLOAK_SESSION',) as removing them breaks the oauth callback flow when OIDC auth is enabled const cookies = [ 'api-token', 'username', 'admin', 'user-id', - 'sessionid', 'AUTH_SESSION_ID', 'KC_RESTART', 'KEYCLOAK_IDENTITY', 'KEYCLOAK_SESSION', + 'sessionid', '_oauth2_proxy', '_oauth2_proxy_csrf', '_oauth2_proxy_1', '_oauth2_proxy_2' ] cookies.forEach(name => { @@ -14,37 +15,14 @@ function clearAuthRelatedCookies() { }) } -function clearKeycloakDataFromLocalStorage() { - console.debug('[StorageCleanup] Clearing Keycloak data from localStorage') - Object.keys(localStorage).forEach(key => { - if (key.startsWith('kc') || key.includes('keycloak')) { - localStorage.removeItem(key) - } - }) -} - function clearSessionStorage() { console.debug('[StorageCleanup] Clearing sessionStorage') sessionStorage.clear() } -function removeOAuthParamsFromUrl() { - console.debug('[StorageCleanup] Removing OAuth params from URL') - const url = new URL(window.location.href) - const hash = url.hash - - if (hash.includes('state=') || hash.includes('code=')) { - const hashParams = new URLSearchParams(hash.substring(1)) - ;['state', 'session_state', 'iss', 'code'].forEach(param => hashParams.delete(param)) - url.hash = hashParams.toString() ? hashParams.toString() : '' - window.history.replaceState({}, document.title, url.toString()) - } -} export function performStartupCleanup(): void { console.log('[StorageCleanup] Performing startup cleanup') clearAuthRelatedCookies(); - clearKeycloakDataFromLocalStorage(); clearSessionStorage(); - removeOAuthParamsFromUrl(); } diff --git a/medcat-trainer/webapp/frontend/tsconfig.vitest.json b/medcat-trainer/webapp/frontend/tsconfig.vitest.json index 1f7f6ce5f..b1c819b68 100644 --- a/medcat-trainer/webapp/frontend/tsconfig.vitest.json +++ b/medcat-trainer/webapp/frontend/tsconfig.vitest.json @@ -1,6 +1,6 @@ { "extends": "./tsconfig.app.json", - "include": ["env.d.ts", "src/tests/**/*.ts"], + "include": ["env.d.ts", "src/tests/**/*.ts", "src/utils/**/*.ts"], "exclude": [], "compilerOptions": { "composite": true,