@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.
- package/CHANGELOG.md +743 -0
- package/OPTIMIZATION-PLAN.md +9 -3
- package/dist/add/add-auth.d.ts +1 -1
- package/dist/add/add-auth.js +1 -1
- package/dist/add/add-cli.js +7 -0
- package/dist/add/add-credential.js +1 -1
- package/dist/add/add-functions.js +199 -8
- package/dist/add/add-http-route.js +16 -6
- package/dist/add/add-middleware.js +46 -3
- package/dist/add/add-personas.d.ts +13 -0
- package/dist/add/add-personas.js +216 -0
- package/dist/add/add-scope.js +1 -1
- package/dist/add/add-secret.js +1 -1
- package/dist/add/add-system-role.d.ts +2 -0
- package/dist/add/add-system-role.js +102 -0
- package/dist/add/add-variable.js +1 -1
- package/dist/add/add-wire-addon.js +31 -0
- package/dist/add/add-workflow.js +12 -1
- package/dist/error-codes.d.ts +17 -1
- package/dist/error-codes.js +23 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +33 -1
- package/dist/types.d.ts +55 -2
- package/dist/utils/annotate-http-route-auth.d.ts +22 -0
- package/dist/utils/annotate-http-route-auth.js +48 -0
- package/dist/utils/check-secret-sinks.d.ts +31 -0
- package/dist/utils/check-secret-sinks.js +117 -0
- package/dist/utils/extract-function-name.js +13 -4
- package/dist/utils/extract-function-node.js +18 -1
- package/dist/utils/extract-secret-usage.d.ts +9 -0
- package/dist/utils/extract-secret-usage.js +61 -0
- package/dist/utils/literal-properties.d.ts +22 -0
- package/dist/utils/literal-properties.js +69 -0
- package/dist/utils/load-addon-functions-meta.js +26 -1
- package/dist/utils/permissions.js +8 -0
- package/dist/utils/post-process.d.ts +68 -3
- package/dist/utils/post-process.js +246 -13
- package/dist/utils/secret-alias-services.d.ts +8 -0
- package/dist/utils/secret-alias-services.js +45 -0
- package/dist/utils/secret-brokers.d.ts +2 -0
- package/dist/utils/secret-brokers.js +9 -0
- package/dist/utils/serialize-inspector-state.d.ts +10 -0
- package/dist/utils/serialize-inspector-state.js +18 -0
- package/dist/utils/serialize-middleware-groups-meta.js +3 -0
- package/dist/utils/single-declaration.d.ts +32 -0
- package/dist/utils/single-declaration.js +43 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
- package/dist/utils/validate-exposed-functions-gated.js +93 -0
- package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
- package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
- package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
- package/dist/utils/workflow/dsl/patterns.js +26 -4
- package/dist/visit.js +4 -0
- package/package.json +3 -3
- package/src/add/add-auth.test.ts +5 -3
- package/src/add/add-auth.ts +1 -1
- package/src/add/add-cli.ts +8 -0
- package/src/add/add-credential.ts +1 -1
- package/src/add/add-functions.test.ts +263 -1
- package/src/add/add-functions.ts +273 -11
- package/src/add/add-http-route.ts +20 -7
- package/src/add/add-middleware.test.ts +196 -0
- package/src/add/add-middleware.ts +64 -4
- package/src/add/add-personas.test.ts +382 -0
- package/src/add/add-personas.ts +285 -0
- package/src/add/add-scenario-step.test.ts +87 -5
- package/src/add/add-scope.test.ts +103 -30
- package/src/add/add-scope.ts +1 -1
- package/src/add/add-secret.ts +1 -1
- package/src/add/add-subject-scenario-steps.test.ts +161 -0
- package/src/add/add-system-role.test.ts +297 -0
- package/src/add/add-system-role.ts +164 -0
- package/src/add/add-variable.ts +1 -1
- package/src/add/add-wire-addon.test.ts +111 -0
- package/src/add/add-wire-addon.ts +29 -0
- package/src/add/add-workflow.ts +21 -1
- package/src/add/addon-bans.test.ts +4 -4
- package/src/add/allow-escape-hatches.test.ts +144 -0
- package/src/add/audit-meta.test.ts +75 -0
- package/src/add/config-value-metadata.test.ts +11 -11
- package/src/add/secret-sink-check.test.ts +211 -0
- package/src/add/wire-name-literal.test.ts +2 -2
- package/src/error-codes.ts +26 -0
- package/src/index.ts +4 -1
- package/src/inspector.ts +39 -0
- package/src/types.ts +56 -2
- package/src/utils/annotate-http-route-auth.test.ts +163 -0
- package/src/utils/annotate-http-route-auth.ts +52 -0
- package/src/utils/check-secret-sinks.ts +145 -0
- package/src/utils/extract-function-name.ts +14 -4
- package/src/utils/extract-function-node.ts +18 -6
- package/src/utils/extract-secret-usage.test.ts +81 -0
- package/src/utils/extract-secret-usage.ts +73 -0
- package/src/utils/literal-properties.ts +97 -0
- package/src/utils/load-addon-functions-meta.ts +32 -2
- package/src/utils/permissions-shorthand.test.ts +79 -0
- package/src/utils/permissions.ts +7 -0
- package/src/utils/post-process.test.ts +133 -0
- package/src/utils/post-process.ts +313 -12
- package/src/utils/secret-alias-services.test.ts +146 -0
- package/src/utils/secret-alias-services.ts +58 -0
- package/src/utils/secret-brokers.test.ts +53 -0
- package/src/utils/secret-brokers.ts +11 -0
- package/src/utils/serialize-inspector-state.ts +28 -0
- package/src/utils/serialize-middleware-groups-meta.ts +3 -0
- package/src/utils/single-declaration.ts +57 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
- package/src/utils/validate-exposed-functions-gated.ts +103 -0
- package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
- package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
- package/src/utils/workflow/dsl/patterns.ts +33 -4
- package/src/visit.ts +4 -0
- 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
|
+
})
|
package/src/add/add-functions.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
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
|
-
|
|
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,
|