@lanonasis/cli 3.9.4 → 3.9.6

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.
@@ -434,6 +434,200 @@ export class CLIConfig {
434
434
  }
435
435
  throw new Error('Auth health endpoints unreachable');
436
436
  }
437
+ getAuthVerificationEndpoints(pathname) {
438
+ const authBase = (this.config.discoveredServices?.auth_base || 'https://auth.lanonasis.com').replace(/\/$/, '');
439
+ return Array.from(new Set([
440
+ `${authBase}${pathname}`,
441
+ `https://auth.lanonasis.com${pathname}`,
442
+ `http://localhost:4000${pathname}`
443
+ ]));
444
+ }
445
+ extractAuthErrorMessage(payload) {
446
+ if (!payload || typeof payload !== 'object') {
447
+ return undefined;
448
+ }
449
+ const data = payload;
450
+ const fields = ['message', 'error', 'reason', 'code'];
451
+ for (const field of fields) {
452
+ const value = data[field];
453
+ if (typeof value === 'string' && value.trim().length > 0) {
454
+ return value;
455
+ }
456
+ }
457
+ return undefined;
458
+ }
459
+ async verifyTokenWithAuthGateway(token) {
460
+ const headers = {
461
+ 'Authorization': `Bearer ${token}`,
462
+ 'X-Project-Scope': 'lanonasis-maas'
463
+ };
464
+ let fallbackReason = 'Unable to verify token with auth gateway';
465
+ // Primary check (required by auth contract): /v1/auth/verify
466
+ for (const endpoint of this.getAuthVerificationEndpoints('/v1/auth/verify')) {
467
+ try {
468
+ const response = await axios.post(endpoint, {}, {
469
+ headers,
470
+ timeout: 5000,
471
+ proxy: false
472
+ });
473
+ const payload = response.data;
474
+ if (payload.valid === true || Boolean(payload.payload)) {
475
+ return { valid: true, method: 'token', endpoint };
476
+ }
477
+ if (payload.valid === false) {
478
+ return {
479
+ valid: false,
480
+ method: 'token',
481
+ endpoint,
482
+ reason: this.extractAuthErrorMessage(payload) || 'Token is invalid'
483
+ };
484
+ }
485
+ }
486
+ catch (error) {
487
+ const normalizedError = this.normalizeServiceError(error);
488
+ const responsePayload = normalizedError.response?.data;
489
+ const responseCode = typeof responsePayload?.code === 'string' ? responsePayload.code : undefined;
490
+ const reason = this.extractAuthErrorMessage(responsePayload) || normalizedError.message || fallbackReason;
491
+ fallbackReason = reason;
492
+ // If auth gateway explicitly rejected token, stop early.
493
+ if ((normalizedError.response?.status === 401 || normalizedError.response?.status === 403) &&
494
+ responseCode &&
495
+ responseCode !== 'AUTH_TOKEN_MISSING') {
496
+ return { valid: false, method: 'token', endpoint, reason };
497
+ }
498
+ }
499
+ }
500
+ // Fallback for deployments where proxy layers strip Authorization headers.
501
+ for (const endpoint of this.getAuthVerificationEndpoints('/v1/auth/verify-token')) {
502
+ try {
503
+ const response = await axios.post(endpoint, { token }, {
504
+ headers: {
505
+ 'Content-Type': 'application/json',
506
+ 'X-Project-Scope': 'lanonasis-maas'
507
+ },
508
+ timeout: 5000,
509
+ proxy: false
510
+ });
511
+ const payload = response.data;
512
+ if (payload.valid === true) {
513
+ return { valid: true, method: 'token', endpoint };
514
+ }
515
+ if (payload.valid === false) {
516
+ return {
517
+ valid: false,
518
+ method: 'token',
519
+ endpoint,
520
+ reason: this.extractAuthErrorMessage(payload) || 'Token is invalid'
521
+ };
522
+ }
523
+ }
524
+ catch (error) {
525
+ const normalizedError = this.normalizeServiceError(error);
526
+ const responsePayload = normalizedError.response?.data;
527
+ fallbackReason = this.extractAuthErrorMessage(responsePayload) || normalizedError.message || fallbackReason;
528
+ }
529
+ }
530
+ return {
531
+ valid: false,
532
+ method: 'token',
533
+ reason: fallbackReason
534
+ };
535
+ }
536
+ async verifyVendorKeyWithAuthGateway(vendorKey) {
537
+ const headers = {
538
+ 'X-API-Key': vendorKey,
539
+ 'X-Auth-Method': 'vendor_key',
540
+ 'X-Project-Scope': 'lanonasis-maas'
541
+ };
542
+ let fallbackReason = 'Unable to verify API key with auth gateway';
543
+ // Primary check (required by auth contract): /v1/auth/verify
544
+ for (const endpoint of this.getAuthVerificationEndpoints('/v1/auth/verify')) {
545
+ try {
546
+ const response = await axios.post(endpoint, {}, {
547
+ headers,
548
+ timeout: 5000,
549
+ proxy: false
550
+ });
551
+ const payload = response.data;
552
+ if (payload.valid === true || Boolean(payload.payload)) {
553
+ return { valid: true, method: 'vendor_key', endpoint };
554
+ }
555
+ if (payload.valid === false) {
556
+ return {
557
+ valid: false,
558
+ method: 'vendor_key',
559
+ endpoint,
560
+ reason: this.extractAuthErrorMessage(payload) || 'API key is invalid'
561
+ };
562
+ }
563
+ }
564
+ catch (error) {
565
+ const normalizedError = this.normalizeServiceError(error);
566
+ const responsePayload = normalizedError.response?.data;
567
+ const responseCode = typeof responsePayload?.code === 'string' ? responsePayload.code : undefined;
568
+ const reason = this.extractAuthErrorMessage(responsePayload) || normalizedError.message || fallbackReason;
569
+ fallbackReason = reason;
570
+ // If auth gateway explicitly rejected API key, stop early.
571
+ if ((normalizedError.response?.status === 401 || normalizedError.response?.status === 403) &&
572
+ responseCode &&
573
+ responseCode !== 'AUTH_TOKEN_MISSING') {
574
+ return { valid: false, method: 'vendor_key', endpoint, reason };
575
+ }
576
+ }
577
+ }
578
+ // Fallback for deployments where reverse proxies don't forward custom auth headers on /verify.
579
+ for (const endpoint of this.getAuthVerificationEndpoints('/v1/auth/verify-api-key')) {
580
+ try {
581
+ const response = await axios.post(endpoint, {}, {
582
+ headers,
583
+ timeout: 5000,
584
+ proxy: false
585
+ });
586
+ const payload = response.data;
587
+ if (payload.valid === true) {
588
+ return { valid: true, method: 'vendor_key', endpoint };
589
+ }
590
+ if (payload.valid === false) {
591
+ return {
592
+ valid: false,
593
+ method: 'vendor_key',
594
+ endpoint,
595
+ reason: this.extractAuthErrorMessage(payload) || 'API key is invalid'
596
+ };
597
+ }
598
+ }
599
+ catch (error) {
600
+ const normalizedError = this.normalizeServiceError(error);
601
+ const responsePayload = normalizedError.response?.data;
602
+ fallbackReason = this.extractAuthErrorMessage(responsePayload) || normalizedError.message || fallbackReason;
603
+ }
604
+ }
605
+ return {
606
+ valid: false,
607
+ method: 'vendor_key',
608
+ reason: fallbackReason
609
+ };
610
+ }
611
+ async verifyCurrentCredentialsWithServer() {
612
+ await this.refreshTokenIfNeeded();
613
+ await this.discoverServices();
614
+ const token = this.getToken();
615
+ const vendorKey = await this.getVendorKeyAsync();
616
+ if (this.config.authMethod === 'vendor_key' && vendorKey) {
617
+ return this.verifyVendorKeyWithAuthGateway(vendorKey);
618
+ }
619
+ if (token) {
620
+ return this.verifyTokenWithAuthGateway(token);
621
+ }
622
+ if (vendorKey) {
623
+ return this.verifyVendorKeyWithAuthGateway(vendorKey);
624
+ }
625
+ return {
626
+ valid: false,
627
+ method: 'none',
628
+ reason: 'No credentials configured'
629
+ };
630
+ }
437
631
  // Manual endpoint override functionality
