# opensocial.group > A proposed standard for groups on atproto: a group is a DID with a set of permissioned spaces under it, with its own identity, members, roles and rules. Everything below plus every lexicon schema: https://docs.opensocial.fyi/llms-full.txt ## Names on the live network The standard names its lexicons `group.opensocial.*`. Until the opensocial.group domain can publish them, the reference host serves the same schemas as `fyi.opensocial.*`. When talking to a real host (https://host.opensocial.fyi), use `fyi.opensocial.*` everywhere: method NSIDs, space types, record `$type`s and OAuth scopes. The docs use the standard names; the schemas in llms-full.txt use the published ones. ## Introduction https://docs.opensocial.fyi/docs/introduction A shared definition of a group on atproto, so the same group can show up, with the same members and roles, in every app it uses. A cycling club posts photos in one app, plans rides in another and talks through routes in a forum. Today each of those apps has its own copy of the club: its own member list, its own admins, its own idea of who's allowed to do what. Change one and the others don't notice. `opensocial.group` proposes that the club exists **once**, as its own account on the network, and every app reads the same group. That means one identity, one membership, one set of roles, and one set of rules. The proposal calls these **atmospheric groups**: groups that span applications and modalities, and may also have a web presence of their own. ### What the standard is Because a group spans apps, those apps need a shared understanding of what a group is and how to talk to it. `opensocial.group` provides that as a suite of **lexicons** describing: - **space types**: the containers a group's data lives in - **record types**: the profile, roles, memberships, rules and labels inside those spaces - **methods**: the calls an app makes to a group's host to join, assign roles, moderate and so on It builds on the [permissioned data protocol](https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data), and in particular on its **spaces** primitive. That protocol deliberately says nothing about what applications mean by their data. This standard adds just the concepts a group needs on top. ### What it covers The proposal focuses on three areas: - **[Roles & access](/docs/roles-and-access).** Who is in the group, what they may do, and who can read which space. - **[Presence & discovery](/docs/presence-and-discovery).** How a group shows up and gets found: its profile, its rules and its public declaration. - **[Moderation](/docs/moderation).** Every group is a moderation service over its own content. It also covers the paths in and out: [membership](/docs/membership), [joining and invites](/docs/joining-and-invites), and [writing as the group](/docs/writing-as-the-group) when a record has to come from the group itself. ### What it leaves alone The standard defines the **interface between a group and an application**. It doesn't define how a group governs itself: voting, holding periods, who elects whom. It also doesn't define what a forum thread or a calendar event looks like. Those belong to the group and to the apps. See [Design posture](/docs/design-posture). ### Where to go next - [Core concepts](/docs/core-concepts): the whole model on one page. - [Spaces & records](/docs/reference/records): the lexicons, generated from the schemas the reference host serves. - [The proposal](https://tangled.org/opensocial.group/proposal): the source these docs follow. ## Core concepts https://docs.opensocial.fyi/docs/core-concepts The whole model in one page. A group is a DID with a set of permissioned spaces under it. > An atmospheric group is a DID with a bunch of permissioned spaces under it. That sentence from the proposal is most of the model. The rest of this page unpacks it. ### The group is an account A group has its own **DID**, just like a person. Apps resolve it the same way and find the server that hosts it. Nothing about the identifier says "group". What marks it as one is the records it publishes. ### Spaces hold the data A **space** is the permissioned-data protocol's container: a set of records under one DID, readable only by those the space allows. A group has: - a **`meta`** space for its public face: profile, description, rules - a **`members`** space for membership and access: members, roles, permissions, and an index of the group's other spaces - one **modality space per app or kind of activity**: a calendar, a forum, a photo pool. Its records use that app's own lexicons. The two `opensocial.group` spaces hold records *about* the group. App data never goes in them. See [Spaces](/docs/spaces). ### Group hosts Groups run on **`opensocial.group`-compliant space hosts**: servers that understand the standard's records and methods natively and enforce them. A group should be able to move from one host to another. A compliant host sits *alongside* the permissioned-data protocol's `simplespace` implementation that every PDS runs, not on top of it. Most PDSes aren't expected to be group hosts. ### Roles, actions and access Authorization is **flat role-based access control**. A group defines roles, gives each member one or more of them, and binds each role to a set of **actions** such as `invite`, `admit`, `label` or `group.configure`. A member may do whatever any of their roles allows. There are no deny rules and no precedence. Every space carries an **`access`** record saying which roles may read it. Everything else about writing in a modality space (who may post, pin or create an event) is up to that app's own lexicon, though it can refer to the group's roles. See [Roles & access](/docs/roles-and-access). ### Membership takes two The group writes a **`membership`** record for a member, and the member writes an **`acceptance`** record of their own. See [Membership](/docs/membership). ### Terms | Term | Meaning | | --- | --- | | **Group** | A DID with `opensocial.group` spaces under it, hosted on a compliant host. | | **Group authority** | The group DID acting through its host. It authors the group's own records. | | **Space** | A permissioned container of records under a DID, from the permissioned data protocol. | | **Modality** | A kind of activity an app provides, such as events, forum threads or photos. Each gets its own space. | | **Role** | A named set of actions, declared by the group. Members hold one or more. | | **Action** | A standardized permission, such as `admit` or `space.create`. The full list is in [Actions](/docs/reference/actions). | | **Steward** | A member whose roles let them manage the group or act on its behalf. | ## Design posture https://docs.opensocial.fyi/docs/design-posture The standard specifies the interface between groups and apps, keeps it as small as it can, and aims at the common case. These principles decide what goes in the standard and what stays out. ### An interface, not a governance model The standard defines the **interface between the group and the application**. The permissioned data protocol avoids describing application semantics, and this standard likewise avoids describing both **group governance** and **application semantics**. That isn't because they matter less. They matter too much to fix in place. By not standardizing them, the standard leaves group stewards free to implement them however suits the group. A group may run arbitrarily complex governance behind the interface, such as votes, holding periods for sensitive actions, or councils. None of that is visible to or required of apps. The group projects the outcome onto the standard's records: someone gains a role, a rule changes, a member is ejected. ### As minimal as possible, but no more Standards like this are hard to change. Keeping it small gives the best chance for experimentation and growth, and for it to fit many different groups. In practice this means a short, fixed list of [actions](/docs/reference/actions), two well-known spaces, and no attempt to model what apps do inside their own spaces. ### Aim at the common case Every group is different, and some have unusual needs. Building all of them into the standard would make it more complex for every implementer. Instead the standard targets the **90% case**: *most* groups should map onto it directly. A group that needs something more complicated can still do whatever it wants in its own backend. It then **projects** that state onto the standard's records, so apps keep working. ## Spaces https://docs.opensocial.fyi/docs/spaces Two well-known spaces about the group, plus one space for each app or modality the group uses. A group's data is divided into spaces, and the standard fixes only two of them. Both hold records *about* the group, never app data. ### The `meta` space [`group.opensocial.meta`](/docs/lexicons/meta) is the group's **public face**: its profile, description, avatar and rules, and how to get in. It concerns the group's overall presence and discoverability. Many groups make `meta` public even when everything else is private. It's still a *space*, just one whose access record allows public reads. Today that means any signed-in account: nobody can read a space while signed out. See [Presence & discovery](/docs/presence-and-discovery). | Record | Purpose | | --- | --- | | [`profile`](/docs/lexicons/profile) | Name, description, avatar, banner, join policy. Record key `self`. | | [`rule`](/docs/lexicons/rule) | One rule each, with a stable URI a moderation action can cite. | | [`access`](/docs/lexicons/access) | Who may read this space. | ### The `members` space [`group.opensocial.members`](/docs/lexicons/members) holds **members-only information**: the member list and each member's acceptance, the roles and what they allow, and an index of the group's other spaces. It concerns roles and access, and is usually readable by members only. | Record | Purpose | | --- | --- | | [`role`](/docs/lexicons/role) | Declares a role. The record key is the role id. | | [`permissions`](/docs/lexicons/permissions) | Binds roles to actions. Record key `self`. | | [`membership`](/docs/lexicons/membership) | The group's side of membership. The record key is the member's DID. | | [`acceptance`](/docs/lexicons/acceptance) | The member's side, written by the member. | | [`space`](/docs/lexicons/space) | One entry per space under the group: the index of what exists. | | [`access`](/docs/lexicons/access) | Who may read this space. | ### Modality spaces Everything members *do* together lives in a separate space per app or modality. A group that wants an events calendar gets a calendar space holding `community.lexicon.calendar.event` records, and a forum gets a forum space holding threads. The standard doesn't specify these space types. They belong to their apps. The ones apps share today, such as the calendar, are listed under [Shared modalities](/docs/reference/modalities). A space is created with [`createSpace`](/docs/lexicons/createSpace), which requires the `space.create` action. It gets its own `access` record and is added to the index in `members`. Apps discover what a group has by reading that index. ### Every space has an `access` record Whether it's `meta`, `members` or a modality space, each space holds an [`access`](/docs/lexicons/access) record (key `self`) saying **which roles may read it**. The host enforces reads. The same record also lists, per role, which OAuth scopes a member may request when [writing as the group](/docs/writing-as-the-group) in that space. Anything beyond reading (who may post, who may pin a thread, who may create an event) is a **modality-specific** feature and belongs to that modality's own lexicon. See [Roles & access](/docs/roles-and-access). ### Space types at a glance | Space type | Key | Hosted by | Purpose | | --- | --- | --- | --- | | [`group.opensocial.meta`](/docs/lexicons/meta) | `self` | the group | Profile, rules, how to get in. | | [`group.opensocial.members`](/docs/lexicons/members) | `self` | the group | Roles, who holds them, authz config, space index. | | [`group.opensocial.invites`](/docs/lexicons/invites) | `self` | **each user** | Where invites arrive. See [Joining & invites](/docs/joining-and-invites). | | *modality spaces* | any | the group | Defined by apps, e.g. a calendar or forum. | ## Roles & access https://docs.opensocial.fyi/docs/roles-and-access Flat role-based access. A member can do whatever any of their roles allows. Reads are enforced per space; everything else is up to the app. ### Flat RBAC Authorization is **flat role-based access control**. A group defines **many roles** and gives each member one or more of them. Each role grants a set of standardized **actions**. There are no deny rules or caveats, so authorization composes by **taking the union of all actions a member's roles allow**. There is no precedence, hierarchy or evaluation order. If any of your roles grants `invite`, you can invite. ```json { "$type": "group.opensocial.permissions", "bindings": [ { "role": "admin", "actions": [ "group.configure", "space.create", "space.configure", "space.delete", "role.assign", "eject", "invite", "admit", "mod.read", "mod.resolve", "label", "takedown" ], "assignable": ["member"] }, { "role": "moderator", "actions": ["mod.read", "mod.resolve", "label"] }, { "role": "member", "actions": [] } ] } ``` Roles are declared by [`role`](/docs/lexicons/role) records in the `members` space and bound to actions by the single [`permissions`](/docs/lexicons/permissions) record. ### Actions There are twelve actions. Each one guards a set of [methods](/docs/reference/methods). | Action | Governs | | --- | --- | | `mod.read` | See the moderation queue and subject histories. | | `mod.resolve` | Resolve or escalate a subject, add notes. | | `label` | Apply and negate labels, except `!hide` and `!takedown`. | | `takedown` | Apply and negate `!hide` and `!takedown`. | | `invite` | Issue invites. | | `admit` | Approve join requests. | | `eject` | Remove a member, bounded by `assignable`. | | `role.assign` | Grant and revoke roles, bounded by `assignable`. | | `space.create` | Create a space under the group DID. | | `space.configure` | Change a space's config, including its `access` record. | | `space.delete` | Delete a space. | | `group.configure` | Edit the profile, rules, roles and permissions. | ### Bounded actions Two actions are **parameterized by the roles they apply to**: assigning a role and ejecting a member. A binding's `assignable` list names the roles its holders may grant, revoke or eject. With `assignable: ["member"]`, a role can make someone a member and eject members, but it can't create another admin or eject one. ### Role conventions Roles may carry conventions. An app like Bluesky may treat roles named `admin` and `moderator` specially and show badges or extra controls for them. Other roles may just be flair. A group is free to ignore any convention. ### Read access, per space Every space has an [`access`](/docs/lexicons/access) record listing the roles that may **read** it. The host enforces this: it only lets a reader into a space if one of their roles is listed. ```json { "$type": "group.opensocial.access", "readableBy": ["member"] } ``` ### Everything else is the app's Who may **write** to a space, pin a thread, post in an announcements channel or create an event is **modality-specific**. These are **app features**, and each is declared in the modality's **own lexicon** within its space. Those declarations can still refer to the group's roles. This is deliberate. The group standard can't know what "pin" means in every app, but every app can say "only `moderator` may pin" using the same roles the group already has. ## Membership https://docs.opensocial.fyi/docs/membership Membership is two-sided. The group grants roles with a membership record, and the member confirms with an acceptance record of their own. Membership is defined **bidirectionally**. The group creates a [`membership`](/docs/lexicons/membership) record in the `members` space, and the member in turn creates an [`acceptance`](/docs/lexicons/acceptance) record in the same space. ### The group's side: `membership` Written by the group authority, keyed by the member's DID, and listing the member's roles. **This is what grants access.** Every read of a space, every role check and every permission to act as the group comes from this record. It is written when someone is admitted, redeems an invite or joins an open group, and it is rewritten by [`assignRoles`](/docs/lexicons/assignRoles). It is deleted when the member [leaves](/docs/lexicons/leaveGroup) or is [ejected](/docs/lexicons/ejectMember). ### The member's side: `acceptance` Written **by the member**, from their own account, into the group's `members` space. It is the member agreeing that they belong. It **gates whether the member appears in the roster**, not what they can access. A member whose `membership` exists but who hasn't written an `acceptance` has full access. They just aren't listed. And nobody can put someone on a group's roster without that person's consent. ### Finding a person's groups The acceptance is stored where everything a person writes into a space is stored: on **their own PDS**. An app answers "which groups am I in?" by asking the person's PDS which spaces of type `group.opensocial.members` they have records in. It doesn't need to crawl groups or read a public roster. ### No public roster There is no public member list. Who is in a group is visible only to those who can read its `members` space, which is usually members. ### Leaving and ejection - [`leaveGroup`](/docs/lexicons/leaveGroup): a member removes themselves. No role needed. - [`ejectMember`](/docs/lexicons/ejectMember): requires `eject`, limited to the roles in the caller's `assignable` list. Both delete the `membership` record, which ends access. A member's `acceptance` is theirs, and only they can delete it. ## Joining & invites https://docs.opensocial.fyi/docs/joining-and-invites One method to get in, three join policies, and invites delivered to a space the invitee owns. ### Join policy A group's [`profile`](/docs/lexicons/profile) sets its `joinPolicy`: | Policy | What `requestJoin` does | | --- | --- | | `open` | Admits the person immediately. | | `request` | Queues a request for someone with the `admit` action to decide. | | `invite` | Admits only someone holding an outstanding invite. Anyone else gets `InviteRequired`. | ### One way in: `requestJoin` Every path into a group goes through [`requestJoin`](/docs/lexicons/requestJoin), called by the person who wants to join. No role is needed. It either admits them (`admitted`) or leaves a request pending (`pending`). An **invite is pre-approval**. If the person has an outstanding invite, `requestJoin` redeems it and admits them whatever the policy. However they got in, the group writes their [`membership`](/docs/membership) and the new member writes their `acceptance`. #### Requests - [`listJoinRequests`](/docs/lexicons/listJoinRequests) (requires `admit`): the queue. - [`admitMember`](/docs/lexicons/admitMember) (requires `admit`): approve, optionally with roles limited by the caller's `assignable`, or deny. - [`cancelJoinRequest`](/docs/lexicons/cancelJoinRequest): the requester withdraws before a decision. ### Invites An invite is an interesting case: it has to reach someone who **isn't yet a member**, so they may have no access to any of the group's spaces. And invites usually shouldn't be broadcast publicly. So each person who wants to receive invites hosts an **`invites` space under their own DID**, of type [`group.opensocial.invites`](/docs/lexicons/invites). A group delivers an invite by writing an [`invite`](/docs/lexicons/invite) record into that space. 1. A member with the `invite` action calls [`createInvite`](/docs/lexicons/createInvite). 2. The group writes an `invite` record into the invitee's `invites` space. 3. The group notifies the invitee's PDS of the write, and the PDS forwards the notification to any services that present it to the person. 4. The invitee reads it and accepts by calling `requestJoin`, which redeems it. **Only the owner** (and apps they authorize) may read the space. The inviting group can write into it but **can't read** other invites there, including its own once written. Outstanding invites are managed with [`listInvites`](/docs/lexicons/listInvites) and [`revokeInvite`](/docs/lexicons/revokeInvite). ## Writing as the group https://docs.opensocial.fyi/docs/writing-as-the-group Some records have to be authored by the group DID. A steward gets a scoped credential for it by signing in with their own account. Some modality-specific records need to be created **by the group DID** itself. Pinning a post at the top of a forum might need a `pin` record authored by the group in the forum space. An announcement channel might accept posts only from the group. ### A credential, not a password A person who needs to write as the group, such as an admin, does so by holding an **OAuth credential for the group DID** and writing the record to the group's host. The host supports all `com.atproto.space.*` create, read, update and delete methods. This doesn't mean the person has "full access" to the group account, or that they sign in to the app as the group in the traditional sense. The group's host is also the **OAuth authorization server** for the group DID. Instead of a password, it asks the person to **sign in with their own account**. ### Scopes come from the `access` record Which OAuth scopes each role may request for the group DID is written into the [`access`](/docs/lexicons/access) record of each relevant space: ```json { "$type": "group.opensocial.access", "readableBy": ["public"], "credentialScopes": [ { "role": "admin", "scopes": ["group.opensocial.profile", "group.opensocial.rule"] } ] } ``` The host grants only what the person's roles allow in that space. An app asking for more gets less, not an error. ### Juggling credentials is the app's job Apps decide how to present this. They might offer an **account switcher** where an admin switches to acting as the group. Or they might do it **transparently**, using the group credential only when the admin does something that needs it, like pinning a post. > **Open question.** The proposal doesn't say **who** may obtain a credential for the group DID. The reference host currently admits any member and then reduces the grant to what their roles allow, so a member with no write permissions still gets a read-only credential that identifies them to an app *as the group*. > > The proposed rule is that a credential is issued only when it keeps at least one write permission. The open question is whether the standard should state this, or tie it to an action such as `group.configure`. ## Presence & discovery https://docs.opensocial.fyi/docs/presence-and-discovery A group's profile and rules live in its meta space. A public declaration lets anyone find it. ### The profile Every group publishes a [`profile`](/docs/lexicons/profile) in its `meta` space: display name, description, avatar and banner, an optional link to the group's own site, and its [join policy](/docs/joining-and-invites#join-policy). Any app that shows or interacts with groups uses this profile to display them. One profile, shown the same way everywhere. ### Rules The group's rules are [`rule`](/docs/lexicons/rule) records in the same space, one record per rule. Each has a stable URI, so a moderation label can cite exactly which rule it enforces. ### Public, but still a space Many groups want their `meta` space **public** even when the rest of the group is private. Even then, the data is still published in a **space**. The space's [`access`](/docs/lexicons/access) record simply allows public reads. A group can equally keep its profile to members only. ### The declaration A group that wants to be easy to find also publishes a [`declaration`](/docs/lexicons/declaration) to the **public broadcast protocol**, in its ordinary public repo. The declaration just points at the group's `meta` space. ```json { "$type": "group.opensocial.declaration", "meta": "at://did:plc:7x2k…/space/group.opensocial.meta/self" } ``` The declaration is for **discovery only**. It announces that the DID is a group and where to look. The profile, rules and everything else stay in spaces. > **Reading without signing in.** Under the permissioned-data alpha, reading any space needs a signed-in reader, even one open to the public. A signed-out visitor to a group's website can't read its public `meta` space directly yet. Until the permissioned-data layer allows anonymous reads, the reference host offers a public listing of the groups it hosts. That listing isn't part of the standard. ## Moderation https://docs.opensocial.fyi/docs/moderation Every group is a moderation service over its own content. Reports come in, labels go out, and a simple queue tracks what's open. Each group is also a **moderation service** with authority over all content in the group. ### Reports in, labels out Moderation works the normal atproto way: **reports come in on one side, labels come out the other**. Sometimes a group-specific action is needed too, such as [ejecting](/docs/lexicons/ejectMember) a member. **Reports** are sent with `createReport`, the same way content is reported to any moderation service, addressed to the group. ### Labels live next to their subject Labels are published as [`label`](/docs/lexicons/label) records by the group DID, **in the same space as the content** they label. A label on a forum post goes in the forum space. Anyone who can see the post can see its label, and nobody else. Labels on **accounts** go in the `members` space. A label can cite the [`rule`](/docs/lexicons/rule) it enforces, and a later label with `neg: true` negates an earlier one. The proposal anticipates a general `com.atproto` label record type. Until one exists, `group.opensocial.label` fills that role. ### Two tiers of labelling - `label` lets a member apply and negate ordinary labels. - `takedown` is needed for the two strongest labels, `!hide` and `!takedown`. A group can let many people tag content while keeping removal with a few. ### The queue On top of this, the standard specifies a simple moderation workflow. A subject (a record or an account) is **open**, **escalated** or **resolved**. | Method | Requires | Does | | --- | --- | --- | | [`listSubjects`](/docs/lexicons/listSubjects) | `mod.read` | The queue, filterable by status. | | [`getSubjectHistory`](/docs/lexicons/getSubjectHistory) | `mod.read` | Every report, label, note and decision on one subject. | | [`resolveSubject`](/docs/lexicons/resolveSubject) | `mod.resolve` | Resolve or escalate, optionally with a note. | | [`applyLabel`](/docs/lexicons/applyLabel) | `label` or `takedown` | Write a label into the subject's space. | | [`negateLabel`](/docs/lexicons/negateLabel) | `label` or `takedown` | Negate an earlier label. | Moderators can see limited information and take limited actions. The aim is to support moderation **inside ordinary apps**, not to replace dedicated tools. ### Beyond the queue Large groups may need much more complex moderation. That usually means bespoke moderation software working with the group directly, not in-app flows. The standard doesn't try to cover it. ## How an app talks to a group https://docs.opensocial.fyi/docs/build/overview The three servers involved, the names to use on the live network, and the six things an app does. This section is a working guide to building an app against a group host: a group's own website, an events app, anything that shows or acts on groups. It follows the requests the reference apps make. ### Names on the live network > **Use fyi.opensocial.* when you call a real host.** The standard names its lexicons `group.opensocial.*`. The reference host serves the same schemas as **`fyi.opensocial.*`** until the opensocial.group domain can publish them. Everything in this section uses the published names, because that's what works today: method NSIDs, space types, record `$type`s and OAuth scopes. | Setting | Value | | --- | --- | | Host URL | `https://host.opensocial.fyi` | | Host DID | `did:web:host.opensocial.fyi` | | Service audience for group methods | `did:web:host.opensocial.fyi#opensocial` | | Namespace | `fyi.opensocial` | ### Three kinds of server Every request goes to one of three places. Getting this right is most of the work. | Server | What it does for your app | | --- | --- | | **The user's own PDS** | Signs them in (OAuth). Mints *service auth* for calling the group host and *delegation tokens* for reading spaces. Stores every record the user writes, including their records inside a group's spaces. | | **The group's host** | The group's PDS: the group DID's `#atproto_pds` endpoint points here. Serves the group API (`fyi.opensocial.*`), exchanges delegation tokens for space credentials, lists who has written into each space, and stores records the group itself writes. | | **Other members' PDSes** | Store *their* records inside the group's spaces. To read a space you read from each writer's PDS. | A group's data isn't in one place. The group's profile, roles and events live with the group. A member's RSVP or acceptance lives with that member. ### What an app does 1. **[Sign the user in](/docs/build/sign-in)** with OAuth, asking for the group permission sets and the host methods you call. 2. **[Find groups](/docs/build/finding-groups)**: from declarations on the network, the host's public listing, or the spaces on the user's own PDS. 3. **[Read a space](/docs/build/reading-spaces)**: delegation token, then space credential, then records from each writer. 4. **[Call group methods](/docs/build/calling-the-host)** with service auth: join, invite, admit, moderate. 5. **[Write records](/docs/build/writing)**: a member's own records into a group space, or records *as the group*. 6. Put it together: **[a group's events page](/docs/build/example-events)**. ### Setting up a project Spaces, space scopes and the `com.atproto.space.*` methods exist only in the **spaces alpha** builds of the atproto packages. Pin them exactly, and override the transitive ones too. Each package also has a plain `0.0.0` release that satisfies a `^0.0.0-spaces-alpha-…` range and can't be installed. ```json { "dependencies": { "@atproto/oauth-client-browser": "0.0.0-spaces-alpha-20260915165437", "@atproto/lex": "0.0.0-spaces-alpha-20260915165437", "@atproto/lex-client": "0.0.0-spaces-alpha-20260915165437", "@atproto/jwk-jose": "^0.2.4" }, "overrides": { O0.0.0-spaces-alpha-20260915165437RIDES } } ``` (`overrides` is npm's field. With pnpm, put the same map under `pnpm.overrides`.) `@atproto/jwk-jose` has no alpha build; the regular release works. #### Imports The snippets in these guides use: ```ts ``` #### Typed calls for `com.atproto.space.*` `new Client(session).call(com.atproto.space.getDelegationToken, …)` needs generated code for those methods. Save the schemas you call into `./lexicons/`, one JSON file per NSID (`lexicons/com/atproto/space/getDelegationToken.json`, …), then generate: ```sh npx lex build --lexicons ./lexicons --out ./src/lexicons ``` The schemas are in [llms-full.txt](/llms-full.txt) under "Permissioned-data methods". Generate only the ones you call: a schema that refers to another (the calendar event refers to `community.lexicon.location.*`) needs that one in the folder too. If you'd rather not generate code, call XRPC through the session directly. `session.fetchHandler("/xrpc/com.atproto.space.getDelegationToken?space=…")` sends an authenticated request to the user's PDS. ### Schemas Every schema these guides mention, including the `com.atproto.space.*` methods, is in [llms-full.txt](/llms-full.txt) under the names the host serves. ## Signing in https://docs.opensocial.fyi/docs/build/sign-in The OAuth scope an app asks for, what the permission sets grant, and the mistakes that fail silently. Sign-in is ordinary atproto OAuth against the user's own PDS. What's specific to groups is the **scope**. ### The scope A group app asks for: ```ts const AUD = encodeURIComponent("did:web:host.opensocial.fyi#opensocial"); const rpc = (method: string) => `rpc:${method}?aud=${AUD}`; const scope = [ "atproto", `include:fyi.opensocial.basePermissions?aud=${AUD}`, "include:fyi.opensocial.calendarPermissions", // Every host method you call, one entry each: rpc("fyi.opensocial.requestJoin"), rpc("fyi.opensocial.getJoinRequest"), rpc("fyi.opensocial.cancelJoinRequest"), rpc("fyi.opensocial.getGroupAuth"), // Only if you list the user's groups (see Finding groups): "space:fyi.opensocial.members?authority=*&action=read_self", ].join(" "); ``` - **`include:fyi.opensocial.basePermissions`**: the group permission set. It covers reading a group's `meta` space, reading the `members` space and writing the user's own `acceptance` there, and using the user's own `invites` space. - **A modality permission set** for each kind of space you use, such as `include:fyi.opensocial.calendarPermissions` for reading an events space and writing RSVPs. - **One `rpc:` entry for every host method you call.** Each takes the same `?aud=`. - **`read_self`** on `fyi.opensocial.members`, only if you call `listSpaces` to find the user's groups. > **List the host methods you call.** [`basePermissions`](/docs/lexicons/basePermissions) grants the common host methods (see the table below), so against host.opensocial.fyi those `rpc:` entries are optional. List them anyway: > > - It covers methods the set doesn't, such as [`getJoinRequest`](/docs/lexicons/getJoinRequest) and `cancelJoinRequest`. - Sessions from before 2026-09-29 were granted an older version of the set that covered no methods. They get the methods at their next sign-in. - On a local dev network, service auth fails for a host whose DID contains an escaped port (`did:web:localhost%3A4000`) unless each method is listed explicitly. > **Encode the whole audience.** `?aud=` must be `encodeURIComponent` of the full service DID, fragment included: `did%3Aweb%3Ahost.opensocial.fyi%23opensocial`. A DID that already contains an escape (like a local `did:web:localhost%3A4000`) gets encoded again (`%253A`). > > Get this wrong and the PDS **silently drops the whole `include:`**. Sign-in succeeds, but every space read fails later. [`listGroups`](/docs/lexicons/listGroups) needs no scope: it takes no authentication. ### What the permission sets grant | Set | Grants | | --- | --- | | `fyi.opensocial.basePermissions` | Read `fyi.opensocial.meta` spaces. Read `fyi.opensocial.members` spaces and write only `fyi.opensocial.acceptance` there. Read and write `fyi.opensocial.invite` in `fyi.opensocial.invites` spaces, and create the user's own invites space. | | `fyi.opensocial.basePermissions` (host methods) | Service auth for `createGroup`, [`provisionGroup`](/docs/lexicons/provisionGroup), `createSpace`, `assignRoles`, `ejectMember`, `listGroups`, [`getGroupAuth`](/docs/lexicons/getGroupAuth), `createInvite`, `requestJoin`, `listInvites`, `revokeInvite`. | | [`fyi.opensocial.calendarPermissions`](/docs/lexicons/calendarPermissions) | Read [`fyi.opensocial.events`](/docs/lexicons/events) spaces and write `community.lexicon.calendar.rsvp` there. | Check what a session actually got with `session.getTokenInfo()`. Its `scope` lists the expanded permissions. ### The namespace rule > **A permission set can only grant space types under its own name.** A PDS expands `include:x.y.somePermissions` only into space permissions whose space type sits under `x.y`, and `rpc` permissions whose methods all sit under `x.y`. A set naming someone else's space type or method **expands to nothing** for that entry. It resolves, grants silently nothing, and the call fails later. An `rpc` entry must also use `inheritAud: true`; one with a fixed `aud` is dropped too. > > This is why a group's calendar is a `fyi.opensocial.events` space holding ordinary `community.lexicon.calendar.*` records. The space type is ours, so our permission set can grant it. The records inside stay standard, because collections aren't subject to the rule. ### A browser client during development A browser app on your own machine uses a **loopback client**: no hosted metadata, and the scope goes inside the client ID. Serve the app from `127.0.0.1`, not `localhost`. ```ts const redirectUri = "http://127.0.0.1:5191/"; const client = new BrowserOAuthClient({ clientMetadata: { client_id: `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`, redirect_uris: [redirectUri], scope, response_types: ["code"], grant_types: ["authorization_code", "refresh_token"], token_endpoint_auth_method: "none", application_type: "native", dpop_bound_access_tokens: true, }, handleResolver: "https://bsky.social", // on a local dev network: the dev PDS // Local dev network only: // plcDirectoryUrl: "http://localhost:2582", // allowHttp: true, }); const result = await client.init(); // after the redirect back: { session } await client.signIn("alex.bsky.social", { scope }); ``` In production, use a hosted client: serve `client-metadata.json` from your app's origin and use its URL as `client_id`. ### Things that go wrong - **Missing scope.** The user's PDS refuses with `403 ScopeMissingError` and names the exact scope it wanted, for example `Missing required scope "rpc:fyi.opensocial.requestJoin?aud=…"`. Add that entry. - **Only ask for permission sets that resolve.** Sign-in fails for users on other PDSes if a set's publisher has no `_lexicon` DNS record. `community.lexicon.calendar.basePermissions` is one such set; use `fyi.opensocial.calendarPermissions`. - **A hosted client's metadata must declare the full scope.** If your `client-metadata.json` declares less than you request, every login fails. Generate both from one function. - **New scopes need a new sign-in.** Existing sessions keep the scope they were granted. After you add a method or a space type, users have to sign in again. ## Finding groups https://docs.opensocial.fyi/docs/build/finding-groups How an app learns that a DID is a group, shows it to signed-out visitors, and lists the groups a person is in. ### From the network: the declaration A group that wants to be found publishes `fyi.opensocial.declaration` (record key `self`) in its **public repo**: ```json { "$type": "fyi.opensocial.declaration", "meta": "at://did:plc:7x2k…/space/fyi.opensocial.meta/self", "createdAt": "2026-09-24T17:37:00.000Z" } ``` Watch for that collection on a firehose (a relay, Jetstream, or the host's own `com.atproto.sync.subscribeRepos`) and you learn about each group as it appears. An indexer can use `fyi.opensocial.declaration` as its signal collection, so it only follows repos that are groups. The declaration only exists while the group's `meta` space is public. A group with a private `meta` publishes none. ### For signed-out visitors: `listGroups` Nobody can read a space without signing in, even a public one, because every space credential starts with a delegation token from the reader's own PDS. For signed-out pages, the reference host offers an unauthenticated listing: ```http GET https://host.opensocial.fyi/xrpc/fyi.opensocial.listGroups ``` ```json { "groups": [ { "did": "did:plc:idmhyhx3335jt2vin45xauu5", "handle": "rainshadow-riders.opensocial.fyi", "meta": "at://did:plc:idmhyhx3335jt2vin45xauu5/space/fyi.opensocial.meta/self", "members": "at://did:plc:idmhyhx3335jt2vin45xauu5/space/fyi.opensocial.members/self", "displayName": "Rainshadow Riders", "description": "Seattle-based cycling club", "url": "https://rainshadow-riders.opensocial.fyi", "joinPolicy": "fyi.opensocial.profile#request", "avatar": "bafkreifqxrjvscxf6uu23gr34uaugzyobcfdfjjajfialsyvbkorzrgo7q", "rules": [] } ] } ``` - Profile and rule fields appear only for groups whose `meta` space is public. Others return just `did`, `handle`, `meta` and `members`. - `avatar` is a CID. Show it from `https://host.opensocial.fyi/img//avatar` (or `/banner`), with `?v=` as a cache-buster. - A deactivated group comes back with `deactivated: true` and no profile. > **Not part of the standard.** [`listGroups`](/docs/lexicons/listGroups) covers a gap in the permissioned-data alpha: there are no anonymous reads. Once public spaces can be read anonymously, read the `meta` space directly instead. ### For a signed-in user: "which groups am I in?" When a member writes their `acceptance` into a group's `members` space, their PDS records that they have a repo in that space. Ask their PDS: ```http GET /xrpc/com.atproto.space.listSpaces?type=fyi.opensocial.members ``` (Through the user's OAuth session. It needs `space:fyi.opensocial.members?authority=*&action=read_self` in the scope, or the PDS refuses with `ScopeMissingError`.) The response is `{ "spaces": [{ "uri": "at:///space/fyi.opensocial.members/self" }], "cursor": … }`. > **Candidates, not proof.** `listSpaces` returns the spaces the user holds a repo in, not the spaces they're a member of. It keeps listing a space after they leave or are ejected. Confirm each one by [getting a read credential](/docs/build/reading-spaces) for its `members` space. `UserNotAuthorized` means they aren't a member. > > The reference console takes a simpler route: it lists every group on the host with `listGroups` and tries to read each `members` space. ### From a handle to a group Resolve the handle to a DID in the usual way. The DID document's `#atproto_pds` service is the group's host, and that's where its spaces and records live. On the reference host, send group methods to `https://host.opensocial.fyi` with audience `did:web:host.opensocial.fyi#opensocial` (see [Calling group methods](/docs/build/calling-the-host)). ## Reading a space https://docs.opensocial.fyi/docs/build/reading-spaces 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 ```text at:///space// the space at:///space///// 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: ```http GET /xrpc/com.atproto.space.getDelegationToken?space=at://did:plc:…/space/fyi.opensocial.members/self ``` The response is `{ "token": "" }`. 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](/docs/build/sign-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: ```http POST https://host.opensocial.fyi/xrpc/com.atproto.space.getSpaceCredential Authorization: Bearer DPoP: Content-Type: application/json { "space": "at://did:plc:…/space/fyi.opensocial.members/self" } ``` The response is `{ "credential": "" }`, 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: ```http Authorization: DPoP DPoP: ``` Records live with whoever wrote them, so a read is two hops: ```http GET https://host.opensocial.fyi/xrpc/com.atproto.space.listRepos?space= → { "repos": [{ "did": "did:plc:group…" }, { "did": "did:plc:alex…" }, …] } GET /xrpc/com.atproto.space.listRecords?space=&repo=&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](https://www.rfc-editor.org/rfc/rfc9449)) is a JWT signed by your key, with the public key in its header: ```ts 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. ```ts 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) => 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:///space/////`. 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. > **No anonymous reads.** Every credential starts with a delegation token from the reader's own PDS, so signed-out visitors can't read any space, even one whose `access` says `"public"`. Use [`listGroups`](/docs/build/finding-groups#for-signed-out-visitors-listgroups) for signed-out pages. ## Calling group methods https://docs.opensocial.fyi/docs/build/calling-the-host Get service auth from the user's own PDS, then call the group host directly. The host checks the caller's roles in that group. Every `fyi.opensocial.*` method (join, invite, admit, assign roles, moderate) is served by the group's host and authorized against the caller's roles **in the group named by the `group` parameter**. The caller proves who they are with **service auth** minted by their own PDS. The host never holds their credentials. ### 1. Service auth, from the user's own PDS Through the user's OAuth session: ```http GET /xrpc/com.atproto.server.getServiceAuth ?aud=did:web:host.opensocial.fyi%23opensocial &lxm=fyi.opensocial.requestJoin ``` The response is `{ "token": "" }`. - `aud` is the host's service DID with the `#opensocial` fragment. - `lxm` is the exact method you're about to call. A token for one method is refused for another. - The PDS only mints it if the session's scope includes `rpc:?aud=`. ### 2. Call the host Send the token as a bearer token, straight to the host (no proxy header): ```http POST https://host.opensocial.fyi/xrpc/fyi.opensocial.requestJoin Authorization: Bearer Content-Type: application/json { "group": "did:plc:idmhyhx3335jt2vin45xauu5", "message": "Hi! I ride on weekends." } ``` ```json { "status": "pending" } ``` Queries are `GET` with the parameters in the query string: ```http GET https://host.opensocial.fyi/xrpc/fyi.opensocial.listSubjects?group=did:plc:…&status=open Authorization: Bearer ``` A helper the reference console uses, trimmed: ```ts async function callHost(session: OAuthSession, lxm: string, body?: unknown, params?: Record) { const { token } = await new Client(session).call(com.atproto.server.getServiceAuth, { aud: "did:web:host.opensocial.fyi#opensocial", lxm, }); const url = new URL(`https://host.opensocial.fyi/xrpc/${lxm}`); for (const [k, v] of Object.entries(params ?? {})) url.searchParams.set(k, v); const res = await fetch(url, { method: body ? "POST" : "GET", headers: { authorization: `Bearer ${token}`, ...(body ? { "content-type": "application/json" } : {}) }, body: body ? JSON.stringify(body) : undefined, }); const out = await res.json().catch(() => ({})); if (!res.ok) throw Object.assign(new Error(out.message ?? out.error), { name: out.error }); return out; } ``` ### Common calls | Call | Body or params | Returns | | --- | --- | --- | | `requestJoin` | `{ group, message? }` | `{ status: "admitted" \| "pending" }`. An outstanding invite for the caller is found and redeemed automatically. | | [`getJoinRequest`](/docs/lexicons/getJoinRequest) | `?group=` | `{ request? }`: the caller's own pending request, absent once decided | | `cancelJoinRequest` | `{ group }` | `{}` | | `listJoinRequests` | `?group=` (needs `admit`) | `{ requests, cursor? }` | | `admitMember` | `{ group, did, decision: "admit" \| "deny", roles? }` | `{}`; `roles` defaults to `["member"]` | | `createInvite` | `{ group, invitee, message? }` (needs `invite`) | `{ uri }`, or `pending:` if the invitee has no invites space yet. The invite still redeems when they ask to join. | | `leaveGroup` | `{ group }` | `{}` | | `listSubjects` | `?group=&status=` (needs `mod.read`) | `{ subjects }` | Every method's full input and output is in the [reference](/docs/reference/methods) and in [llms-full.txt](/llms-full.txt). ### Errors Errors come back as `{ "error": "", "message": "…" }`. The host answers `400` for a refused call and `401` for bad service auth. Scope problems come earlier, from the user's own PDS when you ask for service auth. | Error | When | | --- | --- | | `AlreadyMember` | `requestJoin` by a member. | | `InviteRequired` | `requestJoin` on an invite-only group without an invite. | | `InviteNotFound` | Revoking an invite that doesn't exist. | | `RequestNotFound` | Admitting or cancelling a request that isn't pending. | | `NotAMember` | `leaveGroup` by a non-member. | | `NotAssignable` | Granting, revoking or ejecting a role outside the caller's `assignable`. | | `LastConfigurer` | The only member able to configure the group tries to leave or give that up. | | `Forbidden` | The caller's roles lack the action. | | `NotHosted` | The `group` isn't on this host. | | `SpaceNotFound`, `WellKnownSpace` | `updateSpace`/`deleteSpace` on a missing space, or on `meta`/`members`. | | `SubjectNotFound` | Moderation calls on an unknown subject. | | `DpopRequired`, `InvalidDpop` | [`getGroupAuth`](/docs/lexicons/getGroupAuth) without a valid DPoP proof. | | `InvalidRequest` | Malformed input, including an unknown `group` (message: "unknown group"). | | `401` from the host | Missing, expired or wrong-audience service auth, or a token for a different `lxm`. | | `403 ScopeMissingError` from the user's PDS | The session's scope lacks `rpc:?aud=…`. The message names the exact scope to add. See [Signing in](/docs/build/sign-in). | Show people a plain-language message; keep `error` and `message` for your logs. ## Writing records https://docs.opensocial.fyi/docs/build/writing 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: ```ts 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`](/docs/lexicons/basePermissions) covers acceptances, [`calendarPermissions`](/docs/lexicons/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`. ```http POST https://host.opensocial.fyi/xrpc/fyi.opensocial.getGroupAuth Authorization: Bearer DPoP: Content-Type: application/json { "group": "did:plc:…", "space": "at://did:plc:…/space/fyi.opensocial.events/self" } ``` ```json { "did": "did:plc:…", "accessJwt": "", "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: ```http POST https://host.opensocial.fyi/xrpc/com.atproto.space.createRecord Authorization: DPoP DPoP: 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: ```ts 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](/docs/build/reading-spaces#a-dpop-proof). [`getGroupAuth`](/docs/lexicons/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**: ```ts 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. > **Who may sign in as the group.** The reference host lets any member complete this flow and narrows the grant afterwards, so a member with no write permissions still gets a read-only session as the group. The proposal doesn't settle this yet. See [Writing as the group](/docs/writing-as-the-group). ### Creating a space A modality app creates its space the first time a group needs it: ```http POST https://host.opensocial.fyi/xrpc/fyi.opensocial.createSpace Authorization: Bearer { "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. ## Example: a group's events page https://docs.opensocial.fyi/docs/build/example-events Everything from the previous guides in one flow. Set up a group calendar, list events with who's going, RSVP, and post an event as the group. This walks through the events pages of a group's website. Each step links to the guide that explains it. ### The pieces - **Space:** `at:///space/fyi.opensocial.events/self`, readable by members. - **Events:** `community.lexicon.calendar.event` records, written **by the group** into its own repo in that space. - **RSVPs:** `community.lexicon.calendar.rsvp` records, written by **each member** into their own repo in the same space, on their own PDS. - **Scope:** `atproto`, `include:fyi.opensocial.basePermissions?aud=…`, `include:fyi.opensocial.calendarPermissions`, plus an `rpc:…?aud=…` entry for each host method the page calls: `requestJoin`, [`getJoinRequest`](/docs/lexicons/getJoinRequest), `cancelJoinRequest`, [`getGroupAuth`](/docs/lexicons/getGroupAuth), and `createSpace` if it sets up the calendar. [`listGroups`](/docs/lexicons/listGroups) needs none. See [Signing in](/docs/build/sign-in). ### 1. Make sure the calendar exists Read the group's space index: `fyi.opensocial.space` records in its `members` space ([Reading a space](/docs/build/reading-spaces)). If none has `type: "fyi.opensocial.events"`, an admin creates it with `createSpace` ([Creating a space](/docs/build/writing#creating-a-space)): ```json { "group": "did:plc:…", "type": "fyi.opensocial.events", "skey": "self", "name": "Events", "readableBy": ["member"], "credentialScopes": [{ "role": "admin", "scopes": ["community.lexicon.calendar.event"] }] } ``` `readableBy: ["member"]` makes the calendar members-only. `credentialScopes` lets admins post events as the group. Members can write RSVPs through their own permission set. ### 2. List events and who's going With a credential for the events space: 1. `listRepos` on the group's host gives every writer: the group, plus each member who has RSVP'd. 2. From the host, `listRecords` for the group's repo, collection `community.lexicon.calendar.event`, gives the events. 3. From each member's PDS, `listRecords` for their repo, collection `community.lexicon.calendar.rsvp`, gives their RSVPs. 4. Group RSVPs by `subject.uri`, which is the event's URI. ```ts const space = `at://${group}/space/fyi.opensocial.events/self`; const records = await readSpace(session, space, [ "community.lexicon.calendar.event", "community.lexicon.calendar.rsvp", ]); const events = records.filter((r) => r.collection === "community.lexicon.calendar.event"); const going = (eventUri: string) => records.filter( (r) => r.collection === "community.lexicon.calendar.rsvp" && r.value.subject.uri === eventUri && r.value.status === "community.lexicon.calendar.rsvp#going", ); ``` A reader who isn't a member gets `UserNotAuthorized` at the credential step. Show a "members only" state, not an error: - `getJoinRequest` (`?group=`) tells you whether they've already asked. If `request` is present, show "request pending". - Otherwise offer "Ask to join", which calls `requestJoin` with `{ group, message }`. It returns `admitted` for an open group or `pending` for one that reviews requests. - `cancelJoinRequest` withdraws a pending request. ### 3. RSVP A member writes their RSVP from their own PDS. The rkey is the event's rkey, so changing their answer overwrites it ([A member's own records](/docs/build/writing#a-members-own-records)). The calendar lexicons declare `tid` keys; reusing the event's rkey departs from that on purpose, so one member has at most one RSVP per event. Seeded events may have readable rkeys like `cougar-loop`, so don't assume every key is a TID: ```ts await new Client(session).call(com.atproto.space.putRecord, { space, repo: session.did, collection: "community.lexicon.calendar.rsvp", rkey: event.rkey, record: { $type: "community.lexicon.calendar.rsvp", subject: { uri: event.uri, cid: event.cid }, status: "community.lexicon.calendar.rsvp#going", createdAt: new Date().toISOString(), }, }); ``` ### 4. Post an event as the group An admin posts the event, and the group authors it. Get a `getGroupAuth` token for the events space, then write into the group's repo on the host ([Writing as the group](/docs/build/writing#writing-as-the-group)): ```ts const grant = await getGroupAuth(session, group, space); // { accessJwt, pds, … } await createRecordWithDpop(grant, { space, repo: group, collection: "community.lexicon.calendar.event", record: { $type: "community.lexicon.calendar.event", name: "Saturday coffee ride", startsAt: "2026-10-03T15:00:00.000Z", mode: "community.lexicon.calendar.event#inperson", status: "community.lexicon.calendar.event#scheduled", createdAt: new Date().toISOString(), }, }); ``` `getGroupAuth` returns `Forbidden` for someone whose role has no `credentialScopes` in this space. To know in advance who may post, and show the "new event" button only to them: 1. Read the viewer's roles: their `fyi.opensocial.membership` record in the `members` space (repo = the group, rkey = the viewer's DID) has `roles`. 2. Read the events space's `access` record: `fyi.opensocial.access`, rkey `self`, in the group's repo in that space. 3. The viewer may post if any entry in `credentialScopes` names one of their roles and lists `community.lexicon.calendar.event`, or `"*"`, which means every collection in the space. ### 5. Signed-out visitors A signed-out visitor can't read the events space. Show the group's name, description and rules from [`listGroups`](/docs/build/finding-groups#for-signed-out-visitors-listgroups) and a sign-in button. > **Keep the calendar lexicons as they are.** The records are `community.lexicon.calendar.*`, unchanged, so any calendar app can read them. Only the space type is [`fyi.opensocial.events`](/docs/lexicons/events), so a permission set can grant it (see [the namespace rule](/docs/build/sign-in#the-namespace-rule)). ## Adding groups to an existing app https://docs.opensocial.fyi/docs/build/existing-app What changes when an app that already has users and data takes on groups, using Grain's group photo pools as the worked example. The other guides show the building blocks. This one covers the decisions that come from adding groups to an app people already use. The example throughout is [Grain](https://grain.social), a photo app whose groups each have a members-only **pool** of galleries. ### 1. Give your content a space type of your own Decide what a group's members share in your app, and make that a space type **under your own namespace**. Grain's pool is `social.grain.group`: ```json { "lexicon": 1, "id": "social.grain.group", "defs": { "main": { "type": "space", "key": "literal:self", "name": "Group pool", "collections": [ "social.grain.gallery", "social.grain.gallery.item", "social.grain.photo", "social.grain.favorite", "social.grain.comment" ] } } } ``` - **Keep your existing record types.** A gallery in a pool is the same `social.grain.gallery` as a public one. Only where it's written changes, so your rendering code carries over. - **Publish the space type at your lexicon authority** (the DID in your `_lexicon.` DNS record), like any lexicon you own. A PDS resolves the space type when someone asks for a scope on it. If it can't, sign-in fails. - The group creates the space once, with [`createSpace`](/docs/build/writing#creating-a-space) and `readableBy: ["member"]`. After that, membership is the permission. Your app needs no membership check of its own before a write, because the member's PDS asks the group's host. ### 2. Members write their own records into it A member posts into the pool from their own session, into **their own repo inside the group's space**. Grain writes a gallery, its photos and its items as one commit: ```ts await session.fetchHandler("/xrpc/com.atproto.space.applyWrites", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ space: `at://${group}/space/social.grain.group/self`, repo: session.did, writes: [ { $type: "com.atproto.space.applyWrites#create", collection: "social.grain.gallery", rkey, value: gallery }, // …photos and gallery items ], }), }); ``` Upload blobs through the ordinary blob endpoint first, as for any post. **Leaving takes their content with them.** The records are the member's, so when they leave, their galleries leave the pool, and nothing needs deleting. Grain says so plainly in its UI: "Your galleries leave its pool with you." > **Public content and group content can live side by side.** Grain's first version had members *submit* public galleries and the group *accept* them, with records in public repos. It moved to writing straight into the private pool: no queue, no accept step, and the pool stays members-only. If your app has a public version of the same content, decide early which one groups get. There's no automatic path from one to the other. ### 3. Read with the viewer's own credential Space writes never reach a firehose, so there's nothing to index. Read a pool on demand, [the way every space is read](/docs/build/reading-spaces): a credential for the **viewer**, `listRepos` on the group's host, then each member's records from their PDS. - A non-member's read is refused. That refusal *is* the members-only feature, so show a "members only" state, not an error. - Cache the credential briefly (Grain: 4 minutes), and clear it when the viewer leaves the group. - **Tell "no spaces" apart from "not a member".** A user whose PDS doesn't support spaces can't read any pool. Tell them their account can't do this yet, instead of asking them to join. ### 4. Ask for the scopes, carefully Grain adds these to its existing scope: ```text space:social.grain.group?authority=*&skey=*&collection=social.grain.gallery&…&action=read&action=create&action=update&action=delete space:social.grain.group?authority=*&skey=*&action=read space:fyi.opensocial.members?authority=*&skey=*&collection=fyi.opensocial.acceptance&action=read&action=create&action=update&action=delete rpc:fyi.opensocial.requestJoin?aud=* rpc:fyi.opensocial.leaveGroup?aud=* ``` (Plus `rpc:` entries for [`provisionGroup`](/docs/lexicons/provisionGroup) and `createSpace` if the app starts groups.) - **A separate read-only scope** on your space type (the second line). A session signed in *as a group* is narrowed to what the person's role allows. If reading and writing share one scope, losing the write loses the read, and the group can't open its own pool. - **Only ask where it can work.** Grain requests group and pool scopes only from PDSes that serve `com.atproto.space.getDelegationToken`, and hides "Join" from users who couldn't open a pool. - **Declare every scope you might request in your client metadata.** Some PDSes reject the whole authorization with `invalid_scope` if a requested scope isn't declared there. - **Existing users sign in again.** Their sessions were granted the old scope. The first group call fails with `ScopeMissingError`; send them back through sign-in, with copy that says why ("Sign in again to open this gallery"). ### 5. Show groups where your app shows people A group DID can appear anywhere an account can: as an author, in search, on a profile URL. - **Detect it** by indexing `fyi.opensocial.declaration` from the firehose alongside your own collections. A DID with a declaration is a group. - **Show it** with its group profile. Grain uses its own profile record if the group has written one, falling back to the host's [`listGroups`](/docs/build/finding-groups#for-signed-out-visitors-listgroups) entry (cached for ten minutes). - **Don't show a member count or roster.** Who belongs isn't public. - **When the viewer *is* the group** (signed in as it), skip membership checks, hide Join and Leave, and fetch the profile fresh. A steward who just edited it on the host expects to see the change. ### 6. Membership and joining Grain follows [Finding groups](/docs/build/finding-groups#for-a-signed-in-user-which-groups-am-i-in) and [Calling group methods](/docs/build/calling-the-host): - **Which groups am I in:** `listSpaces?type=fyi.opensocial.members` on the user's PDS for candidates, each confirmed with a members-space credential. Cache a refusal briefly, and bypass the cache on the group's own page. - **Join:** `requestJoin`. If it returns `admitted`, write the member's `acceptance` straight away. If it returns `pending`, write it the first time you see them admitted. - **Leave:** delete the acceptance first, while the space still accepts their writes, then call `leaveGroup`, then clear your cached membership and credential. ### 7. Starting a group without leaving your app An app can create a group for a user and get its own session on it, so the founder never sees a second sign-in. On the reference host this is `fyi.opensocial.provisionGroup`, which isn't part of the standard yet. 1. **Get on the host's list.** A host issues group sessions only to apps it provisions for. On host.opensocial.fyi that's an allowlist of OAuth client IDs; ask the operator. Your app must be a confidential client, able to sign a client assertion. 2. **Founder's service auth:** `getServiceAuth` on the user's PDS with `lxm=fyi.opensocial.provisionGroup`. 3. **Call the host as an OAuth client:** ```http POST https://host.opensocial.fyi/xrpc/fyi.opensocial.provisionGroup Authorization: Bearer DPoP: Content-Type: application/json { "handle": "coffee-riders", "displayName": "Coffee Riders", "scope": "atproto repo:social.grain.actor.profile", "client_id": "https://your.app/oauth-client-metadata.json", "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", "client_assertion": "" } ``` `scope` must fall within the scopes your client metadata declares. The response has `did`, `meta`, `members`, and `session`: a standard OAuth token response, bound to your DPoP key and issued to your client ID. Store it like any session and refresh it at the host's token endpoint. Retry once on `use_dpop_nonce`. Other errors: `UntrustedApp`, `InvalidScope`, `HandleTaken`. 4. **Create your space** for the group with the founder's `createSpace` call, and write anything the group itself should author (Grain writes the group's profile) with the new session. 5. **Wait for the declaration.** It reaches your index through the firehose moments after provisioning. Grain polls briefly so the group's page exists when the founder lands on it. Handles are short: a PDS takes 3 to 18 characters for the first label. Validate before calling, and log the host's error instead of showing it. ### 8. Acting as the group People who can act for the group sign in to your app **with the group's handle** (see [Writing as the group](/docs/build/writing#as-a-session-sign-in-as-the-group)). If your app already has an account switcher, that's all the UI it needs. - **Expect a narrowed session.** The host grants only what the person's role allows. Treat a scope that was withheld as "you can't do that as this group", not as a broken session. Don't delete the session and force a new sign-in. - **One session per account.** If your app stores sessions by DID, a person signing in as a group replaces any session your app got from `provisionGroup` for that group, and the other way round. ### 9. Words on the screen Say what people can do, not how it works. Nobody using a photo app needs to hear "space", "credential", "PDS" or "scope". Grain's copy: | Situation | Copy | | --- | --- | | A non-member opens a pool | "Only members can see this pool. If you've just joined, reload the page." | | Signed out | "This pool is the club's, not the network's. Sign in as a member to see it." | | The user's account can't use pools | "Your account is hosted somewhere that doesn't support private sharing yet." | | Starting a group | "A group has its own account and a pool its members post galleries into. You'll be its first admin." | | Join states | "You're in" · "Request sent to the moderators" · "Invite only" | | The host refused a new group | "That handle is taken or not allowed. Try another one." | ### Checklist - [ ] A space type under your namespace, published at your lexicon authority - [ ] Members write their own records into it; nothing to delete when they leave - [ ] Reads use the viewer's credential; "not a member" and "no spaces" are distinct states - [ ] Scopes declared in client metadata, a separate read-only scope, requested only where spaces work - [ ] Existing users are sent back through sign-in, with a reason - [ ] Groups detected from their declaration and shown with their group profile - [ ] Join, leave and "my groups" per the guides - [ ] Optional: start groups from your app with `provisionGroup` - [ ] No protocol words on screen ## Spaces & records https://docs.opensocial.fyi/docs/reference/records Every space type and record type in the standard. Each links to its full schema. All names are under `group.opensocial`. The schemas on these pages are generated from the lexicons the reference host serves. ### Space types | Name | Purpose | | --- | --- | | `group.opensocial.meta` | The group's public face: profile, rules, how to get in. | | `group.opensocial.members` | Roles, who holds them, the authz config and the space index. | | `group.opensocial.invites` | Hosted by each user, not by groups. Where invites arrive. | ### Record types Records written by the **group authority** are authored by the group DID through its host. The **acceptance** is the exception: the member writes it. The **invite** is written by the group into a space the *invitee* hosts. | Name | Lives in | Purpose | | --- | --- | --- | | `group.opensocial.declaration` | `public repo` | Marks a DID as a group and points at its meta space. | | `group.opensocial.profile` | `meta` | Name, description, avatar, join policy. | | `group.opensocial.rule` | `meta` | One rule, with a stable URI a moderation action can cite. | | `group.opensocial.permissions` | `members` | Binds roles to actions and bounds role.assign. | | `group.opensocial.space` | `members` | One space under the group: the index of what exists. | | `group.opensocial.role` | `members` | Declares that a role exists. | | `group.opensocial.membership` | `members` | The group's side of membership: grants a member their roles. | | `group.opensocial.acceptance` | `members` | The member's side of membership, written by the member. | | `group.opensocial.access` | `every space` | Who may read this space, and what each role may do as the group in it. | | `group.opensocial.label` | `subject's space` | A moderation label, written where its subject lives. | | `group.opensocial.invite` | `invitee's invites` | An invitation, delivered into the invitee's own space. | ## Actions https://docs.opensocial.fyi/docs/reference/actions The twelve standardized actions a role can be granted, and the methods each one guards. A member may do whatever **any** of their roles allows. There are no deny rules or precedence. Actions are bound to roles in the [`permissions`](/docs/lexicons/permissions) record. | Action | Governs | Methods | | --- | --- | --- | | `group.configure` | Edit the profile, rules, roles and permissions. | [`updateProfile`](/docs/lexicons/updateProfile), [`uploadImage`](/docs/lexicons/uploadImage), [`putRule`](/docs/lexicons/putRule), [`deleteRule`](/docs/lexicons/deleteRule), [`putRole`](/docs/lexicons/putRole), [`deleteRole`](/docs/lexicons/deleteRole) | | `space.create` | Create a space under the group DID. | [`createSpace`](/docs/lexicons/createSpace) | | `space.configure` | Change a space's config, including its `access` record. | [`updateSpace`](/docs/lexicons/updateSpace) | | `space.delete` | Delete a space. | [`deleteSpace`](/docs/lexicons/deleteSpace) | | `role.assign` | Grant and revoke roles, bounded by `assignable`. | [`assignRoles`](/docs/lexicons/assignRoles) | | `eject` | Remove a member, bounded by `assignable`. | [`ejectMember`](/docs/lexicons/ejectMember) | | `invite` | Issue invites. | [`createInvite`](/docs/lexicons/createInvite), [`listInvites`](/docs/lexicons/listInvites), [`revokeInvite`](/docs/lexicons/revokeInvite) | | `admit` | Approve join requests. | [`listJoinRequests`](/docs/lexicons/listJoinRequests), [`admitMember`](/docs/lexicons/admitMember) | | `mod.read` | See the moderation queue and subject histories. | [`listSubjects`](/docs/lexicons/listSubjects), [`getSubjectHistory`](/docs/lexicons/getSubjectHistory) | | `mod.resolve` | Resolve or escalate a subject, add notes. | [`resolveSubject`](/docs/lexicons/resolveSubject) | | `label` | Apply and negate labels, except `!hide` and `!takedown`. | [`applyLabel`](/docs/lexicons/applyLabel), [`negateLabel`](/docs/lexicons/negateLabel) | | `takedown` | Apply and negate `!hide` and `!takedown`. | [`applyLabel`](/docs/lexicons/applyLabel), [`negateLabel`](/docs/lexicons/negateLabel) | Some methods need no action at all: [`requestJoin`](/docs/lexicons/requestJoin), [`cancelJoinRequest`](/docs/lexicons/cancelJoinRequest) and [`leaveGroup`](/docs/lexicons/leaveGroup) are called by a person about themselves. ### `assignable` `role.assign` and `eject` are bounded. A binding's `assignable` list names the roles its holders may grant, revoke or eject. Without it they can do neither, even while holding the action. `admitMember`'s optional roles are bounded the same way. ## Methods https://docs.opensocial.fyi/docs/reference/methods The calls an app makes to a group's host, and the action each one requires. Every method is served by the group's host and authorized against **that group's** roles. Each takes the group's DID as `group`. The caller authenticates as themselves, with service auth from their own PDS, so the host knows who is asking without holding any of their credentials. All names are under `group.opensocial`. | Name | Requires | Purpose | | --- | --- | --- | | `group.opensocial.updateProfile` | `group.configure` | Replace the profile. | | `group.opensocial.uploadImage` | `group.configure` | Upload an avatar or banner image. | | `group.opensocial.putRule` | `group.configure` | Create or update a rule. | | `group.opensocial.deleteRule` | `group.configure` | Remove a rule. | | `group.opensocial.putRole` | `group.configure` | Create or update a role and its action bindings. | | `group.opensocial.deleteRole` | `group.configure` | Remove a role. | | `group.opensocial.createSpace` | `space.create` | Add a modality space to the group. | | `group.opensocial.updateSpace` | `space.configure` | Change a space's config, including who can read it. | | `group.opensocial.deleteSpace` | `space.delete` | Remove a space. Refuses the two well-known spaces. | | `group.opensocial.assignRoles` | `role.assign` | Set a member's full role set. | | `group.opensocial.ejectMember` | `eject` | Remove a member and their access. | | `group.opensocial.createInvite` | `invite` | Write an invite into someone's own invites space. | | `group.opensocial.listInvites` | `invite` | Outstanding invites. | | `group.opensocial.revokeInvite` | `invite` | Retract an outstanding invite. | | `group.opensocial.requestJoin` | anyone | Ask to join, or redeem an invite. | | `group.opensocial.cancelJoinRequest` | anyone | Withdraw a pending join request. | | `group.opensocial.leaveGroup` | anyone | Leave the group. | | `group.opensocial.listJoinRequests` | `admit` | The pending-requests queue. | | `group.opensocial.admitMember` | `admit` | Approve or deny a join request. | | `group.opensocial.listSubjects` | `mod.read` | The moderation queue. | | `group.opensocial.getSubjectHistory` | `mod.read` | Every event on one subject. | | `group.opensocial.resolveSubject` | `mod.resolve` | Resolve or escalate a subject, optionally with a note. | | `group.opensocial.applyLabel` | `label / takedown` | Write a label into the subject's space. | | `group.opensocial.negateLabel` | `label / takedown` | Negate a previously applied label. | ## Host extensions https://docs.opensocial.fyi/docs/reference/extensions What the reference host serves beyond the proposal that apps need today. Not part of the standard, so they keep their published fyi.opensocial names. The proposal specifies the interface between groups and apps, but building real apps turned up a few things it doesn't cover yet. The reference host serves them, and the [Build an app](/docs/build/overview) guides use every one. They aren't part of the standard, so they keep their published `fyi.opensocial.*` names instead of `group.opensocial.*`. | Name | Kind | Purpose | | --- | --- | --- | | `fyi.opensocial.listGroups` | method | The groups on a host, with public profiles. For signed-out pages. | | `fyi.opensocial.getGroupAuth` | method | A short-lived token for writing as the group in one space. | | `fyi.opensocial.getJoinRequest` | method | The caller's own pending join request, if any. | | `fyi.opensocial.provisionGroup` | method | Create a group for a person and get the app a session on it. | | `fyi.opensocial.basePermissions` | permission-set | The permission set every group app asks for. | ### Why each one exists - **[`listGroups`](/docs/lexicons/listGroups)**: a space can't be read without signing in, even a public one. Signed-out pages need some way to show a group's profile. It goes away once public spaces can be read anonymously. - **[`getGroupAuth`](/docs/lexicons/getGroupAuth)**: the proposal describes writing as the group with an OAuth credential for the group DID. This gets one for a single space from a member's own session, without a separate sign-in. - **[`getJoinRequest`](/docs/lexicons/getJoinRequest)**: the proposal has `requestJoin` and `cancelJoinRequest`, but no way for a person to read back whether their request is still pending. - **[`provisionGroup`](/docs/lexicons/provisionGroup)**: lets an app start a group for a person and get its own session on it, so the founder never leaves the app. - **[`basePermissions`](/docs/lexicons/basePermissions)**: the permission set every group app asks for. It covers the standard's spaces and the host methods an app calls, which a set can only grant for names under its own. > **Candidates for the standard.** `getJoinRequest` fills a gap next to methods the proposal already has, and `getGroupAuth` is a concrete answer to the proposal's "Writing as the Group DID" section. Both could reasonably move into the standard. ### Not listed here The reference host serves more, but ordinary apps don't need it: - **Running a group:** `createGroup` (without a session), `listGroupTokens` and `revokeGroupToken` (connected apps), `deactivateGroup`, `activateGroup`, `deleteGroup`, recovery keys and migration between hosts. These are for group consoles and hosts. - **Shared data between apps:** the calendar's space type and permission set aren't host features. They're under [Shared modalities](/docs/reference/modalities). The schemas in the table above are also in [llms-full.txt](/llms-full.txt). ## Shared modalities https://docs.opensocial.fyi/docs/reference/modalities Space types and permission sets that let group apps share one kind of data, such as a calendar. Not part of the standard; published under fyi.opensocial as stand-ins. The standard leaves [modality spaces](/docs/spaces#modality-spaces) to apps. When more than one app works with the same kind of data, they need to agree on a space type and a permission set, so a group's calendar made in one app shows up in another. These are the ones the [Build an app](/docs/build/overview) guides use. They aren't part of the standard, and won't become `group.opensocial.*`. | Name | Kind | Purpose | | --- | --- | --- | | `fyi.opensocial.calendarPermissions` | permission-set | Read a group's calendar and RSVP. | | `fyi.opensocial.events` | space | A group's calendar, holding community.lexicon.calendar records. | | `fyi.opensocial.eventImage` | record | A poster image for an event. | > **Stand-ins until the calendar standard can publish them.** A calendar belongs to [lexicon.community](https://lexicon.community), not to opensocial. These are named `fyi.opensocial.*` only because that's a namespace we can publish, and a permission set has to be published under the same name as the space types it grants ([the namespace rule](/docs/build/sign-in#the-namespace-rule)). > > If lexicon.community publishes its own calendar space type and permission set, apps should switch to those, and `eventImage` belongs in the calendar standard too. The events and RSVPs inside are already standard `community.lexicon.calendar.*` records, so only the space type and the scope an app asks for would change. ### Nothing the host has to support A host treats a modality space like any other space. An admin creates it with [`createSpace`](/docs/lexicons/createSpace), its [`access`](/docs/lexicons/access) record says who may read it, and `credentialScopes` says which roles may write as the group there ([Creating a space](/docs/build/writing#creating-a-space)). Any host that implements the standard can hold one, and an app using these works on any such host. ### Why each one exists - **[`events`](/docs/lexicons/events)**: a group calendar that any calendar app can share. The records inside are ordinary `community.lexicon.calendar.*` records, unchanged. The space type is ours only so a permission set can grant it: a set can only grant space types under its own name ([the namespace rule](/docs/build/sign-in#the-namespace-rule)), and `lexicon.community` publishes no permission sets anyone can resolve. - **[`calendarPermissions`](/docs/lexicons/calendarPermissions)**: the permission set an app asks for to read a group's `events` spaces and write the person's own RSVPs. - **[`eventImage`](/docs/lexicons/eventImage)**: a poster for an event. `community.lexicon.calendar.event` carries no image, so the picture is a record of the group's own beside the event, keyed by the event's rkey. ### Not listed yet A group forum follows the same pattern: a `forum` space holding `com.atmoboards.forum.*` records, `forumPermissions`, and `reaction`. They'll be documented here when another app needs them. The schemas in the table above are also in [llms-full.txt](/llms-full.txt). ## Status & implementations https://docs.opensocial.fyi/docs/status Where the proposal stands, what runs today, and what's still open. `opensocial.group` is a **proposal**. The model has been implemented end to end and runs in production, but it isn't frozen, and names and details may change. ### The reference implementation | Piece | What it is | | --- | --- | | **Group host** ([host.opensocial.fyi](https://host.opensocial.fyi)) | A compliant host: a PDS for group DIDs, their spaces, the OAuth authorization server for acting as a group, and the full group API. | | **Console** ([opensocial.fyi](https://opensocial.fyi)) | A management app for stewards: members and requests, roles, profile and rules, moderation, connected apps. | | **A group's own website** | A group's public site that reads its profile, rules and events straight from the group's spaces. | | **[Grain](https://grain.social)** | A third-party photo app. Group photo pools use the standard as-is, and people can start a group without leaving the app. | All of it runs on the permissioned-data (spaces) alpha. ### Namespace The lexicons are published as **`fyi.opensocial.*`** until the `opensocial.group` domain can publish them as `group.opensocial.*`. These docs use the intended names. The schemas are identical, and the reference host already includes the migration for existing groups. ### Open items | Item | Waiting on | | --- | --- | | Reading a public `meta` space without signing in | The permissioned-data layer. See [Presence & discovery](/docs/presence-and-discovery). | | Split read and write decisions for spaces, needed for invite inboxes | A small permissioned-data change: anyone may write into an invites space, only its owner may read it. | | Reports about accounts, not only records | Each group being addressable as its own labeler. | | Who may obtain a credential for the group DID | A decision in the proposal. See [Writing as the group](/docs/writing-as-the-group). | | A `com.atproto` label record type | Upstream. | > Building the prototype also produced host-level pieces the proposal doesn't cover yet: creating and provisioning groups from an app, recovery keys, deactivation and migration between hosts. They'll be documented here as the proposal takes them up. ## FAQ https://docs.opensocial.fyi/docs/faq Short answers to the questions that come up first. **Does this replace the permissioned data protocol?** No. It builds on it. Spaces, space credentials and permissioned records all come from that protocol. This standard adds only what a group needs on top: membership, roles, presence and moderation. **Does it define how a group governs itself?** No, on purpose. Voting, holding periods and councils are up to each group. The standard defines the interface apps rely on, and a group projects the outcome of its governance onto it: someone gains a role, a rule changes, a member is ejected. **Does an app need to understand roles to work with a group?** Only as far as it wants to. Read access to every space is enforced by the group's host. An app that writes into its own modality space can express rules like “only moderators may pin” with the group's roles in its own lexicon, or ignore roles entirely. **Where does members' content live?** On the members' own servers. A photo a member posts to a group's pool is stored with the member, like everything else they write. Only records the group itself authors, such as its profile, rules, memberships and labels, live on the group's host. **Is there a public member list?** No. Membership is visible to whoever can read the group's members space, usually members. A member also appears in a roster only after writing their own acceptance. **Can a group move to a different host?** That's a design goal. A group is a DID like any other, so its identity can move. The reference host can migrate a group to another host of the same kind, and stewards can hold recovery keys that let them move a group without the old host's help. **Why does the prototype use fyi.opensocial instead of group.opensocial?** Lexicon names resolve through DNS, and the opensocial.group domain can't publish them yet. The schemas are the same, and renaming is mechanical: the reference host already carries the migration. Something missing? Open an issue on [the proposal](https://tangled.org/opensocial.group/proposal).