Skip to content

Expose LocalInstance through GraphQL and restrict who can read it - #64

Merged
dahlia merged 4 commits into
fedify-dev:mainfrom
dahlia:graphql/local-instance-field
Sep 5, 2026
Merged

Expose LocalInstance through GraphQL and restrict who can read it#64
dahlia merged 4 commits into
fedify-dev:mainfrom
dahlia:graphql/local-instance-field

Conversation

@dahlia

@dahlia dahlia commented Sep 4, 2026

Copy link
Copy Markdown
Member

LocalInstance was reachable only through node(id:). I added direct navigation from Instance and by slug, plus the reverse link needed to recover the host name. Reviewing that work exposed missing authorization on LocalInstance itself and on private Account fields, which the later commits close.

Naming

I used localInstance rather than the shorter local, because in fediverse APIs local usually reads as a boolean opposite of remote. The root field is localInstanceBySlug, to match the existing accountByUuid.

Authorization

LocalInstance gets a type-level scope, since all of its current fields are operational details. Membership resolves through an explicit join rather than the instances.instanceMembers relation: that relation carries a baked-in accepted IS NOT NULL filter, and whether a query-time where merges with it or replaces it is not obvious. If it replaced it, invited-but-unaccepted members would pass silently.

Account gets field-level scopes instead, so Instance.members can keep showing name and uuid without exposing email, admin, or instances.

What measurement changed

The runScopesOnType comment claimed the flag closes the node(id:) path. Removing the flag left every test green, so the claim was wrong. It closes the data channel only: without it, node(id: $id) { __typename } answers "LocalInstance"; with it, the selection returns no data, though the authorization error still reveals that the ID resolves to something.

Keeping Account.email non-null caused one denied email in a members list to null the entire response, the viewer's own name included. I made the three protected fields nullable so a denial stays local to the field.

Breaking changes

Account.email, Account.admin and Account.instances become nullable. Query.accountByUuid and Query.localInstanceBySlug now require authentication. packages/web needed a matching null guard. I know of no external consumers.

AI assistance

I used Claude Code (Opus 5) for exploration, implementation, and tests. I chose the design after reviewing its options, and verified the result myself. Codex (gpt-5.6-sol) and Claude Fable 5 reviewed the commits; I fixed the test coverage gaps and the inaccurate runScopesOnType comment they found.

The database models an Instance as owning zero or one LocalInstance
through the nullable instances.localId foreign key, but nothing in the
GraphQL schema returned a LocalInstance.  The type was an orphan,
reachable only through node(id:), so clients had no way to get from an
Instance to its slug, expiry, or actor quota.

Expose that edge as a nullable Instance.localInstance field, which
resolves to the backing record for instances this deployment hosts and
to null for remote ones.  The name repeats the parent type rather than
using the shorter "local" on purpose: in the fediverse, "local" reads
as a boolean distinguishing local from remote, and a field of type
LocalInstance named "local" invites that misreading.

Fix the two relation names this builds on in the models package.  The
forward relation was named localInstances even though it is r.one, and
the reverse block was keyed localInstance, which matches no table --
defineRelations keys are schema table export names, with cardinality
carried by r.one/r.many -- so it was registered nowhere.

Also correct three LocalInstance descriptions that had been copied from
Instance and described the wrong type.

Provenance: Claude Code was asked to expose the LocalInstance a given
Instance owns as a GraphQL field, to choose between the names "local"
and "localInstance", and to document the field.  It explored the schema,
relations, and Pothos setup, then proposed a plan; the contributor chose
localInstance, limited the documentation work to the new field plus the
incorrect descriptions, and questioned whether the singular relations.ts
key was intentional, which surfaced that the key must be a table name.
The assistant wrote the field, the relation fixes, and the tests.  A
review loop followed: Codex found that the test fixture reused one UUID
for instances.id, instances.localId, and localInstances.id, so a
relation joining the wrong column would still pass; the fixture now uses
a distinct local-instance ID, verified by mutation testing that the test
fails when the join column is changed.  A second Codex round and two
Claude Fable 5 rounds reported no further findings.  Verified with mise
run check, mise run build, and mise run test.

