Expose LocalInstance through GraphQL and restrict who can read it - #64
Merged
Merged
Conversation
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
sij411
approved these changes
Sep 5, 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.
LocalInstancewas reachable only throughnode(id:). I added direct navigation fromInstanceand by slug, plus the reverse link needed to recover the host name. Reviewing that work exposed missing authorization onLocalInstanceitself and on privateAccountfields, which the later commits close.Naming
I used
localInstancerather than the shorterlocal, because in fediverse APIslocalusually reads as a boolean opposite ofremote. The root field islocalInstanceBySlug, to match the existingaccountByUuid.Authorization
LocalInstancegets a type-level scope, since all of its current fields are operational details. Membership resolves through an explicit join rather than theinstances.instanceMembersrelation: that relation carries a baked-inaccepted IS NOT NULLfilter, and whether a query-timewheremerges with it or replaces it is not obvious. If it replaced it, invited-but-unaccepted members would pass silently.Accountgets field-level scopes instead, soInstance.memberscan keep showingnameanduuidwithout exposingemail,admin, orinstances.What measurement changed
The
runScopesOnTypecomment claimed the flag closes thenode(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.emailnon-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.adminandAccount.instancesbecome nullable.Query.accountByUuidandQuery.localInstanceBySlugnow 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
runScopesOnTypecomment they found.