Reading a space
Get a delegation token from the user's PDS, exchange it at the group's host for a space credential, then read each writer's records from their own PDS.
Reading a group’s space takes three steps and involves all three kinds of server. The reference console does exactly this in readSpace and spaceCredential.
Space and record URIs
at://<authority>/space/<type>/<skey> the space
at://<authority>/space/<type>/<skey>/<writer>/<collection>/<rkey> a record in it
authorityis always a DID: the group, or for an invites space the user.- The well-known spaces use the skey
self:at://did:plc:…/space/fyi.opensocial.members/self. writeris the DID of whoever wrote the record, which isn’t necessarily the authority.
1. Delegation token, from the user’s own PDS
Through the user’s OAuth session:
GET <user's PDS>/xrpc/com.atproto.space.getDelegationToken?space=at://did:plc:…/space/fyi.opensocial.members/self
The response is { "token": "<jwt>" }. The token is short-lived (about 60 seconds), single-use, and addressed to the space’s host. The PDS only issues it if the session’s scope covers reading that space type (see Signing in).
2. Space credential, from the group’s host
Generate a fresh ES256 key for this credential. The credential will be bound to it. Then exchange the token at the authority’s PDS, which for a group is its host:
POST https://host.opensocial.fyi/xrpc/com.atproto.space.getSpaceCredential
Authorization: Bearer <delegation token>
DPoP: <proof signed by your fresh key: htm=POST, htu=this URL>
Content-Type: application/json
{ "space": "at://did:plc:…/space/fyi.opensocial.members/self" }
The response is { "credential": "<jwt>" }, valid for about two hours and bound to your key.
| Error | Meaning |
|---|---|
UserNotAuthorized |
The user’s roles don’t include one the space’s access record lets read. For members, this means not a member. |
SpaceNotFound, SpaceDeleted |
No such space under this group. |
InvalidDelegationToken |
Expired, reused, or addressed elsewhere. Get a new one. |
3. Read the records
Every read carries the credential and a fresh DPoP proof that includes ath, the hash of the credential:
Authorization: DPoP <credential>
DPoP: <proof: htm, htu, ath = base64url(sha256(credential))>
Records live with whoever wrote them, so a read is two hops:
GET https://host.opensocial.fyi/xrpc/com.atproto.space.listRepos?space=<space uri>
→ { "repos": [{ "did": "did:plc:group…" }, { "did": "did:plc:alex…" }, …] }
GET <each writer's PDS>/xrpc/com.atproto.space.listRecords?space=<space uri>&repo=<writer did>&collection=fyi.opensocial.membership
→ { "records": [{ "collection": "…", "rkey": "…", "cid": "…", "value": { … } }], "cursor": … }
listReposgoes to the group’s host. It says who has written into the space.listRecordsgoes to each writer’s own PDS, found from their DID document’s#atproto_pds. Records the group wrote come from the host. A member’s acceptance or RSVP comes from that member’s PDS.collectionis optional inlistRecords: leave it out to get every record the writer has in the space, each tagged with itscollection.getRecordtakesspace,repo,collectionandrkey.- The same credential works on every PDS. Because it has no audience, it’s bound to your key: a bearer credential could be replayed against every host.
A DPoP proof
A DPoP proof (RFC 9449) is a JWT signed by your key, with the public key in its header:
import { JoseKey } from "@atproto/jwk-jose";
const b64url = (b: ArrayBuffer) =>
btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
async function dpopProof(key: JoseKey, htm: string, url: string, credential?: string, nonce?: string) {
const htu = new URL(url);
htu.search = "";
htu.hash = "";
const ath = credential
? b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(credential)))
: undefined;
return key.createJwt(
{ alg: "ES256", typ: "dpop+jwt", jwk: key.bareJwk },
{
jti: b64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
htm,
htu: htu.href,
iat: Math.floor(Date.now() / 1000),
...(ath ? { ath } : {}),
...(nonce ? { nonce } : {}),
},
);
}
const key = await JoseKey.generate(["ES256"]);
Putting it together
A sketch of the reference console’s readSpace. pdsFor(did) resolves a DID document and returns its #atproto_pds endpoint.
async function spaceCredential(session: OAuthSession, space: string) {
const { token } = await new Client(session).call(com.atproto.space.getDelegationToken, { space });
const authority = space.match(/^at:\/\/(did:[^/]+)\/space\//)![1];
const key = await JoseKey.generate(["ES256"]);
const url = `${await pdsFor(authority)}/xrpc/com.atproto.space.getSpaceCredential`;
const res = await fetch(url, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
dpop: await dpopProof(key, "POST", url),
"content-type": "application/json",
},
body: JSON.stringify({ space }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? `HTTP ${res.status}`);
const { credential } = await res.json();
// A fetch that signs every request with the credential and this key.
const signed: typeof fetch = async (input, init) => {
const req = new Request(input, init);
req.headers.set("authorization", `DPoP ${credential}`);
req.headers.set("dpop", await dpopProof(key, req.method, req.url, credential));
return fetch(req);
};
return signed;
}
async function readSpace(session: OAuthSession, space: string, collections: string[]) {
const signed = await spaceCredential(session, space);
const authority = space.match(/^at:\/\/(did:[^/]+)\/space\//)![1];
const q = (params: Record<string, string>) => new URLSearchParams({ space, ...params });
const { repos } = await (
await signed(`${await pdsFor(authority)}/xrpc/com.atproto.space.listRepos?${q({})}`)
).json();
const out: { repo: string; collection: string; rkey: string; cid: string; value: any }[] = [];
await Promise.all(
repos.flatMap((r: { did: string }) =>
collections.map(async (collection) => {
const url = `${await pdsFor(r.did)}/xrpc/com.atproto.space.listRecords?${q({ repo: r.did, collection })}`;
const res = await signed(url);
if (!res.ok) return;
for (const rec of (await res.json()).records)
out.push({ repo: r.did, collection, rkey: rec.rkey, cid: rec.cid, value: rec.value });
}),
),
);
return out;
}
A record’s URI is at://<authority>/space/<type>/<skey>/<repo>/<collection>/<rkey>. This sketch leaves out caching, the 401 retry below and pagination (cursor).
Caching and expiry
- Cache the credential and its key per (user DID, space).
- On a
401, get a new credential once and retry. A401that survives the retry means the user lost access (they left, were ejected, or the space’saccesschanged), so drop the cache entry and show them that. - Don’t cache a failed exchange.