@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.
- package/dist/add/add-functions.js +14 -0
- package/dist/add/add-rpc-invocations.d.ts +1 -1
- package/dist/add/add-rpc-invocations.js +2 -2
- package/dist/add/add-wire-remote-addon.js +25 -4
- package/dist/error-codes.d.ts +5 -0
- package/dist/error-codes.js +5 -0
- package/dist/finalize.js +3 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/lite.d.ts +4 -1
- package/dist/lite.js +3 -1
- package/dist/types.d.ts +16 -0
- package/dist/utils/contract-hashes.js +2 -2
- package/dist/utils/find-wire-remote-addon-specs.d.ts +17 -0
- package/dist/utils/find-wire-remote-addon-specs.js +53 -0
- package/dist/utils/load-addon-functions-meta.js +32 -3
- package/dist/utils/post-process.js +27 -1
- package/dist/utils/remote-addon-sdk.d.ts +57 -0
- package/dist/utils/remote-addon-sdk.js +111 -0
- package/dist/utils/resolve-addon-package.d.ts +1 -0
- package/dist/utils/resolve-addon-package.js +1 -1
- package/dist/utils/resolve-versions.js +18 -0
- package/dist/utils/serialize-inspector-state.d.ts +2 -0
- package/dist/utils/serialize-inspector-state.js +4 -0
- package/dist/utils/validate-addon-zod-contract.d.ts +23 -0
- package/dist/utils/validate-addon-zod-contract.js +70 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +3 -1
- package/dist/utils/validate-exposed-functions-gated.js +15 -8
- package/package.json +3 -3
|
@@ -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.
|
|
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', '
|
|
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.
|
|
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'
|
|
35
|
-
|
|
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
|
-
|
|
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:
|
|
79
|
+
package: packageName,
|
|
80
|
+
...(range ? { range } : {}),
|
|
60
81
|
file: node.getSourceFile().fileName,
|
|
61
82
|
remote: true,
|
|
62
83
|
hasAuth,
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -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",
|
package/dist/error-codes.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
351
|
-
// function the app believes
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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 (!
|
|
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
|
-
|
|
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.
|
|
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.
|
|
73
|
+
"@pikku/core": "^0.12.146"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@pikku/core": "^0.12.
|
|
76
|
+
"@pikku/core": "^0.12.146",
|
|
77
77
|
"@types/node": "^26.6.5"
|
|
78
78
|
},
|
|
79
79
|
"engines": {
|