Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,15 @@ new SessionAuthStrategy(new BasicAuthStrategy(), {
});
```

When the generated client receives a status in `refreshOn`, it calls the wired refresh callback — which invalidates the cached session and re-bootstraps `/session` — then retries the original request once. Capped at one retry. Strategies that don't use `/session` are unaffected (the field is ignored).
When the generated client receives a status in `refreshOn`, it calls the wired refresh callback — which invalidates the cached session and re-bootstraps `/session` — then retries the original request once. Capped at one retry. Wrapping the strategy in `SessionAuthStrategy` requires a `session.endpoint`, but `refreshOn` itself doesn't — see below for the route that works without one.

`refreshOn` isn't limited to `SessionAuthStrategy` — a project on a custom `AuthStrategy` (`.apijack/auth.ts`) can opt in without a `sessionAuth` block at all, via `.apijack/settings.json`:

```json
{ "auth": { "refreshOn": [401] } }
```

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.

### Dropping base-strategy headers post-handshake (opt-in)

Expand Down
1 change: 1 addition & 0 deletions bin/apijack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ const cli = createCli({
specPath,
auth: authStrategy,
sessionAuth,
refreshOn: projectSettings.auth?.refreshOn,
generatedDir,
allowedCidrs: projectConfig?.allowedCidrs,
defaultUrl: projectConfig?.defaultUrl,
Expand Down
40 changes: 40 additions & 0 deletions src/auth/refresh-wiring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { SessionAuthConfig } from './types';
import type { EnvironmentConfig } from '../config';
import { deepMergeSessionAuth } from './config-merge';

export interface RefreshWiringOptions {
sessionAuth?: SessionAuthConfig;
refreshOn?: number[];
}

export interface RefreshWiring {
/** Only set when the merged sessionAuth block defines a handshake endpoint —
* this is what drives SessionAuthStrategy construction and resolveRequestHeaders. */
mergedSessionAuth: SessionAuthConfig | undefined;
/** Statuses that trigger a one-shot session refresh + retry, for ANY strategy. */
refreshOn: number[] | undefined;
}

/**
* Decides the session-auth merge and refresh-retry wiring shared by both
* cli-builder.ts client-construction sites (the createCli routine-runtime path
* and the run() path). Kept pure so both sites stay in lockstep (#135).
*
* `options.refreshOn` (from CliOptions / .apijack/settings.json) takes
* precedence over `sessionAuth.refreshOn`, so a project can opt a custom
* AuthStrategy into refresh-on-401 without ever defining a `sessionAuth` block.
*/
export function resolveRefreshWiring(
options: RefreshWiringOptions,
envConfig: Pick<EnvironmentConfig, 'sessionAuth'> | null | undefined,
): RefreshWiring {
const rawSessionAuth = options.sessionAuth
? deepMergeSessionAuth(options.sessionAuth, envConfig?.sessionAuth)
: undefined;
// Only a block that actually defines a handshake endpoint drives SessionAuthStrategy
// construction and request-header resolution.
const mergedSessionAuth = rawSessionAuth?.session?.endpoint ? rawSessionAuth : undefined;
const refreshOn = options.refreshOn ?? rawSessionAuth?.refreshOn;

return { mergedSessionAuth, refreshOn };
}
22 changes: 10 additions & 12 deletions src/cli-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import { registerRoutineCommand, loadBuiltinRoutines } from './commands/routine/
import { prompt, hiddenPrompt } from './prompt';
import { SessionAuthStrategy } from './auth/session-auth';
import { resolveRequestHeaders } from './auth/resolve-headers';
import { deepMergeSessionAuth } from './auth/config-merge';
import { resolveRefreshWiring } from './auth/refresh-wiring';
import type { SessionAuthConfig } from './auth/types';
import { loadPreRequestHook } from './pre-request';
import type { RoutineResult } from './routine/executor';
import { executeRoutine } from './routine/executor';
Expand Down Expand Up @@ -195,9 +196,7 @@ export function createCli(options: CliOptions): Cli {

// 3. Compute auth strategy + sessionMgr.
const envConfig = getActiveEnvConfig(cliName, configOpts);
const mergedSessionAuth = options.sessionAuth
? deepMergeSessionAuth(options.sessionAuth, envConfig?.sessionAuth)
: undefined;
const { mergedSessionAuth, refreshOn } = resolveRefreshWiring(options, envConfig);
const strategy = mergedSessionAuth
? new SessionAuthStrategy(options.auth, mergedSessionAuth)
: options.auth;
Expand Down Expand Up @@ -255,8 +254,8 @@ export function createCli(options: CliOptions): Cli {
const client = new ApiClientClass(
resolved.baseUrl ?? '',
getHeaders,
mergedSessionAuth ? async () => { await ctx.refreshSession(); } : undefined,
mergedSessionAuth?.refreshOn,
async () => { await ctx.refreshSession(); },
refreshOn,
) as Record<string, unknown>;

ctx.client = client;
Expand Down Expand Up @@ -543,15 +542,14 @@ export function createCli(options: CliOptions): Cli {
}

// 5. Compute auth strategy (no network — just config)
let mergedSessionAuth: ReturnType<typeof deepMergeSessionAuth> | undefined;
let mergedSessionAuth: SessionAuthConfig | undefined;
let refreshOn: number[] | undefined;
let strategy = options.auth;
let sessionMgr: SessionManager | null = null;

if (resolved) {
const envConfig = getActiveEnvConfig(cliName, configOpts);
mergedSessionAuth = options.sessionAuth
? deepMergeSessionAuth(options.sessionAuth, envConfig?.sessionAuth)
: undefined;
({ mergedSessionAuth, refreshOn } = resolveRefreshWiring(options, envConfig));
strategy = mergedSessionAuth
? new SessionAuthStrategy(options.auth, mergedSessionAuth)
: options.auth;
Expand Down Expand Up @@ -656,8 +654,8 @@ export function createCli(options: CliOptions): Cli {
const client = new ApiClientClass(
resolved?.baseUrl ?? '',
getHeaders,
mergedSessionAuth ? async () => { await ctx!.refreshSession(); } : undefined,
mergedSessionAuth?.refreshOn,
ctx ? async () => { await ctx!.refreshSession(); } : undefined,
refreshOn,
) as Record<string, unknown>;

if (ctx) ctx.client = client;
Expand Down
5 changes: 5 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export interface ProjectSettings {
requiresAuth?: boolean;
};
};
auth?: {
/** HTTP statuses that trigger a one-shot session refresh + retry, for
* any auth strategy. See `CliOptions.refreshOn`. */
refreshOn?: number[];
};
}

export function loadProjectSettings(apijackDir: string): ProjectSettings {
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export interface CliOptions {
specPath: string;
auth: AuthStrategy;
sessionAuth?: SessionAuthConfig;
/** HTTP statuses that trigger a one-shot session refresh + retry on the
* generated client, for ANY auth strategy (not just SessionAuthStrategy).
* Takes precedence over `sessionAuth.refreshOn` when both are set. Lets a
* project with a custom AuthStrategy opt in without a `sessionAuth` block. */
refreshOn?: number[];
outputModes?: string[];
generatedDir?: string;
knownSites?: Record<string, { url: string; description: string; group?: string }>;
Expand Down
68 changes: 68 additions & 0 deletions tests/auth/refresh-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, test, expect } from 'bun:test';
import { resolveRefreshWiring } from '../../src/auth/refresh-wiring';
import type { SessionAuthConfig } from '../../src/auth/types';

const fullSessionAuth: SessionAuthConfig = {
session: { endpoint: '/session' },
cookies: { extract: ['SESSION'], applyTo: ['POST', 'PUT', 'DELETE'] },
refreshOn: [401, 403],
};

describe('resolveRefreshWiring', () => {
test('no sessionAuth, no refreshOn — both undefined', () => {
const result = resolveRefreshWiring({}, undefined);
expect(result.mergedSessionAuth).toBeUndefined();
expect(result.refreshOn).toBeUndefined();
});

test('options.refreshOn alone (no sessionAuth block) — refreshOn set, mergedSessionAuth stays undefined', () => {
const result = resolveRefreshWiring({ refreshOn: [401] }, undefined);
expect(result.mergedSessionAuth).toBeUndefined();
expect(result.refreshOn).toEqual([401]);
});

test('sessionAuth with endpoint — mergedSessionAuth populated, refreshOn falls back to sessionAuth.refreshOn', () => {
const result = resolveRefreshWiring({ sessionAuth: fullSessionAuth }, undefined);
expect(result.mergedSessionAuth).toEqual(fullSessionAuth);
expect(result.refreshOn).toEqual([401, 403]);
});

test('options.refreshOn takes precedence over sessionAuth.refreshOn', () => {
const result = resolveRefreshWiring(
{ sessionAuth: fullSessionAuth, refreshOn: [401] },
undefined,
);
expect(result.refreshOn).toEqual([401]);
// mergedSessionAuth is untouched by the precedence rule.
expect(result.mergedSessionAuth?.refreshOn).toEqual([401, 403]);
});

test('envConfig.sessionAuth merges into options.sessionAuth as usual', () => {
const result = resolveRefreshWiring(
{ sessionAuth: fullSessionAuth },
{ sessionAuth: { session: { endpoint: '/auth/session' } } },
);
expect(result.mergedSessionAuth?.session.endpoint).toBe('/auth/session');
expect(result.mergedSessionAuth?.cookies).toEqual(fullSessionAuth.cookies);
});

test('a sessionAuth block without session.endpoint does not populate mergedSessionAuth (guards resolveRequestHeaders)', () => {
// Not expressible through the SessionAuthConfig type from a fully-typed
// caller, but envConfig.sessionAuth is only a Partial<SessionAuthConfig> —
// a JS/dynamic caller could still hand cli-builder a refreshOn-only block.
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]);
});

test('does not mutate inputs', () => {
const sessionAuthCopy = JSON.parse(JSON.stringify(fullSessionAuth));
resolveRefreshWiring(
{ sessionAuth: fullSessionAuth },
{ sessionAuth: { cookies: { applyTo: ['*'] } } },
);
expect(fullSessionAuth).toEqual(sessionAuthCopy);
});
});
Loading