fix(security): stop cross-user offline data leak on shared devices (#1176)

Closes BLOCKER B4 — three reinforcing paths could serve one account's
cached data to the next user on a shared device:

- The Workbox 'api-data' cache keyed trip/user-scoped GETs by URL only
  (cookie-blind). Changed to NetworkOnly; offline reads come from the
  per-user IndexedDB cache via the repo layer instead.
- IndexedDB had no per-user scoping. The Dexie connection is now scoped
  per user (trek-offline-u<id>) behind a Proxy so the ~19 importers keep a
  stable binding; login opens the user DB, logout deletes it and returns
  to the anonymous DB.
- logout() was fire-and-forget and racy: background flush/syncAll could
  re-seed the DB after the wipe. It is now async and ordered — close an
  auth gate, unregister sync triggers, disconnect, clear caches, delete
  the user DB — and flush()/syncAll() bail when the gate is closed.
This commit is contained in:
jubnl
2026-06-15 07:58:20 +02:00
committed by GitHub
parent 0a794583d7
commit 5500405f2f
10 changed files with 223 additions and 28 deletions
+18
View File
@@ -0,0 +1,18 @@
/**
* Auth gate — a single boolean the sync layer checks before touching the
* offline DB. It lets logout disable all background sync (flush / syncAll /
* periodic triggers) *before* awaiting the DB swap, so an in-flight loop can't
* re-seed the database after the user has logged out.
*
* Kept separate from authStore to avoid an import cycle
* (authStore → tripSyncManager → authStore).
*/
let _authed = false
export function setAuthed(value: boolean): void {
_authed = value
}
export function isAuthed(): boolean {
return _authed
}
+2 -1
View File
@@ -7,6 +7,7 @@
*/
import { offlineDb } from '../db/offlineDb'
import { apiClient } from '../api/client'
import { isAuthed } from './authGate'
import type { QueuedMutation } from '../db/offlineDb'
import type { Table } from 'dexie'
@@ -88,7 +89,7 @@ export const mutationQueue = {
* 4xx responses are marked failed and skipped.
*/
async flush(): Promise<void> {
if (_flushing || !navigator.onLine) return
if (_flushing || !navigator.onLine || !isAuthed()) return
_flushing = true
// tempId → realId learned during this flush, so a dependent edit/delete
// queued against an offline-created entity (still holding the negative id)
+2 -1
View File
@@ -29,6 +29,7 @@ import {
clearTripData,
} from '../db/offlineDb'
import { prefetchTilesForTrip } from './tilePrefetcher'
import { isAuthed } from './authGate'
import { useSettingsStore } from '../store/settingsStore'
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
@@ -134,7 +135,7 @@ export const tripSyncManager = {
* No-ops when offline.
*/
async syncAll(): Promise<void> {
if (_syncing || !navigator.onLine) return
if (_syncing || !navigator.onLine || !isAuthed()) return
_syncing = true
try {
const { trips } = await tripsApi.list() as { trips: Trip[] }