From ca39a69b6befc6e87acbcbcc49d4ec7741a366f6 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Sun, 12 Jul 2026 16:05:34 +0000 Subject: [PATCH 1/4] feat(sync): push AI usage telemetry to a team OTLP backend (preview) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in `codeburn sync` command group: setup, push, status, logout, reset. Developers authenticate via standard OIDC browser login (Authorization Code + PKCE, provider-agnostic — Cognito, Auth0, Okta, Keycloak, etc.) and push per-call usage metadata to a team-operated OTLP/HTTP endpoint. Privacy is structural: the payload builder has no field that can carry prompts, responses, file contents, or bash commands. Only metadata leaves the machine (provider, model, tokens, cost, project, tool names, timestamps). There is deliberately no flag to include more. Setup derives everything from a single URL: GET {base}/.well-known/codeburn-export.json → issuer, client_id, traces_path, max_batch_size Modules: - discovery.ts discovery doc fetch/validation (version-gated) - auth.ts OIDC discovery, PKCE, localhost callback (:19876-78), token exchange/refresh/revoke - credentials.ts refresh token in OS keychain (macOS security / Linux libsecret / Windows DPAPI / 0600 file fallback). All subprocess calls use execFileSync arg arrays; tokens flow via stdin or env var — never shell-interpolated - config.ts ~/.config/codeburn/sync.json - ledger.ts client-side sent-ledger for exact dedup (no watermark races), 6-month prune - otlp.ts ParsedApiCall[] → ExportTraceServiceRequest JSON with deterministic IDs: span=SHA256(dedupKey)[:8], trace=SHA256(sessionId)[:16] — every retry idempotent - push.ts orchestration with typed outcomes; pushes run to completion — 429 Retry-After honored (≤120s/wait, 3 retries/batch) before deferring; partial_success is batch-atomic (nothing ledgered, whole batch retries); 50K safety valve, no routine cap - cli.ts command layer; --dry-run reports exact counts/cost without sending Preview feature: protocol may change between releases. AI-Origin: human --- package-lock.json | 48 ++++++ package.json | 1 + src/main.ts | 2 + src/sync/auth.ts | 297 ++++++++++++++++++++++++++++++++++++ src/sync/cli.ts | 328 ++++++++++++++++++++++++++++++++++++++++ src/sync/config.ts | 66 ++++++++ src/sync/credentials.ts | 211 ++++++++++++++++++++++++++ src/sync/discovery.ts | 93 ++++++++++++ src/sync/index.ts | 8 + src/sync/ledger.ts | 75 +++++++++ src/sync/otlp.ts | 139 +++++++++++++++++ src/sync/push.ts | 171 +++++++++++++++++++++ 12 files changed, 1439 insertions(+) create mode 100644 src/sync/auth.ts create mode 100644 src/sync/cli.ts create mode 100644 src/sync/config.ts create mode 100644 src/sync/credentials.ts create mode 100644 src/sync/discovery.ts create mode 100644 src/sync/index.ts create mode 100644 src/sync/ledger.ts create mode 100644 src/sync/otlp.ts create mode 100644 src/sync/push.ts diff --git a/package-lock.json b/package-lock.json index e649491f..16b2e23f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@types/node": "^22.19.17", "@types/react": "^19.2.14", "@types/selfsigned": "^2.0.4", + "playwright": "^1.61.1", "tsup": "^8.4.0", "tsx": "^4.19.0", "typescript": "^5.8.0", @@ -2834,6 +2835,53 @@ "node": ">=16.0.0" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/package.json b/package.json index f3f59c03..9fda978c 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@types/node": "^22.19.17", "@types/react": "^19.2.14", "@types/selfsigned": "^2.0.4", + "playwright": "^1.61.1", "tsup": "^8.4.0", "tsx": "^4.19.0", "typescript": "^5.8.0", diff --git a/src/main.ts b/src/main.ts index a4e435d4..6b4abbcd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,6 +26,7 @@ import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, import { runOptimize } from './optimize.js' import { registerActCommands } from './act/cli.js' import { registerGuardCommands } from './guard/cli.js' +import { registerSyncCommands } from './sync/cli.js' import { runContextCommand } from './context-tree.js' import { renderCompare } from './compare.js' import { @@ -1597,5 +1598,6 @@ program registerActCommands(program) registerGuardCommands(program) +registerSyncCommands(program) program.parse() diff --git a/src/sync/auth.ts b/src/sync/auth.ts new file mode 100644 index 00000000..3ce7af2b --- /dev/null +++ b/src/sync/auth.ts @@ -0,0 +1,297 @@ +/** + * codeburn sync — OIDC authentication. + * + * Authorization Code + PKCE flow with localhost callback server. + */ + +import { createHash, randomBytes } from 'crypto' +import { createServer, type Server } from 'http' +import { URL } from 'url' + +export interface OidcConfig { + authorization_endpoint: string + token_endpoint: string + revocation_endpoint?: string + scopes_supported?: string[] +} + +export interface TokenResponse { + access_token: string + refresh_token?: string + expires_in?: number + token_type: string +} + +export class AuthError extends Error { + constructor(message: string) { + super(message) + this.name = 'AuthError' + } +} + +// --- OIDC Discovery --- + +export async function fetchOidcConfig(issuer: string): Promise { + const url = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration` + + let response: Response + try { + response = await fetch(url) + } catch (err) { + throw new AuthError(`Cannot reach OIDC discovery at ${url}: ${(err as Error).message}`) + } + + if (!response.ok) { + throw new AuthError(`OIDC discovery returned HTTP ${response.status}: ${url}`) + } + + let body: Record + try { + body = await response.json() as Record + } catch { + throw new AuthError(`OIDC discovery returned invalid JSON: ${url}`) + } + + const authorization_endpoint = body.authorization_endpoint + const token_endpoint = body.token_endpoint + if (typeof authorization_endpoint !== 'string' || typeof token_endpoint !== 'string') { + throw new AuthError('OIDC discovery missing authorization_endpoint or token_endpoint') + } + + return { + authorization_endpoint, + token_endpoint, + revocation_endpoint: typeof body.revocation_endpoint === 'string' ? body.revocation_endpoint : undefined, + scopes_supported: Array.isArray(body.scopes_supported) + ? (body.scopes_supported as unknown[]).filter((s): s is string => typeof s === 'string') + : undefined, + } +} + +// --- PKCE --- + +export interface PkceChallenge { + code_verifier: string + code_challenge: string + code_challenge_method: 'S256' +} + +export function generatePkce(): PkceChallenge { + // RFC 7636: 43-128 chars, unreserved characters + const code_verifier = randomBytes(32).toString('base64url') + const code_challenge = createHash('sha256').update(code_verifier).digest('base64url') + return { code_verifier, code_challenge, code_challenge_method: 'S256' } +} + +// --- Auth URL --- + +export const CALLBACK_PORTS = [19876, 19877, 19878] as const + +export interface AuthUrlParams { + authorization_endpoint: string + client_id: string + redirect_uri: string + scopes: string[] + state: string + pkce: PkceChallenge +} + +export function buildAuthUrl(params: AuthUrlParams): string { + const url = new URL(params.authorization_endpoint) + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', params.client_id) + url.searchParams.set('redirect_uri', params.redirect_uri) + url.searchParams.set('scope', params.scopes.join(' ')) + url.searchParams.set('state', params.state) + url.searchParams.set('code_challenge', params.pkce.code_challenge) + url.searchParams.set('code_challenge_method', params.pkce.code_challenge_method) + return url.toString() +} + +export function resolveScopes(requestedScopes: string[], idpScopesSupported?: string[]): string[] { + const scopes = [...requestedScopes] + // Add offline_access only if the IdP advertises it + if (idpScopesSupported?.includes('offline_access') && !scopes.includes('offline_access')) { + scopes.push('offline_access') + } + return scopes +} + +// --- Callback Server --- + +export interface CallbackResult { + code: string + port: number +} + +export function startCallbackServer( + expectedState: string, + timeoutMs: number = 300_000, // 5 minutes +): { promise: Promise; port: number; server: Server } { + let resolvedPort = 0 + let server: Server + + const promise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + server?.close() + reject(new AuthError('Login timed out after 5 minutes. Please try again.')) + }, timeoutMs) + + server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://127.0.0.1`) + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const error = url.searchParams.get('error') + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }) + res.end('

Login failed

You can close this tab.

') + clearTimeout(timer) + server.close() + reject(new AuthError(`IdP returned error: ${error}`)) + return + } + + if (state !== expectedState) { + res.writeHead(400, { 'Content-Type': 'text/plain' }) + res.end('Invalid state parameter') + return // don't close — might be a stale request + } + + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/plain' }) + res.end('Missing authorization code') + return + } + + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end('

✓ Login successful

You can close this tab.

