research: ATProto OAuth permission sets#
date: 2026-01-01 question: how do ATProto OAuth permission sets work, and how could plyr.fm adopt them?
summary#
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).
findings#
how permission sets work#
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.
resolution flow:
- app requests
include:fm.plyr.authBasicFeatures?aud=did:web:api.plyr.fm%23svc_appviewin OAuth scope - PDS extracts NSID
fm.plyr.authBasicFeatures - reverses authority:
fm.plyr→plyr.fm - resolves
plyr.fmto a DID via DNS TXT record - fetches lexicon from that DID's repo at
com.atproto.lexicon.schema/fm.plyr.authBasicFeatures - displays
titleandpermissionsto user in authorization UI
real example from Bailey Townsend's repo (did:plc:rnpkyqnmsw4ipey6eotbdnnf on selfhosted.social):
{
"id": "dev.baileytownsend.demo.authBasicFeatures",
"lexicon": 1,
"$type": "com.atproto.lexicon.schema",
"defs": {
"main": {
"type": "permission-set",
"title": "Basic App Functionality",
"description": "An example simple permission set",
"permissions": [
{
"type": "permission",
"resource": "repo",
"action": ["create"],
"collection": ["dev.baileytownsend.demo.example"]
}
]
}
}
}
plyr.fm's current OAuth implementation#
plyr.fm uses a custom fork of the atproto SDK (git+https://github.com/zzstoatzz/atproto@main) with OAuth 2.1 support.
current scope construction (backend/src/backend/config.py:420-441):
@computed_field
@property
def resolved_scope(self) -> str:
scopes = [
f"repo:{self.track_collection}", # repo:fm.plyr.track
f"repo:{self.like_collection}", # repo:fm.plyr.like
f"repo:{self.comment_collection}", # repo:fm.plyr.comment
f"repo:{self.list_collection}", # repo:fm.plyr.list
f"repo:{self.profile_collection}", # repo:fm.plyr.actor.profile
]
return f"atproto {' '.join(scopes)}"
optional teal.fm scopes (config.py:443-452):
def resolved_scope_with_teal(self, teal_play: str, teal_status: str) -> str:
base = self.resolved_scope
teal_scopes = [f"repo:{teal_play}", f"repo:{teal_status}"]
return f"{base} {' '.join(teal_scopes)}"
resulting scope string:
atproto repo:fm.plyr.track repo:fm.plyr.like repo:fm.plyr.comment repo:fm.plyr.list repo:fm.plyr.actor.profile
developer tokens#
developer tokens are independent OAuth sessions for API/CLI access (backend/src/backend/api/auth.py:333-374).
key differences from regular sessions:
- separate OAuth grant with independent refresh tokens
- configurable expiration (default 90 days, max 365)
- stored with
is_developer_token=Trueflag - don't set browser cookies on exchange
current behavior: dev tokens request the same scopes as regular sessions. with permission sets, we could:
- define
fm.plyr.authFullAppfor browser sessions (all collections) - define
fm.plyr.authDeveloperfor dev tokens (possibly read-heavy, limited write) - define
fm.plyr.authReadOnlyfor third-party apps (read-only access)
namespace constraints#
permission sets can only reference resources in the same NSID namespace. fm.plyr.authBasicFeatures can only grant permissions to fm.plyr.* collections.
this means:
- teal.fm scopes (
fm.teal.alpha.*) cannot be bundled in our permission sets - 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
publishing permission sets#
to publish a permission set, write it to com.atproto.lexicon.schema collection:
# pseudocode
await client.com.atproto.repo.putRecord(
repo=our_did,
collection="com.atproto.lexicon.schema",
rkey="fm.plyr.authBasicFeatures",
record={
"$type": "com.atproto.lexicon.schema",
"lexicon": 1,
"id": "fm.plyr.authBasicFeatures",
"defs": {
"main": {
"type": "permission-set",
"title": "plyr.fm Music Library",
"description": "Create and manage your music library",
"permissions": [...]
}
}
}
)
DNS requirement: lexicon resolution uses _lexicon prefix (distinct from _atproto for handles):
_lexicon.plyr.fm→did=did:plc:vs3hnzq2daqbszxlysywzy54(production)_lexicon.stg.plyr.fm→did=did:plc:vs3hnzq2daqbszxlysywzy54(staging)
official bluesky permission sets#
bluesky defines several permission sets in their lexicons (lexicons/app/bsky/):
app.bsky.authFullApp- full Bluesky app permissionsapp.bsky.authCreatePosts- create posts only (no update/delete)app.bsky.authViewAll- read-only accessapp.bsky.authManageProfile- profile management onlyapp.bsky.authManageNotifications- notification management
these demonstrate the pattern of offering tiered permission levels.
code references#
backend/src/backend/config.py:420-452- current scope constructionbackend/src/backend/_internal/auth.py:165-194- OAuth client creation with scopesbackend/src/backend/api/auth.py:333-374- developer token flowbackend/src/backend/models/session.py- session model withis_developer_tokenflagdocs/lexicons/overview.md- current lexicon documentationdocs/authentication.md- OAuth flow documentation
permission set for plyr.fm#
fm.plyr.authFullApp#
full access for the main web app:
{
"permissions": [
{
"type": "permission",
"resource": "repo",
"action": ["create", "update", "delete"],
"collection": [
"fm.plyr.track",
"fm.plyr.like",
"fm.plyr.comment",
"fm.plyr.list",
"fm.plyr.actor.profile"
]
}
]
}
additional permission sets (e.g., listener-only, read-only) can be added when there's a concrete use case.
resolved questions#
-
DNS setup: lexicon resolution requires
_lexiconTXT records (not_atprotowhich is for handles):- production:
_lexicon.plyr.fm→did=did:plc:vs3hnzq2daqbszxlysywzy54 - staging:
_lexicon.stg.plyr.fm→did=did:plc:vs3hnzq2daqbszxlysywzy54
- production:
-
which repo?: the
plyr.fmaccount (did:plc:vs3hnzq2daqbszxlysywzy54) on bsky.network - just publish tocom.atproto.lexicon.schemacollection -
SDK support: the SDK fork at
zzstoatzz/atprotojust passes scope strings to the PDS - permission set resolution is server-side. any PDS supporting OAuth 2.1 should resolveinclude:scopes.
open questions#
-
teal.fm integration: since teal scopes can't be in our permission sets (different namespace), keep as granular
repo:scopes for teal -
developer token differentiation: should dev tokens get different permission sets than browser sessions?
next steps#
- draft permission set lexicons in
/lexicons/as JSON files - publish to
com.atproto.lexicon.schemacollection on the plyr.fm account - update OAuth client to use
include:fm.plyr.authFullAppscope - test with staging environment first