@pikku/inspector 0.12.49 → 0.12.52

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 (104) hide show
  1. package/CHANGELOG.md +501 -0
  2. package/OPTIMIZATION-PLAN.md +9 -3
  3. package/dist/add/add-auth.d.ts +1 -1
  4. package/dist/add/add-auth.js +1 -1
  5. package/dist/add/add-cli.js +7 -0
  6. package/dist/add/add-credential.js +1 -1
  7. package/dist/add/add-functions.js +156 -7
  8. package/dist/add/add-http-route.js +16 -6
  9. package/dist/add/add-middleware.js +46 -3
  10. package/dist/add/add-personas.d.ts +13 -0
  11. package/dist/add/add-personas.js +210 -0
  12. package/dist/add/add-scope.js +1 -1
  13. package/dist/add/add-secret.js +1 -1
  14. package/dist/add/add-system-role.d.ts +2 -0
  15. package/dist/add/add-system-role.js +102 -0
  16. package/dist/add/add-variable.js +1 -1
  17. package/dist/add/add-wire-addon.js +31 -0
  18. package/dist/error-codes.d.ts +10 -1
  19. package/dist/error-codes.js +11 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/inspector.js +33 -1
  22. package/dist/types.d.ts +46 -2
  23. package/dist/utils/annotate-http-route-auth.d.ts +22 -0
  24. package/dist/utils/annotate-http-route-auth.js +48 -0
  25. package/dist/utils/extract-function-name.js +13 -4
  26. package/dist/utils/extract-function-node.js +18 -1
  27. package/dist/utils/extract-secret-usage.d.ts +9 -0
  28. package/dist/utils/extract-secret-usage.js +61 -0
  29. package/dist/utils/literal-properties.d.ts +22 -0
  30. package/dist/utils/literal-properties.js +69 -0
  31. package/dist/utils/load-addon-functions-meta.js +26 -1
  32. package/dist/utils/permissions.js +8 -0
  33. package/dist/utils/post-process.d.ts +68 -3
  34. package/dist/utils/post-process.js +243 -13
  35. package/dist/utils/schema-generator.d.ts +24 -0
  36. package/dist/utils/schema-generator.js +118 -19
  37. package/dist/utils/secret-alias-services.d.ts +8 -0
  38. package/dist/utils/secret-alias-services.js +45 -0
  39. package/dist/utils/secret-brokers.d.ts +2 -0
  40. package/dist/utils/secret-brokers.js +9 -0
  41. package/dist/utils/serialize-inspector-state.d.ts +10 -0
  42. package/dist/utils/serialize-inspector-state.js +18 -0
  43. package/dist/utils/serialize-middleware-groups-meta.js +3 -0
  44. package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
  45. package/dist/utils/validate-exposed-functions-gated.js +85 -0
  46. package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
  47. package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
  48. package/dist/visit.js +4 -0
  49. package/package.json +3 -3
  50. package/src/add/add-auth.test.ts +5 -3
  51. package/src/add/add-auth.ts +1 -1
  52. package/src/add/add-cli.ts +8 -0
  53. package/src/add/add-credential.ts +1 -1
  54. package/src/add/add-functions.test.ts +263 -1
  55. package/src/add/add-functions.ts +219 -9
  56. package/src/add/add-http-route.ts +20 -7
  57. package/src/add/add-middleware.test.ts +196 -0
  58. package/src/add/add-middleware.ts +64 -4
  59. package/src/add/add-personas.test.ts +268 -0
  60. package/src/add/add-personas.ts +276 -0
  61. package/src/add/add-scenario-step.test.ts +49 -3
  62. package/src/add/add-scope.test.ts +30 -30
  63. package/src/add/add-scope.ts +1 -1
  64. package/src/add/add-secret.ts +1 -1
  65. package/src/add/add-subject-scenario-steps.test.ts +161 -0
  66. package/src/add/add-system-role.test.ts +214 -0
  67. package/src/add/add-system-role.ts +164 -0
  68. package/src/add/add-variable.ts +1 -1
  69. package/src/add/add-wire-addon.test.ts +111 -0
  70. package/src/add/add-wire-addon.ts +29 -0
  71. package/src/add/addon-bans.test.ts +4 -4
  72. package/src/add/config-value-metadata.test.ts +11 -11
  73. package/src/add/wire-name-literal.test.ts +2 -2
  74. package/src/error-codes.ts +13 -0
  75. package/src/index.ts +4 -1
  76. package/src/inspector.ts +39 -0
  77. package/src/types.ts +47 -2
  78. package/src/utils/annotate-http-route-auth.test.ts +163 -0
  79. package/src/utils/annotate-http-route-auth.ts +52 -0
  80. package/src/utils/extract-function-name.ts +14 -4
  81. package/src/utils/extract-function-node.ts +18 -6
  82. package/src/utils/extract-secret-usage.test.ts +81 -0
  83. package/src/utils/extract-secret-usage.ts +73 -0
  84. package/src/utils/literal-properties.ts +97 -0
  85. package/src/utils/load-addon-functions-meta.ts +32 -2
  86. package/src/utils/permissions-shorthand.test.ts +79 -0
  87. package/src/utils/permissions.ts +7 -0
  88. package/src/utils/post-process.test.ts +133 -0
  89. package/src/utils/post-process.ts +310 -12
  90. package/src/utils/schema-cache.test.ts +124 -0
  91. package/src/utils/schema-generator.test.ts +62 -1
  92. package/src/utils/schema-generator.ts +138 -21
  93. package/src/utils/secret-alias-services.test.ts +146 -0
  94. package/src/utils/secret-alias-services.ts +58 -0
  95. package/src/utils/secret-brokers.test.ts +53 -0
  96. package/src/utils/secret-brokers.ts +11 -0
  97. package/src/utils/serialize-inspector-state.ts +28 -0
  98. package/src/utils/serialize-middleware-groups-meta.ts +3 -0
  99. package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
  100. package/src/utils/validate-exposed-functions-gated.ts +94 -0
  101. package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
  102. package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
  103. package/src/visit.ts +4 -0
  104. package/tsconfig.tsbuildinfo +1 -1