Assisted-by: Claude Code:claude-opus-5
Assisted-by: Codex:gpt-5.6-sol
Instance.localInstance let a client walk from an Instance down to its
local record, but there was still no way to start from a slug -- the
label that appears in an instance's host name and the argument
createInstance takes.  The root Query offered only accountByUuid,
viewer, node, and nodes, so a known slug was not a usable entry point.

Add localInstanceBySlug(slug: String!), named to match the existing
accountByUuid(uuid:) so the two root lookups follow one predictable
pattern.  It is nullable and returns null for an unknown slug; the
slug column is unique, so findFirst resolves at most one row.  The
resolver does no slug-format validation, because a slug that could
never satisfy the insert-time check constraint is simply a miss.

The field requires authentication.  Slugs match ^[a-z0-9-]{4,63}$ and
are trivially enumerable, while LocalInstance exposes expires and
maxActors.  Note that this blocks enumeration through this entry point
but does not make LocalInstance private: the type stays reachable
unauthenticated through node(id:) and through accountByUuid, and
scoping the type itself is separate work.

Also expose the reverse edge LocalInstance.instance.  Without it a
slug lookup could not reach the host name, which would leave the new
query half-useful.  It is nullable: the foreign key runs the other way,
so an orphaned LocalInstance is representable even though
createInstance always creates the pair in one transaction.

Provenance: Claude Code was asked to add a root Query field fetching a
LocalInstance by slug, with documentation, and offered
localInstanceBySlug over the suggested localInstance for consistency
with accountByUuid.  The contributor chose that name, chose to require
authentication after the assistant raised slug enumeration as a
concern, and accepted the assistant's proposal to add the reverse
LocalInstance.instance field in the same change.  The assistant wrote
the field, the reverse relation, and the tests, splitting createSession
out of the authenticate test helper to avoid a primary-key clash with
seedInstanceMembers.  The unauthenticated response shape was determined
by probing the running server rather than assumed.  Both behaviors were
mutation-tested: removing authScopes fails the denial test, and
pointing the reverse relation at the wrong join column fails the lookup
test.  Codex and Claude Fable 5 each reviewed the result and reported
no findings.  Verified with mise run check, mise run build, and mise
run test.

Assisted-by: Claude Code:claude-opus-5
LocalInstance carries operational details -- the expiry date and the
actor quota -- but any caller could read it.  The type was reachable
through node(id:), through nodes(ids:), and through Instance.
localInstance off the unauthenticated accountByUuid query, so those
details were effectively public.

Scope the type to the people it concerns: accepted members of the
Instance it backs, plus site administrators.  Membership is a new
parameterized auth scope, localInstanceMember, taking the LocalInstance
UUID; the type combines it with the existing admin scope under $any.
Pothos caches scope results per request by scope name and parameter, so
a LocalInstance appearing repeatedly in one response costs one query.

The membership predicate uses an explicit join rather than the
instances.instanceMembers relation.  That relation carries a baked-in
accepted IS NOT NULL filter, and it is not obvious whether a
query-time where clause merges with that filter or replaces it.  If it
replaced it, invited-but-not-accepted members would silently pass, so
the predicate states all three conditions itself.

Set runScopesOnType so the check runs on the type rather than only on
each field.  Without it a selection touching no scoped field still
resolves, and node(id:) { __typename } would answer "LocalInstance" to
a viewer who may not read one.  This closes the data channel only: the
presence of the authorization error still reveals that an ID resolves
to an existing LocalInstance, since an unknown ID yields a plain null
with no error.  That residual signal is accepted, as it is for slugs,
and it additionally requires already knowing a UUID.

The field-level authenticated scope on Query.localInstanceBySlug stays.
It runs before the resolver, so an unauthenticated caller gets the same
response whether or not the slug exists; relying on the type scope
alone there would open an unauthenticated existence oracle over slugs,
which are guessable in a way UUIDs are not.

