@enbox/dwn-sdk-js 0.4.1 → 0.4.3
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/browser.mjs +3 -10
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/generated/precompiled-validators.js +1065 -2160
- package/dist/esm/generated/precompiled-validators.js.map +1 -1
- package/dist/esm/src/core/grant-authorization.js +14 -1
- package/dist/esm/src/core/grant-authorization.js.map +1 -1
- package/dist/esm/src/core/protocol-authorization-validation.js +4 -21
- package/dist/esm/src/core/protocol-authorization-validation.js.map +1 -1
- package/dist/esm/src/interfaces/protocols-configure.js +5 -6
- package/dist/esm/src/interfaces/protocols-configure.js.map +1 -1
- package/dist/esm/src/types/permission-types.js.map +1 -1
- package/dist/esm/src/utils/protocol-tags.js +262 -0
- package/dist/esm/src/utils/protocol-tags.js.map +1 -0
- package/dist/esm/tests/core/grant-authorization.spec.js +82 -4
- package/dist/esm/tests/core/grant-authorization.spec.js.map +1 -1
- package/dist/esm/tests/core/records-grant-authorization.spec.js +22 -5
- package/dist/esm/tests/core/records-grant-authorization.spec.js.map +1 -1
- package/dist/esm/tests/features/author-delegated-grant.spec.js +134 -10
- package/dist/esm/tests/features/author-delegated-grant.spec.js.map +1 -1
- package/dist/esm/tests/features/permissions.spec.js +6 -6
- package/dist/esm/tests/features/permissions.spec.js.map +1 -1
- package/dist/esm/tests/handlers/records-count.spec.js +2 -2
- package/dist/esm/tests/handlers/records-count.spec.js.map +1 -1
- package/dist/esm/tests/handlers/records-query.spec.js +2 -2
- package/dist/esm/tests/handlers/records-query.spec.js.map +1 -1
- package/dist/esm/tests/handlers/records-read.spec.js +8 -2
- package/dist/esm/tests/handlers/records-read.spec.js.map +1 -1
- package/dist/esm/tests/handlers/records-subscribe.spec.js +2 -2
- package/dist/esm/tests/handlers/records-subscribe.spec.js.map +1 -1
- package/dist/esm/tests/protocols/permission-request.spec.js +4 -4
- package/dist/esm/tests/protocols/permission-request.spec.js.map +1 -1
- package/dist/esm/tests/protocols/permissions.spec.js +35 -2
- package/dist/esm/tests/protocols/permissions.spec.js.map +1 -1
- package/dist/esm/tests/utils/protocol-tags.spec.js +96 -0
- package/dist/esm/tests/utils/protocol-tags.spec.js.map +1 -0
- package/dist/types/generated/precompiled-validators.d.ts.map +1 -1
- package/dist/types/src/core/grant-authorization.d.ts +2 -1
- package/dist/types/src/core/grant-authorization.d.ts.map +1 -1
- package/dist/types/src/core/protocol-authorization-validation.d.ts +1 -1
- package/dist/types/src/core/protocol-authorization-validation.d.ts.map +1 -1
- package/dist/types/src/interfaces/protocols-configure.d.ts.map +1 -1
- package/dist/types/src/types/permission-types.d.ts +4 -1
- package/dist/types/src/types/permission-types.d.ts.map +1 -1
- package/dist/types/src/utils/protocol-tags.d.ts +15 -0
- package/dist/types/src/utils/protocol-tags.d.ts.map +1 -0
- package/dist/types/tests/features/author-delegated-grant.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/records-read.spec.d.ts.map +1 -1
- package/dist/types/tests/utils/protocol-tags.spec.d.ts +2 -0
- package/dist/types/tests/utils/protocol-tags.spec.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/core/grant-authorization.ts +18 -1
- package/src/core/protocol-authorization-validation.ts +8 -25
- package/src/interfaces/protocols-configure.ts +8 -6
- package/src/types/permission-types.ts +4 -1
- package/src/utils/protocol-tags.ts +366 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ProtocolTagsDefinition } from '../types/protocols-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Validates DWN protocol tag schema definitions without invoking Ajv's runtime compiler.
|
|
4
|
+
*
|
|
5
|
+
* The broader protocol message shape is already checked by the precompiled
|
|
6
|
+
* ProtocolRuleSet validator. This function covers the tag-schema constraints
|
|
7
|
+
* that are deliberately modeled as a small DWN subset rather than arbitrary
|
|
8
|
+
* JSON Schema.
|
|
9
|
+
*/
|
|
10
|
+
export declare function validateProtocolTagSchemaDefinition(schema: unknown, dataVar: string): string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Validates a record's `descriptor.tags` against a protocol rule set `$tags` definition.
|
|
13
|
+
*/
|
|
14
|
+
export declare function validateProtocolTags(tagsDefinition: ProtocolTagsDefinition, tags: Record<string, unknown>, dataVar: string): string | undefined;
|
|
15
|
+
//# sourceMappingURL=protocol-tags.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protocol-tags.d.ts","sourceRoot":"","sources":["../../../../src/utils/protocol-tags.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqB,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAuH7F;;;;;;;GAOG;AACH,wBAAgB,mCAAmC,CACjD,MAAM,EAAE,OAAO,EACf,OAAO,EAAE,MAAM,GACd,MAAM,GAAG,SAAS,CAgBpB;AAgLD;;GAEG;AACH,wBAAgB,oBAAoB,CAClC,cAAc,EAAE,sBAAsB,EACtC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,OAAO,EAAE,MAAM,GACd,MAAM,GAAG,SAAS,CA0BpB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"author-delegated-grant.spec.d.ts","sourceRoot":"","sources":["../../../../tests/features/author-delegated-grant.spec.ts"],"names":[],"mappings":"AA2BA,wBAAgB,wBAAwB,IAAI,IAAI,
|
|
1
|
+
{"version":3,"file":"author-delegated-grant.spec.d.ts","sourceRoot":"","sources":["../../../../tests/features/author-delegated-grant.spec.ts"],"names":[],"mappings":"AA2BA,wBAAgB,wBAAwB,IAAI,IAAI,CAurD/C"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"records-read.spec.d.ts","sourceRoot":"","sources":["../../../../tests/handlers/records-read.spec.ts"],"names":[],"mappings":"AAkCA,wBAAgB,sBAAsB,IAAI,IAAI,
|
|
1
|
+
{"version":3,"file":"records-read.spec.d.ts","sourceRoot":"","sources":["../../../../tests/handlers/records-read.spec.ts"],"names":[],"mappings":"AAkCA,wBAAgB,sBAAsB,IAAI,IAAI,CA2nE7C"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protocol-tags.spec.d.ts","sourceRoot":"","sources":["../../../../tests/utils/protocol-tags.spec.ts"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -119,7 +119,8 @@ export class GrantAuthorization {
|
|
|
119
119
|
* Verify that the `interface` and `method` grant scopes match the incoming message.
|
|
120
120
|
*
|
|
121
121
|
* For the Messages interface, a `Read` scope is treated as a unified scope that also authorizes
|
|
122
|
-
* `Query
|
|
122
|
+
* `Query` and `Subscribe` operations. For Records, `Read` is likewise the canonical read-like
|
|
123
|
+
* scope and authorizes `Read`, `Query`, `Subscribe`, and `Count` operations.
|
|
123
124
|
*
|
|
124
125
|
* @throws {DwnError} if the `interface` and `method` of the incoming message do not match the scope of the permission grant.
|
|
125
126
|
*/
|
|
@@ -155,6 +156,22 @@ export class GrantAuthorization {
|
|
|
155
156
|
return;
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
// Records.Read is the only valid read-like Records scope and covers Read, Query,
|
|
160
|
+
// Subscribe, and Count operations. Reject malformed Records Query/Subscribe/Count
|
|
161
|
+
// grants instead of treating them as compatible with the canonical Read scope.
|
|
162
|
+
if (dwnInterface === DwnInterfaceName.Records) {
|
|
163
|
+
const readLikeMethods = [DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe, DwnMethodName.Count];
|
|
164
|
+
if (readLikeMethods.includes(dwnMethod as DwnMethodName)) {
|
|
165
|
+
if (permissionGrant.scope.method !== DwnMethodName.Read) {
|
|
166
|
+
throw new DwnError(
|
|
167
|
+
DwnErrorCode.GrantAuthorizationMethodMismatch,
|
|
168
|
+
`records read-like permission grant must have method 'Read', got '${permissionGrant.scope.method}' for grant ${permissionGrant.id}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
158
175
|
if (dwnMethod !== permissionGrant.scope.method) {
|
|
159
176
|
throw new DwnError(
|
|
160
177
|
DwnErrorCode.GrantAuthorizationMethodMismatch,
|
|
@@ -5,8 +5,8 @@ import type { ProtocolDefinition, ProtocolRuleSet, ProtocolType, ProtocolTypes }
|
|
|
5
5
|
|
|
6
6
|
import { ProtocolRecordLimitStrategy } from '../types/protocols-types.js';
|
|
7
7
|
|
|
8
|
-
import Ajv from 'ajv/dist/2020.js';
|
|
9
8
|
import { Records } from '../utils/records.js';
|
|
9
|
+
import { validateProtocolTags } from '../utils/protocol-tags.js';
|
|
10
10
|
import { DwnError, DwnErrorCode } from './dwn-error.js';
|
|
11
11
|
import { getTypeName, parseCrossProtocolRef } from '../utils/protocols.js';
|
|
12
12
|
|
|
@@ -282,7 +282,7 @@ export function verifySizeLimit(
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
/**
|
|
285
|
-
* Verifies record tags against the `$tags` schema in the rule set
|
|
285
|
+
* Verifies record tags against the `$tags` schema in the rule set.
|
|
286
286
|
* Checks required tags, additional properties, and schema conformance.
|
|
287
287
|
*/
|
|
288
288
|
export function verifyTagsIfNeeded(
|
|
@@ -291,30 +291,13 @@ export function verifyTagsIfNeeded(
|
|
|
291
291
|
): void {
|
|
292
292
|
if (ruleSet.$tags !== undefined) {
|
|
293
293
|
const { tags = {}, protocol, protocolPath } = incomingMessage.message.descriptor;
|
|
294
|
+
const schemaError = validateProtocolTags(
|
|
295
|
+
ruleSet.$tags,
|
|
296
|
+
tags,
|
|
297
|
+
`${protocol}/${protocolPath}/$tags`,
|
|
298
|
+
);
|
|
294
299
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
// if $allowUndefinedTags is set to false and there are properties not defined in the schema, an error is thrown
|
|
298
|
-
const additionalProperties = $allowUndefinedTags || false;
|
|
299
|
-
|
|
300
|
-
// if $requiredTags is set, all required tags must be present
|
|
301
|
-
const required = $requiredTags || [];
|
|
302
|
-
|
|
303
|
-
const ajv = new Ajv.default();
|
|
304
|
-
const compiledTags = ajv.compile({
|
|
305
|
-
type: 'object',
|
|
306
|
-
properties,
|
|
307
|
-
required,
|
|
308
|
-
additionalProperties,
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
const validSchema = compiledTags(tags);
|
|
312
|
-
if (!validSchema) {
|
|
313
|
-
// the `dataVar` is used to add a qualifier to the error message.
|
|
314
|
-
// For example. If the error is related to a tag `status` in a protocol `https://example.protocol` with the protocolPath `example/path`
|
|
315
|
-
// the error would be described as `https://example.protocol/example/path/$tags/status'
|
|
316
|
-
// without this decorator it would show up as `data/status` which may be confusing.
|
|
317
|
-
const schemaError = ajv.errorsText(compiledTags.errors, { dataVar: `${protocol}/${protocolPath}/$tags` });
|
|
300
|
+
if (schemaError !== undefined) {
|
|
318
301
|
throw new DwnError(DwnErrorCode.ProtocolAuthorizationTagsInvalidSchema, `tags schema validation error: ${schemaError}`);
|
|
319
302
|
}
|
|
320
303
|
}
|
|
@@ -7,12 +7,12 @@ import type {
|
|
|
7
7
|
} from '../types/protocols-types.js';
|
|
8
8
|
|
|
9
9
|
import { AbstractMessage } from '../core/abstract-message.js';
|
|
10
|
-
import Ajv from 'ajv/dist/2020.js';
|
|
11
10
|
import { DwnConstant } from '../core/dwn-constant.js';
|
|
12
11
|
import { Message } from '../core/message.js';
|
|
13
12
|
import { PermissionGrant } from '../protocols/permission-grant.js';
|
|
14
13
|
import { ProtocolsGrantAuthorization } from '../core/protocols-grant-authorization.js';
|
|
15
14
|
import { Time } from '../utils/time.js';
|
|
15
|
+
import { validateProtocolTagSchemaDefinition } from '../utils/protocol-tags.js';
|
|
16
16
|
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
|
|
17
17
|
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
|
|
18
18
|
import { isCrossProtocolRef, parseCrossProtocolRef } from '../utils/protocols.js';
|
|
@@ -265,16 +265,18 @@ export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessag
|
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
if (ruleSet.$tags) {
|
|
269
|
-
const ajv = new Ajv.default();
|
|
268
|
+
if (ruleSet.$tags !== undefined) {
|
|
270
269
|
const { $allowUndefinedTags, $requiredTags, ...tagProperties } = ruleSet.$tags;
|
|
271
270
|
|
|
272
|
-
//
|
|
271
|
+
// validate each tag's schema against the DWN-supported tag schema subset
|
|
273
272
|
for (const tag in tagProperties) {
|
|
274
273
|
const tagSchemaDefinition = tagProperties[tag];
|
|
274
|
+
const schemaError = validateProtocolTagSchemaDefinition(
|
|
275
|
+
tagSchemaDefinition,
|
|
276
|
+
`${ruleSetProtocolPath}/$tags/${tag}`,
|
|
277
|
+
);
|
|
275
278
|
|
|
276
|
-
if (
|
|
277
|
-
const schemaError = ajv.errorsText(ajv.errors, { dataVar: `${ruleSetProtocolPath}/$tags/${tag}` });
|
|
279
|
+
if (schemaError !== undefined) {
|
|
278
280
|
throw new DwnError(DwnErrorCode.ProtocolsConfigureInvalidTagSchema, `tags schema validation error: ${schemaError}`);
|
|
279
281
|
}
|
|
280
282
|
}
|
|
@@ -104,10 +104,13 @@ export type MessagesPermissionScope = {
|
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
106
|
* The data model for a permission scope that is specific to the Records interface.
|
|
107
|
+
*
|
|
108
|
+
* `Read` is the only valid read-like Records permission scope and authorizes
|
|
109
|
+
* `RecordsRead`, `RecordsQuery`, `RecordsSubscribe`, and `RecordsCount` operations.
|
|
107
110
|
*/
|
|
108
111
|
export type RecordsPermissionScope = {
|
|
109
112
|
interface: DwnInterfaceName.Records;
|
|
110
|
-
method: DwnMethodName.
|
|
113
|
+
method: DwnMethodName.Read | DwnMethodName.Write | DwnMethodName.Delete;
|
|
111
114
|
protocol: string;
|
|
112
115
|
/** May only be present when `protocol` is defined and `protocolPath` is undefined */
|
|
113
116
|
contextId?: string;
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import type { ProtocolTagSchema, ProtocolTagsDefinition } from '../types/protocols-types.js';
|
|
2
|
+
|
|
3
|
+
type TagSchema = ProtocolTagSchema | Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
type TagValidationError = {
|
|
6
|
+
instancePath: string;
|
|
7
|
+
message: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const allowedTagTypes = new Set(['string', 'number', 'integer', 'boolean', 'array']);
|
|
11
|
+
const allowedArrayItemTypes = new Set(['string', 'number', 'integer']);
|
|
12
|
+
const numericConstraintKeywords = [ 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum' ];
|
|
13
|
+
const stringConstraintKeywords = [ 'minLength', 'maxLength' ];
|
|
14
|
+
const arrayConstraintKeywords = [ 'minItems', 'maxItems', 'minContains', 'maxContains' ];
|
|
15
|
+
|
|
16
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pathSegment(segment: string | number): string {
|
|
21
|
+
return String(segment).replaceAll('~', '~0').replaceAll('/', '~1');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function appendPath(path: string, segment: string | number): string {
|
|
25
|
+
return `${path}/${pathSegment(segment)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hasOwnProperty(value: Record<string, unknown>, property: string): boolean {
|
|
29
|
+
return Object.hasOwn(value, property);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function addError(errors: TagValidationError[], instancePath: string, message: string): void {
|
|
33
|
+
errors.push({ instancePath, message });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isNumber(value: unknown): value is number {
|
|
37
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function valuesEqual(left: unknown, right: unknown): boolean {
|
|
41
|
+
if (Object.is(left, right)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
46
|
+
return left.length === right.length && left.every((value, index) => valuesEqual(value, right[index]));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (isRecord(left) && isRecord(right)) {
|
|
50
|
+
const leftKeys = Object.keys(left);
|
|
51
|
+
const rightKeys = Object.keys(right);
|
|
52
|
+
|
|
53
|
+
return leftKeys.length === rightKeys.length
|
|
54
|
+
&& leftKeys.every(key => Object.hasOwn(right, key) && valuesEqual(left[key], right[key]));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getTagSchemas(tagsDefinition: ProtocolTagsDefinition): Record<string, TagSchema> {
|
|
61
|
+
const schemas: Record<string, TagSchema> = {};
|
|
62
|
+
|
|
63
|
+
for (const [ tag, schema ] of Object.entries(tagsDefinition)) {
|
|
64
|
+
if (tag === '$requiredTags' || tag === '$allowUndefinedTags') {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
schemas[tag] = schema as TagSchema;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return schemas;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateSchemaConstraintTypes(
|
|
75
|
+
schema: Record<string, unknown>,
|
|
76
|
+
dataPath: string,
|
|
77
|
+
errors: TagValidationError[],
|
|
78
|
+
): void {
|
|
79
|
+
if (schema.enum !== undefined) {
|
|
80
|
+
if (!Array.isArray(schema.enum) || schema.enum.length === 0) {
|
|
81
|
+
addError(errors, appendPath(dataPath, 'enum'), 'must be non-empty array');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const keyword of numericConstraintKeywords) {
|
|
86
|
+
if (schema[keyword] !== undefined && !isNumber(schema[keyword])) {
|
|
87
|
+
addError(errors, appendPath(dataPath, keyword), 'must be number');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const keyword of [ ...stringConstraintKeywords, ...arrayConstraintKeywords ]) {
|
|
92
|
+
if (schema[keyword] !== undefined && (!Number.isInteger(schema[keyword]) || (schema[keyword] as number) < 0)) {
|
|
93
|
+
addError(errors, appendPath(dataPath, keyword), 'must be integer');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (schema.uniqueItems !== undefined && typeof schema.uniqueItems !== 'boolean') {
|
|
98
|
+
addError(errors, appendPath(dataPath, 'uniqueItems'), 'must be boolean');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateTagSubschemaDefinition(
|
|
103
|
+
schema: unknown,
|
|
104
|
+
dataPath: string,
|
|
105
|
+
allowedTypes: Set<string>,
|
|
106
|
+
errors: TagValidationError[],
|
|
107
|
+
): void {
|
|
108
|
+
if (!isRecord(schema)) {
|
|
109
|
+
addError(errors, dataPath, 'must be object');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (schema.type !== undefined && (typeof schema.type !== 'string' || !allowedTypes.has(schema.type))) {
|
|
114
|
+
addError(errors, appendPath(dataPath, 'type'), 'must be equal to one of the allowed values');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
validateSchemaConstraintTypes(schema, dataPath, errors);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Validates DWN protocol tag schema definitions without invoking Ajv's runtime compiler.
|
|
122
|
+
*
|
|
123
|
+
* The broader protocol message shape is already checked by the precompiled
|
|
124
|
+
* ProtocolRuleSet validator. This function covers the tag-schema constraints
|
|
125
|
+
* that are deliberately modeled as a small DWN subset rather than arbitrary
|
|
126
|
+
* JSON Schema.
|
|
127
|
+
*/
|
|
128
|
+
export function validateProtocolTagSchemaDefinition(
|
|
129
|
+
schema: unknown,
|
|
130
|
+
dataVar: string,
|
|
131
|
+
): string | undefined {
|
|
132
|
+
const errors: TagValidationError[] = [];
|
|
133
|
+
|
|
134
|
+
validateTagSubschemaDefinition(schema, '', allowedTagTypes, errors);
|
|
135
|
+
|
|
136
|
+
if (isRecord(schema)) {
|
|
137
|
+
if (schema.items !== undefined) {
|
|
138
|
+
validateTagSubschemaDefinition(schema.items, '/items', allowedArrayItemTypes, errors);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (schema.contains !== undefined) {
|
|
142
|
+
validateTagSubschemaDefinition(schema.contains, '/contains', allowedArrayItemTypes, errors);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return formatTagValidationErrors(errors, dataVar);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function validateType(type: unknown, value: unknown, dataPath: string, errors: TagValidationError[]): boolean {
|
|
150
|
+
switch (type) {
|
|
151
|
+
case undefined:
|
|
152
|
+
return true;
|
|
153
|
+
case 'string':
|
|
154
|
+
if (typeof value === 'string') {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
addError(errors, dataPath, 'must be string');
|
|
158
|
+
return false;
|
|
159
|
+
case 'number':
|
|
160
|
+
if (isNumber(value)) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
addError(errors, dataPath, 'must be number');
|
|
164
|
+
return false;
|
|
165
|
+
case 'integer':
|
|
166
|
+
if (isNumber(value) && Number.isInteger(value)) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
addError(errors, dataPath, 'must be integer');
|
|
170
|
+
return false;
|
|
171
|
+
case 'boolean':
|
|
172
|
+
if (typeof value === 'boolean') {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
addError(errors, dataPath, 'must be boolean');
|
|
176
|
+
return false;
|
|
177
|
+
case 'array':
|
|
178
|
+
if (Array.isArray(value)) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
addError(errors, dataPath, 'must be array');
|
|
182
|
+
return false;
|
|
183
|
+
default:
|
|
184
|
+
addError(errors, dataPath, 'must match a supported tag schema type');
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function validateEnum(schema: Record<string, unknown>, value: unknown, dataPath: string, errors: TagValidationError[]): void {
|
|
190
|
+
if (Array.isArray(schema.enum) && !schema.enum.some(allowed => valuesEqual(allowed, value))) {
|
|
191
|
+
addError(errors, dataPath, 'must be equal to one of the allowed values');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function validateNumberConstraints(
|
|
196
|
+
schema: Record<string, unknown>,
|
|
197
|
+
value: unknown,
|
|
198
|
+
dataPath: string,
|
|
199
|
+
errors: TagValidationError[],
|
|
200
|
+
): void {
|
|
201
|
+
if (!isNumber(value)) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (isNumber(schema.minimum) && value < schema.minimum) {
|
|
206
|
+
addError(errors, dataPath, `must be >= ${schema.minimum}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (isNumber(schema.maximum) && value > schema.maximum) {
|
|
210
|
+
addError(errors, dataPath, `must be <= ${schema.maximum}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (isNumber(schema.exclusiveMinimum) && value <= schema.exclusiveMinimum) {
|
|
214
|
+
addError(errors, dataPath, `must be > ${schema.exclusiveMinimum}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (isNumber(schema.exclusiveMaximum) && value >= schema.exclusiveMaximum) {
|
|
218
|
+
addError(errors, dataPath, `must be < ${schema.exclusiveMaximum}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function validateStringConstraints(
|
|
223
|
+
schema: Record<string, unknown>,
|
|
224
|
+
value: unknown,
|
|
225
|
+
dataPath: string,
|
|
226
|
+
errors: TagValidationError[],
|
|
227
|
+
): void {
|
|
228
|
+
if (typeof value !== 'string') {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (Number.isInteger(schema.minLength) && value.length < (schema.minLength as number)) {
|
|
233
|
+
addError(errors, dataPath, `must NOT have fewer than ${schema.minLength} characters`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (Number.isInteger(schema.maxLength) && value.length > (schema.maxLength as number)) {
|
|
237
|
+
addError(errors, dataPath, `must NOT have more than ${schema.maxLength} characters`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function validateArrayConstraints(
|
|
242
|
+
schema: Record<string, unknown>,
|
|
243
|
+
value: unknown,
|
|
244
|
+
dataPath: string,
|
|
245
|
+
errors: TagValidationError[],
|
|
246
|
+
): void {
|
|
247
|
+
if (!Array.isArray(value)) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (Number.isInteger(schema.minItems) && value.length < (schema.minItems as number)) {
|
|
252
|
+
addError(errors, dataPath, `must NOT have fewer than ${schema.minItems} items`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (Number.isInteger(schema.maxItems) && value.length > (schema.maxItems as number)) {
|
|
256
|
+
addError(errors, dataPath, `must NOT have more than ${schema.maxItems} items`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (schema.uniqueItems === true) {
|
|
260
|
+
for (let i = 0; i < value.length; i++) {
|
|
261
|
+
for (let j = i + 1; j < value.length; j++) {
|
|
262
|
+
if (valuesEqual(value[i], value[j])) {
|
|
263
|
+
addError(errors, dataPath, 'must NOT have duplicate items');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function validateTagValue(
|
|
272
|
+
schema: unknown,
|
|
273
|
+
value: unknown,
|
|
274
|
+
dataPath: string,
|
|
275
|
+
errors: TagValidationError[],
|
|
276
|
+
): void {
|
|
277
|
+
if (!isRecord(schema)) {
|
|
278
|
+
addError(errors, dataPath, 'must match a supported tag schema');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const validType = validateType(schema.type, value, dataPath, errors);
|
|
283
|
+
if (!validType) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
validateEnum(schema, value, dataPath, errors);
|
|
288
|
+
validateNumberConstraints(schema, value, dataPath, errors);
|
|
289
|
+
validateStringConstraints(schema, value, dataPath, errors);
|
|
290
|
+
validateArrayConstraints(schema, value, dataPath, errors);
|
|
291
|
+
|
|
292
|
+
if (Array.isArray(value) && isRecord(schema.items)) {
|
|
293
|
+
value.forEach((item, index) => validateTagValue(schema.items, item, appendPath(dataPath, index), errors));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (Array.isArray(value) && isRecord(schema.contains)) {
|
|
297
|
+
validateContains(schema, value, dataPath, errors);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function validateContains(
|
|
302
|
+
schema: Record<string, unknown>,
|
|
303
|
+
value: unknown[],
|
|
304
|
+
dataPath: string,
|
|
305
|
+
errors: TagValidationError[],
|
|
306
|
+
): void {
|
|
307
|
+
const minimumCount = Number.isInteger(schema.minContains) ? schema.minContains as number : 1;
|
|
308
|
+
const maximumCount = Number.isInteger(schema.maxContains) ? schema.maxContains as number : undefined;
|
|
309
|
+
const validItemCount = value.filter(item => {
|
|
310
|
+
const itemErrors: TagValidationError[] = [];
|
|
311
|
+
validateTagValue(schema.contains, item, dataPath, itemErrors);
|
|
312
|
+
return itemErrors.length === 0;
|
|
313
|
+
}).length;
|
|
314
|
+
|
|
315
|
+
if (validItemCount < minimumCount || (maximumCount !== undefined && validItemCount > maximumCount)) {
|
|
316
|
+
const message = maximumCount === undefined
|
|
317
|
+
? `must contain at least ${minimumCount} valid item(s)`
|
|
318
|
+
: `must contain at least ${minimumCount} and no more than ${maximumCount} valid item(s)`;
|
|
319
|
+
addError(errors, dataPath, message);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Validates a record's `descriptor.tags` against a protocol rule set `$tags` definition.
|
|
325
|
+
*/
|
|
326
|
+
export function validateProtocolTags(
|
|
327
|
+
tagsDefinition: ProtocolTagsDefinition,
|
|
328
|
+
tags: Record<string, unknown>,
|
|
329
|
+
dataVar: string,
|
|
330
|
+
): string | undefined {
|
|
331
|
+
const errors: TagValidationError[] = [];
|
|
332
|
+
const tagSchemas = getTagSchemas(tagsDefinition);
|
|
333
|
+
const requiredTags = Array.isArray(tagsDefinition.$requiredTags) ? tagsDefinition.$requiredTags : [];
|
|
334
|
+
|
|
335
|
+
for (const requiredTag of requiredTags) {
|
|
336
|
+
if (!hasOwnProperty(tags, requiredTag)) {
|
|
337
|
+
addError(errors, '', `must have required property '${requiredTag}'`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (tagsDefinition.$allowUndefinedTags !== true) {
|
|
342
|
+
for (const tag of Object.keys(tags)) {
|
|
343
|
+
if (tagSchemas[tag] === undefined) {
|
|
344
|
+
addError(errors, '', 'must NOT have additional properties');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const [ tag, schema ] of Object.entries(tagSchemas)) {
|
|
350
|
+
if (hasOwnProperty(tags, tag)) {
|
|
351
|
+
validateTagValue(schema, tags[tag], appendPath('', tag), errors);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return formatTagValidationErrors(errors, dataVar);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function formatTagValidationErrors(errors: TagValidationError[], dataVar: string): string | undefined {
|
|
359
|
+
if (errors.length === 0) {
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return errors
|
|
364
|
+
.map(error => `${dataVar}${error.instancePath} ${error.message}`)
|
|
365
|
+
.join(', ');
|
|
366
|
+
}
|