Closes #67.
The problem
Every read resolves a signed-in user, and the only issuable bearer belongs to a human: full scope, dies when they leave, wrong shape for a server-to-server read from an app's config.
As the issue notes, this one differs from loghq and bughq: there was no per-site credential to extend. The tracker is a public script beaconing to a site id, so this is a new credential rather than a second mode on an existing one.
What this adds
A site can issue a read key: one site, reads only, no account behind it. 256 bits, prefixed ahq_read_, compared in constant time, shown once when issued, revocable with a last-used stamp written at most once a minute.
Issued and revoked by an admin at POST / DELETE /api/sites/{siteId}/read-key.
Scope — the question the issue asked
Worth settling that list on this issue rather than exposing all forty-odd
/api/sites/{siteId}/*routes to a machine key by default. My suggestion is to start with the summary, the time series, and the top referrers/pages breakdowns, and add on demand.
Implemented as exactly that starting set, and pinned by a test so it cannot drift:
/api/sites/{siteId}/stats/api/sites/{siteId}/timeseries/api/sites/{siteId}/pages/api/sites/{siteId}/referrers
Everything else still requires a person. Adding one is a single line — requireSiteRead instead of requireSiteRole — deliberately.
If that list is wrong, this is the place to say so; it is four lines to change.
The part that needs review
Those four routes drop .middleware('auth'). That middleware 401s before the handler runs, so it would refuse a read key before the handler could read it.
I don't think anything is lost. The middleware was defence in depth behind requireSiteRole, which does the real check and already answers 401 when no credential resolves; the route's own docblock says as much. requireSiteRead falls back to exactly that viewer check for a person, so the human path is unchanged.
The guardrails in tests/unit/api-authz.test.ts went red on this, correctly — they assert every site-scoped read contains requireSiteRole(..., 'viewer') and .middleware('auth'). I did not loosen them. They now assert more than before:
- the four key-readable routes are pinned by name
- no other route may use the new gate, so a machine key cannot spread silently
requireSiteReadmust still fall back to the viewer check, and must only accept something shaped like a key
That last set is the real protection against this change going wrong later.
Smaller notes
Site ids are public — they ride in the tracking snippet — so unlike loghq and bughq, 404 vs 403 leaks nothing here, and the refusals reuse requireSiteRole's own wording so a refusal reads the same whichever credential was presented.
The migration follows the house style note: no semicolons in comments, since the runner splits on them without stripping comment lines.
Checks
16 new unit tests on the authorizer. Full unit suite 1263 pass / 0 fail, including the repo's "no source file types anything as any" rule. Lint clean.
Not included
The settings UI. The credential and its API are what unblocks stacksjs/stacks#2915. Happy to add the panel in a follow-up, or here if you'd rather it land together.