@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
@@ -59,9 +59,15 @@ node --import tsx src/benchmark.ts <target-dir> <iterations>
59
59
  | `wireMCPTool` | `addMCPTool` |
60
60
  | `wireMCPPrompt` | `addMCPPrompt` |
61
61
  | `wireWorkflowGraph` | `addWorkflowGraph` |
62
- | `wireSecret` | `addSecret` |
63
- | `wireOAuth2Credential` | `addOAuth2Credential` |
64
- | `wireVariable` | `addVariable` |
62
+ | `defineSecret` | `addSecret` |
63
+ | `defineCredential` | `addCredential` |
64
+ | `defineScope` | `addScope` |
65
+ | `defineVariable` | `addVariable` |
66
+
67
+ `addAuth` stays outside the map: it matches several identifiers
68
+ (`betterAuthStatelessSession`, `betterAuthSession`) and gates them on
69
+ surrounding context rather than on the identifier alone, so it keeps running on
70
+ every CallExpression.
65
71
 
66
72
  Fallback for unmatched identifiers: check `/pikku.*func/i` regex -> `addFunctions`.
67
73
 
@@ -19,7 +19,7 @@ export declare const AUTH_HANDLER_FUNC_ID = "authHandler";
19
19
  *
20
20
  * The pikku CLI discovers that single export and generates a catch-all
21
21
  * `auth.gen.ts` that wires `${basePath}/**` to one shared handler, registers the
22
- * better-auth session middleware, and emits a `wireSecret` for every configured
22
+ * better-auth session middleware, and emits a `defineSecret` for every configured
23
23
  * social provider — so the auth routes and secret requirements flow through
24
24
  * normal inspection into the deploy manifest.
25
25
  *
