@pikku/core 0.12.64 → 0.12.67

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 (74) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.js +1 -1
  4. package/dist/permissions.d.ts +12 -4
  5. package/dist/permissions.js +11 -32
  6. package/dist/scopes.d.ts +14 -0
  7. package/dist/scopes.js +39 -8
  8. package/dist/testing/service-tests.js +37 -0
  9. package/dist/types/core.types.d.ts +22 -0
  10. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
  11. package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
  12. package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
  13. package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
  14. package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
  15. package/dist/wirings/ai-agent/index.d.ts +1 -1
  16. package/dist/wirings/ai-agent/index.js +1 -1
  17. package/dist/wirings/ai-agent/voice-input.js +3 -3
  18. package/dist/wirings/cli/cli-runner.js +3 -0
  19. package/dist/wirings/cli/command-parser.d.ts +2 -0
  20. package/dist/wirings/cli/command-parser.js +59 -2
  21. package/dist/wirings/credential/credential.types.d.ts +14 -0
  22. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  23. package/dist/wirings/gateway/gateway-runner.js +100 -50
  24. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  25. package/dist/wirings/http/http.types.d.ts +3 -3
  26. package/dist/wirings/scope/validate-scope-definitions.d.ts +8 -0
  27. package/dist/wirings/scope/validate-scope-definitions.js +16 -1
  28. package/dist/wirings/secret/secret.types.d.ts +14 -0
  29. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  30. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  31. package/dist/wirings/variable/variable.types.d.ts +14 -0
  32. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
  33. package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
  34. package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
  35. package/dist/wirings/workflow/workflow.types.d.ts +0 -2
  36. package/package.json +2 -1
  37. package/src/index.ts +2 -1
  38. package/src/permissions.test.ts +14 -8
  39. package/src/permissions.ts +14 -36
  40. package/src/scopes.test.ts +37 -1
  41. package/src/scopes.ts +48 -9
  42. package/src/testing/service-tests.ts +49 -0
  43. package/src/types/core.types.ts +23 -0
  44. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  45. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
  46. package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
  47. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  48. package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
  49. package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
  50. package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
  51. package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
  52. package/src/wirings/ai-agent/index.ts +4 -0
  53. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  54. package/src/wirings/ai-agent/voice-input.ts +8 -10
  55. package/src/wirings/cli/cli-runner.ts +4 -0
  56. package/src/wirings/cli/command-parser.test.ts +130 -0
  57. package/src/wirings/cli/command-parser.ts +80 -2
  58. package/src/wirings/credential/credential.types.ts +14 -0
  59. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  60. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  61. package/src/wirings/gateway/gateway-runner.ts +114 -68
  62. package/src/wirings/gateway/gateway.types.ts +7 -9
  63. package/src/wirings/http/http.types.ts +6 -4
  64. package/src/wirings/scope/scope.test.ts +25 -0
  65. package/src/wirings/scope/validate-scope-definitions.ts +16 -1
  66. package/src/wirings/secret/secret.types.ts +14 -0
  67. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  68. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  69. package/src/wirings/variable/variable.types.ts +14 -0
  70. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
  71. package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
  72. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  73. package/src/wirings/workflow/workflow.types.ts +0 -2
  74. package/tsconfig.tsbuildinfo +1 -1
@@ -1,7 +1,6 @@
1
1
  import type {
2
2
  CoreServices,
3
3
  CoreUserSession,
4
- PermissionMetadata,
5
4
  PikkuWire,
6
5
  } from './types/core.types.js'
