@pikku/inspector 0.12.75 → 0.12.81
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 +330 -0
- package/dist/add/add-analytics.d.ts +9 -0
- package/dist/add/add-analytics.js +178 -0
- package/dist/add/add-auth.js +0 -54
- package/dist/add/add-feature-flag.d.ts +2 -0
- package/dist/add/add-feature-flag.js +162 -0
- package/dist/add/add-wire-addon.js +1 -0
- package/dist/add/add-wire-remote-addon.js +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/inspector.js +4 -0
- package/dist/surface/collect-surface.d.ts +58 -0
- package/dist/surface/collect-surface.js +613 -0
- package/dist/types.d.ts +20 -11
- package/dist/utils/filter-inspector-state.js +14 -0
- package/dist/utils/post-process.js +15 -0
- package/dist/utils/read-module-specifiers.d.ts +8 -0
- package/dist/utils/read-module-specifiers.js +23 -0
- package/dist/utils/read-tsconfig-out-dir.d.ts +5 -0
- package/dist/utils/read-tsconfig-out-dir.js +12 -0
- package/dist/utils/resolve-deploy-target.d.ts +7 -0
- package/dist/utils/resolve-deploy-target.js +15 -7
- package/dist/utils/serialize-inspector-state.d.ts +5 -0
- package/dist/utils/serialize-inspector-state.js +8 -0
- package/dist/visit.js +4 -0
- package/package.json +16 -6
- package/run-tests.sh +21 -6
- package/src/add/add-addon-bans.ts +0 -85
- package/src/add/add-agent-scorer.test.ts +0 -197
- package/src/add/add-agent-scorer.ts +0 -83
- package/src/add/add-agent.test.ts +0 -87
- package/src/add/add-agent.ts +0 -638
- package/src/add/add-approval-description.ts +0 -76
- package/src/add/add-auth.test.ts +0 -791
- package/src/add/add-auth.ts +0 -424
- package/src/add/add-channel.ts +0 -778
- package/src/add/add-cli-renderers.test.ts +0 -74
- package/src/add/add-cli.ts +0 -1006
- package/src/add/add-credential.ts +0 -186
- package/src/add/add-feature.test.ts +0 -306
- package/src/add/add-feature.ts +0 -253
- package/src/add/add-file-extends-core-type.ts +0 -66
- package/src/add/add-file-with-config.ts +0 -45
- package/src/add/add-file-with-factory.test.ts +0 -134
- package/src/add/add-file-with-factory.ts +0 -288
- package/src/add/add-functions.test.ts +0 -743
- package/src/add/add-functions.ts +0 -1548
- package/src/add/add-gateway.ts +0 -143
- package/src/add/add-http-route-ref.test.ts +0 -149
- package/src/add/add-http-route.ts +0 -530
- package/src/add/add-http-routes.ts +0 -471
- package/src/add/add-keyed-wiring.ts +0 -180
- package/src/add/add-mcp-prompt.ts +0 -157
- package/src/add/add-mcp-resource.ts +0 -175
- package/src/add/add-middleware.test.ts +0 -196
- package/src/add/add-middleware.ts +0 -879
- package/src/add/add-permission-auth.test.ts +0 -59
- package/src/add/add-permission.ts +0 -311
- package/src/add/add-personas.test.ts +0 -406
- package/src/add/add-personas.ts +0 -287
- package/src/add/add-queue-worker.ts +0 -117
- package/src/add/add-rpc-invocations.test.ts +0 -287
- package/src/add/add-rpc-invocations.ts +0 -221
- package/src/add/add-scenario-step.test.ts +0 -509
- package/src/add/add-schedule.ts +0 -120
- package/src/add/add-scope.test.ts +0 -419
- package/src/add/add-scope.ts +0 -221
- package/src/add/add-secret.ts +0 -10
- package/src/add/add-subject-scenario-steps.test.ts +0 -161
- package/src/add/add-system-role.test.ts +0 -297
- package/src/add/add-system-role.ts +0 -162
- package/src/add/add-trigger.ts +0 -165
- package/src/add/add-variable.ts +0 -10
- package/src/add/add-wire-addon.test.ts +0 -185
- package/src/add/add-wire-addon.ts +0 -146
- package/src/add/add-wire-remote-addon.ts +0 -80
- package/src/add/add-workflow-edge-cases.test.ts +0 -201
- package/src/add/add-workflow-fanout.test.ts +0 -166
- package/src/add/add-workflow-graph-foreach.test.ts +0 -174
- package/src/add/add-workflow-graph-input.test.ts +0 -94
- package/src/add/add-workflow-graph-notes.test.ts +0 -74
- package/src/add/add-workflow-graph.ts +0 -644
- package/src/add/add-workflow-on-error.test.ts +0 -118
- package/src/add/add-workflow-return-switch.test.ts +0 -121
- package/src/add/add-workflow-silent-drops.test.ts +0 -261
- package/src/add/add-workflow-sleep-expression.test.ts +0 -96
- package/src/add/add-workflow.test.ts +0 -155
- package/src/add/add-workflow.ts +0 -482
- package/src/add/addon-bans.test.ts +0 -121
- package/src/add/addon-contracts.test.ts +0 -221
- package/src/add/allow-escape-hatches.test.ts +0 -144
- package/src/add/audit-meta.test.ts +0 -75
- package/src/add/classified-sink-check.test.ts +0 -347
- package/src/add/config-value-metadata.test.ts +0 -263
- package/src/add/dynamic-import-check.test.ts +0 -119
- package/src/add/expect-eventually-scenario-only.test.ts +0 -108
- package/src/add/function-body-span.test.ts +0 -96
- package/src/add/inline-wiring-function.test.ts +0 -104
- package/src/add/pii-check.test.ts +0 -258
- package/src/add/rpc-type-cast.test.ts +0 -123
- package/src/add/scenario-instrumentation.test.ts +0 -89
- package/src/add/scenario-instrumentation.ts +0 -42
- package/src/add/scenario-marker.test.ts +0 -89
- package/src/add/scenario-not-extractable.test.ts +0 -159
- package/src/add/scenario-services.test.ts +0 -103
- package/src/add/scenario-skip.test.ts +0 -83
- package/src/add/validate-workflow-graph-addons.test.ts +0 -102
- package/src/add/wire-name-literal.test.ts +0 -117
- package/src/error-codes-emitted.test.ts +0 -76
- package/src/error-codes.ts +0 -145
- package/src/index.ts +0 -66
- package/src/inspector.ts +0 -575
- package/src/schema-generator.ts +0 -1
- package/src/source-file-cache.ts +0 -73
- package/src/tsconfig-paths.test.ts +0 -90
- package/src/types-map.ts +0 -141
- package/src/types.ts +0 -775
- package/src/utils/annotate-http-route-auth.test.ts +0 -161
- package/src/utils/annotate-http-route-auth.ts +0 -53
- package/src/utils/check-classified-sinks.ts +0 -215
- package/src/utils/check-pii-output.ts +0 -120
- package/src/utils/compute-diagnostics.test.ts +0 -106
- package/src/utils/compute-required-schemas.ts +0 -48
- package/src/utils/contract-hashes.test.ts +0 -553
- package/src/utils/contract-hashes.ts +0 -387
- package/src/utils/custom-types-generator.test.ts +0 -124
- package/src/utils/custom-types-generator.ts +0 -150
- package/src/utils/detect-schema-vendor.test.ts +0 -123
- package/src/utils/detect-schema-vendor.ts +0 -120
- package/src/utils/does-type-extend-core-type.ts +0 -53
- package/src/utils/ensure-function-metadata.ts +0 -385
- package/src/utils/extract-function-name.ts +0 -612
- package/src/utils/extract-function-node.ts +0 -70
- package/src/utils/extract-node-value.test.ts +0 -117
- package/src/utils/extract-node-value.ts +0 -172
- package/src/utils/extract-secret-usage.test.ts +0 -81
- package/src/utils/extract-secret-usage.ts +0 -73
- package/src/utils/extract-services.ts +0 -70
- package/src/utils/extract-surface-usage.test.ts +0 -147
- package/src/utils/extract-surface-usage.ts +0 -121
- package/src/utils/filter-inspector-state.test.ts +0 -2232
- package/src/utils/filter-inspector-state.ts +0 -1298
- package/src/utils/filter-utils.test.ts +0 -843
- package/src/utils/filter-utils.ts +0 -161
- package/src/utils/find-root-dir.ts +0 -68
- package/src/utils/find-zod-transform.test.ts +0 -70
- package/src/utils/find-zod-transform.ts +0 -103
- package/src/utils/get-exported-variable-name.ts +0 -48
- package/src/utils/get-files-and-methods.ts +0 -157
- package/src/utils/get-property-value.test.ts +0 -141
- package/src/utils/get-property-value.ts +0 -331
- package/src/utils/hash.ts +0 -26
- package/src/utils/literal-properties.ts +0 -97
- package/src/utils/load-addon-functions-meta.test.ts +0 -157
- package/src/utils/load-addon-functions-meta.ts +0 -508
- package/src/utils/load-addon-scopes.test.ts +0 -138
- package/src/utils/middleware.test.ts +0 -204
- package/src/utils/middleware.ts +0 -303
- package/src/utils/nested-project-filter.test.ts +0 -53
- package/src/utils/nested-project-filter.ts +0 -28
- package/src/utils/permissions-shorthand.test.ts +0 -79
- package/src/utils/permissions.test.ts +0 -125
- package/src/utils/permissions.ts +0 -129
- package/src/utils/post-process.test.ts +0 -1094
- package/src/utils/post-process.ts +0 -1604
- package/src/utils/resolve-addon-package.ts +0 -106
- package/src/utils/resolve-core-type.test.ts +0 -142
- package/src/utils/resolve-core-type.ts +0 -35
- package/src/utils/resolve-deploy-target.test.ts +0 -135
- package/src/utils/resolve-deploy-target.ts +0 -65
- package/src/utils/resolve-function-meta.ts +0 -25
- package/src/utils/resolve-function-types.ts +0 -42
- package/src/utils/resolve-identifier.ts +0 -46
- package/src/utils/resolve-ref-contract.ts +0 -71
- package/src/utils/resolve-versions.test.ts +0 -499
- package/src/utils/resolve-versions.ts +0 -228
- package/src/utils/schema-cache.test.ts +0 -124
- package/src/utils/schema-generator.test.ts +0 -272
- package/src/utils/schema-generator.ts +0 -780
- package/src/utils/secret-alias-services.test.ts +0 -146
- package/src/utils/secret-alias-services.ts +0 -58
- package/src/utils/serialize-inspector-state.ts +0 -742
- package/src/utils/serialize-mcp-json.ts +0 -150
- package/src/utils/serialize-middleware-groups-meta.ts +0 -36
- package/src/utils/serialize-openapi-json.ts +0 -277
- package/src/utils/serialize-permissions-groups-meta.ts +0 -7
- package/src/utils/single-declaration.ts +0 -57
- package/src/utils/type-utils.ts +0 -145
- package/src/utils/validate-auth-sessionless.ts +0 -30
- package/src/utils/validate-exposed-functions-gated.test.ts +0 -230
- package/src/utils/validate-exposed-functions-gated.ts +0 -103
- package/src/utils/validate-tags-resolve-to-middleware.test.ts +0 -108
- package/src/utils/validate-tags-resolve-to-middleware.ts +0 -48
- package/src/utils/workflow/derive-workflow-plan.test.ts +0 -178
- package/src/utils/workflow/derive-workflow-plan.ts +0 -117
- package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +0 -1465
- package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +0 -71
- package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +0 -99
- package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +0 -161
- package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +0 -121
- package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +0 -116
- package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +0 -145
- package/src/utils/workflow/dsl/extract-approval-policy.test.ts +0 -71
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +0 -2254
- package/src/utils/workflow/dsl/index.ts +0 -11
- package/src/utils/workflow/dsl/patterns.ts +0 -440
- package/src/utils/workflow/dsl/validation.ts +0 -203
- package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +0 -35
- package/src/utils/workflow/graph/convert-dsl-to-graph.ts +0 -543
- package/src/utils/workflow/graph/fanout-node-identity.test.ts +0 -105
- package/src/utils/workflow/graph/finalize-workflow-wires.ts +0 -310
- package/src/utils/workflow/graph/finalize-workflows.test.ts +0 -50
- package/src/utils/workflow/graph/finalize-workflows.ts +0 -130
- package/src/utils/workflow/graph/index.ts +0 -28
- package/src/utils/workflow/graph/serialize-workflow-graph.ts +0 -240
- package/src/utils/workflow/graph/workflow-graph.types.ts +0 -281
- package/src/visit.ts +0 -181
- package/tsconfig.json +0 -19
- 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
|
-
})
|