@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.
Files changed (99) hide show
  1. package/CHANGELOG.md +441 -0
  2. package/OPTIMIZATION-PLAN.md +9 -3
  3. package/dist/add/add-auth.d.ts +1 -1
  4. package/dist/add/add-auth.js +1 -1
  5. package/dist/add/add-cli.js +7 -0
  6. package/dist/add/add-credential.js +1 -1
  7. package/dist/add/add-functions.js +156 -7
  8. package/dist/add/add-http-route.js +16 -6
  9. package/dist/add/add-middleware.js +46 -3
  10. package/dist/add/add-personas.d.ts +13 -0
  11. package/dist/add/add-personas.js +210 -0
  12. package/dist/add/add-scope.js +1 -1
  13. package/dist/add/add-secret.js +1 -1
  14. package/dist/add/add-system-role.d.ts +2 -0
  15. package/dist/add/add-system-role.js +102 -0
  16. package/dist/add/add-variable.js +1 -1
  17. package/dist/add/add-wire-addon.js +31 -0
  18. package/dist/error-codes.d.ts +10 -1
  19. package/dist/error-codes.js +11 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/inspector.js +33 -1
  22. package/dist/types.d.ts +46 -2
  23. package/dist/utils/annotate-http-route-auth.d.ts +22 -0
  24. package/dist/utils/annotate-http-route-auth.js +48 -0
  25. package/dist/utils/extract-function-name.js +13 -4
  26. package/dist/utils/extract-function-node.js +18 -1
  27. package/dist/utils/extract-secret-usage.d.ts +9 -0
  28. package/dist/utils/extract-secret-usage.js +61 -0
  29. package/dist/utils/literal-properties.d.ts +22 -0
  30. package/dist/utils/literal-properties.js +69 -0
  31. package/dist/utils/load-addon-functions-meta.js +26 -1
  32. package/dist/utils/permissions.js +8 -0
  33. package/dist/utils/post-process.d.ts +68 -3
  34. package/dist/utils/post-process.js +243 -13
  35. package/dist/utils/secret-alias-services.d.ts +8 -0
  36. package/dist/utils/secret-alias-services.js +45 -0
  37. package/dist/utils/secret-brokers.d.ts +2 -0
  38. package/dist/utils/secret-brokers.js +9 -0
  39. package/dist/utils/serialize-inspector-state.d.ts +10 -0
  40. package/dist/utils/serialize-inspector-state.js +18 -0
  41. package/dist/utils/serialize-middleware-groups-meta.js +3 -0
  42. package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
  43. package/dist/utils/validate-exposed-functions-gated.js +85 -0
  44. package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
  45. package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
  46. package/dist/visit.js +4 -0
  47. package/package.json +3 -3
  48. package/src/add/add-auth.test.ts +5 -3
  49. package/src/add/add-auth.ts +1 -1
  50. package/src/add/add-cli.ts +8 -0
  51. package/src/add/add-credential.ts +1 -1
  52. package/src/add/add-functions.test.ts +263 -1
  53. package/src/add/add-functions.ts +219 -9
  54. package/src/add/add-http-route.ts +20 -7
  55. package/src/add/add-middleware.test.ts +196 -0
  56. package/src/add/add-middleware.ts +64 -4
  57. package/src/add/add-personas.test.ts +268 -0
  58. package/src/add/add-personas.ts +276 -0
  59. package/src/add/add-scenario-step.test.ts +49 -3
  60. package/src/add/add-scope.test.ts +30 -30
  61. package/src/add/add-scope.ts +1 -1
  62. package/src/add/add-secret.ts +1 -1
  63. package/src/add/add-subject-scenario-steps.test.ts +161 -0
  64. package/src/add/add-system-role.test.ts +214 -0
  65. package/src/add/add-system-role.ts +164 -0
  66. package/src/add/add-variable.ts +1 -1
  67. package/src/add/add-wire-addon.test.ts +111 -0
  68. package/src/add/add-wire-addon.ts +29 -0
  69. package/src/add/addon-bans.test.ts +4 -4
  70. package/src/add/config-value-metadata.test.ts +11 -11
  71. package/src/add/wire-name-literal.test.ts +2 -2
  72. package/src/error-codes.ts +13 -0
  73. package/src/index.ts +4 -1
  74. package/src/inspector.ts +39 -0
  75. package/src/types.ts +47 -2
  76. package/src/utils/annotate-http-route-auth.test.ts +163 -0
  77. package/src/utils/annotate-http-route-auth.ts +52 -0
  78. package/src/utils/extract-function-name.ts +14 -4
  79. package/src/utils/extract-function-node.ts +18 -6
  80. package/src/utils/extract-secret-usage.test.ts +81 -0
  81. package/src/utils/extract-secret-usage.ts +73 -0
  82. package/src/utils/literal-properties.ts +97 -0
  83. package/src/utils/load-addon-functions-meta.ts +32 -2
  84. package/src/utils/permissions-shorthand.test.ts +79 -0
  85. package/src/utils/permissions.ts +7 -0
  86. package/src/utils/post-process.test.ts +133 -0
  87. package/src/utils/post-process.ts +310 -12
  88. package/src/utils/secret-alias-services.test.ts +146 -0
  89. package/src/utils/secret-alias-services.ts +58 -0
  90. package/src/utils/secret-brokers.test.ts +53 -0
  91. package/src/utils/secret-brokers.ts +11 -0
  92. package/src/utils/serialize-inspector-state.ts +28 -0
  93. package/src/utils/serialize-middleware-groups-meta.ts +3 -0
  94. package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
  95. package/src/utils/validate-exposed-functions-gated.ts +94 -0
  96. package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
  97. package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
  98. package/src/visit.ts +4 -0
  99. package/tsconfig.tsbuildinfo +1 -1
@@ -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 pikkuScenarioStep (workflow wrappers without "func"
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 !== 'pikkuScenarioStep'
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 isScenarioStep = expression.text === 'pikkuScenarioStep'
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 scenarioStepBrowser: boolean | undefined
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
- scenarioStepBrowser = getPropertyValue(firstArg, 'browser') as
562
- | boolean
563
- | undefined
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
- scenarioStepBrowser: scenarioStepBrowser === true ? true : undefined,
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 (inlineMeta && inlineMeta.sessionless === undefined) {
281
- const routeAuth = getPropertyValue(obj, 'auth')
282
- const resolvedAuth =
283
- routeAuth === true || routeAuth === false ? routeAuth : inheritedAuth
284
- if (resolvedAuth === false) {
285
- inlineMeta.sessionless = true
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 { AddWiring, InspectorState } from '../types.js'
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.set(tag, {
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.set(pattern, {
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.set(tag, {
859
+ recordMiddlewareGroup(state.channelMiddleware.tagMiddleware, tag, {
800
860
  exportName: exportedName,
801
861
  sourceFile,
802
862
  position: node.getStart(),