@pikku/inspector 0.12.49 → 0.12.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +501 -0
- package/OPTIMIZATION-PLAN.md +9 -3
- package/dist/add/add-auth.d.ts +1 -1
- package/dist/add/add-auth.js +1 -1
- package/dist/add/add-cli.js +7 -0
- package/dist/add/add-credential.js +1 -1
- package/dist/add/add-functions.js +156 -7
- package/dist/add/add-http-route.js +16 -6
- package/dist/add/add-middleware.js +46 -3
- package/dist/add/add-personas.d.ts +13 -0
- package/dist/add/add-personas.js +210 -0
- package/dist/add/add-scope.js +1 -1
- package/dist/add/add-secret.js +1 -1
- package/dist/add/add-system-role.d.ts +2 -0
- package/dist/add/add-system-role.js +102 -0
- package/dist/add/add-variable.js +1 -1
- package/dist/add/add-wire-addon.js +31 -0
- package/dist/error-codes.d.ts +10 -1
- package/dist/error-codes.js +11 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +33 -1
- package/dist/types.d.ts +46 -2
- package/dist/utils/annotate-http-route-auth.d.ts +22 -0
- package/dist/utils/annotate-http-route-auth.js +48 -0
- package/dist/utils/extract-function-name.js +13 -4
- package/dist/utils/extract-function-node.js +18 -1
- package/dist/utils/extract-secret-usage.d.ts +9 -0
- package/dist/utils/extract-secret-usage.js +61 -0
- package/dist/utils/literal-properties.d.ts +22 -0
- package/dist/utils/literal-properties.js +69 -0
- package/dist/utils/load-addon-functions-meta.js +26 -1
- package/dist/utils/permissions.js +8 -0
- package/dist/utils/post-process.d.ts +68 -3
- package/dist/utils/post-process.js +243 -13
- package/dist/utils/schema-generator.d.ts +24 -0
- package/dist/utils/schema-generator.js +118 -19
- package/dist/utils/secret-alias-services.d.ts +8 -0
- package/dist/utils/secret-alias-services.js +45 -0
- package/dist/utils/secret-brokers.d.ts +2 -0
- package/dist/utils/secret-brokers.js +9 -0
- package/dist/utils/serialize-inspector-state.d.ts +10 -0
- package/dist/utils/serialize-inspector-state.js +18 -0
- package/dist/utils/serialize-middleware-groups-meta.js +3 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
- package/dist/utils/validate-exposed-functions-gated.js +85 -0
- package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
- package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
- package/dist/visit.js +4 -0
- package/package.json +3 -3
- package/src/add/add-auth.test.ts +5 -3
- package/src/add/add-auth.ts +1 -1
- package/src/add/add-cli.ts +8 -0
- package/src/add/add-credential.ts +1 -1
- package/src/add/add-functions.test.ts +263 -1
- package/src/add/add-functions.ts +219 -9
- package/src/add/add-http-route.ts +20 -7
- package/src/add/add-middleware.test.ts +196 -0
- package/src/add/add-middleware.ts +64 -4
- package/src/add/add-personas.test.ts +268 -0
- package/src/add/add-personas.ts +276 -0
- package/src/add/add-scenario-step.test.ts +49 -3
- package/src/add/add-scope.test.ts +30 -30
- package/src/add/add-scope.ts +1 -1
- package/src/add/add-secret.ts +1 -1
- package/src/add/add-subject-scenario-steps.test.ts +161 -0
- package/src/add/add-system-role.test.ts +214 -0
- package/src/add/add-system-role.ts +164 -0
- package/src/add/add-variable.ts +1 -1
- package/src/add/add-wire-addon.test.ts +111 -0
- package/src/add/add-wire-addon.ts +29 -0
- package/src/add/addon-bans.test.ts +4 -4
- package/src/add/config-value-metadata.test.ts +11 -11
- package/src/add/wire-name-literal.test.ts +2 -2
- package/src/error-codes.ts +13 -0
- package/src/index.ts +4 -1
- package/src/inspector.ts +39 -0
- package/src/types.ts +47 -2
- package/src/utils/annotate-http-route-auth.test.ts +163 -0
- package/src/utils/annotate-http-route-auth.ts +52 -0
- package/src/utils/extract-function-name.ts +14 -4
- package/src/utils/extract-function-node.ts +18 -6
- package/src/utils/extract-secret-usage.test.ts +81 -0
- package/src/utils/extract-secret-usage.ts +73 -0
- package/src/utils/literal-properties.ts +97 -0
- package/src/utils/load-addon-functions-meta.ts +32 -2
- package/src/utils/permissions-shorthand.test.ts +79 -0
- package/src/utils/permissions.ts +7 -0
- package/src/utils/post-process.test.ts +133 -0
- package/src/utils/post-process.ts +310 -12
- package/src/utils/schema-cache.test.ts +124 -0
- package/src/utils/schema-generator.test.ts +62 -1
- package/src/utils/schema-generator.ts +138 -21
- package/src/utils/secret-alias-services.test.ts +146 -0
- package/src/utils/secret-alias-services.ts +58 -0
- package/src/utils/secret-brokers.test.ts +53 -0
- package/src/utils/secret-brokers.ts +11 -0
- package/src/utils/serialize-inspector-state.ts +28 -0
- package/src/utils/serialize-middleware-groups-meta.ts +3 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
- package/src/utils/validate-exposed-functions-gated.ts +94 -0
- package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
- package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
- package/src/visit.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
2
|
import { createHash } from 'crypto'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'fs'
|
|
4
10
|
import { dirname, join, resolve } from 'path'
|
|
5
11
|
import { createGenerator, RootlessError } from 'ts-json-schema-generator'
|
|
6
12
|
import { register } from 'tsx/esm/api'
|
|
@@ -75,6 +81,7 @@ let cachedParsedConfig: ts.ParsedCommandLine | undefined
|
|
|
75
81
|
let cachedTsconfigPath: string | undefined
|
|
76
82
|
let cachedCustomTypesContent: string | undefined
|
|
77
83
|
let cachedTSSchemas: Record<string, JSONValue> | undefined
|
|
84
|
+
let cachedTSSchemaDeps: SchemaDep[] | undefined
|
|
78
85
|
|
|
79
86
|
const SCHEMA_CACHE_VERSION = 1
|
|
80
87
|
|
|
@@ -118,32 +125,102 @@ function schemaCacheFile(cacheDir: string): string {
|
|
|
118
125
|
return join(cacheDir, 'ts-schemas.json')
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
|
|
128
|
+
/** One file the generated schemas were derived from, as of when they were generated. */
|
|
129
|
+
export type SchemaDep = { path: string; mtimeMs: number; size: number }
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The files a set of generated schemas was actually derived from.
|
|
133
|
+
*
|
|
134
|
+
* The cache key alone cannot answer whether the schemas are stale. It hashes the
|
|
135
|
+
* *synthesized* custom-types source, but a named type — `KnowledgeBundle`,
|
|
136
|
+
* `DbSchema`, anything a function declares as its input or output — appears
|
|
137
|
+
* there only as a name; its definition is resolved out of a project file or a
|
|
138
|
+
* dependency's `.d.ts`. Edit one of those and the key is unchanged, so a stale
|
|
139
|
+
* schema is served for a type that no longer looks like that. The failure is
|
|
140
|
+
* silent and outlives `rm -rf .pikku`, because this cache lives in
|
|
141
|
+
* `node_modules/.cache`.
|
|
142
|
+
*
|
|
143
|
+
* The program is rooted at the virtual file, so its source files are exactly the
|
|
144
|
+
* transitive closure the schemas depend on — nothing wider, nothing missed.
|
|
145
|
+
* Default-lib files are skipped: they change only when TypeScript itself does,
|
|
146
|
+
* which the inspector version in the key already covers.
|
|
147
|
+
*/
|
|
148
|
+
function fingerprintSchemaDeps(program: ts.Program): SchemaDep[] {
|
|
149
|
+
const deps: SchemaDep[] = []
|
|
150
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
151
|
+
if (program.isSourceFileDefaultLibrary(sourceFile)) continue
|
|
152
|
+
try {
|
|
153
|
+
const stat = statSync(sourceFile.fileName)
|
|
154
|
+
deps.push({
|
|
155
|
+
path: sourceFile.fileName,
|
|
156
|
+
mtimeMs: stat.mtimeMs,
|
|
157
|
+
size: stat.size,
|
|
158
|
+
})
|
|
159
|
+
} catch {
|
|
160
|
+
// The virtual root has no file on disk. Its content is the custom-types
|
|
161
|
+
// source, which the cache key already hashes.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return deps
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Whether every file the schemas were derived from is still exactly as it was.
|
|
169
|
+
*
|
|
170
|
+
* mtime-and-size rather than a content hash: this runs on the cache-hit path,
|
|
171
|
+
* where the whole point is to answer in milliseconds without building a TS
|
|
172
|
+
* program. Anything that rewrites a file — a dependency rebuild, a checkout, an
|
|
173
|
+
* edit — moves its mtime, and a missing file reads as changed, so the answer errs
|
|
174
|
+
* toward regenerating.
|
|
175
|
+
*/
|
|
176
|
+
function schemaDepsUnchanged(deps: unknown): boolean {
|
|
177
|
+
if (!Array.isArray(deps)) return false
|
|
178
|
+
for (const dep of deps as SchemaDep[]) {
|
|
179
|
+
if (!dep || typeof dep.path !== 'string') return false
|
|
180
|
+
try {
|
|
181
|
+
const stat = statSync(dep.path)
|
|
182
|
+
if (stat.mtimeMs !== dep.mtimeMs || stat.size !== dep.size) return false
|
|
183
|
+
} catch {
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function readDiskTSSchemas(
|
|
122
191
|
logger: InspectorLogger,
|
|
123
192
|
cacheDir: string,
|
|
124
193
|
key: string
|
|
125
|
-
): Record<string, JSONValue
|
|
194
|
+
): { schemas: Record<string, JSONValue>; deps: SchemaDep[] } | null {
|
|
126
195
|
const file = schemaCacheFile(cacheDir)
|
|
127
196
|
if (!existsSync(file)) return null
|
|
128
197
|
try {
|
|
129
198
|
const parsed = JSON.parse(readFileSync(file, 'utf-8'))
|
|
130
|
-
if (parsed?.key
|
|
199
|
+
if (parsed?.key !== key || !parsed.schemas) return null
|
|
200
|
+
if (!schemaDepsUnchanged(parsed.deps)) {
|
|
201
|
+
logger.debug(
|
|
202
|
+
'Discarding TS-schema cache: a type it was built from changed'
|
|
203
|
+
)
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
return { schemas: parsed.schemas, deps: parsed.deps }
|
|
131
207
|
} catch (e) {
|
|
132
208
|
logger.debug(`Ignoring unreadable TS-schema cache: ${(e as Error).message}`)
|
|
133
209
|
}
|
|
134
210
|
return null
|
|
135
211
|
}
|
|
136
212
|
|
|
137
|
-
function writeDiskTSSchemas(
|
|
213
|
+
export function writeDiskTSSchemas(
|
|
138
214
|
logger: InspectorLogger,
|
|
139
215
|
cacheDir: string,
|
|
140
216
|
key: string,
|
|
141
|
-
schemas: Record<string, JSONValue
|
|
217
|
+
schemas: Record<string, JSONValue>,
|
|
218
|
+
deps: SchemaDep[]
|
|
142
219
|
): void {
|
|
143
220
|
const file = schemaCacheFile(cacheDir)
|
|
144
221
|
try {
|
|
145
222
|
mkdirSync(dirname(file), { recursive: true })
|
|
146
|
-
writeFileSync(file, JSON.stringify({ key, schemas }))
|
|
223
|
+
writeFileSync(file, JSON.stringify({ key, schemas, deps }))
|
|
147
224
|
} catch (e) {
|
|
148
225
|
logger.debug(`Failed to persist TS-schema cache: ${(e as Error).message}`)
|
|
149
226
|
}
|
|
@@ -234,7 +311,7 @@ function generateTSSchemas(
|
|
|
234
311
|
additionalTypes?: string[],
|
|
235
312
|
additionalProperties: boolean = false,
|
|
236
313
|
generatedZodSchemas?: Record<string, JSONValue>
|
|
237
|
-
): Record<string, JSONValue
|
|
314
|
+
): { schemas: Record<string, JSONValue>; deps: SchemaDep[] } {
|
|
238
315
|
const schemasSet = new Set(typesMap.customTypes.keys())
|
|
239
316
|
for (const { inputs, outputs } of Object.values(functionMeta)) {
|
|
240
317
|
const types = [...(inputs || []), ...(outputs || [])]
|
|
@@ -277,7 +354,7 @@ function generateTSSchemas(
|
|
|
277
354
|
(s) => !PRIMITIVE_TYPES.has(s) && !generatedZodSchemas?.[s]
|
|
278
355
|
)
|
|
279
356
|
if (uncoveredSchemas.length === 0) {
|
|
280
|
-
return {}
|
|
357
|
+
return { schemas: {}, deps: [] }
|
|
281
358
|
}
|
|
282
359
|
logger.debug(
|
|
283
360
|
`generateTSSchemas needed for ${uncoveredSchemas.length} types: ${uncoveredSchemas.slice(0, 3).join(', ')}${uncoveredSchemas.length > 3 ? '...' : ''}`
|
|
@@ -335,7 +412,28 @@ function generateTSSchemas(
|
|
|
335
412
|
}
|
|
336
413
|
})
|
|
337
414
|
|
|
338
|
-
|
|
415
|
+
// Fingerprinted here, while the program is still alive — it is released as soon
|
|
416
|
+
// as the schemas are out of it.
|
|
417
|
+
return { schemas, deps: fingerprintSchemaDeps(program) }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Where the RUNTIME value of a schema lives, given where TypeScript resolved it.
|
|
422
|
+
*
|
|
423
|
+
* A schema imported from a built workspace package resolves to that package's
|
|
424
|
+
* declaration file, and importing a `.d.ts` yields a module with no exports at
|
|
425
|
+
* all — every one of them is a type. The value is in the emitted JS beside it, so
|
|
426
|
+
* that is what gets imported.
|
|
427
|
+
*
|
|
428
|
+
* Falls back to the original path when no sibling JS exists, which keeps the
|
|
429
|
+
* "could not find exported schema" error pointing at the file the developer
|
|
430
|
+
* actually wrote.
|
|
431
|
+
*/
|
|
432
|
+
export const schemaRuntimeFile = (sourceFile: string): string => {
|
|
433
|
+
const match = /\.d\.([cm]?)ts$/.exec(sourceFile)
|
|
434
|
+
if (!match) return sourceFile
|
|
435
|
+
const emitted = sourceFile.replace(/\.d\.([cm]?)ts$/, '.$1js')
|
|
436
|
+
return existsSync(emitted) ? emitted : sourceFile
|
|
339
437
|
}
|
|
340
438
|
|
|
341
439
|
/**
|
|
@@ -472,7 +570,9 @@ async function generateZodSchemas(
|
|
|
472
570
|
|
|
473
571
|
// Collect unique source files and batch-import them in parallel
|
|
474
572
|
const uniqueSourceFiles = [
|
|
475
|
-
...new Set(
|
|
573
|
+
...new Set(
|
|
574
|
+
[...schemaLookup.values()].map((ref) => schemaRuntimeFile(ref.sourceFile))
|
|
575
|
+
),
|
|
476
576
|
]
|
|
477
577
|
logger.debug(
|
|
478
578
|
`[TIMING] Zod schemas: ${schemaLookup.size} schemas from ${uniqueSourceFiles.length} files`
|
|
@@ -492,12 +592,13 @@ async function generateZodSchemas(
|
|
|
492
592
|
const fallbackSchemas: [string, SchemaRef][] = []
|
|
493
593
|
|
|
494
594
|
for (const [schemaName, ref] of schemaLookup.entries()) {
|
|
495
|
-
const
|
|
595
|
+
const runtimeFile = schemaRuntimeFile(ref.sourceFile)
|
|
596
|
+
const mod = importedModules?.get(runtimeFile)
|
|
496
597
|
if (mod) {
|
|
497
598
|
const zodSchema = mod[ref.variableName]
|
|
498
599
|
if (!zodSchema) {
|
|
499
600
|
errors.push(
|
|
500
|
-
`Could not find exported schema '${ref.variableName}' in ${
|
|
601
|
+
`Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(mod).join(', ')}`
|
|
501
602
|
)
|
|
502
603
|
continue
|
|
503
604
|
}
|
|
@@ -531,12 +632,13 @@ async function generateZodSchemas(
|
|
|
531
632
|
`Falling back to register() import for ${fallbackSchemas.length} schema(s)`
|
|
532
633
|
)
|
|
533
634
|
for (const [schemaName, ref] of fallbackSchemas) {
|
|
635
|
+
const runtimeFile = schemaRuntimeFile(ref.sourceFile)
|
|
534
636
|
try {
|
|
535
|
-
const module = await importWithRegister(
|
|
637
|
+
const module = await importWithRegister(runtimeFile)
|
|
536
638
|
const zodSchema = module[ref.variableName]
|
|
537
639
|
if (!zodSchema) {
|
|
538
640
|
errors.push(
|
|
539
|
-
`Could not find exported schema '${ref.variableName}' in ${
|
|
641
|
+
`Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`
|
|
540
642
|
)
|
|
541
643
|
continue
|
|
542
644
|
}
|
|
@@ -595,8 +697,15 @@ export async function generateAllSchemas(
|
|
|
595
697
|
requiredTypes
|
|
596
698
|
)
|
|
597
699
|
|
|
598
|
-
// Fast path: same process, types unchanged — reuse the in-memory result.
|
|
599
|
-
|
|
700
|
+
// Fast path: same process, types unchanged — reuse the in-memory result. The
|
|
701
|
+
// dep check is what makes this correct under `pikku dev`, where the process
|
|
702
|
+
// outlives the edits: the custom-types source is identical whenever a named
|
|
703
|
+
// type changes shape without changing its name.
|
|
704
|
+
if (
|
|
705
|
+
cachedTSSchemas &&
|
|
706
|
+
cachedCustomTypesContent === customTypesContent &&
|
|
707
|
+
schemaDepsUnchanged(cachedTSSchemaDeps)
|
|
708
|
+
) {
|
|
600
709
|
logger.debug('Reusing cached TS schemas (types unchanged)')
|
|
601
710
|
return { ...cachedTSSchemas, ...zodSchemas }
|
|
602
711
|
}
|
|
@@ -612,12 +721,13 @@ export async function generateAllSchemas(
|
|
|
612
721
|
if (disk) {
|
|
613
722
|
logger.debug('Reusing on-disk TS schemas (types unchanged across runs)')
|
|
614
723
|
cachedCustomTypesContent = customTypesContent
|
|
615
|
-
cachedTSSchemas = disk
|
|
616
|
-
|
|
724
|
+
cachedTSSchemas = disk.schemas
|
|
725
|
+
cachedTSSchemaDeps = disk.deps
|
|
726
|
+
return { ...disk.schemas, ...zodSchemas }
|
|
617
727
|
}
|
|
618
728
|
}
|
|
619
729
|
|
|
620
|
-
const tsSchemas = generateTSSchemas(
|
|
730
|
+
const { schemas: tsSchemas, deps: tsSchemaDeps } = generateTSSchemas(
|
|
621
731
|
logger,
|
|
622
732
|
config.tsconfig,
|
|
623
733
|
customTypesContent,
|
|
@@ -639,9 +749,16 @@ export async function generateAllSchemas(
|
|
|
639
749
|
|
|
640
750
|
cachedCustomTypesContent = customTypesContent
|
|
641
751
|
cachedTSSchemas = tsSchemas
|
|
752
|
+
cachedTSSchemaDeps = tsSchemaDeps
|
|
642
753
|
|
|
643
754
|
if (config.cacheDir && cacheKey) {
|
|
644
|
-
writeDiskTSSchemas(
|
|
755
|
+
writeDiskTSSchemas(
|
|
756
|
+
logger,
|
|
757
|
+
config.cacheDir,
|
|
758
|
+
cacheKey,
|
|
759
|
+
tsSchemas,
|
|
760
|
+
tsSchemaDeps
|
|
761
|
+
)
|
|
645
762
|
}
|
|
646
763
|
|
|
647
764
|
return { ...tsSchemas, ...zodSchemas }
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import ts from 'typescript'
|
|
4
|
+
import {
|
|
5
|
+
findSecretAliasServices,
|
|
6
|
+
isSecretServiceType,
|
|
7
|
+
} from './secret-alias-services.js'
|
|
8
|
+
|
|
9
|
+
const SECRET_SERVICE = `
|
|
10
|
+
interface SecretService {
|
|
11
|
+
getSecret(key: string): Promise<string>
|
|
12
|
+
hasSecret(key: string): Promise<boolean>
|
|
13
|
+
setSecret(key: string, value: unknown): Promise<void>
|
|
14
|
+
deleteSecret(key: string): Promise<void>
|
|
15
|
+
getSecrets(keys: string[]): Promise<Record<string, unknown>>
|
|
16
|
+
}
|
|
17
|
+
`
|
|
18
|
+
|
|
19
|
+
const singletonServicesTypeOf = (source: string) => {
|
|
20
|
+
const fileName = '/services.ts'
|
|
21
|
+
const host: ts.CompilerHost = {
|
|
22
|
+
...ts.createCompilerHost({}),
|
|
23
|
+
getSourceFile: (name, languageVersion) =>
|
|
24
|
+
name === fileName
|
|
25
|
+
? ts.createSourceFile(name, source, languageVersion, true)
|
|
26
|
+
: undefined,
|
|
27
|
+
fileExists: (name) => name === fileName,
|
|
28
|
+
readFile: (name) => (name === fileName ? source : undefined),
|
|
29
|
+
}
|
|
30
|
+
const program = ts.createProgram([fileName], { noLib: true }, host)
|
|
31
|
+
const checker = program.getTypeChecker()
|
|
32
|
+
const sourceFile = program.getSourceFile(fileName)!
|
|
33
|
+
const declaration = sourceFile.statements.find(
|
|
34
|
+
(statement): statement is ts.InterfaceDeclaration =>
|
|
35
|
+
ts.isInterfaceDeclaration(statement) &&
|
|
36
|
+
statement.name.text === 'SingletonServices'
|
|
37
|
+
)!
|
|
38
|
+
return {
|
|
39
|
+
checker,
|
|
40
|
+
type: checker.getTypeAtLocation(declaration),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('findSecretAliasServices', () => {
|
|
45
|
+
test('finds a secret service exposed under another name', () => {
|
|
46
|
+
const { type, checker } = singletonServicesTypeOf(`
|
|
47
|
+
${SECRET_SERVICE}
|
|
48
|
+
interface SingletonServices {
|
|
49
|
+
secrets: SecretService
|
|
50
|
+
cfg: SecretService
|
|
51
|
+
}
|
|
52
|
+
`)
|
|
53
|
+
assert.deepEqual(findSecretAliasServices(type, checker), ['cfg'])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('never reports `secrets` itself', () => {
|
|
57
|
+
const { type, checker } = singletonServicesTypeOf(`
|
|
58
|
+
${SECRET_SERVICE}
|
|
59
|
+
interface SingletonServices {
|
|
60
|
+
secrets: SecretService
|
|
61
|
+
}
|
|
62
|
+
`)
|
|
63
|
+
assert.deepEqual(findSecretAliasServices(type, checker), [])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('leaves ordinary services alone', () => {
|
|
67
|
+
const { type, checker } = singletonServicesTypeOf(`
|
|
68
|
+
${SECRET_SERVICE}
|
|
69
|
+
interface Logger { info(message: string): void }
|
|
70
|
+
interface SingletonServices {
|
|
71
|
+
secrets: SecretService
|
|
72
|
+
logger: Logger
|
|
73
|
+
}
|
|
74
|
+
`)
|
|
75
|
+
assert.deepEqual(findSecretAliasServices(type, checker), [])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('is not fooled by a service that only has getSecret', () => {
|
|
79
|
+
const { type, checker } = singletonServicesTypeOf(`
|
|
80
|
+
${SECRET_SERVICE}
|
|
81
|
+
interface Partialish { getSecret(key: string): Promise<string> }
|
|
82
|
+
interface SingletonServices {
|
|
83
|
+
secrets: SecretService
|
|
84
|
+
partialish: Partialish
|
|
85
|
+
}
|
|
86
|
+
`)
|
|
87
|
+
assert.deepEqual(findSecretAliasServices(type, checker), [])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('sees through an optional service', () => {
|
|
91
|
+
const { type, checker } = singletonServicesTypeOf(`
|
|
92
|
+
${SECRET_SERVICE}
|
|
93
|
+
interface SingletonServices {
|
|
94
|
+
secrets: SecretService
|
|
95
|
+
cfg?: SecretService
|
|
96
|
+
}
|
|
97
|
+
`)
|
|
98
|
+
assert.deepEqual(findSecretAliasServices(type, checker), ['cfg'])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('finds a subtype that adds methods', () => {
|
|
102
|
+
const { type, checker } = singletonServicesTypeOf(`
|
|
103
|
+
${SECRET_SERVICE}
|
|
104
|
+
interface Scoped extends SecretService { getAllStatus(): Promise<void> }
|
|
105
|
+
interface SingletonServices {
|
|
106
|
+
secrets: SecretService
|
|
107
|
+
scoped: Scoped
|
|
108
|
+
}
|
|
109
|
+
`)
|
|
110
|
+
assert.deepEqual(findSecretAliasServices(type, checker), ['scoped'])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('reports every alias, sorted', () => {
|
|
114
|
+
const { type, checker } = singletonServicesTypeOf(`
|
|
115
|
+
${SECRET_SERVICE}
|
|
116
|
+
interface SingletonServices {
|
|
117
|
+
secrets: SecretService
|
|
118
|
+
zed: SecretService
|
|
119
|
+
alpha: SecretService
|
|
120
|
+
}
|
|
121
|
+
`)
|
|
122
|
+
assert.deepEqual(findSecretAliasServices(type, checker), ['alpha', 'zed'])
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('isSecretServiceType', () => {
|
|
127
|
+
test('refuses a type whose members are not callable', () => {
|
|
128
|
+
const { type, checker } = singletonServicesTypeOf(`
|
|
129
|
+
${SECRET_SERVICE}
|
|
130
|
+
interface Lookalike {
|
|
131
|
+
getSecret: string
|
|
132
|
+
hasSecret: string
|
|
133
|
+
setSecret: string
|
|
134
|
+
deleteSecret: string
|
|
135
|
+
getSecrets: string
|
|
136
|
+
}
|
|
137
|
+
interface SingletonServices { lookalike: Lookalike }
|
|
138
|
+
`)
|
|
139
|
+
const property = type.getProperty('lookalike')!
|
|
140
|
+
const propertyType = checker.getTypeOfSymbolAtLocation(
|
|
141
|
+
property,
|
|
142
|
+
property.valueDeclaration!
|
|
143
|
+
)
|
|
144
|
+
assert.equal(isSecretServiceType(propertyType, checker), false)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
const SECRET_SERVICE_METHODS = [
|
|
4
|
+
'getSecret',
|
|
5
|
+
'hasSecret',
|
|
6
|
+
'setSecret',
|
|
7
|
+
'deleteSecret',
|
|
8
|
+
'getSecrets',
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
/** Whether a type structurally satisfies `SecretService`. */
|
|
12
|
+
export const isSecretServiceType = (
|
|
13
|
+
type: ts.Type,
|
|
14
|
+
checker: ts.TypeChecker
|
|
15
|
+
): boolean => {
|
|
16
|
+
const nonNullable = checker.getNonNullableType(type)
|
|
17
|
+
return SECRET_SERVICE_METHODS.every((method) => {
|
|
18
|
+
const property = nonNullable.getProperty(method)
|
|
19
|
+
if (!property) {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
const declaration = property.valueDeclaration ?? property.declarations?.[0]
|
|
23
|
+
if (!declaration) {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
const propertyType = checker.getTypeOfSymbolAtLocation(
|
|
27
|
+
property,
|
|
28
|
+
declaration
|
|
29
|
+
)
|
|
30
|
+
return propertyType.getCallSignatures().length > 0
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Singleton service names, other than `secrets`, whose type is a
|
|
36
|
+
* `SecretService`.
|
|
37
|
+
*/
|
|
38
|
+
export const findSecretAliasServices = (
|
|
39
|
+
singletonServicesType: ts.Type,
|
|
40
|
+
checker: ts.TypeChecker
|
|
41
|
+
): string[] => {
|
|
42
|
+
const aliases: string[] = []
|
|
43
|
+
for (const property of singletonServicesType.getProperties()) {
|
|
44
|
+
const name = property.getName()
|
|
45
|
+
if (name === 'secrets') {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
const declaration = property.valueDeclaration ?? property.declarations?.[0]
|
|
49
|
+
if (!declaration) {
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
const type = checker.getTypeOfSymbolAtLocation(property, declaration)
|
|
53
|
+
if (isSecretServiceType(type, checker)) {
|
|
54
|
+
aliases.push(name)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return aliases.sort()
|
|
58
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { isSecretBrokerFunction } from './secret-brokers.js'
|
|
4
|
+
import { computeSecretBrokers } from './post-process.js'
|
|
5
|
+
import type { InspectorState } from '../types.js'
|
|
6
|
+
|
|
7
|
+
describe('isSecretBrokerFunction', () => {
|
|
8
|
+
test('admits the console secret-admin functions', () => {
|
|
9
|
+
assert.ok(isSecretBrokerFunction('pikkuConsoleGetSecret'))
|
|
10
|
+
assert.ok(isSecretBrokerFunction('pikkuConsoleSetSecret'))
|
|
11
|
+
assert.ok(isSecretBrokerFunction('pikkuConsoleHasSecret'))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('admits them when namespaced by an addon', () => {
|
|
15
|
+
assert.ok(isSecretBrokerFunction('pikku:pikkuConsoleGetSecret'))
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('admits nothing else', () => {
|
|
19
|
+
assert.equal(isSecretBrokerFunction('pikkuConsoleGetVariable'), false)
|
|
20
|
+
assert.equal(isSecretBrokerFunction('httpRequest'), false)
|
|
21
|
+
assert.equal(isSecretBrokerFunction('myFunction'), false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('is not fooled by a name that merely contains a broker name', () => {
|
|
25
|
+
assert.equal(isSecretBrokerFunction('notPikkuConsoleGetSecret'), false)
|
|
26
|
+
assert.equal(isSecretBrokerFunction('pikkuConsoleGetSecretly'), false)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('computeSecretBrokers', () => {
|
|
31
|
+
const stateWith = (meta: Record<string, any>) =>
|
|
32
|
+
({ functions: { meta } }) as unknown as InspectorState
|
|
33
|
+
|
|
34
|
+
test('flags a broker so the runner keeps its secrets', () => {
|
|
35
|
+
const state = stateWith({
|
|
36
|
+
pikkuConsoleGetSecret: { pikkuFuncId: 'pikkuConsoleGetSecret' },
|
|
37
|
+
})
|
|
38
|
+
computeSecretBrokers(state)
|
|
39
|
+
assert.equal(state.functions.meta.pikkuConsoleGetSecret!.secretBroker, true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('leaves an ordinary function unflagged', () => {
|
|
43
|
+
const state = stateWith({ createTodo: { pikkuFuncId: 'createTodo' } })
|
|
44
|
+
computeSecretBrokers(state)
|
|
45
|
+
assert.equal(state.functions.meta.createTodo!.secretBroker, undefined)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('falls back to the meta key when there is no pikkuFuncId', () => {
|
|
49
|
+
const state = stateWith({ pikkuConsoleSetSecret: {} })
|
|
50
|
+
computeSecretBrokers(state)
|
|
51
|
+
assert.equal(state.functions.meta.pikkuConsoleSetSecret!.secretBroker, true)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The console's secret administration functions, and nothing else. */
|
|
2
|
+
const SECRET_BROKER_FUNCTIONS = new Set([
|
|
3
|
+
'pikkuConsoleSetSecret',
|
|
4
|
+
'pikkuConsoleGetSecret',
|
|
5
|
+
'pikkuConsoleHasSecret',
|
|
6
|
+
])
|
|
7
|
+
|
|
8
|
+
/** Whether this function keeps the full `SecretService`. */
|
|
9
|
+
export const isSecretBrokerFunction = (functionId: string): boolean =>
|
|
10
|
+
SECRET_BROKER_FUNCTIONS.has(functionId) ||
|
|
11
|
+
SECRET_BROKER_FUNCTIONS.has(functionId.split(':').pop() ?? '')
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SecretUsage } from './extract-secret-usage.js'
|
|
1
2
|
import type { JSONValue } from '@pikku/core'
|
|
2
3
|
import type {
|
|
3
4
|
InspectorDiagnostic,
|
|
@@ -201,6 +202,7 @@ export interface SerializableInspectorState {
|
|
|
201
202
|
secrets: {
|
|
202
203
|
definitions: InspectorState['secrets']['definitions']
|
|
203
204
|
files: string[]
|
|
205
|
+
usage: Record<string, SecretUsage>
|
|
204
206
|
}
|
|
205
207
|
credentials: {
|
|
206
208
|
definitions: InspectorState['credentials']['definitions']
|
|
@@ -210,6 +212,14 @@ export interface SerializableInspectorState {
|
|
|
210
212
|
definitions: InspectorState['scopes']['definitions']
|
|
211
213
|
files: string[]
|
|
212
214
|
}
|
|
215
|
+
systemRoles: {
|
|
216
|
+
definitions: InspectorState['systemRoles']['definitions']
|
|
217
|
+
files: string[]
|
|
218
|
+
}
|
|
219
|
+
personas: {
|
|
220
|
+
definitions: InspectorState['personas']['definitions']
|
|
221
|
+
files: string[]
|
|
222
|
+
}
|
|
213
223
|
variables: {
|
|
214
224
|
definitions: InspectorState['variables']['definitions']
|
|
215
225
|
files: string[]
|
|
@@ -416,6 +426,7 @@ export function serializeInspectorState(
|
|
|
416
426
|
secrets: {
|
|
417
427
|
definitions: state.secrets.definitions,
|
|
418
428
|
files: Array.from(state.secrets.files),
|
|
429
|
+
usage: Object.fromEntries(state.secrets.usage),
|
|
419
430
|
},
|
|
420
431
|
credentials: {
|
|
421
432
|
definitions: state.credentials.definitions,
|
|
@@ -425,6 +436,14 @@ export function serializeInspectorState(
|
|
|
425
436
|
definitions: state.scopes.definitions,
|
|
426
437
|
files: Array.from(state.scopes.files),
|
|
427
438
|
},
|
|
439
|
+
systemRoles: {
|
|
440
|
+
definitions: state.systemRoles.definitions,
|
|
441
|
+
files: Array.from(state.systemRoles.files),
|
|
442
|
+
},
|
|
443
|
+
personas: {
|
|
444
|
+
definitions: state.personas.definitions,
|
|
445
|
+
files: Array.from(state.personas.files),
|
|
446
|
+
},
|
|
428
447
|
variables: {
|
|
429
448
|
definitions: state.variables.definitions,
|
|
430
449
|
files: Array.from(state.variables.files),
|
|
@@ -611,6 +630,7 @@ export function deserializeInspectorState(
|
|
|
611
630
|
secrets: {
|
|
612
631
|
definitions: data.secrets?.definitions || [],
|
|
613
632
|
files: new Set(data.secrets?.files || []),
|
|
633
|
+
usage: new Map(Object.entries(data.secrets?.usage || {})),
|
|
614
634
|
},
|
|
615
635
|
credentials: {
|
|
616
636
|
definitions: data.credentials?.definitions || [],
|
|
@@ -620,6 +640,14 @@ export function deserializeInspectorState(
|
|
|
620
640
|
definitions: data.scopes?.definitions || [],
|
|
621
641
|
files: new Set(data.scopes?.files || []),
|
|
622
642
|
},
|
|
643
|
+
systemRoles: {
|
|
644
|
+
definitions: data.systemRoles?.definitions || [],
|
|
645
|
+
files: new Set(data.systemRoles?.files || []),
|
|
646
|
+
},
|
|
647
|
+
personas: {
|
|
648
|
+
definitions: data.personas?.definitions || [],
|
|
649
|
+
files: new Set(data.personas?.files || []),
|
|
650
|
+
},
|
|
623
651
|
variables: {
|
|
624
652
|
definitions: data.variables?.definitions || [],
|
|
625
653
|
files: new Set(data.variables?.files || []),
|