~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
843 lines · 27.9 KB Raw History
// @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,
  };
}