@atproto/oauth-scopes 0.0.2 → 0.1.0

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 (169) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/atproto-oauth-scope.d.ts +12 -0
  3. package/dist/atproto-oauth-scope.d.ts.map +1 -0
  4. package/dist/atproto-oauth-scope.js +32 -0
  5. package/dist/atproto-oauth-scope.js.map +1 -0
  6. package/dist/index.d.ts +9 -13
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +9 -13
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/lexicon.d.ts +2 -0
  11. package/dist/lib/lexicon.d.ts.map +1 -0
  12. package/dist/lib/lexicon.js +3 -0
  13. package/dist/lib/lexicon.js.map +1 -0
  14. package/dist/lib/mime.d.ts +1 -1
  15. package/dist/lib/mime.d.ts.map +1 -1
  16. package/dist/lib/mime.js +2 -0
  17. package/dist/lib/mime.js.map +1 -1
  18. package/dist/lib/nsid.d.ts +2 -2
  19. package/dist/lib/nsid.d.ts.map +1 -1
  20. package/dist/lib/nsid.js +4 -6
  21. package/dist/lib/nsid.js.map +1 -1
  22. package/dist/lib/parser.d.ts +29 -0
  23. package/dist/lib/parser.d.ts.map +1 -0
  24. package/dist/lib/parser.js +152 -0
  25. package/dist/lib/parser.js.map +1 -0
  26. package/dist/lib/resource-permission.d.ts +10 -0
  27. package/dist/lib/resource-permission.d.ts.map +1 -0
  28. package/dist/lib/resource-permission.js +3 -0
  29. package/dist/lib/resource-permission.js.map +1 -0
  30. package/dist/lib/syntax-lexicon.d.ts +26 -0
  31. package/dist/lib/syntax-lexicon.d.ts.map +1 -0
  32. package/dist/lib/syntax-lexicon.js +58 -0
  33. package/dist/lib/syntax-lexicon.js.map +1 -0
  34. package/dist/lib/syntax-string.d.ts +16 -0
  35. package/dist/lib/syntax-string.d.ts.map +1 -0
  36. package/dist/lib/syntax-string.js +121 -0
  37. package/dist/lib/syntax-string.js.map +1 -0
  38. package/dist/lib/syntax.d.ts +23 -0
  39. package/dist/lib/syntax.d.ts.map +1 -0
  40. package/dist/lib/syntax.js +22 -0
  41. package/dist/lib/syntax.js.map +1 -0
  42. package/dist/lib/util.d.ts +4 -1
  43. package/dist/lib/util.d.ts.map +1 -1
  44. package/dist/lib/util.js +4 -12
  45. package/dist/lib/util.js.map +1 -1
  46. package/dist/scope-permissions-transition.d.ts +15 -0
  47. package/dist/scope-permissions-transition.d.ts.map +1 -0
  48. package/dist/{permission-set-transition.js → scope-permissions-transition.js} +5 -5
  49. package/dist/scope-permissions-transition.js.map +1 -0
  50. package/dist/scope-permissions.d.ts +22 -0
  51. package/dist/scope-permissions.d.ts.map +1 -0
  52. package/dist/{permission-set.js → scope-permissions.js} +20 -16
  53. package/dist/scope-permissions.js.map +1 -0
  54. package/dist/scopes/account-permission.d.ts +35 -0
  55. package/dist/scopes/account-permission.d.ts.map +1 -0
  56. package/dist/scopes/account-permission.js +71 -0
  57. package/dist/scopes/account-permission.js.map +1 -0
  58. package/dist/scopes/blob-permission.d.ts +27 -0
  59. package/dist/scopes/blob-permission.d.ts.map +1 -0
  60. package/dist/scopes/blob-permission.js +86 -0
  61. package/dist/scopes/blob-permission.js.map +1 -0
  62. package/dist/scopes/identity-permission.d.ts +25 -0
  63. package/dist/scopes/identity-permission.d.ts.map +1 -0
  64. package/dist/scopes/identity-permission.js +53 -0
  65. package/dist/scopes/identity-permission.js.map +1 -0
  66. package/dist/scopes/include-scope.d.ts +54 -0
  67. package/dist/scopes/include-scope.d.ts.map +1 -0
  68. package/dist/scopes/include-scope.js +156 -0
  69. package/dist/scopes/include-scope.js.map +1 -0
  70. package/dist/scopes/repo-permission.d.ts +40 -0
  71. package/dist/scopes/repo-permission.d.ts.map +1 -0
  72. package/dist/scopes/repo-permission.js +101 -0
  73. package/dist/scopes/repo-permission.js.map +1 -0
  74. package/dist/scopes/rpc-permission.d.ts +38 -0
  75. package/dist/scopes/rpc-permission.d.ts.map +1 -0
  76. package/dist/scopes/rpc-permission.js +81 -0
  77. package/dist/scopes/rpc-permission.js.map +1 -0
  78. package/dist/scopes-set.d.ts +12 -1
  79. package/dist/scopes-set.d.ts.map +1 -1
  80. package/dist/scopes-set.js +49 -3
  81. package/dist/scopes-set.js.map +1 -1
  82. package/package.json +7 -3
  83. package/src/atproto-oauth-scope.ts +43 -0
  84. package/src/index.ts +10 -14
  85. package/src/lib/lexicon.ts +1 -0
  86. package/src/lib/mime.ts +2 -1
  87. package/src/lib/nsid.ts +5 -6
  88. package/src/lib/parser.ts +176 -0
  89. package/src/lib/resource-permission.ts +10 -0
  90. package/src/lib/syntax-lexicon.ts +55 -0
  91. package/src/lib/syntax-string.test.ts +130 -0
  92. package/src/lib/syntax-string.ts +132 -0
  93. package/src/lib/syntax.test.ts +43 -0
  94. package/src/lib/syntax.ts +47 -0
  95. package/src/lib/util.ts +7 -12
  96. package/src/{permission-set-transition.test.ts → scope-permissions-transition.test.ts} +33 -20
  97. package/src/{permission-set-transition.ts → scope-permissions-transition.ts} +11 -11
  98. package/src/{permission-set.test.ts → scope-permissions.test.ts} +77 -35
  99. package/src/scope-permissions.ts +91 -0
  100. package/src/{resources/account-scope.test.ts → scopes/account-permission.test.ts} +45 -33
  101. package/src/scopes/account-permission.ts +75 -0
  102. package/src/{resources/blob-scope.test.ts → scopes/blob-permission.test.ts} +31 -23
  103. package/src/scopes/blob-permission.ts +105 -0
  104. package/src/{resources/identity-scope.test.ts → scopes/identity-permission.test.ts} +13 -13
  105. package/src/scopes/identity-permission.ts +54 -0
  106. package/src/scopes/include-scope.test.ts +626 -0
  107. package/src/scopes/include-scope.ts +168 -0
  108. package/src/{resources/repo-scope.test.ts → scopes/repo-permission.test.ts} +77 -65
  109. package/src/scopes/repo-permission.ts +111 -0
  110. package/src/scopes/rpc-permission.test.ts +323 -0
  111. package/src/scopes/rpc-permission.ts +85 -0
  112. package/src/scopes-set.test.ts +5 -5
  113. package/src/scopes-set.ts +79 -5
  114. package/tsconfig.build.tsbuildinfo +1 -1
  115. package/tsconfig.tests.tsbuildinfo +1 -1
  116. package/dist/lib/did.d.ts +0 -3
  117. package/dist/lib/did.d.ts.map +0 -1
  118. package/dist/lib/did.js +0 -6
  119. package/dist/lib/did.js.map +0 -1
  120. package/dist/parser.d.ts +0 -31
  121. package/dist/parser.d.ts.map +0 -1
  122. package/dist/parser.js +0 -118
  123. package/dist/parser.js.map +0 -1
  124. package/dist/permission-set-transition.d.ts +0 -15
  125. package/dist/permission-set-transition.d.ts.map +0 -1
  126. package/dist/permission-set-transition.js.map +0 -1
  127. package/dist/permission-set.d.ts +0 -22
  128. package/dist/permission-set.d.ts.map +0 -1
  129. package/dist/permission-set.js.map +0 -1
  130. package/dist/resources/account-scope.d.ts +0 -35
  131. package/dist/resources/account-scope.d.ts.map +0 -1
  132. package/dist/resources/account-scope.js +0 -60
  133. package/dist/resources/account-scope.js.map +0 -1
  134. package/dist/resources/blob-scope.d.ts +0 -25
  135. package/dist/resources/blob-scope.d.ts.map +0 -1
  136. package/dist/resources/blob-scope.js +0 -74
  137. package/dist/resources/blob-scope.js.map +0 -1
  138. package/dist/resources/identity-scope.d.ts +0 -25
  139. package/dist/resources/identity-scope.d.ts.map +0 -1
  140. package/dist/resources/identity-scope.js +0 -46
  141. package/dist/resources/identity-scope.js.map +0 -1
  142. package/dist/resources/repo-scope.d.ts +0 -37
  143. package/dist/resources/repo-scope.d.ts.map +0 -1
  144. package/dist/resources/repo-scope.js +0 -92
  145. package/dist/resources/repo-scope.js.map +0 -1
  146. package/dist/resources/rpc-scope.d.ts +0 -31
  147. package/dist/resources/rpc-scope.d.ts.map +0 -1
  148. package/dist/resources/rpc-scope.js +0 -74
  149. package/dist/resources/rpc-scope.js.map +0 -1
  150. package/dist/syntax.d.ts +0 -76
  151. package/dist/syntax.d.ts.map +0 -1
  152. package/dist/syntax.js +0 -249
  153. package/dist/syntax.js.map +0 -1
  154. package/dist/utilities.d.ts +0 -17
  155. package/dist/utilities.d.ts.map +0 -1
  156. package/dist/utilities.js +0 -108
  157. package/dist/utilities.js.map +0 -1
  158. package/src/lib/did.ts +0 -3
  159. package/src/parser.ts +0 -150
  160. package/src/permission-set.ts +0 -78
  161. package/src/resources/account-scope.ts +0 -66
  162. package/src/resources/blob-scope.ts +0 -86
  163. package/src/resources/identity-scope.ts +0 -49
  164. package/src/resources/repo-scope.ts +0 -101
  165. package/src/resources/rpc-scope.test.ts +0 -280
  166. package/src/resources/rpc-scope.ts +0 -77
  167. package/src/syntax.test.ts +0 -203
  168. package/src/syntax.ts +0 -325
  169. package/src/utilities.ts +0 -109
