@pikku/core 0.12.64 → 0.12.66

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 (61) hide show
  1. package/CHANGELOG.md +221 -0
  2. package/dist/permissions.d.ts +12 -4
  3. package/dist/permissions.js +11 -32
  4. package/dist/testing/service-tests.js +37 -0
  5. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
  6. package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
  7. package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
  8. package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
  9. package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
  10. package/dist/wirings/ai-agent/index.d.ts +1 -1
  11. package/dist/wirings/ai-agent/index.js +1 -1
  12. package/dist/wirings/ai-agent/voice-input.js +3 -3
  13. package/dist/wirings/cli/cli-runner.js +3 -0
  14. package/dist/wirings/cli/command-parser.d.ts +2 -0
  15. package/dist/wirings/cli/command-parser.js +59 -2
  16. package/dist/wirings/credential/credential.types.d.ts +14 -0
  17. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  18. package/dist/wirings/gateway/gateway-runner.js +100 -50
  19. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  20. package/dist/wirings/http/http.types.d.ts +3 -3
  21. package/dist/wirings/secret/secret.types.d.ts +14 -0
  22. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  23. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  24. package/dist/wirings/variable/variable.types.d.ts +14 -0
  25. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
  26. package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
  27. package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
  28. package/dist/wirings/workflow/workflow.types.d.ts +0 -2
  29. package/package.json +2 -1
  30. package/src/permissions.test.ts +14 -8
  31. package/src/permissions.ts +14 -36
  32. package/src/testing/service-tests.ts +49 -0
  33. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  34. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
  35. package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
  36. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  37. package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
  38. package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
  39. package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
  40. package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
  41. package/src/wirings/ai-agent/index.ts +4 -0
  42. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  43. package/src/wirings/ai-agent/voice-input.ts +8 -10
  44. package/src/wirings/cli/cli-runner.ts +4 -0
  45. package/src/wirings/cli/command-parser.test.ts +130 -0
  46. package/src/wirings/cli/command-parser.ts +80 -2
  47. package/src/wirings/credential/credential.types.ts +14 -0
  48. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  49. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  50. package/src/wirings/gateway/gateway-runner.ts +114 -68
  51. package/src/wirings/gateway/gateway.types.ts +7 -9
  52. package/src/wirings/http/http.types.ts +6 -4
  53. package/src/wirings/secret/secret.types.ts +14 -0
  54. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  55. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  56. package/src/wirings/variable/variable.types.ts +14 -0
  57. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
  58. package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
  59. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  60. package/src/wirings/workflow/workflow.types.ts +0 -2
  61. package/tsconfig.tsbuildinfo +1 -1
@@ -24,6 +24,7 @@ export function validateAndBuildVariableDefinitionsMeta(definitions, schemaLooku
24
24
  description: def.description,
25
25
  variableId: def.variableId,
26
26
  schema: def.schema,
27
+ docsUrl: def.docsUrl,
27
28
  sourceFile: def.sourceFile,
28
29
  };
29
30
  }
@@ -37,6 +38,7 @@ export function validateAndBuildVariableDefinitionsMeta(definitions, schemaLooku
37
38
  description: def.description,
38
39
  variableId: def.variableId,
39
40
  schema: def.schema,
41
+ docsUrl: def.docsUrl,
40
42
  sourceFile: def.sourceFile,
41
43
  };
42
44
  }
@@ -4,6 +4,13 @@ export type CoreVariable<T = unknown> = {
4
4
  description?: string;
5
5
  variableId: string;
6
6
  schema: T;
7
+ /**
8
+ * Link to documentation explaining how to obtain this value — a provider's
9
+ * API-key page, a setup guide, an internal runbook. Surfaced by consoles and
10
+ * deploy UIs so a user facing a missing value has somewhere to go instead of
11
+ * an opaque identifier.
12
+ */
13
+ docsUrl?: string;
7
14
  };
