chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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 };
}
/**
* The collections a resolved lexicon document declares for its space type,
* or null when the document does not define a space type.
* @param {object|null|undefined} doc - A lexicon document (defs.main expected)
* @returns {string[] | null}
*/
export function spaceTypeCollections(doc) {
const container = /** @type {{ defs?: Record<string, unknown> }} */ (
doc ?? {}
);
const main =
/** @type {{ type?: unknown, collections?: unknown } | undefined} */ (
container.defs?.main
);
if (main?.type !== 'space') return null;
return toStringArray(main.collections).filter(
(c) => c !== '*' && c.includes('.'),
);
}
/**
* The space types that a scope string names without a `collection`.
* @param {string} scope - Space-separated scope string
* @returns {string[]}
*/
export function bareSpaceTypes(scope) {
const types = new Set();
for (const s of scope.split(' ')) {
const grant = parseSpaceScope(s);
if (grant && grant.spaceType !== '*' && !grant.collections) {
types.add(grant.spaceType);
}
}
return [...types];
}
/**
* Write each space type's declared collections into the `space:` scopes that
* name the type without a `collection`. A write check has no lexicon to read,
* and a grant with no collections permits no write.
* @param {string} scope - Space-separated scope string
* @param {Record<string, string[]>} spaceTypes - Declared collections by type
* @returns {string}
*/
export function expandSpaceScopes(scope, spaceTypes) {
return scope
.split(' ')
.map((s) => {
const grant = parseSpaceScope(s);
if (!grant || grant.spaceType === '*' || grant.collections) return s;
const collections = spaceTypes[grant.spaceType] ?? [];
if (collections.length === 0) return s;
const query = collections.map((c) => `collection=${c}`).join('&');
return `${s}${s.includes('?') ? '&' : '?'}${query}`;
})
.join(' ');
}
/**
* 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,
};
}