@pikku/inspector 0.12.41 → 0.12.43

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 (108) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/dist/add/add-addon-bans.js +1 -0
  3. package/dist/add/add-ai-agent.d.ts +3 -1
  4. package/dist/add/add-ai-agent.js +82 -0
  5. package/dist/add/add-credential.js +3 -1
  6. package/dist/add/add-functions.js +10 -9
  7. package/dist/add/add-gateway.js +3 -0
  8. package/dist/add/add-http-route.js +2 -2
  9. package/dist/add/add-mcp-prompt.js +0 -1
  10. package/dist/add/add-mcp-resource.js +0 -1
  11. package/dist/add/add-permission.d.ts +1 -1
  12. package/dist/add/add-permission.js +3 -177
  13. package/dist/add/add-queue-worker.js +3 -0
  14. package/dist/add/add-schedule.js +3 -0
  15. package/dist/add/add-scope.d.ts +2 -0
  16. package/dist/add/add-scope.js +146 -0
  17. package/dist/add/add-trigger.js +3 -0
  18. package/dist/add/add-wire-remote-addon.d.ts +10 -0
  19. package/dist/add/add-wire-remote-addon.js +66 -0
  20. package/dist/add/add-workflow-graph.js +32 -3
  21. package/dist/error-codes.d.ts +3 -0
  22. package/dist/error-codes.js +4 -0
  23. package/dist/inspector.js +19 -7
  24. package/dist/types.d.ts +19 -13
  25. package/dist/utils/custom-types-generator.js +2 -1
  26. package/dist/utils/ensure-function-metadata.d.ts +15 -0
  27. package/dist/utils/ensure-function-metadata.js +24 -0
  28. package/dist/utils/filter-inspector-state.js +9 -0
  29. package/dist/utils/get-property-value.d.ts +7 -2
  30. package/dist/utils/get-property-value.js +48 -1
  31. package/dist/utils/load-addon-functions-meta.js +81 -6
  32. package/dist/utils/nested-project-filter.d.ts +1 -0
  33. package/dist/utils/nested-project-filter.js +29 -0
  34. package/dist/utils/permissions.d.ts +1 -21
  35. package/dist/utils/permissions.js +17 -108
  36. package/dist/utils/post-process.d.ts +30 -1
  37. package/dist/utils/post-process.js +150 -72
  38. package/dist/utils/serialize-inspector-state.d.ts +13 -8
  39. package/dist/utils/serialize-inspector-state.js +12 -6
  40. package/dist/utils/serialize-permissions-groups-meta.d.ts +0 -2
  41. package/dist/utils/serialize-permissions-groups-meta.js +0 -26
  42. package/dist/utils/workflow/derive-workflow-plan.js +9 -3
  43. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +56 -1
  44. package/dist/utils/workflow/dsl/patterns.d.ts +10 -0
  45. package/dist/utils/workflow/dsl/patterns.js +24 -8
  46. package/dist/utils/workflow/dsl/validation.js +3 -4
  47. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +14 -0
  48. package/dist/utils/workflow/graph/finalize-workflows.d.ts +7 -0
  49. package/dist/utils/workflow/graph/finalize-workflows.js +11 -2
  50. package/dist/utils/workflow/graph/serialize-workflow-graph.d.ts +2 -0
  51. package/dist/utils/workflow/graph/serialize-workflow-graph.js +4 -0
  52. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +6 -2
  53. package/dist/visit.js +4 -0
  54. package/package.json +3 -3
  55. package/src/add/add-addon-bans.ts +1 -0
  56. package/src/add/add-ai-agent.test.ts +87 -0
  57. package/src/add/add-ai-agent.ts +122 -0
  58. package/src/add/add-credential.ts +6 -0
  59. package/src/add/add-functions.ts +10 -12
  60. package/src/add/add-gateway.ts +11 -0
  61. package/src/add/add-http-route.ts +2 -8
  62. package/src/add/add-mcp-prompt.ts +0 -1
  63. package/src/add/add-mcp-resource.ts +0 -1
  64. package/src/add/add-permission.ts +4 -242
  65. package/src/add/add-queue-worker.ts +11 -0
  66. package/src/add/add-schedule.ts +11 -0
  67. package/src/add/add-scope.test.ts +346 -0
  68. package/src/add/add-scope.ts +225 -0
  69. package/src/add/add-trigger.ts +11 -0
  70. package/src/add/add-wire-remote-addon.ts +77 -0
  71. package/src/add/add-workflow-graph-input.test.ts +94 -0
  72. package/src/add/add-workflow-graph-notes.test.ts +74 -0
  73. package/src/add/add-workflow-graph.ts +35 -3
  74. package/src/add/inline-wiring-function.test.ts +104 -0
  75. package/src/add/validate-workflow-graph-addons.test.ts +102 -0
  76. package/src/error-codes.ts +5 -0
  77. package/src/inspector.ts +22 -6
  78. package/src/types.ts +16 -17
  79. package/src/utils/custom-types-generator.test.ts +26 -1
  80. package/src/utils/custom-types-generator.ts +2 -1
  81. package/src/utils/ensure-function-metadata.ts +42 -0
  82. package/src/utils/filter-inspector-state.test.ts +0 -2
  83. package/src/utils/filter-inspector-state.ts +9 -0
  84. package/src/utils/get-property-value.test.ts +141 -0
  85. package/src/utils/get-property-value.ts +62 -1
  86. package/src/utils/load-addon-functions-meta.test.ts +157 -0
  87. package/src/utils/load-addon-functions-meta.ts +94 -6
  88. package/src/utils/load-addon-scopes.test.ts +138 -0
  89. package/src/utils/nested-project-filter.test.ts +53 -0
  90. package/src/utils/nested-project-filter.ts +28 -0
  91. package/src/utils/permissions.test.ts +7 -232
  92. package/src/utils/permissions.ts +20 -160
  93. package/src/utils/post-process.test.ts +269 -4
  94. package/src/utils/post-process.ts +216 -95
  95. package/src/utils/serialize-inspector-state.ts +22 -25
  96. package/src/utils/serialize-permissions-groups-meta.ts +0 -28
  97. package/src/utils/workflow/derive-workflow-plan.test.ts +41 -3
  98. package/src/utils/workflow/derive-workflow-plan.ts +11 -4
  99. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +66 -0
  100. package/src/utils/workflow/dsl/patterns.ts +34 -8
  101. package/src/utils/workflow/dsl/validation.ts +3 -4
  102. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +15 -0
  103. package/src/utils/workflow/graph/finalize-workflows.test.ts +50 -0
  104. package/src/utils/workflow/graph/finalize-workflows.ts +13 -2
  105. package/src/utils/workflow/graph/serialize-workflow-graph.ts +6 -0
  106. package/src/utils/workflow/graph/workflow-graph.types.ts +8 -0
  107. package/src/visit.ts +4 -0
  108. package/tsconfig.tsbuildinfo +1 -1
