@pikku/inspector 0.12.50 → 0.12.54

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 (116) hide show
  1. package/CHANGELOG.md +743 -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 +199 -8
  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 +216 -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/add/add-workflow.js +12 -1
  19. package/dist/error-codes.d.ts +17 -1
  20. package/dist/error-codes.js +23 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/inspector.js +33 -1
  23. package/dist/types.d.ts +55 -2
  24. package/dist/utils/annotate-http-route-auth.d.ts +22 -0
  25. package/dist/utils/annotate-http-route-auth.js +48 -0
  26. package/dist/utils/check-secret-sinks.d.ts +31 -0
  27. package/dist/utils/check-secret-sinks.js +117 -0
  28. package/dist/utils/extract-function-name.js +13 -4
  29. package/dist/utils/extract-function-node.js +18 -1
  30. package/dist/utils/extract-secret-usage.d.ts +9 -0
  31. package/dist/utils/extract-secret-usage.js +61 -0
  32. package/dist/utils/literal-properties.d.ts +22 -0
  33. package/dist/utils/literal-properties.js +69 -0
  34. package/dist/utils/load-addon-functions-meta.js +26 -1
  35. package/dist/utils/permissions.js +8 -0
  36. package/dist/utils/post-process.d.ts +68 -3
  37. package/dist/utils/post-process.js +246 -13
  38. package/dist/utils/secret-alias-services.d.ts +8 -0
  39. package/dist/utils/secret-alias-services.js +45 -0
  40. package/dist/utils/secret-brokers.d.ts +2 -0
  41. package/dist/utils/secret-brokers.js +9 -0
  42. package/dist/utils/serialize-inspector-state.d.ts +10 -0
  43. package/dist/utils/serialize-inspector-state.js +18 -0
  44. package/dist/utils/serialize-middleware-groups-meta.js +3 -0
  45. package/dist/utils/single-declaration.d.ts +32 -0
  46. package/dist/utils/single-declaration.js +43 -0
  47. package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
  48. package/dist/utils/validate-exposed-functions-gated.js +93 -0
  49. package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
  50. package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
  51. package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
  52. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
  53. package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
  54. package/dist/utils/workflow/dsl/patterns.js +26 -4
  55. package/dist/visit.js +4 -0
  56. package/package.json +3 -3
  57. package/src/add/add-auth.test.ts +5 -3
  58. package/src/add/add-auth.ts +1 -1
  59. package/src/add/add-cli.ts +8 -0
  60. package/src/add/add-credential.ts +1 -1
  61. package/src/add/add-functions.test.ts +263 -1
  62. package/src/add/add-functions.ts +273 -11
  63. package/src/add/add-http-route.ts +20 -7
  64. package/src/add/add-middleware.test.ts +196 -0
  65. package/src/add/add-middleware.ts +64 -4
  66. package/src/add/add-personas.test.ts +382 -0
  67. package/src/add/add-personas.ts +285 -0
  68. package/src/add/add-scenario-step.test.ts +87 -5
  69. package/src/add/add-scope.test.ts +103 -30
  70. package/src/add/add-scope.ts +1 -1
  71. package/src/add/add-secret.ts +1 -1
  72. package/src/add/add-subject-scenario-steps.test.ts +161 -0
  73. package/src/add/add-system-role.test.ts +297 -0
  74. package/src/add/add-system-role.ts +164 -0
  75. package/src/add/add-variable.ts +1 -1
  76. package/src/add/add-wire-addon.test.ts +111 -0
  77. package/src/add/add-wire-addon.ts +29 -0
  78. package/src/add/add-workflow.ts +21 -1
  79. package/src/add/addon-bans.test.ts +4 -4
  80. package/src/add/allow-escape-hatches.test.ts +144 -0
  81. package/src/add/audit-meta.test.ts +75 -0
  82. package/src/add/config-value-metadata.test.ts +11 -11
  83. package/src/add/secret-sink-check.test.ts +211 -0
  84. package/src/add/wire-name-literal.test.ts +2 -2
  85. package/src/error-codes.ts +26 -0
  86. package/src/index.ts +4 -1
  87. package/src/inspector.ts +39 -0
  88. package/src/types.ts +56 -2
  89. package/src/utils/annotate-http-route-auth.test.ts +163 -0
  90. package/src/utils/annotate-http-route-auth.ts +52 -0
  91. package/src/utils/check-secret-sinks.ts +145 -0
  92. package/src/utils/extract-function-name.ts +14 -4
  93. package/src/utils/extract-function-node.ts +18 -6
  94. package/src/utils/extract-secret-usage.test.ts +81 -0
  95. package/src/utils/extract-secret-usage.ts +73 -0
  96. package/src/utils/literal-properties.ts +97 -0
  97. package/src/utils/load-addon-functions-meta.ts +32 -2
  98. package/src/utils/permissions-shorthand.test.ts +79 -0
  99. package/src/utils/permissions.ts +7 -0
  100. package/src/utils/post-process.test.ts +133 -0
  101. package/src/utils/post-process.ts +313 -12
  102. package/src/utils/secret-alias-services.test.ts +146 -0
  103. package/src/utils/secret-alias-services.ts +58 -0
  104. package/src/utils/secret-brokers.test.ts +53 -0
  105. package/src/utils/secret-brokers.ts +11 -0
  106. package/src/utils/serialize-inspector-state.ts +28 -0
  107. package/src/utils/serialize-middleware-groups-meta.ts +3 -0
  108. package/src/utils/single-declaration.ts +57 -0
  109. package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
  110. package/src/utils/validate-exposed-functions-gated.ts +103 -0
  111. package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
  112. package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
  113. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
  114. package/src/utils/workflow/dsl/patterns.ts +33 -4
  115. package/src/visit.ts +4 -0
  116. package/tsconfig.tsbuildinfo +1 -1
