@pikku/inspector 0.12.75 → 0.12.82

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 (223) hide show
  1. package/CHANGELOG.md +357 -0
  2. package/dist/add/add-analytics.d.ts +9 -0
  3. package/dist/add/add-analytics.js +178 -0
  4. package/dist/add/add-auth.js +0 -54
  5. package/dist/add/add-feature-flag.d.ts +2 -0
  6. package/dist/add/add-feature-flag.js +162 -0
  7. package/dist/add/add-middleware.js +66 -42
  8. package/dist/add/add-wire-addon.js +7 -4
  9. package/dist/add/add-wire-remote-addon.js +1 -0
  10. package/dist/error-codes.d.ts +1 -0
  11. package/dist/error-codes.js +1 -0
  12. package/dist/index.d.ts +3 -1
  13. package/dist/index.js +3 -1
  14. package/dist/inspector.js +5 -0
  15. package/dist/surface/collect-surface.d.ts +58 -0
  16. package/dist/surface/collect-surface.js +613 -0
  17. package/dist/types.d.ts +36 -12
  18. package/dist/utils/filter-inspector-state.js +14 -0
  19. package/dist/utils/load-addon-functions-meta.js +24 -11
  20. package/dist/utils/post-process.js +15 -0
  21. package/dist/utils/read-module-specifiers.d.ts +8 -0
  22. package/dist/utils/read-module-specifiers.js +23 -0
  23. package/dist/utils/read-tsconfig-out-dir.d.ts +5 -0
  24. package/dist/utils/read-tsconfig-out-dir.js +12 -0
  25. package/dist/utils/resolve-deploy-target.d.ts +7 -0
  26. package/dist/utils/resolve-deploy-target.js +15 -7
  27. package/dist/utils/serialize-inspector-state.d.ts +6 -0
  28. package/dist/utils/serialize-inspector-state.js +10 -0
  29. package/dist/visit.js +4 -0
  30. package/package.json +16 -6
  31. package/run-tests.sh +21 -6
  32. package/src/add/add-addon-bans.ts +0 -85
  33. package/src/add/add-agent-scorer.test.ts +0 -197
  34. package/src/add/add-agent-scorer.ts +0 -83
  35. package/src/add/add-agent.test.ts +0 -87
  36. package/src/add/add-agent.ts +0 -638
  37. package/src/add/add-approval-description.ts +0 -76
  38. package/src/add/add-auth.test.ts +0 -791
  39. package/src/add/add-auth.ts +0 -424
  40. package/src/add/add-channel.ts +0 -778
  41. package/src/add/add-cli-renderers.test.ts +0 -74
  42. package/src/add/add-cli.ts +0 -1006
  43. package/src/add/add-credential.ts +0 -186
  44. package/src/add/add-feature.test.ts +0 -306
  45. package/src/add/add-feature.ts +0 -253
  46. package/src/add/add-file-extends-core-type.ts +0 -66
  47. package/src/add/add-file-with-config.ts +0 -45
  48. package/src/add/add-file-with-factory.test.ts +0 -134
  49. package/src/add/add-file-with-factory.ts +0 -288
  50. package/src/add/add-functions.test.ts +0 -743
  51. package/src/add/add-functions.ts +0 -1548
  52. package/src/add/add-gateway.ts +0 -143
  53. package/src/add/add-http-route-ref.test.ts +0 -149
  54. package/src/add/add-http-route.ts +0 -530
  55. package/src/add/add-http-routes.ts +0 -471
  56. package/src/add/add-keyed-wiring.ts +0 -180
  57. package/src/add/add-mcp-prompt.ts +0 -157
  58. package/src/add/add-mcp-resource.ts +0 -175
  59. package/src/add/add-middleware.test.ts +0 -196
  60. package/src/add/add-middleware.ts +0 -879
  61. package/src/add/add-permission-auth.test.ts +0 -59
  62. package/src/add/add-permission.ts +0 -311
  63. package/src/add/add-personas.test.ts +0 -406
  64. package/src/add/add-personas.ts +0 -287
  65. package/src/add/add-queue-worker.ts +0 -117
  66. package/src/add/add-rpc-invocations.test.ts +0 -287
  67. package/src/add/add-rpc-invocations.ts +0 -221
  68. package/src/add/add-scenario-step.test.ts +0 -509
  69. package/src/add/add-schedule.ts +0 -120
  70. package/src/add/add-scope.test.ts +0 -419
  71. package/src/add/add-scope.ts +0 -221
  72. package/src/add/add-secret.ts +0 -10
  73. package/src/add/add-subject-scenario-steps.test.ts +0 -161
  74. package/src/add/add-system-role.test.ts +0 -297
  75. package/src/add/add-system-role.ts +0 -162
  76. package/src/add/add-trigger.ts +0 -165
  77. package/src/add/add-variable.ts +0 -10
  78. package/src/add/add-wire-addon.test.ts +0 -185
  79. package/src/add/add-wire-addon.ts +0 -146
  80. package/src/add/add-wire-remote-addon.ts +0 -80
  81. package/src/add/add-workflow-edge-cases.test.ts +0 -201
  82. package/src/add/add-workflow-fanout.test.ts +0 -166
  83. package/src/add/add-workflow-graph-foreach.test.ts +0 -174
  84. package/src/add/add-workflow-graph-input.test.ts +0 -94
  85. package/src/add/add-workflow-graph-notes.test.ts +0 -74
  86. package/src/add/add-workflow-graph.ts +0 -644
  87. package/src/add/add-workflow-on-error.test.ts +0 -118
  88. package/src/add/add-workflow-return-switch.test.ts +0 -121
  89. package/src/add/add-workflow-silent-drops.test.ts +0 -261
  90. package/src/add/add-workflow-sleep-expression.test.ts +0 -96
  91. package/src/add/add-workflow.test.ts +0 -155
  92. package/src/add/add-workflow.ts +0 -482
  93. package/src/add/addon-bans.test.ts +0 -121
  94. package/src/add/addon-contracts.test.ts +0 -221
  95. package/src/add/allow-escape-hatches.test.ts +0 -144
  96. package/src/add/audit-meta.test.ts +0 -75
  97. package/src/add/classified-sink-check.test.ts +0 -347
  98. package/src/add/config-value-metadata.test.ts +0 -263
  99. package/src/add/dynamic-import-check.test.ts +0 -119
  100. package/src/add/expect-eventually-scenario-only.test.ts +0 -108
  101. package/src/add/function-body-span.test.ts +0 -96
  102. package/src/add/inline-wiring-function.test.ts +0 -104
  103. package/src/add/pii-check.test.ts +0 -258
  104. package/src/add/rpc-type-cast.test.ts +0 -123
  105. package/src/add/scenario-instrumentation.test.ts +0 -89
  106. package/src/add/scenario-instrumentation.ts +0 -42
  107. package/src/add/scenario-marker.test.ts +0 -89
  108. package/src/add/scenario-not-extractable.test.ts +0 -159
  109. package/src/add/scenario-services.test.ts +0 -103
  110. package/src/add/scenario-skip.test.ts +0 -83
  111. package/src/add/validate-workflow-graph-addons.test.ts +0 -102
  112. package/src/add/wire-name-literal.test.ts +0 -117
  113. package/src/error-codes-emitted.test.ts +0 -76
  114. package/src/error-codes.ts +0 -145
  115. package/src/index.ts +0 -66
  116. package/src/inspector.ts +0 -575
  117. package/src/schema-generator.ts +0 -1
  118. package/src/source-file-cache.ts +0 -73
  119. package/src/tsconfig-paths.test.ts +0 -90
  120. package/src/types-map.ts +0 -141
  121. package/src/types.ts +0 -775
  122. package/src/utils/annotate-http-route-auth.test.ts +0 -161
  123. package/src/utils/annotate-http-route-auth.ts +0 -53
  124. package/src/utils/check-classified-sinks.ts +0 -215
  125. package/src/utils/check-pii-output.ts +0 -120
  126. package/src/utils/compute-diagnostics.test.ts +0 -106
  127. package/src/utils/compute-required-schemas.ts +0 -48
  128. package/src/utils/contract-hashes.test.ts +0 -553
  129. package/src/utils/contract-hashes.ts +0 -387
  130. package/src/utils/custom-types-generator.test.ts +0 -124
  131. package/src/utils/custom-types-generator.ts +0 -150
  132. package/src/utils/detect-schema-vendor.test.ts +0 -123
  133. package/src/utils/detect-schema-vendor.ts +0 -120
  134. package/src/utils/does-type-extend-core-type.ts +0 -53
  135. package/src/utils/ensure-function-metadata.ts +0 -385
  136. package/src/utils/extract-function-name.ts +0 -612
  137. package/src/utils/extract-function-node.ts +0 -70
  138. package/src/utils/extract-node-value.test.ts +0 -117
  139. package/src/utils/extract-node-value.ts +0 -172
  140. package/src/utils/extract-secret-usage.test.ts +0 -81
  141. package/src/utils/extract-secret-usage.ts +0 -73
  142. package/src/utils/extract-services.ts +0 -70
  143. package/src/utils/extract-surface-usage.test.ts +0 -147
  144. package/src/utils/extract-surface-usage.ts +0 -121
  145. package/src/utils/filter-inspector-state.test.ts +0 -2232
  146. package/src/utils/filter-inspector-state.ts +0 -1298
  147. package/src/utils/filter-utils.test.ts +0 -843
  148. package/src/utils/filter-utils.ts +0 -161
  149. package/src/utils/find-root-dir.ts +0 -68
  150. package/src/utils/find-zod-transform.test.ts +0 -70
  151. package/src/utils/find-zod-transform.ts +0 -103
  152. package/src/utils/get-exported-variable-name.ts +0 -48
  153. package/src/utils/get-files-and-methods.ts +0 -157
  154. package/src/utils/get-property-value.test.ts +0 -141
  155. package/src/utils/get-property-value.ts +0 -331
  156. package/src/utils/hash.ts +0 -26
  157. package/src/utils/literal-properties.ts +0 -97
  158. package/src/utils/load-addon-functions-meta.test.ts +0 -157
  159. package/src/utils/load-addon-functions-meta.ts +0 -508
  160. package/src/utils/load-addon-scopes.test.ts +0 -138
  161. package/src/utils/middleware.test.ts +0 -204
  162. package/src/utils/middleware.ts +0 -303
  163. package/src/utils/nested-project-filter.test.ts +0 -53
  164. package/src/utils/nested-project-filter.ts +0 -28
  165. package/src/utils/permissions-shorthand.test.ts +0 -79
  166. package/src/utils/permissions.test.ts +0 -125
  167. package/src/utils/permissions.ts +0 -129
  168. package/src/utils/post-process.test.ts +0 -1094
  169. package/src/utils/post-process.ts +0 -1604
  170. package/src/utils/resolve-addon-package.ts +0 -106
  171. package/src/utils/resolve-core-type.test.ts +0 -142
  172. package/src/utils/resolve-core-type.ts +0 -35
  173. package/src/utils/resolve-deploy-target.test.ts +0 -135
  174. package/src/utils/resolve-deploy-target.ts +0 -65
  175. package/src/utils/resolve-function-meta.ts +0 -25
  176. package/src/utils/resolve-function-types.ts +0 -42
  177. package/src/utils/resolve-identifier.ts +0 -46
  178. package/src/utils/resolve-ref-contract.ts +0 -71
  179. package/src/utils/resolve-versions.test.ts +0 -499
  180. package/src/utils/resolve-versions.ts +0 -228
  181. package/src/utils/schema-cache.test.ts +0 -124
  182. package/src/utils/schema-generator.test.ts +0 -272
  183. package/src/utils/schema-generator.ts +0 -780
  184. package/src/utils/secret-alias-services.test.ts +0 -146
  185. package/src/utils/secret-alias-services.ts +0 -58
  186. package/src/utils/serialize-inspector-state.ts +0 -742
  187. package/src/utils/serialize-mcp-json.ts +0 -150
  188. package/src/utils/serialize-middleware-groups-meta.ts +0 -36
  189. package/src/utils/serialize-openapi-json.ts +0 -277
  190. package/src/utils/serialize-permissions-groups-meta.ts +0 -7
  191. package/src/utils/single-declaration.ts +0 -57
  192. package/src/utils/type-utils.ts +0 -145
  193. package/src/utils/validate-auth-sessionless.ts +0 -30
  194. package/src/utils/validate-exposed-functions-gated.test.ts +0 -230
  195. package/src/utils/validate-exposed-functions-gated.ts +0 -103
  196. package/src/utils/validate-tags-resolve-to-middleware.test.ts +0 -108
  197. package/src/utils/validate-tags-resolve-to-middleware.ts +0 -48
  198. package/src/utils/workflow/derive-workflow-plan.test.ts +0 -178
  199. package/src/utils/workflow/derive-workflow-plan.ts +0 -117
  200. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +0 -1465
  201. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +0 -71
  202. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +0 -99
  203. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +0 -161
  204. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +0 -121
  205. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +0 -116
  206. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +0 -145
  207. package/src/utils/workflow/dsl/extract-approval-policy.test.ts +0 -71
  208. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +0 -2254
  209. package/src/utils/workflow/dsl/index.ts +0 -11
  210. package/src/utils/workflow/dsl/patterns.ts +0 -440
  211. package/src/utils/workflow/dsl/validation.ts +0 -203
  212. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +0 -35
  213. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +0 -543
  214. package/src/utils/workflow/graph/fanout-node-identity.test.ts +0 -105
  215. package/src/utils/workflow/graph/finalize-workflow-wires.ts +0 -310
  216. package/src/utils/workflow/graph/finalize-workflows.test.ts +0 -50
  217. package/src/utils/workflow/graph/finalize-workflows.ts +0 -130
  218. package/src/utils/workflow/graph/index.ts +0 -28
  219. package/src/utils/workflow/graph/serialize-workflow-graph.ts +0 -240
  220. package/src/utils/workflow/graph/workflow-graph.types.ts +0 -281
  221. package/src/visit.ts +0 -181
  222. package/tsconfig.json +0 -19
  223. package/tsconfig.tsbuildinfo +0 -1
