@pikku/inspector 0.12.109 → 0.12.110

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.
@@ -40,6 +40,7 @@ import { findPiiPaths } from '../utils/check-pii-output.js';
40
40
  import { findClassifiedSinks } from '../utils/check-classified-sinks.js';
41
41
  import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js';
42
42
  import { resolveCalleeName } from '../utils/resolve-callee-name.js';
43
+ import { addonZodContractViolation } from '../utils/validate-addon-zod-contract.js';
43
44
  const isValidVariableName = (name) => {
44
45
  const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
45
46
  return regex.test(name);
@@ -887,6 +888,19 @@ export const addFunctions = (logger, node, checker, state, options) => {
887
888
  if (inputNames.length > 1) {
888
889
  logger.warn('More than one input type detected, only the first one will be used as a schema.');
889
890
  }
891
+ if (options.isAddon && remote && !notRpc) {
892
+ const violation = addonZodContractViolation(state.functions.typesMap, {
893
+ name,
894
+ usesGenerics: (typeArguments?.length ?? 0) > 0,
895
+ hasInputSchema: !!inputSchemaRef,
896
+ hasOutputSchema: !!outputSchemaRef,
897
+ inputNames,
898
+ outputNames,
899
+ });
900
+ if (violation) {
901
+ logger.critical(ErrorCode.ADDON_REMOTE_CONTRACT_NOT_ZOD, violation);
902
+ }
903
+ }
890
904
  // Store the input type for later use
891
905
  if (inputTypes.length > 0) {
892
906
  state.typesLookup.set(pikkuFuncId, inputTypes);
@@ -2,7 +2,7 @@ import * as ts from 'typescript';
2
2
  import type { InspectorState, InspectorLogger } from '../types.js';
3
3
  /**
4
4
  * The RPC names a function handler calls through `rpc.invoke(...)` /
5
- * `rpc.remote(...)`, read from its own body.
5
+ * `rpc.mesh(...)`, read from its own body.
6
6
  *
7
7
  * This is what the deploy planner uses to bind a unit to the units its
8
8
  * functions call: without it, a caller and callee split into different units
@@ -44,7 +44,7 @@ function isRpcReceiver(node) {
44
44
  * is not bundled in the caller's deploy unit, cross to another unit through the
45
45
  * `DeploymentService`.
46
46
  */
47
- const CROSS_UNIT_RPC_METHODS = new Set(['invoke', 'remote']);
47
+ const CROSS_UNIT_RPC_METHODS = new Set(['invoke', 'mesh']);
48
48
  /** The name argument when it is statically known: `'x'`, `"x"` or `` `x` ``. */
49
49
  function literalRpcName(arg) {
50
50
  if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
@@ -54,7 +54,7 @@ function literalRpcName(arg) {
54
54
  }
55
55
  /**
56
56
  * The RPC names a function handler calls through `rpc.invoke(...)` /
57
- * `rpc.remote(...)`, read from its own body.
57
+ * `rpc.mesh(...)`, read from its own body.
58
58
  *
59
59
  * This is what the deploy planner uses to bind a unit to the units its
60
60
  * functions call: without it, a caller and callee split into different units
@@ -1,4 +1,6 @@
1
1
  import * as ts from 'typescript';
2
+ import { ErrorCode } from '../error-codes.js';
3
+ import { parseRemoteAddonPackage } from '../utils/remote-addon-sdk.js';
2
4
  /**
3
5
  * Detect wireRemoteAddon({ name: '...', package: '...' }) call expressions and
4
6
  * record the namespace as a **remote** addon declaration. Only name + package
@@ -17,6 +19,7 @@ export function addWireRemoteAddon(node, state, logger) {
17
19
  return;
18
20
  let name;
19
21
  let pkg;
22
+ let pkgNode;
20
23
  // The auth binding is a runtime closure; we only extract the statically
21
24
  // knowable slot names so `pikku verify` can check they exist in the
22
25
  // consumer's own wirings. `hasAuth` distinguishes "public" (omitted) from
@@ -31,8 +34,11 @@ export function addWireRemoteAddon(node, state, logger) {
31
34
  if (key === 'name' && ts.isStringLiteral(prop.initializer)) {
32
35
  name = prop.initializer.text;
33
36
  }
34
- else if (key === 'package' && ts.isStringLiteral(prop.initializer)) {
35
- pkg = prop.initializer.text;
37
+ else if (key === 'package') {
38
+ pkgNode = prop.initializer;
39
+ if (ts.isStringLiteralLike(prop.initializer)) {
40
+ pkg = prop.initializer.text;
41
+ }
36
42
  }
37
43
  else if (key === 'auth' &&
38
44
  ts.isObjectLiteralExpression(prop.initializer)) {
@@ -52,11 +58,26 @@ export function addWireRemoteAddon(node, state, logger) {
52
58
  }
53
59
  }
54
60
  }
61
+ if (pkgNode && pkg === undefined) {
62
+ logger.critical(ErrorCode.REMOTE_ADDON_PACKAGE_NOT_STATIC, `wireRemoteAddon(${name ? `'${name}'` : '…'}) in ${node.getSourceFile().fileName} sets package to '${pkgNode.getText()}', which is not a string literal. Write the package (and its version range) inline, e.g. package: '@scope/addon@^1.0.0', so it can be read without running the file.`);
63
+ return;
64
+ }
55
65
  if (!name || !pkg)
56
66
  return;
57
- logger.debug(`• Found wireRemoteAddon: ${name} → ${pkg} (remote)`);
67
+ let packageName;
68
+ let range;
69
+ try {
70
+ ;
71
+ ({ name: packageName, range } = parseRemoteAddonPackage(pkg));
72
+ }
73
+ catch (e) {
74
+ logger.critical(ErrorCode.REMOTE_ADDON_PACKAGE_INVALID, `wireRemoteAddon('${name}'): ${e instanceof Error ? e.message : e}`);
75
+ return;
76
+ }
77
+ logger.debug(`• Found wireRemoteAddon: ${name} → ${packageName}${range ? `@${range}` : ''} (remote)`);
58
78
  state.rpc.wireAddonDeclarations.set(name, {
59
- package: pkg,
79
+ package: packageName,
80
+ ...(range ? { range } : {}),
60
81
  file: node.getSourceFile().fileName,
61
82
  remote: true,
62
83
  hasAuth,
@@ -42,6 +42,10 @@ export declare enum ErrorCode {
42
42
  ADDON_USES_NOT_WIRED = "PKU347",
43
43
  ADDON_NEEDS_NOT_MAPPED = "PKU348",
44
44
  ADDON_CONTENT_GRANT_INVALID = "PKU349",
45
+ REMOTE_ADDON_PACKAGE_NOT_STATIC = "PKU350",
46
+ REMOTE_ADDON_SDK_CORE_MISMATCH = "PKU351",
47
+ REMOTE_ADDON_SDK_INTEGRITY_MISMATCH = "PKU352",
48
+ REMOTE_ADDON_PACKAGE_INVALID = "PKU353",
45
49
  DB_COLUMN_NAME_TYPE_CONTRADICTION = "PKU480",
46
50
  DB_JSON_COLUMN_UNTYPED = "PKU481",
47
51
  DB_FORMAT_ON_NON_STRING = "PKU482",
@@ -89,6 +93,7 @@ export declare enum ErrorCode {
89
93
  PII_IN_OUTPUT = "PKU910",
90
94
  ADDON_WIRING_NOT_ALLOWED = "PKU920",
91
95
  ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = "PKU921",
96
+ ADDON_REMOTE_CONTRACT_NOT_ZOD = "PKU922",
92
97
  RPC_INVOCATION_TYPE_CAST = "PKU940",
93
98
  KNOWLEDGE_NOTE_MISSING = "PKU960",
94
99
  SECRET_SERVICE_ALIASED = "PKU950",
@@ -45,6 +45,10 @@ export var ErrorCode;
45
45
  ErrorCode["ADDON_USES_NOT_WIRED"] = "PKU347";
46
46
  ErrorCode["ADDON_NEEDS_NOT_MAPPED"] = "PKU348";
47
47
  ErrorCode["ADDON_CONTENT_GRANT_INVALID"] = "PKU349";
48
+ ErrorCode["REMOTE_ADDON_PACKAGE_NOT_STATIC"] = "PKU350";
49
+ ErrorCode["REMOTE_ADDON_SDK_CORE_MISMATCH"] = "PKU351";
50
+ ErrorCode["REMOTE_ADDON_SDK_INTEGRITY_MISMATCH"] = "PKU352";
51
+ ErrorCode["REMOTE_ADDON_PACKAGE_INVALID"] = "PKU353";
48
52
  // Database schema codegen warnings
49
53
  ErrorCode["DB_COLUMN_NAME_TYPE_CONTRADICTION"] = "PKU480";
50
54
  ErrorCode["DB_JSON_COLUMN_UNTYPED"] = "PKU481";
@@ -114,6 +118,7 @@ export var ErrorCode;
114
118
  // Addon authoring errors
115
119
  ErrorCode["ADDON_WIRING_NOT_ALLOWED"] = "PKU920";
116
120
  ErrorCode["ADDON_CONTRACT_HANDLERS_NOT_ALLOWED"] = "PKU921";
121
+ ErrorCode["ADDON_REMOTE_CONTRACT_NOT_ZOD"] = "PKU922";
117
122
  ErrorCode["RPC_INVOCATION_TYPE_CAST"] = "PKU940";
118
123
  // Knowledge base errors
119
124
  ErrorCode["KNOWLEDGE_NOTE_MISSING"] = "PKU960";
package/dist/finalize.js CHANGED
@@ -77,7 +77,9 @@ export const finalizeInspectorState = async (logger, analyzed, options = {}) =>
77
77
  annotateHttpRouteAuth(state);
78
78
  // Last of the validators: they read function meta and every wireAddon
79
79
  // declaration together, so they need the whole program inspected.
80
- validateExposedFunctionsGated(logger, state);
80
+ validateExposedFunctionsGated(logger, state, {
81
+ isAddon: options.isAddon === true,
82
+ });
81
83
  validateTagsResolveToMiddleware(logger, state);
82
84
  if (options.openAPI) {
83
85
  state.openAPISpec = await generateOpenAPISpec(logger, state.functions.meta, state.http.meta, state.schemas, options.openAPI.additionalInfo, pikkuState(null, 'misc', 'errors'));
package/dist/index.d.ts CHANGED
@@ -30,3 +30,7 @@ export { readModuleSpecifiers } from './utils/read-module-specifiers.js';
30
30
  export { readJsxLiteralText } from './utils/read-jsx-literal-text.js';
31
31
  export { readTsconfigOutDir } from './utils/read-tsconfig-out-dir.js';
32
32
  export { addonResolutionDirs, createAddonResolver, } from './utils/addon-resolution.js';
33
+ export { ADDONS_LOCK_FILE, REMOTE_SDK_DIR, SDK_INTEGRITY_MARKER, parseRemoteAddonPackage, emptyAddonsLock, readAddonsLock, serializeAddonsLock, remoteSdkDir, readSdkManifest, checkLockedSdk, } from './utils/remote-addon-sdk.js';
34
+ export { findWireRemoteAddonSpecs } from './utils/find-wire-remote-addon-specs.js';
35
+ export type { AddonsLock, AddonsLockEntry, RemoteAddonSdkManifest, RemoteAddonPackageSpec, LockedSdk, } from './utils/remote-addon-sdk.js';
36
+ export type { WireRemoteAddonSpec } from './utils/find-wire-remote-addon-specs.js';
package/dist/index.js CHANGED
@@ -18,3 +18,5 @@ export { readModuleSpecifiers } from './utils/read-module-specifiers.js';
18
18
  export { readJsxLiteralText } from './utils/read-jsx-literal-text.js';
19
19
  export { readTsconfigOutDir } from './utils/read-tsconfig-out-dir.js';
20
20
  export { addonResolutionDirs, createAddonResolver, } from './utils/addon-resolution.js';
21
+ export { ADDONS_LOCK_FILE, REMOTE_SDK_DIR, SDK_INTEGRITY_MARKER, parseRemoteAddonPackage, emptyAddonsLock, readAddonsLock, serializeAddonsLock, remoteSdkDir, readSdkManifest, checkLockedSdk, } from './utils/remote-addon-sdk.js';
22
+ export { findWireRemoteAddonSpecs } from './utils/find-wire-remote-addon-specs.js';
package/dist/lite.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * reachable from this file imports `typescript`.
10
10
  */
11
11
  export { getInitialInspectorState } from './initial-state.js';
12
+ export { TypesMap } from './types-map.js';
12
13
  export { ErrorCode } from './error-codes.js';
13
14
  export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js';
14
15
  export { AUTH_HANDLER_FUNC_ID } from './utils/inspector-constants.js';
@@ -18,9 +19,11 @@ export { filterInspectorState } from './utils/filter-inspector-state.js';
18
19
  export { incompatibleServicesFor, resolveDeployTarget, } from './utils/resolve-deploy-target.js';
19
20
  export { customTypeNames, generateCustomTypes, sanitizeTypeName, } from './utils/custom-types-generator.js';
20
21
  export { unresolvedSchemaReferences } from './utils/post-process.js';
21
- export { createEmptyManifest, serializeManifest, } from './utils/contract-hashes.js';
22
+ export { computeContractHash, createEmptyManifest, serializeManifest, } from './utils/contract-hashes.js';
22
23
  export { serializeMCPJson } from './utils/serialize-mcp-json.js';
23
24
  export { deserializeDslWorkflow, deserializeGraphWorkflow, deserializeAllDslWorkflows, } from './utils/workflow/dsl/deserialize-dsl-workflow.js';
24
25
  export { resolveFunctionMeta } from './utils/resolve-function-meta.js';
25
26
  export { resolveCoreType } from './utils/resolve-core-type.js';
26
27
  export { addonResolutionDirs, createAddonResolver, } from './utils/addon-resolution.js';
28
+ export { ADDONS_LOCK_FILE, REMOTE_SDK_DIR, SDK_INTEGRITY_MARKER, checkLockedSdk, emptyAddonsLock, parseRemoteAddonPackage, readAddonsLock, readSdkManifest, remoteSdkDir, serializeAddonsLock, } from './utils/remote-addon-sdk.js';
29
+ export type { AddonsLock, AddonsLockEntry, RemoteAddonSdkManifest, RemoteAddonPackageSpec, LockedSdk, } from './utils/remote-addon-sdk.js';
package/dist/lite.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * reachable from this file imports `typescript`.
10
10
  */
11
11
  export { getInitialInspectorState } from './initial-state.js';
12
+ export { TypesMap } from './types-map.js';
12
13
  export { ErrorCode } from './error-codes.js';
13
14
  export { AUTH_HANDLER_FUNC_ID } from './utils/inspector-constants.js';
14
15
  export { SCENARIO_INSTRUMENTATION_FUNCTIONS, isScenarioInstrumentationFunction, } from './add/scenario-instrumentation.js';
@@ -17,9 +18,10 @@ export { filterInspectorState } from './utils/filter-inspector-state.js';
17
18
  export { incompatibleServicesFor, resolveDeployTarget, } from './utils/resolve-deploy-target.js';
18
19
  export { customTypeNames, generateCustomTypes, sanitizeTypeName, } from './utils/custom-types-generator.js';
19
20
  export { unresolvedSchemaReferences } from './utils/post-process.js';
20
- export { createEmptyManifest, serializeManifest, } from './utils/contract-hashes.js';
21
+ export { computeContractHash, createEmptyManifest, serializeManifest, } from './utils/contract-hashes.js';
21
22
  export { serializeMCPJson } from './utils/serialize-mcp-json.js';
22
23
  export { deserializeDslWorkflow, deserializeGraphWorkflow, deserializeAllDslWorkflows, } from './utils/workflow/dsl/deserialize-dsl-workflow.js';
23
24
  export { resolveFunctionMeta } from './utils/resolve-function-meta.js';
24
25
  export { resolveCoreType } from './utils/resolve-core-type.js';
25
26
  export { addonResolutionDirs, createAddonResolver, } from './utils/addon-resolution.js';
27
+ export { ADDONS_LOCK_FILE, REMOTE_SDK_DIR, SDK_INTEGRITY_MARKER, checkLockedSdk, emptyAddonsLock, parseRemoteAddonPackage, readAddonsLock, readSdkManifest, remoteSdkDir, serializeAddonsLock, } from './utils/remote-addon-sdk.js';
package/dist/types.d.ts CHANGED
@@ -23,6 +23,14 @@ import type { VirtualUserDefinitions } from '@pikku/core/virtual-user';
23
23
  import type { VariableDefinitions } from '@pikku/core/variable';
24
24
  import type { TypesMap } from './types-map.js';
25
25
  import type { FunctionsMeta } from '@pikku/core/services';
26
+ /**
27
+ * A remote addon SDK's function meta: each contract function, with the
28
+ * versions of its name the SDK can call and the one a call without `@vN` goes to.
29
+ */
30
+ export type RemoteAddonFunctionsMeta = Record<string, FunctionsMeta[string] & {
31
+ versions?: number[];
32
+ latestVersion?: number;
33
+ }>;
26
34
  import type { FunctionServicesMeta, FunctionWiresMeta } from '@pikku/core/function';
27
35
  import type { JSONValue } from '@pikku/core/utils';
28
36
  import type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js';
@@ -713,6 +721,12 @@ export interface InspectorState {
713
721
  scopes?: string[];
714
722
  /** True when declared via `wireRemoteAddon` — import the addon's `.remote.gen` map, not `.internal.gen` */
715
723
  remote?: boolean;
724
+ /**
725
+ * wireRemoteAddon: the semver range written after the package name
726
+ * (`@scope/name@^1.4.0`). Present means the contract comes from the
727
+ * SDK `pikku all` downloads, not from an installed package.
728
+ */
729
+ range?: string;
716
730
  /** wireRemoteAddon: whether an `auth` binding was supplied (vs a public surface) */
717
731
  hasAuth?: boolean;
718
732
  /** wireRemoteAddon: statically-known `auth.credentialId`, for verify */
@@ -878,6 +892,8 @@ export interface InspectorState {
878
892
  openAPISpec: Record<string, any> | null;
879
893
  diagnostics: InspectorDiagnostic[];
880
894
  addonFunctions: Record<string, FunctionsMeta>;
895
+ /** The function metadata of each versioned `wireRemoteAddon`'s SDK, by namespace. Kept apart from `addonFunctions`: none of it runs here. */
896
+ remoteAddonFunctions?: Record<string, RemoteAddonFunctionsMeta>;
881
897
  exportedContracts: InspectorExportedContractsState;
882
898
  program: ts.Program | null;
883
899
  }
@@ -73,7 +73,7 @@ export function buildCurrentContracts(functionsMeta, allSchemas, typesMap) {
73
73
  for (const [funcId, meta] of Object.entries(functionsMeta)) {
74
74
  // A webhook receive has no callers but its own route, so there is no
75
75
  // contract for a version to protect.
76
- if (meta.remote === true || meta.webhookReceive) {
76
+ if (meta.webhookReceive) {
77
77
  continue;
78
78
  }
79
79
  const parsed = parseVersionedId(funcId);
@@ -253,7 +253,7 @@ export function updateManifest(existing, currentContracts) {
253
253
  export function extractContractsFromMeta(functionsMeta) {
254
254
  const result = new Map();
255
255
  for (const [funcId, meta] of Object.entries(functionsMeta)) {
256
- if (meta.remote === true || meta.webhookReceive || !meta.contractHash) {
256
+ if (meta.webhookReceive || !meta.contractHash) {
257
257
  continue;
258
258
  }
259
259
  const parsed = parseVersionedId(funcId);
@@ -0,0 +1,17 @@
1
+ /** A `wireRemoteAddon({ package })` found in source, with the line its literal sits on. */
2
+ export type WireRemoteAddonSpec = {
3
+ /** The `name` option, when it is a literal. */
4
+ namespace?: string;
5
+ /** The literal as written, e.g. `@scope/name@^1.4.0`. */
6
+ spec: string;
7
+ name: string;
8
+ range?: string;
9
+ /** 1-based line of the literal. */
10
+ line: number;
11
+ };
12
+ /**
13
+ * Every `wireRemoteAddon({ package: '<literal>' })` in one file. A `package`
14
+ * that is not a literal, or that does not parse, is skipped: the inspection
15
+ * reports those.
16
+ */
17
+ export declare const findWireRemoteAddonSpecs: (fileName: string, sourceText: string) => WireRemoteAddonSpec[];
@@ -0,0 +1,53 @@
1
+ import * as ts from 'typescript';
2
+ import { parseRemoteAddonPackage } from './remote-addon-sdk.js';
3
+ /**
4
+ * Every `wireRemoteAddon({ package: '<literal>' })` in one file. A `package`
5
+ * that is not a literal, or that does not parse, is skipped: the inspection
6
+ * reports those.
7
+ */
8
+ export const findWireRemoteAddonSpecs = (fileName, sourceText) => {
9
+ if (!sourceText.includes('wireRemoteAddon'))
10
+ return [];
11
+ const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true);
12
+ const found = [];
13
+ const visit = (node) => {
14
+ if (ts.isCallExpression(node) &&
15
+ ts.isIdentifier(node.expression) &&
16
+ node.expression.text === 'wireRemoteAddon' &&
17
+ node.arguments[0] &&
18
+ ts.isObjectLiteralExpression(node.arguments[0])) {
19
+ let namespace;
20
+ let literal;
21
+ for (const prop of node.arguments[0].properties) {
22
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
23
+ continue;
24
+ if (prop.name.text === 'name' &&
25
+ ts.isStringLiteralLike(prop.initializer)) {
26
+ namespace = prop.initializer.text;
27
+ }
28
+ else if (prop.name.text === 'package' &&
29
+ ts.isStringLiteralLike(prop.initializer)) {
30
+ literal = prop.initializer;
31
+ }
32
+ }
33
+ if (literal) {
34
+ try {
35
+ const { name, range } = parseRemoteAddonPackage(literal.text);
36
+ found.push({
37
+ namespace,
38
+ spec: literal.text,
39
+ name,
40
+ range,
41
+ line: sourceFile.getLineAndCharacterOfPosition(literal.getStart(sourceFile)).line + 1,
42
+ });
43
+ }
44
+ catch {
45
+ // reported by the inspection
46
+ }
47
+ }
48
+ }
49
+ ts.forEachChild(node, visit);
50
+ };
51
+ visit(sourceFile);
52
+ return found;
53
+ };
@@ -3,6 +3,7 @@ import { readFile, readdir } from 'fs/promises';
3
3
  import { join, dirname, parse, relative } from 'path';
4
4
  import { addonResolutionDirs, createAddonResolver, } from './addon-resolution.js';
5
5
  import { ErrorCode } from '../error-codes.js';
6
+ import { checkLockedSdk } from './remote-addon-sdk.js';
6
7
  const isHTTPRouteConfig = (value) => typeof value === 'object' &&
7
8
  value !== null &&
8
9
  'method' in value &&
@@ -239,6 +240,26 @@ export const readAddonContributors = async (dirs, packageName) => {
239
240
  contributors: declared.map(({ module, export: name }) => name === undefined ? { module } : { module, export: name }),
240
241
  };
241
242
  };
243
+ /**
244
+ * A versioned remote addon's function metadata comes from the SDK `pikku addon
245
+ * sync` extracted, and only when it matches the lock — a stale or missing SDK
246
+ * is reported by `validateRemoteAddonDependencies`, not loaded half-way.
247
+ */
248
+ const loadRemoteAddonSdkMeta = async (state, namespace, packageName, range) => {
249
+ let locked;
250
+ try {
251
+ locked = checkLockedSdk(state.rootDir, packageName, range);
252
+ }
253
+ catch {
254
+ return;
255
+ }
256
+ if (!locked.ok)
257
+ return;
258
+ const metaPath = join(locked.dir, 'function', 'pikku-functions-meta.gen.json');
259
+ if (!existsSync(metaPath))
260
+ return;
261
+ (state.remoteAddonFunctions ??= {})[namespace] = JSON.parse(await readFile(metaPath, 'utf-8'));
262
+ };
242
263
  export async function loadAddonFunctionsMeta(logger, state) {
243
264
  const { wireAddonDeclarations } = state.rpc;
244
265
  if (wireAddonDeclarations.size === 0)
@@ -247,8 +268,12 @@ export async function loadAddonFunctionsMeta(logger, state) {
247
268
  // Remote addons (wireRemoteAddon) ship as a devDependency: types only.
248
269
  // Their functions, secrets, variables, schemas and services live on the
249
270
  // HOST that runs them — never load or require any of that here.
250
- if (decl.remote)
271
+ if (decl.remote) {
272
+ if (decl.range) {
273
+ await loadRemoteAddonSdkMeta(state, namespace, decl.package, decl.range);
274
+ }
251
275
  continue;
276
+ }
252
277
  const dirs = addonResolutionDirs(state.rootDir, decl.file);
253
278
  const require = createAddonResolver(dirs);
254
279
  try {
@@ -347,13 +372,17 @@ export async function loadAddonFunctionsMeta(logger, state) {
347
372
  }
348
373
  }
349
374
  }
350
- // Same reasoning as mcp: a listed name the addon does not publish is a
351
- // function the app believes callable and never is.
375
+ // Same reasoning as mcp: a listed name the addon does not publish, or
376
+ // does not declare `expose: true`, is a function the app believes
377
+ // callable and never is.
352
378
  if (Array.isArray(decl.expose)) {
353
379
  for (const funcName of decl.expose) {
354
380
  if (!Object.hasOwn(meta, funcName)) {
355
381
  logger.critical(ErrorCode.ADDON_EXPOSE_FUNCTION_NOT_FOUND, `wireAddon('${namespace}') lists '${funcName}' under expose, but ${decl.package} publishes no such function.`);
356
382
  }
383
+ else if (meta[funcName]?.expose !== true) {
384
+ logger.critical(ErrorCode.ADDON_EXPOSE_FUNCTION_NOT_FOUND, `wireAddon('${namespace}') lists '${funcName}' under expose, but ${decl.package} does not declare it expose: true, so it stays unexposed.`);
385
+ }
357
386
  }
358
387
  }
359
388
  // Load addon secrets meta
@@ -7,6 +7,7 @@ import { relative } from 'node:path';
7
7
  import { AUTH_HANDLER_FUNC_ID, DYNAMIC_SCENARIO_STEP_TARGET, } from './inspector-constants.js';
8
8
  import { flattenScopeDefinitions } from '@pikku/core/scope';
9
9
  import { addonResolutionDirs } from './addon-resolution.js';
10
+ import { ADDONS_LOCK_FILE, checkLockedSdk, readAddonsLock, } from './remote-addon-sdk.js';
10
11
  /**
11
12
  * Stamp the inspected authorize/callbacks service set onto the generated auth
12
13
  * handler's function meta.
@@ -607,8 +608,33 @@ export function validateRemoteAddonDependencies(logger, state) {
607
608
  manifests.set(path, manifest);
608
609
  return manifest;
609
610
  };
611
+ // A versioned remote addon's contract is the SDK `pikku all`
612
+ // downloaded, so what has to hold is that the SDK on disk is the one the
613
+ // lock pins, not that a package is installed.
614
+ let lock;
610
615
  for (const [namespace, decl] of wireAddonDeclarations.entries()) {
611
- if (!decl.remote)
616
+ if (!decl.remote || !decl.range)
617
+ continue;
618
+ if (lock === undefined) {
619
+ try {
620
+ lock = readAddonsLock(state.rootDir);
621
+ }
622
+ catch (e) {
623
+ lock = e instanceof Error ? e : new Error(String(e));
624
+ }
625
+ }
626
+ const reason = lock instanceof Error
627
+ ? lock.message
628
+ : (() => {
629
+ const locked = checkLockedSdk(state.rootDir, decl.package, decl.range, lock);
630
+ return locked.ok ? null : locked.reason;
631
+ })();
632
+ if (reason === null)
633
+ continue;
634
+ logger.critical(ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY, `Remote addon '${namespace}' ('${decl.package}@${decl.range}') has no SDK matching ${ADDONS_LOCK_FILE}: ${reason}. Run 'pikku all' to download it.`);
635
+ }
636
+ for (const [namespace, decl] of wireAddonDeclarations.entries()) {
637
+ if (!decl.remote || decl.range)
612
638
  continue;
613
639
  const pkgJsonPath = join(addonResolutionDirs(state.rootDir, decl.file)[0], 'package.json');
614
640
  const pkgJson = readManifest(pkgJsonPath);
@@ -0,0 +1,57 @@
1
+ /** Committed beside pikku.config.json: the SDK version and integrity each versioned `wireRemoteAddon` resolved to. */
2
+ export declare const ADDONS_LOCK_FILE = "pikku-addons.lock";
3
+ /** Where `pikku all` extracts SDKs, relative to the project root. Gitignored with the rest of `.pikku`. */
4
+ export declare const REMOTE_SDK_DIR: string;
5
+ /** Written into an extracted SDK: the integrity of the tarball it came from. */
6
+ export declare const SDK_INTEGRITY_MARKER = ".integrity";
7
+ export type AddonsLockEntry = {
8
+ /** The range the spec asked for when this entry was written. */
9
+ range: string;
10
+ version: string;
11
+ /** `sha512-<base64>` of the SDK tarball. */
12
+ integrity: string;
13
+ };
14
+ export type AddonsLock = {
15
+ lockfileVersion: 1;
16
+ sdks: Record<string, AddonsLockEntry>;
17
+ };
18
+ /** `manifest.json` at the root of an SDK. */
19
+ export type RemoteAddonSdkManifest = {
20
+ name: string;
21
+ version: string;
22
+ /** The @pikku/core range the SDK's types were generated against. */
23
+ pikkuCore?: string;
24
+ contractHash?: string;
25
+ };
26
+ export type RemoteAddonPackageSpec = {
27
+ name: string;
28
+ range?: string;
29
+ };
30
+ /**
31
+ * `@scope/name@^1.4.0` → name and range, split at the last `@` after the first
32
+ * character. Mirrors `parseRemoteAddonPackage` in @pikku/core, which the
33
+ * runtime uses on the same literal.
34
+ */
35
+ export declare const parseRemoteAddonPackage: (spec: string) => RemoteAddonPackageSpec;
36
+ export declare const emptyAddonsLock: () => AddonsLock;
37
+ /** The project's lock, an empty one when there is none. Throws when the file is not a lock. */
38
+ export declare const readAddonsLock: (rootDir: string) => AddonsLock;
39
+ /** Sorted by package name, each entry's keys in a fixed order, so the same lock always writes the same bytes. */
40
+ export declare const serializeAddonsLock: (lock: AddonsLock) => string;
41
+ export declare const remoteSdkDir: (rootDir: string, name: string, version: string) => string;
42
+ export declare const readSdkManifest: (dir: string) => RemoteAddonSdkManifest | null;
43
+ export type LockedSdk = {
44
+ ok: true;
45
+ entry: AddonsLockEntry;
46
+ dir: string;
47
+ manifest: RemoteAddonSdkManifest;
48
+ } | {
49
+ ok: false;
50
+ reason: string;
51
+ };
52
+ /**
53
+ * Whether the SDK for `name@range` is on disk exactly as the lock pins it: the
54
+ * lock was written for this range, the extracted directory carries the lock's
55
+ * integrity, and its manifest names this package and version.
56
+ */
57
+ export declare const checkLockedSdk: (rootDir: string, name: string, range: string, lock?: AddonsLock) => LockedSdk;
@@ -0,0 +1,111 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ /** Committed beside pikku.config.json: the SDK version and integrity each versioned `wireRemoteAddon` resolved to. */
4
+ export const ADDONS_LOCK_FILE = 'pikku-addons.lock';
5
+ /** Where `pikku all` extracts SDKs, relative to the project root. Gitignored with the rest of `.pikku`. */
6
+ export const REMOTE_SDK_DIR = join('.pikku', 'remote');
7
+ /** Written into an extracted SDK: the integrity of the tarball it came from. */
8
+ export const SDK_INTEGRITY_MARKER = '.integrity';
9
+ /**
10
+ * `@scope/name@^1.4.0` → name and range, split at the last `@` after the first
11
+ * character. Mirrors `parseRemoteAddonPackage` in @pikku/core, which the
12
+ * runtime uses on the same literal.
13
+ */
14
+ export const parseRemoteAddonPackage = (spec) => {
15
+ const trimmed = spec.trim();
16
+ const at = trimmed.lastIndexOf('@');
17
+ if (at <= 0) {
18
+ if (!trimmed || trimmed === '@') {
19
+ throw new Error(`'${spec}' is not a package name`);
20
+ }
21
+ return { name: trimmed };
22
+ }
23
+ const name = trimmed.slice(0, at);
24
+ const range = trimmed.slice(at + 1).trim();
25
+ if (!range) {
26
+ throw new Error(`'${spec}' ends in '@' with no version range after it. Write '${name}@^1.0.0', or drop the '@'.`);
27
+ }
28
+ return { name, range };
29
+ };
30
+ export const emptyAddonsLock = () => ({
31
+ lockfileVersion: 1,
32
+ sdks: {},
33
+ });
34
+ /** The project's lock, an empty one when there is none. Throws when the file is not a lock. */
35
+ export const readAddonsLock = (rootDir) => {
36
+ const path = join(rootDir, ADDONS_LOCK_FILE);
37
+ if (!existsSync(path))
38
+ return emptyAddonsLock();
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
42
+ }
43
+ catch (e) {
44
+ throw new Error(`${path} is not valid JSON: ${e?.message ?? e}`);
45
+ }
46
+ if (!parsed ||
47
+ typeof parsed !== 'object' ||
48
+ typeof parsed.sdks !== 'object') {
49
+ throw new Error(`${path} has no "sdks" object`);
50
+ }
51
+ return { lockfileVersion: 1, sdks: parsed.sdks ?? {} };
52
+ };
53
+ /** Sorted by package name, each entry's keys in a fixed order, so the same lock always writes the same bytes. */
54
+ export const serializeAddonsLock = (lock) => {
55
+ const sdks = {};
56
+ for (const name of Object.keys(lock.sdks).sort()) {
57
+ const { range, version, integrity } = lock.sdks[name];
58
+ sdks[name] = { range, version, integrity };
59
+ }
60
+ return `${JSON.stringify({ lockfileVersion: 1, sdks }, null, 2)}\n`;
61
+ };
62
+ export const remoteSdkDir = (rootDir, name, version) => join(rootDir, REMOTE_SDK_DIR, `${name}@${version}`);
63
+ export const readSdkManifest = (dir) => {
64
+ try {
65
+ return JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf-8'));
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ };
71
+ /**
72
+ * Whether the SDK for `name@range` is on disk exactly as the lock pins it: the
73
+ * lock was written for this range, the extracted directory carries the lock's
74
+ * integrity, and its manifest names this package and version.
75
+ */
76
+ export const checkLockedSdk = (rootDir, name, range, lock = readAddonsLock(rootDir)) => {
77
+ const entry = lock.sdks[name];
78
+ if (!entry) {
79
+ return { ok: false, reason: `${ADDONS_LOCK_FILE} has no entry for it` };
80
+ }
81
+ if (entry.range !== range) {
82
+ return {
83
+ ok: false,
84
+ reason: `${ADDONS_LOCK_FILE} was resolved for '${entry.range}', not '${range}'`,
85
+ };
86
+ }
87
+ const dir = remoteSdkDir(rootDir, name, entry.version);
88
+ let integrity = null;
89
+ try {
90
+ integrity = readFileSync(join(dir, SDK_INTEGRITY_MARKER), 'utf-8').trim();
91
+ }
92
+ catch {
93
+ return { ok: false, reason: `${dir} is missing` };
94
+ }
95
+ if (integrity !== entry.integrity) {
96
+ return {
97
+ ok: false,
98
+ reason: `${dir} was extracted from a different tarball than ${ADDONS_LOCK_FILE} pins`,
99
+ };
100
+ }
101
+ const manifest = readSdkManifest(dir);
102
+ if (!manifest ||
103
+ manifest.name !== name ||
104
+ manifest.version !== entry.version) {
105
+ return {
106
+ ok: false,
107
+ reason: `${dir}/manifest.json does not name ${name}@${entry.version}`,
108
+ };
109
+ }
110
+ return { ok: true, entry, dir, manifest };
111
+ };
@@ -1,5 +1,6 @@
1
1
  import type { CredentialOverrideMeta } from '../types.js';
2
2
  import * as ts from 'typescript';
3
+ export declare const findPackageNameForFile: (filePath: string) => string | null;
3
4
  /**
4
5
  * Resolve the addon package name from an imported identifier.
5
6
  * Checks if the identifier's import module specifier matches any
@@ -2,7 +2,7 @@ import * as ts from 'typescript';
2
2
  import { existsSync, readFileSync } from 'fs';
3
3
  import { dirname, join, parse } from 'path';
4
4
  const packageNameCache = new Map();
5
- const findPackageNameForFile = (filePath) => {
5
+ export const findPackageNameForFile = (filePath) => {
6
6
  if (packageNameCache.has(filePath)) {
7
7
  return packageNameCache.get(filePath);
8
8
  }
@@ -90,6 +90,24 @@ export function resolveLatestVersions(state, logger) {
90
90
  }
91
91
  }
92
92
  }
93
+ // A remote caller names a version the same way: `fn` is the latest remote
94
+ // version, and every remote `fn@vN` stays callable by its own id.
95
+ const remote = [
96
+ ...group.explicit,
97
+ ...(group.unversioned
98
+ ? [{ id: state.rpc.internalMeta[baseName], version: maxVersion + 1 }]
99
+ : []),
100
+ ].filter(({ id }) => functionsMeta[id]?.remote === true);
101
+ if (remote.length > 0) {
102
+ state.rpc.remoteMeta[baseName] = remote.reduce((a, b) => a.version > b.version ? a : b).id;
103
+ for (const { id } of remote) {
104
+ state.rpc.remoteMeta[id] = id;
105
+ const fileEntry = state.rpc.internalFiles.get(id);
106
+ if (fileEntry) {
107
+ state.rpc.remoteFiles.set(id, fileEntry);
108
+ }
109
+ }
110
+ }
93
111
  for (const entry of group.explicit) {
94
112
  state.rpc.invokedFunctions.add(entry.id);
95
113
  }
@@ -194,6 +194,7 @@ export interface SerializableInspectorState {
194
194
  string,
195
195
  {
196
196
  package: string;
197
+ range?: string;
197
198
  file?: string;
198
199
  rpcEndpoint?: string;
199
200
  remote?: boolean;
@@ -326,6 +327,7 @@ export interface SerializableInspectorState {
326
327
  openAPISpec: Record<string, any> | null;
327
328
  diagnostics: InspectorDiagnostic[];
328
329
  addonFunctions: InspectorState['addonFunctions'];
330
+ remoteAddonFunctions?: InspectorState['remoteAddonFunctions'];
329
331
  screensManifest?: InspectorState['screensManifest'];
330
332
  addonScreens?: InspectorState['addonScreens'];
331
333
  addonContributors?: InspectorState['addonContributors'];
@@ -190,6 +190,7 @@ export function serializeInspectorState(state) {
190
190
  openAPISpec: state.openAPISpec,
191
191
  diagnostics: state.diagnostics,
192
192
  addonFunctions: state.addonFunctions,
193
+ remoteAddonFunctions: state.remoteAddonFunctions,
193
194
  screensManifest: state.screensManifest,
194
195
  addonScreens: state.addonScreens,
195
196
  addonContributors: state.addonContributors,
@@ -396,6 +397,9 @@ export function deserializeInspectorState(data) {
396
397
  openAPISpec: data.openAPISpec || null,
397
398
  diagnostics: data.diagnostics || [],
398
399
  addonFunctions: data.addonFunctions || {},
400
+ ...(data.remoteAddonFunctions
401
+ ? { remoteAddonFunctions: data.remoteAddonFunctions }
402
+ : {}),
399
403
  screensManifest: data.screensManifest,
400
404
  addonScreens: data.addonScreens,
401
405
  addonContributors: data.addonContributors,
@@ -0,0 +1,23 @@
1
+ import type { TypesMap } from '../types-map.js';
2
+ /**
3
+ * The named, source-declared types a function's input or output resolves to:
4
+ * every name the typed maps would have to import rather than declare inline.
5
+ * A custom alias (a union, an array of rows) is followed through to the names
6
+ * it references.
7
+ */
8
+ export declare const namedSourceTypes: (typesMap: TypesMap, names: string[]) => string[];
9
+ export interface AddonContractTypeSource {
10
+ name: string;
11
+ usesGenerics: boolean;
12
+ hasInputSchema: boolean;
13
+ hasOutputSchema: boolean;
14
+ inputNames: string[];
15
+ outputNames: string[];
16
+ }
17
+ /**
18
+ * Why a `remote: true` addon function cannot ship in an SDK, or null when it
19
+ * can. Its input and output have to be zod schemas: a TS generic or a named
20
+ * source type makes codegen emit `import type { X } from '../../../src/...'`
21
+ * into the remote map, a path that does not exist on the consumer's machine.
22
+ */
23
+ export declare const addonZodContractViolation: (typesMap: TypesMap, fn: AddonContractTypeSource) => string | null;
@@ -0,0 +1,70 @@
1
+ import { findPackageNameForFile } from './resolve-addon-package.js';
2
+ /**
3
+ * A type declared in `@pikku/core` is part of the SDK's allowed import set, so
4
+ * naming one (an MCP response, say) does not drag the addon's source along.
5
+ * Core is recognised by its install path or by the name in the nearest
6
+ * package.json, never by a directory name an addon could also use.
7
+ */
8
+ const isPikkuCorePath = (path) => /[\\/]node_modules[\\/]@pikku[\\/]core[\\/]/.test(path) ||
9
+ findPackageNameForFile(path) === '@pikku/core';
10
+ /**
11
+ * The named, source-declared types a function's input or output resolves to:
12
+ * every name the typed maps would have to import rather than declare inline.
13
+ * A custom alias (a union, an array of rows) is followed through to the names
14
+ * it references.
15
+ */
16
+ export const namedSourceTypes = (typesMap, names) => {
17
+ const found = new Set();
18
+ const visit = (name, seen) => {
19
+ if (seen.has(name))
20
+ return;
21
+ seen.add(name);
22
+ const custom = typesMap.customTypes.get(name);
23
+ if (custom) {
24
+ for (const ref of custom.references)
25
+ visit(ref, seen);
26
+ return;
27
+ }
28
+ let path = null;
29
+ let originalName = name;
30
+ try {
31
+ const meta = typesMap.getTypeMeta(name);
32
+ path = meta.path;
33
+ originalName = meta.originalName;
34
+ }
35
+ catch {
36
+ return;
37
+ }
38
+ if (path && !isPikkuCorePath(path))
39
+ found.add(originalName);
40
+ };
41
+ for (const name of names)
42
+ visit(name, new Set());
43
+ return [...found].sort();
44
+ };
45
+ /**
46
+ * Why a `remote: true` addon function cannot ship in an SDK, or null when it
47
+ * can. Its input and output have to be zod schemas: a TS generic or a named
48
+ * source type makes codegen emit `import type { X } from '../../../src/...'`
49
+ * into the remote map, a path that does not exist on the consumer's machine.
50
+ */
51
+ export const addonZodContractViolation = (typesMap, fn) => {
52
+ const problems = [];
53
+ if (fn.usesGenerics) {
54
+ problems.push('declares its input/output with TypeScript generics');
55
+ }
56
+ const named = [
57
+ ...(fn.hasInputSchema ? [] : namedSourceTypes(typesMap, fn.inputNames)),
58
+ ...(fn.hasOutputSchema ? [] : namedSourceTypes(typesMap, fn.outputNames)),
59
+ ];
60
+ if (named.length > 0) {
61
+ problems.push(`types its input/output with named source types (${[...new Set(named)].join(', ')})`);
62
+ }
63
+ if (problems.length === 0)
64
+ return null;
65
+ return (`Addon function '${fn.name}' is remote: true but ${problems.join(' and ')}. ` +
66
+ `A remote addon's contract ships as a types-only SDK, so its input and output must be zod schemas: ` +
67
+ `export each schema as a zod variable from a file that does not import #pikku (a sibling <fn>.schemas.ts), ` +
68
+ `reference it as \`input:\` / \`output:\` on the function, and drop the generics. ` +
69
+ `TypeScript types would make the SDK import from the addon's src/, which the consumer does not have.`);
70
+ };
@@ -11,4 +11,6 @@ import type { InspectorLogger, InspectorState } from '../types.js';
11
11
  *
12
12
  * knowledge: decisions/security/console-addon-privileged-functions-gate-themselves.md
13
13
  */
14
- export declare function validateExposedFunctionsGated(logger: InspectorLogger, state: InspectorState): void;
14
+ export declare function validateExposedFunctionsGated(logger: InspectorLogger, state: InspectorState, { isAddon }?: {
15
+ isAddon?: boolean;
16
+ }): void;
@@ -11,9 +11,13 @@ import { ErrorCode } from '../error-codes.js';
11
11
  *
12
12
  * knowledge: decisions/security/console-addon-privileged-functions-gate-themselves.md
13
13
  */
14
- export function validateExposedFunctionsGated(logger, state) {
14
+ export function validateExposedFunctionsGated(logger, state, { isAddon = false } = {}) {
15
15
  const ungated = [];
16
- for (const [funcName, meta] of Object.entries(state.functions.meta)) {
16
+ // An addon's own `expose: true` only makes a function eligible: nothing is
17
+ // reachable until a host's `wireAddon` opts in, and that host is warned below.
18
+ for (const [funcName, meta] of isAddon
19
+ ? []
20
+ : Object.entries(state.functions.meta)) {
17
21
  if (!meta.expose)
18
22
  continue;
19
23
  if (gatesItself(meta))
@@ -22,23 +26,26 @@ export function validateExposedFunctionsGated(logger, state) {
22
26
  continue;
23
27
  ungated.push(funcName);
24
28
  }
25
- // A `wireAddon` expose list can open an addon function its author never
26
- // exposed, so the app that listed it is the one to tell. The namespaced call
27
- // is governed by that instance's config alone.
29
+ // An addon function is exposed only when the app's `wireAddon` opts in, so
30
+ // the app that opted in is the one to tell. The namespaced call is governed
31
+ // by that instance's config alone.
28
32
  //
29
33
  // knowledge: decisions/security/wire-addon-expose-selects-the-rpc-surface.md
30
34
  for (const [namespace, declaration] of state.rpc?.wireAddonDeclarations ??
31
35
  []) {
32
- if (!Array.isArray(declaration.expose))
36
+ if (!declaration.expose)
33
37
  continue;
34
38
  if (declaration.auth === true)
35
39
  continue;
36
40
  if (declaration.scopes && declaration.scopes.length > 0)
37
41
  continue;
38
42
  const addonMeta = state.addonFunctions?.[namespace] ?? {};
39
- for (const funcName of declaration.expose) {
43
+ const selected = Array.isArray(declaration.expose)
44
+ ? declaration.expose
45
+ : Object.keys(addonMeta);
46
+ for (const funcName of selected) {
40
47
  const meta = addonMeta[funcName];
41
- if (!meta || gatesItself(meta))
48
+ if (!meta?.expose || gatesItself(meta))
42
49
  continue;
43
50
  ungated.push(`${namespace}:${funcName}`);
44
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.109",
3
+ "version": "0.12.110",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -70,10 +70,10 @@
70
70
  "zod": "^4.6.7"
71
71
  },
72
72
  "peerDependencies": {
73
- "@pikku/core": "^0.12.145"
73
+ "@pikku/core": "^0.12.146"
74
74
  },
75
75
  "devDependencies": {
76
- "@pikku/core": "^0.12.145",
76
+ "@pikku/core": "^0.12.146",
77
77
  "@types/node": "^26.6.5"
78
78
  },
79
79
  "engines": {