@@ -0,0 +1,168 @@
1
+ import { AtprotoAudience, isAtprotoAudience } from '@atproto/did'
2
+ import { LexPermission, LexPermissionSet } from '../lib/lexicon.js'
3
+ import { Nsid, isNsid } from '../lib/nsid.js'
4
+ import { Parser } from '../lib/parser.js'
5
+ import { LexPermissionSyntax } from '../lib/syntax-lexicon.js'
6
+ import { ScopeStringSyntax } from '../lib/syntax-string.js'
7
+ import { ScopeSyntax, isScopeStringFor } from '../lib/syntax.js'
8
+ import { BlobPermission } from './blob-permission.js'
9
+ import { RepoPermission } from './repo-permission.js'
10
+ import { RpcPermission } from './rpc-permission.js'
11
+
12
+ export { type LexPermission, type LexPermissionSet, type Nsid, isNsid }
13
+
14
+ /**
15
+ * This is used to handle "include:" oauth scope values, used to include
16
+ * permissions from a lexicon defined permission set. Not being a resource
17
+ * permission, it does not implement `Matchable`.
18
+ */
19
+ export class IncludeScope {
20
+ constructor(
21
+ public readonly nsid: Nsid,
22
+ public readonly aud: undefined | AtprotoAudience = undefined,
23
+ ) {}
24
+
25
+ toString() {
26
+ return IncludeScope.parser.format(this)
27
+ }
28
+
29
+ /**
30
+ * Converts an "include:" to the list of permissions it includes, based on the
31
+ * lexicon defined permission set.
32
+ */
33
+ toPermissions(permissionSet: LexPermissionSet) {
34
+ return permissionSet.permissions
35
+ .map(this.parsePermission, this)
36
+ .filter(this.isAllowedPermission, this)
37
+ }
38
+
39
+ protected parsePermission(permission: LexPermission) {
40
+ if (
41
+ permission.resource === 'rpc' &&
42
+ permission.inheritAud === true &&
43
+ permission.aud === undefined &&
44
+ this.aud !== undefined
45
+ ) {
46
+ // "rpc" permissions can "inherit" their audience from "aud" param defined
47
+ // in the "include:<nsid>?aud=<audience>" scope the permission set was
48
+ // loaded from.
49
+ return parsePermission({
50
+ ...permission,
51
+ inheritAud: undefined,
52
+ aud: this.aud,
53
+ })
54
+ }
55
+
56
+ return parsePermission(permission)
57
+ }
58
+
59
+ /**
60
+ * Verifies that a permission included through a lexicon permission set is
61
+ * allowed in the context of the `include:` scope. This basically checks that
62
+ * the permission is "under" the namespace authority of the `include:` scope,
63
+ * and that it only contains "repo:", "rpc:", or "blob:" permissions.
64
+ */
65
+ protected isAllowedPermission(
66
+ permission: unknown,
67
+ ): permission is RpcPermission | RepoPermission | BlobPermission {
68
+ if (permission instanceof RpcPermission) {
69
+ return permission.lxm.every(this.isParentAuthorityOf, this)
70
+ }
71
+
72
+ if (permission instanceof RepoPermission) {
73
+ return permission.collection.every(this.isParentAuthorityOf, this)
74
+ }
75
+
76
+ if (permission instanceof BlobPermission) {
77
+ return true
78
+ }
79
+
80
+ return false
81
+ }
82
+
83
+ /**
84
+ * Verifies that a permission item's nsid is under the same authority as the
85
+ * nsid of the lexicon itself (which is the same as the nsid of the `include:`
86
+ * scope).
87
+ */
88
+ public isParentAuthorityOf(otherNsid: '*' | Nsid) {
89
+ if (otherNsid === '*') {
90
+ return false
91
+ }
92
+
93
+ const lexiconNsid = this.nsid
94
+
95
+ const groupPrefixEnd = lexiconNsid.lastIndexOf('.')
96
+
97
+ // There should always be a dot, but since this is a security feature, let's
98
+ // be strict about it.
99
+ if (groupPrefixEnd === -1) {
100
+ throw new TypeError('Dot character (".") missing from lexicon NSID')
101
+ }
102
+
103
+ // Make sure that otherNsid is at least as long as the "group prefix"
104
+ if (groupPrefixEnd >= otherNsid.length - 1) {
105
+ return false
106
+ }
107
+
108
+ // Make sure that the "otherNsid" starts with the group of the lexiconNsid,
109
+ // up to the dot itself. We check in reverse order as nsids tend to have
110
+ // long common prefixes.
111
+ for (let i = groupPrefixEnd; i >= 0; i--) {
112
+ if (lexiconNsid.charCodeAt(i) !== otherNsid.charCodeAt(i)) {
113
+ return false
114
+ }
115
+ }
116
+
117
+ return true
118
+ }
119
+
120
+ protected static readonly parser = new Parser(
121
+ 'include',
122
+ {
123
+ nsid: {
124
+ multiple: false,
125
+ required: true,
126
+ validate: isNsid,
127
+ },
128
+ aud: {
129
+ multiple: false,
130
+ required: false,
131
+ validate: isAtprotoAudience,
132
+ },
133
+ },
134
+ 'nsid',
135
+ )
136
+
137
+ static fromString(scope: string) {
138
+ if (!isScopeStringFor(scope, 'include')) return null
139
+ const syntax = ScopeStringSyntax.fromString(scope)
140
+ return IncludeScope.fromSyntax(syntax)
141
+ }
142
+
143
+ static fromSyntax(syntax: ScopeSyntax<'include'>) {
144
+ const result = IncludeScope.parser.parse(syntax)
145
+ if (!result) return null
146
+ return new IncludeScope(result.nsid, result.aud)
147
+ }
148
+ }
149
+
150
+ function parsePermission(permission: LexPermission) {
151
+ if (isPermissionForResource(permission, 'repo')) {
152
+ return RepoPermission.fromSyntax(new LexPermissionSyntax(permission))
153
+ }
154
+ if (isPermissionForResource(permission, 'rpc')) {
155
+ return RpcPermission.fromSyntax(new LexPermissionSyntax(permission))
156
+ }
157
+ if (isPermissionForResource(permission, 'blob')) {
158
+ return BlobPermission.fromSyntax(new LexPermissionSyntax(permission))
159
+ }
160
+ return null
161
+ }
162
+
163
+ function isPermissionForResource<P extends LexPermission, T extends string>(
164
+ permission: P,
165
+ type: T,
166
+ ): permission is P & { resource: T } {
167
+ return permission.resource === type
168
+ }
@@ -1,33 +1,33 @@
1
- import { RepoScope } from './repo-scope.js'
1
+ import { RepoPermission } from './repo-permission.js'
2
2
 
