@pikku/inspector 0.12.82 → 0.12.83

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 CHANGED
@@ -1,3 +1,55 @@
1
+ ## 0.12.83
2
+
3
+ ### Patch Changes
4
+
5
+ - 6db6a14: A wiring can now decide whether an addon's credential is per-user or deployment-wide
6
+
7
+ `wireAddon`'s `credentialOverrides` takes an object as well as a rename string:
8
+
9
+ ```ts
10
+ wireAddon({
11
+ name: 'gmail',
12
+ package: '@pikku/addon-gmail',
13
+ credentialOverrides: {
14
+ gmailOAuth: { mode: 'wire' }, // each user connects their own
15
+ calendarOAuth: { name: 'CAL', mode: 'singleton' },
16
+ },
17
+ })
18
+ ```
19
+
20
+ An addon author declares a default with `defineCredential`; the deployment decides, so one addon serves both a per-user product and a single team account.
21
+
22
+ The wire credential service now resolves each credential by what it _is_ rather than by what a lookup returned: `wire` reads only the user's value, `singleton` reads the deployment's. A per-user credential can no longer pick up a platform-level value because the user has not connected — which, before, would have quietly run someone's request against the deployment's own account.
23
+
24
+ A credential is never read from the secret vault. The wire credential service no longer takes a `SecretService` at all, so the only way a credential arrives is the one its mode names — the user's own value, or the deployment's.
25
+
26
+ Modes are resolved at generation time into the credentials meta, so the console's connect flow reflects the wiring rather than the addon's default.
27
+
28
+ Two credentials that resolve to one name are rejected unless they agree on the mode. Generation writes a single metadata entry per name, so a `wire` credential aliased onto a `singleton` one used to take whichever wiring was read last — a per-user credential served from the deployment's own account, or a deployment credential handed out per user. The inspector reports the collision against both wirings by name, and `buildCredentialResolutions` refuses it at runtime too.
29
+
30
+ A mode-only override is checked against the credential it names. `{ mode: 'wire' }` renames nothing, so nothing was validated: an override naming a credential that does not exist was dropped in silence and the credential kept the mode its author declared.
31
+
32
+ A credential the wiring resolves as `singleton` is no longer read out of the user's own store. Per-user values are imported first and a name already present is left alone, so a value stored under a singleton's name — from an earlier wiring, or a connect flow since rewired — decided what the deployment's own slot resolved to. It is now skipped by mode, the same way a `wire` credential is kept away from the deployment's value.
33
+
34
+ The project's own credentials are registered into pikku state from the generated credentials file, so `wire.getCredential` resolves an app-level singleton the same way it resolves an addon's.
35
+
36
+ `pikku new addon` no longer requires a `pikku.config.json` in the working directory. Scaffolding an addon is something you do before a project config exists, so the command now reads one when it is there and falls back to the working directory when it is not.
37
+
38
+ An addon's `pikkuAddonWireServices` factory now declares the same service contract its singleton factory does: what it destructures off the parent's bag is required, and what it returns is built by the addon. Before, a service an addon built per wire was demanded from the consumer, and a wire-only addon declared no contract at all. A nested callback that names its own parameter after the factory's is no longer read as the factory's own: what it destructured went onto the addon's contract, so a consumer was asked for a service the addon never wanted from them.
39
+
40
+ An OAuth2 credential now implies its app secret, so nobody hand-writes one. The client id and secret an OAuth app needs is the same shape every time — `OAuth2AppCredential`, which is what the runtime already reads it as — so the inspector registers a secret for each credential's `appCredentialSecretId`. A hand-written `defineSecret` covering that id still wins, so an author who wants their own description or `docsUrl` keeps it. The derived secret is optional, matching what every hand-written declaration chose: an addon that also authenticates by API key must still deploy without an OAuth app configured.
41
+
42
+ `optional` now means a secret is not reported as missing. `getMissing()` filtered on "not configured" alone, so a secret whose declaration said absence was supported still showed up on the list of things a deployment had to go and supply — burying the ones that genuinely were. `getAllStatus()` still reports it, flagged `optional`.
43
+
44
+ An OAuth2 secret carries its declaration's `optional` through code generation, to the app credential and to the token store alike: nobody connects without the client id and secret, so a deployment allowed to omit the app is never asked for its tokens either. That branch never looked at the flag before.
45
+
46
+ `credentialOAuthProviders` treats an app secret that resolves `undefined` as unconfigured. An optional secret resolves rather than throws, so the absence arrived past the `catch` that was meant to skip the provider — and `.reveal()` on it threw a `TypeError` that took every `getSession` down with it, which is the exact failure that code exists to prevent.
47
+
48
+ - Updated dependencies [cb8f57e]
49
+ - Updated dependencies [cb8f57e]
50
+ - Updated dependencies [6db6a14]
51
+ - @pikku/core@0.12.114
52
+
1
53
  ## 0.12.82