@@ -112,7 +112,7 @@ const isInsideGlobalMiddlewareRegistration = (node) => {
112
112
  *
113
113
  * The pikku CLI discovers that single export and generates a catch-all
114
114
  * `auth.gen.ts` that wires `${basePath}/**` to one shared handler, registers the
115
- * better-auth session middleware, and emits a `wireSecret` for every configured
115
+ * better-auth session middleware, and emits a `defineSecret` for every configured
116
116
  * social provider — so the auth routes and secret requirements flow through
117
117
  * normal inspection into the deploy manifest.
118
118
  *
@@ -99,6 +99,13 @@ function processCLIConfig(logger, node, sourceFile, typeChecker, inspectorState,
99
99
  case 'tags':
100
100
  // Already handled in first pass
101
101
  break;
102
+ case 'auth': {
103
+ const value = getPropertyValue(node, 'auth');
104
+ if (typeof value === 'boolean') {
105
+ programMeta.auth = value;
106
+ }
107
+ break;
108
+ }
102
109
  case 'commands':
103
110
  if (ts.isObjectLiteralExpression(prop.initializer)) {
104
111
  programMeta.commands = processCommands(logger, prop.initializer, sourceFile, typeChecker, programName, inspectorState, options, programTags);
@@ -9,7 +9,7 @@ export const addCredential = (logger, node, checker, state, _options) => {
9
9
  const args = node.arguments;
10
10
  const firstArg = args[0];
11
11
  const expression = node.expression;
12
- if (!ts.isIdentifier(expression) || expression.text !== 'wireCredential') {
12
+ if (!ts.isIdentifier(expression) || expression.text !== 'defineCredential') {
13
13
  return;
14
14
  }
15
15
  if (!firstArg) {
@@ -4,6 +4,24 @@ import { extractFunctionName, funcIdToTypeName, } from '../utils/extract-functio
4
4
  import { extractFunctionNode } from '../utils/extract-function-node.js';
5
5
  import { extractUsedWires } from '../utils/extract-services.js';
6
6
  import { formatVersionedId, parseVersionedId } from '@pikku/core';
7
+ /** Binding keys a `pikkuScenarioStep` may declare, in report order. */
8
+ const SCENARIO_SURFACE_KEYS = [
9
+ 'browser',
10
+ 'cli',
11
+ 'default',
12
+ ];
13
+ /**
14
+ * The step wrappers, and who acts in each.
15
+ *
16
+ * Which function was called *is* the kind. That is the whole reason these are
17
+ * three declarations rather than a `kind:` field on one: a string literal can be
18
+ * copied into an addon package by a consumer, a called identifier cannot.
19
+ */
20
+ const SCENARIO_STEP_KINDS = new Map([
21
+ ['pikkuScenarioStep', 'persona'],
22
+ ['pikkuPlatformScenarioStep', 'platform'],
23
+ ['pikkuAddonScenarioStep', 'addon'],
24
+ ]);
7
25
  import { getPropertyValue, getArrayPropertyValue, getCommonWireMetaData, } from '../utils/get-property-value.js';
8
26
  import { canonicalJSON, hashString } from '../utils/hash.js';
9
27
  import { resolveMiddleware } from '../utils/middleware.js';
@@ -11,6 +29,7 @@ import { resolvePermissions } from '../utils/permissions.js';
11
29
  import { extractWireNames } from '../utils/post-process.js';
12
30
  import { ErrorCode } from '../error-codes.js';
13
31
  import { findPiiPaths } from '../utils/check-pii-output.js';
32
+ import { findRevealedSecretSinks } from '../utils/check-secret-sinks.js';
14
33
  import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js';
15
34
  const isValidVariableName = (name) => {
16
35
  const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
@@ -261,6 +280,66 @@ function computeImplementationHash(args) {
261
280
  config: objectNode ? printNode(objectNode) : null,
262
281
  }));
263
282
  }
283
+ /**
284
+ * Registers a `pikkuRemoteChannelFunc` declaration: a name, its schemas and its
285
+ * description, with no body to inspect. The name has to reach `rpc.internalMeta`
286
+ * for `channel.remote` to resolve the schemas it validates each call against.
287
+ */
288
+ const addRemoteChannelFunc = ({ state, logger, node, pikkuFuncId, name, exportedName, inputSchemaRef, outputSchemaRef, meta, }) => {
289
+ const sourceFile = node.getSourceFile().fileName;
290
+ if (!exportedName) {
291
+ logger.error(`• Remote channel function '${name}' is not exported, this is not allowed.`);
292
+ return;
293
+ }
294
+ // Two files declaring the same capability is the collision that matters:
295
+ // both ids are the bare name, so the id-compatibility check below reads them
296
+ // as the same function and lets the second overwrite the first.
297
+ const existingFunction = state.functions.meta[pikkuFuncId];
298
+ if (existingFunction &&
299
+ existingFunction.sourceFile &&
300
+ existingFunction.sourceFile !== sourceFile) {
301
+ logger.critical(ErrorCode.DUPLICATE_FUNCTION_NAME, `Function name '${name}' is not unique. ` +
302
+ `'${pikkuFuncId}' is already defined in '${existingFunction.sourceFile}' and cannot be redefined in '${sourceFile}'.`);
303
+ return;
304
+ }
305
+ const existing = state.rpc.internalMeta[name];
306
+ if (existing && !areCompatibleFunctionIds(existing, pikkuFuncId)) {
307
+ logger.critical(ErrorCode.DUPLICATE_FUNCTION_NAME, `Function name '${name}' is not unique. ` +
308
+ `It already points to '${existing}' in '${resolveExistingFunctionSource(state, existing) || 'unknown file'}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`);
309
+ return;
310
+ }
311
+ const capitalizedName = funcIdToTypeName(name);
312
+ const registerSchema = (ref, suffix) => {
313
+ if (!ref)
314
+ return null;
315
+ const schemaName = `${capitalizedName}${suffix}`;
316
+ state.schemaLookup.set(schemaName, ref);
317
+ state.functions.typesMap.addCustomType(schemaName, 'unknown', []);
318
+ return schemaName;
319
+ };
320
+ const inputSchemaName = registerSchema(inputSchemaRef, 'Input');
321
+ const outputSchemaName = registerSchema(outputSchemaRef, 'Output');
322
+ state.functions.meta[pikkuFuncId] = {
323
+ pikkuFuncId,
324
+ functionType: 'remote',
325
+ funcWrapper: 'pikkuRemoteChannelFunc',
326
+ sessionless: true,
327
+ name,
328
+ services: { optimized: true, services: [] },
329
+ inputSchemaName,
330
+ outputSchemaName,
331
+ inputs: inputSchemaName ? [inputSchemaName] : [],
332
+ outputs: outputSchemaName ? [outputSchemaName] : [],
333
+ sourceFile,
334
+ exportedName,
335
+ ...meta,
336
+ };
337
+ state.functions.files.set(pikkuFuncId, { path: sourceFile, exportedName });
338
+ state.rpc.internalMeta[name] = pikkuFuncId;
339
+ // Imported so a local call gets the factory's "this runs on the client"
340
+ // error rather than a bare lookup miss.
341
+ state.rpc.internalFiles.set(pikkuFuncId, { path: sourceFile, exportedName });
342
+ };
264
343
  /**
265
344
  * Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
266
345
  * then push into state.functions.meta.
@@ -274,18 +353,19 @@ export const addFunctions = (logger, node, checker, state, options) => {
274
353
  return;
275
354
  }
276
355
  // Match identifiers that contain both "pikku" and "func" (case insensitive),
277
- // plus pikkuScenario and pikkuScenarioStep (workflow wrappers without "func"
278
- // in their names)
356
+ // plus pikkuScenario and the three scenario step wrappers (workflow wrappers
357
+ // without "func" in their names)
279
358
  const pikkuFuncPattern = /pikku.*func/i;
280
359
  if (!pikkuFuncPattern.test(expression.text) &&
281
360
  expression.text !== 'pikkuScenario' &&
282
- expression.text !== 'pikkuScenarioStep') {
361
+ !SCENARIO_STEP_KINDS.has(expression.text)) {
283
362
  return;
284
363
  }
285
364
  // A scenario step is bundled like any invoked function, but is deliberately
286
365
  // never RPC-registered: a step may drive a browser, so exposing it over the
287
366
  // network would put a test harness on the public API surface.
288
- const isScenarioStep = expression.text === 'pikkuScenarioStep';
367
+ const scenarioStepKind = SCENARIO_STEP_KINDS.get(expression.text);
368
+ const isScenarioStep = scenarioStepKind !== undefined;
289
369
  // A scenario's own body. Marked so the codegen partitions and the deploy
290
370
  // analyzer can tell it apart from an application workflow without having to
291
371
  // reach into the workflow graph.
@@ -316,6 +396,17 @@ export const addFunctions = (logger, node, checker, state, options) => {
316
396
  let expose;
317
397
  let remote;
318
398
  let mcp;
399
+ /**
400
+ * A sessionless function's own `auth: true`. `sessionless` records the
401
+ * baseline — a `pikkuFunc` always needs a session — while this records the
402
+ * tightening a `pikkuSessionlessFunc` applies to itself, which is otherwise
403
+ * invisible to anything reading meta.
404
+ */
405
+ let auth;
406
+ /** The author's claim that the body authorizes its own callers. */
407
+ let permissionsInBody;
408
+ /** The function's `audit` config, resolved to its object form. */
409
+ let audit;
319
410
  let readonly_;
320
411
  let deploy;
321
412
  let approvalRequired;
@@ -323,7 +414,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
323
414
  let workflowQueued;
324
415
  let workflowRetries;
325
416
  let workflowTimeout;
326
- let scenarioStepBrowser;
417
+ let scenarioStepSurfaces;
418
+ let scenarioStepAddon;
327
419
  let scenarioStepTemplate;
328
420
  let scopes;
329
421
  let version;
@@ -390,13 +482,74 @@ export const addFunctions = (logger, node, checker, state, options) => {
390
482
  expose = getPropertyValue(firstArg, 'expose');
391
483
  remote = getPropertyValue(firstArg, 'remote');
392
484
  mcp = getPropertyValue(firstArg, 'mcp');
485
+ auth = getPropertyValue(firstArg, 'auth');
486
+ permissionsInBody = getPropertyValue(firstArg, 'permissionsInBody');
487
+ if (permissionsInBody && !options.allow?.permissionsInBody) {
488
+ logger.critical(ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED, `'${name}' declares \`permissionsInBody: true\`, which this project has not enabled. ` +
489
+ `It silences the exposed-and-ungated audit without gating anything, so it is opt-in: ` +
490
+ `set \`allow: { permissionsInBody: true }\` in pikku.config.json if the project accepts ` +
491
+ `permission checks that only a reader can verify. Otherwise express the check as a ` +
492
+ `\`permissions\` entry, or drop \`expose: true\` if the function was not meant to be ` +
493
+ `callable from outside.`);
494
+ return;
495
+ }
496
+ // `audit` is either `true` or `{ durability }`, so it needs the nested read:
497
+ // getPropertyValue stringifies an object initializer rather than parsing it.
498
+ // Mirrors core's `resolveAuditConfig` — the two must agree, or meta would
499
+ // claim a durability the runner does not use.
500
+ const auditProp = firstArg.properties.find((p) => ts.isPropertyAssignment(p) &&
501
+ ts.isIdentifier(p.name) &&
502
+ p.name.text === 'audit');
503
+ if (auditProp && ts.isPropertyAssignment(auditProp)) {
504
+ const auditInit = auditProp.initializer;
505
+ if (auditInit.kind === ts.SyntaxKind.TrueKeyword) {
506
+ audit = { durability: 'best-effort' };
507
+ }
508
+ else if (ts.isObjectLiteralExpression(auditInit)) {
509
+ const durability = getPropertyValue(auditInit, 'durability');
510
+ audit = { durability: durability ?? 'best-effort' };
511
+ }
512
+ }
393
513
  readonly_ = getPropertyValue(firstArg, 'readonly');
394
514
  deploy = getPropertyValue(firstArg, 'deploy');
395
515
  approvalRequired = getPropertyValue(firstArg, 'approvalRequired');
396
516
  workflowQueued = getPropertyValue(firstArg, 'workflowQueued');
397
517
  workflowRetries = getPropertyValue(firstArg, 'workflowRetries');
398
518
  workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout');
399
- scenarioStepBrowser = getPropertyValue(firstArg, 'browser');
519
+ // A step declares one implementation per surface it supports; the binding
520
+ // keys that are present ARE the declaration, so there is nothing to read a
521
+ // value from — only names to collect.
522
+ const declaredSurfaces = SCENARIO_SURFACE_KEYS.filter((surface) => firstArg.properties.some((prop) => (ts.isPropertyAssignment(prop) ||
523
+ ts.isMethodDeclaration(prop) ||
524
+ ts.isShorthandPropertyAssignment(prop)) &&
525
+ ts.isIdentifier(prop.name) &&
526
+ prop.name.text === surface));
527
+ if (scenarioStepKind === 'platform' || scenarioStepKind === 'addon') {
528
+ // Neither kind has surfaces. Nobody clicks "Stripe's webhook arrives", and
529
+ // there is no human behind "the platform has expired the trial" — so both
530
+ // take a single `func`, and a binding key here is a coded error rather
531
+ // than a convention nobody reads.
532
+ if (declaredSurfaces.length > 0) {
533
+ logger.error(`• ${expression.text}('${name}') declares ${declaredSurfaces.join('/')} — a ${scenarioStepKind} step has one implementation, declared as \`func\`. Surfaces are how a person drives the system, and nobody drives this one.`);
534
+ return;
535
+ }
536
+ // The phase rule depends on it: an assertion runs every witness it has and
537
+ // fails if they disagree, so a step with exactly one witness by
538
+ // construction has to be unable to look like it has several.
539
+ scenarioStepSurfaces = ['default'];
540
+ if (scenarioStepKind === 'addon') {
541
+ scenarioStepAddon = getPropertyValue(firstArg, 'addon');
542
+ if (!scenarioStepAddon) {
543
+ logger.error(`• pikkuAddonScenarioStep('${name}') needs a string literal \`addon\` naming the addon whose system acts — the same name its wireAddon declares.`);
544
+ return;
545
+ }
546
+ }
547
+ }
548
+ else {
549
+ scenarioStepSurfaces = declaredSurfaces.length
550
+ ? declaredSurfaces
551
+ : undefined;
552
+ }
400
553
  scenarioStepTemplate = getPropertyValue(firstArg, 'template');
401
554
  scopes = getArrayPropertyValue(firstArg, 'scopes') ?? undefined;
402
555
  // Extract approvalDescription identifier reference
@@ -488,6 +641,23 @@ export const addFunctions = (logger, node, checker, state, options) => {
488
641
  }
489
642
  pikkuFuncId = formatVersionedId(baseName, version);
490
643
  }
644
+ // A remote channel capability is a contract with no body: the connected
645
+ // client answers it. Everything past this point reads the handler, so it gets
646
+ // its own path rather than a nullable handler threaded through all of it.
647
+ if (expression.text === 'pikkuRemoteChannelFunc') {
648
+ addRemoteChannelFunc({
649
+ state,
650
+ logger,
651
+ node,
652
+ pikkuFuncId,
653
+ name,
654
+ exportedName,
655
+ inputSchemaRef,
656
+ outputSchemaRef,
657
+ meta: { version, title, tags, summary, description, errors },
658
+ });
659
+ return;
660
+ }
491
661
  const isMCPToolFunc = expression.text === 'pikkuMCPToolFunc';
492
662
  const isListFunc = expression.text === 'pikkuListFunc';
493
663
  const mcpEnabled = mcp || isMCPToolFunc;
@@ -706,6 +876,18 @@ export const addFunctions = (logger, node, checker, state, options) => {
706
876
  // it runs ONLY when explicitly requested (`pikku all --security`) — see the
707
877
  // classificationCheck option. Default codegen skips it entirely.
708
878
  if (options.classificationCheck) {
879
+ // A revealed vault secret is invisible to the return-type scan below when it
880
+ // is written out mid-body rather than returned, so walk the sinks too.
881
+ for (const violation of findRevealedSecretSinks(checker, handler)) {
882
+ logger.diagnostic({
883
+ severity: 'error',
884
+ code: ErrorCode.SECRET_REVEALED_INTO_SINK,
885
+ message: `Function '${name}' passes a revealed secret to '${violation.sink}' ` +
886
+ `at line ${violation.line} (path '${violation.path}').\n ` +
887
+ `A vault secret must not be written to a log, an audit, a queue, an email or a webhook. ` +
888
+ `Pass the value only to the client that needs it, and reveal it there.`,
889
+ });
890
+ }
709
891
  const sig = checker.getSignatureFromDeclaration(handler);
710
892
  if (sig) {
711
893
  const rawRet = checker.getReturnTypeOfSignature(sig);
@@ -734,7 +916,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
734
916
  message: `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
735
917
  privatePaths.map((p) => `'${p}'`).join(', ') +
736
918
  `.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
737
- `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`,
919
+ `Either require a session (use pikkuFunc) or set the column's security to 'public' in db/annotations.ts if it is safe to expose publicly.`,
738
920
  });
739
921
  }
740
922
  }
@@ -795,6 +977,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
795
977
  inputs: inputNames.filter((n) => n !== 'void') ?? null,
796
978
  outputs: outputNames.filter((n) => n !== 'void') ?? null,
797
979
  expose: expose || undefined,
980
+ auth: auth || undefined,
981
+ permissionsInBody: permissionsInBody || undefined,
982
+ audit,
798
983
  remote: remote || undefined,
799
984
  scenarioStep: isScenarioStep || undefined,
800
985
  scenario: isScenario || undefined,
@@ -806,7 +991,13 @@ export const addFunctions = (logger, node, checker, state, options) => {
806
991
  workflowQueued: workflowQueued === true ? true : undefined,
807
992
  workflowRetries: workflowRetries ?? undefined,
808
993
  workflowTimeout: workflowTimeout ?? undefined,
809
- scenarioStepBrowser: scenarioStepBrowser === true ? true : undefined,
994
+ scenarioStepSurfaces,
995
+ // `persona` is the default reading of an unmarked step, so it is left off
996
+ // rather than written into every step meta a project already has.
997
+ scenarioStepKind: scenarioStepKind && scenarioStepKind !== 'persona'
998
+ ? scenarioStepKind
999
+ : undefined,
1000
+ scenarioStepAddon,
810
1001
  scenarioStepTemplate: scenarioStepTemplate || undefined,
811
1002
  scopes: scopes ?? undefined,
812
1003
  implementationHash,
@@ -145,17 +145,23 @@ export function registerHTTPRoute({ obj, state, checker, logger, sourceFile, bas
145
145
  inlineMeta.sessionless = targetMeta.sessionless;
146
146
  }
147
147
  }
148
+ // The route's own `auth`, falling back to the group's. Recorded on the meta
149
+ // below so an audit can answer "is this route open?" without re-reading the
150
+ // source: the route can tighten a sessionless function, and until this was
151
+ // written down that tightening existed only in the wiring.
152
+ const routeAuthProp = getPropertyValue(obj, 'auth');
153
+ const resolvedAuth = routeAuthProp === true || routeAuthProp === false
154
+ ? routeAuthProp
155
+ : inheritedAuth;
148
156
  // For inline functions (e.g. oauth2 handlers), propagate sessionless: true
149
157
  // when auth is explicitly disabled at the route or group level — the
150
158
  // developer opted out of auth so no session is required.
151
159
  {
152
160
  const inlineMeta = state.functions.meta[funcName];
153
- if (inlineMeta && inlineMeta.sessionless === undefined) {
154
- const routeAuth = getPropertyValue(obj, 'auth');
155
- const resolvedAuth = routeAuth === true || routeAuth === false ? routeAuth : inheritedAuth;
156
- if (resolvedAuth === false) {
157
- inlineMeta.sessionless = true;
158
- }
161
+ if (inlineMeta &&
162
+ inlineMeta.sessionless === undefined &&
163
+ resolvedAuth === false) {
164
+ inlineMeta.sessionless = true;
159
165
  }
160
166
  }
161
167
  // Lookup existing function metadata
@@ -239,6 +245,7 @@ export function registerHTTPRoute({ obj, state, checker, logger, sourceFile, bas
239
245
  route: fullRoute,
240
246
  sourceFile: sourceFile.fileName,
241
247
  method: method,
248
+ ...(resolvedAuth !== undefined && { auth: resolvedAuth }),
242
249
  params: params.length > 0 ? params : undefined,
243
250
  query: query.length > 0 ? query : undefined,
244
251
  inputTypes: undefined,
@@ -284,6 +291,9 @@ export function registerHTTPRouteMeta({ route, state, logger, sourceFile, basePa
284
291
  route: fullRoute,
285
292
  sourceFile: sourceFile.fileName,
286
293
  method: method,
294
+ ...((route.auth ?? inheritedAuth) !== undefined && {
295
+ auth: route.auth ?? inheritedAuth,
296
+ }),
287
297
  params: params.length > 0 ? params : undefined,
288
298
  inputTypes: undefined,
289
299
  tags: tags.length > 0 ? tags : undefined,
@@ -4,6 +4,49 @@ import { extractServicesFromFunction, extractUsedWires, } from '../utils/extract
4
4
  import { extractMiddlewareRefs } from '../utils/middleware.js';
5
5
  import { getPropertyValue } from '../utils/get-property-value.js';
6
6
  import { getPropertyAssignmentInitializer } from '../utils/type-utils.js';
7
+ /**
8
+ * Record a middleware group, keeping any registration already stored under the
9
+ * same pattern or tag.
10
+ *
11
+ * Several files may add middleware for one pattern — an app's own `'*'` group
12
+ * alongside the generated better-auth session bridge, say — and the runtime
13
+ * composes them. A plain `set` kept only the last, and codegen emits imports
14
+ * from what is stored, so the other file was never imported and its middleware
15
+ * silently stopped running. Merging is the only shape that cannot lose a gate;
16
+ * the first registration stays primary so single-registration groups serialize
17
+ * exactly as before.
18
+ */
19
+ function recordMiddlewareGroup(groups, key, group) {
20
+ const existing = groups.get(key);
21
+ if (!existing) {
22
+ groups.set(key, group);
23
+ return;
24
+ }
25
+ // The same call seen twice — the inspector may revisit a file — is not a
26
+ // second registration, and appending it would emit a duplicate import.
27
+ const alreadyRecorded = [
28
+ existing,
29
+ ...(existing.additionalRegistrations ?? []),
30
+ ];
31
+ if (alreadyRecorded.some((r) => r.sourceFile === group.sourceFile && r.position === group.position)) {
32
+ return;
33
+ }
34
+ existing.additionalRegistrations = [
35
+ ...(existing.additionalRegistrations ?? []),
36
+ {
37
+ exportName: group.exportName,
38
+ sourceFile: group.sourceFile,
39
+ position: group.position,
40
+ isFactory: group.isFactory,
41
+ },
42
+ ];
43
+ existing.count += group.count;
44
+ existing.instanceIds = [...existing.instanceIds, ...group.instanceIds];
45
+ existing.services = {
46
+ optimized: false,
47
+ services: Array.from(new Set([...existing.services.services, ...group.services.services])),
48
+ };
49
+ }
7
50
  function renameTempDefinitions(state, definitionIds, groupType, groupKey, storeKey = 'middleware') {
8
51
  const tempIndices = definitionIds
9
52
  .map((name, i) => (name.startsWith('__temp_') ? i : -1))
@@ -238,7 +281,7 @@ export const addMiddleware = (logger, node, checker, state) => {
238
281
  logger.warn(`• Middleware group '${exportedName}' for tag '${tag}' is not wrapped in a factory function. ` +
239
282
  `For tree-shaking, use: export const ${exportedName} = () => addTagMiddleware('${tag}', [...])`);
240
283
  }
241
- state.middleware.tagMiddleware.set(tag, {
284
+ recordMiddlewareGroup(state.middleware.tagMiddleware, tag, {
242
285
  exportName: exportedName,
243
286
  sourceFile,
244
287
  position: node.getStart(),
@@ -348,7 +391,7 @@ export const addMiddleware = (logger, node, checker, state) => {
348
391
  logger.warn(`• HTTP middleware group '${exportedName}' for pattern '${pattern}' is not wrapped in a factory function. ` +
349
392
  `For tree-shaking, use: export const ${exportedName} = () => addHTTPMiddleware('${pattern}', [...])`);
350
393
  }
351
- state.http.routeMiddleware.set(pattern, {
394
+ recordMiddlewareGroup(state.http.routeMiddleware, pattern, {
352
395
  exportName: exportedName,
353
396
  sourceFile,
354
397
  position: node.getStart(),
@@ -582,7 +625,7 @@ export const addMiddleware = (logger, node, checker, state) => {
582
625
  logger.warn(`• Channel middleware group '${exportedName}' for tag '${tag}' is not wrapped in a factory function. ` +
583
626
  `For tree-shaking, use: export const ${exportedName} = () => addChannelMiddleware('${tag}', [...])`);
584
627
  }
585
- state.channelMiddleware.tagMiddleware.set(tag, {
628
+ recordMiddlewareGroup(state.channelMiddleware.tagMiddleware, tag, {
586
629
  exportName: exportedName,
587
630
  sourceFile,
588
631
  position: node.getStart(),
@@ -0,0 +1,13 @@
1
+ import type { AddWiring } from '../types.js';
2
+ /**
3
+ * Inspector for `definePersonas()` calls.
4
+ *
5
+ * A persona is entirely declaration — a person, their roles, what they want —
6
+ * so there is no code body and nothing to resolve at runtime. What is read here
7
+ * is the whole thing, and it is generated straight to `personas.gen.json` for
8
+ * the CLI and the console to read without loading the app.
9
+ *
10
+ * `name` is the one field that cannot be defaulted. A persona without one is
11
+ * not a person, and the console has nothing to show.
12
+ */
13
+ export declare const addPersonas: AddWiring;