apcore-js 0.1.1 → 0.2.0
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/.claude/settings.local.json +2 -1
- package/.pre-commit-config.yaml +2 -2
- package/CHANGELOG.md +63 -0
- package/package.json +1 -1
- package/planning/overview.md +1 -1
- package/src/acl.ts +37 -38
- package/src/bindings.ts +11 -18
- package/src/context.ts +1 -1
- package/src/errors.ts +57 -2
- package/src/executor.ts +74 -49
- package/src/index.ts +6 -2
- package/src/middleware/logging.ts +1 -1
- package/src/middleware/manager.ts +2 -2
- package/src/observability/tracing.ts +1 -1
- package/src/registry/registry.ts +103 -61
- package/src/registry/schema-export.ts +1 -4
- package/src/schema/exporter.ts +1 -4
- package/src/schema/loader.ts +45 -56
- package/src/schema/ref-resolver.ts +1 -4
- package/src/schema/strict.ts +1 -3
- package/src/utils/index.ts +4 -0
- package/tests/integration/test-e2e-flow.test.ts +4 -4
- package/tests/schema/test-annotations.test.ts +135 -0
- package/tests/schema/test-exporter.test.ts +171 -0
- package/tests/test-executor.test.ts +3 -3
- package/tests/test-logging-middleware.test.ts +150 -0
package/.pre-commit-config.yaml
CHANGED
|
@@ -5,13 +5,13 @@ repos:
|
|
|
5
5
|
hooks:
|
|
6
6
|
- id: check-chars
|
|
7
7
|
name: apdev-js check-chars
|
|
8
|
-
entry: apdev-js check-chars
|
|
8
|
+
entry: npx apdev-js check-chars
|
|
9
9
|
language: system
|
|
10
10
|
types_or: [text, ts, javascript]
|
|
11
11
|
|
|
12
12
|
- id: check-imports
|
|
13
13
|
name: apdev-js check-imports
|
|
14
|
-
entry: apdev-js check-imports
|
|
14
|
+
entry: npx apdev-js check-imports --package apcore-js
|
|
15
15
|
language: system
|
|
16
16
|
pass_filenames: false
|
|
17
17
|
always_run: true
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,69 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-02-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Error classes and constants**
|
|
13
|
+
- `ModuleExecuteError` — New error class for module execution failures
|
|
14
|
+
- `InternalError` — New error class for general internal errors
|
|
15
|
+
- `ErrorCodes` — Frozen object with all 26 error code strings for consistent error code usage
|
|
16
|
+
- `ErrorCode` — Type definition for all error codes
|
|
17
|
+
- **Registry constants**
|
|
18
|
+
- `REGISTRY_EVENTS` — Frozen object with standard event names (`register`, `unregister`)
|
|
19
|
+
- `MODULE_ID_PATTERN` — Regex pattern enforcing lowercase/digits/underscores/dots for module IDs (no hyphens allowed to ensure bijective MCP tool name normalization)
|
|
20
|
+
- **Executor methods**
|
|
21
|
+
- `Executor.callAsync()` — Alias for `call()` for compatibility with MCP bridge packages
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **Module ID validation** — Registry now validates module IDs against `MODULE_ID_PATTERN` on registration, rejecting IDs with hyphens or invalid characters
|
|
26
|
+
- **Event handling** — Registry event validation now uses `REGISTRY_EVENTS` constants instead of hardcoded strings
|
|
27
|
+
- **Test updates** — Updated tests to use underscore-separated module IDs instead of hyphens (e.g., `math.add_ten` instead of `math.addTen`, `ctx_test` instead of `ctx-test`)
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **String literals in Registry** — Replaced hardcoded `'register'` and `'unregister'` strings with `REGISTRY_EVENTS.REGISTER` and `REGISTRY_EVENTS.UNREGISTER` constants in event triggers for consistency
|
|
32
|
+
|
|
33
|
+
## [0.1.2] - 2026-02-18
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **Timer leak in executor** — `_executeWithTimeout` now calls `clearTimeout` in `.finally()` to prevent timer leak on normal completion
|
|
38
|
+
- **Path traversal protection** — `resolveTarget` in binding loader rejects module paths containing `..` segments before dynamic `import()`
|
|
39
|
+
- **Bare catch blocks** — 6 silent `catch {}` blocks in registry and middleware manager now log warnings with `[apcore:<subsystem>]` prefix
|
|
40
|
+
- **Python-style error messages** — Fixed `FuncMissingTypeHintError` and `FuncMissingReturnTypeError` to use TypeScript syntax (`: string`, `: Record<string, unknown>`)
|
|
41
|
+
- **Console.log in production** — Replaced `console.log` with `console.info` in logging middleware and `process.stdout.write` in tracing exporter
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **Long method decomposition** — Broke up 4 oversized methods to meet ≤50 line guideline:
|
|
46
|
+
- `Executor.call()` (108 → 6 private helpers)
|
|
47
|
+
- `Registry.discover()` (110 → 7 private helpers)
|
|
48
|
+
- `ACL.load()` (71 → extracted `parseAclRule`)
|
|
49
|
+
- `jsonSchemaToTypeBox()` (80 → 5 converter helpers)
|
|
50
|
+
- **Deeply readonly callChain** — `Context.callChain` type narrowed from `readonly string[]` to `readonly (readonly string[])` preventing mutation via push/splice
|
|
51
|
+
- **Consolidated `deepCopy`** — Removed 4 duplicate `deepCopy` implementations; single shared version now lives in `src/utils/index.ts`
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **42 new tests** for previously uncovered modules:
|
|
56
|
+
- `tests/schema/test-annotations.test.ts` — 16 tests for `mergeAnnotations`, `mergeExamples`, `mergeMetadata`
|
|
57
|
+
- `tests/schema/test-exporter.test.ts` — 14 tests for `SchemaExporter` across all 4 export profiles
|
|
58
|
+
- `tests/test-logging-middleware.test.ts` — 12 tests for `LoggingMiddleware` before/after/onError
|
|
59
|
+
|
|
60
|
+
## [0.1.1] - 2026-02-17
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
- Updated logo URL in README
|
|
65
|
+
|
|
66
|
+
### Changed
|
|
67
|
+
|
|
68
|
+
- Renamed package from `apcore` to `apcore-js`
|
|
69
|
+
- Updated installation instructions
|
|
70
|
+
|
|
8
71
|
## [0.1.0] - 2026-02-16
|
|
9
72
|
|
|
10
73
|
### Added
|
package/package.json
CHANGED
package/planning/overview.md
CHANGED
package/src/acl.ts
CHANGED
|
@@ -16,6 +16,42 @@ export interface ACLRule {
|
|
|
16
16
|
conditions?: Record<string, unknown> | null;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function parseAclRule(rawRule: unknown, index: number): ACLRule {
|
|
20
|
+
if (typeof rawRule !== 'object' || rawRule === null || Array.isArray(rawRule)) {
|
|
21
|
+
throw new ACLRuleError(`Rule ${index} must be a mapping, got ${typeof rawRule}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ruleObj = rawRule as Record<string, unknown>;
|
|
25
|
+
for (const key of ['callers', 'targets', 'effect']) {
|
|
26
|
+
if (!(key in ruleObj)) {
|
|
27
|
+
throw new ACLRuleError(`Rule ${index} missing required key '${key}'`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const effect = ruleObj['effect'] as string;
|
|
32
|
+
if (effect !== 'allow' && effect !== 'deny') {
|
|
33
|
+
throw new ACLRuleError(`Rule ${index} has invalid effect '${effect}', must be 'allow' or 'deny'`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const callers = ruleObj['callers'];
|
|
37
|
+
if (!Array.isArray(callers)) {
|
|
38
|
+
throw new ACLRuleError(`Rule ${index} 'callers' must be a list, got ${typeof callers}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const targets = ruleObj['targets'];
|
|
42
|
+
if (!Array.isArray(targets)) {
|
|
43
|
+
throw new ACLRuleError(`Rule ${index} 'targets' must be a list, got ${typeof targets}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
callers: callers as string[],
|
|
48
|
+
targets: targets as string[],
|
|
49
|
+
effect,
|
|
50
|
+
description: (ruleObj['description'] as string) ?? '',
|
|
51
|
+
conditions: (ruleObj['conditions'] as Record<string, unknown>) ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
19
55
|
export class ACL {
|
|
20
56
|
private _rules: ACLRule[];
|
|
21
57
|
private _defaultEffect: string;
|
|
@@ -56,44 +92,7 @@ export class ACL {
|
|
|
56
92
|
}
|
|
57
93
|
|
|
58
94
|
const defaultEffect = (dataObj['default_effect'] as string) ?? 'deny';
|
|
59
|
-
const rules
|
|
60
|
-
|
|
61
|
-
for (let i = 0; i < rawRules.length; i++) {
|
|
62
|
-
const rawRule = rawRules[i];
|
|
63
|
-
if (typeof rawRule !== 'object' || rawRule === null || Array.isArray(rawRule)) {
|
|
64
|
-
throw new ACLRuleError(`Rule ${i} must be a mapping, got ${typeof rawRule}`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const ruleObj = rawRule as Record<string, unknown>;
|
|
68
|
-
for (const key of ['callers', 'targets', 'effect']) {
|
|
69
|
-
if (!(key in ruleObj)) {
|
|
70
|
-
throw new ACLRuleError(`Rule ${i} missing required key '${key}'`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const effect = ruleObj['effect'] as string;
|
|
75
|
-
if (effect !== 'allow' && effect !== 'deny') {
|
|
76
|
-
throw new ACLRuleError(`Rule ${i} has invalid effect '${effect}', must be 'allow' or 'deny'`);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const callers = ruleObj['callers'];
|
|
80
|
-
if (!Array.isArray(callers)) {
|
|
81
|
-
throw new ACLRuleError(`Rule ${i} 'callers' must be a list, got ${typeof callers}`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const targets = ruleObj['targets'];
|
|
85
|
-
if (!Array.isArray(targets)) {
|
|
86
|
-
throw new ACLRuleError(`Rule ${i} 'targets' must be a list, got ${typeof targets}`);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
rules.push({
|
|
90
|
-
callers: callers as string[],
|
|
91
|
-
targets: targets as string[],
|
|
92
|
-
effect,
|
|
93
|
-
description: (ruleObj['description'] as string) ?? '',
|
|
94
|
-
conditions: (ruleObj['conditions'] as Record<string, unknown>) ?? null,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
95
|
+
const rules = rawRules.map((raw, i) => parseAclRule(raw, i));
|
|
97
96
|
|
|
98
97
|
const acl = new ACL(rules, defaultEffect);
|
|
99
98
|
acl._yamlPath = yamlPath;
|
package/src/bindings.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
-
import { resolve, dirname, join, basename } from 'node:path';
|
|
6
|
+
import { resolve, dirname, join, basename, isAbsolute } from 'node:path';
|
|
7
7
|
import { Type, type TSchema } from '@sinclair/typebox';
|
|
8
8
|
import yaml from 'js-yaml';
|
|
9
9
|
import { FunctionModule } from './decorator.js';
|
|
@@ -18,19 +18,6 @@ import {
|
|
|
18
18
|
import type { Registry } from './registry/registry.js';
|
|
19
19
|
import { jsonSchemaToTypeBox } from './schema/loader.js';
|
|
20
20
|
|
|
21
|
-
const JSON_SCHEMA_TYPE_MAP: Record<string, () => TSchema> = {
|
|
22
|
-
string: () => Type.String(),
|
|
23
|
-
integer: () => Type.Integer(),
|
|
24
|
-
number: () => Type.Number(),
|
|
25
|
-
boolean: () => Type.Boolean(),
|
|
26
|
-
array: () => Type.Array(Type.Unknown()),
|
|
27
|
-
object: () => Type.Record(Type.String(), Type.Unknown()),
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
function buildSchemaFromJsonSchema(schema: Record<string, unknown>): TSchema {
|
|
31
|
-
return jsonSchemaToTypeBox(schema);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
21
|
export class BindingLoader {
|
|
35
22
|
async loadBindings(filePath: string, registry: Registry): Promise<FunctionModule[]> {
|
|
36
23
|
const bindingFileDir = dirname(filePath);
|
|
@@ -113,6 +100,12 @@ export class BindingLoader {
|
|
|
113
100
|
|
|
114
101
|
const [modulePath, callableName] = targetString.split(':', 2);
|
|
115
102
|
|
|
103
|
+
if (modulePath.includes('..')) {
|
|
104
|
+
throw new BindingInvalidTargetError(
|
|
105
|
+
`Module path '${modulePath}' must not contain '..' segments`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
116
109
|
let mod: Record<string, unknown>;
|
|
117
110
|
try {
|
|
118
111
|
mod = await import(modulePath);
|
|
@@ -165,8 +158,8 @@ export class BindingLoader {
|
|
|
165
158
|
if ('input_schema' in binding || 'output_schema' in binding) {
|
|
166
159
|
const inputSchemaDict = (binding['input_schema'] as Record<string, unknown>) ?? {};
|
|
167
160
|
const outputSchemaDict = (binding['output_schema'] as Record<string, unknown>) ?? {};
|
|
168
|
-
inputSchema =
|
|
169
|
-
outputSchema =
|
|
161
|
+
inputSchema = jsonSchemaToTypeBox(inputSchemaDict);
|
|
162
|
+
outputSchema = jsonSchemaToTypeBox(outputSchemaDict);
|
|
170
163
|
} else if ('schema_ref' in binding) {
|
|
171
164
|
const refPath = resolve(bindingFileDir, binding['schema_ref'] as string);
|
|
172
165
|
if (!existsSync(refPath)) {
|
|
@@ -178,10 +171,10 @@ export class BindingLoader {
|
|
|
178
171
|
} catch (e) {
|
|
179
172
|
throw new BindingFileInvalidError(refPath, `YAML parse error: ${e}`);
|
|
180
173
|
}
|
|
181
|
-
inputSchema =
|
|
174
|
+
inputSchema = jsonSchemaToTypeBox(
|
|
182
175
|
(refData['input_schema'] as Record<string, unknown>) ?? {},
|
|
183
176
|
);
|
|
184
|
-
outputSchema =
|
|
177
|
+
outputSchema = jsonSchemaToTypeBox(
|
|
185
178
|
(refData['output_schema'] as Record<string, unknown>) ?? {},
|
|
186
179
|
);
|
|
187
180
|
} else {
|
package/src/context.ts
CHANGED
|
@@ -21,7 +21,7 @@ export function createIdentity(
|
|
|
21
21
|
export class Context {
|
|
22
22
|
readonly traceId: string;
|
|
23
23
|
readonly callerId: string | null;
|
|
24
|
-
readonly callChain: string[];
|
|
24
|
+
readonly callChain: readonly string[];
|
|
25
25
|
readonly executor: unknown;
|
|
26
26
|
readonly identity: Identity | null;
|
|
27
27
|
redactedInputs: Record<string, unknown> | null;
|
package/src/errors.ts
CHANGED
|
@@ -242,7 +242,7 @@ export class FuncMissingTypeHintError extends ModuleError {
|
|
|
242
242
|
constructor(functionName: string, parameterName: string, options?: { cause?: Error; traceId?: string }) {
|
|
243
243
|
super(
|
|
244
244
|
'FUNC_MISSING_TYPE_HINT',
|
|
245
|
-
`Parameter '${parameterName}' in function '${functionName}' has no type annotation. Add a type
|
|
245
|
+
`Parameter '${parameterName}' in function '${functionName}' has no type annotation. Add a type annotation like '${parameterName}: string'.`,
|
|
246
246
|
{ functionName, parameterName },
|
|
247
247
|
options?.cause,
|
|
248
248
|
options?.traceId,
|
|
@@ -255,7 +255,7 @@ export class FuncMissingReturnTypeError extends ModuleError {
|
|
|
255
255
|
constructor(functionName: string, options?: { cause?: Error; traceId?: string }) {
|
|
256
256
|
super(
|
|
257
257
|
'FUNC_MISSING_RETURN_TYPE',
|
|
258
|
-
`Function '${functionName}' has no return type annotation. Add a return type like '
|
|
258
|
+
`Function '${functionName}' has no return type annotation. Add a return type like ': Record<string, unknown>'.`,
|
|
259
259
|
{ functionName },
|
|
260
260
|
options?.cause,
|
|
261
261
|
options?.traceId,
|
|
@@ -367,3 +367,58 @@ export class ModuleLoadError extends ModuleError {
|
|
|
367
367
|
this.name = 'ModuleLoadError';
|
|
368
368
|
}
|
|
369
369
|
}
|
|
370
|
+
|
|
371
|
+
export class ModuleExecuteError extends ModuleError {
|
|
372
|
+
constructor(moduleId: string, reason: string, options?: { cause?: Error; traceId?: string }) {
|
|
373
|
+
super(
|
|
374
|
+
'MODULE_EXECUTE_ERROR',
|
|
375
|
+
`Failed to execute module '${moduleId}': ${reason}`,
|
|
376
|
+
{ moduleId, reason },
|
|
377
|
+
options?.cause,
|
|
378
|
+
options?.traceId,
|
|
379
|
+
);
|
|
380
|
+
this.name = 'ModuleExecuteError';
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export class InternalError extends ModuleError {
|
|
385
|
+
constructor(message: string = 'Internal error', options?: { cause?: Error; traceId?: string }) {
|
|
386
|
+
super('GENERAL_INTERNAL_ERROR', message, {}, options?.cause, options?.traceId);
|
|
387
|
+
this.name = 'InternalError';
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* All framework error codes as constants.
|
|
393
|
+
* Use these instead of hardcoding error code strings.
|
|
394
|
+
*/
|
|
395
|
+
export const ErrorCodes = Object.freeze({
|
|
396
|
+
CONFIG_NOT_FOUND: "CONFIG_NOT_FOUND",
|
|
397
|
+
CONFIG_INVALID: "CONFIG_INVALID",
|
|
398
|
+
ACL_RULE_ERROR: "ACL_RULE_ERROR",
|
|
399
|
+
ACL_DENIED: "ACL_DENIED",
|
|
400
|
+
MODULE_NOT_FOUND: "MODULE_NOT_FOUND",
|
|
401
|
+
MODULE_TIMEOUT: "MODULE_TIMEOUT",
|
|
402
|
+
MODULE_LOAD_ERROR: "MODULE_LOAD_ERROR",
|
|
403
|
+
MODULE_EXECUTE_ERROR: "MODULE_EXECUTE_ERROR",
|
|
404
|
+
SCHEMA_VALIDATION_ERROR: "SCHEMA_VALIDATION_ERROR",
|
|
405
|
+
SCHEMA_NOT_FOUND: "SCHEMA_NOT_FOUND",
|
|
406
|
+
SCHEMA_PARSE_ERROR: "SCHEMA_PARSE_ERROR",
|
|
407
|
+
SCHEMA_CIRCULAR_REF: "SCHEMA_CIRCULAR_REF",
|
|
408
|
+
CALL_DEPTH_EXCEEDED: "CALL_DEPTH_EXCEEDED",
|
|
409
|
+
CIRCULAR_CALL: "CIRCULAR_CALL",
|
|
410
|
+
CALL_FREQUENCY_EXCEEDED: "CALL_FREQUENCY_EXCEEDED",
|
|
411
|
+
GENERAL_INVALID_INPUT: "GENERAL_INVALID_INPUT",
|
|
412
|
+
GENERAL_INTERNAL_ERROR: "GENERAL_INTERNAL_ERROR",
|
|
413
|
+
FUNC_MISSING_TYPE_HINT: "FUNC_MISSING_TYPE_HINT",
|
|
414
|
+
FUNC_MISSING_RETURN_TYPE: "FUNC_MISSING_RETURN_TYPE",
|
|
415
|
+
BINDING_INVALID_TARGET: "BINDING_INVALID_TARGET",
|
|
416
|
+
BINDING_MODULE_NOT_FOUND: "BINDING_MODULE_NOT_FOUND",
|
|
417
|
+
BINDING_CALLABLE_NOT_FOUND: "BINDING_CALLABLE_NOT_FOUND",
|
|
418
|
+
BINDING_NOT_CALLABLE: "BINDING_NOT_CALLABLE",
|
|
419
|
+
BINDING_SCHEMA_MISSING: "BINDING_SCHEMA_MISSING",
|
|
420
|
+
BINDING_FILE_INVALID: "BINDING_FILE_INVALID",
|
|
421
|
+
CIRCULAR_DEPENDENCY: "CIRCULAR_DEPENDENCY",
|
|
422
|
+
} as const);
|
|
423
|
+
|
|
424
|
+
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
package/src/executor.ts
CHANGED
|
@@ -157,60 +157,97 @@ export class Executor {
|
|
|
157
157
|
context?: Context | null,
|
|
158
158
|
): Promise<Record<string, unknown>> {
|
|
159
159
|
let effectiveInputs = inputs ?? {};
|
|
160
|
+
const ctx = this._createContext(moduleId, context);
|
|
161
|
+
this._checkSafety(moduleId, ctx);
|
|
162
|
+
|
|
163
|
+
const mod = this._lookupModule(moduleId);
|
|
164
|
+
this._checkAcl(moduleId, ctx);
|
|
165
|
+
|
|
166
|
+
effectiveInputs = this._validateInputs(mod, effectiveInputs, ctx);
|
|
167
|
+
|
|
168
|
+
return this._executeWithMiddleware(mod, moduleId, effectiveInputs, ctx);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Alias for call(). Provided for compatibility with MCP bridge packages
|
|
173
|
+
* that may call callAsync() by convention.
|
|
174
|
+
*/
|
|
175
|
+
async callAsync(
|
|
176
|
+
moduleId: string,
|
|
177
|
+
inputs?: Record<string, unknown> | null,
|
|
178
|
+
context?: Context | null,
|
|
179
|
+
): Promise<Record<string, unknown>> {
|
|
180
|
+
return this.call(moduleId, inputs, context);
|
|
181
|
+
}
|
|
160
182
|
|
|
161
|
-
|
|
162
|
-
let ctx: Context;
|
|
183
|
+
private _createContext(moduleId: string, context?: Context | null): Context {
|
|
163
184
|
if (context == null) {
|
|
164
|
-
|
|
165
|
-
ctx = ctx.child(moduleId);
|
|
166
|
-
} else {
|
|
167
|
-
ctx = context.child(moduleId);
|
|
185
|
+
return Context.create(this).child(moduleId);
|
|
168
186
|
}
|
|
187
|
+
return context.child(moduleId);
|
|
188
|
+
}
|
|
169
189
|
|
|
170
|
-
|
|
171
|
-
this._checkSafety(moduleId, ctx);
|
|
172
|
-
|
|
173
|
-
// Step 3 -- Lookup
|
|
190
|
+
private _lookupModule(moduleId: string): Record<string, unknown> {
|
|
174
191
|
const module = this._registry.get(moduleId);
|
|
175
192
|
if (module === null) {
|
|
176
193
|
throw new ModuleNotFoundError(moduleId);
|
|
177
194
|
}
|
|
195
|
+
return module as Record<string, unknown>;
|
|
196
|
+
}
|
|
178
197
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// Step 4 -- ACL
|
|
198
|
+
private _checkAcl(moduleId: string, ctx: Context): void {
|
|
182
199
|
if (this._acl !== null) {
|
|
183
200
|
const allowed = this._acl.check(ctx.callerId, moduleId, ctx);
|
|
184
201
|
if (!allowed) {
|
|
185
202
|
throw new ACLDeniedError(ctx.callerId, moduleId);
|
|
186
203
|
}
|
|
187
204
|
}
|
|
205
|
+
}
|
|
188
206
|
|
|
189
|
-
|
|
207
|
+
private _validateInputs(
|
|
208
|
+
mod: Record<string, unknown>,
|
|
209
|
+
inputs: Record<string, unknown>,
|
|
210
|
+
ctx: Context,
|
|
211
|
+
): Record<string, unknown> {
|
|
190
212
|
const inputSchema = mod['inputSchema'] as TSchema | undefined;
|
|
191
|
-
if (inputSchema
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
213
|
+
if (inputSchema == null) return inputs;
|
|
214
|
+
|
|
215
|
+
this._validateSchema(inputSchema, inputs, 'Input');
|
|
216
|
+
ctx.redactedInputs = redactSensitive(
|
|
217
|
+
inputs,
|
|
218
|
+
inputSchema as unknown as Record<string, unknown>,
|
|
219
|
+
);
|
|
220
|
+
return inputs;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private _validateSchema(
|
|
224
|
+
schema: TSchema,
|
|
225
|
+
data: Record<string, unknown>,
|
|
226
|
+
direction: string,
|
|
227
|
+
): void {
|
|
228
|
+
if (Value.Check(schema, data)) return;
|
|
203
229
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
230
|
+
const errors: Array<Record<string, unknown>> = [];
|
|
231
|
+
for (const error of Value.Errors(schema, data)) {
|
|
232
|
+
errors.push({
|
|
233
|
+
field: error.path || '/',
|
|
234
|
+
code: String(error.type),
|
|
235
|
+
message: error.message,
|
|
236
|
+
});
|
|
208
237
|
}
|
|
238
|
+
throw new SchemaValidationError(`${direction} validation failed`, errors);
|
|
239
|
+
}
|
|
209
240
|
|
|
241
|
+
private async _executeWithMiddleware(
|
|
242
|
+
mod: Record<string, unknown>,
|
|
243
|
+
moduleId: string,
|
|
244
|
+
inputs: Record<string, unknown>,
|
|
245
|
+
ctx: Context,
|
|
246
|
+
): Promise<Record<string, unknown>> {
|
|
247
|
+
let effectiveInputs = inputs;
|
|
210
248
|
let executedMiddlewares: Middleware[] = [];
|
|
211
249
|
|
|
212
250
|
try {
|
|
213
|
-
// Step 6 -- Middleware Before
|
|
214
251
|
try {
|
|
215
252
|
[effectiveInputs, executedMiddlewares] = this._middlewareManager.executeBefore(moduleId, effectiveInputs, ctx);
|
|
216
253
|
} catch (e) {
|
|
@@ -226,29 +263,14 @@ export class Executor {
|
|
|
226
263
|
throw e;
|
|
227
264
|
}
|
|
228
265
|
|
|
229
|
-
// Step 7 -- Execute with timeout
|
|
230
266
|
let output = await this._executeWithTimeout(mod, moduleId, effectiveInputs, ctx);
|
|
231
267
|
|
|
232
|
-
// Step 8 -- Output Validation
|
|
233
268
|
const outputSchema = mod['outputSchema'] as TSchema | undefined;
|
|
234
269
|
if (outputSchema != null) {
|
|
235
|
-
|
|
236
|
-
const errors: Array<Record<string, unknown>> = [];
|
|
237
|
-
for (const error of Value.Errors(outputSchema, output)) {
|
|
238
|
-
errors.push({
|
|
239
|
-
field: error.path || '/',
|
|
240
|
-
code: String(error.type),
|
|
241
|
-
message: error.message,
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
throw new SchemaValidationError('Output validation failed', errors);
|
|
245
|
-
}
|
|
270
|
+
this._validateSchema(outputSchema, output, 'Output');
|
|
246
271
|
}
|
|
247
272
|
|
|
248
|
-
// Step 9 -- Middleware After
|
|
249
273
|
output = this._middlewareManager.executeAfter(moduleId, effectiveInputs, output, ctx);
|
|
250
|
-
|
|
251
|
-
// Step 10 -- Return
|
|
252
274
|
return output;
|
|
253
275
|
} catch (exc) {
|
|
254
276
|
if (executedMiddlewares.length > 0) {
|
|
@@ -337,12 +359,15 @@ export class Executor {
|
|
|
337
359
|
return executionPromise;
|
|
338
360
|
}
|
|
339
361
|
|
|
362
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
340
363
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
341
|
-
setTimeout(() => {
|
|
364
|
+
timer = setTimeout(() => {
|
|
342
365
|
reject(new ModuleTimeoutError(moduleId, timeoutMs));
|
|
343
366
|
}, timeoutMs);
|
|
344
367
|
});
|
|
345
368
|
|
|
346
|
-
return Promise.race([executionPromise, timeoutPromise])
|
|
369
|
+
return Promise.race([executionPromise, timeoutPromise]).finally(() => {
|
|
370
|
+
clearTimeout(timer);
|
|
371
|
+
});
|
|
347
372
|
}
|
|
348
373
|
}
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Core
|
|
6
6
|
export { Context, createIdentity } from './context.js';
|
|
7
7
|
export type { Identity } from './context.js';
|
|
8
|
-
export { Registry } from './registry/registry.js';
|
|
8
|
+
export { Registry, REGISTRY_EVENTS, MODULE_ID_PATTERN } from './registry/registry.js';
|
|
9
9
|
export { Executor, redactSensitive, REDACTED_VALUE } from './executor.js';
|
|
10
10
|
|
|
11
11
|
// Module types
|
|
@@ -42,7 +42,11 @@ export {
|
|
|
42
42
|
BindingFileInvalidError,
|
|
43
43
|
CircularDependencyError,
|
|
44
44
|
ModuleLoadError,
|
|
45
|
+
ModuleExecuteError,
|
|
46
|
+
InternalError,
|
|
47
|
+
ErrorCodes,
|
|
45
48
|
} from './errors.js';
|
|
49
|
+
export type { ErrorCode } from './errors.js';
|
|
46
50
|
|
|
47
51
|
// ACL
|
|
48
52
|
export { ACL } from './acl.js';
|
|
@@ -78,4 +82,4 @@ export type { Span, SpanExporter } from './observability/tracing.js';
|
|
|
78
82
|
export { MetricsCollector, MetricsMiddleware } from './observability/metrics.js';
|
|
79
83
|
export { ContextLogger, ObsLoggingMiddleware } from './observability/context-logger.js';
|
|
80
84
|
|
|
81
|
-
export const VERSION = '0.1.
|
|
85
|
+
export const VERSION = '0.1.2';
|
|
@@ -12,7 +12,7 @@ export interface Logger {
|
|
|
12
12
|
|
|
13
13
|
const defaultLogger: Logger = {
|
|
14
14
|
info(message: string, extra?: Record<string, unknown>) {
|
|
15
|
-
console.
|
|
15
|
+
console.info(message, extra ?? '');
|
|
16
16
|
},
|
|
17
17
|
error(message: string, extra?: Record<string, unknown>) {
|
|
18
18
|
console.error(message, extra ?? '');
|
|
@@ -95,8 +95,8 @@ export class MiddlewareManager {
|
|
|
95
95
|
if (result !== null) {
|
|
96
96
|
return result;
|
|
97
97
|
}
|
|
98
|
-
} catch {
|
|
99
|
-
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.warn('[apcore:middleware] Error in onError handler, continuing:', e);
|
|
100
100
|
continue;
|
|
101
101
|
}
|
|
102
102
|
}
|