chore: address oauth PR concerns (#1184)
This commit is contained in:
150
packages/tm-core/src/auth/auth-manager.test.ts
Normal file
150
packages/tm-core/src/auth/auth-manager.test.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Tests for AuthManager singleton behavior
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the logger to verify warnings (must be hoisted before SUT import)
|
||||||
|
const mockLogger = {
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
error: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../logger/index.js', () => ({
|
||||||
|
getLogger: () => mockLogger
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Spy on CredentialStore constructor to verify config propagation
|
||||||
|
const CredentialStoreSpy = vi.fn();
|
||||||
|
vi.mock('./credential-store.js', () => {
|
||||||
|
return {
|
||||||
|
CredentialStore: class {
|
||||||
|
constructor(config: any) {
|
||||||
|
CredentialStoreSpy(config);
|
||||||
|
this.getCredentials = vi.fn(() => null);
|
||||||
|
}
|
||||||
|
getCredentials() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
saveCredentials() {}
|
||||||
|
clearCredentials() {}
|
||||||
|
hasValidCredentials() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock OAuthService to avoid side effects
|
||||||
|
vi.mock('./oauth-service.js', () => {
|
||||||
|
return {
|
||||||
|
OAuthService: class {
|
||||||
|
constructor() {}
|
||||||
|
authenticate() {
|
||||||
|
return Promise.resolve({});
|
||||||
|
}
|
||||||
|
getAuthorizationUrl() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock SupabaseAuthClient to avoid side effects
|
||||||
|
vi.mock('../clients/supabase-client.js', () => {
|
||||||
|
return {
|
||||||
|
SupabaseAuthClient: class {
|
||||||
|
constructor() {}
|
||||||
|
refreshSession() {
|
||||||
|
return Promise.resolve({});
|
||||||
|
}
|
||||||
|
signOut() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import SUT after mocks
|
||||||
|
import { AuthManager } from './auth-manager.js';
|
||||||
|
|
||||||
|
describe('AuthManager Singleton', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset singleton before each test
|
||||||
|
AuthManager.resetInstance();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
CredentialStoreSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the same instance on multiple calls', () => {
|
||||||
|
const instance1 = AuthManager.getInstance();
|
||||||
|
const instance2 = AuthManager.getInstance();
|
||||||
|
|
||||||
|
expect(instance1).toBe(instance2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use config on first call', () => {
|
||||||
|
const config = {
|
||||||
|
baseUrl: 'https://test.auth.com',
|
||||||
|
configDir: '/test/config',
|
||||||
|
configFile: '/test/config/auth.json'
|
||||||
|
};
|
||||||
|
|
||||||
|
const instance = AuthManager.getInstance(config);
|
||||||
|
expect(instance).toBeDefined();
|
||||||
|
|
||||||
|
// Assert that CredentialStore was constructed with the provided config
|
||||||
|
expect(CredentialStoreSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(CredentialStoreSpy).toHaveBeenCalledWith(config);
|
||||||
|
|
||||||
|
// Verify the config is passed to internal components through observable behavior
|
||||||
|
// getCredentials would look in the configured file path
|
||||||
|
const credentials = instance.getCredentials();
|
||||||
|
expect(credentials).toBeNull(); // File doesn't exist, but config was propagated correctly
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn when config is provided after initialization', () => {
|
||||||
|
// Clear previous calls
|
||||||
|
mockLogger.warn.mockClear();
|
||||||
|
|
||||||
|
// First call with config
|
||||||
|
AuthManager.getInstance({ baseUrl: 'https://first.auth.com' });
|
||||||
|
|
||||||
|
// Second call with different config
|
||||||
|
AuthManager.getInstance({ baseUrl: 'https://second.auth.com' });
|
||||||
|
|
||||||
|
// Verify warning was logged
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/config.*after initialization.*ignored/i)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not warn when no config is provided after initialization', () => {
|
||||||
|
// Clear previous calls
|
||||||
|
mockLogger.warn.mockClear();
|
||||||
|
|
||||||
|
// First call with config
|
||||||
|
AuthManager.getInstance({ configDir: '/test/config' });
|
||||||
|
|
||||||
|
// Second call without config
|
||||||
|
AuthManager.getInstance();
|
||||||
|
|
||||||
|
// Verify no warning was logged
|
||||||
|
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow resetting the instance', () => {
|
||||||
|
const instance1 = AuthManager.getInstance();
|
||||||
|
|
||||||
|
// Reset the instance
|
||||||
|
AuthManager.resetInstance();
|
||||||
|
|
||||||
|
// Get new instance
|
||||||
|
const instance2 = AuthManager.getInstance();
|
||||||
|
|
||||||
|
// They should be different instances
|
||||||
|
expect(instance1).not.toBe(instance2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,16 +7,17 @@ import {
|
|||||||
OAuthFlowOptions,
|
OAuthFlowOptions,
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
AuthConfig
|
AuthConfig
|
||||||
} from './types';
|
} from './types.js';
|
||||||
import { CredentialStore } from './credential-store';
|
import { CredentialStore } from './credential-store.js';
|
||||||
import { OAuthService } from './oauth-service';
|
import { OAuthService } from './oauth-service.js';
|
||||||
import { SupabaseAuthClient } from '../clients/supabase-client';
|
import { SupabaseAuthClient } from '../clients/supabase-client.js';
|
||||||
|
import { getLogger } from '../logger/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication manager class
|
* Authentication manager class
|
||||||
*/
|
*/
|
||||||
export class AuthManager {
|
export class AuthManager {
|
||||||
private static instance: AuthManager;
|
private static instance: AuthManager | null = null;
|
||||||
private credentialStore: CredentialStore;
|
private credentialStore: CredentialStore;
|
||||||
private oauthService: OAuthService;
|
private oauthService: OAuthService;
|
||||||
private supabaseClient: SupabaseAuthClient;
|
private supabaseClient: SupabaseAuthClient;
|
||||||
@@ -33,10 +34,23 @@ export class AuthManager {
|
|||||||
static getInstance(config?: Partial<AuthConfig>): AuthManager {
|
static getInstance(config?: Partial<AuthConfig>): AuthManager {
|
||||||
if (!AuthManager.instance) {
|
if (!AuthManager.instance) {
|
||||||
AuthManager.instance = new AuthManager(config);
|
AuthManager.instance = new AuthManager(config);
|
||||||
|
} else if (config) {
|
||||||
|
// Warn if config is provided after initialization
|
||||||
|
const logger = getLogger('AuthManager');
|
||||||
|
logger.warn(
|
||||||
|
'getInstance called with config after initialization; config is ignored.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return AuthManager.instance;
|
return AuthManager.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (useful for testing)
|
||||||
|
*/
|
||||||
|
static resetInstance(): void {
|
||||||
|
AuthManager.instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get stored authentication credentials
|
* Get stored authentication credentials
|
||||||
*/
|
*/
|
||||||
@@ -60,29 +74,6 @@ export class AuthManager {
|
|||||||
return this.oauthService.getAuthorizationUrl();
|
return this.oauthService.getAuthorizationUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate with API key
|
|
||||||
* Note: This would require a custom implementation or Supabase RLS policies
|
|
||||||
*/
|
|
||||||
async authenticateWithApiKey(apiKey: string): Promise<AuthCredentials> {
|
|
||||||
const token = apiKey.trim();
|
|
||||||
if (!token || token.length < 10) {
|
|
||||||
throw new AuthenticationError('Invalid API key', 'INVALID_API_KEY');
|
|
||||||
}
|
|
||||||
|
|
||||||
const authData: AuthCredentials = {
|
|
||||||
token,
|
|
||||||
tokenType: 'api_key',
|
|
||||||
userId: 'api-user',
|
|
||||||
email: undefined,
|
|
||||||
expiresAt: undefined, // API keys don't expire
|
|
||||||
savedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
this.credentialStore.saveCredentials(authData);
|
|
||||||
return authData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh authentication token
|
* Refresh authentication token
|
||||||
*/
|
*/
|
||||||
@@ -129,7 +120,7 @@ export class AuthManager {
|
|||||||
await this.supabaseClient.signOut();
|
await this.supabaseClient.signOut();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log but don't throw - we still want to clear local credentials
|
// Log but don't throw - we still want to clear local credentials
|
||||||
console.warn('Failed to sign out from Supabase:', error);
|
getLogger('AuthManager').warn('Failed to sign out from Supabase:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always clear local credentials (removes auth.json file)
|
// Always clear local credentials (removes auth.json file)
|
||||||
@@ -142,22 +133,4 @@ export class AuthManager {
|
|||||||
isAuthenticated(): boolean {
|
isAuthenticated(): boolean {
|
||||||
return this.credentialStore.hasValidCredentials();
|
return this.credentialStore.hasValidCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get authorization headers
|
|
||||||
*/
|
|
||||||
getAuthHeaders(): Record<string, string> {
|
|
||||||
const authData = this.getCredentials();
|
|
||||||
|
|
||||||
if (!authData) {
|
|
||||||
throw new AuthenticationError(
|
|
||||||
'Not authenticated. Please authenticate first.',
|
|
||||||
'NOT_AUTHENTICATED'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
Authorization: `Bearer ${authData.token}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { AuthConfig } from './types';
|
import { AuthConfig } from './types.js';
|
||||||
|
|
||||||
// Single base domain for all URLs
|
// Single base domain for all URLs
|
||||||
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsup's env option
|
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsup's env option
|
||||||
|
|||||||
575
packages/tm-core/src/auth/credential-store.test.ts
Normal file
575
packages/tm-core/src/auth/credential-store.test.ts
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
/**
|
||||||
|
* Tests for CredentialStore with numeric and string timestamp handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { CredentialStore } from './credential-store.js';
|
||||||
|
import { AuthenticationError } from './types.js';
|
||||||
|
import type { AuthCredentials } from './types.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
// Mock fs module
|
||||||
|
vi.mock('fs');
|
||||||
|
|
||||||
|
// Mock logger
|
||||||
|
const mockLogger = {
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
error: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../logger/index.js', () => ({
|
||||||
|
getLogger: () => mockLogger
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('CredentialStore', () => {
|
||||||
|
let store: CredentialStore;
|
||||||
|
const testDir = '/test/config';
|
||||||
|
const configFile = '/test/config/auth.json';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
store = new CredentialStore({
|
||||||
|
configDir: testDir,
|
||||||
|
configFile: configFile,
|
||||||
|
baseUrl: 'https://api.test.com'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCredentials with timestamp migration', () => {
|
||||||
|
it('should handle string ISO timestamp correctly', () => {
|
||||||
|
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
|
||||||
|
const mockCredentials: AuthCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-123',
|
||||||
|
email: 'test@example.com',
|
||||||
|
expiresAt: futureDate.toISOString(),
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||||
|
JSON.stringify(mockCredentials)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = store.getCredentials();
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.token).toBe('test-token');
|
||||||
|
// The timestamp should be normalized to numeric milliseconds
|
||||||
|
expect(typeof result?.expiresAt).toBe('number');
|
||||||
|
expect(result?.expiresAt).toBe(futureDate.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle numeric timestamp correctly', () => {
|
||||||
|
const futureTimestamp = Date.now() + 7200000; // 2 hours from now
|
||||||
|
const mockCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-456',
|
||||||
|
email: 'test2@example.com',
|
||||||
|
expiresAt: futureTimestamp,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||||
|
JSON.stringify(mockCredentials)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = store.getCredentials();
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.token).toBe('test-token');
|
||||||
|
// Numeric timestamp should remain as-is
|
||||||
|
expect(typeof result?.expiresAt).toBe('number');
|
||||||
|
expect(result?.expiresAt).toBe(futureTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid string timestamp', () => {
|
||||||
|
const mockCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-789',
|
||||||
|
expiresAt: 'invalid-date-string',
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||||
|
JSON.stringify(mockCredentials)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = store.getCredentials();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
'No valid expiration time provided for token'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject NaN timestamp', () => {
|
||||||
|
const mockCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-nan',
|
||||||
|
expiresAt: NaN,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||||
|
JSON.stringify(mockCredentials)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = store.getCredentials();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
'No valid expiration time provided for token'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject Infinity timestamp', () => {
|
||||||
|
const mockCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-inf',
|
||||||
|
expiresAt: Infinity,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||||
|
JSON.stringify(mockCredentials)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = store.getCredentials();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
'No valid expiration time provided for token'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing expiresAt field', () => {
|
||||||
|
const mockCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-no-expiry',
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
// No expiresAt field
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||||
|
JSON.stringify(mockCredentials)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = store.getCredentials();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
'No valid expiration time provided for token'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check token expiration correctly', () => {
|
||||||
|
const expiredTimestamp = Date.now() - 3600000; // 1 hour ago
|
||||||
|
const mockCredentials = {
|
||||||
|
token: 'expired-token',
|
||||||
|
userId: 'user-expired',
|
||||||
|
expiresAt: expiredTimestamp,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||||
|
JSON.stringify(mockCredentials)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = store.getCredentials();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Authentication token has expired'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow expired tokens when requested', () => {
|
||||||
|
const expiredTimestamp = Date.now() - 3600000; // 1 hour ago
|
||||||
|
const mockCredentials = {
|
||||||
|
token: 'expired-token',
|
||||||
|
userId: 'user-expired',
|
||||||
|
expiresAt: expiredTimestamp,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||||
|
JSON.stringify(mockCredentials)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = store.getCredentials({ allowExpired: true });
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.token).toBe('expired-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveCredentials with timestamp normalization', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||||
|
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||||
|
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize string timestamp to ISO string when saving', () => {
|
||||||
|
const futureDate = new Date(Date.now() + 3600000);
|
||||||
|
const credentials: AuthCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-123',
|
||||||
|
expiresAt: futureDate.toISOString(),
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
store.saveCredentials(credentials);
|
||||||
|
|
||||||
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('.tmp'),
|
||||||
|
expect.stringContaining('"expiresAt":'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that the written data contains a valid ISO string
|
||||||
|
const writtenData = vi.mocked(fs.writeFileSync).mock
|
||||||
|
.calls[0][1] as string;
|
||||||
|
const parsed = JSON.parse(writtenData);
|
||||||
|
expect(typeof parsed.expiresAt).toBe('string');
|
||||||
|
expect(new Date(parsed.expiresAt).toISOString()).toBe(parsed.expiresAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert numeric timestamp to ISO string when saving', () => {
|
||||||
|
const futureTimestamp = Date.now() + 7200000;
|
||||||
|
const credentials: AuthCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-456',
|
||||||
|
expiresAt: futureTimestamp,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
store.saveCredentials(credentials);
|
||||||
|
|
||||||
|
const writtenData = vi.mocked(fs.writeFileSync).mock
|
||||||
|
.calls[0][1] as string;
|
||||||
|
const parsed = JSON.parse(writtenData);
|
||||||
|
expect(typeof parsed.expiresAt).toBe('string');
|
||||||
|
expect(new Date(parsed.expiresAt).getTime()).toBe(futureTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid string timestamp when saving', () => {
|
||||||
|
const credentials: AuthCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-789',
|
||||||
|
expiresAt: 'invalid-date' as any,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
let err: unknown;
|
||||||
|
try {
|
||||||
|
store.saveCredentials(credentials);
|
||||||
|
} catch (e) {
|
||||||
|
err = e;
|
||||||
|
}
|
||||||
|
expect(err).toBeInstanceOf(AuthenticationError);
|
||||||
|
expect((err as Error).message).toContain('Invalid expiresAt format');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject NaN timestamp when saving', () => {
|
||||||
|
const credentials: AuthCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-nan',
|
||||||
|
expiresAt: NaN as any,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
let err: unknown;
|
||||||
|
try {
|
||||||
|
store.saveCredentials(credentials);
|
||||||
|
} catch (e) {
|
||||||
|
err = e;
|
||||||
|
}
|
||||||
|
expect(err).toBeInstanceOf(AuthenticationError);
|
||||||
|
expect((err as Error).message).toContain('Invalid expiresAt format');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject Infinity timestamp when saving', () => {
|
||||||
|
const credentials: AuthCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-inf',
|
||||||
|
expiresAt: Infinity as any,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
let err: unknown;
|
||||||
|
try {
|
||||||
|
store.saveCredentials(credentials);
|
||||||
|
} catch (e) {
|
||||||
|
err = e;
|
||||||
|
}
|
||||||
|
expect(err).toBeInstanceOf(AuthenticationError);
|
||||||
|
expect((err as Error).message).toContain('Invalid expiresAt format');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing expiresAt when saving', () => {
|
||||||
|
const credentials: AuthCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-no-expiry',
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
// No expiresAt
|
||||||
|
};
|
||||||
|
|
||||||
|
store.saveCredentials(credentials);
|
||||||
|
|
||||||
|
const writtenData = vi.mocked(fs.writeFileSync).mock
|
||||||
|
.calls[0][1] as string;
|
||||||
|
const parsed = JSON.parse(writtenData);
|
||||||
|
expect(parsed.expiresAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not mutate the original credentials object', () => {
|
||||||
|
const originalTimestamp = Date.now() + 3600000;
|
||||||
|
const credentials: AuthCredentials = {
|
||||||
|
token: 'test-token',
|
||||||
|
userId: 'user-123',
|
||||||
|
expiresAt: originalTimestamp,
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalCredentialsCopy = { ...credentials };
|
||||||
|
|
||||||
|
store.saveCredentials(credentials);
|
||||||
|
|
||||||
|
// Original object should not be modified
|
||||||
|
expect(credentials).toEqual(originalCredentialsCopy);
|
||||||
|
expect(credentials.expiresAt).toBe(originalTimestamp);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('corrupt file handling', () => {
|
||||||
|
it('should quarantine corrupt file on JSON parse error', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
|
||||||
|
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const result = store.getCredentials();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(fs.renameSync).toHaveBeenCalledWith(
|
||||||
|
configFile,
|
||||||
|
expect.stringContaining('.corrupt-')
|
||||||
|
);
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Quarantined corrupt auth file')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle quarantine failure gracefully', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
|
||||||
|
vi.mocked(fs.renameSync).mockImplementation(() => {
|
||||||
|
throw new Error('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = store.getCredentials();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Could not quarantine corrupt file')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearCredentials', () => {
|
||||||
|
it('should delete the auth file when it exists', () => {
|
||||||
|
// Mock file exists
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.unlinkSync).mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
store.clearCredentials();
|
||||||
|
|
||||||
|
expect(fs.existsSync).toHaveBeenCalledWith('/test/config/auth.json');
|
||||||
|
expect(fs.unlinkSync).toHaveBeenCalledWith('/test/config/auth.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw when auth file does not exist', () => {
|
||||||
|
// Mock file does not exist
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => store.clearCredentials()).not.toThrow();
|
||||||
|
|
||||||
|
// Should not try to unlink non-existent file
|
||||||
|
expect(fs.unlinkSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw AuthenticationError when unlink fails', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.unlinkSync).mockImplementation(() => {
|
||||||
|
throw new Error('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
let err: unknown;
|
||||||
|
try {
|
||||||
|
store.clearCredentials();
|
||||||
|
} catch (e) {
|
||||||
|
err = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(AuthenticationError);
|
||||||
|
expect((err as Error).message).toContain('Failed to clear credentials');
|
||||||
|
expect((err as Error).message).toContain('Permission denied');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasValidCredentials', () => {
|
||||||
|
it('should return true when valid unexpired credentials exist', () => {
|
||||||
|
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
|
||||||
|
const credentials = {
|
||||||
|
token: 'valid-token',
|
||||||
|
userId: 'user-123',
|
||||||
|
expiresAt: futureDate.toISOString(),
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
||||||
|
|
||||||
|
expect(store.hasValidCredentials()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when credentials are expired', () => {
|
||||||
|
const pastDate = new Date(Date.now() - 3600000); // 1 hour ago
|
||||||
|
const credentials = {
|
||||||
|
token: 'expired-token',
|
||||||
|
userId: 'user-123',
|
||||||
|
expiresAt: pastDate.toISOString(),
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
||||||
|
|
||||||
|
expect(store.hasValidCredentials()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when no credentials exist', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
|
expect(store.hasValidCredentials()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when file contains invalid JSON', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
|
||||||
|
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
expect(store.hasValidCredentials()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for credentials without expiry', () => {
|
||||||
|
const credentials = {
|
||||||
|
token: 'no-expiry-token',
|
||||||
|
userId: 'user-123',
|
||||||
|
tokenType: 'standard',
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
||||||
|
|
||||||
|
// Credentials without expiry are considered invalid
|
||||||
|
expect(store.hasValidCredentials()).toBe(false);
|
||||||
|
|
||||||
|
// Should log warning about missing expiration
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
'No valid expiration time provided for token'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use allowExpired=false by default', () => {
|
||||||
|
// Spy on getCredentials to verify it's called with correct params
|
||||||
|
const getCredentialsSpy = vi.spyOn(store, 'getCredentials');
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
store.hasValidCredentials();
|
||||||
|
|
||||||
|
expect(getCredentialsSpy).toHaveBeenCalledWith({ allowExpired: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanupCorruptFiles', () => {
|
||||||
|
it('should remove old corrupt files', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const oldFile = 'auth.json.corrupt-' + (now - 8 * 24 * 60 * 60 * 1000); // 8 days old
|
||||||
|
const newFile = 'auth.json.corrupt-' + (now - 1000); // 1 second old
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||||
|
{ name: oldFile, isFile: () => true },
|
||||||
|
{ name: newFile, isFile: () => true },
|
||||||
|
{ name: 'auth.json', isFile: () => true }
|
||||||
|
] as any);
|
||||||
|
vi.mocked(fs.statSync).mockImplementation((filePath) => {
|
||||||
|
if (filePath.includes(oldFile)) {
|
||||||
|
return { mtimeMs: now - 8 * 24 * 60 * 60 * 1000 } as any;
|
||||||
|
}
|
||||||
|
return { mtimeMs: now - 1000 } as any;
|
||||||
|
});
|
||||||
|
vi.mocked(fs.unlinkSync).mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
store.cleanupCorruptFiles();
|
||||||
|
|
||||||
|
expect(fs.unlinkSync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(oldFile)
|
||||||
|
);
|
||||||
|
expect(fs.unlinkSync).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(newFile)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cleanup errors gracefully', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockImplementation(() => {
|
||||||
|
throw new Error('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => store.cleanupCorruptFiles()).not.toThrow();
|
||||||
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Error during corrupt file cleanup')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,13 +3,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { AuthCredentials, AuthenticationError, AuthConfig } from './types';
|
import path from 'path';
|
||||||
import { getAuthConfig } from './config';
|
import { AuthCredentials, AuthenticationError, AuthConfig } from './types.js';
|
||||||
import { getLogger } from '../logger';
|
import { getAuthConfig } from './config.js';
|
||||||
|
import { getLogger } from '../logger/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CredentialStore manages the persistence and retrieval of authentication credentials.
|
||||||
|
*
|
||||||
|
* Runtime vs Persisted Shape:
|
||||||
|
* - When retrieved (getCredentials): expiresAt is normalized to number (milliseconds since epoch)
|
||||||
|
* - When persisted (saveCredentials): expiresAt is stored as ISO string for readability
|
||||||
|
*
|
||||||
|
* This normalization ensures consistent runtime behavior while maintaining
|
||||||
|
* human-readable persisted format in the auth.json file.
|
||||||
|
*/
|
||||||
export class CredentialStore {
|
export class CredentialStore {
|
||||||
private logger = getLogger('CredentialStore');
|
private logger = getLogger('CredentialStore');
|
||||||
private config: AuthConfig;
|
private config: AuthConfig;
|
||||||
|
// Clock skew tolerance for expiry checks (30 seconds)
|
||||||
|
private readonly CLOCK_SKEW_MS = 30_000;
|
||||||
|
|
||||||
constructor(config?: Partial<AuthConfig>) {
|
constructor(config?: Partial<AuthConfig>) {
|
||||||
this.config = getAuthConfig(config);
|
this.config = getAuthConfig(config);
|
||||||
@@ -17,6 +30,7 @@ export class CredentialStore {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get stored authentication credentials
|
* Get stored authentication credentials
|
||||||
|
* @returns AuthCredentials with expiresAt as number (milliseconds) for runtime use
|
||||||
*/
|
*/
|
||||||
getCredentials(options?: { allowExpired?: boolean }): AuthCredentials | null {
|
getCredentials(options?: { allowExpired?: boolean }): AuthCredentials | null {
|
||||||
try {
|
try {
|
||||||
@@ -28,27 +42,71 @@ export class CredentialStore {
|
|||||||
fs.readFileSync(this.config.configFile, 'utf-8')
|
fs.readFileSync(this.config.configFile, 'utf-8')
|
||||||
) as AuthCredentials;
|
) as AuthCredentials;
|
||||||
|
|
||||||
// Check if token is expired
|
// Normalize/migrate timestamps to numeric (handles both number and ISO string)
|
||||||
if (
|
let expiresAtMs: number | undefined;
|
||||||
authData.expiresAt &&
|
if (typeof authData.expiresAt === 'number') {
|
||||||
new Date(authData.expiresAt) < new Date() &&
|
expiresAtMs = Number.isFinite(authData.expiresAt)
|
||||||
!options?.allowExpired
|
? authData.expiresAt
|
||||||
) {
|
: undefined;
|
||||||
this.logger.warn('Authentication token has expired');
|
} else if (typeof authData.expiresAt === 'string') {
|
||||||
|
const parsed = Date.parse(authData.expiresAt);
|
||||||
|
expiresAtMs = Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
} else {
|
||||||
|
expiresAtMs = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate expiration time for tokens
|
||||||
|
if (expiresAtMs === undefined) {
|
||||||
|
this.logger.warn('No valid expiration time provided for token');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the authData with normalized timestamp
|
||||||
|
authData.expiresAt = expiresAtMs;
|
||||||
|
|
||||||
|
// Check if the token has expired (with clock skew tolerance)
|
||||||
|
const now = Date.now();
|
||||||
|
const allowExpired = options?.allowExpired ?? false;
|
||||||
|
if (now >= expiresAtMs - this.CLOCK_SKEW_MS && !allowExpired) {
|
||||||
|
this.logger.warn(
|
||||||
|
'Authentication token has expired or is about to expire',
|
||||||
|
{
|
||||||
|
expiresAt: authData.expiresAt,
|
||||||
|
currentTime: new Date(now).toISOString(),
|
||||||
|
skewWindow: `${this.CLOCK_SKEW_MS / 1000}s`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return valid token
|
||||||
return authData;
|
return authData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to read auth credentials: ${(error as Error).message}`
|
`Failed to read auth credentials: ${(error as Error).message}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Quarantine corrupt file to prevent repeated errors
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.config.configFile)) {
|
||||||
|
const corruptFile = `${this.config.configFile}.corrupt-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
fs.renameSync(this.config.configFile, corruptFile);
|
||||||
|
this.logger.warn(`Quarantined corrupt auth file to: ${corruptFile}`);
|
||||||
|
}
|
||||||
|
} catch (quarantineError) {
|
||||||
|
// If we can't quarantine, log but don't throw
|
||||||
|
this.logger.debug(
|
||||||
|
`Could not quarantine corrupt file: ${(quarantineError as Error).message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save authentication credentials
|
* Save authentication credentials
|
||||||
|
* @param authData - Credentials with expiresAt as number or string (will be persisted as ISO string)
|
||||||
*/
|
*/
|
||||||
saveCredentials(authData: AuthCredentials): void {
|
saveCredentials(authData: AuthCredentials): void {
|
||||||
try {
|
try {
|
||||||
@@ -57,8 +115,32 @@ export class CredentialStore {
|
|||||||
fs.mkdirSync(this.config.configDir, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(this.config.configDir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add timestamp
|
// Add timestamp without mutating caller's object
|
||||||
authData.savedAt = new Date().toISOString();
|
authData = { ...authData, savedAt: new Date().toISOString() };
|
||||||
|
|
||||||
|
// Validate and normalize expiresAt timestamp
|
||||||
|
if (authData.expiresAt !== undefined) {
|
||||||
|
let validTimestamp: number | undefined;
|
||||||
|
|
||||||
|
if (typeof authData.expiresAt === 'number') {
|
||||||
|
validTimestamp = Number.isFinite(authData.expiresAt)
|
||||||
|
? authData.expiresAt
|
||||||
|
: undefined;
|
||||||
|
} else if (typeof authData.expiresAt === 'string') {
|
||||||
|
const parsed = Date.parse(authData.expiresAt);
|
||||||
|
validTimestamp = Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validTimestamp === undefined) {
|
||||||
|
throw new AuthenticationError(
|
||||||
|
`Invalid expiresAt format: ${authData.expiresAt}`,
|
||||||
|
'SAVE_FAILED'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store as ISO string for consistency
|
||||||
|
authData.expiresAt = new Date(validTimestamp).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
// Save credentials atomically with secure permissions
|
// Save credentials atomically with secure permissions
|
||||||
const tempFile = `${this.config.configFile}.tmp`;
|
const tempFile = `${this.config.configFile}.tmp`;
|
||||||
@@ -106,4 +188,54 @@ export class CredentialStore {
|
|||||||
getConfig(): AuthConfig {
|
getConfig(): AuthConfig {
|
||||||
return { ...this.config };
|
return { ...this.config };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old corrupt auth files
|
||||||
|
* Removes corrupt files older than the specified age
|
||||||
|
*/
|
||||||
|
cleanupCorruptFiles(maxAgeMs: number = 7 * 24 * 60 * 60 * 1000): void {
|
||||||
|
try {
|
||||||
|
const dir = path.dirname(this.config.configFile);
|
||||||
|
const baseName = path.basename(this.config.configFile);
|
||||||
|
const prefix = `${baseName}.corrupt-`;
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
const file = entry.name;
|
||||||
|
|
||||||
|
// Check if file matches pattern: baseName.corrupt-{timestamp}
|
||||||
|
if (!file.startsWith(prefix)) continue;
|
||||||
|
const suffix = file.slice(prefix.length);
|
||||||
|
if (!/^\d+$/.test(suffix)) continue; // Fixed regex, not from variable input
|
||||||
|
|
||||||
|
const filePath = path.join(dir, file);
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
const age = now - stats.mtimeMs;
|
||||||
|
|
||||||
|
if (age > maxAgeMs) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
this.logger.debug(`Cleaned up old corrupt file: ${file}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors for individual file cleanup
|
||||||
|
this.logger.debug(
|
||||||
|
`Could not clean up corrupt file ${file}: ${(error as Error).message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log but don't throw - this is a cleanup operation
|
||||||
|
this.logger.debug(
|
||||||
|
`Error during corrupt file cleanup: ${(error as Error).message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
* Authentication module exports
|
* Authentication module exports
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { AuthManager } from './auth-manager';
|
export { AuthManager } from './auth-manager.js';
|
||||||
export { CredentialStore } from './credential-store';
|
export { CredentialStore } from './credential-store.js';
|
||||||
export { OAuthService } from './oauth-service';
|
export { OAuthService } from './oauth-service.js';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AuthCredentials,
|
AuthCredentials,
|
||||||
OAuthFlowOptions,
|
OAuthFlowOptions,
|
||||||
AuthConfig,
|
AuthConfig,
|
||||||
CliData
|
CliData
|
||||||
} from './types';
|
} from './types.js';
|
||||||
|
|
||||||
export { AuthenticationError } from './types';
|
export { AuthenticationError } from './types.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DEFAULT_AUTH_CONFIG,
|
DEFAULT_AUTH_CONFIG,
|
||||||
getAuthConfig
|
getAuthConfig
|
||||||
} from './config';
|
} from './config.js';
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import {
|
|||||||
OAuthFlowOptions,
|
OAuthFlowOptions,
|
||||||
AuthConfig,
|
AuthConfig,
|
||||||
CliData
|
CliData
|
||||||
} from './types';
|
} from './types.js';
|
||||||
import { CredentialStore } from './credential-store';
|
import { CredentialStore } from './credential-store.js';
|
||||||
import { SupabaseAuthClient } from '../clients/supabase-client';
|
import { SupabaseAuthClient } from '../clients/supabase-client.js';
|
||||||
import { getAuthConfig } from './config';
|
import { getAuthConfig } from './config.js';
|
||||||
import { getLogger } from '../logger';
|
import { getLogger } from '../logger/index.js';
|
||||||
import packageJson from '../../../../package.json' with { type: 'json' };
|
import packageJson from '../../../../package.json' with { type: 'json' };
|
||||||
|
|
||||||
export class OAuthService {
|
export class OAuthService {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export interface AuthCredentials {
|
|||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
expiresAt?: string;
|
expiresAt?: string | number;
|
||||||
tokenType?: 'standard' | 'api_key';
|
tokenType?: 'standard';
|
||||||
savedAt: string;
|
savedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,6 @@ export type AuthErrorCode =
|
|||||||
| 'INVALID_STATE'
|
| 'INVALID_STATE'
|
||||||
| 'NO_TOKEN'
|
| 'NO_TOKEN'
|
||||||
| 'TOKEN_EXCHANGE_FAILED'
|
| 'TOKEN_EXCHANGE_FAILED'
|
||||||
| 'INVALID_API_KEY'
|
|
||||||
| 'INVALID_CREDENTIALS'
|
| 'INVALID_CREDENTIALS'
|
||||||
| 'NO_REFRESH_TOKEN'
|
| 'NO_REFRESH_TOKEN'
|
||||||
| 'NOT_AUTHENTICATED'
|
| 'NOT_AUTHENTICATED'
|
||||||
@@ -65,7 +64,10 @@ export type AuthErrorCode =
|
|||||||
| 'CONFIG_MISSING'
|
| 'CONFIG_MISSING'
|
||||||
| 'SAVE_FAILED'
|
| 'SAVE_FAILED'
|
||||||
| 'CLEAR_FAILED'
|
| 'CLEAR_FAILED'
|
||||||
| 'STORAGE_ERROR';
|
| 'STORAGE_ERROR'
|
||||||
|
| 'NOT_SUPPORTED'
|
||||||
|
| 'REFRESH_FAILED'
|
||||||
|
| 'INVALID_RESPONSE';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication error class
|
* Authentication error class
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
* Client exports
|
* Client exports
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { SupabaseAuthClient } from './supabase-client';
|
export { SupabaseAuthClient } from './supabase-client.js';
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createClient, SupabaseClient, User } from '@supabase/supabase-js';
|
import { createClient, SupabaseClient, User } from '@supabase/supabase-js';
|
||||||
import { AuthenticationError } from '../auth/types';
|
import { AuthenticationError } from '../auth/types.js';
|
||||||
import { getLogger } from '../logger';
|
import { getLogger } from '../logger/index.js';
|
||||||
|
|
||||||
export class SupabaseAuthClient {
|
export class SupabaseAuthClient {
|
||||||
private client: SupabaseClient | null = null;
|
private client: SupabaseClient | null = null;
|
||||||
|
|||||||
@@ -309,11 +309,11 @@ describe('ConfigManager', () => {
|
|||||||
expect(manager.getProjectRoot()).toBe(testProjectRoot);
|
expect(manager.getProjectRoot()).toBe(testProjectRoot);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should check if using API storage', () => {
|
it('should check if API is explicitly configured', () => {
|
||||||
expect(manager.isUsingApiStorage()).toBe(false);
|
expect(manager.isApiExplicitlyConfigured()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect API storage', () => {
|
it('should detect when API is explicitly configured', () => {
|
||||||
// Update config for current instance
|
// Update config for current instance
|
||||||
(manager as any).config = {
|
(manager as any).config = {
|
||||||
storage: {
|
storage: {
|
||||||
@@ -323,7 +323,7 @@ describe('ConfigManager', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(manager.isUsingApiStorage()).toBe(true);
|
expect(manager.isApiExplicitlyConfigured()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
* maintainability, testability, and separation of concerns.
|
* maintainability, testability, and separation of concerns.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PartialConfiguration } from '../interfaces/configuration.interface.js';
|
import type {
|
||||||
|
PartialConfiguration,
|
||||||
|
RuntimeStorageConfig
|
||||||
|
} from '../interfaces/configuration.interface.js';
|
||||||
import { ConfigLoader } from './services/config-loader.service.js';
|
import { ConfigLoader } from './services/config-loader.service.js';
|
||||||
import {
|
import {
|
||||||
ConfigMerger,
|
ConfigMerger,
|
||||||
@@ -134,27 +137,28 @@ export class ConfigManager {
|
|||||||
/**
|
/**
|
||||||
* Get storage configuration
|
* Get storage configuration
|
||||||
*/
|
*/
|
||||||
getStorageConfig(): {
|
getStorageConfig(): RuntimeStorageConfig {
|
||||||
type: 'file' | 'api' | 'auto';
|
|
||||||
apiEndpoint?: string;
|
|
||||||
apiAccessToken?: string;
|
|
||||||
apiConfigured: boolean;
|
|
||||||
} {
|
|
||||||
const storage = this.config.storage;
|
const storage = this.config.storage;
|
||||||
|
|
||||||
// Return the configured type (including 'auto')
|
// Return the configured type (including 'auto')
|
||||||
const storageType = storage?.type || 'auto';
|
const storageType = storage?.type || 'auto';
|
||||||
|
const basePath = storage?.basePath ?? this.projectRoot;
|
||||||
|
|
||||||
if (storageType === 'api' || storageType === 'auto') {
|
if (storageType === 'api' || storageType === 'auto') {
|
||||||
return {
|
return {
|
||||||
type: storageType,
|
type: storageType,
|
||||||
|
basePath,
|
||||||
apiEndpoint: storage?.apiEndpoint,
|
apiEndpoint: storage?.apiEndpoint,
|
||||||
apiAccessToken: storage?.apiAccessToken,
|
apiAccessToken: storage?.apiAccessToken,
|
||||||
apiConfigured: Boolean(storage?.apiEndpoint || storage?.apiAccessToken)
|
apiConfigured: Boolean(storage?.apiEndpoint || storage?.apiAccessToken)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: storageType, apiConfigured: false };
|
return {
|
||||||
|
type: storageType,
|
||||||
|
basePath,
|
||||||
|
apiConfigured: false
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -185,9 +189,10 @@ export class ConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if using API storage
|
* Check if explicitly configured to use API storage
|
||||||
|
* Excludes 'auto' type
|
||||||
*/
|
*/
|
||||||
isUsingApiStorage(): boolean {
|
isApiExplicitlyConfigured(): boolean {
|
||||||
return this.getStorageConfig().type === 'api';
|
return this.getStorageConfig().type === 'api';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +225,7 @@ export class ConfigManager {
|
|||||||
await this.persistence.saveConfig(this.config);
|
await this.persistence.saveConfig(this.config);
|
||||||
|
|
||||||
// Re-initialize to respect precedence
|
// Re-initialize to respect precedence
|
||||||
|
this.initialized = false;
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,19 @@ export {
|
|||||||
createTaskMasterCore,
|
createTaskMasterCore,
|
||||||
type TaskMasterCoreOptions,
|
type TaskMasterCoreOptions,
|
||||||
type ListTasksResult
|
type ListTasksResult
|
||||||
} from './task-master-core';
|
} from './task-master-core.js';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type * from './types';
|
export type * from './types/index.js';
|
||||||
|
|
||||||
// Re-export interfaces (types only to avoid conflicts)
|
// Re-export interfaces (types only to avoid conflicts)
|
||||||
export type * from './interfaces';
|
export type * from './interfaces/index.js';
|
||||||
|
|
||||||
// Re-export constants
|
// Re-export constants
|
||||||
export * from './constants';
|
export * from './constants/index.js';
|
||||||
|
|
||||||
// Re-export providers
|
// Re-export providers
|
||||||
export * from './providers';
|
export * from './providers/index.js';
|
||||||
|
|
||||||
// Re-export storage (selectively to avoid conflicts)
|
// Re-export storage (selectively to avoid conflicts)
|
||||||
export {
|
export {
|
||||||
@@ -29,20 +29,20 @@ export {
|
|||||||
ApiStorage,
|
ApiStorage,
|
||||||
StorageFactory,
|
StorageFactory,
|
||||||
type ApiStorageConfig
|
type ApiStorageConfig
|
||||||
} from './storage';
|
} from './storage/index.js';
|
||||||
export { PlaceholderStorage, type StorageAdapter } from './storage';
|
export { PlaceholderStorage, type StorageAdapter } from './storage/index.js';
|
||||||
|
|
||||||
// Re-export parser
|
// Re-export parser
|
||||||
export * from './parser';
|
export * from './parser/index.js';
|
||||||
|
|
||||||
// Re-export utilities
|
// Re-export utilities
|
||||||
export * from './utils';
|
export * from './utils/index.js';
|
||||||
|
|
||||||
// Re-export errors
|
// Re-export errors
|
||||||
export * from './errors';
|
export * from './errors/index.js';
|
||||||
|
|
||||||
// Re-export entities
|
// Re-export entities
|
||||||
export { TaskEntity } from './entities/task.entity';
|
export { TaskEntity } from './entities/task.entity.js';
|
||||||
|
|
||||||
// Re-export authentication
|
// Re-export authentication
|
||||||
export {
|
export {
|
||||||
@@ -51,7 +51,7 @@ export {
|
|||||||
type AuthCredentials,
|
type AuthCredentials,
|
||||||
type OAuthFlowOptions,
|
type OAuthFlowOptions,
|
||||||
type AuthConfig
|
type AuthConfig
|
||||||
} from './auth';
|
} from './auth/index.js';
|
||||||
|
|
||||||
// Re-export logger
|
// Re-export logger
|
||||||
export { getLogger, createLogger, setGlobalLogger } from './logger';
|
export { getLogger, createLogger, setGlobalLogger } from './logger/index.js';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* This file defines the contract for configuration management
|
* This file defines the contract for configuration management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TaskComplexity, TaskPriority } from '../types/index';
|
import type { TaskComplexity, TaskPriority } from '../types/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model configuration for different AI roles
|
* Model configuration for different AI roles
|
||||||
@@ -74,19 +74,48 @@ export interface TagSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage and persistence settings
|
* Storage type options
|
||||||
|
* - 'file': Local file system storage
|
||||||
|
* - 'api': Remote API storage (Hamster integration)
|
||||||
|
* - 'auto': Automatically detect based on auth status
|
||||||
*/
|
*/
|
||||||
export interface StorageSettings {
|
export type StorageType = 'file' | 'api' | 'auto';
|
||||||
/** Storage backend type - 'auto' detects based on auth status */
|
|
||||||
type: 'file' | 'api' | 'auto';
|
/**
|
||||||
/** Base path for file storage */
|
* Runtime storage configuration used for storage backend selection
|
||||||
|
* This is what getStorageConfig() returns and what StorageFactory expects
|
||||||
|
*/
|
||||||
|
export interface RuntimeStorageConfig {
|
||||||
|
/** Storage backend type */
|
||||||
|
type: StorageType;
|
||||||
|
/** Base path for file storage (if configured) */
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
/** API endpoint for API storage (Hamster integration) */
|
/** API endpoint for API storage (Hamster integration) */
|
||||||
apiEndpoint?: string;
|
apiEndpoint?: string;
|
||||||
/** Access token for API authentication */
|
/** Access token for API authentication */
|
||||||
apiAccessToken?: string;
|
apiAccessToken?: string;
|
||||||
/** Indicates whether API is configured (has endpoint or token) */
|
/**
|
||||||
apiConfigured?: boolean;
|
* Indicates whether API is configured (has endpoint or token)
|
||||||
|
* @computed Derived automatically from presence of apiEndpoint or apiAccessToken
|
||||||
|
* @internal Should not be set manually - computed by ConfigManager
|
||||||
|
*/
|
||||||
|
readonly apiConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage and persistence settings
|
||||||
|
* Extended storage settings including file operation preferences
|
||||||
|
*/
|
||||||
|
export interface StorageSettings
|
||||||
|
extends Omit<RuntimeStorageConfig, 'apiConfigured'> {
|
||||||
|
/** Base path for file storage */
|
||||||
|
basePath?: string;
|
||||||
|
/**
|
||||||
|
* Indicates whether API is configured
|
||||||
|
* @computed Derived automatically from presence of apiEndpoint or apiAccessToken
|
||||||
|
* @internal Should not be set manually in user config - computed by ConfigManager
|
||||||
|
*/
|
||||||
|
readonly apiConfigured?: boolean;
|
||||||
/** Enable automatic backups */
|
/** Enable automatic backups */
|
||||||
enableBackup: boolean;
|
enableBackup: boolean;
|
||||||
/** Maximum number of backups to retain */
|
/** Maximum number of backups to retain */
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Storage interfaces
|
// Storage interfaces
|
||||||
export type * from './storage.interface';
|
export type * from './storage.interface.js';
|
||||||
export * from './storage.interface';
|
export * from './storage.interface.js';
|
||||||
|
|
||||||
// AI Provider interfaces
|
// AI Provider interfaces
|
||||||
export type * from './ai-provider.interface';
|
export type * from './ai-provider.interface.js';
|
||||||
export * from './ai-provider.interface';
|
export * from './ai-provider.interface.js';
|
||||||
|
|
||||||
// Configuration interfaces
|
// Configuration interfaces
|
||||||
export type * from './configuration.interface';
|
export type * from './configuration.interface.js';
|
||||||
export * from './configuration.interface';
|
export * from './configuration.interface.js';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* This file defines the contract for all storage implementations
|
* This file defines the contract for all storage implementations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Task, TaskMetadata } from '../types/index';
|
import type { Task, TaskMetadata } from '../types/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for storage operations on tasks
|
* Interface for storage operations on tasks
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @fileoverview Logger factory and singleton management
|
* @fileoverview Logger factory and singleton management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Logger, LoggerConfig } from './logger.js';
|
import { Logger, type LoggerConfig } from './logger.js';
|
||||||
|
|
||||||
// Global logger instance
|
// Global logger instance
|
||||||
let globalLogger: Logger | null = null;
|
let globalLogger: Logger | null = null;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* This file exports all parsing-related classes and functions
|
* This file exports all parsing-related classes and functions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PlaceholderTask } from '../types/index';
|
import type { PlaceholderTask } from '../types/index.js';
|
||||||
|
|
||||||
// Parser implementations will be defined here
|
// Parser implementations will be defined here
|
||||||
// export * from './prd-parser.js';
|
// export * from './prd-parser.js';
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
* Provides business logic and service layer functionality
|
* Provides business logic and service layer functionality
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { TaskService } from './task-service';
|
export { TaskService } from './task-service.js';
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export class TaskService {
|
|||||||
const storageConfig = this.configManager.getStorageConfig();
|
const storageConfig = this.configManager.getStorageConfig();
|
||||||
const projectRoot = this.configManager.getProjectRoot();
|
const projectRoot = this.configManager.getProjectRoot();
|
||||||
|
|
||||||
this.storage = StorageFactory.create(
|
this.storage = StorageFactory.createFromStorageConfig(
|
||||||
{ storage: storageConfig } as any,
|
storageConfig,
|
||||||
projectRoot
|
projectRoot
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IStorage } from '../interfaces/storage.interface.js';
|
import type { IStorage } from '../interfaces/storage.interface.js';
|
||||||
import type { IConfiguration } from '../interfaces/configuration.interface.js';
|
import type {
|
||||||
import { FileStorage } from './file-storage';
|
IConfiguration,
|
||||||
|
RuntimeStorageConfig,
|
||||||
|
StorageSettings
|
||||||
|
} from '../interfaces/configuration.interface.js';
|
||||||
|
import { FileStorage } from './file-storage/index.js';
|
||||||
import { ApiStorage } from './api-storage.js';
|
import { ApiStorage } from './api-storage.js';
|
||||||
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
|
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
|
||||||
import { AuthManager } from '../auth/auth-manager.js';
|
import { AuthManager } from '../auth/auth-manager.js';
|
||||||
@@ -14,6 +18,25 @@ import { getLogger } from '../logger/index.js';
|
|||||||
* Factory for creating storage implementations based on configuration
|
* Factory for creating storage implementations based on configuration
|
||||||
*/
|
*/
|
||||||
export class StorageFactory {
|
export class StorageFactory {
|
||||||
|
/**
|
||||||
|
* Create a storage implementation from runtime storage config
|
||||||
|
* This is the preferred method when you have a RuntimeStorageConfig
|
||||||
|
* @param storageConfig - Runtime storage configuration
|
||||||
|
* @param projectPath - Project root path (for file storage)
|
||||||
|
* @returns Storage implementation
|
||||||
|
*/
|
||||||
|
static createFromStorageConfig(
|
||||||
|
storageConfig: RuntimeStorageConfig,
|
||||||
|
projectPath: string
|
||||||
|
): IStorage {
|
||||||
|
// Wrap the storage config in the expected format, including projectPath
|
||||||
|
// This ensures ApiStorage receives the projectPath for projectId
|
||||||
|
return StorageFactory.create(
|
||||||
|
{ storage: storageConfig, projectPath } as Partial<IConfiguration>,
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a storage implementation based on configuration
|
* Create a storage implementation based on configuration
|
||||||
* @param config - Configuration object
|
* @param config - Configuration object
|
||||||
@@ -35,28 +58,33 @@ export class StorageFactory {
|
|||||||
|
|
||||||
case 'api':
|
case 'api':
|
||||||
if (!StorageFactory.isHamsterAvailable(config)) {
|
if (!StorageFactory.isHamsterAvailable(config)) {
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!config.storage?.apiEndpoint) missing.push('apiEndpoint');
|
||||||
|
if (!config.storage?.apiAccessToken) missing.push('apiAccessToken');
|
||||||
|
|
||||||
// Check if authenticated via AuthManager
|
// Check if authenticated via AuthManager
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
if (!authManager.isAuthenticated()) {
|
if (!authManager.isAuthenticated()) {
|
||||||
throw new TaskMasterError(
|
throw new TaskMasterError(
|
||||||
'API storage configured but not authenticated. Run: tm auth login',
|
`API storage not fully configured (${missing.join(', ') || 'credentials missing'}). Run: tm auth login, or set the missing field(s).`,
|
||||||
ERROR_CODES.MISSING_CONFIGURATION,
|
ERROR_CODES.MISSING_CONFIGURATION,
|
||||||
{ storageType: 'api' }
|
{ storageType: 'api', missing }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Use auth token from AuthManager
|
// Use auth token from AuthManager
|
||||||
const credentials = authManager.getCredentials();
|
const credentials = authManager.getCredentials();
|
||||||
if (credentials) {
|
if (credentials) {
|
||||||
// Merge with existing storage config, ensuring required fields
|
// Merge with existing storage config, ensuring required fields
|
||||||
config.storage = {
|
const nextStorage: StorageSettings = {
|
||||||
...config.storage,
|
...(config.storage as StorageSettings),
|
||||||
type: 'api' as const,
|
type: 'api',
|
||||||
apiAccessToken: credentials.token,
|
apiAccessToken: credentials.token,
|
||||||
apiEndpoint:
|
apiEndpoint:
|
||||||
config.storage?.apiEndpoint ||
|
config.storage?.apiEndpoint ||
|
||||||
process.env.HAMSTER_API_URL ||
|
process.env.HAMSTER_API_URL ||
|
||||||
'https://tryhamster.com/api'
|
'https://tryhamster.com/api'
|
||||||
} as any; // Cast to any to bypass strict type checking for partial config
|
};
|
||||||
|
config.storage = nextStorage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info('☁️ Using API storage');
|
logger.info('☁️ Using API storage');
|
||||||
@@ -77,15 +105,16 @@ export class StorageFactory {
|
|||||||
const credentials = authManager.getCredentials();
|
const credentials = authManager.getCredentials();
|
||||||
if (credentials) {
|
if (credentials) {
|
||||||
// Configure API storage with auth credentials
|
// Configure API storage with auth credentials
|
||||||
config.storage = {
|
const nextStorage: StorageSettings = {
|
||||||
...config.storage,
|
...(config.storage as StorageSettings),
|
||||||
type: 'api' as const,
|
type: 'api',
|
||||||
apiAccessToken: credentials.token,
|
apiAccessToken: credentials.token,
|
||||||
apiEndpoint:
|
apiEndpoint:
|
||||||
config.storage?.apiEndpoint ||
|
config.storage?.apiEndpoint ||
|
||||||
process.env.HAMSTER_API_URL ||
|
process.env.HAMSTER_API_URL ||
|
||||||
'https://tryhamster.com/api'
|
'https://tryhamster.com/api'
|
||||||
} as any; // Cast to any to bypass strict type checking for partial config
|
};
|
||||||
|
config.storage = nextStorage;
|
||||||
logger.info('☁️ Using API storage (authenticated)');
|
logger.info('☁️ Using API storage (authenticated)');
|
||||||
return StorageFactory.createApiStorage(config);
|
return StorageFactory.createApiStorage(config);
|
||||||
}
|
}
|
||||||
@@ -189,6 +218,11 @@ export class StorageFactory {
|
|||||||
// File storage doesn't require additional config
|
// File storage doesn't require additional config
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'auto':
|
||||||
|
// Auto storage is valid - it will determine the actual type at runtime
|
||||||
|
// No specific validation needed as it will fall back to file if API not configured
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
errors.push(`Unknown storage type: ${storageType}`);
|
errors.push(`Unknown storage type: ${storageType}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export {
|
|||||||
isValidTaskId,
|
isValidTaskId,
|
||||||
isValidSubtaskId,
|
isValidSubtaskId,
|
||||||
getParentTaskId
|
getParentTaskId
|
||||||
} from './id-generator';
|
} from './id-generator.js';
|
||||||
|
|
||||||
// Additional utility exports
|
// Additional utility exports
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"types": ["node"],
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
Reference in New Issue
Block a user