From 7616b5dcacc744f64869abb6dc4ad8ff549cad23 Mon Sep 17 00:00:00 2001 From: "Shaun Yang (Openclaw Bot)" Date: Wed, 11 Mar 2026 07:30:06 +0000 Subject: [PATCH 1/7] feat: add OAuth device authorization grant flow to nansen login --- CHANGELOG.md | 13 +++++ src/api.js | 74 ++++++++++++++++++++++++-- src/cli.js | 144 +++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 204 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c42336..8cb3a50d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.17.0 + +### Minor Changes + +- Add RFC 8628 OAuth Device Authorization Grant as the new default login flow. + + - `nansen login` opens a browser, displays a user code, and polls until approved — no manual key copy-paste. + - `nansen login --api-key ` continues to work unchanged for CI and scripting. + - API calls transparently refresh OAuth tokens 60 s before expiry via `_ensureFreshToken()`. + - `nansen logout` clears OAuth tokens as well as legacy API keys. + - `getAuthBaseUrl()` helper exported from `api.js` derives the superapp URL from the API base URL. + - `Authorization: Bearer` header used for OAuth sessions; `apikey` header used for legacy API key sessions (both coexist cleanly). + ## 1.16.1 ### Patch Changes diff --git a/src/api.js b/src/api.js index 414ac7c2..6144d458 100644 --- a/src/api.js +++ b/src/api.js @@ -158,6 +158,22 @@ export function deleteConfig() { return false; } +/** + * Derive the superapp base URL from the API base URL. + * e.g. https://api.nansen.ai → https://app.nansen.ai + * @param {string} baseUrl - The API base URL + * @returns {string} The auth base URL + */ +export function getAuthBaseUrl(baseUrl) { + try { + const u = new URL(baseUrl); + u.hostname = u.hostname.replace(/^api\./, 'app.'); + return u.origin; + } catch { + return 'https://app.nansen.ai'; + } +} + // ============= Response Cache ============= const CACHE_DIR = path.join(CONFIG_DIR, 'cache'); @@ -294,7 +310,7 @@ export function validateTokenAddress(tokenAddress, chain = 'solana') { return validateAddress(tokenAddress, chain); } -function loadConfig() { +export function loadConfig() { // Base config from files, then env vars override individual fields let config = null; @@ -312,7 +328,7 @@ function loadConfig() { } if (!config) { - config = { apiKey: null, baseUrl: 'https://api.nansen.ai' }; + config = { apiKey: null, baseUrl: 'https://api.nansen.ai', accessToken: null, refreshToken: null, tokenExpiry: 0 }; } // Ensure baseUrl default (config file from older versions may omit it) @@ -401,6 +417,9 @@ export class NansenAPI { constructor(apiKey = config.apiKey, baseUrl = config.baseUrl, options = {}) { this.apiKey = apiKey || null; this.baseUrl = baseUrl; + this.accessToken = config.accessToken || null; + this.refreshToken = config.refreshToken || null; + this.tokenExpiry = config.tokenExpiry || 0; this.retryOptions = { ...DEFAULT_RETRY_OPTIONS, ...options.retry }; this.cacheOptions = { enabled: options.cache?.enabled ?? false, @@ -409,6 +428,45 @@ export class NansenAPI { this.defaultHeaders = options.defaultHeaders || {}; } + /** + * Refresh the OAuth access token if it is about to expire (within 60s). + * Clears credentials and throws NansenError if the refresh fails. + */ + async _ensureFreshToken() { + if (!this.accessToken) return; + if (this.tokenExpiry > Date.now() + 60_000) return; + + const authBase = getAuthBaseUrl(this.baseUrl); + const res = await fetch(`${authBase}/auth/device/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: this.refreshToken }) + }); + + if (!res.ok) { + this.accessToken = null; + this.refreshToken = null; + this.tokenExpiry = 0; + throw new NansenError( + 'Session expired. Run `nansen login` to re-authenticate.', + ErrorCode.UNAUTHORIZED, + 401 + ); + } + + const data = await res.json(); + this.accessToken = data.access_token; + this.refreshToken = data.refresh_token; + this.tokenExpiry = Date.now() + (data.expires_in * 1000); + + saveConfig({ + ...loadConfig(), + accessToken: this.accessToken, + refreshToken: this.refreshToken, + tokenExpiry: this.tokenExpiry + }); + } + static cleanBody(body) { return Object.fromEntries( Object.entries(body).filter(([_, v]) => @@ -465,10 +523,12 @@ export class NansenAPI { } async request(endpoint, body = {}, options = {}) { + await this._ensureFreshToken(); + const url = `${this.baseUrl}${endpoint}`; const { maxRetries, baseDelayMs, maxDelayMs, retryOnStatus } = this.retryOptions; const shouldRetry = options.retry !== false; // Allow disabling retry per-request - + // Check cache first (if enabled and not bypassed) const useCache = options.cache !== false && this.cacheOptions.enabled; const cacheTtl = options.cacheTtl ?? this.cacheOptions.ttl; @@ -493,7 +553,9 @@ export class NansenAPI { ...(!isGet && { 'Content-Type': 'application/json' }), 'X-Client-Type': 'nansen-cli', 'X-Client-Version': packageVersion, - ...(this.apiKey ? { 'apikey': this.apiKey } : {}), + ...(this.accessToken + ? { 'Authorization': `Bearer ${this.accessToken}` } + : this.apiKey ? { 'apikey': this.apiKey } : {}), ...this.defaultHeaders, ...options.headers }, @@ -543,7 +605,9 @@ export class NansenAPI { const retryAfterMs = parseRetryAfter(response.headers.get('retry-after')); // Enhance messages for specific error codes - if (code === ErrorCode.UNSUPPORTED_FILTER) { + if (code === ErrorCode.UNAUTHORIZED) { + message = 'Authentication failed. Run `nansen login` to authenticate (OAuth), or `nansen login --api-key ` for API key auth.'; + } else if (code === ErrorCode.UNSUPPORTED_FILTER) { message = message.replace(/\.+$/, '') + '. This filter is not supported for this token/chain combination. Do not retry.'; } else if (code === ErrorCode.CREDITS_EXHAUSTED) { message = message.replace(/\.+$/, '') + '. No retry will help. Check your Nansen dashboard for credit balance.'; diff --git a/src/cli.js b/src/cli.js index 6e26d21f..c0c611c2 100644 --- a/src/cli.js +++ b/src/cli.js @@ -3,7 +3,7 @@ * Extracted from index.js for coverage */ -import { NansenAPI, NansenError, ErrorCode, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, sleep } from './api.js'; +import { NansenAPI, NansenError, ErrorCode, loadConfig, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, sleep, getAuthBaseUrl } from './api.js'; import { buildWalletCommands } from './wallet.js'; import { buildTradingCommands } from './trading.js'; import { resolveAddress, isEnsName } from './ens.js'; @@ -671,7 +671,7 @@ COMMANDS: trade quote, execute wallet create, list, show, export, default, delete, forget-password account Show API key status, plan, and remaining credits - login Save API key (--api-key or NANSEN_API_KEY env var) + login Authenticate via browser (OAuth) or --api-key for legacy key logout Remove saved API key schema JSON schema for all commands (use "nansen schema " for one) cache clear @@ -750,6 +750,7 @@ export function buildCommands(deps = {}) { log = console.log, errorOutput: _errorOutput = console.error, NansenAPIClass: _NansenAPIClass = NansenAPI, + loadConfigFn = loadConfig, saveConfigFn = saveConfig, deleteConfigFn = deleteConfig, getConfigFileFn = getConfigFile, @@ -764,14 +765,15 @@ export function buildCommands(deps = {}) { 'login': async (args, apiInstance, flags, options) => { if (flags.help || flags.h) { - log('nansen login - Save your Nansen API key\n'); + log('nansen login - Authenticate with Nansen\n'); log('USAGE:'); - log(' nansen login --api-key '); - log(' NANSEN_API_KEY= nansen login'); - log(' nansen login --human (interactive prompt)\n'); + log(' nansen login (OAuth device flow — opens browser)'); + log(' nansen login --api-key (legacy API key)'); + log(' NANSEN_API_KEY= nansen login (legacy API key via env)'); + log(' nansen login --human (interactive prompt for API key)\n'); log('OPTIONS:'); - log(' --api-key Your Nansen API key'); - log(' --human Enable interactive prompt'); + log(' --api-key Your Nansen API key (legacy)'); + log(' --human Enable interactive prompt for API key'); log(' --help Show this help\n'); log('Get your API key at: https://app.nansen.ai/api'); return; @@ -797,34 +799,132 @@ export function buildCommands(deps = {}) { apiKey = await promptFn('Enter your API key: ', true); } - if (!apiKey || apiKey.trim().length === 0) { - log(JSON.stringify({ - error: 'API_KEY_REQUIRED', - message: 'No API key provided.', - resolution: [ - 'Run: nansen login --api-key ', - 'Or set NANSEN_API_KEY environment variable', - 'Get your API key at: https://app.nansen.ai/api', - ], - })); + if (apiKey && apiKey.trim().length > 0) { + saveConfigFn({ + apiKey: apiKey.trim(), + baseUrl: 'https://api.nansen.ai' + }); + + log(`✓ Saved to ${getConfigFileFn()}\n`); + log('You can now use the Nansen CLI. Try:'); + log(' nansen research token screener --chain solana --pretty'); + return; + } + + // Default: OAuth device flow + const currentConfig = loadConfigFn(); + const authBase = getAuthBaseUrl(currentConfig.baseUrl || 'https://api.nansen.ai'); + + // Step 1: initiate + let authorizeData; + try { + const authorizeRes = await fetch(`${authBase}/auth/device/authorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + if (!authorizeRes.ok) throw new Error(`HTTP ${authorizeRes.status}`); + authorizeData = await authorizeRes.json(); + } catch (err) { + log(JSON.stringify({ error: 'DEVICE_AUTHORIZE_FAILED', message: `Failed to start device authorization: ${err.message}` })); + exit(1); + return; + } + + const { device_code, user_code, verification_uri, interval = 5, expires_in = 300 } = authorizeData; + + log('\nNansen CLI Login\n'); + log(`1. Open: ${verification_uri}`); + log(`2. Enter code: ${user_code}\n`); + + // Try to open browser (best effort, silent on failure) + try { + const { exec: execChild } = await import('child_process'); + const opener = process.platform === 'darwin' ? 'open' + : process.platform === 'win32' ? 'start' + : 'xdg-open'; + execChild(`${opener} "${verification_uri}"`); + } catch (_) {} + + // Step 2: poll + let pollInterval = interval * 1000; + const deadline = Date.now() + expires_in * 1000; + let accessToken = null, refreshToken = null, tokenExpiry = null; + + if (isTTY) process.stdout.write('Waiting for authorization'); + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, pollInterval)); + if (isTTY) process.stdout.write('.'); + + let pollData; + try { + const pollRes = await fetch(`${authBase}/auth/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_code }) + }); + pollData = await pollRes.json(); + + if (pollRes.ok) { + accessToken = pollData.access_token; + refreshToken = pollData.refresh_token; + tokenExpiry = Date.now() + (pollData.expires_in * 1000); + break; + } + } catch (err) { + // network error — keep polling + continue; + } + + const errCode = pollData?.error; + if (errCode === 'slow_down') { + pollInterval += 5000; + } else if (errCode === 'access_denied') { + if (isTTY) process.stdout.write('\n'); + log(JSON.stringify({ error: 'ACCESS_DENIED', message: 'Authorization was denied.' })); + exit(1); + return; + } else if (errCode === 'expired_token') { + if (isTTY) process.stdout.write('\n'); + log(JSON.stringify({ error: 'EXPIRED', message: 'Code expired. Run `nansen login` again.' })); + exit(1); + return; + } + } + + if (isTTY) process.stdout.write('\n'); + + if (!accessToken) { + log(JSON.stringify({ error: 'TIMEOUT', message: 'Authorization timed out. Run `nansen login` again.' })); exit(1); return; } saveConfigFn({ - apiKey: apiKey.trim(), - baseUrl: 'https://api.nansen.ai' + ...currentConfig, + accessToken, + refreshToken, + tokenExpiry, + apiKey: undefined }); - log(`✓ Saved to ${getConfigFileFn()}\n`); + // Decode email from JWT payload (no verification needed here) + let email = 'your account'; + try { + const payload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64url').toString()); + email = payload.email || payload.sub || email; + } catch (_) {} + + log(`\n✓ Logged in as ${email}`); + log(`Saved to ${getConfigFileFn()}\n`); log('You can now use the Nansen CLI. Try:'); log(' nansen research token screener --chain solana --pretty'); + return; }, 'logout': async (_args, _apiInstance, _flags, _options) => { const deleted = deleteConfigFn(); if (deleted) { - log(`✓ Removed ${getConfigFileFn()}`); + log(`✓ Removed ${getConfigFileFn()} (API key and OAuth tokens cleared)`); } else { log('No saved credentials found'); } From 9001fd4f25d1ab7fc1ffebbcd2ee85ba39fa41c0 Mon Sep 17 00:00:00 2001 From: Shaun Yang Date: Thu, 12 Mar 2026 11:35:07 +0800 Subject: [PATCH 2/7] Fix reference issues to auth URL --- src/api.js | 30 +++++++++++------------------- src/cli.js | 9 +++++---- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/api.js b/src/api.js index 98886c59..75827ae4 100644 --- a/src/api.js +++ b/src/api.js @@ -164,21 +164,7 @@ export function deleteConfig() { return false; } -/** - * Derive the superapp base URL from the API base URL. - * e.g. https://api.nansen.ai → https://app.nansen.ai - * @param {string} baseUrl - The API base URL - * @returns {string} The auth base URL - */ -export function getAuthBaseUrl(baseUrl) { - try { - const u = new URL(baseUrl); - u.hostname = u.hostname.replace(/^api\./, 'app.'); - return u.origin; - } catch { - return 'https://app.nansen.ai'; - } -} +export const DEFAULT_AUTH_BASE_URL = 'https://app.nansen.ai'; // ============= Response Cache ============= @@ -334,13 +320,16 @@ export function loadConfig() { } if (!config) { - config = { apiKey: null, baseUrl: 'https://api.nansen.ai', accessToken: null, refreshToken: null, tokenExpiry: 0 }; + config = { apiKey: null, baseUrl: 'https://api.nansen.ai', authBaseUrl: DEFAULT_AUTH_BASE_URL, accessToken: null, refreshToken: null, tokenExpiry: 0 }; } - // Ensure baseUrl default (config file from older versions may omit it) + // Ensure defaults (config file from older versions may omit these) if (!config.baseUrl) { config.baseUrl = 'https://api.nansen.ai'; } + if (!config.authBaseUrl) { + config.authBaseUrl = DEFAULT_AUTH_BASE_URL; + } // Env vars override individual fields if (process.env.NANSEN_API_KEY) { @@ -349,6 +338,9 @@ export function loadConfig() { if (process.env.NANSEN_BASE_URL) { config.baseUrl = process.env.NANSEN_BASE_URL; } + if (process.env.NANSEN_AUTH_URL) { + config.authBaseUrl = process.env.NANSEN_AUTH_URL; + } return config; } @@ -423,6 +415,7 @@ export class NansenAPI { constructor(apiKey = config.apiKey, baseUrl = config.baseUrl, options = {}) { this.apiKey = apiKey || null; this.baseUrl = baseUrl; + this.authBaseUrl = config.authBaseUrl || DEFAULT_AUTH_BASE_URL; this.accessToken = config.accessToken || null; this.refreshToken = config.refreshToken || null; this.tokenExpiry = config.tokenExpiry || 0; @@ -442,8 +435,7 @@ export class NansenAPI { if (!this.accessToken) return; if (this.tokenExpiry > Date.now() + 60_000) return; - const authBase = getAuthBaseUrl(this.baseUrl); - const res = await fetch(`${authBase}/auth/device/refresh`, { + const res = await fetch(`${this.authBaseUrl}/api/auth/device/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: this.refreshToken }) diff --git a/src/cli.js b/src/cli.js index f8936e74..5d973d99 100644 --- a/src/cli.js +++ b/src/cli.js @@ -3,7 +3,7 @@ * Extracted from index.js for coverage */ -import { NansenAPI, NansenError, ErrorCode, loadConfig, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, sleep, getAuthBaseUrl } from './api.js'; +import { NansenAPI, NansenError, ErrorCode, loadConfig, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, sleep, DEFAULT_AUTH_BASE_URL } from './api.js'; import { buildWalletCommands } from './wallet.js'; import { buildTradingCommands } from './trading.js'; import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js'; @@ -862,12 +862,12 @@ export function buildCommands(deps = {}) { // Default: OAuth device flow const currentConfig = loadConfigFn(); - const authBase = getAuthBaseUrl(currentConfig.baseUrl || 'https://api.nansen.ai'); + const authBase = currentConfig.authBaseUrl || DEFAULT_AUTH_BASE_URL; // Step 1: initiate let authorizeData; try { - const authorizeRes = await fetch(`${authBase}/auth/device/authorize`, { + const authorizeRes = await fetch(`${authBase}/api/auth/device/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); @@ -906,7 +906,7 @@ export function buildCommands(deps = {}) { let pollData; try { - const pollRes = await fetch(`${authBase}/auth/device/token`, { + const pollRes = await fetch(`${authBase}/api/auth/device/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_code }) @@ -921,6 +921,7 @@ export function buildCommands(deps = {}) { } } catch (err) { // network error — keep polling + if (isTTY) process.stdout.write('!'); continue; } From cd07148dc221e4267f1ae663a54b0d67f903ce19 Mon Sep 17 00:00:00 2001 From: Shaun Yang Date: Thu, 12 Mar 2026 11:51:11 +0800 Subject: [PATCH 3/7] Fix polling issue --- CHANGELOG.md | 3 ++- src/cli.js | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc26d1f..820b0440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ - `nansen login --api-key ` continues to work unchanged for CI and scripting. - API calls transparently refresh OAuth tokens 60 s before expiry via `_ensureFreshToken()`. - `nansen logout` clears OAuth tokens as well as legacy API keys. - - `getAuthBaseUrl()` helper exported from `api.js` derives the superapp URL from the API base URL. + - `DEFAULT_AUTH_BASE_URL` exported from `api.js` containing the superapp auth URL. + - new config `authBaseUrl` added, defaulting to `DEFAULT_AUTH_BASE_URL`. - `Authorization: Bearer` header used for OAuth sessions; `apikey` header used for legacy API key sessions (both coexist cleanly). - [#279](https://github.com/nansen-ai/nansen-cli/pull/279) [`174a3d6`](https://github.com/nansen-ai/nansen-cli/commit/174a3d612b3f198c5e5b979c269d896f9d906704) Thanks [@kome12](https://github.com/kome12)! - Add `nansen account` command to verify API key and check credit balance diff --git a/src/cli.js b/src/cli.js index 5d973d99..b8f57240 100644 --- a/src/cli.js +++ b/src/cli.js @@ -913,13 +913,13 @@ export function buildCommands(deps = {}) { }); pollData = await pollRes.json(); - if (pollRes.ok) { + if (pollRes.ok && !pollData.error) { accessToken = pollData.access_token; refreshToken = pollData.refresh_token; tokenExpiry = Date.now() + (pollData.expires_in * 1000); break; } - } catch (err) { + } catch { // network error — keep polling if (isTTY) process.stdout.write('!'); continue; From 47ca9eb97d490fc29f1632c7c80ce0f20a7aa71e Mon Sep 17 00:00:00 2001 From: Shaun Yang Date: Thu, 12 Mar 2026 12:00:18 +0800 Subject: [PATCH 4/7] Fix linter issues --- src/cli.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/cli.js b/src/cli.js index b8f57240..2ebee84d 100644 --- a/src/cli.js +++ b/src/cli.js @@ -892,7 +892,9 @@ export function buildCommands(deps = {}) { : process.platform === 'win32' ? 'start' : 'xdg-open'; execChild(`${opener} "${verification_uri}"`); - } catch (_) {} + } catch (_) { + log('Could not open browser automatically. Please open the URL above manually.\n'); + } // Step 2: poll let pollInterval = interval * 1000; @@ -962,7 +964,10 @@ export function buildCommands(deps = {}) { try { const payload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64url').toString()); email = payload.email || payload.sub || email; - } catch (_) {} + } catch (_) { + /* JWT decode is best-effort; fall back to generic label */ + log('Could not decode user info') + } log(`\n✓ Logged in as ${email}`); log(`Saved to ${getConfigFileFn()}\n`); From 51d1c72cb351c54f6177f84ae7666baa1f342b2f Mon Sep 17 00:00:00 2001 From: Shaun Yang Date: Thu, 12 Mar 2026 13:58:34 +0800 Subject: [PATCH 5/7] Fix tests --- src/__tests__/api.test.js | 4 +- src/__tests__/cli.internal.test.js | 202 +++++++++++++++++++++++++++-- src/cli.js | 6 + 3 files changed, 202 insertions(+), 10 deletions(-) diff --git a/src/__tests__/api.test.js b/src/__tests__/api.test.js index 92300834..f6a80d5e 100644 --- a/src/__tests__/api.test.js +++ b/src/__tests__/api.test.js @@ -1785,7 +1785,7 @@ describe('NansenAPI', () => { mockFetch.mockResolvedValueOnce(errorResponse); - await expect(api.smartMoneyNetflow({})).rejects.toThrow('Invalid API key'); + await expect(api.smartMoneyNetflow({})).rejects.toThrow('Authentication failed. Run `nansen login` to re-authenticate.'); }); it('should show login guidance for 401 when no API key', async () => { @@ -1802,7 +1802,7 @@ describe('NansenAPI', () => { mockFetch.mockResolvedValueOnce(errorResponse); - await expect(apiNoKey.smartMoneyNetflow({})).rejects.toThrow('Not logged in. Run: nansen login'); + await expect(apiNoKey.smartMoneyNetflow({})).rejects.toThrow('Not logged in. Run `nansen login` (OAuth) or `nansen login --api-key `.'); }); it('should throw on network errors after retries', async () => { diff --git a/src/__tests__/cli.internal.test.js b/src/__tests__/cli.internal.test.js index 6d2b5e50..c0dad65e 100644 --- a/src/__tests__/cli.internal.test.js +++ b/src/__tests__/cli.internal.test.js @@ -5,6 +5,10 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Prevent the OAuth device flow from actually opening a browser window. +// vi.mock is hoisted, so it intercepts the dynamic import('child_process') in cli.js. +vi.mock('child_process', () => ({ exec: vi.fn(), default: { exec: vi.fn() } })); import { parseArgs, formatValue, @@ -997,12 +1001,32 @@ describe('buildCommands', () => { }); describe('login command', () => { - it('should exit when no API key provided', async () => { + it('should start OAuth device flow and save tokens when no API key', async () => { const savedEnv = process.env.NANSEN_API_KEY; delete process.env.NANSEN_API_KEY; + vi.stubGlobal('fetch', vi.fn().mockImplementation((url) => { + if (url.includes('/authorize')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ + device_code: 'dc', user_code: 'AAAA-1111', + verification_uri: 'https://app.nansen.ai/device', interval: 0, expires_in: 300 + })}); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ + access_token: 'test-access-token', refresh_token: 'test-refresh-token', expires_in: 3600 + })}); + })); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await commands.login([], null, {}, {}); + + stdoutSpy.mockRestore(); + vi.unstubAllGlobals(); if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; - expect(mockDeps.exit).toHaveBeenCalledWith(1); + expect(mockDeps.saveConfigFn).toHaveBeenCalledWith(expect.objectContaining({ + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + apiKey: undefined, + })); }); it('should exit when API key is whitespace', async () => { @@ -1011,6 +1035,7 @@ describe('buildCommands', () => { await commands.login([], null, {}, { 'api-key': ' ' }); if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; expect(mockDeps.exit).toHaveBeenCalledWith(1); + expect(logs.some(l => l.includes('INVALID_API_KEY'))).toBe(true); }); it('should save config with --api-key option after verification', async () => { @@ -1026,15 +1051,17 @@ describe('buildCommands', () => { }); }); - it('should exit when no API key available', async () => { + it('should report DEVICE_AUTHORIZE_FAILED when auth endpoint returns 403', async () => { const savedEnv = process.env.NANSEN_API_KEY; delete process.env.NANSEN_API_KEY; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403 })); await commands.login([], null, {}, {}); + vi.unstubAllGlobals(); if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; expect(mockDeps.exit).toHaveBeenCalledWith(1); - expect(logs.some(l => l.includes('API_KEY_REQUIRED'))).toBe(true); + expect(logs.some(l => l.includes('DEVICE_AUTHORIZE_FAILED'))).toBe(true); }); it('should reject invalid API key (401)', async () => { @@ -1817,15 +1844,17 @@ describe('login/logout flow', () => { expect(logs.some(l => l.includes('Saved to'))).toBe(true); }); - it('should exit when no API key available', async () => { + it('should report DEVICE_AUTHORIZE_FAILED when auth endpoint returns 403', async () => { const savedEnv = process.env.NANSEN_API_KEY; delete process.env.NANSEN_API_KEY; - + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403 })); + await commands.login([], null, {}, {}); - + + vi.unstubAllGlobals(); if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; - expect(logs.some(l => l.includes('API_KEY_REQUIRED'))).toBe(true); expect(mockDeps.exit).toHaveBeenCalledWith(1); + expect(logs.some(l => l.includes('DEVICE_AUTHORIZE_FAILED'))).toBe(true); }); }); @@ -3462,3 +3491,160 @@ describe('buildPagination', () => { expect(buildPagination({ limit: 25 })).toEqual({ page: 1, per_page: 25 }); }); }); + +// =================== OAuth Device Flow =================== + +import * as childProcess from 'child_process'; + +describe('OAuth device flow', () => { + let mockDeps; + let commands; + let logs; + + beforeEach(() => { + logs = []; + mockDeps = { + log: (msg) => logs.push(msg), + exit: vi.fn(), + promptFn: vi.fn(), + saveConfigFn: vi.fn(), + deleteConfigFn: vi.fn(), + getConfigFileFn: vi.fn(() => '/home/user/.nansen/config.json'), + NansenAPIClass: vi.fn(), + isTTY: false, + }; + commands = buildCommands(mockDeps); + vi.mocked(childProcess.exec).mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function makeFetchMock({ authFails = false, pendingCount = 0 } = {}) { + let pollCalls = 0; + return vi.fn().mockImplementation((url) => { + if (url.includes('/authorize')) { + if (authFails) return Promise.resolve({ ok: false, status: 403 }); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ + device_code: 'dc', user_code: 'AAAA-1111', + verification_uri: 'https://app.nansen.ai/device', interval: 0, expires_in: 300, + })}); + } + pollCalls++; + if (pollCalls <= pendingCount) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ error: 'authorization_pending' }) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ + access_token: 'test-access-token', refresh_token: 'test-refresh-token', expires_in: 3600, + })}); + }); + } + + it('should use DEFAULT_AUTH_BASE_URL when config has no authBaseUrl', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + const fetchMock = makeFetchMock(); + vi.stubGlobal('fetch', fetchMock); + mockDeps.loadConfigFn = () => ({ baseUrl: 'https://api.nansen.ai' }); + commands = buildCommands(mockDeps); + + await commands.login([], null, {}, {}); + + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + const authorizeCall = fetchMock.mock.calls.find(([url]) => url.includes('/authorize')); + expect(authorizeCall[0]).toContain('https://app.nansen.ai'); + }); + + it('should use authBaseUrl from config for device flow', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + const fetchMock = makeFetchMock(); + vi.stubGlobal('fetch', fetchMock); + mockDeps.loadConfigFn = () => ({ authBaseUrl: 'https://staging.app.nansen.ai', baseUrl: 'https://staging.api.nansen.ai' }); + commands = buildCommands(mockDeps); + + await commands.login([], null, {}, {}); + + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + const authorizeCall = fetchMock.mock.calls.find(([url]) => url.includes('/authorize')); + expect(authorizeCall[0]).toContain('https://staging.app.nansen.ai'); + }); + + it('should continue polling when HTTP 201 body contains authorization_pending', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + vi.stubGlobal('fetch', makeFetchMock({ pendingCount: 2 })); + + await commands.login([], null, {}, {}); + + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + expect(mockDeps.saveConfigFn).toHaveBeenCalledWith(expect.objectContaining({ + accessToken: 'test-access-token', + })); + }); + + it('should print ! to stdout on network error during polling', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + let pollCalls = 0; + vi.stubGlobal('fetch', vi.fn().mockImplementation((url) => { + if (url.includes('/authorize')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ + device_code: 'dc', user_code: 'AAAA-1111', + verification_uri: 'https://app.nansen.ai/device', interval: 0, expires_in: 300, + })}); + } + pollCalls++; + if (pollCalls === 1) throw new Error('Network error'); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ + access_token: 'test-access-token', refresh_token: 'test-refresh-token', expires_in: 3600, + })}); + })); + mockDeps.isTTY = true; + commands = buildCommands(mockDeps); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await commands.login([], null, {}, {}); + + const wroteExclamation = stdoutSpy.mock.calls.some(([c]) => c === '!'); + stdoutSpy.mockRestore(); + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + expect(wroteExclamation).toBe(true); + }); + + it('should attempt to open browser on authorize success', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + vi.stubGlobal('fetch', makeFetchMock()); + + await commands.login([], null, {}, {}); + + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + expect(childProcess.exec).toHaveBeenCalledOnce(); + expect(vi.mocked(childProcess.exec).mock.calls[0][0]).toContain('https://app.nansen.ai/device'); + }); + + it('should log fallback message when browser launch throws', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + vi.stubGlobal('fetch', makeFetchMock()); + vi.mocked(childProcess.exec).mockImplementation(() => { throw new Error('no browser'); }); + + await commands.login([], null, {}, {}); + + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + expect(logs.some(l => l.includes('Could not open browser automatically'))).toBe(true); + }); + + it('should not attempt browser launch when authorize fails', async () => { + const savedEnv = process.env.NANSEN_API_KEY; + delete process.env.NANSEN_API_KEY; + vi.stubGlobal('fetch', makeFetchMock({ authFails: true })); + + await commands.login([], null, {}, {}); + + if (savedEnv !== undefined) process.env.NANSEN_API_KEY = savedEnv; + expect(childProcess.exec).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli.js b/src/cli.js index 2ebee84d..df9abd4d 100644 --- a/src/cli.js +++ b/src/cli.js @@ -814,6 +814,12 @@ export function buildCommands(deps = {}) { apiKey = await promptFn('Enter your API key: ', true); } + if (apiKey && apiKey.trim().length === 0) { + log(JSON.stringify({ error: 'INVALID_API_KEY', message: 'API key cannot be blank.' })); + exit(1); + return; + } + if (apiKey && apiKey.trim().length > 0) { // Verify API key before saving const NansenAPIClass = _NansenAPIClass; From e7c84e5e51289e6775fb1787acc69326e8591d63 Mon Sep 17 00:00:00 2001 From: "Tim Nooren (Openclaw Bot)" Date: Thu, 12 Mar 2026 15:25:51 +0000 Subject: [PATCH 6/7] fix: address pr-reviewer findings on test suite --- src/__tests__/cli.internal.test.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/__tests__/cli.internal.test.js b/src/__tests__/cli.internal.test.js index c0dad65e..bddfa493 100644 --- a/src/__tests__/cli.internal.test.js +++ b/src/__tests__/cli.internal.test.js @@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Prevent the OAuth device flow from actually opening a browser window. // vi.mock is hoisted, so it intercepts the dynamic import('child_process') in cli.js. vi.mock('child_process', () => ({ exec: vi.fn(), default: { exec: vi.fn() } })); +import * as childProcess from 'child_process'; import { parseArgs, formatValue, @@ -973,6 +974,7 @@ describe('buildCommands', () => { saveConfigFn: vi.fn(), deleteConfigFn: vi.fn(), getConfigFileFn: vi.fn(() => '/home/user/.nansen/config.json'), + loadConfigFn: vi.fn(() => ({})), NansenAPIClass: vi.fn(), isTTY: true }; @@ -3494,8 +3496,6 @@ describe('buildPagination', () => { // =================== OAuth Device Flow =================== -import * as childProcess from 'child_process'; - describe('OAuth device flow', () => { let mockDeps; let commands; @@ -3510,6 +3510,7 @@ describe('OAuth device flow', () => { saveConfigFn: vi.fn(), deleteConfigFn: vi.fn(), getConfigFileFn: vi.fn(() => '/home/user/.nansen/config.json'), + loadConfigFn: vi.fn(() => ({})), NansenAPIClass: vi.fn(), isTTY: false, }; @@ -3571,7 +3572,7 @@ describe('OAuth device flow', () => { expect(authorizeCall[0]).toContain('https://staging.app.nansen.ai'); }); - it('should continue polling when HTTP 201 body contains authorization_pending', async () => { + it('should continue polling when response body contains authorization_pending', async () => { const savedEnv = process.env.NANSEN_API_KEY; delete process.env.NANSEN_API_KEY; vi.stubGlobal('fetch', makeFetchMock({ pendingCount: 2 })); From 6696204c175408c178ec1456e46c0e87119de986 Mon Sep 17 00:00:00 2001 From: "Tim Nooren (Openclaw Bot)" Date: Thu, 12 Mar 2026 15:31:12 +0000 Subject: [PATCH 7/7] Revert "fix: address pr-reviewer findings on test suite" This reverts commit e7c84e5e51289e6775fb1787acc69326e8591d63. --- src/__tests__/cli.internal.test.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/__tests__/cli.internal.test.js b/src/__tests__/cli.internal.test.js index bddfa493..c0dad65e 100644 --- a/src/__tests__/cli.internal.test.js +++ b/src/__tests__/cli.internal.test.js @@ -9,7 +9,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Prevent the OAuth device flow from actually opening a browser window. // vi.mock is hoisted, so it intercepts the dynamic import('child_process') in cli.js. vi.mock('child_process', () => ({ exec: vi.fn(), default: { exec: vi.fn() } })); -import * as childProcess from 'child_process'; import { parseArgs, formatValue, @@ -974,7 +973,6 @@ describe('buildCommands', () => { saveConfigFn: vi.fn(), deleteConfigFn: vi.fn(), getConfigFileFn: vi.fn(() => '/home/user/.nansen/config.json'), - loadConfigFn: vi.fn(() => ({})), NansenAPIClass: vi.fn(), isTTY: true }; @@ -3496,6 +3494,8 @@ describe('buildPagination', () => { // =================== OAuth Device Flow =================== +import * as childProcess from 'child_process'; + describe('OAuth device flow', () => { let mockDeps; let commands; @@ -3510,7 +3510,6 @@ describe('OAuth device flow', () => { saveConfigFn: vi.fn(), deleteConfigFn: vi.fn(), getConfigFileFn: vi.fn(() => '/home/user/.nansen/config.json'), - loadConfigFn: vi.fn(() => ({})), NansenAPIClass: vi.fn(), isTTY: false, }; @@ -3572,7 +3571,7 @@ describe('OAuth device flow', () => { expect(authorizeCall[0]).toContain('https://staging.app.nansen.ai'); }); - it('should continue polling when response body contains authorization_pending', async () => { + it('should continue polling when HTTP 201 body contains authorization_pending', async () => { const savedEnv = process.env.NANSEN_API_KEY; delete process.env.NANSEN_API_KEY; vi.stubGlobal('fetch', makeFetchMock({ pendingCount: 2 }));