Every response already funnels through runtimeFetch, so a classifier there spots 401s, confirms them against /auth/session (a proxied provider 401 must not read as a logout), and flips a small auth-session store. The web and hosted surfaces show a frosted banner under the header whose Log in button hands off to the session gate's existing unlock flow; sends are paused while expired, the session-load error screen explains the auth case and retries itself after login, and returning to a long-idle window revalidates once via visibility/focus. Native mobile feeds the same signal into its connection re-probe instead of showing the banner; VS Code is exempt.
127 lines
4.9 KiB
TypeScript
127 lines
4.9 KiB
TypeScript
import { create } from 'zustand';
|
|
|
|
// Proactive detection of an expired OpenChamber client session (cookie or
|
|
// bearer). There is no polling: every HTTP response already funnels through
|
|
// runtimeFetch, and this module only classifies what passes by. A 401 alone
|
|
// is NOT proof — OpenCode proxies provider errors through the same routes, so
|
|
// a dead Anthropic key also surfaces as 401. Every suspicion is therefore
|
|
// confirmed with one debounced GET /auth/session before the state flips.
|
|
//
|
|
// Consumers: the web/hosted banner (AuthExpiredBanner), the send guard in the
|
|
// composer, and the native mobile app, which feeds the signal into its own
|
|
// connection orchestration instead of showing the shared banner.
|
|
|
|
export type AuthSessionState = 'ok' | 'expired' | 'reauthenticating';
|
|
|
|
interface AuthSessionStore {
|
|
state: AuthSessionState;
|
|
/** Set only by the confirmed classifier or an explicit auth failure. */
|
|
markExpired: () => void;
|
|
markReauthenticating: () => void;
|
|
markAuthenticated: () => void;
|
|
}
|
|
|
|
export const useAuthSessionStore = create<AuthSessionStore>((set) => ({
|
|
state: 'ok',
|
|
markExpired: () => set((current) => (current.state === 'expired' ? current : { state: 'expired' })),
|
|
markReauthenticating: () => set({ state: 'reauthenticating' }),
|
|
markAuthenticated: () => set({ state: 'ok' }),
|
|
}));
|
|
|
|
// One confirm probe per window: parallel 401s from a burst of requests must
|
|
// not turn into a probe storm, and a provider-side 401 that keeps repeating
|
|
// must not re-probe on every retry.
|
|
const CONFIRM_PROBE_MIN_INTERVAL_MS = 15_000;
|
|
// Focus revalidation only bothers the server when the tab was away long
|
|
// enough for a 12h/7d session to plausibly have died.
|
|
const FOCUS_REVALIDATE_MIN_INTERVAL_MS = 5 * 60_000;
|
|
|
|
let lastProbeAt = 0;
|
|
let probeInFlight = false;
|
|
|
|
// Paths where a 401 is part of a normal flow (wrong password on login, a
|
|
// pairing redeem, the confirm probe itself) rather than evidence of expiry.
|
|
const isExcludedAuthPath = (url: string): boolean => (
|
|
url.includes('/auth/session') || url.includes('/api/client-auth/')
|
|
);
|
|
|
|
const isClassifiablePath = (url: string): boolean => {
|
|
const path = url.startsWith('/') ? url : (() => {
|
|
try {
|
|
return new URL(url).pathname;
|
|
} catch {
|
|
return '';
|
|
}
|
|
})();
|
|
if (!path.startsWith('/api/') && !path.startsWith('/auth/')) return false;
|
|
return !isExcludedAuthPath(path);
|
|
};
|
|
|
|
const confirmSessionExpired = async (): Promise<void> => {
|
|
if (probeInFlight) return;
|
|
probeInFlight = true;
|
|
try {
|
|
// Deferred import: runtime-fetch classifies through this module, and the
|
|
// probe deliberately re-enters it (its /auth/session path is excluded).
|
|
const { runtimeFetch } = await import('./runtime-fetch');
|
|
const response = await runtimeFetch('/auth/session', { credentials: 'include' });
|
|
if (response.status === 401) {
|
|
useAuthSessionStore.getState().markExpired();
|
|
return;
|
|
}
|
|
if (response.ok) {
|
|
// The suspicious 401 came from deeper in the chain (a provider key, an
|
|
// upstream OpenCode instance) — the OpenChamber session is alive.
|
|
const { state, markAuthenticated } = useAuthSessionStore.getState();
|
|
if (state === 'expired') markAuthenticated();
|
|
}
|
|
} catch {
|
|
// Transport failure is connectivity, not authentication; the connection
|
|
// status machinery owns that story.
|
|
} finally {
|
|
probeInFlight = false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Called by runtimeFetch for every response. Cheap by design: everything but
|
|
* a 401 on a classifiable path returns immediately.
|
|
*/
|
|
export const observeRuntimeAuthResponse = (url: string, status: number): void => {
|
|
if (status !== 401) return;
|
|
if (useAuthSessionStore.getState().state === 'expired') return;
|
|
if (!isClassifiablePath(url)) return;
|
|
const now = Date.now();
|
|
if (now - lastProbeAt < CONFIRM_PROBE_MIN_INTERVAL_MS) return;
|
|
lastProbeAt = now;
|
|
void confirmSessionExpired();
|
|
};
|
|
|
|
let watchInstalled = false;
|
|
|
|
/**
|
|
* Revalidates the session when the tab regains visibility after a long
|
|
* absence — the "laptop woke up, everything looks alive, first click fails"
|
|
* case. One request per wake, nothing periodic.
|
|
*/
|
|
export const installAuthSessionFocusWatch = (): void => {
|
|
// Callers are React effects, so a document always exists here.
|
|
if (watchInstalled) return;
|
|
watchInstalled = true;
|
|
let lastConfirmedAt = Date.now();
|
|
const revalidate = () => {
|
|
if (useAuthSessionStore.getState().state !== 'ok') return;
|
|
const now = Date.now();
|
|
if (now - lastConfirmedAt < FOCUS_REVALIDATE_MIN_INTERVAL_MS) return;
|
|
lastConfirmedAt = now;
|
|
lastProbeAt = now;
|
|
void confirmSessionExpired();
|
|
};
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible') revalidate();
|
|
});
|
|
// App switches on desktop can refocus the window without a visibility
|
|
// change; both signals share one throttle, so a wake costs one request.
|
|
window.addEventListener('focus', revalidate);
|
|
};
|