refactor: Restructure monorepo to apps/server/ and apps/web/ layout
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
This commit is contained in:
330
apps/server/agent/credentials/default-credential-manager.ts
Normal file
330
apps/server/agent/credentials/default-credential-manager.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
17
apps/server/agent/credentials/index.ts
Normal file
17
apps/server/agent/credentials/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Credentials Module - Public API
|
||||
*
|
||||
* Exports the AccountCredentialManager port interface and default adapter.
|
||||
* All modules should import from this index file.
|
||||
*/
|
||||
|
||||
// Port interface and types
|
||||
export type {
|
||||
AccountCredentialManager,
|
||||
OAuthCredentials,
|
||||
RefreshResult,
|
||||
CredentialValidationResult,
|
||||
} from './types.js';
|
||||
|
||||
// Adapter implementation
|
||||
export { DefaultAccountCredentialManager } from './default-credential-manager.js';
|
||||
98
apps/server/agent/credentials/types.ts
Normal file
98
apps/server/agent/credentials/types.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Account Credential Manager Types
|
||||
*
|
||||
* Port interface for managing OAuth credentials for agent accounts.
|
||||
* The credential manager reads, validates, refreshes, and persists tokens,
|
||||
* emitting events on state changes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OAuth credentials stored in the account's config directory.
|
||||
*/
|
||||
export interface OAuthCredentials {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
/** Expiry time in milliseconds since epoch. Null for setup tokens with no expiry. */
|
||||
expiresAt: number | null;
|
||||
subscriptionType: string | null;
|
||||
rateLimitTier: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a token refresh attempt.
|
||||
*/
|
||||
export interface RefreshResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
/** Token lifetime in seconds */
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of credential validation or ensureValid operation.
|
||||
*/
|
||||
export interface CredentialValidationResult {
|
||||
/** Whether credentials are currently valid and usable */
|
||||
valid: boolean;
|
||||
/** Current credentials if valid, null otherwise */
|
||||
credentials: OAuthCredentials | null;
|
||||
/** Error message if validation failed */
|
||||
error: string | null;
|
||||
/** Whether credentials were refreshed during this operation */
|
||||
refreshed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Port interface for account credential management.
|
||||
*
|
||||
* Implementations:
|
||||
* - DefaultAccountCredentialManager: File-based adapter using ~/.cw/accounts/<uuid>/.credentials.json
|
||||
*/
|
||||
export interface AccountCredentialManager {
|
||||
/**
|
||||
* Read credentials from a config directory.
|
||||
* Returns null if credentials file is missing or malformed.
|
||||
*/
|
||||
read(configDir: string): OAuthCredentials | null;
|
||||
|
||||
/**
|
||||
* Check if credentials are expired or about to expire.
|
||||
* Uses a buffer (default 5 minutes) to preemptively refresh.
|
||||
*/
|
||||
isExpired(credentials: OAuthCredentials): boolean;
|
||||
|
||||
/**
|
||||
* Refresh an access token using the refresh token.
|
||||
* Returns null if refresh fails.
|
||||
*/
|
||||
refresh(configDir: string, refreshToken: string): Promise<RefreshResult | null>;
|
||||
|
||||
/**
|
||||
* Write updated credentials to the config directory.
|
||||
* Preserves other fields in the credentials file.
|
||||
*/
|
||||
write(
|
||||
configDir: string,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
expiresIn: number,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Ensure credentials are valid, refreshing if needed.
|
||||
* Emits events on refresh or expiration.
|
||||
*
|
||||
* @param configDir - Path to the account's config directory
|
||||
* @param accountId - Optional account ID for event payloads
|
||||
*/
|
||||
ensureValid(configDir: string, accountId?: string): Promise<CredentialValidationResult>;
|
||||
|
||||
/**
|
||||
* Validate credentials without attempting refresh.
|
||||
* Useful for health checks where you want to report state without side effects.
|
||||
*
|
||||
* @param configDir - Path to the account's config directory
|
||||
* @param accountId - Optional account ID for event payloads
|
||||
*/
|
||||
validate(configDir: string, accountId?: string): Promise<CredentialValidationResult>;
|
||||
}
|
||||
Reference in New Issue
Block a user