a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: scope parser

Mary 5ff14281 95eccc01

+3438 -5
+7 -4
CLAUDE.md
··· 79 79 80 80 use `@oomfware/cgr` to ask questions about external repositories: 81 81 82 - npx @oomfware/cgr ask [options] <repo>[#branch] <question> 82 + ``` 83 + npx @oomfware/cgr ask [options] <repo>[#branch] <question> 83 84 84 - options: 85 - -m, --model <model> model to use: opus, sonnet, haiku (default: haiku) 86 - -w, --with <repo> additional repository to include, supports #branch (repeatable) 85 + options: 86 + -m, --model <model> model to use: opus, sonnet, haiku (default: haiku) 87 + -d, --deep clone full history (enables git log/blame/show) 88 + -w, --with <repo> additional repository to include, supports #branch (repeatable) 89 + ``` 87 90 88 91 useful repositories for development: 89 92
-1
package.json
··· 9 9 "@changesets/cli": "^2.29.8", 10 10 "@mary/tar": "jsr:^0.3.1", 11 11 "@mitata/counters": "^0.0.8", 12 - "@oomfware/cgr": "^0.1.3", 13 12 "@prettier/plugin-oxc": "^0.1.3", 14 13 "@typescript/native-preview": "7.0.0-dev.20260119.1", 15 14 "mitata": "^1.0.34",
+215
packages/oauth/scope-parser/README.md
··· 1 + # @atcute/oauth-scope-parser 2 + 3 + parser and matcher for atproto OAuth scopes. 4 + 5 + ## installation 6 + 7 + ```sh 8 + npm install @atcute/oauth-scope-parser 9 + ``` 10 + 11 + ## usage 12 + 13 + ### parsing and matching scopes 14 + 15 + ```typescript 16 + import { ScopeSet } from '@atcute/oauth-scope-parser'; 17 + 18 + const scopes = new ScopeSet('repo:* account:email identity:handle'); 19 + 20 + // check if a specific permission is granted 21 + scopes.matches('repo', { collection: 'app.bsky.feed.post', action: 'create' }); // true 22 + scopes.matches('account', { attr: 'email', action: 'read' }); // true 23 + scopes.matches('account', { attr: 'email', action: 'manage' }); // false 24 + ``` 25 + 26 + ### working with individual permissions 27 + 28 + ```typescript 29 + import { RepoPermission, RpcPermission } from '@atcute/oauth-scope-parser'; 30 + 31 + // parse a scope string 32 + const repo = RepoPermission.fromString('repo:app.bsky.feed.post?action=create'); 33 + repo.matches({ collection: 'app.bsky.feed.post', action: 'create' }); // true 34 + repo.matches({ collection: 'app.bsky.feed.post', action: 'delete' }); // false 35 + 36 + // normalize a scope 37 + repo.toString(); // 'repo:app.bsky.feed.post?action=create' 38 + 39 + // generate minimal scope for a permission 40 + RepoPermission.scopeNeededFor({ collection: 'app.bsky.feed.post', action: 'create' }); 41 + // 'repo:app.bsky.feed.post?action=create' 42 + ``` 43 + 44 + ### normalizing scopes 45 + 46 + ```typescript 47 + import { normalizeScopes, normalizeScopeValue } from '@atcute/oauth-scope-parser'; 48 + 49 + // normalize a single scope 50 + normalizeScopeValue('repo?collection=*&collection=app.bsky.feed.post'); 51 + // 'repo:*' (wildcard absorbs specific collections) 52 + 53 + // normalize a space-separated scope string 54 + normalizeScopes('repo:* repo:app.bsky.feed.post account:email'); 55 + // 'account:email repo:*' (sorted, deduplicated) 56 + ``` 57 + 58 + ## scope types 59 + 60 + | type | syntax | example | 61 + |------|--------|---------| 62 + | `repo` | `repo:<collection>[?action=<action>]` | `repo:app.bsky.feed.post?action=create` | 63 + | `rpc` | `rpc:<lxm>?aud=<audience>` | `rpc:app.bsky.feed.getFeed?aud=*` | 64 + | `blob` | `blob:<accept>` | `blob:image/*` | 65 + | `account` | `account:<attr>[?action=<action>]` | `account:email?action=manage` | 66 + | `identity` | `identity:<attr>` | `identity:handle` | 67 + | `include` | `include:<nsid>[?aud=<audience>]` | `include:app.bsky.authFullApp` | 68 + 69 + ### static scopes 70 + 71 + - `atproto` - base scope (required) 72 + - `transition:generic` - transition scope 73 + - `transition:email` - email transition scope 74 + - `transition:chat.bsky` - chat transition scope 75 + 76 + ## permission sets 77 + 78 + permission sets are lexicon-defined collections of permissions referenced via `include:` scopes. 79 + 80 + ```typescript 81 + import { IncludeScope, type LexiconPermissionSet } from '@atcute/oauth-scope-parser'; 82 + 83 + // parse the include scope 84 + const include = IncludeScope.fromString( 85 + 'include:app.bsky.authCreatePosts?aud=did:web:bsky.social#atproto_pds' 86 + ); 87 + 88 + // resolve the permission set from your lexicon resolver 89 + const permissionSet: LexiconPermissionSet = { 90 + permissions: [ 91 + { 92 + resource: 'rpc', 93 + inheritAud: true, 94 + lxm: ['app.bsky.video.uploadVideo', 'app.bsky.video.getJobStatus'], 95 + }, 96 + { 97 + resource: 'repo', 98 + action: ['create'], 99 + collection: ['app.bsky.feed.post', 'app.bsky.feed.postgate'], 100 + }, 101 + ], 102 + }; 103 + 104 + // expand to concrete permissions 105 + const { permissions, rejected } = include.toPermissions(permissionSet); 106 + 107 + // permissions: [RpcPermission, RepoPermission] 108 + // rejected: [] (any invalid permissions with reasons) 109 + ``` 110 + 111 + ### authority validation 112 + 113 + permission sets can only grant permissions within their own namespace: 114 + 115 + ```typescript 116 + const include = new IncludeScope('app.bsky.authCreatePosts'); 117 + 118 + // allowed: app.bsky.* (same authority) 119 + include.isParentAuthorityOf('app.bsky.feed.post'); // true 120 + include.isParentAuthorityOf('app.bsky.video.uploadVideo'); // true 121 + 122 + // rejected: different authority 123 + include.isParentAuthorityOf('com.example.other'); // false 124 + include.isParentAuthorityOf('*'); // false 125 + ``` 126 + 127 + ### integrating with ScopeSet 128 + 129 + `ScopeSet` handles concrete permissions only. expand `include:` scopes before creating the set: 130 + 131 + ```typescript 132 + import { ScopeSet, IncludeScope, hasScopePrefix } from '@atcute/oauth-scope-parser'; 133 + 134 + function createScopeSet( 135 + scopeString: string, 136 + resolvePermissionSet: (nsid: string) => LexiconPermissionSet | null, 137 + ): ScopeSet { 138 + const expanded: string[] = []; 139 + 140 + for (const scope of scopeString.split(' ')) { 141 + if (hasScopePrefix(scope, 'include')) { 142 + const include = IncludeScope.fromString(scope); 143 + if (include) { 144 + const permissionSet = resolvePermissionSet(include.nsid); 145 + if (permissionSet) { 146 + const { permissions } = include.toPermissions(permissionSet); 147 + for (const perm of permissions) { 148 + expanded.push(perm.toString()); 149 + } 150 + continue; 151 + } 152 + } 153 + } 154 + expanded.push(scope); 155 + } 156 + 157 + return new ScopeSet(expanded); 158 + } 159 + ``` 160 + 161 + ## api reference 162 + 163 + ### ScopeSet 164 + 165 + ```typescript 166 + class ScopeSet extends Set<string> { 167 + constructor(scopes: string | Iterable<string>); 168 + matches<R extends ResourceType>(resource: R, options: ScopeMatchOptions[R]): boolean; 169 + } 170 + ``` 171 + 172 + ### permission classes 173 + 174 + all permission classes share this interface: 175 + 176 + ```typescript 177 + class *Permission { 178 + static fromString(scope: string): *Permission | null; 179 + static fromSyntax(syntax: ScopeSyntax): *Permission | null; 180 + static scopeNeededFor(request: *PermissionMatch): string; 181 + matches(request: *PermissionMatch): boolean; 182 + toString(): string; 183 + } 184 + ``` 185 + 186 + ### IncludeScope 187 + 188 + ```typescript 189 + class IncludeScope { 190 + readonly nsid: Nsid; 191 + readonly aud: AtprotoAudience | undefined; 192 + 193 + static fromString(scope: string): IncludeScope | null; 194 + isParentAuthorityOf(nsid: '*' | Nsid): boolean; 195 + toPermissions(permissionSet: LexiconPermissionSet): ExpandedPermissions; 196 + toString(): string; 197 + } 198 + ``` 199 + 200 + ### normalization functions 201 + 202 + ```typescript 203 + function normalizeScopeValue(scope: string): string | null; 204 + function normalizeScopes(scopes: string): string; 205 + function validateScopes(scopes: string): boolean; 206 + function hasAtprotoScope(scopes: string): boolean; 207 + ``` 208 + 209 + ### syntax utilities 210 + 211 + ```typescript 212 + function parseScopeString(scope: string): ScopeSyntax; 213 + function formatScopeString(options: FormatScopeOptions): string; 214 + function hasScopePrefix(scope: string, prefix: string): boolean; 215 + ```
+75
packages/oauth/scope-parser/lib/index.ts
··· 1 + // syntax parsing 2 + export { 3 + formatScopeString, 4 + getMultiParam, 5 + getSingleParam, 6 + hasUnknownParams, 7 + hasScopePrefix, 8 + parseScopeString, 9 + type FormatScopeOptions, 10 + type NeRoArray, 11 + type ScopeSyntax, 12 + } from './syntax.js'; 13 + 14 + // MIME utilities 15 + export { isAccept, isMime, isRedundantAccept, matchesAccept, matchesAnyAccept } from './mime.js'; 16 + 17 + // permission classes 18 + export { 19 + AccountPermission, 20 + ACCOUNT_ACTIONS, 21 + ACCOUNT_ATTRIBUTES, 22 + type AccountAction, 23 + type AccountAttr, 24 + type AccountPermissionMatch, 25 + } from './permissions/account.js'; 26 + 27 + export { BlobPermission, type Accept, type BlobPermissionMatch } from './permissions/blob.js'; 28 + 29 + export { 30 + IdentityPermission, 31 + IDENTITY_ATTRIBUTES, 32 + type IdentityAttr, 33 + type IdentityPermissionMatch, 34 + } from './permissions/identity.js'; 35 + 36 + export { 37 + IncludeScope, 38 + type ExpandedPermissions, 39 + type IncludeScopeData, 40 + type LexiconBlobPermission, 41 + type LexiconPermission, 42 + type LexiconPermissionSet, 43 + type LexiconRepoPermission, 44 + type LexiconRpcPermission, 45 + type RejectedPermission, 46 + type RejectionReason, 47 + } from './permissions/include.js'; 48 + 49 + export { 50 + RepoPermission, 51 + REPO_ACTIONS, 52 + type CollectionParam, 53 + type RepoAction, 54 + type RepoPermissionMatch, 55 + } from './permissions/repo.js'; 56 + 57 + export { 58 + RpcPermission, 59 + type AudParam, 60 + type LxmParam, 61 + type RpcPermissionMatch, 62 + } from './permissions/rpc.js'; 63 + 64 + // scope set 65 + export { ScopeSet, type ResourceType, type ScopeMatchOptions } from './scope-set.js'; 66 + 67 + // normalization 68 + export { 69 + hasAtprotoScope, 70 + normalizeScopes, 71 + normalizeScopeValue, 72 + STATIC_SCOPES, 73 + type StaticScope, 74 + validateScopes, 75 + } from './normalize.js';
+95
packages/oauth/scope-parser/lib/mime.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { isAccept, isMime, matchesAccept, matchesAnyAccept } from './mime.js'; 4 + 5 + describe('isMime', () => { 6 + it('accepts valid MIME types', () => { 7 + expect(isMime('image/png')).toBe(true); 8 + expect(isMime('application/json')).toBe(true); 9 + expect(isMime('text/html')).toBe(true); 10 + }); 11 + 12 + it('rejects wildcards', () => { 13 + expect(isMime('image/*')).toBe(false); 14 + expect(isMime('*/*')).toBe(false); 15 + }); 16 + 17 + it('rejects invalid formats', () => { 18 + expect(isMime('image/png/extra')).toBe(false); 19 + expect(isMime('*/mime')).toBe(false); 20 + expect(isMime('/png')).toBe(false); 21 + expect(isMime('image/')).toBe(false); 22 + expect(isMime('image')).toBe(false); 23 + expect(isMime('image/ png')).toBe(false); 24 + expect(isMime('image//png')).toBe(false); 25 + }); 26 + }); 27 + 28 + describe('isAccept', () => { 29 + it('accepts valid MIME types', () => { 30 + expect(isAccept('image/png')).toBe(true); 31 + expect(isAccept('application/json')).toBe(true); 32 + expect(isAccept('text/html')).toBe(true); 33 + }); 34 + 35 + it('accepts wildcards', () => { 36 + expect(isAccept('image/*')).toBe(true); 37 + expect(isAccept('*/*')).toBe(true); 38 + }); 39 + 40 + it('rejects invalid wildcards', () => { 41 + expect(isAccept('image/**')).toBe(false); 42 + expect(isAccept('*/png')).toBe(false); 43 + expect(isAccept('*')).toBe(false); 44 + }); 45 + 46 + it('rejects invalid formats', () => { 47 + expect(isAccept('image//png')).toBe(false); 48 + expect(isAccept('/png')).toBe(false); 49 + expect(isAccept('image/')).toBe(false); 50 + expect(isAccept('image/png/extra')).toBe(false); 51 + }); 52 + }); 53 + 54 + describe('matchesAccept', () => { 55 + it('matches exact MIME type', () => { 56 + expect(matchesAccept('image/png', 'image/png')).toBe(true); 57 + expect(matchesAccept('image/png', 'image/jpeg')).toBe(false); 58 + }); 59 + 60 + it('matches with full wildcard', () => { 61 + expect(matchesAccept('*/*', 'image/png')).toBe(true); 62 + expect(matchesAccept('*/*', 'application/json')).toBe(true); 63 + expect(matchesAccept('*/*', 'text/html')).toBe(true); 64 + }); 65 + 66 + it('matches with subtype wildcard', () => { 67 + expect(matchesAccept('image/*', 'image/png')).toBe(true); 68 + expect(matchesAccept('image/*', 'image/jpeg')).toBe(true); 69 + expect(matchesAccept('image/*', 'image/gif')).toBe(true); 70 + expect(matchesAccept('image/*', 'text/html')).toBe(false); 71 + expect(matchesAccept('image/*', 'application/json')).toBe(false); 72 + }); 73 + 74 + it('rejects invalid MIME types', () => { 75 + expect(matchesAccept('image/png', '*/mime')).toBe(false); 76 + expect(matchesAccept('image/png', 'image')).toBe(false); 77 + expect(matchesAccept('image/*', 'image//png')).toBe(false); 78 + expect(matchesAccept('image/*', 'image/ png')).toBe(false); 79 + expect(matchesAccept('*/*', 'image/')).toBe(false); 80 + expect(matchesAccept('*/*', '/mime')).toBe(false); 81 + }); 82 + }); 83 + 84 + describe('matchesAnyAccept', () => { 85 + it('returns false for empty array', () => { 86 + expect(matchesAnyAccept([], 'image/png')).toBe(false); 87 + }); 88 + 89 + it('matches when any pattern matches', () => { 90 + expect(matchesAnyAccept(['image/*'], 'image/jpeg')).toBe(true); 91 + expect(matchesAnyAccept(['image/*'], 'text/html')).toBe(false); 92 + expect(matchesAnyAccept(['image/png', 'application/json'], 'image/png')).toBe(true); 93 + expect(matchesAnyAccept(['image/png', 'application/json'], 'text/html')).toBe(false); 94 + }); 95 + });
+126
packages/oauth/scope-parser/lib/mime.ts
··· 1 + /** 2 + * MIME type validation and matching for blob permissions 3 + */ 4 + 5 + // valid mime type pattern: type/subtype (no wildcards) 6 + const MIME_RE = /^[a-z0-9][a-z0-9!#$&\-^_.+]*\/[a-z0-9][a-z0-9!#$&\-^_.+]*$/i; 7 + 8 + // valid accept pattern: type/subtype or type/* or */* 9 + const ACCEPT_RE = /^(\*|[a-z0-9][a-z0-9!#$&\-^_.+]*)\/(\*|[a-z0-9][a-z0-9!#$&\-^_.+]*)$/i; 10 + 11 + /** 12 + * checks if value is a valid MIME type (no wildcards) 13 + * @param value the value to check 14 + * @returns true if valid MIME type 15 + */ 16 + export const isMime = (value: unknown): value is string => { 17 + return typeof value === 'string' && MIME_RE.test(value); 18 + }; 19 + 20 + /** 21 + * checks if value is a valid accept pattern (allows wildcards) 22 + * @param value the value to check 23 + * @returns true if valid accept pattern 24 + */ 25 + export const isAccept = (value: unknown): value is string => { 26 + if (typeof value !== 'string') { 27 + return false; 28 + } 29 + 30 + const match = ACCEPT_RE.exec(value); 31 + if (!match) { 32 + return false; 33 + } 34 + 35 + // can't have wildcard type with specific subtype (e.g., */png is invalid) 36 + const [, type, subtype] = match; 37 + if (type === '*' && subtype !== '*') { 38 + return false; 39 + } 40 + 41 + return true; 42 + }; 43 + 44 + /** 45 + * checks if an accept pattern matches a specific MIME type 46 + * @param accept the accept pattern (e.g., 'image/*', '*\/*') 47 + * @param mime the MIME type to match against 48 + * @returns true if the accept pattern covers the MIME type 49 + */ 50 + export const matchesAccept = (accept: string, mime: string): boolean => { 51 + // validate the mime type first 52 + if (!isMime(mime)) { 53 + return false; 54 + } 55 + 56 + // full wildcard matches everything 57 + if (accept === '*/*') { 58 + return true; 59 + } 60 + 61 + const slashIdx = accept.indexOf('/'); 62 + if (slashIdx === -1) { 63 + return false; 64 + } 65 + 66 + const acceptType = accept.slice(0, slashIdx); 67 + const acceptSubtype = accept.slice(slashIdx + 1); 68 + 69 + const mimeSlashIdx = mime.indexOf('/'); 70 + const mimeType = mime.slice(0, mimeSlashIdx); 71 + const mimeSubtype = mime.slice(mimeSlashIdx + 1); 72 + 73 + // type must match 74 + if (acceptType !== mimeType) { 75 + return false; 76 + } 77 + 78 + // subtype wildcard or exact match 79 + return acceptSubtype === '*' || acceptSubtype.toLowerCase() === mimeSubtype.toLowerCase(); 80 + }; 81 + 82 + /** 83 + * checks if any accept pattern in the array matches the MIME type 84 + * @param accepts array of accept patterns 85 + * @param mime the MIME type to match against 86 + * @returns true if any pattern matches 87 + */ 88 + export const matchesAnyAccept = (accepts: readonly string[], mime: string): boolean => { 89 + for (const accept of accepts) { 90 + if (matchesAccept(accept, mime)) { 91 + return true; 92 + } 93 + } 94 + return false; 95 + }; 96 + 97 + /** 98 + * checks if an accept pattern is redundant given another pattern 99 + * e.g., 'image/png' is redundant if 'image/*' is present 100 + */ 101 + export const isRedundantAccept = (accept: string, other: string): boolean => { 102 + if (other === '*/*') { 103 + return true; 104 + } 105 + 106 + if (accept === other) { 107 + return true; 108 + } 109 + 110 + const slashIdx = other.indexOf('/'); 111 + if (slashIdx === -1) { 112 + return false; 113 + } 114 + 115 + const otherSubtype = other.slice(slashIdx + 1); 116 + if (otherSubtype !== '*') { 117 + return false; 118 + } 119 + 120 + // other is type/*, check if accept has same type 121 + const otherType = other.slice(0, slashIdx); 122 + const acceptSlashIdx = accept.indexOf('/'); 123 + const acceptType = accept.slice(0, acceptSlashIdx); 124 + 125 + return acceptType.toLowerCase() === otherType.toLowerCase(); 126 + };
+125
packages/oauth/scope-parser/lib/normalize.ts
··· 1 + /** 2 + * scope normalization 3 + * 4 + * normalizes scope strings to canonical form for comparison and storage 5 + */ 6 + 7 + import { AccountPermission } from './permissions/account.js'; 8 + import { BlobPermission } from './permissions/blob.js'; 9 + import { IdentityPermission } from './permissions/identity.js'; 10 + import { IncludeScope } from './permissions/include.js'; 11 + import { RepoPermission } from './permissions/repo.js'; 12 + import { RpcPermission } from './permissions/rpc.js'; 13 + import { hasScopePrefix } from './syntax.js'; 14 + 15 + // #region static scopes 16 + 17 + export const STATIC_SCOPES = ['atproto', 'transition:email', 'transition:generic', 'transition:chat.bsky'] as const; 18 + 19 + export type StaticScope = (typeof STATIC_SCOPES)[number]; 20 + 21 + const isStaticScope = (value: string): value is StaticScope => { 22 + return (STATIC_SCOPES as readonly string[]).includes(value); 23 + }; 24 + 25 + // #endregion 26 + 27 + // #region normalization 28 + 29 + /** 30 + * normalizes a single scope value to canonical form 31 + * @param scope the scope to normalize 32 + * @returns normalized scope or null if invalid 33 + */ 34 + export const normalizeScopeValue = (scope: string): string | null => { 35 + // static scopes pass through as-is 36 + if (isStaticScope(scope)) { 37 + return scope; 38 + } 39 + 40 + // try each permission type 41 + if (hasScopePrefix(scope, 'repo')) { 42 + const perm = RepoPermission.fromString(scope); 43 + return perm?.toString() ?? null; 44 + } 45 + 46 + if (hasScopePrefix(scope, 'rpc')) { 47 + const perm = RpcPermission.fromString(scope); 48 + return perm?.toString() ?? null; 49 + } 50 + 51 + if (hasScopePrefix(scope, 'blob')) { 52 + const perm = BlobPermission.fromString(scope); 53 + return perm?.toString() ?? null; 54 + } 55 + 56 + if (hasScopePrefix(scope, 'account')) { 57 + const perm = AccountPermission.fromString(scope); 58 + return perm?.toString() ?? null; 59 + } 60 + 61 + if (hasScopePrefix(scope, 'identity')) { 62 + const perm = IdentityPermission.fromString(scope); 63 + return perm?.toString() ?? null; 64 + } 65 + 66 + if (hasScopePrefix(scope, 'include')) { 67 + const inc = IncludeScope.fromString(scope); 68 + return inc?.toString() ?? null; 69 + } 70 + 71 + // unknown scope type 72 + return null; 73 + }; 74 + 75 + /** 76 + * normalizes a space-separated scope string 77 + * - parses and re-formats each scope to canonical form 78 + * - filters out invalid scopes 79 + * - deduplicates and sorts 80 + * 81 + * @param scopes the scope string to normalize 82 + * @returns normalized scope string 83 + */ 84 + export const normalizeScopes = (scopes: string): string => { 85 + const values = scopes.split(' ').filter((s) => s.length > 0); 86 + const normalized = new Set<string>(); 87 + 88 + for (const value of values) { 89 + const norm = normalizeScopeValue(value); 90 + if (norm !== null) { 91 + normalized.add(norm); 92 + } 93 + } 94 + 95 + return [...normalized].sort().join(' '); 96 + }; 97 + 98 + /** 99 + * validates that a scope string contains valid scopes 100 + * @param scopes the scope string to validate 101 + * @returns true if all scopes are valid 102 + */ 103 + export const validateScopes = (scopes: string): boolean => { 104 + const values = scopes.split(' ').filter((s) => s.length > 0); 105 + 106 + for (const value of values) { 107 + if (normalizeScopeValue(value) === null) { 108 + return false; 109 + } 110 + } 111 + 112 + return true; 113 + }; 114 + 115 + /** 116 + * checks if a scope string contains the required 'atproto' scope 117 + * @param scopes the scope string to check 118 + * @returns true if 'atproto' scope is present 119 + */ 120 + export const hasAtprotoScope = (scopes: string): boolean => { 121 + const values = scopes.split(' '); 122 + return values.includes('atproto'); 123 + }; 124 + 125 + // #endregion
+101
packages/oauth/scope-parser/lib/permissions/account.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { AccountPermission } from './account.js'; 4 + 5 + describe('AccountPermission', () => { 6 + describe('fromString', () => { 7 + it('parses with attr only (default action)', () => { 8 + const perm = AccountPermission.fromString('account:email'); 9 + expect(perm).not.toBeNull(); 10 + expect(perm!.attr).toBe('email'); 11 + expect(perm!.action).toEqual(['read']); 12 + }); 13 + 14 + it('parses with explicit read action', () => { 15 + const perm = AccountPermission.fromString('account:email?action=read'); 16 + expect(perm).not.toBeNull(); 17 + expect(perm!.attr).toBe('email'); 18 + expect(perm!.action).toEqual(['read']); 19 + }); 20 + 21 + it('parses with manage action', () => { 22 + const perm = AccountPermission.fromString('account:repo?action=manage'); 23 + expect(perm).not.toBeNull(); 24 + expect(perm!.attr).toBe('repo'); 25 + expect(perm!.action).toEqual(['manage']); 26 + }); 27 + 28 + it('parses all valid attributes', () => { 29 + expect(AccountPermission.fromString('account:email')).not.toBeNull(); 30 + expect(AccountPermission.fromString('account:repo')).not.toBeNull(); 31 + expect(AccountPermission.fromString('account:status')).not.toBeNull(); 32 + }); 33 + 34 + it('returns null for invalid attribute', () => { 35 + expect(AccountPermission.fromString('account:invalid')).toBeNull(); 36 + }); 37 + 38 + it('returns null for invalid action', () => { 39 + expect(AccountPermission.fromString('account:email?action=invalid')).toBeNull(); 40 + }); 41 + 42 + it('returns null for malformed scope', () => { 43 + expect(AccountPermission.fromString('invalid:email')).toBeNull(); 44 + expect(AccountPermission.fromString('account')).toBeNull(); 45 + expect(AccountPermission.fromString('')).toBeNull(); 46 + expect(AccountPermission.fromString('account:')).toBeNull(); 47 + }); 48 + }); 49 + 50 + describe('matches', () => { 51 + it('matches exact attr and action', () => { 52 + const perm = AccountPermission.fromString('account:email?action=read')!; 53 + expect(perm.matches({ attr: 'email', action: 'read' })).toBe(true); 54 + expect(perm.matches({ attr: 'email', action: 'manage' })).toBe(false); 55 + expect(perm.matches({ attr: 'repo', action: 'read' })).toBe(false); 56 + }); 57 + 58 + it('manage implies read', () => { 59 + const perm = AccountPermission.fromString('account:email?action=manage')!; 60 + expect(perm.matches({ attr: 'email', action: 'read' })).toBe(true); 61 + expect(perm.matches({ attr: 'email', action: 'manage' })).toBe(true); 62 + }); 63 + 64 + it('default action is read', () => { 65 + const perm = AccountPermission.fromString('account:email')!; 66 + expect(perm.matches({ attr: 'email', action: 'read' })).toBe(true); 67 + expect(perm.matches({ attr: 'email', action: 'manage' })).toBe(false); 68 + }); 69 + }); 70 + 71 + describe('toString', () => { 72 + it('omits default action (read)', () => { 73 + const perm = new AccountPermission('email', ['read']); 74 + expect(perm.toString()).toBe('account:email'); 75 + }); 76 + 77 + it('includes non-default action', () => { 78 + const perm = new AccountPermission('email', ['manage']); 79 + expect(perm.toString()).toBe('account:email?action=manage'); 80 + }); 81 + }); 82 + 83 + describe('normalization consistency', () => { 84 + const cases = [ 85 + ['account:email', 'account:email'], 86 + ['account:email?action=manage', 'account:email?action=manage'], 87 + ['account:repo', 'account:repo'], 88 + ['account:repo?action=manage', 'account:repo?action=manage'], 89 + ['account:status', 'account:status'], 90 + ['account:status?action=manage', 'account:status?action=manage'], 91 + ]; 92 + 93 + for (const [input, expected] of cases) { 94 + it(`normalizes '${input}' to '${expected}'`, () => { 95 + const perm = AccountPermission.fromString(input); 96 + expect(perm).not.toBeNull(); 97 + expect(perm!.toString()).toBe(expected); 98 + }); 99 + } 100 + }); 101 + });
+169
packages/oauth/scope-parser/lib/permissions/account.ts
··· 1 + /** 2 + * account permission parsing and matching 3 + * 4 + * syntax: `account:<attr>[?action=<action>]` 5 + * - attr: 'email', 'repo', or 'status' 6 + * - action: 'read' or 'manage' (defaults to 'read') 7 + * 8 + * note: 'manage' action implies 'read' access 9 + */ 10 + 11 + import { 12 + formatScopeString, 13 + getMultiParam, 14 + getSingleParam, 15 + hasUnknownParams, 16 + hasScopePrefix, 17 + parseScopeString, 18 + type NeRoArray, 19 + type ScopeSyntax, 20 + } from '../syntax.js'; 21 + 22 + // #region types 23 + 24 + export const ACCOUNT_ATTRIBUTES = ['email', 'repo', 'status'] as const; 25 + export type AccountAttr = (typeof ACCOUNT_ATTRIBUTES)[number]; 26 + 27 + export const ACCOUNT_ACTIONS = ['read', 'manage'] as const; 28 + export type AccountAction = (typeof ACCOUNT_ACTIONS)[number]; 29 + 30 + export interface AccountPermissionMatch { 31 + attr: AccountAttr; 32 + action: AccountAction; 33 + } 34 + 35 + // #endregion 36 + 37 + // #region validation 38 + 39 + const KNOWN_KEYS = new Set(['attr', 'action']); 40 + 41 + const isAccountAttr = (value: unknown): value is AccountAttr => { 42 + return value === 'email' || value === 'repo' || value === 'status'; 43 + }; 44 + 45 + const isAccountAction = (value: unknown): value is AccountAction => { 46 + return value === 'read' || value === 'manage'; 47 + }; 48 + 49 + // #endregion 50 + 51 + // #region permission class 52 + 53 + export class AccountPermission { 54 + constructor( 55 + readonly attr: AccountAttr, 56 + readonly action: NeRoArray<AccountAction>, 57 + ) {} 58 + 59 + /** 60 + * checks if this permission covers the requested access 61 + * note: 'manage' action implies 'read' access 62 + */ 63 + matches(request: AccountPermissionMatch): boolean { 64 + if (this.attr !== request.attr) { 65 + return false; 66 + } 67 + 68 + // manage implies read 69 + if (this.action.includes('manage')) { 70 + return true; 71 + } 72 + 73 + return this.action.includes(request.action); 74 + } 75 + 76 + /** 77 + * formats this permission as a scope string 78 + */ 79 + toString(): string { 80 + const params = new URLSearchParams(); 81 + 82 + // omit action if it's the default (read only) 83 + if (!(this.action.length === 1 && this.action[0] === 'read')) { 84 + for (const a of this.action) { 85 + params.append('action', a); 86 + } 87 + } 88 + 89 + return formatScopeString({ prefix: 'account', positional: this.attr, params }); 90 + } 91 + 92 + /** 93 + * parses a scope string into an AccountPermission 94 + * @returns the permission or null if invalid 95 + */ 96 + static fromString(scope: string): AccountPermission | null { 97 + if (!hasScopePrefix(scope, 'account')) { 98 + return null; 99 + } 100 + return AccountPermission.fromSyntax(parseScopeString(scope)); 101 + } 102 + 103 + /** 104 + * parses a pre-parsed scope syntax into an AccountPermission 105 + * @returns the permission or null if invalid 106 + */ 107 + static fromSyntax(syntax: ScopeSyntax): AccountPermission | null { 108 + if (syntax.prefix !== 'account') { 109 + return null; 110 + } 111 + 112 + // reject unknown parameters 113 + if (hasUnknownParams(syntax, KNOWN_KEYS)) { 114 + return null; 115 + } 116 + 117 + // parse attr (required, positional) 118 + const attrRaw = getSingleParam(syntax, 'attr', 'attr'); 119 + if (attrRaw === null || attrRaw === undefined) { 120 + return null; 121 + } 122 + if (!isAccountAttr(attrRaw)) { 123 + return null; 124 + } 125 + 126 + // parse action (optional, defaults to 'read') 127 + const actionRaw = getMultiParam(syntax, 'action'); 128 + let action: NeRoArray<AccountAction>; 129 + 130 + if (actionRaw === null) { 131 + return null; 132 + } else if (actionRaw === undefined || actionRaw.length === 0) { 133 + action = ['read']; 134 + } else { 135 + // validate all action values 136 + for (const a of actionRaw) { 137 + if (!isAccountAction(a)) { 138 + return null; 139 + } 140 + } 141 + action = normalizeAction(actionRaw as NeRoArray<AccountAction>); 142 + } 143 + 144 + return new AccountPermission(attrRaw, action); 145 + } 146 + 147 + /** 148 + * generates the minimal scope string needed for the given access 149 + */ 150 + static scopeNeededFor(request: AccountPermissionMatch): string { 151 + return new AccountPermission(request.attr, [request.action]).toString(); 152 + } 153 + } 154 + 155 + // #endregion 156 + 157 + // #region normalization 158 + 159 + const normalizeAction = (value: NeRoArray<AccountAction>): NeRoArray<AccountAction> => { 160 + if (value.length === 1) { 161 + return value; 162 + } 163 + 164 + // filter to canonical order, cast is safe because input is non-empty 165 + const filtered = ACCOUNT_ACTIONS.filter((a) => value.includes(a)); 166 + return filtered as unknown as NeRoArray<AccountAction>; 167 + }; 168 + 169 + // #endregion
+122
packages/oauth/scope-parser/lib/permissions/blob.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { BlobPermission } from './blob.js'; 4 + 5 + describe('BlobPermission', () => { 6 + describe('fromString', () => { 7 + it('parses single accept', () => { 8 + const perm = BlobPermission.fromString('blob:image/png'); 9 + expect(perm).not.toBeNull(); 10 + expect(perm!.accept).toEqual(['image/png']); 11 + }); 12 + 13 + it('parses multiple accept via query params', () => { 14 + const perm = BlobPermission.fromString('blob?accept=image/png&accept=image/jpeg'); 15 + expect(perm).not.toBeNull(); 16 + expect(perm!.accept).toContain('image/png'); 17 + expect(perm!.accept).toContain('image/jpeg'); 18 + }); 19 + 20 + it('parses full wildcard', () => { 21 + const perm = BlobPermission.fromString('blob:*/*'); 22 + expect(perm).not.toBeNull(); 23 + expect(perm!.accept).toEqual(['*/*']); 24 + }); 25 + 26 + it('parses subtype wildcard', () => { 27 + const perm = BlobPermission.fromString('blob:image/*'); 28 + expect(perm).not.toBeNull(); 29 + expect(perm!.accept).toEqual(['image/*']); 30 + }); 31 + 32 + it('returns null for missing accept', () => { 33 + expect(BlobPermission.fromString('blob')).toBeNull(); 34 + }); 35 + 36 + it('returns null for invalid MIME', () => { 37 + expect(BlobPermission.fromString('blob:invalid')).toBeNull(); 38 + expect(BlobPermission.fromString('blob?accept=invalid-mime')).toBeNull(); 39 + expect(BlobPermission.fromString('blob?accept=invalid')).toBeNull(); 40 + expect(BlobPermission.fromString('blob:*/**')).toBeNull(); 41 + expect(BlobPermission.fromString('blob:*/png')).toBeNull(); 42 + }); 43 + 44 + it('returns null for non-blob scope', () => { 45 + expect(BlobPermission.fromString('invalid')).toBeNull(); 46 + expect(BlobPermission.fromString('scope')).toBeNull(); 47 + }); 48 + }); 49 + 50 + describe('matches', () => { 51 + it('matches exact MIME', () => { 52 + const perm = BlobPermission.fromString('blob:image/png')!; 53 + expect(perm.matches({ mime: 'image/png' })).toBe(true); 54 + expect(perm.matches({ mime: 'image/jpeg' })).toBe(false); 55 + }); 56 + 57 + it('matches full wildcard', () => { 58 + const perm = BlobPermission.fromString('blob:*/*')!; 59 + expect(perm.matches({ mime: 'image/jpeg' })).toBe(true); 60 + expect(perm.matches({ mime: 'application/json' })).toBe(true); 61 + }); 62 + 63 + it('matches subtype wildcard', () => { 64 + const perm = BlobPermission.fromString('blob:image/*')!; 65 + expect(perm.matches({ mime: 'image/png' })).toBe(true); 66 + expect(perm.matches({ mime: 'image/gif' })).toBe(true); 67 + expect(perm.matches({ mime: 'application/json' })).toBe(false); 68 + }); 69 + 70 + it('matches multiple accept values', () => { 71 + const perm = BlobPermission.fromString('blob?accept=image/png&accept=image/jpeg')!; 72 + expect(perm.matches({ mime: 'image/png' })).toBe(true); 73 + expect(perm.matches({ mime: 'image/jpeg' })).toBe(true); 74 + expect(perm.matches({ mime: 'image/gif' })).toBe(false); 75 + }); 76 + }); 77 + 78 + describe('toString', () => { 79 + it('uses positional for single accept', () => { 80 + const perm = new BlobPermission(['image/png']); 81 + expect(perm.toString()).toBe('blob:image/png'); 82 + }); 83 + 84 + it('uses query params for multiple accept', () => { 85 + const perm = new BlobPermission(['image/png', 'image/jpeg']); 86 + expect(perm.toString()).toBe('blob?accept=image/jpeg&accept=image/png'); 87 + }); 88 + 89 + it('normalizes to lowercase', () => { 90 + const perm = new BlobPermission(['IMAGE/PNG']); 91 + expect(perm.toString()).toBe('blob:image/png'); 92 + }); 93 + 94 + it('removes redundant types', () => { 95 + expect(new BlobPermission(['*/*', 'image/*']).toString()).toBe('blob:*/*'); 96 + expect(new BlobPermission(['*/*', 'image/png']).toString()).toBe('blob:*/*'); 97 + expect(new BlobPermission(['image/*', 'image/png']).toString()).toBe('blob:image/*'); 98 + }); 99 + 100 + it('sorts multiple accept values', () => { 101 + const perm = new BlobPermission(['image/png', 'image/jpeg']); 102 + expect(perm.toString()).toBe('blob?accept=image/jpeg&accept=image/png'); 103 + }); 104 + }); 105 + 106 + describe('normalization consistency', () => { 107 + const cases = [ 108 + ['blob:image/png', 'blob:image/png'], 109 + ['blob:image/*', 'blob:image/*'], 110 + ['blob:*/*', 'blob:*/*'], 111 + ['blob?accept=image/png&accept=image/jpeg', 'blob?accept=image/jpeg&accept=image/png'], 112 + ]; 113 + 114 + for (const [input, expected] of cases) { 115 + it(`normalizes '${input}' to '${expected}'`, () => { 116 + const perm = BlobPermission.fromString(input); 117 + expect(perm).not.toBeNull(); 118 + expect(perm!.toString()).toBe(expected); 119 + }); 120 + } 121 + }); 122 + });
+163
packages/oauth/scope-parser/lib/permissions/blob.ts
··· 1 + /** 2 + * blob permission parsing and matching 3 + * 4 + * syntax: `blob:<accept>[?accept=<accept>]` 5 + * - accept: MIME type pattern (e.g., 'image/*', '*\/*', 'image/png') 6 + */ 7 + 8 + import { isAccept, isRedundantAccept, matchesAccept } from '../mime.js'; 9 + 10 + import { 11 + formatScopeString, 12 + getMultiParam, 13 + hasUnknownParams, 14 + hasScopePrefix, 15 + parseScopeString, 16 + type NeRoArray, 17 + type ScopeSyntax, 18 + } from '../syntax.js'; 19 + 20 + // #region types 21 + 22 + export type Accept = string; 23 + 24 + export interface BlobPermissionMatch { 25 + mime: string; 26 + } 27 + 28 + // #endregion 29 + 30 + // #region validation 31 + 32 + const KNOWN_KEYS = new Set(['accept']); 33 + 34 + // #endregion 35 + 36 + // #region permission class 37 + 38 + export class BlobPermission { 39 + constructor(readonly accept: NeRoArray<Accept>) {} 40 + 41 + /** 42 + * checks if this permission covers the requested MIME type 43 + */ 44 + matches(request: BlobPermissionMatch): boolean { 45 + for (const accept of this.accept) { 46 + if (matchesAccept(accept, request.mime)) { 47 + return true; 48 + } 49 + } 50 + return false; 51 + } 52 + 53 + /** 54 + * formats this permission as a scope string 55 + */ 56 + toString(): string { 57 + const accept = normalizeAccept(this.accept); 58 + 59 + const params = new URLSearchParams(); 60 + 61 + // use positional for single accept 62 + let positional: string | undefined; 63 + if (accept.length === 1) { 64 + positional = accept[0]; 65 + } else { 66 + for (const a of accept) { 67 + params.append('accept', a); 68 + } 69 + } 70 + 71 + return formatScopeString({ prefix: 'blob', positional, params }); 72 + } 73 + 74 + /** 75 + * parses a scope string into a BlobPermission 76 + * @returns the permission or null if invalid 77 + */ 78 + static fromString(scope: string): BlobPermission | null { 79 + if (!hasScopePrefix(scope, 'blob')) { 80 + return null; 81 + } 82 + return BlobPermission.fromSyntax(parseScopeString(scope)); 83 + } 84 + 85 + /** 86 + * parses a pre-parsed scope syntax into a BlobPermission 87 + * @returns the permission or null if invalid 88 + */ 89 + static fromSyntax(syntax: ScopeSyntax): BlobPermission | null { 90 + if (syntax.prefix !== 'blob') { 91 + return null; 92 + } 93 + 94 + // reject unknown parameters 95 + if (hasUnknownParams(syntax, KNOWN_KEYS)) { 96 + return null; 97 + } 98 + 99 + // parse accept (required) 100 + const acceptRaw = getMultiParam(syntax, 'accept', 'accept'); 101 + if (acceptRaw === null || acceptRaw === undefined || acceptRaw.length === 0) { 102 + return null; 103 + } 104 + 105 + // validate all accept values 106 + for (const a of acceptRaw) { 107 + if (!isAccept(a)) { 108 + return null; 109 + } 110 + } 111 + 112 + const accept = normalizeAccept(acceptRaw as NeRoArray<Accept>); 113 + 114 + return new BlobPermission(accept); 115 + } 116 + 117 + /** 118 + * generates the minimal scope string needed for the given MIME type 119 + */ 120 + static scopeNeededFor(request: BlobPermissionMatch): string { 121 + return new BlobPermission([request.mime]).toString(); 122 + } 123 + } 124 + 125 + // #endregion 126 + 127 + // #region normalization 128 + 129 + const normalizeAccept = (value: NeRoArray<Accept>): NeRoArray<Accept> => { 130 + // full wildcard subsumes all 131 + if (value.includes('*/*')) { 132 + return ['*/*']; 133 + } 134 + 135 + if (value.length === 1) { 136 + return [value[0].toLowerCase()] as NeRoArray<Accept>; 137 + } 138 + 139 + // lowercase all values 140 + const lower = value.map((a) => a.toLowerCase()); 141 + 142 + // remove redundant values (e.g., image/png is redundant if image/* is present) 143 + const filtered: string[] = []; 144 + for (const accept of lower) { 145 + let redundant = false; 146 + for (const other of lower) { 147 + if (accept !== other && isRedundantAccept(accept, other)) { 148 + redundant = true; 149 + break; 150 + } 151 + } 152 + if (!redundant && !filtered.includes(accept)) { 153 + filtered.push(accept); 154 + } 155 + } 156 + 157 + // sort for canonical output, cast is safe because input is non-empty 158 + filtered.sort(); 159 + 160 + return filtered as unknown as NeRoArray<Accept>; 161 + }; 162 + 163 + // #endregion
+64
packages/oauth/scope-parser/lib/permissions/identity.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { IdentityPermission } from './identity.js'; 4 + 5 + describe('IdentityPermission', () => { 6 + describe('fromString', () => { 7 + it('parses handle attribute', () => { 8 + const perm = IdentityPermission.fromString('identity:handle'); 9 + expect(perm).not.toBeNull(); 10 + expect(perm!.attr).toBe('handle'); 11 + }); 12 + 13 + it('parses wildcard attribute', () => { 14 + const perm = IdentityPermission.fromString('identity:*'); 15 + expect(perm).not.toBeNull(); 16 + expect(perm!.attr).toBe('*'); 17 + }); 18 + 19 + it('returns null for invalid attribute', () => { 20 + expect(IdentityPermission.fromString('identity:invalid')).toBeNull(); 21 + }); 22 + 23 + it('returns null for action parameters', () => { 24 + expect(IdentityPermission.fromString('identity:*?action=*')).toBeNull(); 25 + expect(IdentityPermission.fromString('identity:*?action=manage')).toBeNull(); 26 + expect(IdentityPermission.fromString('identity:*?action=submit')).toBeNull(); 27 + expect(IdentityPermission.fromString('identity:handle?action=invalid')).toBeNull(); 28 + }); 29 + 30 + it('returns null for non-identity scope', () => { 31 + expect(IdentityPermission.fromString('invalid')).toBeNull(); 32 + }); 33 + 34 + it('returns null for invalid format', () => { 35 + expect(IdentityPermission.fromString('identity?attribute=invalid&action=invalid')).toBeNull(); 36 + }); 37 + }); 38 + 39 + describe('matches', () => { 40 + it('matches exact attribute', () => { 41 + const perm = IdentityPermission.fromString('identity:handle')!; 42 + expect(perm.matches({ attr: 'handle' })).toBe(true); 43 + expect(perm.matches({ attr: '*' })).toBe(false); 44 + }); 45 + 46 + it('wildcard matches all attributes', () => { 47 + const perm = IdentityPermission.fromString('identity:*')!; 48 + expect(perm.matches({ attr: '*' })).toBe(true); 49 + expect(perm.matches({ attr: 'handle' })).toBe(true); 50 + }); 51 + }); 52 + 53 + describe('toString', () => { 54 + it('formats handle attribute', () => { 55 + const perm = new IdentityPermission('handle'); 56 + expect(perm.toString()).toBe('identity:handle'); 57 + }); 58 + 59 + it('formats wildcard attribute', () => { 60 + const perm = new IdentityPermission('*'); 61 + expect(perm.toString()).toBe('identity:*'); 62 + }); 63 + }); 64 + });
+108
packages/oauth/scope-parser/lib/permissions/identity.ts
··· 1 + /** 2 + * identity permission parsing and matching 3 + * 4 + * syntax: `identity:<attr>` 5 + * - attr: 'handle' or '*' for all 6 + * 7 + * no action parameter is supported for identity permissions 8 + */ 9 + 10 + import { 11 + formatScopeString, 12 + getSingleParam, 13 + hasUnknownParams, 14 + hasScopePrefix, 15 + parseScopeString, 16 + type ScopeSyntax, 17 + } from '../syntax.js'; 18 + 19 + // #region types 20 + 21 + export const IDENTITY_ATTRIBUTES = ['handle', '*'] as const; 22 + export type IdentityAttr = (typeof IDENTITY_ATTRIBUTES)[number]; 23 + 24 + export interface IdentityPermissionMatch { 25 + attr: IdentityAttr; 26 + } 27 + 28 + // #endregion 29 + 30 + // #region validation 31 + 32 + const KNOWN_KEYS = new Set(['attr']); 33 + 34 + const isIdentityAttr = (value: unknown): value is IdentityAttr => { 35 + return value === 'handle' || value === '*'; 36 + }; 37 + 38 + // #endregion 39 + 40 + // #region permission class 41 + 42 + export class IdentityPermission { 43 + constructor(readonly attr: IdentityAttr) {} 44 + 45 + /** 46 + * checks if this permission covers the requested access 47 + * note: '*' attr covers all attributes including 'handle' 48 + */ 49 + matches(request: IdentityPermissionMatch): boolean { 50 + if (this.attr === '*') { 51 + return true; 52 + } 53 + return this.attr === request.attr; 54 + } 55 + 56 + /** 57 + * formats this permission as a scope string 58 + */ 59 + toString(): string { 60 + return formatScopeString({ prefix: 'identity', positional: this.attr }); 61 + } 62 + 63 + /** 64 + * parses a scope string into an IdentityPermission 65 + * @returns the permission or null if invalid 66 + */ 67 + static fromString(scope: string): IdentityPermission | null { 68 + if (!hasScopePrefix(scope, 'identity')) { 69 + return null; 70 + } 71 + return IdentityPermission.fromSyntax(parseScopeString(scope)); 72 + } 73 + 74 + /** 75 + * parses a pre-parsed scope syntax into an IdentityPermission 76 + * @returns the permission or null if invalid 77 + */ 78 + static fromSyntax(syntax: ScopeSyntax): IdentityPermission | null { 79 + if (syntax.prefix !== 'identity') { 80 + return null; 81 + } 82 + 83 + // reject unknown parameters (including action) 84 + if (hasUnknownParams(syntax, KNOWN_KEYS)) { 85 + return null; 86 + } 87 + 88 + // parse attr (required, positional) 89 + const attrRaw = getSingleParam(syntax, 'attr', 'attr'); 90 + if (attrRaw === null || attrRaw === undefined) { 91 + return null; 92 + } 93 + if (!isIdentityAttr(attrRaw)) { 94 + return null; 95 + } 96 + 97 + return new IdentityPermission(attrRaw); 98 + } 99 + 100 + /** 101 + * generates the minimal scope string needed for the given access 102 + */ 103 + static scopeNeededFor(request: IdentityPermissionMatch): string { 104 + return new IdentityPermission(request.attr).toString(); 105 + } 106 + } 107 + 108 + // #endregion
+299
packages/oauth/scope-parser/lib/permissions/include.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { IncludeScope, type LexiconPermissionSet } from './include.js'; 4 + import { RepoPermission } from './repo.js'; 5 + import { RpcPermission } from './rpc.js'; 6 + 7 + describe('IncludeScope', () => { 8 + describe('fromString', () => { 9 + it('parses nsid only', () => { 10 + const scope = IncludeScope.fromString('include:com.example.bar'); 11 + expect(scope).not.toBeNull(); 12 + expect(scope!.nsid).toBe('com.example.bar'); 13 + expect(scope!.aud).toBeUndefined(); 14 + }); 15 + 16 + it('parses nsid with aud', () => { 17 + const scope = IncludeScope.fromString('include:com.example.baz?aud=did:web:example.com%23my_service'); 18 + expect(scope).not.toBeNull(); 19 + expect(scope!.nsid).toBe('com.example.baz'); 20 + expect(scope!.aud).toBe('did:web:example.com#my_service'); 21 + }); 22 + 23 + it('parses # in aud', () => { 24 + const scope = IncludeScope.fromString('include:com.example.baz?aud=did:web:example.com#my_service'); 25 + expect(scope).not.toBeNull(); 26 + expect(scope!.aud).toBe('did:web:example.com#my_service'); 27 + }); 28 + 29 + it('parses via query params', () => { 30 + const scope = IncludeScope.fromString('include?nsid=com.example.baz'); 31 + expect(scope).not.toBeNull(); 32 + expect(scope!.nsid).toBe('com.example.baz'); 33 + }); 34 + 35 + it('parses via query params with aud', () => { 36 + const scope = IncludeScope.fromString('include?aud=did:web:example.com%23my_service&nsid=com.example.baz'); 37 + expect(scope).not.toBeNull(); 38 + expect(scope!.nsid).toBe('com.example.baz'); 39 + expect(scope!.aud).toBe('did:web:example.com#my_service'); 40 + }); 41 + 42 + it('returns null for invalid cases', () => { 43 + expect(IncludeScope.fromString('')).toBeNull(); 44 + expect(IncludeScope.fromString('repo:com.example.baz')).toBeNull(); 45 + expect(IncludeScope.fromString('include')).toBeNull(); 46 + expect(IncludeScope.fromString('include#')).toBeNull(); 47 + expect(IncludeScope.fromString('include:')).toBeNull(); 48 + expect(IncludeScope.fromString('include:#')).toBeNull(); 49 + expect(IncludeScope.fromString('include:&')).toBeNull(); 50 + }); 51 + 52 + it('returns null for invalid nsid', () => { 53 + expect(IncludeScope.fromString('include:com..example')).toBeNull(); // double dot 54 + expect(IncludeScope.fromString('include:com')).toBeNull(); // too short 55 + expect(IncludeScope.fromString('include:com.example')).toBeNull(); // too short 56 + expect(IncludeScope.fromString('include:9com.example.foo')).toBeNull(); // starts with digit 57 + expect(IncludeScope.fromString('include:com.example.-bar')).toBeNull(); // segment starts with dash 58 + expect(IncludeScope.fromString('include:invalid^nsid')).toBeNull(); // invalid character 59 + expect(IncludeScope.fromString('include:nsid')).toBeNull(); // too short 60 + }); 61 + 62 + it('returns null for invalid aud', () => { 63 + expect(IncludeScope.fromString('include:com.example.baz?aud=')).toBeNull(); // empty aud 64 + expect(IncludeScope.fromString('include:com.example.baz?aud=did:web:example.com')).toBeNull(); // missing service ID 65 + expect(IncludeScope.fromString('include:com.example.baz?aud=invalid^did')).toBeNull(); // invalid aud 66 + }); 67 + }); 68 + 69 + describe('isParentAuthorityOf', () => { 70 + it('returns true for child nsids', () => { 71 + const scope = new IncludeScope('com.example.foo.auth'); 72 + expect(scope.isParentAuthorityOf('com.example.foo.identifier')).toBe(true); 73 + expect(scope.isParentAuthorityOf('com.example.foo.bar.baz')).toBe(true); 74 + }); 75 + 76 + it('returns false for sibling nsids', () => { 77 + const scope = new IncludeScope('com.example.foo.auth'); 78 + expect(scope.isParentAuthorityOf('com.example.bar')).toBe(false); 79 + }); 80 + 81 + it('returns false for different domain', () => { 82 + const scope = new IncludeScope('com.example.foo.auth'); 83 + expect(scope.isParentAuthorityOf('com.atproto.foo')).toBe(false); 84 + }); 85 + 86 + it('returns false for wildcard', () => { 87 + const scope = new IncludeScope('com.example.foo.auth'); 88 + expect(scope.isParentAuthorityOf('*')).toBe(false); 89 + }); 90 + }); 91 + 92 + describe('toString', () => { 93 + it('formats nsid only', () => { 94 + const scope = new IncludeScope('com.example.bar'); 95 + expect(scope.toString()).toBe('include:com.example.bar'); 96 + }); 97 + 98 + it('formats nsid with aud', () => { 99 + const scope = new IncludeScope('com.example.bar', 'did:web:example.com#my_service'); 100 + expect(scope.toString()).toBe('include:com.example.bar?aud=did:web:example.com%23my_service'); 101 + }); 102 + }); 103 + 104 + describe('toPermissions', () => { 105 + it('expands repo permissions', () => { 106 + const scope = new IncludeScope('app.bsky.authCreatePosts'); 107 + const permissionSet: LexiconPermissionSet = { 108 + permissions: [ 109 + { 110 + resource: 'repo', 111 + collection: ['app.bsky.feed.post', 'app.bsky.feed.postgate'], 112 + action: ['create'], 113 + }, 114 + ], 115 + }; 116 + 117 + const result = scope.toPermissions(permissionSet); 118 + 119 + expect(result.rejected).toHaveLength(0); 120 + expect(result.permissions).toHaveLength(1); 121 + expect(result.permissions[0]).toBeInstanceOf(RepoPermission); 122 + 123 + const repoPerm = result.permissions[0] as RepoPermission; 124 + expect(repoPerm.matches({ collection: 'app.bsky.feed.post', action: 'create' })).toBe(true); 125 + expect(repoPerm.matches({ collection: 'app.bsky.feed.post', action: 'delete' })).toBe(false); 126 + }); 127 + 128 + it('expands rpc permissions with inheritAud', () => { 129 + const scope = new IncludeScope('app.bsky.authCreatePosts', 'did:web:bsky.social#atproto_pds'); 130 + const permissionSet: LexiconPermissionSet = { 131 + permissions: [ 132 + { 133 + resource: 'rpc', 134 + inheritAud: true, 135 + lxm: ['app.bsky.video.uploadVideo', 'app.bsky.video.getJobStatus'], 136 + }, 137 + ], 138 + }; 139 + 140 + const result = scope.toPermissions(permissionSet); 141 + 142 + expect(result.rejected).toHaveLength(0); 143 + expect(result.permissions).toHaveLength(1); 144 + expect(result.permissions[0]).toBeInstanceOf(RpcPermission); 145 + 146 + const rpcPerm = result.permissions[0] as RpcPermission; 147 + expect(rpcPerm.aud).toBe('did:web:bsky.social#atproto_pds'); 148 + expect(rpcPerm.matches({ lxm: 'app.bsky.video.uploadVideo', aud: 'did:web:bsky.social#atproto_pds' })).toBe( 149 + true, 150 + ); 151 + }); 152 + 153 + it('uses wildcard aud when inheritAud but no aud on include scope', () => { 154 + const scope = new IncludeScope('app.bsky.authCreatePosts'); // no aud 155 + const permissionSet: LexiconPermissionSet = { 156 + permissions: [ 157 + { 158 + resource: 'rpc', 159 + inheritAud: true, 160 + lxm: ['app.bsky.video.uploadVideo'], 161 + }, 162 + ], 163 + }; 164 + 165 + const result = scope.toPermissions(permissionSet); 166 + 167 + expect(result.rejected).toHaveLength(0); 168 + expect(result.permissions).toHaveLength(1); 169 + 170 + const rpcPerm = result.permissions[0] as RpcPermission; 171 + expect(rpcPerm.aud).toBe('*'); 172 + }); 173 + 174 + it('rejects repo permissions outside authority', () => { 175 + const scope = new IncludeScope('app.bsky.authCreatePosts'); 176 + const permissionSet: LexiconPermissionSet = { 177 + permissions: [ 178 + { 179 + resource: 'repo', 180 + collection: ['com.example.other.collection'], // different authority 181 + action: ['create'], 182 + }, 183 + ], 184 + }; 185 + 186 + const result = scope.toPermissions(permissionSet); 187 + 188 + expect(result.permissions).toHaveLength(0); 189 + expect(result.rejected).toHaveLength(1); 190 + expect(result.rejected[0].reason).toBe('authority_violation'); 191 + }); 192 + 193 + it('rejects rpc permissions outside authority', () => { 194 + const scope = new IncludeScope('app.bsky.authCreatePosts'); 195 + const permissionSet: LexiconPermissionSet = { 196 + permissions: [ 197 + { 198 + resource: 'rpc', 199 + inheritAud: true, 200 + lxm: ['com.example.other.method'], // different authority 201 + }, 202 + ], 203 + }; 204 + 205 + const result = scope.toPermissions(permissionSet); 206 + 207 + expect(result.permissions).toHaveLength(0); 208 + expect(result.rejected).toHaveLength(1); 209 + expect(result.rejected[0].reason).toBe('authority_violation'); 210 + }); 211 + 212 + it('rejects blob permissions', () => { 213 + const scope = new IncludeScope('app.bsky.authCreatePosts'); 214 + const permissionSet: LexiconPermissionSet = { 215 + permissions: [ 216 + { 217 + resource: 'blob', 218 + accept: ['image/*'], 219 + }, 220 + ], 221 + }; 222 + 223 + const result = scope.toPermissions(permissionSet); 224 + 225 + expect(result.permissions).toHaveLength(0); 226 + expect(result.rejected).toHaveLength(1); 227 + expect(result.rejected[0].reason).toBe('blob_not_allowed'); 228 + }); 229 + 230 + it('rejects rpc permissions with specific aud', () => { 231 + const scope = new IncludeScope('app.bsky.authCreatePosts'); 232 + const permissionSet: LexiconPermissionSet = { 233 + permissions: [ 234 + { 235 + resource: 'rpc', 236 + aud: 'did:web:specific.com#service', // specific aud not allowed 237 + lxm: ['app.bsky.video.uploadVideo'], 238 + }, 239 + ], 240 + }; 241 + 242 + const result = scope.toPermissions(permissionSet); 243 + 244 + expect(result.permissions).toHaveLength(0); 245 + expect(result.rejected).toHaveLength(1); 246 + expect(result.rejected[0].reason).toBe('specific_aud_not_allowed'); 247 + }); 248 + 249 + it('handles mixed valid and invalid permissions', () => { 250 + const scope = new IncludeScope('app.bsky.authCreatePosts'); 251 + const permissionSet: LexiconPermissionSet = { 252 + permissions: [ 253 + { 254 + resource: 'repo', 255 + collection: ['app.bsky.feed.post'], // valid 256 + action: ['create'], 257 + }, 258 + { 259 + resource: 'repo', 260 + collection: ['com.example.other'], // invalid - different authority 261 + action: ['create'], 262 + }, 263 + { 264 + resource: 'rpc', 265 + inheritAud: true, 266 + lxm: ['app.bsky.video.uploadVideo'], // valid 267 + }, 268 + ], 269 + }; 270 + 271 + const result = scope.toPermissions(permissionSet); 272 + 273 + expect(result.permissions).toHaveLength(2); 274 + expect(result.rejected).toHaveLength(1); 275 + expect(result.rejected[0].reason).toBe('authority_violation'); 276 + }); 277 + 278 + it('defaults to all actions when action not specified', () => { 279 + const scope = new IncludeScope('app.bsky.authFullApp'); 280 + const permissionSet: LexiconPermissionSet = { 281 + permissions: [ 282 + { 283 + resource: 'repo', 284 + collection: ['app.bsky.feed.post'], 285 + // no action specified - defaults to all 286 + }, 287 + ], 288 + }; 289 + 290 + const result = scope.toPermissions(permissionSet); 291 + 292 + expect(result.permissions).toHaveLength(1); 293 + const repoPerm = result.permissions[0] as RepoPermission; 294 + expect(repoPerm.matches({ collection: 'app.bsky.feed.post', action: 'create' })).toBe(true); 295 + expect(repoPerm.matches({ collection: 'app.bsky.feed.post', action: 'update' })).toBe(true); 296 + expect(repoPerm.matches({ collection: 'app.bsky.feed.post', action: 'delete' })).toBe(true); 297 + }); 298 + }); 299 + });
+393
packages/oauth/scope-parser/lib/permissions/include.ts
··· 1 + /** 2 + * include scope parsing and permission set expansion 3 + * 4 + * syntax: `include:<nsid>[?aud=<audience>]` 5 + * - nsid: the permission set lexicon NSID 6 + * - aud: optional audience that can be inherited by RPC permissions 7 + * 8 + * include scopes reference permission sets defined in lexicons. 9 + * the actual expansion to concrete permissions requires the lexicon definitions. 10 + * 11 + * ## permission set expansion 12 + * 13 + * permission sets are lexicon documents of type "permission-set" that define 14 + * collections of repo and rpc permissions. when a client requests an include 15 + * scope, the authorization server expands it into concrete permissions. 16 + * 17 + * example permission set (app.bsky.authCreatePosts): 18 + * ```json 19 + * { 20 + * "lexicon": 1, 21 + * "id": "app.bsky.authCreatePosts", 22 + * "type": "permission-set", 23 + * "permissions": [ 24 + * { 25 + * "resource": "rpc", 26 + * "inheritAud": true, 27 + * "lxm": ["app.bsky.video.uploadVideo", "app.bsky.video.getJobStatus"] 28 + * }, 29 + * { 30 + * "resource": "repo", 31 + * "action": ["create"], 32 + * "collection": ["app.bsky.feed.post", "app.bsky.feed.postgate"] 33 + * } 34 + * ] 35 + * } 36 + * ``` 37 + * 38 + * ## usage with lexicon resolver 39 + * 40 + * ```typescript 41 + * import { IncludeScope } from '@atcute/oauth-scope-parser'; 42 + * import { createLexiconResolver } from '@atcute/lexicon-resolver'; 43 + * 44 + * const resolver = createLexiconResolver({ ... }); 45 + * 46 + * // parse the include scope 47 + * const include = IncludeScope.fromString('include:app.bsky.authCreatePosts?aud=did:web:bsky.social#atproto_pds'); 48 + * 49 + * // resolve the permission set from the lexicon 50 + * const lexicon = await resolver.resolve(include.nsid); 51 + * const permissionSet = lexicon.def as LexiconPermissionSet; 52 + * 53 + * // expand to concrete permissions 54 + * const result = include.toPermissions(permissionSet); 55 + * 56 + * if (result.permissions.length > 0) { 57 + * // use the expanded permissions for matching 58 + * for (const perm of result.permissions) { 59 + * if (perm instanceof RepoPermission) { 60 + * // handle repo permission 61 + * } else { 62 + * // handle rpc permission 63 + * } 64 + * } 65 + * } 66 + * 67 + * // check for any rejected permissions (authority violations, etc.) 68 + * if (result.rejected.length > 0) { 69 + * console.warn('some permissions were rejected:', result.rejected); 70 + * } 71 + * ``` 72 + * 73 + * ## security: authority validation 74 + * 75 + * permission sets can only grant permissions within their own namespace authority. 76 + * for example, `include:app.bsky.authCreatePosts` can only grant permissions for: 77 + * - `app.bsky.*` collections and methods (same authority) 78 + * 79 + * it cannot grant permissions for: 80 + * - `com.example.*` (different authority) 81 + * - `*` wildcard (too broad) 82 + */ 83 + 84 + import { isNsid, type AtprotoAudience, type Nsid } from '@atcute/lexicons/syntax'; 85 + 86 + import { 87 + formatScopeString, 88 + getSingleParam, 89 + hasUnknownParams, 90 + hasScopePrefix, 91 + parseScopeString, 92 + type NeRoArray, 93 + type ScopeSyntax, 94 + } from '../syntax.js'; 95 + 96 + import { RepoPermission, type RepoAction } from './repo.js'; 97 + import { RpcPermission } from './rpc.js'; 98 + 99 + // #region types 100 + 101 + export interface IncludeScopeData { 102 + nsid: Nsid; 103 + aud: AtprotoAudience | undefined; 104 + } 105 + 106 + /** result of expanding an include scope into concrete permissions */ 107 + export interface ExpandedPermissions { 108 + /** successfully expanded permissions */ 109 + permissions: (RepoPermission | RpcPermission)[]; 110 + /** permissions that were rejected during expansion */ 111 + rejected: RejectedPermission[]; 112 + } 113 + 114 + /** a permission that was rejected during expansion */ 115 + export interface RejectedPermission { 116 + /** the original permission from the lexicon */ 117 + permission: LexiconPermission; 118 + /** why the permission was rejected */ 119 + reason: RejectionReason; 120 + /** additional detail about the rejection */ 121 + detail?: string; 122 + } 123 + 124 + /** reasons why a permission might be rejected during expansion */ 125 + export type RejectionReason = 126 + | 'authority_violation' // permission targets NSID outside include scope's authority 127 + | 'invalid_collection' // collection is not a valid NSID 128 + | 'invalid_lxm' // lxm is not a valid NSID 129 + | 'invalid_action' // action is not a valid repo action 130 + | 'empty_collection' // no collections specified 131 + | 'empty_lxm' // no lxms specified 132 + | 'blob_not_allowed' // blob permissions not allowed in permission sets 133 + | 'specific_aud_not_allowed' // specific aud not allowed (must use inheritAud or *) 134 + | 'unknown_resource'; // unknown resource type 135 + 136 + // #endregion 137 + 138 + // #region validation 139 + 140 + const KNOWN_KEYS = new Set(['nsid', 'aud']); 141 + 142 + // audience must be a DID with a service ID (fragment) 143 + const AUD_RE = /^did:(web|plc):[a-zA-Z0-9._:%-]+#[a-zA-Z0-9._-]+$/; 144 + 145 + const isAtprotoAudience = (value: unknown): value is AtprotoAudience => { 146 + return typeof value === 'string' && AUD_RE.test(value); 147 + }; 148 + 149 + // #endregion 150 + 151 + // #region include scope class 152 + 153 + export class IncludeScope { 154 + constructor( 155 + readonly nsid: Nsid, 156 + readonly aud: AtprotoAudience | undefined = undefined, 157 + ) {} 158 + 159 + /** 160 + * formats this include scope as a scope string 161 + */ 162 + toString(): string { 163 + const params = new URLSearchParams(); 164 + 165 + if (this.aud !== undefined) { 166 + params.set('aud', this.aud); 167 + } 168 + 169 + return formatScopeString({ prefix: 'include', positional: this.nsid, params }); 170 + } 171 + 172 + /** 173 + * checks if this scope's NSID is a parent authority of the given NSID 174 + * used to validate that permission sets only grant permissions under their own namespace 175 + * 176 + * @param otherNsid the NSID to check against 177 + * @returns true if this scope's authority is a parent of the other NSID 178 + */ 179 + isParentAuthorityOf(otherNsid: '*' | Nsid): boolean { 180 + if (otherNsid === '*') { 181 + return false; 182 + } 183 + 184 + // extract authority (everything up to the last dot in the reversed domain) 185 + // e.g., for 'com.example.foo.auth', authority prefix is 'com.example.foo.' 186 + const groupPrefixEnd = this.nsid.lastIndexOf('.'); 187 + if (groupPrefixEnd === -1) { 188 + return false; 189 + } 190 + 191 + const authorityPrefix = this.nsid.slice(0, groupPrefixEnd + 1); 192 + return otherNsid.startsWith(authorityPrefix); 193 + } 194 + 195 + /** 196 + * expands this include scope into concrete permissions using the given permission set 197 + * 198 + * @param permissionSet the permission set definition from the lexicon 199 + * @returns expanded permissions and any rejected entries 200 + */ 201 + toPermissions(permissionSet: LexiconPermissionSet): ExpandedPermissions { 202 + const permissions: (RepoPermission | RpcPermission)[] = []; 203 + const rejected: RejectedPermission[] = []; 204 + 205 + for (const perm of permissionSet.permissions) { 206 + switch (perm.resource) { 207 + case 'repo': { 208 + const result = this.expandRepoPermission(perm); 209 + if (result instanceof RepoPermission) { 210 + permissions.push(result); 211 + } else { 212 + rejected.push(result); 213 + } 214 + break; 215 + } 216 + case 'rpc': { 217 + const result = this.expandRpcPermission(perm); 218 + if (result instanceof RpcPermission) { 219 + permissions.push(result); 220 + } else { 221 + rejected.push(result); 222 + } 223 + break; 224 + } 225 + case 'blob': 226 + // blob permissions are not allowed in permission sets 227 + rejected.push({ permission: perm, reason: 'blob_not_allowed' }); 228 + break; 229 + default: 230 + // unknown resource type 231 + rejected.push({ permission: perm as LexiconPermission, reason: 'unknown_resource' }); 232 + } 233 + } 234 + 235 + return { permissions, rejected }; 236 + } 237 + 238 + private expandRepoPermission(perm: LexiconRepoPermission): RepoPermission | RejectedPermission { 239 + // validate all collections are under our authority 240 + const validCollections: Nsid[] = []; 241 + for (const col of perm.collection) { 242 + if (!isNsid(col)) { 243 + return { permission: perm, reason: 'invalid_collection', detail: col }; 244 + } 245 + if (!this.isParentAuthorityOf(col)) { 246 + return { permission: perm, reason: 'authority_violation', detail: col }; 247 + } 248 + validCollections.push(col); 249 + } 250 + 251 + if (validCollections.length === 0) { 252 + return { permission: perm, reason: 'empty_collection' }; 253 + } 254 + 255 + // validate actions 256 + const actions = perm.action ?? (['create', 'update', 'delete'] as const); 257 + for (const action of actions) { 258 + if (action !== 'create' && action !== 'update' && action !== 'delete') { 259 + return { permission: perm, reason: 'invalid_action', detail: action }; 260 + } 261 + } 262 + 263 + return new RepoPermission( 264 + validCollections as unknown as NeRoArray<Nsid>, 265 + actions as unknown as NeRoArray<RepoAction>, 266 + ); 267 + } 268 + 269 + private expandRpcPermission(perm: LexiconRpcPermission): RpcPermission | RejectedPermission { 270 + // determine audience 271 + let aud: '*' | AtprotoAudience; 272 + 273 + if (perm.inheritAud) { 274 + // inherit from include scope 275 + if (this.aud === undefined) { 276 + // no audience to inherit, use wildcard 277 + aud = '*'; 278 + } else { 279 + aud = this.aud; 280 + } 281 + } else if (perm.aud === '*') { 282 + aud = '*'; 283 + } else if (perm.aud !== undefined) { 284 + // specific audience in permission set - not allowed (must use inheritAud or *) 285 + return { permission: perm, reason: 'specific_aud_not_allowed', detail: perm.aud }; 286 + } else { 287 + // no audience specified, use wildcard 288 + aud = '*'; 289 + } 290 + 291 + // validate all lxms are under our authority 292 + const validLxms: Nsid[] = []; 293 + for (const lxm of perm.lxm) { 294 + if (!isNsid(lxm)) { 295 + return { permission: perm, reason: 'invalid_lxm', detail: lxm }; 296 + } 297 + if (!this.isParentAuthorityOf(lxm)) { 298 + return { permission: perm, reason: 'authority_violation', detail: lxm }; 299 + } 300 + validLxms.push(lxm); 301 + } 302 + 303 + if (validLxms.length === 0) { 304 + return { permission: perm, reason: 'empty_lxm' }; 305 + } 306 + 307 + return new RpcPermission(aud, validLxms as unknown as NeRoArray<Nsid>); 308 + } 309 + 310 + /** 311 + * parses a scope string into an IncludeScope 312 + * @returns the scope or null if invalid 313 + */ 314 + static fromString(scope: string): IncludeScope | null { 315 + if (!hasScopePrefix(scope, 'include')) { 316 + return null; 317 + } 318 + return IncludeScope.fromSyntax(parseScopeString(scope)); 319 + } 320 + 321 + /** 322 + * parses a pre-parsed scope syntax into an IncludeScope 323 + * @returns the scope or null if invalid 324 + */ 325 + static fromSyntax(syntax: ScopeSyntax): IncludeScope | null { 326 + if (syntax.prefix !== 'include') { 327 + return null; 328 + } 329 + 330 + // reject unknown parameters 331 + if (hasUnknownParams(syntax, KNOWN_KEYS)) { 332 + return null; 333 + } 334 + 335 + // parse nsid (required, positional) 336 + const nsidRaw = getSingleParam(syntax, 'nsid', 'nsid'); 337 + if (nsidRaw === null || nsidRaw === undefined) { 338 + return null; 339 + } 340 + if (!isNsid(nsidRaw)) { 341 + return null; 342 + } 343 + 344 + // parse aud (optional) 345 + const audRaw = getSingleParam(syntax, 'aud'); 346 + if (audRaw === null) { 347 + return null; 348 + } 349 + 350 + let aud: AtprotoAudience | undefined; 351 + if (audRaw !== undefined) { 352 + if (!isAtprotoAudience(audRaw)) { 353 + return null; 354 + } 355 + aud = audRaw; 356 + } 357 + 358 + return new IncludeScope(nsidRaw, aud); 359 + } 360 + } 361 + 362 + // #endregion 363 + 364 + // #region permission set types 365 + 366 + /** 367 + * represents a permission set definition from a lexicon 368 + */ 369 + export interface LexiconPermissionSet { 370 + permissions: LexiconPermission[]; 371 + } 372 + 373 + export type LexiconPermission = LexiconRepoPermission | LexiconRpcPermission | LexiconBlobPermission; 374 + 375 + export interface LexiconRepoPermission { 376 + resource: 'repo'; 377 + collection: string[]; 378 + action?: ('create' | 'update' | 'delete')[]; 379 + } 380 + 381 + export interface LexiconRpcPermission { 382 + resource: 'rpc'; 383 + lxm: string[]; 384 + aud?: string; 385 + inheritAud?: boolean; 386 + } 387 + 388 + export interface LexiconBlobPermission { 389 + resource: 'blob'; 390 + accept: string[]; 391 + } 392 + 393 + // #endregion
+169
packages/oauth/scope-parser/lib/permissions/repo.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { RepoPermission } from './repo.js'; 4 + 5 + describe('RepoPermission', () => { 6 + describe('fromString', () => { 7 + it('parses single collection', () => { 8 + const perm = RepoPermission.fromString('repo:com.example.foo'); 9 + expect(perm).not.toBeNull(); 10 + expect(perm!.collection).toEqual(['com.example.foo']); 11 + expect(perm!.action).toEqual(['create', 'update', 'delete']); 12 + }); 13 + 14 + it('parses single collection with action', () => { 15 + const perm = RepoPermission.fromString('repo:com.example.foo?action=create'); 16 + expect(perm).not.toBeNull(); 17 + expect(perm!.collection).toEqual(['com.example.foo']); 18 + expect(perm!.action).toEqual(['create']); 19 + }); 20 + 21 + it('parses single collection with multiple actions', () => { 22 + const perm = RepoPermission.fromString('repo:com.example.foo?action=create&action=update'); 23 + expect(perm).not.toBeNull(); 24 + expect(perm!.collection).toEqual(['com.example.foo']); 25 + expect(perm!.action).toEqual(['create', 'update']); 26 + }); 27 + 28 + it('parses wildcard collection', () => { 29 + const perm = RepoPermission.fromString('repo:*'); 30 + expect(perm).not.toBeNull(); 31 + expect(perm!.collection).toEqual(['*']); 32 + expect(perm!.action).toEqual(['create', 'update', 'delete']); 33 + }); 34 + 35 + it('parses wildcard collection with action', () => { 36 + const perm = RepoPermission.fromString('repo:*?action=create'); 37 + expect(perm).not.toBeNull(); 38 + expect(perm!.collection).toEqual(['*']); 39 + expect(perm!.action).toEqual(['create']); 40 + }); 41 + 42 + it('parses multiple collections via query params', () => { 43 + const perm = RepoPermission.fromString('repo?collection=com.example.foo&collection=com.example.bar'); 44 + expect(perm).not.toBeNull(); 45 + // should be sorted 46 + expect(perm!.collection).toEqual(['com.example.bar', 'com.example.foo']); 47 + }); 48 + 49 + it('returns null for invalid collection', () => { 50 + expect(RepoPermission.fromString('repo:foo bar')).toBeNull(); 51 + expect(RepoPermission.fromString('repo:.foo')).toBeNull(); 52 + expect(RepoPermission.fromString('repo:bar.')).toBeNull(); 53 + expect(RepoPermission.fromString('repo:invalid')).toBeNull(); 54 + }); 55 + 56 + it('returns null for invalid action', () => { 57 + expect(RepoPermission.fromString('repo:com.example.foo?action=invalid')).toBeNull(); 58 + expect(RepoPermission.fromString('repo:*?action=*')).toBeNull(); 59 + }); 60 + 61 + it('returns null for non-repo scope', () => { 62 + expect(RepoPermission.fromString('invalid')).toBeNull(); 63 + expect(RepoPermission.fromString('scope')).toBeNull(); 64 + }); 65 + 66 + it('returns null for missing collection', () => { 67 + expect(RepoPermission.fromString('repo')).toBeNull(); 68 + expect(RepoPermission.fromString('repo?action=create')).toBeNull(); 69 + }); 70 + 71 + it('returns null for unknown params', () => { 72 + expect(RepoPermission.fromString('repo:com.example.foo?invalid=param')).toBeNull(); 73 + }); 74 + }); 75 + 76 + describe('matches', () => { 77 + it('matches exact collection and action', () => { 78 + const perm = RepoPermission.fromString('repo:com.example.foo?action=create')!; 79 + expect(perm.matches({ action: 'create', collection: 'com.example.foo' })).toBe(true); 80 + expect(perm.matches({ action: 'update', collection: 'com.example.foo' })).toBe(false); 81 + expect(perm.matches({ action: 'create', collection: 'com.example.bar' })).toBe(false); 82 + }); 83 + 84 + it('matches wildcard collection', () => { 85 + const perm = RepoPermission.fromString('repo:*?action=create')!; 86 + expect(perm.matches({ action: 'create', collection: 'com.example.foo' })).toBe(true); 87 + expect(perm.matches({ action: 'create', collection: 'com.example.bar' })).toBe(true); 88 + expect(perm.matches({ action: 'delete', collection: 'com.example.foo' })).toBe(false); 89 + }); 90 + 91 + it('matches multiple actions', () => { 92 + const perm = RepoPermission.fromString('repo:com.example.foo?action=create&action=update')!; 93 + expect(perm.matches({ action: 'create', collection: 'com.example.foo' })).toBe(true); 94 + expect(perm.matches({ action: 'update', collection: 'com.example.foo' })).toBe(true); 95 + expect(perm.matches({ action: 'delete', collection: 'com.example.foo' })).toBe(false); 96 + }); 97 + 98 + it('matches default actions (all)', () => { 99 + const perm = RepoPermission.fromString('repo:com.example.foo')!; 100 + expect(perm.matches({ action: 'create', collection: 'com.example.foo' })).toBe(true); 101 + expect(perm.matches({ action: 'update', collection: 'com.example.foo' })).toBe(true); 102 + expect(perm.matches({ action: 'delete', collection: 'com.example.foo' })).toBe(true); 103 + }); 104 + 105 + it('matches wildcard collection with all actions', () => { 106 + const perm = RepoPermission.fromString('repo:*')!; 107 + expect(perm.matches({ action: 'create', collection: 'any.collection.here' })).toBe(true); 108 + expect(perm.matches({ action: 'update', collection: 'any.collection.here' })).toBe(true); 109 + expect(perm.matches({ action: 'delete', collection: 'any.collection.here' })).toBe(true); 110 + }); 111 + }); 112 + 113 + describe('toString', () => { 114 + it('uses positional for single collection', () => { 115 + const perm = new RepoPermission(['com.example.foo'], ['create']); 116 + expect(perm.toString()).toBe('repo:com.example.foo?action=create'); 117 + }); 118 + 119 + it('omits default actions', () => { 120 + const perm = new RepoPermission(['com.example.foo'], ['create', 'update', 'delete']); 121 + expect(perm.toString()).toBe('repo:com.example.foo'); 122 + }); 123 + 124 + it('uses query params for multiple collections', () => { 125 + const perm = new RepoPermission(['com.example.foo', 'com.example.bar'], ['create']); 126 + const str = perm.toString(); 127 + expect(str).toContain('collection=com.example.bar'); 128 + expect(str).toContain('collection=com.example.foo'); 129 + expect(str).toContain('action=create'); 130 + }); 131 + 132 + it('normalizes wildcard collection', () => { 133 + const perm = RepoPermission.fromString('repo?collection=*&collection=com.example.foo')!; 134 + expect(perm.toString()).toBe('repo:*'); 135 + }); 136 + 137 + it('normalizes all actions', () => { 138 + const perm = RepoPermission.fromString('repo:*?action=create&action=update&action=delete')!; 139 + expect(perm.toString()).toBe('repo:*'); 140 + }); 141 + }); 142 + 143 + describe('normalization consistency', () => { 144 + const cases = [ 145 + ['repo:com.example.foo', 'repo:com.example.foo'], 146 + ['repo:com.example.foo?action=create', 'repo:com.example.foo?action=create'], 147 + ['repo:com.example.foo?action=create&action=update', 'repo:com.example.foo?action=create&action=update'], 148 + ['repo:*?action=create&action=update&action=delete', 'repo:*'], 149 + ['repo:com.example.foo?action=create&action=update&action=delete', 'repo:com.example.foo'], 150 + ['repo:*?action=create', 'repo:*?action=create'], 151 + ['repo:*?action=update', 'repo:*?action=update'], 152 + ['repo?collection=*&action=update', 'repo:*?action=update'], 153 + ['repo?collection=*&collection=com.example.foo&action=update', 'repo:*?action=update'], 154 + ['repo?collection=*', 'repo:*'], 155 + ['repo?collection=*&action=create&action=update&action=delete', 'repo:*'], 156 + ['repo?collection=*&collection=com.example.foo', 'repo:*'], 157 + ['repo?action=create&collection=com.example.foo', 'repo:com.example.foo?action=create'], 158 + ['repo?collection=com.example.foo&action=create&action=update&action=delete', 'repo:com.example.foo'], 159 + ]; 160 + 161 + for (const [input, expected] of cases) { 162 + it(`normalizes '${input}' to '${expected}'`, () => { 163 + const perm = RepoPermission.fromString(input); 164 + expect(perm).not.toBeNull(); 165 + expect(perm!.toString()).toBe(expected); 166 + }); 167 + } 168 + }); 169 + });
+213
packages/oauth/scope-parser/lib/permissions/repo.ts
··· 1 + /** 2 + * repository permission parsing and matching 3 + * 4 + * syntax: `repo:<collection>[?action=<action>&collection=<collection>]` 5 + * - collection: NSID or '*' for all collections 6 + * - action: 'create', 'update', 'delete' (defaults to all) 7 + */ 8 + 9 + import { isNsid, type Nsid } from '@atcute/lexicons/syntax'; 10 + 11 + import { 12 + formatScopeString, 13 + getMultiParam, 14 + hasUnknownParams, 15 + hasScopePrefix, 16 + parseScopeString, 17 + type NeRoArray, 18 + type ScopeSyntax, 19 + } from '../syntax.js'; 20 + 21 + // #region types 22 + 23 + export const REPO_ACTIONS = ['create', 'update', 'delete'] as const; 24 + export type RepoAction = (typeof REPO_ACTIONS)[number]; 25 + 26 + export type CollectionParam = '*' | Nsid; 27 + 28 + export interface RepoPermissionMatch { 29 + collection: Nsid; 30 + action: RepoAction; 31 + } 32 + 33 + // #endregion 34 + 35 + // #region validation 36 + 37 + const KNOWN_KEYS = new Set(['collection', 'action']); 38 + 39 + const isRepoAction = (value: unknown): value is RepoAction => { 40 + return value === 'create' || value === 'update' || value === 'delete'; 41 + }; 42 + 43 + const isCollectionParam = (value: unknown): value is CollectionParam => { 44 + return value === '*' || isNsid(value); 45 + }; 46 + 47 + // #endregion 48 + 49 + // #region permission class 50 + 51 + export class RepoPermission { 52 + constructor( 53 + readonly collection: NeRoArray<CollectionParam>, 54 + readonly action: NeRoArray<RepoAction>, 55 + ) {} 56 + 57 + /** 58 + * checks if this permission covers the requested access 59 + */ 60 + matches(request: RepoPermissionMatch): boolean { 61 + return ( 62 + this.action.includes(request.action) && 63 + (this.collection.includes('*') || (this.collection as readonly string[]).includes(request.collection)) 64 + ); 65 + } 66 + 67 + /** 68 + * formats this permission as a scope string 69 + */ 70 + toString(): string { 71 + const collection = normalizeCollection(this.collection); 72 + const action = normalizeAction(this.action); 73 + 74 + const params = new URLSearchParams(); 75 + 76 + // use positional for single collection 77 + let positional: string | undefined; 78 + if (collection.length === 1) { 79 + positional = collection[0]; 80 + } else { 81 + for (const c of collection) { 82 + params.append('collection', c); 83 + } 84 + } 85 + 86 + // omit action if it's the default (all actions) 87 + if (!actionsEqual(action, REPO_ACTIONS)) { 88 + for (const a of action) { 89 + params.append('action', a); 90 + } 91 + } 92 + 93 + return formatScopeString({ prefix: 'repo', positional, params }); 94 + } 95 + 96 + /** 97 + * parses a scope string into a RepoPermission 98 + * @returns the permission or null if invalid 99 + */ 100 + static fromString(scope: string): RepoPermission | null { 101 + if (!hasScopePrefix(scope, 'repo')) { 102 + return null; 103 + } 104 + return RepoPermission.fromSyntax(parseScopeString(scope)); 105 + } 106 + 107 + /** 108 + * parses a pre-parsed scope syntax into a RepoPermission 109 + * @returns the permission or null if invalid 110 + */ 111 + static fromSyntax(syntax: ScopeSyntax): RepoPermission | null { 112 + if (syntax.prefix !== 'repo') { 113 + return null; 114 + } 115 + 116 + // reject unknown parameters 117 + if (hasUnknownParams(syntax, KNOWN_KEYS)) { 118 + return null; 119 + } 120 + 121 + // parse collection (required) 122 + const collectionRaw = getMultiParam(syntax, 'collection', 'collection'); 123 + if (collectionRaw === null || collectionRaw === undefined || collectionRaw.length === 0) { 124 + return null; 125 + } 126 + 127 + // validate all collection values 128 + for (const c of collectionRaw) { 129 + if (!isCollectionParam(c)) { 130 + return null; 131 + } 132 + } 133 + const collection = normalizeCollection(collectionRaw as NeRoArray<CollectionParam>); 134 + 135 + // parse action (optional, defaults to all) 136 + const actionRaw = getMultiParam(syntax, 'action'); 137 + let action: NeRoArray<RepoAction>; 138 + 139 + if (actionRaw === null) { 140 + return null; // both positional and named 141 + } else if (actionRaw === undefined || actionRaw.length === 0) { 142 + action = [...REPO_ACTIONS]; 143 + } else { 144 + // validate all action values 145 + for (const a of actionRaw) { 146 + if (!isRepoAction(a)) { 147 + return null; 148 + } 149 + } 150 + action = normalizeAction(actionRaw as NeRoArray<RepoAction>); 151 + } 152 + 153 + return new RepoPermission(collection, action); 154 + } 155 + 156 + /** 157 + * generates the minimal scope string needed for the given access 158 + */ 159 + static scopeNeededFor(request: RepoPermissionMatch): string { 160 + return new RepoPermission([request.collection], [request.action]).toString(); 161 + } 162 + } 163 + 164 + // #endregion 165 + 166 + // #region normalization 167 + 168 + const normalizeCollection = (value: NeRoArray<CollectionParam>): NeRoArray<CollectionParam> => { 169 + // wildcard subsumes all 170 + if (value.includes('*')) { 171 + return ['*']; 172 + } 173 + 174 + if (value.length === 1) { 175 + return value; 176 + } 177 + 178 + // dedupe and sort, cast is safe because input is non-empty 179 + const sorted = [...new Set(value)].sort(); 180 + return sorted as unknown as NeRoArray<CollectionParam>; 181 + }; 182 + 183 + const normalizeAction = (value: NeRoArray<RepoAction>): NeRoArray<RepoAction> => { 184 + if (value.length === REPO_ACTIONS.length) { 185 + // check if it contains all actions 186 + const hasAll = REPO_ACTIONS.every((a) => value.includes(a)); 187 + if (hasAll) { 188 + return [...REPO_ACTIONS]; 189 + } 190 + } 191 + 192 + if (value.length === 1) { 193 + return value; 194 + } 195 + 196 + // filter to canonical order, cast is safe because input is non-empty 197 + const filtered = REPO_ACTIONS.filter((a) => value.includes(a)); 198 + return filtered as unknown as NeRoArray<RepoAction>; 199 + }; 200 + 201 + const actionsEqual = (a: readonly RepoAction[], b: readonly RepoAction[]): boolean => { 202 + if (a.length !== b.length) { 203 + return false; 204 + } 205 + for (let i = 0; i < a.length; i++) { 206 + if (a[i] !== b[i]) { 207 + return false; 208 + } 209 + } 210 + return true; 211 + }; 212 + 213 + // #endregion
+160
packages/oauth/scope-parser/lib/permissions/rpc.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { RpcPermission } from './rpc.js'; 4 + 5 + describe('RpcPermission', () => { 6 + describe('fromString', () => { 7 + it('parses single lxm with DID audience', () => { 8 + const perm = RpcPermission.fromString('rpc:com.example.service?aud=did:web:example.com%23service_id'); 9 + expect(perm).not.toBeNull(); 10 + expect(perm!.aud).toBe('did:web:example.com#service_id'); 11 + expect(perm!.lxm).toEqual(['com.example.service']); 12 + }); 13 + 14 + it('parses single lxm with wildcard audience', () => { 15 + const perm = RpcPermission.fromString('rpc:com.example.method1?aud=*'); 16 + expect(perm).not.toBeNull(); 17 + expect(perm!.aud).toBe('*'); 18 + expect(perm!.lxm).toEqual(['com.example.method1']); 19 + }); 20 + 21 + it('parses via query params', () => { 22 + const perm = RpcPermission.fromString('rpc?lxm=com.example.method1&aud=*'); 23 + expect(perm).not.toBeNull(); 24 + expect(perm!.aud).toBe('*'); 25 + expect(perm!.lxm).toEqual(['com.example.method1']); 26 + }); 27 + 28 + it('parses multiple lxm via query params', () => { 29 + const perm = RpcPermission.fromString('rpc?aud=*&lxm=com.example.method1&lxm=com.example.method2'); 30 + expect(perm).not.toBeNull(); 31 + expect(perm!.aud).toBe('*'); 32 + expect(perm!.lxm).toContain('com.example.method1'); 33 + expect(perm!.lxm).toContain('com.example.method2'); 34 + }); 35 + 36 + it('decodes # in audience', () => { 37 + const perm = RpcPermission.fromString('rpc:com.example.service?aud=did:web:example.com#service_id'); 38 + expect(perm).not.toBeNull(); 39 + expect(perm!.aud).toBe('did:web:example.com#service_id'); 40 + }); 41 + 42 + it('returns null for missing aud', () => { 43 + expect(RpcPermission.fromString('rpc:com.example.service')).toBeNull(); 44 + expect(RpcPermission.fromString('rpc?lxm=com.example.method1')).toBeNull(); 45 + }); 46 + 47 + it('returns null for missing lxm', () => { 48 + expect(RpcPermission.fromString('rpc?aud=did:web:example.com%23service_id')).toBeNull(); 49 + expect(RpcPermission.fromString('rpc:?aud=did:web:example.com%23service_id')).toBeNull(); 50 + }); 51 + 52 + it('returns null for both positional and query lxm', () => { 53 + expect( 54 + RpcPermission.fromString( 55 + 'rpc:com.example.method1?aud=did:web:example.com%23service_id&lxm=com.example.method2', 56 + ), 57 + ).toBeNull(); 58 + }); 59 + 60 + it('returns null for both wildcards', () => { 61 + expect(RpcPermission.fromString('rpc?aud=*&lxm=*')).toBeNull(); 62 + expect(RpcPermission.fromString('rpc:*?aud=*')).toBeNull(); 63 + }); 64 + 65 + it('returns null for invalid aud', () => { 66 + expect(RpcPermission.fromString('rpc:com.example.service?aud=invalid')).toBeNull(); 67 + expect(RpcPermission.fromString('rpc:foo.bar.baz?aud=did:web:example.com')).toBeNull(); // missing service ID 68 + expect(RpcPermission.fromString('rpc:foo.bar.baz?aud=did:plc:111')).toBeNull(); // missing service ID 69 + }); 70 + 71 + it('returns null for invalid lxm', () => { 72 + expect(RpcPermission.fromString('rpc:invalid?aud=*')).toBeNull(); 73 + expect(RpcPermission.fromString('rpc?lxm=invalid&aud=*')).toBeNull(); 74 + }); 75 + 76 + it('returns null for non-rpc scope', () => { 77 + expect(RpcPermission.fromString('invalid')).toBeNull(); 78 + expect(RpcPermission.fromString('repo:com.example.foo')).toBeNull(); 79 + }); 80 + 81 + it('returns null for unknown params', () => { 82 + expect( 83 + RpcPermission.fromString('rpc:com.example.service?aud=did:web:example.com%23service_id&invalid=param'), 84 + ).toBeNull(); 85 + }); 86 + }); 87 + 88 + describe('matches', () => { 89 + it('matches exact lxm and aud', () => { 90 + const perm = RpcPermission.fromString('rpc:com.example.service?aud=did:web:example.com%23service_id')!; 91 + expect(perm.matches({ lxm: 'com.example.service', aud: 'did:web:example.com#service_id' })).toBe(true); 92 + expect(perm.matches({ lxm: 'com.example.OtherService', aud: 'did:web:example.com#service_id' })).toBe(false); 93 + expect(perm.matches({ lxm: 'com.example.service', aud: 'did:example:456#service_id' })).toBe(false); 94 + }); 95 + 96 + it('matches wildcard aud', () => { 97 + const perm = RpcPermission.fromString('rpc:com.example.method1?aud=*')!; 98 + expect(perm.matches({ lxm: 'com.example.method1', aud: 'did:web:example.com#service_id' })).toBe(true); 99 + expect(perm.matches({ lxm: 'com.example.method1', aud: 'did:plc:abc123#other' })).toBe(true); 100 + }); 101 + 102 + it('matches wildcard lxm', () => { 103 + const perm = RpcPermission.fromString('rpc:*?aud=did:web:example.com%23service_id')!; 104 + expect(perm.matches({ lxm: 'com.example.method1', aud: 'did:web:example.com#service_id' })).toBe(true); 105 + expect(perm.matches({ lxm: 'com.example.anyMethod', aud: 'did:web:example.com#service_id' })).toBe(true); 106 + expect(perm.matches({ lxm: 'com.example.method1', aud: 'did:web:other.com#service_id' })).toBe(false); 107 + }); 108 + }); 109 + 110 + describe('toString', () => { 111 + it('uses positional for single lxm', () => { 112 + const perm = new RpcPermission('did:web:example.com#service_id', ['com.example.service']); 113 + expect(perm.toString()).toBe('rpc:com.example.service?aud=did:web:example.com%23service_id'); 114 + }); 115 + 116 + it('uses query params for multiple lxm', () => { 117 + const perm = new RpcPermission('did:web:example.com#service_id', [ 118 + 'com.example.method1', 119 + 'com.example.method2', 120 + ]); 121 + expect(perm.toString()).toContain('lxm=com.example.method1'); 122 + expect(perm.toString()).toContain('lxm=com.example.method2'); 123 + }); 124 + 125 + it('normalizes wildcard lxm', () => { 126 + const perm = new RpcPermission('did:web:example.com#service_id', ['*', 'com.example.method1']); 127 + expect(perm.toString()).toBe('rpc:*?aud=did:web:example.com%23service_id'); 128 + }); 129 + }); 130 + 131 + describe('normalization consistency', () => { 132 + const cases = [ 133 + [ 134 + 'rpc:com.example.service?aud=did:web:example.com%23service_id', 135 + 'rpc:com.example.service?aud=did:web:example.com%23service_id', 136 + ], 137 + [ 138 + 'rpc:com.example.service?aud=did:web:example.com#service_id', 139 + 'rpc:com.example.service?aud=did:web:example.com%23service_id', 140 + ], 141 + [ 142 + 'rpc?lxm=com.example.method1&lxm=com.example.method2&lxm=*&aud=did:web:example.com%23service_id', 143 + 'rpc:*?aud=did:web:example.com%23service_id', 144 + ], 145 + [ 146 + 'rpc?aud=did:web:example.com%23foo&lxm=com.example.service', 147 + 'rpc:com.example.service?aud=did:web:example.com%23foo', 148 + ], 149 + ['rpc:com.example.method1?&aud=*', 'rpc:com.example.method1?aud=*'], 150 + ]; 151 + 152 + for (const [input, expected] of cases) { 153 + it(`normalizes '${input}' to '${expected}'`, () => { 154 + const perm = RpcPermission.fromString(input); 155 + expect(perm).not.toBeNull(); 156 + expect(perm!.toString()).toBe(expected); 157 + }); 158 + } 159 + }); 160 + });
+180
packages/oauth/scope-parser/lib/permissions/rpc.ts
··· 1 + /** 2 + * RPC permission parsing and matching 3 + * 4 + * syntax: `rpc:<lxm>?aud=<audience>[&lxm=<lxm>]` 5 + * - lxm: lexicon method NSID or '*' for all 6 + * - aud: audience (DID with service ID) or '*' for all 7 + * 8 + * forbidden: `rpc:*?aud=*` (both wildcards not allowed) 9 + */ 10 + 11 + import { isNsid, type AtprotoAudience, type Nsid } from '@atcute/lexicons/syntax'; 12 + 13 + import { 14 + formatScopeString, 15 + getMultiParam, 16 + getSingleParam, 17 + hasUnknownParams, 18 + hasScopePrefix, 19 + parseScopeString, 20 + type NeRoArray, 21 + type ScopeSyntax, 22 + } from '../syntax.js'; 23 + 24 + // #region types 25 + 26 + export type LxmParam = '*' | Nsid; 27 + export type AudParam = '*' | AtprotoAudience; 28 + 29 + export interface RpcPermissionMatch { 30 + lxm: Nsid; 31 + aud: AtprotoAudience; 32 + } 33 + 34 + // #endregion 35 + 36 + // #region validation 37 + 38 + const KNOWN_KEYS = new Set(['lxm', 'aud']); 39 + 40 + // audience must be a DID with a service ID (fragment), or wildcard 41 + // did:web:example.com#service or did:plc:abc123#service 42 + const AUD_RE = /^did:(web|plc):[a-zA-Z0-9._:%-]+#[a-zA-Z0-9._-]+$/; 43 + 44 + const isLxmParam = (value: unknown): value is LxmParam => { 45 + return value === '*' || isNsid(value); 46 + }; 47 + 48 + const isAudParam = (value: unknown): value is AudParam => { 49 + if (value === '*') { 50 + return true; 51 + } 52 + return typeof value === 'string' && AUD_RE.test(value); 53 + }; 54 + 55 + // #endregion 56 + 57 + // #region permission class 58 + 59 + export class RpcPermission { 60 + constructor( 61 + readonly aud: AudParam, 62 + readonly lxm: NeRoArray<LxmParam>, 63 + ) {} 64 + 65 + /** 66 + * checks if this permission covers the requested access 67 + */ 68 + matches(request: RpcPermissionMatch): boolean { 69 + const audMatch = this.aud === '*' || this.aud === request.aud; 70 + const lxmMatch = this.lxm.includes('*') || (this.lxm as readonly string[]).includes(request.lxm); 71 + return audMatch && lxmMatch; 72 + } 73 + 74 + /** 75 + * formats this permission as a scope string 76 + */ 77 + toString(): string { 78 + const lxm = normalizeLxm(this.lxm); 79 + 80 + const params = new URLSearchParams(); 81 + 82 + // use positional for single lxm 83 + let positional: string | undefined; 84 + if (lxm.length === 1) { 85 + positional = lxm[0]; 86 + } else { 87 + for (const l of lxm) { 88 + params.append('lxm', l); 89 + } 90 + } 91 + 92 + params.set('aud', this.aud); 93 + 94 + return formatScopeString({ prefix: 'rpc', positional, params }); 95 + } 96 + 97 + /** 98 + * parses a scope string into an RpcPermission 99 + * @returns the permission or null if invalid 100 + */ 101 + static fromString(scope: string): RpcPermission | null { 102 + if (!hasScopePrefix(scope, 'rpc')) { 103 + return null; 104 + } 105 + return RpcPermission.fromSyntax(parseScopeString(scope)); 106 + } 107 + 108 + /** 109 + * parses a pre-parsed scope syntax into an RpcPermission 110 + * @returns the permission or null if invalid 111 + */ 112 + static fromSyntax(syntax: ScopeSyntax): RpcPermission | null { 113 + if (syntax.prefix !== 'rpc') { 114 + return null; 115 + } 116 + 117 + // reject unknown parameters 118 + if (hasUnknownParams(syntax, KNOWN_KEYS)) { 119 + return null; 120 + } 121 + 122 + // parse lxm (required) 123 + const lxmRaw = getMultiParam(syntax, 'lxm', 'lxm'); 124 + if (lxmRaw === null || lxmRaw === undefined || lxmRaw.length === 0) { 125 + return null; 126 + } 127 + 128 + // validate all lxm values 129 + for (const l of lxmRaw) { 130 + if (!isLxmParam(l)) { 131 + return null; 132 + } 133 + } 134 + const lxm = normalizeLxm(lxmRaw as NeRoArray<LxmParam>); 135 + 136 + // parse aud (required) 137 + const audRaw = getSingleParam(syntax, 'aud'); 138 + if (audRaw === null || audRaw === undefined) { 139 + return null; 140 + } 141 + if (!isAudParam(audRaw)) { 142 + return null; 143 + } 144 + 145 + // both wildcards forbidden 146 + if (audRaw === '*' && lxm.includes('*')) { 147 + return null; 148 + } 149 + 150 + return new RpcPermission(audRaw, lxm); 151 + } 152 + 153 + /** 154 + * generates the minimal scope string needed for the given access 155 + */ 156 + static scopeNeededFor(request: RpcPermissionMatch): string { 157 + return new RpcPermission(request.aud, [request.lxm]).toString(); 158 + } 159 + } 160 + 161 + // #endregion 162 + 163 + // #region normalization 164 + 165 + const normalizeLxm = (value: NeRoArray<LxmParam>): NeRoArray<LxmParam> => { 166 + // wildcard subsumes all 167 + if (value.includes('*')) { 168 + return ['*']; 169 + } 170 + 171 + if (value.length === 1) { 172 + return value; 173 + } 174 + 175 + // dedupe and sort, cast is safe because input is non-empty 176 + const sorted = [...new Set(value)].sort(); 177 + return sorted as unknown as NeRoArray<LxmParam>; 178 + }; 179 + 180 + // #endregion
+116
packages/oauth/scope-parser/lib/scope-set.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { ScopeSet } from './scope-set.js'; 4 + 5 + describe('ScopeSet', () => { 6 + describe('constructor', () => { 7 + it('accepts space-separated string', () => { 8 + const set = new ScopeSet('repo:com.example.foo account:email'); 9 + expect(set.size).toBe(2); 10 + expect(set.has('repo:com.example.foo')).toBe(true); 11 + expect(set.has('account:email')).toBe(true); 12 + }); 13 + 14 + it('accepts iterable', () => { 15 + const set = new ScopeSet(['repo:com.example.foo', 'account:email']); 16 + expect(set.size).toBe(2); 17 + }); 18 + 19 + it('handles empty string', () => { 20 + const set = new ScopeSet(''); 21 + expect(set.size).toBe(0); 22 + }); 23 + }); 24 + 25 + describe('matches', () => { 26 + it('matches account access', () => { 27 + const set = new ScopeSet('account:email'); 28 + expect(set.matches('account', { attr: 'email', action: 'read' })).toBe(true); 29 + expect(set.matches('account', { attr: 'email', action: 'manage' })).toBe(false); 30 + expect(set.matches('account', { attr: 'repo', action: 'read' })).toBe(false); 31 + }); 32 + 33 + it('matches blob access', () => { 34 + const set = new ScopeSet('blob:*/*'); 35 + expect(set.matches('blob', { mime: 'image/png' })).toBe(true); 36 + expect(set.matches('blob', { mime: 'application/json' })).toBe(true); 37 + }); 38 + 39 + it('matches blob subtype wildcard', () => { 40 + const set = new ScopeSet('blob:image/*'); 41 + expect(set.matches('blob', { mime: 'image/png' })).toBe(true); 42 + expect(set.matches('blob', { mime: 'application/json' })).toBe(false); 43 + }); 44 + 45 + it('rejects invalid blob scopes', () => { 46 + expect(new ScopeSet('blob:*').matches('blob', { mime: 'image/png' })).toBe(false); 47 + expect(new ScopeSet('blob:/image').matches('blob', { mime: 'image/png' })).toBe(false); 48 + }); 49 + 50 + it('matches repo wildcard collection', () => { 51 + const set = new ScopeSet('repo:*'); 52 + expect(set.matches('repo', { collection: 'com.example.foo', action: 'create' })).toBe(true); 53 + expect(set.matches('repo', { collection: 'com.example.foo', action: 'update' })).toBe(true); 54 + expect(set.matches('repo', { collection: 'app.bsky.feed.post', action: 'delete' })).toBe(true); 55 + }); 56 + 57 + it('matches repo wildcard with specific action', () => { 58 + const set = new ScopeSet('repo:*?action=create'); 59 + expect(set.matches('repo', { collection: 'com.example.foo', action: 'create' })).toBe(true); 60 + expect(set.matches('repo', { collection: 'app.bsky.feed.post', action: 'create' })).toBe(true); 61 + expect(set.matches('repo', { collection: 'com.example.foo', action: 'update' })).toBe(false); 62 + }); 63 + 64 + it('matches repo specific collection with action', () => { 65 + const set = new ScopeSet('repo:com.example.foo?action=create'); 66 + expect(set.matches('repo', { collection: 'com.example.foo', action: 'create' })).toBe(true); 67 + expect(set.matches('repo', { collection: 'com.example.foo', action: 'update' })).toBe(false); 68 + expect(set.matches('repo', { collection: 'app.bsky.feed.post', action: 'create' })).toBe(false); 69 + }); 70 + 71 + it('rejects invalid repo scopes', () => { 72 + const set = new ScopeSet('repo:not-a-valid-nsid'); 73 + expect(set.matches('repo', { collection: 'not-a-valid-nsid', action: 'create' })).toBe(false); 74 + }); 75 + 76 + it('rejects invalid rpc scopes', () => { 77 + const set = new ScopeSet('rpc:*?lxm=*'); 78 + expect(set.matches('rpc', { aud: 'did:web:example.com#service', lxm: 'com.example.method' })).toBe(false); 79 + }); 80 + 81 + it('matches rpc wildcard aud', () => { 82 + const set = new ScopeSet('rpc:app.bsky.feed.getFeed?aud=*'); 83 + expect(set.matches('rpc', { aud: 'did:web:example.com#service', lxm: 'app.bsky.feed.getFeed' })).toBe(true); 84 + expect(set.matches('rpc', { aud: 'did:plc:blahbla#service', lxm: 'app.bsky.feed.getFeed' })).toBe(true); 85 + expect(set.matches('rpc', { aud: 'did:web:example.com#service', lxm: 'com.example.method' })).toBe(false); 86 + }); 87 + 88 + it('matches rpc wildcard lxm', () => { 89 + const set = new ScopeSet('rpc:*?aud=did:web:example.com%23foo'); 90 + expect(set.matches('rpc', { aud: 'did:web:example.com#foo', lxm: 'com.example.method' })).toBe(true); 91 + expect(set.matches('rpc', { aud: 'did:web:example.com#foo', lxm: 'app.bsky.feed.getFeed' })).toBe(true); 92 + expect(set.matches('rpc', { aud: 'did:web:bar.com#foo', lxm: 'com.example.method' })).toBe(false); 93 + expect(set.matches('rpc', { aud: 'did:web:example.com#bar', lxm: 'com.example.method' })).toBe(false); 94 + }); 95 + 96 + it('matches rpc specific lxm and aud', () => { 97 + const set = new ScopeSet('rpc:app.bsky.feed.getFeed?aud=did:web:example.com%23foo'); 98 + expect(set.matches('rpc', { aud: 'did:web:example.com#foo', lxm: 'app.bsky.feed.getFeed' })).toBe(true); 99 + expect(set.matches('rpc', { aud: 'did:web:example.com#bar', lxm: 'com.example.method' })).toBe(false); 100 + expect(set.matches('rpc', { aud: 'did:plc:blahbla#service', lxm: 'app.bsky.feed.getFeed' })).toBe(false); 101 + }); 102 + 103 + it('matches identity access', () => { 104 + const set = new ScopeSet('identity:handle'); 105 + expect(set.matches('identity', { attr: 'handle' })).toBe(true); 106 + expect(set.matches('identity', { attr: '*' })).toBe(false); 107 + }); 108 + 109 + it('matches identity wildcard', () => { 110 + const set = new ScopeSet('identity:*'); 111 + expect(set.matches('identity', { attr: 'handle' })).toBe(true); 112 + expect(set.matches('identity', { attr: '*' })).toBe(true); 113 + }); 114 + 115 + }); 116 + });
+90
packages/oauth/scope-parser/lib/scope-set.ts
··· 1 + /** 2 + * scope set - a collection of scopes with permission checking 3 + */ 4 + 5 + import { AccountPermission, type AccountPermissionMatch } from './permissions/account.js'; 6 + import { BlobPermission, type BlobPermissionMatch } from './permissions/blob.js'; 7 + import { IdentityPermission, type IdentityPermissionMatch } from './permissions/identity.js'; 8 + import { RepoPermission, type RepoPermissionMatch } from './permissions/repo.js'; 9 + import { RpcPermission, type RpcPermissionMatch } from './permissions/rpc.js'; 10 + 11 + // #region types 12 + 13 + export type ResourceType = 'account' | 'blob' | 'identity' | 'repo' | 'rpc'; 14 + 15 + export interface ScopeMatchOptions { 16 + account: AccountPermissionMatch; 17 + blob: BlobPermissionMatch; 18 + identity: IdentityPermissionMatch; 19 + repo: RepoPermissionMatch; 20 + rpc: RpcPermissionMatch; 21 + } 22 + 23 + // #endregion 24 + 25 + // #region scope set class 26 + 27 + /** 28 + * a set of OAuth scopes with permission checking 29 + */ 30 + export class ScopeSet extends Set<string> { 31 + constructor(scopes?: Iterable<string> | string) { 32 + if (typeof scopes === 'string') { 33 + super(scopes.split(' ').filter((s) => s.length > 0)); 34 + } else { 35 + super(scopes); 36 + } 37 + } 38 + 39 + /** 40 + * checks if any scope in the set matches the requested access 41 + * @param resource the resource type to check 42 + * @param options the access being requested 43 + * @returns true if access is allowed 44 + */ 45 + matches<R extends ResourceType>(resource: R, options: ScopeMatchOptions[R]): boolean { 46 + for (const scope of this) { 47 + if (matchesPermission(scope, resource, options)) { 48 + return true; 49 + } 50 + } 51 + return false; 52 + } 53 + } 54 + 55 + // #endregion 56 + 57 + // #region matching helpers 58 + 59 + const matchesPermission = <R extends ResourceType>( 60 + scope: string, 61 + resource: R, 62 + options: ScopeMatchOptions[R], 63 + ): boolean => { 64 + switch (resource) { 65 + case 'account': { 66 + const perm = AccountPermission.fromString(scope); 67 + return perm !== null && perm.matches(options as AccountPermissionMatch); 68 + } 69 + case 'blob': { 70 + const perm = BlobPermission.fromString(scope); 71 + return perm !== null && perm.matches(options as BlobPermissionMatch); 72 + } 73 + case 'identity': { 74 + const perm = IdentityPermission.fromString(scope); 75 + return perm !== null && perm.matches(options as IdentityPermissionMatch); 76 + } 77 + case 'repo': { 78 + const perm = RepoPermission.fromString(scope); 79 + return perm !== null && perm.matches(options as RepoPermissionMatch); 80 + } 81 + case 'rpc': { 82 + const perm = RpcPermission.fromString(scope); 83 + return perm !== null && perm.matches(options as RpcPermissionMatch); 84 + } 85 + default: 86 + return false; 87 + } 88 + }; 89 + 90 + // #endregion
+137
packages/oauth/scope-parser/lib/syntax.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { formatScopeString, hasScopePrefix, parseScopeString } from './syntax.js'; 4 + 5 + describe('parseScopeString', () => { 6 + it('parses prefix only', () => { 7 + const result = parseScopeString('my-res'); 8 + expect(result.prefix).toBe('my-res'); 9 + expect(result.positional).toBeUndefined(); 10 + expect(result.params).toBeUndefined(); 11 + }); 12 + 13 + it('parses prefix with positional', () => { 14 + const result = parseScopeString('my-res:my-pos'); 15 + expect(result.prefix).toBe('my-res'); 16 + expect(result.positional).toBe('my-pos'); 17 + expect(result.params).toBeUndefined(); 18 + }); 19 + 20 + it('parses prefix with empty positional', () => { 21 + const result = parseScopeString('my-res:'); 22 + expect(result.prefix).toBe('my-res'); 23 + expect(result.positional).toBe(''); 24 + expect(result.params).toBeUndefined(); 25 + }); 26 + 27 + it('parses prefix with positional and params', () => { 28 + const result = parseScopeString('my-res:foo?x=value&y=value-y'); 29 + expect(result.prefix).toBe('my-res'); 30 + expect(result.positional).toBe('foo'); 31 + expect(result.params?.get('x')).toBe('value'); 32 + expect(result.params?.get('y')).toBe('value-y'); 33 + }); 34 + 35 + it('parses prefix with params only', () => { 36 + const result = parseScopeString('my-res?x=value&y=value-y'); 37 + expect(result.prefix).toBe('my-res'); 38 + expect(result.positional).toBeUndefined(); 39 + expect(result.params?.get('x')).toBe('value'); 40 + expect(result.params?.get('y')).toBe('value-y'); 41 + }); 42 + 43 + it('parses multiple values for same param', () => { 44 + const result = parseScopeString('my-res?x=foo&x=bar&x=baz'); 45 + expect(result.prefix).toBe('my-res'); 46 + expect(result.params?.getAll('x')).toEqual(['foo', 'bar', 'baz']); 47 + }); 48 + 49 + it('handles colons in param values (DID)', () => { 50 + const result = parseScopeString('rpc:foo.bar?aud=did:foo:bar?lxm=bar.baz'); 51 + expect(result.prefix).toBe('rpc'); 52 + expect(result.positional).toBe('foo.bar'); 53 + expect(result.params?.get('aud')).toBe('did:foo:bar?lxm=bar.baz'); 54 + }); 55 + 56 + it('decodes URL-encoded positional', () => { 57 + const result = parseScopeString('my-res:my%20pos'); 58 + expect(result.positional).toBe('my pos'); 59 + }); 60 + 61 + it('decodes URL-encoded param values', () => { 62 + const result = parseScopeString('my-res?x=my%20value'); 63 + expect(result.params?.get('x')).toBe('my value'); 64 + }); 65 + 66 + it('allows colon in positional', () => { 67 + const result = parseScopeString('my-res:my:pos'); 68 + expect(result.positional).toBe('my:pos'); 69 + }); 70 + }); 71 + 72 + describe('hasScopePrefix', () => { 73 + it('matches exact prefix', () => { 74 + expect(hasScopePrefix('prefix', 'prefix')).toBe(true); 75 + }); 76 + 77 + it('matches prefix with positional', () => { 78 + expect(hasScopePrefix('prefix:positional', 'prefix')).toBe(true); 79 + }); 80 + 81 + it('matches prefix with params', () => { 82 + expect(hasScopePrefix('prefix?param=value', 'prefix')).toBe(true); 83 + }); 84 + 85 + it('does not match different prefix', () => { 86 + expect(hasScopePrefix('prefix', 'differentResource')).toBe(false); 87 + }); 88 + 89 + it('does not match different prefix with positional', () => { 90 + expect(hasScopePrefix('differentResource:positional', 'prefix')).toBe(false); 91 + }); 92 + 93 + it('does not match partial prefix', () => { 94 + expect(hasScopePrefix('prefix', 'prefi')).toBe(false); 95 + expect(hasScopePrefix('prefix:pos', 'prefi')).toBe(false); 96 + expect(hasScopePrefix('prefix?param=value', 'prefi')).toBe(false); 97 + }); 98 + 99 + it('does not match suffix', () => { 100 + expect(hasScopePrefix('prefix', 'fix')).toBe(false); 101 + expect(hasScopePrefix('prefix:pos', 'fix')).toBe(false); 102 + expect(hasScopePrefix('prefix?param=value', 'fix')).toBe(false); 103 + }); 104 + }); 105 + 106 + describe('formatScopeString', () => { 107 + it('formats prefix only', () => { 108 + expect(formatScopeString({ prefix: 'repo' })).toBe('repo'); 109 + }); 110 + 111 + it('formats prefix with positional', () => { 112 + expect(formatScopeString({ prefix: 'repo', positional: 'com.example.foo' })).toBe('repo:com.example.foo'); 113 + }); 114 + 115 + it('formats prefix with params', () => { 116 + const params = new URLSearchParams(); 117 + params.set('action', 'create'); 118 + expect(formatScopeString({ prefix: 'repo', params })).toBe('repo?action=create'); 119 + }); 120 + 121 + it('formats prefix with positional and params', () => { 122 + const params = new URLSearchParams(); 123 + params.set('action', 'create'); 124 + expect(formatScopeString({ prefix: 'repo', positional: 'com.example.foo', params })).toBe( 125 + 'repo:com.example.foo?action=create', 126 + ); 127 + }); 128 + 129 + it('normalizes URL encoding', () => { 130 + const params = new URLSearchParams(); 131 + params.set('aud', 'did:web:example.com#service'); 132 + // # should stay encoded, but : should be decoded 133 + expect(formatScopeString({ prefix: 'rpc', positional: 'foo.bar', params })).toBe( 134 + 'rpc:foo.bar?aud=did:web:example.com%23service', 135 + ); 136 + }); 137 + });
+237
packages/oauth/scope-parser/lib/syntax.ts
··· 1 + /** 2 + * scope string syntax parsing 3 + * 4 + * parses scope strings in the format: `<prefix>[:<positional>][?<params>]` 5 + * examples: 6 + * - `repo:com.example.foo` 7 + * - `rpc:app.bsky.feed.getFeed?aud=*` 8 + * - `blob?accept=image/png&accept=image/jpeg` 9 + */ 10 + 11 + // #region types 12 + 13 + /** non-empty readonly array */ 14 + export type NeRoArray<T> = readonly [T, ...T[]]; 15 + 16 + /** parsed scope syntax */ 17 + export interface ScopeSyntax { 18 + readonly prefix: string; 19 + readonly positional: string | undefined; 20 + readonly params: URLSearchParams | undefined; 21 + } 22 + 23 + // #endregion 24 + 25 + // #region parsing 26 + 27 + /** 28 + * parses a scope string into its components 29 + * @param scope the scope string to parse 30 + * @returns parsed scope syntax 31 + */ 32 + export const parseScopeString = (scope: string): ScopeSyntax => { 33 + const paramIdx = scope.indexOf('?'); 34 + const colonIdx = scope.indexOf(':'); 35 + const prefixEnd = minIdx(paramIdx, colonIdx); 36 + 37 + if (prefixEnd === -1) { 38 + return { prefix: scope, positional: undefined, params: undefined }; 39 + } 40 + 41 + const prefix = scope.slice(0, prefixEnd); 42 + 43 + // parse positional: appears after : but before ? 44 + let positional: string | undefined; 45 + if (colonIdx !== -1) { 46 + if (paramIdx === -1) { 47 + positional = decodeURIComponent(scope.slice(colonIdx + 1)); 48 + } else if (colonIdx < paramIdx) { 49 + positional = decodeURIComponent(scope.slice(colonIdx + 1, paramIdx)); 50 + } 51 + } 52 + 53 + // parse query string 54 + const params = 55 + paramIdx !== -1 && paramIdx < scope.length - 1 ? new URLSearchParams(scope.slice(paramIdx + 1)) : undefined; 56 + 57 + return { prefix, positional, params }; 58 + }; 59 + 60 + /** 61 + * checks if a scope string starts with a given prefix 62 + * @param scope the scope string 63 + * @param prefix the prefix to check 64 + * @returns true if scope has the given prefix 65 + */ 66 + export const hasScopePrefix = (scope: string, prefix: string): boolean => { 67 + if (!scope.startsWith(prefix)) { 68 + return false; 69 + } 70 + 71 + const len = prefix.length; 72 + if (scope.length === len) { 73 + return true; 74 + } 75 + 76 + const char = scope.charCodeAt(len); 77 + // must be followed by : or ? 78 + return char === 0x3a /* : */ || char === 0x3f /* ? */; 79 + }; 80 + 81 + // #endregion 82 + 83 + // #region formatting 84 + 85 + // characters that should remain unencoded in scope strings 86 + const NORMALIZABLE_CHARS: Record<string, string> = { 87 + '%3A': ':', 88 + '%3a': ':', 89 + '%2F': '/', 90 + '%2f': '/', 91 + '%2B': '+', 92 + '%2b': '+', 93 + '%2C': ',', 94 + '%2c': ',', 95 + '%40': '@', 96 + }; 97 + 98 + /** 99 + * normalizes URL encoding for scope strings 100 + * keeps certain characters unencoded for readability while ensuring # stays encoded 101 + */ 102 + const normalizeEncoding = (value: string): string => { 103 + let end = value.length - 2; 104 + 105 + for (let i = 0; i < end; i++) { 106 + if (value.charCodeAt(i) === 0x25 /* % */) { 107 + const encoded = value.slice(i, i + 3); 108 + const normalized = NORMALIZABLE_CHARS[encoded]; 109 + 110 + if (normalized) { 111 + value = value.slice(0, i) + normalized + value.slice(i + 3); 112 + end -= 2; 113 + } 114 + } 115 + } 116 + 117 + return value; 118 + }; 119 + 120 + export interface FormatScopeOptions { 121 + /** the scope prefix (e.g., 'repo', 'rpc') */ 122 + prefix: string; 123 + /** optional positional value */ 124 + positional?: string; 125 + /** optional query parameters */ 126 + params?: URLSearchParams; 127 + } 128 + 129 + /** 130 + * formats a scope string from its components 131 + * @param options the components to format 132 + * @returns formatted scope string 133 + */ 134 + export const formatScopeString = (options: FormatScopeOptions): string => { 135 + const { prefix, positional, params } = options; 136 + 137 + let scope = prefix; 138 + 139 + if (positional !== undefined) { 140 + scope += ':' + normalizeEncoding(encodeURIComponent(positional)); 141 + } 142 + 143 + if (params && params.size > 0) { 144 + scope += '?' + normalizeEncoding(params.toString()); 145 + } 146 + 147 + return scope; 148 + }; 149 + 150 + // #endregion 151 + 152 + // #region helpers 153 + 154 + const minIdx = (a: number, b: number): number => { 155 + if (a === -1) { 156 + return b; 157 + } 158 + if (b === -1) { 159 + return a; 160 + } 161 + return Math.min(a, b); 162 + }; 163 + 164 + /** 165 + * gets a single value from parsed scope params 166 + * @returns the value, undefined if not present, or null if multiple values exist 167 + */ 168 + export const getSingleParam = ( 169 + syntax: ScopeSyntax, 170 + key: string, 171 + positionalKey?: string, 172 + ): string | null | undefined => { 173 + // check positional first 174 + if (key === positionalKey && syntax.positional !== undefined) { 175 + // can't have both positional and named param 176 + if (syntax.params?.has(key)) { 177 + return null; 178 + } 179 + return syntax.positional; 180 + } 181 + 182 + if (!syntax.params?.has(key)) { 183 + return undefined; 184 + } 185 + 186 + const values = syntax.params.getAll(key); 187 + if (values.length > 1) { 188 + return null; 189 + } 190 + return values[0]; 191 + }; 192 + 193 + /** 194 + * gets multiple values from parsed scope params 195 + * @returns array of values, undefined if not present 196 + */ 197 + export const getMultiParam = ( 198 + syntax: ScopeSyntax, 199 + key: string, 200 + positionalKey?: string, 201 + ): readonly string[] | null | undefined => { 202 + // check positional first 203 + if (key === positionalKey && syntax.positional !== undefined) { 204 + // can't have both positional and named param 205 + if (syntax.params?.has(key)) { 206 + return null; 207 + } 208 + return [syntax.positional]; 209 + } 210 + 211 + if (!syntax.params?.has(key)) { 212 + return undefined; 213 + } 214 + 215 + return syntax.params.getAll(key); 216 + }; 217 + 218 + /** 219 + * checks if parsed scope has any unknown parameters 220 + * @param syntax the parsed scope 221 + * @param knownKeys set of known parameter keys 222 + * @returns true if there are unknown parameters 223 + */ 224 + export const hasUnknownParams = (syntax: ScopeSyntax, knownKeys: ReadonlySet<string>): boolean => { 225 + if (!syntax.params) { 226 + return false; 227 + } 228 + 229 + for (const key of syntax.params.keys()) { 230 + if (!knownKeys.has(key)) { 231 + return true; 232 + } 233 + } 234 + return false; 235 + }; 236 + 237 + // #endregion
+35
packages/oauth/scope-parser/package.json
··· 1 + { 2 + "name": "@atcute/oauth-scope-parser", 3 + "version": "0.1.0", 4 + "description": "OAuth scope parsing and matching for AT Protocol", 5 + "license": "0BSD", 6 + "repository": { 7 + "url": "https://github.com/mary-ext/atcute", 8 + "directory": "packages/oauth/scope-parser" 9 + }, 10 + "files": [ 11 + "dist/", 12 + "lib/", 13 + "!lib/**/*.bench.ts", 14 + "!lib/**/*.test.ts" 15 + ], 16 + "type": "module", 17 + "sideEffects": false, 18 + "exports": { 19 + ".": "./dist/index.js" 20 + }, 21 + "publishConfig": { 22 + "access": "public" 23 + }, 24 + "scripts": { 25 + "build": "tsgo --project tsconfig.build.json", 26 + "test": "vitest", 27 + "prepublish": "rm -rf dist; pnpm run build" 28 + }, 29 + "dependencies": { 30 + "@atcute/lexicons": "workspace:^" 31 + }, 32 + "devDependencies": { 33 + "vitest": "^4.0.16" 34 + } 35 + }
+4
packages/oauth/scope-parser/tsconfig.build.json
··· 1 + { 2 + "extends": "./tsconfig.json", 3 + "exclude": ["lib/**/*.test.ts", "lib/**/*.bench.ts"] 4 + }
+25
packages/oauth/scope-parser/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "types": [], 4 + "rootDir": "lib/", 5 + "outDir": "dist/", 6 + "esModuleInterop": true, 7 + "skipLibCheck": true, 8 + "target": "ESNext", 9 + "allowJs": true, 10 + "resolveJsonModule": true, 11 + "moduleDetection": "force", 12 + "isolatedModules": true, 13 + "verbatimModuleSyntax": true, 14 + "strict": true, 15 + "noImplicitOverride": true, 16 + "noUnusedLocals": true, 17 + "noUnusedParameters": true, 18 + "noFallthroughCasesInSwitch": true, 19 + "module": "NodeNext", 20 + "sourceMap": true, 21 + "declaration": true, 22 + "declarationMap": true 23 + }, 24 + "include": ["lib"] 25 + }
+10
pnpm-lock.yaml
··· 993 993 specifier: latest 994 994 version: 1.3.6 995 995 996 + packages/oauth/scope-parser: 997 + dependencies: 998 + '@atcute/lexicons': 999 + specifier: workspace:^ 1000 + version: link:../../lexicons/lexicons 1001 + devDependencies: 1002 + vitest: 1003 + specifier: ^4.0.16 1004 + version: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0) 1005 + 996 1006 packages/oauth/types: 997 1007 dependencies: 998 1008 '@atcute/identity':