/** * The OrcaRouter connect flow: OAuth 2.0 with PKCE, no client secret, no pre-registered redirect. * * FLOW A (loopback redirect) IS THE DEFAULT. This package always runs on the machine the user is * sitting at -- the dashboard binds a loopback port and the CLI runs in their terminal -- so a * `http://127.0.0.1:/cb` listener is always available and the user clicks once. FLOW B * (out-of-band code) is offered beside it for the case where the dashboard is being read through a * remote browser, or the loopback listener cannot bind, and the code has to travel by hand. * * FLOW C (device grant) IS NOT IMPLEMENTED. The protocol guide says to implement one flow, and a * device grant exists for clients with no browser at all. This one has a browser on both of its * surfaces. The constants for it live in `endpoints.ts` so a later addition does not re-derive them. * * WHAT THE EXCHANGE RETURNS IS A DURABLE API KEY, NOT A REFRESH TOKEN. There is no refresh endpoint * to call and no grant to fabricate, so this module never schedules one. A key is reused until * OrcaRouter revokes it; a 401 from the relay is terminal and means re-authenticate, which * `credential-store.ts` records against the exact account generation that was rejected. * * ATTEMPTS ARE GENERATION-GUARDED. Every start bumps a counter, and every asynchronous step -- * the listener callback, the exchange response, a manual code submission -- confirms it still * belongs to the current generation before it is allowed to produce a credential or change state. * A late success from a cancelled attempt therefore cannot install a key the user did not ask for, * and a late failure cannot clear one they did. */ import { type OrcaRouterOrigins } from './endpoints.js'; /** Auth codes live 10 minutes; the listener waits slightly less so the error is ours, not theirs. */ export declare const DEFAULT_AUTHORIZATION_TIMEOUT_MS = 300000; export type ConnectMode = 'loopback' | 'oob'; export type ConnectFailureKind = 'denied' | 'state-mismatch' | 'timeout' | 'cancelled' | 'exchange-rejected' | 'exchange-failed' | 'rate-limited' | 'network' | 'scope'; /** * A failure a user can act on. * * The message never carries the code, the verifier or the key: an error string reaches logs, the * dashboard and a terminal, and a credential that appears in any of those is a credential that has * to be revoked. `detail` is therefore assembled from status codes and protocol error names only. */ export declare class OrcaConnectError extends Error { readonly kind: ConnectFailureKind; readonly detail: string | null; readonly status: number | null; constructor(kind: ConnectFailureKind, message: string, options?: { detail?: string | null; status?: number | null; }); } export interface StartOptions { readonly mode: ConnectMode; readonly origins: OrcaRouterOrigins; readonly loginHint?: string; readonly workspaceHint?: string; readonly timeoutMs?: number; /** Injected in tests so a fake auth server can be exercised through this exact adapter. */ readonly fetchImpl?: typeof fetch; /** Injected in tests to prove the verifier and state come from a cryptographic RNG. */ readonly random?: (size: number) => Buffer; } export interface StartedConnect { readonly attemptId: string; readonly mode: ConnectMode; readonly authorizeUrl: string; /** Present for Flow A: the loopback URL the consent screen will deliver the code to. */ readonly redirectUri: string | null; } export interface ExchangeOutcome { readonly key: string; readonly accountId: string; readonly scope: string; /** True when the granted scope is not the one requested. Reported, never silently accepted. */ readonly scopeDowngraded: boolean; } /** * Owns at most one live attempt per id. * * The dashboard keeps one of these for the life of the server, which is what makes * "the browser went away mid-login" a recoverable state rather than a stuck one: the page tells the * server to cancel, and the server releases the listener and the pending promise. */ export declare class OrcaConnectManager { private readonly attempts; private generation; private counter; /** Start an attempt. Returns the URL to open; nothing is stored until the exchange succeeds. */ start(options: StartOptions): Promise; private bindListener; private closeListener; /** * Read one callback and decide both its failure and the page to show for it. * * The order is the security property, not a style choice: STATE FIRST, before the code and before * the `error` parameter. The listener is reachable by anything that can reach loopback, so a code * dropped on it by some other page must be discarded without being read, and a denial forged by * that page must not be allowed to end a real attempt either. * * Every branch returns a page, including the ones that carry a failure, because a browser is * sitting on this URL and a blank response reads as a broken tool. */ private readCallback; /** * A code is in hand. * * IT MAY ARRIVE BEFORE ANYONE IS WAITING. The consent screen can deliver its redirect the instant * the listener is bound, which is earlier than the HTTP request that called `complete` has been * read -- so the code is held and handed to the first waiter rather than dropped. Dropping it * would leave the exchange waiting for a second delivery that is never coming, which is a hang. */ private deliverCode; private failAttempt; /** * Hand the manager a code typed by a human (Flow B), or a code from a browser that picked * "Show me a code" while a loopback attempt was waiting. */ submitCode(attemptId: string, code: string): void; /** The attempt's generation, so a caller can prove its response is still current. */ generationOf(attemptId: string): number | null; isCurrent(attemptId: string): boolean; /** * Install this attempt's credential, unless a newer attempt has taken over in the meantime. * * WHY THE CHECK AND THE WRITE ARE ONE OPERATION. A caller that asks "may I install?" and then * awaits the store before writing has left a window between the two, and that window is reachable * in normal use: `start()` is a separate HTTP request, so a second sign-in can arrive and move the * generation while the first one's `saveCredential` is in flight. The older attempt then installs a * key the user already replaced, and the store's own lock does not help -- it serializes the * writes, not the decision that precedes them. * * So the manager owns the whole decision. The generation is re-read synchronously, immediately * before `install` is invoked, and nothing in this process can start a new attempt in between: * JavaScript runs this callback to its first await without yielding, and `start()` is synchronous * up to the point where it bumps the generation. An attempt that has been superseded or cancelled * gets `'superseded'` and its credential is never written. */ installIfCurrent(attemptId: string, install: () => Promise): Promise<{ installed: true; value: T; } | { installed: false; reason: 'unknown' | 'superseded'; }>; /** * Wait for the code and exchange it. * * A cancel, a denial, a timeout, an expired code, a reused code, a 403, a 429 and a transport * failure all end here with an `OrcaConnectError` and no credential. None of them retries: a * hot loop against the exchange endpoint would burn the account's 10-keys-per-24-hours budget * while telling the user nothing. */ complete(attemptId: string): Promise; /** Release the listener, the timer and the pending callbacks. Does not change generation. */ private releaseResources; private awaitCode; /** * Cancel an attempt and release everything it holds. * * Called for an explicit Cancel, for switching authentication method, for a modal closing, for * an unmount, and from the `pagehide` handler -- where the caller must ALSO clear its own busy * flag synchronously, because the guarded continuation that would normally do it refuses to run * once the generation has moved on. */ cancel(attemptId: string): boolean; /** Cancel every live attempt. Used on shutdown, where no credential may be installed. */ cancelAll(): void; /** Live attempt count, so a test can prove a cancel really released the listener. */ get activeCount(): number; private exchange; } //# sourceMappingURL=connect.d.ts.map