Permissioned Spaces

Managing Spaces

Space authority

Every space has an authority: the DID that other services resolve to find the space's host and the key that signs its credentials. Spaces created through HappyView use HappyView's service identity DID, did:web or did:plc, as both the authority and the DID in the space URI:

at://did:web:happyview.example.com/space/com.example.forum/main

The account that creates the space is its creator. The creator administers the space: only the creator, or a HappyView super admin, can update or delete it, change its member list, and manage its invites.

Because every space on an instance shares the instance's DID, a space key is unique per space type across the whole instance. A second account creating a space with the same type and skey gets 409 Conflict.

If the instance has no published service identity, HappyView anchors new spaces on the creator's DID instead. The creator's DID is then both the authority and the DID in the URI, and only HappyView can verify the space's credentials.

Publishing the space host

When a space is created, HappyView adds two entries to its DID document if they are missing:

  • #atproto_space_host, a service entry of type AtprotoSpaceHost pointing at the instance
  • #atproto_space, the verification method that signs space credentials

With a did:web identity, HappyView serves both immediately. With a did:plc identity, the operator must sync service entries to the PLC directory before other services can resolve them.

OAuth scopes

Apps reach spaces through space: OAuth scopes, which name the space's authority. The default, authority=self, covers only spaces whose authority is the signed-in account. It does not cover spaces whose authority is HappyView. Apps must request authority=<HappyView's DID>, or authority=* for any authority:

atproto space:com.example.forum?authority=did:web:happyview.example.com

A session without a covering grant receives 403 Forbidden. Scopes are fixed when a session is created, so fixing a missing grant requires a new authorization.

Creating a space

const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", {
  method: "POST",
  headers: {
    "X-Client-Key": CLIENT_KEY,
    "Authorization": `DPoP ${ACCESS_TOKEN}`,
    "DPoP": DPOP_PROOF,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    spaceType: "com.example.forum",
    skey: "main",
    displayName: "My Forum",
    description: "A place for discussion",
    readPolicy: { $type: "com.atproto.simplespace.defs#memberListPolicy" },
    writePolicy: { $type: "com.atproto.simplespace.defs#memberListPolicy" },
  }),
});
interface CreateSpaceResponse {
  uri: string;
}
const data: CreateSpaceResponse = await response.json();

Input:

FieldTypeRequiredDescription
spaceTypestring (NSID)YesThe space type; describes what this space is for. type, the earlier name, is accepted until v3.
skeystringYesSpace key; differentiates spaces of the same type
displayNamestringNoHuman-readable name
descriptionstringNoDescription of the space
readPolicyobjectNoWho can obtain credentials to read the space. See Policies. Defaults to the member-list policy.
writePolicyobjectNoWhose writes the space accepts. See Policies. Defaults to the member-list policy.
appAccessobjectNoWhich apps can obtain credentials. See App access. Defaults to open.
configobjectNoSpace configuration (see below)

Response (201):

{
  "uri": "at://did:web:happyview.example.com/space/com.example.forum/main"
}

The creator is automatically added as a member with read and write. Use com.atproto.simplespace.getSpace to retrieve the full space object.

Policies

A space has two independent policies. The read policy decides who can obtain a space credential. The write policy decides whose writes the space tracks: which repos appear in listRepos and whose write notifications HappyView accepts and forwards.

Each policy is one of:

$typeGrants access to
com.atproto.simplespace.defs#memberListPolicyMembers with the matching flag: read for the read policy, write for the write policy. The default.
com.atproto.simplespace.defs#publicPolicyEveryone
com.atproto.simplespace.defs#managingAppPolicyWhoever the managing app approves

A managing-app policy names the app's service identifier in managingApp:

{
  "$type": "com.atproto.simplespace.defs#managingAppPolicy",
  "managingApp": "did:web:forum.example.com#forum"
}

HappyView resolves the identifier's fragment to a service entry in the app's DID document, and a bare DID to its #atproto_pds entry. For each decision it calls com.atproto.simplespace.checkUserAccess at that endpoint with service auth from HappyView, passing space, user, and access (read or write). The managing-app policy requires the space's authority to be HappyView's instance DID, because the app expects the authority to sign the call.

A policy with an unknown $type fails with 400 UnsupportedPolicy.

App access

appAccess controls which apps can obtain credentials for the space:

ValueMeaning
{"$type": "com.atproto.simplespace.defs#open"}Any app. The default.
{"$type": "com.atproto.simplespace.defs#allowList", "allowed": ["https://app.example.com/client-metadata.json"]}Only apps whose attested OAuth client_id is in allowed

An unknown variant fails with 400 UnsupportedAppAccess. See App access control for how the check runs.

Space configuration

The config object supports:

