music on atproto
plyr.fm
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