3
- describe('RepoScope', () => {
3
+ describe('RepoPermission', () => {
4
4
  describe('static', () => {
5
5
  describe('fromString', () => {
6
6
  it('should parse positional scope', () => {
7
- const scope = RepoScope.fromString('repo:foo.bar')
7
+ const scope = RepoPermission.fromString('repo:com.example.foo')
8
8
  expect(scope).not.toBeNull()
9
- expect(scope!.collection).toEqual(['foo.bar'])
9
+ expect(scope!.collection).toEqual(['com.example.foo'])
10
10
  expect(scope!.action).toEqual(['create', 'update', 'delete'])
11
11
  })
12
12
 
13
13
  it('should parse valid repo scope with multiple actions', () => {
14
- const scope = RepoScope.fromString(
15
- 'repo:foo.bar?action=create&action=update',
14
+ const scope = RepoPermission.fromString(
15
+ 'repo:com.example.foo?action=create&action=update',
16
16
  )
17
17
  expect(scope).not.toBeNull()
18
- expect(scope!.collection).toEqual(['foo.bar'])
18
+ expect(scope!.collection).toEqual(['com.example.foo'])
19
19
  expect(scope!.action).toEqual(['create', 'update'])
20
20
  })
21
21
 
22
22
  it('should parse valid repo scope without actions (defaults to create, update, delete)', () => {
23
- const scope = RepoScope.fromString('repo:foo.bar')
23
+ const scope = RepoPermission.fromString('repo:com.example.foo')
24
24
  expect(scope).not.toBeNull()
25
- expect(scope!.collection).toEqual(['foo.bar'])
25
+ expect(scope!.collection).toEqual(['com.example.foo'])
26
26
  expect(scope!.action).toEqual(['create', 'update', 'delete'])
27
27
  })
28
28
 
29
29
  it('should allow wildcard collection with specific action', () => {
30
- const scope = RepoScope.fromString('repo:*?action=create')
30
+ const scope = RepoPermission.fromString('repo:*?action=create')
31
31
  expect(scope).not.toBeNull()
32
32
  expect(scope!.collection).toEqual(['*'])
33
33
  expect(scope!.action).toEqual(['create'])
@@ -40,7 +40,7 @@ describe('RepoScope', () => {
40
40
  })
41
41
 
42
42
  it('should allow wildcard collection without actions', () => {
43
- const scope = RepoScope.fromString('repo:*')
43
+ const scope = RepoPermission.fromString('repo:*')
44
44
  expect(scope).not.toBeNull()
45
45
  expect(scope!.collection).toEqual(['*'])
46
46
  expect(scope!.action).toEqual(['create', 'update', 'delete'])
@@ -56,60 +56,63 @@ describe('RepoScope', () => {
56
56
  })
57
57
 
58
58
  it('should ignore scopes with invalid collection names', () => {
59
- expect(RepoScope.fromString('repo:foo bar')).toBeNull()
60
- expect(RepoScope.fromString('repo:.foo')).toBeNull()
61
- expect(RepoScope.fromString('repo:bar.')).toBeNull()
59
+ expect(RepoPermission.fromString('repo:foo bar')).toBeNull()
60
+ expect(RepoPermission.fromString('repo:.foo')).toBeNull()
61
+ expect(RepoPermission.fromString('repo:bar.')).toBeNull()
62
62
  })
63
63
 
64
64
  it('should reject invalid action names', () => {
65
- const scope = RepoScope.fromString('repo:foo.bar?action=invalid')
65
+ const scope = RepoPermission.fromString(
66
+ 'repo:com.example.foo?action=invalid',
67
+ )
66
68
  expect(scope).toBeNull()
67
69
  })
68
70
 
69
71
  it('should return null for invalid repo scope', () => {
70
- expect(RepoScope.fromString('invalid')).toBeNull()
71
- expect(RepoScope.fromString('scope')).toBeNull()
72
+ expect(RepoPermission.fromString('invalid')).toBeNull()
73
+ expect(RepoPermission.fromString('scope')).toBeNull()
72
74
  })
73
75
 
74
76
  for (const invalid of [
75
77
  'repo:*?action=*',
76
78
  'invalid',
77
79
  'repo:invalid',
78
- 'repo:foo.bar?action=invalid',
80
+ 'repo:com.example.foo?action=invalid',
79
81
  'repo?collection=invalid&action=invalid',
80
82
  ]) {
81
83
  it(`should return null for invalid rpc scope: ${invalid}`, () => {
82
- expect(RepoScope.fromString(invalid)).toBeNull()
84
+ expect(RepoPermission.fromString(invalid)).toBeNull()
83
85
  })
84
86
  }
85
87
  })
86
88
 
87
89
  describe('scopeNeededFor', () => {
88
90
  it('should return correct scope string for specific collection and action', () => {
89
- const scope = RepoScope.scopeNeededFor({
90
- collection: 'foo.bar',
91
+ const scope = RepoPermission.scopeNeededFor({
92
+ collection: 'com.example.foo',
91
93
  action: 'create',
92
94
  })
93
- expect(scope).toBe('repo:foo.bar?action=create')
95
+ expect(scope).toBe('repo:com.example.foo?action=create')
94
96
  })
95
97
 
96
98
  it('should return scope that accepts all collections with specific action', () => {
97
- const scope = RepoScope.scopeNeededFor({
99
+ const scope = RepoPermission.scopeNeededFor({
98
100
  collection: '*',
99
101
  action: 'create',
100
102
  })
101
103
  expect(scope).toBe('repo:*?action=create')
102
104
  })
103
105
 
104
- it('ignores invalid collection names', () => {
106
+ it('ignores invalid options', () => {
105
107
  // @NOTE the scopeNeededFor assumes valid input, so it does not validate
106
108
  // collection or action.
107
- const scope = RepoScope.scopeNeededFor({
108
- collection: 'invalid',
109
- // @ts-expect-error
110
- action: 'not-an-action',
111
- })
112
- expect(scope).toBe('repo:invalid?action=not-an-action')
109
+
110
+ expect(
111
+ RepoPermission.scopeNeededFor({
112
+ collection: 'invalid',
113
+ action: 'create',
114
+ }),
115
+ ).toBe('repo:invalid?action=create')
113
116
  })
114
117
  })
115
118
  })
@@ -117,74 +120,81 @@ describe('RepoScope', () => {
117
120
  describe('instance', () => {
118
121
  describe('matches', () => {
119
122
  it('should match create action', () => {
120
- const scope = RepoScope.fromString('repo:foo.bar?action=create')
123
+ const scope = RepoPermission.fromString(
124
+ 'repo:com.example.foo?action=create',
125
+ )
121
126
  expect(scope).not.toBeNull()
122
127
  expect(
123
- scope!.matches({ action: 'create', collection: 'foo.bar' }),
128
+ scope!.matches({ action: 'create', collection: 'com.example.foo' }),
124
129
  ).toBe(true)
125
130
  })
126
131
 
127
132
  it('should not match unspecified action', () => {
128
- const scope = RepoScope.fromString('repo:foo.bar?action=create')
133
+ const scope = RepoPermission.fromString(
134
+ 'repo:com.example.foo?action=create',
135
+ )
129
136
  expect(scope).not.toBeNull()
130
137
  expect(
131
- scope!.matches({ action: 'update', collection: 'foo.bar' }),
138
+ scope!.matches({ action: 'update', collection: 'com.example.foo' }),
132
139
  ).toBe(false)
133
140
  })
134
141
 
135
142
  it('should match wildcard collection', () => {
136
- const scope = RepoScope.fromString('repo:*?action=create')
143
+ const scope = RepoPermission.fromString('repo:*?action=create')
137
144
  expect(scope).not.toBeNull()
138
145
  expect(
139
- scope!.matches({ action: 'create', collection: 'any.collection' }),
146
+ scope!.matches({ action: 'create', collection: 'com.example.bar' }),
140
147
  ).toBe(true)
141
148
  })
142
149
 
143
150
  it('should not match different action with wildcard collection', () => {
144
- const scope = RepoScope.fromString('repo:*?action=create')
151
+ const scope = RepoPermission.fromString('repo:*?action=create')
145
152
  expect(scope).not.toBeNull()
146
153
  expect(
147
- scope!.matches({ action: 'delete', collection: 'any.collection' }),
154
+ scope!.matches({ action: 'delete', collection: 'com.example.bar' }),
148
155
  ).toBe(false)
149
156
  })
150
157
 
151
158
  it('should match multiple actions', () => {
152
- const scope = RepoScope.fromString(
153
- 'repo:foo.bar?action=create&action=update',
159
+ const scope = RepoPermission.fromString(
160
+ 'repo:com.example.foo?action=create&action=update',
154
161
  )
155
162
  expect(scope).not.toBeNull()
156
163
  expect(
157
- scope!.matches({ action: 'create', collection: 'foo.bar' }),
164
+ scope!.matches({ action: 'create', collection: 'com.example.foo' }),
158
165
  ).toBe(true)
159
166
  expect(
160
- scope!.matches({ action: 'update', collection: 'foo.bar' }),
167
+ scope!.matches({ action: 'update', collection: 'com.example.foo' }),
161
168
  ).toBe(true)
162
169
  expect(
163
- scope!.matches({ action: 'delete', collection: 'foo.bar' }),
170
+ scope!.matches({ action: 'delete', collection: 'com.example.foo' }),
164
171
  ).toBe(false)
165
172
  })
166
173
 
167
174
  it('should default to "create", "update", and "delete" actions', () => {
168
- const scope = RepoScope.fromString('repo:foo.bar')
175
+ const scope = RepoPermission.fromString('repo:com.example.foo')
169
176
  expect(scope).not.toBeNull()
170
177
  expect(
171
- scope!.matches({ action: 'create', collection: 'foo.bar' }),
178
+ scope!.matches({ action: 'create', collection: 'com.example.foo' }),
172
179
  ).toBe(true)
173
180
  expect(
174
- scope!.matches({ action: 'update', collection: 'foo.bar' }),
181
+ scope!.matches({ action: 'update', collection: 'com.example.foo' }),
175
182
  ).toBe(true)
176
183
  expect(
177
- scope!.matches({ action: 'delete', collection: 'foo.bar' }),
184
+ scope!.matches({ action: 'delete', collection: 'com.example.foo' }),
178
185
  ).toBe(true)
179
186
  })
180
187
  })
181
188
 
182
189
  describe('toString', () => {
183
190
  it('should format repo scope correctly', () => {
184
- const scope = new RepoScope(['foo.bar'], ['create', 'update'])
191
+ const scope = new RepoPermission(
192
+ ['com.example.foo'],
193
+ ['create', 'update'],
194
+ )
185
195
  expect(scope).not.toBeNull()
186
196
  expect(scope!.toString()).toBe(
187
- 'repo:foo.bar?action=create&action=update',
197
+ 'repo:com.example.foo?action=create&action=update',
188
198
  )
189
199
  })
190
200
  })
@@ -192,22 +202,22 @@ describe('RepoScope', () => {
192
202
 
193
203
  describe('consistency', () => {
194
204
  const testCases: { input: string; expected: string }[] = [
195
- { input: 'repo:foo.bar', expected: 'repo:foo.bar' },
205
+ { input: 'repo:com.example.foo', expected: 'repo:com.example.foo' },
196
206
  {
197
- input: 'repo:foo.bar?action=create',
198
- expected: 'repo:foo.bar?action=create',
207
+ input: 'repo:com.example.foo?action=create',
208
+ expected: 'repo:com.example.foo?action=create',
199
209
  },
200
210
  {
201
- input: 'repo:foo.bar?action=create&action=update',
202
- expected: 'repo:foo.bar?action=create&action=update',
211
+ input: 'repo:com.example.foo?action=create&action=update',
212
+ expected: 'repo:com.example.foo?action=create&action=update',
203
213
  },
204
214
  {
205
215
  input: 'repo:*?action=create&action=update&action=delete',
206
216
  expected: 'repo:*',
207
217
  },
208
218
  {
209
- input: 'repo:foo.bar?action=create&action=update&action=delete',
210
- expected: 'repo:foo.bar',
219
+ input: 'repo:com.example.foo?action=create&action=update&action=delete',
220
+ expected: 'repo:com.example.foo',
211
221
  },
212
222
  { input: 'repo:*?action=create', expected: 'repo:*?action=create' },
213
223
  { input: 'repo:*?action=update', expected: 'repo:*?action=update' },
@@ -216,7 +226,7 @@ describe('RepoScope', () => {
216
226
  expected: 'repo:*?action=update',
217
227
  },
218
228
  {
219
- input: 'repo?collection=*&collection=foo.bar&action=update',
229
+ input: 'repo?collection=*&collection=com.example.foo&action=update',
220
230
  expected: 'repo:*?action=update',
221
231
  },
222
232
  {
@@ -228,27 +238,29 @@ describe('RepoScope', () => {
228
238
  expected: 'repo:*',
229
239
  },
230
240
  {
231
- input: 'repo?collection=*&collection=foo.bar',
241
+ input: 'repo?collection=*&collection=com.example.foo',
232
242
  expected: 'repo:*',
233
243
  },
234
244
  {
235
- input: 'repo?action=create&collection=foo.bar',
236
- expected: 'repo:foo.bar?action=create',
245
+ input: 'repo?action=create&collection=com.example.foo',
246
+ expected: 'repo:com.example.foo?action=create',
237
247
  },
238
248
  {
239
249
  input:
240
- 'repo?collection=foo.bar&action=create&action=update&action=delete',
241
- expected: 'repo:foo.bar',
250
+ 'repo?collection=com.example.foo&action=create&action=update&action=delete',
251
+ expected: 'repo:com.example.foo',
242
252
  },
243
253
  {
244
- input: 'repo?action=create&collection=foo.bar&collection=baz.qux',
245
- expected: 'repo?collection=baz.qux&collection=foo.bar&action=create',
254
+ input:
255
+ 'repo?action=create&collection=com.example.foo&collection=com.example.bar',
256
+ expected:
257
+ 'repo?collection=com.example.bar&collection=com.example.foo&action=create',
246
258
  },
247
259
  ]
248
260
 
249
261
  for (const { input, expected } of testCases) {
250
262
  it(`should properly re-format ${input}`, () => {
251
- expect(RepoScope.fromString(input)?.toString()).toBe(expected)
263
+ expect(RepoPermission.fromString(input)?.toString()).toBe(expected)
252
264
  })
253
265
  }
254
266
  })
@@ -0,0 +1,111 @@
1
+ import { Nsid, isNsid } from '../lib/nsid.js'
2
+ import { Parser } from '../lib/parser.js'
3
+ import { ResourcePermission } from '../lib/resource-permission.js'
4
+ import { ScopeStringSyntax } from '../lib/syntax-string.js'
5
+ import {
6
+ NeArray,
7
+ NeRoArray,
8
+ ScopeSyntax,
9
+ isScopeStringFor,
10
+ } from '../lib/syntax.js'
11
+ import { knownValuesValidator } from '../lib/util.js'
12
+
13
+ export { type Nsid, isNsid }
14
+
15
+ export const REPO_ACTIONS = Object.freeze([
16
+ 'create',
17
+ 'update',
18
+ 'delete',
19
+ ] as const)
20
+ export type RepoAction = (typeof REPO_ACTIONS)[number]
21
+ export const isRepoAction = knownValuesValidator(REPO_ACTIONS)
22
+
23
+ export type CollectionParam = '*' | Nsid
24
+ export const isCollectionParam = (value: unknown): value is CollectionParam =>
25
+ value === '*' || isNsid(value)
26
+
27
+ export type RepoPermissionMatch = {
28
+ collection: string
29
+ action: RepoAction
30
+ }
31
+
32
+ export class RepoPermission
33
+ implements ResourcePermission<'repo', RepoPermissionMatch>
34
+ {
35
+ constructor(
36
+ public readonly collection: NeRoArray<'*' | Nsid>,
37
+ public readonly action: NeRoArray<RepoAction>,
38
+ ) {}
39
+
40
+ matches({ action, collection }: RepoPermissionMatch) {
41
+ return (
42
+ this.action.includes(action) &&
43
+ (this.collection.includes('*') ||
44
+ (this.collection as readonly string[]).includes(collection))
45
+ )
46
+ }
47
+
48
+ toString() {
49
+ return RepoPermission.parser.format(this)
50
+ }
51
+
52
+ protected static readonly parser = new Parser(
53
+ 'repo',
54
+ {
55
+ collection: {
56
+ multiple: true,
57
+ required: true,
58
+ validate: isCollectionParam,
59
+ normalize: (value) => {
60
+ if (value.length > 1) {
61
+ if (value.includes('*')) return ['*'] as const
62
+ return [...new Set(value)].sort() as NeArray<Nsid>
63
+ }
64
+ return value as ['*' | Nsid]
65
+ },
66
+ },
67
+ action: {
68
+ multiple: true,
69
+ required: false,
70
+ validate: isRepoAction,
71
+ default: REPO_ACTIONS,
72
+ normalize: (value) => {
73
+ return value === REPO_ACTIONS
74
+ ? REPO_ACTIONS // No need to filter if the default was used
75
+ : (REPO_ACTIONS.filter(includedIn, value) as NeArray<RepoAction>)
76
+ },
77
+ },
78
+ },
79
+ 'collection',
80
+ )
81
+
82
+ static fromString(scope: string): RepoPermission | null {
83
+ if (!isScopeStringFor(scope, 'repo')) return null
84
+ const syntax = ScopeStringSyntax.fromString(scope)
85
+ return RepoPermission.fromSyntax(syntax)
86
+ }
87
+
88
+ static fromSyntax(syntax: ScopeSyntax<'repo'>): RepoPermission | null {
89
+ const result = RepoPermission.parser.parse(syntax)
90
+ if (!result) return null
91
+
92
+ return new RepoPermission(result.collection, result.action)
93
+ }
94
+
95
+ static scopeNeededFor(options: RepoPermissionMatch): string {
96
+ return RepoPermission.parser.format({
97
+ collection: [options.collection as '*' | Nsid],
98
+ action: [options.action],
99
+ })
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Special utility function to be used as predicate for array methods like
105
+ * `Array.prototype.includes`, etc. When used as predicate, it expects that
106
+ * the array method is called with a `thisArg` that is a readonly array of
107
+ * the same type as the `value` parameter.
108
+ */
109
+ function includedIn<T>(this: readonly T[], value: T): boolean {
110
+ return this.includes(value)
111
+ }