@pikku/core 0.9.12-next.0 → 0.10.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 (83) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/errors/errors.d.ts +2 -0
  3. package/dist/errors/errors.js +6 -0
  4. package/dist/function/function-runner.d.ts +5 -4
  5. package/dist/function/function-runner.js +10 -4
  6. package/dist/function/functions.types.d.ts +67 -1
  7. package/dist/function/functions.types.js +43 -1
  8. package/dist/index.d.ts +1 -7
  9. package/dist/index.js +1 -7
  10. package/dist/middleware/auth-apikey.d.ts +19 -11
  11. package/dist/middleware/auth-apikey.js +33 -33
  12. package/dist/middleware/auth-bearer.d.ts +34 -7
  13. package/dist/middleware/auth-bearer.js +53 -36
  14. package/dist/middleware/auth-cookie.d.ts +30 -12
  15. package/dist/middleware/auth-cookie.js +51 -43
  16. package/dist/middleware/timeout.d.ts +6 -2
  17. package/dist/middleware/timeout.js +7 -9
  18. package/dist/middleware-runner.d.ts +0 -18
  19. package/dist/middleware-runner.js +0 -21
  20. package/dist/permissions.d.ts +82 -20
  21. package/dist/permissions.js +175 -62
  22. package/dist/pikku-state.d.ts +22 -2
  23. package/dist/pikku-state.js +7 -1
  24. package/dist/types/core.types.d.ts +58 -1
  25. package/dist/types/core.types.js +23 -1
  26. package/dist/utils.d.ts +1 -1
  27. package/dist/wirings/channel/channel-handler.js +6 -5
  28. package/dist/wirings/channel/channel-runner.d.ts +3 -1
  29. package/dist/wirings/channel/channel-runner.js +4 -21
  30. package/dist/wirings/channel/channel.types.d.ts +12 -13
  31. package/dist/wirings/cli/channel/cli-channel-runner.js +5 -2
  32. package/dist/wirings/cli/cli-runner.d.ts +10 -0
  33. package/dist/wirings/cli/cli-runner.js +44 -20
  34. package/dist/wirings/cli/cli.types.d.ts +54 -27
  35. package/dist/wirings/cli/command-parser.js +34 -4
  36. package/dist/wirings/cli/index.d.ts +1 -1
  37. package/dist/wirings/cli/index.js +1 -1
  38. package/dist/wirings/http/http-runner.d.ts +31 -1
  39. package/dist/wirings/http/http-runner.js +36 -1
  40. package/dist/wirings/http/http.types.d.ts +2 -1
  41. package/dist/wirings/http/index.d.ts +1 -1
  42. package/dist/wirings/http/index.js +1 -1
  43. package/dist/wirings/mcp/mcp-endpoint-registry.d.ts +1 -1
  44. package/dist/wirings/mcp/mcp-runner.js +2 -0
  45. package/dist/wirings/mcp/mcp.types.d.ts +35 -14
  46. package/dist/wirings/scheduler/scheduler-runner.js +1 -0
  47. package/dist/wirings/scheduler/scheduler.types.d.ts +2 -2
  48. package/package.json +6 -5
  49. package/src/errors/errors.ts +6 -0
  50. package/src/factory-functions.test.ts +60 -1
  51. package/src/function/function-runner.test.ts +8 -3
  52. package/src/function/function-runner.ts +18 -5
  53. package/src/function/functions.types.ts +85 -2
  54. package/src/index.ts +1 -21
  55. package/src/middleware/auth-apikey.ts +42 -57
  56. package/src/middleware/auth-bearer.ts +66 -49
  57. package/src/middleware/auth-cookie.ts +63 -82
  58. package/src/middleware/timeout.ts +10 -14
  59. package/src/middleware-runner.ts +0 -25
  60. package/src/permissions.test.ts +26 -27
  61. package/src/permissions.ts +215 -104
  62. package/src/pikku-state.ts +35 -3
  63. package/src/types/core.types.ts +70 -3
  64. package/src/utils.ts +1 -1
  65. package/src/wirings/channel/channel-handler.ts +11 -10
  66. package/src/wirings/channel/channel-runner.ts +18 -27
  67. package/src/wirings/channel/channel.types.ts +24 -15
  68. package/src/wirings/cli/channel/cli-channel-runner.ts +8 -3
  69. package/src/wirings/cli/cli-runner.test.ts +80 -59
  70. package/src/wirings/cli/cli-runner.ts +57 -21
  71. package/src/wirings/cli/cli.types.ts +74 -44
  72. package/src/wirings/cli/command-parser.test.ts +91 -86
  73. package/src/wirings/cli/command-parser.ts +38 -4
  74. package/src/wirings/cli/index.ts +1 -0
  75. package/src/wirings/http/http-runner.ts +41 -1
  76. package/src/wirings/http/http.types.ts +3 -1
  77. package/src/wirings/http/index.ts +7 -1
  78. package/src/wirings/mcp/mcp-endpoint-registry.ts +1 -1
  79. package/src/wirings/mcp/mcp-runner.ts +2 -0
  80. package/src/wirings/mcp/mcp.types.ts +48 -12
  81. package/src/wirings/scheduler/scheduler-runner.ts +1 -0
  82. package/src/wirings/scheduler/scheduler.types.ts +2 -2
  83. package/tsconfig.tsbuildinfo +1 -1
