~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

A login asks for the space scopes of a PDS that serves spaces

Closed
Closed chadtmiller.com asked to merge into feature/rust from grain.social/grain:spaces/support

What changes

social.grain.privateGallery.getSpaceSupport answers whether the caller's PDS, as its DID document names it, serves permissioned spaces: supported, the pds asked, checkedAt, and the first name of each method it lacks (missing). force=true asks again; anything but true or false is 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.describe first, 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 answers social.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 in space_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_SCOPE is the private gallery, pool, group, rpc:fyi.opensocial.* and members scopes, every word already registered in the client metadata (login::REGISTERED, now tested against oauth::scope(true)); oauth::scope(spaces) is what a login asks. login::asked asks SCOPE alone 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_member sends read and write to simplespace.addMember too: pds.js serves addMember and putMember by 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.mjs puts grain's social.grain.privateGallery and social.grain.group and opensocial's fyi.opensocial.members (as published at did:plc:2gqnilpksz2e7faj3bwvo6qc) in the PDS's lexicon store, as just resolved.

From knotless

  • Scopes: modelled on access.md's "Scopes". The scope is built from the same constants the calls use, and the space scope names each space type, because naming one would make the authorization server resolve its declaration.
    • Divergence: knotless asks every login for 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.
    • This is a judgement call. Aligning would remove login::asked and oauth::scope(spaces).
  • Support probe: knotless has none and learns that a PDS lacks spaces when a call fails (space::unsupported). Grain keeps a probe because getSpaceSupport is one of its lexicon methods: the web asks before it offers a private gallery.
  • put_member: knotless's Admin::put_member sends read and write, 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 and getSpaceSupport cannot disagree about a PDS.

How it was checked

crates/grain-serve/tests/spaces.rs, on the alpha built from 11c5d98: a web login through the TLS proxy and authorize.mjs keeps a session asked for oauth::scope(true), not behind it; getSpaceSupport by cookie says the PDS serves every method (the alpha serves no describe, so this is the control path), force=true asks again, force=yes is 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.rs tests the row kept, replaced and aged, and a checked_at that is no time. fmt and clippy here; the coverage gate and the answers check (which holds getSpaceSupport's answer to its lexicon) are left to the runner.

Shortcomings

  • A PDS that serves spaces but cannot resolve grain's or opensocial's space declarations refuses the whole login. The old server had the same exposure; the declarations are published, and the client metadata registers the scopes, which zds checks.
  • The probe asks a PDS 17 methods at once the first time and whenever a yes is an hour old, as the old server did.
  • pds in the answer is the DID document's PDS, not the web session's service; they are the same for any account that logged in.

Review

Start at crates/grain-serve/src/spaces/support.rs, then login::asked and write::out_of_scope.

🤖 Generated with Claude Code

Approved an earlier version

This 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.

Approved

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-SCOPE session 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 atproto is 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.