diff --git a/js/docs/src/content/docs/guides/installing/access-control.md b/js/docs/src/content/docs/guides/installing/access-control.md
new file mode 100644
index 000000000..750aa0e04
--- /dev/null
+++ b/js/docs/src/content/docs/guides/installing/access-control.md
@@ -0,0 +1,80 @@
+---
+title: Access control
+description: Decide who can view, stream to, upload to, and be syndicated by your node.
+---
+
+Every Streamplace node has a small role-based access policy. Admins edit it
+from **Settings → Access** in the app; the environment variables you may
+already be using keep working as seeds.
+
+## Roles
+
+| Role | What it allows |
+| ----------- | ------------------------------------------------------------------------------- |
+| `admin` | Manage branding and access control. Admins hold every other role automatically. |
+| `viewer` | Use the frontend and playback. Only matters when the viewer mode is not `open`. |
+| `streamer` | Ingest live media to this node. |
+| `syndicate` | Have this account's media carried from other nodes onto this one. |
+| `vod` | Upload videos and have livestreams recorded. |
+
+## Modes
+
+Each role has a mode:
+
+- **open** — everyone holds the role, including anonymous visitors.
+- **allowlist** — only accounts with a grant hold the role.
+- **off** — nobody holds the role. Admins are always exempt.
+
+The `admin` role is always `allowlist`.
+
+## A private node
+
+To bring up a node that nobody can use until you let them in, start it with
+your own DID as admin and the viewer role seeded to `allowlist`:
+
+```bash
+SP_ADMIN_DIDS=did:plc:yourdid SP_ACCESS_POLICY=viewer=allowlist streamplace
+```
+
+Sign in as that admin, open **Settings → Access**, and grant `viewer` to the
+accounts that should get in. Admins never need a viewer grant.
+
+Set the `viewer` role to `allowlist` and the node answers nothing but the
+sign-in flow to anyone who is not on the list. That includes the API, link
+cards, playback, thumbnails and chat. Branding stays public so the sign-in
+wall shows the node's own logo and name. Visitors see a
+"this node is private" wall, and signed-in accounts that are not on the list
+see "you're not on the list". Nodes that share a service key with this one
+(the same station) are not affected.
+
+Grant `viewer` to the accounts that should get in. Admins never need a
+viewer grant.
+
+## Environment seeds
+
+The variables below seed grants at startup. They show up in the Access screen
+marked "from environment" and cannot be revoked there; remove them from the
+environment instead. Grants you add in the app live in the node's state
+database and survive restarts.
+
+| Variable | Seeds |
+| ------------------------ | ------------------------------------------------------------------------------------------------ |
+| `SP_ADMIN_DIDS` | `admin` grants. You need at least one to reach the Access screen the first time. |
+| `SP_ACCESS_POLICY` | Initial modes as `role=mode` pairs, e.g. `viewer=allowlist,vod=off`. A mode set in the app wins. |
+| `SP_ALLOWED_STREAMS` | `streamer` grants. When set, the streamer mode defaults to `allowlist`; when empty, to `open`. |
+| `SP_SYNDICATE` | `syndicate` grants. `*` makes the default mode `open`; empty makes it `off`. |
+| `SP_WIDE_OPEN` | Forces viewer, streamer and vod to `open`. Development only. |
+| `SP_DISABLE_SYNDICATION` | Forces syndicate to `off` in both directions. |
+| `SP_BETA_INVITE_DID` | Accounts holding a `place.stream.beta.invite` for `vod` from this DID also hold the `vod` role. |
+
+When no vod mode has been set explicitly and no invite issuer is configured,
+anyone who may stream may also upload, which is what older nodes did.
+
+## Where the data lives
+
+Grants and the policy are modeled as records in an atproto space owned by
+the node's broadcaster DID, addressed as
+`at://{broadcaster}/space/place.stream.access.control/self/{admin}/place.stream.access.grant/{rkey}`.
+Until the atproto spaces implementation ships they are stored in the node's
+state database under those URIs, so they can move into a real space later
+without changing their shape. The API lives under `place.stream.access.*`.
diff --git a/js/docs/src/content/docs/guides/installing/downloading-streamplace.md b/js/docs/src/content/docs/guides/installing/downloading-streamplace.md
index 412593408..2f99a8769 100644
--- a/js/docs/src/content/docs/guides/installing/downloading-streamplace.md
+++ b/js/docs/src/content/docs/guides/installing/downloading-streamplace.md
@@ -34,7 +34,9 @@ SP_HTTP_ADDR=:80
SP_HTTPS_ADDR=:443
SP_SECURE=true
-# Set this variable to your did:plc or did:web to have admin access to the node
+# Set this variable to your did:plc or did:web to have admin access to the node.
+# Admins can manage branding and access control from Settings in the app,
+# including granting more admins, so you only need to seed the first one here.
SP_ADMIN_DIDS=did:web:example.com,did:plc:rbvrr34edl5ddpuwcubjiost
# If you're running Streamplace behind an HTTPS proxy, you'll want
@@ -46,7 +48,10 @@ SP_BROADCASTER_HOST=example.com
# If you have a multi-node cluster, they'll each need different public DNS names:
SP_SERVER_HOST=prod-nyc0.example.com
-# If you don't want to syndicate everyone, add your list of allowed DIDs here:
+# By default anyone with an atproto account can stream to your node. To
+# restrict ingest to a list of DIDs, seed the streamer allowlist here. You can
+# also manage this (and who may view, upload, or be syndicated) from
+# Settings → Access in the app; see the Access control guide.
SP_ALLOWED_STREAMS=did:web:example.com,did:plc:rbvrr34edl5ddpuwcubjiost
# Useful if your TLS cert and key aren't in the default
diff --git a/js/docs/src/content/docs/lex-reference/access/place-stream-access-creategrant.md b/js/docs/src/content/docs/lex-reference/access/place-stream-access-creategrant.md
new file mode 100644
index 000000000..8ae402986
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/access/place-stream-access-creategrant.md
@@ -0,0 +1,114 @@
+---
+title: place.stream.access.createGrant
+description: Reference for the place.stream.access.createGrant lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `procedure`
+
+Grant a role to an account. Requires the admin role. Idempotent: granting a role an account already holds returns the existing grant.
+
+**Parameters:** _(None defined)_
+
+**Input:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| --------- | ------------------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------- | --------------------------------------- |
+| `subject` | `string` | ✅ | The account to grant to, as a DID or a handle. Handles are resolved to DIDs before the grant is written. | |
+| `role` | [`place.stream.access.defs#role`](/lex-reference/place-stream-access-defs#role) | ✅ | | |
+| `note` | `string` | ❌ | | Max Length: 1000
Max Graphemes: 100 |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ------- | ----------------------------------------------------------------------------------------- | ----- | ----------- | ----------- |
+| `grant` | [`place.stream.access.defs#grantView`](/lex-reference/place-stream-access-defs#grantview) | ✅ | | |
+
+**Possible Errors:**
+
+- `Unauthorized`: The caller is not an admin.
+- `InvalidSubject`: The subject is neither a valid DID nor a resolvable handle.
+- `InvalidRole`: The role is not one this node knows about.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.access.createGrant",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Grant a role to an account. Requires the admin role. Idempotent: granting a role an account already holds returns the existing grant.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["subject", "role"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "description": "The account to grant to, as a DID or a handle. Handles are resolved to DIDs before the grant is written."
+ },
+ "role": {
+ "type": "ref",
+ "ref": "place.stream.access.defs#role"
+ },
+ "note": {
+ "type": "string",
+ "maxLength": 1000,
+ "maxGraphemes": 100
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["grant"],
+ "properties": {
+ "grant": {
+ "type": "ref",
+ "ref": "place.stream.access.defs#grantView"
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The caller is not an admin."
+ },
+ {
+ "name": "InvalidSubject",
+ "description": "The subject is neither a valid DID nor a resolvable handle."
+ },
+ {
+ "name": "InvalidRole",
+ "description": "The role is not one this node knows about."
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/access/place-stream-access-defs.md b/js/docs/src/content/docs/lex-reference/access/place-stream-access-defs.md
new file mode 100644
index 000000000..3007e4090
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/access/place-stream-access-defs.md
@@ -0,0 +1,175 @@
+---
+title: place.stream.access.defs
+description: Reference for the place.stream.access.defs lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `role`
+
+**Type:** `string`
+
+A capability a node grants to an account. admin: manage branding and access control (implies every other role). viewer: use the frontend and playback when the node is private. streamer: ingest media to this node. syndicate: have this account's media carried from other nodes. vod: upload and record VODs.
+
+**Constraints:**
Known Values: `admin`, `viewer`, `streamer`, `syndicate`, `vod`
+
+---
+
+
+
+### `mode`
+
+**Type:** `string`
+
+How a node decides a role. open: every account holds the role. allowlist: only accounts with a grant hold the role. off: nobody holds the role (admins are always exempt).
+
+**Constraints:**
Known Values: `open`, `allowlist`, `off`
+
+---
+
+
+
+### `roleMode`
+
+**Type:** `object`
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ------ | ---------------- | ----- | ----------- | ----------- |
+| `role` | [`#role`](#role) | ✅ | | |
+| `mode` | [`#mode`](#mode) | ✅ | | |
+
+---
+
+
+
+### `grantView`
+
+**Type:** `object`
+
+One account's grant of one role. Grants stored as place.stream.access.grant records in the node's access-control space carry a uri and cid; grants seeded from the node's environment (SP_ADMIN_DIDS, SP_ALLOWED_STREAMS, SP_SYNDICATE) have no uri and cannot be revoked from the API.
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | ---------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
+| `uri` | `string` | ❌ | at://{authority}/space/place.stream.access.control/self/{author}/place.stream.access.grant/{rkey} (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.) | |
+| `cid` | `string` | ❌ | | Format: `cid` |
+| `subject` | `string` | ✅ | | Format: `did` |
+| `role` | [`#role`](#role) | ✅ | | |
+| `source` | `string` | ✅ | space: a record in the access-control space, editable via the API. environment: seeded from the node's configuration. | Known Values: `space`, `environment` |
+| `createdBy` | `string` | ❌ | The admin that created the grant (the record's author). | Format: `did` |
+| `createdAt` | `string` | ❌ | | Format: `datetime` |
+| `note` | `string` | ❌ | | Max Length: 1000
Max Graphemes: 100 |
+
+---
+
+
+
+### `policyView`
+
+**Type:** `object`
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ------- | --------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
+| `roles` | Array of [`#roleMode`](#rolemode) | ✅ | The effective mode of every role the node knows about, including environment overrides such as SP_WIDE_OPEN and SP_DISABLE_SYNDICATION. | |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.access.defs",
+ "defs": {
+ "role": {
+ "type": "string",
+ "description": "A capability a node grants to an account. admin: manage branding and access control (implies every other role). viewer: use the frontend and playback when the node is private. streamer: ingest media to this node. syndicate: have this account's media carried from other nodes. vod: upload and record VODs.",
+ "knownValues": ["admin", "viewer", "streamer", "syndicate", "vod"]
+ },
+ "mode": {
+ "type": "string",
+ "description": "How a node decides a role. open: every account holds the role. allowlist: only accounts with a grant hold the role. off: nobody holds the role (admins are always exempt).",
+ "knownValues": ["open", "allowlist", "off"]
+ },
+ "roleMode": {
+ "type": "object",
+ "required": ["role", "mode"],
+ "properties": {
+ "role": {
+ "type": "ref",
+ "ref": "#role"
+ },
+ "mode": {
+ "type": "ref",
+ "ref": "#mode"
+ }
+ }
+ },
+ "grantView": {
+ "type": "object",
+ "description": "One account's grant of one role. Grants stored as place.stream.access.grant records in the node's access-control space carry a uri and cid; grants seeded from the node's environment (SP_ADMIN_DIDS, SP_ALLOWED_STREAMS, SP_SYNDICATE) have no uri and cannot be revoked from the API.",
+ "required": ["subject", "role", "source"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "description": "at://{authority}/space/place.stream.access.control/self/{author}/place.stream.access.grant/{rkey} (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.)"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "subject": {
+ "type": "string",
+ "format": "did"
+ },
+ "role": {
+ "type": "ref",
+ "ref": "#role"
+ },
+ "source": {
+ "type": "string",
+ "knownValues": ["space", "environment"],
+ "description": "space: a record in the access-control space, editable via the API. environment: seeded from the node's configuration."
+ },
+ "createdBy": {
+ "type": "string",
+ "format": "did",
+ "description": "The admin that created the grant (the record's author)."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime"
+ },
+ "note": {
+ "type": "string",
+ "maxLength": 1000,
+ "maxGraphemes": 100
+ }
+ }
+ },
+ "policyView": {
+ "type": "object",
+ "required": ["roles"],
+ "properties": {
+ "roles": {
+ "type": "array",
+ "description": "The effective mode of every role the node knows about, including environment overrides such as SP_WIDE_OPEN and SP_DISABLE_SYNDICATION.",
+ "items": {
+ "type": "ref",
+ "ref": "#roleMode"
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/access/place-stream-access-deletegrant.md b/js/docs/src/content/docs/lex-reference/access/place-stream-access-deletegrant.md
new file mode 100644
index 000000000..d2f7eec2c
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/access/place-stream-access-deletegrant.md
@@ -0,0 +1,97 @@
+---
+title: place.stream.access.deleteGrant
+description: Reference for the place.stream.access.deleteGrant lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `procedure`
+
+Revoke a grant by its space URI. Requires the admin role. Grants seeded from the environment have no URI and cannot be revoked here.
+
+**Parameters:** _(None defined)_
+
+**Input:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ----- | -------- | ----- | --------------------------------------------------------------------------------------------------- | ----------- |
+| `uri` | `string` | ✅ | (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.) | |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| --------- | --------- | ----- | ----------- | ----------- |
+| `success` | `boolean` | ✅ | | |
+
+**Possible Errors:**
+
+- `Unauthorized`: The caller is not an admin.
+- `NotFound`: No grant with that URI.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.access.deleteGrant",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Revoke a grant by its space URI. Requires the admin role. Grants seeded from the environment have no URI and cannot be revoked here.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "description": "(A space URI; not validated as a classic at-uri because the space form is newer than that grammar.)"
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["success"],
+ "properties": {
+ "success": {
+ "type": "boolean"
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The caller is not an admin."
+ },
+ {
+ "name": "NotFound",
+ "description": "No grant with that URI."
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/access/place-stream-access-getstatus.md b/js/docs/src/content/docs/lex-reference/access/place-stream-access-getstatus.md
new file mode 100644
index 000000000..8b9c235ff
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/access/place-stream-access-getstatus.md
@@ -0,0 +1,79 @@
+---
+title: place.stream.access.getStatus
+description: Reference for the place.stream.access.getStatus lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `query`
+
+Report the caller's roles on this node and the node's access policy. Works unauthenticated (roles then reflect what an anonymous visitor holds). This is the one place.stream method a node always answers, even to accounts locked out by a private viewer policy, so clients can render the right wall.
+
+**Parameters:** _(None defined)_
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| -------- | ------------------------------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
+| `did` | `string` | ❌ | The authenticated caller, when there is one. | Format: `did` |
+| `roles` | Array of [`place.stream.access.defs#role`](/lex-reference/place-stream-access-defs#role) | ✅ | Every role the caller effectively holds. | |
+| `policy` | [`place.stream.access.defs#policyView`](/lex-reference/place-stream-access-defs#policyview) | ✅ | | |
+| `space` | `string` | ✅ | The node's access-control space: at://{authority}/space/place.stream.access.control/self (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.) | |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.access.getStatus",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Report the caller's roles on this node and the node's access policy. Works unauthenticated (roles then reflect what an anonymous visitor holds). This is the one place.stream method a node always answers, even to accounts locked out by a private viewer policy, so clients can render the right wall.",
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["roles", "policy", "space"],
+ "properties": {
+ "did": {
+ "type": "string",
+ "format": "did",
+ "description": "The authenticated caller, when there is one."
+ },
+ "roles": {
+ "type": "array",
+ "description": "Every role the caller effectively holds.",
+ "items": {
+ "type": "ref",
+ "ref": "place.stream.access.defs#role"
+ }
+ },
+ "policy": {
+ "type": "ref",
+ "ref": "place.stream.access.defs#policyView"
+ },
+ "space": {
+ "type": "string",
+ "description": "The node's access-control space: at://{authority}/space/place.stream.access.control/self (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.)"
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/access/place-stream-access-grant.md b/js/docs/src/content/docs/lex-reference/access/place-stream-access-grant.md
new file mode 100644
index 000000000..50038b735
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/access/place-stream-access-grant.md
@@ -0,0 +1,70 @@
+---
+title: place.stream.access.grant
+description: Reference for the place.stream.access.grant lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `record`
+
+Grants one role to one account on a node. Lives in the node's access-control space (space type place.stream.access.control, skey self, authority = the broadcaster DID) and is authored by the admin who created it; until the atproto spaces implementation ships, the node stores it in statedb addressed by its at:// space URI.
+
+**Record Key:** `tid`
+
+**Record Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | ------------------------------------------------------------------------------- | ----- | ------------------------------------------- | --------------------------------------- |
+| `subject` | `string` | ✅ | The account receiving the role. | Format: `did` |
+| `role` | [`place.stream.access.defs#role`](/lex-reference/place-stream-access-defs#role) | ✅ | | |
+| `createdAt` | `string` | ✅ | | Format: `datetime` |
+| `note` | `string` | ❌ | Free-form reminder of why the grant exists. | Max Length: 1000
Max Graphemes: 100 |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.access.grant",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "description": "Grants one role to one account on a node. Lives in the node's access-control space (space type place.stream.access.control, skey self, authority = the broadcaster DID) and is authored by the admin who created it; until the atproto spaces implementation ships, the node stores it in statedb addressed by its at:// space URI.",
+ "record": {
+ "type": "object",
+ "required": ["subject", "role", "createdAt"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "did",
+ "description": "The account receiving the role."
+ },
+ "role": {
+ "type": "ref",
+ "ref": "place.stream.access.defs#role"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime"
+ },
+ "note": {
+ "type": "string",
+ "maxLength": 1000,
+ "maxGraphemes": 100,
+ "description": "Free-form reminder of why the grant exists."
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/access/place-stream-access-listgrants.md b/js/docs/src/content/docs/lex-reference/access/place-stream-access-listgrants.md
new file mode 100644
index 000000000..9fc2db546
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/access/place-stream-access-listgrants.md
@@ -0,0 +1,85 @@
+---
+title: place.stream.access.listGrants
+description: Reference for the place.stream.access.listGrants lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `query`
+
+List every grant on this node, including grants seeded from the environment. Requires the admin role.
+
+**Parameters:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ------ | -------- | ----- | -------------------------------- | ----------- |
+| `role` | `string` | ❌ | Only return grants of this role. | |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| -------- | -------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- |
+| `grants` | Array of [`place.stream.access.defs#grantView`](/lex-reference/place-stream-access-defs#grantview) | ✅ | | |
+
+**Possible Errors:**
+
+- `Unauthorized`: The caller is not an admin.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.access.listGrants",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "List every grant on this node, including grants seeded from the environment. Requires the admin role.",
+ "parameters": {
+ "type": "params",
+ "properties": {
+ "role": {
+ "type": "string",
+ "description": "Only return grants of this role."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["grants"],
+ "properties": {
+ "grants": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "place.stream.access.defs#grantView"
+ }
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The caller is not an admin."
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/access/place-stream-access-policy.md b/js/docs/src/content/docs/lex-reference/access/place-stream-access-policy.md
new file mode 100644
index 000000000..ad85fabc1
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/access/place-stream-access-policy.md
@@ -0,0 +1,60 @@
+---
+title: place.stream.access.policy
+description: Reference for the place.stream.access.policy lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `record`
+
+The node's access policy: the mode of each role. A single record authored by the space authority in the node's access-control space. Roles absent from the record use the node's defaults.
+
+**Record Key:** `literal:self`
+
+**Record Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | ------------------------------------------------------------------------------------------------ | ----- | ----------- | ------------------ |
+| `roles` | Array of [`place.stream.access.defs#roleMode`](/lex-reference/place-stream-access-defs#rolemode) | ✅ | | |
+| `updatedAt` | `string` | ✅ | | Format: `datetime` |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.access.policy",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "literal:self",
+ "description": "The node's access policy: the mode of each role. A single record authored by the space authority in the node's access-control space. Roles absent from the record use the node's defaults.",
+ "record": {
+ "type": "object",
+ "required": ["roles", "updatedAt"],
+ "properties": {
+ "roles": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "place.stream.access.defs#roleMode"
+ }
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "datetime"
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/access/place-stream-access-updatepolicy.md b/js/docs/src/content/docs/lex-reference/access/place-stream-access-updatepolicy.md
new file mode 100644
index 000000000..cea30b71d
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/access/place-stream-access-updatepolicy.md
@@ -0,0 +1,101 @@
+---
+title: place.stream.access.updatePolicy
+description: Reference for the place.stream.access.updatePolicy lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `procedure`
+
+Set the mode of one or more roles. Requires the admin role. Roles not mentioned keep their current mode. The admin role cannot be changed: it is always allowlist.
+
+**Parameters:** _(None defined)_
+
+**Input:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ------- | ------------------------------------------------------------------------------------------------ | ----- | ----------- | ----------- |
+| `roles` | Array of [`place.stream.access.defs#roleMode`](/lex-reference/place-stream-access-defs#rolemode) | ✅ | | |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| -------- | ------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- |
+| `policy` | [`place.stream.access.defs#policyView`](/lex-reference/place-stream-access-defs#policyview) | ✅ | | |
+
+**Possible Errors:**
+
+- `Unauthorized`: The caller is not an admin.
+- `InvalidRole`: A role or mode is not one this node knows about.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.access.updatePolicy",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Set the mode of one or more roles. Requires the admin role. Roles not mentioned keep their current mode. The admin role cannot be changed: it is always allowlist.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["roles"],
+ "properties": {
+ "roles": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "place.stream.access.defs#roleMode"
+ }
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["policy"],
+ "properties": {
+ "policy": {
+ "type": "ref",
+ "ref": "place.stream.access.defs#policyView"
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The caller is not an admin."
+ },
+ {
+ "name": "InvalidRole",
+ "description": "A role or mode is not one this node knows about."
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-getbroadcaster.md b/js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-getbroadcaster.md
index 99c060202..1388cf013 100644
--- a/js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-getbroadcaster.md
+++ b/js/docs/src/content/docs/lex-reference/broadcast/place-stream-broadcast-getbroadcaster.md
@@ -24,11 +24,11 @@ Get information about a Streamplace broadcaster.
**Schema Type:** `object`
-| Name | Type | Req'd | Description | Constraints |
-| ------------- | ----------------- | ----- | --------------------------------------------------------------- | ------------- |
-| `broadcaster` | `string` | ✅ | DID of the Streamplace broadcaster to which this server belongs | Format: `did` |
-| `server` | `string` | ❌ | DID of this particular Streamplace server | Format: `did` |
-| `admins` | Array of `string` | ❌ | Array of DIDs authorized as admins | |
+| Name | Type | Req'd | Description | Constraints |
+| ------------- | ----------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- |
+| `broadcaster` | `string` | ✅ | DID of the Streamplace broadcaster to which this server belongs | Format: `did` |
+| `server` | `string` | ❌ | DID of this particular Streamplace server | Format: `did` |
+| `admins` | Array of `string` | ❌ | Deprecated and no longer populated. Clients learn their own roles from place.stream.access.getStatus; admins manage grants via place.stream.access.\*. | |
---
@@ -69,7 +69,7 @@ Get information about a Streamplace broadcaster.
"type": "string",
"format": "did"
},
- "description": "Array of DIDs authorized as admins"
+ "description": "Deprecated and no longer populated. Clients learn their own roles from place.stream.access.getStatus; admins manage grants via place.stream.access.*."
}
}
}
diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json
index 09e35d024..32ec1d806 100644
--- a/js/docs/src/content/docs/lex-reference/openapi.json
+++ b/js/docs/src/content/docs/lex-reference/openapi.json
@@ -3824,7 +3824,7 @@
},
"admins": {
"type": "array",
- "description": "Array of DIDs authorized as admins",
+ "description": "Deprecated and no longer populated. Clients learn their own roles from place.stream.access.getStatus; admins manage grants via place.stream.access.*.",
"items": {
"type": "string",
"format": "did"
@@ -4279,6 +4279,330 @@
]
}
},
+ "/xrpc/place.stream.access.createGrant": {
+ "post": {
+ "summary": "Grant a role to an account. Requires the admin role. Idempotent: granting a role an account already holds returns the existing grant.",
+ "operationId": "place.stream.access.createGrant",
+ "tags": ["place.stream.access"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "grant": {
+ "$ref": "#/components/schemas/place.stream.access.defs_grantView"
+ }
+ },
+ "required": ["grant"]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["error", "message"],
+ "properties": {
+ "error": {
+ "type": "string",
+ "oneOf": [
+ {
+ "const": "Unauthorized"
+ },
+ {
+ "const": "InvalidSubject"
+ },
+ {
+ "const": "InvalidRole"
+ }
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "subject": {
+ "type": "string",
+ "description": "The account to grant to, as a DID or a handle. Handles are resolved to DIDs before the grant is written."
+ },
+ "role": {
+ "$ref": "#/components/schemas/place.stream.access.defs_role"
+ },
+ "note": {
+ "type": "string",
+ "maxLength": 1000
+ }
+ },
+ "required": ["subject", "role"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/xrpc/place.stream.access.deleteGrant": {
+ "post": {
+ "summary": "Revoke a grant by its space URI. Requires the admin role. Grants seeded from the environment have no URI and cannot be revoked here.",
+ "operationId": "place.stream.access.deleteGrant",
+ "tags": ["place.stream.access"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ }
+ },
+ "required": ["success"]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["error", "message"],
+ "properties": {
+ "error": {
+ "type": "string",
+ "oneOf": [
+ {
+ "const": "Unauthorized"
+ },
+ {
+ "const": "NotFound"
+ }
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "description": "(A space URI; not validated as a classic at-uri because the space form is newer than that grammar.)"
+ }
+ },
+ "required": ["uri"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/xrpc/place.stream.access.getStatus": {
+ "get": {
+ "summary": "Report the caller's roles on this node and the node's access policy. Works unauthenticated (roles then reflect what an anonymous visitor holds). This is the one place.stream method a node always answers, even to accounts locked out by a private viewer policy, so clients can render the right wall.",
+ "operationId": "place.stream.access.getStatus",
+ "tags": ["place.stream.access"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "did": {
+ "type": "string",
+ "description": "The authenticated caller, when there is one.",
+ "format": "did"
+ },
+ "roles": {
+ "type": "array",
+ "description": "Every role the caller effectively holds.",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.access.defs_role"
+ }
+ },
+ "policy": {
+ "$ref": "#/components/schemas/place.stream.access.defs_policyView"
+ },
+ "space": {
+ "type": "string",
+ "description": "The node's access-control space: at://{authority}/space/place.stream.access.control/self (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.)"
+ }
+ },
+ "required": ["roles", "policy", "space"]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/xrpc/place.stream.access.listGrants": {
+ "get": {
+ "summary": "List every grant on this node, including grants seeded from the environment. Requires the admin role.",
+ "operationId": "place.stream.access.listGrants",
+ "tags": ["place.stream.access"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "grants": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.access.defs_grantView"
+ }
+ }
+ },
+ "required": ["grants"]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["error", "message"],
+ "properties": {
+ "error": {
+ "type": "string",
+ "oneOf": [
+ {
+ "const": "Unauthorized"
+ }
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "role",
+ "in": "query",
+ "required": false,
+ "description": "Only return grants of this role.",
+ "schema": {
+ "type": "string",
+ "description": "Only return grants of this role."
+ }
+ }
+ ]
+ }
+ },
+ "/xrpc/place.stream.access.updatePolicy": {
+ "post": {
+ "summary": "Set the mode of one or more roles. Requires the admin role. Roles not mentioned keep their current mode. The admin role cannot be changed: it is always allowlist.",
+ "operationId": "place.stream.access.updatePolicy",
+ "tags": ["place.stream.access"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "policy": {
+ "$ref": "#/components/schemas/place.stream.access.defs_policyView"
+ }
+ },
+ "required": ["policy"]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["error", "message"],
+ "properties": {
+ "error": {
+ "type": "string",
+ "oneOf": [
+ {
+ "const": "Unauthorized"
+ },
+ {
+ "const": "InvalidRole"
+ }
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "roles": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.access.defs_roleMode"
+ }
+ }
+ },
+ "required": ["roles"]
+ }
+ }
+ }
+ }
+ }
+ },
"/xrpc/games.gamesgamesgamesgames.search": {
"get": {
"summary": "Search across all entity types (games, profiles, platforms, collections, engines).",
@@ -7370,6 +7694,78 @@
},
"required": ["issuanceUri", "badgeType", "issuer"]
},
+ "place.stream.access.defs_role": {
+ "type": "string",
+ "description": "A capability a node grants to an account. admin: manage branding and access control (implies every other role). viewer: use the frontend and playback when the node is private. streamer: ingest media to this node. syndicate: have this account's media carried from other nodes. vod: upload and record VODs."
+ },
+ "place.stream.access.defs_grantView": {
+ "type": "object",
+ "description": "One account's grant of one role. Grants stored as place.stream.access.grant records in the node's access-control space carry a uri and cid; grants seeded from the node's environment (SP_ADMIN_DIDS, SP_ALLOWED_STREAMS, SP_SYNDICATE) have no uri and cannot be revoked from the API.",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "description": "at://{authority}/space/place.stream.access.control/self/{author}/place.stream.access.grant/{rkey} (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.)"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "subject": {
+ "type": "string",
+ "format": "did"
+ },
+ "role": {
+ "$ref": "#/components/schemas/place.stream.access.defs_role"
+ },
+ "source": {
+ "type": "string",
+ "description": "space: a record in the access-control space, editable via the API. environment: seeded from the node's configuration."
+ },
+ "createdBy": {
+ "type": "string",
+ "description": "The admin that created the grant (the record's author).",
+ "format": "did"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "note": {
+ "type": "string",
+ "maxLength": 1000
+ }
+ },
+ "required": ["subject", "role", "source"]
+ },
+ "place.stream.access.defs_policyView": {
+ "type": "object",
+ "properties": {
+ "roles": {
+ "type": "array",
+ "description": "The effective mode of every role the node knows about, including environment overrides such as SP_WIDE_OPEN and SP_DISABLE_SYNDICATION.",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.access.defs_roleMode"
+ }
+ }
+ },
+ "required": ["roles"]
+ },
+ "place.stream.access.defs_roleMode": {
+ "type": "object",
+ "properties": {
+ "role": {
+ "$ref": "#/components/schemas/place.stream.access.defs_role"
+ },
+ "mode": {
+ "$ref": "#/components/schemas/place.stream.access.defs_mode"
+ }
+ },
+ "required": ["role", "mode"]
+ },
+ "place.stream.access.defs_mode": {
+ "type": "string",
+ "description": "How a node decides a role. open: every account holds the role. allowlist: only accounts with a grant hold the role. off: nobody holds the role (admins are always exempt)."
+ },
"com.atproto.sync.listRepos_repo": {
"type": "object",
"properties": {