') + clearTimeout(timer) + server.close() + resolve({ code, port: resolvedPort }) + }) + + // Try ports in order + const tryListen = (ports: readonly number[], idx: number) => { + if (idx >= ports.length) { + clearTimeout(timer) + reject(new AuthError(`All callback ports (${ports.join(', ')}) are in use. Close other codeburn instances and retry.`)) + return + } + const port = ports[idx]! + server.once('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + tryListen(ports, idx + 1) + } else { + clearTimeout(timer) + reject(new AuthError(`Callback server error: ${err.message}`)) + } + }) + server.listen(port, '127.0.0.1', () => { + resolvedPort = port + }) + } + + tryListen(CALLBACK_PORTS, 0) + }) + + // Return immediately so caller can read the port + return { promise, port: resolvedPort, server: server! } +} + +// --- Token Exchange --- + +export async function exchangeCode( + tokenEndpoint: string, + code: string, + codeVerifier: string, + redirectUri: string, + clientId: string, +): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + code_verifier: codeVerifier, + redirect_uri: redirectUri, + client_id: clientId, + }) + + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + + if (!response.ok) { + let detail = '' + try { detail = await response.text() } catch {} + throw new AuthError(`Token exchange failed (HTTP ${response.status}): ${detail}`) + } + + const data = await response.json() as Record + if (typeof data.access_token !== 'string') { + throw new AuthError('Token response missing access_token') + } + + return { + access_token: data.access_token, + refresh_token: typeof data.refresh_token === 'string' ? data.refresh_token : undefined, + expires_in: typeof data.expires_in === 'number' ? data.expires_in : undefined, + token_type: typeof data.token_type === 'string' ? data.token_type : 'Bearer', + } +} + +export async function refreshToken( + tokenEndpoint: string, + refreshTokenValue: string, + clientId: string, +): Promise { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshTokenValue, + client_id: clientId, + }) + + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + + if (!response.ok) { + let detail = '' + try { detail = await response.text() } catch {} + if (response.status === 400 || response.status === 401) { + throw new AuthError('Sync auth expired. Run `codeburn sync setup` to re-authenticate.') + } + throw new AuthError(`Token refresh failed (HTTP ${response.status}): ${detail}`) + } + + const data = await response.json() as Record + if (typeof data.access_token !== 'string') { + throw new AuthError('Refresh response missing access_token') + } + + return { + access_token: data.access_token, + refresh_token: typeof data.refresh_token === 'string' ? data.refresh_token : undefined, + expires_in: typeof data.expires_in === 'number' ? data.expires_in : undefined, + token_type: typeof data.token_type === 'string' ? data.token_type : 'Bearer', + } +} + +export async function revokeToken( + revocationEndpoint: string, + token: string, + clientId: string, +): Promise { + try { + await fetch(revocationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token, client_id: clientId }).toString(), + }) + } catch { + // Best-effort — don't fail logout if revocation endpoint is unavailable + } +} diff --git a/src/sync/cli.ts b/src/sync/cli.ts new file mode 100644 index 00000000..10bd9419 --- /dev/null +++ b/src/sync/cli.ts @@ -0,0 +1,328 @@ +/** + * codeburn sync — CLI commands. + * + * Registers: sync setup | push | status | logout | reset + */ + +import type { Command } from 'commander' +import { randomBytes } from 'crypto' + +import { fetchDiscoveryDoc } from './discovery.js' +import { + fetchOidcConfig, + generatePkce, + buildAuthUrl, + resolveScopes, + startCallbackServer, + exchangeCode, + refreshToken, + revokeToken, + CALLBACK_PORTS, +} from './auth.js' +import { createCredentialStore } from './credentials.js' +import { readSyncConfig, writeSyncConfig, deleteSyncConfig, updateLastSync } from './config.js' +import { collectUnsentCalls, sendBatches, batchCalls, MAX_PER_PUSH } from './push.js' + +export function registerSyncCommands(program: Command): void { + const sync = program + .command('sync') + .description('Sync AI usage telemetry to a remote OTLP endpoint') + + // --- setup --- + sync + .command('setup ') + .description('Configure sync with a remote endpoint (one-time)') + .action(async (url: string) => { + const baseUrl = url.replace(/\/$/, '') + process.stderr.write(`Fetching discovery doc from ${baseUrl}...\n`) + + // 1. Fetch codeburn discovery doc + const discovery = await fetchDiscoveryDoc(baseUrl) + process.stderr.write(` Issuer: ${discovery.issuer}\n`) + process.stderr.write(` Client: ${discovery.client_id}\n`) + + // 2. Fetch OIDC configuration from the issuer + const oidc = await fetchOidcConfig(discovery.issuer) + process.stderr.write(` Auth endpoint: ${oidc.authorization_endpoint}\n`) + + // 3. Resolve scopes + const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported) + + // 4. Generate PKCE + const pkce = generatePkce() + const state = randomBytes(16).toString('hex') + + // 5. Start callback server + const { promise: callbackPromise, server } = startCallbackServer(state) + + // Wait briefly for server to bind, then get the port + await new Promise(resolve => setTimeout(resolve, 100)) + const addr = server.address() + const port = typeof addr === 'object' && addr ? addr.port : CALLBACK_PORTS[0] + const redirectUri = `http://127.0.0.1:${port}/callback` + + // 6. Build auth URL and open browser + const authUrl = buildAuthUrl({ + authorization_endpoint: oidc.authorization_endpoint, + client_id: discovery.client_id, + redirect_uri: redirectUri, + scopes, + state, + pkce, + }) + + process.stderr.write(`\nOpening browser for login...\n`) + process.stderr.write(`If the browser doesn't open, visit:\n ${authUrl}\n\n`) + + // Open browser (best-effort, platform-specific). + // execFileSync with args array — authUrl comes from the remote discovery + // doc so it must never be shell-interpolated. Scheme is also validated. + try { + if (!/^https:\/\//.test(authUrl)) { + throw new Error('auth URL must be https') + } + const { execFileSync } = await import('child_process') + if (process.platform === 'darwin') { + execFileSync('open', [authUrl], { stdio: 'ignore' }) + } else if (process.platform === 'win32') { + // `start` is a cmd builtin; empty first arg is the window title + execFileSync('cmd', ['/c', 'start', '', authUrl], { stdio: 'ignore' }) + } else { + execFileSync('xdg-open', [authUrl], { stdio: 'ignore' }) + } + } catch { + // Browser open failed — user sees the URL above + } + + // 7. Wait for callback + process.stderr.write(`Waiting for login (5 min timeout)...\n`) + const callback = await callbackPromise + + // 8. Exchange code for tokens + const tokenRedirectUri = `http://127.0.0.1:${callback.port}/callback` + const tokens = await exchangeCode( + oidc.token_endpoint, + callback.code, + pkce.code_verifier, + tokenRedirectUri, + discovery.client_id, + ) + + if (!tokens.refresh_token) { + process.stderr.write(`Warning: IdP did not return a refresh token. You may need to re-authenticate frequently.\n`) + } + + // 9. Store credentials + const store = createCredentialStore() + if (tokens.refresh_token) { + store.store(tokens.refresh_token) + } + + // 10. Write config + writeSyncConfig({ + baseUrl, + clientId: discovery.client_id, + tracesPath: discovery.traces_path, + issuer: discovery.issuer, + }) + + process.stderr.write(`\n✓ Sync configured successfully.\n`) + process.stderr.write(` Endpoint: ${baseUrl}\n`) + process.stderr.write(` Token stored in: ${store.method()}\n`) + process.stderr.write(`\nRun \`codeburn sync push\` to send telemetry data.\n`) + }) + + // --- status --- + sync + .command('status') + .description('Show sync configuration and auth status') + .action(async () => { + const config = readSyncConfig() + if (!config) { + process.stderr.write('Sync not configured. Run `codeburn sync setup ` first.\n') + process.exit(1) + } + + const store = createCredentialStore() + const token = store.retrieve() + + process.stdout.write(`Endpoint: ${config.baseUrl}\n`) + process.stdout.write(`Traces path: ${config.tracesPath}\n`) + process.stdout.write(`Issuer: ${config.issuer}\n`) + process.stdout.write(`Auth: ${token ? 'configured' : 'missing (run sync setup)'}\n`) + process.stdout.write(`Token storage: ${store.method()}\n`) + process.stdout.write(`Last sync: ${config.lastSync ?? 'never'}\n`) + }) + + // --- logout --- + sync + .command('logout') + .description('Remove stored credentials and revoke token') + .action(async () => { + const config = readSyncConfig() + const store = createCredentialStore() + const token = store.retrieve() + + // Revoke if we have a token and know the revocation endpoint + if (token && config) { + try { + const oidc = await fetchOidcConfig(config.issuer) + if (oidc.revocation_endpoint) { + await revokeToken(oidc.revocation_endpoint, token, config.clientId) + process.stderr.write('Token revoked at IdP.\n') + } + } catch { + // Best-effort revocation + } + } + + store.delete() + deleteSyncConfig() + process.stderr.write('Sync credentials and config removed.\n') + }) + + // --- reset --- + sync + .command('reset') + .description('Clear the sent-ledger (next push re-sends all calls in window)') + .option('--confirm', 'Required to confirm reset') + .action(async (opts: { confirm?: boolean }) => { + if (!opts.confirm) { + process.stderr.write('This will clear the sent-ledger, causing the next push to re-send all data.\n') + process.stderr.write('Run with --confirm to proceed.\n') + process.exit(1) + } + + const { join } = await import('path') + const { homedir } = await import('os') + const { unlinkSync, existsSync } = await import('fs') + + const ledgerPath = join(homedir(), '.cache', 'codeburn', 'sync-ledger.json') + if (existsSync(ledgerPath)) { + unlinkSync(ledgerPath) + process.stderr.write('Ledger cleared. Next push will re-send all calls in window.\n') + } else { + process.stderr.write('No ledger file found (nothing to reset).\n') + } + }) + + // --- push (placeholder for Step 2) --- + sync + .command('push') + .description('Push unsent telemetry data to the configured endpoint') + .option('--since ', 'Time window: today, 7d, 30d, month, all (max 6 months)', '7d') + .option('--dry-run', 'Show what would be sent without sending') + .action(async (opts: { since: string; dryRun?: boolean }) => { + const config = readSyncConfig() + if (!config) { + process.stderr.write('Sync not configured. Run `codeburn sync setup ` first.\n') + process.exit(1) + } + + const store = createCredentialStore() + const rt = store.retrieve() + if (!rt) { + process.stderr.write('No auth token found. Run `codeburn sync setup` to authenticate.\n') + process.exit(1) + } + + // Refresh token + try { + const oidc = await fetchOidcConfig(config.issuer) + const tokens = await refreshToken(oidc.token_endpoint, rt, config.clientId) + + // Store rotated token if present + if (tokens.refresh_token && tokens.refresh_token !== rt) { + store.store(tokens.refresh_token) + } + + if (opts.dryRun) { + process.stderr.write(`[dry-run] Auth: valid (Bearer token obtained)\n`) + } + + // Collect data + const { parseAllSessions } = await import('../parser.js') + const { getDateRange } = await import('../cli-date.js') + + // Map --since to a parser period. Strict: unknown values are an error. + const sinceToPeriod: Record = { + 'today': 'today', + '7d': 'week', 'week': 'week', + '30d': '30days', '30days': '30days', + 'month': 'month', + 'all': 'all', // up to 6 months (parser retention limit) + } + const period = sinceToPeriod[opts.since] + if (!period) { + process.stderr.write(`Unknown --since value "${opts.since}". Valid: today, 7d, 30d, month, all.\n`) + process.exit(1) + } + const { range } = getDateRange(period) + const projects = await parseAllSessions(range) + + // Flatten + filter against sent-ledger + const { allCalls, unsent } = collectUnsentCalls(projects) + + if (opts.dryRun) { + const toPushCount = Math.min(unsent.length, MAX_PER_PUSH) + const cost = unsent.slice(0, MAX_PER_PUSH).reduce((s, c) => s + c.call.costUSD, 0) + process.stderr.write(`[dry-run] Window: ${opts.since} — ${allCalls.length} calls total, ${allCalls.length - unsent.length} already synced\n`) + process.stderr.write(`[dry-run] Would push ${toPushCount} calls ($${cost.toFixed(2)}) to ${config.baseUrl}${config.tracesPath}\n`) + if (unsent.length > MAX_PER_PUSH) { + process.stderr.write(`[dry-run] ${unsent.length - MAX_PER_PUSH} more calls exceed the ${MAX_PER_PUSH} safety limit — a second push would be needed\n`) + } + return + } + + if (unsent.length === 0) { + process.stderr.write(`Nothing to push (${allCalls.length} calls already synced).\n`) + updateLastSync() + return + } + + // Safety valve (not a routine cap — pushes run to completion) + const toPush = unsent.slice(0, MAX_PER_PUSH) + if (unsent.length > MAX_PER_PUSH) { + process.stderr.write(`${unsent.length} unsent calls exceed the ${MAX_PER_PUSH} safety limit. Pushing first ${MAX_PER_PUSH}; run again to continue.\n`) + } + + // Batch and send (loops until done; waits out 429 rate limits) + const discoveryDoc = await fetchDiscoveryDoc(config.baseUrl) + const batches = batchCalls(toPush, discoveryDoc.max_batch_size) + const endpoint = `${config.baseUrl}${config.tracesPath}` + + const result = await sendBatches({ + endpoint, + accessToken: tokens.access_token, + batches, + log: msg => process.stderr.write(`${msg}\n`), + }) + + if (result.outcome === 'auth-rejected') { + process.stderr.write('Auth rejected by server. Run `codeburn sync setup` to re-authenticate.\n') + process.exit(1) + } + if (result.outcome === 'rate-limited') { + process.stderr.write(`Rate limited — gave up after repeated retries. Remaining calls will be sent on the next push.\n`) + } + if (result.outcome === 'server-error') { + process.stderr.write(`Server error (HTTP ${result.httpStatus}). Remaining calls will be sent on the next push.\n`) + } + + // Update lastSync + updateLastSync() + + // Summary + process.stderr.write(`\nSynced ${result.totalSent} calls ($${result.totalCostSent.toFixed(2)}) to ${config.baseUrl}\n`) + if (result.totalRejected > 0) { + process.stderr.write(` ${result.totalRejected} spans rejected (will retry on next push)\n`) + } + if (unsent.length > MAX_PER_PUSH) { + process.stderr.write(` ${unsent.length - MAX_PER_PUSH} calls remaining (safety limit). Run \`codeburn sync push\` again.\n`) + } + } catch (err) { + process.stderr.write(`${(err as Error).message}\n`) + process.exit(1) + } + }) +} diff --git a/src/sync/config.ts b/src/sync/config.ts new file mode 100644 index 00000000..6ddfcec0 --- /dev/null +++ b/src/sync/config.ts @@ -0,0 +1,66 @@ +/** + * codeburn sync — config file management. + * + * Stores non-secret sync configuration at ~/.config/codeburn/sync.json + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' + +export interface SyncConfig { + baseUrl: string + clientId: string + tracesPath: string + issuer: string + lastSync?: string +} + +function configDir(): string { + return join(homedir(), '.config', 'codeburn') +} + +function configPath(): string { + return join(configDir(), 'sync.json') +} + +export function readSyncConfig(): SyncConfig | null { + const path = configPath() + if (!existsSync(path)) return null + + try { + const raw = readFileSync(path, 'utf-8') + const data = JSON.parse(raw) as Record + + if (typeof data.baseUrl !== 'string' || typeof data.clientId !== 'string') { + return null + } + + return { + baseUrl: data.baseUrl, + clientId: data.clientId, + tracesPath: typeof data.tracesPath === 'string' ? data.tracesPath : '/v1/traces', + issuer: typeof data.issuer === 'string' ? data.issuer : '', + lastSync: typeof data.lastSync === 'string' ? data.lastSync : undefined, + } + } catch { + return null + } +} + +export function writeSyncConfig(config: SyncConfig): void { + const dir = configDir() + mkdirSync(dir, { recursive: true }) + writeFileSync(configPath(), JSON.stringify(config, null, 2) + '\n') +} + +export function updateLastSync(): void { + const config = readSyncConfig() + if (!config) return + config.lastSync = new Date().toISOString() + writeSyncConfig(config) +} + +export function deleteSyncConfig(): void { + try { unlinkSync(configPath()) } catch { /* may not exist */ } +} diff --git a/src/sync/credentials.ts b/src/sync/credentials.ts new file mode 100644 index 00000000..a560581b --- /dev/null +++ b/src/sync/credentials.ts @@ -0,0 +1,211 @@ +/** + * codeburn sync — OS credential storage. + * + * Stores refresh tokens in the OS keychain. + * Falls back to a 0600 file when no keychain is available. + */ + +import { execFileSync } from 'child_process' +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' + +const SERVICE_NAME = 'codeburn-sync' +const ACCOUNT_NAME = 'refresh-token' + +export type StorageMethod = 'keychain' | 'secret-tool' | 'dpapi' | 'file' + +export interface CredentialStore { + store(token: string): void + retrieve(): string | null + delete(): void + method(): StorageMethod +} + +// --- macOS Keychain --- + +class KeychainStore implements CredentialStore { + store(token: string): void { + // Delete existing first (add-generic-password fails if entry exists) + try { + execFileSync('security', ['delete-generic-password', '-s', SERVICE_NAME, '-a', ACCOUNT_NAME], { stdio: 'pipe' }) + } catch { /* may not exist */ } + + // Token passed as arg to execFileSync (no shell interpolation). + // Note: still briefly visible in process args on macOS; `security` has no + // stdin mode for -w with non-interactive use, so this is the best available. + execFileSync('security', ['add-generic-password', '-s', SERVICE_NAME, '-a', ACCOUNT_NAME, '-w', token], { stdio: 'pipe' }) + } + + retrieve(): string | null { + try { + const result = execFileSync( + 'security', + ['find-generic-password', '-s', SERVICE_NAME, '-a', ACCOUNT_NAME, '-w'], + { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } + ) + return result.trim() || null + } catch { + return null + } + } + + delete(): void { + try { + execFileSync('security', ['delete-generic-password', '-s', SERVICE_NAME, '-a', ACCOUNT_NAME], { stdio: 'pipe' }) + } catch { /* may not exist */ } + } + + method(): StorageMethod { return 'keychain' } +} + +// --- Linux libsecret --- + +class SecretToolStore implements CredentialStore { + store(token: string): void { + // Token passed via stdin — never appears in argv or shell string + execFileSync( + 'secret-tool', + ['store', `--label=${SERVICE_NAME}`, 'service', SERVICE_NAME, 'account', ACCOUNT_NAME], + { input: token, stdio: ['pipe', 'pipe', 'pipe'] } + ) + } + + retrieve(): string | null { + try { + const result = execFileSync( + 'secret-tool', + ['lookup', 'service', SERVICE_NAME, 'account', ACCOUNT_NAME], + { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } + ) + return result.trim() || null + } catch { + return null + } + } + + delete(): void { + try { + execFileSync('secret-tool', ['clear', 'service', SERVICE_NAME, 'account', ACCOUNT_NAME], { stdio: 'pipe' }) + } catch { /* may not exist */ } + } + + method(): StorageMethod { return 'secret-tool' } +} + +// --- Windows DPAPI --- + +class DpapiStore implements CredentialStore { + private filePath: string + + constructor() { + this.filePath = join(homedir(), '.config', 'codeburn', '.sync-token-dpapi') + } + + store(token: string): void { + // Token passed via environment variable — never in argv or command string + const ps = `$s = ConvertTo-SecureString $env:CODEBURN_SYNC_TOKEN -AsPlainText -Force; ConvertFrom-SecureString $s` + const encrypted = execFileSync('powershell', ['-NoProfile', '-Command', ps], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, CODEBURN_SYNC_TOKEN: token }, + }).trim() + + const dir = join(homedir(), '.config', 'codeburn') + mkdirSync(dir, { recursive: true }) + writeFileSync(this.filePath, encrypted, { mode: 0o600 }) + } + + retrieve(): string | null { + if (!existsSync(this.filePath)) return null + try { + const encrypted = readFileSync(this.filePath, 'utf-8').trim() + const ps = `$s = ConvertTo-SecureString $env:CODEBURN_SYNC_BLOB; [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))` + const result = execFileSync('powershell', ['-NoProfile', '-Command', ps], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, CODEBURN_SYNC_BLOB: encrypted }, + }) + return result.trim() || null + } catch { + return null + } + } + + delete(): void { + try { unlinkSync(this.filePath) } catch { /* may not exist */ } + } + + method(): StorageMethod { return 'dpapi' } +} + +// --- File Fallback --- + +class FileStore implements CredentialStore { + private filePath: string + + constructor() { + this.filePath = join(homedir(), '.config', 'codeburn', '.sync-token') + } + + store(token: string): void { + const dir = join(homedir(), '.config', 'codeburn') + mkdirSync(dir, { recursive: true }) + writeFileSync(this.filePath, token, { mode: 0o600 }) + // Ensure permissions (writeFile mode doesn't always work on existing files) + try { chmodSync(this.filePath, 0o600) } catch {} + } + + retrieve(): string | null { + if (!existsSync(this.filePath)) return null + try { + return readFileSync(this.filePath, 'utf-8').trim() || null + } catch { + return null + } + } + + delete(): void { + try { unlinkSync(this.filePath) } catch { /* may not exist */ } + } + + method(): StorageMethod { return 'file' } +} + +// --- Factory --- + +function isCommandAvailable(cmd: string): boolean { + try { + const probe = process.platform === 'win32' ? 'where' : 'which' + execFileSync(probe, [cmd], { stdio: 'pipe' }) + return true + } catch { + return false + } +} + +export function createCredentialStore(): CredentialStore { + if (process.platform === 'darwin') { + return new KeychainStore() + } + + if (process.platform === 'win32') { + return new DpapiStore() + } + + // Linux: try secret-tool, fall back to file + if (isCommandAvailable('secret-tool')) { + // Also verify the keyring daemon is running + try { + execFileSync('secret-tool', ['lookup', 'service', '__codeburn_probe__', 'account', '__probe__'], { stdio: 'pipe' }) + return new SecretToolStore() + } catch (err) { + // Exit code 1 = not found (keyring works). Other errors = keyring not running. + if ((err as { status?: number }).status === 1) { + return new SecretToolStore() + } + } + } + + return new FileStore() +} diff --git a/src/sync/discovery.ts b/src/sync/discovery.ts new file mode 100644 index 00000000..60f5ffe3 --- /dev/null +++ b/src/sync/discovery.ts @@ -0,0 +1,93 @@ +/** + * codeburn sync — discovery document parser. + * + * Fetches and validates {baseUrl}/.well-known/codeburn-export.json + */ + +export interface CodeburnDiscoveryDoc { + version: number + issuer: string + client_id: string + scopes: string[] + traces_path: string + max_batch_size: number +} + +export class DiscoveryError extends Error { + constructor(message: string) { + super(message) + this.name = 'DiscoveryError' + } +} + +const SUPPORTED_VERSION = 1 + +export function parseDiscoveryDoc(raw: unknown): CodeburnDiscoveryDoc { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new DiscoveryError('Discovery doc must be a JSON object') + } + + const doc = raw as Record + + // Version check + const version = typeof doc.version === 'number' ? doc.version : 1 + if (version > SUPPORTED_VERSION) { + throw new DiscoveryError( + `This endpoint requires codeburn sync v${version}. Please update codeburn.` + ) + } + + // Required fields + const issuer = doc.issuer + if (typeof issuer !== 'string' || !issuer) { + throw new DiscoveryError('Discovery doc missing required field: issuer') + } + + const client_id = doc.client_id + if (typeof client_id !== 'string' || !client_id) { + throw new DiscoveryError('Discovery doc missing required field: client_id') + } + + // Optional with defaults + const scopes = Array.isArray(doc.scopes) + ? doc.scopes.filter((s): s is string => typeof s === 'string') + : ['openid'] + + const traces_path = typeof doc.traces_path === 'string' ? doc.traces_path : '/v1/traces' + + const max_batch_size = typeof doc.max_batch_size === 'number' && doc.max_batch_size > 0 + ? doc.max_batch_size + : 1000 + + return { version, issuer, client_id, scopes, traces_path, max_batch_size } +} + +export async function fetchDiscoveryDoc(baseUrl: string): Promise { + const url = `${baseUrl.replace(/\/$/, '')}/.well-known/codeburn-export.json` + + let response: Response + try { + response = await fetch(url) + } catch (err) { + throw new DiscoveryError(`Cannot reach ${url}: ${(err as Error).message}`) + } + + if (response.status === 404) { + throw new DiscoveryError( + `Server does not support codeburn sync.\n${url} returned 404.` + ) + } + + if (!response.ok) { + throw new DiscoveryError(`${url} returned HTTP ${response.status}`) + } + + let body: unknown + try { + body = await response.json() + } catch { + throw new DiscoveryError(`${url} returned invalid JSON`) + } + + return parseDiscoveryDoc(body) +} diff --git a/src/sync/index.ts b/src/sync/index.ts new file mode 100644 index 00000000..8aa19e4f --- /dev/null +++ b/src/sync/index.ts @@ -0,0 +1,8 @@ +export { registerSyncCommands } from './cli.js' +export { fetchDiscoveryDoc, parseDiscoveryDoc, type CodeburnDiscoveryDoc } from './discovery.js' +export { fetchOidcConfig, generatePkce, buildAuthUrl, resolveScopes, exchangeCode, refreshToken } from './auth.js' +export { createCredentialStore, type CredentialStore, type StorageMethod } from './credentials.js' +export { readSyncConfig, writeSyncConfig, deleteSyncConfig, type SyncConfig } from './config.js' +export { readLedger, appendToLedger, ledgerKeySet, clearLedger } from './ledger.js' +export { buildOtlpPayload, batchCalls, deriveSpanId, deriveTraceId, getDeviceId } from './otlp.js' +export { collectUnsentCalls, sendBatches, parseRetryAfterMs, MAX_PER_PUSH, type PushResult, type PushOutcome } from './push.js' diff --git a/src/sync/ledger.ts b/src/sync/ledger.ts new file mode 100644 index 00000000..eb4d7a34 --- /dev/null +++ b/src/sync/ledger.ts @@ -0,0 +1,75 @@ +/** + * codeburn sync — sent-ledger. + * + * Client-side deduplication: tracks which calls have been successfully pushed. + * Push logic: window minus ledger = what to send. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' + +export interface LedgerEntry { + key: string // deduplicationKey + ts: string // call timestamp (for pruning) +} + +const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000 + +function ledgerPath(): string { + return join(homedir(), '.cache', 'codeburn', 'sync-ledger.json') +} + +export function readLedger(): LedgerEntry[] { + const path = ledgerPath() + if (!existsSync(path)) return [] + try { + const raw = readFileSync(path, 'utf-8') + const entries = JSON.parse(raw) as unknown + if (!Array.isArray(entries)) return [] + return entries.filter( + (e): e is LedgerEntry => typeof e === 'object' && e !== null && typeof e.key === 'string' + ) + } catch { + return [] + } +} + +export function writeLedger(entries: LedgerEntry[]): void { + const dir = join(homedir(), '.cache', 'codeburn') + mkdirSync(dir, { recursive: true }) + writeFileSync(ledgerPath(), JSON.stringify(entries)) +} + +/** Append new entries after a successful push. Also prunes entries older than 6 months. */ +export function appendToLedger(newEntries: LedgerEntry[]): void { + const existing = readLedger() + const cutoff = new Date(Date.now() - SIX_MONTHS_MS).toISOString() + + // Prune old + dedupe + const keySet = new Set(existing.map(e => e.key)) + const pruned = existing.filter(e => !e.ts || e.ts > cutoff) + + for (const entry of newEntries) { + if (!keySet.has(entry.key)) { + pruned.push(entry) + keySet.add(entry.key) + } + } + + writeLedger(pruned) +} + +/** Get the set of already-sent deduplication keys for fast lookup. */ +export function ledgerKeySet(): Set { + return new Set(readLedger().map(e => e.key)) +} + +/** Clear the ledger (for sync reset). Returns the number of entries removed. */ +export function clearLedger(): number { + const path = ledgerPath() + if (!existsSync(path)) return 0 + const count = readLedger().length + unlinkSync(path) + return count +} diff --git a/src/sync/otlp.ts b/src/sync/otlp.ts new file mode 100644 index 00000000..b4704317 --- /dev/null +++ b/src/sync/otlp.ts @@ -0,0 +1,139 @@ +/** + * codeburn sync — OTLP payload builder. + * + * Converts ParsedApiCall[] into an ExportTraceServiceRequest (OTLP/HTTP JSON). + * Span and trace IDs are derived deterministically from deduplicationKey/sessionId. + */ + +import { createHash } from 'crypto' +import { hostname, userInfo } from 'os' +import type { ParsedApiCall } from '../types.js' + +export interface OtlpSpan { + traceId: string + spanId: string + name: string + startTimeUnixNano: string + endTimeUnixNano: string + attributes: OtlpAttribute[] +} + +export interface OtlpAttribute { + key: string + value: OtlpValue +} + +export type OtlpValue = + | { stringValue: string } + | { intValue: string } + | { doubleValue: number } + | { boolValue: boolean } + | { arrayValue: { values: OtlpValue[] } } + +export interface OtlpPayload { + resourceSpans: Array<{ + resource: { attributes: OtlpAttribute[] } + scopeSpans: Array<{ + spans: OtlpSpan[] + }> + }> +} + +// --- Device ID (pseudonymous, stable) --- + +let cachedDeviceId: string | null = null + +export function getDeviceId(): string { + if (cachedDeviceId) return cachedDeviceId + const raw = `${hostname()}:${userInfo().username}` + cachedDeviceId = createHash('sha256').update(raw).digest('hex').slice(0, 16) + return cachedDeviceId +} + +// --- Span/Trace ID derivation (deterministic) --- + +export function deriveSpanId(deduplicationKey: string): string { + return createHash('sha256').update(deduplicationKey).digest('hex').slice(0, 16) +} + +export function deriveTraceId(sessionId: string): string { + return createHash('sha256').update(sessionId).digest('hex').slice(0, 32) +} + +// --- Timestamp conversion --- + +function toUnixNano(isoTimestamp: string): string { + const ms = new Date(isoTimestamp).getTime() + if (isNaN(ms)) return '0' + return (BigInt(ms) * 1_000_000n).toString() +} + +// --- Payload construction --- + +export interface CallWithSession { + call: ParsedApiCall + sessionId: string + project: string +} + +export function buildOtlpPayload(calls: CallWithSession[]): OtlpPayload { + const deviceId = getDeviceId() + + const spans: OtlpSpan[] = calls.map(({ call, sessionId, project }) => { + const startNano = toUnixNano(call.timestamp) + // End time = start + 1ms (we don't have real duration, but OTLP requires both) + const endNano = (BigInt(startNano) + 1_000_000n).toString() + + const attributes: OtlpAttribute[] = [ + { key: 'ai.provider', value: { stringValue: call.provider } }, + { key: 'ai.model', value: { stringValue: call.model } }, + { key: 'ai.input_tokens', value: { intValue: String(call.usage.inputTokens) } }, + { key: 'ai.output_tokens', value: { intValue: String(call.usage.outputTokens) } }, + { key: 'ai.cost_usd', value: { doubleValue: call.costUSD } }, + { key: 'ai.project', value: { stringValue: project } }, + { key: 'ai.speed', value: { stringValue: call.speed } }, + ] + + if (call.tools.length > 0) { + attributes.push({ + key: 'ai.tools', + value: { arrayValue: { values: call.tools.map(t => ({ stringValue: t })) } }, + }) + } + + // cost_estimated = true when provider reports char-based estimates + const isEstimated = call.provider === 'kiro' || call.usage.inputTokens === 0 + attributes.push({ key: 'ai.cost_estimated', value: { boolValue: isEstimated } }) + + return { + traceId: deriveTraceId(sessionId), + spanId: deriveSpanId(call.deduplicationKey), + name: `${call.provider}/${call.model}`, + startTimeUnixNano: startNano, + endTimeUnixNano: endNano, + attributes, + } + }) + + return { + resourceSpans: [{ + resource: { + attributes: [ + { key: 'codeburn.device_id', value: { stringValue: deviceId } }, + ], + }, + scopeSpans: [{ + spans, + }], + }], + } +} + +/** Split calls into batches of maxBatchSize. */ +export function batchCalls(calls: CallWithSession[], maxBatchSize: number): CallWithSession[][] { + const batches: CallWithSession[][] = [] + for (let i = 0; i < calls.length; i += maxBatchSize) { + batches.push(calls.slice(i, i + maxBatchSize)) + } + return batches +} diff --git a/src/sync/push.ts b/src/sync/push.ts new file mode 100644 index 00000000..ffa11028 --- /dev/null +++ b/src/sync/push.ts @@ -0,0 +1,171 @@ +/** + * codeburn sync — push orchestration. + * + * Extracted from the CLI action so the flatten/filter/batch/send/ledger + * pipeline is unit-testable without a full CLI invocation. + */ + +import type { ProjectSummary } from '../types.js' +import { ledgerKeySet, appendToLedger, type LedgerEntry } from './ledger.js' +import { buildOtlpPayload, batchCalls, type CallWithSession } from './otlp.js' + +/** + * Safety valve, not a routine cap — pushes now loop until all batches are + * sent (429s are waited out). This only bounds a single push in pathological + * cases (e.g. corrupted ledger causing a full re-send of years of data). + */ +export const MAX_PER_PUSH = 50_000 + +/** Flatten parsed projects into individual calls and filter out already-sent ones. */ +export function collectUnsentCalls(projects: ProjectSummary[]): { + allCalls: CallWithSession[] + unsent: CallWithSession[] +} { + const allCalls: CallWithSession[] = [] + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + for (const call of turn.assistantCalls) { + allCalls.push({ + call, + sessionId: session.sessionId, + project: project.project, + }) + } + } + } + } + + const sent = ledgerKeySet() + const unsent = allCalls.filter(c => !sent.has(c.call.deduplicationKey)) + return { allCalls, unsent } +} + +export type PushOutcome = 'complete' | 'auth-rejected' | 'rate-limited' | 'server-error' + +export interface PushResult { + outcome: PushOutcome + totalSent: number + totalRejected: number + totalCostSent: number + retryAfter?: string + httpStatus?: number + /** Total milliseconds spent waiting on 429 Retry-After */ + totalWaitMs?: number +} + +export interface SendBatchesOptions { + endpoint: string + accessToken: string + batches: CallWithSession[][] + log?: (msg: string) => void + /** Injectable sleep for tests. Defaults to real setTimeout. */ + sleep?: (ms: number) => Promise + /** Max wait per 429 (caps Retry-After). Default 120s. */ + maxWaitMs?: number + /** Consecutive 429 retries per batch before giving up. Default 3. */ + max429Retries?: number +} + +/** Parse Retry-After header: delta-seconds or HTTP-date. Returns ms, or null. */ +export function parseRetryAfterMs(value: string | null): number | null { + if (!value) return null + if (/^-?\d+$/.test(value.trim())) { + const seconds = Number(value) + return seconds >= 0 ? seconds * 1000 : null + } + const date = Date.parse(value) + if (!isNaN(date)) return Math.max(0, date - Date.now()) + return null +} + +/** + * Send batches sequentially until all are sent. Ledgers each fully-accepted + * batch. Partially-rejected batches are NOT ledgered (OTLP doesn't identify + * which spans were rejected; deterministic span IDs make full-batch retry safe). + * + * 429 responses are honored: waits Retry-After (capped at maxWaitMs, default + * backoff 5s when absent) and retries the same batch, up to max429Retries + * consecutive times before giving up. Stops on 401/5xx — unsent batches + * retry on the next push. + */ +export async function sendBatches(opts: SendBatchesOptions): Promise { + const log = opts.log ?? (() => {}) + const sleep = opts.sleep ?? ((ms: number) => new Promise(r => setTimeout(r, ms))) + const maxWaitMs = opts.maxWaitMs ?? 120_000 + const max429Retries = opts.max429Retries ?? 3 + + let totalSent = 0 + let totalRejected = 0 + let totalCostSent = 0 + let totalWaitMs = 0 + + for (const batch of opts.batches) { + let attempts429 = 0 + + // Retry loop for the current batch (429 only) + for (;;) { + const payload = buildOtlpPayload(batch) + + const response = await fetch(opts.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${opts.accessToken}`, + }, + body: JSON.stringify(payload), + }) + + if (response.status === 401) { + return { outcome: 'auth-rejected', totalSent, totalRejected, totalCostSent, totalWaitMs, httpStatus: 401 } + } + + if (response.status === 429) { + const retryAfterHeader = response.headers.get('Retry-After') + attempts429++ + if (attempts429 > max429Retries) { + return { + outcome: 'rate-limited', totalSent, totalRejected, totalCostSent, totalWaitMs, + retryAfter: retryAfterHeader ?? undefined, httpStatus: 429, + } + } + const waitMs = Math.min(parseRetryAfterMs(retryAfterHeader) ?? 5000, maxWaitMs) + log(` Rate limited — waiting ${Math.round(waitMs / 1000)}s before retrying (attempt ${attempts429}/${max429Retries})`) + totalWaitMs += waitMs + await sleep(waitMs) + continue + } + + if (!response.ok) { + return { outcome: 'server-error', totalSent, totalRejected, totalCostSent, totalWaitMs, httpStatus: response.status } + } + + // Check for partial success + let rejected = 0 + try { + const body = await response.json() as { partialSuccess?: { rejectedSpans?: number } } + rejected = body?.partialSuccess?.rejectedSpans ?? 0 + } catch { /* empty response = full success */ } + + if (rejected > 0) { + // OTLP partial_success doesn't identify WHICH spans were rejected. + // Ledger nothing — the whole batch retries on the next push. + totalRejected += rejected + log(` Batch: ${rejected}/${batch.length} spans rejected — whole batch will retry on next push`) + } else { + const entries: LedgerEntry[] = batch.map(c => ({ + key: c.call.deduplicationKey, + ts: c.call.timestamp, + })) + appendToLedger(entries) + totalSent += batch.length + totalCostSent += batch.reduce((s, c) => s + c.call.costUSD, 0) + } + break // batch done (success or partial) — move to next batch + } + } + + return { outcome: 'complete', totalSent, totalRejected, totalCostSent, totalWaitMs } +} + +export { batchCalls } From 163842edacf63bc860bda9a8608ef0ec7af8e4b8 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Sun, 12 Jul 2026 16:05:34 +0000 Subject: [PATCH 2/4] test(sync): unit, mock-IdP e2e, and developer-only integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline, CI-safe (70 tests): - sync.test.ts (26): discovery parsing, PKCE, auth URL, scope negotiation, callback server (state mismatch, IdP error, timeout, port fallback), config round-trip - sync-ledger-otlp.test.ts (23): deterministic ID derivation, OTLP structure/attributes, batching, ledger append/dedup/prune/clear - sync-push.test.ts (15): pipeline against a scriptable mock OTLP server — success+ledger+cost, partial-success batch NOT ledgered, 401 stop, 429 wait-and-retry / persistent-429 give-up / Retry-After parsing (delta-seconds, HTTP-date, garbage), 5xx deferral, idempotent failure recovery - sync-e2e.test.ts (6) + fixtures/mock-idp.ts: full setup→refresh→ rotate→logout round-trip against an in-process mock IdP Developer-only (skip unless env vars set, never CI): - sync-headless-e2e.test.ts (1): real browser PKCE flow via Playwright - sync-infra-e2e.test.ts (4): push/auth-reject/batch/idempotent re-push against a deployed backend AI-Origin: human --- tests/fixtures/mock-idp.ts | 186 +++++++++++++++ tests/sync-e2e.test.ts | 244 +++++++++++++++++++ tests/sync-headless-e2e.test.ts | 145 ++++++++++++ tests/sync-infra-e2e.test.ts | 232 +++++++++++++++++++ tests/sync-ledger-otlp.test.ts | 286 +++++++++++++++++++++++ tests/sync-push.test.ts | 399 ++++++++++++++++++++++++++++++++ tests/sync.test.ts | 260 +++++++++++++++++++++ 7 files changed, 1752 insertions(+) create mode 100644 tests/fixtures/mock-idp.ts create mode 100644 tests/sync-e2e.test.ts create mode 100644 tests/sync-headless-e2e.test.ts create mode 100644 tests/sync-infra-e2e.test.ts create mode 100644 tests/sync-ledger-otlp.test.ts create mode 100644 tests/sync-push.test.ts create mode 100644 tests/sync.test.ts diff --git a/tests/fixtures/mock-idp.ts b/tests/fixtures/mock-idp.ts new file mode 100644 index 00000000..8bd23aa3 --- /dev/null +++ b/tests/fixtures/mock-idp.ts @@ -0,0 +1,186 @@ +/** + * Mock OIDC Identity Provider for codeburn sync e2e tests. + * + * Serves: + * - /.well-known/codeburn-export.json (discovery doc) + * - /.well-known/openid-configuration (OIDC discovery) + * - /oauth2/authorize (redirect with code — not used directly in tests) + * - /oauth2/token (exchanges code for tokens) + * - /oauth2/revoke (token revocation) + */ + +import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http' + +export interface MockIdpOptions { + /** Port to listen on (0 = ephemeral) */ + port?: number + /** Refresh token to issue */ + refreshToken?: string + /** Access token to issue */ + accessToken?: string + /** Simulate rotation: return a new refresh token on each exchange */ + rotateTokens?: boolean +} + +export interface MockIdp { + port: number + baseUrl: string + server: Server + close(): Promise + /** Tokens issued so far */ + issuedTokens: { access: string[]; refresh: string[] } + /** Revoked tokens */ + revokedTokens: string[] + /** Authorization codes that have been exchanged */ + exchangedCodes: string[] +} + +export async function startMockIdp(opts: MockIdpOptions = {}): Promise { + const refreshToken = opts.refreshToken ?? 'mock-refresh-token-v1' + const accessToken = opts.accessToken ?? 'mock-access-token-xyz' + let currentRefreshToken = refreshToken + let rotationCounter = 0 + + const state: MockIdp = { + port: 0, + baseUrl: '', + server: null!, + issuedTokens: { access: [], refresh: [] }, + revokedTokens: [], + exchangedCodes: [], + close: async () => {}, + } + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', `http://127.0.0.1:${state.port}`) + const path = url.pathname + + // --- Discovery doc --- + if (path === '/.well-known/codeburn-export.json') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + version: 1, + issuer: state.baseUrl, + client_id: 'mock-client-id', + scopes: ['openid', 'codeburn:write'], + traces_path: '/v1/traces', + max_batch_size: 100, + })) + return + } + + // --- OIDC Discovery --- + if (path === '/.well-known/openid-configuration') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + issuer: state.baseUrl, + authorization_endpoint: `${state.baseUrl}/oauth2/authorize`, + token_endpoint: `${state.baseUrl}/oauth2/token`, + revocation_endpoint: `${state.baseUrl}/oauth2/revoke`, + scopes_supported: ['openid', 'offline_access', 'codeburn:write'], + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + })) + return + } + + // --- Token endpoint --- + if (path === '/oauth2/token' && req.method === 'POST') { + let body = '' + req.on('data', chunk => { body += chunk }) + req.on('end', () => { + const params = new URLSearchParams(body) + const grantType = params.get('grant_type') + + if (grantType === 'authorization_code') { + const code = params.get('code') + if (!code) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'invalid_request', error_description: 'missing code' })) + return + } + + state.exchangedCodes.push(code) + state.issuedTokens.access.push(accessToken) + state.issuedTokens.refresh.push(currentRefreshToken) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + access_token: accessToken, + refresh_token: currentRefreshToken, + token_type: 'Bearer', + expires_in: 3600, + })) + return + } + + if (grantType === 'refresh_token') { + const rt = params.get('refresh_token') + if (rt !== currentRefreshToken) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'invalid_grant' })) + return + } + + // Simulate rotation if enabled + if (opts.rotateTokens) { + rotationCounter++ + currentRefreshToken = `mock-refresh-token-v${rotationCounter + 1}` + } + + const newAccess = `${accessToken}-refreshed-${Date.now()}` + state.issuedTokens.access.push(newAccess) + state.issuedTokens.refresh.push(currentRefreshToken) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + access_token: newAccess, + refresh_token: currentRefreshToken, + token_type: 'Bearer', + expires_in: 3600, + })) + return + } + + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'unsupported_grant_type' })) + }) + return + } + + // --- Revocation endpoint --- + if (path === '/oauth2/revoke' && req.method === 'POST') { + let body = '' + req.on('data', chunk => { body += chunk }) + req.on('end', () => { + const params = new URLSearchParams(body) + const token = params.get('token') + if (token) state.revokedTokens.push(token) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end('{}') + }) + return + } + + // --- 404 --- + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not Found') + }) + + await new Promise((resolve, reject) => { + server.listen(opts.port ?? 0, '127.0.0.1', () => { + const addr = server.address() + if (typeof addr === 'object' && addr) { + state.port = addr.port + state.baseUrl = `http://127.0.0.1:${addr.port}` + } + resolve() + }) + server.once('error', reject) + }) + + state.server = server + state.close = () => new Promise(resolve => server.close(() => resolve())) + + return state +} diff --git a/tests/sync-e2e.test.ts b/tests/sync-e2e.test.ts new file mode 100644 index 00000000..682d706f --- /dev/null +++ b/tests/sync-e2e.test.ts @@ -0,0 +1,244 @@ +/** + * End-to-end test for codeburn sync setup flow. + * + * Spins up a mock IdP, runs the auth flow programmatically + * (simulating the browser callback), and verifies tokens are + * stored and retrievable. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { startMockIdp, type MockIdp } from './fixtures/mock-idp.js' +import { fetchDiscoveryDoc } from '../src/sync/discovery.js' +import { + fetchOidcConfig, + generatePkce, + buildAuthUrl, + resolveScopes, + startCallbackServer, + exchangeCode, + refreshToken, + revokeToken, +} from '../src/sync/auth.js' +import { createCredentialStore } from '../src/sync/credentials.js' +import { writeSyncConfig, readSyncConfig, deleteSyncConfig } from '../src/sync/config.js' + +let idp: MockIdp +let tmpHome: string +const originalHome = process.env.HOME + +beforeAll(async () => { + idp = await startMockIdp({ rotateTokens: false }) +}) + +afterAll(async () => { + await idp.close() +}) + +beforeEach(async () => { + tmpHome = await mkdtemp(join(tmpdir(), 'codeburn-sync-e2e-')) + process.env.HOME = tmpHome +}) + +afterEach(async () => { + process.env.HOME = originalHome + await rm(tmpHome, { recursive: true, force: true }) +}) + +describe('sync e2e (mock IdP)', () => { + it('full setup flow: discovery → OIDC → callback → token → store', async () => { + // 1. Fetch discovery doc + const discovery = await fetchDiscoveryDoc(idp.baseUrl) + expect(discovery.version).toBe(1) + expect(discovery.issuer).toBe(idp.baseUrl) + expect(discovery.client_id).toBe('mock-client-id') + expect(discovery.scopes).toContain('codeburn:write') + + // 2. Fetch OIDC config + const oidc = await fetchOidcConfig(discovery.issuer) + expect(oidc.authorization_endpoint).toContain('/oauth2/authorize') + expect(oidc.token_endpoint).toContain('/oauth2/token') + expect(oidc.revocation_endpoint).toContain('/oauth2/revoke') + expect(oidc.scopes_supported).toContain('offline_access') + + // 3. Resolve scopes (offline_access should be added since IdP supports it) + const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported) + expect(scopes).toContain('offline_access') + + // 4. Generate PKCE + state + const pkce = generatePkce() + const state = 'e2e-test-state' + + // 5. Start callback server + const { promise: callbackPromise, server } = startCallbackServer(state, 5000) + await new Promise(resolve => setTimeout(resolve, 100)) + const addr = server.address() + const port = typeof addr === 'object' && addr ? addr.port : 19876 + const redirectUri = `http://127.0.0.1:${port}/callback` + + // 6. Build auth URL (verify it's well-formed) + const authUrl = buildAuthUrl({ + authorization_endpoint: oidc.authorization_endpoint, + client_id: discovery.client_id, + redirect_uri: redirectUri, + scopes, + state, + pkce, + }) + const parsedUrl = new URL(authUrl) + expect(parsedUrl.searchParams.get('code_challenge_method')).toBe('S256') + expect(parsedUrl.searchParams.get('client_id')).toBe('mock-client-id') + + // 7. Simulate browser callback (as if user logged in and IdP redirected) + const authCode = 'e2e-auth-code-12345' + await fetch(`http://127.0.0.1:${port}/callback?code=${authCode}&state=${state}`) + + const callbackResult = await callbackPromise + expect(callbackResult.code).toBe(authCode) + + // 8. Exchange code for tokens + const tokens = await exchangeCode( + oidc.token_endpoint, + callbackResult.code, + pkce.code_verifier, + redirectUri, + discovery.client_id, + ) + expect(tokens.access_token).toBe('mock-access-token-xyz') + expect(tokens.refresh_token).toBe('mock-refresh-token-v1') + expect(tokens.expires_in).toBe(3600) + + // Verify the mock IdP received the code + expect(idp.exchangedCodes).toContain(authCode) + + // 9. Store refresh token + const store = createCredentialStore() + store.store(tokens.refresh_token!) + const retrieved = store.retrieve() + expect(retrieved).toBe('mock-refresh-token-v1') + + // 10. Write config + writeSyncConfig({ + baseUrl: idp.baseUrl, + clientId: discovery.client_id, + tracesPath: discovery.traces_path, + issuer: discovery.issuer, + }) + const config = readSyncConfig() + expect(config).not.toBeNull() + expect(config!.baseUrl).toBe(idp.baseUrl) + expect(config!.clientId).toBe('mock-client-id') + }, 10000) + + it('token refresh flow', async () => { + // Store a refresh token + const store = createCredentialStore() + store.store('mock-refresh-token-v1') + + // Refresh it + const oidc = await fetchOidcConfig(idp.baseUrl) + const tokens = await refreshToken(oidc.token_endpoint, 'mock-refresh-token-v1', 'mock-client-id') + + expect(tokens.access_token).toContain('mock-access-token-xyz-refreshed') + expect(tokens.refresh_token).toBe('mock-refresh-token-v1') // no rotation + expect(tokens.token_type).toBe('Bearer') + }) + + it('refresh with invalid token returns auth error', async () => { + const oidc = await fetchOidcConfig(idp.baseUrl) + + await expect( + refreshToken(oidc.token_endpoint, 'wrong-token', 'mock-client-id') + ).rejects.toThrow('Sync auth expired') + }) + + it('logout revokes token at IdP', async () => { + const store = createCredentialStore() + store.store('token-to-revoke') + + writeSyncConfig({ + baseUrl: idp.baseUrl, + clientId: 'mock-client-id', + tracesPath: '/v1/traces', + issuer: idp.baseUrl, + }) + + // Revoke + const oidc = await fetchOidcConfig(idp.baseUrl) + await revokeToken(oidc.revocation_endpoint!, 'token-to-revoke', 'mock-client-id') + + expect(idp.revokedTokens).toContain('token-to-revoke') + + // Clean up + store.delete() + deleteSyncConfig() + + expect(store.retrieve()).toBeNull() + expect(readSyncConfig()).toBeNull() + }) + + it('status shows correct info after setup', async () => { + const store = createCredentialStore() + store.store('status-test-token') + + writeSyncConfig({ + baseUrl: idp.baseUrl, + clientId: 'mock-client-id', + tracesPath: '/v1/traces', + issuer: idp.baseUrl, + lastSync: '2026-07-07T20:00:00Z', + }) + + const config = readSyncConfig() + const token = store.retrieve() + + expect(config!.baseUrl).toBe(idp.baseUrl) + expect(config!.lastSync).toBe('2026-07-07T20:00:00Z') + expect(token).toBe('status-test-token') + expect(store.method()).toMatch(/keychain|secret-tool|dpapi|file/) + + // Clean up + store.delete() + deleteSyncConfig() + }) +}) + +describe('sync e2e — token rotation', () => { + let rotatingIdp: MockIdp + + beforeAll(async () => { + rotatingIdp = await startMockIdp({ rotateTokens: true, refreshToken: 'rt-rotation-v1' }) + }) + + afterAll(async () => { + await rotatingIdp.close() + }) + + it('stores rotated refresh token after refresh', async () => { + const store = createCredentialStore() + store.store('rt-rotation-v1') + + const oidc = await fetchOidcConfig(rotatingIdp.baseUrl) + + // First refresh — should get rt-rotation-v2 + const tokens1 = await refreshToken(oidc.token_endpoint, 'rt-rotation-v1', 'mock-client-id') + expect(tokens1.refresh_token).toBe('mock-refresh-token-v2') + + // Store the new one (as the client would) + store.store(tokens1.refresh_token!) + + // Second refresh with the new token + const tokens2 = await refreshToken(oidc.token_endpoint, tokens1.refresh_token!, 'mock-client-id') + expect(tokens2.refresh_token).toBe('mock-refresh-token-v3') + + // Old token should fail + await expect( + refreshToken(oidc.token_endpoint, 'rt-rotation-v1', 'mock-client-id') + ).rejects.toThrow('Sync auth expired') + + store.delete() + }) +}) diff --git a/tests/sync-headless-e2e.test.ts b/tests/sync-headless-e2e.test.ts new file mode 100644 index 00000000..f6e6d96a --- /dev/null +++ b/tests/sync-headless-e2e.test.ts @@ -0,0 +1,145 @@ +/** + * E2E test: codeburn sync setup with headless browser against real Cognito. + * + * This test exercises the FULL PKCE flow: + * 1. Starts the callback server (simulating `codeburn sync setup`) + * 2. Builds the auth URL with PKCE challenge + * 3. Opens a headless Chromium to the Cognito Hosted UI + * 4. Fills the login form with test credentials + * 5. Submits → Cognito redirects to localhost callback + * 6. Callback server receives code + state + * 7. Exchanges code for tokens + * + * Requirements: + * - Playwright + Chromium installed (`npx playwright install chromium`) + * - Real Cognito endpoint deployed (CodeburnSyncBackend stack) + * - Test user created in the pool + * + * Run: npx vitest run tests/sync-headless-e2e.test.ts + * Skip in CI without infra: set SKIP_HEADLESS_E2E=1 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { chromium, type Browser, type Page } from 'playwright' + +import { fetchDiscoveryDoc } from '../src/sync/discovery.js' +import { + fetchOidcConfig, + generatePkce, + buildAuthUrl, + resolveScopes, + startCallbackServer, + exchangeCode, +} from '../src/sync/auth.js' +import { randomBytes } from 'crypto' + +// --- Configuration (from deployed stack outputs) --- +const BASE_URL = process.env.CODEBURN_SYNC_URL +const TEST_EMAIL = process.env.CODEBURN_SYNC_EMAIL +const TEST_PASSWORD = process.env.CODEBURN_SYNC_PASSWORD + +// Only runs when ALL three env vars are set. Developer-only test. +const SKIP = !BASE_URL || !TEST_EMAIL || !TEST_PASSWORD + +describe.skipIf(SKIP)('sync setup — headless browser PKCE flow', () => { + let browser: Browser + + beforeAll(async () => { + browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }) + }) + + afterAll(async () => { + await browser?.close() + }) + + it('full PKCE flow: callback server → Cognito login → token exchange', async () => { + // 1. Fetch discovery + OIDC config + const discovery = await fetchDiscoveryDoc(BASE_URL) + const oidc = await fetchOidcConfig(discovery.issuer) + const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported) + + // 2. PKCE + state + const pkce = generatePkce() + const state = randomBytes(16).toString('hex') + + // 3. Start callback server + const { promise: callbackPromise, server } = startCallbackServer(state, 30000) + await new Promise(r => setTimeout(r, 200)) + const addr = server.address() + const port = typeof addr === 'object' && addr ? addr.port : 19876 + const redirectUri = `http://127.0.0.1:${port}/callback` + + // 4. Build auth URL + const authUrl = buildAuthUrl({ + authorization_endpoint: oidc.authorization_endpoint, + client_id: discovery.client_id, + redirect_uri: redirectUri, + scopes, + state, + pkce, + }) + + // 5. Open headless browser to Cognito Hosted UI + const page: Page = await browser.newPage() + + try { + await page.goto(authUrl, { waitUntil: 'networkidle' }) + + // 6. Fill login form + // Cognito Hosted UI renders two tabbed forms (Sign In + Sign Up). + // The inputs exist but may not be CSS-visible. Use JS to fill the + // sign-in form directly, targeting inputs within the form that has + // the signInSubmitButton. + await page.evaluate((creds) => { + const forms = document.querySelectorAll('form') + for (const form of forms) { + if (!form.querySelector('input[name="signInSubmitButton"]')) continue + const username = form.querySelector('input[name="username"]') as HTMLInputElement + const password = form.querySelector('input[name="password"]') as HTMLInputElement + if (username) { username.value = creds.email; username.dispatchEvent(new Event('input', { bubbles: true })) } + if (password) { password.value = creds.password; password.dispatchEvent(new Event('input', { bubbles: true })) } + } + }, { email: TEST_EMAIL, password: TEST_PASSWORD }) + + // 7. Submit the form via JS + await page.evaluate(() => { + const btn = document.querySelector('input[name="signInSubmitButton"]') as HTMLInputElement + if (btn) btn.click() + }) + + // 8. Wait for redirect to our callback server + // Cognito will redirect to http://127.0.0.1:{port}/callback?code=...&state=... + // The page will load our callback server's "Login successful" response + await page.waitForURL(`http://127.0.0.1:${port}/callback*`, { timeout: 15000 }) + + // 9. Callback server should have received the code + const result = await callbackPromise + expect(result.code).toBeTruthy() + expect(result.code.length).toBeGreaterThan(10) + + // 10. Exchange code for tokens + const tokens = await exchangeCode( + oidc.token_endpoint, + result.code, + pkce.code_verifier, + redirectUri, + discovery.client_id, + ) + + expect(tokens.access_token).toBeTruthy() + expect(tokens.refresh_token).toBeTruthy() + expect(tokens.token_type).toBe('Bearer') + expect(tokens.expires_in).toBeGreaterThan(0) + + console.log('✅ Full PKCE flow completed successfully!') + console.log(` Access token: ${tokens.access_token.slice(0, 30)}...`) + console.log(` Refresh token: ${tokens.refresh_token!.slice(0, 30)}...`) + console.log(` Expires in: ${tokens.expires_in}s`) + } finally { + await page.close() + } + }, 60000) // 60s timeout for the full browser flow +}) diff --git a/tests/sync-infra-e2e.test.ts b/tests/sync-infra-e2e.test.ts new file mode 100644 index 00000000..71265e7c --- /dev/null +++ b/tests/sync-infra-e2e.test.ts @@ -0,0 +1,232 @@ +/** + * Integration test: codeburn sync push against real AWS infrastructure. + * + * Requires: + * - Deployed CodeburnSyncBackend stack + * - Environment variables: CODEBURN_SYNC_URL, CODEBURN_SYNC_EMAIL, CODEBURN_SYNC_PASSWORD + * - AWS credentials (for verifying CloudWatch logs) + * + * Run: + * CODEBURN_SYNC_URL=https://xxx.execute-api.us-west-2.amazonaws.com \ + * CODEBURN_SYNC_EMAIL=andklee@amazon.com \ + * CODEBURN_SYNC_PASSWORD='password' \ + * AWS_PROFILE=andklee-dev \ + * npx vitest run tests/sync-infra-e2e.test.ts + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { randomBytes, createHash } from 'crypto' + +import { fetchDiscoveryDoc } from '../src/sync/discovery.js' +import { fetchOidcConfig, refreshToken } from '../src/sync/auth.js' +import { buildOtlpPayload, deriveSpanId, type CallWithSession } from '../src/sync/otlp.js' +import type { ParsedApiCall, TokenUsage } from '../src/types.js' + +const BASE_URL = process.env.CODEBURN_SYNC_URL +const TEST_EMAIL = process.env.CODEBURN_SYNC_EMAIL +const TEST_PASSWORD = process.env.CODEBURN_SYNC_PASSWORD +const AWS_REGION = process.env.AWS_REGION || 'us-west-2' + +const SKIP = !BASE_URL || !TEST_EMAIL || !TEST_PASSWORD + +describe.skipIf(SKIP)('sync infra e2e — push + verify', () => { + let accessToken: string + let clientId: string + let tracesEndpoint: string + const testRunId = randomBytes(4).toString('hex') + + beforeAll(async () => { + // Get tokens via admin API (Cognito USER_PASSWORD_AUTH) + const discovery = await fetchDiscoveryDoc(BASE_URL!) + clientId = discovery.client_id + tracesEndpoint = `${BASE_URL}${discovery.traces_path}` + + // Direct auth (USER_PASSWORD_AUTH) for testing + const tokenEndpoint = `https://codeburn-sync.auth.${AWS_REGION}.amazoncognito.com/oauth2/token` + + // Use Cognito initiateAuth via fetch to the Cognito service + const cognitoUrl = `https://cognito-idp.${AWS_REGION}.amazonaws.com/` + const userPoolId = discovery.issuer.split('/').pop()! + + const authResp = await fetch(cognitoUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-amz-json-1.1', + 'X-Amz-Target': 'AWSCognitoIdentityProviderService.InitiateAuth', + }, + body: JSON.stringify({ + AuthFlow: 'USER_PASSWORD_AUTH', + ClientId: clientId, + AuthParameters: { + USERNAME: TEST_EMAIL, + PASSWORD: TEST_PASSWORD, + }, + }), + }) + + const authResult = await authResp.json() as { AuthenticationResult?: { AccessToken?: string } } + accessToken = authResult.AuthenticationResult?.AccessToken ?? '' + expect(accessToken).toBeTruthy() + }) + + it('pushes a test span and receives 200', async () => { + const testDedup = `test:infra-e2e:${testRunId}:span1` + const usage: TokenUsage = { + inputTokens: 999, + outputTokens: 111, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + } + + const call: ParsedApiCall = { + provider: 'test', + model: 'test-model', + usage, + costUSD: 0.001, + tools: ['TestTool'], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: new Date().toISOString(), + bashCommands: [], + deduplicationKey: testDedup, + } + + const callWithSession: CallWithSession = { + call, + sessionId: `test-session-${testRunId}`, + project: 'infra-e2e-test', + } + + const payload = buildOtlpPayload([callWithSession]) + + const response = await fetch(tracesEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify(payload), + }) + + expect(response.status).toBe(200) + const body = await response.json() as Record + // No partial_success means full success + expect(body.partialSuccess?.rejectedSpans).toBeUndefined() + }) + + it('rejects unauthenticated requests', async () => { + const payload = buildOtlpPayload([]) + + const response = await fetch(tracesEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer invalid-token', + }, + body: JSON.stringify(payload), + }) + + expect(response.status).toBe(401) + }) + + it('pushes multiple spans in one batch', async () => { + const calls: CallWithSession[] = Array.from({ length: 5 }, (_, i) => { + const usage: TokenUsage = { + inputTokens: 100 * (i + 1), + outputTokens: 50 * (i + 1), + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + } + return { + call: { + provider: 'test', + model: 'test-batch-model', + usage, + costUSD: 0.01 * (i + 1), + tools: [], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard' as const, + timestamp: new Date().toISOString(), + bashCommands: [], + deduplicationKey: `test:infra-e2e:${testRunId}:batch:${i}`, + }, + sessionId: `test-batch-session-${testRunId}`, + project: 'infra-e2e-batch', + } + }) + + const payload = buildOtlpPayload(calls) + + const response = await fetch(tracesEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify(payload), + }) + + expect(response.status).toBe(200) + }) + + it('deterministic span IDs allow safe re-push', async () => { + const dedup = `test:infra-e2e:${testRunId}:idempotent` + const spanId = deriveSpanId(dedup) + + // Push same data twice + const call: CallWithSession = { + call: { + provider: 'test', + model: 'test-idempotent', + usage: { inputTokens: 1, outputTokens: 1, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }, + costUSD: 0, + tools: [], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: new Date().toISOString(), + bashCommands: [], + deduplicationKey: dedup, + }, + sessionId: 'idem-session', + project: 'idem-project', + } + + const payload = buildOtlpPayload([call]) + + // First push + const r1 = await fetch(tracesEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` }, + body: JSON.stringify(payload), + }) + expect(r1.status).toBe(200) + + // Second push (identical payload) + const r2 = await fetch(tracesEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` }, + body: JSON.stringify(payload), + }) + expect(r2.status).toBe(200) + + // Both succeed — server tolerates duplicates per OTLP spec + }) +}) diff --git a/tests/sync-ledger-otlp.test.ts b/tests/sync-ledger-otlp.test.ts new file mode 100644 index 00000000..13e7c7a3 --- /dev/null +++ b/tests/sync-ledger-otlp.test.ts @@ -0,0 +1,286 @@ +/** + * Unit tests for sync ledger and OTLP payload builder. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { + deriveSpanId, + deriveTraceId, + buildOtlpPayload, + batchCalls, + getDeviceId, + type CallWithSession, +} from '../src/sync/otlp.js' + +import type { ParsedApiCall, TokenUsage } from '../src/types.js' + +// ── Helpers ─────────────────────────────────────────────────────────── + +function makeCall(overrides: Partial & { deduplicationKey: string }): ParsedApiCall { + const usage: TokenUsage = { + inputTokens: 1000, + outputTokens: 500, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + } + return { + provider: 'kiro', + model: 'claude-sonnet-4-6', + usage, + costUSD: 0.05, + tools: ['Edit', 'Bash'], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-07-10T10:00:00.000Z', + bashCommands: [], + deduplicationKey: 'test:key:1', + ...overrides, + } +} + +function makeCallWithSession(overrides?: Partial & { deduplicationKey?: string }): CallWithSession { + return { + call: makeCall({ deduplicationKey: overrides?.deduplicationKey ?? 'test:key:1', ...overrides }), + sessionId: 'session-abc', + project: 'my-project', + } +} + +// ── OTLP Span/Trace ID Derivation ──────────────────────────────────── + +describe('deriveSpanId', () => { + it('returns 16 hex chars', () => { + const id = deriveSpanId('cursor:bubble:abc123') + expect(id).toMatch(/^[0-9a-f]{16}$/) + }) + + it('is deterministic (same input = same output)', () => { + const a = deriveSpanId('my:dedup:key') + const b = deriveSpanId('my:dedup:key') + expect(a).toBe(b) + }) + + it('different inputs produce different IDs', () => { + const a = deriveSpanId('key-1') + const b = deriveSpanId('key-2') + expect(a).not.toBe(b) + }) +}) + +describe('deriveTraceId', () => { + it('returns 32 hex chars', () => { + const id = deriveTraceId('session-xyz') + expect(id).toMatch(/^[0-9a-f]{32}$/) + }) + + it('is deterministic', () => { + const a = deriveTraceId('session-1') + const b = deriveTraceId('session-1') + expect(a).toBe(b) + }) +}) + +describe('getDeviceId', () => { + it('returns 16 hex chars', () => { + const id = getDeviceId() + expect(id).toMatch(/^[0-9a-f]{16}$/) + }) + + it('is stable across calls', () => { + expect(getDeviceId()).toBe(getDeviceId()) + }) +}) + +// ── OTLP Payload Builder ────────────────────────────────────────────── + +describe('buildOtlpPayload', () => { + it('builds valid OTLP structure with one span', () => { + const payload = buildOtlpPayload([makeCallWithSession()]) + + expect(payload.resourceSpans).toHaveLength(1) + expect(payload.resourceSpans[0]!.resource.attributes).toEqual([ + { key: 'codeburn.device_id', value: { stringValue: expect.stringMatching(/^[0-9a-f]{16}$/) } }, + ]) + + const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans + expect(spans).toHaveLength(1) + + const span = spans[0]! + expect(span.traceId).toMatch(/^[0-9a-f]{32}$/) + expect(span.spanId).toMatch(/^[0-9a-f]{16}$/) + expect(span.name).toBe('kiro/claude-sonnet-4-6') + expect(span.startTimeUnixNano).toBe('1783677600000000000') + }) + + it('includes correct span attributes', () => { + const payload = buildOtlpPayload([makeCallWithSession()]) + const attrs = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes + const attrMap = Object.fromEntries(attrs.map(a => [a.key, a.value])) + + expect(attrMap['ai.provider']).toEqual({ stringValue: 'kiro' }) + expect(attrMap['ai.model']).toEqual({ stringValue: 'claude-sonnet-4-6' }) + expect(attrMap['ai.input_tokens']).toEqual({ intValue: '1000' }) + expect(attrMap['ai.output_tokens']).toEqual({ intValue: '500' }) + expect(attrMap['ai.cost_usd']).toEqual({ doubleValue: 0.05 }) + expect(attrMap['ai.project']).toEqual({ stringValue: 'my-project' }) + expect(attrMap['ai.speed']).toEqual({ stringValue: 'standard' }) + }) + + it('includes tools as array attribute', () => { + const payload = buildOtlpPayload([makeCallWithSession()]) + const attrs = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes + const toolsAttr = attrs.find(a => a.key === 'ai.tools') + + expect(toolsAttr).toBeDefined() + expect(toolsAttr!.value).toEqual({ + arrayValue: { values: [{ stringValue: 'Edit' }, { stringValue: 'Bash' }] }, + }) + }) + + it('omits tools attribute when empty', () => { + const call = makeCallWithSession({ tools: [] as string[] } as any) + const payload = buildOtlpPayload([call]) + const attrs = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.attributes + const toolsAttr = attrs.find(a => a.key === 'ai.tools') + expect(toolsAttr).toBeUndefined() + }) + + it('multiple calls produce multiple spans', () => { + const calls = [ + makeCallWithSession({ deduplicationKey: 'k1' }), + makeCallWithSession({ deduplicationKey: 'k2' }), + makeCallWithSession({ deduplicationKey: 'k3' }), + ] + const payload = buildOtlpPayload(calls) + const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans + expect(spans).toHaveLength(3) + // Each span has a unique spanId + const ids = new Set(spans.map(s => s.spanId)) + expect(ids.size).toBe(3) + }) + + it('same deduplicationKey produces same spanId (idempotent re-send)', () => { + const call = makeCallWithSession({ deduplicationKey: 'stable-key' }) + const p1 = buildOtlpPayload([call]) + const p2 = buildOtlpPayload([call]) + expect(p1.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.spanId) + .toBe(p2.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.spanId) + }) +}) + +// ── Batching ────────────────────────────────────────────────────────── + +describe('batchCalls', () => { + it('returns single batch when under limit', () => { + const calls = Array.from({ length: 5 }, (_, i) => + makeCallWithSession({ deduplicationKey: `k${i}` }) + ) + const batches = batchCalls(calls, 1000) + expect(batches).toHaveLength(1) + expect(batches[0]).toHaveLength(5) + }) + + it('splits into multiple batches at the limit', () => { + const calls = Array.from({ length: 2500 }, (_, i) => + makeCallWithSession({ deduplicationKey: `k${i}` }) + ) + const batches = batchCalls(calls, 1000) + expect(batches).toHaveLength(3) + expect(batches[0]).toHaveLength(1000) + expect(batches[1]).toHaveLength(1000) + expect(batches[2]).toHaveLength(500) + }) + + it('empty input returns empty array', () => { + expect(batchCalls([], 1000)).toEqual([]) + }) +}) + +// ── Ledger ──────────────────────────────────────────────────────────── + +describe('ledger', () => { + let tmpDir: string + const originalHome = process.env.HOME + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-ledger-')) + process.env.HOME = tmpDir + }) + + afterEach(async () => { + process.env.HOME = originalHome + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('readLedger returns empty array when no file', async () => { + const { readLedger } = await import('../src/sync/ledger.js') + expect(readLedger()).toEqual([]) + }) + + it('writeLedger + readLedger round-trips', async () => { + const { writeLedger, readLedger } = await import('../src/sync/ledger.js') + const entries = [ + { key: 'k1', ts: '2026-07-10T00:00:00Z' }, + { key: 'k2', ts: '2026-07-11T00:00:00Z' }, + ] + writeLedger(entries) + expect(readLedger()).toEqual(entries) + }) + + it('appendToLedger adds new entries and deduplicates', async () => { + const { writeLedger, appendToLedger, readLedger } = await import('../src/sync/ledger.js') + writeLedger([{ key: 'existing', ts: '2026-07-01T00:00:00Z' }]) + appendToLedger([ + { key: 'existing', ts: '2026-07-01T00:00:00Z' }, // duplicate + { key: 'new-one', ts: '2026-07-10T00:00:00Z' }, + ]) + const result = readLedger() + expect(result).toHaveLength(2) + expect(result.map(e => e.key).sort()).toEqual(['existing', 'new-one']) + }) + + it('appendToLedger prunes entries older than 6 months', async () => { + const { writeLedger, appendToLedger, readLedger } = await import('../src/sync/ledger.js') + const old = new Date(Date.now() - 200 * 24 * 60 * 60 * 1000).toISOString() // 200 days ago + writeLedger([{ key: 'old-entry', ts: old }]) + appendToLedger([{ key: 'fresh', ts: new Date().toISOString() }]) + const result = readLedger() + expect(result.map(e => e.key)).toEqual(['fresh']) + }) + + it('ledgerKeySet returns set of keys', async () => { + const { writeLedger, ledgerKeySet } = await import('../src/sync/ledger.js') + writeLedger([ + { key: 'a', ts: '2026-07-01T00:00:00Z' }, + { key: 'b', ts: '2026-07-02T00:00:00Z' }, + ]) + const keys = ledgerKeySet() + expect(keys.has('a')).toBe(true) + expect(keys.has('b')).toBe(true) + expect(keys.has('c')).toBe(false) + }) + + it('clearLedger removes the file and returns count', async () => { + const { writeLedger, clearLedger, readLedger } = await import('../src/sync/ledger.js') + writeLedger([{ key: 'x', ts: '2026-07-01T00:00:00Z' }]) + const count = clearLedger() + expect(count).toBe(1) + expect(readLedger()).toEqual([]) + }) + + it('clearLedger returns 0 when no file', async () => { + const { clearLedger } = await import('../src/sync/ledger.js') + expect(clearLedger()).toBe(0) + }) +}) diff --git a/tests/sync-push.test.ts b/tests/sync-push.test.ts new file mode 100644 index 00000000..9d9e804c --- /dev/null +++ b/tests/sync-push.test.ts @@ -0,0 +1,399 @@ +/** + * Unit tests for sync push orchestration (src/sync/push.ts). + * + * Covers the review gaps: partial-success handling, 429 rate limiting, + * 401 auth rejection, 5xx server errors, and the flatten→filter pipeline. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { createServer, type Server } from 'http' +import { mkdtemp, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import type { ParsedApiCall, TokenUsage, ProjectSummary } from '../src/types.js' +import type { CallWithSession } from '../src/sync/otlp.js' + +// ── Helpers ─────────────────────────────────────────────────────────── + +function makeUsage(): TokenUsage { + return { + inputTokens: 100, + outputTokens: 50, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + } +} + +function makeCall(key: string, costUSD = 0.01): ParsedApiCall { + return { + provider: 'test', + model: 'test-model', + usage: makeUsage(), + costUSD, + tools: [], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-07-10T10:00:00.000Z', + bashCommands: [], + deduplicationKey: key, + } +} + +function makeCws(key: string, costUSD = 0.01): CallWithSession { + return { call: makeCall(key, costUSD), sessionId: 'sess-1', project: 'proj-1' } +} + +/** Minimal mock OTLP server with scriptable responses per request. */ +type MockResponse = { status: number; body?: unknown; headers?: Record } + +function startMockOtlp(responses: MockResponse[]): Promise<{ + url: string + server: Server + requests: Array<{ auth: string | undefined; body: unknown }> +}> { + const requests: Array<{ auth: string | undefined; body: unknown }> = [] + let idx = 0 + + return new Promise(resolve => { + const server = createServer((req, res) => { + let raw = '' + req.on('data', c => { raw += c }) + req.on('end', () => { + requests.push({ auth: req.headers.authorization, body: JSON.parse(raw || '{}') }) + const r = responses[Math.min(idx, responses.length - 1)]! + idx++ + res.writeHead(r.status, { 'Content-Type': 'application/json', ...r.headers }) + res.end(r.body !== undefined ? JSON.stringify(r.body) : '{}') + }) + }) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as { port: number } + resolve({ url: `http://127.0.0.1:${addr.port}/v1/traces`, server, requests }) + }) + }) +} + +// ── Test env: isolated HOME so ledger writes go to a temp dir ───────── + +let tmpDir: string +const originalHome = process.env.HOME + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-push-')) + process.env.HOME = tmpDir +}) + +afterEach(async () => { + process.env.HOME = originalHome + await rm(tmpDir, { recursive: true, force: true }) +}) + +// ── collectUnsentCalls ──────────────────────────────────────────────── + +describe('collectUnsentCalls', () => { + it('flattens projects → sessions → turns → calls', async () => { + const { collectUnsentCalls } = await import('../src/sync/push.js') + + const projects = [{ + project: 'proj-a', + sessions: [{ + sessionId: 's1', + turns: [ + { assistantCalls: [makeCall('k1'), makeCall('k2')] }, + { assistantCalls: [makeCall('k3')] }, + ], + }], + }] as unknown as ProjectSummary[] + + const { allCalls, unsent } = collectUnsentCalls(projects) + expect(allCalls).toHaveLength(3) + expect(unsent).toHaveLength(3) + expect(allCalls[0]!.project).toBe('proj-a') + expect(allCalls[0]!.sessionId).toBe('s1') + }) + + it('filters out calls already in the ledger', async () => { + const { collectUnsentCalls } = await import('../src/sync/push.js') + const { writeLedger } = await import('../src/sync/ledger.js') + + writeLedger([{ key: 'k1', ts: '2026-07-10T00:00:00Z' }]) + + const projects = [{ + project: 'p', + sessions: [{ + sessionId: 's1', + turns: [{ assistantCalls: [makeCall('k1'), makeCall('k2')] }], + }], + }] as unknown as ProjectSummary[] + + const { allCalls, unsent } = collectUnsentCalls(projects) + expect(allCalls).toHaveLength(2) + expect(unsent).toHaveLength(1) + expect(unsent[0]!.call.deduplicationKey).toBe('k2') + }) +}) + +// ── sendBatches: success path ───────────────────────────────────────── + +describe('sendBatches — success', () => { + it('sends all batches, ledgers all calls, accumulates cost', async () => { + const { sendBatches } = await import('../src/sync/push.js') + const { readLedger } = await import('../src/sync/ledger.js') + + const { url, server, requests } = await startMockOtlp([{ status: 200, body: {} }]) + try { + const batches = [ + [makeCws('a', 0.10), makeCws('b', 0.20)], + [makeCws('c', 0.30)], + ] + const result = await sendBatches({ endpoint: url, accessToken: 'tok-123', batches }) + + expect(result.outcome).toBe('complete') + expect(result.totalSent).toBe(3) + expect(result.totalRejected).toBe(0) + expect(result.totalCostSent).toBeCloseTo(0.60) + + // Two HTTP requests with Bearer auth + expect(requests).toHaveLength(2) + expect(requests[0]!.auth).toBe('Bearer tok-123') + + // All three keys ledgered + const keys = readLedger().map(e => e.key).sort() + expect(keys).toEqual(['a', 'b', 'c']) + } finally { + server.close() + } + }) +}) + +// ── sendBatches: partial success ────────────────────────────────────── + +describe('sendBatches — partial success', () => { + it('does NOT ledger a partially-rejected batch (whole batch retries)', async () => { + const { sendBatches } = await import('../src/sync/push.js') + const { readLedger } = await import('../src/sync/ledger.js') + + const { url, server } = await startMockOtlp([ + { status: 200, body: { partialSuccess: { rejectedSpans: 1 } } }, // batch 1: partial + { status: 200, body: {} }, // batch 2: full success + ]) + try { + const batches = [ + [makeCws('p1'), makeCws('p2')], // partially rejected — must NOT ledger + [makeCws('ok1')], // fully accepted — must ledger + ] + const result = await sendBatches({ endpoint: url, accessToken: 't', batches }) + + expect(result.outcome).toBe('complete') + expect(result.totalSent).toBe(1) + expect(result.totalRejected).toBe(1) + + const keys = readLedger().map(e => e.key) + expect(keys).toEqual(['ok1']) // p1/p2 absent → they retry next push + } finally { + server.close() + } + }) +}) + +// ── sendBatches: error paths ────────────────────────────────────────── + +describe('sendBatches — errors', () => { + it('401 → auth-rejected, stops immediately, ledgers nothing further', async () => { + const { sendBatches } = await import('../src/sync/push.js') + const { readLedger } = await import('../src/sync/ledger.js') + + const { url, server, requests } = await startMockOtlp([{ status: 401 }]) + try { + const result = await sendBatches({ + endpoint: url, accessToken: 'bad', + batches: [[makeCws('x')], [makeCws('y')]], + }) + expect(result.outcome).toBe('auth-rejected') + expect(result.httpStatus).toBe(401) + expect(result.totalSent).toBe(0) + expect(requests).toHaveLength(1) // second batch never sent + expect(readLedger()).toEqual([]) + } finally { + server.close() + } + }) + + it('429 → waits Retry-After and retries the same batch until it succeeds', async () => { + const { sendBatches } = await import('../src/sync/push.js') + const { readLedger } = await import('../src/sync/ledger.js') + + const sleeps: number[] = [] + const { url, server, requests } = await startMockOtlp([ + { status: 200, body: {} }, // batch 1: ok + { status: 429, headers: { 'Retry-After': '2' } }, // batch 2: limited + { status: 200, body: {} }, // batch 2 retry: ok + { status: 200, body: {} }, // batch 3: ok + ]) + try { + const result = await sendBatches({ + endpoint: url, accessToken: 't', + batches: [[makeCws('sent')], [makeCws('limited')], [makeCws('third')]], + sleep: async ms => { sleeps.push(ms) }, + }) + expect(result.outcome).toBe('complete') + expect(result.totalSent).toBe(3) // ALL batches sent + expect(result.totalWaitMs).toBe(2000) + expect(sleeps).toEqual([2000]) // honored Retry-After: 2 + expect(requests).toHaveLength(4) // 3 batches + 1 retry + expect(readLedger().map(e => e.key).sort()).toEqual(['limited', 'sent', 'third']) + } finally { + server.close() + } + }) + + it('persistent 429 → gives up after max retries, remaining deferred', async () => { + const { sendBatches } = await import('../src/sync/push.js') + const { readLedger } = await import('../src/sync/ledger.js') + + const sleeps: number[] = [] + const { url, server, requests } = await startMockOtlp([ + { status: 429, headers: { 'Retry-After': '1' } }, // every request limited + ]) + try { + const result = await sendBatches({ + endpoint: url, accessToken: 't', + batches: [[makeCws('stuck')], [makeCws('never')]], + sleep: async ms => { sleeps.push(ms) }, + max429Retries: 2, + }) + expect(result.outcome).toBe('rate-limited') + expect(result.totalSent).toBe(0) + expect(sleeps).toEqual([1000, 1000]) // 2 retries = 2 waits + expect(requests).toHaveLength(3) // initial + 2 retries; 2nd batch never sent + expect(readLedger()).toEqual([]) + } finally { + server.close() + } + }) + + it('429 without Retry-After uses 5s default; wait capped at maxWaitMs', async () => { + const { sendBatches } = await import('../src/sync/push.js') + + const sleeps: number[] = [] + const { url, server } = await startMockOtlp([ + { status: 429 }, // no Retry-After → 5s default + { status: 429, headers: { 'Retry-After': '999' } }, // 999s → capped + { status: 200, body: {} }, + ]) + try { + const result = await sendBatches({ + endpoint: url, accessToken: 't', + batches: [[makeCws('x')]], + sleep: async ms => { sleeps.push(ms) }, + maxWaitMs: 10_000, + }) + expect(result.outcome).toBe('complete') + expect(sleeps).toEqual([5000, 10_000]) // default, then capped + } finally { + server.close() + } + }) + + it('5xx → server-error, batch not ledgered, remaining deferred', async () => { + const { sendBatches } = await import('../src/sync/push.js') + const { readLedger } = await import('../src/sync/ledger.js') + + const { url, server, requests } = await startMockOtlp([{ status: 503 }]) + try { + const result = await sendBatches({ + endpoint: url, accessToken: 't', + batches: [[makeCws('a')], [makeCws('b')]], + }) + expect(result.outcome).toBe('server-error') + expect(result.httpStatus).toBe(503) + expect(result.totalSent).toBe(0) + expect(requests).toHaveLength(1) + expect(readLedger()).toEqual([]) + } finally { + server.close() + } + }) + + it('retry after failure re-sends the unledgered calls (idempotent recovery)', async () => { + const { sendBatches, collectUnsentCalls } = await import('../src/sync/push.js') + + // First attempt: server error → nothing ledgered + const first = await startMockOtlp([{ status: 500 }]) + try { + await sendBatches({ endpoint: first.url, accessToken: 't', batches: [[makeCws('r1')]] }) + } finally { + first.server.close() + } + + // Simulate the next push: the same call is still unsent + const projects = [{ + project: 'p', + sessions: [{ sessionId: 's1', turns: [{ assistantCalls: [makeCall('r1')] }] }], + }] as unknown as ProjectSummary[] + const { unsent } = collectUnsentCalls(projects) + expect(unsent).toHaveLength(1) + + // Second attempt succeeds and ledgers + const second = await startMockOtlp([{ status: 200, body: {} }]) + try { + const result = await sendBatches({ endpoint: second.url, accessToken: 't', batches: [unsent] }) + expect(result.outcome).toBe('complete') + expect(result.totalSent).toBe(1) + } finally { + second.server.close() + } + + // Now filtered out + const { unsent: after } = collectUnsentCalls(projects) + expect(after).toHaveLength(0) + }) +}) + +// ── MAX_PER_PUSH safety valve ───────────────────────────────────────── + +describe('MAX_PER_PUSH', () => { + it('is a 50K safety valve (pushes run to completion, not capped at 5K)', async () => { + const { MAX_PER_PUSH } = await import('../src/sync/push.js') + expect(MAX_PER_PUSH).toBe(50_000) + }) +}) + +// ── parseRetryAfterMs ───────────────────────────────────────────────── + +describe('parseRetryAfterMs', () => { + it('parses delta-seconds', async () => { + const { parseRetryAfterMs } = await import('../src/sync/push.js') + expect(parseRetryAfterMs('30')).toBe(30_000) + expect(parseRetryAfterMs('0')).toBe(0) + }) + + it('parses HTTP-date', async () => { + const { parseRetryAfterMs } = await import('../src/sync/push.js') + const future = new Date(Date.now() + 10_000).toUTCString() + const ms = parseRetryAfterMs(future) + expect(ms).toBeGreaterThan(8_000) + expect(ms).toBeLessThanOrEqual(10_500) + }) + + it('past HTTP-date clamps to 0', async () => { + const { parseRetryAfterMs } = await import('../src/sync/push.js') + const past = new Date(Date.now() - 60_000).toUTCString() + expect(parseRetryAfterMs(past)).toBe(0) + }) + + it('returns null for missing or garbage values', async () => { + const { parseRetryAfterMs } = await import('../src/sync/push.js') + expect(parseRetryAfterMs(null)).toBeNull() + expect(parseRetryAfterMs('soon™')).toBeNull() + expect(parseRetryAfterMs('-5')).toBeNull() + }) +}) diff --git a/tests/sync.test.ts b/tests/sync.test.ts new file mode 100644 index 00000000..41e7a076 --- /dev/null +++ b/tests/sync.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm, writeFile, readFile, mkdir } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { createServer, type Server } from 'http' + +import { parseDiscoveryDoc, DiscoveryError } from '../src/sync/discovery.js' +import { + generatePkce, + buildAuthUrl, + resolveScopes, + startCallbackServer, + CALLBACK_PORTS, +} from '../src/sync/auth.js' + +// ── Discovery Doc Parser ────────────────────────────────────────────── + +describe('parseDiscoveryDoc', () => { + it('parses valid v1 doc', () => { + const doc = parseDiscoveryDoc({ + version: 1, + issuer: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXX', + client_id: 'abc123', + scopes: ['openid', 'codeburn:write'], + traces_path: '/v1/traces', + max_batch_size: 500, + }) + expect(doc.version).toBe(1) + expect(doc.issuer).toBe('https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXX') + expect(doc.client_id).toBe('abc123') + expect(doc.scopes).toEqual(['openid', 'codeburn:write']) + expect(doc.traces_path).toBe('/v1/traces') + expect(doc.max_batch_size).toBe(500) + }) + + it('rejects version > 1', () => { + expect(() => parseDiscoveryDoc({ version: 2, issuer: 'x', client_id: 'y' })) + .toThrow('Please update codeburn') + }) + + it('rejects missing issuer', () => { + expect(() => parseDiscoveryDoc({ version: 1, client_id: 'y' })) + .toThrow('missing required field: issuer') + }) + + it('rejects missing client_id', () => { + expect(() => parseDiscoveryDoc({ version: 1, issuer: 'x' })) + .toThrow('missing required field: client_id') + }) + + it('defaults traces_path to /v1/traces when absent', () => { + const doc = parseDiscoveryDoc({ issuer: 'x', client_id: 'y' }) + expect(doc.traces_path).toBe('/v1/traces') + }) + + it('defaults max_batch_size to 1000 when absent', () => { + const doc = parseDiscoveryDoc({ issuer: 'x', client_id: 'y' }) + expect(doc.max_batch_size).toBe(1000) + }) + + it('defaults scopes to ["openid"] when absent', () => { + const doc = parseDiscoveryDoc({ issuer: 'x', client_id: 'y' }) + expect(doc.scopes).toEqual(['openid']) + }) + + it('treats absent version as v1', () => { + const doc = parseDiscoveryDoc({ issuer: 'x', client_id: 'y' }) + expect(doc.version).toBe(1) + }) + + it('rejects non-object input', () => { + expect(() => parseDiscoveryDoc(null)).toThrow('must be a JSON object') + expect(() => parseDiscoveryDoc('string')).toThrow('must be a JSON object') + expect(() => parseDiscoveryDoc([1, 2])).toThrow('must be a JSON object') + }) +}) + +// ── PKCE ────────────────────────────────────────────────────────────── + +describe('generatePkce', () => { + it('generates code_verifier of valid length (43+ chars)', () => { + const { code_verifier } = generatePkce() + expect(code_verifier.length).toBeGreaterThanOrEqual(43) + }) + + it('generates different values each time', () => { + const a = generatePkce() + const b = generatePkce() + expect(a.code_verifier).not.toBe(b.code_verifier) + }) + + it('code_challenge is base64url of SHA-256(verifier)', () => { + const { code_verifier, code_challenge } = generatePkce() + const { createHash } = require('crypto') + const expected = createHash('sha256').update(code_verifier).digest('base64url') + expect(code_challenge).toBe(expected) + }) + + it('uses S256 method', () => { + const { code_challenge_method } = generatePkce() + expect(code_challenge_method).toBe('S256') + }) +}) + +// ── Auth URL ────────────────────────────────────────────────────────── + +describe('buildAuthUrl', () => { + const baseParams = { + authorization_endpoint: 'https://idp.example.com/oauth2/authorize', + client_id: 'test-client', + redirect_uri: 'http://127.0.0.1:19876/callback', + scopes: ['openid', 'codeburn:write'], + state: 'random-state-value', + pkce: generatePkce(), + } + + it('includes all required parameters', () => { + const url = new URL(buildAuthUrl(baseParams)) + expect(url.searchParams.get('response_type')).toBe('code') + expect(url.searchParams.get('client_id')).toBe('test-client') + expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:19876/callback') + expect(url.searchParams.get('state')).toBe('random-state-value') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + }) + + it('joins scopes with space', () => { + const url = new URL(buildAuthUrl(baseParams)) + expect(url.searchParams.get('scope')).toBe('openid codeburn:write') + }) + + it('uses 127.0.0.1 literal (not localhost)', () => { + const url = buildAuthUrl(baseParams) + expect(url).toContain('127.0.0.1') + expect(url).not.toContain('localhost') + }) +}) + +// ── Scope Resolution ────────────────────────────────────────────────── + +describe('resolveScopes', () => { + it('appends offline_access when IdP supports it', () => { + const result = resolveScopes(['openid'], ['openid', 'offline_access', 'profile']) + expect(result).toContain('offline_access') + }) + + it('does not append offline_access when IdP does not support it', () => { + const result = resolveScopes(['openid'], ['openid', 'profile']) + expect(result).not.toContain('offline_access') + }) + + it('does not duplicate offline_access if already requested', () => { + const result = resolveScopes(['openid', 'offline_access'], ['openid', 'offline_access']) + expect(result.filter(s => s === 'offline_access')).toHaveLength(1) + }) + + it('passes through scopes unchanged when IdP scopes unknown', () => { + const result = resolveScopes(['openid', 'codeburn:write'], undefined) + expect(result).toEqual(['openid', 'codeburn:write']) + }) +}) + +// ── Callback Server ────────────────────────────────────────────────── + +describe('startCallbackServer', () => { + it('accepts valid callback with matching state', async () => { + const state = 'test-state-123' + const { promise, server } = startCallbackServer(state, 5000) + + // Wait for server to bind + await new Promise(resolve => setTimeout(resolve, 200)) + const addr = server.address() + const port = typeof addr === 'object' && addr ? addr.port : CALLBACK_PORTS[0] + + // Simulate IdP callback + await fetch(`http://127.0.0.1:${port}/callback?code=auth-code-xyz&state=${state}`) + + const result = await promise + expect(result.code).toBe('auth-code-xyz') + }, 10000) + + it('rejects callback with wrong state', async () => { + const state = 'correct-state' + const { promise, server } = startCallbackServer(state, 5000) + + await new Promise(resolve => setTimeout(resolve, 200)) + const addr = server.address() + const port = typeof addr === 'object' && addr ? addr.port : CALLBACK_PORTS[0] + + // Send with wrong state — server stays running + const resp = await fetch(`http://127.0.0.1:${port}/callback?code=xxx&state=wrong-state`) + expect(resp.status).toBe(400) + + // Now send correct one + await fetch(`http://127.0.0.1:${port}/callback?code=real-code&state=${state}`) + const result = await promise + expect(result.code).toBe('real-code') + }, 10000) + + it('times out after configured duration', async () => { + const { promise } = startCallbackServer('state', 300) // 300ms timeout + + await expect(promise).rejects.toThrow('timed out') + }, 5000) +}) + +// ── Config ──────────────────────────────────────────────────────────── + +describe('syncConfig', () => { + let tmpDir: string + const originalHome = process.env.HOME + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-sync-config-')) + process.env.HOME = tmpDir + }) + + afterEach(async () => { + process.env.HOME = originalHome + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('writes and reads config', async () => { + // Dynamically import to pick up new HOME + const { writeSyncConfig, readSyncConfig } = await import('../src/sync/config.js') + + writeSyncConfig({ + baseUrl: 'https://metrics.test.com', + clientId: 'test-client', + tracesPath: '/v1/traces', + issuer: 'https://idp.test.com', + }) + + const config = readSyncConfig() + expect(config).not.toBeNull() + expect(config!.baseUrl).toBe('https://metrics.test.com') + expect(config!.clientId).toBe('test-client') + }) + + it('returns null when no config exists', async () => { + const { readSyncConfig } = await import('../src/sync/config.js') + const config = readSyncConfig() + expect(config).toBeNull() + }) + + it('config file does not contain tokens', async () => { + const { writeSyncConfig } = await import('../src/sync/config.js') + + writeSyncConfig({ + baseUrl: 'https://metrics.test.com', + clientId: 'client', + tracesPath: '/v1/traces', + issuer: 'https://idp.test.com', + }) + + const raw = await readFile(join(tmpDir, '.config', 'codeburn', 'sync.json'), 'utf-8') + expect(raw).not.toContain('token') + expect(raw).not.toContain('secret') + expect(raw).not.toContain('password') + }) +}) From cec61e3b3057e1ad35315b7b7c50f2bd3c0e169b Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Sun, 12 Jul 2026 16:05:34 +0000 Subject: [PATCH 3/4] docs(sync): user guide, developer/protocol reference, README section - docs/sync/README.md: setup, push, status, logout, reset; privacy guarantees; FAQ - docs/sync/DEVELOPER.md: architecture, discovery/OTLP protocol, sent-ledger design rationale, partial-success and rate-limiting semantics, server contract, testing guide - README.md: sync command group listed under Commands (preview label) AI-Origin: human --- README.md | 13 +++ docs/sync/DEVELOPER.md | 215 +++++++++++++++++++++++++++++++++++++++++ docs/sync/README.md | 130 +++++++++++++++++++++++++ 3 files changed, 358 insertions(+) create mode 100644 docs/sync/DEVELOPER.md create mode 100644 docs/sync/README.md diff --git a/README.md b/README.md index b16391f7..da863255 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,19 @@ Run `codeburn` for the dashboard, or use a subcommand below. Most commands also | `codeburn export` | CSV covering today, 7 days, and 30 days | | `codeburn export -f json` | Export as JSON instead of CSV | +**Sync (team telemetry)** _preview_ + +| Command | What it does | +|---------|--------------| +| `codeburn sync setup ` | One-time setup: OIDC login via browser, stores token securely | +| `codeburn sync push` | Push unsent usage to remote endpoint (default: last 7 days) | +| `codeburn sync push --since 30d` | Push a larger window | +| `codeburn sync status` | Show endpoint, auth state, last sync time | +| `codeburn sync logout` | Revoke token and remove credentials | +| `codeburn sync reset --confirm` | Clear sent-ledger (re-send all data on next push) | + +Sync sends token counts, costs, models, and projects — never prompts or code. This feature is in preview; the protocol may change between releases. See [docs/sync/](docs/sync/) for details. + **Web & devices** | Command | What it does | diff --git a/docs/sync/DEVELOPER.md b/docs/sync/DEVELOPER.md new file mode 100644 index 00000000..130edf11 --- /dev/null +++ b/docs/sync/DEVELOPER.md @@ -0,0 +1,215 @@ +# Sync — Developer Documentation + +Architecture, protocol, server contract, and testing for `codeburn sync`. + +## Architecture + +``` +Developer machine Remote backend +────────────────── ────────────── +~/.config/codeburn/sync.json (config) +~/.config/codeburn/.sync-token (credential) +~/.cache/codeburn/sync-ledger.json (sent-ledger) + +codeburn sync push + │ + ├─ Read config → baseUrl, clientId, issuer, tracesPath + ├─ Read refresh token from OS store + ├─ POST {issuer}/oauth2/token (refresh_token grant) → access_token + ├─ Collect ParsedProviderCall[] for window + ├─ Filter against sent-ledger (only unsent calls) + ├─ Build OTLP/HTTP JSON payload + ├─ POST {baseUrl}{tracesPath} with Bearer token + ├─ On success → append deduplicationKeys to ledger + └─ Update lastSync in config +``` + +## Discovery Protocol + +### Server discovery document + +``` +GET {baseUrl}/.well-known/codeburn-export.json +``` + +```json +{ + "version": 1, + "issuer": "https://cognito-idp.us-west-2.amazonaws.com/us-west-2_XXXX", + "client_id": "70e6sgst2ju6ff9dnrmv4l1tcb", + "scopes": ["openid", "email"], + "traces_path": "/v1/traces", + "max_batch_size": 1000 +} +``` + +| Field | Required | Default | Description | +|---|---|---|---| +| `version` | No | `1` | Client rejects `version > 1` | +| `issuer` | Yes | — | OIDC issuer URL. Client fetches `{issuer}/.well-known/openid-configuration` | +| `client_id` | Yes | — | OAuth client ID for this deployment | +| `scopes` | No | `["openid"]` | Scopes to request. `offline_access` added dynamically if IdP supports it | +| `traces_path` | No | `/v1/traces` | Path for OTLP POST | +| `max_batch_size` | No | `1000` | Max spans per HTTP request | + +### Why not proxy `.well-known/openid-configuration`? + +OIDC requires the `issuer` claim inside the discovery doc to match the URL it was fetched from. Serving Cognito's doc from a different domain violates this constraint. The `codeburn-export.json` doc decouples the metrics endpoint from the identity provider. + +## OIDC Authentication + +### Flow: Authorization Code + PKCE + +1. Client generates `code_verifier` (32 random bytes, base64url) and `code_challenge` (SHA-256 of verifier, base64url) +2. Client starts callback server on `127.0.0.1:19876` (fallback: 19877, 19878) +3. Browser opens `{authorization_endpoint}?response_type=code&client_id=...&redirect_uri=http://127.0.0.1:{port}/callback&code_challenge=...&code_challenge_method=S256&state=...&scope=...` +4. User logs in at IdP → IdP redirects to `http://127.0.0.1:{port}/callback?code=...&state=...` +5. Callback server validates `state`, extracts `code` +6. Client POSTs to `{token_endpoint}` with `grant_type=authorization_code`, `code`, `code_verifier`, `redirect_uri`, `client_id` +7. IdP returns `access_token` + `refresh_token` + +### Fixed ports + +Cognito (and Okta) do exact string comparison on callback URLs. Ephemeral ports fail. We register three fixed ports: `19876`, `19877`, `19878`. The client tries in order, falling back if a port is in use. + +RFC 8252 recommends `127.0.0.1` (IP literal) over `localhost` to avoid IPv6 `::1` resolution. + +### Token refresh + +On every `sync push`: +1. Read refresh token from OS store +2. POST `{token_endpoint}` with `grant_type=refresh_token` +3. Store whatever refresh token the server returns (handles rotation transparently) +4. On `invalid_grant` → stop, prompt user to re-run `sync setup` + +### Scope resolution + +- Request scopes from `codeburn-export.json` +- Add `offline_access` only if `scopes_supported` in OIDC discovery includes it +- Cognito rejects `offline_access` as `invalid_scope` — it issues refresh tokens without it + +## Credential Storage + +| Platform | Method | Implementation | +|---|---|---| +| macOS | Keychain | `security add-generic-password` / `find-generic-password` | +| Linux | libsecret | `secret-tool store` / `secret-tool lookup` | +| Windows | DPAPI | PowerShell `ConvertTo-SecureString` / `ConvertFrom-SecureString` | +| Fallback | File | `~/.config/codeburn/.sync-token` with `0600` permissions | + +No native modules (`keytar` is archived). Shell out to OS CLIs. Fallback reported honestly in `sync status`. + +## OTLP Encoding + +Strict protobuf-JSON mapping of `ExportTraceServiceRequest`. lowerCamelCase fields, hex-encoded IDs, integer enums. + +### Span identity (deterministic) + +``` +span_id = first 8 bytes of SHA-256(deduplicationKey) → hex (16 chars) +trace_id = first 16 bytes of SHA-256(sessionId) → hex (32 chars) +``` + +Re-sends are byte-identical. Server-side dedup is defense-in-depth. + +### Resource attributes + +```json +{ + "resource": { + "attributes": [ + { "key": "codeburn.device_id", "value": { "stringValue": "" } } + ] + } +} +``` + +### Span attributes + +```json +{ + "attributes": [ + { "key": "ai.provider", "value": { "stringValue": "kiro" } }, + { "key": "ai.model", "value": { "stringValue": "claude-sonnet-4-6" } }, + { "key": "ai.input_tokens", "value": { "intValue": "12500" } }, + { "key": "ai.output_tokens", "value": { "intValue": "3200" } }, + { "key": "ai.cost_usd", "value": { "doubleValue": 0.085 } }, + { "key": "ai.project", "value": { "stringValue": "my-app" } }, + { "key": "ai.tools", "value": { "arrayValue": { "values": [{ "stringValue": "Edit" }] } } }, + { "key": "ai.speed", "value": { "stringValue": "standard" } }, + { "key": "ai.cost_estimated", "value": { "boolValue": true } } + ] +} +``` + +## Sent-Ledger + +Client-side deduplication source of truth at `~/.cache/codeburn/sync-ledger.json`. + +Format: JSON array of `{ key: string, ts: string }` objects. + +**Push logic**: collect all calls in window → subtract ledger entries → send remainder → append to ledger on success. + +**Pruning**: entries older than 6 months removed on every push. + +**Why not a watermark?** Timestamp watermarks silently skip late-arriving calls (long sessions, providers that update rows). The ledger is exact. + +### Partial success + +OTLP returns `partial_success.rejected_spans` in the response body. Because OTLP does not identify *which* spans were rejected, the client ledgers nothing for a partially-rejected batch — the entire batch retries on the next push. This is safe: span IDs are deterministic (derived from the deduplication key), so servers that store by span ID treat re-sent spans as idempotent upserts. + +### Rate limiting (429) + +A push runs to completion — there is no routine per-push cap (only a 50,000-call safety valve). Server rate limits are the intended brake: + +- On HTTP 429 the client honors `Retry-After` (delta-seconds or HTTP-date), capped at 120 seconds per wait, defaulting to 5 seconds when the header is absent +- The same batch is retried up to 3 consecutive times; if the server is still rate-limiting after that, the push stops and the remaining (unledgered) calls are sent on the next push +- On 401 or 5xx the push stops immediately with the same resume-on-next-push behavior + +### Server contract + +The backend must implement: + +1. `GET {baseUrl}/.well-known/codeburn-export.json` — returns the discovery doc (public, no auth) +2. `POST {baseUrl}{traces_path}` — accepts OTLP/HTTP JSON with Bearer token + - Validate JWT (issued by the configured IdP) + - Derive developer identity from token's `sub` claim + - Accept `startTimeUnixNano` up to 6 months in the past + - Return standard OTLP response body + +No PII is included in the payload. The server derives identity solely from the authenticated token. + +## Testing + +### Unit tests (`tests/sync.test.ts`) + +26 tests covering pure functions: discovery parsing, PKCE generation, auth URL construction, scope resolution, callback server, config read/write. No network, no browser. + +### Mock IdP e2e (`tests/sync-e2e.test.ts`) + +6 tests with a localhost mock IdP server. Exercises the full auth round-trip, token refresh, rotation, revocation — fully offline, runs in CI. + +### Headless browser e2e (`tests/sync-headless-e2e.test.ts`) + +1 test with Playwright headless Chromium against real Cognito. Proves the actual browser PKCE flow works including Cognito Hosted UI form submission and localhost redirect. + +**Developer-only** — requires: +- Deployed test backend (CDK stack at `../codeburn-sync-backend/`) +- Cognito user with confirmed password +- Environment variables: `CODEBURN_SYNC_URL`, `CODEBURN_SYNC_EMAIL`, `CODEBURN_SYNC_PASSWORD` +- Playwright Chromium installed (`PLAYWRIGHT_BROWSERS_PATH`) + +Skipped by default when env vars are not set. Never runs in CI. + +### Test CDK stack (`codeburn-sync-backend/`) + +Minimal AWS backend for the headless e2e test: +- Cognito User Pool (PKCE, fixed callback ports) +- HTTP API with JWT authorizer +- Discovery Lambda (serves `codeburn-export.json`) +- Ingest Lambda (logs OTLP spans to CloudWatch) + +Deploy: `npx cdk deploy --profile andklee-dev` +Cost: ~$0/mo idle (pay-per-request) + +This is a **test fixture**, not a production reference. Any OIDC provider + OTLP-accepting endpoint satisfies the server contract. diff --git a/docs/sync/README.md b/docs/sync/README.md new file mode 100644 index 00000000..5f1343ed --- /dev/null +++ b/docs/sync/README.md @@ -0,0 +1,130 @@ +# codeburn sync + +Push your AI usage telemetry to a shared backend so teams can track adoption, budgets, and ROI across developers. + +Everything stays local-first: codeburn never sends data without your explicit action, and prompts/code are never included. + +## Quick Start + +```bash +# One-time setup (opens browser for login) +codeburn sync setup https://metrics.your-team.com + +# Push recent usage +codeburn sync push + +# Check status +codeburn sync status +``` + +## Commands + +### `codeburn sync setup ` + +Configures sync with a remote endpoint. Opens your browser for a one-time OIDC login. + +```bash +codeburn sync setup https://metrics.your-team.com +``` + +What happens: +1. Fetches server configuration from `/.well-known/codeburn-export.json` +2. Opens your browser to the identity provider's login page +3. After login, stores a refresh token securely in your OS keychain +4. Saves the endpoint configuration (no secrets) to `~/.config/codeburn/sync.json` + +You only need to do this once. The token refreshes silently on every push. + +### `codeburn sync push` + +Sends unsent AI usage data to the configured endpoint. + +```bash +# Push unsent calls from the last 7 days (default) +codeburn sync push + +# Push a larger window +codeburn sync push --since 30d + +# Preview what would be sent +codeburn sync push --dry-run +``` + +### `codeburn sync status` + +Shows the current sync configuration and authentication state. + +``` +Endpoint: https://metrics.your-team.com +Traces path: /v1/traces +Issuer: https://auth.your-team.com +Auth: configured +Token storage: keychain +Last sync: 2h ago +``` + +### `codeburn sync logout` + +Removes stored credentials and revokes the token at the identity provider. + +```bash +codeburn sync logout +``` + +### `codeburn sync reset --confirm` + +Clears the sent-ledger, causing the next push to re-send all data in the window. Use after a backend migration or if you suspect missing data. + +```bash +codeburn sync reset --confirm +``` + +## What Gets Sent + +Each AI interaction becomes one OTLP span with these attributes: + +| Field | Example | Description | +|---|---|---| +| `ai.provider` | `kiro`, `cursor`, `claude` | Which AI tool | +| `ai.model` | `claude-sonnet-4-6` | Model used | +| `ai.input_tokens` | `12500` | Prompt tokens | +| `ai.output_tokens` | `3200` | Response tokens | +| `ai.cost_usd` | `0.085` | Estimated cost | +| `ai.project` | `my-app` | Project name | +| `ai.tools` | `["Edit", "Bash"]` | Tools invoked | + +A pseudonymous `device_id` distinguishes your machines without revealing hostnames. + +### What is NOT sent + +- **Prompts** — your actual messages to AI are never included +- **Code** — file contents, diffs, and paths stay local +- **Bash commands** — may contain secrets, never sent +- **Your name/email** — identity is derived server-side from your login token + +There is no flag to override this. Privacy is structural, not configurable. + +## Authentication + +Sync uses standard OIDC (the same protocol as "Sign in with Google"). Your team's admin sets up the identity provider — you just click through the browser login once. + +- **Token storage**: macOS Keychain, Windows Credential Manager, or Linux libsecret. Falls back to a `0600` file if no keychain is available. +- **Token lifetime**: typically 30–90 days (set by your admin). You'll be prompted to re-login when it expires. +- **Re-login**: run `codeburn sync setup ` again. + +## FAQ + +**Q: Does sync run automatically?** +A: No. You run `codeburn sync push` when you want. A future version may offer opportunistic push (after each `codeburn report`), but it's always explicit. + +**Q: What if I push the same data twice?** +A: Safe. A local sent-ledger tracks what's been sent. Re-pushing the same window doesn't create duplicates. + +**Q: What if I'm offline for a week?** +A: Next push catches up. The default window is 7 days; use `--since 30d` or `--since all` (up to 6 months) for longer gaps. A push runs to completion regardless of size — server rate limits (429) are waited out automatically. + +**Q: Can my admin see my prompts?** +A: No. Prompts are never included in the payload. The server only sees token counts, costs, model names, and project names. + +**Q: How do I stop syncing?** +A: `codeburn sync logout` removes everything. Or just stop running `push`. From d8a7b2a95f8b51762020794eb230aea2932ed16f Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Mon, 13 Jul 2026 16:45:39 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(sync):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20https=20enforcement,=20keychain=20test=20isolation,?= =?UTF-8?q?=20golden=20ID=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before-merge items: 1. HTTPS enforced on every remote endpoint (RFC 8252 §8.3): baseUrl, issuer, authorization/token/revocation endpoints, and the traces endpoint all reject non-https, with a loopback (127.0.0.1/::1/ localhost) exception for offline tests and local dev. Enforcement is central (assertHttps) — the browser-open guard is no longer the only check whose failure was swallowed. 2. Credential store test isolation: CODEBURN_SYNC_TOKEN_STORE=file forces the file store (honors HOME) so the offline suite never touches the real macOS login keychain. Set in the e2e suite. 3. Golden pins for deriveSpanId/deriveTraceId/deriveDeviceId with fixed inputs and expected hex — the idempotency contract depends on these encodings being stable across releases; a green-tests encoding change would silently double-count history on span-ID-keyed backends. getDeviceId refactored over a pure deriveDeviceId(host, user). Smaller review items: - Callback server: ready promise resolves the actually-bound port from the listening event (kills the 100ms-sleep race after port fallback); Connection: close on all responses + closeAllConnections() on shutdown (pooled keep-alive sockets from a closed server could swallow requests aimed at a later server on the same port); error handler guarded so a post-bind error can never rebind to a different port than advertised; optional ports param ([0] = ephemeral) removes fixed-port contention between parallel test workers. - fetchOidcConfig verifies the issuer claim matches the fetch origin (OIDC Discovery §4.3 mix-up defense). - partialSuccess.rejectedSpans wrapped in Number() — proto3 int64 JSON mapping sends strings from strict protojson servers; += would concatenate. - Ledger writes are atomic (temp + rename); corrupt-ledger recovery and no-tmp-left-behind tests added; XDG_CACHE_HOME honored (ledger is reconstructible state, not config). - Mock IdP now implements /oauth2/authorize (registers PKCE challenge, 302s to redirect_uri) and verifies S256 code_verifier + single-use codes at the token endpoint. The e2e drives the real redirect flow and asserts wrong-verifier and code-reuse are rejected — PKCE binding is now exercised end to end. - sync reset calls clearLedger() instead of reimplementing the path. - push sets exit code 1 on rate-limited/server-error outcomes so cron and script callers can detect incomplete pushes. Deferred (noted for fast-follow): macOS 'security -i' stdin mode (untestable on this Linux box), ai.cost_estimated as a real ParsedApiCall flag (touches core parser types). Sync suite: 81 passing (5x stable), 5 developer-only. AI-Origin: human --- src/sync/auth.ts | 79 +++++++++++++++++++++++------ src/sync/cli.ts | 31 ++++++------ src/sync/credentials.ts | 7 +++ src/sync/discovery.ts | 26 ++++++++++ src/sync/index.ts | 2 +- src/sync/ledger.ts | 20 ++++++-- src/sync/otlp.ts | 8 ++- src/sync/push.ts | 9 +++- tests/fixtures/mock-idp.ts | 45 +++++++++++++++++ tests/sync-e2e.test.ts | 39 +++++++++++---- tests/sync-headless-e2e.test.ts | 6 +-- tests/sync-ledger-otlp.test.ts | 89 +++++++++++++++++++++++++++++++++ tests/sync-push.test.ts | 3 ++ tests/sync.test.ts | 32 ++++++------ 14 files changed, 327 insertions(+), 69 deletions(-) diff --git a/src/sync/auth.ts b/src/sync/auth.ts index 3ce7af2b..fa912322 100644 --- a/src/sync/auth.ts +++ b/src/sync/auth.ts @@ -7,6 +7,7 @@ import { createHash, randomBytes } from 'crypto' import { createServer, type Server } from 'http' import { URL } from 'url' +import { assertHttps } from './discovery.js' export interface OidcConfig { authorization_endpoint: string @@ -32,6 +33,7 @@ export class AuthError extends Error { // --- OIDC Discovery --- export async function fetchOidcConfig(issuer: string): Promise { + assertHttps(issuer, 'OIDC issuer') const url = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration` let response: Response @@ -52,11 +54,26 @@ export async function fetchOidcConfig(issuer: string): Promise { throw new AuthError(`OIDC discovery returned invalid JSON: ${url}`) } + // Issuer mix-up defense (OIDC Discovery §4.3): the issuer claim in the + // metadata must match the issuer we fetched it from. + const issuerClaim = typeof body.issuer === 'string' ? body.issuer.replace(/\/$/, '') : '' + if (issuerClaim !== issuer.replace(/\/$/, '')) { + throw new AuthError( + `OIDC issuer mismatch: metadata claims "${body.issuer}" but was fetched from "${issuer}"` + ) + } + const authorization_endpoint = body.authorization_endpoint const token_endpoint = body.token_endpoint if (typeof authorization_endpoint !== 'string' || typeof token_endpoint !== 'string') { throw new AuthError('OIDC discovery missing authorization_endpoint or token_endpoint') } + // Tokens travel on these endpoints — enforce https (loopback exempt) + assertHttps(authorization_endpoint, 'authorization_endpoint') + assertHttps(token_endpoint, 'token_endpoint') + if (typeof body.revocation_endpoint === 'string') { + assertHttps(body.revocation_endpoint, 'revocation_endpoint') + } return { authorization_endpoint, @@ -127,13 +144,29 @@ export interface CallbackResult { export function startCallbackServer( expectedState: string, timeoutMs: number = 300_000, // 5 minutes -): { promise: Promise; port: number; server: Server } { + ports: readonly number[] = CALLBACK_PORTS, // tests pass [0] for an ephemeral port +): { promise: Promise; ready: Promise; server: Server } { let resolvedPort = 0 let server: Server + let readyResolve!: (port: number) => void + let readyReject!: (err: Error) => void + const ready = new Promise((resolve, reject) => { + readyResolve = resolve + readyReject = reject + }) const promise = new Promise((resolve, reject) => { + // Fully shut down: stop listening AND destroy lingering keep-alive + // sockets. Without this, an HTTP client's pooled connection keeps the + // dead server alive and can swallow requests meant for a later server + // on the same port. + const shutdown = () => { + try { server.close() } catch { /* already closed */ } + try { server.closeAllConnections() } catch { /* Node < 18.2 */ } + } + const timer = setTimeout(() => { - server?.close() + shutdown() reject(new AuthError('Login timed out after 5 minutes. Please try again.')) }, timeoutMs) @@ -143,60 +176,74 @@ export function startCallbackServer( const state = url.searchParams.get('state') const error = url.searchParams.get('error') + // Connection: close on every response — the callback server is + // single-purpose and must never leave pooled keep-alive sockets behind. if (error) { - res.writeHead(400, { 'Content-Type': 'text/html' }) + res.writeHead(400, { 'Content-Type': 'text/html', 'Connection': 'close' }) res.end('