438
632
  async setManualEndpoints(endpoints) {
439
633
  if (!this.config.discoveredServices) {
@@ -520,16 +714,12 @@ export class CLIConfig {
520
714
  return;
521
715
  }
522
716
  try {
523
- // Import axios dynamically to avoid circular dependency
524
- // Ensure service discovery is done
525
717
  await this.discoverServices();
526
- const authBase = this.config.discoveredServices?.auth_base || 'https://auth.lanonasis.com';
527
- // Use pingAuthHealth for validation (simpler and more reliable)
528
- await this.pingAuthHealth(axios, authBase, {
529
- 'X-API-Key': vendorKey,
530
- 'X-Auth-Method': 'vendor_key',
531
- 'X-Project-Scope': 'lanonasis-maas'
532
- }, { timeout: 10000, proxy: false });
718
+ const verification = await this.verifyVendorKeyWithAuthGateway(vendorKey);
719
+ if (verification.valid) {
720
+ return;
721
+ }
722
+ throw new Error(verification.reason || 'Authentication failed. The key may be invalid, expired, or revoked.');
533
723
  }
534
724
  catch (error) {
535
725
  const normalizedError = this.normalizeServiceError(error);
@@ -678,6 +868,9 @@ export class CLIConfig {
678
868
  return this.config.user;
679
869
  }
680
870
  async isAuthenticated() {
871
+ // Attempt refresh for OAuth sessions before checks (prevents intermittent auth dropouts).
872
+ // This is safe to call even when not using OAuth; it will no-op.
873
+ await this.refreshTokenIfNeeded();
681
874
  // Check if using vendor key authentication
682
875
  if (this.config.authMethod === 'vendor_key') {
683
876
  // Use async method to read from encrypted ApiKeyStorage
@@ -698,21 +891,17 @@ export class CLIConfig {
698
891
  }
699
892
  // Vendor key not recently validated - verify with server
700
893
  try {
701
- await this.discoverServices();
702
- const authBase = this.config.discoveredServices?.auth_base || 'https://auth.lanonasis.com';
703
- // Ping auth health with vendor key to verify it's still valid
704
- await this.pingAuthHealth(axios, authBase, {
705
- 'X-API-Key': vendorKey,
706
- 'X-Auth-Method': 'vendor_key',
707
- 'X-Project-Scope': 'lanonasis-maas'
708
- }, { timeout: 5000, proxy: false });
894
+ const verification = await this.verifyVendorKeyWithAuthGateway(vendorKey);
895
+ if (!verification.valid) {
896
+ throw new Error(verification.reason || 'Vendor key validation failed');
897
+ }
709
898
  // Update last validated timestamp on success
710
899
  this.config.lastValidated = new Date().toISOString();
711
900
  await this.save().catch(() => { }); // Don't fail auth check if save fails
712
901
  this.authCheckCache = { isValid: true, timestamp: Date.now() };
713
902
  return true;
714
903
  }
715
- catch (error) {
904
+ catch {
716
905
  // Server validation failed - check for grace period (7 days offline)
717
906
  const gracePeriod = 7 * 24 * 60 * 60 * 1000;
718
907
  const withinGracePeriod = lastValidated &&
@@ -736,6 +925,16 @@ export class CLIConfig {
736
925
  const token = this.getToken();
737
926
  if (!token)
738
927
  return false;
928
+ // OAuth tokens are often opaque (not JWT). Prefer local expiry metadata when present.
929
+ if (this.config.authMethod === 'oauth') {
930
+ const tokenExpiresAt = this.get('token_expires_at');
931
+ if (typeof tokenExpiresAt === 'number') {
932
+ const isValid = Date.now() < tokenExpiresAt;
933
+ this.authCheckCache = { isValid, timestamp: Date.now() };
934
+ return isValid;
935
+ }
936
+ // Fall through to legacy validation when we don't have expiry metadata.
937
+ }
739
938
  // Check cache first
740
939
  if (this.authCheckCache && (Date.now() - this.authCheckCache.timestamp) < this.AUTH_CACHE_TTL) {
741
940
  return this.authCheckCache.isValid;
@@ -934,22 +1133,14 @@ export class CLIConfig {
934
1133
  if (!vendorKey && !token) {
935
1134
  return false;
936
1135
  }
937
- // Import axios dynamically to avoid circular dependency
938
- // Ensure service discovery is done
939
- await this.discoverServices();
940
- const authBase = this.config.discoveredServices?.auth_base || 'https://auth.lanonasis.com';
941
- const headers = {
942
- 'X-Project-Scope': 'lanonasis-maas'
943
- };
944
- if (vendorKey) {
945
- headers['X-API-Key'] = vendorKey;
946
- headers['X-Auth-Method'] = 'vendor_key';
947
- }
948
- else if (token) {
949
- headers['Authorization'] = `Bearer ${token}`;
950
- headers['X-Auth-Method'] = 'jwt';
1136
+ const verification = this.config.authMethod === 'vendor_key' && vendorKey
1137
+ ? await this.verifyVendorKeyWithAuthGateway(vendorKey)
1138
+ : token
1139
+ ? await this.verifyTokenWithAuthGateway(token)
1140
+ : await this.verifyVendorKeyWithAuthGateway(vendorKey);
1141
+ if (!verification.valid) {
1142
+ throw new Error(verification.reason || 'Stored credentials are invalid');
951
1143
  }
952
- await this.pingAuthHealth(axios, authBase, headers);
953
1144
  // Update last validated timestamp
954
1145
  this.config.lastValidated = new Date().toISOString();
955
1146
  await this.resetFailureCount();
@@ -968,11 +1159,85 @@ export class CLIConfig {
968
1159
  return;
969
1160
  }
970
1161
  try {
1162
+ // OAuth token refresh (opaque tokens + refresh_token + token_expires_at)
1163
+ if (this.config.authMethod === 'oauth') {
1164
+ const refreshToken = this.get('refresh_token');
1165
+ if (!refreshToken) {
1166
+ return;
1167
+ }
1168
+ const tokenExpiresAtRaw = this.get('token_expires_at');
1169
+ const tokenExpiresAt = (() => {
1170
+ const n = typeof tokenExpiresAtRaw === 'number'
1171
+ ? tokenExpiresAtRaw
1172
+ : typeof tokenExpiresAtRaw === 'string'
1173
+ ? Number(tokenExpiresAtRaw)
1174
+ : undefined;
1175
+ if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) {
1176
+ return undefined;
1177
+ }
1178
+ // Support both seconds and milliseconds since epoch.
1179
+ // Seconds are ~1.7e9; ms are ~1.7e12.
1180
+ return n < 1e11 ? n * 1000 : n;
1181
+ })();
1182
+ const nowMs = Date.now();
1183
+ const refreshWindowMs = 5 * 60 * 1000; // 5 minutes
1184
+ // If we don't know expiry, don't force a refresh.
1185
+ if (typeof tokenExpiresAt !== 'number' || nowMs < (tokenExpiresAt - refreshWindowMs)) {
1186
+ return;
1187
+ }
1188
+ await this.discoverServices();
1189
+ const authBase = this.getDiscoveredApiUrl();
1190
+ const resp = await axios.post(`${authBase}/oauth/token`, {
1191
+ grant_type: 'refresh_token',
1192
+ refresh_token: refreshToken,
1193
+ client_id: 'lanonasis-cli'
1194
+ }, {
1195
+ headers: { 'Content-Type': 'application/json' },
1196
+ timeout: 10000,
1197
+ proxy: false
1198
+ });
1199
+ // Some gateways wrap responses as `{ data: { ... } }`.
1200
+ const raw = resp?.data;
1201
+ const payload = raw && typeof raw === 'object' && raw.data && typeof raw.data === 'object'
1202
+ ? raw.data
1203
+ : raw;
1204
+ const accessToken = payload?.access_token ?? payload?.token;
1205
+ const refreshedRefreshToken = payload?.refresh_token;
1206
+ const expiresIn = payload?.expires_in;
1207
+ if (typeof accessToken !== 'string' || accessToken.length === 0) {
1208
+ throw new Error('Token refresh response missing access_token');
1209
+ }
1210
+ // setToken() assumes JWT by default; ensure authMethod stays oauth after storing.
1211
+ await this.setToken(accessToken);
1212
+ this.config.authMethod = 'oauth';
1213
+ if (typeof refreshedRefreshToken === 'string' && refreshedRefreshToken.length > 0) {
1214
+ this.config.refresh_token = refreshedRefreshToken;
1215
+ }
1216
+ if (typeof expiresIn === 'number' && Number.isFinite(expiresIn)) {
1217
+ this.config.token_expires_at = Date.now() + (expiresIn * 1000);
1218
+ }
1219
+ // Keep the encrypted "vendor key" in sync for MCP/WebSocket clients that use X-API-Key.
1220
+ // This does not change authMethod away from oauth (setVendorKey guards against that).
1221
+ try {
1222
+ await this.setVendorKey(accessToken);
1223
+ }
1224
+ catch {
1225
+ // Non-fatal: bearer token refresh still helps API calls.
1226
+ }
1227
+ await this.save().catch(() => { });
1228
+ return;
1229
+ }
971
1230
  // Check if token is JWT and if it's close to expiry
972
1231
  if (token.startsWith('cli_')) {
973
1232
  // CLI tokens don't need refresh, they're long-lived
974
1233
  return;
975
1234
  }
1235
+ // Only attempt JWT refresh for tokens that look like JWTs.
1236
+ // OAuth access tokens in this system can be opaque strings; treating them as JWTs
1237
+ // creates noisy failures and can cause unwanted state writes.
1238
+ if (token.split('.').length !== 3) {
1239
+ return;
1240
+ }
976
1241
  const decoded = jwtDecode(token);
977
1242
  const now = Date.now() / 1000;
978
1243
  const exp = typeof decoded.exp === 'number' ? decoded.exp : 0;
@@ -47,6 +47,7 @@ export declare class TextInputHandlerImpl implements TextInputHandler {
47
47
  * Parse raw key event from buffer
48
48
  */
49
49
  private parseKeyEvent;
50
+ private resumeStdinIfSupported;
50
51
  /**
51
52
  * Check if a key event matches a key pattern
52
53
  */
@@ -152,7 +152,7 @@ export class TextInputHandlerImpl {
152
152
  };
153
153
  // Ensure stdin is flowing before adding listener
154
154
  // This is critical after inquirer prompts which may pause stdin
155
- process.stdin.resume();
155
+ this.resumeStdinIfSupported();
156
156
  process.stdin.on('data', handleKeypress);
157
157
  }
158
158
  catch (error) {
@@ -168,13 +168,13 @@ export class TextInputHandlerImpl {
168
168
  if (!this.isRawModeEnabled && process.stdin.isTTY) {
169
169
  this.originalStdinMode = process.stdin.isRaw;
170
170
  process.stdin.setRawMode(true);
171
- process.stdin.resume(); // Ensure stdin is flowing to receive data events
171
+ this.resumeStdinIfSupported(); // Ensure stdin is flowing to receive data events
172
172
  this.isRawModeEnabled = true;
173
173
  }
174
174
  else if (!process.stdin.isTTY) {
175
175
  // Non-TTY mode - can't use raw mode, fall back to line mode
176
176
  console.error('Warning: Not a TTY, inline text input may not work correctly');
177
- process.stdin.resume();
177
+ this.resumeStdinIfSupported();
178
178
  }
179
179
  }
180
180
  /**
@@ -302,6 +302,11 @@ export class TextInputHandlerImpl {
302
302
  }
303
303
  return key;
304
304
  }
305
+ resumeStdinIfSupported() {
306
+ if (typeof process.stdin.resume === 'function') {
307
+ process.stdin.resume();
308
+ }
309
+ }
305
310
  /**
306
311
  * Check if a key event matches a key pattern
307
312
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanonasis/cli",
3
- "version": "3.9.4",
3
+ "version": "3.9.6",
4
4
  "description": "Professional CLI for LanOnasis Memory as a Service (MaaS) with MCP support, seamless inline editing, and enterprise-grade security",
5
5
  "keywords": [
6
6
  "lanonasis",
@@ -73,14 +73,14 @@
73
73
  "zod": "^3.24.4"
74
74
  },
75
75
  "devDependencies": {
76
- "@jest/globals": "^29.7.0",
76
+ "@jest/globals": "^27.5.1",
77
77
  "@types/cli-progress": "^3.11.6",
78
78
  "@types/inquirer": "^9.0.7",
79
79
  "@types/node": "^22.19.3",
80
80
  "@types/ws": "^8.5.12",
81
81
  "fast-check": "^3.15.1",
82
- "jest": "^29.7.0",
83
- "rimraf": "^5.0.7",
82
+ "jest": "^25.0.0",
83
+ "rimraf": "^6.1.3",
84
84
  "ts-jest": "^29.1.1",
85
85
  "typescript": "^5.7.2"
86
86
  },
@@ -88,7 +88,7 @@
88
88
  "build": "rimraf dist && tsc -p tsconfig.json",
89
89
  "prepublishOnly": "npm run build",
90
90
  "postinstall": "node scripts/postinstall.js",
91
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
91
+ "test": "[ -f node_modules/jest/bin/jest.js ] || bun install --no-save; node --experimental-vm-modules node_modules/jest/bin/jest.js",
92
92
  "test:watch": "npm test -- --watch",
93
93
  "test:coverage": "npm test -- --coverage"
94
94
  }