@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
@@ -1,6 +1,6 @@
1
1
  import { strict as assert } from 'assert'
2
2
  import { describe, test } from 'node:test'
3
- import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { inspect } from '../inspector.js'
@@ -381,3 +381,265 @@ describe('pikkuChannelConnectionFunc generic mapping', () => {
381
381
  }
382
382
  })
383
383
  })
384
+
385
+ describe('pikkuRemoteChannelFunc', () => {
386
+ const quietLogger = (): InspectorLogger & {
387
+ errors: string[]
388
+ criticals: Array<{ code: ErrorCode; message: string }>
389
+ } => {
390
+ const errors: string[] = []
391
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
392
+ return {
393
+ debug: () => {},
394
+ info: () => {},
395
+ warn: () => {},
396
+ error: (message: string) => {
397
+ errors.push(message)
398
+ },
399
+ diagnostic: ({ code, message }) => {
400
+ criticals.push({ code, message })
401
+ },
402
+ critical: (code: ErrorCode, message: string) => {
403
+ criticals.push({ code, message })
404
+ },
405
+ hasCriticalErrors: () => criticals.length > 0,
406
+ errors,
407
+ criticals,
408
+ } as any
409
+ }
410
+
411
+ /**
412
+ * The vendor check keys off the declaration file's path, so a stub under
413
+ * `node_modules/zod` is indistinguishable from the real thing and costs this
414
+ * suite no dependency.
415
+ */
416
+ const writeZodStub = async (rootDir: string) => {
417
+ const dir = join(rootDir, 'node_modules', 'zod')
418
+ await mkdir(dir, { recursive: true })
419
+ await writeFile(
420
+ join(dir, 'package.json'),
421
+ JSON.stringify({ name: 'zod', version: '3.0.0', types: 'index.d.ts' })
422
+ )
423
+ await writeFile(
424
+ join(dir, 'index.d.ts'),
425
+ [
426
+ 'export interface ZodType<Output = unknown> {',
427
+ ' _output: Output',
428
+ '}',
429
+ 'export declare const z: {',
430
+ ' object(shape: Record<string, ZodType>): ZodType<Record<string, unknown>>',
431
+ ' string(): ZodType<string>',
432
+ '}',
433
+ '',
434
+ ].join('\n')
435
+ )
436
+ }
437
+
438
+ const DECLARE_WRAPPER = [
439
+ "import { z } from 'zod'",
440
+ 'declare function pikkuRemoteChannelFunc(config: any): any',
441
+ 'declare function pikkuSessionlessFunc(config: any): any',
442
+ ]
443
+
444
+ const withProject = async (
445
+ label: string,
446
+ files: Record<string, string[]>,
447
+ assertions: (
448
+ state: Awaited<ReturnType<typeof inspect>>,
449
+ logger: ReturnType<typeof quietLogger>
450
+ ) => void
451
+ ) => {
452
+ const rootDir = await mkdtemp(join(tmpdir(), `pikku-${label}-`))
453
+ await writeZodStub(rootDir)
454
+ const paths: string[] = []
455
+ for (const [name, lines] of Object.entries(files)) {
456
+ const path = join(rootDir, name)
457
+ await writeFile(path, [...DECLARE_WRAPPER, ...lines, ''].join('\n'))
458
+ paths.push(path)
459
+ }
460
+ const logger = quietLogger()
461
+ try {
462
+ assertions(await inspect(logger, paths, { rootDir }), logger)
463
+ } finally {
464
+ await rm(rootDir, { recursive: true, force: true })
465
+ }
466
+ }
467
+
468
+ test('a declaration with no func is registered rather than rejected', async () => {
469
+ await withProject(
470
+ 'remote-channel-registers',
471
+ {
472
+ 'capabilities.ts': [
473
+ 'export const localCheckoutOutput = z.object({ sha: z.string() })',
474
+ 'export const localCheckout = pikkuRemoteChannelFunc({',
475
+ " title: 'localCheckout',",
476
+ " description: 'Read the current commit of your working tree',",
477
+ ' output: localCheckoutOutput,',
478
+ '})',
479
+ ],
480
+ },
481
+ (state, logger) => {
482
+ // The ordinary path logs "No valid 'func' property" for a config with
483
+ // no callable func, which is what a contract-only declaration is.
484
+ assert.deepStrictEqual(logger.errors, [])
485
+ assert.deepStrictEqual(logger.criticals, [])
486
+
487
+ const meta = state.functions.meta['localCheckout']
488
+ assert.ok(meta, 'the declaration should be registered')
489
+ assert.strictEqual(meta!.functionType, 'remote')
490
+ assert.strictEqual(meta!.funcWrapper, 'pikkuRemoteChannelFunc')
491
+ assert.strictEqual(
492
+ meta!.description,
493
+ 'Read the current commit of your working tree'
494
+ )
495
+ }
496
+ )
497
+ })
498
+
499
+ test('the declared schemas are what each side gets checked against', async () => {
500
+ await withProject(
501
+ 'remote-channel-schemas',
502
+ {
503
+ 'capabilities.ts': [
504
+ 'export const runCommandInput = z.object({ argv: z.string() })',
505
+ 'export const runCommandOutput = z.object({ stdout: z.string() })',
506
+ 'export const runCommand = pikkuRemoteChannelFunc({',
507
+ ' input: runCommandInput,',
508
+ ' output: runCommandOutput,',
509
+ '})',
510
+ ],
511
+ },
512
+ (state) => {
513
+ const meta = state.functions.meta['runCommand']
514
+ assert.strictEqual(meta!.inputSchemaName, 'RunCommandInput')
515
+ assert.strictEqual(meta!.outputSchemaName, 'RunCommandOutput')
516
+ assert.deepStrictEqual(meta!.outputs, ['RunCommandOutput'])
517
+ assert.strictEqual(
518
+ state.schemaLookup.get('RunCommandOutput')?.variableName,
519
+ 'runCommandOutput'
520
+ )
521
+ }
522
+ )
523
+ })
524
+
525
+ test('the name resolves through the RPC map, which is what channel.remote reads', async () => {
526
+ await withProject(
527
+ 'remote-channel-rpc-map',
528
+ {
529
+ 'capabilities.ts': [
530
+ 'export const localCheckoutOutput = z.object({ sha: z.string() })',
531
+ 'export const localCheckout = pikkuRemoteChannelFunc({',
532
+ ' output: localCheckoutOutput,',
533
+ '})',
534
+ ],
535
+ },
536
+ (state) => {
537
+ // Both validators look the name up here and skip validation on a miss,
538
+ // so an unregistered name silently disables the schema check.
539
+ assert.strictEqual(
540
+ state.rpc.internalMeta['localCheckout'],
541
+ 'localCheckout'
542
+ )
543
+ // Imported, so a local call reaches the factory's thrower rather than
544
+ // missing the registry.
545
+ assert.strictEqual(
546
+ state.rpc.internalFiles.get('localCheckout')?.exportedName,
547
+ 'localCheckout'
548
+ )
549
+ }
550
+ )
551
+ })
552
+
553
+ test('an unexported declaration is skipped, the way any unexported function is', async () => {
554
+ await withProject(
555
+ 'remote-channel-unexported',
556
+ {
557
+ 'capabilities.ts': [
558
+ 'const localCheckoutOutput = z.object({ sha: z.string() })',
559
+ 'const localCheckout = pikkuRemoteChannelFunc({',
560
+ ' output: localCheckoutOutput,',
561
+ '})',
562
+ 'void localCheckout',
563
+ ],
564
+ },
565
+ (state) => {
566
+ // No export and no `override` leaves nothing to name it by, so it is
567
+ // dropped upstream and never reaches the remote-channel path.
568
+ assert.strictEqual(state.rpc.internalMeta['localCheckout'], undefined)
569
+ assert.strictEqual(state.functions.meta['localCheckout'], undefined)
570
+ }
571
+ )
572
+ })
573
+
574
+ test('a name claimed by override still has to be exported for the server to import it', async () => {
575
+ await withProject(
576
+ 'remote-channel-override-unexported',
577
+ {
578
+ 'capabilities.ts': [
579
+ 'const localCheckoutOutput = z.object({ sha: z.string() })',
580
+ 'const localCheckout = pikkuRemoteChannelFunc({',
581
+ " override: 'localCheckout',",
582
+ ' output: localCheckoutOutput,',
583
+ '})',
584
+ 'void localCheckout',
585
+ ],
586
+ },
587
+ (state, logger) => {
588
+ assert.strictEqual(
589
+ logger.errors.length,
590
+ 1,
591
+ `expected one error, got ${JSON.stringify(logger.errors)}`
592
+ )
593
+ assert.match(logger.errors[0]!, /not exported/)
594
+ assert.strictEqual(state.rpc.internalMeta['localCheckout'], undefined)
595
+ }
596
+ )
597
+ })
598
+
599
+ test('two capabilities cannot share a name', async () => {
600
+ await withProject(
601
+ 'remote-channel-duplicate',
602
+ {
603
+ 'a.ts': [
604
+ 'export const aOutput = z.object({ sha: z.string() })',
605
+ 'export const localCheckout = pikkuRemoteChannelFunc({',
606
+ ' output: aOutput,',
607
+ '})',
608
+ ],
609
+ 'b.ts': [
610
+ 'export const bOutput = z.object({ sha: z.string() })',
611
+ 'export const localCheckout = pikkuRemoteChannelFunc({',
612
+ ' output: bOutput,',
613
+ '})',
614
+ ],
615
+ },
616
+ (_state, logger) => {
617
+ const collision = logger.criticals.find(
618
+ (entry) => entry.code === ErrorCode.DUPLICATE_FUNCTION_NAME
619
+ )
620
+ assert.ok(collision, 'a name collision should be reported')
621
+ assert.match(collision!.message, /not unique/)
622
+ }
623
+ )
624
+ })
625
+
626
+ test('the ordinary path still rejects a config with no callable func', async () => {
627
+ await withProject(
628
+ 'remote-channel-ordinary-path',
629
+ {
630
+ 'capabilities.ts': [
631
+ 'export const broken = pikkuSessionlessFunc({ auth: false })',
632
+ ],
633
+ },
634
+ (state, logger) => {
635
+ assert.strictEqual(
636
+ logger.errors.length,
637
+ 1,
638
+ `expected one error, got ${JSON.stringify(logger.errors)}`
639
+ )
640
+ assert.match(logger.errors[0]!, /No valid 'func' property/)
641
+ assert.strictEqual(state.functions.meta['broken']?.functionType, 'user')
642
+ }
643
+ )
644
+ })
645
+ })
@@ -8,8 +8,29 @@ import {
8
8
  } from '../utils/extract-function-name.js'
