From 260fe9939e793f874d394a2142c95578dafca728 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Wed, 2 Sep 2026 00:05:03 +0000 Subject: [PATCH 1/3] feat: add email verification --- apps/lachesis/README.md | 10 + .../app/composables/clotho/useAuth.ts | 7 +- .../lachesis/app/lib/profile-query-options.ts | 21 + apps/lachesis/app/lib/query-client-options.ts | 20 + apps/lachesis/app/plugins/auth.ts | 5 +- apps/lachesis/app/plugins/clotho.ts | 43 +- apps/lachesis/package.json | 1 + .../tests/profile-query-options.test.ts | 370 ++++++++++++++++++ 8 files changed, 453 insertions(+), 24 deletions(-) create mode 100644 apps/lachesis/app/lib/profile-query-options.ts create mode 100644 apps/lachesis/app/lib/query-client-options.ts create mode 100644 apps/lachesis/tests/profile-query-options.test.ts diff --git a/apps/lachesis/README.md b/apps/lachesis/README.md index 05db37c..8afb240 100644 --- a/apps/lachesis/README.md +++ b/apps/lachesis/README.md @@ -25,6 +25,16 @@ yarn install bun install ``` +## Testing + +Run the focused Lachesis profile-query test from the repository root: + +```bash +npm test --workspace=@lepse/lachesis +``` + +This is currently a local/manual check; the release workflow builds Lachesis but does not run this frontend test. + ## Development Server Start the development server on `http://localhost:3000`: diff --git a/apps/lachesis/app/composables/clotho/useAuth.ts b/apps/lachesis/app/composables/clotho/useAuth.ts index 182a566..350d076 100644 --- a/apps/lachesis/app/composables/clotho/useAuth.ts +++ b/apps/lachesis/app/composables/clotho/useAuth.ts @@ -1,10 +1,13 @@ import { useMutation, useQuery } from '@tanstack/vue-query' +import { profileQueryOptions } from '~/lib/profile-query-options' export const useAuth = () => { const { $api, $queryClient } = useNuxtApp() const token = useCookie('auth_token', { maxAge: 60 * 60 * 24 * 365 /* one year */ }) - const userQuery = useQuery($api.account.profile.show.queryOptions()) + const userQuery = useQuery( + $api.account.profile.show.queryOptions(undefined, profileQueryOptions(token)) + ) const user = computed(() => userQuery.data.value?.data) const loginMutation = useMutation( @@ -29,7 +32,7 @@ export const useAuth = () => { $api.auth.accessToken.destroy.mutationOptions({ onSuccess: () => { token.value = null - $queryClient.resetQueries({ queryKey: $api.account.profile.show.queryKey() }) + $queryClient.removeQueries({ queryKey: $api.account.profile.show.queryKey() }) }, }) ) diff --git a/apps/lachesis/app/lib/profile-query-options.ts b/apps/lachesis/app/lib/profile-query-options.ts new file mode 100644 index 0000000..89d3545 --- /dev/null +++ b/apps/lachesis/app/lib/profile-query-options.ts @@ -0,0 +1,21 @@ +import { computed, type ComputedRef } from 'vue' + +export const profileQueryLifecycleOptions = { + // The profile is the authority for authentication and email verification state. + staleTime: 0, + refetchOnMount: 'always', + refetchOnWindowFocus: true, +} as const + +type AuthToken = { + value: string | null | undefined +} + +type ProfileQueryOptions = typeof profileQueryLifecycleOptions & { + enabled: ComputedRef +} + +export const profileQueryOptions = (token: AuthToken): ProfileQueryOptions => ({ + ...profileQueryLifecycleOptions, + enabled: computed(() => Boolean(token.value)), +}) diff --git a/apps/lachesis/app/lib/query-client-options.ts b/apps/lachesis/app/lib/query-client-options.ts new file mode 100644 index 0000000..a46a50e --- /dev/null +++ b/apps/lachesis/app/lib/query-client-options.ts @@ -0,0 +1,20 @@ +import { TuyauHTTPError } from '@tuyau/core/client' + +export const appQueryCacheLifetime = 1000 * 60 * 60 * 24 * 7 + +export const appQueryDefaults = { + queries: { + retry: (failureCount: number, error: unknown) => { + if ( + error instanceof TuyauHTTPError && + ([401, 404, 429].includes(error.status ?? 0) || /^5\d\d$/.test(String(error.status))) + ) { + return false + } + return failureCount < 3 + }, + refetchOnWindowFocus: false, + refetchOnMount: false, + gcTime: appQueryCacheLifetime, + }, +} diff --git a/apps/lachesis/app/plugins/auth.ts b/apps/lachesis/app/plugins/auth.ts index 97ea5f2..2038d76 100644 --- a/apps/lachesis/app/plugins/auth.ts +++ b/apps/lachesis/app/plugins/auth.ts @@ -2,8 +2,11 @@ export default defineNuxtPlugin({ name: 'auth', dependsOn: ['clotho'], async setup(app) { + const token = useCookie('auth_token') + if (!token.value) return + await app.$queryClient.prefetchQuery( - app.$api.account.profile.show.queryOptions({}, { retry: false }) + app.$api.account.profile.show.queryOptions(undefined, { retry: false }) ) }, }) diff --git a/apps/lachesis/app/plugins/clotho.ts b/apps/lachesis/app/plugins/clotho.ts index 178c4ff..f436509 100644 --- a/apps/lachesis/app/plugins/clotho.ts +++ b/apps/lachesis/app/plugins/clotho.ts @@ -1,10 +1,11 @@ import { registry } from '@lepse/clotho/registry' -import { createTuyau, TuyauHTTPError } from '@tuyau/core/client' +import { createTuyau } from '@tuyau/core/client' import { persistQueryClient } from '@tanstack/query-persist-client-core' import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister' -import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' +import { focusManager, QueryClient, VueQueryPlugin } from '@tanstack/vue-query' import { createTuyauVueQueryClient } from '@tuyau/vue-query' import { toast } from 'vue-sonner' +import { appQueryDefaults } from '~/lib/query-client-options' export default defineNuxtPlugin({ name: 'clotho', @@ -12,26 +13,26 @@ export default defineNuxtPlugin({ const config = useRuntimeConfig() const token = useCookie('auth_token') - // Use tanstack/vue-query - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: (failureCount, error) => { - if ( - error instanceof TuyauHTTPError && - ([401, 404, 429].includes(error.status ?? 0) || /^5\d\d$/.test(String(error.status))) - ) { - return false - } - return failureCount < 3 - }, - // refetches will be done manually to avoid calling it so many times. - refetchOnWindowFocus: false, - refetchOnMount: false, - gcTime: 1000 * 60 * 60 * 24 * 7, - }, - }, + // Refetch opt-in queries when this window becomes active as well as when its tab becomes + // visible. The verification link is served by Clotho, so the app cannot use a same-origin + // cache event to notify this window. + focusManager.setEventListener((onFocus) => { + if (typeof window === 'undefined') return + + const handleFocus = () => onFocus() + window.addEventListener('visibilitychange', handleFocus) + window.addEventListener('focus', handleFocus) + + return () => { + window.removeEventListener('visibilitychange', handleFocus) + window.removeEventListener('focus', handleFocus) + } }) + + // Use tanstack/vue-query + // Most refetches are manual to avoid redundant requests. The authenticated profile query + // opts into focus and mount refetches in useAuth. + const queryClient = new QueryClient({ defaultOptions: appQueryDefaults }) app.vueApp.use(VueQueryPlugin, { queryClient, clientPersister: (queryClient) => diff --git a/apps/lachesis/package.json b/apps/lachesis/package.json index fcf4b76..d4d10d7 100644 --- a/apps/lachesis/package.json +++ b/apps/lachesis/package.json @@ -9,6 +9,7 @@ "tdev": "tauri dev", "tdev:cef": "tauri dev --features cef", "generate": "nuxt generate", + "test": "node --test --experimental-strip-types tests/profile-query-options.test.ts", "preview": "npm run generate && wrangler dev", "postinstall": "nuxt prepare", "deploy": "npm run generate && wrangler deploy", diff --git a/apps/lachesis/tests/profile-query-options.test.ts b/apps/lachesis/tests/profile-query-options.test.ts new file mode 100644 index 0000000..6468bcc --- /dev/null +++ b/apps/lachesis/tests/profile-query-options.test.ts @@ -0,0 +1,370 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { effectScope, nextTick, ref } from 'vue' +import { createTuyau } from '@tuyau/core/client' +import { createTuyauVueQueryClient } from '@tuyau/vue-query' +import { + dehydrate, + focusManager, + hydrate, + QueryClient, + QueryObserver, + useQuery, +} from '@tanstack/vue-query' +import { registry } from '@lepse/clotho/registry' +import { appQueryDefaults } from '../app/lib/query-client-options.ts' +import { + profileQueryLifecycleOptions, + profileQueryOptions, +} from '../app/lib/profile-query-options.ts' + +type Profile = { + emailVerified: boolean +} + +type ProfileResponse = { + data: Profile +} + +type ProfileServer = { + profile: Profile + requests: number +} + +const tuyauClient = createTuyau({ + baseUrl: 'https://clotho.test', + registry, +}) +const api = createTuyauVueQueryClient({ client: tuyauClient }) +const profileEndpoint = api.account.profile.show +const profileQueryKey = profileEndpoint.queryKey() + +function createAppQueryClient() { + return new QueryClient({ defaultOptions: appQueryDefaults }) +} + +function createProfileApi(server: ProfileServer) { + const client = createTuyau({ + baseUrl: 'https://clotho.test', + registry, + fetch: async () => { + server.requests += 1 + return new Response(JSON.stringify({ data: server.profile }), { + headers: { 'content-type': 'application/json' }, + }) + }, + }) + + return createTuyauVueQueryClient({ client }) +} + +function waitForProfile( + observer: QueryObserver, + emailVerified: boolean +): Promise { + return new Promise((resolve, reject) => { + let settled = false + let unsubscribe = () => {} + const timeout = setTimeout(() => { + settled = true + unsubscribe() + reject(new Error(`Profile did not become ${emailVerified ? 'verified' : 'unverified'}`)) + }, 1000) + + const finish = (profile: Profile) => { + if (settled) return + settled = true + clearTimeout(timeout) + unsubscribe() + resolve(profile) + } + + unsubscribe = observer.subscribe((result) => { + if (result.isSuccess && result.data?.emailVerified === emailVerified) { + finish(result.data) + } + }) + + // QueryObserver can synchronously notify before subscribe returns. + if (settled) unsubscribe() + }) +} + +function waitForValue( + readValue: () => T | undefined, + predicate: (value: T) => boolean, + message: string +): Promise { + return new Promise((resolve, reject) => { + let settled = false + let pollTimer: ReturnType | undefined + const timeout = setTimeout(() => { + settled = true + if (pollTimer) clearTimeout(pollTimer) + reject(new Error(message)) + }, 1000) + + const check = () => { + if (settled) return + + const value = readValue() + if (value !== undefined && predicate(value)) { + settled = true + clearTimeout(timeout) + resolve(value) + return + } + + pollTimer = setTimeout(check, 0) + } + + check() + }) +} + +function waitForQueryTurn() { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +test('focus refetch replaces an open app profile with the verified server state', async () => { + const queryClient = createAppQueryClient() + queryClient.mount() + let serverProfile: Profile = { emailVerified: false } + let fetches = 0 + const observer = new QueryObserver(queryClient, { + queryKey: profileQueryKey, + queryFn: async () => { + fetches += 1 + return serverProfile + }, + ...profileQueryLifecycleOptions, + }) + const keepObserverMounted = observer.subscribe(() => {}) + + try { + await waitForProfile(observer, false) + + serverProfile = { emailVerified: true } + const verified = waitForProfile(observer, true) + focusManager.setFocused(false) + focusManager.setFocused(true) + + assert.equal((await verified).emailVerified, true) + assert.equal(fetches, 2) + } finally { + keepObserverMounted() + observer.destroy() + queryClient.unmount() + queryClient.clear() + focusManager.setFocused(undefined) + } +}) + +test('mount refetch replaces a persisted stale profile after a full reload', async () => { + const persistedClient = createAppQueryClient() + persistedClient.setQueryData(profileQueryKey, { emailVerified: false }) + const persistedState = dehydrate(persistedClient) + persistedClient.clear() + + const queryClient = createAppQueryClient() + hydrate(queryClient, persistedState) + let fetches = 0 + const observer = new QueryObserver(queryClient, { + queryKey: profileQueryKey, + queryFn: async () => { + fetches += 1 + return { emailVerified: true } + }, + ...profileQueryLifecycleOptions, + }) + const keepObserverMounted = observer.subscribe(() => {}) + + try { + assert.equal((await waitForProfile(observer, true)).emailVerified, true) + assert.equal(fetches, 1) + } finally { + keepObserverMounted() + observer.destroy() + queryClient.unmount() + queryClient.clear() + } +}) + +test('application defaults do not refetch a stale profile on focus without lifecycle options', async () => { + const queryClient = createAppQueryClient() + queryClient.mount() + let serverProfile: Profile = { emailVerified: false } + let fetches = 0 + const observer = new QueryObserver(queryClient, { + queryKey: profileQueryKey, + queryFn: async () => { + fetches += 1 + return serverProfile + }, + }) + const keepObserverMounted = observer.subscribe(() => {}) + + try { + await waitForProfile(observer, false) + + serverProfile = { emailVerified: true } + focusManager.setFocused(false) + focusManager.setFocused(true) + await waitForQueryTurn() + + assert.equal(fetches, 1) + assert.equal(observer.getCurrentResult().data?.emailVerified, false) + } finally { + keepObserverMounted() + observer.destroy() + queryClient.unmount() + queryClient.clear() + focusManager.setFocused(undefined) + } +}) + +test('profile query options use the application key shared by mutation writers', () => { + const token = ref(null) + const profileOptions = profileEndpoint.queryOptions(undefined, profileQueryOptions(token)) + + assert.deepEqual(profileOptions.queryKey, profileEndpoint.queryKey()) + assert.equal(profileOptions.enabled?.value, false) + assert.notDeepEqual(profileEndpoint.queryOptions({}).queryKey, profileEndpoint.queryKey()) + + const queryClient = createAppQueryClient() + const profile = { emailVerified: false } + queryClient.setQueryData(profileEndpoint.queryKey(), { data: profile }) + + assert.deepEqual(queryClient.getQueryData(profileOptions.queryKey), { + data: profile, + }) + queryClient.clear() +}) + +test('auth startup skips profile prefetch without a token', async () => { + const globalObject = globalThis as Record + const previousDefineNuxtPlugin = globalObject.defineNuxtPlugin + const previousUseCookie = globalObject.useCookie + const token = ref(null) + const queryOptionsCalls: unknown[][] = [] + let prefetches = 0 + + globalObject.defineNuxtPlugin = (plugin) => plugin + globalObject.useCookie = () => token + + try { + const { default: authPlugin } = + await import('../app/plugins/auth.ts?profile-query-options-test') + const setup = (authPlugin as { setup: (app: unknown) => Promise }).setup + const app = { + $api: { + account: { + profile: { + show: { + queryOptions: (...args: unknown[]) => { + queryOptionsCalls.push(args) + return {} + }, + }, + }, + }, + }, + $queryClient: { + prefetchQuery: async () => { + prefetches += 1 + }, + }, + } + + await setup(app) + assert.equal(prefetches, 0) + + token.value = 'test-token' + await setup(app) + assert.equal(prefetches, 1) + assert.equal(queryOptionsCalls[0]?.[0], undefined) + assert.deepEqual(queryOptionsCalls[0]?.[1], { retry: false }) + } finally { + if (previousDefineNuxtPlugin === undefined) delete globalObject.defineNuxtPlugin + else globalObject.defineNuxtPlugin = previousDefineNuxtPlugin + if (previousUseCookie === undefined) delete globalObject.useCookie + else globalObject.useCookie = previousUseCookie + } +}) + +test('an anonymous profile observer does not refetch on focus and reacts immediately after login', async () => { + const queryClient = createAppQueryClient() + queryClient.mount() + const token = ref(null) + const siblingToken = ref(null) + const server: ProfileServer = { + profile: { emailVerified: true }, + requests: 0, + } + const api = createProfileApi(server) + const endpoint = api.account.profile.show + queryClient.setQueryData(endpoint.queryKey(), { + data: { emailVerified: false }, + }) + const cachedQuery = queryClient.getQueryCache().find({ queryKey: endpoint.queryKey() }) + + const scope = effectScope() + let userQuery: ReturnType | undefined + let siblingQuery: ReturnType | undefined + scope.run(() => { + userQuery = useQuery( + api.account.profile.show.queryOptions(undefined, profileQueryOptions(token)), + queryClient + ) + siblingQuery = useQuery( + api.account.profile.show.queryOptions(undefined, profileQueryOptions(siblingToken)), + queryClient + ) + }) + const query = userQuery! + const sibling = siblingQuery! + + try { + const cachedProfile = query.data.value as ProfileResponse | undefined + assert.equal(cachedProfile?.data.emailVerified, false) + + for (let i = 0; i < 2; i += 1) { + focusManager.setFocused(false) + focusManager.setFocused(true) + } + await waitForQueryTurn() + assert.equal(server.requests, 0) + + token.value = 'test-token' + siblingToken.value = 'test-token' + await nextTick() + const freshProfile = await waitForValue( + () => (query.data.value as ProfileResponse | undefined)?.data, + (profile) => profile.emailVerified, + 'Profile did not become verified after login' + ) + assert.equal(freshProfile.emailVerified, true) + assert.equal(server.requests, 1) + + token.value = null + queryClient.removeQueries({ queryKey: endpoint.queryKey() }) + assert.equal(queryClient.getQueryData(endpoint.queryKey()), undefined) + assert.equal(server.requests, 1) + + siblingToken.value = null + await nextTick() + assert.equal(query.data.value, undefined) + assert.equal(sibling.data.value, undefined) + + focusManager.setFocused(false) + focusManager.setFocused(true) + await waitForQueryTurn() + assert.equal(server.requests, 1) + } finally { + scope.stop() + cachedQuery?.destroy() + queryClient.unmount() + queryClient.clear() + focusManager.setFocused(undefined) + } +}) From 3922fc6579bb14c6000a4b10d50f6c54490f3838 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Wed, 2 Sep 2026 00:05:03 +0000 Subject: [PATCH 2/3] fix: harden B1/B2 auth-cache security --- .../app/composables/clotho/useAuth.ts | 47 +- .../app/composables/clotho/useBackgrounds.ts | 10 +- .../lachesis/app/composables/clotho/useDay.ts | 31 +- .../app/composables/clotho/useGoals.ts | 30 +- .../app/composables/clotho/useTasks.ts | 42 +- apps/lachesis/app/lib/auth-cache.ts | 469 ++++++++++++++++++ apps/lachesis/app/plugins/auth.ts | 31 +- apps/lachesis/app/plugins/clotho.ts | 74 ++- apps/lachesis/package.json | 2 +- apps/lachesis/tests/auth-cache.test.ts | 397 +++++++++++++++ .../tests/profile-query-options.test.ts | 33 +- 11 files changed, 1088 insertions(+), 78 deletions(-) create mode 100644 apps/lachesis/app/lib/auth-cache.ts create mode 100644 apps/lachesis/tests/auth-cache.test.ts diff --git a/apps/lachesis/app/composables/clotho/useAuth.ts b/apps/lachesis/app/composables/clotho/useAuth.ts index 350d076..f8971a1 100644 --- a/apps/lachesis/app/composables/clotho/useAuth.ts +++ b/apps/lachesis/app/composables/clotho/useAuth.ts @@ -1,20 +1,29 @@ import { useMutation, useQuery } from '@tanstack/vue-query' +import { accountMutationScope, accountQueryKey, accountQueryOptions } from '~/lib/auth-cache' import { profileQueryOptions } from '~/lib/profile-query-options' export const useAuth = () => { - const { $api, $queryClient } = useNuxtApp() + const { $api, $queryClient, $authToken, $authScope, $authLifecycle } = useNuxtApp() - const token = useCookie('auth_token', { maxAge: 60 * 60 * 24 * 365 /* one year */ }) + const profileQueryKey = accountQueryKey($api.account.profile.show.queryKey(), $authScope) const userQuery = useQuery( - $api.account.profile.show.queryOptions(undefined, profileQueryOptions(token)) + accountQueryOptions( + $api.account.profile.show.queryOptions(undefined, profileQueryOptions($authToken)), + $authScope, + $authToken + ) + ) + const user = computed(() => + $authLifecycle.isIdentityValidated.value ? userQuery.data.value?.data : undefined ) - const user = computed(() => userQuery.data.value?.data) const loginMutation = useMutation( $api.auth.accessToken.store.mutationOptions({ onSuccess: ({ data }) => { - token.value = data.token - $queryClient.setQueryData($api.account.profile.show.queryKey(), { data: data.user }) + // Publish first. Any profile request triggered by the reactive query must read this token. + $authLifecycle.setToken(data.token) + $queryClient.setQueryData(profileQueryKey.value, { data: data.user }) + $authLifecycle.markIdentityValidated() }, }) ) @@ -22,17 +31,27 @@ export const useAuth = () => { const signupMutation = useMutation( $api.auth.newAccount.store.mutationOptions({ onSuccess: ({ data }) => { - token.value = data.token - $queryClient.setQueryData($api.account.profile.show.queryKey(), { data: data.user }) + $authLifecycle.setToken(data.token) + $queryClient.setQueryData(profileQueryKey.value, { data: data.user }) + $authLifecycle.markIdentityValidated() }, }) ) const logoutMutation = useMutation( $api.auth.accessToken.destroy.mutationOptions({ - onSuccess: () => { - token.value = null - $queryClient.removeQueries({ queryKey: $api.account.profile.show.queryKey() }) + // Clear observers before the request settles, while the shared token remains available for + // the logout bearer. The settled callback then removes the cookie even if the server is + // offline or the token was already revoked. + onMutate: () => { + const accountScope = $authScope.value + $authLifecycle.clearAccountState() + return { accountScope } + }, + onSettled: (_data, _error, _variables, context) => { + if ($authLifecycle.isCurrentTokenScope(accountMutationScope(context))) { + $authLifecycle.setToken(null) + } }, }) ) @@ -42,8 +61,10 @@ export const useAuth = () => { const updateProfileMutation = useMutation( $api.account.profile.update.mutationOptions({ - onSuccess: ({ data }) => { - $queryClient.setQueryData($api.account.profile.show.queryKey(), { data: data.user }) + onMutate: () => ({ accountScope: $authScope.value }), + onSuccess: ({ data }, _variables, context) => { + if (!$authLifecycle.isCurrentScope(accountMutationScope(context))) return + $queryClient.setQueryData(profileQueryKey.value, { data: data.user }) }, }) ) diff --git a/apps/lachesis/app/composables/clotho/useBackgrounds.ts b/apps/lachesis/app/composables/clotho/useBackgrounds.ts index 6223712..96e96f3 100644 --- a/apps/lachesis/app/composables/clotho/useBackgrounds.ts +++ b/apps/lachesis/app/composables/clotho/useBackgrounds.ts @@ -1,15 +1,19 @@ import { useMutation, useQuery } from '@tanstack/vue-query' +import { accountMutationScope, accountQueryKey } from '~/lib/auth-cache' export const useBackgrounds = () => { - const { $api, $queryClient } = useNuxtApp() + const { $api, $queryClient, $authScope, $authLifecycle } = useNuxtApp() const backgroundsQuery = useQuery($api.backgrounds.index.queryOptions()) const backgrounds = computed(() => backgroundsQuery.data.value?.data) + const profileQueryKey = accountQueryKey($api.account.profile.show.queryKey(), $authScope) const backgroundSelectMutation = useMutation( $api.backgrounds.select.mutationOptions({ - onSuccess: ({ data }) => { - $queryClient.setQueryData($api.account.profile.show.queryKey(), { data: data.user }) + onMutate: () => ({ accountScope: $authScope.value }), + onSuccess: ({ data }, _variables, context) => { + if (!$authLifecycle.isCurrentScope(accountMutationScope(context))) return + $queryClient.setQueryData(profileQueryKey.value, { data: data.user }) }, }) ) diff --git a/apps/lachesis/app/composables/clotho/useDay.ts b/apps/lachesis/app/composables/clotho/useDay.ts index 448ecf2..caa4285 100644 --- a/apps/lachesis/app/composables/clotho/useDay.ts +++ b/apps/lachesis/app/composables/clotho/useDay.ts @@ -1,12 +1,25 @@ import { useMutation, useQuery } from '@tanstack/vue-query' +import { accountMutationScope, accountQueryKey, accountQueryOptions } from '~/lib/auth-cache' export const useDay = (date: string = getClientDate()) => { - const { $api, $client, $queryClient } = useNuxtApp() + const { $api, $client, $queryClient, $authToken, $authScope, $authLifecycle } = useNuxtApp() // ─── Session ────────────────────────────────────────────────────────────── - const focusSessionQuery = useQuery($api.day.session.show.queryOptions({ params: { date } })) - const focusSession = computed(() => focusSessionQuery.data.value?.data) + const focusSessionQueryKey = accountQueryKey( + $api.day.session.show.queryKey({ params: { date } }), + $authScope + ) + const focusSessionQuery = useQuery( + accountQueryOptions( + $api.day.session.show.queryOptions({ params: { date } }), + $authScope, + $authToken + ) + ) + const focusSession = computed(() => + $authLifecycle.isIdentityValidated.value ? focusSessionQuery.data.value?.data : undefined + ) const updateFocusSessionMutation = useMutation({ mutationFn: ({ @@ -14,16 +27,20 @@ export const useDay = (date: string = getClientDate()) => { }: { body: Parameters[0]['body'] }) => $client.api.day.session.update({ params: { date }, body }), - onSuccess: (data) => { - $queryClient.setQueryData($api.day.session.show.queryKey({ params: { date } }), data) + onMutate: () => ({ accountScope: $authScope.value }), + onSuccess: (data, _variables, context) => { + if (!$authLifecycle.isCurrentScope(accountMutationScope(context))) return + $queryClient.setQueryData(focusSessionQueryKey.value, data) }, }) const destroyFocusSessionMutation = useMutation({ mutationFn: () => $client.api.day.session.destroy({ params: { date } }), - onSuccess: () => { + onMutate: () => ({ accountScope: $authScope.value }), + onSuccess: (_, _variables, context) => { + if (!$authLifecycle.isCurrentScope(accountMutationScope(context))) return $queryClient.resetQueries({ - queryKey: $api.day.session.show.queryKey({ params: { date } }), + queryKey: focusSessionQueryKey.value, }) }, }) diff --git a/apps/lachesis/app/composables/clotho/useGoals.ts b/apps/lachesis/app/composables/clotho/useGoals.ts index bd1ce97..304ea71 100644 --- a/apps/lachesis/app/composables/clotho/useGoals.ts +++ b/apps/lachesis/app/composables/clotho/useGoals.ts @@ -1,16 +1,24 @@ import { useMutation, useQuery } from '@tanstack/vue-query' +import { accountMutationScope, accountQueryKey, accountQueryOptions } from '~/lib/auth-cache' export const useGoals = () => { - const { $api, $queryClient } = useNuxtApp() + const { $api, $queryClient, $authToken, $authScope, $authLifecycle } = useNuxtApp() - const goalsQuery = useQuery($api.goals.index.queryOptions()) - const goals = computed(() => goalsQuery.data.value?.data) + const goalsQueryKey = accountQueryKey($api.goals.index.queryKey(), $authScope) + const goalsQuery = useQuery( + accountQueryOptions($api.goals.index.queryOptions(), $authScope, $authToken) + ) + const goals = computed(() => + $authLifecycle.isIdentityValidated.value ? goalsQuery.data.value?.data : undefined + ) const createGoalMutation = useMutation( $api.goals.store.mutationOptions({ - onSuccess: ({ data }) => { + onMutate: () => ({ accountScope: $authScope.value }), + onSuccess: ({ data }, _variables, context) => { + if (!$authLifecycle.isCurrentScope(accountMutationScope(context))) return $queryClient.setQueryData( - $api.goals.index.queryKey(), + goalsQueryKey.value, (old) => old && { ...old, data: [...old.data.filter((i) => i.id !== data.id), data] } ) }, @@ -19,9 +27,11 @@ export const useGoals = () => { const updateGoalMutation = useMutation( $api.goals.update.mutationOptions({ - onSuccess: ({ data }) => { + onMutate: () => ({ accountScope: $authScope.value }), + onSuccess: ({ data }, _variables, context) => { + if (!$authLifecycle.isCurrentScope(accountMutationScope(context))) return $queryClient.setQueryData( - $api.goals.index.queryKey(), + goalsQueryKey.value, (old) => old && { ...old, data: [...old.data.filter((i) => i.id !== data.id), data] } ) }, @@ -30,9 +40,11 @@ export const useGoals = () => { const destroyGoalMutation = useMutation( $api.goals.destroy.mutationOptions({ - onSuccess: (_, { params: { id } }) => { + onMutate: () => ({ accountScope: $authScope.value }), + onSuccess: (_, { params: { id } }, context) => { + if (!$authLifecycle.isCurrentScope(accountMutationScope(context))) return $queryClient.setQueryData( - $api.goals.index.queryKey(), + goalsQueryKey.value, (old) => old && { ...old, data: old.data.filter((i) => i.id !== id) } ) }, diff --git a/apps/lachesis/app/composables/clotho/useTasks.ts b/apps/lachesis/app/composables/clotho/useTasks.ts index 143c288..1d82e84 100644 --- a/apps/lachesis/app/composables/clotho/useTasks.ts +++ b/apps/lachesis/app/composables/clotho/useTasks.ts @@ -1,17 +1,25 @@ import { useMutation, useQuery } from '@tanstack/vue-query' +import { accountMutationScope, accountQueryKey, accountQueryOptions } from '~/lib/auth-cache' export const useTasks = () => { - const { $api, $queryClient } = useNuxtApp() + const { $api, $queryClient, $authToken, $authScope, $authLifecycle } = useNuxtApp() - const tasksQuery = useQuery($api.tasks.index.queryOptions()) - const tasks = computed(() => tasksQuery.data.value?.data) + const tasksQueryKey = accountQueryKey($api.tasks.index.queryKey(), $authScope) + const tasksQuery = useQuery( + accountQueryOptions($api.tasks.index.queryOptions(), $authScope, $authToken) + ) + const tasks = computed(() => + $authLifecycle.isIdentityValidated.value ? tasksQuery.data.value?.data : undefined + ) const focusedTaskId = useState('focusedTaskId', () => -1) const createTaskMutation = useMutation( $api.tasks.store.mutationOptions({ - onSuccess: ({ data }) => { + onMutate: () => ({ accountScope: $authScope.value }), + onSuccess: ({ data }, _variables, context) => { + if (!$authLifecycle.isCurrentScope(accountMutationScope(context))) return $queryClient.setQueryData( - $api.tasks.index.queryKey(), + tasksQueryKey.value, (old) => old && { ...old, data: [...old.data.filter((i) => i.id !== data.id), data] } ) }, @@ -20,9 +28,11 @@ export const useTasks = () => { const updateTaskMutation = useMutation( $api.tasks.update.mutationOptions({ - onSuccess: ({ data }) => { + onMutate: () => ({ accountScope: $authScope.value }), + onSuccess: ({ data }, _variables, context) => { + if (!$authLifecycle.isCurrentScope(accountMutationScope(context))) return $queryClient.setQueryData( - $api.tasks.index.queryKey(), + tasksQueryKey.value, (old) => old && { ...old, data: [...old.data.filter((i) => i.id !== data.id), data] } ) }, @@ -31,9 +41,11 @@ export const useTasks = () => { const destroyTaskMutation = useMutation( $api.tasks.destroy.mutationOptions({ - onSuccess: (_, { params: { id } }) => { + onMutate: () => ({ accountScope: $authScope.value }), + onSuccess: (_, { params: { id } }, context) => { + if (!$authLifecycle.isCurrentScope(accountMutationScope(context))) return $queryClient.setQueryData( - $api.tasks.index.queryKey(), + tasksQueryKey.value, (old) => old && { ...old, data: old.data.filter((i) => i.id !== id) } ) }, @@ -42,9 +54,11 @@ export const useTasks = () => { const attachGoalMutation = useMutation( $api.tasks.attachGoal.mutationOptions({ - onSuccess: ({ data }) => { + onMutate: () => ({ accountScope: $authScope.value }), + onSuccess: ({ data }, _variables, context) => { + if (!$authLifecycle.isCurrentScope(accountMutationScope(context))) return $queryClient.setQueryData( - $api.tasks.index.queryKey(), + tasksQueryKey.value, (old) => old && { ...old, data: [...old.data.filter((i) => i.id !== data.id), data] } ) }, @@ -53,9 +67,11 @@ export const useTasks = () => { const detachGoalMutation = useMutation( $api.tasks.attachGoal.mutationOptions({ - onSuccess: ({ data }) => { + onMutate: () => ({ accountScope: $authScope.value }), + onSuccess: ({ data }, _variables, context) => { + if (!$authLifecycle.isCurrentScope(accountMutationScope(context))) return $queryClient.setQueryData( - $api.tasks.index.queryKey(), + tasksQueryKey.value, (old) => old && { ...old, data: [...old.data.filter((i) => i.id !== data.id), data] } ) }, diff --git a/apps/lachesis/app/lib/auth-cache.ts b/apps/lachesis/app/lib/auth-cache.ts new file mode 100644 index 0000000..fd75f63 --- /dev/null +++ b/apps/lachesis/app/lib/auth-cache.ts @@ -0,0 +1,469 @@ +import { computed, ref, toValue, watch, type ComputedRef, type MaybeRef, type Ref } from 'vue' +import { type DehydratedState, type QueryClient, type QueryKey } from '@tanstack/vue-query' +import { persistQueryClientSave } from '@tanstack/query-persist-client-core' +import type { Persister, PersistedClient } from '@tanstack/query-persist-client-core' + +export const authTokenCookieName = 'auth_token' +export const accountQueryScopeField = '__lepse_account_scope__' +export const anonymousAccountScope = 'anonymous' +export const accountQueryPersistenceKey = 'REACT_QUERY_OFFLINE_CACHE' +export const authCacheEventStorageKey = 'lepse:auth-cache:event' + +const accountQueryRoots = new Set([ + 'account', + 'day', + 'focus_sessions', + 'goals', + 'habits', + 'journals', + 'scribbles', + 'task_days', + 'tasks', +]) + +const accountMutationRoots = new Set([ + ...accountQueryRoots, + 'auth', + 'backgrounds', + 'reset', + 'verify', +]) + +type AuthTokenRef = Pick, 'value'> +type StorageLike = Pick + +export type AccountCacheScope = string + +export interface AccountAwarePersister extends Persister { + /** Invalidate writes queued before an account transition. */ + invalidatePendingWrites: () => void +} + +export interface AuthCacheLifecycle { + readonly token: AuthTokenRef + readonly scope: ComputedRef + readonly isIdentityValidated: ComputedRef + setToken: (value: string | null | undefined) => void + markIdentityValidated: (scope?: AccountCacheScope) => void + isCurrentScope: (scope?: AccountCacheScope) => boolean + isCurrentTokenScope: (scope?: AccountCacheScope) => boolean + handleUnauthorized: (authorization?: string | null) => void + clearAccountState: () => void + dispose: () => void +} + +export interface AuthCacheLifecycleOptions { + token: AuthTokenRef + queryClient: QueryClient + persister?: AccountAwarePersister + refreshToken?: () => void +} + +export interface AuthCacheEvent { + id: string + type: 'token-cleared' | 'token-set' + scope: AccountCacheScope +} + +export function accountCacheScope(token: string | null | undefined): AccountCacheScope { + if (!token) return anonymousAccountScope + + // The bearer is deliberately not put in a query key or persisted value. This is only a + // namespace fingerprint; profile data remains untrusted until the current bearer succeeds. + let firstHash = 2166136261 + let secondHash = 2246822507 + for (let index = 0; index < token.length; index += 1) { + const code = token.charCodeAt(index) + firstHash = Math.imul(firstHash ^ code, 16777619) + secondHash = Math.imul(secondHash ^ code, 3266489909) + } + + return `token-${(firstHash >>> 0).toString(16)}-${(secondHash >>> 0).toString(16)}` +} + +export function accountQueryKey( + baseKey: T, + scope: MaybeRef +): ComputedRef { + // Keep Tuyau's data-tag type while adding the runtime namespace segment. + return computed(() => [ + ...baseKey, + { [accountQueryScopeField]: toValue(scope) }, + ]) as unknown as ComputedRef +} + +/** Add an account namespace and a token-presence guard to a generated Tuyau query. */ +export function accountQueryOptions( + options: Options, + scope: MaybeRef, + token: AuthTokenRef +): Options { + return { + ...options, + queryKey: accountQueryKey(options.queryKey, scope), + enabled: computed(() => Boolean(token.value)), + } as Options +} + +export function accountMutationScope(context: unknown): AccountCacheScope | undefined { + if (!context || typeof context !== 'object') return undefined + const scope = (context as { accountScope?: unknown }).accountScope + return typeof scope === 'string' ? scope : undefined +} + +function keySegments(key: QueryKey): ReadonlyArray { + return Array.isArray(key[0]) ? key[0] : key +} + +function hasAccountScopeMarker(key: QueryKey): boolean { + const last = key[key.length - 1] + return ( + typeof last === 'object' && + last !== null && + !Array.isArray(last) && + accountQueryScopeField in last + ) +} + +export function getAccountQueryScope(key: QueryKey): AccountCacheScope | undefined { + const last = key[key.length - 1] + if ( + typeof last !== 'object' || + last === null || + Array.isArray(last) || + !(accountQueryScopeField in last) + ) { + return undefined + } + + const scope = (last as Record)[accountQueryScopeField] + return typeof scope === 'string' ? scope : undefined +} + +/** Recognize both current namespaced keys and legacy unscoped account keys. */ +export function isAccountQueryKey(key: QueryKey): boolean { + const segments = keySegments(key) + return ( + hasAccountScopeMarker(key) || + (typeof segments[0] === 'string' && accountQueryRoots.has(segments[0])) + ) +} + +export function isAccountMutationKey(key: QueryKey | undefined): boolean { + if (!key) return false + const segments = keySegments(key) + return typeof segments[0] === 'string' && accountMutationRoots.has(segments[0]) +} + +function isProfileQueryKey(key: QueryKey): boolean { + const segments = keySegments(key) + return segments[0] === 'account' && segments[1] === 'profile' +} + +function filterDehydratedState( + clientState: DehydratedState, + scope: AccountCacheScope +): DehydratedState { + return { + ...clientState, + // Unscoped account entries are intentionally discarded: their owner cannot be proven. + queries: clientState.queries.filter( + (query) => + !isAccountQueryKey(query.queryKey) || + (getAccountQueryScope(query.queryKey) === scope && scope !== anonymousAccountScope) + ), + // No mutation in Lachesis is currently safe to replay for another bearer (the day-session + // mutations do not even carry a mutation key). Public mutations are not currently persisted. + mutations: [], + } +} + +export function filterPersistedClient( + persistedClient: PersistedClient, + scope: AccountCacheScope +): PersistedClient { + return { + ...persistedClient, + clientState: filterDehydratedState(persistedClient.clientState, scope), + } +} + +/** + * Persist only public data and data belonging to the current bearer namespace. Writes are queued + * serially so a logout cannot be followed by a delayed write containing the old account cache. + */ +export function createAccountAwarePersister(options: { + storage: StorageLike | undefined | null + getScope: () => AccountCacheScope + key?: string +}): AccountAwarePersister { + const { storage, getScope, key = accountQueryPersistenceKey } = options + let writeGeneration = 0 + let pending = Promise.resolve() + + const enqueue = (generation: number, operation: () => Promise) => { + const next = pending.then(async () => { + if (generation !== writeGeneration) return + await operation() + }) + pending = next.catch(() => {}) + return next + } + + return { + persistClient: (persistedClient) => { + if (!storage) return Promise.resolve() + + const generation = writeGeneration + const filtered = filterPersistedClient(persistedClient, getScope()) + return enqueue(generation, async () => { + await storage.setItem(key, JSON.stringify(filtered)) + }) + }, + + restoreClient: async () => { + if (!storage) return undefined + const serialized = await storage.getItem(key) + if (!serialized) return undefined + return filterPersistedClient(JSON.parse(serialized) as PersistedClient, getScope()) + }, + + removeClient: () => { + writeGeneration += 1 + if (!storage) return Promise.resolve() + const generation = writeGeneration + return enqueue(generation, async () => { + await storage.removeItem(key) + }) + }, + + invalidatePendingWrites: () => { + writeGeneration += 1 + }, + } +} + +function authEventId() { + return `${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export function isSessionRequest(request: Pick): boolean { + if (!request.headers.get('authorization')) return false + + try { + const path = new URL(request.url).pathname + return !['/auth/login', '/auth/signup', '/verify/password-reset/request'].some((publicPath) => + path.endsWith(publicPath) + ) + } catch { + // A bearer-bearing request with an unusual URL is safer to treat as authenticated than to + // leave a revoked session active. + return true + } +} + +function isAuthCacheEvent(value: unknown): value is AuthCacheEvent { + if (!value || typeof value !== 'object') return false + const event = value as Partial + return ( + typeof event.id === 'string' && + (event.type === 'token-cleared' || event.type === 'token-set') && + typeof event.scope === 'string' + ) +} + +/** + * Owns the one shared bearer source, account cache teardown, identity validation, and browser + * context notifications. No caller may read or write the auth cookie independently. + */ +export function createAuthCacheLifecycle({ + token, + queryClient, + persister, + refreshToken, +}: AuthCacheLifecycleOptions): AuthCacheLifecycle { + const scope = computed(() => accountCacheScope(token.value)) + const validatedScope = ref(null) + let accountStateCleared = false + let canValidateProfile = true + let suppressBroadcast = false + const seenEventIds = new Set() + + const persistCurrentCache = () => { + if (!persister) return + void persistQueryClientSave({ queryClient, persister }).catch(() => {}) + } + + const clearAccountState = () => { + validatedScope.value = null + accountStateCleared = true + canValidateProfile = false + persister?.invalidatePendingWrites() + + const accountQueries = queryClient.getQueryCache().findAll({ + predicate: (query) => isAccountQueryKey(query.queryKey), + }) + + // Reset first so every mounted observer receives an empty result. Removing an active query + // alone destroys its cache entry but can leave the observer displaying its last result. + for (const query of accountQueries) query.reset() + queryClient.removeQueries({ predicate: (query) => isAccountQueryKey(query.queryKey) }) + persistCurrentCache() + } + + let channel: BroadcastChannel | undefined + const broadcast = (type: AuthCacheEvent['type'], eventScope: AccountCacheScope) => { + if (typeof window === 'undefined') return + + const event: AuthCacheEvent = { id: authEventId(), type, scope: eventScope } + seenEventIds.add(event.id) + + try { + channel?.postMessage(event) + } catch {} + + try { + window.localStorage.setItem(authCacheEventStorageKey, JSON.stringify(event)) + window.localStorage.removeItem(authCacheEventStorageKey) + } catch {} + } + + const applyExternalEvent = (event: AuthCacheEvent) => { + if (seenEventIds.has(event.id)) return + seenEventIds.add(event.id) + + if (event.type === 'token-cleared') { + suppressBroadcast = true + token.value = null + suppressBroadcast = false + // If the source was already empty, its watcher does not run; clear observers anyway. + validatedScope.value = null + clearAccountState() + return + } + + // Cookies remain the security boundary. Refreshing the one shared cookie source lets a + // sibling tab publish a login without putting the bearer in localStorage or BroadcastChannel. + // Nuxt's refreshCookie communicates asynchronously, so hide the old account immediately and + // verify the refreshed namespace on the next task before deciding whether to clear the source. + if (!token.value || accountCacheScope(token.value) !== event.scope) clearAccountState() + suppressBroadcast = true + try { + refreshToken?.() + } catch {} + suppressBroadcast = false + + const verifyRefreshedToken = () => { + if (token.value && accountCacheScope(token.value) === event.scope) return + // The cookie may not be readable in this context (for example, a blocked WebView). Never + // keep rendering the previous account in that case; a subsequent app load can retry. + suppressBroadcast = true + token.value = null + suppressBroadcast = false + validatedScope.value = null + clearAccountState() + } + if (!token.value || accountCacheScope(token.value) !== event.scope) { + setTimeout(verifyRefreshedToken, 0) + } + } + + let storageListener: ((event: StorageEvent) => void) | undefined + + if (typeof window !== 'undefined') { + try { + if (typeof BroadcastChannel !== 'undefined') { + channel = new BroadcastChannel(authCacheEventStorageKey) + channel.addEventListener('message', (message) => { + if (isAuthCacheEvent(message.data)) applyExternalEvent(message.data) + }) + } + } catch {} + + storageListener = (event) => { + if (event.key !== authCacheEventStorageKey || !event.newValue) return + try { + const parsed: unknown = JSON.parse(event.newValue) + if (isAuthCacheEvent(parsed)) applyExternalEvent(parsed) + } catch {} + } + window.addEventListener('storage', storageListener) + } + + const stopTokenWatch = watch( + () => token.value, + (next, previous) => { + if (next === previous) return + + validatedScope.value = null + clearAccountState() + canValidateProfile = Boolean(next) + if (!suppressBroadcast) { + broadcast(next ? 'token-set' : 'token-cleared', accountCacheScope(next)) + } + }, + { flush: 'sync' } + ) + + const stopProfileValidation = queryClient.getQueryCache().subscribe((event) => { + if (event.type !== 'updated' || event.action.type !== 'success') return + if (!isProfileQueryKey(event.query.queryKey)) return + + const queryScope = getAccountQueryScope(event.query.queryKey) + if (canValidateProfile && queryScope && token.value && queryScope === scope.value) { + validatedScope.value = queryScope + accountStateCleared = false + } + }) + + return { + token, + scope, + isIdentityValidated: computed( + () => + Boolean(token.value) && + !accountStateCleared && + validatedScope.value !== null && + validatedScope.value === scope.value + ), + + setToken: (value) => { + token.value = value ?? null + }, + + markIdentityValidated: (validatedForScope = scope.value) => { + if (token.value && validatedForScope === scope.value) { + validatedScope.value = validatedForScope + accountStateCleared = false + canValidateProfile = true + } + }, + + isCurrentScope: (currentScope) => + Boolean(token.value) && !accountStateCleared && currentScope === scope.value, + + isCurrentTokenScope: (currentScope) => Boolean(token.value) && currentScope === scope.value, + + handleUnauthorized: (authorization) => { + if (authorization && authorization !== `Bearer ${token.value}`) return + if (token.value) token.value = null + else { + validatedScope.value = null + clearAccountState() + } + }, + + clearAccountState, + + dispose: () => { + stopTokenWatch() + stopProfileValidation() + channel?.close() + channel = undefined + if (storageListener && typeof window !== 'undefined') { + window.removeEventListener('storage', storageListener) + } + storageListener = undefined + }, + } +} diff --git a/apps/lachesis/app/plugins/auth.ts b/apps/lachesis/app/plugins/auth.ts index 2038d76..a17bb91 100644 --- a/apps/lachesis/app/plugins/auth.ts +++ b/apps/lachesis/app/plugins/auth.ts @@ -1,12 +1,35 @@ +import { accountQueryOptions } from '../lib/auth-cache.ts' +import { profileQueryOptions } from '../lib/profile-query-options.ts' + export default defineNuxtPlugin({ name: 'auth', dependsOn: ['clotho'], async setup(app) { - const token = useCookie('auth_token') + // Hydration must finish before the startup profile request can establish identity. + await app.$queryPersistenceReady + const token = app.$authToken if (!token.value) return - await app.$queryClient.prefetchQuery( - app.$api.account.profile.show.queryOptions(undefined, { retry: false }) - ) + const scope = app.$authScope.value + try { + await app.$queryClient.prefetchQuery( + accountQueryOptions( + app.$api.account.profile.show.queryOptions(undefined, { + retry: false, + ...profileQueryOptions(token), + }), + app.$authScope, + token + ) + ) + + // A successful network profile response (rather than hydrated data) establishes identity. + if (token.value && app.$authScope.value === scope) { + app.$authLifecycle.markIdentityValidated(scope) + } + } catch { + // Keep startup usable while offline or after a revoked token. The query still has the + // explicit no-retry policy; without a successful response the cached profile is not trusted. + } }, }) diff --git a/apps/lachesis/app/plugins/clotho.ts b/apps/lachesis/app/plugins/clotho.ts index f436509..86ecc8c 100644 --- a/apps/lachesis/app/plugins/clotho.ts +++ b/apps/lachesis/app/plugins/clotho.ts @@ -1,17 +1,28 @@ import { registry } from '@lepse/clotho/registry' import { createTuyau } from '@tuyau/core/client' import { persistQueryClient } from '@tanstack/query-persist-client-core' -import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister' import { focusManager, QueryClient, VueQueryPlugin } from '@tanstack/vue-query' import { createTuyauVueQueryClient } from '@tuyau/vue-query' import { toast } from 'vue-sonner' -import { appQueryDefaults } from '~/lib/query-client-options' +import { + accountCacheScope, + authTokenCookieName, + createAccountAwarePersister, + createAuthCacheLifecycle, + isSessionRequest, +} from '~/lib/auth-cache' +import { appQueryCacheLifetime, appQueryDefaults } from '~/lib/query-client-options' export default defineNuxtPlugin({ name: 'clotho', async setup(app) { const config = useRuntimeConfig() - const token = useCookie('auth_token') + // This is the sole auth_token reader. Every API hook and composable consumes this ref through + // the plugin-provided source instead of creating another cookie ref. + const authToken = useCookie(authTokenCookieName, { + maxAge: 60 * 60 * 24 * 365 /* one year */, + }) + const authScope = computed(() => accountCacheScope(authToken.value)) // Refetch opt-in queries when this window becomes active as well as when its tab becomes // visible. The verification link is served by Clotho, so the app cannot use a same-origin @@ -29,35 +40,62 @@ export default defineNuxtPlugin({ } }) - // Use tanstack/vue-query - // Most refetches are manual to avoid redundant requests. The authenticated profile query - // opts into focus and mount refetches in useAuth. + let storage: Storage | undefined + try { + storage = typeof window === 'undefined' ? undefined : window.localStorage + } catch { + // Some WebViews expose localStorage but reject access. The in-memory client remains usable. + } + const queryClient = new QueryClient({ defaultOptions: appQueryDefaults }) + const persister = createAccountAwarePersister({ + storage, + getScope: () => authScope.value, + }) + const authLifecycle = createAuthCacheLifecycle({ + token: authToken, + queryClient, + persister, + // A storage/BroadcastChannel event never carries the bearer. Refresh the one shared cookie + // source instead, and let its synchronous watcher perform the cache transition. + refreshToken: () => refreshCookie(authTokenCookieName), + }) + + let persistenceReady = Promise.resolve() app.vueApp.use(VueQueryPlugin, { queryClient, - clientPersister: (queryClient) => - persistQueryClient({ - queryClient, - maxAge: 1000 * 60 * 60 * 24 * 7, - persister: createAsyncStoragePersister({ storage: localStorage }), - }), + clientPersister: (client) => { + const result = persistQueryClient({ + queryClient: client, + maxAge: appQueryCacheLifetime, + persister, + }) + persistenceReady = result[1] + return result + }, }) - // the tuyau client + // The tuyau client reads the same shared source at request time. A response for an old + // bearer cannot revoke a newer session because the lifecycle compares the sent header. const client = createTuyau({ baseUrl: config.public.apiUrl, registry, hooks: { beforeRequest: [ (request) => { - if (token.value) { - request.headers.set('Authorization', `Bearer ${token.value}`) + if (authToken.value) { + request.headers.set('Authorization', `Bearer ${authToken.value}`) + } else { + request.headers.delete('Authorization') } request.headers.set('x-client-date', getClientDate()) }, ], afterResponse: [ - (_request, _options, response) => { + (request, _options, response) => { + if (response.status === 401 && isSessionRequest(request)) { + authLifecycle.handleUnauthorized(request.headers.get('authorization')) + } if (response.status === 429) { toast.error('Alright, you gotta chill -_-', { description: 'You got rate limited. Retry again later.', @@ -78,6 +116,10 @@ export default defineNuxtPlugin({ queryClient, // queries without context (in plugins for example) client, // for overriding and using client directly instead of type safe options api, // for type-safe query and mutation options + authToken, + authScope, + authLifecycle, + queryPersistenceReady: persistenceReady, }, } }, diff --git a/apps/lachesis/package.json b/apps/lachesis/package.json index d4d10d7..add3e6e 100644 --- a/apps/lachesis/package.json +++ b/apps/lachesis/package.json @@ -9,7 +9,7 @@ "tdev": "tauri dev", "tdev:cef": "tauri dev --features cef", "generate": "nuxt generate", - "test": "node --test --experimental-strip-types tests/profile-query-options.test.ts", + "test": "node --test --experimental-strip-types tests/*.test.ts", "preview": "npm run generate && wrangler dev", "postinstall": "nuxt prepare", "deploy": "npm run generate && wrangler deploy", diff --git a/apps/lachesis/tests/auth-cache.test.ts b/apps/lachesis/tests/auth-cache.test.ts new file mode 100644 index 0000000..859d8dc --- /dev/null +++ b/apps/lachesis/tests/auth-cache.test.ts @@ -0,0 +1,397 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { computed, ref } from 'vue' +import { createTuyau } from '@tuyau/core/client' +import { createTuyauVueQueryClient } from '@tuyau/vue-query' +import { dehydrate, hydrate, QueryClient, QueryObserver } from '@tanstack/vue-query' +import { registry } from '@lepse/clotho/registry' +import { + accountCacheScope, + authCacheEventStorageKey, + accountQueryKey, + accountQueryOptions, + createAccountAwarePersister, + createAuthCacheLifecycle, + filterPersistedClient, + getAccountQueryScope, + isAccountQueryKey, + isSessionRequest, +} from '../app/lib/auth-cache.ts' + +const baseKeys = { + profile: [['account', 'profile', 'show'], { type: 'query' }], + tasks: [['tasks', 'index'], { type: 'query' }], + goals: [['goals', 'index'], { type: 'query' }], + session: [ + ['day', 'session', 'show'], + { request: { params: { date: '2026-09-01' } }, type: 'query' }, + ], +} as const + +function createClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +class MemoryStorage { + value: string | null = null + + async getItem() { + return this.value + } + + async setItem(_key: string, value: string) { + this.value = value + } + + async removeItem() { + this.value = null + } +} + +test('account A logout clears every mounted account observer before account B can render', () => { + const queryClient = createClient() + const token = ref('token-a') + const lifecycle = createAuthCacheLifecycle({ token, queryClient }) + const scopeA = accountCacheScope(token.value) + const scope = computed(() => accountCacheScope(token.value)) + const accountKeys = Object.values(baseKeys).map((key) => accountQueryKey(key, scopeA)) + const publicKey = [['backgrounds', 'index'], { type: 'query' }] as const + + for (const [index, key] of accountKeys.entries()) { + queryClient.setQueryData(key, { owner: 'account-a', index }) + } + queryClient.setQueryData(publicKey, { public: true }) + + const observers = accountKeys.slice(0, 2).map((key) => { + const observer = new QueryObserver(queryClient, { + queryKey: key.value, + queryFn: async () => ({ owner: 'network' }), + enabled: false, + }) + observer.subscribe(() => {}) + return observer + }) + + try { + // The observers still point at the same active cache entries and therefore see A before the + // transition. + assert.equal(observers[0].getCurrentResult().data?.owner, 'account-a') + assert.equal(observers[1].getCurrentResult().data?.owner, 'account-a') + + lifecycle.setToken(null) + + for (const key of accountKeys) { + assert.equal(queryClient.getQueryData(key), undefined) + } + for (const observer of observers) { + assert.equal(observer.getCurrentResult().data, undefined) + } + assert.deepEqual(queryClient.getQueryData(publicKey), { public: true }) + + lifecycle.setToken('token-b') + const accountBKey = accountQueryKey(baseKeys.tasks, scope) + assert.equal(queryClient.getQueryData(accountBKey), undefined) + queryClient.setQueryData(accountBKey, { owner: 'account-b' }) + assert.deepEqual(queryClient.getQueryData(accountBKey), { owner: 'account-b' }) + assert.equal(queryClient.getQueryData(accountQueryKey(baseKeys.tasks, scopeA)), undefined) + } finally { + for (const observer of observers) observer.destroy() + lifecycle.dispose() + queryClient.clear() + } +}) + +test('persisted hydration retains public data but never hydrates another account or legacy account keys', async () => { + const storage = new MemoryStorage() + const tokenA = 'token-a' + const scopeA = accountCacheScope(tokenA) + const scopeB = accountCacheScope('token-b') + const clientA = createClient() + const keyA = accountQueryKey(baseKeys.profile, scopeA) + const publicKey = [['backgrounds', 'index'], { type: 'query' }] as const + clientA.setQueryData(keyA, { owner: 'account-a' }) + clientA.setQueryData(publicKey, { public: true }) + + const persisterA = createAccountAwarePersister({ + storage, + getScope: () => scopeA, + }) + await persisterA.persistClient({ + timestamp: Date.now(), + buster: '', + clientState: dehydrate(clientA), + }) + assert.ok(storage.value) + assert.equal(storage.value.includes(tokenA), false) + + const clientB = createClient() + const persisterB = createAccountAwarePersister({ + storage, + getScope: () => scopeB, + }) + const restoredForB = await persisterB.restoreClient() + hydrate(clientB, restoredForB!.clientState) + + assert.equal(clientB.getQueryData(accountQueryKey(baseKeys.profile, scopeA)), undefined) + assert.deepEqual(clientB.getQueryData(publicKey), { public: true }) + + const clientReload = createClient() + const restoredForA = await persisterA.restoreClient() + hydrate(clientReload, restoredForA!.clientState) + const lifecycleReload = createAuthCacheLifecycle({ + token: ref(tokenA), + queryClient: clientReload, + }) + assert.deepEqual(clientReload.getQueryData(keyA), { owner: 'account-a' }) + assert.equal(lifecycleReload.isIdentityValidated.value, false) + lifecycleReload.dispose() + clientReload.clear() + + const legacyClient = createClient() + legacyClient.setQueryData(baseKeys.profile, { owner: 'unscoped-account-a' }) + const legacyState = dehydrate(legacyClient) + const storedState = JSON.parse(storage.value!) + const filteredState = filterPersistedClient( + { + ...storedState, + clientState: { + ...storedState.clientState, + queries: [...storedState.clientState.queries, ...legacyState.queries], + }, + }, + scopeA + ) + const clientAfterLegacy = createClient() + hydrate(clientAfterLegacy, filteredState.clientState) + // Legacy entries are removed by the restore filter while scoped A data remains available to A. + assert.equal(clientAfterLegacy.getQueryData(baseKeys.profile), undefined) + assert.deepEqual(clientAfterLegacy.getQueryData(keyA), { owner: 'account-a' }) + + clientA.clear() + clientB.clear() + clientAfterLegacy.clear() +}) + +test('logout rewrites persisted storage without account data while preserving public data', async () => { + const storage = new MemoryStorage() + const queryClient = createClient() + const token = ref('token-a') + const scope = computed(() => accountCacheScope(token.value)) + const persister = createAccountAwarePersister({ + storage, + getScope: () => scope.value, + }) + const lifecycle = createAuthCacheLifecycle({ token, queryClient, persister }) + const accountKey = accountQueryKey(baseKeys.tasks, scope) + const publicKey = [['backgrounds', 'index'], { type: 'query' }] as const + queryClient.setQueryData(accountKey, { owner: 'account-a' }) + queryClient.setQueryData(publicKey, { public: true }) + await persister.persistClient({ + timestamp: Date.now(), + buster: '', + clientState: dehydrate(queryClient), + }) + + lifecycle.clearAccountState() + await new Promise((resolve) => setTimeout(resolve, 0)) + const restored = await persister.restoreClient() + const restoredClient = createClient() + hydrate(restoredClient, restored!.clientState) + + try { + assert.equal(restoredClient.getQueryData(accountKey), undefined) + assert.deepEqual(restoredClient.getQueryData(publicKey), { public: true }) + } finally { + lifecycle.dispose() + queryClient.clear() + restoredClient.clear() + } +}) + +test('direct token loss clears profile, task, goal, and session data and mounted siblings', () => { + const queryClient = createClient() + const token = ref('token-a') + const lifecycle = createAuthCacheLifecycle({ token, queryClient }) + const scope = computed(() => accountCacheScope(token.value)) + const taskKey = accountQueryKey(baseKeys.tasks, scope) + const taskObserver = new QueryObserver(queryClient, { + queryKey: taskKey.value, + queryFn: async () => ({ owner: 'network' }), + enabled: false, + }) + const siblingObserver = new QueryObserver(queryClient, { + queryKey: taskKey.value, + queryFn: async () => ({ owner: 'network' }), + enabled: false, + }) + const unsubscribeTask = taskObserver.subscribe(() => {}) + const unsubscribeSibling = siblingObserver.subscribe(() => {}) + queryClient.setQueryData(taskKey, { owner: 'account-a' }) + queryClient.setQueryData(accountQueryKey(baseKeys.profile, scope), { owner: 'account-a' }) + queryClient.setQueryData(accountQueryKey(baseKeys.goals, scope), { owner: 'account-a' }) + queryClient.setQueryData(accountQueryKey(baseKeys.session, scope), { owner: 'account-a' }) + + try { + token.value = null + assert.equal(taskObserver.getCurrentResult().data, undefined) + assert.equal(siblingObserver.getCurrentResult().data, undefined) + assert.equal( + queryClient + .getQueryCache() + .findAll({ predicate: (query) => isAccountQueryKey(query.queryKey) }).length, + 0 + ) + } finally { + unsubscribeTask() + unsubscribeSibling() + taskObserver.destroy() + siblingObserver.destroy() + lifecycle.dispose() + queryClient.clear() + } +}) + +test('the bearer hook reads the new shared token on the first profile request after login', async () => { + const queryClient = createClient() + const token = ref(null) + const lifecycle = createAuthCacheLifecycle({ token, queryClient }) + const seenAuthorization: Array = [] + const client = createTuyau({ + baseUrl: 'https://clotho.test', + registry, + fetch: async () => + new Response(JSON.stringify({ data: { emailVerified: true } }), { + headers: { 'content-type': 'application/json' }, + }), + hooks: { + beforeRequest: [ + (request) => { + if (token.value) request.headers.set('Authorization', `Bearer ${token.value}`) + seenAuthorization.push(request.headers.get('authorization')) + }, + ], + }, + }) + const api = createTuyauVueQueryClient({ client }) + const scope = computed(() => accountCacheScope(token.value)) + + try { + lifecycle.setToken('token-b') + await queryClient.fetchQuery( + accountQueryOptions(api.account.profile.show.queryOptions(), scope, token) + ) + assert.deepEqual(seenAuthorization, ['Bearer token-b']) + + lifecycle.handleUnauthorized('Bearer token-a') + assert.equal(token.value, 'token-b') + lifecycle.handleUnauthorized('Bearer token-b') + assert.equal(token.value, null) + } finally { + lifecycle.dispose() + queryClient.clear() + } +}) + +test('only bearer-bearing protected requests can revoke the shared session', () => { + const bearer = { Authorization: 'Bearer token-a' } + assert.equal( + isSessionRequest( + new Request('https://clotho.test/api/v1/account/profile', { headers: bearer }) + ), + true + ) + assert.equal( + isSessionRequest(new Request('https://clotho.test/api/v1/auth/login', { headers: bearer })), + false + ) + assert.equal( + isSessionRequest(new Request('https://clotho.test/api/v1/auth/signup', { headers: bearer })), + false + ) + assert.equal( + isSessionRequest( + new Request('https://clotho.test/api/v1/verify/password-reset/request', { + headers: bearer, + }) + ), + false + ) + assert.equal(isSessionRequest(new Request('https://clotho.test/api/v1/backgrounds')), false) +}) + +test('cross-context login and logout clear the shared source and mounted account cache', () => { + const previousWindow = (globalThis as Record).window + const previousBroadcastChannel = (globalThis as Record).BroadcastChannel + const listeners = new Map void>() + const fakeStorage = { + getItem: async () => null, + setItem: async () => {}, + removeItem: async () => {}, + } + const fakeWindow = { + localStorage: fakeStorage, + addEventListener: (type: string, listener: (event: unknown) => void) => { + listeners.set(type, listener) + }, + removeEventListener: () => {}, + } + ;(globalThis as Record).window = fakeWindow + ;(globalThis as Record).BroadcastChannel = undefined + + const queryClient = createClient() + const token = ref('token-a') + const lifecycle = createAuthCacheLifecycle({ + token, + queryClient, + refreshToken: () => { + token.value = 'token-b' + }, + }) + const scope = computed(() => accountCacheScope(token.value)) + const taskKey = accountQueryKey(baseKeys.tasks, scope) + queryClient.setQueryData(taskKey, { owner: 'account-a' }) + + try { + listeners.get('storage')?.({ + key: authCacheEventStorageKey, + newValue: JSON.stringify({ + id: 'external-login', + type: 'token-set', + scope: accountCacheScope('token-b'), + }), + }) + assert.equal(token.value, 'token-b') + assert.equal(queryClient.getQueryData(taskKey), undefined) + + listeners.get('storage')?.({ + key: authCacheEventStorageKey, + newValue: JSON.stringify({ + id: 'external-logout', + type: 'token-cleared', + scope: 'anonymous', + }), + }) + assert.equal(token.value, null) + assert.equal(queryClient.getQueryData(taskKey), undefined) + } finally { + lifecycle.dispose() + queryClient.clear() + if (previousWindow === undefined) delete (globalThis as Record).window + else (globalThis as Record).window = previousWindow + if (previousBroadcastChannel === undefined) { + delete (globalThis as Record).BroadcastChannel + } else { + ;(globalThis as Record).BroadcastChannel = previousBroadcastChannel + } + } +}) + +test('account keys cover the authenticated query families without scoping public backgrounds', () => { + for (const key of Object.values(baseKeys)) { + const scoped = accountQueryKey(key, 'token-a').value + assert.equal(isAccountQueryKey(scoped), true) + assert.equal(getAccountQueryScope(scoped), 'token-a') + } + + assert.equal(isAccountQueryKey([['backgrounds', 'index'], { type: 'query' }]), false) +}) diff --git a/apps/lachesis/tests/profile-query-options.test.ts b/apps/lachesis/tests/profile-query-options.test.ts index 6468bcc..d57117f 100644 --- a/apps/lachesis/tests/profile-query-options.test.ts +++ b/apps/lachesis/tests/profile-query-options.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { effectScope, nextTick, ref } from 'vue' +import { computed, effectScope, nextTick, ref } from 'vue' import { createTuyau } from '@tuyau/core/client' import { createTuyauVueQueryClient } from '@tuyau/vue-query' import { @@ -12,6 +12,7 @@ import { useQuery, } from '@tanstack/vue-query' import { registry } from '@lepse/clotho/registry' +import { accountCacheScope, accountQueryKey, accountQueryOptions } from '../app/lib/auth-cache.ts' import { appQueryDefaults } from '../app/lib/query-client-options.ts' import { profileQueryLifecycleOptions, @@ -223,17 +224,23 @@ test('application defaults do not refetch a stale profile on focus without lifec } }) -test('profile query options use the application key shared by mutation writers', () => { +test('profile query options use the scoped application key shared by mutation writers', () => { const token = ref(null) - const profileOptions = profileEndpoint.queryOptions(undefined, profileQueryOptions(token)) - - assert.deepEqual(profileOptions.queryKey, profileEndpoint.queryKey()) + const scope = ref(accountCacheScope('account-a')) + const profileOptions = accountQueryOptions( + profileEndpoint.queryOptions(undefined, profileQueryOptions(token)), + scope, + token + ) + const scopedProfileKey = accountQueryKey(profileEndpoint.queryKey(), scope) + + assert.deepEqual(profileOptions.queryKey.value, scopedProfileKey.value) assert.equal(profileOptions.enabled?.value, false) assert.notDeepEqual(profileEndpoint.queryOptions({}).queryKey, profileEndpoint.queryKey()) const queryClient = createAppQueryClient() const profile = { emailVerified: false } - queryClient.setQueryData(profileEndpoint.queryKey(), { data: profile }) + queryClient.setQueryData(scopedProfileKey, { data: profile }) assert.deepEqual(queryClient.getQueryData(profileOptions.queryKey), { data: profile, @@ -244,13 +251,12 @@ test('profile query options use the application key shared by mutation writers', test('auth startup skips profile prefetch without a token', async () => { const globalObject = globalThis as Record const previousDefineNuxtPlugin = globalObject.defineNuxtPlugin - const previousUseCookie = globalObject.useCookie const token = ref(null) + const scope = computed(() => accountCacheScope(token.value)) const queryOptionsCalls: unknown[][] = [] let prefetches = 0 globalObject.defineNuxtPlugin = (plugin) => plugin - globalObject.useCookie = () => token try { const { default: authPlugin } = @@ -263,7 +269,7 @@ test('auth startup skips profile prefetch without a token', async () => { show: { queryOptions: (...args: unknown[]) => { queryOptionsCalls.push(args) - return {} + return { queryKey: [] } }, }, }, @@ -274,6 +280,10 @@ test('auth startup skips profile prefetch without a token', async () => { prefetches += 1 }, }, + $authToken: token, + $authScope: scope, + $authLifecycle: { markIdentityValidated: () => {} }, + $queryPersistenceReady: Promise.resolve(), } await setup(app) @@ -283,12 +293,11 @@ test('auth startup skips profile prefetch without a token', async () => { await setup(app) assert.equal(prefetches, 1) assert.equal(queryOptionsCalls[0]?.[0], undefined) - assert.deepEqual(queryOptionsCalls[0]?.[1], { retry: false }) + assert.equal((queryOptionsCalls[0]?.[1] as { retry?: boolean }).retry, false) + assert.equal(typeof (queryOptionsCalls[0]?.[1] as { enabled?: unknown }).enabled, 'object') } finally { if (previousDefineNuxtPlugin === undefined) delete globalObject.defineNuxtPlugin else globalObject.defineNuxtPlugin = previousDefineNuxtPlugin - if (previousUseCookie === undefined) delete globalObject.useCookie - else globalObject.useCookie = previousUseCookie } }) From fb0cace2c457d38fa4cfccef0b2cbf74f1ffb6b1 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Wed, 2 Sep 2026 00:05:03 +0000 Subject: [PATCH 3/3] fix: use colon separator in Slice 2 timer --- apps/lachesis/app/components/clock-focus.vue | 3 +-- apps/lachesis/tests/clock-focus.test.ts | 22 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 apps/lachesis/tests/clock-focus.test.ts diff --git a/apps/lachesis/app/components/clock-focus.vue b/apps/lachesis/app/components/clock-focus.vue index d3c5e98..3517a22 100644 --- a/apps/lachesis/app/components/clock-focus.vue +++ b/apps/lachesis/app/components/clock-focus.vue @@ -292,12 +292,11 @@ watch(stopwatch.elapsed, () => { class="2xl:text-10xl text-shadow-foreground-fixed/40 -mt-3 h-min text-8xl leading-none text-shadow-lg sm:text-9xl" > {{ - (!inFocus + !inFocus ? nowStr.slice(0, -3) : focusMethod === 'stopwatch' ? formatted.slice(stopwatch.elapsed.value > 3600000 ? 0 : 3, -4) : formatted - ).replace(':', '꞉' /* modifier colon is more centered */) }} [\s\S]*? { + assert.ok(clockExpression, 'clock interpolation should be present') + assert.doesNotMatch( + component, + /꞉|\\u\{?0*A789/iu, + 'modifier colon must not return anywhere in the component' + ) + assert.match(clockExpression, /nowStr|formatted/) + assert.doesNotMatch(clockExpression, /\.replace\s*\(/) +})