@atproto/oauth-scopes 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE.txt +7 -0
  3. package/dist/index.d.ts +16 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +32 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/lib/did.d.ts +3 -0
  8. package/dist/lib/did.d.ts.map +1 -0
  9. package/dist/lib/did.js +6 -0
  10. package/dist/lib/did.js.map +1 -0
  11. package/dist/lib/mime.d.ts +7 -0
  12. package/dist/lib/mime.d.ts.map +1 -0
  13. package/dist/lib/mime.js +65 -0
  14. package/dist/lib/mime.js.map +1 -0
  15. package/dist/lib/nsid.d.ts +3 -0
  16. package/dist/lib/nsid.d.ts.map +1 -0
  17. package/dist/lib/nsid.js +9 -0
  18. package/dist/lib/nsid.js.map +1 -0
  19. package/dist/lib/util.d.ts +3 -0
  20. package/dist/lib/util.d.ts.map +1 -0
  21. package/dist/lib/util.js +24 -0
  22. package/dist/lib/util.js.map +1 -0
  23. package/dist/parser.d.ts +31 -0
  24. package/dist/parser.d.ts.map +1 -0
  25. package/dist/parser.js +118 -0
  26. package/dist/parser.js.map +1 -0
  27. package/dist/permission-set-transition.d.ts +15 -0
  28. package/dist/permission-set-transition.d.ts.map +1 -0
  29. package/dist/permission-set-transition.js +52 -0
  30. package/dist/permission-set-transition.js.map +1 -0
  31. package/dist/permission-set.d.ts +22 -0
  32. package/dist/permission-set.d.ts.map +1 -0
  33. package/dist/permission-set.js +68 -0
  34. package/dist/permission-set.js.map +1 -0
  35. package/dist/resources/account-scope.d.ts +35 -0
  36. package/dist/resources/account-scope.d.ts.map +1 -0
  37. package/dist/resources/account-scope.js +60 -0
  38. package/dist/resources/account-scope.js.map +1 -0
  39. package/dist/resources/blob-scope.d.ts +25 -0
  40. package/dist/resources/blob-scope.d.ts.map +1 -0
  41. package/dist/resources/blob-scope.js +74 -0
  42. package/dist/resources/blob-scope.js.map +1 -0
  43. package/dist/resources/identity-scope.d.ts +25 -0
  44. package/dist/resources/identity-scope.d.ts.map +1 -0
  45. package/dist/resources/identity-scope.js +46 -0
  46. package/dist/resources/identity-scope.js.map +1 -0
  47. package/dist/resources/repo-scope.d.ts +37 -0
  48. package/dist/resources/repo-scope.d.ts.map +1 -0
  49. package/dist/resources/repo-scope.js +92 -0
  50. package/dist/resources/repo-scope.js.map +1 -0
  51. package/dist/resources/rpc-scope.d.ts +31 -0
  52. package/dist/resources/rpc-scope.d.ts.map +1 -0
  53. package/dist/resources/rpc-scope.js +74 -0
  54. package/dist/resources/rpc-scope.js.map +1 -0
  55. package/dist/scope-missing-error.d.ts +9 -0
  56. package/dist/scope-missing-error.d.ts.map +1 -0
  57. package/dist/scope-missing-error.js +39 -0
  58. package/dist/scope-missing-error.js.map +1 -0
  59. package/dist/scopes-set.d.ts +21 -0
  60. package/dist/scopes-set.d.ts.map +1 -0
  61. package/dist/scopes-set.js +55 -0
  62. package/dist/scopes-set.js.map +1 -0
  63. package/dist/syntax.d.ts +76 -0
  64. package/dist/syntax.d.ts.map +1 -0
  65. package/dist/syntax.js +249 -0
  66. package/dist/syntax.js.map +1 -0
  67. package/dist/utilities.d.ts +17 -0
  68. package/dist/utilities.d.ts.map +1 -0
  69. package/dist/utilities.js +108 -0
  70. package/dist/utilities.js.map +1 -0
  71. package/jest.config.js +5 -0
  72. package/package.json +36 -0
  73. package/src/index.ts +17 -0
  74. package/src/lib/did.ts +3 -0
  75. package/src/lib/mime.test.ts +98 -0
  76. package/src/lib/mime.ts +70 -0
  77. package/src/lib/nsid.ts +6 -0
  78. package/src/lib/util.ts +19 -0
  79. package/src/parser.ts +150 -0
  80. package/src/permission-set-transition.test.ts +109 -0
  81. package/src/permission-set-transition.ts +67 -0
  82. package/src/permission-set.test.ts +225 -0
  83. package/src/permission-set.ts +78 -0
  84. package/src/resources/account-scope.test.ts +175 -0
  85. package/src/resources/account-scope.ts +66 -0
  86. package/src/resources/blob-scope.test.ts +118 -0
  87. package/src/resources/blob-scope.ts +86 -0
  88. package/src/resources/identity-scope.test.ts +80 -0
  89. package/src/resources/identity-scope.ts +49 -0
  90. package/src/resources/repo-scope.test.ts +255 -0
  91. package/src/resources/repo-scope.ts +101 -0
  92. package/src/resources/rpc-scope.test.ts +280 -0
  93. package/src/resources/rpc-scope.ts +77 -0
  94. package/src/scope-missing-error.ts +15 -0
  95. package/src/scopes-set.test.ts +47 -0
  96. package/src/scopes-set.ts +60 -0
  97. package/src/syntax.test.ts +203 -0
  98. package/src/syntax.ts +325 -0
  99. package/src/utilities.ts +109 -0
  100. package/tsconfig.build.json +9 -0
  101. package/tsconfig.build.tsbuildinfo +1 -0
  102. package/tsconfig.json +7 -0
  103. package/tsconfig.tests.json +7 -0
  104. package/tsconfig.tests.tsbuildinfo +1 -0
