@pikku/inspector 0.12.50 → 0.12.54
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 +743 -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 +199 -8
- 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 +216 -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/add/add-workflow.js +12 -1
- package/dist/error-codes.d.ts +17 -1
- package/dist/error-codes.js +23 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +33 -1
- package/dist/types.d.ts +55 -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/check-secret-sinks.d.ts +31 -0
- package/dist/utils/check-secret-sinks.js +117 -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 +246 -13
- 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/single-declaration.d.ts +32 -0
- package/dist/utils/single-declaration.js +43 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
- package/dist/utils/validate-exposed-functions-gated.js +93 -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/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
- package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
- package/dist/utils/workflow/dsl/patterns.js +26 -4
- 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 +273 -11
- 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 +382 -0
- package/src/add/add-personas.ts +285 -0
- package/src/add/add-scenario-step.test.ts +87 -5
- package/src/add/add-scope.test.ts +103 -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 +297 -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/add-workflow.ts +21 -1
- package/src/add/addon-bans.test.ts +4 -4
- package/src/add/allow-escape-hatches.test.ts +144 -0
- package/src/add/audit-meta.test.ts +75 -0
- package/src/add/config-value-metadata.test.ts +11 -11
- package/src/add/secret-sink-check.test.ts +211 -0
- package/src/add/wire-name-literal.test.ts +2 -2
- package/src/error-codes.ts +26 -0
- package/src/index.ts +4 -1
- package/src/inspector.ts +39 -0
- package/src/types.ts +56 -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/check-secret-sinks.ts +145 -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 +313 -12
- 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/single-declaration.ts +57 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
- package/src/utils/validate-exposed-functions-gated.ts +103 -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/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
- package/src/utils/workflow/dsl/patterns.ts +33 -4
- package/src/visit.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
package/OPTIMIZATION-PLAN.md
CHANGED
|
@@ -59,9 +59,15 @@ node --import tsx src/benchmark.ts <target-dir> <iterations>
|
|
|
59
59
|
| `wireMCPTool` | `addMCPTool` |
|
|
60
60
|
| `wireMCPPrompt` | `addMCPPrompt` |
|
|
61
61
|
| `wireWorkflowGraph` | `addWorkflowGraph` |
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
62
|
+
| `defineSecret` | `addSecret` |
|
|
63
|
+
| `defineCredential` | `addCredential` |
|
|
64
|
+
| `defineScope` | `addScope` |
|
|
65
|
+
| `defineVariable` | `addVariable` |
|
|
66
|
+
|
|
67
|
+
`addAuth` stays outside the map: it matches several identifiers
|
|
68
|
+
(`betterAuthStatelessSession`, `betterAuthSession`) and gates them on
|
|
69
|
+
surrounding context rather than on the identifier alone, so it keeps running on
|
|
70
|
+
every CallExpression.
|
|
65
71
|
|
|
66
72
|
Fallback for unmatched identifiers: check `/pikku.*func/i` regex -> `addFunctions`.
|
|
67
73
|
|
package/dist/add/add-auth.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export declare const AUTH_HANDLER_FUNC_ID = "authHandler";
|
|
|
19
19
|
*
|
|
20
20
|
* The pikku CLI discovers that single export and generates a catch-all
|
|
21
21
|
* `auth.gen.ts` that wires `${basePath}/**` to one shared handler, registers the
|
|
22
|
-
* better-auth session middleware, and emits a `
|
|
22
|
+
* better-auth session middleware, and emits a `defineSecret` for every configured
|
|
23
23
|
* social provider — so the auth routes and secret requirements flow through
|
|
24
24
|
* normal inspection into the deploy manifest.
|
|
25
25
|
*
|
package/dist/add/add-auth.js
CHANGED
|
@@ -112,7 +112,7 @@ const isInsideGlobalMiddlewareRegistration = (node) => {
|
|
|
112
112
|
*
|
|
113
113
|
* The pikku CLI discovers that single export and generates a catch-all
|
|
114
114
|
* `auth.gen.ts` that wires `${basePath}/**` to one shared handler, registers the
|
|
115
|
-
* better-auth session middleware, and emits a `
|
|
115
|
+
* better-auth session middleware, and emits a `defineSecret` for every configured
|
|
116
116
|
* social provider — so the auth routes and secret requirements flow through
|
|
117
117
|
* normal inspection into the deploy manifest.
|
|
118
118
|
*
|
package/dist/add/add-cli.js
CHANGED
|
@@ -99,6 +99,13 @@ function processCLIConfig(logger, node, sourceFile, typeChecker, inspectorState,
|
|
|
99
99
|
case 'tags':
|
|
100
100
|
// Already handled in first pass
|
|
101
101
|
break;
|
|
102
|
+
case 'auth': {
|
|
103
|
+
const value = getPropertyValue(node, 'auth');
|
|
104
|
+
if (typeof value === 'boolean') {
|
|
105
|
+
programMeta.auth = value;
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
102
109
|
case 'commands':
|
|
103
110
|
if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
104
111
|
programMeta.commands = processCommands(logger, prop.initializer, sourceFile, typeChecker, programName, inspectorState, options, programTags);
|
|
@@ -9,7 +9,7 @@ export const addCredential = (logger, node, checker, state, _options) => {
|
|
|
9
9
|
const args = node.arguments;
|
|
10
10
|
const firstArg = args[0];
|
|
11
11
|
const expression = node.expression;
|
|
12
|
-
if (!ts.isIdentifier(expression) || expression.text !== '
|
|
12
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'defineCredential') {
|
|
13
13
|
return;
|
|
14
14
|
}
|
|
15
15
|
if (!firstArg) {
|
|
@@ -4,6 +4,24 @@ import { extractFunctionName, funcIdToTypeName, } from '../utils/extract-functio
|
|
|
4
4
|
import { extractFunctionNode } from '../utils/extract-function-node.js';
|
|
5
5
|
import { extractUsedWires } from '../utils/extract-services.js';
|
|
6
6
|
import { formatVersionedId, parseVersionedId } from '@pikku/core';
|
|
7
|
+
/** Binding keys a `pikkuScenarioStep` may declare, in report order. */
|
|
8
|
+
const SCENARIO_SURFACE_KEYS = [
|
|
9
|
+
'browser',
|
|
10
|
+
'cli',
|
|
11
|
+
'default',
|
|
12
|
+
];
|
|
13
|
+
/**
|
|
14
|
+
* The step wrappers, and who acts in each.
|
|
15
|
+
*
|
|
16
|
+
* Which function was called *is* the kind. That is the whole reason these are
|
|
17
|
+
* three declarations rather than a `kind:` field on one: a string literal can be
|
|
18
|
+
* copied into an addon package by a consumer, a called identifier cannot.
|
|
19
|
+
*/
|
|
20
|
+
const SCENARIO_STEP_KINDS = new Map([
|
|
21
|
+
['pikkuScenarioStep', 'persona'],
|
|
22
|
+
['pikkuPlatformScenarioStep', 'platform'],
|
|
23
|
+
['pikkuAddonScenarioStep', 'addon'],
|
|
24
|
+
]);
|
|
7
25
|
import { getPropertyValue, getArrayPropertyValue, getCommonWireMetaData, } from '../utils/get-property-value.js';
|
|
8
26
|
import { canonicalJSON, hashString } from '../utils/hash.js';
|
|
9
27
|
import { resolveMiddleware } from '../utils/middleware.js';
|
|
@@ -11,6 +29,7 @@ import { resolvePermissions } from '../utils/permissions.js';
|
|
|
11
29
|
import { extractWireNames } from '../utils/post-process.js';
|
|
12
30
|
import { ErrorCode } from '../error-codes.js';
|
|
13
31
|
import { findPiiPaths } from '../utils/check-pii-output.js';
|
|
32
|
+
import { findRevealedSecretSinks } from '../utils/check-secret-sinks.js';
|
|
14
33
|
import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js';
|
|
15
34
|
const isValidVariableName = (name) => {
|
|
16
35
|
const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
|
@@ -261,6 +280,66 @@ function computeImplementationHash(args) {
|
|
|
261
280
|
config: objectNode ? printNode(objectNode) : null,
|
|
262
281
|
}));
|
|
263
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Registers a `pikkuRemoteChannelFunc` declaration: a name, its schemas and its
|
|
285
|
+
* description, with no body to inspect. The name has to reach `rpc.internalMeta`
|
|
286
|
+
* for `channel.remote` to resolve the schemas it validates each call against.
|
|
287
|
+
*/
|
|
288
|
+
const addRemoteChannelFunc = ({ state, logger, node, pikkuFuncId, name, exportedName, inputSchemaRef, outputSchemaRef, meta, }) => {
|
|
289
|
+
const sourceFile = node.getSourceFile().fileName;
|
|
290
|
+
if (!exportedName) {
|
|
291
|
+
logger.error(`• Remote channel function '${name}' is not exported, this is not allowed.`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Two files declaring the same capability is the collision that matters:
|
|
295
|
+
// both ids are the bare name, so the id-compatibility check below reads them
|
|
296
|
+
// as the same function and lets the second overwrite the first.
|
|
297
|
+
const existingFunction = state.functions.meta[pikkuFuncId];
|
|
298
|
+
if (existingFunction &&
|
|
299
|
+
existingFunction.sourceFile &&
|
|
300
|
+
existingFunction.sourceFile !== sourceFile) {
|
|
301
|
+
logger.critical(ErrorCode.DUPLICATE_FUNCTION_NAME, `Function name '${name}' is not unique. ` +
|
|
302
|
+
`'${pikkuFuncId}' is already defined in '${existingFunction.sourceFile}' and cannot be redefined in '${sourceFile}'.`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const existing = state.rpc.internalMeta[name];
|
|
306
|
+
if (existing && !areCompatibleFunctionIds(existing, pikkuFuncId)) {
|
|
307
|
+
logger.critical(ErrorCode.DUPLICATE_FUNCTION_NAME, `Function name '${name}' is not unique. ` +
|
|
308
|
+
`It already points to '${existing}' in '${resolveExistingFunctionSource(state, existing) || 'unknown file'}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const capitalizedName = funcIdToTypeName(name);
|
|
312
|
+
const registerSchema = (ref, suffix) => {
|
|
313
|
+
if (!ref)
|
|
314
|
+
return null;
|
|
315
|
+
const schemaName = `${capitalizedName}${suffix}`;
|
|
316
|
+
state.schemaLookup.set(schemaName, ref);
|
|
317
|
+
state.functions.typesMap.addCustomType(schemaName, 'unknown', []);
|
|
318
|
+
return schemaName;
|
|
319
|
+
};
|
|
320
|
+
const inputSchemaName = registerSchema(inputSchemaRef, 'Input');
|
|
321
|
+
const outputSchemaName = registerSchema(outputSchemaRef, 'Output');
|
|
322
|
+
state.functions.meta[pikkuFuncId] = {
|
|
323
|
+
pikkuFuncId,
|
|
324
|
+
functionType: 'remote',
|
|
325
|
+
funcWrapper: 'pikkuRemoteChannelFunc',
|
|
326
|
+
sessionless: true,
|
|
327
|
+
name,
|
|
328
|
+
services: { optimized: true, services: [] },
|
|
329
|
+
inputSchemaName,
|
|
330
|
+
outputSchemaName,
|
|
331
|
+
inputs: inputSchemaName ? [inputSchemaName] : [],
|
|
332
|
+
outputs: outputSchemaName ? [outputSchemaName] : [],
|
|
333
|
+
sourceFile,
|
|
334
|
+
exportedName,
|
|
335
|
+
...meta,
|
|
336
|
+
};
|
|
337
|
+
state.functions.files.set(pikkuFuncId, { path: sourceFile, exportedName });
|
|
338
|
+
state.rpc.internalMeta[name] = pikkuFuncId;
|
|
339
|
+
// Imported so a local call gets the factory's "this runs on the client"
|
|
340
|
+
// error rather than a bare lookup miss.
|
|
341
|
+
state.rpc.internalFiles.set(pikkuFuncId, { path: sourceFile, exportedName });
|
|
342
|
+
};
|
|
264
343
|
/**
|
|
265
344
|
* Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
|
|
266
345
|
* then push into state.functions.meta.
|
|
@@ -274,18 +353,19 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
274
353
|
return;
|
|
275
354
|
}
|
|
276
355
|
// Match identifiers that contain both "pikku" and "func" (case insensitive),
|
|
277
|
-
// plus pikkuScenario and
|
|
278
|
-
// in their names)
|
|
356
|
+
// plus pikkuScenario and the three scenario step wrappers (workflow wrappers
|
|
357
|
+
// without "func" in their names)
|
|
279
358
|
const pikkuFuncPattern = /pikku.*func/i;
|
|
280
359
|
if (!pikkuFuncPattern.test(expression.text) &&
|
|
281
360
|
expression.text !== 'pikkuScenario' &&
|
|
282
|
-
expression.text
|
|
361
|
+
!SCENARIO_STEP_KINDS.has(expression.text)) {
|
|
283
362
|
return;
|
|
284
363
|
}
|
|
285
364
|
// A scenario step is bundled like any invoked function, but is deliberately
|
|
286
365
|
// never RPC-registered: a step may drive a browser, so exposing it over the
|
|
287
366
|
// network would put a test harness on the public API surface.
|
|
288
|
-
const
|
|
367
|
+
const scenarioStepKind = SCENARIO_STEP_KINDS.get(expression.text);
|
|
368
|
+
const isScenarioStep = scenarioStepKind !== undefined;
|
|
289
369
|
// A scenario's own body. Marked so the codegen partitions and the deploy
|
|
290
370
|
// analyzer can tell it apart from an application workflow without having to
|
|
291
371
|
// reach into the workflow graph.
|
|
@@ -316,6 +396,17 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
316
396
|
let expose;
|
|
317
397
|
let remote;
|
|
318
398
|
let mcp;
|
|
399
|
+
/**
|
|
400
|
+
* A sessionless function's own `auth: true`. `sessionless` records the
|
|
401
|
+
* baseline — a `pikkuFunc` always needs a session — while this records the
|
|
402
|
+
* tightening a `pikkuSessionlessFunc` applies to itself, which is otherwise
|
|
403
|
+
* invisible to anything reading meta.
|
|
404
|
+
*/
|
|
405
|
+
let auth;
|
|
406
|
+
/** The author's claim that the body authorizes its own callers. */
|
|
407
|
+
let permissionsInBody;
|
|
408
|
+
/** The function's `audit` config, resolved to its object form. */
|
|
409
|
+
let audit;
|
|
319
410
|
let readonly_;
|
|
320
411
|
let deploy;
|
|
321
412
|
let approvalRequired;
|
|
@@ -323,7 +414,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
323
414
|
let workflowQueued;
|
|
324
415
|
let workflowRetries;
|
|
325
416
|
let workflowTimeout;
|
|
326
|
-
let
|
|
417
|
+
let scenarioStepSurfaces;
|
|
418
|
+
let scenarioStepAddon;
|
|
327
419
|
let scenarioStepTemplate;
|
|
328
420
|
let scopes;
|
|
329
421
|
let version;
|
|
@@ -390,13 +482,74 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
390
482
|
expose = getPropertyValue(firstArg, 'expose');
|
|
391
483
|
remote = getPropertyValue(firstArg, 'remote');
|
|
392
484
|
mcp = getPropertyValue(firstArg, 'mcp');
|
|
485
|
+
auth = getPropertyValue(firstArg, 'auth');
|
|
486
|
+
permissionsInBody = getPropertyValue(firstArg, 'permissionsInBody');
|
|
487
|
+
if (permissionsInBody && !options.allow?.permissionsInBody) {
|
|
488
|
+
logger.critical(ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED, `'${name}' declares \`permissionsInBody: true\`, which this project has not enabled. ` +
|
|
489
|
+
`It silences the exposed-and-ungated audit without gating anything, so it is opt-in: ` +
|
|
490
|
+
`set \`allow: { permissionsInBody: true }\` in pikku.config.json if the project accepts ` +
|
|
491
|
+
`permission checks that only a reader can verify. Otherwise express the check as a ` +
|
|
492
|
+
`\`permissions\` entry, or drop \`expose: true\` if the function was not meant to be ` +
|
|
493
|
+
`callable from outside.`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// `audit` is either `true` or `{ durability }`, so it needs the nested read:
|
|
497
|
+
// getPropertyValue stringifies an object initializer rather than parsing it.
|
|
498
|
+
// Mirrors core's `resolveAuditConfig` — the two must agree, or meta would
|
|
499
|
+
// claim a durability the runner does not use.
|
|
500
|
+
const auditProp = firstArg.properties.find((p) => ts.isPropertyAssignment(p) &&
|
|
501
|
+
ts.isIdentifier(p.name) &&
|
|
502
|
+
p.name.text === 'audit');
|
|
503
|
+
if (auditProp && ts.isPropertyAssignment(auditProp)) {
|
|
504
|
+
const auditInit = auditProp.initializer;
|
|
505
|
+
if (auditInit.kind === ts.SyntaxKind.TrueKeyword) {
|
|
506
|
+
audit = { durability: 'best-effort' };
|
|
507
|
+
}
|
|
508
|
+
else if (ts.isObjectLiteralExpression(auditInit)) {
|
|
509
|
+
const durability = getPropertyValue(auditInit, 'durability');
|
|
510
|
+
audit = { durability: durability ?? 'best-effort' };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
393
513
|
readonly_ = getPropertyValue(firstArg, 'readonly');
|
|
394
514
|
deploy = getPropertyValue(firstArg, 'deploy');
|
|
395
515
|
approvalRequired = getPropertyValue(firstArg, 'approvalRequired');
|
|
396
516
|
workflowQueued = getPropertyValue(firstArg, 'workflowQueued');
|
|
397
517
|
workflowRetries = getPropertyValue(firstArg, 'workflowRetries');
|
|
398
518
|
workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout');
|
|
399
|
-
|
|
519
|
+
// A step declares one implementation per surface it supports; the binding
|
|
520
|
+
// keys that are present ARE the declaration, so there is nothing to read a
|
|
521
|
+
// value from — only names to collect.
|
|
522
|
+
const declaredSurfaces = SCENARIO_SURFACE_KEYS.filter((surface) => firstArg.properties.some((prop) => (ts.isPropertyAssignment(prop) ||
|
|
523
|
+
ts.isMethodDeclaration(prop) ||
|
|
524
|
+
ts.isShorthandPropertyAssignment(prop)) &&
|
|
525
|
+
ts.isIdentifier(prop.name) &&
|
|
526
|
+
prop.name.text === surface));
|
|
527
|
+
if (scenarioStepKind === 'platform' || scenarioStepKind === 'addon') {
|
|
528
|
+
// Neither kind has surfaces. Nobody clicks "Stripe's webhook arrives", and
|
|
529
|
+
// there is no human behind "the platform has expired the trial" — so both
|
|
530
|
+
// take a single `func`, and a binding key here is a coded error rather
|
|
531
|
+
// than a convention nobody reads.
|
|
532
|
+
if (declaredSurfaces.length > 0) {
|
|
533
|
+
logger.error(`• ${expression.text}('${name}') declares ${declaredSurfaces.join('/')} — a ${scenarioStepKind} step has one implementation, declared as \`func\`. Surfaces are how a person drives the system, and nobody drives this one.`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
// The phase rule depends on it: an assertion runs every witness it has and
|
|
537
|
+
// fails if they disagree, so a step with exactly one witness by
|
|
538
|
+
// construction has to be unable to look like it has several.
|
|
539
|
+
scenarioStepSurfaces = ['default'];
|
|
540
|
+
if (scenarioStepKind === 'addon') {
|
|
541
|
+
scenarioStepAddon = getPropertyValue(firstArg, 'addon');
|
|
542
|
+
if (!scenarioStepAddon) {
|
|
543
|
+
logger.error(`• pikkuAddonScenarioStep('${name}') needs a string literal \`addon\` naming the addon whose system acts — the same name its wireAddon declares.`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
scenarioStepSurfaces = declaredSurfaces.length
|
|
550
|
+
? declaredSurfaces
|
|
551
|
+
: undefined;
|
|
552
|
+
}
|
|
400
553
|
scenarioStepTemplate = getPropertyValue(firstArg, 'template');
|
|
401
554
|
scopes = getArrayPropertyValue(firstArg, 'scopes') ?? undefined;
|
|
402
555
|
// Extract approvalDescription identifier reference
|
|
@@ -488,6 +641,23 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
488
641
|
}
|
|
489
642
|
pikkuFuncId = formatVersionedId(baseName, version);
|
|
490
643
|
}
|
|
644
|
+
// A remote channel capability is a contract with no body: the connected
|
|
645
|
+
// client answers it. Everything past this point reads the handler, so it gets
|
|
646
|
+
// its own path rather than a nullable handler threaded through all of it.
|
|
647
|
+
if (expression.text === 'pikkuRemoteChannelFunc') {
|
|
648
|
+
addRemoteChannelFunc({
|
|
649
|
+
state,
|
|
650
|
+
logger,
|
|
651
|
+
node,
|
|
652
|
+
pikkuFuncId,
|
|
653
|
+
name,
|
|
654
|
+
exportedName,
|
|
655
|
+
inputSchemaRef,
|
|
656
|
+
outputSchemaRef,
|
|
657
|
+
meta: { version, title, tags, summary, description, errors },
|
|
658
|
+
});
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
491
661
|
const isMCPToolFunc = expression.text === 'pikkuMCPToolFunc';
|
|
492
662
|
const isListFunc = expression.text === 'pikkuListFunc';
|
|
493
663
|
const mcpEnabled = mcp || isMCPToolFunc;
|
|
@@ -706,6 +876,18 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
706
876
|
// it runs ONLY when explicitly requested (`pikku all --security`) — see the
|
|
707
877
|
// classificationCheck option. Default codegen skips it entirely.
|
|
708
878
|
if (options.classificationCheck) {
|
|
879
|
+
// A revealed vault secret is invisible to the return-type scan below when it
|
|
880
|
+
// is written out mid-body rather than returned, so walk the sinks too.
|
|
881
|
+
for (const violation of findRevealedSecretSinks(checker, handler)) {
|
|
882
|
+
logger.diagnostic({
|
|
883
|
+
severity: 'error',
|
|
884
|
+
code: ErrorCode.SECRET_REVEALED_INTO_SINK,
|
|
885
|
+
message: `Function '${name}' passes a revealed secret to '${violation.sink}' ` +
|
|
886
|
+
`at line ${violation.line} (path '${violation.path}').\n ` +
|
|
887
|
+
`A vault secret must not be written to a log, an audit, a queue, an email or a webhook. ` +
|
|
888
|
+
`Pass the value only to the client that needs it, and reveal it there.`,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
709
891
|
const sig = checker.getSignatureFromDeclaration(handler);
|
|
710
892
|
if (sig) {
|
|
711
893
|
const rawRet = checker.getReturnTypeOfSignature(sig);
|
|
@@ -734,7 +916,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
734
916
|
message: `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
|
|
735
917
|
privatePaths.map((p) => `'${p}'`).join(', ') +
|
|
736
918
|
`.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
|
|
737
|
-
`Either require a session (use pikkuFunc) or
|
|
919
|
+
`Either require a session (use pikkuFunc) or set the column's security to 'public' in db/annotations.ts if it is safe to expose publicly.`,
|
|
738
920
|
});
|
|
739
921
|
}
|
|
740
922
|
}
|
|
@@ -795,6 +977,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
795
977
|
inputs: inputNames.filter((n) => n !== 'void') ?? null,
|
|
796
978
|
outputs: outputNames.filter((n) => n !== 'void') ?? null,
|
|
797
979
|
expose: expose || undefined,
|
|
980
|
+
auth: auth || undefined,
|
|
981
|
+
permissionsInBody: permissionsInBody || undefined,
|
|
982
|
+
audit,
|
|
798
983
|
remote: remote || undefined,
|
|
799
984
|
scenarioStep: isScenarioStep || undefined,
|
|
800
985
|
scenario: isScenario || undefined,
|
|
@@ -806,7 +991,13 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
806
991
|
workflowQueued: workflowQueued === true ? true : undefined,
|
|
807
992
|
workflowRetries: workflowRetries ?? undefined,
|
|
808
993
|
workflowTimeout: workflowTimeout ?? undefined,
|
|
809
|
-
|
|
994
|
+
scenarioStepSurfaces,
|
|
995
|
+
// `persona` is the default reading of an unmarked step, so it is left off
|
|
996
|
+
// rather than written into every step meta a project already has.
|
|
997
|
+
scenarioStepKind: scenarioStepKind && scenarioStepKind !== 'persona'
|
|
998
|
+
? scenarioStepKind
|
|
999
|
+
: undefined,
|
|
1000
|
+
scenarioStepAddon,
|
|
810
1001
|
scenarioStepTemplate: scenarioStepTemplate || undefined,
|
|
811
1002
|
scopes: scopes ?? undefined,
|
|
812
1003
|
implementationHash,
|
|
@@ -145,17 +145,23 @@ export function registerHTTPRoute({ obj, state, checker, logger, sourceFile, bas
|
|
|
145
145
|
inlineMeta.sessionless = targetMeta.sessionless;
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
+
// The route's own `auth`, falling back to the group's. Recorded on the meta
|
|
149
|
+
// below so an audit can answer "is this route open?" without re-reading the
|
|
150
|
+
// source: the route can tighten a sessionless function, and until this was
|
|
151
|
+
// written down that tightening existed only in the wiring.
|
|
152
|
+
const routeAuthProp = getPropertyValue(obj, 'auth');
|
|
153
|
+
const resolvedAuth = routeAuthProp === true || routeAuthProp === false
|
|
154
|
+
? routeAuthProp
|
|
155
|
+
: inheritedAuth;
|
|
148
156
|
// For inline functions (e.g. oauth2 handlers), propagate sessionless: true
|
|
149
157
|
// when auth is explicitly disabled at the route or group level — the
|
|
150
158
|
// developer opted out of auth so no session is required.
|
|
151
159
|
{
|
|
152
160
|
const inlineMeta = state.functions.meta[funcName];
|
|
153
|
-
if (inlineMeta &&
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
inlineMeta.sessionless = true;
|
|
158
|
-
}
|
|
161
|
+
if (inlineMeta &&
|
|
162
|
+
inlineMeta.sessionless === undefined &&
|
|
163
|
+
resolvedAuth === false) {
|
|
164
|
+
inlineMeta.sessionless = true;
|
|
159
165
|
}
|
|
160
166
|
}
|
|
161
167
|
// Lookup existing function metadata
|
|
@@ -239,6 +245,7 @@ export function registerHTTPRoute({ obj, state, checker, logger, sourceFile, bas
|
|
|
239
245
|
route: fullRoute,
|
|
240
246
|
sourceFile: sourceFile.fileName,
|
|
241
247
|
method: method,
|
|
248
|
+
...(resolvedAuth !== undefined && { auth: resolvedAuth }),
|
|
242
249
|
params: params.length > 0 ? params : undefined,
|
|
243
250
|
query: query.length > 0 ? query : undefined,
|
|
244
251
|
inputTypes: undefined,
|
|
@@ -284,6 +291,9 @@ export function registerHTTPRouteMeta({ route, state, logger, sourceFile, basePa
|
|
|
284
291
|
route: fullRoute,
|
|
285
292
|
sourceFile: sourceFile.fileName,
|
|
286
293
|
method: method,
|
|
294
|
+
...((route.auth ?? inheritedAuth) !== undefined && {
|
|
295
|
+
auth: route.auth ?? inheritedAuth,
|
|
296
|
+
}),
|
|
287
297
|
params: params.length > 0 ? params : undefined,
|
|
288
298
|
inputTypes: undefined,
|
|
289
299
|
tags: tags.length > 0 ? tags : undefined,
|
|
@@ -4,6 +4,49 @@ import { extractServicesFromFunction, extractUsedWires, } from '../utils/extract
|
|
|
4
4
|
import { extractMiddlewareRefs } from '../utils/middleware.js';
|
|
5
5
|
import { getPropertyValue } from '../utils/get-property-value.js';
|
|
6
6
|
import { getPropertyAssignmentInitializer } from '../utils/type-utils.js';
|
|
7
|
+
/**
|
|
8
|
+
* Record a middleware group, keeping any registration already stored under the
|
|
9
|
+
* same pattern or tag.
|
|
10
|
+
*
|
|
11
|
+
* Several files may add middleware for one pattern — an app's own `'*'` group
|
|
12
|
+
* alongside the generated better-auth session bridge, say — and the runtime
|
|
13
|
+
* composes them. A plain `set` kept only the last, and codegen emits imports
|
|
14
|
+
* from what is stored, so the other file was never imported and its middleware
|
|
15
|
+
* silently stopped running. Merging is the only shape that cannot lose a gate;
|
|
16
|
+
* the first registration stays primary so single-registration groups serialize
|
|
17
|
+
* exactly as before.
|
|
18
|
+
*/
|
|
19
|
+
function recordMiddlewareGroup(groups, key, group) {
|
|
20
|
+
const existing = groups.get(key);
|
|
21
|
+
if (!existing) {
|
|
22
|
+
groups.set(key, group);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// The same call seen twice — the inspector may revisit a file — is not a
|
|
26
|
+
// second registration, and appending it would emit a duplicate import.
|
|
27
|
+
const alreadyRecorded = [
|
|
28
|
+
existing,
|
|
29
|
+
...(existing.additionalRegistrations ?? []),
|
|
30
|
+
];
|
|
31
|
+
if (alreadyRecorded.some((r) => r.sourceFile === group.sourceFile && r.position === group.position)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
existing.additionalRegistrations = [
|
|
35
|
+
...(existing.additionalRegistrations ?? []),
|
|
36
|
+
{
|
|
37
|
+
exportName: group.exportName,
|
|
38
|
+
sourceFile: group.sourceFile,
|
|
39
|
+
position: group.position,
|
|
40
|
+
isFactory: group.isFactory,
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
existing.count += group.count;
|
|
44
|
+
existing.instanceIds = [...existing.instanceIds, ...group.instanceIds];
|
|
45
|
+
existing.services = {
|
|
46
|
+
optimized: false,
|
|
47
|
+
services: Array.from(new Set([...existing.services.services, ...group.services.services])),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
7
50
|
function renameTempDefinitions(state, definitionIds, groupType, groupKey, storeKey = 'middleware') {
|
|
8
51
|
const tempIndices = definitionIds
|
|
9
52
|
.map((name, i) => (name.startsWith('__temp_') ? i : -1))
|
|
@@ -238,7 +281,7 @@ export const addMiddleware = (logger, node, checker, state) => {
|
|
|
238
281
|
logger.warn(`• Middleware group '${exportedName}' for tag '${tag}' is not wrapped in a factory function. ` +
|
|
239
282
|
`For tree-shaking, use: export const ${exportedName} = () => addTagMiddleware('${tag}', [...])`);
|
|
240
283
|
}
|
|
241
|
-
state.middleware.tagMiddleware
|
|
284
|
+
recordMiddlewareGroup(state.middleware.tagMiddleware, tag, {
|
|
242
285
|
exportName: exportedName,
|
|
243
286
|
sourceFile,
|
|
244
287
|
position: node.getStart(),
|
|
@@ -348,7 +391,7 @@ export const addMiddleware = (logger, node, checker, state) => {
|
|
|
348
391
|
logger.warn(`• HTTP middleware group '${exportedName}' for pattern '${pattern}' is not wrapped in a factory function. ` +
|
|
349
392
|
`For tree-shaking, use: export const ${exportedName} = () => addHTTPMiddleware('${pattern}', [...])`);
|
|
350
393
|
}
|
|
351
|
-
state.http.routeMiddleware
|
|
394
|
+
recordMiddlewareGroup(state.http.routeMiddleware, pattern, {
|
|
352
395
|
exportName: exportedName,
|
|
353
396
|
sourceFile,
|
|
354
397
|
position: node.getStart(),
|
|
@@ -582,7 +625,7 @@ export const addMiddleware = (logger, node, checker, state) => {
|
|
|
582
625
|
logger.warn(`• Channel middleware group '${exportedName}' for tag '${tag}' is not wrapped in a factory function. ` +
|
|
583
626
|
`For tree-shaking, use: export const ${exportedName} = () => addChannelMiddleware('${tag}', [...])`);
|
|
584
627
|
}
|
|
585
|
-
state.channelMiddleware.tagMiddleware
|
|
628
|
+
recordMiddlewareGroup(state.channelMiddleware.tagMiddleware, tag, {
|
|
586
629
|
exportName: exportedName,
|
|
587
630
|
sourceFile,
|
|
588
631
|
position: node.getStart(),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AddWiring } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Inspector for `definePersonas()` calls.
|
|
4
|
+
*
|
|
5
|
+
* A persona is entirely declaration — a person, their roles, what they want —
|
|
6
|
+
* so there is no code body and nothing to resolve at runtime. What is read here
|
|
7
|
+
* is the whole thing, and it is generated straight to `personas.gen.json` for
|
|
8
|
+
* the CLI and the console to read without loading the app.
|
|
9
|
+
*
|
|
10
|
+
* `name` is the one field that cannot be defaulted. A persona without one is
|
|
11
|
+
* not a person, and the console has nothing to show.
|
|
12
|
+
*/
|
|
13
|
+
export declare const addPersonas: AddWiring;
|