A group is started on grain's group host without leaving grain
ClosedThis ports createGroup and getGroupHost onto grain-serve. create_group has the founder's PDS sign service auth for fyi.opensocial.provisionGroup. It then keeps the session the host hands over, opens the pool, writes the profile as the group, and writes the founder's acceptance. oauth::provisioned accepts the session only if the host is the account's own authorization server, and I found no hole in the flow. Checks were still queued when I looked, and I did not see the diff's test and lexicon files. I read the Rust source only.
One thing should change: in oauth::provisioned the DPoP-nonce resend reuses the client assertion.
- The problem.
client.fields(&at)runs once, beforeattempt, so the resend afteruse_dpop_nonceposts the sameclient_assertionwith the samejti. The existingpostcallsfieldsinsideattempt, so each try gets a new assertion. - Why it matters. A host is likely to answer
use_dpop_nonceto the first request from a fresh key, since the nonce cache has nothing for it. A host that remembers assertionjtis, as the reference atproto OAuth provider does, may refuse the resend as a replay. Group creation would then fail asCreateFailedagainst a real host, while the stand-in in the test host does not trackjti. - The fix. Build the body, including the fields, inside
attempt. Have the stand-in host reject a repeatedjtiso the test covers it.
One smaller point: in spaces/group.rs, the doc comment "The group actor, a DID or handle, names…" now sits above a_group instead of group_named, so it describes the wrong function. Move it back.
crates/grain/src/oauth.rsline 789Outdated
) -> Result<Session> { | |
let key = P256Key::generate(); | |
let at = origin(url); | |
let mut sent = body.clone(); |
fields (and its jti) is computed once here and reused by the nonce resend in attempt(1). Build the body with fresh client.fields(&at) inside attempt, as post does, so a host that rejects replayed assertion jtis accepts the retry.
Both points from my earlier review are done.
- Client assertion on resend: in
oauth::provisioned,client.fields(&at)now runs insideattempt, so the resend afteruse_dpop_noncecarries a freshclient_assertionandjti. The DPoP proof is also rebuilt on each try and picks up the cached nonce. The stand-in host intests/host/mod.rsnow recordsjtis and rejects a repeat, so the test covers the resend. - Doc comment: the "The group
actor…" comment is back abovegroup_named.
I didn't read the whole diff this round. I checked the changed areas and found nothing new. I did not look at the checks.
Nothing new needs changing since my approval at 4060fd1. I read only the diff from that commit to 6797ea7 and did not look at the rest of the pull again. The checks were still queued, so I don't know whether they pass.
starter: it now returnsOption<Starter>, and a host that fails to describe itself is logged and not cached. The next call tries again, and bothgetGroupHostandcreate_grouptreat it as "no group host". Behaviour is the same as before and the code is simpler.- Test host: the stand-in now hands the
stolenaccount's token back as a whole-body token response withsub, which covers that contract. It also delays the declaration by 500 ms, so the wait for the declaration to reach the index is exercised, though the 5 s timeout is still not. - Unreachable host: the test adds a group host that does not answer. It checks
{}fromgetGroupHostandNotSupportedfromcreateGroup.
Landed on feature/rust in batch/three (…/3mzbdo7hmjf2b, 236806f), with its head 6797ea7 unchanged.
What changes
social.grain.group.createGroupandgetGroupHostare served by grain-serve (crates/grain-serve/src/spaces/group.rs), under the spaces'unsharedlayer, the write bywrite::writer.--group-host(GRAIN_GROUP_HOSTin deploy) names the group host that has agreed to found groups for grain;getGroupHostanswers its URL and the handle domain itsdescribeServergives, and{}when none is set or the host does not describe itself (logged, and asked again next time).Starting a group: the founder's PDS signs service auth for
fyi.opensocial.provisionGroup; grain posts to it as its confidential OAuth client (a fresh assertion addressed to the host's origin on each try, a DPoP proof of a key made for the session, a nonce answered once) and keeps the session the host hands over as the group's web session (grain::oauth::provisioned,login::provisioned). It is taken only if the host is the group's own authorization server. Then the founder has the host open the pool (fyi.opensocial.createSpaceviacall_host), grain writes the group'ssocial.grain.actor.profileas the group (write::put, factored out ofputRecord, so it is indexed at once), the founder's acceptance is written, and the answer waits up to 5 s for the host's declaration to reach the index. Refusals:NotSupported,InvalidName,HandleUnavailable,CreateFailed,PoolFailed, now declared in the lexicon.Why
The two methods were the last of the group methods not ported. Behaviour follows the old server's
server/xrpc/createGroup.ts,getGroupHost.tsandhelpers/groupHost.ts; the lexicons are already undersocial.grain.groupand are not on main, so addingerrorsand ahandleDomaindescription is not a change to a published schema.Knotless: there is no counterpart to a session handed over by another server, so
oauth::provisionedis built from grain's ownoauth.rs(post,proof,Client::fields,server,made's check that the account logs in where it says), which is knotless'soauth.rs. Knotless's space code (Admin::create_ofwithcom.atproto.space.createSpace/ memberspace) does not apply to the pool: a group's spaces are made by its host, whichfyi.opensocial.createSpaceasks, as joinGroup/leaveGroup already do throughcall_host.How it was checked
A new spaces test,
a_group_is_started_on_the_group_host, against a stand-in founding host (tests/host/mod.rs,Starter): it makes each group an account on the reference PDS, moves its DID document to itself, and as the group's authorization server hands grain a token it trades for the group's session on the way through. It asks for a DPoP nonce first and refuses a client assertion it has seen, and its declaration reaches the index a moment after it answers. The test covers no host and an unanswering host ({},NotSupported), a bad name, a handle refused, a host handing over (as a whole-body token response) an account it does not log in (CreateFailed), success (handle, what the host was sent, the kept session, the profile and membership ongetGroup, the pool readable), and a pool refused. Settings parse--group-host. All five checks pass on the runner.Shortcomings
sessionor whole-body token response withsub).social.grain.unspecced.createGroup/getGroupHost, as it does the other group methods.Review
Start at
create_groupinspaces/group.rs, thenoauth::provisioned.🤖 Generated with Claude Code