@sbc-connect/nuxt-auth 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @sbc-connect/nuxt-auth
2
2
 
3
+ ## 0.1.9
4
+
5
+ ### Patch Changes
6
+
7
+ - [#40](https://github.com/bcgov/connect-nuxt/pull/40) [`f4b62e1`](https://github.com/bcgov/connect-nuxt/commit/f4b62e19570ed062399ce7d23ce07abcf682285f) Thanks [@deetz99](https://github.com/deetz99)! - Add login page and auth middleware issue: bcgov/entity#29335
8
+
9
+ - [#38](https://github.com/bcgov/connect-nuxt/pull/38) [`8b8f067`](https://github.com/bcgov/connect-nuxt/commit/8b8f067aba4cda2cd2cd8de5c6f74ccc24eaf822) Thanks [@deetz99](https://github.com/deetz99)! - Add LaunchDarkly composable with user context. issue: bcgov/entity#29335
10
+
3
11
  ## 0.1.8
4
12
 
5
13
  ### Patch Changes
package/README.md CHANGED
@@ -16,7 +16,7 @@ This package provides the necessary composables, plugins, and logic to integrate
16
16
  - login() and logout() helper functions.
17
17
  - Automatic token refreshing and session validation.
18
18
 
19
- For detailed usage and documentation, please see the [Auth Layer Docs](../../../docs/packages/layers/auth/intro.md).
19
+ For detailed usage and documentation, please see the [Auth Layer Docs](../../../docs/packages/layers/auth/overview.md).
20
20
 
21
21
  ## Usage
22
22
 
@@ -46,14 +46,7 @@ Create a file named .env in the root of the project.
46
46
  Copy the contents of the .env.example file into your new .env file.
47
47
 
48
48
  ### Local Development
49
- For local development, you will need credentials for the development instance of the authentication provider.
50
-
51
- ```
52
- # .env
53
- NUXT_PUBLIC_AUTH_PROVIDER_URL="https://dev.oidc.gov.bc.ca/auth"
54
- NUXT_PUBLIC_AUTH_PROVIDER_REALM="your-realm-name"
55
- NUXT_PUBLIC_AUTH_PROVIDER_CLIENT_ID="your-client-id"
56
- ```
49
+ Copy the contents of the **.env.example** file into your new .env file.
57
50
 
58
51
  ### Production Environments
59
52
  > [!IMPORTANT]
package/app/app.config.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  export default defineAppConfig({
2
2
  connect: {
3
3
  login: {
4
- redirectPath: '',
4
+ redirect: '',
5
5
  idps: ['bcsc', 'bceid', 'idir']
6
6
  },
7
+ logout: {
8
+ redirect: ''
9
+ },
7
10
  header: {
8
11
  loginMenu: true,
9
12
  createAccount: true,
@@ -9,8 +9,7 @@ export const useConnectAuth = () => {
9
9
  * @returns A promise that resolves when login is complete.
10
10
  */
11
11
  function login(idpHint: ConnectIdpHint, redirect?: string): Promise<void> {
12
- const loginRedirectUrl = sessionStorage.getItem(ConnectAuthStorageKey.LOGIN_REDIRECT_URL)
13
- const redirectUri = redirect ?? loginRedirectUrl ?? window.location.href
12
+ const redirectUri = redirect ?? window.location.href
14
13
 
15
14
  return $connectAuth.login(
16
15
  {
@@ -27,8 +26,7 @@ export const useConnectAuth = () => {
27
26
  */
28
27
  function logout(redirect?: string): Promise<void> {
29
28
  const siteminderUrl = rtc.siteminderLogoutUrl
30
- const logoutRedirectUrl = sessionStorage.getItem(ConnectAuthStorageKey.LOGOUT_REDIRECT_URL)
31
- let redirectUri = redirect ?? logoutRedirectUrl ?? window.location.href
29
+ let redirectUri = redirect ?? window.location.href
32
30
 
33
31
  if (siteminderUrl) {
34
32
  redirectUri = `${siteminderUrl}?returl=${redirectUri.replace(/(https?:\/\/)|(\/)+/g, '$1$2')}&retnow=1`
@@ -81,30 +79,10 @@ export const useConnectAuth = () => {
81
79
  })
82
80
  }
83
81
 
84
- function setLoginRedirectUrl(url: string) {
85
- sessionStorage.setItem(ConnectAuthStorageKey.LOGIN_REDIRECT_URL, url)
86
- }
87
-
88
- function setLogoutRedirectUrl(url: string) {
89
- sessionStorage.setItem(ConnectAuthStorageKey.LOGOUT_REDIRECT_URL, url)
90
- }
91
-
92
- function clearLoginRedirectUrl() {
93
- sessionStorage.removeItem(ConnectAuthStorageKey.LOGIN_REDIRECT_URL)
94
- }
95
-
96
- function clearLogoutRedirectUrl() {
97
- sessionStorage.removeItem(ConnectAuthStorageKey.LOGOUT_REDIRECT_URL)
98
- }
99
-
100
82
  return {
101
83
  login,
102
84
  logout,
103
85
  getToken,
104
- clearLoginRedirectUrl,
105
- clearLogoutRedirectUrl,
106
- setLoginRedirectUrl,
107
- setLogoutRedirectUrl,
108
86
  isAuthenticated,
109
87
  authUser
110
88
  }
@@ -11,8 +11,9 @@ export function useConnectHeaderOptions() {
11
11
  const route = useRoute()
12
12
  const overlay = useOverlay()
13
13
  const { t, locale: { value: locale } } = useNuxtApp().$i18n
14
- const { login, logout, isAuthenticated, authUser } = useConnectAuth()
14
+ const { login, isAuthenticated, authUser } = useConnectAuth()
15
15
  const accountStore = useConnectAccountStore()
16
+ const localePath = useLocalePath()
16
17
 
17
18
  const whatsNew = useStorage<ConnectWhatsNewState>('connect-whats-new', { viewed: false, items: [] })
18
19
  const slideover = overlay.create(ConnectSlideoverWhatsNew)
@@ -38,7 +39,7 @@ export function useConnectHeaderOptions() {
38
39
  options.push({
39
40
  label: t('connect.label.logout'),
40
41
  icon: 'i-mdi-logout-variant',
41
- onSelect: () => logout()
42
+ onSelect: () => navigateTo(localePath('/auth/logout'))
42
43
  })
43
44
  return options
44
45
  })
@@ -134,8 +135,8 @@ export function useConnectHeaderOptions() {
134
135
  return options
135
136
  })
136
137
 
137
- const loginRedirectUrl = ac.login.redirectPath
138
- ? appBaseUrl + locale + ac.login.redirectPath
138
+ const loginRedirectUrl = ac.login.redirect
139
+ ? appBaseUrl + locale + ac.login.redirect
139
140
  : undefined
140
141
 
141
142
  const loginOptionsMap: Record<'bcsc' | 'bceid' | 'idir',
@@ -0,0 +1,245 @@
1
+ import { initialize } from 'launchdarkly-js-client-sdk'
2
+ import type { LDClient, LDFlagSet, LDOptions, LDMultiKindContext } from 'launchdarkly-js-client-sdk'
3
+ import { isEqual } from 'es-toolkit'
4
+
5
+ // default anon context
6
+ const anonymousContext: LDMultiKindContext = {
7
+ kind: 'multi',
8
+ org: { key: 'anonymous' },
9
+ user: { key: 'anonymous' }
10
+ }
11
+
12
+ // state
13
+ // Defined outside of composable, so they are created only once and shared
14
+ const ldClient = shallowRef<LDClient | null>(null)
15
+ const ldFlagSet = shallowRef<LDFlagSet>({})
16
+ const ldContext = shallowRef<LDMultiKindContext>(anonymousContext)
17
+ const ldInitialized = ref(false)
18
+ const isInitializing = ref(false)
19
+
20
+ function _createLdContext(): LDMultiKindContext {
21
+ const appName = useRuntimeConfig().public.appName
22
+ const { authUser, isAuthenticated } = useConnectAuth()
23
+ const account = useConnectAccountStore().currentAccount
24
+
25
+ if (!isAuthenticated.value) {
26
+ return anonymousContext
27
+ }
28
+
29
+ // Create user context
30
+ const user = {
31
+ key: authUser.value.keycloakGuid,
32
+ firstName: authUser.value.firstName,
33
+ lastName: authUser.value.lastName,
34
+ email: authUser.value.email,
35
+ roles: authUser.value.roles,
36
+ loginSource: authUser.value.loginSource,
37
+ appSource: appName
38
+ }
39
+
40
+ // Default org to user key if no account
41
+ let org: Partial<ConnectAccount & { key: string, appSource: string }> = { key: user.key, appSource: appName }
42
+
43
+ // Use account info if available
44
+ if (account.id) {
45
+ org = {
46
+ key: String(account.id),
47
+ accountType: account.accountType,
48
+ accountStatus: account.accountStatus,
49
+ type: account.type,
50
+ label: account.label,
51
+ appSource: appName
52
+ }
53
+ }
54
+
55
+ return { kind: 'multi', org, user }
56
+ }
57
+
58
+ function _updateLdContext() {
59
+ if (!ldClient.value) {
60
+ return
61
+ }
62
+
63
+ const newContext = _createLdContext()
64
+ if (isEqual(ldContext.value, newContext)) {
65
+ return
66
+ }
67
+
68
+ ldContext.value = newContext
69
+ ldClient.value.identify(newContext).then(() => {
70
+ ldFlagSet.value = ldClient.value?.allFlags() || {}
71
+ }).catch((error) => {
72
+ console.error('LaunchDarkly: Failed to update context.', error)
73
+ })
74
+ }
75
+
76
+ /**
77
+ * Initializes the LaunchDarkly client.
78
+ */
79
+ function _init(): void {
80
+ const rtc = useRuntimeConfig().public
81
+
82
+ // Prevent re-initialization
83
+ if (ldInitialized.value || isInitializing.value) {
84
+ return
85
+ }
86
+ // Prevent initialization if missing client ID
87
+ if (!rtc.ldClientId) {
88
+ console.error('LaunchDarkly: ldClientId is not configured.')
89
+ return
90
+ }
91
+
92
+ isInitializing.value = true
93
+
94
+ ldContext.value = _createLdContext()
95
+
96
+ const options: LDOptions = {
97
+ streaming: false,
98
+ useReport: false,
99
+ diagnosticOptOut: true
100
+ }
101
+
102
+ try {
103
+ ldClient.value = initialize(rtc.ldClientId, ldContext.value, options)
104
+
105
+ ldClient.value.on('initialized', () => {
106
+ ldInitialized.value = true
107
+ isInitializing.value = false
108
+ ldFlagSet.value = ldClient.value?.allFlags() || {}
109
+ console.info('LaunchDarkly: Anonymous initialization complete.')
110
+ })
111
+
112
+ ldClient.value.on('error', (error) => {
113
+ console.error('LaunchDarkly: Initialization error.', error)
114
+ isInitializing.value = false
115
+ })
116
+ } catch (error) {
117
+ console.error('LaunchDarkly: Failed to initialize.', error)
118
+ isInitializing.value = false
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Composable for the LaunchDarkly service.
124
+ */
125
+ export const useConnectLaunchDarkly = () => {
126
+ // initialize only once
127
+ if (!ldInitialized.value && !isInitializing.value && import.meta.client) {
128
+ _init()
129
+ }
130
+
131
+ const { isAuthenticated } = useConnectAuth()
132
+ const accountStore = useConnectAccountStore()
133
+
134
+ watch(
135
+ [isAuthenticated, () => accountStore.currentAccount],
136
+ () => {
137
+ _updateLdContext()
138
+ },
139
+ { immediate: true }
140
+ )
141
+
142
+ /**
143
+ * Returns a flag's value. Can operate in two modes.
144
+ * @param name The name of the feature flag.
145
+ * @param defaultValue The value to use until the flag is loaded.
146
+ * @param mode - 'reactive' (default) returns a ref that updates automatically.
147
+ * 'await' returns a promise that resolves when the client is ready.
148
+ * @returns A readonly ref or a promise resolving to the flag's value.
149
+ */
150
+ function getFeatureFlag<T>(
151
+ name: string,
152
+ defaultValue: T,
153
+ mode: 'await'
154
+ ): Promise<T>
155
+ function getFeatureFlag<T>(
156
+ name: string,
157
+ defaultValue: T,
158
+ mode?: 'reactive'
159
+ ): Readonly<Ref<T>>
160
+ function getFeatureFlag<T>(
161
+ name: string,
162
+ defaultValue?: T,
163
+ mode?: 'reactive'
164
+ ): Readonly<Ref<T | undefined>>
165
+ function getFeatureFlag<T>(
166
+ name: string,
167
+ defaultValue?: T,
168
+ mode: 'reactive' | 'await' = 'reactive'
169
+ ): Readonly<Ref<T | undefined>> | Promise<T | undefined> {
170
+ if (mode === 'await') {
171
+ if (!ldClient.value) {
172
+ return Promise.resolve(defaultValue)
173
+ }
174
+ return ldClient.value.waitUntilReady()
175
+ .then(() => ldClient.value ? ldClient.value.variation(name, defaultValue) : defaultValue)
176
+ .catch((error) => {
177
+ console.error(`LaunchDarkly: Error waiting for client while getting flag "${name}".`, error)
178
+ return defaultValue
179
+ })
180
+ }
181
+
182
+ return readonly(computed(() => {
183
+ if (!ldClient.value || !ldInitialized.value || !ldFlagSet.value) {
184
+ return defaultValue
185
+ }
186
+ return ldClient.value.variation(name, defaultValue)
187
+ }))
188
+ }
189
+
190
+ /**
191
+ * Returns a flag's value from the locally stored flag set. Can operate in two modes.
192
+ * @param name The name of the feature flag.
193
+ * @param defaultValue The value to use until the flag is loaded.
194
+ * @param mode - 'reactive' (default) returns a ref that updates automatically.
195
+ * 'await' returns a promise that resolves when the client is ready.
196
+ * @returns A readonly ref or a promise resolving to the flag's value.
197
+ */
198
+ async function getStoredFlag<T>(name: string, defaultValue: T, mode: 'await'): Promise<T>
199
+ function getStoredFlag<T>(name: string, defaultValue?: T, mode?: 'reactive'): Readonly<Ref<T>>
200
+ function getStoredFlag<T>(
201
+ name: string,
202
+ defaultValue: T,
203
+ mode: 'reactive' | 'await' = 'reactive'
204
+ ): Readonly<Ref<T>> | Promise<T> {
205
+ if (mode === 'await') {
206
+ if (!ldClient.value) {
207
+ return Promise.resolve(defaultValue)
208
+ }
209
+ return ldClient.value.waitUntilReady()
210
+ .then(() => ldFlagSet.value[name] ?? defaultValue)
211
+ .catch((error) => {
212
+ console.error(`LaunchDarkly: Error waiting for client while getting stored flag "${name}".`, error)
213
+ return defaultValue
214
+ })
215
+ }
216
+
217
+ // reactive mode
218
+ return readonly(computed(() => {
219
+ if (!ldInitialized.value || !ldFlagSet.value) {
220
+ return defaultValue
221
+ }
222
+ return ldFlagSet.value[name] ?? defaultValue
223
+ }))
224
+ }
225
+
226
+ /**
227
+ * Resets the LaunchDarkly state and closes the client connection.
228
+ */
229
+ const $reset = () => {
230
+ ldInitialized.value = false
231
+ isInitializing.value = false
232
+ ldClient.value?.close()
233
+ ldClient.value = null
234
+ ldFlagSet.value = {}
235
+ }
236
+
237
+ return {
238
+ getFeatureFlag,
239
+ getStoredFlag,
240
+ ldInitialized: readonly(ldInitialized),
241
+ ldFlagSet: readonly(ldFlagSet),
242
+ ldClient: readonly(ldClient),
243
+ $reset
244
+ }
245
+ }
@@ -1,5 +1,3 @@
1
1
  export enum ConnectAuthStorageKey {
2
- LOGIN_REDIRECT_URL = 'sbc-connect-login-redirect-url',
3
- LOGOUT_REDIRECT_URL = 'sbc-connect-logout-redirect-url',
4
2
  CONNECT_SESSION_EXPIRED = 'connect-session-expired'
5
3
  }
@@ -0,0 +1,9 @@
1
+ export default defineNuxtRouteMiddleware((to) => {
2
+ const { isAuthenticated } = useConnectAuth()
3
+ const rtc = useRuntimeConfig().public
4
+ const localePath = useLocalePath()
5
+
6
+ if (!isAuthenticated.value) {
7
+ return navigateTo(localePath(`/auth/login?return=${rtc.baseUrl}${to.fullPath.slice(1)}`))
8
+ }
9
+ })
@@ -0,0 +1,99 @@
1
+ <script setup lang="ts">
2
+ import loginImage from '#auth/public/img/BCReg_Generic_Login_image.jpg'
3
+
4
+ const { t, locale } = useI18n()
5
+ const { login } = useConnectAuth()
6
+ const rtc = useRuntimeConfig().public
7
+ const ac = useAppConfig().connect
8
+ const route = useRoute()
9
+
10
+ useHead({
11
+ title: t('connect.page.login.title')
12
+ })
13
+
14
+ definePageMeta({
15
+ layout: 'connect-auth',
16
+ hideBreadcrumbs: true,
17
+ middleware: async (to) => {
18
+ const { $connectAuth, $router, _appConfig } = useNuxtApp()
19
+ if ($connectAuth.authenticated) {
20
+ if (to.query.return) {
21
+ window.location.replace(to.query.return as string)
22
+ return
23
+ }
24
+ await $router.push(_appConfig.connect.login.redirect || '/')
25
+ }
26
+ }
27
+ })
28
+
29
+ const isSessionExpired = sessionStorage.getItem(ConnectAuthStorageKey.CONNECT_SESSION_EXPIRED)
30
+
31
+ const loginOptions = computed(() => {
32
+ const urlReturn = route.query.return
33
+ const redirectUrl = urlReturn !== undefined
34
+ ? urlReturn as string
35
+ : `${rtc.baseUrl}${locale.value}${ac.login.redirect}`
36
+
37
+ const loginOptionsMap: Record<
38
+ 'bcsc' | 'bceid' | 'idir',
39
+ { label: string, icon: string, onClick: () => Promise<void> }
40
+ > = {
41
+ bcsc: {
42
+ label: t('connect.page.login.loginBCSC'),
43
+ icon: 'i-mdi-account-card-details-outline',
44
+ onClick: () => login(ConnectIdpHint.BCSC, redirectUrl)
45
+ },
46
+ bceid: {
47
+ label: t('connect.page.login.loginBCEID'),
48
+ icon: 'i-mdi-two-factor-authentication',
49
+ onClick: () => login(ConnectIdpHint.BCEID, redirectUrl)
50
+ },
51
+ idir: {
52
+ label: t('connect.page.login.loginIDIR'),
53
+ icon: 'i-mdi-account-group-outline',
54
+ onClick: () => login(ConnectIdpHint.IDIR, redirectUrl)
55
+ }
56
+ }
57
+
58
+ return ac.login.idps.map(key => loginOptionsMap[key as keyof typeof loginOptionsMap])
59
+ })
60
+ </script>
61
+
62
+ <template>
63
+ <div class="flex grow flex-col items-center justify-center py-10">
64
+ <div class="flex flex-col items-center gap-10">
65
+ <h1>
66
+ {{ $t('connect.page.login.h1') }}
67
+ </h1>
68
+ <UAlert
69
+ v-if="isSessionExpired"
70
+ color="warning"
71
+ variant="subtle"
72
+ :title="$t('connect.page.login.sessionExpiredAlert.title')"
73
+ :description="$t('connect.page.login.sessionExpiredAlert.description')"
74
+ icon="i-mdi-alert"
75
+ />
76
+ <UCard class="my-auto max-w-md">
77
+ <img
78
+ :src="loginImage"
79
+ class="pb-4"
80
+ :alt="$t('connect.text.imageAltGenericLogin')"
81
+ >
82
+ <div class="space-y-4 pt-2.5">
83
+ <div
84
+ v-for="(option, i) in loginOptions"
85
+ :key="option.label"
86
+ class="flex flex-col items-center gap-1"
87
+ >
88
+ <UButton
89
+ :variant="i === 0 ? 'solid' : 'outline'"
90
+ block
91
+ class="py-2.5"
92
+ v-bind="option"
93
+ />
94
+ </div>
95
+ </div>
96
+ </UCard>
97
+ </div>
98
+ </div>
99
+ </template>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const rtc = useRuntimeConfig().public
4
+ const ac = useAppConfig().connect
5
+ const { locale } = useI18n()
6
+ const { logout } = useConnectAuth()
7
+
8
+ const redirectUrl = computed(() => {
9
+ const urlReturn = route.query.return
10
+ const url = urlReturn !== undefined
11
+ ? urlReturn as string
12
+ : `${rtc.baseUrl}${locale.value}${ac.login.redirect}`
13
+ return url
14
+ })
15
+
16
+ onMounted(async () => {
17
+ await logout(redirectUrl.value)
18
+ })
19
+ </script>
20
+
21
+ <template>
22
+ <ConnectSpinner fullscreen />
23
+ </template>
@@ -2,9 +2,12 @@ declare module '@nuxt/schema' {
2
2
  interface AppConfigInput {
3
3
  connect?: {
4
4
  login?: {
5
- redirectPath?: string
5
+ redirect?: string
6
6
  idps?: Array<'bcsc' | 'bceid' | 'idir'>
7
7
  }
8
+ logout?: {
9
+ redirect?: string
10
+ }
8
11
  header?: {
9
12
  loginMenu?: boolean
10
13
  createAccount?: boolean
@@ -21,6 +21,19 @@ export default {
21
21
  teamMembers: 'Team Members',
22
22
  transactions: 'Transactions'
23
23
  },
24
+ page: {
25
+ login: {
26
+ h1: 'SBC Connect Account Login',
27
+ title: 'Log in - SBC Connect',
28
+ loginBCSC: 'Login with BC Services Card',
29
+ loginBCEID: 'Login with BCeID',
30
+ loginIDIR: 'Login with IDIR',
31
+ sessionExpiredAlert: {
32
+ title: 'Session Expired',
33
+ description: 'Your session has expired. Please log in again to continue.'
34
+ }
35
+ }
36
+ },
24
37
  sessionExpiry: {
25
38
  title: 'Session Expiring Soon',
26
39
  content: 'Your session is about to expire due to inactivity. You will be logged out in {boldStart}0{boldEnd} seconds. Press any key to continue your session. | Your session is about to expire due to inactivity. You will be logged out in {boldStart}1{boldEnd} second. Press any key to continue your session. | Your session is about to expire due to inactivity. You will be logged out in {boldStart}{count}{boldEnd} seconds. Press any key to continue your session.',
@@ -31,6 +44,7 @@ export default {
31
44
  }
32
45
  },
33
46
  text: {
47
+ imageAltGenericLogin: 'Generic Login Image',
34
48
  notifications: {
35
49
  none: 'No Notifications',
36
50
  teamMemberApproval: '{count} team member requires approval to access this account. | {count} team members require approval to access this account.'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sbc-connect/nuxt-auth",
3
3
  "type": "module",
4
- "version": "0.1.8",
4
+ "version": "0.1.9",
5
5
  "repository": "github:bcgov/connect-nuxt",
6
6
  "license": "BSD-3-Clause",
7
7
  "main": "./nuxt.config.ts",
@@ -12,8 +12,8 @@
12
12
  "nuxt": "^4.0.2",
13
13
  "typescript": "^5.8.3",
14
14
  "vue-tsc": "^3.0.4",
15
- "@sbc-connect/eslint-config": "0.0.6",
16
15
  "@sbc-connect/playwright-config": "0.0.6",
16
+ "@sbc-connect/eslint-config": "0.0.6",
17
17
  "@sbc-connect/vitest-config": "0.0.6"
18
18
  },
19
19
  "dependencies": {
@@ -33,7 +33,8 @@
33
33
  "lint": "eslint .",
34
34
  "lint:fix": "eslint --fix .",
35
35
  "test": "pnpm run test:unit; pnpm run test:e2e",
36
- "test:unit": "vitest run",
36
+ "test:unit": "vitest run --passWithNoTests",
37
+ "test:unit:watch": "vitest",
37
38
  "test:e2e": "npx playwright test",
38
39
  "typecheck": "npx nuxt typecheck"
39
40
  }