|
| 1 | +# CLAUDE.md |
| 2 | + |
| 3 | +Operating manual for **coverage-tracker**. Read this first every session. For the one-time convergence refactor, see `coverage-tracker-convergence-plan.md`; this file is the durable spec that stays true after it lands. |
| 4 | + |
| 5 | +## What this is |
| 6 | + |
| 7 | +A self-hostable, open-source (MIT) code-quality dashboard. **One instance per deployer, not multi-tenant.** A single Cloudflare Worker serves the SPA dashboard *and* the API from one apex domain, backed by D1. CI jobs push coverage/complexity metrics; the dashboard reads trends. |
| 8 | + |
| 9 | +## Architecture invariants |
| 10 | + |
| 11 | +- **One Worker, one `wrangler.jsonc`.** Static assets (`assets.directory` → `dist/`) and API live in the same Worker. Do not reintroduce a separate Pages project or a second Worker for the API. |
| 12 | +- **Routing:** `assets.run_worker_first = ["/api/*"]` — only `/api/*` hits the Worker first; everything else is asset-first with `not_found_handling = "single-page-application"` (SPA deep links serve `index.html`). |
| 13 | +- **Single D1 database** (`DB` binding) holds all repos' data. Free-tier cap is **500 MB/database**; the real ceiling is the **write limit** (~100k rows/day), not bytes. |
| 14 | +- **Two coverage tables:** |
| 15 | + - `coverage_runs` — raw per-commit rows, **pruned** after `RETENTION_DAYS` (14). Upsert on `(project_id, commit_sha)`. |
| 16 | + - `coverage_daily` — **permanent** last-of-day snapshots produced by the cron; the historical trend source. Survives the prune. |
| 17 | +- **Rollup is last-run-of-day**, not an average (`ROW_NUMBER() … ORDER BY ran_at DESC`). Idempotent: upsert + predicate delete, safe to re-run. |
| 18 | +- Stack: **Hono** (router), **jose** (JWT/JWKS), **zod** (validation). TypeScript only — no JS. |
| 19 | + |
| 20 | +## Auth model (per route) |
| 21 | + |
| 22 | +| Route | Edge (Cloudflare Access) | In-code | |
| 23 | +|---|---|---| |
| 24 | +| Dashboard SPA (`/`, `/dashboard*`) | **Access-protected** | — | |
| 25 | +| `/api/health` | none | none (public) | |
| 26 | +| `/api/ci/coverage` | none | GitHub Actions **OIDC** (jose, JWKS) | |
| 27 | +| `/api/webhooks/github` | none | GitHub App **HMAC** (`X-Hub-Signature-256`) | |
| 28 | +| `/api/projects/*` | none | **Cloudflare Access JWT** (`Cf-Access-Jwt-Assertion`, verify `aud`) | |
| 29 | + |
| 30 | +## Guardrails (do not violate) |
| 31 | + |
| 32 | +- **Never put a Cloudflare Access application on `/api/*`.** Machine callers (CI OIDC, webhooks, health) must reach the Worker unauthenticated at the edge. API auth is enforced in code. This is the single most important invariant. |
| 33 | +- **Never commit secrets.** Values are set with `wrangler secret put`. Code and `wrangler.jsonc` may reference names only: `GITHUB_OIDC_AUDIENCE`, `GITHUB_WEBHOOK_SECRET`, `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD`, `GITHUB_APP_*`. |
| 34 | +- **Don't hand-write the `Bindings`/`Env` type.** Run `wrangler types` after any `wrangler.jsonc` change. |
| 35 | +- **Don't use Workers Sites** (deprecated). Workers Static Assets only; requires Wrangler v4+. |
| 36 | +- **Don't make `coverage_daily` writes lossy on re-run.** All rollup writes are `ON CONFLICT … DO UPDATE`. |
| 37 | +- Don't widen retention or change rollup semantics without updating `RETENTION_DAYS` / the documented contract; both are single points of change. |
| 38 | + |
| 39 | +## Commands |
| 40 | + |
| 41 | +```bash |
| 42 | +# Dev |
| 43 | +npm run dev # wrangler dev (local assets + Worker) |
| 44 | +wrangler types # regenerate Bindings after config changes |
| 45 | + |
| 46 | +# Database |
| 47 | +wrangler d1 migrations apply coverage --local |
| 48 | +wrangler d1 migrations apply coverage --remote |
| 49 | +wrangler d1 execute coverage --local --command "SELECT ..." |
| 50 | + |
| 51 | +# Test (runs in the Workers runtime with real D1 bindings) |
| 52 | +npm test # @cloudflare/vitest-pool-workers |
| 53 | + |
| 54 | +# Deploy |
| 55 | +wrangler deploy --dry-run # validate before shipping |
| 56 | +wrangler deploy |
| 57 | +wrangler tail # live logs |
| 58 | + |
| 59 | +# Secrets (values never committed) |
| 60 | +wrangler secret put <NAME> |
| 61 | +``` |
| 62 | + |
| 63 | +## Conventions |
| 64 | + |
| 65 | +- **DB access** goes through prepared statements with bound params — no string interpolation into SQL. |
| 66 | +- **Validation** at the edge of every write route via zod; invalid → `422` with issues. |
| 67 | +- **Auth failures:** missing credential → `401`, present-but-invalid → `403`. |
| 68 | +- **Logging:** structured `console.log`/`console.error`; observability is enabled — keep it that way. |
| 69 | +- **The Hono catch-all** `app.all('*', c => c.env.ASSETS.fetch(c.req.raw))` must remain the last route so non-API requests reaching the Worker fall through to assets. |
| 70 | +- **Tests:** confirm `nodejs_compat` is in `wrangler.jsonc` directly — the vitest pool injects it and can mask a missing flag that then fails at deploy. |
| 71 | + |
| 72 | +## Gotchas |
| 73 | + |
| 74 | +- `Cf-Access-Jwt-Assertion`'s `aud` is **per Access application**. If the dashboard is ever split across multiple Access apps, the `/api/projects/*` middleware must accept an array of audiences. |
| 75 | +- The cron (`30 6 * * *` UTC) is the only thing that moves rows from `coverage_runs` to `coverage_daily`. If it stops firing, raw rows accumulate and the historical series stops advancing — check Observability if trends look stale. |
| 76 | +- D1 is single-threaded per database and bills on **rows scanned**. Keep `/api/projects/*` reads index-backed (`idx_runs_project_time`); avoid full scans. |
| 77 | +- A large `DELETE` in the prune is fine at current scale; if raw volume ever grows, batch deletes ~1,000 rows at a time to stay under execution limits. |
0 commit comments