Expand description
The granular atproto OAuth scope grammar, parsed into a typed representation with intersection and containment operations.
plan/oauth.md calls this “the single most important thing” this epic
produces, because plan/scope-policy.md has to intersect a request
against a per-agent ceiling and report exactly which scope was refused —
impossible to do correctly over bare strings, where repo:app.bsky.feed.*
and repo:app.bsky.feed.post are unrelated text but one contains the
other. Everything here is inert with respect to policy: this module
parses the wire grammar, tests whether one Scope contains another, and
intersects a requested ScopeSet against a ceiling ScopeSet. It does
not decide what a ceiling is — that is plan/scope-policy.md, blocked
on this and on policy-store, and it is deliberately not built here.
§The grammar
Space-separated scope strings, one atom each:
atproto— the base scope every atproto OAuth grant carries.transition:generic,transition:chat.bsky,transition:email— the legacy, ungranular scopes the spec keeps for compatibility. Each is modelled as its own variant rather than desugared into the granular scopes it stands in for, becauseplan/scope-policy.mdis explicit thattransition:*is the one exception every granular rule has to reckon with by name, not the one this module should quietly launder away.repo:<collection>[?action=create&action=update…]— write access to one collection. Several actions repeat the parameter, as the permission spec writes every list parameter; noactionquery means all three.rpc:<lxm>?aud=<aud>— permission to call one XRPC method at another service: the audience is a DID and a service fragment, with its#written%23, or*for every audience.blob:<mime>, orblob?accept=<mime>[&accept=<mime>…]— permission to upload a blob of one MIME type or type prefix. The two spellings are the same scope: the positional one names a single type, theacceptone names as many as it repeats. How large a blob may be is not in this grammar; it is the operator’s, in policy and in the server’s blob limit.identity:<attr>— control of the account’s identity:IdentityAttrnames the handle, or*for the whole DID document and handle.plan/scope-policy.mdnever grants one.account:<attr>[?action=<action>]— the account’s hosting:AccountAttrnames what, andAccountActionhow much,readwhen the atom names none. Never granted either.include:<nsid>[?aud=<did>%23<service>]— a permission set, published as a lexicon. As an atom it admits only itself.Includereads it, andInclude::grantsturns the set’s published permissions into therepo:andrpc:atoms it grants.
Every value may be percent-encoded, and every positional value may be
written as a parameter instead: repo?collection=a&collection=b,
rpc?lxm=*&aud=…, identity?attr=handle, include?nsid=…. A list
parameter names one scope per value, so repo?collection=a&collection=b
reads as repo:a repo:b, and that is how it prints.
mime patterns may wildcard either half of type/subtype (image/*
contains image/png).
§Forms the spec does not define
ScopeSet::read reads an atom written in a form the permission spec
does not define as well as it can, and says how in a ScopeWarning.
One such form is a partial wildcard, some.prefix.*, which this grammar
reads as the prefix and every NSID beneath it at any depth: an operator’s
ceiling uses it without a warning.
An atom this grammar cannot read at all — an unknown kind, or a value its
kind does not define — is kept as Scope::Unknown, which admits only
itself, rather than failing the whole string. A request carrying one can
then be judged on the rest. didbot-serve never grants one.
Modules§
- account 🔒
account:scopes, as the permission spec defines them: one attribute of the account’s hosting, and how much control over it.- action 🔒
action=values, and the set a scope carries.- error 🔒
- The limits a scope string is read under, and every way reading one fails.
- identity 🔒
identity:scopes, as the permission spec defines them: control of the account’s DID document and handle.- include 🔒
- What an
include:scope names, and what the permission set it names grants. - parse 🔒
- Splitting one atom into its kind, its positional part and its parameters, as the permission spec’s “Scope String Syntax” writes them.
- pattern 🔒
- The NSID and MIME patterns a scope’s positional value may be.
- read 🔒
- Reading one atom: the scopes it names, and the warnings its form carries.
- scope 🔒
- One parsed scope atom: what it is, what it admits, and how it prints.
- set 🔒
- A canonical set of atoms, and the intersection a ceiling is applied with.
- transition 🔒
- The three legacy
transition:*scopes and what each subsumes. - warning 🔒
- How an atom written in a form the permission spec does not define is read.
Structs§
- Include
- An
include:scope, read: the permission set it names, and the audience that set’srpcpermissions may inherit. - Mime
Pattern - A MIME pattern:
type/subtype, with either half (or both) as*. - Scope
Refused - The exact scope a ceiling refused, for
insufficient_scope. - Scope
Set - A space-separated set of
Scopes, as carried on ascopeparameter or a token response.
Enums§
- Account
Action - How much control an
account:scope grants over its attribute. - Account
Attr - The part of an account’s hosting an
account:scope names. - Action
- One action a
repo:scope’saction=query may name.account:has its own, incrate::AccountAction. - Action
Set - The set of actions a
repo:scope grants. - Identity
Attr - The part of an account’s identity an
identity:scope names. - Include
Error - Why an
include:scope names no permission set. - Nsid
Pattern - An NSID pattern: exact, a recursive prefix wildcard, or the bare
*. - Scope
- One parsed scope atom.
- Scope
Parse Error - A failure parsing one scope atom or a whole scope string.
- Scope
Warning - One way an atom’s form departs from the permission spec, and how this grammar reads it anyway. A spec form carries none.
- Transition
- The three legacy
transition:*scopes.
Constants§
- MAX_
ATOM_ BYTES - The longest atom
Scope::parsereads: room for a longest NSID (317 bytes) with a longest DID (2048 bytes) as itsaud. - MAX_
GRANT_ ATOMS - The most atoms
ScopeSet::parse_grantreads. Four timesMAX_SCOPE_ATOMS, for the same reason asMAX_GRANT_BYTES. - MAX_
GRANT_ BYTES - The longest grant
ScopeSet::parse_grantreads: a request with eachinclude:replaced by the atoms its permission set grants. - MAX_
SCOPE_ ATOMS - The most scopes a string
ScopeSet::parsereads may name. An atom that lists several collections or methods names one for each.ScopeSet::newcompares every pair of scopes, so this also bounds its cost. - MAX_
SCOPE_ BYTES - The longest scope string
ScopeSet::parsereads. A real request is a few hundred bytes.