refactor(identity): one source of truth for federation identity (#333, #326)#337
Merged
Conversation
…#326) Phase 0 of #326. SITE_URL / FEDI_HANDLE / FEDI_DOMAIN were read directly from process.env in 23 places and captured into module-level constants in 12 more. A module-level `const siteUrl = process.env.SITE_URL` is evaluated at import, so it can never observe a value resolved at runtime — which makes a DB-backed identity impossible until everything goes through one accessor. This is that accessor, still env-backed. Adds src/lib/identity.ts exposing the identity triple plus the values that MUST agree with each other: actorId, keyId and the WebFinger subject. site.config.ts now derives from it too, so there is exactly one derivation in the codebase. http-signatures.ts takes keyId from it directly rather than rebuilding the string. This surfaced a real bug, now fixed. The WebFinger route resolved identity on its own with `FEDI_DOMAIN || "localhost"`, while everything else derived the domain from SITE_URL. An instance that set SITE_URL but not FEDI_DOMAIN therefore advertised @you@yourdomain everywhere and answered WebFinger only for acct:you@localhost — a bare 404 to every remote lookup. Undiscoverable and unfollowable, with a site that looked entirely healthy from the inside and nothing logged anywhere. Two smaller divergences went with it: trailing slashes are stripped (an actor id differing by a slash is a different id to a remote server), and the domain fallback keeps a non-default port, matching what the setup wizard already writes — dropping it sent remote lookups to :443, which isn't us. Also gates unreachable addresses in the setup wizard. Finishing setup on localhost or a private range bakes an unfollowable identity into the actor id, and into every post published before moving, since Post.apId stores absolute URLs. Still permitted — testing locally is legitimate — but it now requires an explicit acknowledgement rather than a warning that can be walked past. Enforced server-side; the checkbox only surfaces it. update-profile.test.ts stubbed @/../site.config to inject the origin. Identity no longer comes from there, so it now sets the environment variable the accessor actually reads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Jul 23, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #333. Phase 0 of #326.
Why
SITE_URL/FEDI_HANDLE/FEDI_DOMAINwere read straight fromprocess.envin 23 places and captured into module-level constants in 12 more:A module-level constant can never observe a value resolved at runtime, so a DB-backed identity is impossible until every consumer goes through one accessor. This is that accessor — still env-backed, so resolution is unchanged.
What
src/lib/identity.tsexposes the identity triple plus the three values that must agree with one another:actorId,keyId,webfingerSubject.site.config.tsnow derives from it as well, so there is exactly one derivation in the codebase.http-signatures.tstakeskeyIdfrom it rather than rebuilding the string in two places.It found a real bug
The WebFinger route resolved identity on its own:
So on an instance with
SITE_URLset but noFEDI_DOMAIN:@you@yourdomain.comon every page, served a valid actor, and federated postsacct:you@localhostEvery lookup from Mastodon got a bare 404. Nobody could follow the instance, and nothing logged a problem — from the inside the site looked completely healthy. Exactly the silent-failure class #333 was filed to prevent, sitting in the code already.
Two smaller divergences fixed with it:
u.host). Dropping it sent remote lookups to:443, which isn't us.Also: unreachable addresses in the wizard
Setup could be completed on
localhostor a private range with only a warning. That bakes an identity nobody can follow into the actor id — and, becausePost.apIdstores absolute URLs, into every post published before the move.Still permitted (testing locally is legitimate), but it now takes an explicit tick — "I'm only testing locally" — rather than a warning you can walk straight past. Enforced server-side; the checkbox only surfaces it. Covers loopback,
10/8,172.16/12,192.168/16, link-local and IPv6 unique-local.Verification
npx tsc --noEmitnpx eslint .npx vitest runnpm run buildidentity.tsNew tests cover the invariant that matters —
keyIdstays anchored toactorId, and the WebFingerselfhref equals the actor id used for signing — plus the regression itself: a lookup that previously 404'd now resolves, and the bogusacct:me@localhostidentity is gone.update-profile.test.tsstubbed@/../site.configto inject an origin; identity no longer comes from there, so it now sets the environment variable the accessor actually reads.What this does NOT do
No DB overlay, no UI, no behaviour change to how values resolve (beyond the three divergences above, each deliberate). Phases 1–3 of #326 — the overlay, the change-domain
Move/alsoKnownAsflow, the admin UI — build on this.🤖 Generated with Claude Code