A login asks for the space scopes of a PDS that serves spaces
ClosedThis pull adds getSpaceSupport, which probes the caller's PDS (describe first, else per-method against a control NSID) and caches the answer in space_support (a yes for an hour, a no for five minutes). It makes login::asked request the space scopes only when the PDS serves spaces, and it makes write::out_of_scope compare a session against that per-PDS scope instead of the fixed SCOPE. It also adds the addMember read/write fix and the harness space declarations.
I read support.rs, login::asked, out_of_scope, oauth::scope and behind. I did not read the rest of the roughly 2,200-line diff, including the index code, the tests and the harness changes. The logic I read is sound and matches the description. The control-path comparison, the cache TTLs, the force validation and the 401 for an unauthenticated caller all look right. The base branch's checks are queued, not finished, so I have no result from the runner.
One thing to be aware of, not a blocker. The probe treats a timeout or an unreachable PDS as "serves nothing", and caches that for five minutes. out_of_scope then calls behind against login::asked. So if a probe flaps to "no" at the moment a session that does have the space scopes gets a scope refusal, that session is ended and the user is told to log in again. The risk is small because it needs a scope refusal and a failed probe together. If it matters, treat "could not answer at all" as unknown in out_of_scope and keep the session, rather than caching it as a no.
The flapping-probe note is answered at fabd19c: out_of_scope ends a session only if it is behind both what a login asks of its PDS now and oauth::scope(true), so a session holding the space scopes is never ended by a probe that failed to reach its PDS, while one without them on a PDS that has since gained spaces still logs in again. tests/write.rs checks a space-scoped session refused for scope by a PDS found to serve no spaces is kept.
The new commit fabd19c answers my earlier note. out_of_scope now ends a session only if it is behind what a login asks of its PDS and also behind oauth::scope(true). A failed probe, which reads as "serves no spaces", can no longer end a session that holds the space scopes.
I checked the cases:
- A plain-
SCOPEsession on a PDS found to serve spaces is behind both, so it still logs in again. - A session with the space scopes on a PDS found to serve none is kept.
- A session with only
atprotois still ended.
tests/write.rs adds the kept case. I did not run it or see the runner's checks, so I have no result from them. No further changes are needed.
Landed on feature/rust at fc5f686 through the batch pull Seven reviewed pulls land on feature/rust together, checked once, checked there as a whole. This pull's head is in feature/rust's history.
What changes
social.grain.privateGallery.getSpaceSupportanswers whether the caller's PDS, as its DID document names it, serves permissioned spaces:supported, thepdsasked,checkedAt, and the first name of each method it lacks (missing).force=trueasks again; anything buttrueorfalseis refused. Only an account may ask (cookie or service token).How it is found (
grain-serve/src/spaces/support.rs), as the old server found it:community.lexicon.service.describefirst, taken at its word; else each method grain calls, by each name a PDS may serve it by (com.atproto.space,memberspace,simplespace), told from how the PDS answerssocial.grain.unspecced.methodThatDoesNotExist. A PDS that answers everything alike (bsky.social) or nothing at all serves none. Each question has 5 seconds. What is found is kept inspace_support(already in the schema, and carried by grain-import): a yes for an hour, a no for five minutes.A login asks for the space scopes of a PDS that serves spaces.
grain::oauth::SPACE_SCOPEis the private gallery, pool, group,rpc:fyi.opensocial.*and members scopes, every word already registered in the client metadata (login::REGISTERED, now tested againstoauth::scope(true));oauth::scope(spaces)is what a login asks.login::askedasksSCOPEalone of any other PDS. A write refused for want of scope is "behind" against what a login asks of that session's PDS now, so a session with the space scopes is not sent to log in again, and one without them on a PDS that has since gained spaces is.Admin::put_membersendsreadandwritetosimplespace.addMembertoo: pds.js servesaddMemberandputMemberby one route, which takes them (and gives both when absent).denied's doc says a member's repo missing from the space reads as the same no. Both from pull 1's review.The alpha harness is given the space declarations. The alpha's OAuth provider resolves the lexicon of each space type a scope names and refuses the login (
invalid_scope, "Unable to retrieve space declarations") when it cannot; the dev network resolves none.harness/pds.mjsputs grain'ssocial.grain.privateGalleryandsocial.grain.groupand opensocial'sfyi.opensocial.members(as published atdid:plc:2gqnilpksz2e7faj3bwvo6qc) in the PDS's lexicon store, as just resolved.From knotless
space, relying on a PDS without spaces to drop a scope it does not know. Grain asks only a PDS its probe finds serving spaces. Grain's users log in from any PDS on the network, of any version, where knotless's log in from a few. The old server asked conditionally so that a login never depends on a PDS dropping a scope it cannot parse.login::askedandoauth::scope(spaces).space::unsupported). Grain keeps a probe becausegetSpaceSupportis one of its lexicon methods: the web asks before it offers a private gallery.put_member: knotless'sAdmin::put_membersendsreadandwrite, and grain's now does the same.Why
The second of the spaces stack. Every private gallery and group method is gated on this answer, and none of them works on a session that was not asked for the space scopes, so both come before the methods.
The old server gated the scopes on hatk's
conditionalScopes(whenMethod: createSpace / getDelegationToken); here the gate is the same support probe the methods use, so a login andgetSpaceSupportcannot disagree about a PDS.How it was checked
crates/grain-serve/tests/spaces.rs, on the alpha built from11c5d98: a web login through the TLS proxy andauthorize.mjskeeps a session asked foroauth::scope(true), not behind it;getSpaceSupportby cookie says the PDS serves every method (the alpha serves no describe, so this is the control path),force=trueasks again,force=yesis 400, no caller is 401. Unit tests against a stand-in PDS: a describe taken at its word, and one naming some methods; the control path as pds.js answers (501 for an unknown method, 401 for a known one) with one method missing; one name of each method enough; a PDS answering everything alike, and one not listening; the cache kept, forced, a no stale at 6 minutes, a yes good at 30 and stale at 61, and a server with no index.index/spaces.rstests the row kept, replaced and aged, and achecked_atthat is no time. fmt and clippy here; the coverage gate and the answers check (which holdsgetSpaceSupport's answer to its lexicon) are left to the runner.Shortcomings
pdsin the answer is the DID document's PDS, not the web session'sservice; they are the same for any account that logged in.Review
Start at
crates/grain-serve/src/spaces/support.rs, thenlogin::askedandwrite::out_of_scope.🤖 Generated with Claude Code