Skip to content

Associate each instance with a subdomain - #83

Merged
dahlia merged 13 commits into
fedify-dev:mainfrom
dahlia:associating-subdomains
Sep 18, 2026
Merged

dahlia merged 13 commits into
fedify-dev:mainfrom
dahlia:associating-subdomains

Conversation

@dahlia

@dahlia dahlia commented Sep 15, 2026

Copy link
Copy Markdown
Member

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 :8888 composed foo.drfed.localhost while requests arrived as foo.drfed.localhost:8888, so instance lookups failed there, and generated actor URIs used HTTPS without the port. --root-domain becomes --root-origin, parsed by Optique's origin() and wrapped to add two rules of DrFed's own, and the context carries a URL.

It is required now. Omitting the old option created instance records under drfed.org whatever 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. createInstance instead 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.host before querying, which is what lets a proxy forwarding Host: foo.drfed.net:443 over 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 check and mise run test against 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-Host cases while the router was being written, both reachable without authentication. Host: 1.2.3.4.5 killed the process, because srvx accepts hosts that URL rejects 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_ORIGINS is 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-by trailers. 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.

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
Comment thread packages/models/src/slug.test.ts
@dahlia dahlia moved this from Todo to In progress in NLnet NGI0 Commons Fund (2026) Sep 17, 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
@dahlia
dahlia requested a review from dodok8 September 17, 2026 08:04
@dahlia
dahlia merged commit b5f6732 into fedify-dev:main Sep 18, 2026
4 checks passed
@dahlia
dahlia deleted the associating-subdomains branch September 18, 2026 05:41
@github-project-automation github-project-automation Bot moved this from In progress to Done in NLnet NGI0 Commons Fund (2026) Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Development

Successfully merging this pull request may close these issues.

Associating subdomains

3 participants