1# research: ATProto OAuth permission sets 2 3**date**: 2026-01-01 4**question**: how do ATProto OAuth permission sets work, and how could plyr.fm adopt them? 5 6## summary 7 8ATProto permission sets are lexicon schemas (`type: "permission-set"`) that bundle OAuth permissions under human-readable titles. they're published to `com.atproto.lexicon.schema` in an authority's ATProto repo and resolved by the PDS during OAuth authorization. plyr.fm currently uses granular `repo:` scopes directly; adopting permission sets would provide better UX and enable per-feature authorization (e.g., separate scopes for developer tokens). 9 10## findings 11 12### how permission sets work 13 14permission sets are lexicon documents with `type: "permission-set"` in `defs.main`. they're published to the `com.atproto.lexicon.schema` collection in an ATProto repo and resolved via the NSID's authority domain. 15 16**resolution flow:** 171. app requests `include:fm.plyr.authBasicFeatures?aud=did:web:api.plyr.fm%23svc_appview` in OAuth scope 182. PDS extracts NSID `fm.plyr.authBasicFeatures` 193. reverses authority: `fm.plyr``plyr.fm` 204. resolves `plyr.fm` to a DID via DNS TXT record 215. fetches lexicon from that DID's repo at `com.atproto.lexicon.schema/fm.plyr.authBasicFeatures` 226. displays `title` and `permissions` to user in authorization UI 23 24**real example from Bailey Townsend's repo** (did:plc:rnpkyqnmsw4ipey6eotbdnnf on selfhosted.social): 25 26```json 27{ 28 "id": "dev.baileytownsend.demo.authBasicFeatures", 29 "lexicon": 1, 30 "$type": "com.atproto.lexicon.schema", 31 "defs": { 32 "main": { 33 "type": "permission-set", 34 "title": "Basic App Functionality", 35 "description": "An example simple permission set", 36 "permissions": [ 37 { 38 "type": "permission", 39 "resource": "repo", 40 "action": ["create"], 41 "collection": ["dev.baileytownsend.demo.example"] 42 } 43 ] 44 } 45 } 46} 47``` 48 49### plyr.fm's current OAuth implementation 50 51plyr.fm uses a custom fork of the atproto SDK (`git+https://github.com/zzstoatzz/atproto@main`) with OAuth 2.1 support. 52 53**current scope construction** (`backend/src/backend/config.py:420-441`): 54 55```python 56@computed_field 57@property 58def resolved_scope(self) -> str: 59 scopes = [ 60 f"repo:{self.track_collection}", # repo:fm.plyr.track 61 f"repo:{self.like_collection}", # repo:fm.plyr.like 62 f"repo:{self.comment_collection}", # repo:fm.plyr.comment 63 f"repo:{self.list_collection}", # repo:fm.plyr.list 64 f"repo:{self.profile_collection}", # repo:fm.plyr.actor.profile 65 ] 66 return f"atproto {' '.join(scopes)}" 67``` 68 69**optional teal.fm scopes** (`config.py:443-452`): 70```python 71def resolved_scope_with_teal(self, teal_play: str, teal_status: str) -> str: 72 base = self.resolved_scope 73 teal_scopes = [f"repo:{teal_play}", f"repo:{teal_status}"] 74 return f"{base} {' '.join(teal_scopes)}" 75``` 76 77**resulting scope string:** 78``` 79atproto repo:fm.plyr.track repo:fm.plyr.like repo:fm.plyr.comment repo:fm.plyr.list repo:fm.plyr.actor.profile 80``` 81 82### developer tokens 83 84developer tokens are independent OAuth sessions for API/CLI access (`backend/src/backend/api/auth.py:333-374`). 85 86**key differences from regular sessions:** 87- separate OAuth grant with independent refresh tokens 88- configurable expiration (default 90 days, max 365) 89- stored with `is_developer_token=True` flag 90- don't set browser cookies on exchange 91 92**current behavior:** dev tokens request the same scopes as regular sessions. with permission sets, we could: 931. define `fm.plyr.authFullApp` for browser sessions (all collections) 942. define `fm.plyr.authDeveloper` for dev tokens (possibly read-heavy, limited write) 953. define `fm.plyr.authReadOnly` for third-party apps (read-only access) 96 97### namespace constraints 98 99permission sets can **only reference resources in the same NSID namespace**. `fm.plyr.authBasicFeatures` can only grant permissions to `fm.plyr.*` collections. 100 101this means: 102- teal.fm scopes (`fm.teal.alpha.*`) cannot be bundled in our permission sets 103- we'd still need to request teal scopes separately: `include:fm.plyr.authBasicFeatures repo:fm.teal.alpha.feed.play repo:fm.teal.alpha.actor.status` 104 105### publishing permission sets 106 107to publish a permission set, write it to `com.atproto.lexicon.schema` collection: 108 109```python 110# pseudocode 111await client.com.atproto.repo.putRecord( 112 repo=our_did, 113 collection="com.atproto.lexicon.schema", 114 rkey="fm.plyr.authBasicFeatures", 115 record={ 116 "$type": "com.atproto.lexicon.schema", 117 "lexicon": 1, 118 "id": "fm.plyr.authBasicFeatures", 119 "defs": { 120 "main": { 121 "type": "permission-set", 122 "title": "plyr.fm Music Library", 123 "description": "Create and manage your music library", 124 "permissions": [...] 125 } 126 } 127 } 128) 129``` 130 131**DNS requirement:** lexicon resolution uses `_lexicon` prefix (distinct from `_atproto` for handles): 132- `_lexicon.plyr.fm``did=did:plc:vs3hnzq2daqbszxlysywzy54` (production) 133- `_lexicon.stg.plyr.fm``did=did:plc:vs3hnzq2daqbszxlysywzy54` (staging) 134 135### official bluesky permission sets 136 137bluesky defines several permission sets in their lexicons (`lexicons/app/bsky/`): 138- `app.bsky.authFullApp` - full Bluesky app permissions 139- `app.bsky.authCreatePosts` - create posts only (no update/delete) 140- `app.bsky.authViewAll` - read-only access 141- `app.bsky.authManageProfile` - profile management only 142- `app.bsky.authManageNotifications` - notification management 143 144these demonstrate the pattern of offering tiered permission levels. 145 146## code references 147 148- `backend/src/backend/config.py:420-452` - current scope construction 149- `backend/src/backend/_internal/auth.py:165-194` - OAuth client creation with scopes 150- `backend/src/backend/api/auth.py:333-374` - developer token flow 151- `backend/src/backend/models/session.py` - session model with `is_developer_token` flag 152- `docs/lexicons/overview.md` - current lexicon documentation 153- `docs/authentication.md` - OAuth flow documentation 154 155## permission set for plyr.fm 156 157### fm.plyr.authFullApp 158full access for the main web app: 159```json 160{ 161 "permissions": [ 162 { 163 "type": "permission", 164 "resource": "repo", 165 "action": ["create", "update", "delete"], 166 "collection": [ 167 "fm.plyr.track", 168 "fm.plyr.like", 169 "fm.plyr.comment", 170 "fm.plyr.list", 171 "fm.plyr.actor.profile" 172 ] 173 } 174 ] 175} 176``` 177 178additional permission sets (e.g., listener-only, read-only) can be added when there's a concrete use case. 179 180## resolved questions 181 1821. **DNS setup**: lexicon resolution requires `_lexicon` TXT records (not `_atproto` which is for handles): 183 - production: `_lexicon.plyr.fm``did=did:plc:vs3hnzq2daqbszxlysywzy54` 184 - staging: `_lexicon.stg.plyr.fm``did=did:plc:vs3hnzq2daqbszxlysywzy54` 185 1862. **which repo?**: the `plyr.fm` account (did:plc:vs3hnzq2daqbszxlysywzy54) on bsky.network - just publish to `com.atproto.lexicon.schema` collection 187 1883. **SDK support**: the SDK fork at `zzstoatzz/atproto` just passes scope strings to the PDS - permission set resolution is server-side. any PDS supporting OAuth 2.1 should resolve `include:` scopes. 189 190## open questions 191 1921. **teal.fm integration**: since teal scopes can't be in our permission sets (different namespace), keep as granular `repo:` scopes for teal 193 1942. **developer token differentiation**: should dev tokens get different permission sets than browser sessions? 195 196## next steps 197 1981. draft permission set lexicons in `/lexicons/` as JSON files 1992. publish to `com.atproto.lexicon.schema` collection on the plyr.fm account 2003. update OAuth client to use `include:fm.plyr.authFullApp` scope 2014. test with staging environment first