@pikku/inspector 0.12.50 → 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 +441 -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/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/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
|
@@ -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';
|
|
@@ -261,6 +279,66 @@ function computeImplementationHash(args) {
|
|
|
261
279
|
config: objectNode ? printNode(objectNode) : null,
|
|
262
280
|
}));
|
|
263
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Registers a `pikkuRemoteChannelFunc` declaration: a name, its schemas and its
|
|
284
|
+
* description, with no body to inspect. The name has to reach `rpc.internalMeta`
|
|
285
|
+
* for `channel.remote` to resolve the schemas it validates each call against.
|
|
286
|
+
*/
|
|
287
|
+
const addRemoteChannelFunc = ({ state, logger, node, pikkuFuncId, name, exportedName, inputSchemaRef, outputSchemaRef, meta, }) => {
|
|
288
|
+
const sourceFile = node.getSourceFile().fileName;
|
|
289
|
+
if (!exportedName) {
|
|
290
|
+
logger.error(`• Remote channel function '${name}' is not exported, this is not allowed.`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// Two files declaring the same capability is the collision that matters:
|
|
294
|
+
// both ids are the bare name, so the id-compatibility check below reads them
|
|
295
|
+
// as the same function and lets the second overwrite the first.
|
|
296
|
+
const existingFunction = state.functions.meta[pikkuFuncId];
|
|
297
|
+
if (existingFunction &&
|
|
298
|
+
existingFunction.sourceFile &&
|
|
299
|
+
existingFunction.sourceFile !== sourceFile) {
|
|
300
|
+
logger.critical(ErrorCode.DUPLICATE_FUNCTION_NAME, `Function name '${name}' is not unique. ` +
|
|
301
|
+
`'${pikkuFuncId}' is already defined in '${existingFunction.sourceFile}' and cannot be redefined in '${sourceFile}'.`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const existing = state.rpc.internalMeta[name];
|
|
305
|
+
if (existing && !areCompatibleFunctionIds(existing, pikkuFuncId)) {
|
|
306
|
+
logger.critical(ErrorCode.DUPLICATE_FUNCTION_NAME, `Function name '${name}' is not unique. ` +
|
|
307
|
+
`It already points to '${existing}' in '${resolveExistingFunctionSource(state, existing) || 'unknown file'}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const capitalizedName = funcIdToTypeName(name);
|
|
311
|
+
const registerSchema = (ref, suffix) => {
|
|
312
|
+
if (!ref)
|
|
313
|
+
return null;
|
|
314
|
+
const schemaName = `${capitalizedName}${suffix}`;
|
|
315
|
+
state.schemaLookup.set(schemaName, ref);
|
|
316
|
+
state.functions.typesMap.addCustomType(schemaName, 'unknown', []);
|
|
317
|
+
return schemaName;
|
|
318
|
+
};
|
|
319
|
+
const inputSchemaName = registerSchema(inputSchemaRef, 'Input');
|
|
320
|
+
const outputSchemaName = registerSchema(outputSchemaRef, 'Output');
|
|
321
|
+
state.functions.meta[pikkuFuncId] = {
|
|
322
|
+
pikkuFuncId,
|
|
323
|
+
functionType: 'remote',
|
|
324
|
+
funcWrapper: 'pikkuRemoteChannelFunc',
|
|
325
|
+
sessionless: true,
|
|
326
|
+
name,
|
|
327
|
+
services: { optimized: true, services: [] },
|
|
328
|
+
inputSchemaName,
|
|
329
|
+
outputSchemaName,
|
|
330
|
+
inputs: inputSchemaName ? [inputSchemaName] : [],
|
|
331
|
+
outputs: outputSchemaName ? [outputSchemaName] : [],
|
|
332
|
+
sourceFile,
|
|
333
|
+
exportedName,
|
|
334
|
+
...meta,
|
|
335
|
+
};
|
|
336
|
+
state.functions.files.set(pikkuFuncId, { path: sourceFile, exportedName });
|
|
337
|
+
state.rpc.internalMeta[name] = pikkuFuncId;
|
|
338
|
+
// Imported so a local call gets the factory's "this runs on the client"
|
|
339
|
+
// error rather than a bare lookup miss.
|
|
340
|
+
state.rpc.internalFiles.set(pikkuFuncId, { path: sourceFile, exportedName });
|
|
341
|
+
};
|
|
264
342
|
/**
|
|
265
343
|
* Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
|
|
266
344
|
* then push into state.functions.meta.
|
|
@@ -274,18 +352,19 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
274
352
|
return;
|
|
275
353
|
}
|
|
276
354
|
// Match identifiers that contain both "pikku" and "func" (case insensitive),
|
|
277
|
-
// plus pikkuScenario and
|
|
278
|
-
// in their names)
|
|
355
|
+
// plus pikkuScenario and the three scenario step wrappers (workflow wrappers
|
|
356
|
+
// without "func" in their names)
|
|
279
357
|
const pikkuFuncPattern = /pikku.*func/i;
|
|
280
358
|
if (!pikkuFuncPattern.test(expression.text) &&
|
|
281
359
|
expression.text !== 'pikkuScenario' &&
|
|
282
|
-
expression.text
|
|
360
|
+
!SCENARIO_STEP_KINDS.has(expression.text)) {
|
|
283
361
|
return;
|
|
284
362
|
}
|
|
285
363
|
// A scenario step is bundled like any invoked function, but is deliberately
|
|
286
364
|
// never RPC-registered: a step may drive a browser, so exposing it over the
|
|
287
365
|
// network would put a test harness on the public API surface.
|
|
288
|
-
const
|
|
366
|
+
const scenarioStepKind = SCENARIO_STEP_KINDS.get(expression.text);
|
|
367
|
+
const isScenarioStep = scenarioStepKind !== undefined;
|
|
289
368
|
// A scenario's own body. Marked so the codegen partitions and the deploy
|
|
290
369
|
// analyzer can tell it apart from an application workflow without having to
|
|
291
370
|
// reach into the workflow graph.
|
|
@@ -316,6 +395,15 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
316
395
|
let expose;
|
|
317
396
|
let remote;
|
|
318
397
|
let mcp;
|
|
398
|
+
/**
|
|
399
|
+
* A sessionless function's own `auth: true`. `sessionless` records the
|
|
400
|
+
* baseline — a `pikkuFunc` always needs a session — while this records the
|
|
401
|
+
* tightening a `pikkuSessionlessFunc` applies to itself, which is otherwise
|
|
402
|
+
* invisible to anything reading meta.
|
|
403
|
+
*/
|
|
404
|
+
let auth;
|
|
405
|
+
/** The author's claim that the body authorizes its own callers. */
|
|
406
|
+
let selfAuthenticated;
|
|
319
407
|
let readonly_;
|
|
320
408
|
let deploy;
|
|
321
409
|
let approvalRequired;
|
|
@@ -323,7 +411,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
323
411
|
let workflowQueued;
|
|
324
412
|
let workflowRetries;
|
|
325
413
|
let workflowTimeout;
|
|
326
|
-
let
|
|
414
|
+
let scenarioStepSurfaces;
|
|
415
|
+
let scenarioStepAddon;
|
|
327
416
|
let scenarioStepTemplate;
|
|
328
417
|
let scopes;
|
|
329
418
|
let version;
|
|
@@ -390,13 +479,48 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
390
479
|
expose = getPropertyValue(firstArg, 'expose');
|
|
391
480
|
remote = getPropertyValue(firstArg, 'remote');
|
|
392
481
|
mcp = getPropertyValue(firstArg, 'mcp');
|
|
482
|
+
auth = getPropertyValue(firstArg, 'auth');
|
|
483
|
+
selfAuthenticated = getPropertyValue(firstArg, 'selfAuthenticated');
|
|
393
484
|
readonly_ = getPropertyValue(firstArg, 'readonly');
|
|
394
485
|
deploy = getPropertyValue(firstArg, 'deploy');
|
|
395
486
|
approvalRequired = getPropertyValue(firstArg, 'approvalRequired');
|
|
396
487
|
workflowQueued = getPropertyValue(firstArg, 'workflowQueued');
|
|
397
488
|
workflowRetries = getPropertyValue(firstArg, 'workflowRetries');
|
|
398
489
|
workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout');
|
|
399
|
-
|
|
490
|
+
// A step declares one implementation per surface it supports; the binding
|
|
491
|
+
// keys that are present ARE the declaration, so there is nothing to read a
|
|
492
|
+
// value from — only names to collect.
|
|
493
|
+
const declaredSurfaces = SCENARIO_SURFACE_KEYS.filter((surface) => firstArg.properties.some((prop) => (ts.isPropertyAssignment(prop) ||
|
|
494
|
+
ts.isMethodDeclaration(prop) ||
|
|
495
|
+
ts.isShorthandPropertyAssignment(prop)) &&
|
|
496
|
+
ts.isIdentifier(prop.name) &&
|
|
497
|
+
prop.name.text === surface));
|
|
498
|
+
if (scenarioStepKind === 'platform' || scenarioStepKind === 'addon') {
|
|
499
|
+
// Neither kind has surfaces. Nobody clicks "Stripe's webhook arrives", and
|
|
500
|
+
// there is no human behind "the platform has expired the trial" — so both
|
|
501
|
+
// take a single `func`, and a binding key here is a coded error rather
|
|
502
|
+
// than a convention nobody reads.
|
|
503
|
+
if (declaredSurfaces.length > 0) {
|
|
504
|
+
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.`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
// The phase rule depends on it: an assertion runs every witness it has and
|
|
508
|
+
// fails if they disagree, so a step with exactly one witness by
|
|
509
|
+
// construction has to be unable to look like it has several.
|
|
510
|
+
scenarioStepSurfaces = ['default'];
|
|
511
|
+
if (scenarioStepKind === 'addon') {
|
|
512
|
+
scenarioStepAddon = getPropertyValue(firstArg, 'addon');
|
|
513
|
+
if (!scenarioStepAddon) {
|
|
514
|
+
logger.error(`• pikkuAddonScenarioStep('${name}') needs a string literal \`addon\` naming the addon whose system acts — the same name its wireAddon declares.`);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
scenarioStepSurfaces = declaredSurfaces.length
|
|
521
|
+
? declaredSurfaces
|
|
522
|
+
: undefined;
|
|
523
|
+
}
|
|
400
524
|
scenarioStepTemplate = getPropertyValue(firstArg, 'template');
|
|
401
525
|
scopes = getArrayPropertyValue(firstArg, 'scopes') ?? undefined;
|
|
402
526
|
// Extract approvalDescription identifier reference
|
|
@@ -488,6 +612,23 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
488
612
|
}
|
|
489
613
|
pikkuFuncId = formatVersionedId(baseName, version);
|
|
490
614
|
}
|
|
615
|
+
// A remote channel capability is a contract with no body: the connected
|
|
616
|
+
// client answers it. Everything past this point reads the handler, so it gets
|
|
617
|
+
// its own path rather than a nullable handler threaded through all of it.
|
|
618
|
+
if (expression.text === 'pikkuRemoteChannelFunc') {
|
|
619
|
+
addRemoteChannelFunc({
|
|
620
|
+
state,
|
|
621
|
+
logger,
|
|
622
|
+
node,
|
|
623
|
+
pikkuFuncId,
|
|
624
|
+
name,
|
|
625
|
+
exportedName,
|
|
626
|
+
inputSchemaRef,
|
|
627
|
+
outputSchemaRef,
|
|
628
|
+
meta: { version, title, tags, summary, description, errors },
|
|
629
|
+
});
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
491
632
|
const isMCPToolFunc = expression.text === 'pikkuMCPToolFunc';
|
|
492
633
|
const isListFunc = expression.text === 'pikkuListFunc';
|
|
493
634
|
const mcpEnabled = mcp || isMCPToolFunc;
|
|
@@ -795,6 +936,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
795
936
|
inputs: inputNames.filter((n) => n !== 'void') ?? null,
|
|
796
937
|
outputs: outputNames.filter((n) => n !== 'void') ?? null,
|
|
797
938
|
expose: expose || undefined,
|
|
939
|
+
auth: auth || undefined,
|
|
940
|
+
selfAuthenticated: selfAuthenticated || undefined,
|
|
798
941
|
remote: remote || undefined,
|
|
799
942
|
scenarioStep: isScenarioStep || undefined,
|
|
800
943
|
scenario: isScenario || undefined,
|
|
@@ -806,7 +949,13 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
806
949
|
workflowQueued: workflowQueued === true ? true : undefined,
|
|
807
950
|
workflowRetries: workflowRetries ?? undefined,
|
|
808
951
|
workflowTimeout: workflowTimeout ?? undefined,
|
|
809
|
-
|
|
952
|
+
scenarioStepSurfaces,
|
|
953
|
+
// `persona` is the default reading of an unmarked step, so it is left off
|
|
954
|
+
// rather than written into every step meta a project already has.
|
|
955
|
+
scenarioStepKind: scenarioStepKind && scenarioStepKind !== 'persona'
|
|
956
|
+
? scenarioStepKind
|
|
957
|
+
: undefined,
|
|
958
|
+
scenarioStepAddon,
|
|
810
959
|
scenarioStepTemplate: scenarioStepTemplate || undefined,
|
|
811
960
|
scopes: scopes ?? undefined,
|
|
812
961
|
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;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { DISPOSITIONS } from '@pikku/core/virtual-user';
|
|
3
|
+
import { isRunnablePersona } from '@pikku/core/persona';
|
|
4
|
+
import { ErrorCode } from '../error-codes.js';
|
|
5
|
+
import { booleanProperty, getProperty, numberProperty, stringArrayProperty, stringProperty, unwrapAs, } from '../utils/literal-properties.js';
|
|
6
|
+
/** A dial and the range outside which it is a mistake rather than a choice. */
|
|
7
|
+
const RATES = [
|
|
8
|
+
['temperature', 0, 2],
|
|
9
|
+
['repeatRate', 0, 1],
|
|
10
|
+
['reReadRate', 0, 1],
|
|
11
|
+
];
|
|
12
|
+
/**
|
|
13
|
+
* `{ moves, temperature, repeatRate, … }` — a disposition's dials, overridden.
|
|
14
|
+
*
|
|
15
|
+
* The ranges are checked here rather than at run time because these are the
|
|
16
|
+
* kind of typo that does not throw: `repeatRate: 18` meaning 18% would silently
|
|
17
|
+
* double every call the persona makes, and you would read the run as a product
|
|
18
|
+
* bug.
|
|
19
|
+
*/
|
|
20
|
+
const tuningProperty = (config, onError) => {
|
|
21
|
+
const value = getProperty(config, 'tuning');
|
|
22
|
+
if (!value || !ts.isObjectLiteralExpression(value)) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const tuning = {};
|
|
26
|
+
const movesValue = getProperty(value, 'moves');
|
|
27
|
+
if (movesValue && ts.isObjectLiteralExpression(movesValue)) {
|
|
28
|
+
const moves = {};
|
|
29
|
+
for (const move of ['continue', 'suspend', 'resume', 'abandon']) {
|
|
30
|
+
const weight = numberProperty(movesValue, move);
|
|
31
|
+
if (weight === undefined) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (weight < 0) {
|
|
35
|
+
onError(`tuning.moves.${move} must not be negative (got ${weight}).`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
moves[move] = weight;
|
|
39
|
+
}
|
|
40
|
+
if (Object.keys(moves).length) {
|
|
41
|
+
tuning.moves = moves;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const [name, min, max] of RATES) {
|
|
45
|
+
const rate = numberProperty(value, name);
|
|
46
|
+
if (rate === undefined) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (rate < min || rate > max) {
|
|
50
|
+
onError(`tuning.${name} must be between ${min} and ${max} (got ${rate}).`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
Object.assign(tuning, { [name]: rate });
|
|
54
|
+
}
|
|
55
|
+
for (const flag of ['emptyMemory', 'readOnly', 'invertedOracle']) {
|
|
56
|
+
const declared = booleanProperty(value, flag);
|
|
57
|
+
if (declared !== undefined) {
|
|
58
|
+
tuning[flag] = declared;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const instructions = stringProperty(value, 'instructions');
|
|
62
|
+
if (instructions !== undefined) {
|
|
63
|
+
tuning.instructions = instructions;
|
|
64
|
+
}
|
|
65
|
+
return Object.keys(tuning).length ? tuning : undefined;
|
|
66
|
+
};
|
|
67
|
+
const accountProperty = (value) => {
|
|
68
|
+
if (!value || !ts.isObjectLiteralExpression(value)) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const provider = stringProperty(value, 'provider');
|
|
72
|
+
return provider === undefined ? {} : { provider };
|
|
73
|
+
};
|
|
74
|
+
const linkedAccountsProperty = (config, personaId, logger) => {
|
|
75
|
+
const value = getProperty(config, 'linkedAccounts');
|
|
76
|
+
if (!value || !ts.isObjectLiteralExpression(value)) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const accounts = {};
|
|
80
|
+
for (const prop of value.properties) {
|
|
81
|
+
if (!ts.isPropertyAssignment(prop)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) {
|
|
85
|
+
logger.critical(ErrorCode.NON_LITERAL_WIRE_NAME, `Persona '${personaId}' has a linked account whose key is not a literal.`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const account = accountProperty(prop.initializer);
|
|
89
|
+
if (!account) {
|
|
90
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Persona '${personaId}' linked account '${prop.name.text}' must be an object literal.`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
accounts[prop.name.text] = account;
|
|
94
|
+
}
|
|
95
|
+
return Object.keys(accounts).length ? accounts : undefined;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Inspector for `definePersonas()` calls.
|
|
99
|
+
*
|
|
100
|
+
* A persona is entirely declaration — a person, their roles, what they want —
|
|
101
|
+
* so there is no code body and nothing to resolve at runtime. What is read here
|
|
102
|
+
* is the whole thing, and it is generated straight to `personas.gen.json` for
|
|
103
|
+
* the CLI and the console to read without loading the app.
|
|
104
|
+
*
|
|
105
|
+
* `name` is the one field that cannot be defaulted. A persona without one is
|
|
106
|
+
* not a person, and the console has nothing to show.
|
|
107
|
+
*/
|
|
108
|
+
export const addPersonas = (logger, node, _checker, state) => {
|
|
109
|
+
if (!ts.isCallExpression(node)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const expression = node.expression;
|
|
113
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'definePersonas') {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const firstArg = node.arguments[0];
|
|
117
|
+
if (!firstArg) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const unwrapped = unwrapAs(firstArg);
|
|
121
|
+
if (!ts.isObjectLiteralExpression(unwrapped)) {
|
|
122
|
+
logger.critical(ErrorCode.INVALID_VALUE, 'definePersonas() needs an inline object — personas are read statically, never evaluated.');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const sourceFile = node.getSourceFile().fileName;
|
|
126
|
+
const known = Object.keys(DISPOSITIONS);
|
|
127
|
+
for (const prop of unwrapped.properties) {
|
|
128
|
+
if (!ts.isPropertyAssignment(prop)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) {
|
|
132
|
+
logger.critical(ErrorCode.NON_LITERAL_WIRE_NAME, 'A persona is declared with a key that is not a literal.');
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const id = prop.name.text;
|
|
136
|
+
if (!ts.isObjectLiteralExpression(prop.initializer)) {
|
|
137
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Persona '${id}' must be an object literal.`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const config = prop.initializer;
|
|
141
|
+
const name = stringProperty(config, 'name');
|
|
142
|
+
if (!name) {
|
|
143
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Persona '${id}' needs a literal 'name' — a persona is a person, and the console has nothing to show without one.`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// The generated `definePersonas` types this as a union, so a bad value only
|
|
147
|
+
// reaches here in source that does not compile. Caught anyway: a
|
|
148
|
+
// disposition is the entire behaviour, and defaulting a typo to `realistic`
|
|
149
|
+
// would run a different person than the one that was written.
|
|
150
|
+
const disposition = stringProperty(config, 'disposition');
|
|
151
|
+
if (disposition !== undefined && !known.includes(disposition)) {
|
|
152
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Persona '${id}' has an unknown disposition '${disposition}'. One of: ${known.join(', ')}`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
let tuningFailed = false;
|
|
156
|
+
const tuning = tuningProperty(config, (message) => {
|
|
157
|
+
tuningFailed = true;
|
|
158
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Persona '${id}': ${message}`);
|
|
159
|
+
});
|
|
160
|
+
if (tuningFailed) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const account = accountProperty(getProperty(config, 'account'));
|
|
164
|
+
const declaredRunnable = booleanProperty(config, 'runnable');
|
|
165
|
+
const persona = {
|
|
166
|
+
id,
|
|
167
|
+
name,
|
|
168
|
+
roles: stringArrayProperty(config, 'roles') ?? [],
|
|
169
|
+
goals: stringArrayProperty(config, 'goals') ?? [],
|
|
170
|
+
tags: stringArrayProperty(config, 'tags') ?? [],
|
|
171
|
+
runnable: isRunnablePersona({
|
|
172
|
+
...(declaredRunnable !== undefined
|
|
173
|
+
? { runnable: declaredRunnable }
|
|
174
|
+
: {}),
|
|
175
|
+
...(account ? { account } : {}),
|
|
176
|
+
}),
|
|
177
|
+
sourceFile,
|
|
178
|
+
};
|
|
179
|
+
const jobTitle = stringProperty(config, 'jobTitle');
|
|
180
|
+
if (jobTitle !== undefined)
|
|
181
|
+
persona.jobTitle = jobTitle;
|
|
182
|
+
const description = stringProperty(config, 'description');
|
|
183
|
+
if (description !== undefined)
|
|
184
|
+
persona.description = description;
|
|
185
|
+
const personality = stringProperty(config, 'personality');
|
|
186
|
+
if (personality !== undefined)
|
|
187
|
+
persona.personality = personality;
|
|
188
|
+
if (disposition !== undefined) {
|
|
189
|
+
persona.disposition = disposition;
|
|
190
|
+
}
|
|
191
|
+
if (tuning !== undefined)
|
|
192
|
+
persona.tuning = tuning;
|
|
193
|
+
const fixtures = stringArrayProperty(config, 'fixtures');
|
|
194
|
+
if (fixtures !== undefined)
|
|
195
|
+
persona.fixtures = fixtures;
|
|
196
|
+
// Left absent rather than resolved here: "everywhere but production" is a
|
|
197
|
+
// fact about the config, and the inspector has no business baking today's
|
|
198
|
+
// environment list into a meta file that outlives it.
|
|
199
|
+
const environments = stringArrayProperty(config, 'environments');
|
|
200
|
+
if (environments !== undefined)
|
|
201
|
+
persona.environments = environments;
|
|
202
|
+
if (account !== undefined)
|
|
203
|
+
persona.account = account;
|
|
204
|
+
const linkedAccounts = linkedAccountsProperty(config, id, logger);
|
|
205
|
+
if (linkedAccounts !== undefined)
|
|
206
|
+
persona.linkedAccounts = linkedAccounts;
|
|
207
|
+
state.personas.files.add(sourceFile);
|
|
208
|
+
state.personas.definitions.push(persona);
|
|
209
|
+
}
|
|
210
|
+
};
|
package/dist/add/add-scope.js
CHANGED
|
@@ -89,7 +89,7 @@ export const addScope = (logger, node, checker, state, _options) => {
|
|
|
89
89
|
return;
|
|
90
90
|
}
|
|
91
91
|
const expression = node.expression;
|
|
92
|
-
if (!ts.isIdentifier(expression) || expression.text !== '
|
|
92
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'defineScope') {
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
95
|
const firstArg = node.arguments[0];
|
package/dist/add/add-secret.js
CHANGED