@pikku/inspector 0.12.50 → 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 +441 -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/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/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
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,
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import type { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger, MiddlewareGroupMeta } from '../types.js'
|
|
9
|
+
|
|
10
|
+
const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
11
|
+
({
|
|
12
|
+
debug: () => {},
|
|
13
|
+
info: () => {},
|
|
14
|
+
warn: () => {},
|
|
15
|
+
error: () => {},
|
|
16
|
+
diagnostic: ({ code, message }) => {
|
|
17
|
+
criticals.push({ code, message })
|
|
18
|
+
},
|
|
19
|
+
critical: (code: ErrorCode, message: string) => {
|
|
20
|
+
criticals.push({ code, message })
|
|
21
|
+
},
|
|
22
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
23
|
+
}) satisfies InspectorLogger
|
|
24
|
+
|
|
25
|
+
/** Every file that registered middleware for one pattern or tag. */
|
|
26
|
+
const sourceFiles = (group: MiddlewareGroupMeta | undefined): string[] =>
|
|
27
|
+
group
|
|
28
|
+
? [
|
|
29
|
+
group.sourceFile,
|
|
30
|
+
...(group.additionalRegistrations ?? []).map((r) => r.sourceFile),
|
|
31
|
+
]
|
|
32
|
+
: []
|
|
33
|
+
|
|
34
|
+
/** Runs the inspector over files written into a throwaway directory. */
|
|
35
|
+
const withProject = async (
|
|
36
|
+
prefix: string,
|
|
37
|
+
files: Record<string, string[]>,
|
|
38
|
+
assertions: (state: Awaited<ReturnType<typeof inspect>>) => void
|
|
39
|
+
) => {
|
|
40
|
+
const rootDir = await mkdtemp(join(tmpdir(), prefix))
|
|
41
|
+
const paths: string[] = []
|
|
42
|
+
for (const [name, lines] of Object.entries(files)) {
|
|
43
|
+
const path = join(rootDir, name)
|
|
44
|
+
await writeFile(path, lines.join('\n'))
|
|
45
|
+
paths.push(path)
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
assertions(await inspect(makeLogger([]), paths, { rootDir }))
|
|
49
|
+
} finally {
|
|
50
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('addMiddleware inspector', () => {
|
|
55
|
+
describe('two files registering the same HTTP pattern', () => {
|
|
56
|
+
// The group is keyed by pattern, so the second call used to overwrite the
|
|
57
|
+
// first. Codegen emits imports from what is stored, so the losing file was
|
|
58
|
+
// never imported and its middleware never registered — an app could lose
|
|
59
|
+
// its session bridge to an unrelated '*' group and see nothing go wrong
|
|
60
|
+
// until a request arrived without a session.
|
|
61
|
+
const project = {
|
|
62
|
+
'session.ts': [
|
|
63
|
+
"import { addHTTPMiddleware, pikkuMiddleware } from '#pikku'",
|
|
64
|
+
'export const sessionMiddleware = () =>',
|
|
65
|
+
" addHTTPMiddleware('*', [",
|
|
66
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
67
|
+
' ])',
|
|
68
|
+
],
|
|
69
|
+
'logging.ts': [
|
|
70
|
+
"import { addHTTPMiddleware, pikkuMiddleware } from '#pikku'",
|
|
71
|
+
'export const loggingMiddleware = () =>',
|
|
72
|
+
" addHTTPMiddleware('*', [",
|
|
73
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
74
|
+
' ])',
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test('keeps both registrations', async () => {
|
|
79
|
+
await withProject('pikku-mw-http-collide-', project, (state) => {
|
|
80
|
+
const group = state.http.routeMiddleware.get('*')
|
|
81
|
+
assert.ok(group, "expected a group for '*'")
|
|
82
|
+
assert.equal(
|
|
83
|
+
sourceFiles(group).length,
|
|
84
|
+
2,
|
|
85
|
+
'both files must survive, or one middleware silently stops running'
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('keeps both export names, so both can be imported', async () => {
|
|
91
|
+
await withProject('pikku-mw-http-names-', project, (state) => {
|
|
92
|
+
const group = state.http.routeMiddleware.get('*')!
|
|
93
|
+
const names = [
|
|
94
|
+
group.exportName,
|
|
95
|
+
...(group.additionalRegistrations ?? []).map((r) => r.exportName),
|
|
96
|
+
]
|
|
97
|
+
assert.deepEqual(names.sort(), [
|
|
98
|
+
'loggingMiddleware',
|
|
99
|
+
'sessionMiddleware',
|
|
100
|
+
])
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('unions the services both registrations need', async () => {
|
|
105
|
+
await withProject('pikku-mw-http-services-', project, (state) => {
|
|
106
|
+
const group = state.http.routeMiddleware.get('*')!
|
|
107
|
+
assert.equal(
|
|
108
|
+
new Set(group.services.services).size,
|
|
109
|
+
group.services.services.length,
|
|
110
|
+
'services must not repeat when two groups merge'
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('a single registration is unchanged', async () => {
|
|
117
|
+
await withProject(
|
|
118
|
+
'pikku-mw-http-single-',
|
|
119
|
+
{
|
|
120
|
+
'session.ts': [
|
|
121
|
+
"import { addHTTPMiddleware, pikkuMiddleware } from '#pikku'",
|
|
122
|
+
'export const sessionMiddleware = () =>',
|
|
123
|
+
" addHTTPMiddleware('*', [",
|
|
124
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
125
|
+
' ])',
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
(state) => {
|
|
129
|
+
const group = state.http.routeMiddleware.get('*')!
|
|
130
|
+
assert.equal(group.exportName, 'sessionMiddleware')
|
|
131
|
+
assert.equal(
|
|
132
|
+
group.additionalRegistrations,
|
|
133
|
+
undefined,
|
|
134
|
+
'nothing to merge means nothing extra to serialize'
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('different patterns stay separate groups', async () => {
|
|
141
|
+
await withProject(
|
|
142
|
+
'pikku-mw-http-distinct-',
|
|
143
|
+
{
|
|
144
|
+
'session.ts': [
|
|
145
|
+
"import { addHTTPMiddleware, pikkuMiddleware } from '#pikku'",
|
|
146
|
+
'export const sessionMiddleware = () =>',
|
|
147
|
+
" addHTTPMiddleware('*', [",
|
|
148
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
149
|
+
' ])',
|
|
150
|
+
],
|
|
151
|
+
'api.ts': [
|
|
152
|
+
"import { addHTTPMiddleware, pikkuMiddleware } from '#pikku'",
|
|
153
|
+
'export const apiMiddleware = () =>',
|
|
154
|
+
" addHTTPMiddleware('/api/*', [",
|
|
155
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
156
|
+
' ])',
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
(state) => {
|
|
160
|
+
assert.equal(sourceFiles(state.http.routeMiddleware.get('*')).length, 1)
|
|
161
|
+
assert.equal(
|
|
162
|
+
sourceFiles(state.http.routeMiddleware.get('/api/*')).length,
|
|
163
|
+
1
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('two files registering the same tag keep both registrations', async () => {
|
|
170
|
+
await withProject(
|
|
171
|
+
'pikku-mw-tag-collide-',
|
|
172
|
+
{
|
|
173
|
+
'session.ts': [
|
|
174
|
+
"import { addTagMiddleware, pikkuMiddleware } from '#pikku'",
|
|
175
|
+
'export const sessionTag = () =>',
|
|
176
|
+
" addTagMiddleware('api', [",
|
|
177
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
178
|
+
' ])',
|
|
179
|
+
],
|
|
180
|
+
'logging.ts': [
|
|
181
|
+
"import { addTagMiddleware, pikkuMiddleware } from '#pikku'",
|
|
182
|
+
'export const loggingTag = () =>',
|
|
183
|
+
" addTagMiddleware('api', [",
|
|
184
|
+
' pikkuMiddleware(async (_s, _w, next) => next()),',
|
|
185
|
+
' ])',
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
(state) => {
|
|
189
|
+
assert.equal(
|
|
190
|
+
sourceFiles(state.middleware.tagMiddleware.get('api')).length,
|
|
191
|
+
2
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AddWiring,
|
|
4
|
+
InspectorState,
|
|
5
|
+
MiddlewareGroupMeta,
|
|
6
|
+
} from '../types.js'
|
|
3
7
|
import {
|
|
4
8
|
extractFunctionName,
|
|
5
9
|
isNamedExport,
|
|
@@ -13,6 +17,62 @@ import { extractMiddlewareRefs } from '../utils/middleware.js'
|
|
|
13
17
|
import { getPropertyValue } from '../utils/get-property-value.js'
|
|
14
18
|
import { getPropertyAssignmentInitializer } from '../utils/type-utils.js'
|
|
15
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Record a middleware group, keeping any registration already stored under the
|
|
22
|
+
* same pattern or tag.
|
|
23
|
+
*
|
|
24
|
+
* Several files may add middleware for one pattern — an app's own `'*'` group
|
|
25
|
+
* alongside the generated better-auth session bridge, say — and the runtime
|
|
26
|
+
* composes them. A plain `set` kept only the last, and codegen emits imports
|
|
27
|
+
* from what is stored, so the other file was never imported and its middleware
|
|
28
|
+
* silently stopped running. Merging is the only shape that cannot lose a gate;
|
|
29
|
+
* the first registration stays primary so single-registration groups serialize
|
|
30
|
+
* exactly as before.
|
|
31
|
+
*/
|
|
32
|
+
function recordMiddlewareGroup(
|
|
33
|
+
groups: Map<string, MiddlewareGroupMeta>,
|
|
34
|
+
key: string,
|
|
35
|
+
group: MiddlewareGroupMeta
|
|
36
|
+
): void {
|
|
37
|
+
const existing = groups.get(key)
|
|
38
|
+
if (!existing) {
|
|
39
|
+
groups.set(key, group)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The same call seen twice — the inspector may revisit a file — is not a
|
|
44
|
+
// second registration, and appending it would emit a duplicate import.
|
|
45
|
+
const alreadyRecorded = [
|
|
46
|
+
existing,
|
|
47
|
+
...(existing.additionalRegistrations ?? []),
|
|
48
|
+
]
|
|
49
|
+
if (
|
|
50
|
+
alreadyRecorded.some(
|
|
51
|
+
(r) => r.sourceFile === group.sourceFile && r.position === group.position
|
|
52
|
+
)
|
|
53
|
+
) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
existing.additionalRegistrations = [
|
|
58
|
+
...(existing.additionalRegistrations ?? []),
|
|
59
|
+
{
|
|
60
|
+
exportName: group.exportName,
|
|
61
|
+
sourceFile: group.sourceFile,
|
|
62
|
+
position: group.position,
|
|
63
|
+
isFactory: group.isFactory,
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
existing.count += group.count
|
|
67
|
+
existing.instanceIds = [...existing.instanceIds, ...group.instanceIds]
|
|
68
|
+
existing.services = {
|
|
69
|
+
optimized: false,
|
|
70
|
+
services: Array.from(
|
|
71
|
+
new Set([...existing.services.services, ...group.services.services])
|
|
72
|
+
),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
16
76
|
function renameTempDefinitions(
|
|
17
77
|
state: InspectorState,
|
|
18
78
|
definitionIds: string[],
|
|
@@ -333,7 +393,7 @@ export const addMiddleware: AddWiring = (logger, node, checker, state) => {
|
|
|
333
393
|
)
|
|
334
394
|
}
|
|
335
395
|
|
|
336
|
-
state.middleware.tagMiddleware
|
|
396
|
+
recordMiddlewareGroup(state.middleware.tagMiddleware, tag, {
|
|
337
397
|
exportName: exportedName,
|
|
338
398
|
sourceFile,
|
|
339
399
|
position: node.getStart(),
|
|
@@ -477,7 +537,7 @@ export const addMiddleware: AddWiring = (logger, node, checker, state) => {
|
|
|
477
537
|
)
|
|
478
538
|
}
|
|
479
539
|
|
|
480
|
-
state.http.routeMiddleware
|
|
540
|
+
recordMiddlewareGroup(state.http.routeMiddleware, pattern, {
|
|
481
541
|
exportName: exportedName,
|
|
482
542
|
sourceFile,
|
|
483
543
|
position: node.getStart(),
|
|
@@ -796,7 +856,7 @@ export const addMiddleware: AddWiring = (logger, node, checker, state) => {
|
|
|
796
856
|
)
|
|
797
857
|
}
|
|
798
858
|
|
|
799
|
-
state.channelMiddleware.tagMiddleware
|
|
859
|
+
recordMiddlewareGroup(state.channelMiddleware.tagMiddleware, tag, {
|
|
800
860
|
exportName: exportedName,
|
|
801
861
|
sourceFile,
|
|
802
862
|
position: node.getStart(),
|