ReviewOS

also looking at this

stacks/analyticshq

A read-scoped site key, so a Stacks dashboard can read analytics without a person's credentials

#68
Merged glennmichael123 wants to merge feat/read-scoped-site-key into main
7 files +550 -11
9 days ago

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
  • requireSiteRead must 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.

7 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.