/** * Keeping a usable Google access token available. * * Responsibilities, in order of how often they bite: * * 1. Refresh before use, not after failure. An expired access token that is * only discovered when a call 401s turns every first call of a session * into a retry. Expiry is checked up front with a minute of slack. * 2. Never refresh concurrently. Several tool calls arriving at once must * share one refresh, or Google sees a burst of identical grants and the * losers race to overwrite each other's token. * 3. Persist the result where it was safe to persist it, the encrypted * secret store. Credentials adopted from another tool's files are * **never written back**; that tool owns them and is still using them. * 4. Report a dead refresh token in plain language. A revoked grant is the * one failure a human must act on, and it must not look like a network * blip. */ import type { GoogleOAuthCredentials } from './credential-adoption.js'; /** Result of exchanging a refresh token for a new access token. */ export interface GoogleRefreshResult { readonly accessToken: string; /** Seconds until expiry, as Google returns it. */ readonly expiresInSeconds: number; /** Google may return a narrowed scope set; null when unchanged. */ readonly scopes: readonly string[] | null; } /** Why a refresh failed. Distinguishes "act on this" from "try again". */ export type GoogleRefreshFailure = /** The refresh token is revoked, expired or invalid. A human must re-authorize. */ 'grant-invalid' /** Network or server-side problem. Retrying later is reasonable. */ | 'transient'; export type GoogleRefreshOutcome = { readonly ok: true; readonly result: GoogleRefreshResult; } | { readonly ok: false; readonly failure: GoogleRefreshFailure; readonly problem: string; readonly fix: string; }; /** Injected token-refresh call. Must never include token values in errors. */ export type GoogleRefreshFn = (input: { readonly clientId: string; readonly clientSecret: string; readonly refreshToken: string; readonly tokenUri: string; }) => Promise; /** Persists a refreshed access token. Only ever called for store-owned credentials. */ export type GooglePersistFn = (input: { readonly accessToken: string; readonly expiresAtMs: number; }) => Promise; export interface GoogleTokenManagerDeps { readonly refresh: GoogleRefreshFn; /** Omitted for adopted credentials, which must not be written back. */ readonly persist?: GooglePersistFn; readonly now?: () => number; } /** A token ready to use, plus how it was obtained. */ export interface GoogleAccessToken { readonly accessToken: string; readonly expiresAtMs: number | null; /** True when this call performed a refresh rather than reusing a cached token. */ readonly refreshed: boolean; } export type GoogleAccessTokenOutcome = { readonly ok: true; readonly token: GoogleAccessToken; } | { readonly ok: false; readonly failure: GoogleRefreshFailure; readonly problem: string; readonly fix: string; }; export declare class GoogleTokenManager { private credentials; private readonly deps; private readonly now; /** In-flight refresh, shared by every caller that arrives during it. */ private inFlight; /** * The dead-grant latch. * * Once Google has answered `invalid_grant` for this refresh token, the answer * is final: the token is not going to start working again, and asking a * second time cannot produce new information. The observed failure was six * identical refresh attempts against a revoked grant, each one a round trip * that told the person nothing. * * So the first `grant-invalid` is remembered and every later call returns it * from here without touching the network. This is a latch rather than a * counter because the correct number of repeat attempts is zero, not fewer. * `clearGrantFailure()` lifts it, and only re-authorization should call that. */ private deadGrant; constructor(credentials: GoogleOAuthCredentials, deps: GoogleTokenManagerDeps); /** * True when the grant is known dead and no further refresh will be attempted. * Callers use this to explain rather than to retry. */ grantIsDead(): boolean; /** * Forget a recorded dead grant so refreshes are attempted again. * * Only meaningful after the credential itself has been replaced, a fresh * consent produces a different refresh token, and holding the old verdict * against it would make a successful re-authorization look like a failure. */ clearGrantFailure(): void; /** The recorded verdict, so a caller can restate it without re-asking Google. */ private deadGrantOutcome; /** Scopes the current credential was granted. Safe to display. */ scopes(): readonly string[]; /** True when the cached access token can still be used. */ private cachedTokenUsable; /** * Get a usable access token, refreshing if needed. * Concurrent callers share a single refresh. */ accessToken(): Promise; /** * Force a refresh regardless of cached expiry. Used by the boot-time check, * which is specifically proving the refresh token still works. */ forceRefresh(): Promise; private performRefresh; } /** * The boot-time check. * * Proves at startup whether Google actually works, by exchanging the refresh * token for an access token, a request that reads nothing, sends nothing and * changes nothing on the account. A session then knows its real posture * instead of inferring it from an empty registry, which is precisely the * mistake that led to a user being told email was unconfigured while working * credentials sat on disk. * * Never throws, never logs a token, and is safe to run on every start. */ export interface GoogleBootCheckResult { readonly usable: boolean; /** Plain-language posture line, safe for a status panel or transcript. */ readonly detail: string; readonly needsReauthorization: boolean; readonly scopes: readonly string[]; } export declare function checkGoogleCredentialsAtBoot(manager: GoogleTokenManager | null): Promise; //# sourceMappingURL=token-manager.d.ts.map