@pikku/inspector 0.12.50 → 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 (99) hide show
  1. package/CHANGELOG.md +441 -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/secret-alias-services.d.ts +8 -0
  36. package/dist/utils/secret-alias-services.js +45 -0
  37. package/dist/utils/secret-brokers.d.ts +2 -0
  38. package/dist/utils/secret-brokers.js +9 -0
  39. package/dist/utils/serialize-inspector-state.d.ts +10 -0
  40. package/dist/utils/serialize-inspector-state.js +18 -0
  41. package/dist/utils/serialize-middleware-groups-meta.js +3 -0
  42. package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
  43. package/dist/utils/validate-exposed-functions-gated.js +85 -0
  44. package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
  45. package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
  46. package/dist/visit.js +4 -0
  47. package/package.json +3 -3
  48. package/src/add/add-auth.test.ts +5 -3
  49. package/src/add/add-auth.ts +1 -1
  50. package/src/add/add-cli.ts +8 -0
  51. package/src/add/add-credential.ts +1 -1
  52. package/src/add/add-functions.test.ts +263 -1
  53. package/src/add/add-functions.ts +219 -9
  54. package/src/add/add-http-route.ts +20 -7
  55. package/src/add/add-middleware.test.ts +196 -0
  56. package/src/add/add-middleware.ts +64 -4
  57. package/src/add/add-personas.test.ts +268 -0
  58. package/src/add/add-personas.ts +276 -0
  59. package/src/add/add-scenario-step.test.ts +49 -3
  60. package/src/add/add-scope.test.ts +30 -30
  61. package/src/add/add-scope.ts +1 -1
  62. package/src/add/add-secret.ts +1 -1
  63. package/src/add/add-subject-scenario-steps.test.ts +161 -0
  64. package/src/add/add-system-role.test.ts +214 -0
  65. package/src/add/add-system-role.ts +164 -0
  66. package/src/add/add-variable.ts +1 -1
  67. package/src/add/add-wire-addon.test.ts +111 -0
  68. package/src/add/add-wire-addon.ts +29 -0
  69. package/src/add/addon-bans.test.ts +4 -4
  70. package/src/add/config-value-metadata.test.ts +11 -11
  71. package/src/add/wire-name-literal.test.ts +2 -2
  72. package/src/error-codes.ts +13 -0
  73. package/src/index.ts +4 -1
  74. package/src/inspector.ts +39 -0
  75. package/src/types.ts +47 -2
  76. package/src/utils/annotate-http-route-auth.test.ts +163 -0
  77. package/src/utils/annotate-http-route-auth.ts +52 -0
  78. package/src/utils/extract-function-name.ts +14 -4
  79. package/src/utils/extract-function-node.ts +18 -6
  80. package/src/utils/extract-secret-usage.test.ts +81 -0
  81. package/src/utils/extract-secret-usage.ts +73 -0
  82. package/src/utils/literal-properties.ts +97 -0
  83. package/src/utils/load-addon-functions-meta.ts +32 -2
  84. package/src/utils/permissions-shorthand.test.ts +79 -0
  85. package/src/utils/permissions.ts +7 -0
  86. package/src/utils/post-process.test.ts +133 -0
  87. package/src/utils/post-process.ts +310 -12
  88. package/src/utils/secret-alias-services.test.ts +146 -0
  89. package/src/utils/secret-alias-services.ts +58 -0
  90. package/src/utils/secret-brokers.test.ts +53 -0
  91. package/src/utils/secret-brokers.ts +11 -0
  92. package/src/utils/serialize-inspector-state.ts +28 -0
  93. package/src/utils/serialize-middleware-groups-meta.ts +3 -0
  94. package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
  95. package/src/utils/validate-exposed-functions-gated.ts +94 -0
  96. package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
  97. package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
  98. package/src/visit.ts +4 -0
  99. package/tsconfig.tsbuildinfo +1 -1
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
  }
@@ -4,6 +4,17 @@ import {
4
4
  resolveFunctionDeclaration,
5
5
  } from './type-utils.js'
6
6
 