@@ -10,9 +10,12 @@ import type {
10
10
  MiddlewareMetadata,
11
11
  PermissionMetadata,
12
12
  } from '@pikku/core'
13
+ import { existsSync, readFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
13
15
  import { extractTypeKeys } from './type-utils.js'
14
16
  import { ErrorCode } from '../error-codes.js'
15
17
  import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js'
18
+ import { flattenScopeDefinitions } from '@pikku/core/scope'
16
19
 
17
20
  /**
18
21
  * Stamp the inspected authorize/callbacks service set onto the generated auth
@@ -46,7 +49,7 @@ export function stampAuthHandlerServices(
46
49
  * Skips type:'http' and type:'tag' (reference groups, not individuals).
47
50
  */
48
51
  export function extractWireNames(
49
- list?: MiddlewareMetadata[] | PermissionMetadata[]
52
+ list?: Array<MiddlewareMetadata | PermissionMetadata>
50
53
  ): string[] {
51
54
  if (!list) return []
52
55
  return list
@@ -57,34 +60,25 @@ export function extractWireNames(
57
60
  }
58
61
 
59
62
  /**
60
- * Helper to expand middleware/permission groups into individual names
61
- * and add their services to the aggregation.
62
- * This handles tag-based and HTTP-pattern-based middleware/permission groups.
63
+ * Helper to expand middleware groups into individual names and add their
64
+ * services to the aggregation. Handles tag-based and HTTP-pattern-based
65
+ * middleware groups. Permissions are function-scoped only and carry no groups.
63
66
  */
64
67
  function expandAndAddGroupServices(
65
- list: MiddlewareMetadata[] | PermissionMetadata[] | undefined,
68
+ list: MiddlewareMetadata[] | undefined,
66
69
  state: InspectorState | Omit<InspectorState, 'typesLookup'>,
67
- addServices: (services: FunctionServicesMeta | undefined) => void,
68
- isMiddleware: boolean
70
+ addServices: (services: FunctionServicesMeta | undefined) => void
69
71
  ): void {
70
72
  if (!list) return
71
73
 
72
74
  for (const item of list) {
73
75
  if (item.type === 'tag') {
74
- // Expand tag-based group
75
- const groupMeta = isMiddleware
76
- ? state.middleware.tagMiddleware.get(item.tag)
77
- : state.permissions.tagPermissions.get(item.tag)
78
-
76
+ const groupMeta = state.middleware.tagMiddleware.get(item.tag)
79
77
  if (groupMeta?.services) {
80
78
  addServices(groupMeta.services)
81
79
  }
82
80
  } else if (item.type === 'http' && 'route' in item) {
83
- // Expand HTTP-pattern-based group
84
- const groupMeta = isMiddleware
85
- ? state.http.routeMiddleware.get(item.route)
86
- : state.http.routePermissions.get(item.route)
87
-
81
+ const groupMeta = state.http.routeMiddleware.get(item.route)
88
82
  if (groupMeta?.services) {
89
83
  addServices(groupMeta.services)
90
84
  }
@@ -188,55 +182,33 @@ export function aggregateRequiredServices(
188
182
  'options',
189
183
  ] as const) {
190
184
  for (const routeMeta of Object.values(state.http.meta[method])) {
191
- expandAndAddGroupServices(routeMeta.middleware, state, addServices, true)
192
- expandAndAddGroupServices(
193
- routeMeta.permissions,
194
- state,
195
- addServices,
196
- false
197
- )
185
+ expandAndAddGroupServices(routeMeta.middleware, state, addServices)
198
186
  }
199
187
  }
200
188
 
201
189
  // Also check other wiring types (channels, queues, schedulers, MCP)
202
190
  for (const channelMeta of Object.values(state.channels.meta)) {
203
- expandAndAddGroupServices(channelMeta.middleware, state, addServices, true)
204
- expandAndAddGroupServices(
205
- channelMeta.permissions,
206
- state,
207
- addServices,
208
- false
209
- )
191
+ expandAndAddGroupServices(channelMeta.middleware, state, addServices)
210
192
  }
211
193
 
212
194
  for (const queueMeta of Object.values(state.queueWorkers.meta)) {
213
- expandAndAddGroupServices(queueMeta.middleware, state, addServices, true)
214
- // expandAndAddGroupServices(queueMeta.permissions, state, addServices, false)
195
+ expandAndAddGroupServices(queueMeta.middleware, state, addServices)
215
196
  }
216
197
 
217
198
  for (const scheduleMeta of Object.values(state.scheduledTasks.meta)) {
218
- expandAndAddGroupServices(scheduleMeta.middleware, state, addServices, true)
219
- // expandAndAddGroupServices(scheduleMeta.permissions, state, addServices, false)
199
+ expandAndAddGroupServices(scheduleMeta.middleware, state, addServices)
220
200
  }
221
201
 
222
202
  for (const toolMeta of Object.values(state.mcpEndpoints.toolsMeta)) {
223
- expandAndAddGroupServices(toolMeta.middleware, state, addServices, true)
224
- expandAndAddGroupServices(toolMeta.permissions, state, addServices, false)
203
+ expandAndAddGroupServices(toolMeta.middleware, state, addServices)
225
204
  }
226
205
 
227
206
  for (const promptMeta of Object.values(state.mcpEndpoints.promptsMeta)) {
228
- expandAndAddGroupServices(promptMeta.middleware, state, addServices, true)
229
- expandAndAddGroupServices(promptMeta.permissions, state, addServices, false)
207
+ expandAndAddGroupServices(promptMeta.middleware, state, addServices)
230
208
  }
231
209
 
232
210
  for (const resourceMeta of Object.values(state.mcpEndpoints.resourcesMeta)) {
233
- expandAndAddGroupServices(resourceMeta.middleware, state, addServices, true)
234
- expandAndAddGroupServices(
235
- resourceMeta.permissions,
236
- state,
237
- addServices,
238
- false
239
- )
211
+ expandAndAddGroupServices(resourceMeta.middleware, state, addServices)
240
212
  }
241
213
 
242
214
  // 5. Services from session service factories
@@ -378,17 +350,24 @@ export function validateSecretOverrides(
378
350
  const { wireAddonDeclarations } = state.rpc
379
351
  if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
380
352
 
381
- const secretNames = new Set(state.secrets.definitions.map((d) => d.name))
353
+ // secretOverrides key on (and resolve to) SECRET IDs — the string the addon
354
+ // passes to getSecret — so validate against secretId, falling back to name for
355
+ // older meta without a secretId field.
356
+ const secretIds = new Set(
357
+ state.secrets.definitions.map((d: any) => d.secretId ?? d.name)
358
+ )
382
359
 
383
360
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
384
361
  if (!addonDecl.secretOverrides) continue
385
362
 
386
- for (const secretKey of Object.keys(addonDecl.secretOverrides)) {
387
- if (!secretNames.has(secretKey)) {
388
- const availableSecrets = Array.from(secretNames)
363
+ for (const [logicalName, resolvedName] of Object.entries(
364
+ addonDecl.secretOverrides
365
+ )) {
366
+ if (!secretIds.has(resolvedName)) {
367
+ const availableSecrets = Array.from(secretIds)
389
368
  logger.critical(
390
369
  ErrorCode.INVALID_VALUE,
391
- `Secret override '${secretKey}' in addon '${namespace}' (${addonDecl.package}) does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`
370
+ `Secret override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`
392
371
  )
393
372
  }
394
373
  }
@@ -409,12 +388,14 @@ export function validateCredentialOverrides(
409
388
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
410
389
  if (!addonDecl.credentialOverrides) continue
411
390
 
412
- for (const credentialKey of Object.keys(addonDecl.credentialOverrides)) {
413
- if (!credentialNames.has(credentialKey)) {
391
+ for (const [logicalName, resolvedName] of Object.entries(
392
+ addonDecl.credentialOverrides
393
+ )) {
394
+ if (!credentialNames.has(resolvedName)) {
414
395
  const availableCredentials = Array.from(credentialNames)
415
396
  logger.critical(
416
397
  ErrorCode.INVALID_VALUE,
417
- `Credential override '${credentialKey}' in addon '${namespace}' (${addonDecl.package}) does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`
398
+ `Credential override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`
418
399
  )
419
400
  }
420
401
  }
@@ -428,23 +409,120 @@ export function validateVariableOverrides(
428
409
  const { wireAddonDeclarations } = state.rpc
429
410
  if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
430
411
 
431
- const variableNames = new Set(state.variables.definitions.map((d) => d.name))
412
+ // variableOverrides key on (and resolve to) VARIABLE IDs, so validate against
413
+ // variableId, falling back to name for older meta without a variableId field.
414
+ const variableIds = new Set(
415
+ state.variables.definitions.map((d: any) => d.variableId ?? d.name)
416
+ )
432
417
 
433
418
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
434
419
  if (!addonDecl.variableOverrides) continue
435
420
 
436
- for (const variableKey of Object.keys(addonDecl.variableOverrides)) {
437
- if (!variableNames.has(variableKey)) {
438
- const availableVariables = Array.from(variableNames)
421
+ for (const [logicalName, resolvedName] of Object.entries(
422
+ addonDecl.variableOverrides
423
+ )) {
424
+ if (!variableIds.has(resolvedName)) {
425
+ const availableVariables = Array.from(variableIds)
439
426
  logger.critical(
440
427
  ErrorCode.INVALID_VALUE,
441
- `Variable override '${variableKey}' in addon '${namespace}' (${addonDecl.package}) does not exist. Available variables: ${availableVariables.join(', ') || 'none'}`
428
+ `Variable override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a variable that does not exist. Available variables: ${availableVariables.join(', ') || 'none'}`
442
429
  )
443
430
  }
444
431
  }
445
432
  }
446
433
  }
447
434
 
435
+ /**
436
+ * A `wireRemoteAddon` package ships types only — its handlers run on the host —
437
+ * so it MUST be a devDependency, not a production dependency (a prod dep would
438
+ * drag in the runtime deps remote consumption exists to avoid). This is the
439
+ * mirror image of `wireAddon`, which requires a production dependency.
440
+ */
441
+ export function validateRemoteAddonDependencies(
442
+ logger: InspectorLogger,
443
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
444
+ ): void {
445
+ const { wireAddonDeclarations } = state.rpc
446
+ if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
447
+
448
+ const hasRemote = Array.from(wireAddonDeclarations.values()).some(
449
+ (d) => d.remote
450
+ )
451
+ if (!hasRemote) return
452
+
453
+ const pkgJsonPath = join(state.rootDir, 'package.json')
454
+ if (!existsSync(pkgJsonPath)) return // no manifest to check (e.g. some tests)
455
+
456
+ let pkgJson: {
457
+ dependencies?: Record<string, string>
458
+ devDependencies?: Record<string, string>
459
+ }
460
+ try {
461
+ pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))
462
+ } catch (e: any) {
463
+ logger.warn(
464
+ `Could not read ${pkgJsonPath} to verify remote addon dependencies: ${e?.message ?? e}`
465
+ )
466
+ return
467
+ }
468
+
469
+ const prodDeps = pkgJson.dependencies ?? {}
470
+ const devDeps = pkgJson.devDependencies ?? {}
471
+
472
+ for (const [namespace, decl] of wireAddonDeclarations.entries()) {
473
+ if (!decl.remote) continue
474
+ if (decl.package in devDeps) continue // correct
475
+
476
+ if (decl.package in prodDeps) {
477
+ logger.critical(
478
+ ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY,
479
+ `Remote addon '${namespace}' ('${decl.package}') is a production dependency, but wireRemoteAddon consumes it for types only — its handlers run on the host. Move '${decl.package}' from "dependencies" to "devDependencies".`
480
+ )
481
+ } else {
482
+ logger.critical(
483
+ ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY,
484
+ `Remote addon '${namespace}' ('${decl.package}') is wired with wireRemoteAddon but is not in "devDependencies". Add '${decl.package}' to "devDependencies" (types only).`
485
+ )
486
+ }
487
+ }
488
+ }
489
+
490
+ /**
491
+ * The auth a `wireRemoteAddon` consumer binds must reference a slot the consumer
492
+ * actually declares: a `credentialId` must be a wired credential, a `secretId`
493
+ * a wired secret. A custom `resolve()` and a public (omitted) surface are not
494
+ * statically checkable and are left to runtime.
495
+ */
496
+ export function validateRemoteAddonAuth(
497
+ logger: InspectorLogger,
498
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
499
+ ): void {
500
+ const { wireAddonDeclarations } = state.rpc
501
+ if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
502
+
503
+ const credentialNames = new Set(
504
+ state.credentials?.definitions.map((d) => d.name) ?? []
505
+ )
506
+ const secretNames = new Set(state.secrets.definitions.map((d) => d.name))
507
+
508
+ for (const [namespace, decl] of wireAddonDeclarations.entries()) {
509
+ if (!decl.remote) continue
510
+
511
+ if (decl.authCredentialId && !credentialNames.has(decl.authCredentialId)) {
512
+ logger.critical(
513
+ ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED,
514
+ `Remote addon '${namespace}' binds auth.credentialId '${decl.authCredentialId}', but no such credential is wired. Available credentials: ${Array.from(credentialNames).join(', ') || 'none'}`
515
+ )
516
+ }
517
+ if (decl.authSecretId && !secretNames.has(decl.authSecretId)) {
518
+ logger.critical(
519
+ ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED,
520
+ `Remote addon '${namespace}' binds auth.secretId '${decl.authSecretId}', but no such secret is wired. Available secrets: ${Array.from(secretNames).join(', ') || 'none'}`
521
+ )
522
+ }
523
+ }
524
+ }
525
+
448
526
  export function computeResolvedIOTypes(state: InspectorState): void {
449
527
  const { functions } = state
450
528
  for (const [pikkuFuncId, meta] of Object.entries(functions.meta)) {
@@ -531,36 +609,8 @@ export function computeMiddlewareGroupsMeta(state: InspectorState): void {
531
609
  }
532
610
 
533
611
  export function computePermissionsGroupsMeta(state: InspectorState): void {
534
- const httpGroups: Record<string, any> = {}
535
- for (const [pattern, meta] of state.http.routePermissions.entries()) {
536
- httpGroups[pattern] = {
537
- exportName: meta.exportName,
538
- sourceFile: meta.sourceFile,
539
- position: meta.position,
540
- services: meta.services,
541
- count: meta.count,
542
- instanceIds: meta.instanceIds,
543
- isFactory: meta.isFactory,
544
- }
545
- }
546
-
547
- const tagGroups: Record<string, any> = {}
548
- for (const [tag, meta] of state.permissions.tagPermissions.entries()) {
549
- tagGroups[tag] = {
550
- exportName: meta.exportName,
551
- sourceFile: meta.sourceFile,
552
- position: meta.position,
553
- services: meta.services,
554
- count: meta.count,
555
- instanceIds: meta.instanceIds,
556
- isFactory: meta.isFactory,
557
- }
558
- }
559
-
560
612
  state.permissionsGroupsMeta = {
561
613
  definitions: state.permissions.definitions,
562
- httpGroups,
563
- tagGroups,
564
614
  }
565
615
  }
566
616
 
@@ -632,6 +682,44 @@ export function validateAgentModels(
632
682
  }
633
683
  }
634
684
 
685
+ /**
686
+ * A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
687
+ * `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
688
+ * registers and codegen fails deep in type-checking with an opaque error. Fail
689
+ * early with an actionable message pointing at `scaffold: { graph: true }`.
690
+ */
691
+ export function validateWorkflowGraphAddons(
692
+ logger: InspectorLogger,
693
+ state: InspectorState
694
+ ): void {
695
+ const addonGraphWired = Array.from(
696
+ state.rpc.wireAddonDeclarations.values()
697
+ ).some((decl) => decl.package === '@pikku/addon-graph')
698
+ if (addonGraphWired) {
699
+ return
700
+ }
701
+
702
+ for (const [name, graph] of Object.entries(state.workflows.graphMeta)) {
703
+ for (const node of Object.values(graph.nodes)) {
704
+ if (!('rpcName' in node) || typeof node.rpcName !== 'string') {
705
+ continue
706
+ }
707
+ if (!node.rpcName.startsWith('graph:')) {
708
+ continue
709
+ }
710
+ if (state.functions.meta[node.rpcName]) {
711
+ continue
712
+ }
713
+ logger.critical(
714
+ ErrorCode.WORKFLOW_GRAPH_ADDON_NOT_WIRED,
715
+ `Workflow graph '${name}' references '${node.rpcName}' but @pikku/addon-graph is not wired. ` +
716
+ `Enable "scaffold": { "graph": true } in pikku.config.json (and install @pikku/addon-graph), ` +
717
+ `or wire it manually with wireAddon({ name: 'graph', package: '@pikku/addon-graph' }).`
718
+ )
719
+ }
720
+ }
721
+ }
722
+
635
723
  /**
636
724
  * Validates that Zod schemas and wiring side-effects (wireHTTPRoutes,
637
725
  * addPermission, addHTTPMiddleware, etc.) do not coexist in the same file.
@@ -658,14 +746,6 @@ export function validateSchemaWiringSeparation(
658
746
  wiringFiles.add(file)
659
747
  }
660
748
 
661
- // Permission wirings (addPermission calls)
662
- for (const meta of state.permissions.tagPermissions.values()) {
663
- wiringFiles.add(meta.sourceFile)
664
- }
665
- for (const meta of state.http.routePermissions.values()) {
666
- wiringFiles.add(meta.sourceFile)
667
- }
668
-
669
749
  // Middleware wirings (addHTTPMiddleware calls)
670
750
  for (const meta of state.http.routeMiddleware.values()) {
671
751
  wiringFiles.add(meta.sourceFile)
@@ -769,3 +849,44 @@ export function computeDiagnostics(state: InspectorState): void {
769
849
 
770
850
  state.diagnostics = diagnostics
771
851
  }
852
+
853
+ /**
854
+ * Validates that every scope referenced by a function is declared via
855
+ * `wireScope`. Runs after all visitors, so declaration order does not matter.
856
+ *
857
+ * A `*` suffix is a wildcard requirement: `admin:*` requires the `admin` scope
858
+ * to be declared, and grants its whole subtree.
859
+ */
860
+ export function validateScopeReferences(
861
+ logger: InspectorLogger,
862
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
863
+ ): void {
864
+ const declared = new Set(
865
+ flattenScopeDefinitions(state.scopes.definitions).map((s) => s.id)
866
+ )
867
+
868
+ for (const [funcName, meta] of Object.entries(state.functions.meta)) {
869
+ if (!meta.scopes?.length) continue
870
+
871
+ for (const scope of meta.scopes) {
872
+ // A trailing wildcard grants a subtree; the node it hangs off must exist.
873
+ const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope
874
+
875
+ if (scope === '*') {
876
+ logger.critical(
877
+ ErrorCode.INVALID_VALUE,
878
+ `Function '${funcName}' requires the bare wildcard scope '*'. A function must require a specific scope — '*' is only meaningful as a grant.`
879
+ )
880
+ continue
881
+ }
882
+
883
+ if (!declared.has(declaredForm)) {
884
+ const available = Array.from(declared)
885
+ logger.critical(
886
+ ErrorCode.INVALID_VALUE,
887
+ `Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with wireScope. Available scopes: ${available.join(', ') || 'none'}`
888
+ )
889
+ }
890
+ }
891
+ }
892
+ }
@@ -112,14 +112,6 @@ export interface SerializableInspectorState {
112
112
  : never,
113
113
  ]
114
114
  >
115
- routePermissions: Array<
116
- [
117
- string,
118
- InspectorState['http']['routePermissions'] extends Map<string, infer V>
119
- ? V
120
- : never,
121
- ]
122
- >
123
115
  }
124
116
  channels: {
125
117
  files: string[]
@@ -154,6 +146,8 @@ export interface SerializableInspectorState {
154
146
  internalFiles: Array<[string, { path: string; exportedName: string }]>
155
147
  exposedMeta: InspectorState['rpc']['exposedMeta']
156
148
  exposedFiles: Array<[string, { path: string; exportedName: string }]>
149
+ remoteMeta: InspectorState['rpc']['remoteMeta']
150
+ remoteFiles: Array<[string, { path: string; exportedName: string }]>
157
151
  invokedFunctions: string[]
158
152
  invokedFunctionsByFile?: Array<[string, string[]]>
159
153
  usedAddons: string[]
@@ -163,6 +157,10 @@ export interface SerializableInspectorState {
163
157
  {
164
158
  package: string
165
159
  rpcEndpoint?: string
160
+ remote?: boolean
161
+ hasAuth?: boolean
162
+ authCredentialId?: string
163
+ authSecretId?: string
166
164
  secretOverrides?: Record<string, string>
167
165
  variableOverrides?: Record<string, string>
168
166
  credentialOverrides?: Record<string, string>
@@ -203,6 +201,10 @@ export interface SerializableInspectorState {
203
201
  definitions: InspectorState['credentials']['definitions']
204
202
  files: string[]
205
203
  }
204
+ scopes: {
205
+ definitions: InspectorState['scopes']['definitions']
206
+ files: string[]
207
+ }
206
208
  variables: {
207
209
  definitions: InspectorState['variables']['definitions']
208
210
  files: string[]
@@ -244,17 +246,6 @@ export interface SerializableInspectorState {
244
246
  permissions: {
245
247
  definitions: InspectorState['permissions']['definitions']
246
248
  instances: InspectorState['permissions']['instances']
247
- tagPermissions: Array<
248
- [
249
- string,
250
- InspectorState['permissions']['tagPermissions'] extends Map<
251
- string,
252
- infer V
253
- >
254
- ? V
255
- : never,
256
- ]
257
- >
258
249
  }
259
250
  serviceAggregation: {
260
251
  requiredServices: string[]
@@ -344,7 +335,6 @@ export function serializeInspectorState(
344
335
  meta: state.http.meta,
345
336
  files: Array.from(state.http.files),
346
337
  routeMiddleware: Array.from(state.http.routeMiddleware.entries()),
347
- routePermissions: Array.from(state.http.routePermissions.entries()),
348
338
  },
349
339
  channels: {
350
340
  files: Array.from(state.channels.files),
@@ -379,6 +369,8 @@ export function serializeInspectorState(
379
369
  internalFiles: Array.from(state.rpc.internalFiles.entries()),
380
370
  exposedMeta: state.rpc.exposedMeta,
381
371
  exposedFiles: Array.from(state.rpc.exposedFiles.entries()),
372
+ remoteMeta: state.rpc.remoteMeta,
373
+ remoteFiles: Array.from(state.rpc.remoteFiles.entries()),
382
374
  invokedFunctions: Array.from(state.rpc.invokedFunctions),
383
375
  invokedFunctionsByFile: Array.from(
384
376
  (
@@ -423,6 +415,10 @@ export function serializeInspectorState(
423
415
  definitions: state.credentials.definitions,
424
416
  files: Array.from(state.credentials.files),
425
417
  },
418
+ scopes: {
419
+ definitions: state.scopes.definitions,
420
+ files: Array.from(state.scopes.files),
421
+ },
426
422
  variables: {
427
423
  definitions: state.variables.definitions,
428
424
  files: Array.from(state.variables.files),
@@ -446,7 +442,6 @@ export function serializeInspectorState(
446
442
  permissions: {
447
443
  definitions: state.permissions.definitions,
448
444
  instances: state.permissions.instances,
449
- tagPermissions: Array.from(state.permissions.tagPermissions.entries()),
450
445
  },
451
446
  serviceAggregation: {
452
447
  requiredServices: Array.from(state.serviceAggregation.requiredServices),
@@ -536,7 +531,6 @@ export function deserializeInspectorState(
536
531
  meta: data.http.meta,
537
532
  files: new Set(data.http.files),
538
533
  routeMiddleware: new Map(data.http.routeMiddleware),
539
- routePermissions: new Map(data.http.routePermissions),
540
534
  },
541
535
  channels: {
542
536
  files: new Set(data.channels.files),
@@ -571,6 +565,8 @@ export function deserializeInspectorState(
571
565
  internalFiles: new Map(data.rpc.internalFiles),
572
566
  exposedMeta: data.rpc.exposedMeta,
573
567
  exposedFiles: new Map(data.rpc.exposedFiles),
568
+ remoteMeta: data.rpc.remoteMeta ?? {},
569
+ remoteFiles: new Map(data.rpc.remoteFiles ?? []),
574
570
  invokedFunctions: new Set(data.rpc.invokedFunctions),
575
571
  invokedFunctionsByFile: new Map(
576
572
  (data.rpc.invokedFunctionsByFile || []).map(
@@ -613,6 +609,10 @@ export function deserializeInspectorState(
613
609
  definitions: data.credentials?.definitions || [],
614
610
  files: new Set(data.credentials?.files || []),
615
611
  },
612
+ scopes: {
613
+ definitions: data.scopes?.definitions || [],
614
+ files: new Set(data.scopes?.files || []),
615
+ },
616
616
  variables: {
617
617
  definitions: data.variables?.definitions || [],
618
618
  files: new Set(data.variables?.files || []),
@@ -634,7 +634,6 @@ export function deserializeInspectorState(
634
634
  permissions: {
635
635
  definitions: data.permissions.definitions,
636
636
  instances: data.permissions.instances || {},
637
- tagPermissions: new Map(data.permissions.tagPermissions),
638
637
  },
639
638
  serviceAggregation: {
640
639
  requiredServices: new Set(data.serviceAggregation.requiredServices),
@@ -654,8 +653,6 @@ export function deserializeInspectorState(
654
653
  },
655
654
  permissionsGroupsMeta: data.permissionsGroupsMeta || {
656
655
  definitions: {},
657
- httpGroups: {},
658
- tagGroups: {},
659
656
  },
660
657
  requiredSchemas: new Set(data.requiredSchemas || []),
661
658
  openAPISpec: data.openAPISpec || null,
@@ -1,35 +1,7 @@
1
1
  import type { InspectorState } from '../types.js'
2
2
 
3
3
  export const serializePermissionsGroupsMeta = (state: InspectorState) => {
4
- const httpGroups: Record<string, any> = {}
5
- for (const [pattern, meta] of state.http.routePermissions.entries()) {
6
- httpGroups[pattern] = {
7
- exportName: meta.exportName,
8
- sourceFile: meta.sourceFile,
9
- position: meta.position,
10
- services: meta.services,
11
- count: meta.count,
12
- instanceIds: meta.instanceIds,
13
- isFactory: meta.isFactory,
14
- }
15
- }
16
-
17
- const tagGroups: Record<string, any> = {}
18
- for (const [tag, meta] of state.permissions.tagPermissions.entries()) {
19
- tagGroups[tag] = {
20
- exportName: meta.exportName,
21
- sourceFile: meta.sourceFile,
22
- position: meta.position,
23
- services: meta.services,
24
- count: meta.count,
25
- instanceIds: meta.instanceIds,
26
- isFactory: meta.isFactory,
27
- }
28
- }
29
-
30
4
  return {
31
5
  definitions: state.permissions.definitions,
32
- httpGroups,
33
- tagGroups,
34
6
  }
35
7
  }
@@ -84,7 +84,11 @@ describe('deriveWorkflowPlan', () => {
84
84
  } as WorkflowStepMeta,
85
85
  ])
86
86
  assert.equal(plan.deterministic, false)
87
- assert.equal(plan.plannedSteps, undefined, 'loop count is runtime-dependent')
87
+ assert.equal(
88
+ plan.plannedSteps,
89
+ undefined,
90
+ 'loop count is runtime-dependent'
91
+ )
88
92
  })
89
93
 
90
94
  test('a fanout nested inside a branch still suppresses the plan', () => {
@@ -133,8 +137,42 @@ describe('deriveWorkflowPlan', () => {
133
137
  { stepName: 'build' },
134
138
  { stepName: '__workflow_suspend:building', displayName: 'Building' },
135
139
  { stepName: 'publish' },
136
- { stepName: '__workflow_suspend:awaiting_approval', displayName: 'Awaiting approval' },
137
- { stepName: '__workflow_suspend:building-image', displayName: 'Building image' },
140
+ {
141
+ stepName: '__workflow_suspend:awaiting_approval',
142
+ displayName: 'Awaiting approval',
143
+ },
144
+ {
145
+ stepName: '__workflow_suspend:building-image',
146
+ displayName: 'Building image',
147
+ },
148
+ ])
149
+ })
150
+
151
+ test('approval steps appear in the plan with __workflow_approval: key and sentence-case displayName', () => {
152
+ const plan = deriveWorkflowPlan([
153
+ rpc('build'),
154
+ { type: 'approval', reason: 'awaiting_approval' } as WorkflowStepMeta,
155
+ rpc('publish'),
156
+ ])
157
+ assert.equal(plan.deterministic, true)
158
+ assert.deepEqual(plan.plannedSteps, [
159
+ { stepName: 'build' },
160
+ {
161
+ stepName: '__workflow_approval:awaiting_approval',
162
+ displayName: 'Awaiting approval',
163
+ },
164
+ { stepName: 'publish' },
165
+ ])
166
+ })
167
+
168
+ test('an approval is namespaced apart from a suspend sharing its reason', () => {
169
+ const plan = deriveWorkflowPlan([
170
+ { type: 'suspend', reason: 'sign_off' } as WorkflowStepMeta,
171
+ { type: 'approval', reason: 'sign_off' } as WorkflowStepMeta,
138
172
  ])
173
+ assert.deepEqual(
174
+ plan.plannedSteps.map((s) => s.stepName),
175
+ ['__workflow_suspend:sign_off', '__workflow_approval:sign_off']
176
+ )
139
177
  })
140
178
  })