// @pds/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 a blob scope string into its components. * Format: blob:[,...] * @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. */ export class ScopePermissions { /** * @param {string | undefined} scopeString - Space-separated scope string */ constructor(scopeString) { /** @type {Set} */ this.scopes = new Set( scopeString ? scopeString.split(' ').filter((s) => s) : [], ); /** @type {Array<{ collection: string, actions: string[] }>} */ this.repoPermissions = []; /** @type {Array<{ accept: string[] }>} */ this.blobPermissions = []; 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); } } /** * 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') ); } /** * 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.hasFullAccess()) 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.hasFullAccess()) 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, repoPermissions: Map, blobPermissions: string[] }} */ 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); } } const blobPermissions = []; for (const s of scopes) { const blob = parseBlobScope(s); if (blob) blobPermissions.push(...blob.accept); } return { hasAtproto: scopes.includes('atproto'), hasTransitionGeneric: scopes.includes('transition:generic'), repoPermissions, blobPermissions, }; }