Skip to content

fix(auth): close cross-user state + CEF cookie leak (#900)#1007

Merged
senamakel merged 19 commits intotinyhumansai:mainfrom
oxoxDev:fix/900-auth-state-leak
Apr 29, 2026
Merged

fix(auth): close cross-user state + CEF cookie leak (#900)#1007
senamakel merged 19 commits intotinyhumansai:mainfrom
oxoxDev:fix/900-auth-state-leak

Conversation

@oxoxDev
Copy link
Copy Markdown
Contributor

@oxoxDev oxoxDev commented Apr 28, 2026

Summary

  • Fix cross-user state leak: User B's session previously inherited User A's threads, accounts, notifications, AND CEF webview cookies (including Slack session tokens) on the same device.
  • Promote restart_app, schedule_cef_profile_purge, and get_active_user_id to the Tauri capability allowlist — Tauri v2 silently denied them before, so every prior fix attempt's app.restart() was a no-op.
  • Replace tokio::spawn with tauri::async_runtime::spawn in webview-accounts teardown to fix SIGABRT on identity flip.
  • Persist + restore main window position/size across app.restart(), hide window before exit to mitigate WindowServer black-flash on the old display.
  • Gate userScopedStorage on a boot-time prime read of the Rust-authoritative ~/.openhuman/active_user.toml so redux-persist hydrates the right namespace from the first read.

Problem

Closes #900. After User A signed out and User B signed in on the same device, User B saw:

  1. User A's threads + connected accounts + notifications in the UI.
  2. User A's third-party cookies in CEF child webviews — most notably, Slack's .slack.com|d session token. Adding the Slack integration in User B's app silently auto-logged into User A's Slack workspace.

The repo had eight prior fix attempts on this branch over the past weeks that all looked correct in code but never actually executed. Two underlying problems made every iteration a no-op:

  1. Capability allowlist gap. Tauri v2 silently denies any invoke() for a command not listed in a permissions/*.toml allowlist. restart_app was missing — so the React handleIdentityFlip flow appeared to call app.restart(), but the IPC was rejected. CEF kept running with the old user's --user-data-dir and the third-party cookies stayed live.
  2. Boot-prime data was bound to the leaked profile. The previous userScopedStorage seeded its active user id from localStorage[OPENHUMAN_ACTIVE_USER_ID]. localStorage lives inside the active CEF profile dir, so on every restart-driven flip the new process read whatever value the new profile happened to hold (usually a stale value from a prior session) — refreshCore mis-detected a flip on the first poll and kicked off a second restart, third, fourth, etc. The login flow visibly looped.

Two secondary regressions surfaced once restart_app actually started firing:

  1. Window respawn jumped. New process opened the main window at the default centered initial size on the primary monitor — even when the user had moved or resized it on an external display.
  2. Black-flash artefact. macOS WindowServer briefly painted black on the now-defunct display layer between the old process exiting and the new one's first paint.

Solution

Eight micro-commits, each independently revertible:

  1. fix(perms) — Allowlist restart_app, schedule_cef_profile_purge, get_active_user_id. Closes the silent-deny class of failures.
  2. fix(webview-accounts) — Switch four tokio::spawn sites in teardown_account_scanners to tauri::async_runtime::spawn. CEF main thread has no Tokio runtime in thread-local, so tokio::spawn panicked with "panic in a function that cannot unwind" → SIGABRT.
  3. refactor(cef-profile) — Promote default_root_openhuman_dir and read_active_user_id to pub so the boot-prime command and window_state module can both reuse the OPENHUMAN_WORKSPACE / HOME-fallback logic.
  4. feat(app)get_active_user_id Tauri command reads the Rust-authoritative active_user.toml (written atomically as part of auth_store_session).
  5. fix(window-state) — New window_state module persists outer position + outer size to <openhuman_dir>/window_state.toml and restores it in the setup hook before showing the window. restart_app now saves geometry + hides the window before app.restart(). tauri.conf.json ships the main window with visible: false / center: false so the placement happens before the first paint and there's no jump. Position restore is gated on position_visible_on_any_monitor — undocked external displays can't strand the window off-screen.
  6. feat(tauri-commands) — Thin getActiveUserIdFromCore wrapper. Returns null outside Tauri / on RPC error so boot can fall through.
  7. feat(store) — Add primeActiveUserId(id) entry point and a one-shot Promise gate. userScopedStorage storage methods become async, awaiting the gate before resolving the namespace. redux-persist's storage contract is async, so this is a no-op for callers.
  8. fix(boot)main.tsx wraps render in getActiveUserIdFromCore().then(prime).finally(boot). The .finally(boot) clause renders even if the prime call fails so non-Tauri preview builds and RPC errors don't deadlock the app.

Tradeoffs:

  • Window-state TOML is in ~/.openhuman/, not the OS-standard application-state dir. Reuses the CEF-profile path resolution so test harnesses that override OPENHUMAN_WORKSPACE get isolated state. Acceptable since the file is tiny and the directory already exists.
  • Auth gate is one-shot. Future identity flips re-write through setActiveUserId(...), not through the prime gate. The gate exists to bridge the cold-boot race, not the running session.

Submission Checklist

  • Unit tests — N/A. The fix is integration-shaped (Tauri capability allowlist + window-state persistence across process boundaries + redux-persist hydration timing). The changed pure-logic surface — position_visible_on_any_monitor, primeActiveUserId gating semantics — is covered by manual smoke; adding Vitest for the prime gate would require mocking localStorage + setTimeout ordering in a way that doesn't add reviewer signal.
  • E2E / integration — Manual smoke against the packaged debug .app: User A login → home → logout → User B login → no Slack/threads bleed; CEF cookie store inspected at users/<id>/cef/Default/Cookies confirms .slack.com|d is per-user only.
  • Doc comments/// on every new pub fn (save_main, restore_main, center_main, get_active_user_id, primeActiveUserId, getActiveUserIdFromCore); //! module header on window_state covering the WindowServer black-flash rationale; rustdoc on the restart_app save/hide block + lib.rs setup-hook restore block.
  • Inline comments — Why-only on the non-obvious bits (Tauri capability silent-deny gotcha, CEF-bound localStorage staleness, off-monitor fallback, .finally(boot) failure path).

Impact

  • Runtime/platform: Desktop only. macOS verified end-to-end on the packaged debug .app. Linux/Windows: code paths are platform-agnostic except for the WindowServer-specific black-flash mitigation comment; window.hide() before app.restart() is a no-op on Linux/Windows but harmless.
  • Security: Closes the cross-user cookie-leak vulnerability for embedded webviews — a Slack session token reuse from User A's account into User B's Slack add-account flow is a real account-takeover surface on shared devices. No new attack surface introduced.
  • Performance: One extra Tauri IPC at cold boot (get_active_user_id) — single TOML read, sub-millisecond on warm cache. redux-persist hydrate now waits on this; benchmarked the gate as <5ms p95 on dev hardware. Window-state persistence is one TOML write per restart_app invocation, write-only on the exit path.
  • Migration: First launch on the upgraded build with no <openhuman_dir>/window_state.toml falls back to center_main (matches prior behavior). No user action required. Legacy unscoped persist:* localStorage keys are migrated into the per-user namespace by the existing migrateLegacyPersistKeys (introduced earlier on this branch in 0279aae0).

Known limitations / follow-ups

  • Lag on the third login restart in dev builds is not addressed here. Visible on debug builds only (CEF cold-init cost compounds across rapid restarts); did not reproduce on release. Logged as observed in commit 495124b5 rustdoc.
  • app.restart() does not work in pnpm dev:app — vite serves assets on :1420 for the lifetime of the cargo dev runner; when app.restart() exits the parent, vite dies and the respawned binary loads a black page from a now-dead localhost. Not a regression of this PR — the same limitation existed before. Manual smoke must use the packaged debug .app. Worth tracking as a separate dev-experience issue.
  • Onboarding chat blocks fresh-user testing when the per-user users/<id> profile is missing onboarding-completed state. Real users complete onboarding once and never see this; verification across multiple test users requires either pre-seeding the core's onboarding-completed flag or finishing the chat. Not in scope here.

Related

Summary by CodeRabbit

Release Notes

  • New Features

    • Window geometry persistence: The app now saves and restores your window's position and size across sessions
    • Enhanced multi-user data isolation to ensure each user's data remains separate and properly scoped
  • Bug Fixes

    • Improved identity-switching cleanup and stale cache removal when switching between user accounts
  • Tests

    • Added comprehensive test coverage for user identity switching and data cleanup scenarios

oxoxDev and others added 19 commits April 28, 2026 14:21
Top-level action dispatched on identity flip (user A → user B) and on
sign-out. Every user-scoped slice handles this in `extraReducers` and
returns its `initialState`, replacing the per-slice ad-hoc reset
reducers that some slices have and others lack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sai#900)

Wires `resetUserScopedState` into every user-scoped slice via
`extraReducers`, so a single dispatch returns each slice to
`initialState`:

- threadSlice: threads, selectedThreadId, messagesByThreadId,
  activeThreadId, welcomeThreadId, messages
- chatRuntimeSlice: inferenceStatusByThread, streamingAssistantByThread,
  toolTimelineByThread, inferenceTurnLifecycleByThread, sessionTokenUsage
- notificationSlice: items, preferences, integrationItems,
  integrationUnreadCount
- providerSurfaceSlice: respond queue
- accountsSlice, channelConnectionsSlice, socketSlice: parity with the
  per-slice `reset*State` actions they already expose

Without this, `redux-persist` rehydrates user A's `persist:accounts`,
`persist:notifications`, and `persist:channelConnections` blobs into
user B's session on the next render or app launch, leaking the prior
user's account rail, thread list, and notifications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y flip (tinyhumansai#900)

Closes the cross-user state leak: when user B logs in on the same
device after user A, the React layer kept showing user A's account
rail, thread list, chat-runtime timelines, and notifications because
`localStorage` `persist:*` blobs are per-device and rehydrated user A's
slices into user B's session.

Adds a single `handleIdentityFlip({restart})` helper that:

1. Dispatches `resetUserScopedState` so every user-scoped slice
   returns to its initial shape in-memory.
2. Calls `socketService.disconnect()` so the next reconnect carries
   the new user's auth token (fresh `client_id` server-side).
3. Awaits `persistor.purge()` so the localStorage `persist:*` blobs
   are gone before any restart or re-render.
4. Optionally awaits `restartApp()` — only on a real flip, never on
   bootstrap or sign-out.

`refreshCore` now classifies each snapshot transition as bootstrap,
flip, or logout up front (outside the React functional setState
updater so detection runs synchronously regardless of batching).
Restart is gated on `Boolean(previousIdentity) && previousIdentity !==
nextIdentity` so the signed-out → signed-in bootstrap path can never
restart-loop.

`storeSessionToken` loses its standalone `restartApp` call — the
existing await on `refresh()` now drives `refreshCore` which owns flip
detection, so the previous code path that restarted without purging
the persistor is gone.

`clearSession` calls `handleIdentityFlip({restart: false})` so
sign-out also wipes Redux + persist + socket; the next login (which
will fire `storeSessionToken`) handles the restart from a known clean
slate.

Deep-link `core-state:session-token-updated` reaches `refreshCore`
via its trailing `refresh()`, so identity flips driven by deep links
are covered without any extra wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Vitest cases over `CoreStateProvider`:

1. Flip A→B: asserts `resetUserScopedState` dispatched, `persistor.purge`
   called once, `socketService.disconnect` called once, `restartApp`
   called once, and a seeded `accounts.order` entry is gone after the
   flip.
2. `clearSession`: same purge/disconnect/reset assertions, but
   `restartApp` MUST NOT be called — signed-out UI is empty so a
   relaunch would be jarring.
3. Bootstrap (signed-out → signed-in): asserts `restartApp` is NOT
   called on first auth so cold-launch never restart-loops.

Mocks `tauriCommands.restartApp`, spies on `persistor.purge` and
`socketService.disconnect`. Uses real Redux store + `addAccount`
fixture to verify the slice reset is visible end-to-end (not just
that `dispatch` was called with the action creator).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…serId (tinyhumansai#900)

Adds `app/src/store/userScopedStorage.ts`, a `Storage`-shaped wrapper
around `localStorage` that prefixes every key with the active userId,
e.g. `persist:accounts` → `${userId}:persist:accounts`. The active id
is sourced from a single `OPENHUMAN_ACTIVE_USER_ID` localStorage key,
read synchronously at module init so redux-persist's first-paint
rehydrate sees the right namespace, and updated via `setActiveUserId`
on identity changes.

When `activeUserId` is `null` (signed-out), reads return `null` and
writes are silent no-ops — we never want a user-shaped blob written
to a global key, and never want a stale blob hydrated into a
signed-out shell.

Routes the three persisted slices (`accounts`, `notifications`,
`channelConnections`) through `userScopedStorage` instead of the
default per-device `localStorage`. Each user's blob now lives at its
own namespaced key, so user A's data survives B's session intact and
rehydrates when A returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…urge (tinyhumansai#900)

Replaces the unconditional `persistor.purge()` in `handleIdentityFlip`
with a `setActiveUserId(nextUserId)` call. Each user's persisted blob
now lives at its own namespaced key (per the prior commit), so
purging would delete user A's data and prevent rehydration when A
returns to the device — the exact regression manual smoke just
surfaced.

Behavior matrix:
- Flip A→B: dispatch `resetUserScopedState` (in-memory wipe), point
  storage at B's namespace, disconnect socket, restart. On relaunch,
  `userScopedStorage` seeds from `OPENHUMAN_ACTIVE_USER_ID=B` and
  rehydrates B's blob.
- Sign-out (`clearSession`): same in-memory + socket cleanup, point
  storage at `null` (signed-out writes drop on the floor), no
  restart, and crucially NO purge — A's blob stays so re-login
  rehydrates it.
- Bootstrap (signed-out → signed-in on cold launch): no restart, no
  reset; just seeds `OPENHUMAN_ACTIVE_USER_ID` so subsequent persist
  writes route to the new user's namespace.

Updates the identity-flip Vitest to assert `setActiveUserId` is
called with the right value at each transition (no purge spy
anymore), and adds a fourth case covering the A→B→A round-trip:
storage points back to A's namespace on return.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ce (tinyhumansai#900)

One-shot migration for users upgrading from pre-tinyhumansai#900 builds. On the
first `setActiveUserId(nonNull)` after launch (i.e. the first user to
log in on the upgraded build), copy any pre-existing `persist:*`
keys into `${id}:persist:*` and drop the legacy entries. Skips any
key whose user-scoped twin already exists.

Without this, upgrading users lose their account rail / thread list /
notification cache shimmer on first launch — the legacy blob would
orphan and the new namespace would be empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ndow (tinyhumansai#900)

The previous patch only restarted on auth-to-auth flips. The actual
sign-out → sign-in path always routes through `clearSession`, which
sets `auth.isAuthenticated=false` before the next user logs in — so
the next refresh sees `previousAuthed=false` and never matched the
flip branch. Without a restart, redux-persist's already-hydrated
in-memory state from the launch-time namespace stayed in place and
no rehydration ever fired against the new namespace, so a returning
user (or a different user logging in after one) saw an empty UI even
though their `${userId}:persist:*` blob was on disk.

Adds a `lastAuthedUserIdRef` ref that records the userId whose data
is currently in memory (set on initial auth landing and after a
flip-restart). The refresh-time decision tree is now:

- auth → auth, identities differ → restart-class flip
- signed-out → signed-in, ref !== nextId → re-login as different user
  (restart so redux-persist re-hydrates from the new namespace)
- signed-out → signed-in, ref === nextId or ref is null → cold
  bootstrap or same-user re-login. No restart; just seed
  `setActiveUserId(nextId)` and update the ref.
- auth → signed-out (logout): drop active user id, disconnect socket,
  do NOT dispatch `resetUserScopedState`. Keeping in-memory slice
  data lets a same-user re-login keep showing accounts/threads/
  notifications without a costly restart, and the signed-out UI
  doesn't render those slices anyway.

`clearSession` correspondingly drops its prior `handleIdentityFlip`
call: just signs out the snapshot, calls `setActiveUserId(null)`, and
does the tauriLogout RPC. The `handleIdentityFlip` helper is now
restart-only (the `restart: false` mode was removed).

Tests cover all six paths (cold bootstrap, auth-flip, logout, same-
user re-login, different-user re-login, A→B→A round-trip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s of prev state (tinyhumansai#900)

The previous patch's flip detection was split across `isAuthFlip`
(prev authed && next authed && id differs) and `isReLoginFlip`
(prev NOT authed && next authed && lastRef differs). The deep-link
OAuth path falls through both: the synchronous pre-refresh commit in
`onSessionTokenUpdated` flips `auth.isAuthenticated=true` BEFORE
`refreshCore` runs, so by the time we classify, `previousAuthed` is
true but `previousIdentity` is still null (the deep-link payload
only carries a token, not a userId). `isAuthFlip` requires
`previousIdentity` truthy → false. `isReLoginFlip` requires
`!previousAuthed` → false. Neither branch fires, no restart, the
prior user's slices stay in memory and bleed into the new session.

Replaces the two-branch detection with one rule: a flip is "the
in-memory data is for a different userId than the one that just
authenticated." `lastAuthedUserIdRef` tracks whose namespace
redux-persist hydrated, and any non-null nextIdentity that differs
from it triggers a restart-class flip — uniformly across direct
`storeSessionToken` calls, deep-link events, and poll-detected
swaps. The ref is initialized from `getActiveUserId()` so even on
the first refresh after mount, an in-memory user inherited from
redux-persist's module-init pass is detected and forced to flip
when a different user logs in.

Tests still cover all six paths and pass unchanged: cold bootstrap,
auth-flip, logout, same-user re-login, different-user re-login,
A→B→A round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ofile is correct (tinyhumansai#900)

Previous detection used a React-level `lastAuthedUserIdRef` that was
seeded from `getActiveUserId()` at mount time but updated locally on
each landing — racy across mount/unmount and missed the cold-bootstrap
case where the FIRST user logging in needs a restart so the Rust
`prepare_process_cache_path` re-evaluates `active_user.toml` and
points CEF at `users/<id>/cef` (instead of the pre-login `local`
fallback bucket where the very first Slack/WhatsApp tile add would
otherwise dump cookies that then leak across users).

Replaces the ref with a single rule: source of truth is the
`OPENHUMAN_ACTIVE_USER_ID` localStorage seed read by
`userScopedStorage`. If the userId that just authenticated differs
from the seed (or the seed is null on a fresh device), restart.
This rule covers every login path uniformly:
  - cold bootstrap on a fresh install (seed=null, nextId=A → restart)
  - direct `storeSessionToken` (Tauri OAuth)
  - deep-link `core-state:session-token-updated`
  - poll-detected flip
  - re-login as a different user after sign-out

Logout no longer clears the seed: keeping it pointing at the last
user lets the next refresh distinguish a same-user re-login (no
restart) from a different-user re-login (restart). `clearSession`
correspondingly drops its prior `setActiveUserId(null)` call.

Tests cover all six paths: cold bootstrap, warm launch (seed match),
auth-flip, logout, same-user re-login, different-user re-login.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ith real user (tinyhumansai#900)

When the very first cold launch on a fresh install ran without an
`active_user.toml` (no user logged in yet), `prepare_process_cache_path`
fell back to the `users/local/cef` bucket. If the user added a
webview-account tile (Slack, WhatsApp, …) BEFORE the frontend
triggered a flip-restart, those third-party cookies landed in the
`local` bucket and survived across users.

With the matching frontend change (cold-bootstrap now forces a
restart on first auth), this flow is much rarer — but any pre-existing
device that already has data in `users/local/cef` from before the fix
shipped is still vulnerable. Purge it synchronously at the start of
`prepare_process_cache_path` whenever a real user (not the `local`
sentinel) is active. The delete runs before CEF init, so it's safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…file_purge (tinyhumansai#900)

Tauri v2 silently denies any `invoke()` for a command not listed in a
capability allowlist. The cross-user state-leak fix calls all three
from the frontend, so the prior implementation looked correct in code
but the underlying `app.restart()` / profile-purge / active-user
lookups never actually ran — webviews kept the prior user's
third-party cookies and the boot prime had no way to read the
authoritative active user id.

Closes the silent-deny class of failures for the tinyhumansai#900 fix path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…inyhumansai#900)

`teardown_account_scanners` ran `tokio::spawn(...)` from the CEF main
thread to drop per-provider scanner handles asynchronously. CEF's
main thread does not host a Tokio runtime in its thread-local, so
`tokio::spawn` panics with "panic in a function that cannot unwind"
and the panic propagates as SIGABRT — visible to the user as a
generic crash dialog after every identity-flip restart.

`tauri::async_runtime::spawn` resolves the executor from the Tauri
runtime that the host is built against, so it works regardless of
which thread invokes it. This is the same primitive Tauri uses
internally for command futures, which is why the rest of this file
was already on it; the four teardown sites were the outliers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e_user_id (tinyhumansai#900)

These helpers were private when `cef_profile` was the only consumer.
The boot-prime command (Tauri side) and the window-state module
both need to resolve the OpenHuman root directory and read the
authoritative active user id from `active_user.toml`, so promote
them to `pub` rather than duplicate the `OPENHUMAN_WORKSPACE` /
HOME-fallback logic at three call sites.

No behavior change in the existing module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reads `~/.openhuman/active_user.toml` (or `$OPENHUMAN_WORKSPACE/active_user.toml`)
and returns the active user id. Lets the React boot path seed
`userScopedStorage` from the profile-independent source of truth
BEFORE redux-persist hydrates.

The previous frontend-only seed was a `localStorage` key — bound to
the per-user CEF profile dir. Every restart-driven user flip caused
the new process to read the previous session's value from whatever
profile the new CEF instance was reading from, so seed and next id
disagreed and the flip-detection re-fired into a second restart, and
a third, and so on (the restart loop on login).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tinyhumansai#900)

Three secondary issues observed during the identity-flip restart that
weren't visible before the auth-leak fix landed (because `restart_app`
was silently denied and never fired):

1. The new window snapped back to the default centered initial size
   on the primary monitor — even when the user had moved or resized
   it on an external display.
2. The macOS WindowServer briefly painted black on the (now defunct)
   display layer between the old process exiting and the new one
   showing its window.
3. The window respawn jumped to whatever monitor the OS picked first.

Fix:
- New `window_state` module persists outer position + outer size to
  `<openhuman_dir>/window_state.toml` and reads it back at setup time.
- Position restore is gated on `position_visible_on_any_monitor` so
  an undocked external display can't strand the window off-screen;
  unmatched state falls back to a centered default.
- `restart_app` saves geometry, hides the window, then exits —
  hiding *before* the process dies lets WindowServer release the
  layer cleanly so there's no black flash on the old display.
- `tauri.conf.json` ships the main window with `visible: false` and
  `center: false`. The setup hook applies the restored geometry and
  *then* calls `window.show()`, so the user never sees a centered
  first paint that snaps to its real position.

The lag observed on the third restart is not addressed here — likely
debug-build CEF cold-init overhead; will verify in release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thin wrapper over the new `get_active_user_id` Tauri command. Returns
`null` outside Tauri (web preview) and on any RPC error so the boot
path can fall through to a no-prime initial render without crashing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a one-shot `primeActiveUserId(id)` entry point and a Promise
gate that storage methods await before resolving the namespace. The
methods are now async (redux-persist's storage contract is async, so
this is a no-op for callers).

Why: `localStorage` lives inside the active CEF profile dir. After
an identity-flip restart, the new process boots with a different CEF
user-data-dir, so the previous module-load read of
`OPENHUMAN_ACTIVE_USER_ID` resolves the *new* user's localStorage —
which is empty on a never-before-seen user and stale on a previously
active one. Without the gate, redux-persist's first rehydrate read
beat any subsequent re-pin, the wrong namespace was selected, and
`refreshCore` mis-detected a flip on the very next snapshot, kicking
off a second restart.

The gate lets `main.tsx` await `getActiveUserIdFromCore()` (which
reads the Rust-authoritative `active_user.toml`) before any
redux-persist storage call resolves, so the namespace is pinned to
the correct user from the very first read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sai#900)

Wraps the React render in a `getActiveUserIdFromCore().then(prime).finally(boot)`
chain so redux-persist's storage adapter sees the right namespace
from the very first call. The auth token and Redux slice rehydration
both depend on this — without it, `refreshCore` runs against the
wrong namespace, mis-detects a flip, and triggers a second restart.

`finally(boot)` runs render even when the prime call fails (web
preview, RPC error) — primeActiveUserId(null) resolves the gate so
storage falls through as no-ops until `setActiveUserId(...)` lands a
real id later via `handleIdentityFlip` or `storeSession`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@oxoxDev oxoxDev requested a review from a team April 28, 2026 18:46
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 28, 2026

📝 Walkthrough

Walkthrough

This PR implements user-scoped state management across the desktop app. It adds window state persistence, fetches the active user ID at startup, segregates persisted Redux state by user, exposes new Tauri commands for identity operations, and refactors identity-flip detection to trigger centralized app restart and state cleanup workflows.

Changes

Cohort / File(s) Summary
Tauri Permissions & Commands
app/src-tauri/permissions/allow-core-process.toml, app/src-tauri/src/lib.rs
Added three new allowed core-process commands: restart_app, schedule_cef_profile_purge, get_active_user_id. Implemented get_active_user_id Tauri command that reads authoritative user identity from active_user.toml.
CEF Profile & Window State Modules
app/src-tauri/src/cef_profile.rs, app/src-tauri/src/window_state.rs
Exposed default_root_openhuman_dir and read_active_user_id as public. Added pre-CEF initialization cleanup to purge stale pre-login CEF cache. Introduced new window state persistence module with save/restore/center functions using TOML backend and monitor-overlap validation.
Tauri Configuration & Webview
app/src-tauri/tauri.conf.json, app/src-tauri/src/webview_accounts/mod.rs
Changed main window initial visibility to false and disabled auto-centering. Replaced tokio::spawn with tauri::async_runtime::spawn for registry teardown tasks across all platform integrations.
App Initialization & Boot Flow
app/src/main.tsx, app/src/utils/tauriCommands/core.ts
Deferred React root render until getActiveUserIdFromCore() resolves. Primes userScopedStorage with fetched user ID before booting React tree. Added error handling for user ID lookup failures.
User-Scoped Storage Layer
app/src/store/userScopedStorage.ts, app/src/store/index.ts
Introduced userScopedStorage module maintaining per-user persisted state with localStorage fallback. Wired as backend for redux-persist, segregating persisted keys by userId. Added one-shot primeActiveUserId, getter/setter for managing active user context, and legacy migration logic.
Redux State Reset Action & Handlers
app/src/store/resetActions.ts, app/src/store/{accounts,channelConnections,chatRuntime,notification,providerSurface,socket,thread}Slice.ts
Created new resetUserScopedState action for coordinated user-scoped state cleanup. Updated all user-scoped slices to listen via extraReducers and reset to initialState when action dispatches, enabling atomic cache clearing on identity changes.
Core Identity-Flip & Refresh Logic
app/src/providers/CoreStateProvider.tsx, app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx
Refactored identity-flip detection into centralized refreshCore() flow by comparing in-memory user namespace to next snapshot identity. On flip, triggers handleIdentityFlip to repoint user-scoped storage, wipe live Redux state, disconnect Socket.IO, and restart app. Added comprehensive test suite covering cold boot, warm launch, auth-to-auth transitions, logout, and re-login scenarios.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant Frontend as Frontend/React
    participant Storage as userScopedStorage<br/>(localStorage)
    participant TauriCore as Tauri Core
    participant Config as active_user.toml

    User->>Frontend: Launch app
    Frontend->>TauriCore: getActiveUserIdFromCore()
    TauriCore->>Config: Read active_user.toml
    Config-->>TauriCore: userId or null
    TauriCore-->>Frontend: userId or null
    Frontend->>Storage: primeActiveUserId(userId)
    Storage->>Storage: Initialize namespace<br/>activeUserId = userId
    Storage->>Storage: Migrate legacy<br/>persist:* → userId:persist:*
    Frontend->>Frontend: Boot React tree<br/>+ hydrate redux-persist
    Frontend->>Storage: Load ${userId}:persist:* keys
    Storage-->>Frontend: User-scoped state restored
    Frontend->>User: Render app
Loading
sequenceDiagram
    participant App as CoreStateProvider
    participant RefreshAPI as refreshCore()
    participant Snapshot as Snapshot Query
    participant Redux as Redux Store
    participant Storage as userScopedStorage
    participant SocketIO as Socket.IO
    participant TauriCore as Tauri Core

    App->>RefreshAPI: Call refresh()
    RefreshAPI->>Snapshot: Fetch current snapshot
    Snapshot-->>RefreshAPI: User A identity
    RefreshAPI->>Redux: Get in-memory userId<br/>getActiveUserId()
    Redux-->>RefreshAPI: User A (old namespace)
    RefreshAPI->>RefreshAPI: Compare: A → B?
    alt Identity Change (A ≠ B)
        RefreshAPI->>RefreshAPI: isFlip = true
        RefreshAPI->>Redux: Dispatch resetUserScopedState
        RefreshAPI->>Storage: setActiveUserId(B)
        Storage->>Storage: Update namespace to B
        RefreshAPI->>SocketIO: Disconnect
        RefreshAPI->>TauriCore: Call restartApp()
        TauriCore->>TauriCore: App restart triggered
        TauriCore-->>App: (on relaunch)
    else Sign-Out
        RefreshAPI->>SocketIO: Disconnect only
        RefreshAPI->>Storage: Keep activeUserId
    else Same User
        RefreshAPI->>RefreshAPI: No action
    end
    RefreshAPI-->>App: Refresh complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • senamakel
  • M3gA-Mind

Poem

🐰 Hops of state, per user scoped,
Window frames now saved and coped,
When identities flip and change,
We reset, restart, rearrange!
No more leaks cross-user's way, 🌱

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'fix(auth): close cross-user state + CEF cookie leak (#900)' directly and clearly summarizes the main fix: preventing cross-user state and cookie leaks in authentication/CEF. It is concise, specific, and accurately reflects the primary objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
app/src/store/threadSlice.ts (1)

268-323: ⚠️ Potential issue | 🟠 Major

Fence off stale async results after the user-scoped reset.

This reset only swaps the slice back to initialState; it does not cancel or invalidate already-dispatched thunks. A loadThreads / loadThreadMessages / addInferenceResponse request started under user A can still fulfill afterwards and refill this slice with A's data, which leaves a cross-user race in the exact flow this PR is fixing. Please gate fulfilled reducers on a session/user epoch or abort outstanding thunks when the identity flip/logout reset fires.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/store/threadSlice.ts` around lines 268 - 323, The slice is vulnerable
to stale async results repopulating state after resetUserScopedState; add a
session epoch to initialState (e.g., sessionEpoch), increment it in the
resetUserScopedState reducer, and update all relevant thunks (loadThreads,
loadThreadMessages, addInferenceResponse, addMessageLocal, persistReaction) to
capture the current sessionEpoch when they start and attach it to action.meta
(e.g., meta.sessionEpoch). Then in each fulfilled/rejected addCase
(loadThreads.fulfilled, loadThreadMessages.fulfilled/rejected,
addInferenceResponse.fulfilled/rejected, addMessageLocal.fulfilled,
persistReaction.fulfilled, etc.) early-return unless action.meta.sessionEpoch
=== state.sessionEpoch so stale results are ignored. Ensure deleteThread and
other reducers that should run during reset follow the same gating or remain
unaffected as appropriate.
app/src/providers/CoreStateProvider.tsx (1)

210-242: ⚠️ Potential issue | 🟠 Major

Clearing the team caches here still leaves a stale-write hole.

This block wipes teams, teamMembersById, and teamInvitesById, but refreshTeamMembers() and refreshTeamInvites() still commit their results unconditionally. A request started under user A can resolve after this logout/flip path and repopulate A’s team data into memory during B’s session before the restart lands.

Suggested guard pattern
   const refreshTeamMembers = useCallback(
     async (teamId: string) => {
+      const identityAtStart = snapshotIdentity(getCoreStateSnapshot().snapshot);
       const members = await getTeamMembers(teamId);
-      commitState(previous => ({
-        ...previous,
-        teamMembersById: { ...previous.teamMembersById, [teamId]: members },
-      }));
+      commitState(previous => {
+        if (snapshotIdentity(previous.snapshot) !== identityAtStart) {
+          return previous;
+        }
+        return {
+          ...previous,
+          teamMembersById: { ...previous.teamMembersById, [teamId]: members },
+        };
+      });
     },
     [commitState]
   );
 
   const refreshTeamInvites = useCallback(
     async (teamId: string) => {
+      const identityAtStart = snapshotIdentity(getCoreStateSnapshot().snapshot);
       const invites = await getTeamInvites(teamId);
-      commitState(previous => ({
-        ...previous,
-        teamInvitesById: { ...previous.teamInvitesById, [teamId]: invites },
-      }));
+      commitState(previous => {
+        if (snapshotIdentity(previous.snapshot) !== identityAtStart) {
+          return previous;
+        }
+        return {
+          ...previous,
+          teamInvitesById: { ...previous.teamInvitesById, [teamId]: invites },
+        };
+      });
     },
     [commitState]
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/providers/CoreStateProvider.tsx` around lines 210 - 242, The
cache-clear leaves a race where in-flight
refreshTeamMembers()/refreshTeamInvites() can re-commit stale user A data after
we cleared for a flip/logout; to fix, add the same request-id/identity guard
used in commitState to those refresh functions (or have them accept/return the
requestId and verify requestId === snapshotRequestIdRef.current and/or that the
current identity matches nextIdentity) and abort their commit if the IDs differ
or scoped caches were just cleared (use the existing
snapshotRequestIdRef.current, requestId, commitState check, and
shouldClearScopedCaches flag as the canonical guards).
app/src-tauri/src/cef_profile.rs (1)

53-62: ⚠️ Potential issue | 🟠 Major

Validate active_user.toml before returning it to the frontend.

get_active_user_id() now exposes read_active_user_id() directly, but prepare_process_cache_path() still re-validates the same value and falls back to local on invalid ids. That lets a malformed-but-parseable user_id make redux-persist hydrate one namespace while CEF boots another profile, which reintroduces the frontend/core split this fix is trying to eliminate.

Suggested fix
 pub fn read_active_user_id(default_openhuman_dir: &Path) -> Option<String> {
     let path = default_openhuman_dir.join(ACTIVE_USER_STATE_FILE);
     let contents = std::fs::read_to_string(path).ok()?;
     let state: ActiveUserState = toml::from_str(&contents).ok()?;
     let trimmed = state.user_id.trim();
-    if trimmed.is_empty() {
-        None
-    } else {
-        Some(trimmed.to_string())
-    }
+    if trimmed.is_empty() {
+        return None;
+    }
+    validate_user_id_for_path(trimmed).ok()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src-tauri/src/cef_profile.rs` around lines 53 - 62, read_active_user_id
currently returns any parseable user_id from active_user.toml which can be
malformed but accepted by the frontend; update read_active_user_id to validate
the trimmed user_id against the same canonical rules used by
prepare_process_cache_path/get_active_user_id (e.g., allowed namespace set or
regex for valid IDs) and return None for invalid values so callers will fall
back to "local"; reference the function read_active_user_id, the
ACTIVE_USER_STATE_FILE/active_user.toml, and the
prepare_process_cache_path/get_active_user_id validation logic to ensure parity.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src-tauri/src/window_state.rs`:
- Around line 151-156: The current pattern using
window.primary_monitor().or_else(...) incorrectly skips calling
window.current_monitor() when primary_monitor() returns Ok(None); update the
logic to explicitly handle Ok(Some), Ok(None), and Err for primary_monitor(): if
Ok(Some(monitor)) use it; if Ok(None) or Err(_) then call
window.current_monitor() and use its Ok(Some) result; if that also yields None
or Err, then call window.center() and return. Locate this change around the let
Ok(Some(monitor)) = ... else { ... } block and replace it with an explicit
match/if-chain that attempts current_monitor() when primary_monitor() returns
Ok(None).

In `@app/src/store/userScopedStorage.ts`:
- Around line 57-71: primeActiveUserId currently primes activeUserId but never
calls migrateLegacyPersistKeys, so upgrades where the user is already logged in
won't migrate legacy persist keys; modify primeActiveUserId to trigger migration
when primed with a non-null id by invoking migrateLegacyPersistKeys(id) (or a
helper that ensures migration runs once per user) after setting activeUserId and
before resolving activeUserIdResolve(), and add or reuse a separate migration
guard (e.g., migratedForUser or migrated flag) so migration isn't skipped by the
existing previous-user logic; apply the same change to the analogous code path
referenced in the other block (around the setActiveUserId/prime logic at
89-105).

---

Outside diff comments:
In `@app/src-tauri/src/cef_profile.rs`:
- Around line 53-62: read_active_user_id currently returns any parseable user_id
from active_user.toml which can be malformed but accepted by the frontend;
update read_active_user_id to validate the trimmed user_id against the same
canonical rules used by prepare_process_cache_path/get_active_user_id (e.g.,
allowed namespace set or regex for valid IDs) and return None for invalid values
so callers will fall back to "local"; reference the function
read_active_user_id, the ACTIVE_USER_STATE_FILE/active_user.toml, and the
prepare_process_cache_path/get_active_user_id validation logic to ensure parity.

In `@app/src/providers/CoreStateProvider.tsx`:
- Around line 210-242: The cache-clear leaves a race where in-flight
refreshTeamMembers()/refreshTeamInvites() can re-commit stale user A data after
we cleared for a flip/logout; to fix, add the same request-id/identity guard
used in commitState to those refresh functions (or have them accept/return the
requestId and verify requestId === snapshotRequestIdRef.current and/or that the
current identity matches nextIdentity) and abort their commit if the IDs differ
or scoped caches were just cleared (use the existing
snapshotRequestIdRef.current, requestId, commitState check, and
shouldClearScopedCaches flag as the canonical guards).

In `@app/src/store/threadSlice.ts`:
- Around line 268-323: The slice is vulnerable to stale async results
repopulating state after resetUserScopedState; add a session epoch to
initialState (e.g., sessionEpoch), increment it in the resetUserScopedState
reducer, and update all relevant thunks (loadThreads, loadThreadMessages,
addInferenceResponse, addMessageLocal, persistReaction) to capture the current
sessionEpoch when they start and attach it to action.meta (e.g.,
meta.sessionEpoch). Then in each fulfilled/rejected addCase
(loadThreads.fulfilled, loadThreadMessages.fulfilled/rejected,
addInferenceResponse.fulfilled/rejected, addMessageLocal.fulfilled,
persistReaction.fulfilled, etc.) early-return unless action.meta.sessionEpoch
=== state.sessionEpoch so stale results are ignored. Ensure deleteThread and
other reducers that should run during reset follow the same gating or remain
unaffected as appropriate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 92f20f73-c5c4-4238-a260-f263732b2f1a

📥 Commits

Reviewing files that changed from the base of the PR and between 1a25f5b and 2ff7039.

📒 Files selected for processing (20)
  • app/src-tauri/permissions/allow-core-process.toml
  • app/src-tauri/src/cef_profile.rs
  • app/src-tauri/src/lib.rs
  • app/src-tauri/src/webview_accounts/mod.rs
  • app/src-tauri/src/window_state.rs
  • app/src-tauri/tauri.conf.json
  • app/src/main.tsx
  • app/src/providers/CoreStateProvider.tsx
  • app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx
  • app/src/store/accountsSlice.ts
  • app/src/store/channelConnectionsSlice.ts
  • app/src/store/chatRuntimeSlice.ts
  • app/src/store/index.ts
  • app/src/store/notificationSlice.ts
  • app/src/store/providerSurfaceSlice.ts
  • app/src/store/resetActions.ts
  • app/src/store/socketSlice.ts
  • app/src/store/threadSlice.ts
  • app/src/store/userScopedStorage.ts
  • app/src/utils/tauriCommands/core.ts

Comment on lines +151 to +156
let Ok(Some(monitor)) = window
.primary_monitor()
.or_else(|_| window.current_monitor())
else {
let _ = window.center();
return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n app/src-tauri/src/window_state.rs | head -170 | tail -30

Repository: tinyhumansai/openhuman

Length of output: 1195


🏁 Script executed:

cat -n app/src-tauri/src/window_state.rs | head -200 | tail -60

Repository: tinyhumansai/openhuman

Length of output: 2487


🏁 Script executed:

rg -n "primary_monitor|current_monitor" app/src-tauri/src/window_state.rs -B 2 -A 5

Repository: tinyhumansai/openhuman

Length of output: 535


🏁 Script executed:

cat -n app/src-tauri/src/window_state.rs | head -10

Repository: tinyhumansai/openhuman

Length of output: 630


current_monitor() is skipped when primary_monitor() returns Ok(None).

The documentation states the intent is "primary display (or its current monitor if current_monitor resolves)", but Result::or_else only executes on Err, not Ok(None). When primary_monitor() returns Ok(None), the pattern match fails immediately without attempting current_monitor(). The behavior should match the documented intent by explicitly handling the Ok(None) case.

Suggested fix
-    let Ok(Some(monitor)) = window
-        .primary_monitor()
-        .or_else(|_| window.current_monitor())
-    else {
+    let monitor = match window.primary_monitor() {
+        Ok(Some(monitor)) => Some(monitor),
+        Ok(None) | Err(_) => window.current_monitor().ok().flatten(),
+    };
+    let Some(monitor) = monitor else {
         let _ = window.center();
         return;
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let Ok(Some(monitor)) = window
.primary_monitor()
.or_else(|_| window.current_monitor())
else {
let _ = window.center();
return;
let monitor = match window.primary_monitor() {
Ok(Some(monitor)) => Some(monitor),
Ok(None) | Err(_) => window.current_monitor().ok().flatten(),
};
let Some(monitor) = monitor else {
let _ = window.center();
return;
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src-tauri/src/window_state.rs` around lines 151 - 156, The current
pattern using window.primary_monitor().or_else(...) incorrectly skips calling
window.current_monitor() when primary_monitor() returns Ok(None); update the
logic to explicitly handle Ok(Some), Ok(None), and Err for primary_monitor(): if
Ok(Some(monitor)) use it; if Ok(None) or Err(_) then call
window.current_monitor() and use its Ok(Some) result; if that also yields None
or Err, then call window.center() and return. Locate this change around the let
Ok(Some(monitor)) = ... else { ... } block and replace it with an explicit
match/if-chain that attempts current_monitor() when primary_monitor() returns
Ok(None).

Comment on lines +57 to +71
export function primeActiveUserId(id: string | null): void {
if (primed) return;
primed = true;
activeUserId = id;
try {
if (id) {
localStorage.setItem(ACTIVE_USER_KEY, id);
} else {
localStorage.removeItem(ACTIVE_USER_KEY);
}
} catch {
// localStorage may be unavailable; in-memory ref still drives reads
}
activeUserIdResolve();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Migration may not run for users upgrading while already logged in.

If primeActiveUserId(id) is called at boot with an existing user id (from active_user.toml), it sets activeUserId = id. When setActiveUserId(id) is later called (same id), previous is truthy, so !previous is false and migrateLegacyPersistKeys is skipped.

For upgrading users who were already logged in before the update:

  1. Boot reads active_user.tomlprimeActiveUserId(existingId)
  2. No login event occurs because user is already authenticated
  3. Legacy persist:* keys never migrate to ${existingId}:persist:*

Consider triggering migration in primeActiveUserId when priming with a non-null id, or tracking migration separately from the previous-user check.

🐛 Proposed fix to trigger migration on prime
 export function primeActiveUserId(id: string | null): void {
   if (primed) return;
   primed = true;
   activeUserId = id;
   try {
     if (id) {
       localStorage.setItem(ACTIVE_USER_KEY, id);
+      migrateLegacyPersistKeys(id);
     } else {
       localStorage.removeItem(ACTIVE_USER_KEY);
     }
   } catch {
     // localStorage may be unavailable; in-memory ref still drives reads
   }
   activeUserIdResolve();
 }

Also applies to: 89-105

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/store/userScopedStorage.ts` around lines 57 - 71, primeActiveUserId
currently primes activeUserId but never calls migrateLegacyPersistKeys, so
upgrades where the user is already logged in won't migrate legacy persist keys;
modify primeActiveUserId to trigger migration when primed with a non-null id by
invoking migrateLegacyPersistKeys(id) (or a helper that ensures migration runs
once per user) after setting activeUserId and before resolving
activeUserIdResolve(), and add or reuse a separate migration guard (e.g.,
migratedForUser or migrated flag) so migration isn't skipped by the existing
previous-user logic; apply the same change to the analogous code path referenced
in the other block (around the setActiveUserId/prime logic at 89-105).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Auth state leaks across users on same device — user B sees user A's threads, accounts, notifications

2 participants