@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,70 @@
1
+ // @TODO Refactor in shared location for use with other @atproto packages
2
+
3
+ function isStringSlashString(value: string): value is `${string}/${string}` {
4
+ const slashIndex = value.indexOf('/')
5
+
6
+ if (slashIndex === -1) return false // Missing slash
7
+ if (slashIndex === 0) return false // No leading part before the slash
8
+ if (slashIndex === value.length - 1) return false // No trailing part after the slash
9
+ if (value.includes('/', slashIndex + 1)) return false // More than one slash
10
+ if (value.includes(' ')) return false // Spaces are not allowed
11
+
12
+ return true
13
+ }
14
+
15
+ export type Mime = `${string}/${string}`
16
+
17
+ export function isMime(value: string): value is Mime {
18
+ return isStringSlashString(value) && !value.includes('*')
19
+ }
20
+
21
+ export type Accept = '*/*' | `${string}/*` | Mime
22
+
23
+ export function isAccept(value: string): value is Accept {
24
+ if (value === '*/*') return true // Fast path for the most common case
25
+ if (!isStringSlashString(value)) return false
26
+ return !value.includes('*') || value.endsWith('/*')
27
+ }
28
+
29
+ /**
30
+ * @note "unsafe" in that it does not check if either {@link accept} or
31
+ * {@link mime} are actually valid values (and could, therefore, lead to false
32
+ * positives if forged values are used).
33
+ */
34
+ function matchesAcceptUnsafe(accept: Accept, mime: Mime): boolean {
35
+ if (accept === '*/*') {
36
+ return true
37
+ }
38
+ if (accept.endsWith('/*')) {
39
+ return mime.startsWith(accept.slice(0, -1))
40
+ }
41
+ return accept === mime
42
+ }
43
+
44
+ export function matchesAccept(accept: Accept, mime: string): boolean {
45
+ return isMime(mime) && matchesAcceptUnsafe(accept, mime)
46
+ }
47
+
48
+ /**
49
+ * @note "unsafe" in that it does not check if either {@link accept} or
50
+ * {@link mime} are actually valid values (and could, therefore, lead to false
51
+ * positives if forged values are used).
52
+ */
53
+ function matchesAnyAcceptUnsafe(
54
+ acceptable: Iterable<Accept>,
55
+ mime: Mime,
56
+ ): boolean {
57
+ for (const accept of acceptable) {
58
+ if (matchesAcceptUnsafe(accept, mime)) {
59
+ return true
60
+ }
61
+ }
62
+ return false
63
+ }
64
+
65
+ export function matchesAnyAccept(
66
+ acceptable: Iterable<Accept>,
67
+ mime: string,
68
+ ): boolean {
69
+ return isMime(mime) && matchesAnyAcceptUnsafe(acceptable, mime)
70
+ }
@@ -0,0 +1,6 @@
1
+ export type NSID = `${string}.${string}`
2
+ export const isNSID = (value: string): value is NSID =>
3
+ value.includes('.') &&
4
+ !value.includes(' ') &&
5
+ !value.startsWith('.') &&
6
+ !value.endsWith('.')
@@ -0,0 +1,19 @@
1
+ export function minIdx(a: number, b: number): number {
2
+ if (a === -1) return b
3
+ if (b === -1) return a
4
+ return Math.min(a, b)
5
+ }
6
+
7
+ export function toRecord(
8
+ iterable: Iterable<[key: string, value: string]>,
9
+ ): Record<string, [string, ...string[]]> {
10
+ const record: Record<string, [string, ...string[]]> = Object.create(null)
11
+ for (const [key, value] of iterable) {
12
+ if (Object.hasOwn(record, key)) {
13
+ record[key]!.push(value)
14
+ } else {
15
+ record[key] = [value]
16
+ }
17
+ }
18
+ return record
19
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,150 @@
1
+ import {
2
+ NeRoArray,
3
+ ResourceSyntax,
4
+ ScopeForResource,
5
+ formatScope,
6
+ } from './syntax.js'
7
+
8
+ type InferStringPredicate<T extends undefined | ((value: string) => boolean)> =
9
+ T extends ((value: string) => value is infer U extends string) ? U : string
10
+
11
+ type ParamsSchema = Record<
12
+ string,
13
+ | {
14
+ multiple: false
15
+ required: boolean
16
+ default?: string
17
+ normalize?: (value: string) => string
18
+ validate?: (value: string) => boolean
19
+ }
20
+ | {
21
+ multiple: true
22
+ required: boolean
23
+ default?: NeRoArray<string>
24
+ normalize?: (value: NeRoArray<string>) => NeRoArray<string>
25
+ validate?: (value: string) => boolean
26
+ }
27
+ >
28
+
29
+ type ParsedParams<S extends ParamsSchema> = {
30
+ [K in keyof S]:
31
+ | (S[K]['required'] extends true
32
+ ? never
33
+ : 'default' extends keyof S[K]
34
+ ? S[K]['default']
35
+ : undefined)
36
+ | (S[K]['multiple'] extends true
37
+ ? NeRoArray<InferStringPredicate<S[K]['validate']>>
38
+ : InferStringPredicate<S[K]['validate']>)
39
+ } & NonNullable<unknown>
40
+
41
+ export class Parser<R extends string, S extends ParamsSchema> {
42
+ readonly schemaKeys: ReadonlyArray<keyof S & string>
43
+
44
+ constructor(
45
+ readonly resource: R,
46
+ readonly schema: S,
47
+ readonly positionalName?: keyof S & string,
48
+ ) {
49
+ this.schemaKeys = Object.keys(schema)
50
+ }
51
+
52
+ format(values: ParsedParams<S>): ScopeForResource<R> {
53
+ // Build params
54
+ const params: [
55
+ name: string,
56
+ value: undefined | string | NeRoArray<string>,
57
+ ][] = []
58
+
59
+ for (const key of this.schemaKeys) {
60
+ const value = values[key]
61
+ // Ignore undefined values
62
+ if (value === undefined) continue
63
+
64
+ const schema = this.schema[key]
65
+
66
+ // @TODO: when the value is an array, we could remove duplicates
67
+
68
+ // Normalize the value if a normalization function is provided
69
+ const normalized = schema.normalize
70
+ ? schema.normalize(value as any)
71
+ : value
72
+
73
+ // Ignore values that are equal to the default value
74
+ if (!schema.required) {
75
+ if (schema.default === normalized) continue
76
+ if (
77
+ schema.multiple &&
78
+ schema.default &&
79
+ arrayParamEquals(schema.default, normalized as NeRoArray<string>)
80
+ ) {
81
+ continue
82
+ }
83
+ }
84
+
85
+ params.push([key, normalized])
86
+ }
87
+
88
+ return formatScope<R>(this.resource, params, this.positionalName)
89
+ }
90
+
91
+ parse(syntax: ResourceSyntax) {
92
+ if (!syntax.is(this.resource)) return null
93
+ if (syntax.containsParamsOtherThan(this.schemaKeys)) return null
94
+
95
+ const result: Record<string, undefined | string | NeRoArray<string>> =
96
+ Object.create(null)
97
+
98
+ for (const key of this.schemaKeys) {
99
+ const definition = this.schema[key]
100
+
101
+ const value = definition.multiple
102
+ ? syntax.getMulti(key, key === this.positionalName)
103
+ : syntax.getSingle(key, key === this.positionalName)
104
+
105
+ if (value === null) return null // Value is not valid
106
+ if (value === undefined && definition.required) return null
107
+
108
+ if (value !== undefined && definition.validate) {
109
+ if (definition.multiple) {
110
+ if (!(value as NeRoArray<string>).every(definition.validate)) {
111
+ return null
112
+ }
113
+ } else {
114
+ if (!definition.validate(value as string)) {
115
+ return null
116
+ }
117
+ }
118
+ }
119
+
120
+ result[key] = value ?? definition.default
121
+ }
122
+
123
+ return result as ParsedParams<S>
124
+ }
125
+
126
+ parseString(scope: string): ParsedParams<S> | null {
127
+ const syntax = ResourceSyntax.fromString(scope)
128
+ return this.parse(syntax)
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Two param arrays are considered equal if they contain the same values,
134
+ * regardless of the order and duplicates.
135
+ * @param a - The first array to compare.
136
+ * @param b - The second array to compare.
137
+ */
138
+ function arrayParamEquals(
139
+ a: readonly unknown[],
140
+ b: readonly unknown[],
141
+ ): boolean {
142
+ for (const item of a) if (!b.includes(item)) return false
143
+ for (const item of b) if (!a.includes(item)) return false
144
+ return true
145
+ }
146
+
147
+ export function knownValuesValidator<T extends string>(values: Iterable<T>) {
148
+ const set = new Set<string>(values)
149
+ return (value: string): value is T => set.has(value)
150
+ }
@@ -0,0 +1,109 @@
1
+ import { PermissionSetTransition } from './permission-set-transition.js'
2
+
3
+ describe('PermissionSetTransition', () => {
4
+ describe('allowsAccount', () => {
5
+ it('should allow account:email with transition:email', () => {
6
+ const set = new PermissionSetTransition('transition:email account:repo')
7
+ expect(set.allowsAccount({ attr: 'email', action: 'read' })).toBe(true)
8
+ expect(set.allowsAccount({ attr: 'email', action: 'manage' })).toBe(true)
9
+
10
+ expect(set.allowsAccount({ attr: 'repo', action: 'read' })).toBe(true)
11
+ expect(set.allowsAccount({ attr: 'repo', action: 'manage' })).toBe(false)
12
+
13
+ expect(set.allowsAccount({ attr: 'status', action: 'read' })).toBe(false)
14
+ expect(set.allowsAccount({ attr: 'status', action: 'manage' })).toBe(
15
+ false,
16
+ )
17
+ })
18
+ })
19
+
20
+ describe('allowsBlob', () => {
21
+ it('should allow blob with transition:generic', () => {
22
+ const set = new PermissionSetTransition('transition:generic')
23
+ expect(set.allowsBlob({ mime: 'foo/bar' })).toBe(true)
24
+ })
25
+ })
26
+
27
+ describe('allowsRepo', () => {
28
+ it('should allow repo with transition:generic', () => {
29
+ const set = new PermissionSetTransition('transition:generic')
30
+ expect(
31
+ set.allowsRepo({ collection: 'app.bsky.feed.post', action: 'create' }),
32
+ ).toBe(true)
33
+ expect(
34
+ set.allowsRepo({ collection: 'app.bsky.feed.post', action: 'delete' }),
35
+ ).toBe(true)
36
+ expect(
37
+ set.allowsRepo({ collection: 'com.example.foo', action: 'create' }),
38
+ ).toBe(true)
39
+ expect(
40
+ set.allowsRepo({ collection: 'com.example.foo', action: 'update' }),
41
+ ).toBe(true)
42
+ })
43
+ })
44
+
45
+ describe('allowsRpc', () => {
46
+ it('should allow rpc with transition:generic', () => {
47
+ const set = new PermissionSetTransition('transition:generic')
48
+ expect(
49
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'app.bsky.feed.post' }),
50
+ ).toBe(true)
51
+ expect(
52
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'com.example.foo' }),
53
+ ).toBe(true)
54
+ expect(set.allowsRpc({ aud: 'did:example:123', lxm: '*' })).toBe(true)
55
+ })
56
+
57
+ it('should allow chat.bsky.* methods with "transition:chat.bsky"', () => {
58
+ const set = new PermissionSetTransition('transition:chat.bsky')
59
+ expect(
60
+ set.allowsRpc({
61
+ aud: 'did:example:123',
62
+ lxm: 'chat.bsky.message.send',
63
+ }),
64
+ ).toBe(true)
65
+ expect(
66
+ set.allowsRpc({
67
+ aud: 'did:example:123',
68
+ lxm: 'chat.bsky.conversation.get',
69
+ }),
70
+ ).toBe(true)
71
+
72
+ // Control
73
+
74
+ expect(
75
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'app.bsky.feed.post' }),
76
+ ).toBe(false)
77
+ expect(
78
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'com.example.foo' }),
79
+ ).toBe(false)
80
+ expect(set.allowsRpc({ aud: 'did:example:123', lxm: '*' })).toBe(false)
81
+ })
82
+
83
+ it('should reject chat methods with "transition:generic"', () => {
84
+ const set = new PermissionSetTransition('transition:generic')
85
+
86
+ expect(
87
+ set.allowsRpc({
88
+ aud: 'did:example:123',
89
+ lxm: 'chat.bsky.message.send',
90
+ }),
91
+ ).toBe(false)
92
+ expect(
93
+ set.allowsRpc({
94
+ aud: 'did:example:123',
95
+ lxm: 'chat.bsky.conversation.get',
96
+ }),
97
+ ).toBe(false)
98
+
99
+ // Control
100
+
101
+ expect(
102
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'app.bsky.feed.post' }),
103
+ ).toBe(true)
104
+ expect(
105
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'com.example.foo' }),
106
+ ).toBe(true)
107
+ })
108
+ })
109
+ })
@@ -0,0 +1,67 @@
1
+ import {
2
+ AccountScopeMatch,
3
+ BlobScopeMatch,
4
+ PermissionSet,
5
+ RepoScopeMatch,
6
+ RpcScopeMatch,
7
+ } from './permission-set.js'
8
+
9
+ /**
10
+ * Overrides the default permission set to allow transitional scopes to be used
11
+ * in place of the generic scopes.
12
+ */
13
+ export class PermissionSetTransition extends PermissionSet {
14
+ get hasTransitionGeneric(): boolean {
15
+ return this.scopes.has('transition:generic')
16
+ }
17
+
18
+ get hasTransitionEmail(): boolean {
19
+ return this.scopes.has('transition:email')
20
+ }
21
+
22
+ get hasTransitionChatBsky(): boolean {
23
+ return this.scopes.has('transition:chat.bsky')
24
+ }
25
+
26
+ override allowsAccount(options: AccountScopeMatch): boolean {
27
+ if (options.attr === 'email' && this.hasTransitionEmail) {
28
+ return true
29
+ }
30
+
31
+ return super.allowsAccount(options)
32
+ }
33
+
34
+ override allowsBlob(options: BlobScopeMatch): boolean {
35
+ if (this.hasTransitionGeneric) {
36
+ return true
37
+ }
38
+
39
+ return super.allowsBlob(options)
40
+ }
41
+
42
+ override allowsRepo(options: RepoScopeMatch): boolean {
43
+ if (this.hasTransitionGeneric) {
44
+ return true
45
+ }
46
+
47
+ return super.allowsRepo(options)
48
+ }
49
+
50
+ override allowsRpc(options: RpcScopeMatch) {
51
+ const { lxm } = options
52
+
53
+ if (this.hasTransitionGeneric && lxm === '*') {
54
+ return true
55
+ }
56
+
57
+ if (this.hasTransitionGeneric && !lxm.startsWith('chat.bsky.')) {
58
+ return true
59
+ }
60
+
61
+ if (this.hasTransitionChatBsky && lxm.startsWith('chat.bsky.')) {
62
+ return true
63
+ }
64
+
65
+ return super.allowsRpc(options)
66
+ }
67
+ }
@@ -0,0 +1,225 @@
1
+ import { PermissionSet } from './permission-set.js'
2
+
3
+ describe('PermissionSet', () => {
4
+ describe('allowsAccount', () => {
5
+ it('should properly allow "account:email"', () => {
6
+ const set = new PermissionSet('account:email')
7
+
8
+ expect(set.allowsAccount({ attr: 'email', action: 'read' })).toBe(true)
9
+ expect(set.allowsAccount({ attr: 'email', action: 'manage' })).toBe(false)
10
+
11
+ expect(set.allowsAccount({ attr: 'repo', action: 'read' })).toBe(false)
12
+ expect(set.allowsAccount({ attr: 'repo', action: 'manage' })).toBe(false)
13
+
14
+ expect(set.allowsAccount({ attr: 'status', action: 'read' })).toBe(false)
15
+ expect(set.allowsAccount({ attr: 'status', action: 'manage' })).toBe(
16
+ false,
17
+ )
18
+ })
19
+
20
+ it('should ignore "transition:email"', () => {
21
+ const set = new PermissionSet('transition:email')
22
+
23
+ expect(set.allowsAccount({ attr: 'email', action: 'read' })).toBe(false)
24
+ expect(set.allowsAccount({ attr: 'email', action: 'manage' })).toBe(false)
25
+ })
26
+ })
27
+
28
+ describe('allowsBlob', () => {
29
+ it('should allow any mime with "blob:*/*"', () => {
30
+ const set = new PermissionSet('blob:*/*')
31
+ expect(set.allowsBlob({ mime: 'image/png' })).toBe(true)
32
+ expect(set.allowsBlob({ mime: 'application/json' })).toBe(true)
33
+ })
34
+
35
+ it('should only allow images with "blob:image/*"', () => {
36
+ const set = new PermissionSet('blob:image/*')
37
+ expect(set.allowsBlob({ mime: 'image/png' })).toBe(true)
38
+ expect(set.allowsBlob({ mime: 'application/json' })).toBe(false)
39
+ })
40
+
41
+ it('should ignore invalid scope "blob:*"', () => {
42
+ const set = new PermissionSet('blob:*')
43
+ expect(set.allowsBlob({ mime: 'image/png' })).toBe(false)
44
+ expect(set.allowsBlob({ mime: 'application/json' })).toBe(false)
45
+ })
46
+
47
+ it('should ignore invalid scope "blob:/image"', () => {
48
+ const set = new PermissionSet('blob:/image')
49
+ expect(set.allowsBlob({ mime: 'image/png' })).toBe(false)
50
+ expect(set.allowsBlob({ mime: 'application/json' })).toBe(false)
51
+ })
52
+
53
+ it('should ignore "transition:generic"', () => {
54
+ const set = new PermissionSet('transition:generic')
55
+ expect(set.allowsBlob({ mime: 'image/png' })).toBe(false)
56
+ expect(set.allowsBlob({ mime: 'application/json' })).toBe(false)
57
+ })
58
+ })
59
+
60
+ describe('allowsRepo', () => {
61
+ it('should allow any repo action with "repo:*"', () => {
62
+ const set = new PermissionSet('repo:*')
63
+ expect(
64
+ set.allowsRepo({ collection: 'com.example.foo', action: 'create' }),
65
+ ).toBe(true)
66
+ expect(
67
+ set.allowsRepo({ collection: 'com.example.foo', action: 'update' }),
68
+ ).toBe(true)
69
+ expect(
70
+ set.allowsRepo({ collection: 'app.bsky.feed.post', action: 'delete' }),
71
+ ).toBe(true)
72
+ })
73
+
74
+ it('should allow specific repo actions', () => {
75
+ const set = new PermissionSet('repo:*?action=create')
76
+ expect(
77
+ set.allowsRepo({ collection: 'com.example.foo', action: 'create' }),
78
+ ).toBe(true)
79
+ expect(
80
+ set.allowsRepo({ collection: 'app.bsky.feed.post', action: 'create' }),
81
+ ).toBe(true)
82
+
83
+ // Control
84
+
85
+ expect(
86
+ set.allowsRepo({ collection: 'com.example.foo', action: 'update' }),
87
+ ).toBe(false)
88
+ expect(
89
+ set.allowsRepo({ collection: 'app.bsky.feed.post', action: 'delete' }),
90
+ ).toBe(false)
91
+ })
92
+
93
+ it('should allow specific repo collection & actions', () => {
94
+ const set = new PermissionSet('repo:com.example.foo?action=create')
95
+ expect(
96
+ set.allowsRepo({ collection: 'com.example.foo', action: 'create' }),
97
+ ).toBe(true)
98
+
99
+ // Control
100
+
101
+ expect(
102
+ set.allowsRepo({ collection: 'com.example.foo', action: 'update' }),
103
+ ).toBe(false)
104
+ expect(
105
+ set.allowsRepo({ collection: 'app.bsky.feed.post', action: 'delete' }),
106
+ ).toBe(false)
107
+ expect(
108
+ set.allowsRepo({ collection: 'app.bsky.feed.post', action: 'create' }),
109
+ ).toBe(false)
110
+ })
111
+
112
+ it('should ignore transition:generic', () => {
113
+ const set = new PermissionSet('transition:generic')
114
+ expect(
115
+ set.allowsRepo({ collection: 'app.bsky.feed.post', action: 'create' }),
116
+ ).toBe(false)
117
+ expect(
118
+ set.allowsRepo({ collection: 'app.bsky.feed.post', action: 'delete' }),
119
+ ).toBe(false)
120
+ expect(
121
+ set.allowsRepo({ collection: 'com.example.foo', action: 'create' }),
122
+ ).toBe(false)
123
+ expect(
124
+ set.allowsRepo({ collection: 'com.example.foo', action: 'update' }),
125
+ ).toBe(false)
126
+ })
127
+ })
128
+
129
+ describe('allowsRpc', () => {
130
+ it('should ignore "rpc:*?lxm=*"', () => {
131
+ const set = new PermissionSet('rpc:*?lxm=*')
132
+ expect(
133
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'com.example.method' }),
134
+ ).toBe(false)
135
+ expect(
136
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'app.bsky.feed.getFeed' }),
137
+ ).toBe(false)
138
+ })
139
+
140
+ it('should allow constraining "lxm"', () => {
141
+ const set = new PermissionSet('rpc:app.bsky.feed.getFeed?aud=*')
142
+ expect(
143
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'app.bsky.feed.getFeed' }),
144
+ ).toBe(true)
145
+ expect(
146
+ set.allowsRpc({ aud: 'did:plc:blahbla', lxm: 'app.bsky.feed.getFeed' }),
147
+ ).toBe(true)
148
+
149
+ // Control
150
+
151
+ expect(
152
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'com.example.method' }),
153
+ ).toBe(false)
154
+ })
155
+
156
+ it('should allow constraining "aud"', () => {
157
+ const set = new PermissionSet('rpc:*?aud=did:example:123')
158
+ expect(
159
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'com.example.method' }),
160
+ ).toBe(true)
161
+ expect(
162
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'app.bsky.feed.getFeed' }),
163
+ ).toBe(true)
164
+
165
+ // Control
166
+
167
+ expect(
168
+ set.allowsRpc({ aud: 'did:plc:blahbla', lxm: 'com.example.method' }),
169
+ ).toBe(false)
170
+ })
171
+
172
+ it('should allow constraining "lxm" and "aud"', () => {
173
+ const set = new PermissionSet(
174
+ 'rpc:app.bsky.feed.getFeed?aud=did:example:123',
175
+ )
176
+ expect(
177
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'app.bsky.feed.getFeed' }),
178
+ ).toBe(true)
179
+
180
+ // Control
181
+
182
+ expect(
183
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'com.example.method' }),
184
+ ).toBe(false)
185
+ expect(
186
+ set.allowsRpc({ aud: 'did:plc:blahbla', lxm: 'app.bsky.feed.getFeed' }),
187
+ ).toBe(false)
188
+ })
189
+
190
+ it('should ignore "transition:generic"', () => {
191
+ const set = new PermissionSet('transition:generic')
192
+ expect(
193
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'app.bsky.feed.getFeed' }),
194
+ ).toBe(false)
195
+ expect(
196
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'com.example.method' }),
197
+ ).toBe(false)
198
+ })
199
+
200
+ it('should ignore "transition:chat.bsky"', () => {
201
+ const set = new PermissionSet('transition:chat.bsky')
202
+ expect(
203
+ set.allowsRpc({
204
+ aud: 'did:example:123',
205
+ lxm: 'chat.bsky.message.send',
206
+ }),
207
+ ).toBe(false)
208
+ expect(
209
+ set.allowsRpc({
210
+ aud: 'did:example:123',
211
+ lxm: 'chat.bsky.conversation.get',
212
+ }),
213
+ ).toBe(false)
214
+
215
+ // Control
216
+
217
+ expect(
218
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'app.bsky.feed.post' }),
219
+ ).toBe(false)
220
+ expect(
221
+ set.allowsRpc({ aud: 'did:example:123', lxm: 'com.example.foo' }),
222
+ ).toBe(false)
223
+ })
224
+ })
225
+ })