chore: address oauth PR concerns (#1184)

This commit is contained in:
Ralph Khreish
2025-09-08 01:15:19 +02:00
parent 7cf4004038
commit 15900d9fd5
23 changed files with 1042 additions and 139 deletions

View 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);
});
});

View File

@@ -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}`
};
}
} }

View File

@@ -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

View 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')
);
});
});
});

View File

@@ -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}`
);
}
}
} }

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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

View File

@@ -2,4 +2,4 @@
* Client exports * Client exports
*/ */
export { SupabaseAuthClient } from './supabase-client'; export { SupabaseAuthClient } from './supabase-client.js';

View File

@@ -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;

View File

@@ -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);
}); });
}); });

View File

@@ -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();
} }

View File

@@ -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';

View File

@@ -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 */

View File

@@ -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';

View File

@@ -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

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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
); );

View File

@@ -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}`);
} }

View File

@@ -11,7 +11,7 @@ export {
isValidTaskId, isValidTaskId,
isValidSubtaskId, isValidSubtaskId,
getParentTaskId getParentTaskId
} from './id-generator'; } from './id-generator.js';
// Additional utility exports // Additional utility exports

View File

@@ -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": {