@pikku/inspector 0.12.49 → 0.12.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +501 -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 +156 -7
- 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 +210 -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/error-codes.d.ts +10 -1
- package/dist/error-codes.js +11 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +33 -1
- package/dist/types.d.ts +46 -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/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 +243 -13
- package/dist/utils/schema-generator.d.ts +24 -0
- package/dist/utils/schema-generator.js +118 -19
- 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/validate-exposed-functions-gated.d.ts +14 -0
- package/dist/utils/validate-exposed-functions-gated.js +85 -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/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 +219 -9
- 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 +268 -0
- package/src/add/add-personas.ts +276 -0
- package/src/add/add-scenario-step.test.ts +49 -3
- package/src/add/add-scope.test.ts +30 -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 +214 -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/addon-bans.test.ts +4 -4
- package/src/add/config-value-metadata.test.ts +11 -11
- package/src/add/wire-name-literal.test.ts +2 -2
- package/src/error-codes.ts +13 -0
- package/src/index.ts +4 -1
- package/src/inspector.ts +39 -0
- package/src/types.ts +47 -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/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 +310 -12
- package/src/utils/schema-cache.test.ts +124 -0
- package/src/utils/schema-generator.test.ts +62 -1
- package/src/utils/schema-generator.ts +138 -21
- 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/validate-exposed-functions-gated.test.ts +230 -0
- package/src/utils/validate-exposed-functions-gated.ts +94 -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/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
|
@@ -9,7 +9,28 @@ import {
|
|
|
9
9
|
import { extractFunctionNode } from '../utils/extract-function-node.js'
|
|
10
10
|
import { extractUsedWires } from '../utils/extract-services.js'
|
|
11
11
|
import type { 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,
|
|
@@ -366,6 +387,112 @@ function computeImplementationHash(args: {
|
|
|
366
387
|
)
|
|
367
388
|
}
|
|
368
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Registers a `pikkuRemoteChannelFunc` declaration: a name, its schemas and its
|
|
392
|
+
* description, with no body to inspect. The name has to reach `rpc.internalMeta`
|
|
393
|
+
* for `channel.remote` to resolve the schemas it validates each call against.
|
|
394
|
+
*/
|
|
395
|
+
const addRemoteChannelFunc = ({
|
|
396
|
+
state,
|
|
397
|
+
logger,
|
|
398
|
+
node,
|
|
399
|
+
pikkuFuncId,
|
|
400
|
+
name,
|
|
401
|
+
exportedName,
|
|
402
|
+
inputSchemaRef,
|
|
403
|
+
outputSchemaRef,
|
|
404
|
+
meta,
|
|
405
|
+
}: {
|
|
406
|
+
state: InspectorState
|
|
407
|
+
logger: Parameters<AddWiring>[0]
|
|
408
|
+
node: ts.CallExpression
|
|
409
|
+
pikkuFuncId: string
|
|
410
|
+
name: string
|
|
411
|
+
exportedName?: string | null
|
|
412
|
+
inputSchemaRef: SchemaRef | null
|
|
413
|
+
outputSchemaRef: SchemaRef | null
|
|
414
|
+
meta: {
|
|
415
|
+
version?: number
|
|
416
|
+
title?: string
|
|
417
|
+
tags?: string[]
|
|
418
|
+
summary?: string
|
|
419
|
+
description?: string
|
|
420
|
+
errors?: string[]
|
|
421
|
+
}
|
|
422
|
+
}) => {
|
|
423
|
+
const sourceFile = node.getSourceFile().fileName
|
|
424
|
+
|
|
425
|
+
if (!exportedName) {
|
|
426
|
+
logger.error(
|
|
427
|
+
`• Remote channel function '${name}' is not exported, this is not allowed.`
|
|
428
|
+
)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Two files declaring the same capability is the collision that matters:
|
|
433
|
+
// both ids are the bare name, so the id-compatibility check below reads them
|
|
434
|
+
// as the same function and lets the second overwrite the first.
|
|
435
|
+
const existingFunction = state.functions.meta[pikkuFuncId]
|
|
436
|
+
if (
|
|
437
|
+
existingFunction &&
|
|
438
|
+
existingFunction.sourceFile &&
|
|
439
|
+
existingFunction.sourceFile !== sourceFile
|
|
440
|
+
) {
|
|
441
|
+
logger.critical(
|
|
442
|
+
ErrorCode.DUPLICATE_FUNCTION_NAME,
|
|
443
|
+
`Function name '${name}' is not unique. ` +
|
|
444
|
+
`'${pikkuFuncId}' is already defined in '${existingFunction.sourceFile}' and cannot be redefined in '${sourceFile}'.`
|
|
445
|
+
)
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const existing = state.rpc.internalMeta[name]
|
|
450
|
+
if (existing && !areCompatibleFunctionIds(existing, pikkuFuncId)) {
|
|
451
|
+
logger.critical(
|
|
452
|
+
ErrorCode.DUPLICATE_FUNCTION_NAME,
|
|
453
|
+
`Function name '${name}' is not unique. ` +
|
|
454
|
+
`It already points to '${existing}' in '${resolveExistingFunctionSource(state, existing) || 'unknown file'}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`
|
|
455
|
+
)
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const capitalizedName = funcIdToTypeName(name)
|
|
460
|
+
const registerSchema = (
|
|
461
|
+
ref: SchemaRef | null,
|
|
462
|
+
suffix: 'Input' | 'Output'
|
|
463
|
+
) => {
|
|
464
|
+
if (!ref) return null
|
|
465
|
+
const schemaName = `${capitalizedName}${suffix}`
|
|
466
|
+
state.schemaLookup.set(schemaName, ref)
|
|
467
|
+
state.functions.typesMap.addCustomType(schemaName, 'unknown', [])
|
|
468
|
+
return schemaName
|
|
469
|
+
}
|
|
470
|
+
const inputSchemaName = registerSchema(inputSchemaRef, 'Input')
|
|
471
|
+
const outputSchemaName = registerSchema(outputSchemaRef, 'Output')
|
|
472
|
+
|
|
473
|
+
state.functions.meta[pikkuFuncId] = {
|
|
474
|
+
pikkuFuncId,
|
|
475
|
+
functionType: 'remote',
|
|
476
|
+
funcWrapper: 'pikkuRemoteChannelFunc',
|
|
477
|
+
sessionless: true,
|
|
478
|
+
name,
|
|
479
|
+
services: { optimized: true, services: [] },
|
|
480
|
+
inputSchemaName,
|
|
481
|
+
outputSchemaName,
|
|
482
|
+
inputs: inputSchemaName ? [inputSchemaName] : [],
|
|
483
|
+
outputs: outputSchemaName ? [outputSchemaName] : [],
|
|
484
|
+
sourceFile,
|
|
485
|
+
exportedName,
|
|
486
|
+
...meta,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
state.functions.files.set(pikkuFuncId, { path: sourceFile, exportedName })
|
|
490
|
+
state.rpc.internalMeta[name] = pikkuFuncId
|
|
491
|
+
// Imported so a local call gets the factory's "this runs on the client"
|
|
492
|
+
// error rather than a bare lookup miss.
|
|
493
|
+
state.rpc.internalFiles.set(pikkuFuncId, { path: sourceFile, exportedName })
|
|
494
|
+
}
|
|
495
|
+
|
|
369
496
|
/**
|
|
370
497
|
* Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
|
|
371
498
|
* then push into state.functions.meta.
|
|
@@ -387,13 +514,13 @@ export const addFunctions: AddWiring = (
|
|
|
387
514
|
}
|
|
388
515
|
|
|
389
516
|
// Match identifiers that contain both "pikku" and "func" (case insensitive),
|
|
390
|
-
// plus pikkuScenario and
|
|
391
|
-
// in their names)
|
|
517
|
+
// plus pikkuScenario and the three scenario step wrappers (workflow wrappers
|
|
518
|
+
// without "func" in their names)
|
|
392
519
|
const pikkuFuncPattern = /pikku.*func/i
|
|
393
520
|
if (
|
|
394
521
|
!pikkuFuncPattern.test(expression.text) &&
|
|
395
522
|
expression.text !== 'pikkuScenario' &&
|
|
396
|
-
expression.text
|
|
523
|
+
!SCENARIO_STEP_KINDS.has(expression.text)
|
|
397
524
|
) {
|
|
398
525
|
return
|
|
399
526
|
}
|
|
@@ -401,7 +528,8 @@ export const addFunctions: AddWiring = (
|
|
|
401
528
|
// A scenario step is bundled like any invoked function, but is deliberately
|
|
402
529
|
// never RPC-registered: a step may drive a browser, so exposing it over the
|
|
403
530
|
// network would put a test harness on the public API surface.
|
|
404
|
-
const
|
|
531
|
+
const scenarioStepKind = SCENARIO_STEP_KINDS.get(expression.text)
|
|
532
|
+
const isScenarioStep = scenarioStepKind !== undefined
|
|
405
533
|
|
|
406
534
|
// A scenario's own body. Marked so the codegen partitions and the deploy
|
|
407
535
|
// analyzer can tell it apart from an application workflow without having to
|
|
@@ -444,6 +572,15 @@ export const addFunctions: AddWiring = (
|
|
|
444
572
|
let expose: boolean | undefined
|
|
445
573
|
let remote: boolean | undefined
|
|
446
574
|
let mcp: boolean | undefined
|
|
575
|
+
/**
|
|
576
|
+
* A sessionless function's own `auth: true`. `sessionless` records the
|
|
577
|
+
* baseline — a `pikkuFunc` always needs a session — while this records the
|
|
578
|
+
* tightening a `pikkuSessionlessFunc` applies to itself, which is otherwise
|
|
579
|
+
* invisible to anything reading meta.
|
|
580
|
+
*/
|
|
581
|
+
let auth: boolean | undefined
|
|
582
|
+
/** The author's claim that the body authorizes its own callers. */
|
|
583
|
+
let selfAuthenticated: boolean | undefined
|
|
447
584
|
let readonly_: boolean | undefined
|
|
448
585
|
let deploy: 'serverless' | 'server' | 'auto' | undefined
|
|
449
586
|
let approvalRequired: boolean | undefined
|
|
@@ -451,7 +588,8 @@ export const addFunctions: AddWiring = (
|
|
|
451
588
|
let workflowQueued: boolean | undefined
|
|
452
589
|
let workflowRetries: number | undefined
|
|
453
590
|
let workflowTimeout: string | undefined
|
|
454
|
-
let
|
|
591
|
+
let scenarioStepSurfaces: ScenarioSurface[] | undefined
|
|
592
|
+
let scenarioStepAddon: string | undefined
|
|
455
593
|
let scenarioStepTemplate: string | undefined
|
|
456
594
|
let scopes: string[] | undefined
|
|
457
595
|
let version: number | undefined
|
|
@@ -540,6 +678,10 @@ export const addFunctions: AddWiring = (
|
|
|
540
678
|
expose = getPropertyValue(firstArg, 'expose') as boolean | undefined
|
|
541
679
|
remote = getPropertyValue(firstArg, 'remote') as boolean | undefined
|
|
542
680
|
mcp = getPropertyValue(firstArg, 'mcp') as boolean | undefined
|
|
681
|
+
auth = getPropertyValue(firstArg, 'auth') as boolean | undefined
|
|
682
|
+
selfAuthenticated = getPropertyValue(firstArg, 'selfAuthenticated') as
|
|
683
|
+
| boolean
|
|
684
|
+
| undefined
|
|
543
685
|
readonly_ = getPropertyValue(firstArg, 'readonly') as boolean | undefined
|
|
544
686
|
deploy = getPropertyValue(firstArg, 'deploy') as
|
|
545
687
|
| 'serverless'
|
|
@@ -558,9 +700,50 @@ export const addFunctions: AddWiring = (
|
|
|
558
700
|
workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout') as
|
|
559
701
|
| string
|
|
560
702
|
| undefined
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
703
|
+
// A step declares one implementation per surface it supports; the binding
|
|
704
|
+
// keys that are present ARE the declaration, so there is nothing to read a
|
|
705
|
+
// value from — only names to collect.
|
|
706
|
+
const declaredSurfaces = SCENARIO_SURFACE_KEYS.filter((surface) =>
|
|
707
|
+
firstArg.properties.some(
|
|
708
|
+
(prop) =>
|
|
709
|
+
(ts.isPropertyAssignment(prop) ||
|
|
710
|
+
ts.isMethodDeclaration(prop) ||
|
|
711
|
+
ts.isShorthandPropertyAssignment(prop)) &&
|
|
712
|
+
ts.isIdentifier(prop.name) &&
|
|
713
|
+
prop.name.text === surface
|
|
714
|
+
)
|
|
715
|
+
)
|
|
716
|
+
if (scenarioStepKind === 'platform' || scenarioStepKind === 'addon') {
|
|
717
|
+
// Neither kind has surfaces. Nobody clicks "Stripe's webhook arrives", and
|
|
718
|
+
// there is no human behind "the platform has expired the trial" — so both
|
|
719
|
+
// take a single `func`, and a binding key here is a coded error rather
|
|
720
|
+
// than a convention nobody reads.
|
|
721
|
+
if (declaredSurfaces.length > 0) {
|
|
722
|
+
logger.error(
|
|
723
|
+
`• ${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.`
|
|
724
|
+
)
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
// The phase rule depends on it: an assertion runs every witness it has and
|
|
728
|
+
// fails if they disagree, so a step with exactly one witness by
|
|
729
|
+
// construction has to be unable to look like it has several.
|
|
730
|
+
scenarioStepSurfaces = ['default']
|
|
731
|
+
if (scenarioStepKind === 'addon') {
|
|
732
|
+
scenarioStepAddon = getPropertyValue(firstArg, 'addon') as
|
|
733
|
+
| string
|
|
734
|
+
| undefined
|
|
735
|
+
if (!scenarioStepAddon) {
|
|
736
|
+
logger.error(
|
|
737
|
+
`• pikkuAddonScenarioStep('${name}') needs a string literal \`addon\` naming the addon whose system acts — the same name its wireAddon declares.`
|
|
738
|
+
)
|
|
739
|
+
return
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
} else {
|
|
743
|
+
scenarioStepSurfaces = declaredSurfaces.length
|
|
744
|
+
? declaredSurfaces
|
|
745
|
+
: undefined
|
|
746
|
+
}
|
|
564
747
|
scenarioStepTemplate = getPropertyValue(firstArg, 'template') as
|
|
565
748
|
| string
|
|
566
749
|
| undefined
|
|
@@ -684,6 +867,24 @@ export const addFunctions: AddWiring = (
|
|
|
684
867
|
pikkuFuncId = formatVersionedId(baseName, version)
|
|
685
868
|
}
|
|
686
869
|
|
|
870
|
+
// A remote channel capability is a contract with no body: the connected
|
|
871
|
+
// client answers it. Everything past this point reads the handler, so it gets
|
|
872
|
+
// its own path rather than a nullable handler threaded through all of it.
|
|
873
|
+
if (expression.text === 'pikkuRemoteChannelFunc') {
|
|
874
|
+
addRemoteChannelFunc({
|
|
875
|
+
state,
|
|
876
|
+
logger,
|
|
877
|
+
node,
|
|
878
|
+
pikkuFuncId,
|
|
879
|
+
name,
|
|
880
|
+
exportedName,
|
|
881
|
+
inputSchemaRef,
|
|
882
|
+
outputSchemaRef,
|
|
883
|
+
meta: { version, title, tags, summary, description, errors },
|
|
884
|
+
})
|
|
885
|
+
return
|
|
886
|
+
}
|
|
887
|
+
|
|
687
888
|
const isMCPToolFunc = expression.text === 'pikkuMCPToolFunc'
|
|
688
889
|
const isListFunc = expression.text === 'pikkuListFunc'
|
|
689
890
|
const mcpEnabled = mcp || isMCPToolFunc
|
|
@@ -1109,6 +1310,8 @@ export const addFunctions: AddWiring = (
|
|
|
1109
1310
|
inputs: inputNames.filter((n) => n !== 'void') ?? null,
|
|
1110
1311
|
outputs: outputNames.filter((n) => n !== 'void') ?? null,
|
|
1111
1312
|
expose: expose || undefined,
|
|
1313
|
+
auth: auth || undefined,
|
|
1314
|
+
selfAuthenticated: selfAuthenticated || undefined,
|
|
1112
1315
|
remote: remote || undefined,
|
|
1113
1316
|
scenarioStep: isScenarioStep || undefined,
|
|
1114
1317
|
scenario: isScenario || undefined,
|
|
@@ -1120,7 +1323,14 @@ export const addFunctions: AddWiring = (
|
|
|
1120
1323
|
workflowQueued: workflowQueued === true ? true : undefined,
|
|
1121
1324
|
workflowRetries: workflowRetries ?? undefined,
|
|
1122
1325
|
workflowTimeout: workflowTimeout ?? undefined,
|
|
1123
|
-
|
|
1326
|
+
scenarioStepSurfaces,
|
|
1327
|
+
// `persona` is the default reading of an unmarked step, so it is left off
|
|
1328
|
+
// rather than written into every step meta a project already has.
|
|
1329
|
+
scenarioStepKind:
|
|
1330
|
+
scenarioStepKind && scenarioStepKind !== 'persona'
|
|
1331
|
+
? scenarioStepKind
|
|
1332
|
+
: undefined,
|
|
1333
|
+
scenarioStepAddon,
|
|
1124
1334
|
scenarioStepTemplate: scenarioStepTemplate || undefined,
|
|
1125
1335
|
scopes: scopes ?? undefined,
|
|
1126
1336
|
implementationHash,
|
|
@@ -272,18 +272,27 @@ export function registerHTTPRoute({
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// The route's own `auth`, falling back to the group's. Recorded on the meta
|
|
276
|
+
// below so an audit can answer "is this route open?" without re-reading the
|
|
277
|
+
// source: the route can tighten a sessionless function, and until this was
|
|
278
|
+
// written down that tightening existed only in the wiring.
|
|
279
|
+
const routeAuthProp = getPropertyValue(obj, 'auth')
|
|
280
|
+
const resolvedAuth =
|
|
281
|
+
routeAuthProp === true || routeAuthProp === false
|
|
282
|
+
? routeAuthProp
|
|
283
|
+
: inheritedAuth
|
|
284
|
+
|
|
275
285
|
// For inline functions (e.g. oauth2 handlers), propagate sessionless: true
|
|
276
286
|
// when auth is explicitly disabled at the route or group level — the
|
|
277
287
|
// developer opted out of auth so no session is required.
|
|
278
288
|
{
|
|
279
289
|
const inlineMeta = state.functions.meta[funcName]
|
|
280
|
-
if (
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
290
|
+
if (
|
|
291
|
+
inlineMeta &&
|
|
292
|
+
inlineMeta.sessionless === undefined &&
|
|
293
|
+
resolvedAuth === false
|
|
294
|
+
) {
|
|
295
|
+
inlineMeta.sessionless = true
|
|
287
296
|
}
|
|
288
297
|
}
|
|
289
298
|
|
|
@@ -416,6 +425,7 @@ export function registerHTTPRoute({
|
|
|
416
425
|
route: fullRoute,
|
|
417
426
|
sourceFile: sourceFile.fileName,
|
|
418
427
|
method: method as HTTPMethod,
|
|
428
|
+
...(resolvedAuth !== undefined && { auth: resolvedAuth }),
|
|
419
429
|
params: params.length > 0 ? params : undefined,
|
|
420
430
|
query: query.length > 0 ? query : undefined,
|
|
421
431
|
inputTypes: undefined,
|
|
@@ -484,6 +494,9 @@ export function registerHTTPRouteMeta({
|
|
|
484
494
|
route: fullRoute,
|
|
485
495
|
sourceFile: sourceFile.fileName,
|
|
486
496
|
method: method as HTTPMethod,
|
|
497
|
+
...((route.auth ?? inheritedAuth) !== undefined && {
|
|
498
|
+
auth: route.auth ?? inheritedAuth,
|
|
499
|
+
}),
|
|
487
500
|
params: params.length > 0 ? params : undefined,
|
|
488
501
|
inputTypes: undefined,
|
|
489
502
|
tags: tags.length > 0 ? tags : undefined,
|