/** * Session scaffolding for the MCP fleet — five related-but-distinct surfaces * consolidated behind one subpath (`@chrischall/mcp-utils/session`): * * 1. {@link SessionRegistry} — an *ephemeral, in-memory* registry of signed-in * sessions keyed by account identity, plus {@link registerSessionTools} for * the structurally-identical `*_register_session` / `*_set_active_session` / * `*_get_session_context` MCP tool trio. Used by the realty MCPs * (zillow/redfin/compass/homes/onehome). * * 2. {@link SessionStore} — a *disk-persisted* store with hardened file perms * (0600 file / 0700 dir), normalized keys, and a most-recently-used "active" * pointer. Used by ofw/creditkarma/honeybook. * * 3. {@link StatePersistence} — the opt-in seam that lets the two managers * below survive a process restart, with {@link createFileStatePersistence} * (atomic, 0600) and {@link resolveStateDir} (`MCP_DATA_DIR` → `HOME`) as * the disk-backed default. Without it a scale-to-zero host re-runs a full * login on every cold start, against endpoints that often rate-limit it. * * 4. {@link TokenManager} — a bearer-token lifecycle manager: a lazily * bootstrapped login, proactive refresh inside a 5-minute skew window, * reactive 401-replay, and a single-flight semaphore so concurrent callers * coalesce into ONE exchange. Used by skylight/canvas/creditkarma/honeybook/zola. * * 5. {@link CookieSessionManager} — the cookie-session analog of TokenManager: * a single-flight login + reactive expiry-replay (with heuristic, not just * status-code, expiry detection) + clear-on-settle so a rejected login never * sticks. Used by artsonia/canvas/evite/signupgenius/skylight. * * Security-sensitive by design (file perms + token-refresh races), so this is * the one audited implementation the fleet shares. */ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; /** How a session was authenticated. Open-ended so per-MCP modes slot in. */ export type AuthMode = 'browser_session' | 'unknown' | (string & {}); /** A registered, signed-in session as surfaced to tools and clients. */ export interface SessionToken { /** Opaque label id (`Date.now().toString(36)` + random). Stable across re-registration. */ session_id: string; /** * Caller-supplied identity for the signed-in account (usually the saved * email). Re-registering the same identity updates the existing entry in * place rather than creating a duplicate. */ account_identity: string; auth_mode: AuthMode; /** Whether the session is currently usable for tool calls. */ auth_ready: boolean; /** ISO timestamp of when the session was last registered/refreshed. */ registered_at: string; /** ISO expiry, or `null` for "no known expiry". */ auth_expires_at: string | null; } /** Snapshot returned by {@link SessionRegistry.getContext}. */ export interface SessionContext { active_session_id: string | null; sessions: SessionToken[]; } /** Arguments to {@link SessionRegistry.register}. */ export interface RegisterArgs { account_identity: string; auth_mode?: AuthMode; /** * `undefined` keeps any existing expiry on re-registration; an explicit * value (including `null`) replaces it. This distinction matters — coercing * with `?? null` would silently wipe a previously-set expiry. */ auth_expires_at?: string | null; } /** * Per-process, in-memory registry of signed-in sessions. The constructor takes * no arguments, so it's safe to instantiate per test. Sessions are keyed by * `session_id` but de-duplicated by `account_identity`. */ export declare class SessionRegistry { private readonly sessions; private activeId; /** * Register a new session, or refresh the existing one keyed by * `account_identity`. The first session registered becomes the active one. */ register(args: RegisterArgs): SessionToken; /** Switch the active session. Returns `false` if the id is unknown. */ setActive(sessionId: string): boolean; /** Look up a session by id (returns a copy, or `null`). */ get(sessionId: string): SessionToken | null; /** Snapshot of the full registry plus the active id. */ getContext(): SessionContext; /** The active session id, if any. */ activeSessionId(): string | null; /** Number of registered sessions. */ size(): number; /** * Resolve which session a tool call should route through: * - `requested` set and known → it; * - `requested` set but unknown → throws; * - `requested` undefined → the active session; * - no sessions registered → `null` (caller uses the default transport). */ resolve(requested: string | undefined): string | null; /** Clear all state. Test helper. */ reset(): void; } /** Construct a fresh in-memory {@link SessionRegistry}. */ export declare function createSessionRegistry(): SessionRegistry; /** Options for {@link registerSessionTools}. */ export interface RegisterSessionToolsOptions { /** * Tool-name prefix, e.g. `'zillow'` → `zillow_register_session`. This is the * one per-MCP knob; everything else is identical across the fleet. */ prefix: string; /** Human label for the service (defaults to the prefix). */ serviceLabel?: string; } /** * Register the structurally-identical session tool trio against `server`, * backed by `registry`. Replaces every MCP's hand-rolled `src/tools/sessions.ts`. * * `${prefix}_register_session` accepts an optional `mark_active` boolean * (default `false`): when `true`, the newly-registered session is immediately * made active in the same call (equivalent to a follow-up * `${prefix}_set_active_session`). Omitting it preserves the * first-registered-wins active-session behaviour. */ export declare function registerSessionTools(server: McpServer, registry: SessionRegistry, opts: RegisterSessionToolsOptions): void; /** * Given a full URL or just an origin, return the origin without a trailing * slash. Falls back to trimming a trailing slash for non-URL input. */ export declare function normalizeOrigin(input: string): string; /** Options for {@link SessionStore}. `T` is the persisted record shape. */ export interface SessionStoreOptions { /** Absolute path to the JSON file. Parent dirs are created as needed. */ filePath: string; /** Extract the unique key (e.g. origin / account id) from a record. */ keyOf: (session: T) => string; /** * Normalize a key before storing/looking up. Defaults to * {@link normalizeOrigin}. The stored record's key field is also normalized. */ normalizeKey?: (key: string) => string; } /** * Disk-persisted session store with hardened file permissions. Records are kept * in a `Map` keyed by their normalized key, persisted as a JSON array (insertion * order). The file is written with mode `0600` and its directory `0700` so other * users on the machine cannot read captured credentials. * * `add` marks the record most-recently-used; {@link SessionStore.getActiveSession} * (and `get()` with no argument) returns it, so a tool call that omits an explicit * key picks up the latest session automatically. */ export declare class SessionStore> { private sessions; private mostRecentKey; private readonly filePath; private readonly keyOf; private readonly normalizeKey; constructor(opts: SessionStoreOptions); private loadFromDisk; /** * Move a corrupt store file to `.corrupt` (or `.corrupt-` if a * previous backup exists) so a subsequent save cannot overwrite the only * copy of the prior credentials. Best-effort: returns the backup path, or * `null` if the rename failed. */ private preserveCorruptFile; /** Serialize the store to its on-disk JSON form (array, insertion order). */ serialize(): string; /** Parse on-disk JSON back into a keyed `Map`; empty map on invalid input. */ private deserialize; private saveToDisk; /** Insert or replace a record, normalizing its key and marking it active. */ add(session: T): void; /** Look up by key; with no key, returns the active (most-recent) session. */ get(key?: string): T | null; /** The most-recently-added session, or `null`. */ getActiveSession(): T | null; /** All sessions in insertion order. */ list(): T[]; /** Remove a session; fixes up the active pointer. Returns whether it existed. */ remove(key: string): boolean; /** Clear in-memory state without touching disk. Test helper. */ resetForTest(): void; } /** * A place to keep a credential between processes. * * Why this exists: {@link TokenManager} and {@link CookieSessionManager} own a * credential's lifecycle *within* a process, and every fleet server used to * throw that credential away on exit — so a cold start re-ran the full login * even when a valid refresh token had been minted seconds earlier. On * `mcp-host` that is the normal case, not the exception: children idle out * after ten minutes and the machine scales to zero behind them. Several * services rate-limit the login endpoint, and at least one (per the kiaaccess * notes) escalates repeated attempts to a captcha that breaks server-side auth * for the account permanently. Re-login is not always a free retry. * * Deliberately opt-in: a manager given no `persistence` behaves exactly as it * did before, and no credential reaches a disk because a package was upgraded. * * Both methods may be sync or async. The fleet's own implementation * ({@link createFileStatePersistence}) is sync — it writes one small file — but * the async signature leaves room for a backend that has to go over a wire. * * **A failing implementation should throw.** Whether losing a write is * survivable is the MANAGER's call, not the store's: for most services a failed * save costs a re-login, but freshbooks-mcp rotates single-use refresh tokens, * so a new token that does not reach disk locks the account out on the next * start. The managers catch every call and, by default, swallow it — pass * `onPersistError` to observe or to make it fatal. */ export interface StatePersistence { /** Read the stored state; `null` when absent, unparseable, or unusable. */ load(): T | null | Promise; /** Write state, replacing whatever was there. */ save(state: T): void | Promise; /** * Discard the stored state. Optional, but a manager that detects its stored * credential is no good calls this — without it, an expired session is read * straight back off disk and the expiry loops. */ clear?(): void | Promise; } /** * A {@link StatePersistence} that is synchronous by construction — what the * file-backed stores in this module return. * * The base interface allows a promise so a network-backed store can implement * it, which means `load()` on the concrete file store was typed * `T | null | Promise` even though it reads one small file and cannot * suspend. Every consumer composing a store — wrapping `load` to add a legacy * fallback, say — then needed a narrowing cast at the call site for a value that * was never going to be a promise. Advertising the narrower type removes the * cast without changing what is returned. * * Assignable to {@link StatePersistence} in the ordinary way, so anything that * takes the general interface still takes one of these. */ export interface SyncStatePersistence { /** Read the stored state; `null` when absent, unparseable, or unusable. */ load(): T | null; /** Write state, replacing whatever was there. Throws if the write fails. */ save(state: T): void; /** Discard the stored state. */ clear(): void; } /** * Wraps a failure that came from WRITING state, so the managers can tell it * apart from a failure of the credential itself. * * This distinction is load-bearing, not cosmetic. `TokenManager` recovers from a * rejected refresh by discarding the stored record and re-running the login — * correct for a revoked token, catastrophic for a disk error, because the * refresh that just SUCCEEDED already burned the old token upstream. Deleting * the record at that point is precisely the lockout `onPersistError` exists to * prevent, so a persistence failure is never routed into that recovery. */ export declare class StatePersistenceError extends Error { readonly cause: unknown; constructor(cause: unknown); } /** Options for {@link createFileStatePersistence}. */ export interface FileStatePersistenceOptions { /** Absolute path to the JSON file. Parent directories are created as needed. */ filePath: string; /** * Bind the record to the credential that minted it. Pass the value whose change * should invalidate the cache — an env-supplied refresh token, an account id. * Only a salted HMAC digest is written, never the value, and the salt is fresh * per write. It is a change-detector rather than a password store, so prefer a * non-secret discriminator where one exists. * * Without this a rotated credential is silently shadowed by a cache minted * from the old one: the operator re-bootstraps, and the server keeps using * what it had. freshbooks-mcp discovered this and tracked it as * `seededFromEnv`; this is the generalised, non-secret-storing form. * * A record with no binding is not accepted when one is required — it cannot * be shown to belong to this credential. */ boundTo?: string; /** * Narrow the parsed JSON to `T`, returning `null` to reject it. Without this * any well-formed JSON is handed back and the caller must check the shape — * the managers do, but a custom consumer should pass a guard. */ validate?: (raw: unknown) => T | null; } /** * File-backed {@link StatePersistence}. `load` never throws — an absent, corrupt * or rejected record is simply `null`. `save` DOES throw on a failed write, so * the manager above it can decide what that means. * * The file is `0600`, re-asserted after * the write because `mode` only applies on creation. A directory is created * `0700` and re-asserted the same way — but ONLY one this call creates: a bare * {@link resolveStateDir} is `$HOME`, and on `mcp-host` the data dir exists * before the child starts, so re-permissioning a pre-existing directory would * be an invasive side effect of writing one token file rather than hardening. * * Two differences from {@link SessionStore}, which is why this is its own * implementation rather than a wrapper over it. It holds ONE record rather than * a keyed collection; and it replaces the file **atomically** — written to a * temp file beside it, then renamed over the target — because two children of * the same registration can share a data directory, and a half-written token * file that parses as valid JSON is worse than none. * * A load failure (absent, corrupt, rejected by `validate`) returns `null`; a * save failure throws, leaving the previous file intact — the atomic replace * means a failed write never damages what was already there. On `mcp-host` this * belongs under {@link resolveStateDir}, which needs * the registration to declare `state.dataDir: true` — the runner's * unpersisted-state detector will report the omission rather than let the * writes silently vanish on the next idle-stop. */ export declare function createFileStatePersistence(opts: FileStatePersistenceOptions): SyncStatePersistence; /** Options for {@link createKeyedFileStatePersistence}. */ export interface KeyedFileStatePersistenceOptions { /** Absolute path to the shared JSON file. Parent dirs are created as needed. */ filePath: string; /** Narrow a stored record to `T`, returning `null` to reject it. */ validate?: (raw: unknown) => T | null; /** * Normalize a key before storing or looking up. Defaults to trim + lowercase, * because these keys are account identities (emails, usernames) — NOT origins. * kiaaccess-mcp and alphaportal-mcp both had to override `SessionStore`'s * origin normalizer for exactly this reason, so it is the default here. */ normalizeKey?: (key: string) => string; } /** A keyed store: hand each key out as its own {@link StatePersistence}. */ export interface KeyedStatePersistence { /** * A single-record view of one key, shaped exactly like the persistence the * managers take — so a multi-account server gives each account its own * {@link TokenManager} over one shared file. */ forKey(key: string): SyncStatePersistence; /** The normalized keys currently held. */ keys(): string[]; } /** * Many records in one file, keyed by account. * * {@link createFileStatePersistence} holds exactly one record, which is wrong * for any server that authenticates as more than one identity — and actively * unsafe for one that serves several users from a single process, where a * single-record file would hand one user's token to the next. kiaaccess-mcp and * alphaportal-mcp both hand-rolled this over {@link SessionStore}; this is the * shared form, with the same atomic-replace and `0600`/`0700` hardening as the * single-record store. * * Reads go through the file each time rather than an in-process cache, so a * record written by a SIBLING process is picked up — the property kiaaccess-mcp's * "constructed per call" comment exists to preserve. * * WRITES, though, are whole-file read-modify-write: two processes saving * DIFFERENT keys at the same instant can lose one of the two, because each * rewrites the map it read. The replace is atomic, so the file is never torn — * only a concurrent sibling's update can be dropped, and the loser re-authenticates * rather than reading anything wrong. That is acceptable for credential caches * (rare writes, self-healing) and would not be for a general-purpose store. If * that ever stops being true the fix is a lock file, not a bigger read. */ export declare function createKeyedFileStatePersistence(opts: KeyedFileStatePersistenceOptions): KeyedStatePersistence; /** Options for {@link resolveStateDir}. */ export interface ResolveStateDirOptions { /** Environment to read (defaults to `process.env`) — injectable for tests. */ env?: Record; /** Optional service-scoped subdirectory to join onto the base. */ subdir?: string; } /** * Where a server should keep state that must survive a restart. * * `MCP_DATA_DIR` first — that is the variable `mcp-host` injects for a * registration with `state.dataDir: true`, pointing at a path on the Fly volume * keyed by the registration itself (a slot `$HOME` is handed out by arrival * order and moves between boots, which is why the data dir is the fix and a * bigger rootfs is not). Then `HOME`, then the OS home directory. * * Blank and unexpanded-placeholder values (`${MCP_DATA_DIR}`, the shape a host * config leaves behind when a variable was never substituted) are ignored * rather than used as a literal directory name — the same hardening * {@link readEnvVar} applies. */ export declare function resolveStateDir(opts?: ResolveStateDirOptions): string; /** Options for {@link resolveStateFile}. */ export interface ResolveStateFileOptions extends ResolveStateDirOptions { /** * An env var naming the file outright, checked first. Every fleet repo that * hand-rolled persistence has one (`KIA_SESSION_FILE`, `VIBO_SESSION_FILE`, * `ALPHAPORTAL_SESSION_FILE`) and every one of them uses it to keep its test * suite off the developer's real `$HOME` — which is worth having by default * rather than rediscovering per repo. */ envVar?: string; /** File name inside the resolved directory. */ fileName: string; } /** * The full path to a state file: `` if set, else * {@link resolveStateDir}`//`. * * The override goes through the same hardened {@link readEnvVar} as the base, so * a host forwarding an unexpanded `${...}` — or the literal `null` — falls back * rather than creating a relative directory of that name under the process cwd. * The result is always absolute: `~` expands against the same home the fallback * uses, and anything relative is resolved, because * {@link FileStatePersistenceOptions.filePath} is documented as absolute and a * cwd-relative store would move with the process. */ export declare function resolveStateFile(opts: ResolveStateFileOptions): string; /** Refresh proactively this many ms before the access token expires. */ export declare const TOKEN_REFRESH_SKEW_MS: number; /** A bearer access token + (optional) refresh token + absolute expiry. */ export interface BearerTokens { accessToken: string; /** Refresh token, if the flow uses one. */ refreshToken?: string; /** Absolute expiry in epoch milliseconds. */ expiresAt: number; } /** Result a {@link TokenManagerOptions.refresh} call must return. */ export interface RefreshedTokens { accessToken: string; /** Omit to keep the current refresh token (rotation is optional). */ refreshToken?: string; expiresAt: number; } /** Options for {@link TokenManager}. */ export interface TokenManagerOptions { /** * The starting tokens — either the tokens themselves, or a **bootstrap * function** that mints them (typically a full login). * * Pass the function form to get the persistence benefit: it is invoked only * when {@link TokenManagerOptions.persistence} has nothing usable, so a * restart that finds a stored token never logs in at all, and one that finds * an expired token with a refresh token spends a refresh instead of a login. * It is single-flighted like every other credential operation here, so a * burst of first calls hits a rate-limited login endpoint exactly once. * * The eager object form is unchanged: the caller already paid for the login, * so persistence is not consulted and the tokens are used as given. */ initial: BearerTokens | (() => Promise); /** * Exchange the current refresh token for fresh tokens. Called at most once * per concurrent burst (the in-flight promise is shared). */ refresh: (refreshToken: string) => Promise; /** * Override the skew window (ms before expiry that triggers a proactive * refresh). Defaults to {@link TOKEN_REFRESH_SKEW_MS} (5 minutes). */ skewMs?: number; /** * Keep tokens across process restarts. Read once on the bootstrap path * (function-form `initial` only), written after every successful bootstrap * and refresh, including rotation. Omit for the previous in-memory-only * behaviour. See {@link StatePersistence}. */ persistence?: StatePersistence; /** * Decide whether a {@link TokenManagerOptions.refresh} rejection means the * credential itself is dead (re-mint via the bootstrap) or the endpoint was * merely unreachable (surface it, keep the token). * * The distinction matters in both directions. Treating a transient failure as * revocation deletes a still-VALID refresh token and burns a login against an * endpoint that may rate-limit or escalate to a captcha — the exact cost this * whole feature exists to avoid. Treating a real revocation as transient * leaves the server broken until someone deletes the stored file by hand. * * The default resolves that by only excusing failures that are transient *by * construction* — a {@link RateLimitedError}, a {@link RequestTimeoutError}, * or an {@link ApiError} with a 5xx status. Anything else is assumed to be a * dead credential, which keeps the recover-from-revocation guarantee. Override * it for a service that signals revocation some other way (or, conversely, one * that answers a live token with a 5xx). Mirrors the permanent-vs-transient * split {@link CookieSessionManagerOptions.isPermanentError} already makes. */ isRefreshRevoked?: (err: unknown) => boolean; /** * Called when a {@link TokenManagerOptions.persistence} write fails. * * Default: the failure is swallowed and the request proceeds on the * in-memory token — right when a lost write merely costs a future re-login. * It is WRONG when the service rotates single-use refresh tokens: the old one * is already spent upstream, so a new one that never reaches disk locks the * account out on the next start. **Throw from this hook to make the write * fatal**, with a message naming the recovery (freshbooks-mcp's case). */ onPersistError?: (err: unknown) => void; /** Injectable clock (defaults to `Date.now`) — for tests. */ now?: () => number; } /** * Manages a bearer access token's lifecycle: * * - **Lazy bootstrap:** with a function-form {@link TokenManagerOptions.initial} * the login runs on first use, and only if {@link TokenManagerOptions.persistence} * has no usable token — the difference between a cold start costing a login * and costing nothing. * - **Proactive:** {@link TokenManager.getAccessToken} refreshes when the token * is within `skewMs` (default 5 min) of expiry, returning a still-valid token. * - **Reactive:** {@link TokenManager.withAuth} runs a request, and on a `401` * refreshes once and replays exactly once (no infinite loop). * - **Race-safe:** concurrent refreshes (and concurrent bootstraps) coalesce * onto a single in-flight promise, so a burst of callers triggers exactly ONE * exchange. The in-flight promise is cleared on settle so a later attempt can * run again — a rejected bootstrap never sticks. * - **Recoverable:** when a refresh fails and a bootstrap function is available, * the stored credential is discarded and the login re-runs. A refresh token * revoked between two runs of the process must not brick the server. */ export declare class TokenManager { private tokens; private readonly bootstrapFn; private readonly refreshFn; private readonly skewMs; private readonly persistence; private readonly now; private readonly isRefreshRevokedFn; private readonly onPersistErrorFn; private inFlight; private bootstrapInFlight; /** * Persistence is consulted at most once per process. Without this the * revoked-token recovery below re-reads the SAME rejected record — `clear()` * is optional on {@link StatePersistence} and its failures are swallowed, so * recovery must not depend on it. After the first read the in-memory tokens * (or their deliberate absence) are the truth. */ private persistenceRead; constructor(opts: TokenManagerOptions); /** Whether the token is within the skew window of (or past) expiry. */ private needsRefresh; /** * A stored token is worth using when it is still valid, OR when it carries a * refresh token — an expired-but-refreshable token still saves the login, * which is the expensive half. */ private isUsable; /** Read persisted tokens, guarding shape and usability. Never throws. */ private loadPersisted; /** * Write tokens. Silent by default (a lost write costs a future login, not this * request); throws a {@link StatePersistenceError} when `onPersistError` does. */ private persist; /** Discard persisted tokens (a refresh they could not satisfy). Never throws. */ private clearPersisted; /** The current tokens, single-flighting the bootstrap if there are none. */ private ensureTokens; /** One bootstrap attempt: persisted tokens if usable, else the login. */ private runBootstrap; /** * Single-flight refresh. Concurrent callers share one in-flight promise; it is * cleared on settle (success or failure) so a subsequent refresh can proceed. */ refreshNow(): Promise; /** One refresh attempt against the current refresh token. */ private runRefresh; /** * Recover from a refresh the current credential could not satisfy — commonly * a refresh token restored from a previous process and revoked since. Without * a bootstrap to fall back on this is terminal; with one, re-minting beats * staying broken forever. Shared so the two entry points cannot diverge. */ private reBootstrap; /** Get a valid access token, refreshing proactively inside the skew window. */ getAccessToken(): Promise; /** Current absolute expiry (epoch ms), or `0` before the first bootstrap. */ getExpiresAt(): number; /** * Run an authenticated request with reactive 401-replay. `call` receives a * valid access token and returns a `Response`. On `401`, the token is * refreshed once and `call` is invoked again exactly once. * * Guarded against double-refresh: if a concurrent caller already rotated the * token while this request was in flight (single-flight settled and cleared), * a late `401` from a request sent under the OLD token does NOT trigger a * second refresh — under refresh-token rotation that would consume and * invalidate the freshly-issued refresh token. It just replays with the * current token. */ withAuth(call: (accessToken: string) => Promise): Promise; } /** * The minimal cookie-session shape the manager understands. Callers usually * supply a richer `S` (extra cookies, a parsed JWT, a frame id, …) — only * `cookieHeader` is structurally required, `csrfToken` is the common optional * second field (Django/Rails CSRF rotation). */ export interface CookieSession { /** The `Cookie:` request header value for authenticated calls. */ cookieHeader: string; /** A rotating CSRF token, when the site uses one (e.g. Evite's `X-CSRFToken`). */ csrfToken?: string; } /** * Options for {@link CookieSessionManager}. `S` is the caller-defined session * shape; `R` is the response type `withSession`'s `call` resolves to, defaulting * to the web {@link Response} (override it for a custom/non-fetch transport). */ export interface CookieSessionManagerOptions { /** * Site-specific login that mints a fresh cookie session. The manager owns * *when* this runs — lazily on first {@link CookieSessionManager.ensure}, * and again after {@link CookieSessionManager.invalidate} (or an expiry * detected by {@link CookieSessionManagerOptions.isExpired}). Called at most * once per concurrent burst (the in-flight promise is shared, single-flight). */ login: () => Promise; /** * Decide whether a response indicates the session expired and a re-login is * warranted. This is the injection point for body/URL heuristics: status * codes alone are insufficient — SignUpGenius serves a `200` HTML login page * on expiry, and Artsonia expires by redirecting away from the target URL. * May read the body/headers (return a promise) or just the status (sync). * When `R` is the web `Response` (the default), the body is NOT consumed for * you — if you read it, pass a clone (`res.clone()`) so the caller can still * read the original. (A custom `R` has whatever read semantics you give it.) * * **Optional.** Omit it for ensure-only consumers with no per-request expiry * path (e.g. Skylight, whose re-auth lives in {@link TokenManager}): the * default `() => false` treats every response as non-expired, so * {@link CookieSessionManager.withSession}'s replay path simply never triggers. */ isExpired?: (res: R) => boolean | Promise; /** * Distinguish a *permanent* configuration error (missing/invalid credentials) * from a *transient* login failure (network blip, 5xx, login rate-limit). A * permanent error is cached and rethrown on every subsequent {@link ensure} * (the server can never recover without new config); a transient error leaves * state unset so the next {@link ensure} retries the login. Mirrors Skylight's * `NO_ENV_CONFIG_MARKER` check. Defaults to treating ALL failures as transient * (always retry) — the safe default for sites with no config/transient split. */ isPermanentError?: (err: unknown) => boolean; /** * Proactive session TTL, in milliseconds. A session older than this (by * login/seed time) is treated as stale: the next {@link ensure} invalidates * and re-logs-in (single-flight — a stale burst coalesces onto ONE login). * Omit for reactive-only expiry via {@link isExpired}. Mirrors * infinitecampus's 5-hour `SESSION_TTL_MS` (which hand-rolled an * `ensureFresh` wrapper for exactly this) and {@link TokenManager}'s * proactive-refresh discipline. */ maxAgeMs?: number; /** Injectable clock for {@link maxAgeMs} staleness (defaults to `Date.now`) — for tests. */ now?: () => number; /** * Called when {@link CookieSessionManager.withSession}'s expiry-replay * re-login FAILS — by default that failure is swallowed and the original * expired-looking response is returned, which hides an actionable login * error (e.g. "fetchproxy bridge down — open a signed-in tab") behind a * generic 401. Use this hook to record the error (infinitecampus's * `lastLoginError` bookkeeping) or `throw err` inside it to surface the * login failure to the caller instead of the stale response. */ onReplayLoginError?: (err: unknown) => void; /** * Keep the session across process restarts. Read ONCE, on the first login * path, and written after every successful login and {@link * CookieSessionManager.seed}. {@link CookieSessionManager.invalidate} clears * it — without that, a session detected as expired would be read straight * back off disk and the expiry would loop. * * The stored envelope carries the login time alongside the session so * {@link CookieSessionManagerOptions.maxAgeMs} keeps counting from the * original login rather than restarting at the restore. Omit for the previous * in-memory-only behaviour. See {@link StatePersistence}. */ persistence?: StatePersistence>; /** * Called when a {@link CookieSessionManagerOptions.persistence} write fails. * Swallowed by default — the in-memory session is still usable. * * Where a throwing hook goes depends on which write failed. The login path * awaits its write, so the error reaches a direct {@link CookieSessionManager.ensure} * caller — but it is deliberately NOT offered to * {@link CookieSessionManagerOptions.isPermanentError} first, because caching a * disk error as a permanent config failure would brick every later `ensure()`. * * Two places it does NOT reach the caller. {@link CookieSessionManager.withSession}'s * expiry replay catches `ensure()` and returns the stale response by design, so * a "fatal" write there is dropped unless * {@link CookieSessionManagerOptions.onReplayLoginError} rethrows too. And the * `seed()`/`invalidate()` writes are fire-and-forget onto the ordering chain, * whose retained tail swallows. */ onPersistError?: (err: unknown) => void; } /** What {@link CookieSessionManagerOptions.persistence} stores: a session plus its login time. */ export interface PersistedCookieSession { session: S; /** Epoch ms the session was minted or seeded — the `maxAgeMs` clock. */ sessionAt: number; } /** * Cookie-session analog of {@link TokenManager}: owns a site's cookie-session * lifecycle with the same single-flight / replay / clear-on-settle discipline, * so the fleet's cookie-session MCPs stop re-implementing (and subtly * mis-implementing) it. Structurally prevents the audit-flagged class of bugs: * * - **Single-flight:** concurrent {@link ensure} callers coalesce onto ONE * in-flight {@link CookieSessionManagerOptions.login} (no thundering-herd * re-login against a rate-limited endpoint). * - **Clear-on-settle:** the in-flight promise is cleared whether it resolves * or rejects, so a *rejected* login never sticks — the next {@link ensure} * retries (fixes Evite's poisoned-promise H1 and Skylight's gap). * - **Exactly-one replay:** {@link withSession} re-logs-in and replays a request * AT MOST once on expiry; a persistent expiry surfaces rather than looping. * - **Heuristic expiry:** {@link CookieSessionManagerOptions.isExpired} is the * injection point for body/URL detection (SignUpGenius's 200-HTML-login-page, * Artsonia's redirect-away), not just status codes. * - **Permanent vs. transient:** only a permanent config error is cached * ({@link CookieSessionManagerOptions.isPermanentError}); transient login * failures stay retryable (Skylight's marker discipline). * * Two type parameters: `S` is the caller-defined session shape, and `R` is the * response type {@link CookieSessionManager.withSession}'s `call` resolves to, * defaulting to the web {@link Response}. The manager is response-agnostic — it * only hands `R` to {@link CookieSessionManagerOptions.isExpired} and never * inspects it — so override `R` for a custom/non-fetch transport (e.g. * Artsonia's `{ setCookie?, location?, url, body }`). Existing adopters that * write `CookieSessionManager` keep `R = Response` unchanged. * * {@link CookieSessionManagerOptions.isExpired} is **optional** and defaults to * `() => false`, so ensure-only consumers with no per-request expiry path * (Skylight) can omit it; `withSession` then simply never replays. * * Target consumers (the 5 cohort MCPs whose hand-rolled re-login this replaces): * `artsonia-mcp` (`AuthManager`), `canvas-parent-mcp` (`ensureAuth` + 401-replay, * the reference impl), `evite-mcp` (`getSession`/`reauthenticate` + CSRF), * `signupgenius-mcp` (`ensureAuth` + 401/403/200-HTML replay), and `skylight-mcp` * (`makeGetClient` single-flight + `NO_ENV_CONFIG_MARKER` caching). */ export declare class CookieSessionManager { private session; private inFlight; /** A cached *permanent* config error: once set, every `ensure` rethrows it. */ private permanentError; /** When the current session was minted (login) or installed (seed) — for maxAgeMs. */ private sessionAt; private readonly loginFn; private readonly isExpiredFn; private readonly isPermanentErrorFn; private readonly maxAgeMs; private readonly now; private readonly onReplayLoginErrorFn; private readonly persistence; private readonly onPersistErrorFn; /** Persistence is consulted once per process; a miss must not be re-read. */ private persistenceRead; /** * Serializes persistence writes. `seed()` and `invalidate()` are synchronous * by contract and so fire-and-forget their save/clear; with an async backend a * slow save could otherwise land AFTER the clear that followed it and leave an * invalidated session on disk. */ private persistChain; constructor(opts: CookieSessionManagerOptions); /** The current session, or `undefined` before the first successful login. */ get current(): S | undefined; /** True when {@link CookieSessionManagerOptions.maxAgeMs} says the session is too old. */ private isStale; /** * Return the current session, or single-flight a login if there is none. * * Concurrent callers share one in-flight login; the in-flight promise is * cleared on settle (success OR failure) so a rejected login never sticks and * the next call retries. A login error classified permanent by * {@link CookieSessionManagerOptions.isPermanentError} is cached and rethrown * on every later call; a transient error is not cached (next call retries). * With {@link CookieSessionManagerOptions.maxAgeMs} set, a session past its * TTL is invalidated first, so the call falls through to a (single-flight) * re-login. */ ensure(): Promise; /** * Install an externally-minted session (e.g. infinitecampus's CUPS linked- * district discovery, which mints sessions outside {@link * CookieSessionManagerOptions.login}). The seed becomes the current session * with a fresh {@link CookieSessionManagerOptions.maxAgeMs} clock, and any * in-flight login is DETACHED: its waiters still receive its result, but a * late resolution will not overwrite the seed. A cached permanent config * error is left intact — the login path is still misconfigured, and the next * post-seed re-login should keep saying so. */ seed(session: S): void; /** * One login attempt. Self-clears `inFlight` on settle so a rejected login * never sticks, and stamps the resulting session only while it's still the * live attempt (an `invalidate()` mid-flight cleared `inFlight` — a stale * resolution must not re-stamp the dropped session over the new state). */ private runLogin; /** * Drop the current session (and any in-flight login) so the next * {@link ensure} re-runs {@link CookieSessionManagerOptions.login}. Does NOT * clear a cached permanent config error — that only clears on a fresh process * (new config). Used to recover from a detected session expiry. */ invalidate(): void; /** * The persisted session, if there is one worth using. Read at most once per * process — after that the in-memory session (or its absence) is the truth, * so an invalidate() cannot be undone by a stale file. */ private restoreFromPersistence; /** * Append a persistence op to the chain, preserving call order. * * The RETURNED promise can reject — that is the whole `StatePersistenceError` * path, and the awaited login write depends on it. Only the retained chain is * swallowed, so one failed write cannot poison every later one. */ private enqueuePersist; /** Write the session. Silent unless `onPersistError` throws. */ private persist; /** Discard the persisted session. Never throws. */ private clearPersisted; /** * Run an authenticated `call` with the current session and reactive * expiry-replay. `call` receives the session and returns a `Response`. If * {@link CookieSessionManagerOptions.isExpired} flags the response, the * session is invalidated, a single-flight re-login runs, and `call` is * replayed EXACTLY once. A persistent expiry surfaces the (second) response * rather than looping. If the re-login itself fails, the original * (expired-looking) response is returned so the caller can surface a clean * sign-in error rather than the login failure. * * `call` resolves to `R` (the generic response type, default {@link Response}); * the manager passes it untouched to {@link CookieSessionManagerOptions.isExpired} * and returns it untouched, so a custom transport type flows through cleanly. */ withSession(call: (session: S) => Promise): Promise; } /** Construct a {@link CookieSessionManager}. */ export declare function createCookieSessionManager(opts: CookieSessionManagerOptions): CookieSessionManager; //# sourceMappingURL=index.d.ts.map