EqualifyEverything / equalify-iris

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>
Blake Bertuccelli-Booth Blake Bertuccelli-Booth committed on Sep 11, 2026, 01:53 PM
Showing 19 changed files +1310 additions -64 deletions
Browse files at this commit →