apcore-js 0.1.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 +11 -0
- package/.gitmessage +60 -0
- package/.pre-commit-config.yaml +28 -0
- package/CHANGELOG.md +47 -0
- package/CLAUDE.md +68 -0
- package/README.md +131 -0
- package/apcore-logo.svg +79 -0
- package/package.json +37 -0
- package/planning/acl-system/overview.md +54 -0
- package/planning/acl-system/plan.md +92 -0
- package/planning/acl-system/state.json +76 -0
- package/planning/acl-system/tasks/acl-core.md +226 -0
- package/planning/acl-system/tasks/acl-rule.md +92 -0
- package/planning/acl-system/tasks/conditional-rules.md +259 -0
- package/planning/acl-system/tasks/pattern-matching.md +152 -0
- package/planning/acl-system/tasks/yaml-loading.md +271 -0
- package/planning/core-executor/overview.md +53 -0
- package/planning/core-executor/plan.md +88 -0
- package/planning/core-executor/state.json +76 -0
- package/planning/core-executor/tasks/async-support.md +106 -0
- package/planning/core-executor/tasks/execution-pipeline.md +113 -0
- package/planning/core-executor/tasks/redaction.md +85 -0
- package/planning/core-executor/tasks/safety-checks.md +65 -0
- package/planning/core-executor/tasks/setup.md +75 -0
- package/planning/decorator-bindings/overview.md +62 -0
- package/planning/decorator-bindings/plan.md +104 -0
- package/planning/decorator-bindings/state.json +87 -0
- package/planning/decorator-bindings/tasks/binding-directory.md +79 -0
- package/planning/decorator-bindings/tasks/binding-loader.md +148 -0
- package/planning/decorator-bindings/tasks/explicit-schemas.md +85 -0
- package/planning/decorator-bindings/tasks/function-module.md +127 -0
- package/planning/decorator-bindings/tasks/module-factory.md +89 -0
- package/planning/decorator-bindings/tasks/schema-modes.md +142 -0
- package/planning/middleware-system/overview.md +48 -0
- package/planning/middleware-system/plan.md +102 -0
- package/planning/middleware-system/state.json +65 -0
- package/planning/middleware-system/tasks/adapters.md +170 -0
- package/planning/middleware-system/tasks/base.md +115 -0
- package/planning/middleware-system/tasks/logging-middleware.md +304 -0
- package/planning/middleware-system/tasks/manager.md +313 -0
- package/planning/observability/overview.md +53 -0
- package/planning/observability/plan.md +119 -0
- package/planning/observability/state.json +98 -0
- package/planning/observability/tasks/context-logger.md +201 -0
- package/planning/observability/tasks/exporters.md +121 -0
- package/planning/observability/tasks/metrics-collector.md +162 -0
- package/planning/observability/tasks/metrics-middleware.md +141 -0
- package/planning/observability/tasks/obs-logging-middleware.md +179 -0
- package/planning/observability/tasks/span-model.md +120 -0
- package/planning/observability/tasks/tracing-middleware.md +179 -0
- package/planning/overview.md +81 -0
- package/planning/registry-system/overview.md +57 -0
- package/planning/registry-system/plan.md +114 -0
- package/planning/registry-system/state.json +109 -0
- package/planning/registry-system/tasks/dependencies.md +157 -0
- package/planning/registry-system/tasks/entry-point.md +148 -0
- package/planning/registry-system/tasks/metadata.md +198 -0
- package/planning/registry-system/tasks/registry-core.md +323 -0
- package/planning/registry-system/tasks/scanner.md +172 -0
- package/planning/registry-system/tasks/schema-export.md +261 -0
- package/planning/registry-system/tasks/types.md +124 -0
- package/planning/registry-system/tasks/validation.md +177 -0
- package/planning/schema-system/overview.md +56 -0
- package/planning/schema-system/plan.md +121 -0
- package/planning/schema-system/state.json +98 -0
- package/planning/schema-system/tasks/exporter.md +153 -0
- package/planning/schema-system/tasks/loader.md +106 -0
- package/planning/schema-system/tasks/ref-resolver.md +133 -0
- package/planning/schema-system/tasks/strict-mode.md +140 -0
- package/planning/schema-system/tasks/typebox-generation.md +133 -0
- package/planning/schema-system/tasks/types-and-annotations.md +160 -0
- package/planning/schema-system/tasks/validator.md +149 -0
- package/src/acl.ts +188 -0
- package/src/bindings.ts +208 -0
- package/src/config.ts +24 -0
- package/src/context.ts +75 -0
- package/src/decorator.ts +110 -0
- package/src/errors.ts +369 -0
- package/src/executor.ts +348 -0
- package/src/index.ts +81 -0
- package/src/middleware/adapters.ts +54 -0
- package/src/middleware/base.ts +33 -0
- package/src/middleware/index.ts +6 -0
- package/src/middleware/logging.ts +103 -0
- package/src/middleware/manager.ts +105 -0
- package/src/module.ts +41 -0
- package/src/observability/context-logger.ts +201 -0
- package/src/observability/index.ts +4 -0
- package/src/observability/metrics.ts +212 -0
- package/src/observability/tracing.ts +187 -0
- package/src/registry/dependencies.ts +99 -0
- package/src/registry/entry-point.ts +64 -0
- package/src/registry/index.ts +8 -0
- package/src/registry/metadata.ts +111 -0
- package/src/registry/registry.ts +314 -0
- package/src/registry/scanner.ts +150 -0
- package/src/registry/schema-export.ts +177 -0
- package/src/registry/types.ts +32 -0
- package/src/registry/validation.ts +38 -0
- package/src/schema/annotations.ts +67 -0
- package/src/schema/exporter.ts +93 -0
- package/src/schema/index.ts +14 -0
- package/src/schema/loader.ts +270 -0
- package/src/schema/ref-resolver.ts +235 -0
- package/src/schema/strict.ts +128 -0
- package/src/schema/types.ts +73 -0
- package/src/schema/validator.ts +82 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/pattern.ts +30 -0
- package/tests/helpers.ts +30 -0
- package/tests/integration/test-acl-safety.test.ts +268 -0
- package/tests/integration/test-binding-executor.test.ts +194 -0
- package/tests/integration/test-e2e-flow.test.ts +117 -0
- package/tests/integration/test-error-propagation.test.ts +259 -0
- package/tests/integration/test-middleware-chain.test.ts +120 -0
- package/tests/integration/test-observability-integration.test.ts +438 -0
- package/tests/observability/test-context-logger.test.ts +123 -0
- package/tests/observability/test-metrics.test.ts +89 -0
- package/tests/observability/test-tracing.test.ts +131 -0
- package/tests/registry/test-dependencies.test.ts +70 -0
- package/tests/registry/test-entry-point.test.ts +133 -0
- package/tests/registry/test-metadata.test.ts +265 -0
- package/tests/registry/test-registry.test.ts +140 -0
- package/tests/registry/test-scanner.test.ts +257 -0
- package/tests/registry/test-schema-export.test.ts +224 -0
- package/tests/registry/test-validation.test.ts +75 -0
- package/tests/schema/test-loader.test.ts +97 -0
- package/tests/schema/test-ref-resolver.test.ts +105 -0
- package/tests/schema/test-strict.test.ts +139 -0
- package/tests/schema/test-validator.test.ts +64 -0
- package/tests/test-acl.test.ts +206 -0
- package/tests/test-bindings.test.ts +227 -0
- package/tests/test-config.test.ts +76 -0
- package/tests/test-context.test.ts +151 -0
- package/tests/test-decorator.test.ts +173 -0
- package/tests/test-errors.test.ts +204 -0
- package/tests/test-executor.test.ts +252 -0
- package/tests/test-middleware-manager.test.ts +185 -0
- package/tests/test-middleware.test.ts +86 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +18 -0
package/src/acl.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACL (Access Control List) types and implementation for apcore.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import yaml from 'js-yaml';
|
|
7
|
+
import type { Context } from './context.js';
|
|
8
|
+
import { ACLRuleError, ConfigNotFoundError } from './errors.js';
|
|
9
|
+
import { matchPattern } from './utils/pattern.js';
|
|
10
|
+
|
|
11
|
+
export interface ACLRule {
|
|
12
|
+
callers: string[];
|
|
13
|
+
targets: string[];
|
|
14
|
+
effect: string;
|
|
15
|
+
description: string;
|
|
16
|
+
conditions?: Record<string, unknown> | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ACL {
|
|
20
|
+
private _rules: ACLRule[];
|
|
21
|
+
private _defaultEffect: string;
|
|
22
|
+
private _yamlPath: string | null = null;
|
|
23
|
+
debug: boolean = false;
|
|
24
|
+
|
|
25
|
+
constructor(rules: ACLRule[], defaultEffect: string = 'deny') {
|
|
26
|
+
this._rules = [...rules];
|
|
27
|
+
this._defaultEffect = defaultEffect;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static load(yamlPath: string): ACL {
|
|
31
|
+
if (!existsSync(yamlPath)) {
|
|
32
|
+
throw new ConfigNotFoundError(yamlPath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let data: unknown;
|
|
36
|
+
try {
|
|
37
|
+
const content = readFileSync(yamlPath, 'utf-8');
|
|
38
|
+
data = yaml.load(content);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (e instanceof ConfigNotFoundError) throw e;
|
|
41
|
+
throw new ACLRuleError(`Invalid YAML in ${yamlPath}: ${e}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
45
|
+
throw new ACLRuleError(`ACL config must be a mapping, got ${typeof data}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const dataObj = data as Record<string, unknown>;
|
|
49
|
+
if (!('rules' in dataObj)) {
|
|
50
|
+
throw new ACLRuleError("ACL config missing required 'rules' key");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const rawRules = dataObj['rules'];
|
|
54
|
+
if (!Array.isArray(rawRules)) {
|
|
55
|
+
throw new ACLRuleError(`'rules' must be a list, got ${typeof rawRules}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const defaultEffect = (dataObj['default_effect'] as string) ?? 'deny';
|
|
59
|
+
const rules: ACLRule[] = [];
|
|
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
|
+
}
|
|
97
|
+
|
|
98
|
+
const acl = new ACL(rules, defaultEffect);
|
|
99
|
+
acl._yamlPath = yamlPath;
|
|
100
|
+
return acl;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
check(callerId: string | null, targetId: string, context?: Context | null): boolean {
|
|
104
|
+
const effectiveCaller = callerId === null ? '@external' : callerId;
|
|
105
|
+
const rules = [...this._rules];
|
|
106
|
+
const defaultEffect = this._defaultEffect;
|
|
107
|
+
|
|
108
|
+
for (const rule of rules) {
|
|
109
|
+
if (this._matchesRule(rule, effectiveCaller, targetId, context ?? null)) {
|
|
110
|
+
return rule.effect === 'allow';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return defaultEffect === 'allow';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private _matchPattern(pattern: string, value: string, context: Context | null): boolean {
|
|
118
|
+
if (pattern === '@external') return value === '@external';
|
|
119
|
+
if (pattern === '@system') {
|
|
120
|
+
return context !== null && context.identity !== null && context.identity.type === 'system';
|
|
121
|
+
}
|
|
122
|
+
return matchPattern(pattern, value);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private _matchesRule(rule: ACLRule, caller: string, target: string, context: Context | null): boolean {
|
|
126
|
+
const callerMatch = rule.callers.some((p) => this._matchPattern(p, caller, context));
|
|
127
|
+
if (!callerMatch) return false;
|
|
128
|
+
|
|
129
|
+
const targetMatch = rule.targets.some((p) => this._matchPattern(p, target, context));
|
|
130
|
+
if (!targetMatch) return false;
|
|
131
|
+
|
|
132
|
+
if (rule.conditions != null) {
|
|
133
|
+
if (!this._checkConditions(rule.conditions, context)) return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private _checkConditions(conditions: Record<string, unknown>, context: Context | null): boolean {
|
|
140
|
+
if (context === null) return false;
|
|
141
|
+
|
|
142
|
+
if ('identity_types' in conditions) {
|
|
143
|
+
const types = conditions['identity_types'] as string[];
|
|
144
|
+
if (context.identity === null || !types.includes(context.identity.type)) return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if ('roles' in conditions) {
|
|
148
|
+
const roles = conditions['roles'] as string[];
|
|
149
|
+
if (context.identity === null) return false;
|
|
150
|
+
const identityRoles = new Set(context.identity.roles);
|
|
151
|
+
if (!roles.some((r) => identityRoles.has(r))) return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if ('max_call_depth' in conditions) {
|
|
155
|
+
const maxDepth = conditions['max_call_depth'] as number;
|
|
156
|
+
if (context.callChain.length > maxDepth) return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
addRule(rule: ACLRule): void {
|
|
163
|
+
this._rules.unshift(rule);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
removeRule(callers: string[], targets: string[]): boolean {
|
|
167
|
+
for (let i = 0; i < this._rules.length; i++) {
|
|
168
|
+
const rule = this._rules[i];
|
|
169
|
+
if (
|
|
170
|
+
JSON.stringify(rule.callers) === JSON.stringify(callers) &&
|
|
171
|
+
JSON.stringify(rule.targets) === JSON.stringify(targets)
|
|
172
|
+
) {
|
|
173
|
+
this._rules.splice(i, 1);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
reload(): void {
|
|
181
|
+
if (this._yamlPath === null) {
|
|
182
|
+
throw new ACLRuleError('Cannot reload: ACL was not loaded from a YAML file');
|
|
183
|
+
}
|
|
184
|
+
const reloaded = ACL.load(this._yamlPath);
|
|
185
|
+
this._rules = reloaded._rules;
|
|
186
|
+
this._defaultEffect = reloaded._defaultEffect;
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/bindings.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML binding loader for zero-code-modification module integration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { resolve, dirname, join, basename } from 'node:path';
|
|
7
|
+
import { Type, type TSchema } from '@sinclair/typebox';
|
|
8
|
+
import yaml from 'js-yaml';
|
|
9
|
+
import { FunctionModule } from './decorator.js';
|
|
10
|
+
import {
|
|
11
|
+
BindingCallableNotFoundError,
|
|
12
|
+
BindingFileInvalidError,
|
|
13
|
+
BindingInvalidTargetError,
|
|
14
|
+
BindingModuleNotFoundError,
|
|
15
|
+
BindingNotCallableError,
|
|
16
|
+
BindingSchemaMissingError,
|
|
17
|
+
} from './errors.js';
|
|
18
|
+
import type { Registry } from './registry/registry.js';
|
|
19
|
+
import { jsonSchemaToTypeBox } from './schema/loader.js';
|
|
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
|
+
export class BindingLoader {
|
|
35
|
+
async loadBindings(filePath: string, registry: Registry): Promise<FunctionModule[]> {
|
|
36
|
+
const bindingFileDir = dirname(filePath);
|
|
37
|
+
|
|
38
|
+
let content: string;
|
|
39
|
+
try {
|
|
40
|
+
content = readFileSync(filePath, 'utf-8');
|
|
41
|
+
} catch (e) {
|
|
42
|
+
throw new BindingFileInvalidError(filePath, String(e));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let data: unknown;
|
|
46
|
+
try {
|
|
47
|
+
data = yaml.load(content);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
throw new BindingFileInvalidError(filePath, `YAML parse error: ${e}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (data === null || data === undefined) {
|
|
53
|
+
throw new BindingFileInvalidError(filePath, 'File is empty');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const dataObj = data as Record<string, unknown>;
|
|
57
|
+
if (!('bindings' in dataObj)) {
|
|
58
|
+
throw new BindingFileInvalidError(filePath, "Missing 'bindings' key");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const bindings = dataObj['bindings'];
|
|
62
|
+
if (!Array.isArray(bindings)) {
|
|
63
|
+
throw new BindingFileInvalidError(filePath, "'bindings' must be a list");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const results: FunctionModule[] = [];
|
|
67
|
+
for (const entry of bindings) {
|
|
68
|
+
const entryObj = entry as Record<string, unknown>;
|
|
69
|
+
if (!('module_id' in entryObj)) {
|
|
70
|
+
throw new BindingFileInvalidError(filePath, "Binding entry missing 'module_id'");
|
|
71
|
+
}
|
|
72
|
+
if (!('target' in entryObj)) {
|
|
73
|
+
throw new BindingFileInvalidError(filePath, "Binding entry missing 'target'");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const fm = await this._createModuleFromBinding(entryObj, bindingFileDir);
|
|
77
|
+
registry.register(entryObj['module_id'] as string, fm);
|
|
78
|
+
results.push(fm);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async loadBindingDir(
|
|
85
|
+
dirPath: string,
|
|
86
|
+
registry: Registry,
|
|
87
|
+
pattern: string = '*.binding.yaml',
|
|
88
|
+
): Promise<FunctionModule[]> {
|
|
89
|
+
if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) {
|
|
90
|
+
throw new BindingFileInvalidError(dirPath, 'Directory does not exist');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const files = readdirSync(dirPath)
|
|
94
|
+
.filter((f) => {
|
|
95
|
+
// Simple glob matching for *.binding.yaml
|
|
96
|
+
const suffix = pattern.replace('*', '');
|
|
97
|
+
return f.endsWith(suffix);
|
|
98
|
+
})
|
|
99
|
+
.sort();
|
|
100
|
+
|
|
101
|
+
const results: FunctionModule[] = [];
|
|
102
|
+
for (const f of files) {
|
|
103
|
+
const fms = await this.loadBindings(join(dirPath, f), registry);
|
|
104
|
+
results.push(...fms);
|
|
105
|
+
}
|
|
106
|
+
return results;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async resolveTarget(targetString: string): Promise<(...args: unknown[]) => unknown> {
|
|
110
|
+
if (!targetString.includes(':')) {
|
|
111
|
+
throw new BindingInvalidTargetError(targetString);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const [modulePath, callableName] = targetString.split(':', 2);
|
|
115
|
+
|
|
116
|
+
let mod: Record<string, unknown>;
|
|
117
|
+
try {
|
|
118
|
+
mod = await import(modulePath);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
throw new BindingModuleNotFoundError(modulePath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (callableName.includes('.')) {
|
|
124
|
+
const [className, methodName] = callableName.split('.', 2);
|
|
125
|
+
const cls = mod[className];
|
|
126
|
+
if (cls == null) {
|
|
127
|
+
throw new BindingCallableNotFoundError(className, modulePath);
|
|
128
|
+
}
|
|
129
|
+
let instance: Record<string, unknown>;
|
|
130
|
+
try {
|
|
131
|
+
instance = new (cls as new () => Record<string, unknown>)();
|
|
132
|
+
} catch {
|
|
133
|
+
throw new BindingCallableNotFoundError(callableName, modulePath);
|
|
134
|
+
}
|
|
135
|
+
const method = instance[methodName];
|
|
136
|
+
if (method == null) {
|
|
137
|
+
throw new BindingCallableNotFoundError(callableName, modulePath);
|
|
138
|
+
}
|
|
139
|
+
if (typeof method !== 'function') {
|
|
140
|
+
throw new BindingNotCallableError(targetString);
|
|
141
|
+
}
|
|
142
|
+
return method.bind(instance) as (...args: unknown[]) => unknown;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const result = mod[callableName];
|
|
146
|
+
if (result == null) {
|
|
147
|
+
throw new BindingCallableNotFoundError(callableName, modulePath);
|
|
148
|
+
}
|
|
149
|
+
if (typeof result !== 'function') {
|
|
150
|
+
throw new BindingNotCallableError(targetString);
|
|
151
|
+
}
|
|
152
|
+
return result as (...args: unknown[]) => unknown;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async _createModuleFromBinding(
|
|
156
|
+
binding: Record<string, unknown>,
|
|
157
|
+
bindingFileDir: string,
|
|
158
|
+
): Promise<FunctionModule> {
|
|
159
|
+
const func = await this.resolveTarget(binding['target'] as string);
|
|
160
|
+
const moduleId = binding['module_id'] as string;
|
|
161
|
+
|
|
162
|
+
let inputSchema: TSchema;
|
|
163
|
+
let outputSchema: TSchema;
|
|
164
|
+
|
|
165
|
+
if ('input_schema' in binding || 'output_schema' in binding) {
|
|
166
|
+
const inputSchemaDict = (binding['input_schema'] as Record<string, unknown>) ?? {};
|
|
167
|
+
const outputSchemaDict = (binding['output_schema'] as Record<string, unknown>) ?? {};
|
|
168
|
+
inputSchema = buildSchemaFromJsonSchema(inputSchemaDict);
|
|
169
|
+
outputSchema = buildSchemaFromJsonSchema(outputSchemaDict);
|
|
170
|
+
} else if ('schema_ref' in binding) {
|
|
171
|
+
const refPath = resolve(bindingFileDir, binding['schema_ref'] as string);
|
|
172
|
+
if (!existsSync(refPath)) {
|
|
173
|
+
throw new BindingFileInvalidError(refPath, 'Schema reference file not found');
|
|
174
|
+
}
|
|
175
|
+
let refData: Record<string, unknown>;
|
|
176
|
+
try {
|
|
177
|
+
refData = (yaml.load(readFileSync(refPath, 'utf-8')) as Record<string, unknown>) ?? {};
|
|
178
|
+
} catch (e) {
|
|
179
|
+
throw new BindingFileInvalidError(refPath, `YAML parse error: ${e}`);
|
|
180
|
+
}
|
|
181
|
+
inputSchema = buildSchemaFromJsonSchema(
|
|
182
|
+
(refData['input_schema'] as Record<string, unknown>) ?? {},
|
|
183
|
+
);
|
|
184
|
+
outputSchema = buildSchemaFromJsonSchema(
|
|
185
|
+
(refData['output_schema'] as Record<string, unknown>) ?? {},
|
|
186
|
+
);
|
|
187
|
+
} else {
|
|
188
|
+
// No schema, use permissive
|
|
189
|
+
inputSchema = Type.Record(Type.String(), Type.Unknown());
|
|
190
|
+
outputSchema = Type.Record(Type.String(), Type.Unknown());
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return new FunctionModule({
|
|
194
|
+
execute: async (inputs, context) => {
|
|
195
|
+
const result = await func(inputs, context);
|
|
196
|
+
if (result === null || result === undefined) return {};
|
|
197
|
+
if (typeof result === 'object' && !Array.isArray(result)) return result as Record<string, unknown>;
|
|
198
|
+
return { result };
|
|
199
|
+
},
|
|
200
|
+
moduleId,
|
|
201
|
+
inputSchema,
|
|
202
|
+
outputSchema,
|
|
203
|
+
description: (binding['description'] as string) ?? undefined,
|
|
204
|
+
tags: (binding['tags'] as string[]) ?? null,
|
|
205
|
+
version: (binding['version'] as string) ?? '1.0.0',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration accessor with dot-path key support.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class Config {
|
|
6
|
+
private _data: Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
constructor(data?: Record<string, unknown>) {
|
|
9
|
+
this._data = data ?? {};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
get(key: string, defaultValue?: unknown): unknown {
|
|
13
|
+
const parts = key.split('.');
|
|
14
|
+
let current: unknown = this._data;
|
|
15
|
+
for (const part of parts) {
|
|
16
|
+
if (current !== null && typeof current === 'object' && part in (current as Record<string, unknown>)) {
|
|
17
|
+
current = (current as Record<string, unknown>)[part];
|
|
18
|
+
} else {
|
|
19
|
+
return defaultValue;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return current;
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution context, identity, and context creation.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface Identity {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly type: string;
|
|
8
|
+
readonly roles: readonly string[];
|
|
9
|
+
readonly attrs: Readonly<Record<string, unknown>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createIdentity(
|
|
13
|
+
id: string,
|
|
14
|
+
type: string = 'user',
|
|
15
|
+
roles: string[] = [],
|
|
16
|
+
attrs: Record<string, unknown> = {},
|
|
17
|
+
): Identity {
|
|
18
|
+
return Object.freeze({ id, type, roles: Object.freeze([...roles]), attrs: Object.freeze({ ...attrs }) });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Context {
|
|
22
|
+
readonly traceId: string;
|
|
23
|
+
readonly callerId: string | null;
|
|
24
|
+
readonly callChain: string[];
|
|
25
|
+
readonly executor: unknown;
|
|
26
|
+
readonly identity: Identity | null;
|
|
27
|
+
redactedInputs: Record<string, unknown> | null;
|
|
28
|
+
readonly data: Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
traceId: string,
|
|
32
|
+
callerId: string | null = null,
|
|
33
|
+
callChain: string[] = [],
|
|
34
|
+
executor: unknown = null,
|
|
35
|
+
identity: Identity | null = null,
|
|
36
|
+
redactedInputs: Record<string, unknown> | null = null,
|
|
37
|
+
data: Record<string, unknown> = {},
|
|
38
|
+
) {
|
|
39
|
+
this.traceId = traceId;
|
|
40
|
+
this.callerId = callerId;
|
|
41
|
+
this.callChain = callChain;
|
|
42
|
+
this.executor = executor;
|
|
43
|
+
this.identity = identity;
|
|
44
|
+
this.redactedInputs = redactedInputs;
|
|
45
|
+
this.data = data;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static create(
|
|
49
|
+
executor: unknown = null,
|
|
50
|
+
identity: Identity | null = null,
|
|
51
|
+
data?: Record<string, unknown>,
|
|
52
|
+
): Context {
|
|
53
|
+
return new Context(
|
|
54
|
+
crypto.randomUUID(),
|
|
55
|
+
null,
|
|
56
|
+
[],
|
|
57
|
+
executor,
|
|
58
|
+
identity,
|
|
59
|
+
null,
|
|
60
|
+
data ?? {},
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
child(targetModuleId: string): Context {
|
|
65
|
+
return new Context(
|
|
66
|
+
this.traceId,
|
|
67
|
+
this.callChain.length > 0 ? this.callChain[this.callChain.length - 1] : null,
|
|
68
|
+
[...this.callChain, targetModuleId],
|
|
69
|
+
this.executor,
|
|
70
|
+
this.identity,
|
|
71
|
+
null,
|
|
72
|
+
this.data, // shared reference
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/decorator.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module factory, FunctionModule wrapper.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript version uses explicit TypeBox schemas instead of runtime type inference.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { TSchema } from '@sinclair/typebox';
|
|
8
|
+
import type { Context } from './context.js';
|
|
9
|
+
import type { ModuleAnnotations, ModuleExample } from './module.js';
|
|
10
|
+
|
|
11
|
+
export function normalizeResult(result: unknown): Record<string, unknown> {
|
|
12
|
+
if (result === null || result === undefined) return {};
|
|
13
|
+
if (typeof result === 'object' && !Array.isArray(result)) return result as Record<string, unknown>;
|
|
14
|
+
return { result };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class FunctionModule {
|
|
18
|
+
readonly moduleId: string;
|
|
19
|
+
readonly inputSchema: TSchema;
|
|
20
|
+
readonly outputSchema: TSchema;
|
|
21
|
+
readonly description: string;
|
|
22
|
+
readonly documentation: string | null;
|
|
23
|
+
readonly tags: string[] | null;
|
|
24
|
+
readonly version: string;
|
|
25
|
+
readonly annotations: ModuleAnnotations | null;
|
|
26
|
+
readonly metadata: Record<string, unknown> | null;
|
|
27
|
+
readonly examples: ModuleExample[] | null;
|
|
28
|
+
|
|
29
|
+
private _executeFn: (inputs: Record<string, unknown>, context: Context) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
constructor(options: {
|
|
32
|
+
execute: (inputs: Record<string, unknown>, context: Context) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
33
|
+
moduleId: string;
|
|
34
|
+
inputSchema: TSchema;
|
|
35
|
+
outputSchema: TSchema;
|
|
36
|
+
description?: string;
|
|
37
|
+
documentation?: string | null;
|
|
38
|
+
tags?: string[] | null;
|
|
39
|
+
version?: string;
|
|
40
|
+
annotations?: ModuleAnnotations | null;
|
|
41
|
+
metadata?: Record<string, unknown> | null;
|
|
42
|
+
examples?: ModuleExample[] | null;
|
|
43
|
+
}) {
|
|
44
|
+
this.moduleId = options.moduleId;
|
|
45
|
+
this.inputSchema = options.inputSchema;
|
|
46
|
+
this.outputSchema = options.outputSchema;
|
|
47
|
+
this.description = options.description ?? `Module ${options.moduleId}`;
|
|
48
|
+
this.documentation = options.documentation ?? null;
|
|
49
|
+
this.tags = options.tags ?? null;
|
|
50
|
+
this.version = options.version ?? '1.0.0';
|
|
51
|
+
this.annotations = options.annotations ?? null;
|
|
52
|
+
this.metadata = options.metadata ?? null;
|
|
53
|
+
this.examples = options.examples ?? null;
|
|
54
|
+
this._executeFn = options.execute;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async execute(inputs: Record<string, unknown>, context: Context): Promise<Record<string, unknown>> {
|
|
58
|
+
const result = await this._executeFn(inputs, context);
|
|
59
|
+
return normalizeResult(result);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function makeAutoId(name: string): string {
|
|
64
|
+
let raw = name.toLowerCase();
|
|
65
|
+
raw = raw.replace(/[^a-z0-9_.]/g, '_');
|
|
66
|
+
const segments = raw.split('.');
|
|
67
|
+
return segments
|
|
68
|
+
.map((s) => (s && s[0] >= '0' && s[0] <= '9' ? '_' + s : s))
|
|
69
|
+
.join('.');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a FunctionModule from options. TypeScript version requires explicit schemas.
|
|
74
|
+
*/
|
|
75
|
+
export function module(options: {
|
|
76
|
+
id?: string;
|
|
77
|
+
inputSchema: TSchema;
|
|
78
|
+
outputSchema: TSchema;
|
|
79
|
+
description?: string;
|
|
80
|
+
documentation?: string | null;
|
|
81
|
+
annotations?: ModuleAnnotations | null;
|
|
82
|
+
tags?: string[] | null;
|
|
83
|
+
version?: string;
|
|
84
|
+
metadata?: Record<string, unknown> | null;
|
|
85
|
+
examples?: ModuleExample[] | null;
|
|
86
|
+
execute: (inputs: Record<string, unknown>, context: Context) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
87
|
+
registry?: { register(moduleId: string, module: unknown): void } | null;
|
|
88
|
+
}): FunctionModule {
|
|
89
|
+
const moduleId = options.id ?? makeAutoId('anonymous');
|
|
90
|
+
|
|
91
|
+
const fm = new FunctionModule({
|
|
92
|
+
execute: options.execute,
|
|
93
|
+
moduleId,
|
|
94
|
+
inputSchema: options.inputSchema,
|
|
95
|
+
outputSchema: options.outputSchema,
|
|
96
|
+
description: options.description,
|
|
97
|
+
documentation: options.documentation,
|
|
98
|
+
tags: options.tags,
|
|
99
|
+
version: options.version,
|
|
100
|
+
annotations: options.annotations,
|
|
101
|
+
metadata: options.metadata,
|
|
102
|
+
examples: options.examples,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (options.registry) {
|
|
106
|
+
options.registry.register(fm.moduleId, fm);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return fm;
|
|
110
|
+
}
|