9
9
  import { extractFunctionNode } from '../utils/extract-function-node.js'
10
10
  import { extractUsedWires } from '../utils/extract-services.js'
11
- import type { FunctionServicesMeta } from '@pikku/core'
11
+ import type { AuditDurability, FunctionServicesMeta } from '@pikku/core'
12
+ import type { ScenarioStepKind, ScenarioSurface } from '@pikku/core/workflow'
12
13
  import { formatVersionedId, parseVersionedId } from '@pikku/core'
14
+
15
+ /** Binding keys a `pikkuScenarioStep` may declare, in report order. */
16
+ const SCENARIO_SURFACE_KEYS: readonly ScenarioSurface[] = [
17
+ 'browser',
18
+ 'cli',
19
+ 'default',
20
+ ]
21
+
22
+ /**
23
+ * The step wrappers, and who acts in each.
24
+ *
25
+ * Which function was called *is* the kind. That is the whole reason these are
26
+ * three declarations rather than a `kind:` field on one: a string literal can be
27
+ * copied into an addon package by a consumer, a called identifier cannot.
28
+ */
29
+ const SCENARIO_STEP_KINDS = new Map<string, ScenarioStepKind>([
30
+ ['pikkuScenarioStep', 'persona'],
31
+ ['pikkuPlatformScenarioStep', 'platform'],
32
+ ['pikkuAddonScenarioStep', 'addon'],
33
+ ])
13
34
  import {
14
35
  getPropertyValue,
15
36
  getArrayPropertyValue,
@@ -21,6 +42,7 @@ import { resolvePermissions } from '../utils/permissions.js'
21
42
  import { extractWireNames } from '../utils/post-process.js'
22
43
  import { ErrorCode } from '../error-codes.js'
23
44
  import { findPiiPaths } from '../utils/check-pii-output.js'
45
+ import { findRevealedSecretSinks } from '../utils/check-secret-sinks.js'
24
46
  import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js'
25
47
  import type { NodeType } from '@pikku/core/node'
26
48
 
@@ -366,6 +388,112 @@ function computeImplementationHash(args: {
366
388
  )
367
389
  }
368
390
 
391
+ /**
392
+ * Registers a `pikkuRemoteChannelFunc` declaration: a name, its schemas and its
393
+ * description, with no body to inspect. The name has to reach `rpc.internalMeta`
394
+ * for `channel.remote` to resolve the schemas it validates each call against.
395
+ */
396
+ const addRemoteChannelFunc = ({
397
+ state,
398
+ logger,
399
+ node,
400
+ pikkuFuncId,
401
+ name,
402
+ exportedName,
403
+ inputSchemaRef,
404
+ outputSchemaRef,
405
+ meta,
406
+ }: {
407
+ state: InspectorState
408
+ logger: Parameters<AddWiring>[0]
409
+ node: ts.CallExpression
410
+ pikkuFuncId: string
411
+ name: string
412
+ exportedName?: string | null
413
+ inputSchemaRef: SchemaRef | null
414
+ outputSchemaRef: SchemaRef | null
415
+ meta: {
416
+ version?: number
417
+ title?: string
418
+ tags?: string[]
419
+ summary?: string
420
+ description?: string
421
+ errors?: string[]
422
+ }
423
+ }) => {
424
+ const sourceFile = node.getSourceFile().fileName
425
+
426
+ if (!exportedName) {
427
+ logger.error(
428
+ `• Remote channel function '${name}' is not exported, this is not allowed.`
429
+ )
430
+ return
431
+ }
432
+
433
+ // Two files declaring the same capability is the collision that matters:
434
+ // both ids are the bare name, so the id-compatibility check below reads them
435
+ // as the same function and lets the second overwrite the first.
436
+ const existingFunction = state.functions.meta[pikkuFuncId]
437
+ if (
438
+ existingFunction &&
439
+ existingFunction.sourceFile &&
440
+ existingFunction.sourceFile !== sourceFile
441
+ ) {
442
+ logger.critical(
443
+ ErrorCode.DUPLICATE_FUNCTION_NAME,
444
+ `Function name '${name}' is not unique. ` +
445
+ `'${pikkuFuncId}' is already defined in '${existingFunction.sourceFile}' and cannot be redefined in '${sourceFile}'.`
446
+ )
447
+ return
448
+ }
449
+
450
+ const existing = state.rpc.internalMeta[name]
451
+ if (existing && !areCompatibleFunctionIds(existing, pikkuFuncId)) {
452
+ logger.critical(
453
+ ErrorCode.DUPLICATE_FUNCTION_NAME,
454
+ `Function name '${name}' is not unique. ` +
455
+ `It already points to '${existing}' in '${resolveExistingFunctionSource(state, existing) || 'unknown file'}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`
456
+ )
457
+ return
458
+ }
459
+
460
+ const capitalizedName = funcIdToTypeName(name)
461
+ const registerSchema = (
462
+ ref: SchemaRef | null,
463
+ suffix: 'Input' | 'Output'
464
+ ) => {
465
+ if (!ref) return null
466
+ const schemaName = `${capitalizedName}${suffix}`
467
+ state.schemaLookup.set(schemaName, ref)
468
+ state.functions.typesMap.addCustomType(schemaName, 'unknown', [])
469
+ return schemaName
470
+ }
471
+ const inputSchemaName = registerSchema(inputSchemaRef, 'Input')
472
+ const outputSchemaName = registerSchema(outputSchemaRef, 'Output')
473
+
474
+ state.functions.meta[pikkuFuncId] = {
475
+ pikkuFuncId,
476
+ functionType: 'remote',
477
+ funcWrapper: 'pikkuRemoteChannelFunc',
478
+ sessionless: true,
479
+ name,
480
+ services: { optimized: true, services: [] },
481
+ inputSchemaName,
482
+ outputSchemaName,
483
+ inputs: inputSchemaName ? [inputSchemaName] : [],
484
+ outputs: outputSchemaName ? [outputSchemaName] : [],
485
+ sourceFile,
486
+ exportedName,
487
+ ...meta,
488
+ }
489
+
490
+ state.functions.files.set(pikkuFuncId, { path: sourceFile, exportedName })
491
+ state.rpc.internalMeta[name] = pikkuFuncId
492
+ // Imported so a local call gets the factory's "this runs on the client"
493
+ // error rather than a bare lookup miss.
494
+ state.rpc.internalFiles.set(pikkuFuncId, { path: sourceFile, exportedName })
495
+ }
496
+
369
497
  /**
370
498
  * Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
371
499
  * then push into state.functions.meta.
@@ -387,13 +515,13 @@ export const addFunctions: AddWiring = (
387
515
  }
388
516
 
389
517
  // Match identifiers that contain both "pikku" and "func" (case insensitive),
390
- // plus pikkuScenario and pikkuScenarioStep (workflow wrappers without "func"
391
- // in their names)
518
+ // plus pikkuScenario and the three scenario step wrappers (workflow wrappers
519
+ // without "func" in their names)
392
520
  const pikkuFuncPattern = /pikku.*func/i
393
521
  if (
394
522
  !pikkuFuncPattern.test(expression.text) &&
395
523
  expression.text !== 'pikkuScenario' &&
396
- expression.text !== 'pikkuScenarioStep'
524
+ !SCENARIO_STEP_KINDS.has(expression.text)
397
525
  ) {
398
526
  return
399
527
  }
@@ -401,7 +529,8 @@ export const addFunctions: AddWiring = (
401
529
  // A scenario step is bundled like any invoked function, but is deliberately
402
530
  // never RPC-registered: a step may drive a browser, so exposing it over the
403
531
  // network would put a test harness on the public API surface.
404
- const isScenarioStep = expression.text === 'pikkuScenarioStep'
532
+ const scenarioStepKind = SCENARIO_STEP_KINDS.get(expression.text)
533
+ const isScenarioStep = scenarioStepKind !== undefined
405
534
 
406
535
  // A scenario's own body. Marked so the codegen partitions and the deploy
407
536
  // analyzer can tell it apart from an application workflow without having to
@@ -444,6 +573,17 @@ export const addFunctions: AddWiring = (
444
573
  let expose: boolean | undefined
445
574
  let remote: boolean | undefined
446
575
  let mcp: boolean | undefined
576
+ /**
577
+ * A sessionless function's own `auth: true`. `sessionless` records the
578
+ * baseline — a `pikkuFunc` always needs a session — while this records the
579
+ * tightening a `pikkuSessionlessFunc` applies to itself, which is otherwise
580
+ * invisible to anything reading meta.
581
+ */
582
+ let auth: boolean | undefined
583
+ /** The author's claim that the body authorizes its own callers. */
584
+ let permissionsInBody: boolean | undefined
585
+ /** The function's `audit` config, resolved to its object form. */
586
+ let audit: { durability: AuditDurability } | undefined
447
587
  let readonly_: boolean | undefined
448
588
  let deploy: 'serverless' | 'server' | 'auto' | undefined
449
589
  let approvalRequired: boolean | undefined
@@ -451,7 +591,8 @@ export const addFunctions: AddWiring = (
451
591
  let workflowQueued: boolean | undefined
452
592
  let workflowRetries: number | undefined
453
593
  let workflowTimeout: string | undefined
454
- let scenarioStepBrowser: boolean | undefined
594
+ let scenarioStepSurfaces: ScenarioSurface[] | undefined
595
+ let scenarioStepAddon: string | undefined
455
596
  let scenarioStepTemplate: string | undefined
456
597
  let scopes: string[] | undefined
457
598
  let version: number | undefined
@@ -540,6 +681,44 @@ export const addFunctions: AddWiring = (
540
681
  expose = getPropertyValue(firstArg, 'expose') as boolean | undefined
541
682
  remote = getPropertyValue(firstArg, 'remote') as boolean | undefined
542
683
  mcp = getPropertyValue(firstArg, 'mcp') as boolean | undefined
684
+ auth = getPropertyValue(firstArg, 'auth') as boolean | undefined
685
+ permissionsInBody = getPropertyValue(firstArg, 'permissionsInBody') as
686
+ | boolean
687
+ | undefined
688
+ if (permissionsInBody && !options.allow?.permissionsInBody) {
689
+ logger.critical(
690
+ ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED,
691
+ `'${name}' declares \`permissionsInBody: true\`, which this project has not enabled. ` +
692
+ `It silences the exposed-and-ungated audit without gating anything, so it is opt-in: ` +
693
+ `set \`allow: { permissionsInBody: true }\` in pikku.config.json if the project accepts ` +
694
+ `permission checks that only a reader can verify. Otherwise express the check as a ` +
695
+ `\`permissions\` entry, or drop \`expose: true\` if the function was not meant to be ` +
696
+ `callable from outside.`
697
+ )
698
+ return
699
+ }
700
+ // `audit` is either `true` or `{ durability }`, so it needs the nested read:
701
+ // getPropertyValue stringifies an object initializer rather than parsing it.
702
+ // Mirrors core's `resolveAuditConfig` — the two must agree, or meta would
703
+ // claim a durability the runner does not use.
704
+ const auditProp = firstArg.properties.find(
705
+ (p) =>
706
+ ts.isPropertyAssignment(p) &&
707
+ ts.isIdentifier(p.name) &&
708
+ p.name.text === 'audit'
709
+ )
710
+ if (auditProp && ts.isPropertyAssignment(auditProp)) {
711
+ const auditInit = auditProp.initializer
712
+ if (auditInit.kind === ts.SyntaxKind.TrueKeyword) {
713
+ audit = { durability: 'best-effort' }
714
+ } else if (ts.isObjectLiteralExpression(auditInit)) {
715
+ const durability = getPropertyValue(
716
+ auditInit,
717
+ 'durability'
718
+ ) as AuditDurability | null
719
+ audit = { durability: durability ?? 'best-effort' }
720
+ }
721
+ }
543
722
  readonly_ = getPropertyValue(firstArg, 'readonly') as boolean | undefined
544
723
  deploy = getPropertyValue(firstArg, 'deploy') as
545
724
  | 'serverless'
@@ -558,9 +737,50 @@ export const addFunctions: AddWiring = (
558
737
  workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout') as
559
738
  | string
560
739
  | undefined
561
- scenarioStepBrowser = getPropertyValue(firstArg, 'browser') as
562
- | boolean
563
- | undefined
740
+ // A step declares one implementation per surface it supports; the binding
741
+ // keys that are present ARE the declaration, so there is nothing to read a
742
+ // value from — only names to collect.
743
+ const declaredSurfaces = SCENARIO_SURFACE_KEYS.filter((surface) =>
744
+ firstArg.properties.some(
745
+ (prop) =>
746
+ (ts.isPropertyAssignment(prop) ||
747
+ ts.isMethodDeclaration(prop) ||
748
+ ts.isShorthandPropertyAssignment(prop)) &&
749
+ ts.isIdentifier(prop.name) &&
750
+ prop.name.text === surface
751
+ )
752
+ )
753
+ if (scenarioStepKind === 'platform' || scenarioStepKind === 'addon') {
754
+ // Neither kind has surfaces. Nobody clicks "Stripe's webhook arrives", and
755
+ // there is no human behind "the platform has expired the trial" — so both
756
+ // take a single `func`, and a binding key here is a coded error rather
757
+ // than a convention nobody reads.
758
+ if (declaredSurfaces.length > 0) {
759
+ logger.error(
760
+ `• ${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.`
761
+ )
762
+ return
763
+ }
764
+ // The phase rule depends on it: an assertion runs every witness it has and
765
+ // fails if they disagree, so a step with exactly one witness by
766
+ // construction has to be unable to look like it has several.
767
+ scenarioStepSurfaces = ['default']
768
+ if (scenarioStepKind === 'addon') {
769
+ scenarioStepAddon = getPropertyValue(firstArg, 'addon') as
770
+ | string
771
+ | undefined
772
+ if (!scenarioStepAddon) {
773
+ logger.error(
774
+ `• pikkuAddonScenarioStep('${name}') needs a string literal \`addon\` naming the addon whose system acts — the same name its wireAddon declares.`
775
+ )
776
+ return
777
+ }
778
+ }
779
+ } else {
780
+ scenarioStepSurfaces = declaredSurfaces.length
781
+ ? declaredSurfaces
782
+ : undefined
783
+ }
564
784
  scenarioStepTemplate = getPropertyValue(firstArg, 'template') as
565
785
  | string
566
786
  | undefined
@@ -684,6 +904,24 @@ export const addFunctions: AddWiring = (
684
904
  pikkuFuncId = formatVersionedId(baseName, version)
685
905
  }
686
906
 
907
+ // A remote channel capability is a contract with no body: the connected
908
+ // client answers it. Everything past this point reads the handler, so it gets
909
+ // its own path rather than a nullable handler threaded through all of it.
910
+ if (expression.text === 'pikkuRemoteChannelFunc') {
911
+ addRemoteChannelFunc({
912
+ state,
913
+ logger,
914
+ node,
915
+ pikkuFuncId,
916
+ name,
917
+ exportedName,
918
+ inputSchemaRef,
919
+ outputSchemaRef,
920
+ meta: { version, title, tags, summary, description, errors },
921
+ })
922
+ return
923
+ }
924
+
687
925
  const isMCPToolFunc = expression.text === 'pikkuMCPToolFunc'
688
926
  const isListFunc = expression.text === 'pikkuListFunc'
689
927
  const mcpEnabled = mcp || isMCPToolFunc
@@ -995,6 +1233,20 @@ export const addFunctions: AddWiring = (
995
1233
  // it runs ONLY when explicitly requested (`pikku all --security`) — see the
996
1234
  // classificationCheck option. Default codegen skips it entirely.
997
1235
  if (options.classificationCheck) {
1236
+ // A revealed vault secret is invisible to the return-type scan below when it
1237
+ // is written out mid-body rather than returned, so walk the sinks too.
1238
+ for (const violation of findRevealedSecretSinks(checker, handler)) {
1239
+ logger.diagnostic({
1240
+ severity: 'error',
1241
+ code: ErrorCode.SECRET_REVEALED_INTO_SINK,
1242
+ message:
1243
+ `Function '${name}' passes a revealed secret to '${violation.sink}' ` +
1244
+ `at line ${violation.line} (path '${violation.path}').\n ` +
1245
+ `A vault secret must not be written to a log, an audit, a queue, an email or a webhook. ` +
1246
+ `Pass the value only to the client that needs it, and reveal it there.`,
1247
+ })
1248
+ }
1249
+
998
1250
  const sig = checker.getSignatureFromDeclaration(handler)
999
1251
  if (sig) {
1000
1252
  const rawRet = checker.getReturnTypeOfSignature(sig)
@@ -1031,7 +1283,7 @@ export const addFunctions: AddWiring = (
1031
1283
  `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
1032
1284
  privatePaths.map((p) => `'${p}'`).join(', ') +
1033
1285
  `.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
1034
- `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`,
1286
+ `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.`,
1035
1287
  })
1036
1288
  }
1037
1289
  }
@@ -1109,6 +1361,9 @@ export const addFunctions: AddWiring = (
1109
1361
  inputs: inputNames.filter((n) => n !== 'void') ?? null,
1110
1362
  outputs: outputNames.filter((n) => n !== 'void') ?? null,
1111
1363
  expose: expose || undefined,
1364
+ auth: auth || undefined,
1365
+ permissionsInBody: permissionsInBody || undefined,
1366
+ audit,
1112
1367
  remote: remote || undefined,
1113
1368
  scenarioStep: isScenarioStep || undefined,
1114
1369
  scenario: isScenario || undefined,
@@ -1120,7 +1375,14 @@ export const addFunctions: AddWiring = (
1120
1375
  workflowQueued: workflowQueued === true ? true : undefined,
1121
1376
  workflowRetries: workflowRetries ?? undefined,
1122
1377
  workflowTimeout: workflowTimeout ?? undefined,
1123
- scenarioStepBrowser: scenarioStepBrowser === true ? true : undefined,
1378
+ scenarioStepSurfaces,
1379
+ // `persona` is the default reading of an unmarked step, so it is left off
1380
+ // rather than written into every step meta a project already has.
1381
+ scenarioStepKind:
1382
+ scenarioStepKind && scenarioStepKind !== 'persona'
1383
+ ? scenarioStepKind
1384
+ : undefined,
1385
+ scenarioStepAddon,
1124
1386
  scenarioStepTemplate: scenarioStepTemplate || undefined,
1125
1387
  scopes: scopes ?? undefined,
1126
1388
  implementationHash,