feat: add ATProto OAuth permission sets (#697)

adds permission set lexicon that bundles OAuth permissions under
a human-readable title. users will see "plyr.fm Music Library" instead
of "fm.plyr.track, fm.plyr.like, fm.plyr.comment..."

lexicon:
- fm.plyr.authFullApp: full access for main web app

config:
- ATPROTO_USE_PERMISSION_SETS=true enables permission sets
- defaults to false (granular scopes) until lexicons are published

docs:
- research doc on how permission sets work
- updated lexicons overview with permission set section

to enable: publish lexicon to com.atproto.lexicon.schema on plyr.fm
authority repo, then set ATPROTO_USE_PERMISSION_SETS=true

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 802ee1e4 dddc4569

Changed files
+271 -2
backend
src
backend
docs
lexicons
+23 -1
STATUS.md
··· 45 45 46 46 ## recent work 47 47 48 + ### January 2026 49 + 50 + #### atprotofans supporters display (PRs #695-696, Jan 1) 51 + 52 + **supporters now visible on artist pages** - artists using atprotofans can show their supporters: 53 + - compact overlapping avatar circles (GitHub sponsors style) with "+N" overflow badge 54 + - clicks link to supporter's plyr.fm artist page (keeps users in-app) 55 + - `POST /artists/batch` endpoint enriches supporter DIDs with avatar_url from our Artist table 56 + - frontend fetches from atprotofans, enriches via backend, renders with consistent avatar pattern 57 + 58 + **route ordering fix** (PR #696): FastAPI was matching `/artists/batch` as `/{did}` with did="batch". moved POST route before the catchall GET route. 59 + 60 + --- 61 + 62 + #### UI polish (PRs #692-694, Dec 31 - Jan 1) 63 + 64 + - **feed/library toggle** (PR #692): consistent header layout with toggle between feed and library views 65 + - **shuffle button moved** (PR #693): shuffle now in queue component instead of player controls 66 + - **justfile consistency** (PR #694): standardized `just run` across frontend/backend modules 67 + 68 + --- 69 + 48 70 ### December 2025 49 71 50 72 #### avatar sync on login (PR #685, Dec 31) ··· 360 382 361 383 --- 362 384 363 - this is a living document. last updated 2025-12-31. 385 + this is a living document. last updated 2026-01-01.
+10 -1
backend/src/backend/config.py
··· 362 362 validation_alias="ATPROTO_SCOPE_OVERRIDE", 363 363 description="Optional OAuth scope override", 364 364 ) 365 + use_permission_sets: bool = Field( 366 + default=False, 367 + validation_alias="ATPROTO_USE_PERMISSION_SETS", 368 + description="Use ATProto permission sets instead of granular repo scopes. Requires permission set lexicons to be published to the app namespace authority.", 369 + ) 365 370 oauth_encryption_key: str = Field( 366 371 default="", 367 372 validation_alias="OAUTH_ENCRYPTION_KEY", ··· 425 430 if self.scope_override: 426 431 return self.scope_override 427 432 428 - # base scopes: our track, like, comment, list, and profile collections 433 + # use permission sets if enabled (requires lexicons to be published) 434 + if self.use_permission_sets: 435 + return f"atproto include:{self.app_namespace}.authFullApp" 436 + 437 + # fallback: granular repo scopes for each collection 429 438 scopes = [ 430 439 f"repo:{self.track_collection}", 431 440 f"repo:{self.like_collection}",
+16
docs/lexicons/overview.md
··· 139 139 140 140 this removes the manual sync burden and enables type-safe ATProto record handling. 141 141 142 + ## permission sets 143 + 144 + permission sets bundle OAuth permissions under human-readable titles. instead of users seeing "fm.plyr.track, fm.plyr.like, ..." they see "plyr.fm Music Library". 145 + 146 + ### fm.plyr.authFullApp 147 + 148 + full access for the main web app - create/update/delete on all collections. 149 + 150 + ### enabling permission sets 151 + 152 + set `ATPROTO_USE_PERMISSION_SETS=true` to use `include:fm.plyr.authFullApp` instead of granular scopes. 153 + 154 + **requirement**: permission set lexicons must be published to `com.atproto.lexicon.schema` collection on the `plyr.fm` authority repo (`did:plc:vs3hnzq2daqbszxlysywzy54`). 155 + 156 + see [research doc](../research/2026-01-01-atproto-oauth-permission-sets.md) for implementation details. 157 + 142 158 ## adding new lexicons 143 159 144 160 when adding a new record type:
+197
docs/research/2026-01-01-atproto-oauth-permission-sets.md
··· 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 + 8 + ATProto 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 + 14 + permission 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:** 17 + 1. app requests `include:fm.plyr.authBasicFeatures?aud=did:web:api.plyr.fm%23svc_appview` in OAuth scope 18 + 2. PDS extracts NSID `fm.plyr.authBasicFeatures` 19 + 3. reverses authority: `fm.plyr` → `plyr.fm` 20 + 4. resolves `plyr.fm` to a DID via DNS TXT record 21 + 5. fetches lexicon from that DID's repo at `com.atproto.lexicon.schema/fm.plyr.authBasicFeatures` 22 + 6. 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 + 51 + plyr.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 58 + def 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 71 + def 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 + ``` 79 + atproto 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 + 84 + developer 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: 93 + 1. define `fm.plyr.authFullApp` for browser sessions (all collections) 94 + 2. define `fm.plyr.authDeveloper` for dev tokens (possibly read-heavy, limited write) 95 + 3. define `fm.plyr.authReadOnly` for third-party apps (read-only access) 96 + 97 + ### namespace constraints 98 + 99 + permission sets can **only reference resources in the same NSID namespace**. `fm.plyr.authBasicFeatures` can only grant permissions to `fm.plyr.*` collections. 100 + 101 + this 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 + 107 + to publish a permission set, write it to `com.atproto.lexicon.schema` collection: 108 + 109 + ```python 110 + # pseudocode 111 + await 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:** `plyr.fm` must have a TXT record `did=did:plc:...` pointing to the repo containing the lexicons. 132 + 133 + ### official bluesky permission sets 134 + 135 + bluesky defines several permission sets in their lexicons (`lexicons/app/bsky/`): 136 + - `app.bsky.authFullApp` - full Bluesky app permissions 137 + - `app.bsky.authCreatePosts` - create posts only (no update/delete) 138 + - `app.bsky.authViewAll` - read-only access 139 + - `app.bsky.authManageProfile` - profile management only 140 + - `app.bsky.authManageNotifications` - notification management 141 + 142 + these demonstrate the pattern of offering tiered permission levels. 143 + 144 + ## code references 145 + 146 + - `backend/src/backend/config.py:420-452` - current scope construction 147 + - `backend/src/backend/_internal/auth.py:165-194` - OAuth client creation with scopes 148 + - `backend/src/backend/api/auth.py:333-374` - developer token flow 149 + - `backend/src/backend/models/session.py` - session model with `is_developer_token` flag 150 + - `docs/lexicons/overview.md` - current lexicon documentation 151 + - `docs/authentication.md` - OAuth flow documentation 152 + 153 + ## permission set for plyr.fm 154 + 155 + ### fm.plyr.authFullApp 156 + full access for the main web app: 157 + ```json 158 + { 159 + "permissions": [ 160 + { 161 + "type": "permission", 162 + "resource": "repo", 163 + "action": ["create", "update", "delete"], 164 + "collection": [ 165 + "fm.plyr.track", 166 + "fm.plyr.like", 167 + "fm.plyr.comment", 168 + "fm.plyr.list", 169 + "fm.plyr.actor.profile" 170 + ] 171 + } 172 + ] 173 + } 174 + ``` 175 + 176 + additional permission sets (e.g., listener-only, read-only) can be added when there's a concrete use case. 177 + 178 + ## resolved questions 179 + 180 + 1. **DNS setup**: `_atproto.plyr.fm` already has TXT record `did=did:plc:vs3hnzq2daqbszxlysywzy54` 181 + 182 + 2. **which repo?**: the `plyr.fm` account (did:plc:vs3hnzq2daqbszxlysywzy54) on bsky.network - just publish to `com.atproto.lexicon.schema` collection 183 + 184 + 3. **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. 185 + 186 + ## open questions 187 + 188 + 1. **teal.fm integration**: since teal scopes can't be in our permission sets (different namespace), keep as granular `repo:` scopes for teal 189 + 190 + 2. **developer token differentiation**: should dev tokens get different permission sets than browser sessions? 191 + 192 + ## next steps 193 + 194 + 1. draft permission set lexicons in `/lexicons/` as JSON files 195 + 2. publish to `com.atproto.lexicon.schema` collection on the plyr.fm account 196 + 3. update OAuth client to use `include:fm.plyr.authFullApp` scope 197 + 4. test with staging environment first
+25
lexicons/authFullApp.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.plyr.authFullApp", 4 + "defs": { 5 + "main": { 6 + "type": "permission-set", 7 + "title": "plyr.fm", 8 + "description": "Upload and manage audio content, playlists, likes, and comments.", 9 + "permissions": [ 10 + { 11 + "type": "permission", 12 + "resource": "repo", 13 + "action": ["create", "update", "delete"], 14 + "collection": [ 15 + "fm.plyr.track", 16 + "fm.plyr.like", 17 + "fm.plyr.comment", 18 + "fm.plyr.list", 19 + "fm.plyr.actor.profile" 20 + ] 21 + } 22 + ] 23 + } 24 + } 25 + }