Merge pull request #660 from Enclavet/feat/sync
Some checks are pending
CI / semgrep (push) Waiting to run

feat: `codeburn sync` — push usage telemetry to a team backend (preview)
This commit is contained in:
Resham Joshi 2026-07-13 11:58:31 -07:00 committed by GitHub
commit 67f0df0020
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3807 additions and 0 deletions

View file

@ -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 <url>` | 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 |

215
docs/sync/DEVELOPER.md Normal file
View file

@ -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": "<SHA-256(hostname+username)[:16]>" } }
]
}
}
```
### 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.

130
docs/sync/README.md Normal file
View file

@ -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 <url>`
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 `<url>/.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 3090 days (set by your admin). You'll be prompted to re-login when it expires.
- **Re-login**: run `codeburn sync setup <url>` 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`.

48
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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()

346
src/sync/auth.ts Normal file
View file

@ -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<OidcConfig> {
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<string, unknown>
try {
body = await response.json() as Record<string, unknown>
} 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<CallbackResult>; ready: Promise<number>; server: Server } {
let resolvedPort = 0
let server: Server
let readyResolve!: (port: number) => void
let readyReject!: (err: Error) => void
const ready = new Promise<number>((resolve, reject) => {
readyResolve = resolve
readyReject = reject
})
const promise = new Promise<CallbackResult>((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('<html><body><h1>Login failed</h1><p>You can close this tab.</p></body></html>')
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('<html><body><h1>✓ Login successful</h1><p>You can close this tab.</p></body></html>')
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<TokenResponse> {
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<string, unknown>
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<TokenResponse> {
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<string, unknown>
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<void> {
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
}
}

327
src/sync/cli.ts Normal file
View file

@ -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 <url>')
.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 <url>` 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 <period>', '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 <url>` 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<string, string> = {
'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)
}
})
}

66
src/sync/config.ts Normal file
View file

@ -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<string, unknown>
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 */ }
}

218
src/sync/credentials.ts Normal file
View file

@ -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()
}

119
src/sync/discovery.ts Normal file
View file

@ -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<string, unknown>
// 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<CodeburnDiscoveryDoc> {
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)
}

8
src/sync/index.ts Normal file
View file

@ -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'

87
src/sync/ledger.ts Normal file
View file

@ -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<string> {
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
}

143
src/sync/otlp.ts Normal file
View file

@ -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
}

176
src/sync/push.ts Normal file
View file

@ -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<void>
/** 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<PushResult> {
assertHttps(opts.endpoint, 'Traces endpoint')
const log = opts.log ?? (() => {})
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>(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 }

231
tests/fixtures/mock-idp.ts vendored Normal file
View file

@ -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<void>
/** 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<MockIdp> {
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<string, string>()
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<void>((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
}

265
tests/sync-e2e.test.ts Normal file
View file

@ -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()
})
})

View file

@ -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
})

View file

@ -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<string, unknown>
// 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
})
})

View file

@ -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<ParsedApiCall> & { 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<ParsedApiCall> & { 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/)
})
})

402
tests/sync-push.test.ts Normal file
View file

@ -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 flattenfilter 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<string, string> }
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()
})
})

260
tests/sync.test.ts Normal file
View file

@ -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')
})
})