FieldTypeDefaultDescription
membership_publicbooleanfalseWhether the space, its member list, and its repo list are visible without authentication
records_publicbooleanfalseDeprecated. Stored with the space but not enforced. Use a public readPolicy for a space anyone may read. Accepted until v3.

The field names are snake_case, unlike the rest of the request.

Additional fields are preserved as-is. One recognized additional field is allowedCollections (a JSON array of collection NSID strings), auto-populated at creation time from the space type lexicon's defs.main.collections. When present and non-empty, createRecord/putRecord/applyWrites reject writes (400) to any collection not on the list; deletes are never restricted. A space without this field, or with an empty list, allows writes to any collection.

Legacy policy fields

The following forms are deprecated and kept until v3:

  • policy or mintPolicy: a single policy name, "public", "member-list", or "managing-app", applied to both reads and writes. For "managing-app", the app goes in a sibling managingApp or managingAppDid field. readPolicy and writePolicy take precedence when present.
  • appAccess with type: {"type": "open"} and {"type": "allowList", "allowed": [...]} are read as the matching $type variants.

Getting a space

const response = await fetch(
  "https://happyview.example.com/xrpc/com.atproto.simplespace.getSpace?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,
    },
  },
);
const data = await response.json();

Response:

{
  "uri": "at://did:web:happyview.example.com/space/com.example.forum/main",
  "space": {
    "id": "5d1c8e0a-7b2f-4c39-8e61-3f4a9b2d7e10",
    "did": "did:web:happyview.example.com",
    "authority_did": "did:web:happyview.example.com",
    "creator_did": "did:plc:creator123",
    "type": "com.example.forum",
    "skey": "main",
    "display_name": "My Forum",
    "description": "A place for discussion",
    "read_policy": { "$type": "com.atproto.simplespace.defs#memberListPolicy" },
    "write_policy": { "$type": "com.atproto.simplespace.defs#memberListPolicy" },
    "app_access": { "$type": "com.atproto.simplespace.defs#open" },
    "config": {
      "membership_public": false,
      "records_public": false,
      "allowedCollections": ["com.example.forum.post"]
    },
    "revision": "3l2tkbx7225co",
    "created_at": "2026-09-30T12:00:00Z",
    "updated_at": "2026-09-30T12:00:00Z"
  },
  "config": {
    "$type": "com.atproto.simplespace.defs#spaceConfig",
    "readPolicy": { "$type": "com.atproto.simplespace.defs#memberListPolicy" },
    "writePolicy": { "$type": "com.atproto.simplespace.defs#memberListPolicy" },
    "appAccess": { "$type": "com.atproto.simplespace.defs#open" }
  }
}

If membership_public is false, the caller must be authenticated and be the creator or a member. Everyone else receives 404 Not Found.

dev.happyview.space.getSpace is a deprecated alias, kept until v3.

Listing spaces

Returns spaces where the authenticated user is a member.

const response = await fetch(
  "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20",
  {
    headers: {
      "X-Client-Key": CLIENT_KEY,
      "Authorization": `DPoP ${ACCESS_TOKEN}`,
      "DPoP": DPOP_PROOF,
    },
  },
);
interface SpaceView {
  uri: string;
  isOwner: boolean;
}
interface ListSpacesResponse {
  spaces: SpaceView[];
  cursor?: string;
}
const data: ListSpacesResponse = await response.json();

Parameters:

FieldTypeRequiredDefaultDescription
didstringNoauthenticated userFilter by DID
spaceTypestring (NSID)NoLists only spaces of this type. type, the earlier name, is accepted until v3.
limitintegerNo50Max spaces to return (1-100)
cursorstringNoPagination cursor

Response:

{
  "spaces": [
    {
      "uri": "at://did:web:happyview.example.com/space/com.example.forum/main",
      "isOwner": true
    }
  ],
  "cursor": "MjAyNi0wOS0zMFQxMjowMDowMFp8YXQ6Ly9kaWQ6d2ViOmhhcHB5dmlldy5leGFtcGxlLmNvbS9zcGFjZS9jb20uZXhhbXBsZS5mb3J1bS9tYWlu"
}

isOwner is true when the user is the space's creator.

Updating a space

Only the space's creator or a HappyView super admin can update a space.

const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", {
  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",
    displayName: "Updated Forum Name",
    readPolicy: { $type: "com.atproto.simplespace.defs#publicPolicy" },
  }),
});

All fields except space are optional, and take the same values as in createSpace, including the legacy policy fields. Only provided fields are updated. A supplied policy, appAccess, or config replaces the current value whole. To clear displayName or description, pass null.

The response contains the space URI and the updated space object, in the same shape as getSpace's uri and space fields.

Deleting a space

Only the space's creator or a HappyView super admin can delete a space.

const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", {
  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",
  }),
});

Response (200):

{
  "success": true
}