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`. 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..fa912322 --- /dev/null +++ b/src/sync/auth.ts @@ -0,0 +1,346 @@ +/** + * 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' +import { assertHttps } from './discovery.js' + +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 { + assertHttps(issuer, 'OIDC issuer') + 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}`) + } + + // 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, + 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 + 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(() => { + shutdown() + 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') + + // 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', 'Connection': 'close' }) + res.end('

Login failed

You can close this tab.

') + clearTimeout(timer) + shutdown() + reject(new AuthError(`IdP returned error: ${error}`)) + return + } + + if (state !== expectedState) { + 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', 'Connection': 'close' }) + res.end('Missing authorization code') + return + } + + res.writeHead(200, { 'Content-Type': 'text/html', 'Connection': 'close' }) + res.end('

✓ Login successful

You can close this tab.

') + clearTimeout(timer) + shutdown() + resolve({ code, port: resolvedPort }) + }) + + // 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) + 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) + const authErr = new AuthError(`Callback server error: ${err.message}`) + readyReject(authErr) + reject(authErr) + } + }) + server.listen(port, '127.0.0.1', () => { + // 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(ports, 0) + }) + + // `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 --- + +export async function exchangeCode( + tokenEndpoint: string, + code: string, + codeVerifier: string, + redirectUri: string, + clientId: string, +): Promise { + assertHttps(tokenEndpoint, 'token_endpoint') + 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 { + assertHttps(tokenEndpoint, 'token_endpoint') + 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..9136dbfd --- /dev/null +++ b/src/sync/cli.ts @@ -0,0 +1,327 @@ +/** + * 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 — 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 + 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 { 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 entries 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`) + } + + // 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/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..9f802599 --- /dev/null +++ b/src/sync/credentials.ts @@ -0,0 +1,218 @@ +/** + * 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 { + // 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() + } + + 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..abd97dc1 --- /dev/null +++ b/src/sync/discovery.ts @@ -0,0 +1,119 @@ +/** + * 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 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 { + 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') + } + + // 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') + : ['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 { + assertHttps(baseUrl, 'Base URL') + 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..347d03e4 --- /dev/null +++ b/src/sync/index.ts @@ -0,0 +1,8 @@ +export { registerSyncCommands } from './cli.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' +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..99ef8560 --- /dev/null +++ b/src/sync/ledger.ts @@ -0,0 +1,87 @@ +/** + * 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, renameSync } 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 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(cacheDir(), '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 = cacheDir() + mkdirSync(dir, { recursive: true }) + // 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. */ +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..8a12c7e6 --- /dev/null +++ b/src/sync/otlp.ts @@ -0,0 +1,143 @@ +/** + * 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 + +/** 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 + cachedDeviceId = deriveDeviceId(hostname(), userInfo().username) + 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..0444c718 --- /dev/null +++ b/src/sync/push.ts @@ -0,0 +1,176 @@ +/** + * 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 { assertHttps } from './discovery.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 { + 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 + 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 | 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) { + // 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 } diff --git a/tests/fixtures/mock-idp.ts b/tests/fixtures/mock-idp.ts new file mode 100644 index 00000000..e873b6b6 --- /dev/null +++ b/tests/fixtures/mock-idp.ts @@ -0,0 +1,231 @@ +/** + * 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 { createHash } from 'crypto' +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 + let codeCounter = 0 + // code -> S256 code_challenge registered at /oauth2/authorize + const pendingCodes = new Map() + + 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 + } + + // --- 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 = '' + 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 + } + + // 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) + + 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..de1f5c4d --- /dev/null +++ b/tests/sync-e2e.test.ts @@ -0,0 +1,265 @@ +/** + * 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 +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 () => { + 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, 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) + 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. 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).toMatch(/^mock-code-/) + + // 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(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() + 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..2d40cdb6 --- /dev/null +++ b/tests/sync-headless-e2e.test.ts @@ -0,0 +1,143 @@ +/** + * 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, ready } = startCallbackServer(state, 30000) + const port = await ready + 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..0b32f80d --- /dev/null +++ b/tests/sync-ledger-otlp.test.ts @@ -0,0 +1,375 @@ +/** + * 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, + deriveDeviceId, + 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) + }) + + // 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', () => { + 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) + }) + + // 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', () => { + it('returns 16 hex chars', () => { + const id = getDeviceId() + expect(id).toMatch(/^[0-9a-f]{16}$/) + }) + + 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 ────────────────────────────────────────────── + +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 + // 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 () => { + 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) + }) + + 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 new file mode 100644 index 00000000..0fe5c9bd --- /dev/null +++ b/tests/sync-push.test.ts @@ -0,0 +1,402 @@ +/** + * 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 + // 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 () => { + 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..5c512572 --- /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: 'https://idp.example', 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: 'https://idp.example' })) + .toThrow('missing required field: client_id') + }) + + it('defaults traces_path to /v1/traces when absent', () => { + 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: 'https://idp.example', client_id: 'y' }) + expect(doc.max_batch_size).toBe(1000) + }) + + it('defaults scopes to ["openid"] when absent', () => { + 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: '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') + 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, ready } = startCallbackServer(state, 5000, [0]) + + // Wait for server to bind + const port = await ready + + // 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, 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`) + 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, [0]) // 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') + }) +})