feat(bootstrap): add createSessionLifter for renewable session lifts - #191
Conversation
bootstrap() returns a VALUE, so consumers naturally capture a session
once at process start and hand it to their client. That is the API's
grain, not a consumer mistake, and it produces a bug that stays
invisible until the site's credential lifetime is short enough to
notice:
* The expiry dead end. A browser-backed account has no password, so
when the captured credential lapses there is nothing to re-login
with — the MCP is unauthenticated for the life of the process even
though the browser held a live session the whole time.
* The sticky startup failure. A lift that failed at boot (user not
signed in yet) is cached just as permanently, so signing in
afterwards changes nothing.
A fleet audit found four MCPs carrying the one-shot capture and four
that had independently hand-rolled the renewable shape. That split is
the argument for putting it in the library.
createSessionLifter(opts) returns a repeatable lift. Construction is
pure — no server, no listen(), no pair prompt — so it drops straight
into a session manager's `login`, and every expiry re-reads the browser.
Deliberately excluded: TTL tracking and result caching. Expiry semantics
are app-specific (some sites need the lifted token exchanged before it
is usable; others lapse on an idle timer the library cannot observe), so
the caller owns WHEN to re-lift and the lifter owns HOW. Post-processing
composes in userland instead of needing a hook. Concurrent calls are
single-flighted so two simultaneous expiries share one bridge round-trip
— de-duplication, not caching.
bootstrap() is kept and reimplemented as one invocation of a lifter, so
the two cannot drift. It stays first-class because the tool-invoked
capture pattern (a capture_session tool the user runs on demand) is
legitimately one-shot.
Verified against a live bridge: deferred construction, two independent
lifts from one lifter, and concurrent single-flighting.
Closes #183
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @chrischall's task in 2m 45s —— View job Review: PR #191 —
|
| * bridge round-trip rather than racing two. Once a lift settles the next call | ||
| * starts a fresh one — this is de-duplication, not caching. | ||
| */ | ||
| export function createSessionLifter(opts: BootstrapOpts): SessionLifter { |
There was a problem hiding this comment.
🟡 Nit: now that createSessionLifter is the recommended entry point whenever a session can expire, the file-level doc comment at the top of this file (lines 1-17) is stale — it still frames the whole module as a "one-shot session-capture helper" and only describes bootstrap()'s lifecycle, with no mention of this function. Worth a short update so a reader skimming just the header doesn't get the superseded picture.
|
🟡 Auto-review verdict: warn — The createSessionLifter implementation is correct — the single-flight/finally microtask ordering, error-recovery, and bootstrap() delegation all check out — and test coverage is thorough; only a stale file-level doc comment nit was found. |
🤖 I have created a release *beep* *boop* --- ## [1.9.0](v1.8.0...v1.9.0) (2026-08-02) ### Features * **bootstrap:** add createSessionLifter for renewable session lifts ([#191](#191)) ([b2c7049](b2c7049)) ### Bug Fixes * **cli:** catch every gate-[#2](#2) scope rejection, not just three ([#187](#187)) ([b2ecced](b2ecced)), closes [#185](#185) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…#113) The fetchproxy path captured `JSESSIONID`/`XSRF-TOKEN` once, at process start, and handed the values to the client. `JSESSIONID` is a Java servlet session with a short idle timeout, so the first lapse killed the path for the life of the process: the synthesized account has empty credentials, `verify.jsp` was therefore not an option, and `login()` raised a `permanent` `AuthFailedError` telling the user to **"restart the MCP"**. Restarting really was the only cure — which is precisely the bug. The browser held a live session the whole time and nothing ever re-read it. A lift that failed at boot was equally sticky: signing in afterwards changed nothing until restart. ## The fix `resolveAuth()` now returns a `refresh` function instead of a captured value, and the primary manager's `login` calls it on the first request **and on every expiry**. A lapsed session recovers by re-reading the browser. Same defect and same shape as `signupgenius-mcp`; tracked fleet-wide in chrischall/fetchproxy#183. When `createSessionLifter` publishes (chrischall/fetchproxy#191) this hand-rolled lifter can collapse onto it. ## On the tests Three existing tests asserted the *old* behavior — `refuses to attempt verify.jsp`, `caches the permanent no-creds error`, `refuses to retry on 401 with empty creds`. They weren't merely failing; they encoded the dead end as correct. Rather than delete them I rewrote them to hold the guarantee that still matters: - **Empty credentials are never POSTed to `verify.jsp`** — preserved and still asserted, now across a renewal. - **The permanent-error cache** still applies, but only in the genuinely unrecoverable case: no credentials *and* no lift. - Added coverage for a lift failing *during* a renewal (user signs out mid-session), which the old dead-end path had covered incidentally. 295 tests, 100% coverage, build clean. --- **Auto-review follow-up (#114) addressed:** The `fail` finding was real and mine. In fetchproxy mode the primary's login short-circuited to the lift and never ran `discoverLinkedDistricts`, while `fetchproxyDiscoveryRan` latched true after the first request. A linked district that lost its session re-authed *through the primary* into a primary that never re-seeded it — permanently broken until a restart, which is the exact failure this PR set out to remove. Env mode never had this: `mintSessionCookie()` ends with discovery, so every primary re-mint re-seeds. The lift branch now does the same, which makes the latch unnecessary — discovery runs on one condition in both modes (a primary session was minted), so no flag is left to go stale. Both nits fixed too (stale CLAUDE.md/README copy, and a test whose title still described the old dead-end). 296 tests, 100% coverage. Closes #114 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Auto-review follow-up for #191. The file-level comment still described the module as one-shot-only and never mentioned `createSessionLifter` — so the first thing a reader sees pointed at exactly the shape that PR set out to stop being the default. Rewritten to lead with the choice: `createSessionLifter` for anything whose session can expire, `bootstrap` for genuinely one-shot callers (the user-invoked `capture_session` tool pattern). Includes the *why*, since the failure mode is what makes the default matter — capturing a value once is how an MCP ends up working for one credential lifetime and then dying with no way back. Docs only; 1092 tests pass, typecheck clean. Closes #192 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…minal (#108) The fetchproxy path captured `canvas_session`/`pseudonym_credentials` once, at process start, and handed the cookie to the client. `canReauth()` returned **false** in that mode — the synthesized account has empty `username`/`password`, so there was nothing to re-mint with — which made a 401 terminal. The user was told to re-sign-in in the browser, but doing so changed nothing until the MCP restarted, because nothing ever re-read the tab. ## The fix `resolveAuth()` returns a `refresh` function instead of a captured cookie, and the client calls it on the first mint **and on every 401**. `canReauth()` now counts a lift as re-auth capability, so the existing exactly-once replay in `CookieSessionManager` finally has something to replay with. ## Severity Canvas session cookies are long-lived, so this was **latent** rather than actively biting — unlike `signupgenius-mcp` (30-minute JWT) and `infinitecampus-mcp` (servlet session idle timeout), where the same one-shot capture killed the integration outright. Fixing it here closes the pattern rather than waiting for a user to hit it. ## Scope The lift is additive — nothing else changes: | Mode | Behavior | |---|---| | `session` + lift (fetchproxy) | Re-lifts on 401, replays once | | `session` + `CANVAS_USERNAME`/`PASSWORD` | Still mints via `sessionLogin` | | `token` | Untouched — no refresh path, 401 still terminal | | `oauth` | Untouched — already re-mints | 229 tests, 100% coverage, typecheck and build clean. Part of the fleet-wide audit in chrischall/fetchproxy#183. Collapses onto `createSessionLifter` (chrischall/fetchproxy#191) once that publishes. --- **Auto-review follow-up (#109) addressed.** The `fail` findings were both real: - `tests/auth.test.ts:62` and `:118` still asserted `result.preloaded` — a field this PR removes. `expect(undefined).toBeUndefined()` passes for the wrong reason, so both tests silently stopped verifying what they were written for (that env-var paths carry no fetchproxy lift). They now assert on `refresh`. - `CLAUDE.md` documented the removed design in four places, including a "fetchproxy 401s are terminal" gotcha that is now the *opposite* of the shipped behavior. Plus the nit: the `ResolvedAuth` doc comment survived the rename half-edited, describing `refresh` as a captured cookie string rather than the lift function it is. 229 tests, 100% coverage. Closes #109 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #183.
The problem
bootstrap()returns a value, so consumers naturally capture a session once at process start and hand it to their client. That's the API's grain, not a consumer mistake — and it produces a bug that stays invisible until the site's credential lifetime is short enough to notice:The audit in #183 found four MCPs carrying the one-shot capture (
infinitecampus,groupon,setlist,canvas-parent) and four that had independently hand-rolled the renewable shape (ofw,resy,evite,zola). Four repos solving it and four not is the argument for putting it in the library.The API
Construction is pure — no server, no
listen(), no pair prompt — so it drops straight into whatever mints a session, and every expiry re-reads the browser.Answering the design questions from #183
Keep
bootstrap()or deprecate it? Kept, and reimplemented as one invocation of a lifter so the two cannot drift. It stays first-class because the tool-invoked capture pattern (vibo,honeybook: acapture_sessiontool the user runs on demand, which then persists the token) is legitimately one-shot.Should the lifter own TTL/caching? No. Expiry semantics are app-specific — some sites need the lifted token exchanged before it's usable, others lapse on an idle timer the library can't observe. The caller's session manager owns when to re-lift; the lifter owns how.
A
renewhook? Not needed — post-processing composes in userland:Adding a hook would bake app-specific auth into the bridge library for no expressiveness gain.
One thing not in the issue: concurrent calls are single-flighted, so two simultaneous expiries share one bridge round-trip rather than racing two open/close cycles for the same MCP. That's de-duplication, not caching — once a lift settles, the next call starts a fresh one.
Verification
1092 tests pass; typecheck clean. Beyond the stubs, verified against a live bridge:
Follow-on
The four affected MCPs can migrate to this once it publishes.
signupgenius-mcpandinfinitecampus-mcpalready have the renewable shape hand-rolled and will simplify onto it.