MenuWriting records

Writing records

A member writes their own records into a group's space from their own PDS. Records that must come from the group are written with a credential for the group DID.

There are two kinds of write, and they go to different places.

Who authors it Examples Where it goes
A member their acceptance, an RSVP, a forum post the member’s own PDS, through their OAuth session
The group an event on the group calendar, a pinned post the group’s host, with a credential for the group DID

A member’s own records

A member writes into a group’s space from their own PDS, with repo set to their own DID:

await new Client(session).call(com.atproto.space.putRecord, {
  space: "at://did:plc:group…/space/fyi.opensocial.members/self",
  repo: session.did,
  collection: "fyi.opensocial.acceptance",
  rkey: "self",
  record: { $type: "fyi.opensocial.acceptance", createdAt: new Date().toISOString() },
});
  • The user’s scope must allow the write. basePermissions covers acceptances, calendarPermissions covers RSVPs.
  • Use putRecord with a stable rkey when a person has one record per thing: one acceptance per group, one RSVP per event (rkey = the event’s rkey). Changing it is then an overwrite.
  • A member’s own PDS stores the record even if they aren’t a member. The group’s host refuses it only afterwards. Don’t treat a successful write as proof of membership.
  • Leaving: delete the acceptance (com.atproto.space.deleteRecord, same space/repo/collection/rkey) before calling leaveGroup. Once they’ve left, the space no longer takes their writes.

Writing as the group

Records the group itself must author, like a calendar event or a pin, are written with a credential for the group DID. There are two ways to get one.

With your own account: getGroupAuth

A short-lived credential for one space, with no sign-in flow. The member’s roles decide what it can write, according to that space’s access.credentialScopes.

POST https://host.opensocial.fyi/xrpc/fyi.opensocial.getGroupAuth
Authorization: Bearer <service auth, lxm=fyi.opensocial.getGroupAuth>
DPoP: <proof signed by a fresh key: htm=POST, htu=this URL>
Content-Type: application/json

{ "group": "did:plc:…", "space": "at://did:plc:…/space/fyi.opensocial.events/self" }
{
  "did": "did:plc:…",
  "accessJwt": "<jwt, bound to your key>",
  "pds": "https://host.opensocial.fyi",
  "collections": ["community.lexicon.calendar.event", "fyi.opensocial.eventImage"],
  "repoCollections": ["community.lexicon.calendar.event"],
  "scope": "atproto space:fyi.opensocial.events?authority=did:plc:…&skey=self&collection=…&action=create&action=update&action=delete …",
  "expiresAt": "…"
}

collections are what the token may write in the space. repoCollections are what it may write in the group’s public repo. The token lasts 15 minutes. Use it at pds (the group’s host) with repo set to the group’s DID:

POST https://host.opensocial.fyi/xrpc/com.atproto.space.createRecord
Authorization: DPoP <accessJwt>
DPoP: <proof with the same key: htm, htu, ath>
Content-Type: application/json

{
  "space": "at://did:plc:…/space/fyi.opensocial.events/self",
  "repo": "did:plc:…",
  "collection": "community.lexicon.calendar.event",
  "record": { "$type": "community.lexicon.calendar.event", "name": "Saturday coffee ride", "startsAt": "…", "createdAt": "…" }
}

If the response is a 401 with a DPoP-Nonce header, repeat the request once with that nonce in the proof.

The two calls together:

async function getGroupAuth(session: OAuthSession, group: string, space: string) {
  const key = await JoseKey.generate(["ES256"]);
  const url = "https://host.opensocial.fyi/xrpc/fyi.opensocial.getGroupAuth";
  const { token } = await new Client(session).call(com.atproto.server.getServiceAuth, {
    aud: "did:web:host.opensocial.fyi#opensocial",
    lxm: "fyi.opensocial.getGroupAuth",
  });
  const res = await fetch(url, {
    method: "POST",
    headers: {
      authorization: `Bearer ${token}`,
      dpop: await dpopProof(key, "POST", url),
      "content-type": "application/json",
    },
    body: JSON.stringify({ group, space }),
  });
  if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? `HTTP ${res.status}`);
  return { ...(await res.json()), key };
}

async function createRecordWithDpop(grant: { accessJwt: string; pds: string; key: JoseKey }, input: object) {
  const url = `${grant.pds}/xrpc/com.atproto.space.createRecord`;
  const send = async (nonce?: string) =>
    fetch(url, {
      method: "POST",
      headers: {
        authorization: `DPoP ${grant.accessJwt}`,
        dpop: await dpopProof(grant.key, "POST", url, grant.accessJwt, nonce),
        "content-type": "application/json",
      },
      body: JSON.stringify(input),
    });
  let res = await send();
  const nonce = res.headers.get("dpop-nonce");
  if (res.status === 401 && nonce) res = await send(nonce);
  if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? `HTTP ${res.status}`);
  return res.json(); // { uri, cid }
}

dpopProof is the helper from Reading a space.

getGroupAuth fails with Forbidden when the space’s access record grants the caller’s roles nothing. For example, the members space deliberately grants no role any write.

As a session: sign in as the group

An app can also run ordinary OAuth with the group’s handle:

await oauthClient.signIn("rainshadow-riders.opensocial.fyi", { scope });

The host notices the handle belongs to a group. It asks the person to sign in with their own account, shows a consent screen naming the group, the person and their role, and issues a session for the group DID. The session is narrowed to what the person’s roles allow: scopes their roles don’t permit are dropped silently, not refused. So check the granted scope (session.getTokenInfo()) before offering actions.

Use this for apps with an “acting as the group” mode. Use getGroupAuth for occasional writes from a member’s normal session.

Creating a space

A modality app creates its space the first time a group needs it:

POST https://host.opensocial.fyi/xrpc/fyi.opensocial.createSpace
Authorization: Bearer <service auth, lxm=fyi.opensocial.createSpace>

{
  "group": "did:plc:…",
  "type": "fyi.opensocial.events",
  "skey": "self",
  "name": "Events",
  "readableBy": ["member"],
  "credentialScopes": [
    { "role": "admin", "scopes": ["community.lexicon.calendar.event", "fyi.opensocial.eventImage"] }
  ]
}

The response is { "uri": "at://did:plc:…/space/fyi.opensocial.events/self" }. In credentialScopes, "*" means every collection in the space. The caller needs space.create. The host creates the space, writes its access record from readableBy and credentialScopes, and adds it to the group’s space index (a fyi.opensocial.space record in members). Check that index first, so you don’t create a second one.