2
54
 
3
55
  ### Patch Changes
@@ -11,13 +11,16 @@ const wrapperFunctionMap = {
11
11
  pikkuServerLifecycle: 'ServerLifecycle',
12
12
  };
13
13
  /**
14
- * What an addon's `pikkuAddonServices` factory takes from the parent: the names
15
- * destructured off its second parameter, either in the parameter list or from a
16
- * `const { … } = existingServices` in the body.
14
+ * What an addon's services factory takes from the parent: the names
15
+ * destructured off the parameter carrying the parent's bag, either in the
16
+ * parameter list or from a `const { … } = existingServices` in the body.
17
+ *
18
+ * `pikkuAddonServices` receives it second, after the config;
19
+ * `pikkuAddonWireServices` receives it first, ahead of the wire.
17
20
  */
18
- const extractForwardedServices = (functionNode) => {
21
+ const extractForwardedServices = (functionNode, servicesParamIndex) => {
19
22
  const forwarded = [];
20
- const secondParam = functionNode.parameters[1];
23
+ const secondParam = functionNode.parameters[servicesParamIndex];
21
24
  if (!secondParam)
22
25
  return forwarded;
23
26
  const collectBinding = (pattern) => {
@@ -43,6 +46,22 @@ const extractForwardedServices = (functionNode) => {
43
46
  const body = functionNode.body;
44
47
  if (!ts.isBlock(body))
45
48
  return forwarded;
49
+ /**
50
+ * A nested callback is free to name its own parameter `services` too. Its
51
+ * destructuring belongs to that parameter, not the factory's, so descending
52
+ * into it would put a service on the addon's contract the factory never
53
+ * asked the parent for.
54
+ */
55
+ const shadowsParam = (node) => {
56
+ if (!ts.isFunctionLike(node))
57
+ return false;
58
+ return node.parameters.some((parameter) => bindsName(parameter.name));
59
+ };
60
+ const bindsName = (name) => {
61
+ if (ts.isIdentifier(name))
62
+ return name.text === paramName;
63
+ return name.elements.some((element) => !ts.isOmittedExpression(element) && bindsName(element.name));
64
+ };
46
65
  const visit = (node) => {
47
66
  if (ts.isVariableDeclaration(node) &&
48
67
  ts.isObjectBindingPattern(node.name) &&
@@ -50,7 +69,10 @@ const extractForwardedServices = (functionNode) => {
50
69
  ts.isIdentifier(node.initializer) &&
51
70
  node.initializer.text === paramName) {
52
71
  collectBinding(node.name);
72
+ return;
53
73
  }
74
+ if (shadowsParam(node))
75
+ return;
54
76
  ts.forEachChild(node, visit);
55
77
  };
56
78
  ts.forEachChild(body, visit);
@@ -143,10 +165,15 @@ export const addFileWithFactory = (node, checker, methods = new Map(), expectedT
143
165
  state.wireServicesMeta.set(variableName, servicesMeta.services);
144
166
  }
145
167
  // Extract existing services an addon needs from the parent
146
- // (second parameter of pikkuAddonServices callback)
147
- if (wrapperFunctionName === 'pikkuAddonServices' && functionNode) {
168
+ // (second parameter of pikkuAddonServices callback). A wire
169
+ // factory reads the same parent bag and returns services of its
170
+ // own, so it declares the same contract — a service it builds per
171
+ // wire is one the addon owns, not one its consumer owes.
172
+ if ((wrapperFunctionName === 'pikkuAddonServices' ||
173
+ wrapperFunctionName === 'pikkuAddonWireServices') &&
174
+ functionNode) {
148
175
  state.addonServicesFactorySeen = true;
149
- const forwarded = new Set(extractForwardedServices(functionNode));
176
+ const forwarded = new Set(extractForwardedServices(functionNode, wrapperFunctionName === 'pikkuAddonWireServices' ? 0 : 1));
150
177
  for (const name of forwarded) {
151
178
  state.addonRequiredParentServices.push(name);
152
179
  }
@@ -30,6 +30,54 @@ function parseStringRecord(obj) {
30
30
  }
31
31
  return result;
32
32
  }
33
+ /**
34
+ * `credentialOverrides` takes either a rename string or an object that also
35
+ * decides where the value is read from, so it cannot use `parseStringRecord`.
36
+ * A non-literal member is dropped rather than guessed at — the wiring is read
37
+ * statically, and a value this cannot see must not be reported as a mode.
38
+ */
39
+ function parseCredentialOverrideRecord(obj) {
40
+ const result = {};
41
+ for (const prop of obj.properties) {
42
+ if (!ts.isPropertyAssignment(prop))
43
+ continue;
44
+ const keyNode = prop.name;
45
+ const key = ts.isIdentifier(keyNode)
46
+ ? keyNode.text
47
+ : ts.isStringLiteral(keyNode)
48
+ ? keyNode.text
49
+ : undefined;
50
+ if (!key)
51
+ continue;
52
+ if (ts.isStringLiteral(prop.initializer)) {
53
+ result[key] = prop.initializer.text;
54
+ continue;
55
+ }
56
+ if (!ts.isObjectLiteralExpression(prop.initializer))
57
+ continue;
58
+ const override = {};
59
+ for (const member of prop.initializer.properties) {
60
+ if (!ts.isPropertyAssignment(member))
61
+ continue;
62
+ const memberKey = ts.isIdentifier(member.name)
63
+ ? member.name.text
64
+ : ts.isStringLiteral(member.name)
65
+ ? member.name.text
66
+ : undefined;
67
+ if (!memberKey || !ts.isStringLiteral(member.initializer))
68
+ continue;
69
+ if (memberKey === 'name')
70
+ override.name = member.initializer.text;
71
+ else if (memberKey === 'mode' &&
72
+ (member.initializer.text === 'wire' ||
73
+ member.initializer.text === 'singleton')) {
74
+ override.mode = member.initializer.text;
75
+ }
76
+ }
77
+ result[key] = override;
78
+ }
79
+ return result;
80
+ }
33
81
  /**
34
82
  * Detect wireAddon({ name: '...', package: '...' }) call expressions and
35
83
  * populate state.rpc.wireAddonDeclarations and state.rpc.usedAddons.
@@ -98,7 +146,7 @@ export function addWireAddon(node, state, logger) {
98
146
  }
99
147
  else if (key === 'credentialOverrides' &&
100
148
  ts.isObjectLiteralExpression(prop.initializer)) {
101
- credentialOverrides = parseStringRecord(prop.initializer);
149
+ credentialOverrides = parseCredentialOverrideRecord(prop.initializer);
102
150
  }
103
151
  else if (key === 'secretGrants') {
104
152
  secretGrants = parseStringArray(prop.initializer);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { inspect, getInitialInspectorState } from './inspector.js';
2
2
  export type { TypesMap } from './types-map.js';
3
- export type { AddonConfig, AuthDefinition, InspectorDiagnostic, InspectorFilters, InspectorHTTPState, InspectorLogger, InspectorMiddlewareState, InspectorOptions, InspectorPermissionState, InspectorState, MetaInputTypes, MiddlewareGroupMeta, MiddlewareRegistration, SchemaRef, } from './types.js';
3
+ export type { AddonConfig, AuthDefinition, CredentialOverrideMeta, InspectorDiagnostic, InspectorFilters, InspectorHTTPState, InspectorLogger, InspectorMiddlewareState, InspectorOptions, InspectorPermissionState, InspectorState, MetaInputTypes, MiddlewareGroupMeta, MiddlewareRegistration, SchemaRef, } from './types.js';
4
4
  export type { FilesAndMethodsErrors } from './utils/get-files-and-methods.js';
5
5
  export { ErrorCode } from './error-codes.js';
6
6
  export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js';
package/dist/inspector.js CHANGED
@@ -7,7 +7,7 @@ import { getFilesAndMethods } from './utils/get-files-and-methods.js';
7
7
  import { findCommonAncestor } from './utils/find-root-dir.js';
8
8
  import { createSourceFileCacheHost } from './source-file-cache.js';
9
9
  import { createNestedProjectFilter } from './utils/nested-project-filter.js';
10
- import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateAgentToolReferences, validateAgentScorerReferences, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, validateSystemRoleScopes, validatePersonaRoles, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, validateNoSecretAliasServices, validateSecretUsage, computeDiagnostics, validateSchemaReferences, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateWorkflowGraphAddons, } from './utils/post-process.js';
10
+ import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateAgentToolReferences, validateAgentScorerReferences, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, validateSystemRoleScopes, validatePersonaRoles, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, validateNoSecretAliasServices, registerDerivedOAuth2AppSecrets, validateSecretUsage, computeDiagnostics, validateSchemaReferences, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateWorkflowGraphAddons, } from './utils/post-process.js';
11
11
  import { validateExposedFunctionsGated } from './utils/validate-exposed-functions-gated.js';
12
12
  import { validateTagsResolveToMiddleware } from './utils/validate-tags-resolve-to-middleware.js';
13
13
  import { annotateHttpRouteAuth } from './utils/annotate-http-route-auth.js';
@@ -397,6 +397,7 @@ export const inspect = async (logger, routeFiles, options = {}) => {
397
397
  computeMiddlewareGroupsMeta(state);
398
398
  computePermissionsGroupsMeta(state);
399
399
  validateNoSecretAliasServices(logger, checker, state);
400
+ registerDerivedOAuth2AppSecrets(state);
400
401
  validateSecretUsage(logger, state);
401
402
  computeDiagnostics(state);
402
403
  validateSchemaWiringSeparation(logger, state);
package/dist/types.d.ts CHANGED
@@ -469,6 +469,14 @@ export interface InspectorFeature {
469
469
  hasBefore: boolean;
470
470
  hasAfter: boolean;
471
471
  }
472
+ /**
473
+ * A `wireAddon` credential override as it survives static reading: a rename
474
+ * string, or the object form that also decides how the value resolves.
475
+ */
476
+ export type CredentialOverrideMeta = string | {
477
+ name?: string;
478
+ mode?: 'singleton' | 'wire';
479
+ };
472
480
  export interface InspectorState {
473
481
  rootDir: string;
474
482
  singletonServicesTypeImportMap: PathToNameAndType;
@@ -600,7 +608,7 @@ export interface InspectorState {
600
608
  authSecretId?: string;
601
609
  secretOverrides?: Record<string, string>;
602
610
  variableOverrides?: Record<string, string>;
603
- credentialOverrides?: Record<string, string>;
611
+ credentialOverrides?: Record<string, CredentialOverrideMeta>;
604
612
  /** Secrets the app lends this instance, named as the addon reads them. */
605
613
  secretGrants?: string[];
606
614
  /** Credentials the app lends this instance, named as the addon reads them. */
@@ -161,6 +161,12 @@ export declare function validateNoSecretAliasServices(logger: InspectorLogger, c
161
161
  * at first request rather than at deploy; a non-literal key is a read the
162
162
  * manifest cannot cover, so a per-unit scope cannot be narrowed around it.
163
163
  */
164
+ /**
165
+ * Registers the app secrets the project's OAuth2 credentials imply, so a
166
+ * deployment is asked for the client id and secret every connect flow needs
167
+ * without an author restating a shape the runtime already fixes.
168
+ */
169
+ export declare function registerDerivedOAuth2AppSecrets(state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
164
170
  export declare function validateSecretUsage(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
165
171
  export declare function computeDiagnostics(state: InspectorState): void;
166
172
  /**
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { extractTypeKeys } from './type-utils.js';
5
5
  import { ErrorCode } from '../error-codes.js';
6
6
  import { findSecretAliasServices } from './secret-alias-services.js';
7
+ import { deriveOAuth2AppSecrets } from '@pikku/core/secret';
7
8
  import { resolveCoreType } from './resolve-core-type.js';
8
9
  import { relative } from 'node:path';
9
10
  import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js';
@@ -385,15 +386,42 @@ export function validateCredentialOverrides(logger, state) {
385
386
  if (!wireAddonDeclarations || wireAddonDeclarations.size === 0)
386
387
  return;
387
388
  const credentialNames = new Set(state.credentials?.definitions.map((d) => d.name) ?? []);
389
+ /** Only a rename has a target to check; a mode-only override renames nothing. */
390
+ const renameTarget = (override) => typeof override === 'string' ? override : override.name;
391
+ const overrideMode = (override) => typeof override === 'string' ? undefined : override.mode;
392
+ /**
393
+ * Which declaration set each resolved name's mode. Credential generation
394
+ * writes one shared metadata entry per name, so two declarations that map
395
+ * onto one name with opposite modes leave whichever ran last in the file the
396
+ * console reads — and the other addon silently gets the wrong resolution.
397
+ */
398
+ const modeClaims = new Map();
388
399
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
389
- for (const [logicalName, resolvedName] of Object.entries(addonDecl.credentialOverrides ?? {})) {
400
+ for (const [logicalName, override] of Object.entries(addonDecl.credentialOverrides ?? {})) {
401
+ // A mode-only override still names a credential — the addon's own. It
402
+ // has to exist, or generation resolves nothing and the credential keeps
403
+ // its default mode with no sign that the wiring asked for another.
404
+ const renamed = renameTarget(override);
405
+ const resolvedName = renamed ?? logicalName;
390
406
  if (!credentialNames.has(resolvedName)) {
391
407
  const availableCredentials = Array.from(credentialNames);
392
- logger.critical(ErrorCode.INVALID_VALUE, `Credential override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`);
408
+ const target = renamed
409
+ ? `'${logicalName}' -> '${resolvedName}'`
410
+ : `'${logicalName}'`;
411
+ logger.critical(ErrorCode.INVALID_VALUE, `Credential override ${target} in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`);
393
412
  }
413
+ const mode = overrideMode(override);
414
+ if (!mode)
415
+ continue;
416
+ const claim = modeClaims.get(resolvedName);
417
+ if (claim && claim.mode !== mode) {
418
+ logger.critical(ErrorCode.INVALID_VALUE, `Credential '${resolvedName}' is wired '${claim.mode}' by '${claim.logicalName}' in addon '${claim.namespace}' and '${mode}' by '${logicalName}' in addon '${namespace}'. One credential holds one mode, so the second wiring would silently take the first's resolution. Give them separate names, or wire both the same way.`);
419
+ }
420
+ modeClaims.set(resolvedName, { mode, namespace, logicalName });
394
421
  }
395
422
  for (const logicalName of addonDecl.credentialGrants ?? []) {
396
- const resolvedName = addonDecl.credentialOverrides?.[logicalName] ?? logicalName;
423
+ const override = addonDecl.credentialOverrides?.[logicalName];
424
+ const resolvedName = (override ? renameTarget(override) : undefined) ?? logicalName;
397
425
  if (!credentialNames.has(resolvedName)) {
398
426
  const availableCredentials = Array.from(credentialNames);
399
427
  logger.critical(ErrorCode.INVALID_VALUE, `Credential grant '${logicalName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`);
@@ -1029,6 +1057,15 @@ export function validateNoSecretAliasServices(logger, checker, state) {
1029
1057
  * at first request rather than at deploy; a non-literal key is a read the
1030
1058
  * manifest cannot cover, so a per-unit scope cannot be narrowed around it.
1031
1059
  */
1060
+ /**
1061
+ * Registers the app secrets the project's OAuth2 credentials imply, so a
1062
+ * deployment is asked for the client id and secret every connect flow needs
1063
+ * without an author restating a shape the runtime already fixes.
1064
+ */
1065
+ export function registerDerivedOAuth2AppSecrets(state) {
1066
+ const derived = deriveOAuth2AppSecrets(state.credentials?.definitions ?? [], state.secrets.definitions);
1067
+ state.secrets.definitions.push(...derived);
1068
+ }
1032
1069
  export function validateSecretUsage(logger, state) {
1033
1070
  const declared = new Set(state.secrets.definitions.map((d) => d.secretId));
1034
1071
  for (const definition of state.secrets.definitions) {
@@ -1,3 +1,4 @@
1
+ import type { CredentialOverrideMeta } from '../types.js';
1
2
  import * as ts from 'typescript';
2
3
  /**
3
4
  * Resolve the addon package name from an imported identifier.
@@ -12,5 +13,5 @@ export declare const resolveAddonName: (identifier: ts.Identifier, checker: ts.T
12
13
  rpcEndpoint?: string;
13
14
  secretOverrides?: Record<string, string>;
14
15
  variableOverrides?: Record<string, string>;
15
- credentialOverrides?: Record<string, string>;
16
+ credentialOverrides?: Record<string, CredentialOverrideMeta>;
16
17
  }>) => string | null;
@@ -1,3 +1,4 @@
1
+ import type { CredentialOverrideMeta } from '../types.js';
1
2
  import type { SecretUsage } from './extract-secret-usage.js';
2
3
  import type { JSONValue } from '@pikku/core/utils';
3
4
  import type { InspectorDiagnostic, InspectorFeature, InspectorState } from '../types.js';
@@ -197,7 +198,7 @@ export interface SerializableInspectorState {
197
198
  authSecretId?: string;
198
199
  secretOverrides?: Record<string, string>;
199
200
  variableOverrides?: Record<string, string>;
200
- credentialOverrides?: Record<string, string>;
201
+ credentialOverrides?: Record<string, CredentialOverrideMeta>;
201
202
  secretGrants?: string[];
202
203
  credentialGrants?: string[];
203
204
  globalSecrets?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.82",
3
+ "version": "0.12.83",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -55,10 +55,10 @@
55
55
  "zod-to-ts": "^2.1.0"
56
56
  },
57
57
  "peerDependencies": {
58
- "@pikku/core": "^0.12.113"
58
+ "@pikku/core": "^0.12.114"
59
59
  },
60
60
  "devDependencies": {
61
- "@pikku/core": "^0.12.113",
61
+ "@pikku/core": "^0.12.114",
62
62
  "@types/node": "^24.13.3"
63
63
  },
64
64
  "engines": {