fix: make refreshOn reachable for custom AuthStrategy projects (#135) - #145
Merged
Conversation
Decouple the session-refresh + retry wiring in cli-builder.ts from mergedSessionAuth so a project on a custom AuthStrategy can opt into refresh-on-401 without a sessionAuth block (and without being force-wrapped in SessionAuthStrategy). Adds CliOptions.refreshOn / .apijack/settings.json `auth.refreshOn`, with options.refreshOn taking precedence over the existing sessionAuth.refreshOn fallback.
- Drop the now-type-only deepMergeSessionAuth import in cli-builder.ts; annotate mergedSessionAuth as SessionAuthConfig | undefined directly. - Add a custom-strategy regression test mirroring #98: when the refresh callback throws, the original 401 survives with {status, body, cause} intact. - Add the sibling settings.test.ts case for auth.refreshOn. - Reword the CLAUDE.md sentence above the new custom-strategy paragraph so it's scoped to sessionAuth.refreshOn rather than reading as a blanket claim about all strategies.
) The prior wording said sessionAuth.refreshOn only takes effect with a session.endpoint, which contradicts resolveRefreshWiring (refreshOn is read off the raw merge before the endpoint narrowing) and the corresponding unit test. It's the SessionAuthStrategy wrapping, not refreshOn, that requires session.endpoint.
garretpremo
commented
Aug 4, 2026
garretpremo
left a comment
Contributor
Author
There was a problem hiding this comment.
🤖 Automated review by claude — generated by the
review-issueskill. Treat as advisory; a human still owns the merge decision.
All six acceptance criteria from #135 are met, the change is tightly scoped to the wiring it claims to fix, and CI is green across all 11 checks. The mutation claim in the PR body reproduces exactly: reverting the createCli site alone fails 3 of the new tests, reverting the run() site alone fails 1 — so both call sites are genuinely covered rather than asserted.
Verification performed:
bun teston92d73f8— 1037 pass / 1 fail, the single failure beingtests/plugin/paths.test.tsasserting the checkout directory is namedapijack(I ran from a/tmpworktree; unrelated to this diff, and green on CI).- Mutation check on both
cli-builder.tssites, as above. - Traced every
mergedSessionAuthreference on the branch (src/cli-builder.ts:199,200,252,545,552,553,651) — the only consumers areSessionAuthStrategyconstruction andresolveRequestHeaders, both of which genuinely want the narrowed value. No third client-construction site exists, so "CLI and MCP paths" is exhaustive. - Confirmed the unconditional
onRefreshNeededis inert withoutrefreshOn: the generated client gates onthis.onRefreshNeeded && this.refreshOn?.includes(res.status)(src/codegen/client.ts:232). - Confirmed the
run()-pathctxgate is sound —ctxandsessionMgrare both gated onresolved, so thesessionMgr!assertions insiderefreshSessioncannot fire with a null manager.
Non-blocking observations
- Narrowing
mergedSessionAuthonsession?.endpointsilently disables the handshake for a typo'd config. The narrowing is the right call —resolveRequestHeadersdereferencesconfig.cookies.applyTounguarded — and it's unreachable from a fully-typed caller sinceSessionAuthConfig.session.endpointis required. ButenvConfig.sessionAutharrives from JSON as an untypedPartial, so a user who misspells the key (sessions:instead ofsession:) now falls through tooptions.authwith noSessionAuthStrategyat all, where previously they'd have gotten a loud failure fetching<baseUrl>undefined. Failing softer is arguably an improvement, but a one-line startup warning whenrawSessionAuthis present andmergedSessionAuthis not would keep the diagnosis short. settings.jsonauth.refreshOnis unvalidated.loadProjectSettingsis a bareJSON.parse+ cast, so{ "auth": { "refreshOn": 401 } }reaches the generated client and surfaces asthis.refreshOn.includes is not a functionon the first non-ok response — an error that points nowhere nearsettings.json. This matches howcustomCommands.defaults.requiresAuthis handled today, so it's a consistency call rather than a regression.- Docs cover the settings.json route but not the
createClioption. The new CLAUDE.md paragraph gives custom-strategy projects the.apijack/settings.jsonroute, which only applies to consumers on the sharedapijackbinary; a project with its ownbin/<cli>.tsopts in viacreateCli({ refreshOn: [401] }). That's documented onCliOptions.refreshOninsrc/types.ts, but a reader in CLAUDE.md won't see it.
Nitpicks
- The Project Extensions table's
.apijack/settings.jsonrow still reads "Framework defaults (see below)", and the only place that file's shape is spelled out is thecustomCommandsexample under "Opt-in auth for custom commands and dispatchers". A reader scanning for settings keys won't findauth.refreshOn, which lives several sections earlier under Session Auth. A cross-reference in either direction would help.
4 tasks
garretpremo
commented
Aug 4, 2026
garretpremo
left a comment
Contributor
Author
There was a problem hiding this comment.
🤖 Final review by claude — generated by the
final-reviewskill. CI is green, no blockers, soak window elapsed. Marking approved and merging.
Approved.
Outstanding observations from first-pass review
- Narrowing
mergedSessionAuthonsession?.endpointsilently disables the handshake for a typo'd config. The narrowing is the right call —resolveRequestHeadersdereferencesconfig.cookies.applyTounguarded — and it's unreachable from a fully-typed caller sinceSessionAuthConfig.session.endpointis required. ButenvConfig.sessionAutharrives from JSON as an untypedPartial, so a user who misspells the key (sessions:instead ofsession:) now falls through tooptions.authwith noSessionAuthStrategyat all, where previously they'd have gotten a loud failure fetching<baseUrl>undefined. Failing softer is arguably an improvement, but a one-line startup warning whenrawSessionAuthis present andmergedSessionAuthis not would keep the diagnosis short. settings.jsonauth.refreshOnis unvalidated.loadProjectSettingsis a bareJSON.parse+ cast, so{ "auth": { "refreshOn": 401 } }reaches the generated client and surfaces asthis.refreshOn.includes is not a functionon the first non-ok response — an error that points nowhere nearsettings.json. This matches howcustomCommands.defaults.requiresAuthis handled today, so it's a consistency call rather than a regression.- Docs cover the settings.json route but not the
createClioption. The new CLAUDE.md paragraph gives custom-strategy projects the.apijack/settings.jsonroute, which only applies to consumers on the sharedapijackbinary; a project with its ownbin/<cli>.tsopts in viacreateCli({ refreshOn: [401] }). That's documented onCliOptions.refreshOninsrc/types.ts, but a reader in CLAUDE.md won't see it.
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #135
Summary
sessionAuth.refreshOn(#77) was unreachable for any project that supplies its ownAuthStrategyvia.apijack/auth.ts. Both client-construction sites insrc/cli-builder.tsgated the refresh-and-retry wiring onmergedSessionAuth, which is only populated from asessionAuthblock in the env config — something a custom-strategy project doesn't have.onRefreshNeededcame outundefined, so a stale-session 401 propagated to the caller with no retry, breaking routines mid-run rather than at a step boundary.The obvious workaround made it worse: adding a
sessionAuthblock to unlockrefreshOnalso flipped the strategy ternary, wrapping the custom strategy as the base of aSessionAuthStrategyand running the/sessionhandshake twice. Custom strategy or 401 auto-retry — not both.This decouples the two.
refreshOnis now sourced independently of whetherSessionAuthStrategyis in use, andonRefreshNeededis passed whenever a refresh is possible at all (ctx.refreshSession()was already strategy-agnostic — it invalidates the cached session and re-resolves through whatever strategy is wired).refreshOnalone decides whether the retry fires. The one-retry cap and the "propagate the original error if the refresh fails" behavior from #77/#98 are untouched.What changes
New:
src/auth/refresh-wiring.tsBoth
cli-builder.tssites shared the same merge-and-gate logic; it's now one pure function so they can't drift:Narrowing
mergedSessionAuth(rather than widening what flows downstream) is deliberate:resolveRequestHeadersdereferencesconfig.cookies.applyTounguarded (src/auth/resolve-headers.ts:13), so acookies-less block must never reach it.Modified:
src/cli-builder.ts— both wiring sitesThe
createClipath (~L196-258) and therun()path (~L544-658) both call the helper and passrefreshOnthrough independently ofmergedSessionAuth:On the
run()path the ctx is nullable, so the callback stays gated onctx(bothctxandsessionMgrare gated onresolved, so thesessionMgr!assertions insiderefreshSessioncan't fire with a null manager). PassingonRefreshNeededunconditionally is inert withoutrefreshOn— the generated client gates the whole branch onthis.refreshOn?.includes(res.status)(src/codegen/client.ts:232).New opt-in surface
A custom-strategy project can now enable this with no
sessionAuthblock at all:src/types.ts—CreateCliOptions.refreshOn?: number[]src/settings.ts—ProjectSettings.auth?: { refreshOn?: number[] }bin/apijack.ts— threadsrefreshOn: projectSettings.auth?.refreshOnintocreateCli(...)Precedence: explicit
options.refreshOnwins; otherwise it falls back to the mergedsessionAuth.refreshOn, so every existing #77 config behaves identically.Docs
CLAUDE.md— the "Stale-session refresh and retry (opt-in)" section now states thatrefreshOnisn't limited toSessionAuthStrategy, shows thesettings.jsonroute, and corrects the old blanket claim that strategies not using/sessionare unaffected..apijack/settings.json's new key is documented alongside the existingcustomCommandsdefault.Acceptance criteria
AuthStrategyandrefreshOn: [401]recovers from a stale-session 401 in-process, calling the strategy'sauthenticate()once and retrying the original request oncerefreshOndoes not force a custom strategy to be wrapped inSessionAuthStrategy(no double/sessionhandshake, no mandatorycookies.extract)refreshOncombination, mirroring the SessionAuthStrategy: auto-refresh and retry once on stale-session 401/403 #77 test for theSessionAuthStrategycasecli-builder.ts(CLI and MCP paths) are coveredrefreshOnapplies to custom strategies too, and how to enable it without asessionAuthblocksessionAuth.refreshOnpathTest plan
bun test— 1038 pass / 0 fail (12 new)bun run lint— 0 errors (118 pre-existing warnings, none in changed files)tsc --noEmit— 15 errors, all pre-existing and in untouched files (src/commands/config/register.ts,src/mcp/tools/*.spec.ts); identical breakdown before and afterNew coverage:
tests/cli-builder-refresh-wiring.integration.test.ts— a customAuthStrategy(notSessionAuthStrategy) against a local HTTP server: the stale 401 triggers exactly one re-authenticate()and exactly one retry (asserted via distinctBearer token-1→token-2on the retried request), withsessionEndpointHits === 0proving no handshake was performed and nocookiesconfig supplied. Also coversoptions.refreshOnprecedence, thesessionAuthfallback, and SessionAuthStrategy: preserve original 401/403 (with refresh error ascause) when refresh callback throws #98 error propagation on the custom-strategy path ({status, body, cause}intact when the refresh callback itself throws).tests/auth/refresh-wiring.test.ts— unit coverage of the merge/narrow/precedence decision, including the endpoint-less block that must not reachresolveRequestHeaders.tests/settings.test.ts—auth.refreshOnparsing, mirroring the existingcustomCommands.defaults.requiresAuthtest.Both wiring sites were mutation-verified during review rather than assumed: reverting the
createClisite alone fails 3 of the new tests; reverting therun()site alone fails the one that exercises it (401 propagates instead of retrying). No new test passes against the unfixed code.