8
15
  export type VariableDefinitionMeta = {
9
16
  name: string;
@@ -11,6 +18,13 @@ export type VariableDefinitionMeta = {
11
18
  description?: string;
12
19
  variableId: string;
13
20
  schema?: Record<string, unknown> | string;
21
+ /**
22
+ * Link to documentation explaining how to obtain this value — a provider's
23
+ * API-key page, a setup guide, an internal runbook. Surfaced by consoles and
24
+ * deploy UIs so a user facing a missing value has somewhere to go instead of
25
+ * an opaque identifier.
26
+ */
27
+ docsUrl?: string;
14
28
  sourceFile?: string;
15
29
  };
16
30
  export type VariableDefinitionsMeta = Record<string, VariableDefinitionMeta>;
@@ -15,6 +15,13 @@ export interface WorkflowStepOptions {
15
15
  retries?: number;
16
16
  /** Delay between retry attempts (e.g., '1s', '2s', '2min') */
17
17
  retryDelay?: string | number;
18
+ /**
19
+ * RPC to invoke for compensation when this step fails after exhausting its
20
+ * retries. Mirrors a graph node's `onError`: the handler receives
21
+ * `{ error: { message } }` and the original error is still thrown, so the
22
+ * workflow fails — this is compensation, not recovery.
23
+ */
24
+ onError?: string;
18
25
  /**
19
26
  * Run this step as an actor (scenarios). The RPC is sent through the
20
27
  * actor's authenticated client over the REAL transport — never dispatched
@@ -214,16 +221,20 @@ export interface ParallelGroupStepMeta {
214
221
  */
215
222
  export interface FanoutStepMeta {
216
223
  type: 'fanout';
217
- /** Step name for this fanout */
218
- stepName: string;
224
+ /**
225
+ * Step name for this fanout. Optional: a fanout is not itself a cached step,
226
+ * and node ids are step names — borrowing a body step's name would give the
227
+ * loop and that step the same id, collapsing one onto the other.
228
+ */
229
+ stepName?: string;
219
230
  /** Source array variable name */
220
231
  sourceVar: string;
221
232
  /** Iterator variable name */
222
233
  itemVar: string;
223
234
  /** Execution mode */
224
235
  mode: 'parallel' | 'sequential';
225
- /** Child step to execute per iteration */
226
- child: RpcStepMeta;
236
+ /** Steps to execute inline per iteration, in order */
237
+ body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>;
227
238
  /** Time between iterations (sequential mode only) */
228
239
  timeBetween?: string;
229
240
  }
@@ -234,6 +245,12 @@ export interface ReturnStepMeta {
234
245
  type: 'return';
235
246
  /** Output bindings */
236
247
  outputs: Record<string, OutputBinding>;
248
+ /**
249
+ * Variables spread into the returned object (`return { ...r }`), or the sole
250
+ * returned variable (`return r`). Their fields are not enumerable statically,
251
+ * so they are recorded by name rather than expanded into `outputs`.
252
+ */
253
+ spread?: string[];
237
254
  }
238
255
  /**
239
256
  * Inline step metadata (legacy support)
@@ -258,6 +275,13 @@ export interface SleepStepMeta {
258
275
  stepName: string;
259
276
  /** Sleep duration */
260
277
  duration: string | number;
278
+ /**
279
+ * Source text of a duration only known at runtime (e.g. a loop variable).
280
+ * The closure evaluates it, so it is legal DSL; it is kept separate from
281
+ * `duration` so regenerated code emits it raw rather than as a string
282
+ * literal, exactly as `expression` does on a set step.
283
+ */
284
+ expression?: string;
261
285
  }
262
286
  /**
263
287
  * Cancel step metadata
@@ -276,8 +300,14 @@ export interface SetStepMeta {
276
300
  type: 'set';
277
301
  /** Variable name to set (must be in context) */
278
302
  variable: string;
279
- /** Value to assign (literal or expression) */
280
- value: unknown;
303
+ /** Literal value to assign. Mutually exclusive with `expression`. */
304
+ value?: unknown;
305
+ /**
306
+ * Source text of a non-literal assignment (e.g. `count + 1`). Kept separate
307
+ * from `value` so regenerated code can emit it raw — a string `value` is a
308
+ * string literal, an `expression` is code.
309
+ */
310
+ expression?: string;
281
311
  }
282
312
  /**
283
313
  * Switch case metadata
@@ -443,6 +443,14 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
443
443
  * loop exits immediately without recording a step error or retrying.
444
444
  */
445
445
  private runInlineRetryLoop;
446
+ /**
447
+ * Run a failed step's compensation handler as a durable step of its own.
448
+ *
449
+ * Durable rather than a bare invoke so a replay does not compensate twice —
450
+ * a handler is typically a refund or a rollback. `onError` is deliberately
451
+ * not forwarded: a compensation handler cannot itself compensate.
452
+ */
453
+ private runStepCompensation;
446
454
  private rpcStep;
447
455
  private inlineStep;
448
456
  private sleepStep;
@@ -1156,6 +1156,16 @@ export class PikkuWorkflowService {
1156
1156
  }
1157
1157
  }
1158
1158
  }
