Something went wrong. Try again.
An AT Protocol Personal Data Server written in JavaScript pdsjs.dev
pds atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786// @pdsjs/core/scope - OAuth scope parsing and enforcement// Handles repo and blob scope permissions for AT Protocol OAuth
/** * Parse a repo scope string into collection and actions. * Official format: repo:collection?action=create&action=update * Or: repo?collection=foo&action=create * Without actions defaults to all: create, update, delete * @param {string} scope - The scope string to parse * @returns {{ collection: string, actions: string[] } | null} Parsed scope or null if invalid */export function parseRepoScope(scope) { if (!scope.startsWith('repo:') && !scope.startsWith('repo?')) return null;
const ALL_ACTIONS = ['create', 'update', 'delete']; let collection; let actions;
const questionIdx = scope.indexOf('?'); if (questionIdx === -1) { // repo:collection (no query params = all actions) collection = scope.slice(5); actions = ALL_ACTIONS; } else { // Parse query parameters const queryString = scope.slice(questionIdx + 1); const params = new URLSearchParams(queryString); const pathPart = scope.startsWith('repo:') ? scope.slice(5, questionIdx) : '';
collection = pathPart || params.get('collection'); actions = params.getAll('action'); if (actions.length === 0) actions = ALL_ACTIONS; }
if (!collection) return null;
// Validate actions const validActions = [ ...new Set(actions.filter((a) => ALL_ACTIONS.includes(a))), ]; if (validActions.length === 0) return null;
return { collection, actions: validActions };}
/** * Parse an rpc scope string into lexicon methods and audience. * Official format: rpc:<lxm>?aud=<did|did#serviceId|*> * Or: rpc?lxm=<nsid>&lxm=<nsid>&aud=<aud> * Both lxm and aud are required; '*' wildcards both. * @param {string} scope - The scope string to parse * @returns {{ lxms: string[]|'*', aud: string } | null} Parsed scope or null if invalid */export function parseRpcScope(scope) { if (!scope.startsWith('rpc:') && !scope.startsWith('rpc?')) return null;
const questionIdx = scope.indexOf('?'); const pathPart = questionIdx === -1 ? scope.slice(4) : scope.startsWith('rpc:') ? scope.slice(4, questionIdx) : ''; const params = new URLSearchParams( questionIdx === -1 ? '' : scope.slice(questionIdx + 1), );
const aud = params.get('aud'); if (!aud) return null;
const lxms = pathPart ? [decodeURIComponent(pathPart)] : params.getAll('lxm'); if (lxms.length === 0) return null;
if (lxms.includes('*')) return { lxms: '*', aud }; return { lxms, aud };}
/** * Parse an include scope string referencing a permission-set lexicon. * Official format: include:<nsid>[?aud=<did|did#serviceId>] * The NSID names a lexicon whose main def is a permission-set; the optional * aud is injected into the set's rpc permissions that declare inheritAud. * @param {string} scope - The scope string to parse * @returns {{ nsid: string, aud: string|null } | null} Parsed scope or null if invalid */export function parseIncludeScope(scope) { if (!scope.startsWith('include:')) return null;
const questionIdx = scope.indexOf('?'); const nsid = questionIdx === -1 ? scope.slice(8) : scope.slice(8, questionIdx); // An NSID has at least three segments (authority + name), no wildcards. if (nsid.split('.').length < 3 || nsid.includes('*')) return null;
const params = new URLSearchParams( questionIdx === -1 ? '' : scope.slice(questionIdx + 1), ); const aud = params.get('aud'); if (aud !== null && !aud.startsWith('did:')) return null;
return { nsid, aud };}
/** * One entry of a permission set's `permissions` array, as loosely as a * resolved lexicon document may carry it. * @typedef {Object} LexPermission * @property {unknown} [type] * @property {unknown} [resource] * @property {unknown} [collection] * @property {unknown} [action] * @property {unknown} [lxm] * @property {unknown} [aud] * @property {unknown} [inheritAud] * @property {unknown} [spaceType] - `type` names the permission, so a space * permission spells its own type this way * @property {unknown} [authority] * @property {unknown} [skey] * @property {unknown} [manage] */
/** * @typedef {{ type: string, permissions: LexPermission[] } & Record<string, unknown>} PermissionSetDef */
/** * The permission-set definition inside a resolved lexicon document, or null * when the document does not define one. * @param {object|null|undefined} doc - A lexicon document (defs.main expected) * @returns {PermissionSetDef | null} */export function permissionSetDef(doc) { const container = /** @type {{ defs?: Record<string, unknown> }} */ ( doc ?? {} ); const main = /** @type {{ type?: unknown, permissions?: unknown } | undefined} */ ( container.defs?.main ); if (main?.type !== 'permission-set') return null; if (!Array.isArray(main.permissions)) return null; return /** @type {PermissionSetDef} */ (main);}
/** @param {unknown} value @returns {string[]} */function toStringArray(value) { if (typeof value === 'string') return [value]; if (Array.isArray(value)) return value.filter((v) => typeof v === 'string'); return [];}
/** * Expand a permission set into concrete repo:/rpc:/space: scope strings. * * A set may only grant NSIDs under its own authority: for a set named * `app.bsky.authFullApp` every collection and lxm must start with `app.bsky.`. * A space permission is the exception, and only for its collections: the space * type is authority-checked, while the collections belong to that type's * declaration, which another authority may own. * * Wildcards are never grantable from a set, rpc permissions must inherit * their audience from the include scope (a concrete aud baked into the set is * ignored), and resources other than repo/rpc/space are not grantable. Entries * that violate these rules are dropped rather than granted. * * @param {string} setNsid - The permission set's own NSID * @param {object|null} doc - The resolved lexicon document for that NSID * @param {string|null} [aud] - Audience from the include scope, for inheritAud * @returns {string[] | null} Concrete scope strings, or null when the document * is not a permission set */export function expandPermissionSet(setNsid, doc, aud = null) { const def = permissionSetDef(doc); if (!def) return null;
const authorityPrefix = `${setNsid.split('.').slice(0, -1).join('.')}.`; /** @param {string} nsid */ const contained = (nsid) => nsid.startsWith(authorityPrefix) && !nsid.includes('*');
const ALL_ACTIONS = ['create', 'update', 'delete']; const scopes = []; for (const perm of def.permissions) { if (perm?.type !== 'permission') continue;
if (perm.resource === 'repo') { const actions = [ ...new Set( toStringArray(perm.action).filter((a) => ALL_ACTIONS.includes(a)), ), ]; const granted = actions.length === 0 ? ALL_ACTIONS : actions; for (const collection of toStringArray(perm.collection)) { if (!contained(collection)) continue; scopes.push( granted.length === ALL_ACTIONS.length ? `repo:${collection}` : `repo:${collection}?${granted.map((a) => `action=${a}`).join('&')}`, ); } } else if (perm.resource === 'rpc') { if (perm.inheritAud !== true || perm.aud !== undefined || !aud) continue; for (const lxm of toStringArray(perm.lxm)) { if (!contained(lxm)) continue; scopes.push(`rpc:${lxm}?aud=${encodeURIComponent(aud)}`); } } else if (perm.resource === 'space') { // JSON spells the space type `spaceType`, because `type` names the // permission itself. The scope string calls it back. const spaceType = typeof perm.spaceType === 'string' ? perm.spaceType : ''; if (!contained(spaceType)) continue;
const params = new URLSearchParams(); // `authority=*` is what reaches a space somebody else hosts. Absent, the // scope means authority=self, which no shared space matches. const authority = typeof perm.authority === 'string' ? perm.authority : null; if (authority && authority !== 'self') params.set('authority', authority); const skey = typeof perm.skey === 'string' ? perm.skey : null; if (skey && skey !== '*') params.set('skey', skey); for (const collection of toStringArray(perm.collection)) { params.append('collection', collection); } const declared = toStringArray(perm.action); const actions = [ ...new Set(declared.filter((a) => SPACE_ACTIONS.includes(a))), ]; // An action list naming nothing this server knows would otherwise read // as an absent list, which grants the full default four. if (declared.length > 0 && actions.length === 0) continue; const isDefault = actions.length === SPACE_DEFAULT_ACTIONS.length && SPACE_DEFAULT_ACTIONS.every((a) => actions.includes(a)); if (!isDefault) { for (const action of actions) params.append('action', action); } // An absent `manage` param grants no verb at all, so a `manage` list this // server does not know drops itself and leaves the record actions. const manage = new Set( toStringArray(perm.manage).filter((m) => SPACE_MANAGE_OPS.includes(m)), ); for (const op of manage) params.append('manage', op);
const query = params.toString(); scopes.push(query ? `space:${spaceType}?${query}` : `space:${spaceType}`); } } return scopes;}
/** * Replace include: tokens in a scope string with the concrete scopes their * resolved permission sets grant. Tokens whose set is missing from * `permissionSets` (or resolves to something that is not a permission set) * expand to nothing, so an unresolved include never widens access. * @param {string} scope - Space-separated scope string * @param {Record<string, object|null>} [permissionSets] - Resolved lexicon * documents keyed by NSID * @returns {string} The expanded scope string */export function expandScopeString(scope, permissionSets = {}) { const out = []; for (const s of scope.split(' ').filter(Boolean)) { const include = parseIncludeScope(s); if (!include) { out.push(s); continue; } const expanded = expandPermissionSet( include.nsid, permissionSets[include.nsid] ?? null, include.aud, ); if (expanded) out.push(...expanded); } return [...new Set(out)].join(' ') || 'atproto';}
/** * Parse a blob scope string into its components. * Format: blob:<mime>[,<mime>...] * @param {string} scope - The scope string to parse * @returns {{ accept: string[] } | null} Parsed scope or null if invalid */export function parseBlobScope(scope) { if (!scope.startsWith('blob:')) return null;
const mimeStr = scope.slice(5); // Remove 'blob:' if (!mimeStr) return null;
const accept = mimeStr.split(',').filter((m) => m); if (accept.length === 0) return null;
return { accept };}
/** * Check if a MIME pattern matches an actual MIME type. * @param {string} pattern - MIME pattern (e.g., 'image/*', '*\/*', 'image/png') * @param {string} mime - Actual MIME type to check * @returns {boolean} Whether the pattern matches */export function matchesMime(pattern, mime) { const p = pattern.toLowerCase(); const m = mime.toLowerCase();
if (p === '*/*') return true;
if (p.endsWith('/*')) { const pType = p.slice(0, -2); const mType = m.split('/')[0]; return pType === mType; }
return p === m;}
/** * Error thrown when a required scope is missing. */export class ScopeMissingError extends Error { /** * @param {string} scope - The missing scope */ constructor(scope) { super(`Missing required scope "${scope}"`); this.name = 'ScopeMissingError'; this.scope = scope; this.status = 403; }}
/** * Parses and checks OAuth scope permissions. */// ── space: scopes (permissioned data, proposal 0016) ────────────────────────
/** Record-level actions. `read` is whole-space; `read_self` is own-repo only. */export const SPACE_ACTIONS = [ 'read_self', 'read', 'create', 'update', 'delete',];
/** * Omitting `action` grants everything except `read_self`, which `read` already * implies. */export const SPACE_DEFAULT_ACTIONS = ['read', 'create', 'update', 'delete'];
/** Space-level management verbs, governed by the separate `manage=` param. */export const SPACE_MANAGE_OPS = ['create', 'update', 'delete'];
const SKEY_MAX_LENGTH = 512;
/** * Parse a `space:` scope. * * space:<spaceType>[?authority=<did|self|*>][&skey=<skey|*>] * [&collection=<nsid>...][&action=<action>...][&manage=<op>...] * * `authority` defaults to `self` — the granting user's own DID — and `skey` * defaults to `*`. Omitting `collection` leaves it null, meaning "the space * type's declared collections", which only the space type can resolve; callers * that need a concrete list supply it. * * @param {string} scope * @returns {{spaceType: string, authority: string, skey: string, collections: string[]|null, actions: string[], manage: string[]} | null} */export function parseSpaceScope(scope) { if (!scope.startsWith('space:') && !scope.startsWith('space?')) return null;
const questionIdx = scope.indexOf('?'); const pathPart = scope.startsWith('space:') ? scope.slice(6, questionIdx === -1 ? undefined : questionIdx) : ''; const params = new URLSearchParams( questionIdx === -1 ? '' : scope.slice(questionIdx + 1), );
const spaceType = pathPart || params.get('type'); // A space type is an NSID or `*`; NSIDs always contain a dot, which is what // keeps them distinguishable from the `space` marker in a URI. if (!spaceType) return null; if (spaceType !== '*' && !spaceType.includes('.')) return null;
const authority = params.get('authority') ?? 'self'; if ( authority !== '*' && authority !== 'self' && !authority.startsWith('did:') ) { return null; }
const skey = params.get('skey') ?? '*'; if (!skey || skey.length > SKEY_MAX_LENGTH) return null;
const rawCollections = params.getAll('collection'); const collections = rawCollections.length === 0 ? null : rawCollections; if (collections?.some((c) => c !== '*' && !c.includes('.'))) return null;
const rawActions = params.getAll('action'); const actions = rawActions.length === 0 ? [...SPACE_DEFAULT_ACTIONS] : [...new Set(rawActions.filter((a) => SPACE_ACTIONS.includes(a)))]; if (actions.length === 0 && rawActions.length > 0) return null;
const manage = [ ...new Set( params.getAll('manage').filter((m) => SPACE_MANAGE_OPS.includes(m)), ), ];
return { spaceType, authority, skey, collections, actions, manage };}
/** * Whether a grant covers a request against a space. * * A request is authorized when its target matches the grant's * `(authority, spaceType, skey)` — each component equal or covered by `*` — * and the action is granted. Write actions additionally require the target * collection to be covered. * * @param {ReturnType<typeof parseSpaceScope>} grant * @param {Object} req * @param {string} req.spaceType * @param {string} req.authority - the space authority's DID * @param {string} req.skey * @param {string} req.action * @param {string} [req.collection] - required for create/update/delete * @param {string} [req.userDid] - resolves a grant's `self` authority * @param {string[]} [req.declaredCollections] - the space type's declared * collections, used when the grant omitted `collection` * @returns {boolean} */export function spaceScopeMatches(grant, req) { if (!grant) return false;
if (grant.spaceType !== '*' && grant.spaceType !== req.spaceType) return false;
const authority = grant.authority === 'self' ? (req.userDid ?? null) : grant.authority; if (authority !== '*' && authority !== req.authority) return false;
if (grant.skey !== '*' && grant.skey !== req.skey) return false;
// `read` implies `read_self`, but not the reverse. const granted = grant.actions.includes(req.action) || (req.action === 'read_self' && grant.actions.includes('read')); if (!granted) return false;
// read and read_self are collection-independent; writes are not. if (req.action === 'read' || req.action === 'read_self') return true;
const collections = grant.collections ?? req.declaredCollections ?? []; if (!req.collection) return false; return collections.includes('*') || collections.includes(req.collection);}
/** * Whether a grant permits a space-level management verb. Not implied by any * record action. * * @param {ReturnType<typeof parseSpaceScope>} grant * @param {Object} req * @param {string} req.spaceType * @param {string} req.authority * @param {string} req.skey * @param {string} req.op - one of SPACE_MANAGE_OPS * @param {string} [req.userDid] * @returns {boolean} */export function spaceScopeAllowsManage(grant, req) { if (!grant) return false; if (grant.spaceType !== '*' && grant.spaceType !== req.spaceType) return false; const authority = grant.authority === 'self' ? (req.userDid ?? null) : grant.authority; if (authority !== '*' && authority !== req.authority) return false; if (grant.skey !== '*' && grant.skey !== req.skey) return false; return grant.manage.includes(req.op);}
export class ScopePermissions { /** * @param {string | undefined} scopeString - Space-separated scope string */ constructor(scopeString) { /** @type {Set<string>} */ this.scopes = new Set( scopeString ? scopeString.split(' ').filter((s) => s) : [], );
/** @type {Array<{ collection: string, actions: string[] }>} */ this.repoPermissions = [];
/** @type {Array<{ accept: string[] }>} */ this.blobPermissions = [];
/** @type {Array<{ lxms: string[]|'*', aud: string }>} */ this.rpcPermissions = [];
/** @type {Array<NonNullable<ReturnType<typeof parseSpaceScope>>>} */ this.spacePermissions = [];
for (const scope of this.scopes) { const repo = parseRepoScope(scope); if (repo) this.repoPermissions.push(repo);
const blob = parseBlobScope(scope); if (blob) this.blobPermissions.push(blob);
const rpc = parseRpcScope(scope); if (rpc) this.rpcPermissions.push(rpc);
const space = parseSpaceScope(scope); if (space) this.spacePermissions.push(space); } }
/** * Check if a proxied RPC call to a service method is allowed. * The audience is compared in combined `did#serviceId` form; lexicon * method comparison is case-insensitive. * @param {string} lxm - Lexicon method NSID being called * @param {string} aud - Target service as `did#serviceId` * @returns {boolean} */ allowsRpc(lxm, aud) { if (this.skipsGranularChecks()) return true;
const lxmLower = lxm.toLowerCase(); return this.rpcPermissions.some( (perm) => (perm.aud === '*' || perm.aud === aud) && (perm.lxms === '*' || perm.lxms.some((l) => l.toLowerCase() === lxmLower)), ); }
/** * Check whether any granted space scope covers this request. * @param {Parameters<typeof spaceScopeMatches>[1]} req * @returns {boolean} */ allowsSpace(req) { if (this.isPasswordSession()) return true; return this.spacePermissions.some((g) => spaceScopeMatches(g, req)); }
/** * Check whether any granted space scope permits a management verb. * @param {Parameters<typeof spaceScopeAllowsManage>[1]} req * @returns {boolean} */ allowsSpaceManage(req) { if (this.isPasswordSession()) return true; return this.spacePermissions.some((g) => spaceScopeAllowsManage(g, req)); }
/** * Whether this session holds the account itself rather than a grant over * part of it: the password login, which is the account holder acting * directly on a server that hosts one account. * * It is the only session a space is reachable from without a `space:` scope * naming it. `atproto` is in every OAuth authorization request, so reading * full access as space access would give every application every space the * account can reach, at every authority, without asking. The reference * refuses a space even to `transition:generic`, a token that predates * permissioned data. * @returns {boolean} */ isPasswordSession() { return this.scopes.has('com.atproto.access'); }
/** * Check if full access is granted (atproto, transition:generic, or legacy com.atproto.access). * @returns {boolean} */ hasFullAccess() { return ( this.scopes.has('atproto') || this.scopes.has('transition:generic') || this.scopes.has('com.atproto.access') ); }
/** * Whether this is an app-password session, whose scope string names no * granular permissions at all. * * The reference PDS asserts repo, blob and rpc permissions only for OAuth * credentials, and bounds an app password by other means: it cannot mint or * list app passwords, and it never sees the account email. Held apart from * hasFullAccess so those bounds stay in force, and so space scopes -- which * the reference has no equivalent of -- are not granted by this route. * @returns {boolean} */ isAppPasswordSession() { return ( this.scopes.has('com.atproto.appPass') || this.scopes.has('com.atproto.appPassPrivileged') ); }
/** * Whether granular repo, blob and rpc permissions apply to this session at * all. They do not for the legacy session scopes. * @returns {boolean} */ skipsGranularChecks() { return this.hasFullAccess() || this.isAppPasswordSession(); }
/** * Check if the session may read the account's own email address. Granted by * atproto's `transition:email` scope or the granular `account:email` scope. * Deliberately NOT implied by full access: `transition:generic` and `atproto` * cover the repository but not the email, matching the reference PDS. * @returns {boolean} */ hasEmailAccess() { for (const scope of this.scopes) { if ( scope === 'transition:email' || scope === 'account:email' || scope.startsWith('account:email?') ) { return true; } } return false; }
/** * Check if a repo operation is allowed. * @param {string} collection - The collection NSID * @param {string} action - The action (create, update, delete) * @returns {boolean} */ allowsRepo(collection, action) { if (this.skipsGranularChecks()) return true;
for (const perm of this.repoPermissions) { const collectionMatch = perm.collection === '*' || perm.collection === collection; const actionMatch = perm.actions.includes(action); if (collectionMatch && actionMatch) return true; }
return false; }
/** * Assert that a repo operation is allowed, throwing if not. * @param {string} collection - The collection NSID * @param {string} action - The action (create, update, delete) * @throws {ScopeMissingError} */ assertRepo(collection, action) { if (!this.allowsRepo(collection, action)) { throw new ScopeMissingError(`repo:${collection}?action=${action}`); } }
/** * Check if a blob operation is allowed. * @param {string} mime - The MIME type of the blob * @returns {boolean} */ allowsBlob(mime) { if (this.skipsGranularChecks()) return true;
for (const perm of this.blobPermissions) { for (const pattern of perm.accept) { if (matchesMime(pattern, mime)) return true; } }
return false; }
/** * Assert that a blob operation is allowed, throwing if not. * @param {string} mime - The MIME type of the blob * @throws {ScopeMissingError} */ assertBlob(mime) { if (!this.allowsBlob(mime)) { throw new ScopeMissingError(`blob:${mime}`); } }}
/** * Parse scope string into display-friendly structure. * @param {string} scope - Space-separated scope string * @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, hasEmail: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, rpcPermissions: Array<{aud: string, lxms: string[]|'*'}>, includeScopes: Array<{nsid: string, aud: string|null}>, blobPermissions: string[], spacePermissions: Array<NonNullable<ReturnType<typeof parseSpaceScope>>> }} */export function parseScopesForDisplay(scope) { const scopes = scope.split(' ').filter((s) => s);
const repoPermissions = new Map();
for (const s of scopes) { const repo = parseRepoScope(s); if (repo) { const existing = repoPermissions.get(repo.collection) || { create: false, update: false, delete: false, }; for (const action of repo.actions) { existing[action] = true; } repoPermissions.set(repo.collection, existing); } }
// Merge rpc grants by audience so one card can say "call X, Y towards Z". /** @type {Map<string, Set<string>|'*'>} */ const rpcByAud = new Map(); for (const s of scopes) { const rpc = parseRpcScope(s); if (!rpc) continue; const existing = rpcByAud.get(rpc.aud); if (rpc.lxms === '*' || existing === '*') { rpcByAud.set(rpc.aud, '*'); } else { const set = existing ?? new Set(); for (const lxm of rpc.lxms) set.add(lxm); rpcByAud.set(rpc.aud, set); } } const rpcPermissions = [...rpcByAud.entries()].map(([aud, lxms]) => ({ aud, lxms: lxms === '*' ? /** @type {'*'} */ ('*') : [...lxms].sort(), }));
const includeScopes = []; for (const s of scopes) { const include = parseIncludeScope(s); if (include) includeScopes.push(include); }
const blobPermissions = []; for (const s of scopes) { const blob = parseBlobScope(s); if (blob) blobPermissions.push(...blob.accept); }
const spacePermissions = []; for (const s of scopes) { const space = parseSpaceScope(s); if (space) spacePermissions.push(space); }
return { hasAtproto: scopes.includes('atproto'), hasTransitionGeneric: scopes.includes('transition:generic'), hasEmail: scopes.some( (s) => s === 'transition:email' || s === 'account:email' || s.startsWith('account:email?'), ), repoPermissions, rpcPermissions, includeScopes, blobPermissions, spacePermissions, };}