A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

feat(oauth): add granular scope enforcement and consent permissions table

Add granular OAuth scope enforcement:
- parseRepoScope() parses repo:collection?action=create&action=update format
- parseBlobScope() parses blob:image/* format with MIME wildcards
- ScopePermissions class for checking repo/blob permissions
- Enforced on createRecord, putRecord, deleteRecord, applyWrites, uploadBlob

Add consent page permissions table:
- Identity-only: "wants to uniquely identify you" message
- Granular scopes: Table with Collection + Create/Update/Delete columns
- Full access: Warning banner for transition:generic
- parseScopesForDisplay() helper for consent page rendering

Also includes:
- Comprehensive E2E tests for scope enforcement and consent display
- OAuth token helper extracted to test/helpers/oauth.js
- Updated scope-comparison.md documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+14
CHANGELOG.md
··· 6 6 7 7 ## [Unreleased] 8 8 9 + ### Added 10 + 11 + - **Granular OAuth scope enforcement** on repo and blob endpoints 12 + - `parseRepoScope()` parses `repo:collection?action=create&action=update` format 13 + - `parseBlobScope()` parses `blob:image/*` format with MIME wildcards 14 + - `ScopePermissions` class for checking repo/blob permissions 15 + - Enforced on createRecord, putRecord, deleteRecord, applyWrites, uploadBlob 16 + - **Consent page permissions table** displaying scopes in human-readable format 17 + - Identity-only: "wants to uniquely identify you" message 18 + - Granular scopes: Table with Collection + Create/Update/Delete columns 19 + - Full access: Warning banner for `transition:generic` 20 + - `parseScopesForDisplay()` helper for consent page rendering 21 + - E2E tests for scope enforcement and consent page display 22 + 9 23 ## [0.2.0] - 2026-01-07 10 24 11 25 ### Added
+46 -51
docs/scope-comparison.md
··· 8 8 9 9 | Scope Type | Format | pds.js | atproto PDS | 10 10 |------------|--------|--------|-------------| 11 - | `atproto` | Static | Checked (loose) | Required for all OAuth | 12 - | `transition:generic` | Static | Not recognized | Full repo/blob bypass | 11 + | `atproto` | Static | Full access | Required for all OAuth | 12 + | `transition:generic` | Static | Full access | Full repo/blob bypass | 13 13 | `transition:email` | Static | N/A | Read account email | 14 14 | `transition:chat.bsky` | Static | N/A | Chat RPC access | 15 - | `repo:<collection>:<action>` | Granular | Not parsed | Full parsing + enforcement | 16 - | `blob:<mime>` | Granular | Not parsed | Full parsing + enforcement | 17 - | `rpc:<aud>:<lxm>` | Granular | Not parsed | Full parsing + enforcement | 15 + | `repo:<collection>?action=<action>` | Granular | Full parsing + enforcement | Full parsing + enforcement | 16 + | `blob:<mime>` | Granular | Full parsing + enforcement | Full parsing + enforcement | 17 + | `rpc:<aud>:<lxm>` | Granular | Not implemented | Full parsing + enforcement | 18 18 19 19 --- 20 20 ··· 24 24 25 25 | Aspect | pds.js | atproto PDS | 26 26 |--------|--------|-------------| 27 - | Scope check | `hasRequiredScope(scope, 'atproto')` | `permissions.assertRepo({ action: 'create', collection })` | 28 - | Required scope | `atproto` anywhere in scope string | `repo:<collection>:create` or `transition:generic` or `atproto` | 29 - | OAuth-only check | No (checks all tokens) | Yes (legacy Bearer bypasses) | 30 - | Error response | 403 "Insufficient scope for repo write" | 403 "Missing required scope \"repo:...\"" | 27 + | Scope check | `ScopePermissions.allowsRepo(collection, 'create')` | `permissions.assertRepo({ action: 'create', collection })` | 28 + | Required scope | `repo:<collection>?action=create` or `transition:generic` or `atproto` | `repo:<collection>?action=create` or `transition:generic` or `atproto` | 29 + | OAuth-only check | Yes (legacy tokens without scope bypass) | Yes (legacy Bearer bypasses) | 30 + | Error response | 403 "Missing required scope \"repo:...?action=...\"" | 403 "Missing required scope \"repo:...?action=...\"" | 31 31 32 32 ### com.atproto.repo.putRecord 33 33 34 34 | Aspect | pds.js | atproto PDS | 35 35 |--------|--------|-------------| 36 - | Scope check | `hasRequiredScope(scope, 'atproto')` | `assertRepo({ action: 'create' })` AND `assertRepo({ action: 'update' })` | 37 - | Required scope | `atproto` | Both `repo:<collection>:create` AND `repo:<collection>:update` | 38 - | Notes | Single check | Requires both since putRecord can create or update | 36 + | Scope check | `allowsRepo(collection, 'create')` AND `allowsRepo(collection, 'update')` | `assertRepo({ action: 'create' })` AND `assertRepo({ action: 'update' })` | 37 + | Required scope | `repo:<collection>?action=create&action=update` | `repo:<collection>?action=create&action=update` | 38 + | Notes | Requires both since putRecord can create or update | Requires both since putRecord can create or update | 39 39 40 40 ### com.atproto.repo.deleteRecord 41 41 42 42 | Aspect | pds.js | atproto PDS | 43 43 |--------|--------|-------------| 44 - | Scope check | `hasRequiredScope(scope, 'atproto')` | `permissions.assertRepo({ action: 'delete', collection })` | 45 - | Required scope | `atproto` | `repo:<collection>:delete` | 44 + | Scope check | `ScopePermissions.allowsRepo(collection, 'delete')` | `permissions.assertRepo({ action: 'delete', collection })` | 45 + | Required scope | `repo:<collection>?action=delete` | `repo:<collection>?action=delete` | 46 46 47 47 ### com.atproto.repo.applyWrites 48 48 49 49 | Aspect | pds.js | atproto PDS | 50 50 |--------|--------|-------------| 51 - | Scope check | `hasRequiredScope(scope, 'atproto')` | Iterates all writes, asserts each unique action/collection pair | 52 - | Required scope | `atproto` | All `repo:<collection>:<action>` for each write | 53 - | Per-write validation | No | Yes | 51 + | Scope check | Iterates all writes, checks each unique action/collection pair | Iterates all writes, asserts each unique action/collection pair | 52 + | Required scope | All `repo:<collection>?action=<action>` for each write | All `repo:<collection>?action=<action>` for each write | 53 + | Per-write validation | Yes | Yes | 54 54 55 55 ### com.atproto.repo.uploadBlob 56 56 57 57 | Aspect | pds.js | atproto PDS | 58 58 |--------|--------|-------------| 59 - | Scope check | `hasRequiredScope(scope, 'atproto')` | `permissions.assertBlob({ mime: encoding })` | 60 - | Required scope | `atproto` | `blob:<mime-type>` (e.g., `blob:image/*`) | 61 - | MIME type awareness | No | Yes (validates against Content-Type) | 59 + | Scope check | `ScopePermissions.allowsBlob(contentType)` | `permissions.assertBlob({ mime: encoding })` | 60 + | Required scope | `blob:<mime-type>` (e.g., `blob:image/*`) | `blob:<mime-type>` (e.g., `blob:image/*`) | 61 + | MIME type awareness | Yes (validates against Content-Type) | Yes (validates against Content-Type) | 62 62 63 63 ### app.bsky.actor.getPreferences 64 64 ··· 81 81 | Feature | pds.js | atproto PDS | 82 82 |---------|--------|-------------| 83 83 | Scope string splitting | `scope.split(' ')` | `ScopesSet` class | 84 - | Repo scope parsing | None | `RepoPermission.fromString()` | 85 - | Blob scope parsing | None | `BlobPermission.fromString()` | 84 + | Repo scope parsing | `parseRepoScope()` | `RepoPermission.fromString()` | 85 + | Repo scope format | `repo:collection?action=create&action=update` | `repo:collection?action=create&action=update` | 86 + | Blob scope parsing | `parseBlobScope()` | `BlobPermission.fromString()` | 86 87 | RPC scope parsing | None | `RpcPermission.fromString()` | 87 - | Scope validation | None (accepts any string) | Validates syntax, ignores invalid | 88 - | Scope normalization | None | Sorts, dedupes, simplifies wildcards | 88 + | Scope validation | Returns null for invalid | Validates syntax, ignores invalid | 89 + | Action deduplication | Yes (via Set) | Yes | 90 + | Default actions | All (create, update, delete) when no `?action=` | All (create, update, delete) when no `?action=` | 89 91 90 92 --- 91 93 ··· 93 95 94 96 | Feature | pds.js | atproto PDS | 95 97 |---------|--------|-------------| 96 - | Permission class | None | `ScopePermissions` / `ScopePermissionsTransition` | 97 - | `allowsRepo(collection, action)` | N/A | Yes | 98 - | `allowsBlob(mime)` | N/A | Yes (with MIME wildcard matching) | 98 + | Permission class | `ScopePermissions` | `ScopePermissions` / `ScopePermissionsTransition` | 99 + | `allowsRepo(collection, action)` | Yes | Yes | 100 + | `allowsBlob(mime)` | Yes (with MIME wildcard matching) | Yes (with MIME wildcard matching) | 99 101 | `allowsRpc(aud, lxm)` | N/A | Yes | 100 - | Transition scope handling | None | `transition:generic` bypasses repo/blob checks | 101 - | Error messages | Generic | Specific missing scope in error | 102 + | Transition scope handling | `transition:generic` bypasses repo/blob checks | `transition:generic` bypasses repo/blob checks | 103 + | Error messages | Specific missing scope in error | Specific missing scope in error | 102 104 103 105 --- 104 106 ··· 114 116 115 117 --- 116 118 117 - ## Transition Scope Behavior (atproto PDS) 118 - 119 - | Scope | Effect | 120 - |-------|--------| 121 - | `transition:generic` | Bypasses ALL repo permission checks | 122 - | `transition:generic` | Bypasses ALL blob permission checks | 123 - | `transition:generic` | Allows all RPC except `chat.bsky.*` | 124 - | `transition:chat.bsky` | Allows `chat.bsky.*` RPC methods | 125 - | `transition:email` | Allows `account:email:read` | 119 + ## Transition Scope Behavior 126 120 127 - pds.js does not recognize any transition scopes. 121 + | Scope | pds.js | atproto PDS | 122 + |-------|--------|-------------| 123 + | `transition:generic` | Bypasses all repo/blob permission checks | Bypasses ALL repo/blob permission checks | 124 + | `transition:chat.bsky` | Not implemented | Allows `chat.bsky.*` RPC methods | 125 + | `transition:email` | Not implemented | Allows `account:email:read` | 128 126 129 127 --- 130 128 ··· 132 130 133 131 | Category | pds.js | atproto PDS | 134 132 |----------|--------|-------------| 135 - | Scope parsing | String contains check | Full parser per scope type | 136 - | Enforcement granularity | Binary (has atproto or not) | Per-collection, per-action | 137 - | Transition scope support | None | Full | 138 - | MIME-aware blob scopes | No | Yes | 133 + | Scope parsing | Full parser for repo/blob | Full parser per scope type | 134 + | Enforcement granularity | Per-collection, per-action | Per-collection, per-action | 135 + | Transition scope support | `transition:generic` only | Full | 136 + | MIME-aware blob scopes | Yes | Yes | 139 137 | RPC scopes | No | Yes | 140 - | Error specificity | Generic 403 | Names missing scope | 138 + | Error specificity | Names missing scope | Names missing scope | 141 139 142 140 --- 143 141 144 - ## Gaps to Address 142 + ## Remaining Gaps 145 143 146 - 1. **Scope parsing** — Need to parse `repo:*:create` and `blob:image/*` syntax 147 - 2. **Permission class** — Need `allowsRepo(collection, action)` and `allowsBlob(mime)` methods 148 - 3. **Transition scopes** — Need `transition:generic` to bypass checks 149 - 4. **Per-endpoint enforcement** — Check specific scope at each write endpoint 150 - 5. **MIME matching** — `blob:image/*` should match `image/png`, `image/jpeg`, etc. 151 - 6. **Error messages** — Return which scope is missing, not generic error 144 + 1. **RPC scopes** — `rpc:<aud>:<lxm>` parsing and enforcement not implemented 145 + 2. **Additional transition scopes** — `transition:chat.bsky` and `transition:email` not implemented 146 + 3. **Scope validation at PAR** — Could validate scope syntax during authorization request
+516 -97
src/pds.js
··· 575 575 576 576 return { jkt, jti: payload.jti, iat: payload.iat, jwk: header.jwk }; 577 577 } 578 - /** 579 - * Render the OAuth consent page HTML. 580 - * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params 581 - * @returns {string} HTML page content 582 - */ 583 - function renderConsentPage({ 584 - clientName, 585 - clientId, 586 - scope, 587 - requestUri, 588 - error = '', 589 - }) { 590 - /** @param {string} s */ 591 - const escHtml = (s) => 592 - s 593 - .replace(/&/g, '&amp;') 594 - .replace(/</g, '&lt;') 595 - .replace(/>/g, '&gt;') 596 - .replace(/"/g, '&quot;'); 597 - return `<!DOCTYPE html> 598 - <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> 599 - <title>Authorize</title> 600 - <style> 601 - *{box-sizing:border-box} 602 - body{font-family:system-ui,sans-serif;max-width:400px;margin:40px auto;padding:20px;background:#1a1a1a;color:#e0e0e0} 603 - h2{color:#fff;margin-bottom:24px} 604 - p{color:#b0b0b0;line-height:1.5} 605 - b{color:#fff} 606 - .error{color:#ff6b6b;background:#2d1f1f;padding:12px;margin:12px 0;border-radius:6px;border:1px solid #4a2020} 607 - label{display:block;margin:16px 0 6px;color:#b0b0b0;font-size:14px} 608 - input[type="password"]{width:100%;padding:12px;background:#2a2a2a;border:1px solid #404040;border-radius:6px;color:#fff;font-size:16px} 609 - input[type="password"]:focus{outline:none;border-color:#4a9eff;box-shadow:0 0 0 2px rgba(74,158,255,0.2)} 610 - .actions{display:flex;gap:12px;margin-top:24px} 611 - button{flex:1;padding:12px 20px;border-radius:6px;font-size:16px;font-weight:500;cursor:pointer;transition:background 0.15s} 612 - .deny{background:#2a2a2a;color:#e0e0e0;border:1px solid #404040} 613 - .deny:hover{background:#333} 614 - .approve{background:#2563eb;color:#fff;border:none} 615 - .approve:hover{background:#1d4ed8} 616 - </style></head> 617 - <body><h2>Sign in to authorize</h2> 618 - <p><b>${escHtml(clientName)}</b> wants to access your account.</p> 619 - <p>Scope: ${escHtml(scope)}</p> 620 - ${error ? `<p class="error">${escHtml(error)}</p>` : ''} 621 - <form method="POST" action="/oauth/authorize"> 622 - <input type="hidden" name="request_uri" value="${escHtml(requestUri)}"> 623 - <input type="hidden" name="client_id" value="${escHtml(clientId)}"> 624 - <label>Password</label><input type="password" name="password" required autofocus> 625 - <div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button> 626 - <button type="submit" name="action" value="approve" class="approve">Authorize</button></div> 627 - </form></body></html>`; 628 - } 629 578 630 579 /** 631 580 * Encode integer as unsigned varint ··· 3241 3190 3242 3191 /** @param {Request} request */ 3243 3192 async handleUploadBlob(request) { 3244 - // Require auth 3245 - const authHeader = request.headers.get('Authorization'); 3246 - if (!authHeader || !authHeader.startsWith('Bearer ')) { 3247 - return errorResponse( 3248 - 'AuthRequired', 3249 - 'Missing or invalid authorization header', 3250 - 401, 3251 - ); 3252 - } 3193 + // Check if auth was already done by outer handler (OAuth/DPoP flow) 3194 + const authedDid = request.headers.get('x-authed-did'); 3195 + if (!authedDid) { 3196 + // Fallback to legacy Bearer token auth 3197 + const authHeader = request.headers.get('Authorization'); 3198 + if (!authHeader || !authHeader.startsWith('Bearer ')) { 3199 + return errorResponse( 3200 + 'AuthRequired', 3201 + 'Missing or invalid authorization header', 3202 + 401, 3203 + ); 3204 + } 3253 3205 3254 - const token = authHeader.slice(7); 3255 - const jwtSecret = this.env?.JWT_SECRET; 3256 - if (!jwtSecret) { 3257 - return errorResponse( 3258 - 'InternalServerError', 3259 - 'Server not configured for authentication', 3260 - 500, 3261 - ); 3262 - } 3206 + const token = authHeader.slice(7); 3207 + const jwtSecret = this.env?.JWT_SECRET; 3208 + if (!jwtSecret) { 3209 + return errorResponse( 3210 + 'InternalServerError', 3211 + 'Server not configured for authentication', 3212 + 500, 3213 + ); 3214 + } 3263 3215 3264 - try { 3265 - await verifyAccessJwt(token, jwtSecret); 3266 - } catch (err) { 3267 - const message = err instanceof Error ? err.message : String(err); 3268 - return errorResponse('InvalidToken', message, 401); 3216 + try { 3217 + await verifyAccessJwt(token, jwtSecret); 3218 + } catch (err) { 3219 + const message = err instanceof Error ? err.message : String(err); 3220 + return errorResponse('InvalidToken', message, 401); 3221 + } 3269 3222 } 3270 3223 3271 3224 const did = await this.getDid(); ··· 4549 4502 return { did: payload.sub, scope: payload.scope }; 4550 4503 } 4551 4504 4505 + // ╔══════════════════════════════════════════════════════════════════════════════╗ 4506 + // ║ SCOPES ║ 4507 + // ║ OAuth scope parsing and permission checking ║ 4508 + // ╚══════════════════════════════════════════════════════════════════════════════╝ 4509 + 4552 4510 /** 4553 - * Check if the token scope allows the requested operation. 4554 - * Legacy tokens (no scope) are always allowed; OAuth tokens must have 'atproto' scope. 4555 - * @param {string | undefined} scope - The token scope 4556 - * @param {string} requiredScope - The required scope (e.g., 'atproto') 4557 - * @returns {boolean} Whether the scope is sufficient 4511 + * Parse a repo scope string into collection and actions. 4512 + * Official format: repo:collection?action=create&action=update 4513 + * Or: repo?collection=foo&action=create 4514 + * Without actions defaults to all: create, update, delete 4515 + * @param {string} scope - The scope string to parse 4516 + * @returns {{ collection: string, actions: string[] } | null} Parsed scope or null if invalid 4558 4517 */ 4559 - function hasRequiredScope(scope, requiredScope) { 4560 - // Legacy tokens without scope are trusted for all operations 4561 - if (!scope) return true; 4562 - // Check if the scope includes the required scope 4563 - const scopes = scope.split(' '); 4564 - return scopes.includes(requiredScope); 4518 + export function parseRepoScope(scope) { 4519 + if (!scope.startsWith('repo:') && !scope.startsWith('repo?')) return null; 4520 + 4521 + const ALL_ACTIONS = ['create', 'update', 'delete']; 4522 + let collection; 4523 + let actions; 4524 + 4525 + const questionIdx = scope.indexOf('?'); 4526 + if (questionIdx === -1) { 4527 + // repo:collection (no query params = all actions) 4528 + collection = scope.slice(5); 4529 + actions = ALL_ACTIONS; 4530 + } else { 4531 + // Parse query parameters 4532 + const queryString = scope.slice(questionIdx + 1); 4533 + const params = new URLSearchParams(queryString); 4534 + const pathPart = scope.startsWith('repo:') 4535 + ? scope.slice(5, questionIdx) 4536 + : ''; 4537 + 4538 + collection = pathPart || params.get('collection'); 4539 + actions = params.getAll('action'); 4540 + if (actions.length === 0) actions = ALL_ACTIONS; 4541 + } 4542 + 4543 + if (!collection) return null; 4544 + 4545 + // Validate actions 4546 + const validActions = [ 4547 + ...new Set(actions.filter((a) => ALL_ACTIONS.includes(a))), 4548 + ]; 4549 + if (validActions.length === 0) return null; 4550 + 4551 + return { collection, actions: validActions }; 4552 + } 4553 + 4554 + /** 4555 + * Parse a blob scope string into its components. 4556 + * Format: blob:<mime>[,<mime>...] 4557 + * @param {string} scope - The scope string to parse 4558 + * @returns {{ accept: string[] } | null} Parsed scope or null if invalid 4559 + */ 4560 + export function parseBlobScope(scope) { 4561 + if (!scope.startsWith('blob:')) return null; 4562 + 4563 + const mimeStr = scope.slice(5); // Remove 'blob:' 4564 + if (!mimeStr) return null; 4565 + 4566 + const accept = mimeStr.split(',').filter((m) => m); 4567 + if (accept.length === 0) return null; 4568 + 4569 + return { accept }; 4570 + } 4571 + 4572 + /** 4573 + * Check if a MIME pattern matches an actual MIME type. 4574 + * @param {string} pattern - MIME pattern (e.g., 'image/\*', '\*\/\*', 'image/png') 4575 + * @param {string} mime - Actual MIME type to check 4576 + * @returns {boolean} Whether the pattern matches 4577 + */ 4578 + export function matchesMime(pattern, mime) { 4579 + const p = pattern.toLowerCase(); 4580 + const m = mime.toLowerCase(); 4581 + 4582 + if (p === '*/*') return true; 4583 + 4584 + if (p.endsWith('/*')) { 4585 + const pType = p.slice(0, -2); 4586 + const mType = m.split('/')[0]; 4587 + return pType === mType; 4588 + } 4589 + 4590 + return p === m; 4591 + } 4592 + 4593 + /** 4594 + * Error thrown when a required scope is missing. 4595 + */ 4596 + class ScopeMissingError extends Error { 4597 + /** 4598 + * @param {string} scope - The missing scope 4599 + */ 4600 + constructor(scope) { 4601 + super(`Missing required scope "${scope}"`); 4602 + this.name = 'ScopeMissingError'; 4603 + this.scope = scope; 4604 + this.status = 403; 4605 + } 4606 + } 4607 + 4608 + /** 4609 + * Parses and checks OAuth scope permissions. 4610 + */ 4611 + export class ScopePermissions { 4612 + /** 4613 + * @param {string | undefined} scopeString - Space-separated scope string 4614 + */ 4615 + constructor(scopeString) { 4616 + /** @type {Set<string>} */ 4617 + this.scopes = new Set( 4618 + scopeString ? scopeString.split(' ').filter((s) => s) : [], 4619 + ); 4620 + 4621 + /** @type {Array<{ collection: string, actions: string[] }>} */ 4622 + this.repoPermissions = []; 4623 + 4624 + /** @type {Array<{ accept: string[] }>} */ 4625 + this.blobPermissions = []; 4626 + 4627 + for (const scope of this.scopes) { 4628 + const repo = parseRepoScope(scope); 4629 + if (repo) this.repoPermissions.push(repo); 4630 + 4631 + const blob = parseBlobScope(scope); 4632 + if (blob) this.blobPermissions.push(blob); 4633 + } 4634 + } 4635 + 4636 + /** 4637 + * Check if full access is granted (atproto or transition:generic). 4638 + * @returns {boolean} 4639 + */ 4640 + hasFullAccess() { 4641 + return this.scopes.has('atproto') || this.scopes.has('transition:generic'); 4642 + } 4643 + 4644 + /** 4645 + * Check if a repo operation is allowed. 4646 + * @param {string} collection - The collection NSID 4647 + * @param {string} action - The action (create, update, delete) 4648 + * @returns {boolean} 4649 + */ 4650 + allowsRepo(collection, action) { 4651 + if (this.hasFullAccess()) return true; 4652 + 4653 + for (const perm of this.repoPermissions) { 4654 + const collectionMatch = 4655 + perm.collection === '*' || perm.collection === collection; 4656 + const actionMatch = perm.actions.includes(action); 4657 + if (collectionMatch && actionMatch) return true; 4658 + } 4659 + 4660 + return false; 4661 + } 4662 + 4663 + /** 4664 + * Assert that a repo operation is allowed, throwing if not. 4665 + * @param {string} collection - The collection NSID 4666 + * @param {string} action - The action (create, update, delete) 4667 + * @throws {ScopeMissingError} 4668 + */ 4669 + assertRepo(collection, action) { 4670 + if (!this.allowsRepo(collection, action)) { 4671 + throw new ScopeMissingError(`repo:${collection}?action=${action}`); 4672 + } 4673 + } 4674 + 4675 + /** 4676 + * Check if a blob operation is allowed. 4677 + * @param {string} mime - The MIME type of the blob 4678 + * @returns {boolean} 4679 + */ 4680 + allowsBlob(mime) { 4681 + if (this.hasFullAccess()) return true; 4682 + 4683 + for (const perm of this.blobPermissions) { 4684 + for (const pattern of perm.accept) { 4685 + if (matchesMime(pattern, mime)) return true; 4686 + } 4687 + } 4688 + 4689 + return false; 4690 + } 4691 + 4692 + /** 4693 + * Assert that a blob operation is allowed, throwing if not. 4694 + * @param {string} mime - The MIME type of the blob 4695 + * @throws {ScopeMissingError} 4696 + */ 4697 + assertBlob(mime) { 4698 + if (!this.allowsBlob(mime)) { 4699 + throw new ScopeMissingError(`blob:${mime}`); 4700 + } 4701 + } 4702 + } 4703 + 4704 + // ╔══════════════════════════════════════════════════════════════════════════════╗ 4705 + // ║ CONSENT PAGE DISPLAY ║ 4706 + // ║ OAuth consent page rendering with scope visualization ║ 4707 + // ╚══════════════════════════════════════════════════════════════════════════════╝ 4708 + 4709 + /** 4710 + * Parse scope string into display-friendly structure. 4711 + * @param {string} scope - Space-separated scope string 4712 + * @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }} 4713 + */ 4714 + export function parseScopesForDisplay(scope) { 4715 + const scopes = scope.split(' ').filter((s) => s); 4716 + 4717 + const repoPermissions = new Map(); 4718 + 4719 + for (const s of scopes) { 4720 + const repo = parseRepoScope(s); 4721 + if (repo) { 4722 + const existing = repoPermissions.get(repo.collection) || { 4723 + create: false, 4724 + update: false, 4725 + delete: false, 4726 + }; 4727 + for (const action of repo.actions) { 4728 + existing[action] = true; 4729 + } 4730 + repoPermissions.set(repo.collection, existing); 4731 + } 4732 + } 4733 + 4734 + const blobPermissions = []; 4735 + for (const s of scopes) { 4736 + const blob = parseBlobScope(s); 4737 + if (blob) blobPermissions.push(...blob.accept); 4738 + } 4739 + 4740 + return { 4741 + hasAtproto: scopes.includes('atproto'), 4742 + hasTransitionGeneric: scopes.includes('transition:generic'), 4743 + repoPermissions, 4744 + blobPermissions, 4745 + }; 4746 + } 4747 + 4748 + /** 4749 + * Escape HTML special characters. 4750 + * @param {string} s 4751 + * @returns {string} 4752 + */ 4753 + function escapeHtml(s) { 4754 + return s 4755 + .replace(/&/g, '&amp;') 4756 + .replace(/</g, '&lt;') 4757 + .replace(/>/g, '&gt;') 4758 + .replace(/"/g, '&quot;'); 4759 + } 4760 + 4761 + /** 4762 + * Render repo permissions as HTML table. 4763 + * @param {Map<string, {create: boolean, update: boolean, delete: boolean}>} repoPermissions 4764 + * @returns {string} HTML string 4765 + */ 4766 + function renderRepoTable(repoPermissions) { 4767 + if (repoPermissions.size === 0) return ''; 4768 + 4769 + let rows = ''; 4770 + for (const [collection, actions] of repoPermissions) { 4771 + const displayCollection = collection === '*' ? '* (any)' : collection; 4772 + rows += `<tr> 4773 + <td>${escapeHtml(displayCollection)}</td> 4774 + <td class="check">${actions.create ? '✓' : ''}</td> 4775 + <td class="check">${actions.update ? '✓' : ''}</td> 4776 + <td class="check">${actions.delete ? '✓' : ''}</td> 4777 + </tr>`; 4778 + } 4779 + 4780 + return `<div class="permissions-section"> 4781 + <div class="section-label">Repository permissions:</div> 4782 + <table class="permissions-table"> 4783 + <thead><tr><th>Collection</th><th title="Create">C</th><th title="Update">U</th><th title="Delete">D</th></tr></thead> 4784 + <tbody>${rows}</tbody> 4785 + </table> 4786 + </div>`; 4787 + } 4788 + 4789 + /** 4790 + * Render blob permissions as HTML list. 4791 + * @param {string[]} blobPermissions 4792 + * @returns {string} HTML string 4793 + */ 4794 + function renderBlobList(blobPermissions) { 4795 + if (blobPermissions.length === 0) return ''; 4796 + 4797 + const items = blobPermissions 4798 + .map( 4799 + (mime) => 4800 + `<li>${escapeHtml(mime === '*/*' ? 'All file types' : mime)}</li>`, 4801 + ) 4802 + .join(''); 4803 + 4804 + return `<div class="permissions-section"> 4805 + <div class="section-label">Upload permissions:</div> 4806 + <ul class="blob-list">${items}</ul> 4807 + </div>`; 4808 + } 4809 + 4810 + /** 4811 + * Render full permissions display based on parsed scopes. 4812 + * @param {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }} parsed 4813 + * @returns {string} HTML string 4814 + */ 4815 + function renderPermissionsHtml(parsed) { 4816 + if (parsed.hasTransitionGeneric) { 4817 + return `<div class="warning">⚠️ Full repository access requested<br> 4818 + <small>This app can create, update, and delete any data in your repository.</small></div>`; 4819 + } 4820 + 4821 + if ( 4822 + parsed.repoPermissions.size === 0 && 4823 + parsed.blobPermissions.length === 0 4824 + ) { 4825 + return ''; 4826 + } 4827 + 4828 + return ( 4829 + renderRepoTable(parsed.repoPermissions) + 4830 + renderBlobList(parsed.blobPermissions) 4831 + ); 4832 + } 4833 + 4834 + /** 4835 + * Render the OAuth consent page HTML. 4836 + * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params 4837 + * @returns {string} HTML page content 4838 + */ 4839 + function renderConsentPage({ 4840 + clientName, 4841 + clientId, 4842 + scope, 4843 + requestUri, 4844 + error = '', 4845 + }) { 4846 + const parsed = parseScopesForDisplay(scope); 4847 + const isIdentityOnly = 4848 + parsed.repoPermissions.size === 0 && 4849 + parsed.blobPermissions.length === 0 && 4850 + !parsed.hasTransitionGeneric; 4851 + 4852 + return `<!DOCTYPE html> 4853 + <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> 4854 + <title>Authorize</title> 4855 + <style> 4856 + *{box-sizing:border-box} 4857 + body{font-family:system-ui,sans-serif;max-width:400px;margin:40px auto;padding:20px;background:#1a1a1a;color:#e0e0e0} 4858 + h2{color:#fff;margin-bottom:24px} 4859 + p{color:#b0b0b0;line-height:1.5} 4860 + b{color:#fff} 4861 + .error{color:#ff6b6b;background:#2d1f1f;padding:12px;margin:12px 0;border-radius:6px;border:1px solid #4a2020} 4862 + label{display:block;margin:16px 0 6px;color:#b0b0b0;font-size:14px} 4863 + input[type="password"]{width:100%;padding:12px;background:#2a2a2a;border:1px solid #404040;border-radius:6px;color:#fff;font-size:16px} 4864 + input[type="password"]:focus{outline:none;border-color:#4a9eff;box-shadow:0 0 0 2px rgba(74,158,255,0.2)} 4865 + .actions{display:flex;gap:12px;margin-top:24px} 4866 + button{flex:1;padding:12px 20px;border-radius:6px;font-size:16px;font-weight:500;cursor:pointer;transition:background 0.15s} 4867 + .deny{background:#2a2a2a;color:#e0e0e0;border:1px solid #404040} 4868 + .deny:hover{background:#333} 4869 + .approve{background:#2563eb;color:#fff;border:none} 4870 + .approve:hover{background:#1d4ed8} 4871 + .permissions-section{margin:16px 0} 4872 + .section-label{color:#b0b0b0;font-size:13px;margin-bottom:8px} 4873 + .permissions-table{width:100%;border-collapse:collapse;font-size:13px} 4874 + .permissions-table th{color:#808080;font-weight:normal;text-align:left;padding:4px 8px;border-bottom:1px solid #333} 4875 + .permissions-table th:not(:first-child){text-align:center;width:32px} 4876 + .permissions-table td{padding:4px 8px;border-bottom:1px solid #2a2a2a} 4877 + .permissions-table td:not(:first-child){text-align:center} 4878 + .permissions-table .check{color:#4ade80} 4879 + .blob-list{margin:0;padding-left:20px;color:#e0e0e0;font-size:13px} 4880 + .blob-list li{margin:4px 0} 4881 + .warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0} 4882 + .warning small{color:#d4a000;display:block;margin-top:4px} 4883 + </style></head> 4884 + <body><h2>Sign in to authorize</h2> 4885 + <p><b>${escapeHtml(clientName)}</b> ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}</p> 4886 + ${renderPermissionsHtml(parsed)} 4887 + ${error ? `<p class="error">${escapeHtml(error)}</p>` : ''} 4888 + <form method="POST" action="/oauth/authorize"> 4889 + <input type="hidden" name="request_uri" value="${escapeHtml(requestUri)}"> 4890 + <input type="hidden" name="client_id" value="${escapeHtml(clientId)}"> 4891 + <label>Password</label><input type="password" name="password" required autofocus> 4892 + <div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button> 4893 + <button type="submit" name="action" value="approve" class="approve">Authorize</button></div> 4894 + </form></body></html>`; 4565 4895 } 4566 4896 4567 4897 /** ··· 4575 4905 if ('error' in auth) return auth.error; 4576 4906 4577 4907 // Validate scope for blob upload 4578 - if (!hasRequiredScope(auth.scope, 'atproto')) { 4579 - return errorResponse( 4580 - 'Forbidden', 4581 - 'Insufficient scope for blob upload', 4582 - 403, 4583 - ); 4908 + if (auth.scope !== undefined) { 4909 + const contentType = 4910 + request.headers.get('content-type') || 'application/octet-stream'; 4911 + const permissions = new ScopePermissions(auth.scope); 4912 + if (!permissions.allowsBlob(contentType)) { 4913 + return errorResponse( 4914 + 'Forbidden', 4915 + `Missing required scope "blob:${contentType}"`, 4916 + 403, 4917 + ); 4918 + } 4584 4919 } 4920 + // Legacy tokens without scope are trusted (backward compat) 4585 4921 4586 4922 // Route to the user's DO based on their DID from the token 4587 4923 const id = env.PDS.idFromName(auth.did); 4588 4924 const pds = env.PDS.get(id); 4589 - return pds.fetch(request); 4925 + // Pass x-authed-did so DO knows auth was already done (avoids DPoP replay detection) 4926 + return pds.fetch( 4927 + new Request(request.url, { 4928 + method: request.method, 4929 + headers: { 4930 + ...Object.fromEntries(request.headers), 4931 + 'x-authed-did': auth.did, 4932 + }, 4933 + body: request.body, 4934 + }), 4935 + ); 4590 4936 } 4591 4937 4592 4938 /** ··· 4599 4945 const auth = await requireAuth(request, env, defaultPds); 4600 4946 if ('error' in auth) return auth.error; 4601 4947 4602 - // Validate scope for repo write 4603 - if (!hasRequiredScope(auth.scope, 'atproto')) { 4604 - return errorResponse('Forbidden', 'Insufficient scope for repo write', 403); 4605 - } 4606 - 4607 4948 const body = await request.json(); 4608 4949 const repo = body.repo; 4609 4950 if (!repo) { ··· 4613 4954 if (auth.did !== repo) { 4614 4955 return errorResponse('Forbidden', "Cannot modify another user's repo", 403); 4615 4956 } 4957 + 4958 + // Granular scope validation for OAuth tokens 4959 + if (auth.scope !== undefined) { 4960 + const permissions = new ScopePermissions(auth.scope); 4961 + const url = new URL(request.url); 4962 + const endpoint = url.pathname; 4963 + 4964 + if (endpoint === '/xrpc/com.atproto.repo.createRecord') { 4965 + const collection = body.collection; 4966 + if (!collection) { 4967 + return errorResponse('InvalidRequest', 'missing collection param', 400); 4968 + } 4969 + if (!permissions.allowsRepo(collection, 'create')) { 4970 + return errorResponse( 4971 + 'Forbidden', 4972 + `Missing required scope "repo:${collection}:create"`, 4973 + 403, 4974 + ); 4975 + } 4976 + } else if (endpoint === '/xrpc/com.atproto.repo.putRecord') { 4977 + const collection = body.collection; 4978 + if (!collection) { 4979 + return errorResponse('InvalidRequest', 'missing collection param', 400); 4980 + } 4981 + // putRecord requires both create and update permissions 4982 + if ( 4983 + !permissions.allowsRepo(collection, 'create') || 4984 + !permissions.allowsRepo(collection, 'update') 4985 + ) { 4986 + const missing = !permissions.allowsRepo(collection, 'create') 4987 + ? 'create' 4988 + : 'update'; 4989 + return errorResponse( 4990 + 'Forbidden', 4991 + `Missing required scope "repo:${collection}:${missing}"`, 4992 + 403, 4993 + ); 4994 + } 4995 + } else if (endpoint === '/xrpc/com.atproto.repo.deleteRecord') { 4996 + const collection = body.collection; 4997 + if (!collection) { 4998 + return errorResponse('InvalidRequest', 'missing collection param', 400); 4999 + } 5000 + if (!permissions.allowsRepo(collection, 'delete')) { 5001 + return errorResponse( 5002 + 'Forbidden', 5003 + `Missing required scope "repo:${collection}:delete"`, 5004 + 403, 5005 + ); 5006 + } 5007 + } else if (endpoint === '/xrpc/com.atproto.repo.applyWrites') { 5008 + const writes = body.writes || []; 5009 + for (const write of writes) { 5010 + const collection = write.collection; 5011 + if (!collection) continue; 5012 + 5013 + let action; 5014 + if (write.$type === 'com.atproto.repo.applyWrites#create') { 5015 + action = 'create'; 5016 + } else if (write.$type === 'com.atproto.repo.applyWrites#update') { 5017 + action = 'update'; 5018 + } else if (write.$type === 'com.atproto.repo.applyWrites#delete') { 5019 + action = 'delete'; 5020 + } else { 5021 + continue; 5022 + } 5023 + 5024 + if (!permissions.allowsRepo(collection, action)) { 5025 + return errorResponse( 5026 + 'Forbidden', 5027 + `Missing required scope "repo:${collection}:${action}"`, 5028 + 403, 5029 + ); 5030 + } 5031 + } 5032 + } 5033 + } 5034 + // Legacy tokens without scope are trusted (backward compat) 4616 5035 4617 5036 const id = env.PDS.idFromName(repo); 4618 5037 const pds = env.PDS.get(id);
+428 -1
test/e2e.test.js
··· 3 3 * Uses Node's built-in test runner and fetch 4 4 */ 5 5 6 - import { describe, it, before, after } from 'node:test'; 7 6 import assert from 'node:assert'; 8 7 import { spawn } from 'node:child_process'; 9 8 import { randomBytes } from 'node:crypto'; 9 + import { after, before, describe, it } from 'node:test'; 10 10 import { DpopClient } from './helpers/dpop.js'; 11 + import { getOAuthTokenWithScope } from './helpers/oauth.js'; 11 12 12 13 const BASE = 'http://localhost:8787'; 13 14 const DID = `did:plc:test${randomBytes(8).toString('hex')}`; ··· 1022 1023 const data = await parRes2.json(); 1023 1024 assert.strictEqual(data.error, 'invalid_dpop_proof'); 1024 1025 assert.ok(data.message?.includes('replay')); 1026 + }); 1027 + }); 1028 + 1029 + describe('Scope Enforcement', () => { 1030 + it('createRecord denied with insufficient scope', async () => { 1031 + // Get token that only allows creating likes, not posts 1032 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1033 + 'repo:app.bsky.feed.like?action=create', 1034 + DID, 1035 + PASSWORD, 1036 + ); 1037 + 1038 + const proof = await dpop.createProof( 1039 + 'POST', 1040 + `${BASE}/xrpc/com.atproto.repo.createRecord`, 1041 + accessToken, 1042 + ); 1043 + 1044 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1045 + method: 'POST', 1046 + headers: { 1047 + 'Content-Type': 'application/json', 1048 + Authorization: `DPoP ${accessToken}`, 1049 + DPoP: proof, 1050 + }, 1051 + body: JSON.stringify({ 1052 + repo: DID, 1053 + collection: 'app.bsky.feed.post', // Not allowed by scope 1054 + record: { text: 'test', createdAt: new Date().toISOString() }, 1055 + }), 1056 + }); 1057 + 1058 + assert.strictEqual(res.status, 403, 'Should reject with 403'); 1059 + const body = await res.json(); 1060 + assert.ok( 1061 + body.message?.includes('Missing required scope'), 1062 + 'Error should mention missing scope', 1063 + ); 1064 + }); 1065 + 1066 + it('createRecord allowed with matching scope', async () => { 1067 + // Get token that allows creating posts 1068 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1069 + 'repo:app.bsky.feed.post?action=create', 1070 + DID, 1071 + PASSWORD, 1072 + ); 1073 + 1074 + const proof = await dpop.createProof( 1075 + 'POST', 1076 + `${BASE}/xrpc/com.atproto.repo.createRecord`, 1077 + accessToken, 1078 + ); 1079 + 1080 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1081 + method: 'POST', 1082 + headers: { 1083 + 'Content-Type': 'application/json', 1084 + Authorization: `DPoP ${accessToken}`, 1085 + DPoP: proof, 1086 + }, 1087 + body: JSON.stringify({ 1088 + repo: DID, 1089 + collection: 'app.bsky.feed.post', 1090 + record: { text: 'scope test', createdAt: new Date().toISOString() }, 1091 + }), 1092 + }); 1093 + 1094 + assert.strictEqual(res.status, 200, 'Should allow with correct scope'); 1095 + const body = await res.json(); 1096 + assert.ok(body.uri, 'Should return uri'); 1097 + 1098 + // Note: We don't clean up here because our token only has create scope 1099 + // The record will be cleaned up by subsequent tests with full-access tokens 1100 + }); 1101 + 1102 + it('createRecord allowed with wildcard collection scope', async () => { 1103 + // Get token that allows creating any record type 1104 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1105 + 'repo:*?action=create', 1106 + DID, 1107 + PASSWORD, 1108 + ); 1109 + 1110 + const proof = await dpop.createProof( 1111 + 'POST', 1112 + `${BASE}/xrpc/com.atproto.repo.createRecord`, 1113 + accessToken, 1114 + ); 1115 + 1116 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1117 + method: 'POST', 1118 + headers: { 1119 + 'Content-Type': 'application/json', 1120 + Authorization: `DPoP ${accessToken}`, 1121 + DPoP: proof, 1122 + }, 1123 + body: JSON.stringify({ 1124 + repo: DID, 1125 + collection: 'app.bsky.feed.post', 1126 + record: { 1127 + text: 'wildcard scope test', 1128 + createdAt: new Date().toISOString(), 1129 + }, 1130 + }), 1131 + }); 1132 + 1133 + assert.strictEqual( 1134 + res.status, 1135 + 200, 1136 + 'Wildcard scope should allow any collection', 1137 + ); 1138 + }); 1139 + 1140 + it('deleteRecord denied without delete scope', async () => { 1141 + // Get token that only has create scope 1142 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1143 + 'repo:app.bsky.feed.post?action=create', 1144 + DID, 1145 + PASSWORD, 1146 + ); 1147 + 1148 + const proof = await dpop.createProof( 1149 + 'POST', 1150 + `${BASE}/xrpc/com.atproto.repo.deleteRecord`, 1151 + accessToken, 1152 + ); 1153 + 1154 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.deleteRecord`, { 1155 + method: 'POST', 1156 + headers: { 1157 + 'Content-Type': 'application/json', 1158 + Authorization: `DPoP ${accessToken}`, 1159 + DPoP: proof, 1160 + }, 1161 + body: JSON.stringify({ 1162 + repo: DID, 1163 + collection: 'app.bsky.feed.post', 1164 + rkey: 'nonexistent', // Doesn't matter, should fail on scope first 1165 + }), 1166 + }); 1167 + 1168 + assert.strictEqual( 1169 + res.status, 1170 + 403, 1171 + 'Should reject delete without delete scope', 1172 + ); 1173 + }); 1174 + 1175 + it('uploadBlob denied with mismatched MIME scope', async () => { 1176 + // Get token that only allows image uploads 1177 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1178 + 'blob:image/*', 1179 + DID, 1180 + PASSWORD, 1181 + ); 1182 + 1183 + const proof = await dpop.createProof( 1184 + 'POST', 1185 + `${BASE}/xrpc/com.atproto.repo.uploadBlob`, 1186 + accessToken, 1187 + ); 1188 + 1189 + // Try to upload a video (not allowed by scope) 1190 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 1191 + method: 'POST', 1192 + headers: { 1193 + 'Content-Type': 'video/mp4', 1194 + Authorization: `DPoP ${accessToken}`, 1195 + DPoP: proof, 1196 + }, 1197 + body: new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), // Fake MP4 header 1198 + }); 1199 + 1200 + assert.strictEqual( 1201 + res.status, 1202 + 403, 1203 + 'Should reject video upload with image-only scope', 1204 + ); 1205 + const body = await res.json(); 1206 + assert.ok( 1207 + body.message?.includes('Missing required scope'), 1208 + 'Error should mention missing scope', 1209 + ); 1210 + }); 1211 + 1212 + it('uploadBlob allowed with matching MIME scope', async () => { 1213 + // Get token that allows image uploads 1214 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1215 + 'blob:image/*', 1216 + DID, 1217 + PASSWORD, 1218 + ); 1219 + 1220 + const proof = await dpop.createProof( 1221 + 'POST', 1222 + `${BASE}/xrpc/com.atproto.repo.uploadBlob`, 1223 + accessToken, 1224 + ); 1225 + 1226 + // Minimal PNG 1227 + const pngBytes = new Uint8Array([ 1228 + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 1229 + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 1230 + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 1231 + 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 1232 + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 1233 + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, 1234 + ]); 1235 + 1236 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 1237 + method: 'POST', 1238 + headers: { 1239 + 'Content-Type': 'image/png', 1240 + Authorization: `DPoP ${accessToken}`, 1241 + DPoP: proof, 1242 + }, 1243 + body: pngBytes, 1244 + }); 1245 + 1246 + assert.strictEqual( 1247 + res.status, 1248 + 200, 1249 + 'Should allow image upload with image scope', 1250 + ); 1251 + }); 1252 + 1253 + it('transition:generic grants full access', async () => { 1254 + // Get token with transition:generic scope (full access) 1255 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1256 + 'transition:generic', 1257 + DID, 1258 + PASSWORD, 1259 + ); 1260 + 1261 + const proof = await dpop.createProof( 1262 + 'POST', 1263 + `${BASE}/xrpc/com.atproto.repo.createRecord`, 1264 + accessToken, 1265 + ); 1266 + 1267 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1268 + method: 'POST', 1269 + headers: { 1270 + 'Content-Type': 'application/json', 1271 + Authorization: `DPoP ${accessToken}`, 1272 + DPoP: proof, 1273 + }, 1274 + body: JSON.stringify({ 1275 + repo: DID, 1276 + collection: 'app.bsky.feed.post', 1277 + record: { 1278 + text: 'transition scope test', 1279 + createdAt: new Date().toISOString(), 1280 + }, 1281 + }), 1282 + }); 1283 + 1284 + assert.strictEqual( 1285 + res.status, 1286 + 200, 1287 + 'transition:generic should grant full access', 1288 + ); 1289 + }); 1290 + }); 1291 + 1292 + describe('Consent page display', () => { 1293 + it('consent page shows permissions table for granular scopes', async () => { 1294 + const dpop = await DpopClient.create(); 1295 + const clientId = 'http://localhost:3000'; 1296 + const redirectUri = 'http://localhost:3000/callback'; 1297 + const codeVerifier = randomBytes(32).toString('base64url'); 1298 + 1299 + const challengeBuffer = await crypto.subtle.digest( 1300 + 'SHA-256', 1301 + new TextEncoder().encode(codeVerifier), 1302 + ); 1303 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1304 + 1305 + // PAR request with granular scopes 1306 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1307 + const parRes = await fetch(`${BASE}/oauth/par`, { 1308 + method: 'POST', 1309 + headers: { 1310 + 'Content-Type': 'application/x-www-form-urlencoded', 1311 + DPoP: parProof, 1312 + }, 1313 + body: new URLSearchParams({ 1314 + client_id: clientId, 1315 + redirect_uri: redirectUri, 1316 + response_type: 'code', 1317 + scope: 1318 + 'atproto repo:app.bsky.feed.post?action=create&action=update blob:image/*', 1319 + code_challenge: codeChallenge, 1320 + code_challenge_method: 'S256', 1321 + state: 'test-state', 1322 + login_hint: DID, 1323 + }).toString(), 1324 + }); 1325 + 1326 + assert.strictEqual(parRes.status, 200, 'PAR should succeed'); 1327 + const { request_uri } = await parRes.json(); 1328 + 1329 + // GET the authorize page 1330 + const authorizeRes = await fetch( 1331 + `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, 1332 + ); 1333 + 1334 + const html = await authorizeRes.text(); 1335 + 1336 + // Verify permissions table is rendered 1337 + assert.ok( 1338 + html.includes('Repository permissions:'), 1339 + 'Should show repo permissions section', 1340 + ); 1341 + assert.ok( 1342 + html.includes('app.bsky.feed.post'), 1343 + 'Should show collection name', 1344 + ); 1345 + assert.ok( 1346 + html.includes('Upload permissions:'), 1347 + 'Should show upload permissions section', 1348 + ); 1349 + assert.ok(html.includes('image/*'), 'Should show blob MIME type'); 1350 + }); 1351 + 1352 + it('consent page shows identity message for atproto-only scope', async () => { 1353 + const dpop = await DpopClient.create(); 1354 + const clientId = 'http://localhost:3000'; 1355 + const redirectUri = 'http://localhost:3000/callback'; 1356 + const codeVerifier = randomBytes(32).toString('base64url'); 1357 + 1358 + const challengeBuffer = await crypto.subtle.digest( 1359 + 'SHA-256', 1360 + new TextEncoder().encode(codeVerifier), 1361 + ); 1362 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1363 + 1364 + // PAR request with atproto only (identity-only) 1365 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1366 + const parRes = await fetch(`${BASE}/oauth/par`, { 1367 + method: 'POST', 1368 + headers: { 1369 + 'Content-Type': 'application/x-www-form-urlencoded', 1370 + DPoP: parProof, 1371 + }, 1372 + body: new URLSearchParams({ 1373 + client_id: clientId, 1374 + redirect_uri: redirectUri, 1375 + response_type: 'code', 1376 + scope: 'atproto', 1377 + code_challenge: codeChallenge, 1378 + code_challenge_method: 'S256', 1379 + state: 'test-state', 1380 + login_hint: DID, 1381 + }).toString(), 1382 + }); 1383 + 1384 + assert.strictEqual(parRes.status, 200, 'PAR should succeed'); 1385 + const { request_uri } = await parRes.json(); 1386 + 1387 + // GET the authorize page 1388 + const authorizeRes = await fetch( 1389 + `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, 1390 + ); 1391 + 1392 + const html = await authorizeRes.text(); 1393 + 1394 + // Verify identity-only message 1395 + assert.ok( 1396 + html.includes('wants to uniquely identify you'), 1397 + 'Should show identity-only message', 1398 + ); 1399 + assert.ok( 1400 + !html.includes('Repository permissions:'), 1401 + 'Should NOT show permissions table', 1402 + ); 1403 + }); 1404 + 1405 + it('consent page shows warning for transition:generic scope', async () => { 1406 + const dpop = await DpopClient.create(); 1407 + const clientId = 'http://localhost:3000'; 1408 + const redirectUri = 'http://localhost:3000/callback'; 1409 + const codeVerifier = randomBytes(32).toString('base64url'); 1410 + 1411 + const challengeBuffer = await crypto.subtle.digest( 1412 + 'SHA-256', 1413 + new TextEncoder().encode(codeVerifier), 1414 + ); 1415 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1416 + 1417 + // PAR request with transition:generic (full access) 1418 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1419 + const parRes = await fetch(`${BASE}/oauth/par`, { 1420 + method: 'POST', 1421 + headers: { 1422 + 'Content-Type': 'application/x-www-form-urlencoded', 1423 + DPoP: parProof, 1424 + }, 1425 + body: new URLSearchParams({ 1426 + client_id: clientId, 1427 + redirect_uri: redirectUri, 1428 + response_type: 'code', 1429 + scope: 'atproto transition:generic', 1430 + code_challenge: codeChallenge, 1431 + code_challenge_method: 'S256', 1432 + state: 'test-state', 1433 + login_hint: DID, 1434 + }).toString(), 1435 + }); 1436 + 1437 + assert.strictEqual(parRes.status, 200, 'PAR should succeed'); 1438 + const { request_uri } = await parRes.json(); 1439 + 1440 + // GET the authorize page 1441 + const authorizeRes = await fetch( 1442 + `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, 1443 + ); 1444 + 1445 + const html = await authorizeRes.text(); 1446 + 1447 + // Verify warning banner 1448 + assert.ok( 1449 + html.includes('Full repository access requested'), 1450 + 'Should show full access warning', 1451 + ); 1025 1452 }); 1026 1453 }); 1027 1454
+85
test/helpers/oauth.js
··· 1 + /** 2 + * OAuth flow helpers for e2e tests 3 + */ 4 + 5 + import { randomBytes } from 'node:crypto'; 6 + import { DpopClient } from './dpop.js'; 7 + 8 + const BASE = 'http://localhost:8787'; 9 + 10 + /** 11 + * Get an OAuth token with a specific scope via full PAR -> authorize -> token flow 12 + * @param {string} scope - The scope to request 13 + * @param {string} did - The DID to authenticate as 14 + * @param {string} password - The password for authentication 15 + * @returns {Promise<{accessToken: string, refreshToken: string, dpop: DpopClient}>} 16 + */ 17 + export async function getOAuthTokenWithScope(scope, did, password) { 18 + const dpop = await DpopClient.create(); 19 + const clientId = 'http://localhost:3000'; 20 + const redirectUri = 'http://localhost:3000/callback'; 21 + const codeVerifier = randomBytes(32).toString('base64url'); 22 + const challengeBuffer = await crypto.subtle.digest( 23 + 'SHA-256', 24 + new TextEncoder().encode(codeVerifier), 25 + ); 26 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 27 + 28 + // PAR request 29 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 30 + const parRes = await fetch(`${BASE}/oauth/par`, { 31 + method: 'POST', 32 + headers: { 33 + 'Content-Type': 'application/x-www-form-urlencoded', 34 + DPoP: parProof, 35 + }, 36 + body: new URLSearchParams({ 37 + client_id: clientId, 38 + redirect_uri: redirectUri, 39 + response_type: 'code', 40 + scope: scope, 41 + code_challenge: codeChallenge, 42 + code_challenge_method: 'S256', 43 + login_hint: did, 44 + }).toString(), 45 + }); 46 + const parData = await parRes.json(); 47 + 48 + // Authorize 49 + const authRes = await fetch(`${BASE}/oauth/authorize`, { 50 + method: 'POST', 51 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 52 + body: new URLSearchParams({ 53 + request_uri: parData.request_uri, 54 + client_id: clientId, 55 + password: password, 56 + }).toString(), 57 + redirect: 'manual', 58 + }); 59 + const location = authRes.headers.get('location'); 60 + const authCode = new URL(location).searchParams.get('code'); 61 + 62 + // Token exchange 63 + const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 64 + const tokenRes = await fetch(`${BASE}/oauth/token`, { 65 + method: 'POST', 66 + headers: { 67 + 'Content-Type': 'application/x-www-form-urlencoded', 68 + DPoP: tokenProof, 69 + }, 70 + body: new URLSearchParams({ 71 + grant_type: 'authorization_code', 72 + code: authCode, 73 + client_id: clientId, 74 + redirect_uri: redirectUri, 75 + code_verifier: codeVerifier, 76 + }).toString(), 77 + }); 78 + const tokenData = await tokenRes.json(); 79 + 80 + return { 81 + accessToken: tokenData.access_token, 82 + refreshToken: tokenData.refresh_token, 83 + dpop, 84 + }; 85 + }
+342 -1
test/pds.test.js
··· 12 12 cidToString, 13 13 computeJwkThumbprint, 14 14 createAccessJwt, 15 - createCid, 16 15 createBlobCid, 16 + createCid, 17 17 createRefreshJwt, 18 18 createTid, 19 19 findBlobRefs, ··· 23 23 hexToBytes, 24 24 importPrivateKey, 25 25 isLoopbackClient, 26 + matchesMime, 27 + parseBlobScope, 28 + parseRepoScope, 29 + parseScopesForDisplay, 30 + ScopePermissions, 26 31 sign, 27 32 sniffMimeType, 28 33 validateClientMetadata, ··· 827 832 ); 828 833 }); 829 834 }); 835 + 836 + describe('Scope Parsing', () => { 837 + describe('parseRepoScope', () => { 838 + test('parses repo scope with query parameter action', () => { 839 + const result = parseRepoScope('repo:app.bsky.feed.post?action=create'); 840 + assert.deepStrictEqual(result, { 841 + collection: 'app.bsky.feed.post', 842 + actions: ['create'], 843 + }); 844 + }); 845 + 846 + test('parses repo scope with multiple query parameter actions', () => { 847 + const result = parseRepoScope( 848 + 'repo:app.bsky.feed.post?action=create&action=update', 849 + ); 850 + assert.deepStrictEqual(result, { 851 + collection: 'app.bsky.feed.post', 852 + actions: ['create', 'update'], 853 + }); 854 + }); 855 + 856 + test('parses repo scope without actions as all actions', () => { 857 + const result = parseRepoScope('repo:app.bsky.feed.post'); 858 + assert.deepStrictEqual(result, { 859 + collection: 'app.bsky.feed.post', 860 + actions: ['create', 'update', 'delete'], 861 + }); 862 + }); 863 + 864 + test('parses wildcard collection with action', () => { 865 + const result = parseRepoScope('repo:*?action=create'); 866 + assert.deepStrictEqual(result, { 867 + collection: '*', 868 + actions: ['create'], 869 + }); 870 + }); 871 + 872 + test('parses query-only format', () => { 873 + const result = parseRepoScope( 874 + 'repo?collection=app.bsky.feed.post&action=create', 875 + ); 876 + assert.deepStrictEqual(result, { 877 + collection: 'app.bsky.feed.post', 878 + actions: ['create'], 879 + }); 880 + }); 881 + 882 + test('deduplicates repeated actions', () => { 883 + const result = parseRepoScope( 884 + 'repo:app.bsky.feed.post?action=create&action=create&action=update', 885 + ); 886 + assert.deepStrictEqual(result, { 887 + collection: 'app.bsky.feed.post', 888 + actions: ['create', 'update'], 889 + }); 890 + }); 891 + 892 + test('returns null for non-repo scope', () => { 893 + assert.strictEqual(parseRepoScope('atproto'), null); 894 + assert.strictEqual(parseRepoScope('blob:image/*'), null); 895 + assert.strictEqual(parseRepoScope('transition:generic'), null); 896 + }); 897 + 898 + test('returns null for invalid repo scope', () => { 899 + assert.strictEqual(parseRepoScope('repo:'), null); 900 + assert.strictEqual(parseRepoScope('repo?'), null); 901 + }); 902 + }); 903 + 904 + describe('parseBlobScope', () => { 905 + test('parses wildcard MIME', () => { 906 + const result = parseBlobScope('blob:*/*'); 907 + assert.deepStrictEqual(result, { accept: ['*/*'] }); 908 + }); 909 + 910 + test('parses type wildcard', () => { 911 + const result = parseBlobScope('blob:image/*'); 912 + assert.deepStrictEqual(result, { accept: ['image/*'] }); 913 + }); 914 + 915 + test('parses specific MIME', () => { 916 + const result = parseBlobScope('blob:image/png'); 917 + assert.deepStrictEqual(result, { accept: ['image/png'] }); 918 + }); 919 + 920 + test('parses multiple MIMEs', () => { 921 + const result = parseBlobScope('blob:image/png,image/jpeg'); 922 + assert.deepStrictEqual(result, { accept: ['image/png', 'image/jpeg'] }); 923 + }); 924 + 925 + test('returns null for non-blob scope', () => { 926 + assert.strictEqual(parseBlobScope('atproto'), null); 927 + assert.strictEqual(parseBlobScope('repo:*:create'), null); 928 + }); 929 + }); 930 + 931 + describe('matchesMime', () => { 932 + test('wildcard matches everything', () => { 933 + assert.strictEqual(matchesMime('*/*', 'image/png'), true); 934 + assert.strictEqual(matchesMime('*/*', 'video/mp4'), true); 935 + }); 936 + 937 + test('type wildcard matches same type', () => { 938 + assert.strictEqual(matchesMime('image/*', 'image/png'), true); 939 + assert.strictEqual(matchesMime('image/*', 'image/jpeg'), true); 940 + assert.strictEqual(matchesMime('image/*', 'video/mp4'), false); 941 + }); 942 + 943 + test('exact match', () => { 944 + assert.strictEqual(matchesMime('image/png', 'image/png'), true); 945 + assert.strictEqual(matchesMime('image/png', 'image/jpeg'), false); 946 + }); 947 + 948 + test('case insensitive', () => { 949 + assert.strictEqual(matchesMime('image/PNG', 'image/png'), true); 950 + assert.strictEqual(matchesMime('IMAGE/*', 'image/png'), true); 951 + }); 952 + }); 953 + }); 954 + 955 + describe('ScopePermissions', () => { 956 + describe('static scopes', () => { 957 + test('atproto grants full access', () => { 958 + const perms = new ScopePermissions('atproto'); 959 + assert.strictEqual( 960 + perms.allowsRepo('app.bsky.feed.post', 'create'), 961 + true, 962 + ); 963 + assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); 964 + assert.strictEqual(perms.allowsBlob('image/png'), true); 965 + assert.strictEqual(perms.allowsBlob('video/mp4'), true); 966 + }); 967 + 968 + test('transition:generic grants full repo/blob access', () => { 969 + const perms = new ScopePermissions('transition:generic'); 970 + assert.strictEqual( 971 + perms.allowsRepo('app.bsky.feed.post', 'create'), 972 + true, 973 + ); 974 + assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); 975 + assert.strictEqual(perms.allowsBlob('image/png'), true); 976 + }); 977 + }); 978 + 979 + describe('repo scopes', () => { 980 + test('wildcard collection allows any collection', () => { 981 + const perms = new ScopePermissions('repo:*?action=create'); 982 + assert.strictEqual( 983 + perms.allowsRepo('app.bsky.feed.post', 'create'), 984 + true, 985 + ); 986 + assert.strictEqual( 987 + perms.allowsRepo('app.bsky.feed.like', 'create'), 988 + true, 989 + ); 990 + assert.strictEqual( 991 + perms.allowsRepo('app.bsky.feed.post', 'delete'), 992 + false, 993 + ); 994 + }); 995 + 996 + test('specific collection restricts to that collection', () => { 997 + const perms = new ScopePermissions( 998 + 'repo:app.bsky.feed.post?action=create', 999 + ); 1000 + assert.strictEqual( 1001 + perms.allowsRepo('app.bsky.feed.post', 'create'), 1002 + true, 1003 + ); 1004 + assert.strictEqual( 1005 + perms.allowsRepo('app.bsky.feed.like', 'create'), 1006 + false, 1007 + ); 1008 + }); 1009 + 1010 + test('multiple actions', () => { 1011 + const perms = new ScopePermissions('repo:*?action=create&action=update'); 1012 + assert.strictEqual(perms.allowsRepo('x', 'create'), true); 1013 + assert.strictEqual(perms.allowsRepo('x', 'update'), true); 1014 + assert.strictEqual(perms.allowsRepo('x', 'delete'), false); 1015 + }); 1016 + 1017 + test('multiple scopes combine', () => { 1018 + const perms = new ScopePermissions( 1019 + 'repo:app.bsky.feed.post?action=create repo:app.bsky.feed.like?action=delete', 1020 + ); 1021 + assert.strictEqual( 1022 + perms.allowsRepo('app.bsky.feed.post', 'create'), 1023 + true, 1024 + ); 1025 + assert.strictEqual( 1026 + perms.allowsRepo('app.bsky.feed.like', 'delete'), 1027 + true, 1028 + ); 1029 + assert.strictEqual( 1030 + perms.allowsRepo('app.bsky.feed.post', 'delete'), 1031 + false, 1032 + ); 1033 + }); 1034 + 1035 + test('allowsRepo with query param format scopes', () => { 1036 + const perms = new ScopePermissions( 1037 + 'atproto repo:app.bsky.feed.post?action=create', 1038 + ); 1039 + assert.strictEqual( 1040 + perms.allowsRepo('app.bsky.feed.post', 'create'), 1041 + true, 1042 + ); 1043 + assert.strictEqual( 1044 + perms.allowsRepo('app.bsky.feed.post', 'delete'), 1045 + true, 1046 + ); // atproto grants full access 1047 + }); 1048 + }); 1049 + 1050 + describe('blob scopes', () => { 1051 + test('wildcard allows any MIME', () => { 1052 + const perms = new ScopePermissions('blob:*/*'); 1053 + assert.strictEqual(perms.allowsBlob('image/png'), true); 1054 + assert.strictEqual(perms.allowsBlob('video/mp4'), true); 1055 + }); 1056 + 1057 + test('type wildcard restricts to type', () => { 1058 + const perms = new ScopePermissions('blob:image/*'); 1059 + assert.strictEqual(perms.allowsBlob('image/png'), true); 1060 + assert.strictEqual(perms.allowsBlob('image/jpeg'), true); 1061 + assert.strictEqual(perms.allowsBlob('video/mp4'), false); 1062 + }); 1063 + 1064 + test('specific MIME restricts exactly', () => { 1065 + const perms = new ScopePermissions('blob:image/png'); 1066 + assert.strictEqual(perms.allowsBlob('image/png'), true); 1067 + assert.strictEqual(perms.allowsBlob('image/jpeg'), false); 1068 + }); 1069 + }); 1070 + 1071 + describe('empty/no scope', () => { 1072 + test('no scope denies everything', () => { 1073 + const perms = new ScopePermissions(''); 1074 + assert.strictEqual(perms.allowsRepo('x', 'create'), false); 1075 + assert.strictEqual(perms.allowsBlob('image/png'), false); 1076 + }); 1077 + 1078 + test('undefined scope denies everything', () => { 1079 + const perms = new ScopePermissions(undefined); 1080 + assert.strictEqual(perms.allowsRepo('x', 'create'), false); 1081 + }); 1082 + }); 1083 + 1084 + describe('assertRepo', () => { 1085 + test('throws ScopeMissingError when denied', () => { 1086 + const perms = new ScopePermissions( 1087 + 'repo:app.bsky.feed.post?action=create', 1088 + ); 1089 + assert.throws(() => perms.assertRepo('app.bsky.feed.like', 'create'), { 1090 + message: /Missing required scope/, 1091 + }); 1092 + }); 1093 + 1094 + test('does not throw when allowed', () => { 1095 + const perms = new ScopePermissions( 1096 + 'repo:app.bsky.feed.post?action=create', 1097 + ); 1098 + assert.doesNotThrow(() => 1099 + perms.assertRepo('app.bsky.feed.post', 'create'), 1100 + ); 1101 + }); 1102 + }); 1103 + 1104 + describe('assertBlob', () => { 1105 + test('throws ScopeMissingError when denied', () => { 1106 + const perms = new ScopePermissions('blob:image/*'); 1107 + assert.throws(() => perms.assertBlob('video/mp4'), { 1108 + message: /Missing required scope/, 1109 + }); 1110 + }); 1111 + 1112 + test('does not throw when allowed', () => { 1113 + const perms = new ScopePermissions('blob:image/*'); 1114 + assert.doesNotThrow(() => perms.assertBlob('image/png')); 1115 + }); 1116 + }); 1117 + }); 1118 + 1119 + describe('parseScopesForDisplay', () => { 1120 + test('parses identity-only scope', () => { 1121 + const result = parseScopesForDisplay('atproto'); 1122 + assert.strictEqual(result.hasAtproto, true); 1123 + assert.strictEqual(result.hasTransitionGeneric, false); 1124 + assert.strictEqual(result.repoPermissions.size, 0); 1125 + assert.deepStrictEqual(result.blobPermissions, []); 1126 + }); 1127 + 1128 + test('parses granular repo scopes', () => { 1129 + const result = parseScopesForDisplay( 1130 + 'atproto repo:app.bsky.feed.post?action=create&action=update', 1131 + ); 1132 + assert.strictEqual(result.repoPermissions.size, 1); 1133 + const postPerms = result.repoPermissions.get('app.bsky.feed.post'); 1134 + assert.deepStrictEqual(postPerms, { 1135 + create: true, 1136 + update: true, 1137 + delete: false, 1138 + }); 1139 + }); 1140 + 1141 + test('merges multiple scopes for same collection', () => { 1142 + const result = parseScopesForDisplay( 1143 + 'atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete', 1144 + ); 1145 + const postPerms = result.repoPermissions.get('app.bsky.feed.post'); 1146 + assert.deepStrictEqual(postPerms, { 1147 + create: true, 1148 + update: false, 1149 + delete: true, 1150 + }); 1151 + }); 1152 + 1153 + test('parses blob scopes', () => { 1154 + const result = parseScopesForDisplay('atproto blob:image/*'); 1155 + assert.deepStrictEqual(result.blobPermissions, ['image/*']); 1156 + }); 1157 + 1158 + test('detects transition:generic', () => { 1159 + const result = parseScopesForDisplay('atproto transition:generic'); 1160 + assert.strictEqual(result.hasTransitionGeneric, true); 1161 + }); 1162 + 1163 + test('handles empty scope string', () => { 1164 + const result = parseScopesForDisplay(''); 1165 + assert.strictEqual(result.hasAtproto, false); 1166 + assert.strictEqual(result.hasTransitionGeneric, false); 1167 + assert.strictEqual(result.repoPermissions.size, 0); 1168 + assert.deepStrictEqual(result.blobPermissions, []); 1169 + }); 1170 + });