@@ -1,161 +0,0 @@
1
- import assert from 'node:assert/strict'
2
- import { describe, test } from 'node:test'
3
- import { annotateHttpRouteAuth } from './annotate-http-route-auth.js'
4
-
5
- const ADDON_PACKAGE = '@addon/console'
6
-
7
- /**
8
- * A single GET route plus the function it points at, with the four inputs the
9
- * resolution reads spread across both.
10
- */
11
- const resolve = (
12
- route: Record<string, unknown>,
13
- meta: Record<string, unknown> = {},
14
- addons: Record<string, unknown> = {}
15
- ): boolean | undefined => {
16
- const state = {
17
- functions: { meta: { handler: { pikkuFuncId: 'handler', ...meta } } },
18
- http: {
19
- meta: {
20
- get: {
21
- '/thing': {
22
- pikkuFuncId: 'handler',
23
- route: '/thing',
24
- method: 'get',
25
- ...route,
26
- },
27
- },
28
- },
29
- },
30
- rpc: { wireAddonDeclarations: new Map(Object.entries(addons)) },
31
- } as any
32
-
33
- annotateHttpRouteAuth(state)
34
- return state.http.meta.get['/thing'].requiresSession
35
- }
36
-
37
- describe('annotateHttpRouteAuth', () => {
38
- test('a pikkuFunc always requires a session', () => {
39
- assert.equal(resolve({}, { sessionless: false }), true)
40
- })
41
-
42
- test('a sessionless function on a plain route does not', () => {
43
- assert.equal(resolve({}, { sessionless: true }), false)
44
- })
45
-
46
- test("the function's own auth requires one", () => {
47
- assert.equal(resolve({}, { sessionless: true, auth: true }), true)
48
- })
49
-
50
- test("the route's auth requires one, even of a sessionless function", () => {
51
- assert.equal(resolve({ auth: true }, { sessionless: true }), true)
52
- })
53
-
54
- test('an explicit auth: false on the route does not undo the function', () => {
55
- // Layers only ever tighten. A route cannot open a pikkuFunc back up.
56
- assert.equal(resolve({ auth: false }, { sessionless: false }), true)
57
- })
58
-
59
- test('function scopes require one', () => {
60
- // Scopes are matched against the session's and fail closed, so an
61
- // anonymous caller is rejected — reporting the route as open would be a lie.
62
- assert.equal(resolve({}, { sessionless: true, scopes: ['admin'] }), true)
63
- })
64
-
65
- test("the addon's auth requires one", () => {
66
- assert.equal(
67
- resolve(
68
- { packageName: ADDON_PACKAGE },
69
- { sessionless: true },
70
- { console: { package: ADDON_PACKAGE, auth: true } }
71
- ),
72
- true
73
- )
74
- })
75
-
76
- test("the addon's scopes require one", () => {
77
- assert.equal(
78
- resolve(
79
- { packageName: ADDON_PACKAGE },
80
- { sessionless: true },
81
- { console: { package: ADDON_PACKAGE, scopes: ['admin'] } }
82
- ),
83
- true
84
- )
85
- })
86
-
87
- test("another package's addon gate does not reach this route", () => {
88
- assert.equal(
89
- resolve(
90
- { packageName: '@app/api' },
91
- { sessionless: true },
92
- { console: { package: ADDON_PACKAGE, auth: true } }
93
- ),
94
- false
95
- )
96
- })
97
-
98
- test('an ungated addon leaves the route as its function found it', () => {
99
- assert.equal(
100
- resolve(
101
- { packageName: ADDON_PACKAGE },
102
- { sessionless: true },
103
- { console: { package: ADDON_PACKAGE } }
104
- ),
105
- false
106
- )
107
- })
108
-
109
- test('a ref route resolves against its target', () => {
110
- const state = {
111
- functions: { meta: {} },
112
- addonFunctions: {
113
- console: { target: { pikkuFuncId: 'target', sessionless: false } },
114
- },
115
- http: {
116
- meta: {
117
- get: {
118
- '/thing': {
119
- pikkuFuncId: 'console:target',
120
- packageName: ADDON_PACKAGE,
121
- route: '/thing',
122
- method: 'get',
123
- },
124
- },
125
- },
126
- },
127
- rpc: { wireAddonDeclarations: new Map() },
128
- } as any
129
-
130
- annotateHttpRouteAuth(state)
131
- assert.equal(state.http.meta.get['/thing'].requiresSession, true)
132
- })
133
-
134
- test('a route whose function meta is missing is not reported as open', () => {
135
- const state = {
136
- functions: { meta: {} },
137
- http: {
138
- meta: {
139
- get: {
140
- '/thing': {
141
- pikkuFuncId: 'gone',
142
- route: '/thing',
143
- method: 'get',
144
- auth: true,
145
- },
146
- },
147
- },
148
- },
149
- rpc: { wireAddonDeclarations: new Map() },
150
- } as any
151
-
152
- annotateHttpRouteAuth(state)
153
- assert.equal(state.http.meta.get['/thing'].requiresSession, true)
154
- })
155
-
156
- test('tolerates http state being absent', () => {
157
- assert.doesNotThrow(() =>
158
- annotateHttpRouteAuth({ functions: { meta: {} } } as any)
159
- )
160
- })
161
- })
@@ -1,53 +0,0 @@
1
- import type { InspectorState } from '../types.js'
2
- import { resolveFunctionMeta } from './resolve-function-meta.js'
3
-
4
- /**
5
- * Resolve, per HTTP route, whether reaching it requires a session.
6
- *
7
- * Four separate places can demand one — the function's `sessionless`, the
8
- * function's own `auth`, the route's (or its group's) `auth`, and the addon the
9
- * function belongs to — and they combine with OR: any of them is enough. Until
10
- * this was written down, "which routes are open?" could only be answered by
11
- * joining all four by hand, and getting the join wrong reads an open route as a
12
- * closed one, which is the direction that costs something.
13
- *
14
- * A scope gate counts as requiring a session. Scopes are checked against the
15
- * session's and fail closed, so an anonymous caller is rejected either way; not
16
- * counting them would mark a route open that nobody can reach anonymously.
17
- *
18
- * Anything not statically knowable resolves to requiring a session, matching
19
- * `validateExposedFunctionsGated`: an over-cautious `true` costs a route that
20
- * looks stricter than it is, a wrong `false` costs an audit.
21
- *
22
- * knowledge: decisions/security/addon-auth-and-tags-only-tighten.md
23
- */
24
- export function annotateHttpRouteAuth(state: InspectorState): void {
25
- const gatedPackages = packagesGatedByTheirAddon(state)
26
-
27
- for (const routes of Object.values(state.http?.meta ?? {})) {
28
- for (const route of Object.values(routes)) {
29
- const meta = resolveFunctionMeta(state, route.pikkuFuncId)
30
-
31
- route.requiresSession =
32
- // A pikkuFunc always requires a session, on every path.
33
- meta?.sessionless === false ||
34
- meta?.auth === true ||
35
- (meta?.scopes?.length ?? 0) > 0 ||
36
- route.auth === true ||
37
- (route.packageName !== undefined &&
38
- gatedPackages.has(route.packageName))
39
- }
40
- }
41
- }
42
-
43
- /** Packages whose `wireAddon` demands a session from everything inside them. */
44
- function packagesGatedByTheirAddon(state: InspectorState): Set<string> {
45
- const gated = new Set<string>()
46
- for (const declaration of state.rpc?.wireAddonDeclarations?.values() ?? []) {
47
- if (!declaration.package) continue
48
- if (declaration.auth === true || (declaration.scopes?.length ?? 0) > 0) {
49
- gated.add(declaration.package)
50
- }
51
- }
52
- return gated
53
- }
@@ -1,215 +0,0 @@
1
- import * as ts from 'typescript'
2
- import { findPiiPaths } from './check-pii-output.js'
3
- import { doesTypeExtendsCore } from './does-type-extend-core-type.js'
4
-
5
- export type SinkClassification = 'secret' | 'pii'
6
-
7
- export type SinkViolation = {
8
- /** How the value reaches the outside world, e.g. `logger.info`. */
9
- sink: string
10
- /** Dotted path to the secret inside the argument, or `<argument>` when bare. */
11
- path: string
12
- /** Position of the offending argument, for the diagnostic message. */
13
- line: number
14
- /** Which brand was found, so the caller can pick the diagnostic. */
15
- classification: SinkClassification
16
- }
17
-
18
- /**
19
- * A method that writes its arguments somewhere a classified value must never
20
- * land, keyed by the core interface that owns it. `'*'` means every method on
21
- * the type is a sink, which is true of a logger.
22
- *
23
- * `rejects` differs per sink because the two brands leak differently. A vault
24
- * secret is wrong everywhere: no sink has a legitimate reason to receive one.
25
- * PII is wrong only where it leaves the operator's control, or lands somewhere
26
- * never designed to hold it:
27
- *
28
- * - a log is read far more widely than the record it came from, is usually
29
- * shipped to a third-party aggregator, and is retained long past the
30
- * lifetime of any consent — so PII does not belong in one;
31
- * - a webhook posts to somebody else's server, which is the same disclosure
32
- * with a different transport;
33
- * - an email is addressed to the data subject, and their own name in it is
34
- * the point;
35
- * - an audit is a record in the operator's own database, and naming who did
36
- * what is usually the legal requirement rather than a breach of it;
37
- * - a queue payload stays on the operator's own infrastructure and is
38
- * consumed by their own worker.
39
- *
40
- * `private` is deliberately absent everywhere: it marks a value that is not
41
- * public, which is a statement about who may read the row — not a claim that
42
- * writing it down is a disclosure.
43
- */
44
- const SINKS: Array<{
45
- coreType: string
46
- methods: '*' | string[]
47
- rejects: SinkClassification[]
48
- }> = [
49
- { coreType: 'Logger', methods: '*', rejects: ['secret', 'pii'] },
50
- { coreType: 'WebhookService', methods: ['send'], rejects: ['secret', 'pii'] },
51
- { coreType: 'QueueService', methods: ['add'], rejects: ['secret'] },
52
- { coreType: 'EmailService', methods: ['send'], rejects: ['secret'] },
53
- {
54
- coreType: 'AuditService',
55
- methods: ['audit', 'write'],
56
- rejects: ['secret'],
57
- },
58
- { coreType: 'AuditLog', methods: ['write'], rejects: ['secret'] },
59
- ]
60
-
61
- /** `console` has no Pikku type, and is read as widely as any logger. */
62
- const CONSOLE_REJECTS: SinkClassification[] = ['secret', 'pii']
63
-
64
- /**
65
- * Finds revealed vault secrets — and, for the sinks that reject them, PII
66
- * columns — that flow into a sink.
67
- *
68
- * `SecretValue` is nominally typed, so the type system alone stops it reaching
69
- * any of these. `.reveal()` is the deliberate escape hatch, and it hands back
70
- * `Secret<T>` — the erasable classification brand. That brand is what this scan
71
- * follows, which means it catches the revealed value both inline
72
- * (`logger.info(s.reveal())`) and through a local binding, because the local's
73
- * inferred type carries the brand too.
74
- *
75
- * PII arrives by the same route without a `.reveal()`: `Pii<T>` is the brand the
76
- * generated row types carry, so a column read straight out of the database keeps
77
- * it through a local binding and into the call.
78
- *
79
- * The brand is optional by design, so an explicit annotation (`const t: string =
80
- * s.reveal()`, or `const email: string = user.email`) erases it and this scan
81
- * goes quiet. That is the documented limit
82
- * of a structural marker: it catches the mistake, not someone determined to
83
- * silence it.
84
- *
85
- * Sinks are matched on the receiver's resolved core interface, which means the
86
- * scan needs the services parameter to be typed. Real projects get that from
87
- * the generated `#pikku` factory; a hand-rolled file that calls the raw core
88
- * factory without annotating its services leaves the parameter `any`, and an
89
- * `any` receiver matches nothing.
90
- */
91
- export function findClassifiedSinks(
92
- checker: ts.TypeChecker,
93
- handler: ts.Node
94
- ): SinkViolation[] {
95
- const violations: SinkViolation[] = []
96
-
97
- const visit = (node: ts.Node): void => {
98
- if (
99
- ts.isCallExpression(node) &&
100
- ts.isPropertyAccessExpression(node.expression)
101
- ) {
102
- const sink = describeSink(checker, node.expression)
103
- if (sink) {
104
- for (const arg of node.arguments) {
105
- const found = findClassifiedExpression(checker, arg, sink.rejects)
106
- if (found) {
107
- violations.push({
108
- sink: sink.name,
109
- path: found.node.getText(),
110
- line:
111
- found.node
112
- .getSourceFile()
113
- .getLineAndCharacterOfPosition(found.node.getStart()).line +
114
- 1,
115
- classification: found.classification,
116
- })
117
- }
118
- }
119
- }
120
- }
121
- ts.forEachChild(node, visit)
122
- }
123
- visit(handler)
124
-
125
- return violations
126
- }
127
-
128
- /**
129
- * Finds the first expression inside a sink argument whose own type carries one
130
- * of the brands this sink rejects, and returns it so the diagnostic can quote
131
- * it.
132
- *
133
- * This walks the argument rather than typing it whole, because an object
134
- * literal in an argument position is contextually typed by the parameter — for
135
- * a guarded sink that parameter is `Safe<M>`, and resolving the literal against
136
- * it drops the optional brand before it can be read. Each inner expression
137
- * (`token.reveal()`, or a reference to a local holding it) carries its own type
138
- * regardless of the surrounding context, so reading those is what actually
139
- * sees the secret.
140
- */
141
- function findClassifiedExpression(
142
- checker: ts.TypeChecker,
143
- node: ts.Node,
144
- rejects: SinkClassification[],
145
- /**
146
- * The node is only being read to reach a property of it, so its own type says
147
- * nothing about what the call receives. Without this, `user.userId` reports
148
- * the row's `email` column: the walk types the `user` receiver on its way
149
- * down, and a row type is branded wherever any of its columns are.
150
- */
151
- isReceiver = false
152
- ): { node: ts.Expression; classification: SinkClassification } | undefined {
153
- // Descend first, so an object literal wrapping the secret reports the
154
- // expression that actually holds it (`token.reveal()`) rather than the whole
155
- // literal. Nothing inside a `SecretValue` receiver is itself branded, so a
156
- // bare `token.reveal()` still falls through to the self-check below.
157
- for (const child of node.getChildren()) {
158
- // A property name is not a value, and typing it re-resolves the property.
159
- if (ts.isPropertyAssignment(node) && child === node.name) continue
160
- const childIsReceiver =
161
- (ts.isPropertyAccessExpression(node) ||
162
- ts.isElementAccessExpression(node)) &&
163
- child === node.expression
164
- const found = findClassifiedExpression(
165
- checker,
166
- child,
167
- rejects,
168
- childIsReceiver
169
- )
170
- if (found) return found
171
- }
172
-
173
- if (!isReceiver && ts.isExpression(node) && !ts.isStringLiteralLike(node)) {
174
- const type = checker.getTypeAtLocation(node)
175
- /* `secret` outranks `pii` when a value somehow carries both, so the caller
176
- reports the more serious of the two rather than whichever came first. */
177
- for (const classification of ['secret', 'pii'] as const) {
178
- if (!rejects.includes(classification)) continue
179
- const hit = findPiiPaths(checker, type).some(
180
- (f) => f.classification === classification
181
- )
182
- if (hit) return { node, classification }
183
- }
184
- }
185
-
186
- return undefined
187
- }
188
-
189
- /**
190
- * Names the sink a call writes to, or undefined when the call is not one.
191
- * `console` is matched by identifier because it is a global with no Pikku type;
192
- * everything else is matched on its resolved core interface so a renamed
193
- * service or a decorator still counts.
194
- */
195
- function describeSink(
196
- checker: ts.TypeChecker,
197
- callee: ts.PropertyAccessExpression
198
- ): { name: string; rejects: SinkClassification[] } | undefined {
199
- const method = callee.name.text
200
- const receiver = callee.expression
201
-
202
- if (ts.isIdentifier(receiver) && receiver.text === 'console') {
203
- return { name: `console.${method}`, rejects: CONSOLE_REJECTS }
204
- }
205
-
206
- const receiverType = checker.getTypeAtLocation(receiver)
207
- for (const { coreType, methods, rejects } of SINKS) {
208
- if (methods !== '*' && !methods.includes(method)) continue
209
- if (doesTypeExtendsCore(receiverType, checker, new Set(), coreType)) {
210
- return { name: `${receiver.getText()}.${method}`, rejects }
211
- }
212
- }
213
-
214
- return undefined
215
- }
@@ -1,120 +0,0 @@
1
- import * as ts from 'typescript'
2
-
3
- export type ClassifiedField = {
4
- path: string
5
- classification: 'private' | 'pii' | 'secret' | string
6
- }
7
-
8
- /**
9
- * Recursively walks a resolved TypeScript type looking for `__classification__` brands —
10
- * the structural marker emitted by `Private<T>`, `Pii<T>`, and `Secret<T>`.
11
- *
12
- * `Private<T> = T & { readonly __classification__: 'private' }` shows up in the TS type
13
- * system as an intersection whose constituents include a type with a `__classification__`
14
- * property. We detect that by checking whether any constituent of an
15
- * intersection exposes a property named `__classification__`.
16
- *
17
- * Returns the list of classified fields found, each with its dotted path and
18
- * classification level (e.g. `[{ path: 'email', classification: 'private' }]`).
19
- * An empty array means clean.
20
- */
21
- export function findPiiPaths(
22
- checker: ts.TypeChecker,
23
- type: ts.Type,
24
- path = '',
25
- depth = 0,
26
- seen = new Set<ts.Type>()
27
- ): ClassifiedField[] {
28
- if (depth > 8 || seen.has(type)) return []
29
- seen.add(type)
30
-
31
- // ── Is this type itself branded? ─────────────────────────────────────────
32
- // Private<T> = T & { readonly __classification__?: 'private' } → isIntersection()
33
- // where one constituent has a `__classification__` property whose type is a
34
- // string literal. The marker is OPTIONAL (so plain values stay assignable to
35
- // branded columns), which means its resolved type is `'private' | undefined` —
36
- // a union, not a bare literal. Read the level union-aware via `literalString`,
37
- // otherwise pii/secret silently downgrade to the `'private'` fallback.
38
- if (type.isIntersection()) {
39
- for (const t of type.types) {
40
- const classificationProp = t
41
- .getProperties()
42
- .find((p) => p.name === '__classification__')
43
- if (classificationProp) {
44
- const decl =
45
- classificationProp.valueDeclaration ??
46
- classificationProp.declarations?.[0]
47
- const classification = decl
48
- ? (literalString(
49
- checker.getTypeOfSymbolAtLocation(classificationProp, decl)
50
- ) ?? 'private')
51
- : 'private'
52
- return [{ path: path || '<return value>', classification }]
53
- }
54
- }
55
- }
56
-
57
- const violations: ClassifiedField[] = []
58
-
59
- // ── Union: check every branch ─────────────────────────────────────────────
60
- if (type.isUnion()) {
61
- for (const branch of type.types) {
62
- violations.push(...findPiiPaths(checker, branch, path, depth, seen))
63
- }
64
- return violations
65
- }
66
-
67
- // ── Object: recurse into named properties ─────────────────────────────────
68
- if (type.flags & ts.TypeFlags.Object) {
69
- const ref = type as ts.TypeReference
70
- for (const arg of (ref as any).typeArguments ?? []) {
71
- violations.push(...findPiiPaths(checker, arg, path, depth + 1, seen))
72
- }
73
-
74
- const numberIndex = checker.getIndexTypeOfType(type, ts.IndexKind.Number)
75
- if (numberIndex) {
76
- const idxPath = path ? `${path}[]` : '[]'
77
- violations.push(
78
- ...findPiiPaths(checker, numberIndex, idxPath, depth + 1, seen)
79
- )
80
- }
81
- const stringIndex = checker.getIndexTypeOfType(type, ts.IndexKind.String)
82
- if (stringIndex) {
83
- const idxPath = path ? `${path}[*]` : '[*]'
84
- violations.push(
85
- ...findPiiPaths(checker, stringIndex, idxPath, depth + 1, seen)
86
- )
87
- }
88
-
89
- for (const prop of type.getProperties()) {
90
- if (prop.name.startsWith('__')) continue
91
- const decl = prop.valueDeclaration ?? prop.declarations?.[0]
92
- if (!decl) continue
93
- const propType = checker.getTypeOfSymbolAtLocation(prop, decl)
94
- const subPath = path ? `${path}.${prop.name}` : prop.name
95
- violations.push(
96
- ...findPiiPaths(checker, propType, subPath, depth + 1, seen)
97
- )
98
- }
99
- }
100
-
101
- return violations
102
- }
103
-
104
- /**
105
- * Recover a string-literal value from a type that may be the literal itself or a
106
- * union containing it (e.g. `'private' | undefined`, produced by the optional
107
- * `__classification__?` marker). Returns undefined when no string literal is
108
- * present so the caller can apply its own fallback.
109
- */
110
- function literalString(type: ts.Type): string | undefined {
111
- const value = (type as { value?: unknown }).value
112
- if (typeof value === 'string') return value
113
- if (type.isUnion()) {
114
- for (const member of type.types) {
115
- const found = literalString(member)
116
- if (found !== undefined) return found
117
- }
118
- }
119
- return undefined
120
- }
@@ -1,106 +0,0 @@
1
- import { test, describe } from 'node:test'
2
- import { strict as assert } from 'node:assert'
3
- import { computeDiagnostics } from './post-process.js'
4
- import type { InspectorState } from '../types.js'
5
- import { ErrorCode } from '../error-codes.js'
6
-
7
- function stateWithFunctions(
8
- meta: InspectorState['functions']['meta'],
9
- dynamicImportIds: string[] = []
10
- ): InspectorState {
11
- return {
12
- functions: { meta, dynamicImportIds: new Set(dynamicImportIds) },
13
- middleware: { definitions: {} },
14
- permissions: { definitions: {} },
15
- } as unknown as InspectorState
16
- }
17
-
18
- describe('computeDiagnostics', () => {
19
- test('flags a user-authored function that does not destructure services', () => {
20
- const state = stateWithFunctions({
21
- myFunc: {
22
- pikkuFuncId: 'myFunc',
23
- inputSchemaName: null,
24
- outputSchemaName: null,
25
- sourceFile: '/project/src/my-func.ts',
26
- services: { optimized: false, services: ['kysely'] },
27
- },
28
- })
29
- computeDiagnostics(state)
30
- assert.equal(state.diagnostics.length, 1)
31
- assert.equal(state.diagnostics[0].code, ErrorCode.SERVICES_NOT_DESTRUCTURED)
32
- })
33
-
34
- test('does NOT flag a generated .gen.ts function (user cannot edit it)', () => {
35
- const state = stateWithFunctions({
36
- cliRaw: {
37
- pikkuFuncId: 'cliRaw',
38
- inputSchemaName: null,
39
- outputSchemaName: null,
40
- sourceFile: '/project/src/wirings/cli-channel.gen.ts',
41
- services: { optimized: false, services: ['kysely'] },
42
- },
43
- authHandler: {
44
- pikkuFuncId: 'authHandler',
45
- inputSchemaName: null,
46
- outputSchemaName: null,
47
- sourceFile: '/project/.pikku/auth.gen.ts',
48
- wires: { optimized: false, wires: ['http'] },
49
- },
50
- })
51
- computeDiagnostics(state)
52
- assert.equal(state.diagnostics.length, 0)
53
- })
54
-
55
- test('does NOT flag a synthetic route bridge with no source file', () => {
56
- const state = stateWithFunctions({
57
- 'http:get:/workflow-run/:runId/stream': {
58
- pikkuFuncId: 'http:get:/workflow-run/:runId/stream',
59
- inputSchemaName: null,
60
- outputSchemaName: null,
61
- services: { optimized: false, services: [] },
62
- },
63
- })
64
- computeDiagnostics(state)
65
- assert.equal(state.diagnostics.length, 0)
66
- })
67
-
68
- test('flags a user-authored function that does a dynamic import in its body', () => {
69
- const state = stateWithFunctions(
70
- {
71
- greedy: {
72
- pikkuFuncId: 'greedy',
73
- inputSchemaName: null,
74
- outputSchemaName: null,
75
- sourceFile: '/project/src/greedy.ts',
76
- services: { optimized: true, services: ['logger'] },
77
- },
78
- },
79
- ['greedy']
80
- )
81
- computeDiagnostics(state)
82
- assert.equal(
83
- state.diagnostics.filter(
84
- (d) => d.code === ErrorCode.FUNCTION_DYNAMIC_IMPORT
85
- ).length,
86
- 1
87
- )
88
- })
89
-
90
- test('does NOT flag a dynamic import in a generated .gen.ts function', () => {
91
- const state = stateWithFunctions(
92
- {
93
- authHandler: {
94
- pikkuFuncId: 'authHandler',
95
- inputSchemaName: null,
96
- outputSchemaName: null,
97
- sourceFile: '/project/.pikku/auth.gen.ts',
98
- services: { optimized: true, services: [] },
99
- },
100
- },
101
- ['authHandler']
102
- )
103
- computeDiagnostics(state)
104
- assert.equal(state.diagnostics.length, 0)
105
- })
106
- })