7
6
  import type {
@@ -60,29 +59,6 @@ const verifyPermissions = async (
60
59
  return false
61
60
  }
62
61
 
63
- /**
64
- * Retrieves a registered permission function by its name.
65
- *
66
- * This function looks up permissions that was registered with registerPermission.
67
- * It's used internally by the framework to resolve permission references in metadata.
68
- *
69
- * @param {string} name - The unique name (pikkuFuncId) of the permission function.
70
- * @param {string | null} packageName - Optional package namespace.
71
- * @returns {CorePikkuPermission | undefined} The permission function, or undefined if not found.
72
- *
73
- * @internal
74
- */
75
- const getPermissionByName = (
76
- name: string,
77
- packageName: string | null = null
78
- ): CorePikkuPermission | undefined => {
79
- const permissionStore = pikkuState(packageName, 'misc', 'permissions')
80
- const permission = permissionStore[name]
81
- if (Array.isArray(permission) && permission.length === 1) {
82
- return permission[0]
83
- }
84
- return undefined
85
- }
86
62
 
87
63
  const globalPermissionsCache: Record<
88
64
  string,
@@ -190,12 +166,15 @@ export const runPermissions = async ({
190
166
  wire,
191
167
  data,
192
168
  packageName = null,
169
+ label = 'function',
193
170
  }: {
194
171
  funcPermissions?: CorePermissionGroup | CorePikkuPermission[]
195
172
  services: CoreServices
196
173
  wire: PikkuWire<any, never, any, CoreUserSession, never, never, never>
197
174
  data: any
198
175
  packageName?: string | null
176
+ /** What the non-global gate is called in debug logs, e.g. 'function', 'agent'. */
177
+ label?: string
199
178
  }) => {
200
179
  const globals = resolveGlobalPermissions(packageName)
201
180
  for (const entry of globals) {
@@ -211,7 +190,7 @@ export const runPermissions = async ({
211
190
  : funcPermissions
212
191
  if (group && Object.keys(group).length > 0) {
213
192
  if (!(await verifyPermissions(group, services, data, wire))) {
214
- services.logger.debug('Permission denied - function permission')
193
+ services.logger.debug(`Permission denied - ${label} permission`)
215
194
  throw new ForbiddenError('Permission denied')
216
195
  }
217
196
  }
@@ -224,14 +203,20 @@ export const runPermissions = async ({
224
203
  * request data which isn't available at filter time. Global auth requirements
225
204
  * are included so a filtered list honours app-wide auth.
226
205
  *
227
- * @param funcPermissions - The PermissionMetadata[] from function or agent metadata
206
+ * `funcPermissions` is the live {@link CorePermissionGroup} from the function or
207
+ * agent config, not the metadata form: the `pikkuAuth` brand only survives on
208
+ * the actual predicate objects, and the by-name registry those metadata entries
209
+ * would resolve against is never populated. Passing metadata here would silently
210
+ * collect nothing and let every gated tool through.
211
+ *
212
+ * @param funcPermissions - The live permission group from the func/agent config
228
213
  * @param session - The user's session
229
214
  * @param services - Singleton services
230
215
  * @param packageName - Optional package namespace
231
216
  * @returns true if the session passes the auth checks (or no auth checks exist)
232
217
  */
233
218
  export const checkAuthPermissions = async (
234
- funcPermissions: PermissionMetadata[] | undefined,
219
+ funcPermissions: CorePermissionGroup | undefined,
235
220
  session: CoreUserSession,
236
221
  services: CoreServices,
237
222
  packageName: string | null = null
@@ -271,15 +256,8 @@ export const checkAuthPermissions = async (
271
256
  collect(entry)
272
257
  }
273
258
 
274
- if (funcPermissions?.length) {
275
- for (const meta of funcPermissions) {
276
- if (meta.type === 'wire') {
277
- const permission = getPermissionByName(meta.name, packageName)
278
- if (permission) {
279
- collect(permission)
280
- }
281
- }
282
- }
259
+ if (funcPermissions) {
260
+ collect(funcPermissions)
283
261
  }
284
262
 
285
263
  // No auth permissions = allowed (only data-dependent permissions exist)
@@ -1,6 +1,6 @@
1
1
  import { describe, test } from 'node:test'
2
2
  import * as assert from 'node:assert'
3
- import { verifyScopes } from './scopes.js'
3
+ import { hasScopes, verifyScopes } from './scopes.js'
4
4
  import { MissingScopeError } from './errors/errors.js'
5
5
  import type { CoreUserSession } from './types/core.types.js'
6
6
 
@@ -165,3 +165,39 @@ describe('verifyScopes', () => {
165
165
  )
166
166
  })
167
167
  })
168
+
169
+ describe('hasScopes', () => {
170
+ test('true on an exact match', () => {
171
+ assert.equal(hasScopes(['invoices:create'], ['invoices:create']), true)
172
+ })
173
+
174
+ test('false when the grant is unrelated', () => {
175
+ assert.equal(hasScopes(['invoices:create'], ['billing:read']), false)
176
+ })
177
+
178
+ test('shares the hierarchy rules with verifyScopes', () => {
179
+ assert.equal(hasScopes(['admin:impersonate'], ['admin']), true)
180
+ assert.equal(hasScopes(['admin:impersonate'], ['admin:*']), true)
181
+ assert.equal(hasScopes(['admin:impersonate'], ['*']), true)
182
+ assert.equal(hasScopes(['admin'], ['admin:impersonate']), false)
183
+ })
184
+
185
+ test('AND semantics: every required scope must be satisfied', () => {
186
+ assert.equal(hasScopes(['a:read', 'a:write'], ['a:read']), false)
187
+ assert.equal(hasScopes(['a:read', 'a:write'], ['a']), true)
188
+ })
189
+
190
+ test('fails closed on absent or empty grants', () => {
191
+ assert.equal(hasScopes(['invoices:create'], undefined), false)
192
+ assert.equal(hasScopes(['invoices:create'], []), false)
193
+ })
194
+
195
+ test('an empty requirement is satisfied by anything', () => {
196
+ assert.equal(hasScopes([], undefined), true)
197
+ assert.equal(hasScopes(undefined, undefined), true)
198
+ })
199
+
200
+ test('accepts a Set of held grants', () => {
201
+ assert.equal(hasScopes(['admin:impersonate'], new Set(['admin'])), true)
202
+ })
203
+ })
package/src/scopes.ts CHANGED
@@ -37,6 +37,51 @@ const satisfyingGrants = (scope: string): string[] => {
37
37
  const holds = (held: ReadonlySet<string>, scope: string): boolean =>
38
38
  satisfyingGrants(scope).some((grant) => held.has(grant))
39
39
 
40
+ /**
41
+ * The first required scope a set of held grants does not satisfy, or `null`
42
+ * when every one is satisfied.
43
+ *
44
+ * Scopes are an AND gate: every entry in `required` must be satisfied. This is
45
+ * deliberately distinct from `permissions`, which OR together — a scope can
46
+ * only ever narrow access, so adding one to a function can never widen it.
47
+ *
48
+ * Fails closed: an absent `held` satisfies nothing.
49
+ */
50
+ const firstUnsatisfied = (
51
+ required: readonly string[] | undefined,
52
+ held: Iterable<string> | undefined
53
+ ): string | null => {
54
+ if (!required || required.length === 0) {
55
+ return null
56
+ }
57
+
58
+ const grants = new Set(held ?? [])
59
+ for (const scope of required) {
60
+ if (!holds(grants, scope)) {
61
+ return scope
62
+ }
63
+ }
64
+ return null
65
+ }
66
+
67
+ /**
68
+ * Whether a set of held grants satisfies every required scope.
69
+ *
70
+ * The non-throwing counterpart to {@link verifyScopes}, for deciding rather
71
+ * than enforcing — an authorization gate that falls back to another check when
72
+ * it is not satisfied, rather than rejecting the request outright.
73
+ *
74
+ * Fails closed: an absent or empty `held` satisfies nothing. An empty
75
+ * `required` is satisfied by anything.
76
+ *
77
+ * @param required - Scopes to check for. Empty means no gate.
78
+ * @param held - The grants held, e.g. `session.scopes`. May be undefined.
79
+ */
80
+ export const hasScopes = (
81
+ required: readonly string[] | undefined,
82
+ held: Iterable<string> | undefined
83
+ ): boolean => firstUnsatisfied(required, held) === null
84
+
40
85
  /**
41
86
  * Verifies that a session holds every required scope, throwing on the first
42
87
  * one it does not.
@@ -56,14 +101,8 @@ export const verifyScopes = (
56
101
  required: readonly string[] | undefined,
57
102
  session: CoreUserSession | undefined
58
103
  ): void => {
59
- if (!required || required.length === 0) {
60
- return
61
- }
62
-
63
- const held = new Set(session?.scopes ?? [])
64
- for (const scope of required) {
65
- if (!holds(held, scope)) {
66
- throw new MissingScopeError(scope)
67
- }
104
+ const missing = firstUnsatisfied(required, session?.scopes)
105
+ if (missing !== null) {
106
+ throw new MissingScopeError(missing)
68
107
  }
69
108
  }
@@ -1086,6 +1086,55 @@ export function defineServiceTests(config: ServiceTestConfig): void {
1086
1086
  assert.ok(Array.isArray(threads))
1087
1087
  })
1088
1088
 
1089
+ if (services.aiStorageService) {
1090
+ const storageFactory = services.aiStorageService
1091
+
1092
+ // The `owners` constraint is what keeps the generated thread-management
1093
+ // functions from leaking across tenants: a caller may only list threads
1094
+ // owned by one of their session principals, matching the
1095
+ // `principal:sub-partition` composition resolveOwnerResourceId writes.
1096
+ test('listThreads scopes to the given owners, including sub-partitions', async () => {
1097
+ const storage = await storageFactory()
1098
+ await storage.createThread('owner-alice')
1099
+ await storage.createThread('owner-alice:project-1')
1100
+ await storage.createThread('owner-bob:secret')
1101
+
1102
+ const threads = await agentService.listThreads({
1103
+ owners: ['owner-alice'],
1104
+ })
1105
+ const ids = threads.map((t) => t.resourceId)
1106
+
1107
+ assert.ok(ids.includes('owner-alice'))
1108
+ assert.ok(ids.includes('owner-alice:project-1'))
1109
+ assert.ok(
1110
+ !ids.some((id) => id.startsWith('owner-bob')),
1111
+ "another owner's threads must not be listed"
1112
+ )
1113
+ })
1114
+
1115
+ test('listThreads with an owner does not match a lookalike prefix', async () => {
1116
+ const storage = await storageFactory()
1117
+ await storage.createThread('owner-al')
1118
+ await storage.createThread('owner-alice-evil:p')
1119
+
1120
+ const threads = await agentService.listThreads({
1121
+ owners: ['owner-al'],
1122
+ })
1123
+ const ids = threads.map((t) => t.resourceId)
1124
+
1125
+ assert.ok(ids.includes('owner-al'))
1126
+ assert.ok(!ids.includes('owner-alice-evil:p'))
1127
+ })
1128
+
1129
+ test('listThreads with an empty owners list returns nothing', async () => {
1130
+ const storage = await storageFactory()
1131
+ await storage.createThread('owner-empty-check')
1132
+
1133
+ const threads = await agentService.listThreads({ owners: [] })
1134
+ assert.deepEqual(threads, [])
1135
+ })
1136
+ }
1137
+
1089
1138
  test('getThread returns null for missing', async () => {
1090
1139
  const thread = await agentService.getThread('missing-thread')
1091
1140
  assert.equal(thread, null)
@@ -269,6 +269,22 @@ export interface CoreUserSession {
269
269
  scopes?: string[]
270
270
  }
271
271
 
272
+ /**
273
+ * The shape pikku needs from whatever auth library a project wires: something
274
+ * that can answer an HTTP request and expose its own endpoints as callable
275
+ * methods. Kept structural so core stays independent of any one auth package —
276
+ * `@pikku/better-auth`'s `BetterAuthInstance` is this type.
277
+ */
278
+ export interface AuthInstance {
279
+ handler: (request: Request) => Promise<Response>
280
+ api: Record<string, any>
281
+ /**
282
+ * The auth library's resolved context. Optional because a hand-built instance
283
+ * may omit it.
284
+ */
285
+ $context?: Promise<any>
286
+ }
287
+
272
288
  /**
273
289
  * Interface for core singleton services provided by Pikku.
274
290
  */
@@ -341,6 +357,13 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
341
357
  * better-auth's `mapSession`), never by the function runner.
342
358
  */
343
359
  scopeService?: ScopeService
360
+ /**
361
+ * The project's resolved auth instance, built once by the factory an auth
362
+ * package registers (e.g. `pikkuBetterAuth`) and injected by the generated
363
+ * `pikkuServices` wrapper — which is why service factories are forbidden from
364
+ * returning it themselves. Absent when the project wires no auth.
365
+ */
366
+ auth?: () => Promise<AuthInstance>
344
367
  }
345
368
 
346
369
  /**
@@ -0,0 +1,204 @@
1
+ import { beforeEach, describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ resetPikkuState,
6
+ pikkuState,
7
+ setSingletonServices,
8
+ } from '../../pikku-state.js'
9
+ import { assertAgentAuthorized } from './ai-agent-prepare.js'
10
+ import { clearPermissionsCache } from '../../permissions.js'
11
+ import { ForbiddenError, MissingScopeError } from '../../errors/errors.js'
12
+ import type { CoreAIAgent } from './ai-agent.types.js'
13
+ import type { CoreUserSession } from '../../types/core.types.js'
14
+
15
+ const logger = {
16
+ debug: () => {},
17
+ info: () => {},
18
+ warn: () => {},
19
+ error: () => {},
20
+ } as any
21
+
22
+ beforeEach(() => {
23
+ resetPikkuState()
24
+ clearPermissionsCache()
25
+ setSingletonServices({ logger } as any)
26
+ })
27
+
28
+ const addAgent = (
29
+ agentName: string,
30
+ overrides: Partial<CoreAIAgent> = {}
31
+ ): CoreAIAgent => {
32
+ const agent: CoreAIAgent = {
33
+ name: agentName,
34
+ description: `${agentName} description`,
35
+ goal: `${agentName} goal`,
36
+ instructions: `${agentName} instructions`,
37
+ model: 'test/test-model',
38
+ ...overrides,
39
+ } as CoreAIAgent
40
+
41
+ pikkuState(null, 'agent', 'agents').set(agentName, agent)
42
+ return agent
43
+ }
44
+
45
+ const sessionService = (session: CoreUserSession | undefined) =>
46
+ ({
47
+ get: () => session,
48
+ setInitial: () => {},
49
+ sessionChanged: false,
50
+ }) as any
51
+
52
+ describe('assertAgentAuthorized', () => {
53
+ test('resolves when the agent declares no authorization and a session exists', async () => {
54
+ const agent = addAgent('open')
55
+ await assertAgentAuthorized(
56
+ agent,
57
+ { sessionService: sessionService({ userId: 'u1' }) },
58
+ null
59
+ )
60
+ })
61
+
62
+ test('does not require a session unless auth is explicitly true', async () => {
63
+ const agent = addAgent('sessionless')
64
+ await assertAgentAuthorized(
65
+ agent,
66
+ { sessionService: sessionService(undefined) },
67
+ null
68
+ )
69
+ })
70
+
71
+ test('resolves with no sessionService at all (cron / queue invocation)', async () => {
72
+ const agent = addAgent('no-session-service')
73
+ await assertAgentAuthorized(agent, {}, null)
74
+ })
75
+
76
+ test('throws ForbiddenError when auth is explicitly true and no session exists', async () => {
77
+ const agent = addAgent('needs-session', { auth: true })
78
+ await assert.rejects(
79
+ () =>
80
+ assertAgentAuthorized(
81
+ agent,
82
+ { sessionService: sessionService(undefined) },
83
+ null
84
+ ),
85
+ ForbiddenError
86
+ )
87
+ })
88
+
89
+ test('enforces scopes even when auth is not required', async () => {
90
+ const agent = addAgent('scoped-sessionless', { scopes: ['reports:read'] })
91
+ await assert.rejects(
92
+ () => assertAgentAuthorized(agent, {}, null),
93
+ MissingScopeError
94
+ )
95
+ })
96
+
97
+ test('throws ForbiddenError when the permission group denies', async () => {
98
+ const agent = addAgent('guarded', {
99
+ permissions: { admin: async () => false },
100
+ })
101
+ await assert.rejects(
102
+ () =>
103
+ assertAgentAuthorized(
104
+ agent,
105
+ { sessionService: sessionService({ userId: 'u1' }) },
106
+ null
107
+ ),
108
+ ForbiddenError
109
+ )
110
+ })
111
+
112
+ test('resolves when at least one permission branch passes', async () => {
113
+ const agent = addAgent('or-group', {
114
+ permissions: {
115
+ admin: async () => false,
116
+ owner: async () => true,
117
+ },
118
+ })
119
+ await assertAgentAuthorized(
120
+ agent,
121
+ { sessionService: sessionService({ userId: 'u1' }) },
122
+ null
123
+ )
124
+ })
125
+
126
+ test('passes the session through to permission functions', async () => {
127
+ let seen: CoreUserSession | undefined
128
+ const agent = addAgent('sees-session', {
129
+ permissions: {
130
+ admin: async (_services: any, _data: any, wire: any) => {
131
+ seen = wire.session
132
+ return true
133
+ },
134
+ },
135
+ })
136
+ await assertAgentAuthorized(
137
+ agent,
138
+ { sessionService: sessionService({ userId: 'u1' }) },
139
+ null
140
+ )
141
+ assert.equal(seen?.userId, 'u1')
142
+ })
143
+
144
+ test('throws MissingScopeError when a required scope is not held', async () => {
145
+ const agent = addAgent('scoped', { scopes: ['reports:read'] })
146
+ await assert.rejects(
147
+ () =>
148
+ assertAgentAuthorized(
149
+ agent,
150
+ { sessionService: sessionService({ userId: 'u1', scopes: [] }) },
151
+ null
152
+ ),
153
+ MissingScopeError
154
+ )
155
+ })
156
+
157
+ test('resolves when the session holds the required scope', async () => {
158
+ const agent = addAgent('scoped-ok', { scopes: ['reports:read'] })
159
+ await assertAgentAuthorized(
160
+ agent,
161
+ {
162
+ sessionService: sessionService({
163
+ userId: 'u1',
164
+ scopes: ['reports:read'],
165
+ }),
166
+ },
167
+ null
168
+ )
169
+ })
170
+
171
+ test('resolves when an ancestor scope grant covers the requirement', async () => {
172
+ const agent = addAgent('scoped-parent', { scopes: ['reports:read'] })
173
+ await assertAgentAuthorized(
174
+ agent,
175
+ {
176
+ sessionService: sessionService({ userId: 'u1', scopes: ['reports'] }),
177
+ },
178
+ null
179
+ )
180
+ })
181
+
182
+ test('checks scopes before permissions', async () => {
183
+ let permissionRan = false
184
+ const agent = addAgent('scope-first', {
185
+ scopes: ['reports:read'],
186
+ permissions: {
187
+ admin: async () => {
188
+ permissionRan = true
189
+ return true
190
+ },
191
+ },
192
+ })
193
+ await assert.rejects(
194
+ () =>
195
+ assertAgentAuthorized(
196
+ agent,
197
+ { sessionService: sessionService({ userId: 'u1', scopes: [] }) },
198
+ null
199
+ ),
200
+ MissingScopeError
201
+ )
202
+ assert.equal(permissionRan, false)
203
+ })
204
+ })