@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
@@ -1,6 +1,9 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { extractTypeKeys } from './type-utils.js';
2
4
  import { ErrorCode } from '../error-codes.js';
3
5
  import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js';
6
+ import { flattenScopeDefinitions } from '@pikku/core/scope';
4
7
  /**
5
8
  * Stamp the inspected authorize/callbacks service set onto the generated auth
6
9
  * handler's function meta.
@@ -39,28 +42,22 @@ export function extractWireNames(list) {
39
42
  .map((item) => item.name);
40
43
  }
41
44
  /**
42
- * Helper to expand middleware/permission groups into individual names
43
- * and add their services to the aggregation.
44
- * This handles tag-based and HTTP-pattern-based middleware/permission groups.
45
+ * Helper to expand middleware groups into individual names and add their
46
+ * services to the aggregation. Handles tag-based and HTTP-pattern-based
47
+ * middleware groups. Permissions are function-scoped only and carry no groups.
45
48
  */
46
- function expandAndAddGroupServices(list, state, addServices, isMiddleware) {
49
+ function expandAndAddGroupServices(list, state, addServices) {
47
50
  if (!list)
48
51
  return;
49
52
  for (const item of list) {
50
53
  if (item.type === 'tag') {
51
- // Expand tag-based group
52
- const groupMeta = isMiddleware
53
- ? state.middleware.tagMiddleware.get(item.tag)
54
- : state.permissions.tagPermissions.get(item.tag);
54
+ const groupMeta = state.middleware.tagMiddleware.get(item.tag);
55
55
  if (groupMeta?.services) {
56
56
  addServices(groupMeta.services);
57
57
  }
58
58
  }
59
59
  else if (item.type === 'http' && 'route' in item) {
60
- // Expand HTTP-pattern-based group
61
- const groupMeta = isMiddleware
62
- ? state.http.routeMiddleware.get(item.route)
63
- : state.http.routePermissions.get(item.route);
60
+ const groupMeta = state.http.routeMiddleware.get(item.route);
64
61
  if (groupMeta?.services) {
65
62
  addServices(groupMeta.services);
66
63
  }
@@ -149,34 +146,27 @@ export function aggregateRequiredServices(state) {
149
146
  'options',
150
147
  ]) {
151
148
  for (const routeMeta of Object.values(state.http.meta[method])) {
152
- expandAndAddGroupServices(routeMeta.middleware, state, addServices, true);
153
- expandAndAddGroupServices(routeMeta.permissions, state, addServices, false);
149
+ expandAndAddGroupServices(routeMeta.middleware, state, addServices);
154
150
  }
155
151
  }
156
152
  // Also check other wiring types (channels, queues, schedulers, MCP)
157
153
  for (const channelMeta of Object.values(state.channels.meta)) {
158
- expandAndAddGroupServices(channelMeta.middleware, state, addServices, true);
159
- expandAndAddGroupServices(channelMeta.permissions, state, addServices, false);
154
+ expandAndAddGroupServices(channelMeta.middleware, state, addServices);
160
155
  }
161
156
  for (const queueMeta of Object.values(state.queueWorkers.meta)) {
162
- expandAndAddGroupServices(queueMeta.middleware, state, addServices, true);
163
- // expandAndAddGroupServices(queueMeta.permissions, state, addServices, false)
157
+ expandAndAddGroupServices(queueMeta.middleware, state, addServices);
164
158
  }
165
159
  for (const scheduleMeta of Object.values(state.scheduledTasks.meta)) {
166
- expandAndAddGroupServices(scheduleMeta.middleware, state, addServices, true);
167
- // expandAndAddGroupServices(scheduleMeta.permissions, state, addServices, false)
160
+ expandAndAddGroupServices(scheduleMeta.middleware, state, addServices);
168
161
  }
169
162
  for (const toolMeta of Object.values(state.mcpEndpoints.toolsMeta)) {
170
- expandAndAddGroupServices(toolMeta.middleware, state, addServices, true);
171
- expandAndAddGroupServices(toolMeta.permissions, state, addServices, false);
163
+ expandAndAddGroupServices(toolMeta.middleware, state, addServices);
172
164
  }
173
165
  for (const promptMeta of Object.values(state.mcpEndpoints.promptsMeta)) {
174
- expandAndAddGroupServices(promptMeta.middleware, state, addServices, true);
175
- expandAndAddGroupServices(promptMeta.permissions, state, addServices, false);
166
+ expandAndAddGroupServices(promptMeta.middleware, state, addServices);
176
167
  }
177
168
  for (const resourceMeta of Object.values(state.mcpEndpoints.resourcesMeta)) {
178
- expandAndAddGroupServices(resourceMeta.middleware, state, addServices, true);
179
- expandAndAddGroupServices(resourceMeta.permissions, state, addServices, false);
169
+ expandAndAddGroupServices(resourceMeta.middleware, state, addServices);
180
170
  }
181
171
  // 5. Services from session service factories
182
172
  for (const singletonServices of state.wireServicesMeta.values()) {
@@ -299,14 +289,17 @@ export function validateSecretOverrides(logger, state) {
299
289
  const { wireAddonDeclarations } = state.rpc;
300
290
  if (!wireAddonDeclarations || wireAddonDeclarations.size === 0)
301
291
  return;
302
- const secretNames = new Set(state.secrets.definitions.map((d) => d.name));
292
+ // secretOverrides key on (and resolve to) SECRET IDs — the string the addon
293
+ // passes to getSecret — so validate against secretId, falling back to name for
294
+ // older meta without a secretId field.
295
+ const secretIds = new Set(state.secrets.definitions.map((d) => d.secretId ?? d.name));
303
296
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
304
297
  if (!addonDecl.secretOverrides)
305
298
  continue;
306
- for (const secretKey of Object.keys(addonDecl.secretOverrides)) {
307
- if (!secretNames.has(secretKey)) {
308
- const availableSecrets = Array.from(secretNames);
309
- logger.critical(ErrorCode.INVALID_VALUE, `Secret override '${secretKey}' in addon '${namespace}' (${addonDecl.package}) does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`);
299
+ for (const [logicalName, resolvedName] of Object.entries(addonDecl.secretOverrides)) {
300
+ if (!secretIds.has(resolvedName)) {
301
+ const availableSecrets = Array.from(secretIds);
302
+ logger.critical(ErrorCode.INVALID_VALUE, `Secret override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`);
310
303
  }
311
304
  }
312
305
  }
@@ -319,10 +312,10 @@ export function validateCredentialOverrides(logger, state) {
319
312
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
320
313
  if (!addonDecl.credentialOverrides)
321
314
  continue;
322
- for (const credentialKey of Object.keys(addonDecl.credentialOverrides)) {
323
- if (!credentialNames.has(credentialKey)) {
315
+ for (const [logicalName, resolvedName] of Object.entries(addonDecl.credentialOverrides)) {
316
+ if (!credentialNames.has(resolvedName)) {
324
317
  const availableCredentials = Array.from(credentialNames);
325
- logger.critical(ErrorCode.INVALID_VALUE, `Credential override '${credentialKey}' in addon '${namespace}' (${addonDecl.package}) does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`);
318
+ logger.critical(ErrorCode.INVALID_VALUE, `Credential override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`);
326
319
  }
327
320
  }
328
321
  }
@@ -331,18 +324,82 @@ export function validateVariableOverrides(logger, state) {
331
324
  const { wireAddonDeclarations } = state.rpc;
332
325
  if (!wireAddonDeclarations || wireAddonDeclarations.size === 0)
333
326
  return;
334
- const variableNames = new Set(state.variables.definitions.map((d) => d.name));
327
+ // variableOverrides key on (and resolve to) VARIABLE IDs, so validate against
328
+ // variableId, falling back to name for older meta without a variableId field.
329
+ const variableIds = new Set(state.variables.definitions.map((d) => d.variableId ?? d.name));
335
330
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
336
331
  if (!addonDecl.variableOverrides)
337
332
  continue;
338
- for (const variableKey of Object.keys(addonDecl.variableOverrides)) {
339
- if (!variableNames.has(variableKey)) {
340
- const availableVariables = Array.from(variableNames);
341
- logger.critical(ErrorCode.INVALID_VALUE, `Variable override '${variableKey}' in addon '${namespace}' (${addonDecl.package}) does not exist. Available variables: ${availableVariables.join(', ') || 'none'}`);
333
+ for (const [logicalName, resolvedName] of Object.entries(addonDecl.variableOverrides)) {
334
+ if (!variableIds.has(resolvedName)) {
335
+ const availableVariables = Array.from(variableIds);
336
+ logger.critical(ErrorCode.INVALID_VALUE, `Variable override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a variable that does not exist. Available variables: ${availableVariables.join(', ') || 'none'}`);
342
337
  }
343
338
  }
344
339
  }
345
340
  }
341
+ /**
342
+ * A `wireRemoteAddon` package ships types only — its handlers run on the host —
343
+ * so it MUST be a devDependency, not a production dependency (a prod dep would
344
+ * drag in the runtime deps remote consumption exists to avoid). This is the
345
+ * mirror image of `wireAddon`, which requires a production dependency.
346
+ */
347
+ export function validateRemoteAddonDependencies(logger, state) {
348
+ const { wireAddonDeclarations } = state.rpc;
349
+ if (!wireAddonDeclarations || wireAddonDeclarations.size === 0)
350
+ return;
351
+ const hasRemote = Array.from(wireAddonDeclarations.values()).some((d) => d.remote);
352
+ if (!hasRemote)
353
+ return;
354
+ const pkgJsonPath = join(state.rootDir, 'package.json');
355
+ if (!existsSync(pkgJsonPath))
356
+ return; // no manifest to check (e.g. some tests)
357
+ let pkgJson;
358
+ try {
359
+ pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
360
+ }
361
+ catch (e) {
362
+ logger.warn(`Could not read ${pkgJsonPath} to verify remote addon dependencies: ${e?.message ?? e}`);
363
+ return;
364
+ }
365
+ const prodDeps = pkgJson.dependencies ?? {};
366
+ const devDeps = pkgJson.devDependencies ?? {};
367
+ for (const [namespace, decl] of wireAddonDeclarations.entries()) {
368
+ if (!decl.remote)
369
+ continue;
370
+ if (decl.package in devDeps)
371
+ continue; // correct
372
+ if (decl.package in prodDeps) {
373
+ logger.critical(ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY, `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".`);
374
+ }
375
+ else {
376
+ logger.critical(ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY, `Remote addon '${namespace}' ('${decl.package}') is wired with wireRemoteAddon but is not in "devDependencies". Add '${decl.package}' to "devDependencies" (types only).`);
377
+ }
378
+ }
379
+ }
380
+ /**
381
+ * The auth a `wireRemoteAddon` consumer binds must reference a slot the consumer
382
+ * actually declares: a `credentialId` must be a wired credential, a `secretId`
383
+ * a wired secret. A custom `resolve()` and a public (omitted) surface are not
384
+ * statically checkable and are left to runtime.
385
+ */
386
+ export function validateRemoteAddonAuth(logger, state) {
387
+ const { wireAddonDeclarations } = state.rpc;
388
+ if (!wireAddonDeclarations || wireAddonDeclarations.size === 0)
389
+ return;
390
+ const credentialNames = new Set(state.credentials?.definitions.map((d) => d.name) ?? []);
391
+ const secretNames = new Set(state.secrets.definitions.map((d) => d.name));
392
+ for (const [namespace, decl] of wireAddonDeclarations.entries()) {
393
+ if (!decl.remote)
394
+ continue;
395
+ if (decl.authCredentialId && !credentialNames.has(decl.authCredentialId)) {
396
+ logger.critical(ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED, `Remote addon '${namespace}' binds auth.credentialId '${decl.authCredentialId}', but no such credential is wired. Available credentials: ${Array.from(credentialNames).join(', ') || 'none'}`);
397
+ }
398
+ if (decl.authSecretId && !secretNames.has(decl.authSecretId)) {
399
+ logger.critical(ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED, `Remote addon '${namespace}' binds auth.secretId '${decl.authSecretId}', but no such secret is wired. Available secrets: ${Array.from(secretNames).join(', ') || 'none'}`);
400
+ }
401
+ }
402
+ }
346
403
  export function computeResolvedIOTypes(state) {
347
404
  const { functions } = state;
348
405
  for (const [pikkuFuncId, meta] of Object.entries(functions.meta)) {
@@ -424,34 +481,8 @@ export function computeMiddlewareGroupsMeta(state) {
424
481
  };
425
482
  }
426
483
  export function computePermissionsGroupsMeta(state) {
427
- const httpGroups = {};
428
- for (const [pattern, meta] of state.http.routePermissions.entries()) {
429
- httpGroups[pattern] = {
430
- exportName: meta.exportName,
431
- sourceFile: meta.sourceFile,
432
- position: meta.position,
433
- services: meta.services,
434
- count: meta.count,
435
- instanceIds: meta.instanceIds,
436
- isFactory: meta.isFactory,
437
- };
438
- }
439
- const tagGroups = {};
440
- for (const [tag, meta] of state.permissions.tagPermissions.entries()) {
441
- tagGroups[tag] = {
442
- exportName: meta.exportName,
443
- sourceFile: meta.sourceFile,
444
- position: meta.position,
445
- services: meta.services,
446
- count: meta.count,
447
- instanceIds: meta.instanceIds,
448
- isFactory: meta.isFactory,
449
- };
450
- }
451
484
  state.permissionsGroupsMeta = {
452
485
  definitions: state.permissions.definitions,
453
- httpGroups,
454
- tagGroups,
455
486
  };
456
487
  }
457
488
  const PRIMITIVE_TYPES = new Set([
@@ -508,6 +539,34 @@ export function validateAgentModels(logger, state) {
508
539
  }
509
540
  }
510
541
  }
542
+ /**
543
+ * A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
544
+ * `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
545
+ * registers and codegen fails deep in type-checking with an opaque error. Fail
546
+ * early with an actionable message pointing at `scaffold: { graph: true }`.
547
+ */
548
+ export function validateWorkflowGraphAddons(logger, state) {
549
+ const addonGraphWired = Array.from(state.rpc.wireAddonDeclarations.values()).some((decl) => decl.package === '@pikku/addon-graph');
550
+ if (addonGraphWired) {
551
+ return;
552
+ }
553
+ for (const [name, graph] of Object.entries(state.workflows.graphMeta)) {
554
+ for (const node of Object.values(graph.nodes)) {
555
+ if (!('rpcName' in node) || typeof node.rpcName !== 'string') {
556
+ continue;
557
+ }
558
+ if (!node.rpcName.startsWith('graph:')) {
559
+ continue;
560
+ }
561
+ if (state.functions.meta[node.rpcName]) {
562
+ continue;
563
+ }
564
+ logger.critical(ErrorCode.WORKFLOW_GRAPH_ADDON_NOT_WIRED, `Workflow graph '${name}' references '${node.rpcName}' but @pikku/addon-graph is not wired. ` +
565
+ `Enable "scaffold": { "graph": true } in pikku.config.json (and install @pikku/addon-graph), ` +
566
+ `or wire it manually with wireAddon({ name: 'graph', package: '@pikku/addon-graph' }).`);
567
+ }
568
+ }
569
+ }
511
570
  /**
512
571
  * Validates that Zod schemas and wiring side-effects (wireHTTPRoutes,
513
572
  * addPermission, addHTTPMiddleware, etc.) do not coexist in the same file.
@@ -528,13 +587,6 @@ export function validateSchemaWiringSeparation(logger, state) {
528
587
  for (const file of state.http.files) {
529
588
  wiringFiles.add(file);
530
589
  }
531
- // Permission wirings (addPermission calls)
532
- for (const meta of state.permissions.tagPermissions.values()) {
533
- wiringFiles.add(meta.sourceFile);
534
- }
535
- for (const meta of state.http.routePermissions.values()) {
536
- wiringFiles.add(meta.sourceFile);
537
- }
538
590
  // Middleware wirings (addHTTPMiddleware calls)
539
591
  for (const meta of state.http.routeMiddleware.values()) {
540
592
  wiringFiles.add(meta.sourceFile);
@@ -628,3 +680,29 @@ export function computeDiagnostics(state) {
628
680
  }
629
681
  state.diagnostics = diagnostics;
630
682
  }
683
+ /**
684
+ * Validates that every scope referenced by a function is declared via
685
+ * `wireScope`. Runs after all visitors, so declaration order does not matter.
686
+ *
687
+ * A `*` suffix is a wildcard requirement: `admin:*` requires the `admin` scope
688
+ * to be declared, and grants its whole subtree.
689
+ */
690
+ export function validateScopeReferences(logger, state) {
691
+ const declared = new Set(flattenScopeDefinitions(state.scopes.definitions).map((s) => s.id));
692
+ for (const [funcName, meta] of Object.entries(state.functions.meta)) {
693
+ if (!meta.scopes?.length)
694
+ continue;
695
+ for (const scope of meta.scopes) {
696
+ // A trailing wildcard grants a subtree; the node it hangs off must exist.
697
+ const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope;
698
+ if (scope === '*') {
699
+ logger.critical(ErrorCode.INVALID_VALUE, `Function '${funcName}' requires the bare wildcard scope '*'. A function must require a specific scope — '*' is only meaningful as a grant.`);
700
+ continue;
701
+ }
702
+ if (!declared.has(declaredForm)) {
703
+ const available = Array.from(declared);
704
+ logger.critical(ErrorCode.INVALID_VALUE, `Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with wireScope. Available scopes: ${available.join(', ') || 'none'}`);
705
+ }
706
+ }
707
+ }
708
+ }
@@ -127,10 +127,6 @@ export interface SerializableInspectorState {
127
127
  string,
128
128
  InspectorState['http']['routeMiddleware'] extends Map<string, infer V> ? V : never
129
129
  ]>;
130
- routePermissions: Array<[
131
- string,
132
- InspectorState['http']['routePermissions'] extends Map<string, infer V> ? V : never
133
- ]>;
134
130
  };
135
131
  channels: {
136
132
  files: string[];
@@ -177,6 +173,11 @@ export interface SerializableInspectorState {
177
173
  path: string;
178
174
  exportedName: string;
179
175
  }]>;
176
+ remoteMeta: InspectorState['rpc']['remoteMeta'];
177
+ remoteFiles: Array<[string, {
178
+ path: string;
179
+ exportedName: string;
180
+ }]>;
180
181
  invokedFunctions: string[];
181
182
  invokedFunctionsByFile?: Array<[string, string[]]>;
182
183
  usedAddons: string[];
@@ -185,6 +186,10 @@ export interface SerializableInspectorState {
185
186
  {
186
187
  package: string;
187
188
  rpcEndpoint?: string;
189
+ remote?: boolean;
190
+ hasAuth?: boolean;
191
+ authCredentialId?: string;
192
+ authSecretId?: string;
188
193
  secretOverrides?: Record<string, string>;
189
194
  variableOverrides?: Record<string, string>;
190
195
  credentialOverrides?: Record<string, string>;
@@ -227,6 +232,10 @@ export interface SerializableInspectorState {
227
232
  definitions: InspectorState['credentials']['definitions'];
228
233
  files: string[];
229
234
  };
235
+ scopes: {
236
+ definitions: InspectorState['scopes']['definitions'];
237
+ files: string[];
238
+ };
230
239
  variables: {
231
240
  definitions: InspectorState['variables']['definitions'];
232
241
  files: string[];
@@ -254,10 +263,6 @@ export interface SerializableInspectorState {
254
263
  permissions: {
255
264
  definitions: InspectorState['permissions']['definitions'];
256
265
  instances: InspectorState['permissions']['instances'];
257
- tagPermissions: Array<[
258
- string,
259
- InspectorState['permissions']['tagPermissions'] extends Map<string, infer V> ? V : never
260
- ]>;
261
266
  };
262
267
  serviceAggregation: {
263
268
  requiredServices: string[];
@@ -42,7 +42,6 @@ export function serializeInspectorState(state) {
42
42
  meta: state.http.meta,
43
43
  files: Array.from(state.http.files),
44
44
  routeMiddleware: Array.from(state.http.routeMiddleware.entries()),
45
- routePermissions: Array.from(state.http.routePermissions.entries()),
46
45
  },
47
46
  channels: {
48
47
  files: Array.from(state.channels.files),
@@ -77,6 +76,8 @@ export function serializeInspectorState(state) {
77
76
  internalFiles: Array.from(state.rpc.internalFiles.entries()),
78
77
  exposedMeta: state.rpc.exposedMeta,
79
78
  exposedFiles: Array.from(state.rpc.exposedFiles.entries()),
79
+ remoteMeta: state.rpc.remoteMeta,
80
+ remoteFiles: Array.from(state.rpc.remoteFiles.entries()),
80
81
  invokedFunctions: Array.from(state.rpc.invokedFunctions),
81
82
  invokedFunctionsByFile: Array.from((state.rpc.invokedFunctionsByFile ?? new Map()).entries()).map(([file, fns]) => [file, Array.from(fns)]),
82
83
  usedAddons: Array.from(state.rpc.usedAddons),
@@ -115,6 +116,10 @@ export function serializeInspectorState(state) {
115
116
  definitions: state.credentials.definitions,
116
117
  files: Array.from(state.credentials.files),
117
118
  },
119
+ scopes: {
120
+ definitions: state.scopes.definitions,
121
+ files: Array.from(state.scopes.files),
122
+ },
118
123
  variables: {
119
124
  definitions: state.variables.definitions,
120
125
  files: Array.from(state.variables.files),
@@ -136,7 +141,6 @@ export function serializeInspectorState(state) {
136
141
  permissions: {
137
142
  definitions: state.permissions.definitions,
138
143
  instances: state.permissions.instances,
139
- tagPermissions: Array.from(state.permissions.tagPermissions.entries()),
140
144
  },
141
145
  serviceAggregation: {
142
146
  requiredServices: Array.from(state.serviceAggregation.requiredServices),
@@ -201,7 +205,6 @@ export function deserializeInspectorState(data) {
201
205
  meta: data.http.meta,
202
206
  files: new Set(data.http.files),
203
207
  routeMiddleware: new Map(data.http.routeMiddleware),
204
- routePermissions: new Map(data.http.routePermissions),
205
208
  },
206
209
  channels: {
207
210
  files: new Set(data.channels.files),
@@ -236,6 +239,8 @@ export function deserializeInspectorState(data) {
236
239
  internalFiles: new Map(data.rpc.internalFiles),
237
240
  exposedMeta: data.rpc.exposedMeta,
238
241
  exposedFiles: new Map(data.rpc.exposedFiles),
242
+ remoteMeta: data.rpc.remoteMeta ?? {},
243
+ remoteFiles: new Map(data.rpc.remoteFiles ?? []),
239
244
  invokedFunctions: new Set(data.rpc.invokedFunctions),
240
245
  invokedFunctionsByFile: new Map((data.rpc.invokedFunctionsByFile || []).map(([file, fns]) => [file, new Set(fns)])),
241
246
  usedAddons: new Set(data.rpc.usedAddons || []),
@@ -274,6 +279,10 @@ export function deserializeInspectorState(data) {
274
279
  definitions: data.credentials?.definitions || [],
275
280
  files: new Set(data.credentials?.files || []),
276
281
  },
282
+ scopes: {
283
+ definitions: data.scopes?.definitions || [],
284
+ files: new Set(data.scopes?.files || []),
285
+ },
277
286
  variables: {
278
287
  definitions: data.variables?.definitions || [],
279
288
  files: new Set(data.variables?.files || []),
@@ -295,7 +304,6 @@ export function deserializeInspectorState(data) {
295
304
  permissions: {
296
305
  definitions: data.permissions.definitions,
297
306
  instances: data.permissions.instances || {},
298
- tagPermissions: new Map(data.permissions.tagPermissions),
299
307
  },
300
308
  serviceAggregation: {
301
309
  requiredServices: new Set(data.serviceAggregation.requiredServices),
@@ -315,8 +323,6 @@ export function deserializeInspectorState(data) {
315
323
  },
316
324
  permissionsGroupsMeta: data.permissionsGroupsMeta || {
317
325
  definitions: {},
318
- httpGroups: {},
319
- tagGroups: {},
320
326
  },
321
327
  requiredSchemas: new Set(data.requiredSchemas || []),
322
328
  openAPISpec: data.openAPISpec || null,
@@ -1,6 +1,4 @@
1
1
  import type { InspectorState } from '../types.js';
2
2
  export declare const serializePermissionsGroupsMeta: (state: InspectorState) => {
3
3
  definitions: Record<string, import("../types.js").InspectorPermissionDefinition>;
4
- httpGroups: Record<string, any>;
5
- tagGroups: Record<string, any>;
6
4
  };
@@ -1,31 +1,5 @@
1
1
  export const serializePermissionsGroupsMeta = (state) => {
2
- const httpGroups = {};
3
- for (const [pattern, meta] of state.http.routePermissions.entries()) {
4
- httpGroups[pattern] = {
5
- exportName: meta.exportName,
6
- sourceFile: meta.sourceFile,
7
- position: meta.position,
8
- services: meta.services,
9
- count: meta.count,
10
- instanceIds: meta.instanceIds,
11
- isFactory: meta.isFactory,
12
- };
13
- }
14
- const tagGroups = {};
15
- for (const [tag, meta] of state.permissions.tagPermissions.entries()) {
16
- tagGroups[tag] = {
17
- exportName: meta.exportName,
18
- sourceFile: meta.sourceFile,
19
- position: meta.position,
20
- services: meta.services,
21
- count: meta.count,
22
- instanceIds: meta.instanceIds,
23
- isFactory: meta.isFactory,
24
- };
25
- }
26
2
  return {
27
3
  definitions: state.permissions.definitions,
28
- httpGroups,
29
- tagGroups,
30
4
  };
31
5
  };
@@ -48,9 +48,7 @@ function containsConditional(steps) {
48
48
  * e.g. "awaiting_approval" → "Awaiting approval", "building-image" → "Building image"
49
49
  */
50
50
  function reasonToLabel(reason) {
51
- return reason
52
- .replace(/[-_]/g, ' ')
53
- .replace(/^(.)/, (c) => c.toUpperCase());
51
+ return reason.replace(/[-_]/g, ' ').replace(/^(.)/, (c) => c.toUpperCase());
54
52
  }
55
53
  /** Flatten named steps (rpc/inline/sleep/parallel children) in source order. */
56
54
  function collectNamedSteps(steps) {
@@ -72,6 +70,14 @@ function collectNamedSteps(steps) {
72
70
  displayName: reasonToLabel(step.reason),
73
71
  });
74
72
  break;
73
+ case 'approval':
74
+ // Same as suspend, but namespaced `__workflow_approval:` to match the
75
+ // runtime's separate step key.
76
+ planned.push({
77
+ stepName: `__workflow_approval:${step.reason}`,
78
+ displayName: reasonToLabel(step.reason),
79
+ });
80
+ break;
75
81
  case 'parallel':
76
82
  for (const child of step.children) {
77
83
  planned.push({ stepName: child.stepName });
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
2
+ import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isWorkflowApprovalCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
3
3
  import { validateNoDisallowedPatterns, validateAwaitedCalls, formatValidationErrors, } from './validation.js';
4
4
  import { extractStringLiteral, extractNumberLiteral, } from '../../extract-node-value.js';
5
5
  /**
@@ -363,6 +363,19 @@ function extractExpressionStatement(statement, context) {
363
363
  if (isWorkflowSuspendCall(call, context.checker)) {
364
364
  return extractSuspendStep(call, context);
365
365
  }
366
+ if (isWorkflowApprovalCall(call, context.checker)) {
367
+ const step = extractApprovalStep(call, context, outputVar);
368
+ // Unlike suspend, an approval yields a value, so a downstream step can
369
+ // reference it — track the binding the same way workflow.do() does.
370
+ if (outputVar && step) {
371
+ const type = context.checker.getTypeAtLocation(expr);
372
+ context.outputVars.set(outputVar, { type, node: expr });
373
+ if (isArrayType(type, context.checker)) {
374
+ context.arrayVars.add(outputVar);
375
+ }
376
+ }
377
+ return step;
378
+ }
366
379
  // Check for parallel group or fanout
367
380
  if (isParallelFanout(call)) {
368
381
  return extractParallelFanout(call, context);
@@ -543,6 +556,48 @@ function extractSuspendStep(call, context) {
543
556
  return null;
544
557
  }
545
558
  }
559
+ /**
560
+ * Extract approval step from workflow.approval() call
561
+ */
562
+ function extractApprovalStep(call, context, outputVar) {
563
+ const args = call.arguments;
564
+ if (args.length < 1)
565
+ return null;
566
+ try {
567
+ const reason = extractStringLiteral(args[0], context.checker);
568
+ const step = {
569
+ type: 'approval',
570
+ reason,
571
+ };
572
+ if (outputVar) {
573
+ step.outputVar = outputVar;
574
+ }
575
+ // The `schema` option is a runtime value validated inside the workflow body,
576
+ // so it is deliberately not serialized here — only `expiry`, which the graph
577
+ // and planned-step ladder need in order to describe the gate.
578
+ const options = args[1];
579
+ if (options && ts.isObjectLiteralExpression(options)) {
580
+ for (const prop of options.properties) {
581
+ if (ts.isPropertyAssignment(prop) &&
582
+ prop.name.getText() === 'expiry' &&
583
+ (ts.isStringLiteral(prop.initializer) ||
584
+ ts.isNumericLiteral(prop.initializer))) {
585
+ step.expiry = ts.isNumericLiteral(prop.initializer)
586
+ ? Number(prop.initializer.text)
587
+ : prop.initializer.text;
588
+ }
589
+ }
590
+ }
591
+ return step;
592
+ }
593
+ catch (error) {
594
+ context.errors.push({
595
+ message: `Failed to extract approval step: ${error instanceof Error ? error.message : String(error)}`,
596
+ node: call,
597
+ });
598
+ return null;
599
+ }
600
+ }
546
601
  /**
547
602
  * Extract cancel step from throw WorkflowCancelledException statement
548
603
  */
@@ -2,6 +2,12 @@ import * as ts from 'typescript';
2
2
  /**
3
3
  * Pattern detection helpers for DSL workflow extraction
4
4
  */
5
+ /**
6
+ * The wire object handed to a DSL func is named `workflow` in a workflow and
7
+ * `scenario` in a scenario — both carry the same `do`/`sleep`/`suspend` DSL, so
8
+ * step extraction accepts either identifier.
9
+ */
10
+ export declare function isWorkflowWireIdentifier(expr: ts.Expression): boolean;
5
11
  /**
6
12
  * Check if a call expression is workflow.do() or workflow.expectEventually()
7
13
  * (both are RPC steps; expectEventually is the polling variant used by scenarios)
@@ -25,6 +31,10 @@ export declare function isWorkflowSleepCall(node: ts.CallExpression, checker: ts
25
31
  * Check if a call expression is workflow.suspend()
26
32
  */
27
33
  export declare function isWorkflowSuspendCall(node: ts.CallExpression, _checker: ts.TypeChecker): boolean;
34
+ /**
35
+ * Check if a call expression is workflow.approval()
36
+ */
37
+ export declare function isWorkflowApprovalCall(node: ts.CallExpression, _checker: ts.TypeChecker): boolean;
28
38
  /**
29
39
  * Check if a throw statement throws WorkflowCancelledException
30
40
  * Matches: throw new WorkflowCancelledException(...) or throw WorkflowCancelledException(...)