Files
ccs/src/codex-auth/codex-auth-dashboard-service.ts
T

262 lines
9.2 KiB
TypeScript

/**
* Dashboard service for codex-auth profile summary.
*
* Reads the profile registry, decodes each profile's auth.json JWT,
* resolves the active profile via env precedence, and returns the
* API response shape for GET /api/codex/profiles.
*
* Security: tokens NEVER appear in the returned object. Only display-safe
* fields (email, plan, accountId) are extracted from JWT. auth.json is
* read/decoded and then discarded.
*
* Cache: 5-second in-memory cache reduces full registry/auth reads during
* dashboard polling, but each call still stats the registry so corruption or
* edits are surfaced immediately instead of serving stale success.
*/
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../services/logging';
import { decodeAccountIdentity } from './codex-account-identity';
import { hasStructurallyValidIdToken } from './decode-id-token';
import { getCodexAuthRegistryPath, getCodexInstancesDir } from './codex-profile-paths';
import { CODEX_PROFILE_SCHEMA_VERSION } from './types';
import { CodexProfileRegistry } from './codex-profile-registry';
import type { CodexProfileData, CodexProfileMetadata } from './types';
const logger = createLogger('codex-auth:dashboard');
// ── Response types ──────────────────────────────────────────────────────────
export interface CodexAuthProfileEntry {
name: string;
codexHome: string;
email: string | null;
plan: string | null;
accountId: string | null;
lastUsed: string | null;
authValid: boolean;
}
export interface CodexAuthActiveProfile {
name: string | null;
source: 'default' | 'env' | 'explicit-codex-home';
codexHome: string;
}
export interface CodexAuthProfilesSummary {
active: CodexAuthActiveProfile | null;
default: string | null;
profiles: CodexAuthProfileEntry[];
}
// ── Cache ───────────────────────────────────────────────────────────────────
let cache: {
value: CodexAuthProfilesSummary;
expiresAt: number;
registrySignature: string;
} | null = null;
const TTL_MS = 5000;
/**
* Invalidate the in-process cache so the next call re-reads from disk.
* Useful for Phase 2 CLI commands running in the same process as the dashboard.
* Out-of-process invocations rely on the 5s TTL.
*/
export function invalidateCodexAuthProfilesCache(): void {
cache = null;
}
// ── Registry helpers ────────────────────────────────────────────────────────
function getRegistryCacheSignature(): string {
const registryPath = getCodexAuthRegistryPath();
try {
const stat = fs.statSync(registryPath);
return ['present', stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':');
} catch (err) {
if ((err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
return 'missing';
}
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.dashboard.registry-stat-failed', `Registry stat failed: ${msg}`);
throw new Error('Codex auth profile registry could not be checked safely');
}
}
function readRegistry(): CodexProfileData {
const registryPath = getCodexAuthRegistryPath();
if (!fs.existsSync(registryPath)) {
return { version: CODEX_PROFILE_SCHEMA_VERSION, default: null, profiles: {} };
}
try {
const registry = new CodexProfileRegistry(registryPath);
const profiles: Record<string, CodexProfileMetadata> = {};
for (const name of registry.listProfiles()) {
profiles[name] = registry.getProfile(name);
}
return {
version: CODEX_PROFILE_SCHEMA_VERSION,
default: registry.getDefault(),
profiles,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.dashboard.registry-read-failed', `Registry read failed: ${msg}`);
throw new Error(`Codex auth profile registry could not be read safely: ${msg}`);
}
}
// ── Profile entry builder ───────────────────────────────────────────────────
function buildProfileEntry(name: string): CodexAuthProfileEntry {
const codexHome = path.join(getCodexInstancesDir(), name);
const authJsonPath = path.join(codexHome, 'auth.json');
let authValid = false;
let email: string | null = null;
let plan: string | null = null;
let accountId: string | null = null;
try {
if (fs.existsSync(authJsonPath)) {
// decodeAccountIdentity never throws; returns {} on any error
const identity = decodeAccountIdentity(authJsonPath);
authValid = Object.keys(identity).length > 0 || _hasStructurallyValidIdToken(authJsonPath);
email = identity.email ?? null;
plan = identity.plan_type ?? null;
accountId = identity.account_id ?? null;
logger.debug('codex-auth.dashboard.decoded', 'Decoded auth profile summary', {
profileName: name,
hasEmail: email !== null,
hasPlan: plan !== null,
hasAccountId: accountId !== null,
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(
'codex-auth.dashboard.decode-error',
`Failed to decode auth for profile=${name}: ${msg}`
);
}
return {
name,
codexHome,
email,
plan,
accountId,
// lastUsed is set below by caller from registry metadata
lastUsed: null,
authValid,
};
}
/**
* Check whether auth.json has a parseable JWT payload, even if it has no
* display fields. A non-empty but malformed token is not valid auth.
* This sets authValid=true for valid-but-sparse tokens.
*/
function _hasStructurallyValidIdToken(authJsonPath: string): boolean {
try {
const raw = fs.readFileSync(authJsonPath, 'utf8');
const parsed = JSON.parse(raw) as { tokens?: { id_token?: string } };
const idToken = parsed?.tokens?.id_token;
return typeof idToken === 'string' && hasStructurallyValidIdToken(idToken);
} catch {
return false;
}
}
// ── Active resolution ───────────────────────────────────────────────────────
function resolveActive(registry: CodexProfileData): CodexAuthActiveProfile | null {
const instancesDir = getCodexInstancesDir();
// Precedence 1: explicit $CODEX_HOME set by env (ccsxp, manual)
const codexHome = (process.env.CODEX_HOME ?? '').trim();
if (codexHome) {
// Attempt reverse-map: does any registered profile's codexHome match?
const matchedName =
Object.keys(registry.profiles).find((name) => path.join(instancesDir, name) === codexHome) ??
null;
return {
name: matchedName,
source: 'explicit-codex-home',
codexHome,
};
}
// Precedence 2: $CCS_CODEX_PROFILE env var
const profileEnv = (process.env.CCS_CODEX_PROFILE ?? '').trim();
if (profileEnv) {
if (!Object.prototype.hasOwnProperty.call(registry.profiles, profileEnv)) {
logger.warn(
'codex-auth.dashboard.stale-env-profile',
'Ignoring CCS_CODEX_PROFILE because it is not registered',
{ profileName: profileEnv }
);
return null;
}
return {
name: profileEnv,
source: 'env',
codexHome: path.join(instancesDir, profileEnv),
};
}
// Precedence 3: registry default
const defaultProfile = registry.default;
if (defaultProfile) {
return {
name: defaultProfile,
source: 'default',
codexHome: path.join(instancesDir, defaultProfile),
};
}
// Precedence 4: no active profile (legacy ~/.codex mode)
return null;
}
// ── Core builder ────────────────────────────────────────────────────────────
async function buildSummary(): Promise<CodexAuthProfilesSummary> {
const registry = readRegistry();
const active = resolveActive(registry);
const profiles: CodexAuthProfileEntry[] = Object.entries(registry.profiles).map(
([name, meta]) => {
const entry = buildProfileEntry(name);
entry.lastUsed = meta.last_used ?? null;
return entry;
}
);
return {
active,
default: registry.default,
profiles,
};
}
// ── Public API ──────────────────────────────────────────────────────────────
/**
* Returns the codex-auth profiles summary, using a 5s in-memory cache.
* Tokens are never included in the returned object.
*/
export async function getCodexAuthProfilesSummary(): Promise<CodexAuthProfilesSummary> {
const now = Date.now();
const registrySignature = getRegistryCacheSignature();
if (cache && cache.expiresAt > now && cache.registrySignature === registrySignature) {
return cache.value;
}
const value = await buildSummary();
cache = { value, expiresAt: now + TTL_MS, registrySignature };
return value;
}