# Self-hosted audit API — design

Date: 2026-08-09 · Linear: ELEVA-311 · Status: approved

Expose `headless-seo-check` as a private HTTP service on our own server
("169"), so that our sites — and only our sites — can request an audit and
read the JSON report. Authentication is enforced by nginx with one key per
site; the CLI binary is not modified in any way.

## Architecture

```
our sites ──Bearer key──> nginx (TLS, :443) ──X-HSC-Site──> Bun service (127.0.0.1:8169)
                                                                 └─> spawns headless-seo-check <target> --json …
```

Three parts, each replaceable on its own:

1. **nginx** owns TLS, authentication and rate limiting. The service never
   sees an unauthenticated request.
2. **`selfhost/server.ts`** (Bun, zero npm dependencies) translates one HTTP
   request into one run of the existing binary and maps its exit code to an
   HTTP status. It binds to `127.0.0.1` only.
3. **The binary** is the released `headless-seo-check`, unchanged. Its JSON
   document — contract §4 — is the response body, verbatim.

## Authentication — nginx only

- One key per site, format `k_<site>_<48 hex>` (`make-key.sh`, backed by
  `openssl rand -hex 24`).
- `map $http_authorization $hsc_site { default ""; "Bearer k_elevaseo_…" "elevaseo"; }`
  in `/etc/nginx/conf.d/hsc-keys.conf`. Rotation or revocation is one line
  and `nginx -s reload`.
- Empty `$hsc_site` → `401` returned by nginx; the service is never reached.
- nginx forwards `X-HSC-Site: $hsc_site`. The service trusts this header
  because nothing but nginx can reach the loopback port. It strips any
  client-supplied value of that header (`proxy_set_header` overwrites).

## Key → target binding

`selfhost/keys.json` maps each site name to the origins its key may audit:

```json
{ "elevaseo": ["https://www.elevaseo.com"], "client2": ["https://client2.example"] }
```

A request whose target origin is not in the authenticated site's list is
refused with `403`. A leaked key therefore cannot turn the server into an
open audit proxy that hits arbitrary hosts with Googlebot's User-Agent.
Comparison is on the origin (scheme, host, port) after URL normalisation.

## API

`POST /audit` — JSON body:

| field | type | maps to |
| --- | --- | --- |
| `site` | string, required | the positional argument (must pass the key→target check) |
| `sample` | int ≥ 1 | `--sample N` |
| `all` | bool | `--all` |
| `urls` | string[] | `--urls a,b,c` |
| `allow_dynamic` | bool | `--allow-dynamic` |
| `no_verify` | bool | `--no-verify` |

The service adds `--json` and nothing else; the CLI surface stays the single
source of truth. Unknown fields are rejected (`400`), as are `sample`+`all`
together (the binary would refuse them anyway; failing early gives a better
message).

Responses:

| condition | status | body |
| --- | --- | --- |
| binary exit 0 or 1 | `200` | the report, verbatim (it carries `exit_code` and `summary.blocking`) |
| binary exit 2 | `422` | the fatal document, verbatim (still valid JSON, per contract §4.6) |
| target not allowed for this key | `403` | `{"error":"target not allowed for this key"}` |
| malformed body / unknown field / bad combination | `400` | `{"error":…}` |
| more than 2 audits already running | `429` | `{"error":"busy"}` + `Retry-After` |
| binary ran past 180 s | `504` | `{"error":"audit timed out"}` (process killed) |

`GET /healthz` → `200 {"ok":true}`, no auth (nginx allows it through without
a key so uptime monitoring works).

Concurrency: a counting semaphore of 2. The binary already caps itself at 6
connections per run; running many audits in parallel would multiply that
against the audited sites.

## nginx configuration (shipped as `nginx.conf.example`)

- TLS server block (certbot-ready), `location /audit` → `proxy_pass
  http://127.0.0.1:8169`, `proxy_read_timeout 200s` (above the service's
  180 s kill), request body limit 16 KB.
- `limit_req_zone $hsc_site zone=hsc:1m rate=10r/m; limit_req zone=hsc burst=5;`
- `location /healthz` proxied without the key check.

## Deliverables (`selfhost/` in this repository)

| file | role |
| --- | --- |
| `server.ts` | the whole service (~200 lines), Bun only |
| `server.test.ts` | `bun test`, using a fake binary (shell script replaying known outputs) — request→flags translation, status mapping, key→target check, semaphore, timeout |
| `keys.json.example` | site → allowed origins |
| `nginx.conf.example` + `hsc-keys.conf.example` | reverse proxy and key map |
| `hsc.service` | systemd unit — dedicated `hsc` user, `Restart=on-failure`, env `HSC_BIN`, `HSC_KEYS`, `HSC_PORT` |
| `make-key.sh` | prints a new `k_<site>_<hex>` and the nginx map line to paste |
| `README.md` | install runbook: build (musl or on-box cargo), bun via mise, systemd, certbot, key creation, first audit with curl |

## Out of scope (deliberate)

No persistent job queue, no webhooks, no dashboard, no multi-tenancy beyond
keys, no `--serve` mode in the Rust binary. Scheduled audits are a cron job
calling this API — nothing to build.

## Error handling & operational notes

- The service logs one line per request (site, target, flags, status,
  duration) to stdout — journald captures it.
- If the binary is missing or not executable at startup, the service exits
  non-zero immediately (systemd surfaces it) rather than failing on the
  first request.
- `keys.json` is re-read on SIGHUP; malformed JSON keeps the previous
  in-memory version and logs the error.

## Testing strategy

All tests run offline with a fake `HSC_BIN`. The fake replays: an exit-0
report, an exit-1 report, an exit-2 fatal, a hang (for the timeout test,
with a short `HSC_TIMEOUT_MS` override), and an argv echo (to pin the exact
flags the service builds). Real-binary smoke test documented in the README
(one curl against the fixture of `scripts/real-runs.sh`), not automated.
