MenuReading a space

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
  • authority is 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.
  • writer is 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": … }
  • listRepos goes to the group’s host. It says who has written into the space.
  • listRecords goes 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.
  • collection is optional in listRecords: leave it out to get every record the writer has in the space, each tagged with its collection. getRecord takes space, repo, collection and rkey.
  • 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. A 401 that survives the retry means the user lost access (they left, were ejected, or the space’s access changed), so drop the cache entry and show them that.
  • Don’t cache a failed exchange.