package/dist/types.d.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';
@@ -14,6 +15,8 @@ import type { NodesMeta } from '@pikku/core/node';
14
15
  import type { SecretDefinitions } from '@pikku/core/secret';
15
16
  import type { CredentialDefinitions } from '@pikku/core/credential';
16
17
  import type { ScopeDefinitions } from '@pikku/core/scope';
18
+ import type { SystemRoleDefinitions } from '@pikku/core/role';
19
+ import type { PersonaDefinitions } from '@pikku/core/persona';
17
20
  import type { VariableDefinitions } from '@pikku/core/variable';
18
21
  import type { TypesMap } from './types-map.js';
19
22
  import type { FunctionsMeta, FunctionServicesMeta, FunctionWiresMeta, JSONValue } from '@pikku/core';
@@ -31,14 +34,30 @@ export type MetaInputTypes = Map<string, {
31
34
  params: string[] | undefined;
32
35
  body: string[] | undefined;
33
36
  }>;
34
- export interface MiddlewareGroupMeta {
37
+ /** One `addHTTPMiddleware`/`addTagMiddleware`/`addChannelMiddleware` call. */
38
+ export interface MiddlewareRegistration {
35
39
  exportName: string | null;
36
40
  sourceFile: string;
37
41
  position: number;
42
+ isFactory: boolean;
43
+ }
44
+ export interface MiddlewareGroupMeta extends MiddlewareRegistration {
38
45
  services: FunctionServicesMeta;
39
46
  count: number;
40
47
  instanceIds: string[];
41
- isFactory: boolean;
48
+ /**
49
+ * The registrations after the first, when several files add middleware for
50
+ * the same pattern or tag.
51
+ *
52
+ * A group is keyed by its pattern/tag, so without this a second file's call
53
+ * overwrote the first's and only the survivor got an import emitted into
54
+ * pikku-middleware.gen.ts — the losing file was never imported, so its
55
+ * registration never ran. The runtime composes repeated registrations for a
56
+ * pattern happily; only codegen dropped one, which is how an app could lose
57
+ * its session-bridge middleware to an unrelated `'*'` group and see nothing
58
+ * go wrong until a request arrived without a session.
59
+ */
60
+ additionalRegistrations?: MiddlewareRegistration[];
42
61
  }
43
62
  export interface InspectorHTTPState {
44
63
  metaInputTypes: MetaInputTypes;
@@ -204,6 +223,12 @@ export type InspectorOptions = Partial<{
204
223
  setupOnly: boolean;
205
224
  rootDir: string;
206
225
  isAddon: boolean;
226
+ /**
227
+ * Fail the build on metadata that only an agent needs — currently an agent
228
+ * tool with no description. Off by default: the runtime falls back to the
229
+ * tool's own name, so this is a quality gate rather than a correctness one.
230
+ */
231
+ strictMeta: boolean;
207
232
  sourceFile: ts.SourceFile;
208
233
  /**
209
234
  * The project's tsconfig, read for its path mappings only. The inspector's
@@ -243,6 +268,15 @@ export type InspectorOptions = Partial<{
243
268
  * function, which is expensive. Enabled via `pikku all --security`.
244
269
  */
245
270
  classificationCheck: boolean;
271
+ /**
272
+ * Escape hatches the project has opted into, from `allow` in
273
+ * `pikku.config.json`. Absent or false means the feature is refused with a
274
+ * critical naming the flag that would permit it.
275
+ */
276
+ allow: Partial<{
277
+ permissionsInBody: boolean;
278
+ complexWorkflows: boolean;
279
+ }>;
246
280
  }>;
247
281
  export interface InspectorLogger {
248
282
  info: (message: string) => void;
@@ -464,6 +498,15 @@ export interface InspectorState {
464
498
  package: string;
465
499
  rpcEndpoint?: string;
466
500
  mcp?: boolean;
501
+ /**
502
+ * The addon's own gates. `runPikkuFunc` applies these to every function
503
+ * in the package, on every wiring path — not just `namespace:function`.
504
+ * Absent when the declared value was not a statically-knowable literal,
505
+ * which must be read as "unknown", never as "ungated".
506
+ */
507
+ auth?: boolean;
508
+ tags?: string[];
509
+ scopes?: string[];
467
510
  /** True when declared via `wireRemoteAddon` — import the addon's `.remote.gen` map, not `.internal.gen` */
468
511
  remote?: boolean;
469
512
  /** wireRemoteAddon: whether an `auth` binding was supplied (vs a public surface) */
@@ -522,6 +565,8 @@ export interface InspectorState {
522
565
  secrets: {
523
566
  definitions: SecretDefinitions;
524
567
  files: Set<string>;
568
+ /** Secret reads found per source file, keyed by absolute path. */
569
+ usage: Map<string, SecretUsage>;
525
570
  };
526
571
  credentials: {
527
572
  definitions: CredentialDefinitions;
@@ -531,6 +576,14 @@ export interface InspectorState {
531
576
  definitions: ScopeDefinitions;
532
577
  files: Set<string>;
533
578
  };
579
+ systemRoles: {
580
+ definitions: SystemRoleDefinitions;
581
+ files: Set<string>;
582
+ };
583
+ personas: {
584
+ definitions: PersonaDefinitions;
585
+ files: Set<string>;
586
+ };
534
587
  variables: {
535
588
  definitions: VariableDefinitions;
536
589
  files: Set<string>;
@@ -0,0 +1,22 @@
1
+ import type { InspectorState } from '../types.js';
2
+ /**
3
+ * Resolve, per HTTP route, whether reaching it requires a session.
4
+ *
5
+ * Four separate places can demand one — the function's `sessionless`, the
6
+ * function's own `auth`, the route's (or its group's) `auth`, and the addon the
7
+ * function belongs to — and they combine with OR: any of them is enough. Until
8
+ * this was written down, "which routes are open?" could only be answered by
9
+ * joining all four by hand, and getting the join wrong reads an open route as a
10
+ * closed one, which is the direction that costs something.
11
+ *
12
+ * A scope gate counts as requiring a session. Scopes are checked against the
13
+ * session's and fail closed, so an anonymous caller is rejected either way; not
14
+ * counting them would mark a route open that nobody can reach anonymously.
15
+ *
16
+ * Anything not statically knowable resolves to requiring a session, matching
17
+ * `validateExposedFunctionsGated`: an over-cautious `true` costs a route that
18
+ * looks stricter than it is, a wrong `false` costs an audit.
19
+ *
20
+ * knowledge: decisions/security/addon-auth-and-tags-only-tighten.md
21
+ */
22
+ export declare function annotateHttpRouteAuth(state: InspectorState): void;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Resolve, per HTTP route, whether reaching it requires a session.
3
+ *
4
+ * Four separate places can demand one — the function's `sessionless`, the
5
+ * function's own `auth`, the route's (or its group's) `auth`, and the addon the
6
+ * function belongs to — and they combine with OR: any of them is enough. Until
7
+ * this was written down, "which routes are open?" could only be answered by
8
+ * joining all four by hand, and getting the join wrong reads an open route as a
9
+ * closed one, which is the direction that costs something.
10
+ *
11
+ * A scope gate counts as requiring a session. Scopes are checked against the
12
+ * session's and fail closed, so an anonymous caller is rejected either way; not
13
+ * counting them would mark a route open that nobody can reach anonymously.
14
+ *
15
+ * Anything not statically knowable resolves to requiring a session, matching
16
+ * `validateExposedFunctionsGated`: an over-cautious `true` costs a route that
17
+ * looks stricter than it is, a wrong `false` costs an audit.
18
+ *
19
+ * knowledge: decisions/security/addon-auth-and-tags-only-tighten.md
20
+ */
21
+ export function annotateHttpRouteAuth(state) {
22
+ const gatedPackages = packagesGatedByTheirAddon(state);
23
+ for (const routes of Object.values(state.http?.meta ?? {})) {
24
+ for (const route of Object.values(routes)) {
25
+ const meta = state.functions.meta[route.refTarget ?? route.pikkuFuncId];
26
+ route.requiresSession =
27
+ // A pikkuFunc always requires a session, on every path.
28
+ meta?.sessionless === false ||
29
+ meta?.auth === true ||
30
+ (meta?.scopes?.length ?? 0) > 0 ||
31
+ route.auth === true ||
32
+ (route.packageName !== undefined &&
33
+ gatedPackages.has(route.packageName));
34
+ }
35
+ }
36
+ }
37
+ /** Packages whose `wireAddon` demands a session from everything inside them. */
38
+ function packagesGatedByTheirAddon(state) {
39
+ const gated = new Set();
40
+ for (const declaration of state.rpc?.wireAddonDeclarations?.values() ?? []) {
41
+ if (!declaration.package)
42
+ continue;
43
+ if (declaration.auth === true || (declaration.scopes?.length ?? 0) > 0) {
44
+ gated.add(declaration.package);
45
+ }
46
+ }
47
+ return gated;
48
+ }
@@ -0,0 +1,31 @@
1
+ import * as ts from 'typescript';
2
+ export type SecretSinkViolation = {
3
+ /** How the value reaches the outside world, e.g. `logger.info`. */
4
+ sink: string;
5
+ /** Dotted path to the secret inside the argument, or `<argument>` when bare. */
6
+ path: string;
7
+ /** Position of the offending argument, for the diagnostic message. */
8
+ line: number;
9
+ };
10
+ /**
11
+ * Finds revealed vault secrets that flow into a sink.
12
+ *
13
+ * `SecretValue` is nominally typed, so the type system alone stops it reaching
14
+ * any of these. `.reveal()` is the deliberate escape hatch, and it hands back
15
+ * `Secret<T>` — the erasable classification brand. That brand is what this scan
16
+ * follows, which means it catches the revealed value both inline
17
+ * (`logger.info(s.reveal())`) and through a local binding, because the local's
18
+ * inferred type carries the brand too.
19
+ *
20
+ * The brand is optional by design, so an explicit annotation (`const t: string =
21
+ * s.reveal()`) erases it and this scan goes quiet. That is the documented limit
22
+ * of a structural marker: it catches the mistake, not someone determined to
23
+ * silence it.
24
+ *
25
+ * Sinks are matched on the receiver's resolved core interface, which means the
26
+ * scan needs the services parameter to be typed. Real projects get that from
27
+ * the generated `#pikku` factory; a hand-rolled file that calls the raw core
28
+ * factory without annotating its services leaves the parameter `any`, and an
29
+ * `any` receiver matches nothing.
30
+ */
31
+ export declare function findRevealedSecretSinks(checker: ts.TypeChecker, handler: ts.Node): SecretSinkViolation[];
@@ -0,0 +1,117 @@
1
+ import * as ts from 'typescript';
2
+ import { findPiiPaths } from './check-pii-output.js';
3
+ import { doesTypeExtendsCore } from './does-type-extend-core-type.js';
4
+ /**
5
+ * A method that writes its arguments somewhere a secret must never land, keyed
6
+ * by the core interface that owns it. `'*'` means every method on the type is a
7
+ * sink, which is true of a logger.
8
+ */
9
+ const SINKS = [
10
+ { coreType: 'Logger', methods: '*' },
11
+ { coreType: 'QueueService', methods: ['add'] },
12
+ { coreType: 'EmailService', methods: ['send'] },
13
+ { coreType: 'WebhookService', methods: ['send'] },
14
+ { coreType: 'AuditService', methods: ['audit', 'write'] },
15
+ { coreType: 'AuditLog', methods: ['write'] },
16
+ ];
17
+ /**
18
+ * Finds revealed vault secrets that flow into a sink.
19
+ *
20
+ * `SecretValue` is nominally typed, so the type system alone stops it reaching
21
+ * any of these. `.reveal()` is the deliberate escape hatch, and it hands back
22
+ * `Secret<T>` — the erasable classification brand. That brand is what this scan
23
+ * follows, which means it catches the revealed value both inline
24
+ * (`logger.info(s.reveal())`) and through a local binding, because the local's
25
+ * inferred type carries the brand too.
26
+ *
27
+ * The brand is optional by design, so an explicit annotation (`const t: string =
28
+ * s.reveal()`) erases it and this scan goes quiet. That is the documented limit
29
+ * of a structural marker: it catches the mistake, not someone determined to
30
+ * silence it.
31
+ *
32
+ * Sinks are matched on the receiver's resolved core interface, which means the
33
+ * scan needs the services parameter to be typed. Real projects get that from
34
+ * the generated `#pikku` factory; a hand-rolled file that calls the raw core
35
+ * factory without annotating its services leaves the parameter `any`, and an
36
+ * `any` receiver matches nothing.
37
+ */
38
+ export function findRevealedSecretSinks(checker, handler) {
39
+ const violations = [];
40
+ const visit = (node) => {
41
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
42
+ const sink = describeSink(checker, node.expression);
43
+ if (sink) {
44
+ for (const arg of node.arguments) {
45
+ const found = findSecretExpression(checker, arg);
46
+ if (found) {
47
+ violations.push({
48
+ sink,
49
+ path: found.getText(),
50
+ line: found
51
+ .getSourceFile()
52
+ .getLineAndCharacterOfPosition(found.getStart()).line + 1,
53
+ });
54
+ }
55
+ }
56
+ }
57
+ }
58
+ ts.forEachChild(node, visit);
59
+ };
60
+ visit(handler);
61
+ return violations;
62
+ }
63
+ /**
64
+ * Finds the first expression inside a sink argument whose own type carries the
65
+ * secret brand, and returns it so the diagnostic can quote it.
66
+ *
67
+ * This walks the argument rather than typing it whole, because an object
68
+ * literal in an argument position is contextually typed by the parameter — for
69
+ * a guarded sink that parameter is `Safe<M>`, and resolving the literal against
70
+ * it drops the optional brand before it can be read. Each inner expression
71
+ * (`token.reveal()`, or a reference to a local holding it) carries its own type
72
+ * regardless of the surrounding context, so reading those is what actually
73
+ * sees the secret.
74
+ */
75
+ function findSecretExpression(checker, node) {
76
+ // Descend first, so an object literal wrapping the secret reports the
77
+ // expression that actually holds it (`token.reveal()`) rather than the whole
78
+ // literal. Nothing inside a `SecretValue` receiver is itself branded, so a
79
+ // bare `token.reveal()` still falls through to the self-check below.
80
+ for (const child of node.getChildren()) {
81
+ // A property name is not a value, and typing it re-resolves the property.
82
+ if (ts.isPropertyAssignment(node) && child === node.name)
83
+ continue;
84
+ const found = findSecretExpression(checker, child);
85
+ if (found)
86
+ return found;
87
+ }
88
+ if (ts.isExpression(node) && !ts.isStringLiteralLike(node)) {
89
+ const type = checker.getTypeAtLocation(node);
90
+ const isSecret = findPiiPaths(checker, type).some((f) => f.classification === 'secret');
91
+ if (isSecret)
92
+ return node;
93
+ }
94
+ return undefined;
95
+ }
96
+ /**
97
+ * Names the sink a call writes to, or undefined when the call is not one.
98
+ * `console` is matched by identifier because it is a global with no Pikku type;
99
+ * everything else is matched on its resolved core interface so a renamed
100
+ * service or a decorator still counts.
101
+ */
102
+ function describeSink(checker, callee) {
103
+ const method = callee.name.text;
104
+ const receiver = callee.expression;
105
+ if (ts.isIdentifier(receiver) && receiver.text === 'console') {
106
+ return `console.${method}`;
107
+ }
108
+ const receiverType = checker.getTypeAtLocation(receiver);
109
+ for (const { coreType, methods } of SINKS) {
110
+ if (methods !== '*' && !methods.includes(method))
111
+ continue;
112
+ if (doesTypeExtendsCore(receiverType, checker, new Set(), coreType)) {
113
+ return `${receiver.getText()}.${method}`;
114
+ }
115
+ }
116
+ return undefined;
117
+ }
@@ -29,15 +29,24 @@ export function funcIdToTypeName(id) {
29
29
  .join('');
30
30
  }
31
31
  /**
32
- * `pikkuScenarioStep({ name })` is the one wrapper whose `name` is its identity:
33
- * scenarios reference a step by that string, so it must become the pikkuFuncId
34
- * (and hence the generated step map's key), not just a cosmetic label the way
32
+ * The step wrappers, whichever kind of subject acts in them. A scenario calls a
33
+ * platform or addon step by name exactly as it calls a persona one.
34
+ */
35
+ const SCENARIO_STEP_WRAPPERS = new Set([
36
+ 'pikkuScenarioStep',
37
+ 'pikkuPlatformScenarioStep',
38
+ 'pikkuAddonScenarioStep',
39
+ ]);
40
+ /**
41
+ * A scenario step is the one wrapper whose `name` is its identity: scenarios
42
+ * reference a step by that string, so it must become the pikkuFuncId (and hence
43
+ * the generated step map's key), not just a cosmetic label the way
35
44
  * `pikkuMiddleware`/`pikkuAgent` use it.
36
45
  */
37
46
  function extractScenarioStepName(callExpr, result) {
38
47
  if (!ts.isCallExpression(callExpr) ||
39
48
  !ts.isIdentifier(callExpr.expression) ||
40
- callExpr.expression.text !== 'pikkuScenarioStep') {
49
+ !SCENARIO_STEP_WRAPPERS.has(callExpr.expression.text)) {
41
50
  return;
42
51
  }
43
52
  const firstArg = callExpr.arguments[0];
@@ -1,5 +1,15 @@
1
1
  import * as ts from 'typescript';
2
2
  import { getPropertyAssignmentInitializer, resolveFunctionDeclaration, } from './type-utils.js';
3
+ /**
4
+ * Where a config object's implementation can live, in the order it is looked for.
5
+ *
6
+ * `func` is the ordinary case. A `pikkuScenarioStep` instead declares one
7
+ * implementation per surface, and every binding shares the step's input and
8
+ * output — that is what makes them interchangeable — so any one of them types
9
+ * the step. `default` is preferred because it is the binding every step is
10
+ * expected to have.
11
+ */
12
+ const IMPLEMENTATION_KEYS = ['func', 'default', 'browser', 'cli'];
3
13
  /**
4
14
  * Extracts the actual function node from a pikkuFunc/pikkuWorkflowFunc call
5
15
  * Handles both direct function form and config object form { func: ... }
@@ -10,7 +20,14 @@ export function extractFunctionNode(firstArg, checker) {
10
20
  // Check if first argument is a config object with 'func' property
11
21
  if (ts.isObjectLiteralExpression(firstArg)) {
12
22
  isDirectFunction = false;
13
- const funcInitializer = getPropertyAssignmentInitializer(firstArg, 'func', true, checker);
23
+ let funcInitializer;
24
+ for (const key of IMPLEMENTATION_KEYS) {
25
+ funcInitializer =
26
+ getPropertyAssignmentInitializer(firstArg, key, true, checker) ??
27
+ undefined;
28
+ if (funcInitializer)
29
+ break;
30
+ }
14
31
  if (funcInitializer) {
15
32
  funcNode = funcInitializer;
16
33
  }
@@ -0,0 +1,9 @@
1
+ import ts from 'typescript';
2
+ export type SecretUsage = {
3
+ /** Secret ids read with a string literal, so statically known. */
4
+ keys: string[];
5
+ /** Reads whose key could not be resolved statically, as `line:source`. */
6
+ dynamic: string[];
7
+ };
8
+ /** Secret reads in a file, whether the key is statically known or not. */
9
+ export declare const extractSecretUsage: (sourceFile: ts.SourceFile) => SecretUsage;
@@ -0,0 +1,61 @@
1
+ import ts from 'typescript';
2
+ const SINGLE_KEY_METHODS = new Set(['getSecret', 'hasSecret']);
3
+ const KEY_LIST_METHODS = new Set(['getSecrets']);
4
+ const literalKey = (node) => {
5
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
6
+ return node.text;
7
+ }
8
+ return undefined;
9
+ };
10
+ /** Secret reads in a file, whether the key is statically known or not. */
11
+ export const extractSecretUsage = (sourceFile) => {
12
+ const keys = new Set();
13
+ const dynamic = new Set();
14
+ const record = (node, argument) => {
15
+ const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
16
+ dynamic.add(`${line + 1}:${(argument ?? node).getText()}`);
17
+ };
18
+ const visit = (node) => {
19
+ if (ts.isCallExpression(node) &&
20
+ ts.isPropertyAccessExpression(node.expression)) {
21
+ const method = node.expression.name.text;
22
+ const [firstArgument] = node.arguments;
23
+ if (SINGLE_KEY_METHODS.has(method)) {
24
+ if (!firstArgument) {
25
+ record(node);
26
+ }
27
+ else {
28
+ const key = literalKey(firstArgument);
29
+ if (key === undefined) {
30
+ record(node, firstArgument);
31
+ }
32
+ else {
33
+ keys.add(key);
34
+ }
35
+ }
36
+ }
37
+ else if (KEY_LIST_METHODS.has(method)) {
38
+ if (firstArgument && ts.isArrayLiteralExpression(firstArgument)) {
39
+ for (const element of firstArgument.elements) {
40
+ const key = literalKey(element);
41
+ if (key === undefined) {
42
+ record(node, element);
43
+ }
44
+ else {
45
+ keys.add(key);
46
+ }
47
+ }
48
+ }
49
+ else {
50
+ record(node, firstArgument);
51
+ }
52
+ }
53
+ }
54
+ ts.forEachChild(node, visit);
55
+ };
56
+ visit(sourceFile);
57
+ return {
58
+ keys: [...keys].sort(),
59
+ dynamic: [...dynamic].sort(),
60
+ };
61
+ };
@@ -0,0 +1,22 @@
1
+ import * as ts from 'typescript';
2
+ /**
3
+ * Readers for object-literal properties that must be statically knowable.
4
+ *
5
+ * Declarations extracted this way are never evaluated — the CLI reads them from
6
+ * source and writes them to JSON that a deployed stage carries without the app.
7
+ * So a computed value is not "unsupported", it is unreadable, and every reader
8
+ * here returns `undefined` for one rather than guessing.
9
+ */
10
+ export declare const getProperty: (config: ts.ObjectLiteralExpression, name: string) => ts.Expression | undefined;
11
+ export declare const stringProperty: (config: ts.ObjectLiteralExpression, name: string) => string | undefined;
12
+ export declare const numberProperty: (config: ts.ObjectLiteralExpression, name: string) => number | undefined;
13
+ export declare const booleanProperty: (config: ts.ObjectLiteralExpression, name: string) => boolean | undefined;
14
+ /**
15
+ * All-or-nothing: an array with one computed entry reads as unreadable rather
16
+ * than as the entries that happened to be literals. A half-read `roles` or
17
+ * `scopes` array is worse than none — it typechecks, runs, and grants less
18
+ * than the source says.
19
+ */
20
+ export declare const stringArrayProperty: (config: ts.ObjectLiteralExpression, name: string) => string[] | undefined;
21
+ /** Unwraps `x as const` / `x satisfies T` so a cast is still extracted. */
22
+ export declare const unwrapAs: (node: ts.Expression) => ts.Expression;
@@ -0,0 +1,69 @@
1
+ import * as ts from 'typescript';
2
+ /**
3
+ * Readers for object-literal properties that must be statically knowable.
4
+ *
5
+ * Declarations extracted this way are never evaluated — the CLI reads them from
6
+ * source and writes them to JSON that a deployed stage carries without the app.
7
+ * So a computed value is not "unsupported", it is unreadable, and every reader
8
+ * here returns `undefined` for one rather than guessing.
9
+ */
10
+ export const getProperty = (config, name) => {
11
+ const property = config.properties.find((p) => ts.isPropertyAssignment(p) &&
12
+ (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
13
+ p.name.text === name);
14
+ return property && ts.isPropertyAssignment(property)
15
+ ? property.initializer
16
+ : undefined;
17
+ };
18
+ export const stringProperty = (config, name) => {
19
+ const value = getProperty(config, name);
20
+ return value && ts.isStringLiteralLike(value) ? value.text : undefined;
21
+ };
22
+ export const numberProperty = (config, name) => {
23
+ const value = getProperty(config, name);
24
+ if (!value) {
25
+ return undefined;
26
+ }
27
+ // `-1` parses as a unary minus over a literal, not as a literal. Reading it
28
+ // rather than ignoring it is what lets a negative be *rejected* by the caller
29
+ // instead of silently dropped and defaulted.
30
+ if (ts.isPrefixUnaryExpression(value) &&
31
+ value.operator === ts.SyntaxKind.MinusToken &&
32
+ ts.isNumericLiteral(value.operand)) {
33
+ return -Number(value.operand.text);
34
+ }
35
+ return ts.isNumericLiteral(value) ? Number(value.text) : undefined;
36
+ };
37
+ export const booleanProperty = (config, name) => {
38
+ const value = getProperty(config, name);
39
+ if (!value) {
40
+ return undefined;
41
+ }
42
+ if (value.kind === ts.SyntaxKind.TrueKeyword) {
43
+ return true;
44
+ }
45
+ if (value.kind === ts.SyntaxKind.FalseKeyword) {
46
+ return false;
47
+ }
48
+ return undefined;
49
+ };
50
+ /**
51
+ * All-or-nothing: an array with one computed entry reads as unreadable rather
52
+ * than as the entries that happened to be literals. A half-read `roles` or
53
+ * `scopes` array is worse than none — it typechecks, runs, and grants less
54
+ * than the source says.
55
+ */
56
+ export const stringArrayProperty = (config, name) => {
57
+ const value = getProperty(config, name);
58
+ if (!value || !ts.isArrayLiteralExpression(value)) {
59
+ return undefined;
60
+ }
61
+ const values = value.elements
62
+ .filter(ts.isStringLiteralLike)
63
+ .map((e) => e.text);
64
+ return values.length === value.elements.length ? values : undefined;
65
+ };
66
+ /** Unwraps `x as const` / `x satisfies T` so a cast is still extracted. */
67
+ export const unwrapAs = (node) => ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
68
+ ? unwrapAs(node.expression)
69
+ : node;
@@ -69,6 +69,25 @@ const applyPackageToChannelContracts = (contracts, packageName, namespace) => {
69
69
  }
70
70
  }
71
71
  };
72
+ /**
73
+ * Resolves an addon's generated metadata, preferring the verbose copy.
74
+ *
75
+ * An addon publishes both: the minimal one its own bootstrap imports, and the
76
+ * verbose one for anything that needs the fields stripped out of it. A consumer
77
+ * is squarely in the second category — it is reading the addon to describe it,
78
+ * not to run it.
79
+ */
80
+ const resolveAddonMeta = (require, packageName, baseName) => {
81
+ for (const suffix of ['-verbose.gen.json', '.gen.json']) {
82
+ try {
83
+ return require.resolve(`${packageName}/.pikku/${baseName}${suffix}`);
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ }
89
+ return null;
90
+ };
72
91
  /**
73
92
  * After the setup sweep discovers wireAddon() declarations, load each addon
74
93
  * package's function metadata so that wiring handlers (channels, HTTP routes,
@@ -86,7 +105,13 @@ export async function loadAddonFunctionsMeta(logger, state) {
86
105
  if (decl.remote)
87
106
  continue;
88
107
  try {
89
- const metaPath = require.resolve(`${decl.package}/.pikku/function/pikku-functions-meta.gen.json`);
108
+ // Verbose first. `description` is one of the fields stripped from the
109
+ // minimal copy, and it is what an addon's function is offered to a model
110
+ // under — both as an MCP tool below and as an agent tool. Read the minimal
111
+ // one and every addon function looks undescribed.
112
+ const metaPath = resolveAddonMeta(require, decl.package, 'function/pikku-functions-meta');
113
+ if (!metaPath)
114
+ throw new Error('no function metadata');
90
115
  const raw = await readFile(metaPath, 'utf-8');
91
116
  const meta = JSON.parse(raw);
92
117
  state.addonFunctions[namespace] = meta;
@@ -42,6 +42,14 @@ export function extractPermissionPikkuNames(node, checker, rootDir) {
42
42
  if (ts.isPropertyAssignment(prop)) {
43
43
  extractFromElement(prop.initializer);
44
44
  }
45
+ else if (ts.isShorthandPropertyAssignment(prop)) {
46
+ // `permissions: { canAdminOrg }`. The runtime resolves this exactly
47
+ // like the longhand form — `verifyPermissions` has a non-array branch —
48
+ // so dropping it here reported a guarded function as having no
49
+ // permissions at all, which is the most dangerous direction to be wrong
50
+ // in: an audit reading meta sees an open door where one is shut.
51
+ extractFromElement(prop.name);
52
+ }
45
53
  }
46
54
  }
47
55
  return names;