diff --git a/src/commands/auth/setup.test.ts b/src/commands/auth/setup.test.ts index 372477d..fb16593 100644 --- a/src/commands/auth/setup.test.ts +++ b/src/commands/auth/setup.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type CallbackServer } from '../../lib/auth/callback-server.js'; +import { AuthRequiredError, TransientAuthError } from '../../lib/auth/api.js'; import { readAuth, saveAuth, type AuthState } from '../../lib/fs/config.js'; import { runSetup, type SetupDeps } from './setup.js'; @@ -29,6 +30,7 @@ const makeDeps = () => { openBrowser: vi.fn().mockResolvedValue(undefined), printStatus: vi.fn().mockResolvedValue(undefined), ensureDaemon: vi.fn().mockResolvedValue({}), + introspect: vi.fn().mockResolvedValue({ userId: 'u1' }), }; return { deps, server, close }; }; @@ -104,12 +106,38 @@ describe('runSetup', () => { expect(readAuth()?.tokens.accessToken).toBe('at-1'); }); - it('short-circuits when already signed in', async () => { + it('short-circuits when already signed in and the session validates', async () => { saveAuth(existingAuth); const { deps } = makeDeps(); await runSetup({}, deps); + expect(deps.introspect).toHaveBeenCalledOnce(); expect(deps.register).not.toHaveBeenCalled(); expect(deps.printStatus).toHaveBeenCalled(); + expect(readAuth()?.tokens.accessToken).toBe('old-at'); // creds untouched + }); + + it('auto-enters sign-in when the stored session is terminally expired', async () => { + saveAuth(existingAuth); + const { deps } = makeDeps(); + (deps.introspect as ReturnType).mockRejectedValue( + new AuthRequiredError('session expired') + ); + await runSetup({}, deps); + // No --reauth flag needed: a dead session falls straight through to a fresh sign-in. + expect(deps.register).toHaveBeenCalled(); + expect(readAuth()?.clientId).toBe('client-1'); + }); + + it('does not force reauth or wipe creds on a transient verify failure', async () => { + saveAuth(existingAuth); + const { deps } = makeDeps(); + (deps.introspect as ReturnType).mockRejectedValue( + new TransientAuthError('temporarily failed') + ); + await runSetup({}, deps); + expect(deps.register).not.toHaveBeenCalled(); + expect(deps.printStatus).toHaveBeenCalled(); + expect(readAuth()?.tokens.accessToken).toBe('old-at'); // creds intact }); it('re-authenticates with --reauth despite existing auth', async () => { diff --git a/src/commands/auth/setup.ts b/src/commands/auth/setup.ts index da40ea2..3b6c43a 100644 --- a/src/commands/auth/setup.ts +++ b/src/commands/auth/setup.ts @@ -10,6 +10,7 @@ import { readAuth, type AuthState, } from '../../lib/fs/config.js'; +import { AuthRequiredError, introspectToken } from '../../lib/auth/api.js'; import { buildAuthorizeUrl, exchangeCode, @@ -37,6 +38,7 @@ export interface SetupDeps { openBrowser: (url: string) => Promise; printStatus: () => Promise; ensureDaemon: typeof ensureDaemon; + introspect: typeof introspectToken; } const defaultDeps: SetupDeps = { @@ -50,6 +52,7 @@ const defaultDeps: SetupDeps = { }, printStatus: () => runStatus({}), ensureDaemon, + introspect: introspectToken, }; const toAuthState = (fqdn: string, clientId: string, tokens: TokenResponse): AuthState => ({ @@ -86,6 +89,28 @@ const disconnect = async (deps: SetupDeps): Promise => { console.log('Disconnected - local credentials removed.'); }; +// Validate the stored session instead of trusting mere token presence. Returns true to short-circuit +// (valid or a transient blip - creds intact), false to proceed into a fresh sign-in (truly expired). +const alreadySignedIn = async (auth: AuthState, deps: SetupDeps): Promise => { + try { + await deps.introspect(auth, links(auth.siteFqdn)); + console.log( + `Already signed in. Run ${chalk.white('agentage setup --reauth')} to sign in again.\n` + ); + await deps.printStatus(); + return true; + } catch (err) { + if (err instanceof AuthRequiredError) { + console.log('Session expired - signing you in again.\n'); + return false; // fall through to the sign-in flow, no --reauth needed + } + // Transient: do not force reauth on a blip; keep credentials and show status. + console.log('You appear signed in (could not fully verify - temporary).\n'); + await deps.printStatus(); + return true; + } +}; + export const runSetup = async ( opts: SetupOptions, deps: SetupDeps = defaultDeps @@ -94,13 +119,8 @@ export const runSetup = async ( await disconnect(deps); return; } - if (readAuth() && !opts.reauth) { - console.log( - `Already signed in. Run ${chalk.white('agentage setup --reauth')} to sign in again.\n` - ); - await deps.printStatus(); - return; - } + const stored = readAuth(); + if (stored && !opts.reauth && (await alreadySignedIn(stored, deps))) return; const fqdn = siteFqdn(); const target = links(fqdn); ensureConfigDir(); diff --git a/src/commands/status/status.ts b/src/commands/status/status.ts index e787b18..c096439 100644 --- a/src/commands/status/status.ts +++ b/src/commands/status/status.ts @@ -22,6 +22,8 @@ const row = (label: string, value: string): void => { // Introspection already proved the session is active server-side. const authLine = (auth: StatusReport['auth']): string => { if (!auth.signedIn) return `${mark(false)} ${auth.note ?? 'not signed in'}`; + // Transient: we hold a valid-looking token but could not re-verify - a non-terminal `~`, never ✗. + if (auth.transient) return `${chalk.yellow('~')} ${auth.note ?? 'signed in'}`; return `${mark(true)} signed in (session active)`; }; diff --git a/src/lib/auth/api.test.ts b/src/lib/auth/api.test.ts index 4f214e3..9a45259 100644 --- a/src/lib/auth/api.test.ts +++ b/src/lib/auth/api.test.ts @@ -2,7 +2,14 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { authedGet, authedPost, AuthRequiredError, currentBearer, introspectToken } from './api.js'; +import { + authedGet, + authedPost, + AuthRequiredError, + currentBearer, + introspectToken, + TransientAuthError, +} from './api.js'; import { readAuth, type AuthState } from '../fs/config.js'; import { links } from '../net/origins.js'; import { VERSION } from '../../utils/version.js'; @@ -288,16 +295,49 @@ describe('introspectToken', () => { expect(readAuth()?.tokens.accessToken).toBe('new-token'); }); - it('throws AuthRequiredError when a 200 + null survives a failed refresh', async () => { + it('throws AuthRequiredError when a 200 + null survives a terminal refresh (invalid_grant)', async () => { const fetchMock = vi.fn((url: string) => Promise.resolve( - String(url).includes('/token') ? jsonResponse(400, {}) : jsonResponse(200, null) + String(url).includes('/token') + ? jsonResponse(400, { error: 'invalid_grant' }) + : jsonResponse(200, null) ) ); vi.stubGlobal('fetch', fetchMock); await expect(introspectToken(makeAuth(), target)).rejects.toThrow(AuthRequiredError); }); + it('throws TransientAuthError (not AuthRequired) when a 200 + null refresh blips 429', async () => { + const fetchMock = vi.fn((url: string) => + Promise.resolve( + String(url).includes('/token') ? jsonResponse(429, {}) : jsonResponse(200, null) + ) + ); + vi.stubGlobal('fetch', fetchMock); + await expect(introspectToken(makeAuth(), target)).rejects.toThrow(TransientAuthError); + }); + + it('reports the unexpired stored token as signed-in when get-session blips 5xx', async () => { + const expiresAt = Date.now() + 3_600_000; + const fetchMock = vi.fn(async () => jsonResponse(500, {})); + vi.stubGlobal('fetch', fetchMock); + const auth = makeAuth({ expiresAt }); + const session = await introspectToken(auth, target); + // Held an unexpired token: treat the 5xx as a blip, report the stored expiry, never throw. + expect(session.expiresAt).toBe(new Date(expiresAt).toISOString()); + }); + + it('throws TransientAuthError when an expired token cannot refresh (5xx)', async () => { + const fetchMock = vi.fn((url: string) => + Promise.resolve( + String(url).includes('/token') ? jsonResponse(503, {}) : jsonResponse(200, {}) + ) + ); + vi.stubGlobal('fetch', fetchMock); + const auth = makeAuth({ expiresAt: Date.now() - 1000 }); + await expect(introspectToken(auth, target)).rejects.toThrow(TransientAuthError); + }); + it('refreshes and re-introspects when the session reports an already-past expiry', async () => { const past = new Date(Date.now() - 60_000).toISOString(); const future = new Date(Date.now() + 3_600_000).toISOString(); diff --git a/src/lib/auth/api.ts b/src/lib/auth/api.ts index dd35fc0..98c9ede 100644 --- a/src/lib/auth/api.ts +++ b/src/lib/auth/api.ts @@ -1,35 +1,47 @@ import { mutateAuth, type AuthState, type StoredTokens } from '../fs/config.js'; -import { refreshTokens } from './oauth.js'; +import { refreshTokens, TokenRequestError } from './oauth.js'; import { type Links } from '../net/origins.js'; import { requestHeaders } from '../net/user-agent.js'; +import { AuthRequiredError, isTerminalRefresh, TransientAuthError } from './auth-errors.js'; + +export { AuthRequiredError, TransientAuthError } from './auth-errors.js'; // Every authed call is CLI-originated; identify the caller alongside the bearer. const cliHeaders = (): Record => requestHeaders({ component: 'cli' }); -export class AuthRequiredError extends Error { - constructor(message = 'not signed in') { - super(message); - this.name = 'AuthRequiredError'; +// Refresh the stored token in place. On success mutates auth + persists and returns. On failure it +// throws: AuthRequiredError when the grant is dead (401/invalid_grant/invalid_client), else +// TransientAuthError (429/5xx/network/timeout) - a blip that must not be read as a dead session. +export const refreshOrThrow = async (auth: AuthState, links: Links): Promise => { + if (!auth.tokens.refreshToken) throw new AuthRequiredError('no refresh token'); + let fresh; + try { + fresh = await refreshTokens(links.auth, auth.clientId, auth.tokens.refreshToken); + } catch (err) { + if (err instanceof TokenRequestError && isTerminalRefresh(err.status, err.oauthError)) + throw new AuthRequiredError('session expired'); + throw new TransientAuthError('refresh temporarily failed'); } -} + const tokens: StoredTokens = { + accessToken: fresh.access_token, + refreshToken: fresh.refresh_token ?? auth.tokens.refreshToken, + expiresAt: fresh.expires_in ? Date.now() + fresh.expires_in * 1000 : undefined, + }; + auth.tokens = tokens; // the caller retries its request with this in-memory copy + // Persist under the lock, folding the new tokens onto the freshly-read state so a concurrent + // writer's other fields (clientId, siteFqdn) are not clobbered by a stale in-memory copy. + await mutateAuth((current) => { + const base = current ?? auth; + base.tokens = tokens; + return base; + }); +}; +// Boolean shim for the request paths that just want "refresh once, then retry": any failure +// (terminal or transient) collapses to false so they fall through to their own status handling. const tryRefresh = async (auth: AuthState, links: Links): Promise => { - if (!auth.tokens.refreshToken) return false; try { - const fresh = await refreshTokens(links.auth, auth.clientId, auth.tokens.refreshToken); - const tokens: StoredTokens = { - accessToken: fresh.access_token, - refreshToken: fresh.refresh_token ?? auth.tokens.refreshToken, - expiresAt: fresh.expires_in ? Date.now() + fresh.expires_in * 1000 : undefined, - }; - auth.tokens = tokens; // the caller retries its request with this in-memory copy - // Persist under the lock, folding the new tokens onto the freshly-read state so a concurrent - // writer's other fields (clientId, siteFqdn) are not clobbered by a stale in-memory copy. - await mutateAuth((current) => { - const base = current ?? auth; - base.tokens = tokens; - return base; - }); + await refreshOrThrow(auth, links); return true; } catch { return false; @@ -92,38 +104,5 @@ export const authedPost = async ( return res; }; -interface IntrospectionResponse { - userId?: string; - accessTokenExpiresAt?: string; -} - -export interface TokenSession { - userId?: string; - expiresAt?: string; -} - -const isPast = (iso?: string): boolean => { - if (!iso) return false; - const t = Date.parse(iso); - return !Number.isNaN(t) && t <= Date.now(); -}; - -// The OAuth introspection endpoint: the only live surface that validates the -// CLI's bearer token today (backend REST accepts session cookies only). -// get-session answers 200 + null (not 401) for an expired/inactive session, so authedGet's -// on-401 refresh never fires here. Mirror currentBearer: refresh proactively when the stored -// token is past expiry, reactively once when a 200 + null slips through, and once more when the -// returned session reports an already-past expiry - always re-introspect so the returned expiry -// reflects the post-refresh token and a checkmark never sits next to a stale timestamp. -export const introspectToken = async (auth: AuthState, links: Links): Promise => { - const url = `${links.auth}/api/auth/mcp/get-session`; - const get = (): Promise => - authedGet(auth, links, url); - const expired = auth.tokens.expiresAt !== undefined && auth.tokens.expiresAt <= Date.now(); - if (expired && !(await tryRefresh(auth, links))) throw new AuthRequiredError('session expired'); - let body = await get(); - const stale = !body || isPast(body.accessTokenExpiresAt); - if (stale && (await tryRefresh(auth, links))) body = await get(); - if (!body) throw new AuthRequiredError('no active session'); - return { userId: body.userId, expiresAt: body.accessTokenExpiresAt }; -}; +// Re-export the introspection surface so existing callers keep importing from './api.js'. +export { introspectToken, type TokenSession } from './introspect.js'; diff --git a/src/lib/auth/auth-errors.ts b/src/lib/auth/auth-errors.ts new file mode 100644 index 0000000..a268d22 --- /dev/null +++ b/src/lib/auth/auth-errors.ts @@ -0,0 +1,27 @@ +// Auth failures split two ways: TERMINAL means the session truly needs re-auth (401, +// invalid_grant/invalid_client on the refresh grant); TRANSIENT means a blip we must not report as +// expired (429, 5xx, network, timeout, unexpected redirect). Mirrors web#219's get-session taxonomy. + +// The session is genuinely gone: user must sign in again. +export class AuthRequiredError extends Error { + constructor(message = 'not signed in') { + super(message); + this.name = 'AuthRequiredError'; + } +} + +// A temporary failure (rate limit / server hiccup / network) - the session may still be valid. +export class TransientAuthError extends Error { + constructor(message = 'could not verify session') { + super(message); + this.name = 'TransientAuthError'; + } +} + +// OAuth token-endpoint error codes that mean the refresh grant is dead, not merely throttled. +const TERMINAL_GRANT_ERRORS = new Set(['invalid_grant', 'invalid_client']); + +// Classify a failed refresh POST: 401 or an explicit invalid_grant/invalid_client is terminal; +// everything else (429, 5xx, network, redirect, unknown) is transient. +export const isTerminalRefresh = (status: number, oauthError?: string): boolean => + status === 401 || (oauthError !== undefined && TERMINAL_GRANT_ERRORS.has(oauthError)); diff --git a/src/lib/auth/introspect.ts b/src/lib/auth/introspect.ts new file mode 100644 index 0000000..af795a0 --- /dev/null +++ b/src/lib/auth/introspect.ts @@ -0,0 +1,104 @@ +import { type AuthState } from '../fs/config.js'; +import { type Links } from '../net/origins.js'; +import { authedGet, refreshOrThrow } from './api.js'; +import { AuthRequiredError, TransientAuthError } from './auth-errors.js'; + +interface IntrospectionResponse { + userId?: string; + accessTokenExpiresAt?: string; +} + +export interface TokenSession { + userId?: string; + expiresAt?: string; +} + +const isPast = (iso?: string): boolean => { + if (!iso) return false; + const t = Date.parse(iso); + return !Number.isNaN(t) && t <= Date.now(); +}; + +const sleep = (ms: number): Promise => + new Promise((resolve) => { + // unref so a pending backoff never keeps `status` from exiting. + const timer = setTimeout(resolve, ms); + timer.unref(); + }); + +// Introspection over global fetch: a network error reflects as a transient (a blip must not read as +// a dead session), never letting the raw error escape to gatherStatus. Only a 401 stays terminal. +const getSession = async (auth: AuthState, links: Links): Promise => { + const url = `${links.auth}/api/auth/mcp/get-session`; + try { + return await authedGet(auth, links, url); + } catch (err) { + if (err instanceof AuthRequiredError) throw err; + throw new TransientAuthError('get-session temporarily failed'); + } +}; + +const mapSession = (body: IntrospectionResponse): TokenSession => ({ + userId: body.userId, + expiresAt: body.accessTokenExpiresAt, +}); + +const isUnexpired = (auth: AuthState): boolean => + auth.tokens.expiresAt !== undefined && auth.tokens.expiresAt > Date.now(); + +// A cosmetic freshen when get-session returns a present-but-past-expiry body: refresh to display the +// post-refresh expiry, but tolerate a transient refresh failure by keeping the present body. +const freshenIfStale = async ( + auth: AuthState, + links: Links, + body: IntrospectionResponse +): Promise => { + if (!isPast(body.accessTokenExpiresAt)) return mapSession(body); + try { + await refreshOrThrow(auth, links); + } catch (err) { + if (err instanceof TransientAuthError) return mapSession(body); // keep the last good session + throw err; + } + return mapSession((await getSession(auth, links)) ?? body); +}; + +// The OAuth introspection endpoint: the only live surface that validates the CLI's bearer today +// (backend REST accepts session cookies only). get-session answers 200 + null (not 401) for an +// inactive session, so the on-401 refresh never fires here. TERMINAL failures (401 / dead grant) +// throw AuthRequiredError; TRANSIENT ones (429/5xx/network/timeout) throw TransientAuthError so the +// caller renders a temporary note, never "session expired". +const introspectOnce = async (auth: AuthState, links: Links): Promise => { + const expired = auth.tokens.expiresAt !== undefined && auth.tokens.expiresAt <= Date.now(); + if (expired) await refreshOrThrow(auth, links); // throws AuthRequired or Transient by class + // With an unexpired token, a transient get-session is not proof of a dead session: we still hold a + // valid bearer, so report signed-in against the stored expiry rather than surfacing the blip. + const holdUnexpired = isUnexpired(auth); + let body: IntrospectionResponse | null; + try { + body = await getSession(auth, links); + } catch (err) { + if (holdUnexpired && err instanceof TransientAuthError) + return { expiresAt: new Date(auth.tokens.expiresAt as number).toISOString() }; + throw err; + } + // 200 + null = definitive no-session: refresh is decisive (terminal -> expired, transient -> blip). + if (!body) { + await refreshOrThrow(auth, links); + const after = await getSession(auth, links); + if (!after) throw new AuthRequiredError('no active session'); + return mapSession(after); + } + return freshenIfStale(auth, links, body); +}; + +// A retry-once with a short unref'd backoff absorbs a lone transient blip; a terminal error skips it. +export const introspectToken = async (auth: AuthState, links: Links): Promise => { + try { + return await introspectOnce(auth, links); + } catch (err) { + if (err instanceof AuthRequiredError) throw err; // terminal: no point retrying a dead session + await sleep(300); + return introspectOnce(auth, links); // one transient-only retry; a 2nd transient propagates + } +}; diff --git a/src/lib/auth/oauth.ts b/src/lib/auth/oauth.ts index 94139ae..a3504a5 100644 --- a/src/lib/auth/oauth.ts +++ b/src/lib/auth/oauth.ts @@ -71,13 +71,28 @@ export const buildAuthorizeUrl = ( return url.toString(); }; +// A token-endpoint failure that carries the HTTP status and any RFC 6749 `error` code so the +// caller can tell a dead grant (invalid_grant/invalid_client/401) from a transient blip (429/5xx). +export class TokenRequestError extends Error { + constructor( + readonly status: number, + readonly oauthError?: string + ) { + super(`token request failed (${status}${oauthError ? ` ${oauthError}` : ''})`); + this.name = 'TokenRequestError'; + } +} + const postForm = async (url: string, form: Record): Promise => { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', ...cliHeaders() }, body: new URLSearchParams(form).toString(), }); - if (!res.ok) throw new Error(`token request failed (${res.status})`); + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null; + throw new TokenRequestError(res.status, body?.error); + } return (await res.json()) as TokenResponse; }; diff --git a/src/lib/status/status-info.test.ts b/src/lib/status/status-info.test.ts index 64c800d..471d58e 100644 --- a/src/lib/status/status-info.test.ts +++ b/src/lib/status/status-info.test.ts @@ -108,12 +108,14 @@ describe('gatherStatus', () => { expect(report.auth.note).toBeUndefined(); }); - it('reports session expired when the 200 + null refresh fails', async () => { + it('reports session expired when a 200 + null survives a terminal (invalid_grant) refresh', async () => { vi.stubGlobal( 'fetch', vi.fn((url: string) => Promise.resolve( - String(url).includes('/token') ? jsonResponse(400, {}) : jsonResponse(200, null) + String(url).includes('/token') + ? jsonResponse(400, { error: 'invalid_grant' }) + : jsonResponse(200, null) ) ) ); @@ -122,12 +124,14 @@ describe('gatherStatus', () => { expect(report.auth.note).toBe('session expired - run: agentage setup'); }); - it('downgrades to signed-out with a hint when the token is rejected', async () => { + it('reports session expired when the token is authoritatively rejected (401)', async () => { vi.stubGlobal( 'fetch', vi.fn((url: string) => Promise.resolve( - String(url).includes('/token') ? jsonResponse(400, {}) : jsonResponse(401, {}) + String(url).includes('/token') + ? jsonResponse(400, { error: 'invalid_grant' }) + : jsonResponse(401, {}) ) ) ); @@ -136,14 +140,37 @@ describe('gatherStatus', () => { expect(report.auth.note).toContain('session expired'); }); - it('reports verification failures without claiming signed-in', async () => { + it('reports signed-in (never expired) on a 5xx blip while holding an unexpired token', async () => { + const future = Date.now() + 3_600_000; vi.stubGlobal( 'fetch', vi.fn(async () => jsonResponse(500, {})) ); - const report = await gatherStatus(auth, 'dev.agentage.io'); - expect(report.auth.signedIn).toBe(false); - expect(report.auth.note).toContain('could not verify session'); + const report = await gatherStatus( + { ...auth, tokens: { ...auth.tokens, expiresAt: future } }, + 'dev.agentage.io' + ); + // An unexpired token + a get-session blip stays a clean signed-in (exit 0), never "expired". + expect(report.auth.signedIn).toBe(true); + expect(report.auth.note ?? '').not.toContain('expired'); + }); + + it('does not report expired when a refresh blips transiently on an expired token', async () => { + // Stored token past expiry -> proactive refresh returns 429 (transient), never invalid_grant. + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => + Promise.resolve( + String(url).includes('/token') ? jsonResponse(429, {}) : jsonResponse(200, null) + ) + ) + ); + const report = await gatherStatus( + { ...auth, tokens: { ...auth.tokens, expiresAt: Date.now() - 1000 } }, + 'dev.agentage.io' + ); + expect(report.auth.transient).toBe(true); + expect(report.auth.note).not.toContain('expired'); }); it('reports the daemon as stopped when no pidfile is present', async () => { diff --git a/src/lib/status/status-info.ts b/src/lib/status/status-info.ts index bdcbaca..b0ee55c 100644 --- a/src/lib/status/status-info.ts +++ b/src/lib/status/status-info.ts @@ -33,7 +33,7 @@ export interface StatusReport { fqdn: string; env: Env; target: { fqdn: string; env: Env; reachable: boolean }; - auth: { signedIn: boolean; tokenExpiresAt?: string; note?: string }; + auth: { signedIn: boolean; tokenExpiresAt?: string; note?: string; transient?: boolean }; endpoint: { url: string; reachable: boolean }; update: UpdateInfo; daemon?: DaemonStatus; @@ -101,6 +101,13 @@ const probeDaemon = async (): Promise => { return { status, sync }; }; +// AuthRequiredError = truly expired (terminal). TransientAuthError (or any non-terminal) = a blip +// while we could not re-verify: never rendered as expired, never told to run setup, exit stays 0. +const classifyAuthError = (err: unknown): StatusReport['auth'] => + err instanceof AuthRequiredError + ? { signedIn: false, note: 'session expired - run: agentage setup' } + : { signedIn: true, transient: true, note: 'signed in (could not re-verify - temporary)' }; + export const gatherStatus = async (auth: AuthState | null, fqdn: string): Promise => { const target = links(fqdn); const env = environment(fqdn); @@ -130,13 +137,7 @@ export const gatherStatus = async (auth: AuthState | null, fqdn: string): Promis const session = await introspectToken(auth, target); report.auth = { signedIn: true, tokenExpiresAt: session.expiresAt }; } catch (err) { - report.auth = - err instanceof AuthRequiredError - ? { signedIn: false, note: 'session expired - run: agentage setup' } - : { - signedIn: false, - note: `could not verify session: ${err instanceof Error ? err.message : String(err)}`, - }; + report.auth = classifyAuthError(err); } return report; };