@@ -1,17 +1,13 @@
1
1
  import { RequestTimeoutError } from '../errors/errors.js'
2
- import { CorePikkuMiddleware } from '../types/core.types.js'
2
+ import { pikkuMiddleware, pikkuMiddlewareFactory } from '../types/core.types.js'
3
3
 
4
- export const timeout = (timeout: number) => {
5
- const middleware: CorePikkuMiddleware = async (
6
- _services,
7
- _interaction,
8
- next
9
- ) => {
10
- setTimeout(() => {
11
- throw new RequestTimeoutError()
12
- }, timeout)
4
+ export const timeout = () =>
5
+ pikkuMiddlewareFactory<{ timeout: number }>(({ timeout }) =>
6
+ pikkuMiddleware(async (_services, _interaction, next) => {
7
+ setTimeout(() => {
8
+ throw new RequestTimeoutError()
9
+ }, timeout)
13
10
 
14
- await next()
15
- }
16
- return middleware
17
- }
11
+ await next()
12
+ })
13
+ )
@@ -92,31 +92,6 @@ export const addMiddleware = <PikkuMiddleware extends CorePikkuMiddleware>(
92
92
  return middleware
93
93
  }
94
94
 
95
- /**
96
- * Registers a single middleware function by name in the global middleware store.
97
- *
98
- * This function is used by CLI-generated code to register middleware functions
99
- * that can be referenced by name in metadata. It stores middleware in a special
100
- * namespace to avoid conflicts with tag-based middleware.
101
- *
102
- * @param {string} name - The unique name (pikkuFuncName) of the middleware function.
103
- * @param {CorePikkuMiddleware} middleware - The middleware function to register.
104
- *
105
- * @example
106
- * ```typescript
107
- * // Called by CLI-generated pikku-middleware.gen.ts
108
- * registerMiddleware('authMiddleware_src_middleware_ts_10_5', authMiddleware)
109
- * registerMiddleware('loggingMiddleware_src_middleware_ts_20_10', loggingMiddleware)
110
- * ```
111
- */
112
- export const registerMiddleware = (
113
- name: string,
114
- middleware: CorePikkuMiddleware
115
- ) => {
116
- const middlewareStore = pikkuState('misc', 'middleware')
117
- middlewareStore[name] = [middleware]
118
- }
119
-
120
95
  /**
121
96
  * Retrieves a registered middleware function by its name.
122
97
  *
@@ -181,7 +181,7 @@ describe('runPermissions', () => {
181
181
  addPermission('wiringTag', [wiringTagPermission])
182
182
 
183
183
  await runPermissions(PikkuWiringTypes.rpc, Math.random().toString(), {
184
- wiringTags: ['wiringTag'],
184
+ wireInheritedPermissions: [{ type: 'tag', tag: 'wiringTag' }],
185
185
  allServices: mockServices,
186
186
  data: {},
187
187
  session: mockSession,
@@ -205,7 +205,7 @@ describe('runPermissions', () => {
205
205
  addPermission('wiringTag', [wiringTagPermission])
206
206
  addPermission('funcTag', [funcTagPermission])
207
207
 
208
- const wiringPermissions: CorePermissionGroup = {
208
+ const wirePermissions: CorePermissionGroup = {
209
209
  permissions: [
210
210
  async () => {
211
211
  executionOrder.push('wiringPermission')
@@ -224,16 +224,16 @@ describe('runPermissions', () => {
224
224
  }
225
225
 
226
226
  await runPermissions(PikkuWiringTypes.rpc, Math.random().toString(), {
227
- wiringTags: ['wiringTag'],
228
- wiringPermissions,
229
- funcTags: ['funcTag'],
227
+ wireInheritedPermissions: [{ type: 'tag', tag: 'wiringTag' }],
228
+ wirePermissions,
229
+ funcInheritedPermissions: [{ type: 'tag', tag: 'funcTag' }],
230
230
  funcPermissions,
231
231
  allServices: mockServices,
232
232
  data: {},
233
233
  session: mockSession,
234
234
  })
235
235
 
236
- // Order: wiringTagswiringPermissionsfuncTags → funcPermissions
236
+ // Order: wireInheritedPermissions (wiringTag) wirePermissionsfuncInheritedPermissions (funcTag) → funcPermissions
237
237
  assert.deepEqual(executionOrder, [
238
238
  'wiringTag',
239
239
  'wiringPermission',
@@ -250,7 +250,7 @@ describe('runPermissions', () => {
250
250
 
251
251
  // Should not throw because at least one permission passes
252
252
  await runPermissions(PikkuWiringTypes.rpc, Math.random().toString(), {
253
- wiringTags: ['atLeastOneTestTag'],
253
+ wireInheritedPermissions: [{ type: 'tag', tag: 'atLeastOneTestTag' }],
254
254
  allServices: mockServices,
255
255
  data: {},
256
256
  session: mockSession,
@@ -265,7 +265,7 @@ describe('runPermissions', () => {
265
265
 
266
266
  await assert.rejects(
267
267
  runPermissions(PikkuWiringTypes.rpc, Math.random().toString(), {
268
- wiringTags: ['allFailTestTag'],
268
+ wireInheritedPermissions: [{ type: 'tag', tag: 'allFailTestTag' }],
269
269
  allServices: mockServices,
270
270
  data: {},
271
271
  session: mockSession,
@@ -277,13 +277,13 @@ describe('runPermissions', () => {
277
277
  })
278
278
 
279
279
  test('should throw error when wiring permissions fail', async () => {
280
- const wiringPermissions: CorePermissionGroup = {
280
+ const wirePermissions: CorePermissionGroup = {
281
281
  permissions: [async () => false],
282
282
  }
283
283
 
284
284
  await assert.rejects(
285
285
  runPermissions(PikkuWiringTypes.rpc, Math.random().toString(), {
286
- wiringPermissions,
286
+ wirePermissions,
287
287
  allServices: mockServices,
288
288
  data: {},
289
289
  session: mockSession,
@@ -301,7 +301,7 @@ describe('runPermissions', () => {
301
301
 
302
302
  await assert.rejects(
303
303
  runPermissions(PikkuWiringTypes.rpc, Math.random().toString(), {
304
- funcTags: ['funcTag'],
304
+ funcInheritedPermissions: [{ type: 'tag', tag: 'funcTag' }],
305
305
  allServices: mockServices,
306
306
  data: {},
307
307
  session: mockSession,
@@ -330,7 +330,7 @@ describe('runPermissions', () => {
330
330
  )
331
331
  })
332
332
 
333
- test('should stop execution at first failing level', async () => {
333
+ test('should evaluate all permissions and pass if at least one succeeds', async () => {
334
334
  const executionOrder: string[] = []
335
335
 
336
336
  const failingWiringTagPermission = async () => {
@@ -340,7 +340,7 @@ describe('runPermissions', () => {
340
340
 
341
341
  addPermission('failingWiringTag', [failingWiringTagPermission])
342
342
 
343
- const wiringPermissions: CorePermissionGroup = {
343
+ const wirePermissions: CorePermissionGroup = {
344
344
  permissions: [
345
345
  async () => {
346
346
  executionOrder.push('wiringPermission')
@@ -349,18 +349,17 @@ describe('runPermissions', () => {
349
349
  ],
350
350
  }
351
351
 
352
- await assert.rejects(
353
- runPermissions(PikkuWiringTypes.rpc, Math.random().toString(), {
354
- wiringTags: ['failingWiringTag'],
355
- wiringPermissions,
356
- allServices: mockServices,
357
- data: {},
358
- session: mockSession,
359
- })
360
- )
352
+ // Should NOT throw because at least one permission (wirePermissions) passes
353
+ await runPermissions(PikkuWiringTypes.rpc, Math.random().toString(), {
354
+ wireInheritedPermissions: [{ type: 'tag', tag: 'failingWiringTag' }],
355
+ wirePermissions,
356
+ allServices: mockServices,
357
+ data: {},
358
+ session: mockSession,
359
+ })
361
360
 
362
- // Should only execute wiring tag permissions, not wiring permissions
363
- assert.deepEqual(executionOrder, ['wiringTag'])
361
+ // Both permissions should be evaluated
362
+ assert.deepEqual(executionOrder, ['wiringTag', 'wiringPermission'])
364
363
  })
365
364
 
366
365
  test('should handle array permissions in tag-based permissions', async () => {
@@ -370,7 +369,7 @@ describe('runPermissions', () => {
370
369
 
371
370
  // Should not throw because verifyPermissions handles array properly
372
371
  await runPermissions(PikkuWiringTypes.rpc, Math.random().toString(), {
373
- wiringTags: ['arrayTestTag'],
372
+ wireInheritedPermissions: [{ type: 'tag', tag: 'arrayTestTag' }],
374
373
  allServices: mockServices,
375
374
  data: {},
376
375
  session: mockSession,
@@ -386,7 +385,7 @@ describe('runPermissions', () => {
386
385
 
387
386
  // Should not throw because verifyPermissions handles objects properly
388
387
  await runPermissions(PikkuWiringTypes.rpc, Math.random().toString(), {
389
- wiringTags: ['objectTestTag'],
388
+ wireInheritedPermissions: [{ type: 'tag', tag: 'objectTestTag' }],
390
389
  allServices: mockServices,
391
390
  data: {},
392
391
  session: mockSession,
@@ -410,7 +409,7 @@ describe('runPermissions', () => {
410
409
  addPermission('paramTestTag', [testPermission])
411
410
 
412
411
  await runPermissions(PikkuWiringTypes.rpc, Math.random().toString(), {
413
- wiringTags: ['paramTestTag'],
412
+ wireInheritedPermissions: [{ type: 'tag', tag: 'paramTestTag' }],
414
413
  allServices: mockServices,
415
414
  data: testData,
416
415
  session: mockSession,
@@ -2,6 +2,7 @@ import {
2
2
  CoreServices,
3
3
  CoreUserSession,
4
4
  PikkuWiringTypes,
5
+ PermissionMetadata,
5
6
  } from './types/core.types.js'
6
7
  import {
7
8
  CorePermissionGroup,
@@ -9,6 +10,7 @@ import {
9
10
  } from './function/functions.types.js'
10
11
  import { pikkuState } from './pikku-state.js'
11
12
  import { ForbiddenError } from './errors/errors.js'
13
+ import { freezeDedupe } from './utils.js'
12
14
 
13
15
  /**
14
16
  * This function validates permissions by iterating over permission groups and executing the corresponding permission functions. If all functions in at least one group return true, the permission is considered valid.
@@ -51,43 +53,93 @@ export const verifyPermissions = async (
51
53
  return false
52
54
  }
53
55
 
56
+ /**
57
+ * Registers a single permission function by name in the global permission store.
58
+ *
59
+ * This function is used by CLI-generated code to register permission functions
60
+ * that can be referenced by name in metadata. It stores permissions in a special
61
+ * namespace to avoid conflicts with tag-based permissions.
62
+ *
63
+ * @param {string} name - The unique name (pikkuFuncName) of the permission function.
64
+ * @param {CorePikkuPermission} permission - The permission function to register.
65
+ *
66
+ * @example
67
+ * ```typescript
68
+ * // Called by CLI-generated pikku-permissions.gen.ts
69
+ * registerPermission('adminPermission_src_permissions_ts_10_5', adminPermission)
70
+ * registerPermission('readPermission_src_permissions_ts_20_10', readPermission)
71
+ * ```
72
+ */
73
+ export const registerPermission = (
74
+ name: string,
75
+ permission: CorePikkuPermission<any>
76
+ ) => {
77
+ const permissionStore = pikkuState('misc', 'permissions')
78
+ permissionStore[name] = [permission]
79
+ }
80
+
81
+ /**
82
+ * Retrieves a registered permission function by its name.
83
+ *
84
+ * This function looks up permissions that was registered with registerPermission.
85
+ * It's used internally by the framework to resolve permission references in metadata.
86
+ *
87
+ * @param {string} name - The unique name (pikkuFuncName) of the permission function.
88
+ * @returns {CorePikkuPermission | undefined} The permission function, or undefined if not found.
89
+ *
90
+ * @internal
91
+ */
92
+ export const getPermissionByName = (
93
+ name: string
94
+ ): CorePikkuPermission | undefined => {
95
+ const permissionStore = pikkuState('misc', 'permissions')
96
+ const permission = permissionStore[name]
97
+ if (Array.isArray(permission) && permission.length === 1) {
98
+ return permission[0]
99
+ }
100
+ return undefined
101
+ }
102
+
54
103
  /**
55
104
  * Adds global permissions for a specific tag.
56
105
  *
57
106
  * This function allows you to register permissions that will be applied to
58
107
  * any wiring (HTTP, Channel, Queue, Scheduler, MCP) that includes the matching tag.
59
108
  *
109
+ * For tree-shaking benefits, wrap in a factory function:
110
+ * `export const x = () => addPermission('tag', [...])`
111
+ *
60
112
  * @param {string} tag - The tag that the permissions should apply to.
61
113
  * @param {any[]} permissions - The permissions array to apply for the specified tag.
62
114
  *
63
- * @throws {Error} If permissions for the tag already exist.
115
+ * @returns {CorePermissionGroup | CorePikkuPermission[]} The permissions (for chaining/wrapping).
64
116
  *
65
117
  * @example
66
118
  * ```typescript
67
- * // Add admin permissions for admin endpoints
68
- * addPermission('admin', [adminPermission])
69
- *
70
- * // Add authentication permissions for auth endpoints
71
- * addPermission('auth', [authPermission])
119
+ * // Recommended: tree-shakeable
120
+ * export const adminPermissions = () => addPermission('admin', [
121
+ * adminPermission,
122
+ * rolePermission({ role: 'admin' })
123
+ * ])
72
124
  *
73
- * // Add read permissions for all API endpoints
74
- * addPermission('api', [readPermission])
125
+ * // Also works: no tree-shaking
126
+ * export const apiPermissions = addPermission('api', [
127
+ * readPermission
128
+ * ])
75
129
  * ```
76
130
  */