7
+ /**
8
+ * Where a config object's implementation can live, in the order it is looked for.
9
+ *
10
+ * `func` is the ordinary case. A `pikkuScenarioStep` instead declares one
11
+ * implementation per surface, and every binding shares the step's input and
12
+ * output — that is what makes them interchangeable — so any one of them types
13
+ * the step. `default` is preferred because it is the binding every step is
14
+ * expected to have.
15
+ */
16
+ const IMPLEMENTATION_KEYS = ['func', 'default', 'browser', 'cli'] as const
17
+
7
18
  /**
8
19
  * Extracts the actual function node from a pikkuFunc/pikkuWorkflowFunc call
9
20
  * Handles both direct function form and config object form { func: ... }
@@ -22,12 +33,13 @@ export function extractFunctionNode(
22
33
  // Check if first argument is a config object with 'func' property
23
34
  if (ts.isObjectLiteralExpression(firstArg)) {
24
35
  isDirectFunction = false
25
- const funcInitializer = getPropertyAssignmentInitializer(
26
- firstArg,
27
- 'func',
28
- true,
29
- checker
30
- )
36
+ let funcInitializer: ts.Expression | undefined
37
+ for (const key of IMPLEMENTATION_KEYS) {
38
+ funcInitializer =
39
+ getPropertyAssignmentInitializer(firstArg, key, true, checker) ??
40
+ undefined
41
+ if (funcInitializer) break
42
+ }
31
43
 
32
44
  if (funcInitializer) {
33
45
  funcNode = funcInitializer
@@ -0,0 +1,81 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import ts from 'typescript'
4
+ import { extractSecretUsage } from './extract-secret-usage.js'
5
+
6
+ const usageOf = (source: string) =>
7
+ extractSecretUsage(
8
+ ts.createSourceFile('/services.ts', source, ts.ScriptTarget.ESNext, true)
9
+ )
10
+
11
+ describe('extractSecretUsage', () => {
12
+ test('collects a literal getSecret key', () => {
13
+ const usage = usageOf(`await secrets.getSecret('STRIPE_KEY')`)
14
+ assert.deepEqual(usage.keys, ['STRIPE_KEY'])
15
+ assert.deepEqual(usage.dynamic, [])
16
+ })
17
+
18
+ test('collects hasSecret and template literals with no substitutions', () => {
19
+ const usage = usageOf(
20
+ 'await secrets.hasSecret(`NOTION_TOKEN`)\nawait secrets.getSecret("A")'
21
+ )
22
+ assert.deepEqual(usage.keys, ['A', 'NOTION_TOKEN'])
23
+ })
24
+
25
+ test('collects every key of a getSecrets array', () => {
26
+ const usage = usageOf(`await secrets.getSecrets(['B', 'A'])`)
27
+ assert.deepEqual(usage.keys, ['A', 'B'])
28
+ })
29
+
30
+ test('flags a computed key as dynamic', () => {
31
+ const usage = usageOf(`await secrets.getSecret(name)`)
32
+ assert.deepEqual(usage.keys, [])
33
+ assert.equal(usage.dynamic.length, 1)
34
+ assert.match(usage.dynamic[0]!, /^1:name$/)
35
+ })
36
+
37
+ test('flags a template literal with a substitution as dynamic', () => {
38
+ const usage = usageOf('await secrets.getSecret(`PREFIX_${suffix}`)')
39
+ assert.deepEqual(usage.keys, [])
40
+ assert.equal(usage.dynamic.length, 1)
41
+ })
42
+
43
+ test('flags a non-array getSecrets argument as dynamic', () => {
44
+ const usage = usageOf(`await secrets.getSecrets(keys)`)
45
+ assert.equal(usage.dynamic.length, 1)
46
+ })
47
+
48
+ test('records both the literal and the dynamic keys of one call', () => {
49
+ const usage = usageOf(`await secrets.getSecrets(['A', other])`)
50
+ assert.deepEqual(usage.keys, ['A'])
51
+ assert.equal(usage.dynamic.length, 1)
52
+ })
53
+
54
+ test('finds reads nested inside a factory body', () => {
55
+ const usage = usageOf(`
56
+ export const createSingletonServices = async ({ secrets }) => {
57
+ const key = await secrets.getSecret('DEEP_KEY')
58
+ return { thing: new Thing(key) }
59
+ }
60
+ `)
61
+ assert.deepEqual(usage.keys, ['DEEP_KEY'])
62
+ })
63
+
64
+ test('deduplicates a key read twice', () => {
65
+ const usage = usageOf(
66
+ `await secrets.getSecret('A'); await secrets.getSecret('A')`
67
+ )
68
+ assert.deepEqual(usage.keys, ['A'])
69
+ })
70
+
71
+ test('ignores unrelated calls', () => {
72
+ const usage = usageOf(`await variables.get('HOME'); logger.info('hi')`)
73
+ assert.deepEqual(usage.keys, [])
74
+ assert.deepEqual(usage.dynamic, [])
75
+ })
76
+
77
+ test('reports the line a dynamic read is on', () => {
78
+ const usage = usageOf(`const a = 1\nconst b = 2\nsecrets.getSecret(name)`)
79
+ assert.match(usage.dynamic[0]!, /^3:/)
80
+ })
81
+ })
@@ -0,0 +1,73 @@
1
+ import ts from 'typescript'
2
+
3
+ export type SecretUsage = {
4
+ /** Secret ids read with a string literal, so statically known. */
5
+ keys: string[]
6
+ /** Reads whose key could not be resolved statically, as `line:source`. */
7
+ dynamic: string[]
8
+ }
9
+
10
+ const SINGLE_KEY_METHODS = new Set(['getSecret', 'hasSecret'])
11
+ const KEY_LIST_METHODS = new Set(['getSecrets'])
12
+
13
+ const literalKey = (node: ts.Expression): string | undefined => {
14
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
15
+ return node.text
16
+ }
17
+ return undefined
18
+ }
19
+
20
+ /** Secret reads in a file, whether the key is statically known or not. */
21
+ export const extractSecretUsage = (sourceFile: ts.SourceFile): SecretUsage => {
22
+ const keys = new Set<string>()
23
+ const dynamic = new Set<string>()
24
+
25
+ const record = (node: ts.CallExpression, argument?: ts.Expression) => {
26
+ const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
27
+ dynamic.add(`${line + 1}:${(argument ?? node).getText()}`)
28
+ }
29
+
30
+ const visit = (node: ts.Node): void => {
31
+ if (
32
+ ts.isCallExpression(node) &&
33
+ ts.isPropertyAccessExpression(node.expression)
34
+ ) {
35
+ const method = node.expression.name.text
36
+ const [firstArgument] = node.arguments
37
+
38
+ if (SINGLE_KEY_METHODS.has(method)) {
39
+ if (!firstArgument) {
40
+ record(node)
41
+ } else {
42
+ const key = literalKey(firstArgument)
43
+ if (key === undefined) {
44
+ record(node, firstArgument)
45
+ } else {
46
+ keys.add(key)
47
+ }
48
+ }
49
+ } else if (KEY_LIST_METHODS.has(method)) {
50
+ if (firstArgument && ts.isArrayLiteralExpression(firstArgument)) {
51
+ for (const element of firstArgument.elements) {
52
+ const key = literalKey(element)
53
+ if (key === undefined) {
54
+ record(node, element)
55
+ } else {
56
+ keys.add(key)
57
+ }
58
+ }
59
+ } else {
60
+ record(node, firstArgument)
61
+ }
62
+ }
63
+ }
64
+ ts.forEachChild(node, visit)
65
+ }
66
+
67
+ visit(sourceFile)
68
+
69
+ return {
70
+ keys: [...keys].sort(),
71
+ dynamic: [...dynamic].sort(),
72
+ }
73
+ }
@@ -0,0 +1,97 @@
1
+ import * as ts from 'typescript'
2
+
3
+ /**
4
+ * Readers for object-literal properties that must be statically knowable.
5
+ *
6
+ * Declarations extracted this way are never evaluated — the CLI reads them from
7
+ * source and writes them to JSON that a deployed stage carries without the app.
8
+ * So a computed value is not "unsupported", it is unreadable, and every reader
9
+ * here returns `undefined` for one rather than guessing.
10
+ */
11
+
12
+ export const getProperty = (
13
+ config: ts.ObjectLiteralExpression,
14
+ name: string
15
+ ): ts.Expression | undefined => {
16
+ const property = config.properties.find(
17
+ (p) =>
18
+ ts.isPropertyAssignment(p) &&
19
+ (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
20
+ p.name.text === name
21
+ )
22
+ return property && ts.isPropertyAssignment(property)
23
+ ? property.initializer
24
+ : undefined
25
+ }
26
+
27
+ export const stringProperty = (
28
+ config: ts.ObjectLiteralExpression,
29
+ name: string
30
+ ): string | undefined => {
31
+ const value = getProperty(config, name)
32
+ return value && ts.isStringLiteralLike(value) ? value.text : undefined
33
+ }
34
+
35
+ export const numberProperty = (
36
+ config: ts.ObjectLiteralExpression,
37
+ name: string
38
+ ): number | undefined => {
39
+ const value = getProperty(config, name)
40
+ if (!value) {
41
+ return undefined
42
+ }
43
+ // `-1` parses as a unary minus over a literal, not as a literal. Reading it
44
+ // rather than ignoring it is what lets a negative be *rejected* by the caller
45
+ // instead of silently dropped and defaulted.
46
+ if (
47
+ ts.isPrefixUnaryExpression(value) &&
48
+ value.operator === ts.SyntaxKind.MinusToken &&
49
+ ts.isNumericLiteral(value.operand)
50
+ ) {
51
+ return -Number(value.operand.text)
52
+ }
53
+ return ts.isNumericLiteral(value) ? Number(value.text) : undefined
54
+ }
55
+
56
+ export const booleanProperty = (
57
+ config: ts.ObjectLiteralExpression,
58
+ name: string
59
+ ): boolean | undefined => {
60
+ const value = getProperty(config, name)
61
+ if (!value) {
62
+ return undefined
63
+ }
64
+ if (value.kind === ts.SyntaxKind.TrueKeyword) {
65
+ return true
66
+ }
67
+ if (value.kind === ts.SyntaxKind.FalseKeyword) {
68
+ return false
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ /**
74
+ * All-or-nothing: an array with one computed entry reads as unreadable rather
75
+ * than as the entries that happened to be literals. A half-read `roles` or
76
+ * `scopes` array is worse than none — it typechecks, runs, and grants less
77
+ * than the source says.
78
+ */
79
+ export const stringArrayProperty = (
80
+ config: ts.ObjectLiteralExpression,
81
+ name: string
82
+ ): string[] | undefined => {
83
+ const value = getProperty(config, name)
84
+ if (!value || !ts.isArrayLiteralExpression(value)) {
85
+ return undefined
86
+ }
87
+ const values = value.elements
88
+ .filter(ts.isStringLiteralLike)
89
+ .map((e) => e.text)
90
+ return values.length === value.elements.length ? values : undefined
91
+ }
92
+
93
+ /** Unwraps `x as const` / `x satisfies T` so a cast is still extracted. */
94
+ export const unwrapAs = (node: ts.Expression): ts.Expression =>
95
+ ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
96
+ ? unwrapAs(node.expression)
97
+ : node
@@ -118,6 +118,29 @@ const applyPackageToChannelContracts = (
118
118
  }
119
119
  }
120
120
 
121
+ /**
122
+ * Resolves an addon's generated metadata, preferring the verbose copy.
123
+ *
124
+ * An addon publishes both: the minimal one its own bootstrap imports, and the
125
+ * verbose one for anything that needs the fields stripped out of it. A consumer
126
+ * is squarely in the second category — it is reading the addon to describe it,
127
+ * not to run it.
128
+ */
129
+ const resolveAddonMeta = (
130
+ require: NodeRequire,
131
+ packageName: string,
132
+ baseName: string
133
+ ): string | null => {
134
+ for (const suffix of ['-verbose.gen.json', '.gen.json']) {
135
+ try {
136
+ return require.resolve(`${packageName}/.pikku/${baseName}${suffix}`)
137
+ } catch {
138
+ continue
139
+ }
140
+ }
141
+ return null
142
+ }
143
+
121
144
  /**
122
145
  * After the setup sweep discovers wireAddon() declarations, load each addon
123
146
  * package's function metadata so that wiring handlers (channels, HTTP routes,
@@ -138,9 +161,16 @@ export async function loadAddonFunctionsMeta(
138
161
  // HOST that runs them — never load or require any of that here.
139
162
  if (decl.remote) continue
140
163
  try {
141
- const metaPath = require.resolve(
142
- `${decl.package}/.pikku/function/pikku-functions-meta.gen.json`
164
+ // Verbose first. `description` is one of the fields stripped from the
165
+ // minimal copy, and it is what an addon's function is offered to a model
166
+ // under — both as an MCP tool below and as an agent tool. Read the minimal
167
+ // one and every addon function looks undescribed.
168
+ const metaPath = resolveAddonMeta(
169
+ require,
170
+ decl.package,
171
+ 'function/pikku-functions-meta'
143
172
  )
173
+ if (!metaPath) throw new Error('no function metadata')
144
174
  const raw = await readFile(metaPath, 'utf-8')
145
175
  const meta = JSON.parse(raw)
146
176
  state.addonFunctions[namespace] = meta