REST API & GitHub App auth
miucr serve can run as a small deployable service: an authenticated JSON
REST API for queuing and reading reviews, plus opt-in GitHub App installation
auth as an alternative to a static PAT. Both are opt-in; the default serve
path (HMAC webhook + PAT, see Serve & Action) is unchanged.
Scope: single-operator. The REST API is gated by one shared bearer token. That bearer is one trust boundary: whoever holds it owns every review the service has handled. This is not a multi-tenant SaaS: there is no per-user isolation, no per-review authorization beyond “holds the bearer”, and no tenant column. Run it as your own single-operator service; do not hand the bearer to mutually-distrusting parties. See Threat model below.
REST API
Section titled “REST API”Enable the API by setting MIUCR_API_TOKEN in the environment and wiring a
store (the default SQLite store is wired automatically). Without MIUCR_API_TOKEN
the /v1 routes are not registered at all; serve stays webhook-only.
MIUCR_API_TOKEN=$(openssl rand -hex 32) \WEBHOOK_SECRET=… GITHUB_TOKEN=… ANTHROPIC_API_KEY=… \ miucr serve --addr :8080 --repos owner/repoThe bearer is env-only (like WEBHOOK_SECRET); there is intentionally no
flag, so it never lands in argv / ps / shell history.
Endpoints
Section titled “Endpoints”| Method | Path | Auth | Behaviour |
|---|---|---|---|
POST | /v1/reviews | bearer | Queue a review. Returns 202 + a server-generated id. |
GET | /v1/reviews/{id} | bearer | Read the persisted review record (whitelisted fields). |
GET | /healthz | none | Liveness probe (unchanged). |
POST | /webhook | HMAC | The existing webhook receiver (unchanged). |
POST /v1/reviews
Section titled “POST /v1/reviews”curl -sS -X POST https://your-host/v1/reviews \ -H "Authorization: Bearer $MIUCR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"owner":"acme","repo":"widgets","number":42}'The body is {owner, repo, number} (the PR number). The server:
- Validates the body (
owner,reponon-empty,number > 0) → else400. - Checks the
--reposallowlist → off-allowlist is an explicit403(unlike the webhook’s silent200-ignore). - Generates the review id with
crypto/rand; the id is never client-supplied. - Persists a
pendingrecord under that id, then enqueues the review onto the same bounded worker pool the webhook uses. - Two branches from the enqueue:
- Enqueue rejected (queue full or job coalesced) → the record is flipped to
failedand the endpoint returns503(queue.full) so the client retries. - Enqueue accepted → returns
202with the id, in themiucr.cli/v1envelope:
- Enqueue rejected (queue full or job coalesced) → the record is flipped to
{ "ok": true, "api_version": "miucr.cli/v1", "kind": "review.accepted", "command": "reviews", "data": { "id": "9f2c…", "status": "pending" }, "artifacts": [], "warnings": []}GET /v1/reviews/{id}
Section titled “GET /v1/reviews/{id}”curl -sS https://your-host/v1/reviews/9f2c… \ -H "Authorization: Bearer $MIUCR_API_TOKEN"Reads the record back. The data block is a whitelist
(id, status, created_at, findings, stats) and deliberately omits the clone
path (RepoDir, a host /tmp path) and any other host-revealing field:
{ "ok": true, "api_version": "miucr.cli/v1", "kind": "review.result", "command": "reviews", "data": { "id": "9f2c…", "status": "done", "created_at": "2026-06-22T07:10:00Z", "findings": [], "stats": {} }, "artifacts": [], "warnings": []}An unknown id is a 404.
Status lifecycle
Section titled “Status lifecycle”status | Meaning |
|---|---|
pending | Queued; the worker has not finished. findings/stats are empty. |
done | The review finished; findings/stats are populated. |
failed | The review errored, or a stuck pending row aged past the review timeout, or the job could not be enqueued at submit time (worker queue full/coalesced → 503), in which case the record is failed at creation, never having been attempted. |
The worker persists the final record under the same id when the review
finishes (done with findings/stats, or failed on error). Stuck-pending
recovery: if a worker crashes mid-review, a later GET that finds a pending
row older than the review timeout lazily flips it to failed, so a crash
never leaves an eternal pending.
HTTP error map
Section titled “HTTP error map”| Status | When |
|---|---|
400 | Malformed/invalid JSON body; missing owner/repo/number. |
401 | Missing or wrong bearer (see auth below); empty configured token. |
403 | Target repo not in --repos. |
404 | GET of an unknown review id. |
405 | Wrong method on a /v1 route. |
413 | Request body over the 64 KB cap. |
500 | Internal error (e.g. token/store unavailable, id generation failed). |
503 | Worker queue full or the job was coalesced; the just-persisted record is flipped to failed and the client should retry. |
GitHub App installation auth (opt-in)
Section titled “GitHub App installation auth (opt-in)”By default serve authenticates to GitHub with a PAT (GITHUB_TOKEN). The
[github] config section opts into GitHub App installation auth instead, so
miucr can act as the App across an operator’s installation:
[github]mode = "app"app_id = "123456"installation_id = "78901234"private_key_path = "/etc/miucr/app-key.pem"| Key | Required (App) | Notes |
|---|---|---|
mode | no | pat (default) or app. Anything but app keeps PAT mode. |
app_id | yes | The numeric GitHub App ID (the JWT iss). |
installation_id | yes | The numeric installation id (the App’s installation URL). |
private_key_path | yes | Path to the App private-key PEM (never inline PEM). |
How it works
Section titled “How it works”- Mint an App JWT. miucr signs a short-lived RS256 JWT
(
crypto/rsaSignPKCS1v15+crypto/sha256; PKCS#1 or PKCS#8 keys viacrypto/x509;base64RawURL segments).issis the app id,iatis back-dated ~60 s for clock skew, andexpis ~9 min (GitHub rejects > 10 min). No JWT library / no new module. - Exchange for an installation token via go-github’s
Apps.CreateInstallationToken. - Cache it in-memory with refresh-before-expiry (~5 min margin) and
single-flight (one in-flight mint per installation, so a refresh can’t
stampede GitHub). The installation token is just a bearer; it flows through
the existing
WithAuthTokenunchanged; nothing else in the review path moves.
Installation tokens live in memory only: never persisted, never logged, never in the envelope. They are lost on restart and re-minted on demand.
Threat model
Section titled “Threat model”This service is an authenticated HTTP daemon that may hold an RSA private key and mint GitHub tokens, so the boundaries are explicit.
Single-operator, one bearer
Section titled “Single-operator, one bearer”- One shared bearer = one trust boundary. Anyone with
MIUCR_API_TOKENcan queue reviews for any allowlisted repo and read every stored review. There is no per-user / per-tenant isolation and no per-review authorization. Treat the bearer like a root credential for the service. - Server-generated ids only. The review id is
crypto/rand; a client can never choose an id, so it can’t probe or collide with another id by guessing. (This is not multi-tenant isolation; a holder of the bearer can still read any id it learns. It removes the forgeable-id class, not the shared-bearer scope.)
Authentication
Section titled “Authentication”- Bearer is env-only (
MIUCR_API_TOKEN); mirrorsWEBHOOK_SECRET; a flag would leak viaargv/ps/ history. - Empty token can never authenticate. The middleware checks
len(configured token) == 0 → 401before the constant-time compare, because an empty-vs-emptysubtle.ConstantTimeComparereturns equal. With no token configured the/v1routes are not even registered. - Constant-time compare (
subtle.ConstantTimeCompare) on the bearer; a strict case-insensitiveBearerscheme parse (a partial/odd scheme →401, never a partial match).
Secrets handling
Section titled “Secrets handling”- Private key is path-only. It is read at startup, parsed, and the raw PEM
bytes are zeroed. It is never inline in config, never logged, never in the
envelope. (
config.RedactStringcannot mask a multi-line PEM, so the key must never become a config/log value at all.) - Installation tokens are in-memory only: never persisted/logged/enveloped.
- The GET envelope is a whitelist:
RepoDir(the host/tmpclone path) and other host paths are never exposed. Every serve-side error string is funneled throughconfig.RedactString.
Request hardening (shared with the webhook)
Section titled “Request hardening (shared with the webhook)”- Body cap via
MaxBytesReader(64 KB for the JSON API); an oversized body maps to413viaerrors.As(*http.MaxBytesError). - Method + path guards on every
/v1route (wrong method →405). - Explicit allowlist 403 off the
--reposallowlist. - A panic in any review is recovered on the worker; it never kills a worker or the daemon.
Live smoke (manual, key-gated)
Section titled “Live smoke (manual, key-gated)”A //go:build live smoke verifies the real App-auth path (mint JWT → installation
token) end-to-end. It is excluded from default builds and CI and is skipped
unless the App envs are set:
MIUCR_LIVE_APP_ID=123456 \MIUCR_LIVE_APP_INSTALL_ID=78901234 \MIUCR_LIVE_APP_KEY_PATH=/path/to/app-key.pem \ go test -tags live -run TestLiveAppInstallationToken ./internal/github/...Never run the live smoke in CI and never paste a real key, token, or bearer into a test, fixture, doc, or commit.