Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
331 lines
9.4 KiB
TypeScript
331 lines
9.4 KiB
TypeScript
/**
|
|
* Default Account Credential Manager
|
|
*
|
|
* File-based adapter implementing AccountCredentialManager port.
|
|
* Reads/writes credentials from ~/.cw/accounts/<uuid>/.credentials.json
|
|
* and emits events on credential state changes.
|
|
*/
|
|
|
|
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import type { EventBus } from '../../events/index.js';
|
|
import type {
|
|
AccountCredentialManager,
|
|
OAuthCredentials,
|
|
RefreshResult,
|
|
CredentialValidationResult,
|
|
} from './types.js';
|
|
import type {
|
|
AccountCredentialsRefreshedEvent,
|
|
AccountCredentialsExpiredEvent,
|
|
AccountCredentialsValidatedEvent,
|
|
} from '../../events/types.js';
|
|
import { createModuleLogger } from '../../logger/index.js';
|
|
|
|
const log = createModuleLogger('credential-manager');
|
|
|
|
/** Anthropic OAuth token refresh endpoint */
|
|
const TOKEN_REFRESH_URL = 'https://console.anthropic.com/v1/oauth/token';
|
|
|
|
/** OAuth client ID for Claude CLI */
|
|
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
|
|
/** Buffer before expiry to trigger refresh (5 minutes) */
|
|
const TOKEN_REFRESH_BUFFER_MS = 300_000;
|
|
|
|
/**
|
|
* DefaultAccountCredentialManager - File-based credential management with event emission.
|
|
*
|
|
* Implements the AccountCredentialManager port for managing OAuth credentials
|
|
* stored in account config directories.
|
|
*/
|
|
export class DefaultAccountCredentialManager implements AccountCredentialManager {
|
|
constructor(private eventBus?: EventBus) {}
|
|
|
|
/**
|
|
* Read credentials from a config directory.
|
|
*/
|
|
read(configDir: string): OAuthCredentials | null {
|
|
try {
|
|
const credPath = join(configDir, '.credentials.json');
|
|
if (!existsSync(credPath)) return null;
|
|
|
|
const raw = readFileSync(credPath, 'utf-8');
|
|
const parsed = JSON.parse(raw);
|
|
const oauth = parsed.claudeAiOauth;
|
|
|
|
if (!oauth || !oauth.accessToken) return null;
|
|
|
|
return {
|
|
accessToken: oauth.accessToken,
|
|
refreshToken: oauth.refreshToken ?? null,
|
|
expiresAt: oauth.expiresAt ?? null,
|
|
subscriptionType: oauth.subscriptionType ?? null,
|
|
rateLimitTier: oauth.rateLimitTier ?? null,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if credentials are expired or about to expire.
|
|
*/
|
|
isExpired(credentials: OAuthCredentials): boolean {
|
|
if (!credentials.expiresAt) return false; // Setup tokens without expiry are treated as non-expired
|
|
return credentials.expiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS;
|
|
}
|
|
|
|
/**
|
|
* Refresh an access token using the refresh token.
|
|
*/
|
|
async refresh(configDir: string, refreshToken: string): Promise<RefreshResult | null> {
|
|
try {
|
|
const response = await fetch(TOKEN_REFRESH_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
client_id: OAUTH_CLIENT_ID,
|
|
scope: 'user:inference user:profile',
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
log.warn({ configDir, status: response.status }, 'token refresh failed');
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
return {
|
|
accessToken: data.access_token,
|
|
refreshToken: data.refresh_token,
|
|
expiresIn: data.expires_in,
|
|
};
|
|
} catch (err) {
|
|
log.error({ configDir, err: err instanceof Error ? err.message : String(err) }, 'token refresh error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write updated credentials to the config directory.
|
|
*/
|
|
write(
|
|
configDir: string,
|
|
accessToken: string,
|
|
refreshToken: string,
|
|
expiresIn: number,
|
|
): void {
|
|
const credPath = join(configDir, '.credentials.json');
|
|
|
|
// Read existing credentials to preserve other fields
|
|
let existing: Record<string, unknown> = {};
|
|
try {
|
|
if (existsSync(credPath)) {
|
|
existing = JSON.parse(readFileSync(credPath, 'utf-8'));
|
|
}
|
|
} catch {
|
|
// Start fresh if can't read
|
|
}
|
|
|
|
// Calculate expiry in milliseconds
|
|
const nowMs = Date.now();
|
|
const expiresAt = nowMs + expiresIn * 1000;
|
|
|
|
// Update claudeAiOauth section
|
|
const claudeAiOauth = (existing.claudeAiOauth as Record<string, unknown>) ?? {};
|
|
claudeAiOauth.accessToken = accessToken;
|
|
claudeAiOauth.refreshToken = refreshToken;
|
|
claudeAiOauth.expiresAt = expiresAt;
|
|
existing.claudeAiOauth = claudeAiOauth;
|
|
|
|
// Ensure directory exists
|
|
mkdirSync(dirname(credPath), { recursive: true });
|
|
|
|
// Write back (compact JSON for consistency)
|
|
writeFileSync(credPath, JSON.stringify(existing));
|
|
log.debug({ configDir }, 'credentials written after token refresh');
|
|
}
|
|
|
|
/**
|
|
* Ensure credentials are valid, refreshing if needed.
|
|
*/
|
|
async ensureValid(configDir: string, accountId?: string): Promise<CredentialValidationResult> {
|
|
const credentials = this.read(configDir);
|
|
|
|
if (!credentials) {
|
|
log.warn({ configDir, accountId }, 'no credentials found');
|
|
this.emitExpired(accountId, 'credentials_missing', 'Credentials file not found');
|
|
return {
|
|
valid: false,
|
|
credentials: null,
|
|
error: 'Credentials file not found',
|
|
refreshed: false,
|
|
};
|
|
}
|
|
|
|
if (!this.isExpired(credentials)) {
|
|
log.debug({ configDir, accountId }, 'credentials valid, no refresh needed');
|
|
this.emitValidated(accountId, true, credentials.expiresAt, false);
|
|
return {
|
|
valid: true,
|
|
credentials,
|
|
error: null,
|
|
refreshed: false,
|
|
};
|
|
}
|
|
|
|
// Credentials expired — attempt refresh if we have a refresh token
|
|
if (!credentials.refreshToken) {
|
|
log.warn({ configDir, accountId }, 'setup token expired, no refresh token available');
|
|
this.emitExpired(accountId, 'token_expired', 'Setup token expired, no refresh token available');
|
|
return {
|
|
valid: false,
|
|
credentials: null,
|
|
error: 'Setup token expired, no refresh token available',
|
|
refreshed: false,
|
|
};
|
|
}
|
|
|
|
log.info({ configDir, accountId }, 'credentials expired, refreshing');
|
|
const previousExpiresAt = credentials.expiresAt;
|
|
const refreshed = await this.refresh(configDir, credentials.refreshToken);
|
|
|
|
if (!refreshed) {
|
|
log.error({ configDir, accountId }, 'failed to refresh credentials');
|
|
this.emitExpired(accountId, 'refresh_failed', 'Token refresh failed');
|
|
return {
|
|
valid: false,
|
|
credentials: null,
|
|
error: 'Token refresh failed',
|
|
refreshed: false,
|
|
};
|
|
}
|
|
|
|
// Write refreshed credentials
|
|
const newRefreshToken = refreshed.refreshToken || credentials.refreshToken;
|
|
this.write(configDir, refreshed.accessToken, newRefreshToken, refreshed.expiresIn);
|
|
|
|
const newExpiresAt = Date.now() + refreshed.expiresIn * 1000;
|
|
log.info({ configDir, accountId, expiresIn: refreshed.expiresIn }, 'credentials refreshed');
|
|
|
|
this.emitRefreshed(accountId, newExpiresAt, previousExpiresAt);
|
|
this.emitValidated(accountId, true, newExpiresAt, true);
|
|
|
|
// Read back updated credentials
|
|
const updatedCredentials = this.read(configDir);
|
|
return {
|
|
valid: true,
|
|
credentials: updatedCredentials,
|
|
error: null,
|
|
refreshed: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate credentials without attempting refresh.
|
|
*/
|
|
async validate(configDir: string, accountId?: string): Promise<CredentialValidationResult> {
|
|
const credentials = this.read(configDir);
|
|
|
|
if (!credentials) {
|
|
this.emitValidated(accountId, false, null, false);
|
|
return {
|
|
valid: false,
|
|
credentials: null,
|
|
error: 'Credentials file not found',
|
|
refreshed: false,
|
|
};
|
|
}
|
|
|
|
const expired = this.isExpired(credentials);
|
|
this.emitValidated(accountId, !expired, credentials.expiresAt, false);
|
|
|
|
if (expired) {
|
|
return {
|
|
valid: false,
|
|
credentials,
|
|
error: 'Token expired',
|
|
refreshed: false,
|
|
};
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
credentials,
|
|
error: null,
|
|
refreshed: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Emit credentials refreshed event.
|
|
*/
|
|
private emitRefreshed(
|
|
accountId: string | undefined,
|
|
expiresAt: number,
|
|
previousExpiresAt: number | null,
|
|
): void {
|
|
if (!this.eventBus) return;
|
|
|
|
const event: AccountCredentialsRefreshedEvent = {
|
|
type: 'account:credentials_refreshed',
|
|
timestamp: new Date(),
|
|
payload: {
|
|
accountId: accountId ?? null,
|
|
expiresAt,
|
|
previousExpiresAt,
|
|
},
|
|
};
|
|
this.eventBus.emit(event);
|
|
}
|
|
|
|
/**
|
|
* Emit credentials expired event.
|
|
*/
|
|
private emitExpired(
|
|
accountId: string | undefined,
|
|
reason: 'token_expired' | 'refresh_failed' | 'credentials_missing',
|
|
error: string | null,
|
|
): void {
|
|
if (!this.eventBus) return;
|
|
|
|
const event: AccountCredentialsExpiredEvent = {
|
|
type: 'account:credentials_expired',
|
|
timestamp: new Date(),
|
|
payload: {
|
|
accountId: accountId ?? null,
|
|
reason,
|
|
error,
|
|
},
|
|
};
|
|
this.eventBus.emit(event);
|
|
}
|
|
|
|
/**
|
|
* Emit credentials validated event.
|
|
*/
|
|
private emitValidated(
|
|
accountId: string | undefined,
|
|
valid: boolean,
|
|
expiresAt: number | null,
|
|
wasRefreshed: boolean,
|
|
): void {
|
|
if (!this.eventBus) return;
|
|
|
|
const event: AccountCredentialsValidatedEvent = {
|
|
type: 'account:credentials_validated',
|
|
timestamp: new Date(),
|
|
payload: {
|
|
accountId: accountId ?? null,
|
|
valid,
|
|
expiresAt,
|
|
wasRefreshed,
|
|
},
|
|
};
|
|
this.eventBus.emit(event);
|
|
}
|
|
}
|