Permissioned Spaces

Members

The member list records who can read and write within a space. Each member has two independent flags:

  • read: the member can read the space's records, and can obtain space credentials under a member-list read policy.
  • write: the member can write records into the space, and is admitted as a writer under a member-list write policy.

A member can hold either flag, both, or neither. The space's policies decide whether the member list is consulted at all.

Only the space's creator or a HappyView super admin can change the member list.

Setting a member

com.atproto.simplespace.putMember adds a member, or replaces the flags of an existing member. Both flags are required, so a call never grants or withdraws access by default.

const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.putMember", {
  method: "POST",
  headers: {
    "X-Client-Key": CLIENT_KEY,
    "Authorization": `DPoP ${ACCESS_TOKEN}`,
    "DPoP": DPOP_PROOF,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    space: "at://did:web:happyview.example.com/space/com.example.forum/main",
    did: "did:plc:newmember",
    read: true,
    write: true,
  }),
});
interface Member {
  id: string;
  space_id: string;
  did: string;
  access: { read: boolean; write: boolean };
  is_delegation: boolean;
  granted_by: string | null;
  created_at: string;
}
const data: { member: Member } = await response.json();

Input:

FieldTypeRequiredDefaultDescription
spacestringYesThe space URI
didstringYesDID of the member, or a space URI for delegation
readbooleanYesWhether the member can read the space
writebooleanYesWhether the member can write to the space
isDelegationbooleanNofalseWhether this member is a delegated space (HappyView extension)

Response (201):

{
  "member": {
    "id": "0b7f6a52-4f0e-4d1e-9f3c-2a8f1e6d9c41",
    "space_id": "5d1c8e0a-7b2f-4c39-8e61-3f4a9b2d7e10",
    "did": "did:plc:newmember",
    "access": { "read": true, "write": true },
    "is_delegation": false,
    "granted_by": "did:plc:creator123",
    "created_at": "2026-09-30T12:00:00Z"
  }
}

Setting read to false revokes the member's outstanding space credentials. See Revocation.

Members limited to reading their own records keep that limit when putMember updates them. The limit is set with the read_self access word, through legacy addMember or the Lua API.

Removing a member

Removing a member also revokes their outstanding space credentials. Removing a DID that is not a member fails with 404.

const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember", {
  method: "POST",
  headers: {
    "X-Client-Key": CLIENT_KEY,
    "Authorization": `DPoP ${ACCESS_TOKEN}`,
    "DPoP": DPOP_PROOF,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    space: "at://did:web:happyview.example.com/space/com.example.forum/main",
    did: "did:plc:newmember",
  }),
});

Response (200):

{
  "success": true
}

Listing members

const response = await fetch(
  "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=at://did:web:happyview.example.com/space/com.example.forum/main",
  {
    headers: {
      "X-Client-Key": CLIENT_KEY,
      "Authorization": `DPoP ${ACCESS_TOKEN}`,
      "DPoP": DPOP_PROOF,
    },
  },
);
interface ResolvedMember {
  did: string;
  read: boolean;
  write: boolean;
}
const data: { members: ResolvedMember[] } = await response.json();

If the space's membership_public config is true, this endpoint is accessible without authentication. Otherwise, the caller must be authenticated and be a member.

The response returns the resolved member list, with delegation chains traversed and flattened:

{
  "members": [
    { "did": "did:plc:creator123", "read": true, "write": true },
    { "did": "did:plc:delegated-user", "read": true, "write": false },
    { "did": "did:plc:newmember", "read": true, "write": true }
  ]
}

Delegation

A space can be added as a member of another space by setting isDelegation: true and passing the delegated space's URI as did. The delegated space's members become members of this space, with the flags they hold in the delegated space.

const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.putMember", {
  method: "POST",
  headers: {
    "X-Client-Key": CLIENT_KEY,
    "Authorization": `DPoP ${ACCESS_TOKEN}`,
    "DPoP": DPOP_PROOF,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    space: "at://did:web:happyview.example.com/space/com.example.forum/main",
    did: "at://did:web:happyview.example.com/space/com.example.team/engineering",
    read: true,
    write: false,
    isDelegation: true,
  }),
});

Delegation chains are resolved up to 10 levels deep. When a user appears in several chains, each flag is combined separately: the user can read if any path grants read, and can write if any path grants write.

Example: nested teams

In this example, the forum's resolved member list contains:

  • Alice with read and write (write from Engineering)
  • Bob with read and write (via Engineering)
  • Carol with read (via Design)

Legacy addMember

com.atproto.simplespace.addMember and dev.happyview.space.addMember are deprecated and kept until v3. They take a single access word in place of the two flags:

accessEquivalent
writeread: true, write: true
read (default)read: true, write: false
read_selfReads limited to the member's own records
noneread: false, write: false

addMember fails with 409 Conflict when the DID is already a member. Use putMember to change an existing member.