newo 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/akb.js CHANGED
@@ -2,8 +2,8 @@ import fs from 'fs-extra';
2
2
  /**
3
3
  * Parse AKB file and extract articles
4
4
  */
5
- export function parseAkbFile(filePath) {
6
- const content = fs.readFileSync(filePath, 'utf8');
5
+ export async function parseAkbFile(filePath) {
6
+ const content = await fs.readFile(filePath, 'utf8');
7
7
  const articles = [];
8
8
  // Split by article separators (---)
9
9
  const sections = content.split(/^---\s*$/gm).filter(section => section.trim());
@@ -22,27 +22,31 @@ export function parseAkbFile(filePath) {
22
22
  * Parse individual article section
23
23
  */
24
24
  function parseArticleSection(lines) {
25
- let topicName = '';
26
- let category = '';
27
- let summary = '';
28
- let keywords = '';
29
- let topicSummary = '';
25
+ const state = {
26
+ topicName: '',
27
+ category: '',
28
+ summary: '',
29
+ keywords: '',
30
+ topicSummary: ''
31
+ };
30
32
  // Find topic name (# r001)
31
33
  const topicLine = lines.find(line => line.match(/^#\s+r\d+/));
32
- if (!topicLine)
34
+ if (!topicLine) {
35
+ console.warn('No topic line found in section');
33
36
  return null;
34
- topicName = topicLine.replace(/^#\s+/, '').trim();
37
+ }
38
+ state.topicName = topicLine.replace(/^#\s+/, '').trim();
35
39
  // Extract category/subcategory/description (first ## line)
36
40
  const categoryLine = lines.find(line => line.startsWith('## ') && line.includes(' / '));
37
41
  if (categoryLine) {
38
- category = categoryLine.replace(/^##\s+/, '').trim();
42
+ state.category = categoryLine.replace(/^##\s+/, '').trim();
39
43
  }
40
44
  // Extract summary (second ## line)
41
45
  const summaryLineIndex = lines.findIndex(line => line.startsWith('## ') && line.includes(' / '));
42
46
  if (summaryLineIndex >= 0 && summaryLineIndex + 1 < lines.length) {
43
47
  const nextLine = lines[summaryLineIndex + 1];
44
48
  if (nextLine && nextLine.startsWith('## ') && !nextLine.includes(' / ')) {
45
- summary = nextLine.replace(/^##\s+/, '').trim();
49
+ state.summary = nextLine.replace(/^##\s+/, '').trim();
46
50
  }
47
51
  }
48
52
  // Extract keywords (third ## line)
@@ -50,7 +54,7 @@ function parseArticleSection(lines) {
50
54
  if (keywordsLineIndex >= 0) {
51
55
  const keywordsLine = lines[keywordsLineIndex];
52
56
  if (keywordsLine) {
53
- keywords = keywordsLine.replace(/^##\s+/, '').trim();
57
+ state.keywords = keywordsLine.replace(/^##\s+/, '').trim();
54
58
  }
55
59
  }
56
60
  // Extract category content
@@ -58,17 +62,17 @@ function parseArticleSection(lines) {
58
62
  const categoryEndIndex = lines.findIndex(line => line.includes('</Category>'));
59
63
  if (categoryStartIndex >= 0 && categoryEndIndex >= 0) {
60
64
  const categoryLines = lines.slice(categoryStartIndex, categoryEndIndex + 1);
61
- topicSummary = categoryLines.join('\n');
65
+ state.topicSummary = categoryLines.join('\n');
62
66
  }
63
67
  // Create topic_facts array
64
- const topicFacts = [category, summary, keywords].filter(fact => fact.trim() !== '');
68
+ const topicFacts = [state.category, state.summary, state.keywords].filter(fact => fact.trim() !== '');
65
69
  return {
66
- topic_name: category, // Use the descriptive title as topic_name
70
+ topic_name: state.category, // Use the descriptive title as topic_name
67
71
  persona_id: null, // Will be set when importing
68
- topic_summary: topicSummary,
72
+ topic_summary: state.topicSummary,
69
73
  topic_facts: topicFacts,
70
74
  confidence: 100,
71
- source: topicName, // Use the ID (r001) as source
75
+ source: state.topicName, // Use the ID (r001) as source
72
76
  labels: ['rag_context']
73
77
  };
74
78
  }
package/dist/api.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type AxiosInstance } from 'axios';
2
- import type { ProjectMeta, Agent, Skill, FlowEvent, FlowState, AkbImportArticle } from './types.js';
3
- export declare function makeClient(verbose?: boolean): Promise<AxiosInstance>;
2
+ import type { ProjectMeta, Agent, Skill, FlowEvent, FlowState, AkbImportArticle, CustomerProfile } from './types.js';
3
+ export declare function makeClient(verbose?: boolean, token?: string): Promise<AxiosInstance>;
4
4
  export declare function listProjects(client: AxiosInstance): Promise<ProjectMeta[]>;
5
5
  export declare function listAgents(client: AxiosInstance, projectId: string): Promise<Agent[]>;
6
6
  export declare function getProjectMeta(client: AxiosInstance, projectId: string): Promise<ProjectMeta>;
@@ -10,4 +10,5 @@ export declare function updateSkill(client: AxiosInstance, skillObject: Skill):
10
10
  export declare function listFlowEvents(client: AxiosInstance, flowId: string): Promise<FlowEvent[]>;
11
11
  export declare function listFlowStates(client: AxiosInstance, flowId: string): Promise<FlowState[]>;
12
12
  export declare function importAkbArticle(client: AxiosInstance, articleData: AkbImportArticle): Promise<unknown>;
13
+ export declare function getCustomerProfile(client: AxiosInstance): Promise<CustomerProfile>;
13
14
  //# sourceMappingURL=api.d.ts.map
package/dist/api.js CHANGED
@@ -1,17 +1,14 @@
1
- import axios from 'axios';
2
- import dotenv from 'dotenv';
1
+ import axios, {} from 'axios';
3
2
  import { getValidAccessToken, forceReauth } from './auth.js';
4
- dotenv.config();
5
- const { NEWO_BASE_URL } = process.env;
6
- export async function makeClient(verbose = false) {
7
- let accessToken = await getValidAccessToken();
3
+ import { ENV } from './env.js';
4
+ // Per-request retry tracking to avoid shared state issues
5
+ const RETRY_SYMBOL = Symbol('retried');
6
+ export async function makeClient(verbose = false, token) {
7
+ let accessToken = token || await getValidAccessToken();
8
8
  if (verbose)
9
9
  console.log('✓ Access token obtained');
10
- if (!NEWO_BASE_URL) {
11
- throw new Error('NEWO_BASE_URL is not set in environment variables');
12
- }
13
10
  const client = axios.create({
14
- baseURL: NEWO_BASE_URL,
11
+ baseURL: ENV.NEWO_BASE_URL,
15
12
  headers: { accept: 'application/json' }
16
13
  });
17
14
  client.interceptors.request.use(async (config) => {
@@ -26,7 +23,6 @@ export async function makeClient(verbose = false) {
26
23
  }
27
24
  return config;
28
25
  });
29
- let retried = false;
30
26
  client.interceptors.response.use((response) => {
31
27
  if (verbose) {
32
28
  console.log(`← ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url}`);
@@ -34,7 +30,8 @@ export async function makeClient(verbose = false) {
34
30
  console.log(' Response:', JSON.stringify(response.data, null, 2));
35
31
  }
36
32
  else if (response.data) {
37
- console.log(` Response: [${typeof response.data}] ${Array.isArray(response.data) ? response.data.length + ' items' : 'large object'}`);
33
+ const itemCount = Array.isArray(response.data) ? response.data.length : Object.keys(response.data).length;
34
+ console.log(` Response: [${typeof response.data}] ${Array.isArray(response.data) ? itemCount + ' items' : 'large object'}`);
38
35
  }
39
36
  }
40
37
  return response;
@@ -45,15 +42,17 @@ export async function makeClient(verbose = false) {
45
42
  if (error.response?.data)
46
43
  console.log(' Error data:', error.response.data);
47
44
  }
48
- if (status === 401 && !retried) {
49
- retried = true;
50
- if (verbose)
51
- console.log('🔄 Retrying with fresh token...');
52
- accessToken = await forceReauth();
53
- if (error.config) {
54
- error.config.headers = error.config.headers || {};
55
- error.config.headers.Authorization = `Bearer ${accessToken}`;
56
- return client.request(error.config);
45
+ // Use per-request retry tracking to avoid shared state issues
46
+ const config = error.config;
47
+ if (status === 401 && !config?.[RETRY_SYMBOL]) {
48
+ if (config) {
49
+ config[RETRY_SYMBOL] = true;
50
+ if (verbose)
51
+ console.log('🔄 Retrying with fresh token...');
52
+ accessToken = await forceReauth();
53
+ config.headers = config.headers || {};
54
+ config.headers.Authorization = `Bearer ${accessToken}`;
55
+ return client.request(config);
57
56
  }
58
57
  }
59
58
  throw error;
@@ -97,4 +96,8 @@ export async function importAkbArticle(client, articleData) {
97
96
  const response = await client.post('/api/v1/akb/append-manual', articleData);
98
97
  return response.data;
99
98
  }
99
+ export async function getCustomerProfile(client) {
100
+ const response = await client.get('/api/v1/customer/profile');
101
+ return response.data;
102
+ }
100
103
  //# sourceMappingURL=api.js.map
package/dist/auth.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { StoredTokens } from './types.js';
2
- export declare function exchangeApiKeyForToken(): Promise<StoredTokens>;
3
- export declare function refreshWithEndpoint(refreshToken: string): Promise<StoredTokens>;
4
- export declare function getValidAccessToken(): Promise<string>;
5
- export declare function forceReauth(): Promise<string>;
1
+ import type { StoredTokens, CustomerConfig } from './types.js';
2
+ export declare function exchangeApiKeyForToken(customer?: CustomerConfig): Promise<StoredTokens>;
3
+ export declare function refreshWithEndpoint(refreshToken: string, customer?: CustomerConfig): Promise<StoredTokens>;
4
+ export declare function getValidAccessToken(customer?: CustomerConfig): Promise<string>;
5
+ export declare function forceReauth(customer?: CustomerConfig): Promise<string>;
6
6
  //# sourceMappingURL=auth.d.ts.map
package/dist/auth.js CHANGED
@@ -1,23 +1,169 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
- import axios from 'axios';
4
- import dotenv from 'dotenv';
5
- dotenv.config();
6
- const { NEWO_BASE_URL, NEWO_API_KEY, NEWO_ACCESS_TOKEN, NEWO_REFRESH_TOKEN, NEWO_REFRESH_URL } = process.env;
3
+ import axios, { AxiosError } from 'axios';
4
+ import { ENV } from './env.js';
5
+ import { customerStateDir } from './fsutil.js';
7
6
  const STATE_DIR = path.join(process.cwd(), '.newo');
8
- const TOKENS_PATH = path.join(STATE_DIR, 'tokens.json');
9
- async function saveTokens(tokens) {
10
- await fs.ensureDir(STATE_DIR);
11
- await fs.writeJson(TOKENS_PATH, tokens, { spaces: 2 });
7
+ // Constants for validation and timeouts
8
+ const API_KEY_MIN_LENGTH = 10;
9
+ const TOKEN_MIN_LENGTH = 20;
10
+ const REQUEST_TIMEOUT = 30000; // 30 seconds
11
+ const TOKEN_EXPIRY_BUFFER = 60000; // 1 minute buffer for token expiry
12
+ // Validation functions
13
+ function validateApiKey(apiKey, customerIdn) {
14
+ if (!apiKey || typeof apiKey !== 'string') {
15
+ throw new Error(`Invalid API key format${customerIdn ? ` for customer ${customerIdn}` : ''}: must be a non-empty string`);
16
+ }
17
+ if (apiKey.length < API_KEY_MIN_LENGTH) {
18
+ throw new Error(`API key too short${customerIdn ? ` for customer ${customerIdn}` : ''}: minimum ${API_KEY_MIN_LENGTH} characters required`);
19
+ }
20
+ if (apiKey.includes(' ') || apiKey.includes('\n') || apiKey.includes('\t')) {
21
+ throw new Error(`Invalid API key format${customerIdn ? ` for customer ${customerIdn}` : ''}: contains invalid characters`);
22
+ }
23
+ }
24
+ function validateTokens(tokens) {
25
+ if (!tokens.access_token || typeof tokens.access_token !== 'string' || tokens.access_token.length < TOKEN_MIN_LENGTH) {
26
+ throw new Error('Invalid access token format: must be a non-empty string with minimum length');
27
+ }
28
+ if (tokens.refresh_token && (typeof tokens.refresh_token !== 'string' || tokens.refresh_token.length < TOKEN_MIN_LENGTH)) {
29
+ throw new Error('Invalid refresh token format: must be a non-empty string with minimum length');
30
+ }
31
+ if (tokens.expires_at && (typeof tokens.expires_at !== 'number' || tokens.expires_at <= 0)) {
32
+ throw new Error('Invalid token expiry: must be a positive number');
33
+ }
34
+ }
35
+ function validateUrl(url, name) {
36
+ if (!url || typeof url !== 'string') {
37
+ throw new Error(`${name} must be a non-empty string`);
38
+ }
39
+ try {
40
+ new URL(url);
41
+ }
42
+ catch {
43
+ throw new Error(`${name} must be a valid URL format`);
44
+ }
45
+ if (!url.startsWith('https://') && !url.startsWith('http://')) {
46
+ throw new Error(`${name} must use HTTP or HTTPS protocol`);
47
+ }
48
+ }
49
+ // Enhanced logging function
50
+ function logAuthEvent(level, message, meta) {
51
+ const timestamp = new Date().toISOString();
52
+ const logEntry = {
53
+ timestamp,
54
+ level,
55
+ module: 'auth',
56
+ message,
57
+ ...meta
58
+ };
59
+ // Sanitize sensitive data
60
+ const sanitized = JSON.parse(JSON.stringify(logEntry, (key, value) => {
61
+ if (typeof key === 'string' && (key.toLowerCase().includes('key') || key.toLowerCase().includes('token') || key.toLowerCase().includes('secret'))) {
62
+ return typeof value === 'string' ? `${value.slice(0, 8)}...` : value;
63
+ }
64
+ return value;
65
+ }));
66
+ if (level === 'error') {
67
+ console.error(JSON.stringify(sanitized));
68
+ }
69
+ else if (level === 'warn') {
70
+ console.warn(JSON.stringify(sanitized));
71
+ }
72
+ else {
73
+ console.log(JSON.stringify(sanitized));
74
+ }
75
+ }
76
+ // Enhanced error handling for network requests
77
+ function handleNetworkError(error, operation, customerIdn) {
78
+ const customerInfo = customerIdn ? ` for customer ${customerIdn}` : '';
79
+ if (error instanceof AxiosError) {
80
+ const statusCode = error.response?.status;
81
+ const responseData = error.response?.data;
82
+ if (statusCode === 401) {
83
+ throw new Error(`Authentication failed${customerInfo}: Invalid API key or credentials`);
84
+ }
85
+ else if (statusCode === 403) {
86
+ throw new Error(`Access forbidden${customerInfo}: Insufficient permissions`);
87
+ }
88
+ else if (statusCode === 429) {
89
+ throw new Error(`Rate limit exceeded${customerInfo}: Please try again later`);
90
+ }
91
+ else if (statusCode && statusCode >= 500) {
92
+ throw new Error(`Server error${customerInfo}: The NEWO service is temporarily unavailable (${statusCode})`);
93
+ }
94
+ else if (error.code === 'ECONNREFUSED') {
95
+ throw new Error(`Connection refused${customerInfo}: Cannot reach NEWO service`);
96
+ }
97
+ else if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
98
+ throw new Error(`Network timeout${customerInfo}: Check your internet connection`);
99
+ }
100
+ else {
101
+ throw new Error(`Network error during ${operation}${customerInfo}: ${error.message}${responseData ? ` - ${JSON.stringify(responseData)}` : ''}`);
102
+ }
103
+ }
104
+ throw new Error(`Failed to ${operation}${customerInfo}: ${error instanceof Error ? error.message : String(error)}`);
105
+ }
106
+ function tokensPath(customerIdn) {
107
+ if (customerIdn) {
108
+ return path.join(customerStateDir(customerIdn), 'tokens.json');
109
+ }
110
+ return path.join(STATE_DIR, 'tokens.json'); // Legacy path
12
111
  }
13
- async function loadTokens() {
14
- if (await fs.pathExists(TOKENS_PATH)) {
15
- return fs.readJson(TOKENS_PATH);
112
+ async function saveTokens(tokens, customerIdn) {
113
+ try {
114
+ validateTokens(tokens);
115
+ const filePath = tokensPath(customerIdn);
116
+ await fs.ensureDir(path.dirname(filePath));
117
+ await fs.writeJson(filePath, tokens, { spaces: 2 });
118
+ logAuthEvent('info', 'Tokens saved successfully', {
119
+ customerIdn: customerIdn || 'legacy',
120
+ expiresAt: tokens.expires_at ? new Date(tokens.expires_at).toISOString() : undefined,
121
+ hasRefreshToken: !!tokens.refresh_token
122
+ });
123
+ }
124
+ catch (error) {
125
+ logAuthEvent('error', 'Failed to save tokens', {
126
+ customerIdn: customerIdn || 'legacy',
127
+ error: error instanceof Error ? error.message : String(error)
128
+ });
129
+ throw new Error(`Failed to save authentication tokens${customerIdn ? ` for customer ${customerIdn}` : ''}: ${error instanceof Error ? error.message : String(error)}`);
16
130
  }
17
- if (NEWO_ACCESS_TOKEN || NEWO_REFRESH_TOKEN) {
131
+ }
132
+ async function loadTokens(customerIdn) {
133
+ try {
134
+ const filePath = tokensPath(customerIdn);
135
+ if (await fs.pathExists(filePath)) {
136
+ const tokens = await fs.readJson(filePath);
137
+ // Validate loaded tokens
138
+ try {
139
+ validateTokens(tokens);
140
+ }
141
+ catch (validationError) {
142
+ logAuthEvent('warn', 'Loaded tokens failed validation, will regenerate', {
143
+ customerIdn: customerIdn || 'legacy',
144
+ error: validationError instanceof Error ? validationError.message : String(validationError)
145
+ });
146
+ return null; // Force token regeneration
147
+ }
148
+ logAuthEvent('info', 'Tokens loaded successfully', {
149
+ customerIdn: customerIdn || 'legacy',
150
+ expiresAt: tokens.expires_at ? new Date(tokens.expires_at).toISOString() : undefined,
151
+ hasRefreshToken: !!tokens.refresh_token
152
+ });
153
+ return tokens;
154
+ }
155
+ }
156
+ catch (error) {
157
+ logAuthEvent('warn', 'Failed to load tokens from file', {
158
+ customerIdn: customerIdn || 'legacy',
159
+ error: error instanceof Error ? error.message : String(error)
160
+ });
161
+ }
162
+ // Fallback to environment tokens for legacy mode or bootstrap
163
+ if (!customerIdn && (ENV.NEWO_ACCESS_TOKEN || ENV.NEWO_REFRESH_TOKEN)) {
18
164
  const tokens = {
19
- access_token: NEWO_ACCESS_TOKEN || '',
20
- refresh_token: NEWO_REFRESH_TOKEN || '',
165
+ access_token: ENV.NEWO_ACCESS_TOKEN || '',
166
+ refresh_token: ENV.NEWO_REFRESH_TOKEN || '',
21
167
  expires_at: Date.now() + 10 * 60 * 1000
22
168
  };
23
169
  await saveTokens(tokens);
@@ -26,79 +172,190 @@ async function loadTokens() {
26
172
  return null;
27
173
  }
28
174
  function isExpired(tokens) {
29
- if (!tokens?.expires_at)
30
- return false;
31
- return Date.now() >= tokens.expires_at - 10_000;
175
+ if (!tokens?.expires_at) {
176
+ logAuthEvent('warn', 'Token has no expiry time, treating as expired');
177
+ return true;
178
+ }
179
+ const currentTime = Date.now();
180
+ const expiryTime = tokens.expires_at;
181
+ const timeUntilExpiry = expiryTime - currentTime;
182
+ if (timeUntilExpiry <= TOKEN_EXPIRY_BUFFER) {
183
+ logAuthEvent('info', 'Token is expired or expires soon', {
184
+ expiresAt: new Date(expiryTime).toISOString(),
185
+ timeUntilExpiry: Math.round(timeUntilExpiry / 1000)
186
+ });
187
+ return true;
188
+ }
189
+ return false;
32
190
  }
33
- export async function exchangeApiKeyForToken() {
34
- if (!NEWO_API_KEY) {
35
- throw new Error('NEWO_API_KEY not set. Provide an API key in .env');
36
- }
37
- const url = `${NEWO_BASE_URL}/api/v1/auth/api-key/token`;
38
- const response = await axios.post(url, {}, {
39
- headers: {
40
- 'x-api-key': NEWO_API_KEY,
41
- 'accept': 'application/json'
42
- }
43
- });
44
- const data = response.data;
45
- const access = data.access_token || data.token || data.accessToken;
46
- const refresh = data.refresh_token || data.refreshToken || '';
47
- const expiresInSec = data.expires_in || data.expiresIn || 3600;
191
+ function normalizeTokenResponse(tokenResponse) {
192
+ const access = tokenResponse.access_token || tokenResponse.token || tokenResponse.accessToken;
193
+ const refresh = tokenResponse.refresh_token || tokenResponse.refreshToken || '';
194
+ const expiresInSec = tokenResponse.expires_in || tokenResponse.expiresIn || 3600;
48
195
  if (!access) {
49
- throw new Error('Failed to get access token from API key exchange');
196
+ throw new Error('Invalid token response: missing access token');
50
197
  }
51
- const tokens = {
52
- access_token: access,
53
- refresh_token: refresh,
54
- expires_at: Date.now() + expiresInSec * 1000
55
- };
56
- await saveTokens(tokens);
57
- return tokens;
198
+ return { access, refresh, expiresInSec };
58
199
  }
59
- export async function refreshWithEndpoint(refreshToken) {
60
- if (!NEWO_REFRESH_URL) {
61
- throw new Error('NEWO_REFRESH_URL not set');
62
- }
63
- const response = await axios.post(NEWO_REFRESH_URL, { refresh_token: refreshToken }, { headers: { 'accept': 'application/json' } });
64
- const data = response.data;
65
- const access = data.access_token || data.token || data.accessToken;
66
- const refresh = data.refresh_token ?? refreshToken;
67
- const expiresInSec = data.expires_in || 3600;
68
- if (!access) {
69
- throw new Error('Failed to get access token from refresh');
200
+ export async function exchangeApiKeyForToken(customer) {
201
+ const apiKey = customer?.apiKey || ENV.NEWO_API_KEY;
202
+ const customerIdn = customer?.idn;
203
+ // Validate inputs
204
+ if (!apiKey) {
205
+ throw new Error(customer
206
+ ? `API key not set for customer ${customer.idn}. Set NEWO_CUSTOMER_${customer.idn.toUpperCase()}_API_KEY in your environment`
207
+ : 'NEWO_API_KEY not set. Provide an API key in .env file');
208
+ }
209
+ validateApiKey(apiKey, customerIdn);
210
+ validateUrl(ENV.NEWO_BASE_URL, 'NEWO_BASE_URL');
211
+ logAuthEvent('info', 'Exchanging API key for tokens', { customerIdn: customerIdn || 'legacy' });
212
+ try {
213
+ const url = `${ENV.NEWO_BASE_URL}/api/v1/auth/api-key/token`;
214
+ const response = await axios.post(url, {}, {
215
+ timeout: REQUEST_TIMEOUT,
216
+ headers: {
217
+ 'x-api-key': apiKey,
218
+ 'accept': 'application/json',
219
+ 'user-agent': 'newo-cli/1.5.0'
220
+ }
221
+ });
222
+ if (!response.data) {
223
+ throw new Error('Empty response from token exchange endpoint');
224
+ }
225
+ const { access, refresh, expiresInSec } = normalizeTokenResponse(response.data);
226
+ const tokens = {
227
+ access_token: access,
228
+ refresh_token: refresh,
229
+ expires_at: Date.now() + expiresInSec * 1000
230
+ };
231
+ // Validate tokens before saving
232
+ validateTokens(tokens);
233
+ await saveTokens(tokens, customerIdn);
234
+ logAuthEvent('info', 'API key exchange completed successfully', {
235
+ customerIdn: customerIdn || 'legacy',
236
+ expiresAt: new Date(tokens.expires_at).toISOString()
237
+ });
238
+ return tokens;
239
+ }
240
+ catch (error) {
241
+ logAuthEvent('error', 'API key exchange failed', {
242
+ customerIdn: customerIdn || 'legacy',
243
+ error: error instanceof Error ? error.message : String(error)
244
+ });
245
+ handleNetworkError(error, 'exchange API key for token', customerIdn);
70
246
  }
71
- const tokens = {
72
- access_token: access,
73
- refresh_token: refresh,
74
- expires_at: Date.now() + expiresInSec * 1000
75
- };
76
- await saveTokens(tokens);
77
- return tokens;
78
247
  }
79
- export async function getValidAccessToken() {
80
- let tokens = await loadTokens();
81
- if (!tokens || !tokens.access_token) {
82
- tokens = await exchangeApiKeyForToken();
83
- return tokens.access_token;
248
+ export async function refreshWithEndpoint(refreshToken, customer) {
249
+ const customerIdn = customer?.idn;
250
+ // Validate inputs
251
+ if (!ENV.NEWO_REFRESH_URL) {
252
+ throw new Error('NEWO_REFRESH_URL not set in environment');
84
253
  }
85
- if (!isExpired(tokens)) {
86
- return tokens.access_token;
254
+ if (!refreshToken || typeof refreshToken !== 'string' || refreshToken.length < TOKEN_MIN_LENGTH) {
255
+ throw new Error(`Invalid refresh token${customerIdn ? ` for customer ${customerIdn}` : ''}: must be a non-empty string with minimum length`);
87
256
  }
88
- if (NEWO_REFRESH_URL && tokens.refresh_token) {
89
- try {
90
- tokens = await refreshWithEndpoint(tokens.refresh_token);
257
+ validateUrl(ENV.NEWO_REFRESH_URL, 'NEWO_REFRESH_URL');
258
+ logAuthEvent('info', 'Refreshing tokens using refresh endpoint', { customerIdn: customerIdn || 'legacy' });
259
+ try {
260
+ const response = await axios.post(ENV.NEWO_REFRESH_URL, { refresh_token: refreshToken }, {
261
+ timeout: REQUEST_TIMEOUT,
262
+ headers: {
263
+ 'accept': 'application/json',
264
+ 'user-agent': 'newo-cli/1.5.0'
265
+ }
266
+ });
267
+ if (!response.data) {
268
+ throw new Error('Empty response from token refresh endpoint');
269
+ }
270
+ const { access, expiresInSec } = normalizeTokenResponse(response.data);
271
+ const refresh = response.data.refresh_token || response.data.refreshToken || refreshToken;
272
+ const tokens = {
273
+ access_token: access,
274
+ refresh_token: refresh,
275
+ expires_at: Date.now() + expiresInSec * 1000
276
+ };
277
+ // Validate tokens before saving
278
+ validateTokens(tokens);
279
+ await saveTokens(tokens, customerIdn);
280
+ logAuthEvent('info', 'Token refresh completed successfully', {
281
+ customerIdn: customerIdn || 'legacy',
282
+ expiresAt: new Date(tokens.expires_at).toISOString()
283
+ });
284
+ return tokens;
285
+ }
286
+ catch (error) {
287
+ logAuthEvent('error', 'Token refresh failed', {
288
+ customerIdn: customerIdn || 'legacy',
289
+ error: error instanceof Error ? error.message : String(error)
290
+ });
291
+ handleNetworkError(error, 'refresh token', customerIdn);
292
+ }
293
+ }
294
+ export async function getValidAccessToken(customer) {
295
+ const customerIdn = customer?.idn;
296
+ logAuthEvent('info', 'Getting valid access token', { customerIdn: customerIdn || 'legacy' });
297
+ try {
298
+ let tokens = await loadTokens(customerIdn);
299
+ // No tokens found, exchange API key
300
+ if (!tokens || !tokens.access_token) {
301
+ logAuthEvent('info', 'No existing tokens found, exchanging API key', { customerIdn: customerIdn || 'legacy' });
302
+ tokens = await exchangeApiKeyForToken(customer);
91
303
  return tokens.access_token;
92
304
  }
93
- catch (error) {
94
- console.warn('Refresh failed, falling back to API key exchange…');
305
+ // Tokens are valid and not expired
306
+ if (!isExpired(tokens)) {
307
+ logAuthEvent('info', 'Using existing valid access token', { customerIdn: customerIdn || 'legacy' });
308
+ return tokens.access_token;
95
309
  }
310
+ // Try to refresh if refresh URL and token available
311
+ if (ENV.NEWO_REFRESH_URL && tokens.refresh_token) {
312
+ try {
313
+ logAuthEvent('info', 'Attempting to refresh expired token', { customerIdn: customerIdn || 'legacy' });
314
+ tokens = await refreshWithEndpoint(tokens.refresh_token, customer);
315
+ return tokens.access_token;
316
+ }
317
+ catch (error) {
318
+ const message = error instanceof Error ? error.message : String(error);
319
+ logAuthEvent('warn', 'Token refresh failed, falling back to API key exchange', {
320
+ customerIdn: customerIdn || 'legacy',
321
+ error: message
322
+ });
323
+ }
324
+ }
325
+ else {
326
+ logAuthEvent('info', 'No refresh endpoint or refresh token available, using API key exchange', {
327
+ customerIdn: customerIdn || 'legacy'
328
+ });
329
+ }
330
+ // Fallback to API key exchange
331
+ tokens = await exchangeApiKeyForToken(customer);
332
+ return tokens.access_token;
333
+ }
334
+ catch (error) {
335
+ logAuthEvent('error', 'Failed to get valid access token', {
336
+ customerIdn: customerIdn || 'legacy',
337
+ error: error instanceof Error ? error.message : String(error)
338
+ });
339
+ throw new Error(`Unable to obtain valid access token${customerIdn ? ` for customer ${customerIdn}` : ''}: ${error instanceof Error ? error.message : String(error)}`);
96
340
  }
97
- tokens = await exchangeApiKeyForToken();
98
- return tokens.access_token;
99
341
  }
100
- export async function forceReauth() {
101
- const tokens = await exchangeApiKeyForToken();
102
- return tokens.access_token;
342
+ export async function forceReauth(customer) {
343
+ const customerIdn = customer?.idn;
344
+ logAuthEvent('info', 'Forcing re-authentication', { customerIdn: customerIdn || 'legacy' });
345
+ try {
346
+ const tokens = await exchangeApiKeyForToken(customer);
347
+ logAuthEvent('info', 'Forced re-authentication completed successfully', {
348
+ customerIdn: customerIdn || 'legacy',
349
+ expiresAt: new Date(tokens.expires_at).toISOString()
350
+ });
351
+ return tokens.access_token;
352
+ }
353
+ catch (error) {
354
+ logAuthEvent('error', 'Forced re-authentication failed', {
355
+ customerIdn: customerIdn || 'legacy',
356
+ error: error instanceof Error ? error.message : String(error)
357
+ });
358
+ throw error;
359
+ }
103
360
  }
104
361
  //# sourceMappingURL=auth.js.map