77
131
  export const addPermission = (
78
132
  tag: string,
79
133
  permissions: CorePermissionGroup | CorePikkuPermission[]
80
- ) => {
81
- const permissionsStore = pikkuState('misc', 'permissions')
82
-
83
- // Check if tag already exists
84
- if (permissionsStore[tag]) {
134
+ ): CorePermissionGroup | CorePikkuPermission[] => {
135
+ const tagGroups = pikkuState('permissions', 'tagGroup')
136
+ if (tagGroups[tag]) {
85
137
  throw new Error(
86
138
  `Permissions for tag '${tag}' already exist. Use a different tag or remove the existing permissions first.`
87
139
  )
88
140
  }
89
-
90
- permissionsStore[tag] = permissions
141
+ tagGroups[tag] = permissions
142
+ return permissions
91
143
  }
92
144
 
93
145
  const EMPTY: readonly (CorePermissionGroup | CorePikkuPermission)[] = []
@@ -114,7 +166,7 @@ export const getPermissionsForTags = (
114
166
  return EMPTY
115
167
  }
116
168
 
117
- const permissionsStore = pikkuState('misc', 'permissions')
169
+ const permissionsStore = pikkuState('permissions', 'tagGroup')
118
170
  const applicablePermissions: Array<
119
171
  CorePermissionGroup | CorePikkuPermission
120
172
  > = []
@@ -134,17 +186,9 @@ export const getPermissionsForTags = (
134
186
  return applicablePermissions
135
187
  }
136
188
 
137
- const permissionCache: Record<
189
+ const combinedPermissionsCache: Record<
138
190
  PikkuWiringTypes,
139
- Partial<
140
- Record<
141
- string,
142
- {
143
- wiringTags: readonly (CorePermissionGroup | CorePikkuPermission)[]
144
- funcTags: readonly (CorePermissionGroup | CorePikkuPermission)[]
145
- }
146
- >
147
- >
191
+ Record<string, readonly (CorePermissionGroup | CorePikkuPermission)[]>
148
192
  > = {
149
193
  [PikkuWiringTypes.http]: {},
150
194
  [PikkuWiringTypes.rpc]: {},
@@ -156,111 +200,178 @@ const permissionCache: Record<
156
200
  }
157
201
 
158
202
  /**
159
- * Runs permission checks in the specified order:
160
- * 1) wiring tag permissions - at least one must pass if any exist
161
- * 2) wiring permissions - must pass if defined
162
- * 3) function tag permissions - at least one must pass if any exist
163
- * 4) function permissions - must pass if defined
203
+ * Combines wiring-specific permissions with function-level permissions.
204
+ *
205
+ * This function resolves permission metadata into actual permission functions and combines them.
206
+ * It filters out wire permissions without tags from inheritedPermissions to avoid duplication
207
+ * (those are passed separately as wirePermissions).
208
+ *
209
+ * @param {object} options - Configuration object for combining permissions.
210
+ * @param {PermissionMetadata[] | undefined} options.wireInheritedPermissions - Metadata from wiring (HTTP + tags + wire with tags).
211
+ * @param {CorePermissionGroup | CorePikkuPermission[] | undefined} options.wirePermissions - Inline wire permissions.
212
+ * @param {PermissionMetadata[] | undefined} options.funcInheritedPermissions - Function permissions metadata (only tags).
213
+ * @param {CorePermissionGroup | CorePikkuPermission[] | undefined} options.funcPermissions - Inline function permissions.
214
+ * @returns {(CorePermissionGroup | CorePikkuPermission)[]} Combined array of resolved permissions.
215
+ *
216
+ * @example
217
+ * ```typescript
218
+ * const combined = combinePermissions(wireType, wireId, {
219
+ * wireInheritedPermissions: meta.permissions,
220
+ * wirePermissions: inlinePermissions,
221
+ * funcInheritedPermissions: funcMeta.permissions,
222
+ * funcPermissions: funcConfig.permissions
223
+ * })
224
+ * ```
164
225
  */
165
- export const runPermissions = async (
226
+ export const combinePermissions = (
166
227
  wireType: PikkuWiringTypes,
167
228
  uid: string,
168
229
  {
169
- wiringTags,
170
- wiringPermissions,
171
- funcTags,
230
+ wireInheritedPermissions,
231
+ wirePermissions,
232
+ funcInheritedPermissions,
172
233
  funcPermissions,
173
- allServices,
174
- data,
175
- session,
176
234
  }: {
177
- wiringTags?: string[]
178
- wiringPermissions?: CorePermissionGroup
179
- funcTags?: string[]
180
- funcPermissions?: CorePermissionGroup
181
- allServices: CoreServices
182
- data: any
183
- session?: CoreUserSession
235
+ wireInheritedPermissions?: PermissionMetadata[]
236
+ wirePermissions?: CorePermissionGroup | CorePikkuPermission[]
237
+ funcInheritedPermissions?: PermissionMetadata[]
238
+ funcPermissions?: CorePermissionGroup | CorePikkuPermission[]
239
+ } = {}
240
+ ): readonly (CorePermissionGroup | CorePikkuPermission)[] => {
241
+ if (combinedPermissionsCache[wireType][uid]) {
242
+ return combinedPermissionsCache[wireType][uid]
184
243
  }
185
- ) => {
186
- let cachedPermission = permissionCache[wireType][uid]
187
- if (!cachedPermission) {
188
- cachedPermission = {
189
- wiringTags: getPermissionsForTags(wiringTags),
190
- funcTags: getPermissionsForTags(funcTags),
244
+
245
+ const resolved: (CorePermissionGroup | CorePikkuPermission)[] = []
246
+
247
+ // 1. Resolve wire inherited permissions (HTTP + tag groups + individual wire permissions)
248
+ if (wireInheritedPermissions) {
249
+ for (const meta of wireInheritedPermissions) {
250
+ if (meta.type === 'http') {
251
+ // Look up HTTP permission group from pikkuState
252
+ const group = pikkuState('permissions', 'httpGroup')[meta.route]
253
+ if (group) {
254
+ if (Array.isArray(group)) {
255
+ resolved.push(...group)
256
+ } else {
257
+ resolved.push(group)
258
+ }
259
+ }
260
+ } else if (meta.type === 'tag') {
261
+ // Look up tag permission group from pikkuState
262
+ const group = pikkuState('permissions', 'tagGroup')[meta.tag]
263
+ if (group) {
264
+ if (Array.isArray(group)) {
265
+ resolved.push(...group)
266
+ } else {
267
+ resolved.push(group)
268
+ }
269
+ }
270
+ } else if (meta.type === 'wire') {
271
+ // Individual wire permission (exported, not inline)
272
+ const permission = getPermissionByName(meta.name)
273
+ if (permission) {
274
+ resolved.push(permission)
275
+ }
276
+ }
191
277
  }
192
- permissionCache[wireType][uid] = cachedPermission
193
278
  }
194
- let permissioned = true
195
279
 
196
- // 1. Wiring tag permissions - at least one must pass if any exist
197
- const wiringTaggedPermissions = cachedPermission.wiringTags
198
- if (wiringTaggedPermissions.length > 0) {
199
- permissioned = false // Start false, need at least one to pass
200
- for (const permissions of wiringTaggedPermissions) {
201
- const result = await verifyPermissions(
202
- typeof permissions === 'function' ? { permissions } : permissions,
203
- allServices,
204
- data,
205
- session
206
- )
207
- if (result) {
208
- permissioned = true
209
- break // At least one passed, we're good at this level
210
- }
280
+ // 2. Add inline wire permissions
281
+ if (wirePermissions) {
282
+ if (Array.isArray(wirePermissions)) {
283
+ resolved.push(...wirePermissions)
284
+ } else {
285
+ resolved.push(wirePermissions)
211
286
  }
212
- if (!permissioned) {
213
- allServices.logger.debug('Permission denied - wiring tag permissions')
214
- throw new ForbiddenError('Permission denied')
287
+ }
288
+
289
+ // 3. Resolve function inherited permissions (only tags, wire permissions already handled)
290
+ if (funcInheritedPermissions) {
291
+ for (const meta of funcInheritedPermissions) {
292
+ if (meta.type === 'tag') {
293
+ // Look up tag permission group from pikkuState
294
+ const group = pikkuState('permissions', 'tagGroup')[meta.tag]
295
+ if (group) {
296
+ if (Array.isArray(group)) {
297
+ resolved.push(...group)
298
+ } else {
299
+ resolved.push(group)
300
+ }
301
+ }
302
+ }
303
+ // Note: wire permissions are already handled in wireInheritedPermissions
215
304
  }
216
305
  }
217
306
 
218
- // 2. Wiring permissions - must pass if defined
219
- if (wiringPermissions) {
220
- permissioned = await verifyPermissions(
221
- wiringPermissions,
222
- allServices,
223
- data,
224
- session
225
- )
226
- if (!permissioned) {
227
- allServices.logger.debug('Permission denied - wiring permissions')
228
- throw new ForbiddenError('Permission denied')
307
+ // 4. Add inline function permissions
308
+ if (funcPermissions) {
309
+ if (Array.isArray(funcPermissions)) {
310
+ resolved.push(...funcPermissions)
311
+ } else {
312
+ resolved.push(funcPermissions)
229
313
  }
230
314
  }
231
315
 
232
- // 3. Function tag permissions - at least one must pass if any exist
233
- const funcTaggedPermissions = cachedPermission.funcTags
234
- if (funcTaggedPermissions.length > 0) {
235
- permissioned = false // Start false, need at least one to pass
236
- for (const permissions of funcTaggedPermissions) {
316
+ // Deduplicate and freeze
317
+ combinedPermissionsCache[wireType][uid] = freezeDedupe(resolved) as readonly (
318
+ | CorePermissionGroup
319
+ | CorePikkuPermission
320
+ )[]
321
+
322
+ return combinedPermissionsCache[wireType][uid]
323
+ }
324
+
325
+ /**
326
+ * Runs permission checks using combined permissions from all sources.
327
+ * Combines permissions from wire and function levels, then validates them.
328
+ */
329
+ export const runPermissions = async (
330
+ wireType: PikkuWiringTypes,
331
+ uid: string,
332
+ {
333
+ wireInheritedPermissions,
334
+ wirePermissions,
335
+ funcInheritedPermissions,
336
+ funcPermissions,
337
+ allServices,
338
+ data,
339
+ session,
340
+ }: {
341
+ wireInheritedPermissions?: PermissionMetadata[]
342
+ wirePermissions?: CorePermissionGroup | CorePikkuPermission[]
343
+ funcInheritedPermissions?: PermissionMetadata[]
344
+ funcPermissions?: CorePermissionGroup | CorePikkuPermission[]
345
+ allServices: CoreServices
346
+ data: any
347
+ session?: CoreUserSession
348
+ }
349
+ ) => {
350
+ // Combine all permissions: wireInheritedPermissions → wirePermissions → funcInheritedPermissions → funcPermissions
351
+ const allPermissions = combinePermissions(wireType, uid, {
352
+ wireInheritedPermissions,
353
+ wirePermissions,
354
+ funcInheritedPermissions,
355
+ funcPermissions,
356
+ })
357
+
358
+ // Check all combined permissions - at least one must pass if any exist
359
+ if (allPermissions.length > 0) {
360
+ let permissioned = false
361
+ for (const permission of allPermissions) {
237
362
  const result = await verifyPermissions(
238
- typeof permissions === 'function' ? { permissions } : permissions,
363
+ typeof permission === 'function' ? { permission } : permission,
239
364
  allServices,
240
365
  data,
241
366
  session
242
367
  )
243
368
  if (result) {
244
369
  permissioned = true
245
- break // At least one passed, we're good at this level
370
+ // Continue executing all permissions (don't break early)
246
371
  }
247
372
  }
248
373
  if (!permissioned) {
249
- allServices.logger.debug('Permission denied - function tag permissions')
250
- throw new ForbiddenError('Permission denied')
251
- }
252
- }
253
-
254
- // 4. Function permissions - must pass if defined
255
- if (funcPermissions) {
256
- permissioned = await verifyPermissions(
257
- funcPermissions,
258
- allServices,
259
- data,
260
- session
261
- )
262
- if (!permissioned) {
263
- allServices.logger.debug('Permission denied - function permissions')
374
+ allServices.logger.debug('Permission denied - combined permissions')
264
375
  throw new ForbiddenError('Permission denied')
265
376
  }
266
377
  }