Groups are read, joined and left through grain-serve
ClosedI read spaces/group.rs in full. I did not read the rest of the 5,000-line diff, including the tests and the stand-in host. I did not check the runner's results.
The pull ports getGroup, listGroups, getSubmissions, joinGroup and leaveGroup onto grain-serve. Membership comes from whether the group's host gives the viewer a credential for its members space, which is a sound design. I found nothing that must change before merging. Two things to tighten, either here or in the pools pull:
join_groupandleave_groupdon't validateinput.group.getSubmissionschecksvalid_did, but these two don't. They don't checkis_groupeither, so any string reacheshost_of, and the viewer's PDS is asked to sign a service token for whatever host that returns. The guarded client limits the damage. Still, a non-DID or a non-group should get a 400 before any outbound call or space write.is_membercaches every error as a refusal. AnyErrfromcredential_foris cached as "not a member" for 2 minutes. That includes a network error, a timeout and a failure on the viewer's own PDS.listGroupsandgroups_ofread that cache, so a brief host outage hides a member's groups for 2 minutes. Only an explicit refusal from the host should be cached. Transient errors should go uncached.
This is round 2. The head is still the commit I reviewed in round 1, so neither point I raised has been addressed. Both are small fixes.
join_groupandleave_groupdo not validateinput.group.get_submissionsrejects a non-DID with a 400.join_grouppasses the string straight tocall_host, which reacheshost_ofand a service-token request. It also does not checkis_group. Return 400 for an invalid DID or a non-group before any outbound call or space write. Do the same inleave_group.is_membercaches everyErras a refusal. A network error, a timeout or a failure on the viewer's own PDS is cached as "not a member" for 2 minutes.listGroupsandgroups_ofread that cache, so a brief outage hides a member's groups. Cache only an explicit refusal from the host. For any other error, return it or answer false without caching.
crates/grain-serve/src/spaces/group.rsline 585Outdated
/// `joinGroup`: asks `group`'s host to let the caller in. Admitted, the | |
/// caller's acceptance is written; pending, it is written when the group's | |
/// page next finds them admitted ([`get_group`]). | |
pub async fn join_group( |
input.group is not checked with valid_did or is_group before call_host. Return a 400 first. leave_group needs the same check.
crates/grain-serve/src/spaces/group.rsline 144Outdated
Ok(true) | |
} | |
Err(_) => { | |
refused.insert(key, Instant::now()); |
Any Err is cached as a refusal for 2 minutes, including transient failures. Cache only an explicit host refusal.
This is round 3. Neither point from round 2 is fixed at the new head. I re-read join_group, leave_group and is_member there.
join_groupandleave_groupstill don't validateinput.group. Both go straight fromparsetocall_host, so any string reacheshost_ofand a service-token request. Return 400 for an invalid DID or a non-group (valid_didandis_group, asget_submissionsdoes) before any outbound call or acceptance write.is_memberstill caches everyErras a refusal for 2 minutes. A timeout, a network error or a failure on the viewer's own PDS hides a member's groups fromlistGroupsandgroups_of. Cache only an explicit refusal from the host. For other errors, return the error or answer false without caching.
crates/grain-serve/src/spaces/group.rsline 126Outdated
/// Whether `group`'s host admits `viewer` to its member list: given a | |
/// credential for it. A group is a member of none. A refusal is kept a | |
/// while, unless `fresh`. | |
pub async fn is_member(app: &App, viewer: &str, group: &str, fresh: bool) -> Result<bool, Failure> { |
Cache only an explicit host refusal here. Don't cache transient errors as a refusal.
Done at 7162529 (df95ac7): is_member keeps only a NotAuthorized from credential_for, which is the host's refusal. Any other failure answers false for that call and is not cached, so the next listGroups or groups_of asks again.
crates/grain-serve/src/spaces/group.rsline 561Outdated
/// `joinGroup`: asks `group`'s host to let the caller in. Admitted, the | |
/// caller's acceptance is written; pending, it is written when the group's | |
/// page next finds them admitted ([`get_group`]). | |
pub async fn join_group( |
Validate input.group (valid DID, is_group) with a 400 before call_host. leave_group needs the same check.
Done at 7162529 (df95ac7): joinGroup and leaveGroup call a_group first, which refuses with a 400 anything that is not a valid DID the index knows as a group, before host_of runs or an acceptance is written. The test joins and leaves an account that is not a group, and the string nobody: 400 each. A declared group whose host cannot be found is still 502.
Round 4. Both points from round 3 are fixed at this head. I re-read join_group, leave_group and is_member in spaces/group.rs. I did not re-read the rest of the diff.
- Input validation:
join_groupandleave_groupnow calla_group, which requiresvalid_didandis_group. It runs right afterparse, beforecall_host,host_ofor any write. A bad group gets a 400. - Refusal caching:
is_membernow caches an error only whene.error == "NotAuthorized". Any other error answers false once and is not cached, so a timeout or a failure on the viewer's own PDS no longer hides a member's groups.
I found nothing else that must change. The shortcoming the pull already lists is acceptable: leave_group withdraws the acceptance before the host answers, and the next getGroup writes it back.
Round 5. Since round 4 the head changed only in the rule uri. views in spaces/group.rs now builds at://<group>/fyi.opensocial.rule/<rkey>, an at-uri as the lexicon wants, and the test asserts it. This matches the "Differences" note in the pull. The input validation and refusal-only caching I approved in round 4 are untouched. I found nothing that must change.
Landed on feature/rust at 92c202b, fast-forwarded with spaces/pools (Pools are read and written through grain-serve), which contains this head (b45225e) and passed every check against 164794a. Review rounds ran out, so this was merged on the last approval (2e66775). The only change since then is merging in 67d8d95, which was read and doesn't change behaviour. Membership is checked per viewer through a credential for the member list; join and leave go through write::writer.
What changes
Groups are served by grain-serve (
crates/grain-serve/src/spaces/group.rs):social.grain.group.getGroup,listGroups,getSubmissions,joinGroupandleaveGroup, as their lexicons say. A group is an account with afyi.opensocial.declarationin the index. Its host is the PDS its DID document names, and that host's/.well-known/did.jsonnames the DID that service tokens are addressed to. The group's profile is its grain profile, or else what the host'sfyi.opensocial.listGroupslists of it (kept 10 minutes, avatar from the host's/img/<group>/avatar). Its pool count and last activity come from itssocial.grain.group.itemrecords in the index.Who reads what. A group's roster lives in its
fyi.opensocial.membersspace, so grain answers whether the viewer is a member by getting them a credential for that space, which the host gives only to members. Grain does not keep its own copy of the roster.viewer.memberis true only for a viewer the host admits, and is checked fresh ongetGroup.NotAuthorized) is kept, for 2 minutes. Any other failure, such as a timeout, the network, or the viewer's own PDS, answers "not a member" that once and is asked again next time, so a short outage does not hide a member's groups.pendingCount. A group belongs to no groups.getSubmissionslists what was offered to a group and neither pooled nor declined. Anyone can call it, as on the old server, because submissions are public records in the index.Tests check that an outsider is not a member, and that one pending admission is not a member either.
Joining and leaving are the host's to decide. Grain asks the host as the viewer, with a service token their PDS signs for the host's DID. Before any host is looked up or anything is written,
joinGroupandleaveGrouprefuse with a 400 anything that is not a DID the index knows as a group.fyi.opensocial.acceptance(self) into the members space. A viewer left pending gets theirs written bygetGrouponce the host admits them.From knotless
is_member). It is the same mechanism as pull 3, with the one-minute re-check and the logout forgetting, which also covers the member lists a viewer was refused.rpc:service tokens at any audience).read_by). Grain has no such record for a group the viewer has not opened here, so it confirms each one as it is asked about.Why
This is the fourth pull of the spaces stack, on top of pulls 1 to 3. It ports the groups the web shows; pools follow in the next pull.
How it was checked
crates/grain-serve/tests/spaces.rsruns on the alpha against a stand-in group host (tests/host/mod.rs). The stand-in makes a group account with a recovery key, makes its members and pool spaces, and points its PLC document at itself. It serveslistGroups,requestJoin(open: put on the members list to read, and on the pool to write; otherwise pending) andleaveGroup.The test covers:
getGroup, which writes the acceptance;getSubmissions;listGroupswith a gallery's standing in each group;did:web:grain.test;listGroupswith no one asking.There is a plan check for the
listGroupsquery and the view's queries. fmt and clippy ran here; coverage is left to the runner.Differences from the old server
listGroupsreturns no cursor. It returns up tolimitgroups ordered by latest pool activity, as the old server did on its first page.InvalidRequestcarrying the host's error and message. A host that fails or cannot be reached is 502UpstreamFailure, not 400.joinGroupandleaveGrouprefuse anything but a group's DID with a 400 before asking a host.uriisat://<group>/fyi.opensocial.rule/<rkey>, the at-uri it would have in the group's repo, as grain names other records in a space. The old server sent the space-record address, which is not an at-uri and failsgroupView's lexicon.createGroupandgetGroupHostare not ported. They need a provisioning host's configuration and its session protocol; that is still open.Shortcomings
leaveGroupwithdraws the acceptance before the host answers, so a host that then refuses leaves the viewer admitted but with no acceptance. The nextgetGroupwrites it back.Review
Start at the module doc in
spaces/group.rsandis_member, thenjoin_groupandleave_group.tests/host/mod.rsis test scaffolding.🤖 Generated with Claude Code