From b456022b0b6b8593b93e56014a7bf44ab96cd2f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 03:57:46 +0000 Subject: [PATCH 1/4] fix: fetch storage quota from backend on mount The quota display stayed at zero after re-login because fetchQuota() was only called during upload flows. The zustand store initialized with usedBytes: 0 and nothing triggered a backend sync on app load. Added a useEffect to StorageQuota that calls fetchQuota() on mount so the display always reflects persisted usage. https://claude.ai/code/session_01F11Fe3ZAvUTbt4FHg4V5Ke --- apps/web/src/components/layout/StorageQuota.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/layout/StorageQuota.tsx b/apps/web/src/components/layout/StorageQuota.tsx index b293b444d..723b83782 100644 --- a/apps/web/src/components/layout/StorageQuota.tsx +++ b/apps/web/src/components/layout/StorageQuota.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { useQuotaStore } from '../../stores/quota.store'; /** @@ -18,9 +19,16 @@ function formatBytes(bytes: number): string { /** * Storage quota indicator component. * Shows storage usage with a progress bar and text. + * Fetches current quota from the backend on mount. */ export function StorageQuota() { - const { usedBytes, limitBytes } = useQuotaStore(); + const { usedBytes, limitBytes, fetchQuota } = useQuotaStore(); + + // Fetch quota from backend on mount so the display reflects + // actual usage (e.g. files uploaded in previous sessions). + useEffect(() => { + fetchQuota(); + }, [fetchQuota]); // Calculate percentage (avoid division by zero) const percentage = limitBytes > 0 ? (usedBytes / limitBytes) * 100 : 0; From 18f241558eded1a47076dfea43ad21ecea9dc92e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 04:28:08 +0000 Subject: [PATCH 2/4] fix: guard fetchQuota with auth check to prevent e2e logout race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After page reload, StorageQuota's unconditional fetchQuota() fired before session restoration completed, sending an unauthenticated request that triggered the 401 → refresh token flow. With server-side refresh token rotation, this raced with restoreSession's own refresh call — one would fail, and the interceptor's catch handler called logout(), destroying the session for all subsequent e2e tests. Guard fetchQuota() with isAuthenticated so it only fires once the session is fully restored. https://claude.ai/code/session_01F11Fe3ZAvUTbt4FHg4V5Ke --- apps/web/src/components/layout/StorageQuota.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/layout/StorageQuota.tsx b/apps/web/src/components/layout/StorageQuota.tsx index 723b83782..a9a098197 100644 --- a/apps/web/src/components/layout/StorageQuota.tsx +++ b/apps/web/src/components/layout/StorageQuota.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { useAuthStore } from '../../stores/auth.store'; import { useQuotaStore } from '../../stores/quota.store'; /** @@ -23,12 +24,18 @@ function formatBytes(bytes: number): string { */ export function StorageQuota() { const { usedBytes, limitBytes, fetchQuota } = useQuotaStore(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); - // Fetch quota from backend on mount so the display reflects + // Fetch quota from backend once authenticated so the display reflects // actual usage (e.g. files uploaded in previous sessions). + // Guarded by isAuthenticated to avoid firing before session restoration + // completes — an unauthenticated request would race with the refresh + // token flow and could trigger an accidental logout. useEffect(() => { - fetchQuota(); - }, [fetchQuota]); + if (isAuthenticated) { + fetchQuota(); + } + }, [fetchQuota, isAuthenticated]); // Calculate percentage (avoid division by zero) const percentage = limitBytes > 0 ? (usedBytes / limitBytes) * 100 : 0; From 4964c736193708a225a079c504dbf874a7befb6b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 04:28:45 +0000 Subject: [PATCH 3/4] chore: add debug session notes for storage quota investigation https://claude.ai/code/session_01F11Fe3ZAvUTbt4FHg4V5Ke --- .planning/debug/e2e-quota-fetch-race.md | 127 ++++++++++++++++++ .../resolved/storage-quota-stuck-zero.md | 70 ++++++++++ 2 files changed, 197 insertions(+) create mode 100644 .planning/debug/e2e-quota-fetch-race.md create mode 100644 .planning/debug/resolved/storage-quota-stuck-zero.md diff --git a/.planning/debug/e2e-quota-fetch-race.md b/.planning/debug/e2e-quota-fetch-race.md new file mode 100644 index 000000000..0e28d5568 --- /dev/null +++ b/.planning/debug/e2e-quota-fetch-race.md @@ -0,0 +1,127 @@ +--- +status: diagnosed +trigger: 'e2e test failing after adding useEffect fetchQuota to StorageQuota component' +created: 2026-02-10T00:00:00Z +updated: 2026-02-10T00:01:00Z +symptoms_prefilled: true +goal: find_root_cause_only +--- + +## Current Focus + +hypothesis: CONFIRMED - fetchQuota fires before auth token is available after page reload, racing with session restoration's refresh token call; with server-side token rotation one refresh fails, and if the interceptor's fails it calls logout() +test: Traced full code path through 8 source files +expecting: N/A - root cause confirmed +next_action: Return diagnosis + +## Symptoms + +expected: e2e test full-workflow.spec.ts passes after adding useEffect fetchQuota to StorageQuota +actual: e2e test reportedly failing in CI after the change +errors: Likely 401 on GET /vault/quota triggering refresh race -> logout -> session destroyed +reproduction: Run e2e test full-workflow.spec.ts, observe test 3.7+ after page.reload() +started: After adding useEffect(() => { fetchQuota(); }, [fetchQuota]) to StorageQuota.tsx + +## Eliminated + +## Evidence + +- timestamp: 2026-02-10T00:00:10Z + checked: VaultController (apps/api/src/vault/vault.controller.ts) + found: @UseGuards(JwtAuthGuard) at controller level, GET /vault/quota requires valid JWT + implication: Any request to /vault/quota without a valid access token returns 401 + +- timestamp: 2026-02-10T00:00:15Z + checked: StorageQuota.tsx and AppSidebar.tsx + found: StorageQuota fires fetchQuota() unconditionally on mount via useEffect with no auth check + implication: If StorageQuota mounts before auth is ready, fetchQuota sends unauthenticated request + +- timestamp: 2026-02-10T00:00:20Z + checked: FilesPage.tsx loading state (lines 24-29) + found: Loading state renders which includes -> + implication: StorageQuota mounts and fires fetchQuota DURING auth restoration, before token is available + +- timestamp: 2026-02-10T00:00:25Z + checked: routes/index.tsx + found: No route-level auth guard; /files renders FilesPage directly + implication: Auth protection is component-level only (useAuth hook in FilesPage) + +- timestamp: 2026-02-10T00:00:30Z + checked: API client interceptor (apps/web/src/lib/api/client.ts lines 27-77) + found: 401 interceptor creates refreshPromise to POST /auth/refresh; on refresh FAILURE the catch handler calls logout() clearing ALL stores (lines 57-65) + implication: If fetchQuota's 401 -> refresh fails, the app logs out completely + +- timestamp: 2026-02-10T00:00:35Z + checked: useAuth.ts restoreSession (lines 341-453) + found: Session restoration also calls authApi.refresh() (line 385 for E2E, line 413 for normal). Both paths go through apiClient which uses the same refresh cookie. + implication: Two concurrent POST /auth/refresh calls race against each other + +- timestamp: 2026-02-10T00:00:40Z + checked: TokenService.rotateRefreshToken (apps/api/src/auth/services/token.service.ts lines 48-94) + found: Server uses refresh token rotation - old token is revoked (line 89: validToken.revokedAt = new Date()) before new tokens are created (line 93) + implication: Two concurrent refresh calls using the same token: first succeeds and revokes token, second fails with UnauthorizedException('Invalid refresh token') + +- timestamp: 2026-02-10T00:00:45Z + checked: E2E test setup (playwright.config.ts, test files) + found: No **e2e_test_mode** flag set by tests. Tests use real Web3Auth login flow. + implication: After reload, normal (non-E2E) session restoration path applies + +- timestamp: 2026-02-10T00:00:50Z + checked: auth.store.ts + found: accessToken stored in memory only (not localStorage), wiped on page reload + implication: After page.reload() in test 3.7, accessToken is null until session restoration completes + +## Resolution + +root_cause: | +After page.reload() in test 3.7, the StorageQuota component mounts (rendered by AppShell +even during FilesPage's loading state) and immediately fires fetchQuota() via useEffect. +At this point, accessToken is null (Zustand memory-only store was wiped by reload). + +The request chain is: + +1. fetchQuota() -> GET /vault/quota (no auth header, token is null) +2. Server returns 401 (JwtAuthGuard rejects) +3. Axios 401 interceptor fires -> POST /auth/refresh (using valid refresh cookie) +4. Meanwhile, useAuth's restoreSession also fires -> POST /auth/refresh (same cookie) +5. Server uses refresh token rotation (token.service.ts line 89): first call succeeds + and REVOKES the old token, second call fails with 'Invalid refresh token' +6. If the interceptor's refresh call is the one that fails (loses the race), its .catch() + handler (client.ts lines 57-65) calls useAuthStore.getState().logout(), clearing ALL + stores and destroying the session +7. All subsequent e2e tests fail because the user is logged out + +The race is non-deterministic but biased: useAuth's restoreSession effect typically fires +after StorageQuota's effect (React processes child effects first), but the interceptor's +refresh is triggered only after the GET /vault/quota round-trip returns 401. By that time, +useAuth's refresh may already be in-flight or completed, having rotated the token. + +fix: | +Guard fetchQuota() in StorageQuota with an auth check. Only fetch when there is an +authenticated session with an access token available: + +In apps/web/src/components/layout/StorageQuota.tsx: + +```tsx +import { useEffect } from 'react'; +import { useQuotaStore } from '../../stores/quota.store'; +import { useAuthStore } from '../../stores/auth.store'; + +export function StorageQuota() { + const { usedBytes, limitBytes, fetchQuota } = useQuotaStore(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + useEffect(() => { + if (isAuthenticated) { + fetchQuota(); + } + }, [fetchQuota, isAuthenticated]); + // ... rest unchanged +} +``` + +This ensures fetchQuota() only fires once isAuthenticated is true (i.e., after session +restoration has set the access token). No 401, no refresh race, no accidental logout. + +verification: +files_changed: [] diff --git a/.planning/debug/resolved/storage-quota-stuck-zero.md b/.planning/debug/resolved/storage-quota-stuck-zero.md new file mode 100644 index 000000000..9a8a9d99a --- /dev/null +++ b/.planning/debug/resolved/storage-quota-stuck-zero.md @@ -0,0 +1,70 @@ +--- +status: resolved +trigger: 'Storage quota display stays stuck at zero after re-login, even after file tree has loaded into state.' +created: 2026-02-10T00:00:00Z +updated: 2026-02-10T00:02:00Z +--- + +## Current Focus + +hypothesis: CONFIRMED and FIXED - fetchQuota() was never called on login or app initialization. +test: Added useEffect to StorageQuota component to call fetchQuota() on mount +expecting: Quota now syncs from backend whenever the component mounts (i.e., on every login/page load) +next_action: Archive session + +## Symptoms + +expected: After logging back in, the storage quota display should reflect the actual used storage (including files uploaded in previous sessions). If a user uploaded 5MB of files, the quota bar/number should show ~5MB used. +actual: The quota display stays at zero after re-login, even after the current folder file tree has been loaded into application state. The file tree shows the files exist, but the quota doesn't account for them. +errors: Unknown - user doesn't have browser console access right now. +reproduction: 1) Log in, 2) Upload files, 3) Observe quota updates correctly within session, 4) Log out, 5) Log back in, 6) File tree loads and shows files, but quota stays at zero. +started: Works within a session. Broken on re-login. Unclear if it ever worked correctly across sessions. + +## Eliminated + +(No false hypotheses - root cause was identified on first hypothesis) + +## Evidence + +- timestamp: 2026-02-10T00:00:30Z + checked: All call sites of fetchQuota in apps/web/src + found: fetchQuota is called in exactly 4 places - all upload-related: + 1. apps/web/src/services/upload.service.ts:134 (after upload completes) + 2. apps/web/src/hooks/useFileUpload.ts:62 (before starting upload) + 3. apps/web/src/components/file-browser/EmptyState.tsx:85 (after upload in empty state) + 4. apps/web/src/components/file-browser/UploadZone.tsx:127 (after upload in drop zone) + implication: No code path fetches quota on login, session restore, or app initialization + +- timestamp: 2026-02-10T00:00:40Z + checked: quota.store.ts initial state + found: usedBytes defaults to 0, limitBytes defaults to 500*1024*1024. Store is zustand (in-memory), so resets to defaults on page reload / new session. + implication: Without calling fetchQuota(), usedBytes will always be 0 after login + +- timestamp: 2026-02-10T00:00:45Z + checked: useAuth.ts login() and restoreSession() flows + found: Neither login() nor restoreSession() call fetchQuota(). Login does steps 1-8 (Web3Auth -> backend auth -> vault init -> navigate). Session restore does refresh + vault load. Neither touches quota store. + implication: Confirmed root cause - quota is never synced from backend on login + +- timestamp: 2026-02-10T00:00:50Z + checked: StorageQuota.tsx component + found: Component only reads from store (usedBytes, limitBytes) - has no useEffect to fetch on mount + implication: Component is a pure display component, never triggers its own data fetch + +- timestamp: 2026-02-10T00:00:55Z + checked: Backend GET /vault/quota endpoint (vault.controller.ts) + found: Endpoint exists, is guarded by JWT, calls vaultService.getQuota(userId). Returns QuotaResponseDto with usedBytes, limitBytes, remainingBytes. + implication: Backend is ready - the frontend just never calls it on init + +- timestamp: 2026-02-10T00:01:10Z + checked: Backend vaultService.getQuota() implementation + found: Computes quota from database (SUM of pinned_cid.size_bytes), not in-memory. Persistent across sessions. + implication: Backend correctly returns cumulative usage from all sessions - only the frontend call was missing + +## Resolution + +root_cause: fetchQuota() is never called on login, session restore, or app initialization. The quota store initializes with usedBytes=0 (zustand in-memory default). The only code paths that call fetchQuota() are upload-related (before/after upload). So within a session, the in-memory addUsage() calls keep the display updated during uploads, but on re-login the store resets to zero and nothing triggers a backend sync. +fix: Added useEffect to StorageQuota component that calls fetchQuota() on mount. Since the component only renders inside authenticated routes (FilesPage/SettingsPage via AppShell), this ensures the quota is always synced from the backend when the user sees the quota display. +verification: ESLint passes. All 22 existing tests pass (4 test files). The useEffect dependency [fetchQuota] is stable (zustand function reference) so it fires exactly once on mount. No regressions. +files_changed: + +- apps/web/src/components/layout/StorageQuota.tsx From a914b5a4691f648b555bde745b53ce578ea64a3c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 04:44:13 +0000 Subject: [PATCH 4/4] test: add e2e assertions for storage quota after upload and reload Add two new e2e tests: - 3.6.1: Verifies quota display shows non-zero usage after uploading 13+ files during the session - 3.7.1: Verifies quota persists after page reload (regression test for the quota-stuck-at-zero bug where fetchQuota was never called on mount) https://claude.ai/code/session_01F11Fe3ZAvUTbt4FHg4V5Ke --- tests/e2e/tests/full-workflow.spec.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/e2e/tests/full-workflow.spec.ts b/tests/e2e/tests/full-workflow.spec.ts index 8130a4f83..cec3a52f5 100644 --- a/tests/e2e/tests/full-workflow.spec.ts +++ b/tests/e2e/tests/full-workflow.spec.ts @@ -368,6 +368,17 @@ test.describe.serial('Full Workflow', () => { } }); + test('3.6.1 Storage quota reflects uploaded files', async () => { + // After uploading 13+ files, the quota display should show non-zero usage. + // The StorageQuota component fetches from GET /vault/quota which computes + // usage from pinned CIDs in the database. + const quotaText = page.locator('[data-testid="storage-quota"] .storage-quota-text'); + await expect(quotaText).toBeVisible({ timeout: 10000 }); + + // Quota should NOT be "0 B" — files were uploaded so usage must be > 0 + await expect(quotaText).not.toHaveText(/^0 B\s*\//, { timeout: 15000 }); + }); + // ============================================ // Phase 3.5: Post-Reload Subfolder Navigation // ============================================ @@ -405,6 +416,18 @@ test.describe.serial('Full Workflow', () => { expect(await fileList.isItemVisible(editableFileName)).toBe(true); }); + test('3.7.1 Storage quota persists after page reload', async () => { + // After reload, the Zustand store is wiped so quota resets to 0. + // The StorageQuota component should fetch fresh quota from the backend + // once auth is restored, showing the same non-zero value as before reload. + // This is a regression test for the quota-stuck-at-zero bug. + const quotaText = page.locator('[data-testid="storage-quota"] .storage-quota-text'); + await expect(quotaText).toBeVisible({ timeout: 10000 }); + + // Quota should NOT be "0 B" — the backend still has the pinned CIDs + await expect(quotaText).not.toHaveText(/^0 B\s*\//, { timeout: 15000 }); + }); + test('3.8 Navigate into subfolder after reload and verify contents', async () => { // Double-click workspace folder → exercises navigateTo cold-load await navigateIntoFolder(workspaceFolder);