Login failed

You can close this tab.

') clearTimeout(timer) - server.close() + shutdown() reject(new AuthError(`IdP returned error: ${error}`)) return } if (state !== expectedState) { - res.writeHead(400, { 'Content-Type': 'text/plain' }) + res.writeHead(400, { 'Content-Type': 'text/plain', 'Connection': 'close' }) res.end('Invalid state parameter') return // don't close — might be a stale request } if (!code) { - res.writeHead(400, { 'Content-Type': 'text/plain' }) + res.writeHead(400, { 'Content-Type': 'text/plain', 'Connection': 'close' }) res.end('Missing authorization code') return } - res.writeHead(200, { 'Content-Type': 'text/html' }) + res.writeHead(200, { 'Content-Type': 'text/html', 'Connection': 'close' }) res.end('

✓ Login successful

You can close this tab.

') clearTimeout(timer) - server.close() + shutdown() resolve({ code, port: resolvedPort }) }) - // Try ports in order + // Try ports in order. The error handler is guarded on `resolvedPort` so a + // late error event can never trigger a second listen() after a successful + // bind (which would silently move the server off the advertised port). const tryListen = (ports: readonly number[], idx: number) => { if (idx >= ports.length) { clearTimeout(timer) - reject(new AuthError(`All callback ports (${ports.join(', ')}) are in use. Close other codeburn instances and retry.`)) + const err = new AuthError(`All callback ports (${ports.join(', ')}) are in use. Close other codeburn instances and retry.`) + readyReject(err) + reject(err) return } const port = ports[idx]! server.once('error', (err: NodeJS.ErrnoException) => { + if (resolvedPort !== 0) return // already bound — never rebind if (err.code === 'EADDRINUSE') { tryListen(ports, idx + 1) } else { clearTimeout(timer) - reject(new AuthError(`Callback server error: ${err.message}`)) + const authErr = new AuthError(`Callback server error: ${err.message}`) + readyReject(authErr) + reject(authErr) } }) server.listen(port, '127.0.0.1', () => { - resolvedPort = port + // port 0 = OS-assigned ephemeral port — read the real one back + const addr = server.address() + resolvedPort = typeof addr === 'object' && addr ? addr.port : port + readyResolve(resolvedPort) }) } - tryListen(CALLBACK_PORTS, 0) + tryListen(ports, 0) }) - // Return immediately so caller can read the port - return { promise, port: resolvedPort, server: server! } + // `ready` resolves with the actually-bound port once listening — callers + // must await it before building the redirect URI (port fallback means the + // first port in CALLBACK_PORTS is not guaranteed). + return { promise, ready, server: server! } } // --- Token Exchange --- @@ -208,6 +255,7 @@ export async function exchangeCode( redirectUri: string, clientId: string, ): Promise { + assertHttps(tokenEndpoint, 'token_endpoint') const body = new URLSearchParams({ grant_type: 'authorization_code', code, @@ -246,6 +294,7 @@ export async function refreshToken( refreshTokenValue: string, clientId: string, ): Promise { + assertHttps(tokenEndpoint, 'token_endpoint') const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshTokenValue, diff --git a/src/sync/cli.ts b/src/sync/cli.ts index 10bd9419..9136dbfd 100644 --- a/src/sync/cli.ts +++ b/src/sync/cli.ts @@ -52,13 +52,10 @@ export function registerSyncCommands(program: Command): void { const pkce = generatePkce() const state = randomBytes(16).toString('hex') - // 5. Start callback server - const { promise: callbackPromise, server } = startCallbackServer(state) - - // Wait briefly for server to bind, then get the port - await new Promise(resolve => setTimeout(resolve, 100)) - const addr = server.address() - const port = typeof addr === 'object' && addr ? addr.port : CALLBACK_PORTS[0] + // 5. Start callback server — await the actually-bound port (port + // fallback means it may not be the first in CALLBACK_PORTS) + const { promise: callbackPromise, ready } = startCallbackServer(state) + const port = await ready const redirectUri = `http://127.0.0.1:${port}/callback` // 6. Build auth URL and open browser @@ -193,16 +190,12 @@ export function registerSyncCommands(program: Command): void { process.exit(1) } - const { join } = await import('path') - const { homedir } = await import('os') - const { unlinkSync, existsSync } = await import('fs') - - const ledgerPath = join(homedir(), '.cache', 'codeburn', 'sync-ledger.json') - if (existsSync(ledgerPath)) { - unlinkSync(ledgerPath) - process.stderr.write('Ledger cleared. Next push will re-send all calls in window.\n') + const { clearLedger } = await import('./ledger.js') + const removed = clearLedger() + if (removed > 0) { + process.stderr.write(`Ledger cleared (${removed} entries). Next push will re-send all calls in window.\n`) } else { - process.stderr.write('No ledger file found (nothing to reset).\n') + process.stderr.write('No ledger entries found (nothing to reset).\n') } }) @@ -320,6 +313,12 @@ export function registerSyncCommands(program: Command): void { if (unsent.length > MAX_PER_PUSH) { process.stderr.write(` ${unsent.length - MAX_PER_PUSH} calls remaining (safety limit). Run \`codeburn sync push\` again.\n`) } + + // Non-zero exit when the push did not complete, so cron/scripts can + // detect it. Ledgered progress is kept; next push resumes. + if (result.outcome !== 'complete') { + process.exitCode = 1 + } } catch (err) { process.stderr.write(`${(err as Error).message}\n`) process.exit(1) diff --git a/src/sync/credentials.ts b/src/sync/credentials.ts index a560581b..9f802599 100644 --- a/src/sync/credentials.ts +++ b/src/sync/credentials.ts @@ -185,6 +185,13 @@ function isCommandAvailable(cmd: string): boolean { } export function createCredentialStore(): CredentialStore { + // Test/CI escape hatch: force the file store (respects $HOME, so tests + // can fully isolate with a temp HOME). Without this, darwin machines + // would hit the real login keychain during the offline test suite. + if (process.env.CODEBURN_SYNC_TOKEN_STORE === 'file') { + return new FileStore() + } + if (process.platform === 'darwin') { return new KeychainStore() } diff --git a/src/sync/discovery.ts b/src/sync/discovery.ts index 60f5ffe3..abd97dc1 100644 --- a/src/sync/discovery.ts +++ b/src/sync/discovery.ts @@ -20,6 +20,28 @@ export class DiscoveryError extends Error { } } +const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost']) + +/** + * Enforce https on remote endpoints (RFC 8252 §8.3). Refresh tokens and + * bearer tokens travel on these URLs — plaintext http is only permitted + * for loopback addresses (local development and offline tests). + */ +export function assertHttps(url: string, label: string): void { + let parsed: URL + try { + parsed = new URL(url) + } catch { + throw new DiscoveryError(`${label} is not a valid URL: ${url}`) + } + if (parsed.protocol === 'https:') return + if (parsed.protocol === 'http:' && LOOPBACK_HOSTS.has(parsed.hostname)) return + throw new DiscoveryError( + `${label} must use https (got ${parsed.protocol}//${parsed.host}). ` + + `Plain http is only allowed for loopback (127.0.0.1).` + ) +} + const SUPPORTED_VERSION = 1 export function parseDiscoveryDoc(raw: unknown): CodeburnDiscoveryDoc { @@ -48,6 +70,9 @@ export function parseDiscoveryDoc(raw: unknown): CodeburnDiscoveryDoc { throw new DiscoveryError('Discovery doc missing required field: client_id') } + // Format validation after presence checks (clearer errors) + assertHttps(issuer, 'Issuer') + // Optional with defaults const scopes = Array.isArray(doc.scopes) ? doc.scopes.filter((s): s is string => typeof s === 'string') @@ -63,6 +88,7 @@ export function parseDiscoveryDoc(raw: unknown): CodeburnDiscoveryDoc { } export async function fetchDiscoveryDoc(baseUrl: string): Promise { + assertHttps(baseUrl, 'Base URL') const url = `${baseUrl.replace(/\/$/, '')}/.well-known/codeburn-export.json` let response: Response diff --git a/src/sync/index.ts b/src/sync/index.ts index 8aa19e4f..347d03e4 100644 --- a/src/sync/index.ts +++ b/src/sync/index.ts @@ -1,5 +1,5 @@ export { registerSyncCommands } from './cli.js' -export { fetchDiscoveryDoc, parseDiscoveryDoc, type CodeburnDiscoveryDoc } from './discovery.js' +export { fetchDiscoveryDoc, parseDiscoveryDoc, assertHttps, type CodeburnDiscoveryDoc } from './discovery.js' export { fetchOidcConfig, generatePkce, buildAuthUrl, resolveScopes, exchangeCode, refreshToken } from './auth.js' export { createCredentialStore, type CredentialStore, type StorageMethod } from './credentials.js' export { readSyncConfig, writeSyncConfig, deleteSyncConfig, type SyncConfig } from './config.js' diff --git a/src/sync/ledger.ts b/src/sync/ledger.ts index eb4d7a34..99ef8560 100644 --- a/src/sync/ledger.ts +++ b/src/sync/ledger.ts @@ -5,7 +5,7 @@ * Push logic: window minus ledger = what to send. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'fs' import { join } from 'path' import { homedir } from 'os' @@ -16,8 +16,15 @@ export interface LedgerEntry { const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000 +function cacheDir(): string { + // Honor XDG_CACHE_HOME — the ledger is reconstructible state, not config + const xdg = process.env.XDG_CACHE_HOME + const base = xdg && xdg.trim() ? xdg : join(homedir(), '.cache') + return join(base, 'codeburn') +} + function ledgerPath(): string { - return join(homedir(), '.cache', 'codeburn', 'sync-ledger.json') + return join(cacheDir(), 'sync-ledger.json') } export function readLedger(): LedgerEntry[] { @@ -36,9 +43,14 @@ export function readLedger(): LedgerEntry[] { } export function writeLedger(entries: LedgerEntry[]): void { - const dir = join(homedir(), '.cache', 'codeburn') + const dir = cacheDir() mkdirSync(dir, { recursive: true }) - writeFileSync(ledgerPath(), JSON.stringify(entries)) + // Atomic write: a crash mid-write must not corrupt the ledger — a corrupt + // ledger reads as empty and the next push re-sends the whole window. + const path = ledgerPath() + const tmp = `${path}.tmp` + writeFileSync(tmp, JSON.stringify(entries)) + renameSync(tmp, path) } /** Append new entries after a successful push. Also prunes entries older than 6 months. */ diff --git a/src/sync/otlp.ts b/src/sync/otlp.ts index b4704317..8a12c7e6 100644 --- a/src/sync/otlp.ts +++ b/src/sync/otlp.ts @@ -43,10 +43,14 @@ export interface OtlpPayload { let cachedDeviceId: string | null = null +/** Pure derivation — exposed so the encoding can be golden-pinned in tests. */ +export function deriveDeviceId(host: string, username: string): string { + return createHash('sha256').update(`${host}:${username}`).digest('hex').slice(0, 16) +} + export function getDeviceId(): string { if (cachedDeviceId) return cachedDeviceId - const raw = `${hostname()}:${userInfo().username}` - cachedDeviceId = createHash('sha256').update(raw).digest('hex').slice(0, 16) + cachedDeviceId = deriveDeviceId(hostname(), userInfo().username) return cachedDeviceId } diff --git a/src/sync/push.ts b/src/sync/push.ts index ffa11028..0444c718 100644 --- a/src/sync/push.ts +++ b/src/sync/push.ts @@ -6,6 +6,7 @@ */ import type { ProjectSummary } from '../types.js' +import { assertHttps } from './discovery.js' import { ledgerKeySet, appendToLedger, type LedgerEntry } from './ledger.js' import { buildOtlpPayload, batchCalls, type CallWithSession } from './otlp.js' @@ -90,6 +91,7 @@ export function parseRetryAfterMs(value: string | null): number | null { * retry on the next push. */ export async function sendBatches(opts: SendBatchesOptions): Promise { + assertHttps(opts.endpoint, 'Traces endpoint') const log = opts.log ?? (() => {}) const sleep = opts.sleep ?? ((ms: number) => new Promise(r => setTimeout(r, ms))) const maxWaitMs = opts.maxWaitMs ?? 120_000 @@ -143,8 +145,11 @@ export async function sendBatches(opts: SendBatchesOptions): Promise // Check for partial success let rejected = 0 try { - const body = await response.json() as { partialSuccess?: { rejectedSpans?: number } } - rejected = body?.partialSuccess?.rejectedSpans ?? 0 + const body = await response.json() as { partialSuccess?: { rejectedSpans?: number | string } } + // proto3 int64 JSON mapping: strict protojson servers send int64 as a + // string — Number() both so `totalRejected +=` never concatenates. + rejected = Number(body?.partialSuccess?.rejectedSpans ?? 0) + if (!Number.isFinite(rejected) || rejected < 0) rejected = 0 } catch { /* empty response = full success */ } if (rejected > 0) { diff --git a/tests/fixtures/mock-idp.ts b/tests/fixtures/mock-idp.ts index 8bd23aa3..e873b6b6 100644 --- a/tests/fixtures/mock-idp.ts +++ b/tests/fixtures/mock-idp.ts @@ -9,6 +9,7 @@ * - /oauth2/revoke (token revocation) */ +import { createHash } from 'crypto' import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http' export interface MockIdpOptions { @@ -40,6 +41,9 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise const accessToken = opts.accessToken ?? 'mock-access-token-xyz' let currentRefreshToken = refreshToken let rotationCounter = 0 + let codeCounter = 0 + // code -> S256 code_challenge registered at /oauth2/authorize + const pendingCodes = new Map() const state: MockIdp = { port: 0, @@ -84,6 +88,27 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise return } + // --- Authorize endpoint (records the PKCE challenge, issues a code, + // redirects to the client's redirect_uri like a real IdP) --- + if (path === '/oauth2/authorize' && req.method === 'GET') { + const challenge = url.searchParams.get('code_challenge') + const method = url.searchParams.get('code_challenge_method') + const redirectUri = url.searchParams.get('redirect_uri') + const reqState = url.searchParams.get('state') + if (!challenge || method !== 'S256' || !redirectUri) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'invalid_request', error_description: 'missing code_challenge/S256/redirect_uri' })) + return + } + codeCounter++ + const code = `mock-code-${codeCounter}` + pendingCodes.set(code, challenge) + const location = `${redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(reqState ?? '')}` + res.writeHead(302, { Location: location }) + res.end() + return + } + // --- Token endpoint --- if (path === '/oauth2/token' && req.method === 'POST') { let body = '' @@ -100,6 +125,26 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise return } + // PKCE S256 verification (RFC 7636 §4.6): the code must have been + // issued by /oauth2/authorize, and BASE64URL(SHA256(code_verifier)) + // must equal the challenge registered with it. + const expectedChallenge = pendingCodes.get(code) + if (!expectedChallenge) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'invalid_grant', error_description: 'unknown or reused code' })) + return + } + const verifier = params.get('code_verifier') + const computed = verifier + ? createHash('sha256').update(verifier).digest('base64url') + : '' + if (computed !== expectedChallenge) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'invalid_grant', error_description: 'PKCE verification failed' })) + return + } + pendingCodes.delete(code) // single-use + state.exchangedCodes.push(code) state.issuedTokens.access.push(accessToken) state.issuedTokens.refresh.push(currentRefreshToken) diff --git a/tests/sync-e2e.test.ts b/tests/sync-e2e.test.ts index 682d706f..de1f5c4d 100644 --- a/tests/sync-e2e.test.ts +++ b/tests/sync-e2e.test.ts @@ -29,13 +29,20 @@ import { writeSyncConfig, readSyncConfig, deleteSyncConfig } from '../src/sync/c let idp: MockIdp let tmpHome: string const originalHome = process.env.HOME +const originalStore = process.env.CODEBURN_SYNC_TOKEN_STORE beforeAll(async () => { idp = await startMockIdp({ rotateTokens: false }) + // Force the file store so this suite never touches the real OS keychain + // (on darwin, createCredentialStore() would otherwise ignore HOME and + // read/write the login keychain under the real service/account names). + process.env.CODEBURN_SYNC_TOKEN_STORE = 'file' }) afterAll(async () => { await idp.close() + if (originalStore === undefined) delete process.env.CODEBURN_SYNC_TOKEN_STORE + else process.env.CODEBURN_SYNC_TOKEN_STORE = originalStore }) beforeEach(async () => { @@ -73,10 +80,8 @@ describe('sync e2e (mock IdP)', () => { const state = 'e2e-test-state' // 5. Start callback server - const { promise: callbackPromise, server } = startCallbackServer(state, 5000) - await new Promise(resolve => setTimeout(resolve, 100)) - const addr = server.address() - const port = typeof addr === 'object' && addr ? addr.port : 19876 + const { promise: callbackPromise, ready } = startCallbackServer(state, 5000, [0]) + const port = await ready const redirectUri = `http://127.0.0.1:${port}/callback` // 6. Build auth URL (verify it's well-formed) @@ -92,12 +97,17 @@ describe('sync e2e (mock IdP)', () => { expect(parsedUrl.searchParams.get('code_challenge_method')).toBe('S256') expect(parsedUrl.searchParams.get('client_id')).toBe('mock-client-id') - // 7. Simulate browser callback (as if user logged in and IdP redirected) - const authCode = 'e2e-auth-code-12345' - await fetch(`http://127.0.0.1:${port}/callback?code=${authCode}&state=${state}`) + // 7. Drive the real authorize flow: hit the IdP's authorize endpoint + // (registers the PKCE challenge, issues a code), then follow its + // redirect to our local callback server — like a browser would. + const authResp = await fetch(authUrl, { redirect: 'manual' }) + expect(authResp.status).toBe(302) + const location = authResp.headers.get('location')! + expect(location).toContain(`http://127.0.0.1:${port}/callback`) + await fetch(location) const callbackResult = await callbackPromise - expect(callbackResult.code).toBe(authCode) + expect(callbackResult.code).toMatch(/^mock-code-/) // 8. Exchange code for tokens const tokens = await exchangeCode( @@ -112,7 +122,18 @@ describe('sync e2e (mock IdP)', () => { expect(tokens.expires_in).toBe(3600) // Verify the mock IdP received the code - expect(idp.exchangedCodes).toContain(authCode) + expect(idp.exchangedCodes).toContain(callbackResult.code) + + // 8b. PKCE negative checks: wrong verifier rejected; code is single-use + const authResp2 = await fetch(authUrl.replace(`state=${state}`, 'state=neg-test'), { redirect: 'manual' }) + const loc2 = new URL(authResp2.headers.get('location')!) + const code2 = loc2.searchParams.get('code')! + await expect( + exchangeCode(oidc.token_endpoint, code2, 'wrong-verifier-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', redirectUri, discovery.client_id) + ).rejects.toThrow(/PKCE verification failed|Token exchange failed/) + await expect( + exchangeCode(oidc.token_endpoint, callbackResult.code, pkce.code_verifier, redirectUri, discovery.client_id) + ).rejects.toThrow(/unknown or reused code|Token exchange failed/) // 9. Store refresh token const store = createCredentialStore() diff --git a/tests/sync-headless-e2e.test.ts b/tests/sync-headless-e2e.test.ts index f6e6d96a..2d40cdb6 100644 --- a/tests/sync-headless-e2e.test.ts +++ b/tests/sync-headless-e2e.test.ts @@ -66,10 +66,8 @@ describe.skipIf(SKIP)('sync setup — headless browser PKCE flow', () => { const state = randomBytes(16).toString('hex') // 3. Start callback server - const { promise: callbackPromise, server } = startCallbackServer(state, 30000) - await new Promise(r => setTimeout(r, 200)) - const addr = server.address() - const port = typeof addr === 'object' && addr ? addr.port : 19876 + const { promise: callbackPromise, ready } = startCallbackServer(state, 30000) + const port = await ready const redirectUri = `http://127.0.0.1:${port}/callback` // 4. Build auth URL diff --git a/tests/sync-ledger-otlp.test.ts b/tests/sync-ledger-otlp.test.ts index 13e7c7a3..0b32f80d 100644 --- a/tests/sync-ledger-otlp.test.ts +++ b/tests/sync-ledger-otlp.test.ts @@ -13,6 +13,7 @@ import { buildOtlpPayload, batchCalls, getDeviceId, + deriveDeviceId, type CallWithSession, } from '../src/sync/otlp.js' @@ -76,6 +77,15 @@ describe('deriveSpanId', () => { const b = deriveSpanId('key-2') expect(a).not.toBe(b) }) + + // GOLDEN PIN — do not update this value. The idempotency contract depends + // on span IDs being stable across releases: if the hash input or encoding + // ever changes, every re-sent span gets a new identity and backends that + // key on span ID double-count history. If this test fails, revert the + // encoding change (or design an explicit migration). + it('golden: SHA-256(deduplicationKey) first 8 bytes as hex', () => { + expect(deriveSpanId('golden-dedup-key')).toBe('ec3ca28cceacf381') + }) }) describe('deriveTraceId', () => { @@ -89,6 +99,11 @@ describe('deriveTraceId', () => { const b = deriveTraceId('session-1') expect(a).toBe(b) }) + + // GOLDEN PIN — see deriveSpanId golden test for why this must not change. + it('golden: SHA-256(sessionId) first 16 bytes as hex', () => { + expect(deriveTraceId('golden-session-id')).toBe('ff1b1358ef64c52f80e50e7ae47ca176') + }) }) describe('getDeviceId', () => { @@ -100,6 +115,12 @@ describe('getDeviceId', () => { it('is stable across calls', () => { expect(getDeviceId()).toBe(getDeviceId()) }) + + // GOLDEN PIN — device ID must be stable across releases so a developer's + // machine keeps one identity in the backend. + it('golden: SHA-256(hostname:username) first 8 bytes as hex', () => { + expect(deriveDeviceId('host.example', 'alice')).toBe('004d1a2fc048f575') + }) }) // ── OTLP Payload Builder ────────────────────────────────────────────── @@ -216,6 +237,9 @@ describe('ledger', () => { beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-ledger-')) process.env.HOME = tmpDir + // env-isolation.ts redirects XDG_CACHE_HOME to a per-worker sandbox shared + // across tests — the ledger honors XDG, so point it at the per-test dir. + process.env.XDG_CACHE_HOME = join(tmpDir, '.cache') }) afterEach(async () => { @@ -283,4 +307,69 @@ describe('ledger', () => { const { clearLedger } = await import('../src/sync/ledger.js') expect(clearLedger()).toBe(0) }) + + it('corrupt ledger file reads as empty (crash-safe recovery)', async () => { + const { readLedger } = await import('../src/sync/ledger.js') + const { mkdirSync, writeFileSync } = await import('fs') + const { join } = await import('path') + const dir = join(process.env.HOME!, '.cache', 'codeburn') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'sync-ledger.json'), '{"truncated mid-wri') + expect(readLedger()).toEqual([]) + }) + + it('writes are atomic — no .tmp file left behind', async () => { + const { writeLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + writeLedger([{ key: 'a', ts: '2026-07-01T00:00:00Z' }]) + const dir = join(process.env.HOME!, '.cache', 'codeburn') + expect(existsSync(join(dir, 'sync-ledger.json'))).toBe(true) + expect(existsSync(join(dir, 'sync-ledger.json.tmp'))).toBe(false) + }) + + it('honors XDG_CACHE_HOME when set', async () => { + const { writeLedger, readLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + const xdgDir = join(process.env.HOME!, 'xdg-cache') + const original = process.env.XDG_CACHE_HOME + process.env.XDG_CACHE_HOME = xdgDir + try { + writeLedger([{ key: 'xdg-entry', ts: '2026-07-01T00:00:00Z' }]) + expect(existsSync(join(xdgDir, 'codeburn', 'sync-ledger.json'))).toBe(true) + expect(readLedger().map(e => e.key)).toEqual(['xdg-entry']) + } finally { + if (original === undefined) delete process.env.XDG_CACHE_HOME + else process.env.XDG_CACHE_HOME = original + } + }) +}) + +// ── assertHttps (RFC 8252 §8.3) ─────────────────────────────────────── + +describe('assertHttps', () => { + it('accepts https URLs', async () => { + const { assertHttps } = await import('../src/sync/discovery.js') + expect(() => assertHttps('https://telemetry.example.com', 'Base URL')).not.toThrow() + }) + + it('accepts http on loopback (offline tests, local dev)', async () => { + const { assertHttps } = await import('../src/sync/discovery.js') + expect(() => assertHttps('http://127.0.0.1:8080/x', 'Base URL')).not.toThrow() + expect(() => assertHttps('http://localhost:3000', 'Base URL')).not.toThrow() + expect(() => assertHttps('http://[::1]:9999', 'Base URL')).not.toThrow() + }) + + it('rejects plain http on non-loopback hosts', async () => { + const { assertHttps } = await import('../src/sync/discovery.js') + expect(() => assertHttps('http://telemetry.example.com', 'Base URL')).toThrow(/must use https/) + expect(() => assertHttps('http://192.168.1.10', 'Issuer')).toThrow(/must use https/) + }) + + it('rejects non-http(s) schemes and garbage', async () => { + const { assertHttps } = await import('../src/sync/discovery.js') + expect(() => assertHttps('ftp://example.com', 'Base URL')).toThrow(/must use https/) + expect(() => assertHttps('not a url', 'Base URL')).toThrow(/not a valid URL/) + }) }) diff --git a/tests/sync-push.test.ts b/tests/sync-push.test.ts index 9d9e804c..0fe5c9bd 100644 --- a/tests/sync-push.test.ts +++ b/tests/sync-push.test.ts @@ -89,6 +89,9 @@ const originalHome = process.env.HOME beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-push-')) process.env.HOME = tmpDir + // env-isolation.ts redirects XDG_CACHE_HOME to a per-worker sandbox shared + // across tests — the ledger honors XDG, so point it at the per-test dir. + process.env.XDG_CACHE_HOME = join(tmpDir, '.cache') }) afterEach(async () => { diff --git a/tests/sync.test.ts b/tests/sync.test.ts index 41e7a076..5c512572 100644 --- a/tests/sync.test.ts +++ b/tests/sync.test.ts @@ -34,7 +34,7 @@ describe('parseDiscoveryDoc', () => { }) it('rejects version > 1', () => { - expect(() => parseDiscoveryDoc({ version: 2, issuer: 'x', client_id: 'y' })) + expect(() => parseDiscoveryDoc({ version: 2, issuer: 'https://idp.example', client_id: 'y' })) .toThrow('Please update codeburn') }) @@ -44,30 +44,35 @@ describe('parseDiscoveryDoc', () => { }) it('rejects missing client_id', () => { - expect(() => parseDiscoveryDoc({ version: 1, issuer: 'x' })) + expect(() => parseDiscoveryDoc({ version: 1, issuer: 'https://idp.example' })) .toThrow('missing required field: client_id') }) it('defaults traces_path to /v1/traces when absent', () => { - const doc = parseDiscoveryDoc({ issuer: 'x', client_id: 'y' }) + const doc = parseDiscoveryDoc({ issuer: 'https://idp.example', client_id: 'y' }) expect(doc.traces_path).toBe('/v1/traces') }) it('defaults max_batch_size to 1000 when absent', () => { - const doc = parseDiscoveryDoc({ issuer: 'x', client_id: 'y' }) + const doc = parseDiscoveryDoc({ issuer: 'https://idp.example', client_id: 'y' }) expect(doc.max_batch_size).toBe(1000) }) it('defaults scopes to ["openid"] when absent', () => { - const doc = parseDiscoveryDoc({ issuer: 'x', client_id: 'y' }) + const doc = parseDiscoveryDoc({ issuer: 'https://idp.example', client_id: 'y' }) expect(doc.scopes).toEqual(['openid']) }) it('treats absent version as v1', () => { - const doc = parseDiscoveryDoc({ issuer: 'x', client_id: 'y' }) + const doc = parseDiscoveryDoc({ issuer: 'https://idp.example', client_id: 'y' }) expect(doc.version).toBe(1) }) + it('rejects non-https issuer (RFC 8252 §8.3)', () => { + expect(() => parseDiscoveryDoc({ version: 1, issuer: 'http://idp.example', client_id: 'y' })) + .toThrow(/must use https/) + }) + it('rejects non-object input', () => { expect(() => parseDiscoveryDoc(null)).toThrow('must be a JSON object') expect(() => parseDiscoveryDoc('string')).toThrow('must be a JSON object') @@ -164,12 +169,10 @@ describe('resolveScopes', () => { describe('startCallbackServer', () => { it('accepts valid callback with matching state', async () => { const state = 'test-state-123' - const { promise, server } = startCallbackServer(state, 5000) + const { promise, ready } = startCallbackServer(state, 5000, [0]) // Wait for server to bind - await new Promise(resolve => setTimeout(resolve, 200)) - const addr = server.address() - const port = typeof addr === 'object' && addr ? addr.port : CALLBACK_PORTS[0] + const port = await ready // Simulate IdP callback await fetch(`http://127.0.0.1:${port}/callback?code=auth-code-xyz&state=${state}`) @@ -180,11 +183,8 @@ describe('startCallbackServer', () => { it('rejects callback with wrong state', async () => { const state = 'correct-state' - const { promise, server } = startCallbackServer(state, 5000) - - await new Promise(resolve => setTimeout(resolve, 200)) - const addr = server.address() - const port = typeof addr === 'object' && addr ? addr.port : CALLBACK_PORTS[0] + const { promise, ready } = startCallbackServer(state, 5000, [0]) + const port = await ready // Send with wrong state — server stays running const resp = await fetch(`http://127.0.0.1:${port}/callback?code=xxx&state=wrong-state`) @@ -197,7 +197,7 @@ describe('startCallbackServer', () => { }, 10000) it('times out after configured duration', async () => { - const { promise } = startCallbackServer('state', 300) // 300ms timeout + const { promise } = startCallbackServer('state', 300, [0]) // 300ms timeout await expect(promise).rejects.toThrow('timed out') }, 5000)