@@ -0,0 +1,78 @@
1
+ import { AccountScope, AccountScopeMatch } from './resources/account-scope.js'
2
+ import { BlobScope, BlobScopeMatch } from './resources/blob-scope.js'
3
+ import {
4
+ IdentityScope,
5
+ IdentityScopeMatch,
6
+ } from './resources/identity-scope.js'
7
+ import { RepoScope, RepoScopeMatch } from './resources/repo-scope.js'
8
+ import { RpcScope, RpcScopeMatch } from './resources/rpc-scope.js'
9
+ import { ScopeMissingError } from './scope-missing-error.js'
10
+ import { ScopesSet } from './scopes-set.js'
11
+
12
+ export type {
13
+ AccountScopeMatch,
14
+ BlobScopeMatch,
15
+ IdentityScopeMatch,
16
+ RepoScopeMatch,
17
+ RpcScopeMatch,
18
+ }
19
+
20
+ export class PermissionSet {
21
+ public readonly scopes: ScopesSet
22
+
23
+ constructor(scopes?: null | string | Iterable<string>) {
24
+ this.scopes = new ScopesSet(
25
+ typeof scopes === 'string' ? scopes.split(' ') : scopes,
26
+ )
27
+ }
28
+
29
+ public allowsAccount(options: AccountScopeMatch): boolean {
30
+ return this.scopes.matches('account', options)
31
+ }
32
+ public assertAccount(options: AccountScopeMatch): void {
33
+ if (!this.allowsAccount(options)) {
34
+ const scope = AccountScope.scopeNeededFor(options)
35
+ throw new ScopeMissingError(scope)
36
+ }
37
+ }
38
+
39
+ public allowsIdentity(options: IdentityScopeMatch): boolean {
40
+ return this.scopes.matches('identity', options)
41
+ }
42
+ public assertIdentity(options: IdentityScopeMatch): void {
43
+ if (!this.allowsIdentity(options)) {
44
+ const scope = IdentityScope.scopeNeededFor(options)
45
+ throw new ScopeMissingError(scope)
46
+ }
47
+ }
48
+
49
+ public allowsBlob(options: BlobScopeMatch): boolean {
50
+ return this.scopes.matches('blob', options)
51
+ }
52
+ public assertBlob(options: BlobScopeMatch): void {
53
+ if (!this.allowsBlob(options)) {
54
+ const scope = BlobScope.scopeNeededFor(options)
55
+ throw new ScopeMissingError(scope)
56
+ }
57
+ }
58
+
59
+ public allowsRepo(options: RepoScopeMatch): boolean {
60
+ return this.scopes.matches('repo', options)
61
+ }
62
+ public assertRepo(options: RepoScopeMatch): void {
63
+ if (!this.allowsRepo(options)) {
64
+ const scope = RepoScope.scopeNeededFor(options)
65
+ throw new ScopeMissingError(scope)
66
+ }
67
+ }
68
+
69
+ public allowsRpc(options: RpcScopeMatch): boolean {
70
+ return this.scopes.matches('rpc', options)
71
+ }
72
+ public assertRpc(options: RpcScopeMatch): void {
73
+ if (!this.allowsRpc(options)) {
74
+ const scope = RpcScope.scopeNeededFor(options)
75
+ throw new ScopeMissingError(scope)
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,175 @@
1
+ import { AccountScope } from './account-scope.js'
2
+
3
+ describe('AccountScope', () => {
4
+ describe('static', () => {
5
+ describe('fromString', () => {
6
+ it('should parse valid scope strings', () => {
7
+ const scope1 = AccountScope.fromString('account:email?action=read')
8
+ expect(scope1).not.toBeNull()
9
+ expect(scope1!.attr).toBe('email')
10
+ expect(scope1!.action).toBe('read')
11
+
12
+ const scope2 = AccountScope.fromString('account:repo?action=manage')
13
+ expect(scope2).not.toBeNull()
14
+ expect(scope2!.attr).toBe('repo')
15
+ expect(scope2!.action).toBe('manage')
16
+ })
17
+
18
+ it('should parse scope without action (defaults to read)', () => {
19
+ const scope = AccountScope.fromString('account:status')
20
+ expect(scope).not.toBeNull()
21
+ expect(scope!.attr).toBe('status')
22
+ expect(scope!.action).toBe('read')
23
+ })
24
+
25
+ it('should reject invalid attribute names', () => {
26
+ const scope = AccountScope.fromString('account:invalid')
27
+ expect(scope).toBeNull()
28
+ })
29
+
30
+ it('should reject invalid action names', () => {
31
+ const scope = AccountScope.fromString('account:email?action=invalid')
32
+ expect(scope).toBeNull()
33
+ })
34
+
35
+ it('should reject malformed scope strings', () => {
36
+ expect(AccountScope.fromString('invalid:email')).toBeNull()
37
+ expect(AccountScope.fromString('account')).toBeNull()
38
+ expect(AccountScope.fromString('')).toBeNull()
39
+ expect(AccountScope.fromString('account:')).toBeNull()
40
+ })
41
+ })
42
+
43
+ describe('scopeNeededFor', () => {
44
+ it('should return correct scope string for read actions', () => {
45
+ expect(
46
+ AccountScope.scopeNeededFor({ attr: 'email', action: 'read' }),
47
+ ).toBe('account:email')
48
+ expect(
49
+ AccountScope.scopeNeededFor({ attr: 'repo', action: 'read' }),
50
+ ).toBe('account:repo')
51
+ expect(
52
+ AccountScope.scopeNeededFor({ attr: 'status', action: 'read' }),
53
+ ).toBe('account:status')
54
+ })
55
+
56
+ it('should return correct scope string for manage actions', () => {
57
+ expect(
58
+ AccountScope.scopeNeededFor({ attr: 'email', action: 'manage' }),
59
+ ).toBe('account:email?action=manage')
60
+ expect(
61
+ AccountScope.scopeNeededFor({ attr: 'repo', action: 'manage' }),
62
+ ).toBe('account:repo?action=manage')
63
+ expect(
64
+ AccountScope.scopeNeededFor({
65
+ attr: 'status',
66
+ action: 'manage',
67
+ }),
68
+ ).toBe('account:status?action=manage')
69
+ })
70
+ })
71
+ })
72
+
73
+ describe('instance', () => {
74
+ describe('matches', () => {
75
+ it('should match read action', () => {
76
+ const scope = AccountScope.fromString('account:email?action=read')
77
+ expect(scope).not.toBeNull()
78
+ expect(scope!.matches({ attr: 'email', action: 'read' })).toBe(true)
79
+ })
80
+
81
+ it('should match manage action', () => {
82
+ const scope = AccountScope.fromString('account:repo?action=manage')
83
+ expect(scope).not.toBeNull()
84
+ expect(scope!.matches({ attr: 'repo', action: 'manage' })).toBe(true)
85
+ })
86
+
87
+ it('should not match unspecified action', () => {
88
+ const scope = AccountScope.fromString('account:email?action=read')
89
+ expect(scope).not.toBeNull()
90
+ expect(scope!.matches({ attr: 'email', action: 'manage' })).toBe(false)
91
+ })
92
+
93
+ it('should not match different attribute', () => {
94
+ const scope = AccountScope.fromString('account:email?action=read')
95
+ expect(scope).not.toBeNull()
96
+ expect(scope!.matches({ attr: 'repo', action: 'read' })).toBe(false)
97
+ })
98
+
99
+ it('should default to "read" action', () => {
100
+ const scope = AccountScope.fromString('account:email')
101
+ expect(scope).not.toBeNull()
102
+ expect(scope!.matches({ attr: 'email', action: 'read' })).toBe(true)
103
+ expect(scope!.matches({ attr: 'email', action: 'manage' })).toBe(false)
104
+ })
105
+
106
+ it('should work with all valid attributes', () => {
107
+ const emailScope = AccountScope.fromString('account:email?action=read')
108
+ const repoScope = AccountScope.fromString('account:repo?action=manage')
109
+ const statusScope = AccountScope.fromString(
110
+ 'account:status?action=read',
111
+ )
112
+
113
+ expect(emailScope).not.toBeNull()
114
+ expect(repoScope).not.toBeNull()
115
+ expect(statusScope).not.toBeNull()
116
+
117
+ expect(emailScope!.matches({ attr: 'email', action: 'read' })).toBe(
118
+ true,
119
+ )
120
+ expect(repoScope!.matches({ attr: 'repo', action: 'manage' })).toBe(
121
+ true,
122
+ )
123
+ expect(statusScope!.matches({ attr: 'status', action: 'read' })).toBe(
124
+ true,
125
+ )
126
+ })
127
+
128
+ it('should allow read when "manage" action is specified', () => {
129
+ const scope = AccountScope.fromString('account:email?action=manage')
130
+ expect(scope).not.toBeNull()
131
+ expect(scope!.matches({ attr: 'email', action: 'read' })).toBe(true)
132
+ })
133
+ })
134
+
135
+ describe('toString', () => {
136
+ it('should format scope with explicit action', () => {
137
+ const scope = new AccountScope('email', 'manage')
138
+ expect(scope.toString()).toBe('account:email?action=manage')
139
+ })
140
+
141
+ it('should format scope with default action', () => {
142
+ const scope = new AccountScope('repo', 'read')
143
+ expect(scope.toString()).toBe('account:repo')
144
+ })
145
+
146
+ it('should format all attributes correctly', () => {
147
+ expect(new AccountScope('email', 'read').toString()).toBe(
148
+ 'account:email',
149
+ )
150
+ expect(new AccountScope('repo', 'read').toString()).toBe('account:repo')
151
+ expect(new AccountScope('status', 'read').toString()).toBe(
152
+ 'account:status',
153
+ )
154
+ expect(new AccountScope('email', 'manage').toString()).toBe(
155
+ 'account:email?action=manage',
156
+ )
157
+ })
158
+ })
159
+ })
160
+
161
+ it('should maintain consistency between toString and fromString', () => {
162
+ const testCases = [
163
+ 'account:email',
164
+ 'account:email?action=manage',
165
+ 'account:repo',
166
+ 'account:repo?action=manage',
167
+ 'account:status',
168
+ 'account:status?action=manage',
169
+ ]
170
+
171
+ for (const scope of testCases) {
172
+ expect(AccountScope.fromString(scope)?.toString()).toBe(scope)
173
+ }
174
+ })
175
+ })
@@ -0,0 +1,66 @@
1
+ import { Parser, knownValuesValidator } from '../parser.js'
2
+ import { ResourceSyntax, isScopeForResource } from '../syntax.js'
3
+
4
+ const ACCOUNT_ATTRIBUTES = Object.freeze(['email', 'repo', 'status'] as const)
5
+ export type AccountAttribute = (typeof ACCOUNT_ATTRIBUTES)[number]
6
+
7
+ const ACCOUNT_ACTIONS = Object.freeze(['read', 'manage'] as const)
8
+ export type AccountAction = (typeof ACCOUNT_ACTIONS)[number]
9
+
10
+ export const accountParser = new Parser(
11
+ 'account',
12
+ {
13
+ attr: {
14
+ multiple: false,
15
+ required: true,
16
+ validate: knownValuesValidator(ACCOUNT_ATTRIBUTES),
17
+ },
18
+ action: {
19
+ multiple: false,
20
+ required: false,
21
+ validate: knownValuesValidator(ACCOUNT_ACTIONS),
22
+ default: 'read' as const,
23
+ },
24
+ },
25
+ 'attr',
26
+ )
27
+
28
+ export type AccountScopeMatch = {
29
+ attr: AccountAttribute
30
+ action: AccountAction
31
+ }
32
+
33
+ export class AccountScope {
34
+ constructor(
35
+ public readonly attr: AccountAttribute,
36
+ public readonly action: AccountAction,
37
+ ) {}
38
+
39
+ matches(options: AccountScopeMatch): boolean {
40
+ return (
41
+ this.attr === options.attr &&
42
+ (this.action === 'manage' || this.action === options.action)
43
+ )
44
+ }
45
+
46
+ toString() {
47
+ return accountParser.format(this)
48
+ }
49
+
50
+ static fromString(scope: string) {
51
+ if (!isScopeForResource(scope, 'account')) return null
52
+ const syntax = ResourceSyntax.fromString(scope)
53
+ return this.fromSyntax(syntax)
54
+ }
55
+
56
+ static fromSyntax(syntax: ResourceSyntax) {
57
+ const result = accountParser.parse(syntax)
58
+ if (!result) return null
59
+
60
+ return new AccountScope(result.attr, result.action)
61
+ }
62
+
63
+ static scopeNeededFor(options: AccountScopeMatch): string {
64
+ return accountParser.format(options)
65
+ }
66
+ }
@@ -0,0 +1,118 @@
1
+ import { BlobScope } from './blob-scope.js'
2
+
3
+ describe('BlobScope', () => {
4
+ describe('static', () => {
5
+ describe('fromString', () => {
6
+ it('should parse positional scope', () => {
7
+ const scope = BlobScope.fromString('blob:image/png')
8
+ expect(scope).not.toBeNull()
9
+ expect(scope!.accept).toEqual(['image/png'])
10
+ })
11
+
12
+ it('should parse valid blob scope with multiple accept parameters', () => {
13
+ const scope = BlobScope.fromString(
14
+ 'blob?accept=image/png&accept=image/jpeg',
15
+ )
16
+ expect(scope).not.toBeNull()
17
+ expect(scope!.accept).toEqual(['image/png', 'image/jpeg'])
18
+ })
19
+
20
+ it('should reject blob scope without accept', () => {
21
+ const scope = BlobScope.fromString('blob')
22
+ expect(scope).toBeNull()
23
+ })
24
+
25
+ for (const invalid of [
26
+ 'invalid',
27
+ 'scope',
28
+ 'blob:invalid',
29
+ 'blob?accept=invalid-mime',
30
+ 'blob?accept=invalid',
31
+ 'blob:*/**',
32
+ 'blob:*/png',
33
+ ]) {
34
+ it(`should return null for invalid rpc scope: ${invalid}`, () => {
35
+ expect(BlobScope.fromString(invalid)).toBeNull()
36
+ })
37
+ }
38
+ })
39
+
40
+ describe('scopeNeededFor', () => {
41
+ it('should return correct scope string for specific MIME type', () => {
42
+ const scope = BlobScope.scopeNeededFor({ mime: 'image/png' })
43
+ expect(scope).toBe('blob:image/png')
44
+ })
45
+
46
+ it('should return scope that accepts all MIME types', () => {
47
+ const scope = BlobScope.scopeNeededFor({ mime: 'application/json' })
48
+ expect(scope).toBe('blob:application/json')
49
+ })
50
+ })
51
+ })
52
+
53
+ describe('instance', () => {
54
+ describe('matches', () => {
55
+ it('should match exact MIME type', () => {
56
+ const scope = BlobScope.fromString('blob:image/png')
57
+ expect(scope).not.toBeNull()
58
+ expect(scope!.matches({ mime: 'image/png' })).toBe(true)
59
+ })
60
+
61
+ it('should match wildcard MIME type', () => {
62
+ const scope = BlobScope.fromString('blob:*/*')
63
+ expect(scope).not.toBeNull()
64
+ expect(scope!.matches({ mime: 'image/jpeg' })).toBe(true)
65
+ expect(scope!.matches({ mime: 'application/json' })).toBe(true)
66
+ })
67
+
68
+ it('should match subtype wildcard MIME type', () => {
69
+ const scope = BlobScope.fromString('blob:image/*')
70
+ expect(scope).not.toBeNull()
71
+ expect(scope!.matches({ mime: 'image/gif' })).toBe(true)
72
+ })
73
+
74
+ it('should not match different MIME type', () => {
75
+ const scope = BlobScope.fromString('blob:image/png')
76
+ expect(scope).not.toBeNull()
77
+ expect(scope!.matches({ mime: 'image/jpeg' })).toBe(false)
78
+ })
79
+
80
+ it('should match multiple accept values', () => {
81
+ const scope = BlobScope.fromString(
82
+ 'blob?accept=image/png&accept=image/jpeg',
83
+ )
84
+ expect(scope).not.toBeNull()
85
+ expect(scope!.matches({ mime: 'image/png' })).toBe(true)
86
+ expect(scope!.matches({ mime: 'image/jpeg' })).toBe(true)
87
+ expect(scope!.matches({ mime: 'image/gif' })).toBe(false)
88
+ })
89
+ })
90
+
91
+ describe('toString', () => {
92
+ it('should format scope with accept parameter', () => {
93
+ const scope = new BlobScope(['image/png', 'image/jpeg'])
94
+ expect(scope.toString()).toBe('blob?accept=image/png&accept=image/jpeg')
95
+ })
96
+
97
+ it('should strip redundant accept parameters', () => {
98
+ expect(new BlobScope(['*/*', 'image/*']).toString()).toBe('blob:*/*')
99
+ expect(new BlobScope(['*/*', 'image/png']).toString()).toBe('blob:*/*')
100
+ expect(new BlobScope(['image/*', 'image/png']).toString()).toBe(
101
+ 'blob:image/*',
102
+ )
103
+ })
104
+
105
+ it('should use positional format for single accept', () => {
106
+ expect(new BlobScope(['image/png']).toString()).toBe('blob:image/png')
107
+ expect(new BlobScope(['image/*']).toString()).toBe('blob:image/*')
108
+ expect(new BlobScope(['*/*']).toString()).toBe('blob:*/*')
109
+ })
110
+
111
+ it('should use query format for multiple accepts', () => {
112
+ expect(new BlobScope(['image/png', 'image/jpeg']).toString()).toBe(
113
+ 'blob?accept=image/png&accept=image/jpeg',
114
+ )
115
+ })
116
+ })
117
+ })
118
+ })
@@ -0,0 +1,86 @@
1
+ import { Accept, isAccept, matchesAnyAccept } from '../lib/mime.js'
2
+ import { Parser } from '../parser.js'
3
+ import { NeRoArray, ResourceSyntax, isScopeForResource } from '../syntax.js'
4
+
5
+ export const DEFAULT_ACCEPT = Object.freeze(['*/*'] as const)
6
+
7
+ export const blobParser = new Parser(
8
+ 'blob',
9
+ {
10
+ accept: {
11
+ multiple: true,
12
+ required: true,
13
+ validate: isAccept,
14
+ normalize: (value) => {
15
+ // Returns a more concise representation of the accept values.
16
+ if (value.includes('*/*')) return DEFAULT_ACCEPT
17
+
18
+ return value.map(toLowerCase).filter(isNonRedundant) as [
19
+ Accept,
20
+ ...Accept[],
21
+ ]
22
+ },
23
+ },
24
+ },
25
+ 'accept',
26
+ )
27
+
28
+ export type BlobScopeMatch = {
29
+ mime: string
30
+ }
31
+
32
+ export class BlobScope {
33
+ constructor(public readonly accept: NeRoArray<Accept>) {}
34
+
35
+ matches(options: BlobScopeMatch) {
36
+ return matchesAnyAccept(this.accept, options.mime)
37
+ }
38
+
39
+ toString() {
40
+ return blobParser.format(this)
41
+ }
42
+
43
+ static fromString(scope: string) {
44
+ if (!isScopeForResource(scope, 'blob')) return null
45
+ const syntax = ResourceSyntax.fromString(scope)
46
+ return this.fromSyntax(syntax)
47
+ }
48
+
49
+ static fromSyntax(syntax: ResourceSyntax) {
50
+ const result = blobParser.parse(syntax)
51
+ if (!result) return null
52
+
53
+ return new BlobScope(result.accept)
54
+ }
55
+
56
+ static scopeNeededFor(options: BlobScopeMatch) {
57
+ return blobParser.format({
58
+ accept: [options.mime as Accept],
59
+ })
60
+ }
61
+ }
62
+
63
+ function toLowerCase(value: string): string {
64
+ return value.toLowerCase()
65
+ }
66
+
67
+ function isNonRedundant(
68
+ value: string,
69
+ index: number,
70
+ arr: readonly string[],
71
+ ): boolean {
72
+ if (value.endsWith('/*')) {
73
+ // assuming the array contains unique element, wildcards cannot be redundant
74
+ // with one another ('image/*' is not redundant with 'text/*')
75
+ return true
76
+ }
77
+ const base = value.split('/', 1)[0]
78
+ if (arr.includes(`${base}/*`)) {
79
+ // If another value in the array is a wildcard for the same base, we can
80
+ // skip this one as it is redundant. e.g. if the array contains 'image/png'
81
+ // and 'image/*', we can skip 'image/png' because 'image/*' already covers
82
+ // it.
83
+ return false
84
+ }
85
+ return true
86
+ }
@@ -0,0 +1,80 @@
1
+ import { IdentityScope } from './identity-scope.js'
2
+
3
+ describe('IdentityScope', () => {
4
+ describe('static', () => {
5
+ describe('fromString', () => {
6
+ it('should parse positional scope', () => {
7
+ const scope = IdentityScope.fromString('identity:handle')
8
+ expect(scope).not.toBeNull()
9
+ expect(scope!.attr).toBe('handle')
10
+ })
11
+
12
+ it('should parse valid identity scope with wildcard attribute', () => {
13
+ const scope = IdentityScope.fromString('identity:*')
14
+ expect(scope).not.toBeNull()
15
+ expect(scope!.attr).toBe('*')
16
+ })
17
+
18
+ it('should return null for invalid identity scope', () => {
19
+ expect(IdentityScope.fromString('invalid')).toBeNull()
20
+ expect(IdentityScope.fromString('identity:invalid')).toBeNull()
21
+ })
22
+
23
+ for (const invalid of [
24
+ 'identity:*?action=*',
25
+ 'identity:*?action=manage',
26
+ 'identity:*?action=submit',
27
+ 'invalid',
28
+ 'identity:invalid',
29
+ 'identity:handle?action=invalid',
30
+ 'identity?attribute=invalid&action=invalid',
31
+ ]) {
32
+ it(`should return null for invalid rpc scope: ${invalid}`, () => {
33
+ expect(IdentityScope.fromString(invalid)).toBeNull()
34
+ })
35
+ }
36
+ })
37
+
38
+ describe('scopeNeededFor', () => {
39
+ it('should return correct scope string for specific attribute and action', () => {
40
+ const scope = IdentityScope.scopeNeededFor({ attr: 'handle' })
41
+ expect(scope).toBe('identity:handle')
42
+ })
43
+
44
+ it('should return scope that accepts all attributes with specific action', () => {
45
+ const scope = IdentityScope.scopeNeededFor({ attr: '*' })
46
+ expect(scope).toBe('identity:*')
47
+ })
48
+ })
49
+ })
50
+
51
+ describe('instance', () => {
52
+ describe('matches', () => {
53
+ it('should match default attribute and action', () => {
54
+ const scope = IdentityScope.fromString('identity:handle')
55
+ expect(scope).not.toBeNull()
56
+ expect(scope!.matches({ attr: 'handle' })).toBe(true)
57
+ expect(scope!.matches({ attr: '*' })).toBe(false)
58
+ })
59
+
60
+ it('should match wildcard attribute with specific action', () => {
61
+ const scope = IdentityScope.fromString('identity:*')
62
+ expect(scope).not.toBeNull()
63
+ expect(scope!.matches({ attr: '*' })).toBe(true)
64
+ expect(scope!.matches({ attr: 'handle' })).toBe(true)
65
+ })
66
+ })
67
+
68
+ describe('toString', () => {
69
+ it('should format scope with default action', () => {
70
+ const scope = new IdentityScope('handle')
71
+ expect(scope.toString()).toBe('identity:handle')
72
+ })
73
+
74
+ it('should format wildcard attribute with default action', () => {
75
+ const scope = new IdentityScope('*')
76
+ expect(scope.toString()).toBe('identity:*')
77
+ })
78
+ })
79
+ })
80
+ })
@@ -0,0 +1,49 @@
1
+ import { Parser, knownValuesValidator } from '../parser.js'
2
+ import { ResourceSyntax, isScopeForResource } from '../syntax.js'
3
+
4
+ const IDENTITY_ATTRIBUTES = Object.freeze(['handle', '*'] as const)
5
+ export type IdentityAttribute = (typeof IDENTITY_ATTRIBUTES)[number]
6
+
7
+ export const identityParser = new Parser(
8
+ 'identity',
9
+ {
10
+ attr: {
11
+ multiple: false,
12
+ required: true,
13
+ validate: knownValuesValidator(IDENTITY_ATTRIBUTES),
14
+ },
15
+ },
16
+ 'attr',
17
+ )
18
+
19
+ export type IdentityScopeMatch = {
20
+ attr: IdentityAttribute
21
+ }
22
+
23
+ export class IdentityScope {
24
+ constructor(public readonly attr: IdentityAttribute) {}
25
+
26
+ matches(options: IdentityScopeMatch): boolean {
27
+ return this.attr === '*' || this.attr === options.attr
28
+ }
29
+
30
+ toString() {
31
+ return identityParser.format(this)
32
+ }
33
+
34
+ static fromString(scope: string) {
35
+ if (!isScopeForResource(scope, 'identity')) return null
36
+ const syntax = ResourceSyntax.fromString(scope)
37
+ return this.fromSyntax(syntax)
38
+ }
39
+
40
+ static fromSyntax(syntax: ResourceSyntax) {
41
+ const result = identityParser.parse(syntax)
42
+ if (!result) return null
43
+ return new IdentityScope(result.attr)
44
+ }
45
+
46
+ static scopeNeededFor(options: IdentityScopeMatch): string {
47
+ return identityParser.format(options)
48
+ }
49
+ }