1159
+ /**
1160
+ * Run a failed step's compensation handler as a durable step of its own.
1161
+ *
1162
+ * Durable rather than a bare invoke so a replay does not compensate twice —
1163
+ * a handler is typically a refund or a rollback. `onError` is deliberately
1164
+ * not forwarded: a compensation handler cannot itself compensate.
1165
+ */
1166
+ async runStepCompensation(runId, stepName, onErrorRpcName, rpcService, error) {
1167
+ await this.rpcStep(runId, `${stepName}:onError`, onErrorRpcName, { error: { message: error.message } }, rpcService, { retries: 0 });
1168
+ }
1159
1169
  async rpcStep(runId, logicalStepName, rpcName, data, rpcService, stepOptions) {
1160
1170
  // Capture the predecessor before nextStepKey advances the lineage to us.
1161
1171
  const fromStepName = this.lastStepName(runId);
@@ -1168,6 +1178,7 @@ export class PikkuWorkflowService {
1168
1178
  retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
1169
1179
  retryDelay: stepOptions?.retryDelay,
1170
1180
  actor: stepOptions?.actor,
1181
+ onError: stepOptions?.onError,
1171
1182
  };
1172
1183
  // Check if step already exists
1173
1184
  let stepState;
@@ -1186,6 +1197,11 @@ export class PikkuWorkflowService {
1186
1197
  // Step failed with retries exhausted - throw error to fail the workflow
1187
1198
  const error = new Error(stepState.error?.message ||
1188
1199
  `Step '${stepName}' failed after exhausting all retries`);
1200
+ // Compensation, mirroring a graph node's onError: run the handler, then
1201
+ // still throw — the workflow fails either way.
1202
+ if (resolvedStepOptions.onError) {
1203
+ await this.runStepCompensation(runId, stepName, resolvedStepOptions.onError, rpcService, error);
1204
+ }
1189
1205
  // Preserve original error properties if available
1190
1206
  if (stepState.error) {
1191
1207
  Object.assign(error, stepState.error);
@@ -194,8 +194,6 @@ export type CoreWorkflow<PikkuFunctionConfig extends CorePikkuFunctionConfig<any
194
194
  func: PikkuFunctionConfig;
195
195
  /** Middleware chain for this workflow */
196
196
  middleware?: PikkuFunctionConfig['middleware'];
197
- /** Permission requirements */
198
- permissions?: PikkuFunctionConfig['permissions'];
199
197
  /** Tags for organization and filtering */
200
198
  tags?: string[];
201
199
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.64",
3
+ "version": "0.12.66",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -33,6 +33,7 @@
33
33
  "./scheduler": "./dist/wirings/scheduler/index.js",
34
34
  "./trigger": "./dist/wirings/trigger/index.js",
35
35
  "./rpc": "./dist/wirings/rpc/index.js",
36
+ "./safe-fetch": "./dist/utils/safe-fetch.js",
36
37
  "./mcp": "./dist/wirings/mcp/index.js",
37
38
  "./ai-agent": "./dist/wirings/ai-agent/index.js",
38
39
  "./gateway": "./dist/wirings/gateway/index.js",
@@ -6,7 +6,7 @@ import {
6
6
  clearPermissionsCache,
7
7
  checkAuthPermissions,
8
8
  } from './permissions.js'
9
- import { pikkuState, resetPikkuState } from './pikku-state.js'
9
+ import { resetPikkuState } from './pikku-state.js'
10
10
  import { pikkuAuth } from './function/functions.types.js'
11
11
  import type { CoreServices, CoreUserSession } from './types/core.types.js'
12
12
  import type { CorePermissionGroup } from './function/functions.types.js'
@@ -190,25 +190,31 @@ describe('checkAuthPermissions', () => {
190
190
  )
191
191
  })
192
192
 
193
- test('resolves a function-referenced (wire) auth predicate by name', async () => {
194
- const store = pikkuState(null, 'misc', 'permissions')
195
- store['isAdmin'] = [pikkuAuth(async () => false)] as any
193
+ test('evaluates a live pikkuAuth predicate from the config group', async () => {
196
194
  assert.equal(
197
195
  await checkAuthPermissions(
198
- [{ type: 'wire', name: 'isAdmin' }],
196
+ { admin: pikkuAuth(async () => false) as any },
199
197
  mockSession,
200
198
  mockServices
201
199
  ),
202
200
  false
203
201
  )
202
+ assert.equal(
203
+ await checkAuthPermissions(
204
+ { admin: pikkuAuth(async () => true) as any },
205
+ mockSession,
206
+ mockServices
207
+ ),
208
+ true
209
+ )
204
210
  })
205
211
 
206
212
  test('ignores data-dependent permissions (no auth marker)', async () => {
207
- const store = pikkuState(null, 'misc', 'permissions')
208
- store['ownsRow'] = [async () => false] as any
213
+ // A bare pikkuPermission carries no __pikkuAuth brand, so it cannot be
214
+ // evaluated at filter time and must not gate the tool list.
209
215
  assert.equal(
210
216
  await checkAuthPermissions(
211
- [{ type: 'wire', name: 'ownsRow' }],
217
+ { ownsRow: (async () => false) as any },
212
218
  mockSession,
213
219
  mockServices
214
220
  ),
@@ -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)
@@ -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)
@@ -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
+ })