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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

Groups are read, joined and left through grain-serve

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

What changes

Groups are served by grain-serve (crates/grain-serve/src/spaces/group.rs): social.grain.group.getGroup, listGroups, getSubmissions, joinGroup and leaveGroup, as their lexicons say. A group is an account with a fyi.opensocial.declaration in the index. Its host is the PDS its DID document names, and that host's /.well-known/did.json names the DID that service tokens are addressed to. The group's profile is its grain profile, or else what the host's fyi.opensocial.listGroups lists of it (kept 10 minutes, avatar from the host's /img/<group>/avatar). Its pool count and last activity come from its social.grain.group.item records in the index.

Who reads what. A group's roster lives in its fyi.opensocial.members space, 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.member is true only for a viewer the host admits, and is checked fresh on getGroup.
  • The groups a viewer is in are the members spaces their PDS lists, each checked the same way. A PDS keeps listing a space after its acceptance is gone, and stores an acceptance the host never admitted.
  • Only the host's refusal (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.
  • A group signed in as itself is "acting" and sees its pendingCount. A group belongs to no groups.
  • getSubmissions lists 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, joinGroup and leaveGroup refuse with a 400 anything that is not a DID the index knows as a group.

  • Once admitted, grain writes the viewer's fyi.opensocial.acceptance (self) into the members space. A viewer left pending gets theirs written by getGroup once the host admits them.
  • Leaving withdraws the acceptance while the space still takes the viewer's writes. Then it asks the host, drops both kept credentials, and marks the group refused.

From knotless

  • Checking membership: knotless proves a member by getting a credential from the authority, and grain does the same for a group's members space (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.
  • The host: knotless's organizations are its own gateway's, and it has no counterpart to an opensocial group host. Calling a host by service token follows how knotless calls its gateways (rpc: service tokens at any audience).
  • Divergence: the groups a viewer is in are the members spaces their own PDS lists, each confirmed with a credential. knotless records proven reads in its index instead (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.rs runs 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 serves listGroups, requestJoin (open: put on the members list to read, and on the pool to write; otherwise pending) and leaveGroup.

The test covers:

  • the listing's view, and 400 for an account that is not a group;
  • an open join admitted, with the member flags as the host says;
  • a closed join left pending and not a member, then admitted at the host and found by getGroup, which writes the acceptance;
  • submissions, pooled items and declines in the view and in getSubmissions;
  • listGroups with a gallery's standing in each group;
  • acting as the group by service token, on a second server named did:web:grain.test;
  • leaving, and leaving again (400 with the host's message);
  • joining or leaving an account that is not a group, or a string that is not a DID: a 400, with no host asked;
  • joining a declared group whose host cannot be found: 502;
  • a gallery that was never offered, and listGroups with no one asking.

There is a plan check for the listGroups query and the view's queries. fmt and clippy ran here; coverage is left to the runner.

Differences from the old server

  • listGroups returns no cursor. It returns up to limit groups ordered by latest pool activity, as the old server did on its first page.
  • A refusal from the host is 400 InvalidRequest carrying the host's error and message. A host that fails or cannot be reached is 502 UpstreamFailure, not 400.
  • joinGroup and leaveGroup refuse anything but a group's DID with a 400 before asking a host.
  • A rule's uri is at://<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 fails groupView's lexicon.
  • createGroup and getGroupHost are not ported. They need a provisioning host's configuration and its session protocol; that is still open.
  • Membership answers come from the members space credential, with no table of members kept here.

Shortcomings

  • The listing and refusal caches are per process.
  • leaveGroup withdraws the acceptance before the host answers, so a host that then refuses leaves the viewer admitted but with no acceptance. The next getGroup writes it back.
  • The simplespace path (pds.js, zds) is not run here.

Review

Start at the module doc in spaces/group.rs and is_member, then join_group and leave_group. tests/host/mod.rs is test scaffolding.

🤖 Generated with Claude Code

Approved an earlier version

I 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_group and leave_group don't validate input.group. getSubmissions checks valid_did, but these two don't. They don't check is_group either, so any string reaches host_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_member caches every error as a refusal. Any Err from credential_for is cached as "not a member" for 2 minutes. That includes a network error, a timeout and a failure on the viewer's own PDS. listGroups and groups_of read 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.
Asked for changes to an earlier version

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_group and leave_group do not validate input.group. get_submissions rejects a non-DID with a 400. join_group passes the string straight to call_host, which reaches host_of and a service-token request. It also does not check is_group. Return 400 for an invalid DID or a non-group before any outbound call or space write. Do the same in leave_group.
  • is_member caches every Err as 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. listGroups and groups_of read 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(
review.knotless.dev

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());
review.knotless.dev

Any Err is cached as a refusal for 2 minutes, including transient failures. Cache only an explicit host refusal.

Asked for changes to an earlier version

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_group and leave_group still don't validate input.group. Both go straight from parse to call_host, so any string reaches host_of and a service-token request. Return 400 for an invalid DID or a non-group (valid_did and is_group, as get_submissions does) before any outbound call or acceptance write.
  • is_member still caches every Err as a refusal for 2 minutes. A timeout, a network error or a failure on the viewer's own PDS hides a member's groups from listGroups and groups_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> {
review.knotless.dev

Cache only an explicit host refusal here. Don't cache transient errors as a refusal.

chadtmiller.com

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(
review.knotless.dev

Validate input.group (valid DID, is_group) with a 400 before call_host. leave_group needs the same check.

chadtmiller.com

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.

Approved an earlier version

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_group and leave_group now call a_group, which requires valid_did and is_group. It runs right after parse, before call_host, host_of or any write. A bad group gets a 400.
  • Refusal caching: is_member now caches an error only when e.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.

Approved an earlier version

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.