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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Type } from '@sinclair/typebox';
|
|
3
|
+
import { Value } from '@sinclair/typebox/value';
|
|
4
|
+
import { jsonSchemaToTypeBox } from '../../src/schema/loader.js';
|
|
5
|
+
|
|
6
|
+
describe('jsonSchemaToTypeBox', () => {
|
|
7
|
+
it('converts string type', () => {
|
|
8
|
+
const schema = jsonSchemaToTypeBox({ type: 'string' });
|
|
9
|
+
expect(Value.Check(schema, 'hello')).toBe(true);
|
|
10
|
+
expect(Value.Check(schema, 123)).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('converts integer type', () => {
|
|
14
|
+
const schema = jsonSchemaToTypeBox({ type: 'integer' });
|
|
15
|
+
expect(Value.Check(schema, 42)).toBe(true);
|
|
16
|
+
expect(Value.Check(schema, 3.14)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('converts number type', () => {
|
|
20
|
+
const schema = jsonSchemaToTypeBox({ type: 'number' });
|
|
21
|
+
expect(Value.Check(schema, 3.14)).toBe(true);
|
|
22
|
+
expect(Value.Check(schema, 'abc')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('converts boolean type', () => {
|
|
26
|
+
const schema = jsonSchemaToTypeBox({ type: 'boolean' });
|
|
27
|
+
expect(Value.Check(schema, true)).toBe(true);
|
|
28
|
+
expect(Value.Check(schema, 'true')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('converts null type', () => {
|
|
32
|
+
const schema = jsonSchemaToTypeBox({ type: 'null' });
|
|
33
|
+
expect(Value.Check(schema, null)).toBe(true);
|
|
34
|
+
expect(Value.Check(schema, undefined)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('converts object with properties', () => {
|
|
38
|
+
const schema = jsonSchemaToTypeBox({
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
name: { type: 'string' },
|
|
42
|
+
age: { type: 'integer' },
|
|
43
|
+
},
|
|
44
|
+
required: ['name'],
|
|
45
|
+
});
|
|
46
|
+
expect(Value.Check(schema, { name: 'Alice', age: 30 })).toBe(true);
|
|
47
|
+
expect(Value.Check(schema, { name: 'Alice' })).toBe(true);
|
|
48
|
+
expect(Value.Check(schema, { age: 30 })).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('converts array type', () => {
|
|
52
|
+
const schema = jsonSchemaToTypeBox({
|
|
53
|
+
type: 'array',
|
|
54
|
+
items: { type: 'string' },
|
|
55
|
+
});
|
|
56
|
+
expect(Value.Check(schema, ['a', 'b'])).toBe(true);
|
|
57
|
+
expect(Value.Check(schema, [1, 2])).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('converts enum', () => {
|
|
61
|
+
const schema = jsonSchemaToTypeBox({ enum: ['a', 'b', 'c'] });
|
|
62
|
+
expect(Value.Check(schema, 'a')).toBe(true);
|
|
63
|
+
expect(Value.Check(schema, 'd')).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('converts anyOf', () => {
|
|
67
|
+
const schema = jsonSchemaToTypeBox({
|
|
68
|
+
anyOf: [{ type: 'string' }, { type: 'number' }],
|
|
69
|
+
});
|
|
70
|
+
expect(Value.Check(schema, 'hello')).toBe(true);
|
|
71
|
+
expect(Value.Check(schema, 42)).toBe(true);
|
|
72
|
+
expect(Value.Check(schema, true)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns Unknown for unrecognized schema', () => {
|
|
76
|
+
const schema = jsonSchemaToTypeBox({});
|
|
77
|
+
expect(Value.Check(schema, 'anything')).toBe(true);
|
|
78
|
+
expect(Value.Check(schema, 42)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('converts string with constraints', () => {
|
|
82
|
+
const schema = jsonSchemaToTypeBox({ type: 'string', minLength: 2, maxLength: 5 });
|
|
83
|
+
expect(Value.Check(schema, 'ab')).toBe(true);
|
|
84
|
+
expect(Value.Check(schema, 'a')).toBe(false);
|
|
85
|
+
expect(Value.Check(schema, 'abcdef')).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('converts object without properties', () => {
|
|
89
|
+
const schema = jsonSchemaToTypeBox({ type: 'object' });
|
|
90
|
+
expect(Value.Check(schema, { any: 'value' })).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('converts array without items', () => {
|
|
94
|
+
const schema = jsonSchemaToTypeBox({ type: 'array' });
|
|
95
|
+
expect(Value.Check(schema, [1, 'two', true])).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RefResolver } from '../../src/schema/ref-resolver.js';
|
|
3
|
+
import { SchemaCircularRefError, SchemaNotFoundError } from '../../src/errors.js';
|
|
4
|
+
|
|
5
|
+
describe('RefResolver', () => {
|
|
6
|
+
it('resolves local $ref', () => {
|
|
7
|
+
const resolver = new RefResolver('/tmp/schemas');
|
|
8
|
+
const schema = {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
name: { $ref: '#/definitions/NameType' },
|
|
12
|
+
},
|
|
13
|
+
definitions: {
|
|
14
|
+
NameType: { type: 'string' },
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
const resolved = resolver.resolve(schema);
|
|
18
|
+
expect((resolved['properties'] as Record<string, unknown>)['name']).toEqual({ type: 'string' });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('detects circular references', () => {
|
|
22
|
+
const resolver = new RefResolver('/tmp/schemas');
|
|
23
|
+
const schema = {
|
|
24
|
+
definitions: {
|
|
25
|
+
A: { $ref: '#/definitions/B' },
|
|
26
|
+
B: { $ref: '#/definitions/A' },
|
|
27
|
+
},
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
x: { $ref: '#/definitions/A' },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
expect(() => resolver.resolve(schema)).toThrow(SchemaCircularRefError);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('resolves nested $ref', () => {
|
|
37
|
+
const resolver = new RefResolver('/tmp/schemas');
|
|
38
|
+
const schema = {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
user: { $ref: '#/definitions/User' },
|
|
42
|
+
},
|
|
43
|
+
definitions: {
|
|
44
|
+
User: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
name: { type: 'string' },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const resolved = resolver.resolve(schema);
|
|
53
|
+
const user = (resolved['properties'] as Record<string, unknown>)['user'] as Record<string, unknown>;
|
|
54
|
+
expect(user['type']).toBe('object');
|
|
55
|
+
expect(user['properties']).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('throws SchemaNotFoundError for missing pointer segment', () => {
|
|
59
|
+
const resolver = new RefResolver('/tmp/schemas');
|
|
60
|
+
const schema = {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
x: { $ref: '#/definitions/Missing' },
|
|
64
|
+
},
|
|
65
|
+
definitions: {},
|
|
66
|
+
};
|
|
67
|
+
expect(() => resolver.resolve(schema)).toThrow(SchemaNotFoundError);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('clearCache works', () => {
|
|
71
|
+
const resolver = new RefResolver('/tmp/schemas');
|
|
72
|
+
resolver.clearCache();
|
|
73
|
+
// Should not throw
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('respects max depth', () => {
|
|
77
|
+
const resolver = new RefResolver('/tmp/schemas', 2);
|
|
78
|
+
// Properties must come before definitions so the $ref chain isn't
|
|
79
|
+
// collapsed by in-place resolution of definitions first.
|
|
80
|
+
const schema = {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
x: { $ref: '#/definitions/A' },
|
|
84
|
+
},
|
|
85
|
+
definitions: {
|
|
86
|
+
A: { $ref: '#/definitions/B' },
|
|
87
|
+
B: { $ref: '#/definitions/C' },
|
|
88
|
+
C: { type: 'string' },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
expect(() => resolver.resolve(schema)).toThrow(SchemaCircularRefError);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('resolves schema without $ref unchanged', () => {
|
|
95
|
+
const resolver = new RefResolver('/tmp/schemas');
|
|
96
|
+
const schema = {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
name: { type: 'string' },
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const resolved = resolver.resolve(schema);
|
|
103
|
+
expect(resolved).toEqual(schema);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { toStrictSchema, applyLlmDescriptions, stripExtensions } from '../../src/schema/strict.js';
|
|
3
|
+
|
|
4
|
+
describe('toStrictSchema', () => {
|
|
5
|
+
it('adds additionalProperties: false', () => {
|
|
6
|
+
const schema = {
|
|
7
|
+
type: 'object',
|
|
8
|
+
properties: {
|
|
9
|
+
name: { type: 'string' },
|
|
10
|
+
},
|
|
11
|
+
required: ['name'],
|
|
12
|
+
};
|
|
13
|
+
const strict = toStrictSchema(schema);
|
|
14
|
+
expect(strict['additionalProperties']).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('makes all properties required', () => {
|
|
18
|
+
const schema = {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
name: { type: 'string' },
|
|
22
|
+
age: { type: 'integer' },
|
|
23
|
+
},
|
|
24
|
+
required: ['name'],
|
|
25
|
+
};
|
|
26
|
+
const strict = toStrictSchema(schema);
|
|
27
|
+
expect(strict['required']).toEqual(['age', 'name']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('makes optional properties nullable', () => {
|
|
31
|
+
const schema = {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
name: { type: 'string' },
|
|
35
|
+
age: { type: 'integer' },
|
|
36
|
+
},
|
|
37
|
+
required: ['name'],
|
|
38
|
+
};
|
|
39
|
+
const strict = toStrictSchema(schema);
|
|
40
|
+
const props = strict['properties'] as Record<string, Record<string, unknown>>;
|
|
41
|
+
expect(props['age']['type']).toEqual(['integer', 'null']);
|
|
42
|
+
expect(props['name']['type']).toBe('string');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not modify original schema', () => {
|
|
46
|
+
const schema: Record<string, unknown> = {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: { name: { type: 'string' } },
|
|
49
|
+
};
|
|
50
|
+
toStrictSchema(schema);
|
|
51
|
+
expect(schema['additionalProperties']).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('strips x- extensions', () => {
|
|
55
|
+
const schema = {
|
|
56
|
+
type: 'object',
|
|
57
|
+
'x-sensitive': true,
|
|
58
|
+
properties: {
|
|
59
|
+
name: { type: 'string', 'x-llm-description': 'Name field' },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
const strict = toStrictSchema(schema);
|
|
63
|
+
expect(strict['x-sensitive']).toBeUndefined();
|
|
64
|
+
const props = strict['properties'] as Record<string, Record<string, unknown>>;
|
|
65
|
+
expect(props['name']['x-llm-description']).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('strips default values', () => {
|
|
69
|
+
const schema = {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
name: { type: 'string', default: 'world' },
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const strict = toStrictSchema(schema);
|
|
76
|
+
const props = strict['properties'] as Record<string, Record<string, unknown>>;
|
|
77
|
+
expect(props['name']['default']).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('applyLlmDescriptions', () => {
|
|
82
|
+
it('replaces description with x-llm-description', () => {
|
|
83
|
+
const schema = {
|
|
84
|
+
description: 'Original',
|
|
85
|
+
'x-llm-description': 'LLM version',
|
|
86
|
+
properties: {
|
|
87
|
+
name: {
|
|
88
|
+
type: 'string',
|
|
89
|
+
description: 'Name',
|
|
90
|
+
'x-llm-description': 'User name for LLM',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
applyLlmDescriptions(schema);
|
|
95
|
+
expect(schema['description']).toBe('LLM version');
|
|
96
|
+
expect((schema['properties'] as Record<string, Record<string, unknown>>)['name']['description']).toBe('User name for LLM');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('does not modify without x-llm-description', () => {
|
|
100
|
+
const schema = { description: 'Original' };
|
|
101
|
+
applyLlmDescriptions(schema);
|
|
102
|
+
expect(schema['description']).toBe('Original');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('stripExtensions', () => {
|
|
107
|
+
it('removes x- prefixed keys', () => {
|
|
108
|
+
const schema: Record<string, unknown> = {
|
|
109
|
+
type: 'string',
|
|
110
|
+
'x-custom': 'value',
|
|
111
|
+
'x-another': 123,
|
|
112
|
+
};
|
|
113
|
+
stripExtensions(schema);
|
|
114
|
+
expect(schema['x-custom']).toBeUndefined();
|
|
115
|
+
expect(schema['x-another']).toBeUndefined();
|
|
116
|
+
expect(schema['type']).toBe('string');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('removes default key', () => {
|
|
120
|
+
const schema: Record<string, unknown> = {
|
|
121
|
+
type: 'string',
|
|
122
|
+
default: 'hello',
|
|
123
|
+
};
|
|
124
|
+
stripExtensions(schema);
|
|
125
|
+
expect(schema['default']).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('handles nested structures', () => {
|
|
129
|
+
const schema: Record<string, unknown> = {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: {
|
|
132
|
+
name: { type: 'string', 'x-sensitive': true },
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
stripExtensions(schema);
|
|
136
|
+
const props = schema['properties'] as Record<string, Record<string, unknown>>;
|
|
137
|
+
expect(props['name']['x-sensitive']).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Type } from '@sinclair/typebox';
|
|
3
|
+
import { SchemaValidator } from '../../src/schema/validator.js';
|
|
4
|
+
import { SchemaValidationError } from '../../src/errors.js';
|
|
5
|
+
|
|
6
|
+
describe('SchemaValidator', () => {
|
|
7
|
+
it('validates correct data', () => {
|
|
8
|
+
const validator = new SchemaValidator();
|
|
9
|
+
const schema = Type.Object({ name: Type.String() });
|
|
10
|
+
const result = validator.validate({ name: 'Alice' }, schema);
|
|
11
|
+
expect(result.valid).toBe(true);
|
|
12
|
+
expect(result.errors).toHaveLength(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('rejects invalid data', () => {
|
|
16
|
+
const validator = new SchemaValidator();
|
|
17
|
+
const schema = Type.Object({ name: Type.String() });
|
|
18
|
+
const result = validator.validate({ name: 123 }, schema);
|
|
19
|
+
expect(result.valid).toBe(false);
|
|
20
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('validates without coercion', () => {
|
|
24
|
+
const validator = new SchemaValidator(false);
|
|
25
|
+
const schema = Type.Object({ x: Type.Number() });
|
|
26
|
+
const result = validator.validate({ x: 42 }, schema);
|
|
27
|
+
expect(result.valid).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('validateInput returns data on valid input', () => {
|
|
31
|
+
const validator = new SchemaValidator();
|
|
32
|
+
const schema = Type.Object({ x: Type.Number() });
|
|
33
|
+
const data = validator.validateInput({ x: 42 }, schema);
|
|
34
|
+
expect(data['x']).toBe(42);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('validateInput throws on invalid input', () => {
|
|
38
|
+
const validator = new SchemaValidator();
|
|
39
|
+
const schema = Type.Object({ x: Type.Number() });
|
|
40
|
+
expect(() => validator.validateInput({ x: 'not-a-number' }, schema)).toThrow(SchemaValidationError);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('validateOutput returns data on valid output', () => {
|
|
44
|
+
const validator = new SchemaValidator();
|
|
45
|
+
const schema = Type.Object({ result: Type.String() });
|
|
46
|
+
const data = validator.validateOutput({ result: 'ok' }, schema);
|
|
47
|
+
expect(data['result']).toBe('ok');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('validateOutput throws on invalid output', () => {
|
|
51
|
+
const validator = new SchemaValidator();
|
|
52
|
+
const schema = Type.Object({ result: Type.String() });
|
|
53
|
+
expect(() => validator.validateOutput({ result: 123 }, schema)).toThrow(SchemaValidationError);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('error details include path and message', () => {
|
|
57
|
+
const validator = new SchemaValidator();
|
|
58
|
+
const schema = Type.Object({ nested: Type.Object({ x: Type.Number() }) });
|
|
59
|
+
const result = validator.validate({ nested: { x: 'bad' } }, schema);
|
|
60
|
+
expect(result.valid).toBe(false);
|
|
61
|
+
expect(result.errors[0].path).toBeDefined();
|
|
62
|
+
expect(result.errors[0].message).toBeDefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ACL } from '../src/acl.js';
|
|
3
|
+
import { Context, createIdentity } from '../src/context.js';
|
|
4
|
+
|
|
5
|
+
function makeContext(opts: {
|
|
6
|
+
callerId?: string | null;
|
|
7
|
+
callChain?: string[];
|
|
8
|
+
identityType?: string;
|
|
9
|
+
roles?: string[];
|
|
10
|
+
} = {}): Context {
|
|
11
|
+
const identity = opts.identityType
|
|
12
|
+
? createIdentity('test-user', opts.identityType, opts.roles ?? [])
|
|
13
|
+
: null;
|
|
14
|
+
return new Context(
|
|
15
|
+
'trace-test',
|
|
16
|
+
opts.callerId ?? null,
|
|
17
|
+
opts.callChain ?? [],
|
|
18
|
+
null,
|
|
19
|
+
identity,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('ACL', () => {
|
|
24
|
+
it('allows access when allow rule matches', () => {
|
|
25
|
+
const acl = new ACL([
|
|
26
|
+
{ callers: ['module.a'], targets: ['module.b'], effect: 'allow', description: '' },
|
|
27
|
+
]);
|
|
28
|
+
expect(acl.check('module.a', 'module.b')).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('denies access when deny rule matches', () => {
|
|
32
|
+
const acl = new ACL([
|
|
33
|
+
{ callers: ['module.a'], targets: ['module.b'], effect: 'deny', description: '' },
|
|
34
|
+
]);
|
|
35
|
+
expect(acl.check('module.a', 'module.b')).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns default deny when no rule matches', () => {
|
|
39
|
+
const acl = new ACL([
|
|
40
|
+
{ callers: ['module.a'], targets: ['module.b'], effect: 'allow', description: '' },
|
|
41
|
+
]);
|
|
42
|
+
expect(acl.check('module.x', 'module.y')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('first-match-wins: deny before allow', () => {
|
|
46
|
+
const acl = new ACL([
|
|
47
|
+
{ callers: ['module.a'], targets: ['module.b'], effect: 'deny', description: '' },
|
|
48
|
+
{ callers: ['module.a'], targets: ['module.b'], effect: 'allow', description: '' },
|
|
49
|
+
]);
|
|
50
|
+
expect(acl.check('module.a', 'module.b')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('first-match-wins: allow before deny', () => {
|
|
54
|
+
const acl = new ACL([
|
|
55
|
+
{ callers: ['module.a'], targets: ['module.b'], effect: 'allow', description: '' },
|
|
56
|
+
{ callers: ['module.a'], targets: ['module.b'], effect: 'deny', description: '' },
|
|
57
|
+
]);
|
|
58
|
+
expect(acl.check('module.a', 'module.b')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('default effect allow when no rules match', () => {
|
|
62
|
+
const acl = new ACL([], 'allow');
|
|
63
|
+
expect(acl.check('any', 'thing')).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('maps null callerId to @external', () => {
|
|
67
|
+
const acl = new ACL([
|
|
68
|
+
{ callers: ['@external'], targets: ['public.api'], effect: 'allow', description: '' },
|
|
69
|
+
]);
|
|
70
|
+
expect(acl.check(null, 'public.api')).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not match @external for real module caller', () => {
|
|
74
|
+
const acl = new ACL([
|
|
75
|
+
{ callers: ['@external'], targets: ['public.api'], effect: 'allow', description: '' },
|
|
76
|
+
]);
|
|
77
|
+
expect(acl.check('module.a', 'public.api')).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('wildcard * matches all callers', () => {
|
|
81
|
+
const acl = new ACL([
|
|
82
|
+
{ callers: ['*'], targets: ['public.api'], effect: 'allow', description: '' },
|
|
83
|
+
]);
|
|
84
|
+
expect(acl.check('module.a', 'public.api')).toBe(true);
|
|
85
|
+
expect(acl.check('module.b', 'public.api')).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('wildcard * matches all targets', () => {
|
|
89
|
+
const acl = new ACL([
|
|
90
|
+
{ callers: ['module.admin'], targets: ['*'], effect: 'allow', description: '' },
|
|
91
|
+
]);
|
|
92
|
+
expect(acl.check('module.admin', 'anything')).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('prefix wildcard matching', () => {
|
|
96
|
+
const acl = new ACL([
|
|
97
|
+
{ callers: ['core.*'], targets: ['data.*'], effect: 'allow', description: '' },
|
|
98
|
+
]);
|
|
99
|
+
expect(acl.check('core.auth', 'data.store')).toBe(true);
|
|
100
|
+
expect(acl.check('other.x', 'data.y')).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('@system matches system identity type', () => {
|
|
104
|
+
const acl = new ACL([
|
|
105
|
+
{ callers: ['@system'], targets: ['*'], effect: 'allow', description: '' },
|
|
106
|
+
]);
|
|
107
|
+
const ctx = makeContext({ identityType: 'system' });
|
|
108
|
+
expect(acl.check('any.module', 'any.target', ctx)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('@system does not match non-system identity', () => {
|
|
112
|
+
const acl = new ACL([
|
|
113
|
+
{ callers: ['@system'], targets: ['*'], effect: 'allow', description: '' },
|
|
114
|
+
]);
|
|
115
|
+
const ctx = makeContext({ identityType: 'user' });
|
|
116
|
+
expect(acl.check('any.module', 'any.target', ctx)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('conditions: identity_types allows matching type', () => {
|
|
120
|
+
const acl = new ACL([{
|
|
121
|
+
callers: ['*'], targets: ['admin'], effect: 'allow', description: '',
|
|
122
|
+
conditions: { identity_types: ['admin'] },
|
|
123
|
+
}]);
|
|
124
|
+
const ctx = makeContext({ identityType: 'admin' });
|
|
125
|
+
expect(acl.check('mod.a', 'admin', ctx)).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('conditions: identity_types denies non-matching type', () => {
|
|
129
|
+
const acl = new ACL([{
|
|
130
|
+
callers: ['*'], targets: ['admin'], effect: 'allow', description: '',
|
|
131
|
+
conditions: { identity_types: ['admin'] },
|
|
132
|
+
}]);
|
|
133
|
+
const ctx = makeContext({ identityType: 'user' });
|
|
134
|
+
expect(acl.check('mod.a', 'admin', ctx)).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('conditions: roles allows matching role', () => {
|
|
138
|
+
const acl = new ACL([{
|
|
139
|
+
callers: ['*'], targets: ['settings'], effect: 'allow', description: '',
|
|
140
|
+
conditions: { roles: ['editor', 'admin'] },
|
|
141
|
+
}]);
|
|
142
|
+
const ctx = makeContext({ identityType: 'user', roles: ['editor'] });
|
|
143
|
+
expect(acl.check('mod.a', 'settings', ctx)).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('conditions: roles denies missing role', () => {
|
|
147
|
+
const acl = new ACL([{
|
|
148
|
+
callers: ['*'], targets: ['settings'], effect: 'allow', description: '',
|
|
149
|
+
conditions: { roles: ['admin'] },
|
|
150
|
+
}]);
|
|
151
|
+
const ctx = makeContext({ identityType: 'user', roles: ['viewer'] });
|
|
152
|
+
expect(acl.check('mod.a', 'settings', ctx)).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('conditions: max_call_depth allows within limit', () => {
|
|
156
|
+
const acl = new ACL([{
|
|
157
|
+
callers: ['*'], targets: ['deep'], effect: 'allow', description: '',
|
|
158
|
+
conditions: { max_call_depth: 3 },
|
|
159
|
+
}]);
|
|
160
|
+
const ctx = makeContext({ callChain: ['a', 'b'] });
|
|
161
|
+
expect(acl.check('mod.a', 'deep', ctx)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('conditions: max_call_depth denies exceeding limit', () => {
|
|
165
|
+
const acl = new ACL([{
|
|
166
|
+
callers: ['*'], targets: ['deep'], effect: 'allow', description: '',
|
|
167
|
+
conditions: { max_call_depth: 2 },
|
|
168
|
+
}]);
|
|
169
|
+
const ctx = makeContext({ callChain: ['a', 'b', 'c'] });
|
|
170
|
+
expect(acl.check('mod.a', 'deep', ctx)).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('conditions fail when no context provided', () => {
|
|
174
|
+
const acl = new ACL([{
|
|
175
|
+
callers: ['*'], targets: ['deep'], effect: 'allow', description: '',
|
|
176
|
+
conditions: { max_call_depth: 5 },
|
|
177
|
+
}]);
|
|
178
|
+
expect(acl.check('mod.a', 'deep')).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('addRule adds to highest priority', () => {
|
|
182
|
+
const acl = new ACL([
|
|
183
|
+
{ callers: ['*'], targets: ['*'], effect: 'deny', description: '' },
|
|
184
|
+
]);
|
|
185
|
+
expect(acl.check('mod.a', 'mod.b')).toBe(false);
|
|
186
|
+
|
|
187
|
+
acl.addRule({ callers: ['mod.a'], targets: ['mod.b'], effect: 'allow', description: '' });
|
|
188
|
+
expect(acl.check('mod.a', 'mod.b')).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('removeRule removes matching rule', () => {
|
|
192
|
+
const acl = new ACL([
|
|
193
|
+
{ callers: ['mod.a'], targets: ['mod.b'], effect: 'allow', description: '' },
|
|
194
|
+
]);
|
|
195
|
+
expect(acl.check('mod.a', 'mod.b')).toBe(true);
|
|
196
|
+
|
|
197
|
+
const removed = acl.removeRule(['mod.a'], ['mod.b']);
|
|
198
|
+
expect(removed).toBe(true);
|
|
199
|
+
expect(acl.check('mod.a', 'mod.b')).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('removeRule returns false when no match', () => {
|
|
203
|
+
const acl = new ACL([]);
|
|
204
|
+
expect(acl.removeRule(['x'], ['y'])).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
});
|