@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,6 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
2
|
import { createHash } from 'crypto';
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from 'fs';
|
|
4
4
|
import { dirname, join, resolve } from 'path';
|
|
5
5
|
import { createGenerator, RootlessError } from 'ts-json-schema-generator';
|
|
6
6
|
import { register } from 'tsx/esm/api';
|
|
@@ -57,6 +57,7 @@ let cachedParsedConfig;
|
|
|
57
57
|
let cachedTsconfigPath;
|
|
58
58
|
let cachedCustomTypesContent;
|
|
59
59
|
let cachedTSSchemas;
|
|
60
|
+
let cachedTSSchemaDeps;
|
|
60
61
|
const SCHEMA_CACHE_VERSION = 1;
|
|
61
62
|
// This package's own version — folded into the cache key so that upgrading
|
|
62
63
|
// @pikku/inspector (the channel through which a schema-format change ships)
|
|
@@ -90,25 +91,93 @@ function tsSchemaCacheKey(customTypesContent, config) {
|
|
|
90
91
|
function schemaCacheFile(cacheDir) {
|
|
91
92
|
return join(cacheDir, 'ts-schemas.json');
|
|
92
93
|
}
|
|
93
|
-
|
|
94
|
+
/**
|
|
95
|
+
* The files a set of generated schemas was actually derived from.
|
|
96
|
+
*
|
|
97
|
+
* The cache key alone cannot answer whether the schemas are stale. It hashes the
|
|
98
|
+
* *synthesized* custom-types source, but a named type — `KnowledgeBundle`,
|
|
99
|
+
* `DbSchema`, anything a function declares as its input or output — appears
|
|
100
|
+
* there only as a name; its definition is resolved out of a project file or a
|
|
101
|
+
* dependency's `.d.ts`. Edit one of those and the key is unchanged, so a stale
|
|
102
|
+
* schema is served for a type that no longer looks like that. The failure is
|
|
103
|
+
* silent and outlives `rm -rf .pikku`, because this cache lives in
|
|
104
|
+
* `node_modules/.cache`.
|
|
105
|
+
*
|
|
106
|
+
* The program is rooted at the virtual file, so its source files are exactly the
|
|
107
|
+
* transitive closure the schemas depend on — nothing wider, nothing missed.
|
|
108
|
+
* Default-lib files are skipped: they change only when TypeScript itself does,
|
|
109
|
+
* which the inspector version in the key already covers.
|
|
110
|
+
*/
|
|
111
|
+
function fingerprintSchemaDeps(program) {
|
|
112
|
+
const deps = [];
|
|
113
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
114
|
+
if (program.isSourceFileDefaultLibrary(sourceFile))
|
|
115
|
+
continue;
|
|
116
|
+
try {
|
|
117
|
+
const stat = statSync(sourceFile.fileName);
|
|
118
|
+
deps.push({
|
|
119
|
+
path: sourceFile.fileName,
|
|
120
|
+
mtimeMs: stat.mtimeMs,
|
|
121
|
+
size: stat.size,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// The virtual root has no file on disk. Its content is the custom-types
|
|
126
|
+
// source, which the cache key already hashes.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return deps;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Whether every file the schemas were derived from is still exactly as it was.
|
|
133
|
+
*
|
|
134
|
+
* mtime-and-size rather than a content hash: this runs on the cache-hit path,
|
|
135
|
+
* where the whole point is to answer in milliseconds without building a TS
|
|
136
|
+
* program. Anything that rewrites a file — a dependency rebuild, a checkout, an
|
|
137
|
+
* edit — moves its mtime, and a missing file reads as changed, so the answer errs
|
|
138
|
+
* toward regenerating.
|
|
139
|
+
*/
|
|
140
|
+
function schemaDepsUnchanged(deps) {
|
|
141
|
+
if (!Array.isArray(deps))
|
|
142
|
+
return false;
|
|
143
|
+
for (const dep of deps) {
|
|
144
|
+
if (!dep || typeof dep.path !== 'string')
|
|
145
|
+
return false;
|
|
146
|
+
try {
|
|
147
|
+
const stat = statSync(dep.path);
|
|
148
|
+
if (stat.mtimeMs !== dep.mtimeMs || stat.size !== dep.size)
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
export function readDiskTSSchemas(logger, cacheDir, key) {
|
|
94
158
|
const file = schemaCacheFile(cacheDir);
|
|
95
159
|
if (!existsSync(file))
|
|
96
160
|
return null;
|
|
97
161
|
try {
|
|
98
162
|
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
99
|
-
if (parsed?.key
|
|
100
|
-
return
|
|
163
|
+
if (parsed?.key !== key || !parsed.schemas)
|
|
164
|
+
return null;
|
|
165
|
+
if (!schemaDepsUnchanged(parsed.deps)) {
|
|
166
|
+
logger.debug('Discarding TS-schema cache: a type it was built from changed');
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return { schemas: parsed.schemas, deps: parsed.deps };
|
|
101
170
|
}
|
|
102
171
|
catch (e) {
|
|
103
172
|
logger.debug(`Ignoring unreadable TS-schema cache: ${e.message}`);
|
|
104
173
|
}
|
|
105
174
|
return null;
|
|
106
175
|
}
|
|
107
|
-
function writeDiskTSSchemas(logger, cacheDir, key, schemas) {
|
|
176
|
+
export function writeDiskTSSchemas(logger, cacheDir, key, schemas, deps) {
|
|
108
177
|
const file = schemaCacheFile(cacheDir);
|
|
109
178
|
try {
|
|
110
179
|
mkdirSync(dirname(file), { recursive: true });
|
|
111
|
-
writeFileSync(file, JSON.stringify({ key, schemas }));
|
|
180
|
+
writeFileSync(file, JSON.stringify({ key, schemas, deps }));
|
|
112
181
|
}
|
|
113
182
|
catch (e) {
|
|
114
183
|
logger.debug(`Failed to persist TS-schema cache: ${e.message}`);
|
|
@@ -196,7 +265,7 @@ function generateTSSchemas(logger, tsconfig, customTypesContent, typesMap, funct
|
|
|
196
265
|
// so that failed Zod conversions fall through to TS schema generation.
|
|
197
266
|
const uncoveredSchemas = [...schemasSet].filter((s) => !PRIMITIVE_TYPES.has(s) && !generatedZodSchemas?.[s]);
|
|
198
267
|
if (uncoveredSchemas.length === 0) {
|
|
199
|
-
return {};
|
|
268
|
+
return { schemas: {}, deps: [] };
|
|
200
269
|
}
|
|
201
270
|
logger.debug(`generateTSSchemas needed for ${uncoveredSchemas.length} types: ${uncoveredSchemas.slice(0, 3).join(', ')}${uncoveredSchemas.length > 3 ? '...' : ''}`);
|
|
202
271
|
const virtualFilePath = join(dirname(resolve(tsconfig)), '__pikku_virtual_types__.ts');
|
|
@@ -239,8 +308,29 @@ function generateTSSchemas(logger, tsconfig, customTypesContent, typesMap, funct
|
|
|
239
308
|
logger.error(`[${ErrorCode.SCHEMA_GENERATION_ERROR}] Error generating schema: ${schema}. Message: ${e.message}. Type info: ${customType ? `type=${customType.type}` : 'not in typesMap'}`);
|
|
240
309
|
}
|
|
241
310
|
});
|
|
242
|
-
|
|
311
|
+
// Fingerprinted here, while the program is still alive — it is released as soon
|
|
312
|
+
// as the schemas are out of it.
|
|
313
|
+
return { schemas, deps: fingerprintSchemaDeps(program) };
|
|
243
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* Where the RUNTIME value of a schema lives, given where TypeScript resolved it.
|
|
317
|
+
*
|
|
318
|
+
* A schema imported from a built workspace package resolves to that package's
|
|
319
|
+
* declaration file, and importing a `.d.ts` yields a module with no exports at
|
|
320
|
+
* all — every one of them is a type. The value is in the emitted JS beside it, so
|
|
321
|
+
* that is what gets imported.
|
|
322
|
+
*
|
|
323
|
+
* Falls back to the original path when no sibling JS exists, which keeps the
|
|
324
|
+
* "could not find exported schema" error pointing at the file the developer
|
|
325
|
+
* actually wrote.
|
|
326
|
+
*/
|
|
327
|
+
export const schemaRuntimeFile = (sourceFile) => {
|
|
328
|
+
const match = /\.d\.([cm]?)ts$/.exec(sourceFile);
|
|
329
|
+
if (!match)
|
|
330
|
+
return sourceFile;
|
|
331
|
+
const emitted = sourceFile.replace(/\.d\.([cm]?)ts$/, '.$1js');
|
|
332
|
+
return existsSync(emitted) ? emitted : sourceFile;
|
|
333
|
+
};
|
|
244
334
|
/**
|
|
245
335
|
* Import all source files in parallel using tsx's register() API.
|
|
246
336
|
*
|
|
@@ -333,7 +423,7 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
|
|
|
333
423
|
}
|
|
334
424
|
// Collect unique source files and batch-import them in parallel
|
|
335
425
|
const uniqueSourceFiles = [
|
|
336
|
-
...new Set([...schemaLookup.values()].map((ref) => ref.sourceFile)),
|
|
426
|
+
...new Set([...schemaLookup.values()].map((ref) => schemaRuntimeFile(ref.sourceFile))),
|
|
337
427
|
];
|
|
338
428
|
logger.debug(`[TIMING] Zod schemas: ${schemaLookup.size} schemas from ${uniqueSourceFiles.length} files`);
|
|
339
429
|
const importStart = performance.now();
|
|
@@ -343,11 +433,12 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
|
|
|
343
433
|
// Track schemas that need per-file tsImport fallback
|
|
344
434
|
const fallbackSchemas = [];
|
|
345
435
|
for (const [schemaName, ref] of schemaLookup.entries()) {
|
|
346
|
-
const
|
|
436
|
+
const runtimeFile = schemaRuntimeFile(ref.sourceFile);
|
|
437
|
+
const mod = importedModules?.get(runtimeFile);
|
|
347
438
|
if (mod) {
|
|
348
439
|
const zodSchema = mod[ref.variableName];
|
|
349
440
|
if (!zodSchema) {
|
|
350
|
-
errors.push(`Could not find exported schema '${ref.variableName}' in ${
|
|
441
|
+
errors.push(`Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(mod).join(', ')}`);
|
|
351
442
|
continue;
|
|
352
443
|
}
|
|
353
444
|
try {
|
|
@@ -368,11 +459,12 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
|
|
|
368
459
|
if (fallbackSchemas.length > 0) {
|
|
369
460
|
logger.debug(`Falling back to register() import for ${fallbackSchemas.length} schema(s)`);
|
|
370
461
|
for (const [schemaName, ref] of fallbackSchemas) {
|
|
462
|
+
const runtimeFile = schemaRuntimeFile(ref.sourceFile);
|
|
371
463
|
try {
|
|
372
|
-
const module = await importWithRegister(
|
|
464
|
+
const module = await importWithRegister(runtimeFile);
|
|
373
465
|
const zodSchema = module[ref.variableName];
|
|
374
466
|
if (!zodSchema) {
|
|
375
|
-
errors.push(`Could not find exported schema '${ref.variableName}' in ${
|
|
467
|
+
errors.push(`Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`);
|
|
376
468
|
continue;
|
|
377
469
|
}
|
|
378
470
|
processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger);
|
|
@@ -395,8 +487,13 @@ export async function generateAllSchemas(logger, config, state) {
|
|
|
395
487
|
const zodSchemas = await generateZodSchemas(logger, state.schemaLookup, state.functions.typesMap);
|
|
396
488
|
const requiredTypes = new Set();
|
|
397
489
|
const customTypesContent = generateCustomTypes(state.functions.typesMap, requiredTypes);
|
|
398
|
-
// Fast path: same process, types unchanged — reuse the in-memory result.
|
|
399
|
-
|
|
490
|
+
// Fast path: same process, types unchanged — reuse the in-memory result. The
|
|
491
|
+
// dep check is what makes this correct under `pikku dev`, where the process
|
|
492
|
+
// outlives the edits: the custom-types source is identical whenever a named
|
|
493
|
+
// type changes shape without changing its name.
|
|
494
|
+
if (cachedTSSchemas &&
|
|
495
|
+
cachedCustomTypesContent === customTypesContent &&
|
|
496
|
+
schemaDepsUnchanged(cachedTSSchemaDeps)) {
|
|
400
497
|
logger.debug('Reusing cached TS schemas (types unchanged)');
|
|
401
498
|
return { ...cachedTSSchemas, ...zodSchemas };
|
|
402
499
|
}
|
|
@@ -411,11 +508,12 @@ export async function generateAllSchemas(logger, config, state) {
|
|
|
411
508
|
if (disk) {
|
|
412
509
|
logger.debug('Reusing on-disk TS schemas (types unchanged across runs)');
|
|
413
510
|
cachedCustomTypesContent = customTypesContent;
|
|
414
|
-
cachedTSSchemas = disk;
|
|
415
|
-
|
|
511
|
+
cachedTSSchemas = disk.schemas;
|
|
512
|
+
cachedTSSchemaDeps = disk.deps;
|
|
513
|
+
return { ...disk.schemas, ...zodSchemas };
|
|
416
514
|
}
|
|
417
515
|
}
|
|
418
|
-
const tsSchemas = generateTSSchemas(logger, config.tsconfig, customTypesContent, state.functions.typesMap, state.functions.meta, state.http.meta, config.schemasFromTypes, config.schema?.additionalProperties, zodSchemas);
|
|
516
|
+
const { schemas: tsSchemas, deps: tsSchemaDeps } = generateTSSchemas(logger, config.tsconfig, customTypesContent, state.functions.typesMap, state.functions.meta, state.http.meta, config.schemasFromTypes, config.schema?.additionalProperties, zodSchemas);
|
|
419
517
|
// Release the program once the schemas are out of it. It spans the whole
|
|
420
518
|
// tsconfig (~2.5k files, ~600MB on a large tree) and was previously pinned at
|
|
421
519
|
// module scope for the life of the process, so it stacked on top of the main
|
|
@@ -425,8 +523,9 @@ export async function generateAllSchemas(logger, config, state) {
|
|
|
425
523
|
cachedSchemaProgram = undefined;
|
|
426
524
|
cachedCustomTypesContent = customTypesContent;
|
|
427
525
|
cachedTSSchemas = tsSchemas;
|
|
526
|
+
cachedTSSchemaDeps = tsSchemaDeps;
|
|
428
527
|
if (config.cacheDir && cacheKey) {
|
|
429
|
-
writeDiskTSSchemas(logger, config.cacheDir, cacheKey, tsSchemas);
|
|
528
|
+
writeDiskTSSchemas(logger, config.cacheDir, cacheKey, tsSchemas, tsSchemaDeps);
|
|
430
529
|
}
|
|
431
530
|
return { ...tsSchemas, ...zodSchemas };
|
|
432
531
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type ts from 'typescript';
|
|
2
|
+
/** Whether a type structurally satisfies `SecretService`. */
|
|
3
|
+
export declare const isSecretServiceType: (type: ts.Type, checker: ts.TypeChecker) => boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Singleton service names, other than `secrets`, whose type is a
|
|
6
|
+
* `SecretService`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const findSecretAliasServices: (singletonServicesType: ts.Type, checker: ts.TypeChecker) => string[];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const SECRET_SERVICE_METHODS = [
|
|
2
|
+
'getSecret',
|
|
3
|
+
'hasSecret',
|
|
4
|
+
'setSecret',
|
|
5
|
+
'deleteSecret',
|
|
6
|
+
'getSecrets',
|
|
7
|
+
];
|
|
8
|
+
/** Whether a type structurally satisfies `SecretService`. */
|
|
9
|
+
export const isSecretServiceType = (type, checker) => {
|
|
10
|
+
const nonNullable = checker.getNonNullableType(type);
|
|
11
|
+
return SECRET_SERVICE_METHODS.every((method) => {
|
|
12
|
+
const property = nonNullable.getProperty(method);
|
|
13
|
+
if (!property) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const declaration = property.valueDeclaration ?? property.declarations?.[0];
|
|
17
|
+
if (!declaration) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const propertyType = checker.getTypeOfSymbolAtLocation(property, declaration);
|
|
21
|
+
return propertyType.getCallSignatures().length > 0;
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Singleton service names, other than `secrets`, whose type is a
|
|
26
|
+
* `SecretService`.
|
|
27
|
+
*/
|
|
28
|
+
export const findSecretAliasServices = (singletonServicesType, checker) => {
|
|
29
|
+
const aliases = [];
|
|
30
|
+
for (const property of singletonServicesType.getProperties()) {
|
|
31
|
+
const name = property.getName();
|
|
32
|
+
if (name === 'secrets') {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const declaration = property.valueDeclaration ?? property.declarations?.[0];
|
|
36
|
+
if (!declaration) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const type = checker.getTypeOfSymbolAtLocation(property, declaration);
|
|
40
|
+
if (isSecretServiceType(type, checker)) {
|
|
41
|
+
aliases.push(name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return aliases.sort();
|
|
45
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
/** Whether this function keeps the full `SecretService`. */
|
|
8
|
+
export const isSecretBrokerFunction = (functionId) => SECRET_BROKER_FUNCTIONS.has(functionId) ||
|
|
9
|
+
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 { InspectorDiagnostic, InspectorFeature, InspectorState } from '../types.js';
|
|
3
4
|
/**
|
|
@@ -228,6 +229,7 @@ export interface SerializableInspectorState {
|
|
|
228
229
|
secrets: {
|
|
229
230
|
definitions: InspectorState['secrets']['definitions'];
|
|
230
231
|
files: string[];
|
|
232
|
+
usage: Record<string, SecretUsage>;
|
|
231
233
|
};
|
|
232
234
|
credentials: {
|
|
233
235
|
definitions: InspectorState['credentials']['definitions'];
|
|
@@ -237,6 +239,14 @@ export interface SerializableInspectorState {
|
|
|
237
239
|
definitions: InspectorState['scopes']['definitions'];
|
|
238
240
|
files: string[];
|
|
239
241
|
};
|
|
242
|
+
systemRoles: {
|
|
243
|
+
definitions: InspectorState['systemRoles']['definitions'];
|
|
244
|
+
files: string[];
|
|
245
|
+
};
|
|
246
|
+
personas: {
|
|
247
|
+
definitions: InspectorState['personas']['definitions'];
|
|
248
|
+
files: string[];
|
|
249
|
+
};
|
|
240
250
|
variables: {
|
|
241
251
|
definitions: InspectorState['variables']['definitions'];
|
|
242
252
|
files: string[];
|
|
@@ -112,6 +112,7 @@ export function serializeInspectorState(state) {
|
|
|
112
112
|
secrets: {
|
|
113
113
|
definitions: state.secrets.definitions,
|
|
114
114
|
files: Array.from(state.secrets.files),
|
|
115
|
+
usage: Object.fromEntries(state.secrets.usage),
|
|
115
116
|
},
|
|
116
117
|
credentials: {
|
|
117
118
|
definitions: state.credentials.definitions,
|
|
@@ -121,6 +122,14 @@ export function serializeInspectorState(state) {
|
|
|
121
122
|
definitions: state.scopes.definitions,
|
|
122
123
|
files: Array.from(state.scopes.files),
|
|
123
124
|
},
|
|
125
|
+
systemRoles: {
|
|
126
|
+
definitions: state.systemRoles.definitions,
|
|
127
|
+
files: Array.from(state.systemRoles.files),
|
|
128
|
+
},
|
|
129
|
+
personas: {
|
|
130
|
+
definitions: state.personas.definitions,
|
|
131
|
+
files: Array.from(state.personas.files),
|
|
132
|
+
},
|
|
124
133
|
variables: {
|
|
125
134
|
definitions: state.variables.definitions,
|
|
126
135
|
files: Array.from(state.variables.files),
|
|
@@ -276,6 +285,7 @@ export function deserializeInspectorState(data) {
|
|
|
276
285
|
secrets: {
|
|
277
286
|
definitions: data.secrets?.definitions || [],
|
|
278
287
|
files: new Set(data.secrets?.files || []),
|
|
288
|
+
usage: new Map(Object.entries(data.secrets?.usage || {})),
|
|
279
289
|
},
|
|
280
290
|
credentials: {
|
|
281
291
|
definitions: data.credentials?.definitions || [],
|
|
@@ -285,6 +295,14 @@ export function deserializeInspectorState(data) {
|
|
|
285
295
|
definitions: data.scopes?.definitions || [],
|
|
286
296
|
files: new Set(data.scopes?.files || []),
|
|
287
297
|
},
|
|
298
|
+
systemRoles: {
|
|
299
|
+
definitions: data.systemRoles?.definitions || [],
|
|
300
|
+
files: new Set(data.systemRoles?.files || []),
|
|
301
|
+
},
|
|
302
|
+
personas: {
|
|
303
|
+
definitions: data.personas?.definitions || [],
|
|
304
|
+
files: new Set(data.personas?.files || []),
|
|
305
|
+
},
|
|
288
306
|
variables: {
|
|
289
307
|
definitions: data.variables?.definitions || [],
|
|
290
308
|
files: new Set(data.variables?.files || []),
|
|
@@ -9,6 +9,9 @@ const serializeGroupMap = (groupMap) => {
|
|
|
9
9
|
count: meta.count,
|
|
10
10
|
instanceIds: meta.instanceIds,
|
|
11
11
|
isFactory: meta.isFactory,
|
|
12
|
+
...(meta.additionalRegistrations && {
|
|
13
|
+
additionalRegistrations: meta.additionalRegistrations,
|
|
14
|
+
}),
|
|
12
15
|
};
|
|
13
16
|
}
|
|
14
17
|
return result;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { InspectorLogger, InspectorState } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A function reachable through the generated `POST /rpc/:rpcName` dispatcher
|
|
4
|
+
* that nothing will stop.
|
|
5
|
+
*
|
|
6
|
+
* `rpc.exposed` refuses anything without `expose: true`, so the population is
|
|
7
|
+
* small and deliberate — someone typed it. What makes one dangerous is the
|
|
8
|
+
* combination: exposed, sessionless, and carrying no gate of its own or from
|
|
9
|
+
* the addon it belongs to. The dispatcher cannot help, because it does not know
|
|
10
|
+
* what it is dispatching to.
|
|
11
|
+
*
|
|
12
|
+
* knowledge: decisions/security/console-addon-privileged-functions-gate-themselves.md
|
|
13
|
+
*/
|
|
14
|
+
export declare function validateExposedFunctionsGated(logger: InspectorLogger, state: InspectorState): void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ErrorCode } from '../error-codes.js';
|
|
2
|
+
/**
|
|
3
|
+
* A function reachable through the generated `POST /rpc/:rpcName` dispatcher
|
|
4
|
+
* that nothing will stop.
|
|
5
|
+
*
|
|
6
|
+
* `rpc.exposed` refuses anything without `expose: true`, so the population is
|
|
7
|
+
* small and deliberate — someone typed it. What makes one dangerous is the
|
|
8
|
+
* combination: exposed, sessionless, and carrying no gate of its own or from
|
|
9
|
+
* the addon it belongs to. The dispatcher cannot help, because it does not know
|
|
10
|
+
* what it is dispatching to.
|
|
11
|
+
*
|
|
12
|
+
* knowledge: decisions/security/console-addon-privileged-functions-gate-themselves.md
|
|
13
|
+
*/
|
|
14
|
+
export function validateExposedFunctionsGated(logger, state) {
|
|
15
|
+
const ungated = [];
|
|
16
|
+
for (const [funcName, meta] of Object.entries(state.functions.meta)) {
|
|
17
|
+
if (!meta.expose)
|
|
18
|
+
continue;
|
|
19
|
+
// A pikkuFunc always requires a session, on every path.
|
|
20
|
+
if (meta.sessionless === false)
|
|
21
|
+
continue;
|
|
22
|
+
// Scenario steps are refused by rpcExposed regardless of `expose`.
|
|
23
|
+
if (meta.scenarioStep)
|
|
24
|
+
continue;
|
|
25
|
+
// The author declared that the body authorizes its own callers — a gate
|
|
26
|
+
// this check has no way to see. Taking the claim at face value is the
|
|
27
|
+
// point: without it the warning fires forever on functions that are fine,
|
|
28
|
+
// and a warning that is usually wrong stops being read.
|
|
29
|
+
if (meta.selfAuthenticated === true)
|
|
30
|
+
continue;
|
|
31
|
+
// The function gates itself.
|
|
32
|
+
if (meta.auth === true)
|
|
33
|
+
continue;
|
|
34
|
+
if (meta.scopes && meta.scopes.length > 0)
|
|
35
|
+
continue;
|
|
36
|
+
if (meta.permissions && meta.permissions.length > 0)
|
|
37
|
+
continue;
|
|
38
|
+
if (isGatedByItsAddon(state, meta.packageName))
|
|
39
|
+
continue;
|
|
40
|
+
ungated.push(funcName);
|
|
41
|
+
}
|
|
42
|
+
if (ungated.length === 0)
|
|
43
|
+
return;
|
|
44
|
+
const shown = ungated.slice(0, 10);
|
|
45
|
+
const rest = ungated.length - shown.length;
|
|
46
|
+
logger.diagnostic({
|
|
47
|
+
severity: 'warn',
|
|
48
|
+
code: ErrorCode.EXPOSED_FUNCTION_HAS_NO_GATE,
|
|
49
|
+
message: `${ungated.length} exposed function${ungated.length === 1 ? '' : 's'} ` +
|
|
50
|
+
`require neither a session nor any permission or scope, and ` +
|
|
51
|
+
`${ungated.length === 1 ? 'is' : 'are'} reachable by anyone through ` +
|
|
52
|
+
`POST /rpc/:rpcName: ${shown.join(', ')}` +
|
|
53
|
+
(rest > 0 ? `, and ${rest} more` : '') +
|
|
54
|
+
`. Add scopes or permissions to the function, gate the whole addon with ` +
|
|
55
|
+
`wireAddon({ scopes: [...] }), or drop \`expose: true\` if it was not ` +
|
|
56
|
+
`meant to be callable from outside.`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether every function in this package is already gated by its `wireAddon`.
|
|
61
|
+
* Those gates are applied by `runPikkuFunc` on every wiring path, so a function
|
|
62
|
+
* with no gate of its own is still covered by them.
|
|
63
|
+
*
|
|
64
|
+
* An addon whose `scopes`/`auth` were not statically knowable reads as
|
|
65
|
+
* unknown, and unknown is treated as gated — a false negative here costs a
|
|
66
|
+
* missing warning, a false positive costs trust in every warning.
|
|
67
|
+
*
|
|
68
|
+
* knowledge: decisions/security/addon-scopes-are-resolved-where-the-function-runs.md
|
|
69
|
+
*/
|
|
70
|
+
function isGatedByItsAddon(state, packageName) {
|
|
71
|
+
if (!packageName)
|
|
72
|
+
return false;
|
|
73
|
+
const declarations = state.rpc?.wireAddonDeclarations;
|
|
74
|
+
if (!declarations)
|
|
75
|
+
return false;
|
|
76
|
+
for (const declaration of declarations.values()) {
|
|
77
|
+
if (declaration.package !== packageName)
|
|
78
|
+
continue;
|
|
79
|
+
if (declaration.auth === true)
|
|
80
|
+
return true;
|
|
81
|
+
if (declaration.scopes && declaration.scopes.length > 0)
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { InspectorLogger, InspectorState } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* An addon tag naming middleware nobody registered.
|
|
4
|
+
*
|
|
5
|
+
* `wireAddon({ tags: ['admin'] })` reads like a gate and is applied like one —
|
|
6
|
+
* right up until no `addTagMiddleware('admin', …)` exists, at which point it
|
|
7
|
+
* resolves to an empty list and every request sails through. Nothing at runtime
|
|
8
|
+
* distinguishes "this tag runs three middlewares" from "this tag does nothing".
|
|
9
|
+
*
|
|
10
|
+
* Only **addon** tags are reported. A tag on a function is just as likely to be
|
|
11
|
+
* organizational — grouping, documentation, OpenAPI — and warning about every
|
|
12
|
+
* one of those would bury the case that matters. A tag on `wireAddon` is
|
|
13
|
+
* different: it is the documented way to apply a gate across a whole addon, so
|
|
14
|
+
* an inert one is never decorative. An exposed function relying on a tag it
|
|
15
|
+
* has no other gate behind is already reported by
|
|
16
|
+
* `validateExposedFunctionsGated`, which does not count tags as a gate.
|
|
17
|
+
*
|
|
18
|
+
* knowledge: decisions/security/addon-auth-and-tags-only-tighten.md
|
|
19
|
+
*/
|
|
20
|
+
export declare function validateTagsResolveToMiddleware(logger: InspectorLogger, state: InspectorState): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ErrorCode } from '../error-codes.js';
|
|
2
|
+
/**
|
|
3
|
+
* An addon tag naming middleware nobody registered.
|
|
4
|
+
*
|
|
5
|
+
* `wireAddon({ tags: ['admin'] })` reads like a gate and is applied like one —
|
|
6
|
+
* right up until no `addTagMiddleware('admin', …)` exists, at which point it
|
|
7
|
+
* resolves to an empty list and every request sails through. Nothing at runtime
|
|
8
|
+
* distinguishes "this tag runs three middlewares" from "this tag does nothing".
|
|
9
|
+
*
|
|
10
|
+
* Only **addon** tags are reported. A tag on a function is just as likely to be
|
|
11
|
+
* organizational — grouping, documentation, OpenAPI — and warning about every
|
|
12
|
+
* one of those would bury the case that matters. A tag on `wireAddon` is
|
|
13
|
+
* different: it is the documented way to apply a gate across a whole addon, so
|
|
14
|
+
* an inert one is never decorative. An exposed function relying on a tag it
|
|
15
|
+
* has no other gate behind is already reported by
|
|
16
|
+
* `validateExposedFunctionsGated`, which does not count tags as a gate.
|
|
17
|
+
*
|
|
18
|
+
* knowledge: decisions/security/addon-auth-and-tags-only-tighten.md
|
|
19
|
+
*/
|
|
20
|
+
export function validateTagsResolveToMiddleware(logger, state) {
|
|
21
|
+
const registered = new Set([
|
|
22
|
+
...state.middleware.tagMiddleware.keys(),
|
|
23
|
+
...state.channelMiddleware.tagMiddleware.keys(),
|
|
24
|
+
]);
|
|
25
|
+
for (const [namespace, declaration] of state.rpc?.wireAddonDeclarations ??
|
|
26
|
+
[]) {
|
|
27
|
+
for (const tag of declaration.tags ?? []) {
|
|
28
|
+
if (registered.has(tag))
|
|
29
|
+
continue;
|
|
30
|
+
logger.diagnostic({
|
|
31
|
+
severity: 'warn',
|
|
32
|
+
code: ErrorCode.TAG_RESOLVES_TO_NO_MIDDLEWARE,
|
|
33
|
+
message: `wireAddon('${namespace}') declares tag '${tag}', but no ` +
|
|
34
|
+
`addTagMiddleware('${tag}', …) registers anything for it. The tag ` +
|
|
35
|
+
`resolves to an empty middleware list, so it applies no gate to the ` +
|
|
36
|
+
`addon at all. Register middleware for it, or drop the tag if it is ` +
|
|
37
|
+
`only meant to group and document.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/visit.js
CHANGED
|
@@ -23,6 +23,8 @@ import { addAuth } from './add/add-auth.js';
|
|
|
23
23
|
import { addSecret } from './add/add-secret.js';
|
|
24
24
|
import { addCredential } from './add/add-credential.js';
|
|
25
25
|
import { addScope } from './add/add-scope.js';
|
|
26
|
+
import { addSystemRole } from './add/add-system-role.js';
|
|
27
|
+
import { addPersonas } from './add/add-personas.js';
|
|
26
28
|
import { addVariable } from './add/add-variable.js';
|
|
27
29
|
import { addWorkflowGraph } from './add/add-workflow-graph.js';
|
|
28
30
|
import { addFeature } from './add/add-feature.js';
|
|
@@ -69,6 +71,8 @@ export const visitRoutes = (logger, checker, node, state, options) => {
|
|
|
69
71
|
addSecret(logger, node, checker, state, nextOptions);
|
|
70
72
|
addCredential(logger, node, checker, state, nextOptions);
|
|
71
73
|
addScope(logger, node, checker, state, nextOptions);
|
|
74
|
+
addSystemRole(logger, node, checker, state, nextOptions);
|
|
75
|
+
addPersonas(logger, node, checker, state, nextOptions);
|
|
72
76
|
addVariable(logger, node, checker, state, nextOptions);
|
|
73
77
|
addHTTPRoute(logger, node, checker, state, nextOptions);
|
|
74
78
|
addHTTPRoutes(logger, node, checker, state, nextOptions);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.52",
|
|
4
4
|
"description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
"zod-to-ts": "^2.1.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@pikku/core": "^0.12.
|
|
48
|
+
"@pikku/core": "^0.12.74"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.74",
|
|
52
52
|
"@types/node": "^24.11.0"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
package/src/add/add-auth.test.ts
CHANGED
|
@@ -13,8 +13,10 @@ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
|
13
13
|
info: () => {},
|
|
14
14
|
warn: () => {},
|
|
15
15
|
error: () => {},
|
|
16
|
-
diagnostic: ({ code, message }) => {
|
|
17
|
-
|
|
16
|
+
diagnostic: ({ severity, code, message }) => {
|
|
17
|
+
if (severity !== 'warn') {
|
|
18
|
+
criticals.push({ code, message })
|
|
19
|
+
}
|
|
18
20
|
},
|
|
19
21
|
critical: (code: ErrorCode, message: string) => {
|
|
20
22
|
criticals.push({ code, message })
|
|
@@ -111,7 +113,7 @@ describe('addAuth inspector', () => {
|
|
|
111
113
|
'export const auth = pikkuBetterAuth(() =>',
|
|
112
114
|
' betterAuth({',
|
|
113
115
|
' session: { cookieCache: { enabled: true } },',
|
|
114
|
-
|
|
116
|
+
' emailAndPassword: { enabled: true },',
|
|
115
117
|
' })',
|
|
116
118
|
')',
|
|
117
119
|
].join('\n')
|
package/src/add/add-auth.ts
CHANGED
|
@@ -139,7 +139,7 @@ const isInsideGlobalMiddlewareRegistration = (node: ts.Node): boolean => {
|
|
|
139
139
|
*
|
|
140
140
|
* The pikku CLI discovers that single export and generates a catch-all
|
|
141
141
|
* `auth.gen.ts` that wires `${basePath}/**` to one shared handler, registers the
|
|
142
|
-
* better-auth session middleware, and emits a `
|
|
142
|
+
* better-auth session middleware, and emits a `defineSecret` for every configured
|
|
143
143
|
* social provider — so the auth routes and secret requirements flow through
|
|
144
144
|
* normal inspection into the deploy manifest.
|
|
145
145
|
*
|
package/src/add/add-cli.ts
CHANGED
|
@@ -153,6 +153,14 @@ function processCLIConfig(
|
|
|
153
153
|
// Already handled in first pass
|
|
154
154
|
break
|
|
155
155
|
|
|
156
|
+
case 'auth': {
|
|
157
|
+
const value = getPropertyValue(node, 'auth')
|
|
158
|
+
if (typeof value === 'boolean') {
|
|
159
|
+
programMeta.auth = value
|
|
160
|
+
}
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
|
|
156
164
|
case 'commands':
|
|
157
165
|
if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
158
166
|
programMeta.commands = processCommands(
|
|
@@ -24,7 +24,7 @@ export const addCredential: AddWiring = (
|
|
|
24
24
|
const firstArg = args[0]
|
|
25
25
|
const expression = node.expression
|
|
26
26
|
|
|
27
|
-
if (!ts.isIdentifier(expression) || expression.text !== '
|
|
27
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'defineCredential') {
|
|
28
28
|
return
|
|
29
29
|
}
|
|
30
30
|
|