Associate each instance with a subdomain - #83
Merged
Merged
Conversation
DrFed needs to derive per-instance subdomains from a configurable root origin, which means the root origin has to carry a scheme and a port as well as a host name. Neither of Optique's built-in domain() nor url() value parsers expresses that: domain() drops the scheme and the port, while url() accepts paths, queries and credentials that an origin has no room for. This adds a hand-written origin() value parser to @drfed/drfed. It normalizes rather than rejects, reducing any absolute URL down to its origin, so HTTPS://Example.COM, https://example.com/, https://example.com/path?q#f and https://user:pw@example.com/ all parse to the same https://example.com. DrFed does not support mounting under a sub-path, so there is nothing meaningful to preserve past the origin. Input that is not an absolute URL is rejected, as is any URL whose protocol falls outside allowedProtocols or has no tuple origin, such as mailto: and data:. Nothing consumes origin() yet; a later commit replaces the current --root-domain option with a required --root-origin built on it. Since @drfed/drfed had neither an exports field nor a test script, this also adds both, so that the parser can be tested through the @drfed/drfed/valueparser subpath export the way the other packages do. AI provenance: I asked Claude Code (Opus 5) to design and implement a custom Optique value parser for root origins, and to decide how strictly it should treat URLs that carry more than an origin. Claude wrote the parser, the tests and the package metadata; I chose normalization over rejection, since an origin option has no use for a path. Claude Code (Fable 5) then reviewed the result and found that malformed allowedProtocols entries missing their trailing colon were accepted at construction and then rejected every valid input, so a guard mirroring Optique's own url() parser was added along with tests for it. Codex (GPT-6 Astra) also reviewed and reported no findings. I verified the result with mise run check, mise run build and mise run test. fedify-dev#77 Assisted-by: Claude Code:claude-opus-5 Assisted-by: Claude Code:claude-fable-5
An instance's slug becomes the leftmost label of its host name, so an
instance with the slug foo-bar is served at foo-bar.<root domain>. The
check constraint did not hold slugs to that, though: ^[a-z0-9-]{4,63}$
admits -foo and foo-, which no resolver accepts, and ab--cd, which RFC
5891 section 4.2.3.1 reserves.
The new rule anchors both ends on a letter or a digit and rejects the
reserved LDH labels, with one deliberate exception. The xn-- prefix
stays allowed, so that an instance can carry an internationalized domain
name; DrFed exists to debug federation, and IDN host names are one of
the things that break it.
The rule now lives in two places. isValidSlug() in the new
@drfed/models/slug module is what application code will call -- a later
commit has createInstance reject a bad slug with a proper error instead
of letting the constraint violation escape as an unhandled query error
-- and the constraint itself remains the backstop. Nothing keeps the
two in agreement automatically, so a test migrates a real PGlite
database and asserts that it accepts and rejects exactly what
isValidSlug() does.
The constraint is also renamed from instances_slug_check to
local_instances_slug_check, matching the table it has been attached to
since the local and remote instance tables were separated. The
migration rewrites no rows; a database holding a slug the new rule
rejects will fail to migrate and has to be corrected by hand, which for
now can only happen to a local development database.
fedify-dev#77
AI provenance: I asked Claude Code (Opus 5) to tighten the slug rule to
the DNS label grammar and to keep the TypeScript and SQL halves in
agreement, and I decided that xn-- should be permitted rather than
blocked, since IDN support is worth more here than the marginal
homograph risk on throwaway development instances. Claude wrote the
module, the schema change, the migration and the tests. Codex (GPT-6
Astra) reviewed and differential-tested the two regexes over 1,929
inputs without finding a divergence. Claude Code (Fable 5) reviewed and
pointed out that no committed test exercised the SQL half at all, so the
database-backed test above was added in response. I verified the result
with mise run check, mise run build and mise run test, and separately
confirmed that the migration applies to a fresh PGlite database.
Assisted-by: Claude Code:claude-opus-5
Assisted-by: Claude Code:claude-fable-5
instances.host was varchar(100), which is not enough for what the column actually holds. Locally it holds `<slug>.<root domain>`, and a slug may be 63 characters on its own, so a root domain of 37 characters already overflows it; remote hosts discovered from the fediverse can be longer still. An overflow surfaced as a raw "value too long" error rather than anything a caller could act on. The column now holds 259 characters: a DNS name is at most 253 octets, and the value is an authority rather than a bare host name, so it may carry a `:port` suffix of up to 6 more characters. That matters because the authority is what Fedify's Context.host reports, and the federation dispatchers look instances up by it; an upcoming commit replaces --root-domain with a --root-origin that can name a port, at which point hosts such as foo-bar.drfed.localhost:8888 start being stored. The migration only widens the column, so no data is rewritten or lost and the unique constraint is unaffected. fedify-dev#77 AI provenance: this commit exists because Claude Code (Fable 5), while reviewing the preceding slug change, noticed that a maximum-length slug could overflow the host column; I decided to fix it here rather than defer it, since the surrounding work is already about composing these host names. Claude Code (Opus 5) made the schema change and generated the migration. Codex (GPT-6 Astra) reviewed it and verified against PGlite that earlier migrations still replay, existing rows survive, uniqueness holds and the migration is safe to re-run. Claude Code (Fable 5) then observed that 253 would be too small once hosts carry ports, which is exactly where this series is heading, so the bound was raised to 259 before committing. I verified the result with mise run check, mise run build and mise run test, and confirmed against PGlite that a 259-character authority is accepted and a 260-character one is rejected. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5
DrFed serves each instance from its own subdomain of a configurable root
domain, which means two things have to be decided consistently in
several places: what authority an instance is federated under, and what
a request should be served from given the authority it arrived on.
This adds those decisions as pure functions in @drfed/graphql/origin,
ahead of the commits that consume them. instanceHost() and
instanceOrigin() compose an instance's authority from a root origin and
a slug, carrying a non-default port along, because that authority has to
equal Fedify's Context.host for the dispatchers to find the instance at
all. classifyHost() sorts an incoming authority into one of three:
instance exactly one label below the root domain. An instance
nobody has created still lands here, and ends in a 404
once every dispatcher resolves to nothing.
misdirected under the root domain but deeper than one label, so it
can never name an instance.
admin everything else: the root origin, the listening socket,
an internal name a reverse proxy uses.
The scheme is deliberately not compared, since a deployment behind a
TLS-terminating proxy sees plain HTTP requests while its root origin is
HTTPS. The port is compared, because it is part of the authority, but
through a canonicalization that reads both 80 and 443 as no port -- URL
elides a port only when it is the default for that URL's own scheme, so
comparing the raw values would have let the scheme back in through the
side door and put the control surface on a tenant's authority. Host
names are likewise compared with the root zone's trailing dot stripped,
since example.com. and example.com name the same host but the URL parser
keeps the dot.
fedify-dev#77
AI provenance: I asked Claude Code (Opus 5) to extract the authority
composition and request classification rules into testable functions,
having already decided the three-way classification and that the scheme
must be ignored while the port must not. Claude wrote the module and
the tests. Both reviewers then found real holes in the comparison:
Codex (GPT-6 Astra) showed that a trailing root-zone dot made a tenant
subdomain classify as the control surface, and Claude Code (Fable 5)
showed that comparing URL.port raw reintroduced the scheme, so a Host
header naming port 443 forwarded over plain HTTP did the same thing.
Both were fixed with regression tests, along with a missing test for the
empty-label case. A related mismatch -- a dotted request now classifies
as an instance but misses the dispatcher lookup, because Context.host
keeps the dot -- is recorded for the routing commit, where the request
URL is the right place to canonicalize. I verified the result with mise
run check, mise run build and mise run test.
Assisted-by: Claude Code:claude-opus-5
Assisted-by: Codex:gpt-6-astra
Assisted-by: Claude Code:claude-fable-5
The root domain a deployment serves instances under was an optional --root-domain, and when it was omitted @drfed/graphql quietly filled in "drfed.org". Installed software has no business defaulting to the project's own domain: an operator who forgot the option would hand their users subdomains of somebody else's zone, with nothing said about it. It is required now, and the old name is gone rather than aliased, since 0.1.0 has not been released. A domain was also the wrong shape. An instance has to be found by the authority requests arrive on, which is a scheme, a host and a port -- not a host alone. Without the port, a development server on :8888 composed foo-bar.drfed.localhost while Fedify reported foo-bar.drfed.localhost:8888, so no instance was ever found and actor URIs came out with a hardcoded https: that pointed nowhere. So the option is --root-origin, parsed by the origin() value parser added earlier in this series, and ServerContext carries a URL rather than a string. IP addresses are refused there: every instance is a subdomain of this origin, and foo-bar.127.0.0.1 is not a host name -- it is not even a URL that parses. Two consequences beyond the rename. createInstance composes through instanceHost(), so a non-default port reaches instances.host. And generateActors now reads that stored host instead of recomposing it from the slug, taking its scheme from the root origin, so actor URIs cannot drift from the instance the rest of the fediverse already knows. For development, mise run dev reads DRFED_ROOT_ORIGIN from the same .env the server is started with, falling back to http://drfed.localhost:8888. Any subdomain of localhost resolves to the loopback address with no DNS or /etc/hosts setup, which is what makes per-instance subdomains usable locally at all. fedify-dev#77 AI provenance: I asked Claude Code (Opus 5) to carry the root origin through every layer in one commit, having already decided that the option must be required, that it must be an origin rather than a domain, and that drfed.localhost is the development default. Claude made the change and wrote the tests. Codex (GPT-6 Astra) reviewed over three rounds and found that IP-literal origins were accepted although they cannot take a subdomain, that the resulting check was bypassable by a blob: URL whose hostname is empty while its origin carries the authority, and that neither the CLI contract nor the actor URI change was covered by a test that could fail. Claude Code (Fable 5) then found that the test added for the retired option passed for the wrong reason. All were fixed; the two behavioural tests were mutation-tested by restoring the old behaviour and confirming they fail. A related defect of the same class -- emailFrom defaulting to noreply@drfed.org -- was raised by Fable and is being fixed in the next commit. I verified the result with mise run check, mise run build and mise run test, and by running the built binary to confirm the option is required, rejects a non-HTTP scheme and an IP address, and leaves schema generation usable with no deployment configuration. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5
emailFrom had the defect the previous commit removed from the root domain: it fell back to noreply@drfed.org, runServer never passed anything else, and no option existed to. So every installed deployment sent its login mail claiming to be drfed.org, through the operator's own SMTP server, which drfed.org's SPF and DMARC records do not authorize. Receiving hosts reject or junk that, which breaks signing in outright, and whatever does arrive is misattributed to the project. There is now a --email-from option, and when it is omitted the address is derived from the deployment's own root origin instead, as noreply@<host>. The host name, not the authority: a development server on :8888 must not claim to be noreply@drfed.localhost:8888. Two normalizations came out of this. A root origin may be written with the root zone's trailing dot, and drfed.example. is not a valid email domain, so origin() now strips that dot while normalizing, as it already did for case, trailing slashes, paths and credentials. canonicalHostname() in @drfed/graphql/origin, which applied the same rule internally, is exported so the derivation can use it for callers that build a URL directly rather than going through the CLI. And a host name longer than 253 characters is refused outright, since Upyo will not build a message whose sender domain exceeds that; a configuration mistake belongs at startup, next to the option at fault, not inside every login request. fedify-dev#77 AI provenance: this commit exists because Claude Code (Fable 5), while reviewing the previous one, noticed that emailFrom defaulted to the project's own domain; I decided on an optional flag with a derived default rather than a required one. Claude Code (Opus 5) made the change and wrote the tests. Codex (GPT-6 Astra) found the trailing-dot case, and also claimed a 253-character host name made Upyo throw; I tested that and reported it as false, which was itself wrong -- 253 is exactly the last accepted length, so I had landed on the boundary rather than disproved the concern. Claude Code (Fable 5) caught the off-by-one, and the guard above is the result. Fable also noticed that nothing verified the port stays out of the address. A related defect of the same class in DRFED_LOGIN_ORIGINS is filed separately as fedify-dev#81. I verified the result with mise run check, mise run build and mise run test, and confirmed the Upyo length boundary directly. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5
Every host served both of DrFed's faces. An instance's subdomain answered /graphql, and the root origin answered ActivityPub paths -- 404, but only because no instance happened to claim that host. The separation the subdomains exist for was not actually enforced anywhere. Requests are now routed by authority alone. A subdomain one label below the root origin is an instance and serves ActivityPub only; the root origin and every other authority serve the control surface and never answer as an instance. Anything under the root domain but deeper than one label can never name an instance, so it is answered 421 rather than a bare 404, which tells whoever misconfigured the DNS something useful. The listening socket and internal proxy names fall on the control side deliberately: the frontend reaches the backend at 127.0.0.1, naming no instance at all. Routing asks the database nothing. A subdomain nobody has claimed still goes to ActivityPub, where every dispatcher resolves to nothing and the request ends in a 404 of its own. Two things the authority may be spelled with needed care. A server adapter can hand over a host that `URL` refuses -- srvx checks the `Host` header against a pattern and builds the request URL by concatenation, so `1.2.3.4.5` and undecodable A-labels arrive intact -- and parsing one threw where nothing was waiting to catch it, so a single unauthenticated request ended the process. An adapter may also substitute for a host it cannot read, as srvx does with `_invalid_`, which would have handed the control surface to a request that named a tenant. Both are answered 400, per RFC 9110 section 7.2. Going the other way, a reverse proxy may forward an authority that names an instance without matching its stored host: `Host: demo.drfed.net:443` forwarded over plain HTTP against a stored `demo.drfed.net`. The dispatchers now canonicalize before looking the instance up, so those resolve instead of 404ing. The canonicalization is applied to the lookup key and not to the request, so nothing in the signature verification path sees a synthesized request. Finally, changing a deployment's root origin strands any instance already created under the old one. Startup now says so. It does not repair anything: an instance's host is woven into actor URIs the rest of the fediverse has already stored, so rewriting it would break the federation it was meant to fix. fedify-dev#77 AI provenance: I asked Claude Code (Opus 5) to implement the routing table I had decided on, along with the startup warning and the canonicalization deferred from an earlier commit in this series. Claude wrote the module, the wiring and the tests. The reviewers found four defects, all reproduced before fixing: Codex (GPT-6 Astra) found that an instance whose slug is a malformed A-label made the startup scan throw, keeping the whole deployment from starting, and that srvx's invalid-authority substitute let a tenant-looking request reach GraphQL with a 200. Claude Code (Fable 5) found that a single unauthenticated request with a host such as 1.2.3.4.5 killed the process, which I confirmed by running it against a live server, and that the dispatcher tolerance had no test that could fail. Fable also pointed out that a comment I had written claimed more than ships, since srvx rejects trailing-dot hosts before they reach the router; the comment now says what actually happens. The behavioural tests were mutation-tested by restoring the old behaviour one call site at a time. I verified the result with mise run check, mise run build and mise run test, and by exercising the whole routing table against a running server with a seeded instance. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5
createInstance left slug validation to the database. A slug the check
constraint refused came back as an unhandled DrizzleQueryError rather
than as one of the errors the mutation is declared to return, so a
client asking for `-foo` got an internal error instead of being told
what was wrong with it. There is now an InvalidSlug member of
CreateInstanceErrorType, and the check runs before the insert.
Moving the check into TypeScript also lets it say something the
constraint never could. A slug may begin with `xn--`, on purpose, so
that an instance can carry an internationalized domain name -- but
carrying the prefix is not the same as being Punycode. `xn--a` passes
every shape rule and the constraint, and yet
`new URL("https://xn--a.drfed.net")` throws, so an instance created
under it could never be addressed at all. isValidSlug() now requires
the label to round-trip through domainToASCII(). Postgres cannot decode
Punycode, so the two halves are deliberately asymmetric from here on:
the constraint guards the shape, and this guards the meaning.
Instance also gains a `url` field carrying the absolute origin it is
served at, so that callers stop assembling one themselves. A local
instance follows this deployment's root origin, which is what makes a
development instance come out as http with its port attached; a remote
one is always https, since the root origin says nothing about hosts
elsewhere on the fediverse. It is built by concatenation rather than
through URL, so that a host left behind by an older, laxer rule yields a
useless string rather than throwing in the middle of a query.
Also fixes a lint warning committed by mistake in the previous commit,
where a comment began with a lowercase word.
fedify-dev#77
AI provenance: I asked Claude Code (Opus 5) to wire the slug rule into
the mutation and to expose the instance origin the frontend will need,
and I decided that Punycode decodability belongs in TypeScript rather
than being forced into the database constraint. Claude made the change
and wrote the tests. Codex (GPT-6 Astra) reviewed and confirmed against
a live database that the new field neither throws nor misreports for a
stranded local instance or for a malformed host predating the stricter
rule; Claude Code (Fable 5) reviewed independently and found nothing to
fix. I verified the result with mise run check, mise run build and
mise run test.
Assisted-by: Claude Code:claude-opus-5
Assisted-by: Codex:gpt-6-astra
Assisted-by: Claude Code:claude-fable-5
The instance page built its federation endpoint links by putting `https://` in front of the host name. That is right in production and wrong everywhere else: a development instance is served at http://slug.drfed.localhost:8888, and every link on the page pointed at an https URL that answers nowhere. The links now come from the origin the server reports, which is exactly what Instance.url was added for. The three hand-written list entries become one list built from a small helper, since they differed only in a label and a path. fedify-dev#77 AI provenance: I asked Claude Code (Opus 5) to make the page consume the new field, and it wrote the change. Codex (GPT-6 Astra) and Claude Code (Fable 5) both reviewed and reported nothing to fix. Since this package has no test suite, I verified it by hand against a running mise run dev stack: signing in, creating an instance and opening its page now shows the host as legislature-comestible-lashes.drfed.localhost:8888 with all three endpoints linked as http URLs carrying that port. The linked endpoints themselves still answer 404 on a development server, which is expected and unrelated: no NodeInfo dispatcher is registered yet, the shared inbox only accepts POST, and WebFinger has no handle to resolve until an actor exists. An earlier commit in this series verified that a real actor and its WebFinger do resolve over an instance subdomain. Assisted-by: Claude Code:claude-opus-5
Nine commits changed how DrFed is addressed and none of them said so outside the code. The README now has a section on the root origin: what it is required for, that each instance is a subdomain of it, what each authority is served, and what an operator has to arrange -- a wildcard DNS record, a matching wildcard certificate, and a reverse proxy that forwards both the Host header and the forwarded protocol. That last one is worth stating plainly: without `X-Forwarded-Proto: https` the request looks like plain HTTP and every actor URI DrFed mints names `http://`, which is not where the actor lives. The routing table is qualified rather than left to be read as host-based isolation. The authority has to match in full, port included, so a request on some other port is served the control surface rather than a 404. The options table gains --root-origin, --email-from and --smtp-url, and loses a stray parenthesis that had been sitting inside the backticks of the --listen default. The usage examples now pass --root-origin, since without it the command no longer starts. CONTRIBUTING.md gets the same option list, a pointer to where routing and authority composition live so that the next person changes them in one place, and a note that an option naming a web origin should use this repository's own origin() value parser rather than Optique's url(). Two corrections while there: it claimed every package with tests has a test script, which is now every package except the web frontend; and it told contributors to check before building, which fails on a fresh checkout because the type checker reads each package's dependencies out of their dist directories. CI has always built first. fedify-dev#77 AI provenance: I asked Claude Code (Opus 5) to write up the behaviour this series introduced, and it drafted both documents. Codex (GPT-6 Astra) reviewed for accuracy against the code and found two operational omissions: the forwarded-protocol requirement, and that the routing table read as though GraphQL were isolated by host name when the port must match too. I verified the first against a running server before rewriting it -- forwarding only Host really does yield http actor URIs, and adding the header really does fix them -- and both are now documented. Claude Code (Fable 5) reviewed independently afterwards. No behaviour changed in this commit. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5
CI failed on every platform while this branch passed locally. The cause was not the tests: mise.toml pins node to a floating major, so CI runs a different patch release than I do, with different bundled ICU, and the two disagree about whether `http://xn--a.drfed.net/` parses. Mine says no; CI says yes. isValidSlug() asked that same question, through domainToASCII(), to decide whether an `xn--` label was decodable Punycode. A rule every deployment has to agree on cannot be answered out of whichever ICU the local Node happens to bundle: the same slug would be accepted on one server and refused on another. So the function goes back to shape alone, and loses its dependency on node:url. What actually matters is narrower, and belongs where the answer only has to hold locally. createInstance now refuses a slug whose composed host this runtime cannot parse, since an instance nothing can address is worse than a rejected slug. That rejection carries its own message: a slug that reaches it has satisfied every shape rule, so quoting those rules back at the user would name conditions they met. The tests stop asserting IDNA outcomes. The unparseable hosts are now 1.2.3.4.5 and 999.1.1.1, which the WHATWG IPv4 host parser rejects by specification rather than by ICU, and the new guard is covered by a test that takes its expectation from URL.canParse() rather than hardcoding one, so it asserts that the guard and the runtime agree instead of prescribing what the runtime should say. A stored host that no longer parses stays tolerated: the startup scan reports it rather than throwing. generateActors does still throw over one, which is filed separately. fedify-dev#77 AI provenance: the failure was mine to diagnose; I traced it to the floating node pin and decided the fix had to remove the runtime dependency from the rule rather than only from the assertions, since the production behaviour was the part that varied. Claude Code (Opus 5) made the change. Codex (GPT-6 Astra) reviewed and pointed out that the new guard had no test, and proposed deriving the expectation from URL.canParse() rather than hardcoding it, which is what the test now does. Claude Code (Fable 5) reviewed and found that the error message described rules the rejected slug satisfied, and separately that generateActors throws over such a host. I mutation tested the guard by deleting it and confirming the new test fails. Verified with mise run build, mise run check and mise run test. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5
Optique 1.3.0 ships an origin() value parser, so the one this branch wrote by hand is gone. The library's does the same normalization and more of it: credentials are refused rather than quietly dropped, opaque schemes are refused when the allow list is built rather than when a value arrives, and how much of a URL beyond the origin to tolerate is a choice rather than a fixed rule. What remains is a wrapper, because two of the rules on this option are not properties of origins in general. The root origin may not name an IP address, since every instance is a subdomain of it and foo.127.0.0.1 is not a host name; and its host may not run past the 253 octets DNS allows, since login mail is sent from that domain and the mail library will not build a message with a longer one. Both came out of review on this branch, and the built-in has no option for either. The wrapper forwards the parser member by member instead of spreading it. origin() exposes placeholder as a getter, and spreading would have called it once and handed every caller the same mutable URL. It also forwards validate(), which Optique uses for values that did not come from the command line; without it that check degrades to format() followed by parse(), and since format() emits only the origin, a value carrying credentials would have been laundered into an accepted one. The two rules live in one helper both parse() and validate() call, so they cannot come to different conclusions about the same value. The unknown-option error in 1.3.0 names the offending token, so the test for the retired --root-domain now asserts that name appears instead of matching a generic message. fedify-dev#77 AI provenance: the maintainer pointed out the new release and asked for the swap. Claude Code (Opus 5) made the change and decided to keep a wrapper rather than lose the two review-derived rules. Codex (GPT-6 Astra) found that the wrapper had dropped validate(), which it reproduced, and then verified member-for-member parity with the built-in once that was fixed. Claude Code (Fable 5) found that a doc comment had been orphaned, so the published declaration shipped undocumented, and that credential rejection was asserted only on the validate path. I verified each against the built output before acting, and mutation tested validate() by deleting it. Checked with mise run build, mise run check and mise run test. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5
dodok8
reviewed
Sep 16, 2026
The create-instance form validated slugs more loosely than the server does: its regex allowed -foo, foo- and ab--cd, all of which isValidSlug() and the database constraint refuse. The schema now mirrors that rule, so the form cannot accept a slug the mutation will turn away. Nothing is broken today. The field is read-only and filled from faker's noun list, whose thousand words all lowercase to [a-z0-9-], never begin or end with a hyphen, and never contain a double one, so three of them joined always satisfy the rule. The divergence would only surface if the field became editable, which is why it is worth closing now rather than after. The rule is copied rather than imported. This package depends on no @DrFed package and reaches the server only over GraphQL; taking a dependency on the model layer for a check that exists to save a round trip would trade a real boundary for a small convenience. The server remains the authority, and the copy carries a comment saying so. fedify-dev#83 (comment) AI provenance: reviewing the pull request, dodok8 asked whether faker-generated slugs satisfy the tightened rule and said the form should either match it or rely on faker. I asked Claude Code (Opus 5) to check rather than reason about it; enumerating faker's dictionary showed the generated slugs always conform, so the question resolved to the second half, and I chose to match the rule anyway because the form was looser than the server in ways nothing prevents a future editable field from hitting. Claude made the change and fuzzed the schema against isValidSlug() over 400000 inputs without finding a disagreement. Codex (GPT-6 Astra) reviewed, compared the two exhaustively over 66430 inputs, and reported nothing to fix. Verified with mise run build, mise run check and mise run test. Assisted-by: Claude Code:claude-opus-5 Assisted-by: Codex:gpt-6-astra
dodok8
approved these changes
Sep 17, 2026
sij411
approved these changes
Sep 18, 2026
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.
Fixes #77.
Why an origin, not a domain
An instance is found by the authority a request arrives on, which Fedify reports as
Context.host. A bare domain cannot express that. A development server on:8888composedfoo.drfed.localhostwhile requests arrived asfoo.drfed.localhost:8888, so instance lookups failed there, and generated actor URIs used HTTPS without the port.--root-domainbecomes--root-origin, parsed by Optique'sorigin()and wrapped to add two rules of DrFed's own, and the context carries aURL.It is required now. Omitting the old option created instance records under
drfed.orgwhatever the deployment's own domain was.The slug rule is shape only, on purpose. Whether an
xn--label is decodable Punycode is answered out of the runtime's bundled ICU, so a rule built on it accepts a slug on one deployment and refuses it on another.createInstanceinstead refuses a slug whose composed host the local runtime cannot parse, which is the thing that actually matters.Why route by authority
Every request previously went through Fedify with a GraphQL fallback, instance subdomains included, so the separation subdomains exist for was not enforced. Routing now decides from the authority alone, in packages/drfed/src/serving.ts, and the classification sits beside the functions that compose an instance's authority so routing and instance creation share one set of rules. The authority has to match in full, port included, so this is not hostname-based isolation.
The dispatchers canonicalize
Context.hostbefore querying, which is what lets a proxy forwardingHost: foo.drfed.net:443over plain HTTP still reach its instance. The request itself is left intact, so signature verification never sees a synthesized one.Why warn instead of repair
Startup warns when a stored local instance authority falls outside the configured root, and changes nothing. Those authorities are already inside actor URIs the rest of the fediverse has stored.
Review
Twelve commits. I ran
mise run build,mise run checkandmise run testagainst each one separately, after rebasing. Checked by hand against a running server: the routing table, an actor and its WebFinger over an instance subdomain, and the instance page in a browser.Review found two malformed-
Hostcases while the router was being written, both reachable without authentication.Host: 1.2.3.4.5killed the process, because srvx accepts hosts thatURLrejects and attaches no rejection handler.Host: tenant.drfed.net.reached GraphQL with a 200, because srvx substitutes_invalid_for a host it cannot parse. Both answer 400 now, with regression tests.Trailing-dot normalization for
DRFED_LOGIN_ORIGINSis tracked separately in #81.AI assistance
Written with Claude Code (Opus 5), which produced the code and tests under my direction; the design decisions are mine. Codex (GPT-6 Astra) and Claude Code (Fable 5) reviewed before each commit landed, and their findings are recorded in the commit messages along with
Assisted-bytrailers. I checked reviewer claims against the code before acting on them, including one I first judged wrong and was myself mistaken about. Selected regression tests were verified by restoring the old behaviour and confirming they fail.