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

docs: add scope validation comparison vs official atproto PDS

Documents gaps in OAuth scope enforcement between pds.js and the
official implementation, covering repo/blob/transition scopes.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

Changed files
+151
docs
+151
docs/scope-comparison.md
··· 1 + # Scope Validation Comparison: pds.js vs atproto PDS 2 + 3 + Comparison of OAuth scope validation between this implementation and the official AT Protocol PDS. 4 + 5 + --- 6 + 7 + ## Scope Types Supported 8 + 9 + | Scope Type | Format | pds.js | atproto PDS | 10 + |------------|--------|--------|-------------| 11 + | `atproto` | Static | Checked (loose) | Required for all OAuth | 12 + | `transition:generic` | Static | Not recognized | Full repo/blob bypass | 13 + | `transition:email` | Static | N/A | Read account email | 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 | 18 + 19 + --- 20 + 21 + ## Scope Enforcement by Endpoint 22 + 23 + ### com.atproto.repo.createRecord 24 + 25 + | Aspect | pds.js | atproto PDS | 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:...\"" | 31 + 32 + ### com.atproto.repo.putRecord 33 + 34 + | Aspect | pds.js | atproto PDS | 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 | 39 + 40 + ### com.atproto.repo.deleteRecord 41 + 42 + | Aspect | pds.js | atproto PDS | 43 + |--------|--------|-------------| 44 + | Scope check | `hasRequiredScope(scope, 'atproto')` | `permissions.assertRepo({ action: 'delete', collection })` | 45 + | Required scope | `atproto` | `repo:<collection>:delete` | 46 + 47 + ### com.atproto.repo.applyWrites 48 + 49 + | Aspect | pds.js | atproto PDS | 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 | 54 + 55 + ### com.atproto.repo.uploadBlob 56 + 57 + | Aspect | pds.js | atproto PDS | 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) | 62 + 63 + ### app.bsky.actor.getPreferences 64 + 65 + | Aspect | pds.js | atproto PDS | 66 + |--------|--------|-------------| 67 + | Scope check | Requires auth only | `permissions.assertRpc({ aud, lxm })` | 68 + | Required scope | Any valid auth | `rpc:app.bsky.actor.getPreferences` | 69 + 70 + ### app.bsky.actor.putPreferences 71 + 72 + | Aspect | pds.js | atproto PDS | 73 + |--------|--------|-------------| 74 + | Scope check | Requires auth only | `permissions.assertRpc({ aud, lxm })` | 75 + | Required scope | Any valid auth | `rpc:app.bsky.actor.putPreferences` | 76 + 77 + --- 78 + 79 + ## Scope Parsing 80 + 81 + | Feature | pds.js | atproto PDS | 82 + |---------|--------|-------------| 83 + | Scope string splitting | `scope.split(' ')` | `ScopesSet` class | 84 + | Repo scope parsing | None | `RepoPermission.fromString()` | 85 + | Blob scope parsing | None | `BlobPermission.fromString()` | 86 + | 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 | 89 + 90 + --- 91 + 92 + ## Permission Checking 93 + 94 + | Feature | pds.js | atproto PDS | 95 + |---------|--------|-------------| 96 + | Permission class | None | `ScopePermissions` / `ScopePermissionsTransition` | 97 + | `allowsRepo(collection, action)` | N/A | Yes | 98 + | `allowsBlob(mime)` | N/A | Yes (with MIME wildcard matching) | 99 + | `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 + 103 + --- 104 + 105 + ## OAuth Flow 106 + 107 + | Feature | pds.js | atproto PDS | 108 + |---------|--------|-------------| 109 + | `scopes_supported` in metadata | `['atproto']` | `['atproto']` (but accepts granular) | 110 + | Scope validation at PAR | None | Validates syntax | 111 + | Scope stored in token | Yes | Yes | 112 + | Scope returned in token response | Yes | Yes | 113 + | `atproto` scope required | Checked at endpoints | Required at token verification | 114 + 115 + --- 116 + 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` | 126 + 127 + pds.js does not recognize any transition scopes. 128 + 129 + --- 130 + 131 + ## Summary 132 + 133 + | Category | pds.js | atproto PDS | 134 + |----------|--------|-------------| 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 | 139 + | RPC scopes | No | Yes | 140 + | Error specificity | Generic 403 | Names missing scope | 141 + 142 + --- 143 + 144 + ## Gaps to Address 145 + 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