Provenance: the contributor asked for authentication on LocalInstance,
and Claude Code first implemented a plain authenticated scope; the
contributor then reconsidered and asked for a membership check via a
dedicated auth scope, pointing at the Pothos scope-auth documentation,
and chose to let site administrators through and to accept the
existence oracle for logged-in non-members.  The assistant read the
plugin's type definitions and the documentation, wrote the scope, the
predicate, and the tests, and determined every unauthorized response
shape by probing a running server rather than assuming it.  Each branch
of the policy was mutation-tested: dropping the accepted filter, the
admin branch, the membership branch, or the localId join each fails a
specific test.  Codex reviewed twice and found three coverage gaps --
no nodes(ids:) or localInstanceBySlug test for a logged-in non-member,
an allow case that could not distinguish an accepted member from an
instance admin, and a non-member case that could not detect losing the
localId join -- all three fixed and mutation-verified.  Claude Fable 5
reviewed twice and found that a comment overclaimed what
runScopesOnType prevents; the comment now states what is actually true.
Verified with mise run check, mise run build, and mise run test.

Assisted-by: Claude Code:claude-opus-5
Assisted-by: Codex:gpt-5.6-sol
Assisted-by: Claude Code:claude-fable-5
Query.accountByUuid was unauthenticated and Account.email was readable
by anyone, so knowing or guessing an account UUID exposed an email
address -- and through Account.instances and Instance.members, the
email of every member of every instance that account belongs to.  A
pre-existing test demonstrated it: a request with no Authorization
header received two members' addresses.

Scope email, admin, and instances to the account itself and to site
administrators, via a new accountSelf auth scope combined with the
existing admin scope under $any.  Unlike localInstanceMember, this
scope is a plain comparison against the viewer's account id and issues
no query.  Leave uuid, name, and created public: Instance.members
exists so members can see one another, and scoping the whole type
would either break that outright or, if merely gated on being signed
in, barely help.  Add authenticated to Query.accountByUuid as well, so
the cheapest enumeration entry point closes before the resolver runs.

The three fields become nullable, which is a breaking schema change and
is the point.  Left non-null, one denied email deep in a members list
nulled the entire response through null propagation -- the viewer's own
name, the instance list, every member's name -- because the null bubbles
up to the first nullable ancestor.  This was measured against a running
server, not assumed.  Nullable confines the denial to the field that
was denied.  packages/web needed a matching null guard on
viewer().instances, which the schema change forces.

Granting the login response its own account required care.
completeLoginChallenge resolves on a request that has no session yet,
so accountSelf cannot see the viewer and the response would have lost
its own email.  Session.account now grants ownAccount, which the field
scopes accept.  This is sound because Session is returned only by
completeLoginChallenge, is not a Node, and is reachable nowhere else,
so the account behind a session is always the viewer's own; and the
grant covers only that account's direct fields, not other accounts
reachable beneath it.

Provenance: after Claude Code reported, as an out-of-scope finding from
the previous change's review, that member emails were readable
anonymously, the contributor asked whether Account should be scoped
too.  The assistant found that accountByUuid is unused by the frontend
and that closing it alone would not help, since node(id:) reaches
Instance and its members regardless, and recommended field-level scopes
over a type-level one because Account mixes public and private data.
The contributor chose that approach and chose to authenticate
accountByUuid.  Both the null-propagation blast radius and every
unauthorized response shape were determined by probing a running
server.  A frontend type error was caught only after re-running
check:types following build, since the first run typechecked stale
Relay artifacts.  Each branch was mutation-tested: removing
accountSelf, the admin branch, the ownAccount grant, or the field
scopes each fails specific tests.  Codex reviewed twice and found that
no test covered the node(id:) entry path; two were added, one
authenticated and one anonymous.  Claude Fable 5 reviewed twice, read
the installed Pothos plugin sources to confirm the grant cannot cascade
past Session.account's direct fields, and found that the containment of
that grant was asserted nowhere; a test now pins it.  Verified with
mise run build, then mise run check, then mise run test.

Assisted-by: Claude Code:claude-opus-5
Assisted-by: Codex:gpt-5.6-sol
Assisted-by: Claude Code:claude-fable-5
@dahlia dahlia added this to the DrFed 0.1.0 milestone Sep 4, 2026
@dahlia dahlia self-assigned this Sep 4, 2026
@dahlia dahlia added the enhancement New feature or request label Sep 4, 2026
@dahlia dahlia moved this from Todo to In progress in NLnet NGI0 Commons Fund (2026) Sep 5, 2026
@dahlia
dahlia merged commit 7a6f1c1 into fedify-dev:main Sep 5, 2026
4 checks passed
@github-project-automation github-project-automation Bot moved this from In progress to Done in NLnet NGI0 Commons Fund (2026) Sep 5, 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.

2 participants