mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-04 13:51:50 +00:00
Merge pull request #660 from Enclavet/feat/sync
Some checks are pending
CI / semgrep (push) Waiting to run
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:
commit
67f0df0020
22 changed files with 3807 additions and 0 deletions
13
README.md
13
README.md
|
|
@ -388,6 +388,19 @@ Run `codeburn` for the dashboard, or use a subcommand below. Most commands also
|
|||
| `codeburn export` | CSV covering today, 7 days, and 30 days |
|
||||
| `codeburn export -f json` | Export as JSON instead of CSV |
|
||||
|
||||
**Sync (team telemetry)** _preview_
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `codeburn sync setup <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
215
docs/sync/DEVELOPER.md
Normal 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
130
docs/sync/README.md
Normal 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 30–90 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
48
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
346
src/sync/auth.ts
Normal 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
327
src/sync/cli.ts
Normal 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
66
src/sync/config.ts
Normal 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
218
src/sync/credentials.ts
Normal 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
119
src/sync/discovery.ts
Normal 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
8
src/sync/index.ts
Normal 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
87
src/sync/ledger.ts
Normal 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
143
src/sync/otlp.ts
Normal 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
176
src/sync/push.ts
Normal 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
231
tests/fixtures/mock-idp.ts
vendored
Normal 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
265
tests/sync-e2e.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
143
tests/sync-headless-e2e.test.ts
Normal file
143
tests/sync-headless-e2e.test.ts
Normal 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
|
||||
})
|
||||
232
tests/sync-infra-e2e.test.ts
Normal file
232
tests/sync-infra-e2e.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
375
tests/sync-ledger-otlp.test.ts
Normal file
375
tests/sync-ledger-otlp.test.ts
Normal 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
402
tests/sync-push.test.ts
Normal 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 flatten→filter pipeline.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { createServer, type Server } from 'http'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import type { ParsedApiCall, TokenUsage, ProjectSummary } from '../src/types.js'
|
||||
import type { CallWithSession } from '../src/sync/otlp.js'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeUsage(): TokenUsage {
|
||||
return {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCall(key: string, costUSD = 0.01): ParsedApiCall {
|
||||
return {
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
usage: makeUsage(),
|
||||
costUSD,
|
||||
tools: [],
|
||||
mcpTools: [],
|
||||
skills: [],
|
||||
subagentTypes: [],
|
||||
hasAgentSpawn: false,
|
||||
hasPlanMode: false,
|
||||
speed: 'standard',
|
||||
timestamp: '2026-07-10T10:00:00.000Z',
|
||||
bashCommands: [],
|
||||
deduplicationKey: key,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCws(key: string, costUSD = 0.01): CallWithSession {
|
||||
return { call: makeCall(key, costUSD), sessionId: 'sess-1', project: 'proj-1' }
|
||||
}
|
||||
|
||||
/** Minimal mock OTLP server with scriptable responses per request. */
|
||||
type MockResponse = { status: number; body?: unknown; headers?: Record<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
260
tests/sync.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue