Skip to content

feat(auth): keep the last validated account offline, read-only - #2927

Open
Chadpiha wants to merge 15 commits into
mainfrom
feat/offline-account-presentation
Open

Chadpiha wants to merge 15 commits into
mainfrom
feat/offline-account-presentation

Conversation

@Chadpiha

Copy link
Copy Markdown
Collaborator

Summary

When the app launched with no network, it ran entirely as a guest. Better Auth's get-session never resolved, so fetchPolicy never ran, usePolicyStore.accountId stayed null, and team spaces, the workspace switcher and the account UI were all hidden. #2921's workspace list cache was never read on that path.

When get-session gets no answer (network error, 408, 429 or 5xx), the app now shows the last validated account read-only, if all of these hold:

  • main's account binding (accountScopeBinding.js, keyed by a sha256 of the exact bearer token it validated) names that account for the current credential generation;
  • the renderer's account scope was last reconciled for that same account, with nothing pending (scope action "none");
  • a profile saved during a validated session in the last 30 days exists for that account (openwhispr:accountProfile).

In that state, useAuth reports isSignedIn: true, the saved user, session: null and a new isOfflineAccount: true. Policy, managed config and the workspace list load from their existing caches for that account.

What stays fenced

  • Nothing marks the credential generation as validated, so getValidatedAuthGeneration() stays null. Sync, leaderboard and insights don't run.
  • Authenticated cloudApi calls are refused before any request (AUTH_CONTEXT_UNVALIDATED).
  • Account reconciliation, purge, set-active-account-scope and setIsSignedIn run only after a real get-session.

Back online

  • Same user: confirmed through the normal path, and the saved profile is refreshed.
  • Different user, or no user: purged through reconciliation, as today. The purge also clears the saved profile.
  • 401 or 403: drops to guest, and that credential is never shown offline again.

Signing out offline clears the token, which ends the offline account, and clears the saved profile.

The threat model is in docs/superpowers/specs/2026-10-23-offline-account-presentation-design.md.

Changes

  • src/lib/authRequestContext.ts: tracks the offline account and the generations the server rejected; adds getOfflineAccount(). handleAuthRequestError now separates "the server couldn't answer" from "the server rejected this credential".
  • src/lib/offlineAccountProfile.ts (new): the saved profile and the pure resolveOfflineAccount decision.
  • src/hooks/useAuth.ts: shows the offline account, loads its cached policy, managed config and workspace list, saves the profile while validated, and clears it on purge.
  • src/lib/auth.ts: sign-out clears the saved profile.

No main-process changes and no new UI strings.

Tests

  • test/hooks/useAuthOfflineAccount.test.js (new) mounts the real useAuth over the real auth context and account scope:
    • an offline launch shows the account read-only, with sync blocked;
    • a scope reconciled for another account, or a pending purge, stays a guest;
    • back online: same user confirmed; different user purged; no user purged; 401 drops to guest.
  • test/lib/authRequestContext.test.js: binding match and mismatch, 503 counted as offline, 401 rejection, confirmation, sign-out.
  • test/lib/offlineAccountProfile.test.js: id mismatch, 30-day age, malformed entries, and the decision helper.
  • test/stores/workspaceListCache.test.js: a fenced list request shows the account's own cache and never another account's.

Locally, the auth, account-scope, workspace and sign-out tests pass (72/72), and tsc and eslint are clean. The rest of npm test fails only for environment reasons here: better-sqlite3 is built for a different Node version, and some connector tests time out under load but pass when run alone.

@Chadpiha
Chadpiha added this pull request to stack #2928 October 23, 2026 11:02
Base automatically changed from feat/workspace-capabilities to main October 28, 2026 08:16
@Chadpiha
Chadpiha force-pushed the feat/offline-account-presentation branch from fd2b76e to 12ccbc9 Compare October 28, 2026 08:16
@Chadpiha
Chadpiha force-pushed the feat/offline-account-presentation branch from 751cda5 to 1e44b48 Compare October 28, 2026 13:39
Chadpiha and others added 15 commits October 30, 2026 14:40
An offline launch ran the whole app as a guest: get-session never resolved,
so policy, the workspace list cache and team spaces never loaded. When
get-session gets no answer, present the account main's token-bound binding
names, if the renderer's scope was last reconciled for that account and a
profile it validated in the last 30 days is saved. Nothing validates the
credential generation, so sync and authenticated cloud API calls stay
fenced; a later get-session confirms or purges as before, and a 401 keeps
that credential from being presented offline again.
The dictation window's cross-origin get-session always fails, so it took
the offline path on every launch, online too, duplicating the scope mirror.
Only the control panel now presents an offline account.

- Keep it through refetches: Better Auth marks each one pending while it
  holds no session, which flickered the account to guest.
- Ask for the session again on focus and every minute while an account is
  shown without a validated credential, since nothing else does when the
  auth server recovers while the network stayed up.
- Only a 401 is a verdict (proxy 4xx are not); it also drops the saved
  profile, and a session with no user stays a verdict through a later 5xx.
- Stop deleting main's managed-config cache on every identity change, so
  it serves an offline launch; main prunes older credentials' entries.
- Refuse checkout, billing portal, plan switches and referral invites
  without a validated session: main sends the stored bearer unfenced.
- Show the account read-only: an offline notice in Settings, Offline in
  the profile menu, Retry instead of Sign in on the leaderboard, and a
  plain message when accepting an invitation.
Better Auth clears sessionError when a refetch starts, so after a failed
refetch reconciliation re-committed the validated lease with no answer, and
the session retry turned that into a once-a-minute cycle that restamped the
saved profile, defeating its 30-day bound. A failed refetch now marks the
generation, and only a new answer clears it.

- Present a session whose refetch failed read-only (isOfflineAccount), the
  same as an offline launch.
- Keep presenting the offline account once the server answers for it until
  reconciliation validates it, so reconnecting is never signed out.
- On a non-401 error, read main's binding before dropping the session and
  apply both in one update.
- Ignore session requests Better Auth cancelled to start another one.
- Stamp the profile with the server's answer, refresh it on later answers.
- Retry only when no session request is in flight; load the offline
  account's caches once, not per useAuth consumer.
Since an identity change no longer clears the store, a new identity whose
first config fetch failed held the previous identity's enforced scopes.
Only the same identity's last config stands in now.
Team and workspace dialogs showed main's "no validated credential
generation" string, and billing refusals a generic error or nothing.
cloudApi now turns AUTH_CONTEXT_UNVALIDATED into auth.unconfirmedSession,
which billing, referral invites and the invitation modal share. The
usage-limit prompt disables Upgrade for an account shown offline.

The Settings notice gets a title that doesn't repeat its Offline badge.
A crash before sign-out could leave the deleted account presentable offline for up to 30 days.
@Chadpiha
Chadpiha force-pushed the feat/offline-account-presentation branch from 1e44b48 to 06a5035 Compare October 30, 2026 12:40

This branch has not been deployed

No deployments
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.

2 participants