feat(auth): serve callers who send no token, without pretending they are a user (#458)
A deployment can set `github.anonymous_token` to one of its own GitHub tokens. Callers who send
NO `Authorization` header are then served as that account instead of refused, so a visitor can
convert a page before deciding to sign in. Unset — the default — nothing changes: a token is
required on every call.
The server half of #456. The demo page still shows the GitHub step; skipping it needs this
first and is a second, small PR, so #456 stays open and this carries no closing keyword.
Because a shared credential is a shared IDENTITY, and two things in Iris are keyed on identity:
- Session ownership is `github_user_id` and nothing else, so `GET /v1/sessions` would list every
anonymous visitor's documents to every anonymous visitor. It now refuses them with
`403 anonymous_session_list` — refused rather than filtered, since the request carries no
property separating one anonymous caller from another. Sessions stay reachable by id.
- `upload_per_minute` is keyed per user, so one shared id would be one bucket for the whole
internet. Anonymous requests key by address instead.
Only a request with NO header is served. `Bearer <expired>` still 401s: a client sending a
credential is trying to be someone, and quietly moving it into a shared account's session space
would land its uploads where it cannot list them. The credential itself is validated through
`GET /user` like any other. `GET /v1/me` answers `anonymous: true`, which is also how a client
detects the mode. Iris warns at every boot when the key is set, naming the consequences and
never the token.
Six review rounds, and every round's finding was created by the previous round's fix.
- **Round 1, blocking.** `req.anonymous` was keyed on the request SHAPE, but ownership
downstream is `github_user_id` alone — so presenting the anonymous account's own token as an
ordinary Bearer got `200` on `GET /v1/sessions` and listed a visitor's document to whoever
holds that token, with no server access. Reproduced before fixing. Now keyed on the IDENTITY
reached, and all four operator-facing places ask for a DEDICATED account and state the
residue. Also: a 403 while filing from an anonymous session blamed the GitHub App's
installation, because `usingServiceToken` was two-valued and `issue_token` is unset in that
case — there are three filing credentials, and `FilingCredential` is now threaded to both
filing sites.
- **Round 2.** The unresolvable-credential swallow charged every authenticated request an extra
uncached `GET /user` for the life of the process, because the likely cause (a mistyped config
value) never clears. Memoized on 401 only: a 401 is an answer, while a 403, a 5xx or a thrown
fetch is GitHub failing to give one. `fetchUser` throws with the status as a FIELD.
- **Round 3.** That memo was consulted only on the signed-in branch, so the half an outside
caller drives still paid per request. The flag became a TTL'd negative cache — not the
literal remedy, which would latch for the process and take anonymous access down until a
restart on one spurious `Bad credentials`.
- **Round 4, blocking.** The TTL renewed itself, so it was still that latch. The cached refusal
is raised as `userLookupError(401)` so the reply comes from one place, and the `catch` that
RECORDS rejections read the synthetic error as GitHub's answer: every cached refusal rewrote
the expiry to `now + TTL_MS`. Measured, window pinned 1s out: one request moved it 299,000 ms.
Fixed by deciding `cachedRejection` before the `try` and requiring `!cachedRejection` at the
write — an entry may only be written by the lookup that learned the answer.
- **Round 5.** Two comments pointed at symbols this branch removed.
- **Round 6.** No open findings, nothing withdrawn.
Every pin was measured red under a mutation rather than assumed: dropping the session-list
guard returns 200 with a shared-identity session in the body; widening the served shape from
`!header` to `!match` serves `Basic …`; dropping the anonymous filing branch prints the old
"Install the app on upstream_repo" hint verbatim; disabling the hoisted short-circuit reddens
the two cache tests plus the "GitHub was not asked" line.
Docs corrected wherever they said the opposite: README, config.example.yaml,
docs/github-auth.md, docs/API.md, docs/design-notes.md.
tsc --noEmit clean; npm test 1720/1720 (was 1711).
Co-Authored-By: bbertucc <46652+bbertucc@users.noreply.github.com>
Co-Authored-By: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>