Add Step-up rechallenge implementation#2869
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Also extracts API_HOST/API_URL/PRODUCTION_API_HOST into a leaf constants module and lazy-requires the rechallenge link inside API() to break a circular-dependency cycle that caused Jest mocks to misfire in the rechallenge test suite.
Implements `vip defensive-mode configure` with full flag validation, interactive prompting for missing required flags, and non-interactive hard-error mode.
…e guards, telemetry order, teardown race) - configure: log current effective config and proposed input before mutating (Fix 1) - enable/disable: add --non-interactive option and guard; error on production mutation attempted non-interactively without --skip-confirmation (Fix 2) - flow: add clientType=cli to rechallenge_required event (Fix 3) - flow: fire rechallenge_verified before rechallenge_exchanged to match spec order (Fix 4) - link: split innerSub into firstSub/retrySub to eliminate teardown race during async gap (Fix 5) - enable/disable/configure: use console.error for error-path chalk.red messages (Fix 6) - token-cache: document single-blob keychain strategy (Fix 7) - tests: assert proposed config logged in configure; add non-interactive-production exit tests for enable/disable; assert rechallenge_verified fires before rechallenge_exchanged Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
There was a problem hiding this comment.
Pull request overview
This PR introduces a new “step-up / rechallenge” elevated-permissions flow that integrates with the existing Apollo GraphQL client (via a custom ApolloLink), adds an elevated-token cache, and wires the flow into logout. It also adds a new vip defensive-mode CLI command group backed by new defensive-mode GraphQL mutations, along with a broad suite of unit tests around these behaviors.
Changes:
- Add rechallenge (step-up) client, flow orchestration, ApolloLink integration, error types, and elevated-token keychain cache.
- Add defensive-mode GraphQL mutation helpers and new CLI commands (
vip defensive-mode enable|disable|configure). - Add unit tests for rechallenge components, defensive-mode API, logout behavior, and defensive-mode command behavior.
Reviewed changes
Copilot reviewed 22 out of 30 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/token.ts | Switches API host/constants import to avoid pulling full api.ts graph. |
| src/lib/rechallenge/types.ts | Defines rechallenge protocol/types and constants. |
| src/lib/rechallenge/token-cache.ts | Implements keychain-backed elevated-token cache keyed by scope. |
| src/lib/rechallenge/open-browser.ts | Adds wrapper around ESM-only open for browser launching. |
| src/lib/rechallenge/link.ts | Adds ApolloLink that pre-attaches cached elevated token and runs rechallenge on elevated-permission errors. |
| src/lib/rechallenge/index.ts | Exposes rechallenge module exports. |
| src/lib/rechallenge/flow.ts | Implements rechallenge orchestration (session create → poll → exchange → cache). |
| src/lib/rechallenge/errors.ts | Adds typed rechallenge error classes. |
| src/lib/rechallenge/client.ts | Adds HTTP client for rechallenge session/status/exchange endpoints. |
| src/lib/logout.ts | Clears elevated-token cache on logout in addition to primary token purge. |
| src/lib/defensive-mode/api.ts | Adds GraphQL mutations/helpers to update defensive-mode status/config. |
| src/lib/api/http.ts | Switches API_HOST import to new constants module (reduces circular deps). |
| src/lib/api/feature-flags.ts | Lazy-initializes Apollo client to break a circular dependency chain. |
| src/lib/api/constants.ts | Introduces shared API host/URL constants (re-exported by api.ts). |
| src/lib/api.ts | Re-exports constants and injects rechallenge link via lazy require() to avoid Jest mock timing issues. |
| src/bin/vip.js | Registers the new defensive-mode top-level CLI command. |
| src/bin/vip-defensive-mode.js | Adds vip defensive-mode parent command and subcommand wiring. |
| src/bin/vip-defensive-mode-enable.js | Adds vip defensive-mode enable command implementation. |
| src/bin/vip-defensive-mode-disable.js | Adds vip defensive-mode disable command implementation. |
| src/bin/vip-defensive-mode-configure.js | Adds vip defensive-mode configure command implementation with flag validation/prompting. |
| package.json | Adds new vip-defensive-mode* bin entries for distribution. |
| tests/lib/rechallenge/token-cache.test.ts | Unit tests for elevated-token cache behavior and corruption handling. |
| tests/lib/rechallenge/link.test.ts | Unit tests for rechallenge ApolloLink preflight, retry, and error propagation. |
| tests/lib/rechallenge/flow.test.ts | Unit tests for rechallenge flow polling/exchange/caching and interactivity detection. |
| tests/lib/rechallenge/client.test.ts | Unit tests for rechallenge HTTP client request shapes and non-2xx error handling. |
| tests/lib/logout.test.ts | Verifies logout clears both primary token and elevated-token cache + telemetry. |
| tests/lib/defensive-mode/api.test.ts | Verifies defensive-mode mutation input formatting and optional field omission. |
| tests/bin/vip-defensive-mode-enable.js | Tests command registration and enable behavior (including non-interactive production safety). |
| tests/bin/vip-defensive-mode-disable.js | Tests command registration and disable behavior (including non-interactive production safety). |
| tests/bin/vip-defensive-mode-configure.js | Tests configure flag validation, non-interactive behavior, logging, and telemetry. |
…ad validation, dedupe commands) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| console.log( JSON.stringify( currentConfig, null, 2 ) ); | ||
| } | ||
| console.log( chalk.bold( 'Proposed defensive-mode configuration:' ) ); | ||
| console.log( JSON.stringify( input, null, 2 ) ); |
There was a problem hiding this comment.
Maybe make the output more user-friendly?
There was a problem hiding this comment.
Reworked in 5a7ca83: current/proposed values now render as a settings table (via the existing table() helper), and the JSON blob was dropped from the production confirm prompt.
| await http( '/logout', { method: 'post' } ); | ||
|
|
||
| await Token.purge(); | ||
| await tokenCache.clearAll(); |
There was a problem hiding this comment.
Should we do the same on login?
Elevated tokens are keyed only by the API host and operation scope. Logout clears them, but direct vip login token replacement does not, so cached elevation can cross user identities on the same machine.
This is not a very likely scenario, but I think it makes sense to apply this hardening.
There was a problem hiding this comment.
Applied in 5a7ca83: vip login now calls tokenCache.clearAll() right after Token.set() replaces the stored token. That's the only Token.set call site, so login and logout now both clear cached elevation.
| "vip-defensive-mode": "dist/bin/vip-defensive-mode.js", | ||
| "vip-defensive-mode-configure": "dist/bin/vip-defensive-mode-configure.js", | ||
| "vip-defensive-mode-disable": "dist/bin/vip-defensive-mode-disable.js", | ||
| "vip-defensive-mode-enable": "dist/bin/vip-defensive-mode-enable.js", |
There was a problem hiding this comment.
I guess we need to update src/lib/cli/internal-bin-loader.js as well.
There was a problem hiding this comment.
Added in 5a7ca83. Diffed package.json bin keys against the loader — the four vip-defensive-mode* entries were the only missing ones.
| return new Promise( ( resolve, reject ) => { | ||
| if ( signal?.aborted ) { | ||
| reject( new Error( 'aborted' ) ); | ||
| return; | ||
| } | ||
| const timer = setTimeout( () => { | ||
| signal?.removeEventListener( 'abort', onAbort ); | ||
| resolve(); | ||
| }, ms ); | ||
| function onAbort() { | ||
| clearTimeout( timer ); | ||
| reject( new Error( 'aborted' ) ); | ||
| } | ||
| signal?.addEventListener( 'abort', onAbort, { once: true } ); | ||
| } ); |
There was a problem hiding this comment.
import { setTimeout } from 'node:timers/promises';| return new Promise( ( resolve, reject ) => { | |
| if ( signal?.aborted ) { | |
| reject( new Error( 'aborted' ) ); | |
| return; | |
| } | |
| const timer = setTimeout( () => { | |
| signal?.removeEventListener( 'abort', onAbort ); | |
| resolve(); | |
| }, ms ); | |
| function onAbort() { | |
| clearTimeout( timer ); | |
| reject( new Error( 'aborted' ) ); | |
| } | |
| signal?.addEventListener( 'abort', onAbort, { once: true } ); | |
| } ); | |
| try { | |
| await setTimeout( ms, undefined, { signal } ); | |
| } catch ( err ) { | |
| if ( err.name === 'AbortError' ) { | |
| throw new Error( 'aborted' ); | |
| } else { | |
| throw err; | |
| } | |
| } |
There was a problem hiding this comment.
Done in 5a7ca83: the hand-rolled sleep() is replaced with setTimeout from node:timers/promises (with { signal }). The caller already converts any sleep rejection to RechallengeAbortedError, so the AbortError branch lives there.
| } | ||
|
|
||
| function parsePositiveInt( raw ) { | ||
| if ( raw === undefined || raw === null || raw === '' ) { |
There was a problem hiding this comment.
| if ( raw === undefined || raw === null || raw === '' ) { | |
| if ( raw === undefined || raw === null || raw === '' || typeof raw === 'boolean' ) { |
--connection-threshold-absolute without parameters is treated like --connection-threshold-absolute=true; Number( true ) coerces to 1. I am not sure if it is OK.
There was a problem hiding this comment.
Fixed in 5a7ca83: parsePositiveInt now rejects booleans (bare flags) and blank/whitespace strings, so --connection-threshold-absolute without a value errors instead of coercing to 1. Added a regression test.
…bin loader, sleep, flag parsing) - Render current/proposed defensive-mode config as a table instead of raw JSON, and drop the JSON blob from the production confirm prompt - Clear the elevated-token cache when `vip login` replaces the stored token, so cached elevation cannot carry across user identities - Register the four vip-defensive-mode* bins in internal-bin-loader.js - Replace the hand-rolled abortable sleep in rechallenge flow with setTimeout from node:timers/promises - Reject boolean/blank values in parsePositiveInt so bare flags like `--connection-threshold-absolute` error instead of coercing to 1 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The loop polls session status sequentially by design; each iteration must finish before the next starts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e client trunk replaced node-fetch with undici (#2837), so in the PR merge ref the node-fetch types no longer resolve and type-aware lint flags every member access as unsafe. Derive the response type from http() instead, which tracks whichever fetch implementation the merged tree uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|



Description
This pull request adds a comprehensive suite of unit tests for the new "rechallenge" (elevated permissions) flow and related features. The tests cover the rechallenge client, flow orchestration, Apollo GraphQL link integration, defensive mode API, and logout logic, ensuring that the elevated permissions workflow is robust, correctly integrated, and well-covered by automated tests.
Rechallenge/Elevated Permissions Flow Testing:
__tests__/lib/rechallenge/client.test.ts).__tests__/lib/rechallenge/flow.test.ts).__tests__/lib/rechallenge/link.test.ts).Defensive Mode API Testing:
updateDefensiveModeStatusandupdateDefensiveModeConfigAPI calls, checking input formatting and omission of optional fields (__tests__/lib/defensive-mode/api.test.ts).Logout Flow Testing:
__tests__/lib/logout.test.ts).Changelog Description
Added
Removed
Fixed
Changed
Pull request checklist
New release checklist
Steps to Test
Outline the steps to test and verify the PR here.
Example:
npm run build./dist/bin/vip-cookies.js nom