@@ -1,6 +1,19 @@
1
1
  import * as ts from 'typescript'
2
2
  import type { InspectorState, InspectorLogger } from '../types.js'
3
3
 
4
+ function parseStringArray(node: ts.Expression): string[] | undefined {
5
+ if (!ts.isArrayLiteralExpression(node)) return undefined
6
+ const values: string[] = []
7
+ for (const element of node.elements) {
8
+ // A non-literal entry (a spread, a const reference) is not statically
9
+ // knowable. Dropping the whole array rather than a silent partial keeps a
10
+ // consumer from reading a short list as the complete set of gates.
11
+ if (!ts.isStringLiteral(element)) return undefined
12
+ values.push(element.text)
13
+ }
14
+ return values
15
+ }
16
+
4
17
  function parseStringRecord(
5
18
  obj: ts.ObjectLiteralExpression
6
19
  ): Record<string, string> {
@@ -41,6 +54,9 @@ export function addWireAddon(
41
54
  let pkg: string | undefined
42
55
  let rpcEndpoint: string | undefined
43
56
  let mcp: boolean | undefined
57
+ let auth: boolean | undefined
58
+ let tags: string[] | undefined
59
+ let scopes: string[] | undefined
44
60
  let secretOverrides: Record<string, string> | undefined
45
61
  let variableOverrides: Record<string, string> | undefined
46
62
  let credentialOverrides: Record<string, string> | undefined
@@ -61,6 +77,16 @@ export function addWireAddon(
61
77
  prop.initializer.kind === ts.SyntaxKind.FalseKeyword)
62
78
  ) {
63
79
  mcp = prop.initializer.kind === ts.SyntaxKind.TrueKeyword
80
+ } else if (
81
+ key === 'auth' &&
82
+ (prop.initializer.kind === ts.SyntaxKind.TrueKeyword ||
83
+ prop.initializer.kind === ts.SyntaxKind.FalseKeyword)
84
+ ) {
85
+ auth = prop.initializer.kind === ts.SyntaxKind.TrueKeyword
86
+ } else if (key === 'tags') {
87
+ tags = parseStringArray(prop.initializer)
88
+ } else if (key === 'scopes') {
89
+ scopes = parseStringArray(prop.initializer)
64
90
  } else if (
65
91
  key === 'secretOverrides' &&
66
92
  ts.isObjectLiteralExpression(prop.initializer)
@@ -86,6 +112,9 @@ export function addWireAddon(
86
112
  package: pkg,
87
113
  rpcEndpoint,
88
114
  mcp,
115
+ auth,
116
+ tags,
117
+ scopes,
89
118
  secretOverrides,
90
119
  variableOverrides,
91
120
  credentialOverrides,
@@ -104,17 +104,17 @@ describe('addon authoring bans', () => {
104
104
  })
105
105
  })
106
106
 
107
- test('wireSecret inside an addon is allowed', async () => {
107
+ test('defineSecret inside an addon is allowed', async () => {
108
108
  const source = [
109
- "import { wireSecret } from '@pikku/core'",
110
- "wireSecret({ name: 'example', secretId: 'EXAMPLE' } as any)",
109
+ "import { defineSecret } from '@pikku/core'",
110
+ "defineSecret({ name: 'example', secretId: 'EXAMPLE' } as any)",
111
111
  ].join('\n')
112
112
  await withTempApp(source, async (file, rootDir) => {
113
113
  const { logger, criticals } = recordingLogger()
114
114
  await inspect(logger, [file], { rootDir, isAddon: true })
115
115
  assert.ok(
116
116
  !criticals.some((c) => c.code === ErrorCode.ADDON_WIRING_NOT_ALLOWED),
117
- `expected wireSecret to be allowed, got ${JSON.stringify(criticals)}`
117
+ `expected defineSecret to be allowed, got ${JSON.stringify(criticals)}`
118
118
  )
119
119
  })
120
120
  })
@@ -36,17 +36,17 @@ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
36
36
  * downstream, which is exactly how `displayName` went unused.
37
37
  */
38
38
  describe('config value metadata (docsUrl)', () => {
39
- test('carries docsUrl from wireSecret into the definition', async () => {
39
+ test('carries docsUrl from defineSecret into the definition', async () => {
40
40
  const rootDir = await mkdtemp(`${fixtureRoot}-secret-`)
41
41
  const file = join(rootDir, 'secret.ts')
42
42
 
43
43
  await writeFile(
44
44
  file,
45
45
  [
46
- "import { wireSecret } from '@pikku/core'",
46
+ "import { defineSecret } from '@pikku/core'",
47
47
  "import { z } from 'zod'",
48
48
  'export const StripeKeySchema = z.string()',
49
- 'wireSecret({',
49
+ 'defineSecret({',
50
50
  " name: 'stripeKey',",
51
51
  " displayName: 'Stripe Secret Key',",
52
52
  " secretId: 'STRIPE_SECRET_KEY',",
@@ -76,10 +76,10 @@ describe('config value metadata (docsUrl)', () => {
76
76
  await writeFile(
77
77
  file,
78
78
  [
79
- "import { wireSecret } from '@pikku/core'",
79
+ "import { defineSecret } from '@pikku/core'",
80
80
  "import { z } from 'zod'",
81
81
  'export const ApiKeySchema = z.string()',
82
- 'wireSecret({',
82
+ 'defineSecret({',
83
83
  " name: 'apiKey',",
84
84
  " displayName: 'API Key',",
85
85
  " secretId: 'API_KEY',",
@@ -101,17 +101,17 @@ describe('config value metadata (docsUrl)', () => {
101
101
  }
102
102
  })
103
103
 
104
- test('carries docsUrl from wireVariable into the definition', async () => {
104
+ test('carries docsUrl from defineVariable into the definition', async () => {
105
105
  const rootDir = await mkdtemp(`${fixtureRoot}-variable-`)
106
106
  const file = join(rootDir, 'variable.ts')
107
107
 
108
108
  await writeFile(
109
109
  file,
110
110
  [
111
- "import { wireVariable } from '@pikku/core'",
111
+ "import { defineVariable } from '@pikku/core'",
112
112
  "import { z } from 'zod'",
113
113
  'export const ConsoleUrlSchema = z.string()',
114
- 'wireVariable({',
114
+ 'defineVariable({',
115
115
  " name: 'consoleUrl',",
116
116
  " displayName: 'Console URL',",
117
117
  " variableId: 'CONSOLE_URL',",
@@ -134,17 +134,17 @@ describe('config value metadata (docsUrl)', () => {
134
134
  }
135
135
  })
136
136
 
137
- test('carries docsUrl from wireCredential into the definition', async () => {
137
+ test('carries docsUrl from defineCredential into the definition', async () => {
138
138
  const rootDir = await mkdtemp(`${fixtureRoot}-credential-`)
139
139
  const file = join(rootDir, 'credential.ts')
140
140
 
141
141
  await writeFile(
142
142
  file,
143
143
  [
144
- "import { wireCredential } from '@pikku/core'",
144
+ "import { defineCredential } from '@pikku/core'",
145
145
  "import { z } from 'zod'",
146
146
  'export const TokenSchema = z.object({ token: z.string() })',
147
- 'wireCredential({',
147
+ 'defineCredential({',
148
148
  " name: 'githubToken',",
149
149
  " displayName: 'GitHub Token',",
150
150
  " type: 'wire',",
@@ -62,9 +62,9 @@ describe('wiring name must be a string literal', () => {
62
62
  await writeFile(
63
63
  file,
64
64
  [
65
- "import { wireSecret } from '@pikku/core'",
65
+ "import { defineSecret } from '@pikku/core'",
66
66
  'const SECRET_ID = "STRIPE_SECRET_KEY"',
67
- 'wireSecret({',
67
+ 'defineSecret({',
68
68
  ' secretId: SECRET_ID,',
69
69
  " name: 'Stripe secret key',",
70
70
  " displayName: 'Stripe secret key',",
@@ -25,6 +25,7 @@ export enum ErrorCode {
25
25
  SCENARIO_BROWSER_STEP_NEEDS_ACTOR = 'PKU677',
26
26
  SCENARIO_STEP_TARGET_NOT_STATIC = 'PKU678',
27
27
  SCENARIO_NOT_EXTRACTABLE = 'PKU679',
28
+ SCENARIO_HAS_NO_ASSERTION = 'PKU680',
28
29
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
29
30
  INVALID_DSL_WORKFLOW = 'PKU641',
30
31
  WORKFLOW_GRAPH_ADDON_NOT_WIRED = 'PKU642',
@@ -53,6 +54,8 @@ export enum ErrorCode {
53
54
  ROUTE_PARAM_MISMATCH = 'PKU571',
54
55
  ROUTE_QUERY_MISMATCH = 'PKU572',
55
56
  AUTH_DISABLED_REQUIRES_SESSIONLESS = 'PKU573',
57
+ EXPOSED_FUNCTION_HAS_NO_GATE = 'PKU574',
58
+ TAG_RESOLVES_TO_NO_MIDDLEWARE = 'PKU575',
56
59
 
57
60
  // Versioning errors
58
61
  DUPLICATE_FUNCTION_VERSION = 'PKU850',
@@ -70,6 +73,11 @@ export enum ErrorCode {
70
73
  MISSING_MODEL = 'PKU145',
71
74
  INVALID_MODEL = 'PKU146',
72
75
 
76
+ // Agent tool reference errors
77
+ AGENT_TOOL_UNKNOWN_NAMESPACE = 'PKU152',
78
+ AGENT_TOOL_NOT_FOUND = 'PKU153',
79
+ AGENT_TOOL_MISSING_DESCRIPTION = 'PKU154',
80
+
73
81
  // File structure errors
74
82
  SCHEMA_AND_WIRING_COLOCATED = 'PKU490',
75
83
 
@@ -90,6 +98,11 @@ export enum ErrorCode {
90
98
  ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = 'PKU921',
91
99
 
92
100
  RPC_INVOCATION_TYPE_CAST = 'PKU940',
101
+
102
+ // Secret boundary errors
103
+ SECRET_SERVICE_ALIASED = 'PKU950',
104
+ SECRET_NOT_DECLARED = 'PKU951',
105
+ SECRET_KEY_NOT_STATIC = 'PKU952',
93
106
  }
94
107
 
95
108
  /**
package/src/index.ts CHANGED
@@ -32,7 +32,10 @@ export type {
32
32
  VersionManifest,
33
33
  VersionManifestEntry,
34
34
  } from './utils/contract-hashes.js'
35
- export type { InspectorFeature, InspectorFeatureEntry } from './types.js'
35
+ export type {
36
+ InspectorFeature,
37
+ InspectorFeatureEntry,
38
+ } from './types.js'
36
39
  export { serializeMCPJson } from './utils/serialize-mcp-json.js'
37
40
  export type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js'
38
41
  export {
package/src/inspector.ts CHANGED
@@ -15,22 +15,31 @@ import {
15
15
  aggregateRequiredServices,
16
16
  stampAuthHandlerServices,
17
17
  validateAgentModels,
18
+ validateAgentToolReferences,
18
19
  validateSecretOverrides,
19
20
  validateVariableOverrides,
20
21
  validateCredentialOverrides,
21
22
  validateRemoteAddonDependencies,
22
23
  validateRemoteAddonAuth,
23
24
  validateScopeReferences,
25
+ validateSystemRoleScopes,
26
+ validatePersonaRoles,
24
27
  computeResolvedIOTypes,
25
28
  computeMiddlewareGroupsMeta,
26
29
  computePermissionsGroupsMeta,
27
30
  computeRequiredSchemas,
31
+ computeSecretBrokers,
32
+ validateNoSecretAliasServices,
33
+ validateSecretUsage,
28
34
  computeDiagnostics,
29
35
  validateSchemaWiringSeparation,
30
36
  validateScenarioServices,
31
37
  validateScenarioSteps,
32
38
  validateWorkflowGraphAddons,
33
39
  } from './utils/post-process.js'
40
+ import { validateExposedFunctionsGated } from './utils/validate-exposed-functions-gated.js'
41
+ import { validateTagsResolveToMiddleware } from './utils/validate-tags-resolve-to-middleware.js'
42
+ import { annotateHttpRouteAuth } from './utils/annotate-http-route-auth.js'
34
43
  import { generateOpenAPISpec } from './utils/serialize-openapi-json.js'
35
44
  import { pikkuState } from '@pikku/core/internal'
36
45
  import { resolveLatestVersions } from './utils/resolve-versions.js'
@@ -40,6 +49,7 @@ import {
40
49
  finalizeWorkflowWires,
41
50
  } from './utils/workflow/graph/finalize-workflow-wires.js'
42
51
  import { generateAllSchemas } from './utils/schema-generator.js'
52
+ import { extractSecretUsage } from './utils/extract-secret-usage.js'
43
53
  import {
44
54
  loadAddonFunctionsMeta,
45
55
  loadAddonSchemas,
@@ -169,6 +179,7 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
169
179
  secrets: {
170
180
  definitions: [],
171
181
  files: new Set(),
182
+ usage: new Map(),
172
183
  },
173
184
  credentials: {
174
185
  definitions: [],
@@ -178,6 +189,14 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
178
189
  definitions: [],
179
190
  files: new Set(),
180
191
  },
192
+ systemRoles: {
193
+ definitions: [],
194
+ files: new Set(),
195
+ },
196
+ personas: {
197
+ definitions: [],
198
+ files: new Set(),
199
+ },
181
200
  variables: {
182
201
  definitions: [],
183
202
  files: new Set(),
@@ -354,6 +373,13 @@ export const inspect = async (
354
373
 
355
374
  const state = getInitialInspectorState(rootDir)
356
375
 
376
+ for (const sourceFile of sourceFiles) {
377
+ const usage = extractSecretUsage(sourceFile)
378
+ if (usage.keys.length > 0 || usage.dynamic.length > 0) {
379
+ state.secrets.usage.set(sourceFile.fileName, usage)
380
+ }
381
+ }
382
+
357
383
  // First sweep: add all functions
358
384
  const startSetup = performance.now()
359
385
  for (const sourceFile of sourceFiles) {
@@ -451,11 +477,21 @@ export const inspect = async (
451
477
  computeResolvedIOTypes(state)
452
478
  computeMiddlewareGroupsMeta(state)
453
479
  computePermissionsGroupsMeta(state)
480
+ computeSecretBrokers(state)
481
+ validateNoSecretAliasServices(logger, checker, state)
482
+ validateSecretUsage(logger, state)
454
483
  computeDiagnostics(state)
455
484
  validateSchemaWiringSeparation(logger, state)
456
485
  validateWorkflowGraphAddons(logger, state)
457
486
  validateScenarioServices(logger, state)
458
487
  validateScenarioSteps(logger, state)
488
+ // Needs every route, every function and every wireAddon declaration
489
+ // together, so it can only run once the whole program is inspected.
490
+ annotateHttpRouteAuth(state)
491
+ // Last of the validators: they read function meta and every wireAddon
492
+ // declaration together, so they need the whole program inspected.
493
+ validateExposedFunctionsGated(logger, state)
494
+ validateTagsResolveToMiddleware(logger, state)
459
495
 
460
496
  if (options.openAPI) {
461
497
  state.openAPISpec = await generateOpenAPISpec(
@@ -469,12 +505,15 @@ export const inspect = async (
469
505
  }
470
506
 
471
507
  validateAgentModels(logger, state)
508
+ validateAgentToolReferences(logger, state, options)
472
509
  validateSecretOverrides(logger, state)
473
510
  validateVariableOverrides(logger, state)
474
511
  validateCredentialOverrides(logger, state)
475
512
  validateRemoteAddonDependencies(logger, state)
476
513
  validateRemoteAddonAuth(logger, state)
477
514
  validateScopeReferences(logger, state)
515
+ validateSystemRoleScopes(logger, state)
516
+ validatePersonaRoles(logger, state)
478
517
  }
479
518
 
480
519
  state.program = program
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type * as ts from 'typescript'
2
+ import type { SecretUsage } from './utils/extract-secret-usage.js'
2
3
  import type { ChannelMessageMeta, ChannelsMeta } from '@pikku/core/channel'
3
4
  import type { GatewaysMeta } from '@pikku/core/gateway'
4
5
  import type { HTTPWiringsMeta } from '@pikku/core/http'
@@ -18,6 +19,8 @@ import type { NodesMeta } from '@pikku/core/node'
18
19
  import type { SecretDefinitions } from '@pikku/core/secret'
19
20
  import type { CredentialDefinitions } from '@pikku/core/credential'
20
21
  import type { ScopeDefinitions } from '@pikku/core/scope'
22
+ import type { SystemRoleDefinitions } from '@pikku/core/role'
23
+ import type { PersonaDefinitions } from '@pikku/core/persona'
21
24
  import type { VariableDefinitions } from '@pikku/core/variable'
22
25
  import type { TypesMap } from './types-map.js'
23
26
  import type {
@@ -48,14 +51,31 @@ export type MetaInputTypes = Map<
48
51
  }
49
52
  >
50
53
 
51
- export interface MiddlewareGroupMeta {
54
+ /** One `addHTTPMiddleware`/`addTagMiddleware`/`addChannelMiddleware` call. */
55
+ export interface MiddlewareRegistration {
52
56
  exportName: string | null // null if not exported
53
57
  sourceFile: string
54
58
  position: number
59
+ isFactory: boolean // true if wrapped in () => add...()
60
+ }
61
+
62
+ export interface MiddlewareGroupMeta extends MiddlewareRegistration {
55
63
  services: FunctionServicesMeta
56
64
  count: number
57
65
  instanceIds: string[]
58
- isFactory: boolean // true if wrapped in () => add...()
66
+ /**
67
+ * The registrations after the first, when several files add middleware for
68
+ * the same pattern or tag.
69
+ *
70
+ * A group is keyed by its pattern/tag, so without this a second file's call
71
+ * overwrote the first's and only the survivor got an import emitted into
72
+ * pikku-middleware.gen.ts — the losing file was never imported, so its
73
+ * registration never ran. The runtime composes repeated registrations for a
74
+ * pattern happily; only codegen dropped one, which is how an app could lose
75
+ * its session-bridge middleware to an unrelated `'*'` group and see nothing
76
+ * go wrong until a request arrived without a session.
77
+ */
78
+ additionalRegistrations?: MiddlewareRegistration[]
59
79
  }
60
80
 
61
81
  export interface InspectorHTTPState {
@@ -272,6 +292,12 @@ export type InspectorOptions = Partial<{
272
292
  setupOnly: boolean
273
293
  rootDir: string
274
294
  isAddon: boolean
295
+ /**
296
+ * Fail the build on metadata that only an agent needs — currently an agent
297
+ * tool with no description. Off by default: the runtime falls back to the
298
+ * tool's own name, so this is a quality gate rather than a correctness one.
299
+ */
300
+ strictMeta: boolean
275
301
  sourceFile: ts.SourceFile
276
302
  /**
277
303
  * The project's tsconfig, read for its path mappings only. The inspector's
@@ -530,6 +556,15 @@ export interface InspectorState {
530
556
  package: string
531
557
  rpcEndpoint?: string
532
558
  mcp?: boolean
559
+ /**
560
+ * The addon's own gates. `runPikkuFunc` applies these to every function
561
+ * in the package, on every wiring path — not just `namespace:function`.
562
+ * Absent when the declared value was not a statically-knowable literal,
563
+ * which must be read as "unknown", never as "ungated".
564
+ */
565
+ auth?: boolean
566
+ tags?: string[]
567
+ scopes?: string[]
533
568
  /** True when declared via `wireRemoteAddon` — import the addon's `.remote.gen` map, not `.internal.gen` */
534
569
  remote?: boolean
535
570
  /** wireRemoteAddon: whether an `auth` binding was supplied (vs a public surface) */
@@ -586,6 +621,8 @@ export interface InspectorState {
586
621
  secrets: {
587
622
  definitions: SecretDefinitions
588
623
  files: Set<string>
624
+ /** Secret reads found per source file, keyed by absolute path. */
625
+ usage: Map<string, SecretUsage>
589
626
  }
590
627
  credentials: {
591
628
  definitions: CredentialDefinitions
@@ -595,6 +632,14 @@ export interface InspectorState {
595
632
  definitions: ScopeDefinitions
596
633
  files: Set<string>
597
634
  }
635
+ systemRoles: {
636
+ definitions: SystemRoleDefinitions
637
+ files: Set<string>
638
+ }
639
+ personas: {
640
+ definitions: PersonaDefinitions
641
+ files: Set<string>
642
+ }
598
643
  variables: {
599
644
  definitions: VariableDefinitions
600
645
  files: Set<string>
@@ -0,0 +1,163 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, test } from 'node:test'
3
+ import { annotateHttpRouteAuth } from './annotate-http-route-auth.js'
4
+
5
+ const ADDON_PACKAGE = '@addon/console'
6
+
7
+ /**
8
+ * A single GET route plus the function it points at, with the four inputs the
9
+ * resolution reads spread across both.
10
+ */
11
+ const resolve = (
12
+ route: Record<string, unknown>,
13
+ meta: Record<string, unknown> = {},
14
+ addons: Record<string, unknown> = {}
15
+ ): boolean | undefined => {
16
+ const state = {
17
+ functions: { meta: { handler: { pikkuFuncId: 'handler', ...meta } } },
18
+ http: {
19
+ meta: {
20
+ get: {
21
+ '/thing': {
22
+ pikkuFuncId: 'handler',
23
+ route: '/thing',
24
+ method: 'get',
25
+ ...route,
26
+ },
27
+ },
28
+ },
29
+ },
30
+ rpc: { wireAddonDeclarations: new Map(Object.entries(addons)) },
31
+ } as any
32
+
33
+ annotateHttpRouteAuth(state)
34
+ return state.http.meta.get['/thing'].requiresSession
35
+ }
36
+
37
+ describe('annotateHttpRouteAuth', () => {
38
+ test('a pikkuFunc always requires a session', () => {
39
+ assert.equal(resolve({}, { sessionless: false }), true)
40
+ })
41
+
42
+ test('a sessionless function on a plain route does not', () => {
43
+ assert.equal(resolve({}, { sessionless: true }), false)
44
+ })
45
+
46
+ test("the function's own auth requires one", () => {
47
+ assert.equal(resolve({}, { sessionless: true, auth: true }), true)
48
+ })
49
+
50
+ test("the route's auth requires one, even of a sessionless function", () => {
51
+ assert.equal(resolve({ auth: true }, { sessionless: true }), true)
52
+ })
53
+
54
+ test('an explicit auth: false on the route does not undo the function', () => {
55
+ // Layers only ever tighten. A route cannot open a pikkuFunc back up.
56
+ assert.equal(resolve({ auth: false }, { sessionless: false }), true)
57
+ })
58
+
59
+ test('function scopes require one', () => {
60
+ // Scopes are matched against the session's and fail closed, so an
61
+ // anonymous caller is rejected — reporting the route as open would be a lie.
62
+ assert.equal(resolve({}, { sessionless: true, scopes: ['admin'] }), true)
63
+ })
64
+
65
+ test("the addon's auth requires one", () => {
66
+ assert.equal(
67
+ resolve(
68
+ { packageName: ADDON_PACKAGE },
69
+ { sessionless: true },
70
+ { console: { package: ADDON_PACKAGE, auth: true } }
71
+ ),
72
+ true
73
+ )
74
+ })
75
+
76
+ test("the addon's scopes require one", () => {
77
+ assert.equal(
78
+ resolve(
79
+ { packageName: ADDON_PACKAGE },
80
+ { sessionless: true },
81
+ { console: { package: ADDON_PACKAGE, scopes: ['admin'] } }
82
+ ),
83
+ true
84
+ )
85
+ })
86
+
87
+ test("another package's addon gate does not reach this route", () => {
88
+ assert.equal(
89
+ resolve(
90
+ { packageName: '@app/api' },
91
+ { sessionless: true },
92
+ { console: { package: ADDON_PACKAGE, auth: true } }
93
+ ),
94
+ false
95
+ )
96
+ })
97
+
98
+ test('an ungated addon leaves the route as its function found it', () => {
99
+ assert.equal(
100
+ resolve(
101
+ { packageName: ADDON_PACKAGE },
102
+ { sessionless: true },
103
+ { console: { package: ADDON_PACKAGE } }
104
+ ),
105
+ false
106
+ )
107
+ })
108
+
109
+ test('a ref route resolves against its target', () => {
110
+ const state = {
111
+ functions: {
112
+ meta: {
113
+ inline: { pikkuFuncId: 'inline', sessionless: true },
114
+ target: { pikkuFuncId: 'target', sessionless: false },
115
+ },
116
+ },
117
+ http: {
118
+ meta: {
119
+ get: {
120
+ '/thing': {
121
+ pikkuFuncId: 'inline',
122
+ refTarget: 'target',
123
+ route: '/thing',
124
+ method: 'get',
125
+ },
126
+ },
127
+ },
128
+ },
129
+ rpc: { wireAddonDeclarations: new Map() },
130
+ } as any
131
+
132
+ annotateHttpRouteAuth(state)
133
+ assert.equal(state.http.meta.get['/thing'].requiresSession, true)
134
+ })
135
+
136
+ test('a route whose function meta is missing is not reported as open', () => {
137
+ const state = {
138
+ functions: { meta: {} },
139
+ http: {
140
+ meta: {
141
+ get: {
142
+ '/thing': {
143
+ pikkuFuncId: 'gone',
144
+ route: '/thing',
145
+ method: 'get',
146
+ auth: true,
147
+ },
148
+ },
149
+ },
150
+ },
151
+ rpc: { wireAddonDeclarations: new Map() },
152
+ } as any
153
+
154
+ annotateHttpRouteAuth(state)
155
+ assert.equal(state.http.meta.get['/thing'].requiresSession, true)
156
+ })
157
+
158
+ test('tolerates http state being absent', () => {
159
+ assert.doesNotThrow(() =>
160
+ annotateHttpRouteAuth({ functions: { meta: {} } } as any)
161
+ )
162
+ })
163
+ })
@@ -0,0 +1,52 @@
1
+ import type { InspectorState } from '../types.js'
2
+
3
+ /**
4
+ * Resolve, per HTTP route, whether reaching it requires a session.
5
+ *
6
+ * Four separate places can demand one — the function's `sessionless`, the
7
+ * function's own `auth`, the route's (or its group's) `auth`, and the addon the
8
+ * function belongs to — and they combine with OR: any of them is enough. Until
9
+ * this was written down, "which routes are open?" could only be answered by
10
+ * joining all four by hand, and getting the join wrong reads an open route as a
11
+ * closed one, which is the direction that costs something.
12
+ *
13
+ * A scope gate counts as requiring a session. Scopes are checked against the
14
+ * session's and fail closed, so an anonymous caller is rejected either way; not
15
+ * counting them would mark a route open that nobody can reach anonymously.
16
+ *
17
+ * Anything not statically knowable resolves to requiring a session, matching
18
+ * `validateExposedFunctionsGated`: an over-cautious `true` costs a route that
19
+ * looks stricter than it is, a wrong `false` costs an audit.
20
+ *
21
+ * knowledge: decisions/security/addon-auth-and-tags-only-tighten.md
22
+ */
23
+ export function annotateHttpRouteAuth(state: InspectorState): void {
24
+ const gatedPackages = packagesGatedByTheirAddon(state)
25
+
26
+ for (const routes of Object.values(state.http?.meta ?? {})) {
27
+ for (const route of Object.values(routes)) {
28
+ const meta = state.functions.meta[route.refTarget ?? route.pikkuFuncId]
29
+
30
+ route.requiresSession =
31
+ // A pikkuFunc always requires a session, on every path.
32
+ meta?.sessionless === false ||
33
+ meta?.auth === true ||
34
+ (meta?.scopes?.length ?? 0) > 0 ||
35
+ route.auth === true ||
36
+ (route.packageName !== undefined &&
37
+ gatedPackages.has(route.packageName))
38
+ }
39
+ }
40
+ }
41
+
42
+ /** Packages whose `wireAddon` demands a session from everything inside them. */
43
+ function packagesGatedByTheirAddon(state: InspectorState): Set<string> {
44
+ const gated = new Set<string>()
45
+ for (const declaration of state.rpc?.wireAddonDeclarations?.values() ?? []) {
46
+ if (!declaration.package) continue
47
+ if (declaration.auth === true || (declaration.scopes?.length ?? 0) > 0) {
48
+ gated.add(declaration.package)
49
+ }
50
+ }
51
+ return gated
52
+ }
@@ -46,9 +46,19 @@ export function funcIdToTypeName(id: string): string {
46
46
  }
47
47
 
48
48
  /**
49
- * `pikkuScenarioStep({ name })` is the one wrapper whose `name` is its identity:
50
- * scenarios reference a step by that string, so it must become the pikkuFuncId
51
- * (and hence the generated step map's key), not just a cosmetic label the way
49
+ * The step wrappers, whichever kind of subject acts in them. A scenario calls a
50
+ * platform or addon step by name exactly as it calls a persona one.
51
+ */
52
+ const SCENARIO_STEP_WRAPPERS = new Set([
53
+ 'pikkuScenarioStep',
54
+ 'pikkuPlatformScenarioStep',
55
+ 'pikkuAddonScenarioStep',
56
+ ])
57
+
58
+ /**
59
+ * A scenario step is the one wrapper whose `name` is its identity: scenarios
60
+ * reference a step by that string, so it must become the pikkuFuncId (and hence
61
+ * the generated step map's key), not just a cosmetic label the way
52
62
  * `pikkuMiddleware`/`pikkuAgent` use it.
53
63
  */
54
64
  function extractScenarioStepName(
@@ -58,7 +68,7 @@ function extractScenarioStepName(
58
68
  if (
59
69
  !ts.isCallExpression(callExpr) ||
60
70
  !ts.isIdentifier(callExpr.expression) ||
61
- callExpr.expression.text !== 'pikkuScenarioStep'
71
+ !SCENARIO_STEP_WRAPPERS.has(callExpr.expression.text)
62
72
  ) {
63
73
  return
64
74
  }