diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
index 3eb51e6..3e19ead 100644
--- a/.github/workflows/verify.yml
+++ b/.github/workflows/verify.yml
@@ -8,7 +8,8 @@ on:
jobs:
verify:
- runs-on: ubuntu-latest
+ # Pin the supported browser-test image; ubuntu-latest now selects 26.04.
+ runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
diff --git a/apps/web/e2e/auth-sync.spec.ts b/apps/web/e2e/auth-sync.spec.ts
index 20f96ce..130b37f 100644
--- a/apps/web/e2e/auth-sync.spec.ts
+++ b/apps/web/e2e/auth-sync.spec.ts
@@ -589,3 +589,49 @@ test('data safety: CRM customers, deals and follow-up tasks sync between two sig
await contextB.close()
}
})
+
+test('data safety: expired session pauses once and same-account sign-in uploads preserved edits', async ({ browser }) => {
+ test.setTimeout(150_000)
+ const credentials = { email: `expiry-${crypto.randomUUID()}@example.test`, password: 'test-only-password-1234' }
+ const cookie = await authRequest('sign-up/email', { ...credentials, name: 'Session Recovery Test' })
+ const context = await browser.newContext({ serviceWorkers: 'block' })
+ try {
+ await context.addCookies([cookie])
+ await context.addInitScript(() => localStorage.setItem('workbench.ui.language', 'en'))
+ const page = await context.newPage()
+ await page.goto('/workspace/settings?section=data')
+ await expect(page.getByTestId('sync-safety-status')).toContainText('Cloud confirmed', { timeout: 45000 })
+ await page.getByRole('switch', { name: 'Toggle cloud sync' }).uncheck()
+ const task = await page.evaluate(async () => {
+ const { createDexieDatabaseService } = await import('/src/data/services/DexieDatabaseService.ts')
+ return createDexieDatabaseService().tasks.add({ title: 'Preserve this edit across session expiry', description: 'Not uploaded yet', status: 'todo', priority: null, tags: [], subtasks: [] })
+ })
+ // Revoke the isolated server session without invoking client logout or data clearing.
+ await page.evaluate(async () => { await (await import('/src/config/authClient.ts')).authClient.signOut() })
+ let syncRequests = 0
+ page.on('request', request => { if (request.url().includes('/sync/rxdb/')) syncRequests++ })
+ await page.getByRole('switch', { name: 'Toggle cloud sync' }).check()
+ await expect(page.getByRole('button', { name: 'Sign in again', exact: true })).toBeVisible({ timeout: 45000 })
+ await expect(page.getByText('Your session has expired. Sync is paused and local changes are preserved. Sign in to the same account to resume.', { exact: true })).toBeVisible()
+ expect(await page.evaluate(async id => (await (await import('/src/data/db/index.ts')).db.tasks.get(id))?.title, task.id)).toBe(task.title)
+ await expect.poll(() => page.evaluate(async () => (await import('/src/data/db/index.ts')).db.pendingSyncChanges.count())).toBeGreaterThan(0)
+ const stoppedAt = syncRequests
+ await page.evaluate(() => { window.dispatchEvent(new Event('online')); window.dispatchEvent(new Event('focus')) })
+ await page.waitForTimeout(1500)
+ expect(syncRequests).toBe(stoppedAt)
+ await page.getByRole('button', { name: 'Sign in again', exact: true }).click()
+ const login = page.locator('.login-modal')
+ await login.locator('input[autocomplete="username"]').fill(credentials.email)
+ await login.locator('input[autocomplete="current-password"]').fill(credentials.password)
+ await login.locator('button[type="submit"]').click()
+ await expect(login).not.toBeVisible()
+ await expect(page.getByTestId('sync-safety-status')).toContainText('Cloud confirmed', { timeout: 45000 })
+ expect(await page.evaluate(async id => (await (await import('/src/data/db/index.ts')).db.tasks.get(id))?.title, task.id)).toBe(task.title)
+ const cloudHasTask = await page.evaluate(async id => {
+ const { syncApi } = await import('/src/data/sync/client.ts')
+ const result = await syncApi.rxdbPull({ entityType: 'tasks', checkpoint: null, limit: 100 })
+ return result.documents.some(row => row.id === id)
+ }, task.id)
+ expect(cloudHasTask).toBe(true)
+ } finally { await context.close() }
+})
diff --git a/apps/web/focus-go-api/auth/betterAuth.js b/apps/web/focus-go-api/auth/betterAuth.js
index d07333b..f4baef8 100644
--- a/apps/web/focus-go-api/auth/betterAuth.js
+++ b/apps/web/focus-go-api/auth/betterAuth.js
@@ -1,5 +1,5 @@
import { betterAuth } from 'better-auth'
-import { username } from 'better-auth/plugins'
+import { bearer, username } from 'better-auth/plugins'
import dotenv from 'dotenv'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
@@ -62,6 +62,7 @@ export const auth = betterAuth({
},
socialProviders: googleProvider,
plugins: [
+ bearer(),
username({
minUsernameLength: 3,
maxUsernameLength: 30,
diff --git a/apps/web/focus-go-api/auth/sessionRenewal.test.js b/apps/web/focus-go-api/auth/sessionRenewal.test.js
new file mode 100644
index 0000000..b8366b0
--- /dev/null
+++ b/apps/web/focus-go-api/auth/sessionRenewal.test.js
@@ -0,0 +1,33 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import Database from 'better-sqlite3'
+import { betterAuth } from 'better-auth'
+import { bearer } from 'better-auth/plugins'
+import { getMigrations } from 'better-auth/db/migration'
+
+test('bearer session renewal extends active sessions but never revives expired or revoked sessions', async () => {
+ const database = new Database(':memory:')
+ const options = {
+ database,
+ secret: 'isolated-session-renewal-test-secret-32chars',
+ baseURL: 'http://localhost:3000',
+ emailAndPassword: { enabled: true },
+ plugins: [bearer()],
+ session: { expiresIn: 3600, updateAge: 60 },
+ }
+ try {
+ await (await getMigrations(options)).runMigrations()
+ const auth = betterAuth(options)
+ const signup = await auth.api.signUpEmail({ body: { email: 'renewal@example.test', password: 'test-password-12345', name: 'Test' } })
+ const token = signup.token
+ const soon = new Date(Date.now() + 30_000)
+ database.prepare('UPDATE session SET expiresAt = ? WHERE token = ?').run(soon.toISOString(), token)
+ const headers = new Headers({ authorization: `Bearer ${token}` })
+ const renewed = await auth.api.getSession({ headers })
+ assert.equal(renewed.session.token, token)
+ assert.ok(new Date(renewed.session.expiresAt).getTime() > Date.now() + 3_000_000)
+ database.prepare('UPDATE session SET expiresAt = ? WHERE token = ?').run(new Date(Date.now() - 1000).toISOString(), token)
+ assert.equal(await auth.api.getSession({ headers }), null)
+ assert.equal(await auth.api.getSession({ headers: new Headers({ authorization: 'Bearer revoked-token' }) }), null)
+ } finally { database.close() }
+})
diff --git a/apps/web/focus-go-api/test/fixtures/sync-auth-browser-server.js b/apps/web/focus-go-api/test/fixtures/sync-auth-browser-server.js
index c850b9b..c476e03 100644
--- a/apps/web/focus-go-api/test/fixtures/sync-auth-browser-server.js
+++ b/apps/web/focus-go-api/test/fixtures/sync-auth-browser-server.js
@@ -2,6 +2,7 @@ import express from 'express'
import cors from 'cors'
import Database from 'better-sqlite3'
import { betterAuth } from 'better-auth'
+import { bearer } from 'better-auth/plugins'
import { toNodeHandler } from 'better-auth/node'
import { createSyncRouter } from '../../routes/sync.js'
import { SYNC_TABLES } from '../../sync/config.js'
@@ -71,6 +72,7 @@ app.use('/sync', createSyncRouter({ database, authMiddleware: requireTestAccount
const server = app.listen(0, '127.0.0.1', () => {
const port = server.address().port
auth = betterAuth({
+ plugins: [bearer()],
database,
secret: 'focusgo-browser-sync-test-secret-32chars',
baseURL: `http://127.0.0.1:${port}/api/auth`,
diff --git a/apps/web/src/app/routes/SettingsRoute.theme-pack.test.tsx b/apps/web/src/app/routes/SettingsRoute.theme-pack.test.tsx
index 2160f6f..17dcb09 100644
--- a/apps/web/src/app/routes/SettingsRoute.theme-pack.test.tsx
+++ b/apps/web/src/app/routes/SettingsRoute.theme-pack.test.tsx
@@ -8,6 +8,11 @@ import SettingsRoute from './SettingsRoute'
import { THEME_BEFORE_MODE_TOGGLE_EVENT, clearThemePackPreview, getThemePalette } from '../../shared/theme/themePack'
import { applyTheme, writeStoredThemePreference } from '../../shared/theme/theme'
+const syncMocks = vi.hoisted(() => ({ expired: false, sync: vi.fn() }))
+vi.mock('../../data/sync/auth', () => ({ useSyncAuthRequired: () => syncMocks.expired }))
+vi.mock('../../data/sync/service', () => ({ useSyncActions: () => ({ enabled: true, setEnabled: vi.fn(), syncNow: syncMocks.sync }), useSyncStatus: () => syncMocks.expired ? ({ status: 'error', lastError: 'internal error repeated', lastSyncedAt: null }) : null }))
+vi.mock('../layout/LoginModal', () => ({ default: () =>
}))
+
const mockDashboardGet = vi.fn()
const mockDashboardUpsert = vi.fn()
@@ -23,6 +28,8 @@ vi.mock('../../data/repositories/dashboardRepo', () => ({
},
}))
+vi.mock('../../data/sync/conflicts', () => ({ listSyncConflicts: async () => [], markSyncConflictReviewed: vi.fn(), restoreSyncConflictAsCopy: vi.fn() }))
+
vi.mock('../../data/db', () => ({
db: {
delete: vi.fn(),
@@ -98,6 +105,7 @@ const chooseThemePack = async (optionText: string) => {
describe('SettingsRoute theme pack preview', () => {
beforeEach(() => {
+ syncMocks.expired = false
window.localStorage.clear()
applyTheme('light')
globalThis.ResizeObserver = class ResizeObserver {
@@ -131,6 +139,23 @@ describe('SettingsRoute theme pack preview', () => {
vi.clearAllMocks()
})
+ it('replaces repeated auth errors with a safe re-login action', async () => {
+ const { setAuth } = await import('../../store/auth')
+ setAuth({ accessToken: 'expired-test', user: { id: 'same-account' } })
+ syncMocks.expired = true
+ const user = userEvent.setup()
+ renderRoute()
+ try {
+ await user.click(screen.getByRole('tab', { name: /settings.module.data.title/ }))
+ expect(await screen.findByText('settings.data.sync.authRequired')).toBeVisible()
+ expect(screen.getByText('settings.data.sync.authStatus')).toBeVisible()
+ expect(screen.queryByText('internal error repeated')).not.toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'settings.data.sync.signInAgain' }))
+ expect(await screen.findByRole('dialog', { name: 'Reauthenticate' })).toBeVisible()
+ expect(syncMocks.sync).not.toHaveBeenCalled()
+ } finally { setAuth(null) }
+ })
+
it('previews selected theme pack immediately and does not persist it', async () => {
renderRoute()
await chooseThemePack('settings.themePack.option.b')
diff --git a/apps/web/src/app/routes/SettingsRoute.tsx b/apps/web/src/app/routes/SettingsRoute.tsx
index 9010186..1ef4ebf 100644
--- a/apps/web/src/app/routes/SettingsRoute.tsx
+++ b/apps/web/src/app/routes/SettingsRoute.tsx
@@ -1,4 +1,5 @@
import AnonymousCountsSetting from '../../shared/metrics/AnonymousCountsSetting'
+import { useSyncAuthRequired } from '../../data/sync/auth'
import { LEGAL_DOCUMENTS, policyUrl, type LegalDocument, type LegalDocumentKey } from '../../shared/legal/policies'
import { Suspense, lazy, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react'
import { motion } from 'motion/react'
@@ -597,6 +598,7 @@ const SettingsRoute = () => {
const { requireAuth, isGated } = useAuthGate()
const isLoggedIn = useIsLoggedIn()
const [showLoginModal, setShowLoginModal] = useState(false)
+ const syncAuthRequired = useSyncAuthRequired()
const cloudSyncQuota = useCloudSyncQuota()
const toast = useToast()
const syncState = useSyncStatus()
@@ -1685,7 +1687,7 @@ const SettingsRoute = () => {
aria-label={t('settings.data.sync.toggle')}
/>
- {syncStatusLabel}
+ {syncAuthRequired ? t('settings.data.sync.authStatus') : syncStatusLabel}
{lastSyncedLabel}
{cloudSyncQuota ? {t('settings.data.sync.quota', { used: (cloudSyncQuota.usedBytes / 1024 / 1024).toFixed(1), limit: (cloudSyncQuota.limitBytes / 1024 / 1024).toFixed(0) })}
: null}
{cloudSyncEnabled && syncState?.status === 'blocked' && syncConflicts.some((item) => item.accountId === getAuth()?.user?.id && !item.resolvedAt) ? (
@@ -1694,15 +1696,15 @@ const SettingsRoute = () => {
) : cloudSyncEnabled && syncState?.status === 'error' && syncState.lastError ? (
- {t('settings.data.sync.error', { message: syncState.lastError })}
+ {syncAuthRequired ? t('settings.data.sync.authRequired') : t('settings.data.sync.error', { message: syncState.lastError })}
) : null}
)}
diff --git a/apps/web/src/config/authClient.ts b/apps/web/src/config/authClient.ts
index 93810c6..b37d7da 100644
--- a/apps/web/src/config/authClient.ts
+++ b/apps/web/src/config/authClient.ts
@@ -75,7 +75,7 @@ const requestAuth = async (path: string, init?: RequestInit): Promise => {
const payload = parseAuthPayload(text, response.headers.get('content-type'))
if (!response.ok) {
const error = payload as AuthErrorPayload | null
- throw new Error(normalizeAuthErrorMessage(error, `Auth request failed (${response.status})`))
+ throw Object.assign(new Error(normalizeAuthErrorMessage(error, `Auth request failed (${response.status})`)), { status: response.status })
}
return payload as T
}
@@ -120,7 +120,7 @@ export const authClient = {
body: JSON.stringify({ code, state, verifier }),
}),
- getSession: () => requestAuth('/get-session'),
+ getSession: (token?: string) => requestAuth('/get-session', { signal: AbortSignal.timeout(10_000), ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}) }),
signOut: () =>
requestAuth('/sign-out', {
diff --git a/apps/web/src/data/sync/auth.test.ts b/apps/web/src/data/sync/auth.test.ts
new file mode 100644
index 0000000..b4dc50a
--- /dev/null
+++ b/apps/web/src/data/sync/auth.test.ts
@@ -0,0 +1,93 @@
+// @vitest-environment jsdom
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({ session: vi.fn(), fetch: vi.fn(), save: vi.fn(), auth: { accessToken: 'old', user: { id: 'a' } } as { accessToken?: string; user: { id: string } } }))
+vi.mock('../../config/authClient', () => ({ authClient: { getSession: mocks.session } }))
+vi.mock('../../store/auth', () => ({ getAuth: () => mocks.auth, setAuth: (value: typeof mocks.auth) => { mocks.auth = value }, subscribeAuth: () => () => {} }))
+vi.mock('../../shared/apiBase', () => ({ fetchApi: mocks.fetch }))
+vi.mock('../../platform', () => ({ getPlatform: () => ({ saveAuthToken: mocks.save }) }))
+const session = (token = 'old') => ({ session: { token }, user: { id: 'auth-a' } })
+beforeEach(() => {
+ vi.resetModules()
+ mocks.auth = { accessToken: 'old', user: { id: 'a' } }
+ mocks.session.mockReset().mockResolvedValue(session())
+ mocks.fetch.mockReset().mockResolvedValue({ ok: true, json: async () => ({ id: 'a' }) })
+ mocks.save.mockReset().mockResolvedValue(undefined)
+})
+
+describe('sync session recovery', () => {
+ it('keeps an offline account hint without a token out of the expired state', async () => {
+ mocks.auth = { user: { id: 'a' } }
+ const online = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
+ try {
+ const { ensureSyncSession, isSyncAuthRequired, SyncAuthError } = await import('./auth')
+ const error = await ensureSyncSession().catch(error => error)
+ expect(error).not.toBeInstanceOf(SyncAuthError)
+ expect(error.message).toContain('offline')
+ expect(isSyncAuthRequired()).toBe(false)
+ expect(mocks.session).not.toHaveBeenCalled()
+ } finally { online.mockRestore() }
+ })
+ it('recovers an online account hint from its cookie without needing a token', async () => {
+ mocks.auth = { user: { id: 'a' } }
+ const { ensureSyncSession } = await import('./auth')
+ await ensureSyncSession()
+ expect(mocks.auth.accessToken).toBe('old')
+ expect(mocks.session).toHaveBeenCalledTimes(1)
+ })
+ it('deduplicates concurrent renewal and throttles successful checks', async () => {
+ const { ensureSyncSession } = await import('./auth')
+ await Promise.all([ensureSyncSession(), ensureSyncSession(), ensureSyncSession()])
+ await ensureSyncSession()
+ expect(mocks.session).toHaveBeenCalledTimes(1)
+ await ensureSyncSession(true)
+ expect(mocks.session).toHaveBeenCalledTimes(2)
+ })
+ it('recovers a missing cookie with a bearer session and persists the renewed token', async () => {
+ mocks.session.mockResolvedValueOnce(null).mockResolvedValueOnce(session('renewed'))
+ const { ensureSyncSession } = await import('./auth')
+ await ensureSyncSession()
+ expect(mocks.session.mock.calls).toEqual([[], ['old']])
+ expect(mocks.auth.accessToken).toBe('renewed')
+ expect(mocks.save).toHaveBeenCalledWith('renewed')
+ })
+ it('does not adopt another account from cookies', async () => {
+ mocks.session.mockResolvedValue(session('other-token'))
+ mocks.fetch.mockResolvedValue({ ok: true, json: async () => ({ id: 'b' }) })
+ const { ensureSyncSession, SyncAuthError } = await import('./auth')
+ await expect(ensureSyncSession()).rejects.toBeInstanceOf(SyncAuthError)
+ expect(mocks.auth).toEqual({ accessToken: 'old', user: { id: 'a' } })
+ expect(mocks.save).not.toHaveBeenCalled()
+ })
+ it('preserves identity when expired and resumes only after a new token', async () => {
+ mocks.session.mockResolvedValue(null)
+ const { ensureSyncSession, SyncAuthError, pauseSyncForAuth, isSyncAuthRequired } = await import('./auth')
+ await expect(ensureSyncSession()).rejects.toBeInstanceOf(SyncAuthError)
+ pauseSyncForAuth()
+ expect(isSyncAuthRequired()).toBe(true)
+ await expect(ensureSyncSession()).rejects.toBeInstanceOf(SyncAuthError)
+ expect(mocks.session).toHaveBeenCalledTimes(2)
+ expect(mocks.auth.user.id).toBe('a')
+ mocks.auth = { ...mocks.auth, accessToken: 'new-login' }
+ expect(isSyncAuthRequired()).toBe(false)
+ })
+ it('does not treat a network failure as expiration', async () => {
+ mocks.session.mockRejectedValue(new TypeError('Failed to fetch'))
+ const { ensureSyncSession, SyncAuthError, isSyncAuthRequired } = await import('./auth')
+ const error = await ensureSyncSession().catch(error => error)
+ expect(error).not.toBeInstanceOf(SyncAuthError)
+ expect(isSyncAuthRequired()).toBe(false)
+ expect(mocks.auth.accessToken).toBe('old')
+ })
+ it('discards renewal after another login completes', async () => {
+ let finish!: (value: ReturnType) => void
+ mocks.session.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
+ const { ensureSyncSession } = await import('./auth')
+ const pending = ensureSyncSession()
+ mocks.auth = { accessToken: 'new-login', user: { id: 'b' } }
+ finish(session('renewed'))
+ await expect(pending).rejects.toThrow('Sync account changed')
+ expect(mocks.fetch).not.toHaveBeenCalled()
+ expect(mocks.auth.accessToken).toBe('new-login')
+ })
+})
diff --git a/apps/web/src/data/sync/auth.ts b/apps/web/src/data/sync/auth.ts
new file mode 100644
index 0000000..8413551
--- /dev/null
+++ b/apps/web/src/data/sync/auth.ts
@@ -0,0 +1,73 @@
+import { useSyncExternalStore } from 'react'
+import { authClient } from '../../config/authClient'
+import { getAuth, setAuth, subscribeAuth } from '../../store/auth'
+import { getPlatform } from '../../platform'
+import { fetchApi } from '../../shared/apiBase'
+
+export const SYNC_AUTH_REQUIRED = 'Sync paused: please sign in again. Local changes are preserved.'
+export class SyncAuthError extends Error {
+ constructor() { super(SYNC_AUTH_REQUIRED); this.name = 'SyncAuthError' }
+}
+export const isSyncAuthError = (error: unknown): boolean => {
+ if (error instanceof SyncAuthError) return true
+ const wrapped = error as { message?: string; parameters?: { errors?: unknown[] } } | null
+ return wrapped?.message === SYNC_AUTH_REQUIRED || Boolean(wrapped?.parameters?.errors?.some(isSyncAuthError))
+}
+
+let blocked: { token: string | undefined; accountId: string } | undefined
+let lastCheckedToken: string | undefined
+let lastCheckedAt = 0
+let pending: Promise | undefined
+const listeners = new Set<() => void>()
+const notify = () => listeners.forEach(listener => listener())
+export const isSyncAuthRequired = () => Boolean(blocked && getAuth()?.user?.id === blocked.accountId && getAuth()?.accessToken === blocked.token)
+export const pauseSyncForAuth = () => {
+ const auth = getAuth()
+ blocked = auth?.user?.id ? { token: auth.accessToken, accountId: auth.user.id } : undefined
+ notify()
+}
+export const useSyncAuthRequired = () => useSyncExternalStore(listener => {
+ listeners.add(listener)
+ const unsubscribe = subscribeAuth(listener)
+ return () => { listeners.delete(listener); unsubscribe() }
+}, isSyncAuthRequired, () => false)
+
+// Recovery runs between cycles, never halfway through applying a response. A
+// different cookie account must not claim this device's pending local changes.
+export const ensureSyncSession = async (force = false) => {
+ if (isSyncAuthRequired()) throw new SyncAuthError()
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) throw new Error('Sync paused while offline')
+ const original = getAuth()
+ const token = original?.accessToken as string | undefined
+ const accountId = original?.user?.id
+ if (!accountId) throw new SyncAuthError()
+ if (!force && token && lastCheckedToken === token && Date.now() - lastCheckedAt < 300_000) return
+ if (pending) return pending
+ const check = async () => {
+ let session = await authClient.getSession()
+ // Bearer fallback also supports platforms where cookies are unavailable.
+ // The auth library validates AND renews it; revoked/expired tokens stay invalid.
+ if (!session?.session?.token && token) session = await authClient.getSession(token)
+ if (getAuth()?.accessToken !== token || getAuth()?.user?.id !== accountId) throw new Error('Sync account changed')
+ const renewedToken = session?.session?.token
+ if (!renewedToken || !session?.user) throw new SyncAuthError()
+ const response = await fetchApi('/user/profile', { signal: AbortSignal.timeout(10_000), headers: { Authorization: `Bearer ${renewedToken}` } })
+ if (response.status === 401 || response.status === 403) throw new SyncAuthError()
+ if (!response.ok) throw new Error('Unable to check sync session. Please retry.')
+ const profile = await response.json()
+ if (getAuth()?.accessToken !== token || getAuth()?.user?.id !== accountId) throw new Error('Sync account changed')
+ if (String(profile.id) !== String(accountId)) throw new SyncAuthError()
+ lastCheckedToken = renewedToken
+ lastCheckedAt = Date.now()
+ if (renewedToken !== token) {
+ setAuth({ ...original, accessToken: renewedToken })
+ await getPlatform().saveAuthToken(renewedToken)
+ }
+ }
+ pending = check().catch(error => {
+ if (getAuth()?.accessToken !== token || getAuth()?.user?.id !== accountId) throw new Error('Sync account changed')
+ if (error?.status === 401 || error?.status === 403) throw new SyncAuthError()
+ throw error
+ }).finally(() => { pending = undefined })
+ return pending
+}
diff --git a/apps/web/src/data/sync/client.test.ts b/apps/web/src/data/sync/client.test.ts
index cef0d86..4c0a797 100644
--- a/apps/web/src/data/sync/client.test.ts
+++ b/apps/web/src/data/sync/client.test.ts
@@ -98,6 +98,6 @@ describe('syncApi rxdb endpoints', () => {
entityType: 'notes',
checkpoint: null,
limit: 100,
- })).rejects.toThrow(/Sync auth token invalid or expired/)
+ })).rejects.toThrow(/Sync request blocked by browser or network/)
})
})
diff --git a/apps/web/src/data/sync/client.ts b/apps/web/src/data/sync/client.ts
index 0c6fed3..3306403 100644
--- a/apps/web/src/data/sync/client.ts
+++ b/apps/web/src/data/sync/client.ts
@@ -1,3 +1,4 @@
+import { SyncAuthError } from './auth'
import { getAuth } from '../../store/auth'
import { db } from '../db'
import { buildApiUrl, fetchApi } from '../../shared/apiBase'
@@ -35,7 +36,7 @@ const runSyncConnectivityDiagnosis = async () => {
body: JSON.stringify({ entityType: 'notes', checkpoint: null, limit: 1 }),
})
if (syncProbe.status === 401) {
- return new Error('Sync auth token invalid or expired — please log in again')
+ return new Error(`Sync request blocked by browser or network: ${base}`)
}
} catch {
return new Error(`Sync request blocked by browser or network: ${base}`)
@@ -60,7 +61,7 @@ const fetchJson = async (path: string, init?: RequestInit): Promise => {
throw await normalizeSyncFetchError(error)
}
if (!response.ok) {
- if (response.status === 401) throw new Error('Sync failed: session expired, please log in again')
+ if (response.status === 401) throw new SyncAuthError()
// Capture server body to surface in entityErrors so the user can see *why*
// it 500'd (e.g. unknown entityType, malformed checkpoint). Without this
// the client just retries forever against a broken endpoint.
diff --git a/apps/web/src/data/sync/rxdb.test.ts b/apps/web/src/data/sync/rxdb.test.ts
index 1702eb1..ec0cc1f 100644
--- a/apps/web/src/data/sync/rxdb.test.ts
+++ b/apps/web/src/data/sync/rxdb.test.ts
@@ -53,6 +53,17 @@ describe('rxdb sync migration', () => {
await db.delete({ disableAutoOpen: false })
})
+ it('stops launching entities on 401, shows one error, and retains pending local edits', async () => {
+ const { SYNC_AUTH_REQUIRED } = await import('./auth')
+ await db.pendingSyncChanges.add({ accountId: 'test-account', entityType: 'notes', entityId: 'local', op: 'upsert', payload: { id: 'local', title: 'Keep me', updatedAt: 1, createdAt: 1 }, createdAt: 1 } as never)
+ fetchMock.mockResolvedValue({ ok: false, status: 401 } as Response)
+ await expect(runRxdbSyncCycle()).rejects.toThrow(SYNC_AUTH_REQUIRED)
+ const entityTypes = new Set(fetchMock.mock.calls.map(([, init]) => JSON.parse(String(init?.body)).entityType))
+ expect(entityTypes.size).toBeLessThanOrEqual(6)
+ expect((await db.syncState.get('cloud-sync'))?.lastError).toBe(SYNC_AUTH_REQUIRED)
+ expect(await db.pendingSyncChanges.count()).toBe(1)
+ })
+
it('migrates existing Dexie rows into RxDB and pushes them through the new endpoint', async () => {
await db.tasks.put({
id: 'task-local',
diff --git a/apps/web/src/data/sync/rxdb.ts b/apps/web/src/data/sync/rxdb.ts
index 09f5586..a10ab44 100644
--- a/apps/web/src/data/sync/rxdb.ts
+++ b/apps/web/src/data/sync/rxdb.ts
@@ -1,3 +1,4 @@
+import { isSyncAuthError, SyncAuthError } from './auth'
import { hasPendingSyncChange, pendingForAccount, type PendingSyncChange } from './pendingChanges'
import { suppressSyncCapture } from './capture'
import { createRxDatabase, removeRxDatabase, type RxCollection } from 'rxdb'
@@ -585,9 +586,9 @@ export const runRxdbSyncCycle = async (requestedEntities?: SyncEntityType[]) =>
syncValidationErrors = []
if (!getAuth()?.accessToken) {
- const message = 'Sync failed: session expired, please log in again'
- await setStatus('error', message)
- throw new Error(message)
+ const error = new SyncAuthError()
+ await setStatus('error', error.message)
+ throw error
}
// No remote request can succeed in a known-offline browser. Keep RxDB's local
@@ -600,6 +601,7 @@ export const runRxdbSyncCycle = async (requestedEntities?: SyncEntityType[]) =>
await setStatus('syncing')
const entityErrors: string[] = []
+ let authFailed = false
// Process entities in parallel using a bounded worker pool.
// Each worker pulls from the shared queue until it is empty, so we never
@@ -611,13 +613,14 @@ export const runRxdbSyncCycle = async (requestedEntities?: SyncEntityType[]) =>
: [...SYNC_ENTITY_TYPES]
const runWorker = async () => {
while (queue.length > 0) {
- if (rxdbResetRequested) return
+ if (rxdbResetRequested || authFailed) return
const entityType = queue.shift()
if (!entityType) return
try {
await syncEntity(entityType)
} catch (error) {
- entityErrors.push(`${entityType}: ${extractSyncErrorMessage(error)}`)
+ if (isSyncAuthError(error)) authFailed = true
+ else entityErrors.push(`${entityType}: ${extractSyncErrorMessage(error)}`)
}
}
}
@@ -628,7 +631,12 @@ export const runRxdbSyncCycle = async (requestedEntities?: SyncEntityType[]) =>
if (rxdbResetRequested) return
- const errors = [...entityErrors, ...syncValidationErrors]
+ if (authFailed) {
+ const error = new SyncAuthError()
+ await setStatus('error', error.message)
+ throw error
+ }
+ const errors = [...new Set([...entityErrors, ...syncValidationErrors])]
if (errors.length > 0) {
const message = errors.join('; ')
await setStatus('error', message)
diff --git a/apps/web/src/data/sync/service.test.tsx b/apps/web/src/data/sync/service.test.tsx
index a2aef7c..d8d007b 100644
--- a/apps/web/src/data/sync/service.test.tsx
+++ b/apps/web/src/data/sync/service.test.tsx
@@ -4,8 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SyncProvider } from './service'
import { SYNC_LOCAL_COMMIT_EVENT } from './capture'
-const mocks = vi.hoisted(() => ({ cycle: vi.fn(), ready: vi.fn(), stop: vi.fn(), markStatus: vi.fn().mockResolvedValue({}) }))
-vi.mock('../../store/auth', () => ({ getAuth: () => ({ user: { id: 'account-a' } }), useIsLoggedIn: () => true, refreshAuthProfile: async () => {} }))
+const mocks = vi.hoisted(() => ({ cycle: vi.fn(), ready: vi.fn(), stop: vi.fn(), session: vi.fn(), paused: false, token: 'token', authListener: (() => {}) as () => void, markStatus: vi.fn().mockResolvedValue({}) }))
+vi.mock('./auth', async importOriginal => ({ ...(await importOriginal()), ensureSyncSession: mocks.session, isSyncAuthRequired: () => mocks.paused, useSyncAuthRequired: () => mocks.paused, pauseSyncForAuth: () => { mocks.paused = true } }))
+vi.mock('../../store/auth', () => ({ getAuth: () => ({ accessToken: mocks.token, user: { id: 'account-a' } }), subscribeAuth: (listener: () => void) => { mocks.authListener = listener; return () => {} }, useIsLoggedIn: () => true, refreshAuthProfile: async () => {} }))
vi.mock('../../shared/hooks/usePageActivity', () => ({ usePageActivity: () => 'visible' }))
vi.mock('../seed', () => ({ seedDatabase: async () => false }))
vi.mock('../db', () => ({ db: { localSymbolic: { where: () => ({ equals: () => ({ toArray: async () => [] }) }) }, pendingSyncChanges: { toArray: async () => [] } } }))
@@ -16,7 +17,7 @@ vi.mock('./stream', () => ({ subscribeSyncStream: () => mocks.stop }))
const commit = () => window.dispatchEvent(new CustomEvent(SYNC_LOCAL_COMMIT_EVENT, { detail: { entityTypes: ['notes'] } }))
const tick = async (milliseconds: number) => { await act(async () => { await vi.advanceTimersByTimeAsync(milliseconds) }) }
-beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-16T12:00:00Z')); vi.clearAllMocks(); mocks.cycle.mockResolvedValue(undefined); mocks.ready.mockResolvedValue(undefined); localStorage.clear() })
+beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-16T12:00:00Z')); vi.clearAllMocks(); mocks.paused = false; mocks.token = 'token'; mocks.session.mockReset().mockResolvedValue(undefined); mocks.cycle.mockReset().mockResolvedValue(undefined); mocks.ready.mockResolvedValue(undefined); localStorage.clear() })
afterEach(() => { cleanup(); vi.useRealTimers() })
describe('sync scheduling', () => {
@@ -75,3 +76,29 @@ describe('sync failures are visible even before replication starts', () => {
expect(mocks.markStatus).toHaveBeenCalledWith('error', 'IndexedDB unavailable')
})
})
+describe('expired sync session', () => {
+ it('stops periodic, reconnect and edit retries until a new login, then resumes', async () => {
+ const { SyncAuthError } = await import('./auth')
+ mocks.session.mockRejectedValueOnce(new SyncAuthError())
+ render()
+ await tick(0)
+ expect(mocks.paused).toBe(true)
+ act(() => { commit(); window.dispatchEvent(new Event('online')) })
+ await tick(600_000)
+ expect(mocks.cycle).not.toHaveBeenCalled()
+ expect(mocks.session).toHaveBeenCalledTimes(1)
+ act(() => { mocks.paused = false; mocks.token = 'new-token'; mocks.authListener() })
+ await tick(1000)
+ expect(mocks.cycle).toHaveBeenCalledTimes(1)
+ })
+
+ it('recovers a rejected session once, and stops when the retried cycle is still unauthorized', async () => {
+ const { SyncAuthError } = await import('./auth')
+ mocks.cycle.mockRejectedValue(new SyncAuthError())
+ render()
+ await tick(0)
+ expect(mocks.session.mock.calls).toEqual([[], [true]])
+ expect(mocks.cycle).toHaveBeenCalledTimes(2)
+ expect(mocks.paused).toBe(true)
+ })
+})
diff --git a/apps/web/src/data/sync/service.tsx b/apps/web/src/data/sync/service.tsx
index 3c46c3c..dd4050e 100644
--- a/apps/web/src/data/sync/service.tsx
+++ b/apps/web/src/data/sync/service.tsx
@@ -1,5 +1,6 @@
+import { ensureSyncSession, isSyncAuthError, isSyncAuthRequired, pauseSyncForAuth, useSyncAuthRequired } from './auth'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
-import { getAuth, refreshAuthProfile, useIsLoggedIn } from '../../store/auth'
+import { getAuth, refreshAuthProfile, subscribeAuth, useIsLoggedIn } from '../../store/auth'
import { dispatchSyncDataUpdated, SYNC_DATA_UPDATED_EVENT, SYNC_STATUS_CHANGED_EVENT, type SyncDataUpdatedDetail } from './constants'
import type { SyncEntityType } from './types'
import { syncStateRepo } from './repository'
@@ -39,6 +40,7 @@ const loadRxdbSync = () => import('./rxdb')
export const SyncProvider = ({ children }: { children: ReactNode }) => {
const isLoggedIn = useIsLoggedIn()
+ const authRequired = useSyncAuthRequired()
const [state, setState] = useState(null)
const [enabled, setEnabledState] = useState(readCloudSyncEnabled)
const runningRef = useRef(false)
@@ -80,7 +82,7 @@ export const SyncProvider = ({ children }: { children: ReactNode }) => {
const lastSyncEndedAtRef = useRef(0)
const consecutiveErrorsRef = useRef(0)
const syncNow = useCallback(async (trigger: string = 'manual', entities?: SyncEntityType[]) => {
- if (!enabled || !isLoggedIn) return
+ if (!enabled || !isLoggedIn || isSyncAuthRequired()) return
if (runningRef.current) {
if (trigger === 'change' || trigger === 'manual' || trigger === 'online') {
if (!entities) fullSyncRequestedRef.current = true
@@ -106,23 +108,39 @@ export const SyncProvider = ({ children }: { children: ReactNode }) => {
console.info('[sync] cycle start', { trigger })
runningRef.current = true
let cycleError: unknown = null
+ let attemptedToken = getAuth()?.accessToken
try {
const { runRxdbSyncCycle } = await loadRxdbSync()
- await runRxdbSyncCycle(entities)
- const accountId = getAuth()?.user?.id
- if (accountId) {
- const symbolic = await db.localSymbolic.where('ownerId').equals(accountId).toArray()
- if (symbolic.some(symbolicNeedsUpload)) {
- const { syncSymbolicNow } = await import('../../features/symbolic/sync')
- await syncSymbolicNow(accountId)
+ await ensureSyncSession()
+ attemptedToken = getAuth()?.accessToken
+ const runAuthenticatedCycle = async () => {
+ await runRxdbSyncCycle(entities)
+ const accountId = getAuth()?.user?.id
+ if (accountId) {
+ const symbolic = await db.localSymbolic.where('ownerId').equals(accountId).toArray()
+ if (symbolic.some(symbolicNeedsUpload)) {
+ const { syncSymbolicNow } = await import('../../features/symbolic/sync')
+ await syncSymbolicNow(accountId)
+ }
}
}
+ try {
+ await runAuthenticatedCycle()
+ } catch (error) {
+ if (!isSyncAuthError(error)) throw error
+ await ensureSyncSession(true)
+ attemptedToken = getAuth()?.accessToken
+ await runAuthenticatedCycle()
+ }
await refreshAuthProfile()
consecutiveErrorsRef.current = 0
} catch (error) {
cycleError = error
consecutiveErrorsRef.current += 1
- await syncStateRepo.markStatus('error', error instanceof Error ? error.message : String(error)).catch(() => {})
+ if (getAuth()?.accessToken === attemptedToken) {
+ if (isSyncAuthError(error)) pauseSyncForAuth()
+ await syncStateRepo.markStatus('error', error instanceof Error ? error.message : String(error)).catch(() => {})
+ }
} finally {
runningRef.current = false
lastSyncEndedAtRef.current = Date.now()
@@ -141,6 +159,23 @@ export const SyncProvider = ({ children }: { children: ReactNode }) => {
useEffect(() => { syncNowRef.current = syncNow }, [syncNow])
+ useEffect(() => {
+ let token = getAuth()?.accessToken
+ let accountId = getAuth()?.user?.id
+ return subscribeAuth(() => {
+ const auth = getAuth()
+ if (auth?.accessToken === token && auth?.user?.id === accountId) return
+ token = auth?.accessToken
+ accountId = auth?.user?.id
+ if (token && !isSyncAuthRequired()) {
+ consecutiveErrorsRef.current = 0
+ lastSyncEndedAtRef.current = 0
+ queueSync()
+ }
+ })
+ }, [queueSync])
+
+
useEffect(() => {
if (!enabled || !isLoggedIn) return
const queuedEntities = queuedEntitiesRef.current
@@ -163,9 +198,9 @@ export const SyncProvider = ({ children }: { children: ReactNode }) => {
}, [enabled, isLoggedIn, queueSync])
useEffect(() => {
- if (!enabled || !isLoggedIn || pageActivity !== 'visible') return
+ if (!enabled || !isLoggedIn || authRequired || pageActivity !== 'visible') return
return subscribeSyncStream(entities => queueSync(entities))
- }, [enabled, isLoggedIn, pageActivity, queueSync])
+ }, [enabled, isLoggedIn, authRequired, pageActivity, queueSync])
const initialize = useCallback(async () => {
if (!enabled || !isLoggedIn || runningRef.current) return
diff --git a/apps/web/src/data/sync/stream.test.ts b/apps/web/src/data/sync/stream.test.ts
index d7e0449..2e71e84 100644
--- a/apps/web/src/data/sync/stream.test.ts
+++ b/apps/web/src/data/sync/stream.test.ts
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { consumeSyncStream, subscribeSyncStream } from './stream'
+const authState = vi.hoisted(() => ({ paused: false }))
+vi.mock('./auth', () => ({ isSyncAuthRequired: () => authState.paused }))
vi.mock('../../store/auth', () => ({ getAuth: () => ({ accessToken: 'stream-test-token' }) }))
describe('authenticated sync notifications', () => {
@@ -12,6 +14,24 @@ describe('authenticated sync notifications', () => {
expect(onChange.mock.calls).toEqual([[], [['notes']]])
})
+ it('hands a 401 to recovery and does not reconnect while authentication is paused', async () => {
+ vi.useFakeTimers()
+ const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401 })
+ vi.stubGlobal('fetch', fetchMock)
+ const onChange = vi.fn(() => { authState.paused = true })
+ const unsubscribe = subscribeSyncStream(onChange)
+ try {
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(onChange).toHaveBeenCalledOnce()
+ expect(fetchMock).toHaveBeenCalledOnce()
+ } finally {
+ unsubscribe()
+ authState.paused = false
+ vi.unstubAllGlobals()
+ vi.useRealTimers()
+ }
+ })
+
it('aborts streaming requests and cancels retries on unsubscribe', async () => {
const fetchMock = vi.fn().mockImplementation((_url, init) => new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))))
vi.stubGlobal('fetch', fetchMock)
diff --git a/apps/web/src/data/sync/stream.ts b/apps/web/src/data/sync/stream.ts
index dcab6d7..be324cb 100644
--- a/apps/web/src/data/sync/stream.ts
+++ b/apps/web/src/data/sync/stream.ts
@@ -1,3 +1,4 @@
+import { isSyncAuthRequired } from './auth'
import { getAuth } from '../../store/auth'
import { fetchApi } from '../../shared/apiBase'
import { SYNC_ENTITY_TYPES, type SyncEntityType } from './types'
@@ -34,12 +35,13 @@ export const subscribeSyncStream = (onChange: (entities?: SyncEntityType[]) => v
let timer: ReturnType | undefined
let failures = 0
const connect = async () => {
- if (stopped) return
+ if (stopped || isSyncAuthRequired()) return
controller = new AbortController()
try {
const token = getAuth()?.accessToken
if (!token) return
const response = await fetchApi('/sync/events', { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal })
+ if (response.status === 401) onChange() // Let the cycle recover once or pause; reconnect checks the pause gate.
if (!response.ok || !response.body) throw new Error('Sync notification unavailable')
failures = 0
await consumeSyncStream(response.body, onChange)
diff --git a/apps/web/src/features/symbolic/sync.test.ts b/apps/web/src/features/symbolic/sync.test.ts
index a962601..e3f29b7 100644
--- a/apps/web/src/features/symbolic/sync.test.ts
+++ b/apps/web/src/features/symbolic/sync.test.ts
@@ -1,6 +1,7 @@
import 'fake-indexeddb/auto'
import { beforeEach, afterAll, describe, it, expect, vi } from 'vitest'
-const auth = vi.hoisted(() => ({ owner: 'guest', session: 'test' }))
+const auth = vi.hoisted(() => ({ owner: 'guest', session: 'test', paused: false }))
+vi.mock('../../data/sync/auth', async importOriginal => ({ ...(await importOriginal()), isSyncAuthRequired: () => auth.paused }))
const fetchApi = vi.hoisted(() => vi.fn())
vi.mock('../../store/auth', () => ({ getAuth: () => auth.owner === 'guest' ? null : { user: { id: auth.owner }, accessToken: auth.session } }))
vi.mock('../../shared/apiBase', () => ({ fetchApi }))
@@ -41,10 +42,30 @@ function useServer() {
const browserStorage = new Map()
vi.stubGlobal('localStorage', { getItem: (key: string) => browserStorage.get(key) ?? null, setItem: (key: string, value: string) => browserStorage.set(key, value), clear: () => browserStorage.clear() })
beforeEach(async () => {
- auth.owner = 'guest'; auth.session = 'test'; localStorage.clear(); resumeSymbolicSyncAfterResetFailure(); await db.localSymbolic.clear(); fetchApi.mockReset(); useServer()
+ auth.owner = 'guest'; auth.session = 'test'; auth.paused = false; localStorage.clear(); resumeSymbolicSyncAfterResetFailure(); await db.localSymbolic.clear(); fetchApi.mockReset(); useServer()
})
afterAll(() => db.close())
describe('symbolic rebuild persistence and sync', () => {
+ it('does not send independent timer requests while authentication is paused', async () => {
+ auth.owner = 'owner'
+ const profile = await saveBirthProfile('owner', birth, null, null)
+ auth.paused = true
+ vi.useFakeTimers()
+ const stop = startSymbolicSync('owner')
+ try {
+ await vi.advanceTimersByTimeAsync(90_000)
+ expect(fetchApi).not.toHaveBeenCalled()
+ } finally { stop(); vi.useRealTimers() }
+ expect((await db.localSymbolic.get(['owner', profile.id!]))?.dirty).toBe(true)
+ })
+ it('classifies a server 401 as authentication failure and preserves pending records', async () => {
+ auth.owner = 'owner'
+ const profile = await saveBirthProfile('owner', birth, null, null)
+ fetchApi.mockResolvedValue(Response.json({ error: 'expired' }, { status: 401 }))
+ const { SyncAuthError } = await import('../../data/sync/auth')
+ await expect(syncSymbolicNow('owner')).rejects.toBeInstanceOf(SyncAuthError)
+ expect((await db.localSymbolic.get(['owner', profile.id!]))?.dirty).toBe(true)
+ })
it('resumes after switching accounts but keeps explicit resets paused', async () => {
auth.owner = 'owner-a'; pauseSymbolicSyncForAccountCleanup()
const same = startSymbolicSync('owner-a'); same()
diff --git a/apps/web/src/features/symbolic/sync.ts b/apps/web/src/features/symbolic/sync.ts
index 18b8f6b..87390ad 100644
--- a/apps/web/src/features/symbolic/sync.ts
+++ b/apps/web/src/features/symbolic/sync.ts
@@ -1,3 +1,4 @@
+import { isSyncAuthRequired, SyncAuthError } from '../../data/sync/auth'
import { db } from '../../data/db'
import { getAuth } from '../../store/auth'
import { fetchApi } from '../../shared/apiBase'
@@ -68,12 +69,13 @@ if (typeof window !== 'undefined') {
window.addEventListener('focusgo:db-reset-requested', pauseSymbolicSyncForAccountCleanup)
window.addEventListener('focusgo:auth-changed', handleSymbolicOwnerChange)
}
-const guard = (owner: string, captured: number) => { assertSymbolicOwner(owner); if (stopped || captured !== epoch) throw new Error('Symbolic sync was reset'); if (!symbolicCloudEnabled()) throw new Error('Cloud sync is off') }
+const guard = (owner: string, captured: number) => { assertSymbolicOwner(owner); if (isSyncAuthRequired()) throw new SyncAuthError(); if (stopped || captured !== epoch) throw new Error('Symbolic sync was reset'); if (!symbolicCloudEnabled()) throw new Error('Cloud sync is off') }
const request = async (owner: string, captured: number, path: string, body: unknown): Promise => {
guard(owner, captured)
const token = getAuth()?.accessToken
const response = await fetchApi('/symbolic/' + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: JSON.stringify(body) })
guard(owner, captured)
+ if (response.status === 401) throw new SyncAuthError()
const data = await response.json()
guard(owner, captured)
if (!response.ok) throw new Error(typeof data?.error === 'string' ? data.error : 'Symbolic sync failed')
@@ -117,6 +119,11 @@ async function mergeRemote(owner: string, remote: SymbolicWireRow) {
const runners = new Map>()
export function syncSymbolicNow(owner: string): Promise {
if (owner === 'guest' || !symbolicCloudEnabled() || stopped) { setStatus(owner, { status: 'local', conflicts: 0 }); return Promise.resolve() }
+ if (isSyncAuthRequired()) {
+ const error = new SyncAuthError()
+ setStatus(owner, { status: 'error', message: error.message, conflicts: 0 })
+ return Promise.reject(error)
+ }
const running = runners.get(owner)
if (running) return running
const promise = runSync(owner).finally(() => { if (runners.get(owner) === promise) runners.delete(owner) })
diff --git a/apps/web/src/shared/i18n/messages/en.ts b/apps/web/src/shared/i18n/messages/en.ts
index e1459ec..4f9f7bd 100644
--- a/apps/web/src/shared/i18n/messages/en.ts
+++ b/apps/web/src/shared/i18n/messages/en.ts
@@ -297,6 +297,9 @@ export const enMessages: TranslationMessages = {
'settings.data.sync.lastSynced': 'Last synced: {{time}}',
'settings.data.sync.lastSynced.never': 'Last synced: Never',
'settings.data.sync.error': 'Latest error: {{message}}',
+ 'settings.data.sync.authStatus': 'Sync paused · Sign-in required',
+ 'settings.data.sync.signInAgain': 'Sign in again',
+ 'settings.data.sync.authRequired': 'Your session has expired. Sync is paused and local changes are preserved. Sign in to the same account to resume.',
'settings.data.sync.action': 'Sync now',
'settings.data.sync.conflicts.title': 'Sync conflict copies',
'settings.data.sync.conflicts.description': 'When two devices change the same item, the cloud version stays active and this device’s edit is saved here. Compare both copies; notes can be recovered as a separate note.',
diff --git a/apps/web/src/shared/i18n/messages/zh.ts b/apps/web/src/shared/i18n/messages/zh.ts
index 2649645..88be087 100644
--- a/apps/web/src/shared/i18n/messages/zh.ts
+++ b/apps/web/src/shared/i18n/messages/zh.ts
@@ -297,6 +297,9 @@ export const zhMessages: TranslationMessages = {
'settings.data.sync.lastSynced': '上次同步:{{time}}',
'settings.data.sync.lastSynced.never': '上次同步:从未',
'settings.data.sync.error': '最近错误:{{message}}',
+ 'settings.data.sync.authStatus': '同步已暂停 · 需要重新登录',
+ 'settings.data.sync.signInAgain': '重新登录',
+ 'settings.data.sync.authRequired': '登录已失效,云同步已暂停。本机修改仍保留,请重新登录原账号后继续同步。',
'settings.data.sync.action': '立即同步',
'settings.data.sync.conflicts.title': '同步冲突副本',
'settings.data.sync.conflicts.description': '两台设备同时修改同一条内容时,云端版本继续使用;本机修改会保存在这里。可对照两份内容,并把笔记的本机版本另存为新笔记。',
diff --git a/apps/web/src/shared/i18n/types.ts b/apps/web/src/shared/i18n/types.ts
index d0bbc3c..daa581b 100644
--- a/apps/web/src/shared/i18n/types.ts
+++ b/apps/web/src/shared/i18n/types.ts
@@ -1,6 +1,9 @@
export type LanguageCode = 'en' | 'zh'
export type TranslationKey =
+ | 'settings.data.sync.authRequired'
+ | 'settings.data.sync.authStatus'
+ | 'settings.data.sync.signInAgain'
| 'notes.paste.failed'
| 'notes.block.delete'
| 'notes.block.duplicate'