diff --git a/CLAUDE.md b/CLAUDE.md index 2c5ba23..3c3ec47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,6 +224,8 @@ When the generated client receives a status in `refreshOn`, it calls the wired r The refresh callback re-invokes the custom strategy's `authenticate()` rather than re-bootstrapping `/session`. `settings.json` `auth.refreshOn` takes precedence over `sessionAuth.refreshOn` when both are set. +`.apijack/settings.json` only applies to consumers of the shared `apijack` binary; a project with its own `bin/.ts` sets the same option programmatically instead: `createCli({ refreshOn: [401], /* ... */ })`. + ### Dropping base-strategy headers post-handshake (opt-in) By default, `SessionAuthStrategy` mirrors the wrapped base strategy's headers (e.g. `Authorization: Basic …` from `BasicAuthStrategy`) onto every post-handshake API request. For stateful backends — Spring Security with 2FA, for instance — re-presenting the base credentials on every call re-triggers the auth filter and either re-prompts, 401s, or invalidates the active session. Opt in to `dropBaseHeaders` to strip them: @@ -250,7 +252,7 @@ The `.apijack/` directory at a project root is auto-loaded when the CLI runs ins | `.apijack/auth.ts` | Project-level `AuthStrategy` and optional `onChallenge` | | `.apijack/plugins.ts` | Project-level plugin registrations (`default: ApijackPlugin[]` — each entry passed to `cli.use(...)`) | | `.apijack/routines/*.yaml` | Routines available via `routine run ` | -| `.apijack/settings.json` | Framework defaults (see below) | +| `.apijack/settings.json` | Framework defaults (see below; also `auth.refreshOn` — see "Stale-session refresh and retry" above) | | `.apijack/aliases.json` | Project-local command aliases (see below) | ### Command aliases diff --git a/src/auth/refresh-wiring.ts b/src/auth/refresh-wiring.ts index 8b22a9c..e962ad2 100644 --- a/src/auth/refresh-wiring.ts +++ b/src/auth/refresh-wiring.ts @@ -36,5 +36,11 @@ export function resolveRefreshWiring( const mergedSessionAuth = rawSessionAuth?.session?.endpoint ? rawSessionAuth : undefined; const refreshOn = options.refreshOn ?? rawSessionAuth?.refreshOn; + if (rawSessionAuth && !mergedSessionAuth) { + console.warn( + '[apijack] sessionAuth is set but missing session.endpoint — SessionAuthStrategy will not be used.', + ); + } + return { mergedSessionAuth, refreshOn }; } diff --git a/tests/auth/refresh-wiring.test.ts b/tests/auth/refresh-wiring.test.ts index c9acce3..0f0c6d7 100644 --- a/tests/auth/refresh-wiring.test.ts +++ b/tests/auth/refresh-wiring.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from 'bun:test'; +import { describe, test, expect, spyOn } from 'bun:test'; import { resolveRefreshWiring } from '../../src/auth/refresh-wiring'; import type { SessionAuthConfig } from '../../src/auth/types'; @@ -50,11 +50,17 @@ describe('resolveRefreshWiring', () => { // Not expressible through the SessionAuthConfig type from a fully-typed // caller, but envConfig.sessionAuth is only a Partial — // a JS/dynamic caller could still hand cli-builder a refreshOn-only block. + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); const refreshOnlySessionAuth = { refreshOn: [401] } as unknown as SessionAuthConfig; - const result = resolveRefreshWiring({ sessionAuth: refreshOnlySessionAuth }, undefined); - expect(result.mergedSessionAuth).toBeUndefined(); - // refreshOn still surfaces from the raw (unguarded) merge. - expect(result.refreshOn).toEqual([401]); + + try { + const result = resolveRefreshWiring({ sessionAuth: refreshOnlySessionAuth }, undefined); + expect(result.mergedSessionAuth).toBeUndefined(); + // refreshOn still surfaces from the raw (unguarded) merge. + expect(result.refreshOn).toEqual([401]); + } finally { + warnSpy.mockRestore(); + } }); test('does not mutate inputs', () => { @@ -65,4 +71,41 @@ describe('resolveRefreshWiring', () => { ); expect(fullSessionAuth).toEqual(sessionAuthCopy); }); + + describe('missing session.endpoint diagnostic warning', () => { + test('warns when sessionAuth is set but has no session.endpoint', () => { + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + const refreshOnlySessionAuth = { refreshOn: [401] } as unknown as SessionAuthConfig; + + try { + resolveRefreshWiring({ sessionAuth: refreshOnlySessionAuth }, undefined); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]![0]).toContain('session.endpoint'); + } finally { + warnSpy.mockRestore(); + } + }); + + test('does not warn when there is no sessionAuth at all', () => { + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + try { + resolveRefreshWiring({ refreshOn: [401] }, undefined); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + test('does not warn when sessionAuth has a session.endpoint', () => { + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + try { + resolveRefreshWiring({ sessionAuth: fullSessionAuth }, undefined); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + }); });