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

Compare changes

Choose any two refs to compare.

+3439 -6
+8 -5
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> 83 - 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) 82 + ``` 83 + npx @oomfware/cgr ask [options] <repo>[#branch] <question> 84 + 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':