Closes #14.
The problem
Every read endpoint authenticated a person. app/Support/request-auth.ts resolved a bearer through Auth.getUserFromToken, so the only credential that worked carried that account's full scope and died when they left. The one machine credential, ingest_key, is write-only by design.
A Stacks dashboard reading this project server-to-server had nothing correct to hold.
What this adds
A project can issue a read key: one project, reads only, no account behind it.
It is treated as a secret, which the ingest key beside it deliberately is not:
| ingest key | read key | |
|---|---|---|
| purpose | write to one project | read one project |
| secrecy | public, ships in client bundles | secret, server config only |
| compared | === | constant time |
| shown | in the project listing | once, when issued |
| size | 128 bits | 256 bits, lhq_read_ prefixed |
The prefix does two jobs: secret scanners can match it, and a read key is told apart from a user's token before either is verified — so a read key is never handed to Auth.getUserFromToken.
Constant-time comparison is not ceremony here. === on strings short-circuits at the first differing byte, which leaks the length of a correct guess to anyone who can time the response. That is an attack on a secret somebody can guess at repeatedly, and not one on a public ingest key — which is why only this side does it.
Endpoints
Exactly the two the issue names:
GET /api/projects/{projectId}/logs, filters unchangedGET /api/logs/{logId}
Issued and revoked by the owner at POST / DELETE /api/projects/{projectId}/read-key, beside the existing rotate-key.
Two things worth review
A key for another project answers 404, not 403, matching what ownsProject already does: a caller who cannot read a project should not learn it exists.
GET /api/logs/{logId} reads the entry before authorising, then authorises against that entry's project. Doing it the other way — 404 for a missing id before checking the caller — would let an unauthenticated caller discover which log ids are real. With an empty project id for a missing entry, which neither a key nor a user can read, an unauthenticated caller gets 401 either way.
Cost
The last-used stamp is written at most once a minute per key, not per request: the dashboard polls the stream, and an UPDATE in front of every poll would cost more than the stamp is worth.
Checks
18 new unit tests on the authorizer, covering the shapes a bearer header might carry, a project with no key issued, another project's key, a hand-edited column, and the stamp's resolution window. Full unit suite 297 pass / 0 fail, lint clean.
Not included
The settings UI. The credential and its API are what unblocks stacksjs/stacks#2915; issuing a key today is a POST as the owner. Happy to add the settings panel beside the ingest key in a follow-up, or here if you'd rather it land together.