adapt-schemas 1.0.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/lib/Schemas.js ADDED
@@ -0,0 +1,370 @@
1
+ import _ from 'lodash'
2
+ import Ajv from 'ajv/dist/2020.js'
3
+ import { EventEmitter } from 'events'
4
+ import { glob } from 'glob'
5
+ import path from 'path'
6
+ import { fileURLToPath } from 'url'
7
+ import safeRegex from 'safe-regex'
8
+ import Schema from './Schema.js'
9
+ import Keywords from './Keywords.js'
10
+ import XSSDefaults from './XSSDefaults.js'
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
13
+ const BASE_SCHEMA_PATH = '../schema/base.schema.json'
14
+
15
+ /**
16
+ * Schema library errors
17
+ */
18
+ export class SchemaError extends Error {
19
+ constructor(code, message, data = {}) {
20
+ super(message)
21
+ this.code = code
22
+ this.data = data
23
+ this.name = 'SchemaError'
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Standalone JSON Schema library for the Adapt framework
29
+ */
30
+ class Schemas extends EventEmitter {
31
+ /**
32
+ * Creates a new Schemas instance
33
+ * @param {Object} options Configuration options
34
+ * @param {Boolean} options.enableCache Enable schema caching (default: true)
35
+ * @param {Object} options.xssWhitelist Custom XSS whitelist tags/attributes
36
+ * @param {Boolean} options.xssWhitelistOverride Replace default whitelist instead of extending
37
+ * @param {Object} options.formatOverrides Custom string format RegExp patterns
38
+ * @param {Object} options.directoryReplacements Replacements for isDirectory keyword (e.g. { '$ROOT': '/app' })
39
+ */
40
+ constructor(options = {}) {
41
+ super()
42
+
43
+ this.options = _.defaults(options, {
44
+ enableCache: true,
45
+ xssWhitelist: {},
46
+ xssWhitelistOverride: false,
47
+ formatOverrides: {},
48
+ directoryReplacements: {}
49
+ })
50
+
51
+ /**
52
+ * Reference to all registered schemas
53
+ * @type {Object<string, Schema>}
54
+ */
55
+ this.schemas = {}
56
+
57
+ /**
58
+ * Temporary store of extension schemas
59
+ * @type {Object<string, string[]>}
60
+ */
61
+ this.schemaExtensions = {}
62
+
63
+ /**
64
+ * Tags and attributes to be whitelisted by the XSS filter
65
+ * @type {Object}
66
+ */
67
+ this.xssWhitelist = Object.assign(
68
+ {},
69
+ this.options.xssWhitelistOverride ? {} : XSSDefaults,
70
+ this.options.xssWhitelist
71
+ )
72
+
73
+ /**
74
+ * Reference to the Ajv instance
75
+ * @type {Ajv}
76
+ */
77
+ this.validator = new Ajv({
78
+ addUsedSchema: false,
79
+ allErrors: true,
80
+ allowUnionTypes: true,
81
+ loadSchema: this.getSchema.bind(this),
82
+ removeAdditional: 'all',
83
+ strict: false,
84
+ verbose: true,
85
+ keywords: Keywords.all(this.options.directoryReplacements)
86
+ })
87
+
88
+ this.addStringFormats({
89
+ 'date-time': /[A-Za-z0-9:+()]+/,
90
+ time: /^(\d{2}):(\d{2}):(\d{2})\+(\d{2}):(\d{2})$/,
91
+ uri: /^(.+):\/\/(www\.)?[-a-zA-Z0-9@:%_+.~#?&//=]{1,256}/
92
+ })
93
+
94
+ if (this.options.formatOverrides) {
95
+ this.addStringFormats(this.options.formatOverrides)
96
+ }
97
+
98
+ this._initialized = false
99
+ }
100
+
101
+ /**
102
+ * Initializes the schema library by loading the base schema
103
+ * @returns {Promise<SchemaLibrary>}
104
+ */
105
+ async init() {
106
+ if (this._initialized) return this
107
+
108
+ await this.resetSchemaRegistry()
109
+ this._initialized = true
110
+ this.emit('initialized')
111
+ return this
112
+ }
113
+
114
+ /**
115
+ * Empties the schema registry (with the exception of the base schema)
116
+ * @returns {Promise<void>}
117
+ */
118
+ async resetSchemaRegistry() {
119
+ this.schemas = {
120
+ base: await this.createSchema(path.resolve(__dirname, BASE_SCHEMA_PATH), { enableCache: true })
121
+ }
122
+ this.emit('reset')
123
+ }
124
+
125
+ /**
126
+ * Adds string formats to the Ajv validator
127
+ * @param {Object<string, RegExp>} formats
128
+ */
129
+ addStringFormats(formats) {
130
+ Object.entries(formats).forEach(([name, re]) => {
131
+ const isUnsafe = !safeRegex(re)
132
+ if (isUnsafe) {
133
+ this.emit('warning', `Unsafe RegExp for format '${name}' (${re}), using default`)
134
+ }
135
+ this.validator.addFormat(name, isUnsafe ? /.*/ : re)
136
+ })
137
+ }
138
+
139
+ /**
140
+ * Adds a new keyword to be used in JSON schemas
141
+ * @param {Object} definition AJV keyword definition
142
+ */
143
+ addKeyword(definition) {
144
+ try {
145
+ this.validator.addKeyword(definition)
146
+ } catch (e) {
147
+ this.emit('warning', `Failed to define keyword '${definition.keyword}', ${e}`)
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Loads schemas from directories matching glob patterns
153
+ * @param {string|string[]} patterns Glob pattern(s) for schema directories
154
+ * @param {Object} options Glob options
155
+ * @param {string[]} options.ignore Patterns to exclude
156
+ * @param {string} options.cwd Base directory for glob patterns
157
+ * @returns {Promise<void>}
158
+ */
159
+ async loadSchemas(patterns, options = {}) {
160
+ if (!this._initialized) {
161
+ await this.init()
162
+ }
163
+
164
+ const globOptions = {
165
+ cwd: options.cwd || process.cwd(),
166
+ absolute: true,
167
+ ignore: options.ignore || []
168
+ }
169
+
170
+ const patternList = Array.isArray(patterns) ? patterns : [patterns]
171
+ const allFiles = []
172
+
173
+ for (const pattern of patternList) {
174
+ const files = await glob(pattern, globOptions)
175
+ allFiles.push(...files)
176
+ }
177
+
178
+ // Deduplicate files
179
+ const uniqueFiles = [...new Set(allFiles)]
180
+
181
+ const results = await Promise.allSettled(
182
+ uniqueFiles.map(f => this.registerSchema(f))
183
+ )
184
+
185
+ results
186
+ .filter(r => r.status === 'rejected')
187
+ .forEach(r => this.emit('warning', r.reason))
188
+
189
+ this.emit('schemasLoaded', Object.keys(this.schemas))
190
+ }
191
+
192
+ /**
193
+ * Registers a single JSON schema for use
194
+ * @param {string} filePath Path to the schema file
195
+ * @param {Object} options Extra options
196
+ * @param {boolean} options.replace Replace existing schema with same name
197
+ * @returns {Promise<Schema>}
198
+ */
199
+ async registerSchema(filePath, options = {}) {
200
+ if (!_.isString(filePath)) {
201
+ throw new SchemaError('INVALID_PARAMS', 'filePath must be a string', { params: ['filePath'] })
202
+ }
203
+
204
+ const schema = await this.createSchema(filePath, options)
205
+
206
+ if (this.schemas[schema.name]) {
207
+ if (options.replace) {
208
+ this.deregisterSchema(schema.name)
209
+ } else {
210
+ throw new SchemaError('SCHEMA_EXISTS', `Schema '${schema.name}' already exists`, {
211
+ schemaName: schema.name,
212
+ filePath
213
+ })
214
+ }
215
+ }
216
+
217
+ this.schemas[schema.name] = schema
218
+ this.schemaExtensions?.[schema.name]?.forEach(s => schema.addExtension(s))
219
+
220
+ if (schema.raw.$patch) {
221
+ this.extendSchema(schema.raw.$patch?.source?.$ref, schema.name)
222
+ }
223
+
224
+ this.emit('schemaRegistered', schema.name, filePath)
225
+ return schema
226
+ }
227
+
228
+ /**
229
+ * Deregisters a single JSON schema
230
+ * @param {string} name Schema name to deregister
231
+ */
232
+ deregisterSchema(name) {
233
+ if (this.schemas[name]) {
234
+ delete this.schemas[name]
235
+ }
236
+ // Remove schema from any extensions lists
237
+ Object.entries(this.schemaExtensions).forEach(([base, extensions]) => {
238
+ this.schemaExtensions[base] = extensions.filter(s => s !== name)
239
+ })
240
+ this.emit('schemaDeregistered', name)
241
+ }
242
+
243
+ /**
244
+ * Creates a new Schema instance
245
+ * @param {string} filePath Path to the schema file
246
+ * @param {Object} options Options passed to Schema constructor
247
+ * @returns {Promise<Schema>}
248
+ */
249
+ async createSchema(filePath, options = {}) {
250
+ const schema = new Schema({
251
+ enableCache: options.enableCache ?? this.options.enableCache,
252
+ filePath,
253
+ validator: this.validator,
254
+ xssWhitelist: this.xssWhitelist,
255
+ schemaLibrary: this,
256
+ ...options
257
+ })
258
+
259
+ this.schemaExtensions?.[schema.name]?.forEach(s => schema.addExtension(s))
260
+ delete this.schemaExtensions?.[schema.name]
261
+
262
+ return schema.load()
263
+ }
264
+
265
+ /**
266
+ * Extends an existing schema with extra properties
267
+ * @param {string} baseSchemaName The name of the schema to extend
268
+ * @param {string} extSchemaName The name of the schema to extend with
269
+ */
270
+ extendSchema(baseSchemaName, extSchemaName) {
271
+ const baseSchema = this.schemas[baseSchemaName]
272
+ if (baseSchema) {
273
+ baseSchema.addExtension(extSchemaName)
274
+ } else {
275
+ if (!this.schemaExtensions[baseSchemaName]) {
276
+ this.schemaExtensions[baseSchemaName] = []
277
+ }
278
+ this.schemaExtensions[baseSchemaName].push(extSchemaName)
279
+ }
280
+ this.emit('schemaExtended', baseSchemaName, extSchemaName)
281
+ }
282
+
283
+ /**
284
+ * Retrieves the specified schema
285
+ * @param {string} schemaName The name of the schema to return
286
+ * @param {Object} options
287
+ * @param {boolean} options.useCache Use cached build if available
288
+ * @param {boolean} options.compile Compile the schema (default: true)
289
+ * @param {boolean} options.applyExtensions Apply extension schemas (default: true)
290
+ * @param {function} options.extensionFilter Filter function for extensions
291
+ * @returns {Promise<Schema>}
292
+ */
293
+ async getSchema(schemaName, options = {}) {
294
+ const schema = this.schemas[schemaName]
295
+ if (!schema) {
296
+ throw new SchemaError('MISSING_SCHEMA', `Schema '${schemaName}' not found`, { schemaName })
297
+ }
298
+ return schema.build(options)
299
+ }
300
+
301
+ /**
302
+ * Validates data against a named schema
303
+ * @param {string} schemaName The schema name
304
+ * @param {Object} data The data to validate
305
+ * @param {Object} options Validation options
306
+ * @returns {Promise<Object>} The validated data with defaults applied
307
+ */
308
+ async validate(schemaName, data, options = {}) {
309
+ const schema = await this.getSchema(schemaName)
310
+ return schema.validate(data, options)
311
+ }
312
+
313
+ /**
314
+ * Returns the built schema object
315
+ * @param {string} schemaName The schema name
316
+ * @param {Object} options Build options
317
+ * @returns {Promise<Object>} The built schema object
318
+ */
319
+ async getBuiltSchema(schemaName, options = {}) {
320
+ const schema = await this.getSchema(schemaName)
321
+ return schema.built
322
+ }
323
+
324
+ /**
325
+ * Returns schema defaults as a structured object
326
+ * @param {string} schemaName The schema name
327
+ * @returns {Promise<Object>} The defaults object
328
+ */
329
+ async getSchemaDefaults(schemaName) {
330
+ const schema = await this.getSchema(schemaName)
331
+ return schema.getObjectDefaults()
332
+ }
333
+
334
+ /**
335
+ * Extracts _globals defaults from the course schema
336
+ * @param {string} schemaName Schema name (defaults to 'course')
337
+ * @returns {Promise<Object>} The _globals defaults
338
+ */
339
+ async getGlobalsDefaults(schemaName = 'course') {
340
+ const schema = await this.getSchema(schemaName)
341
+ const defaults = schema.getObjectDefaults()
342
+ return defaults._globals || {}
343
+ }
344
+
345
+ /**
346
+ * Returns list of all registered schema names
347
+ * @returns {string[]}
348
+ */
349
+ getSchemaNames() {
350
+ return Object.keys(this.schemas)
351
+ }
352
+
353
+ /**
354
+ * Returns information about all registered schemas
355
+ * @returns {Object}
356
+ */
357
+ getSchemaInfo() {
358
+ return Object.entries(this.schemas).reduce((info, [name, schema]) => {
359
+ info[name] = {
360
+ filePath: schema.filePath,
361
+ extensions: schema.extensions,
362
+ hasParent: !!schema.raw?.$merge?.source?.$ref,
363
+ isPatch: !!schema.raw?.$patch
364
+ }
365
+ return info
366
+ }, {})
367
+ }
368
+ }
369
+
370
+ export default Schemas
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Default XSS whitelist for HTML tags and attributes
3
+ */
4
+ export default {
5
+ a: ['class', 'href', 'rel', 'target', 'title'],
6
+ abbr: ['title'],
7
+ address: [],
8
+ area: ['alt', 'coords', 'href', 'shape'],
9
+ article: [],
10
+ aside: ['aria-hidden', 'class', 'role'],
11
+ audio: [
12
+ 'autoplay',
13
+ 'controls',
14
+ 'crossorigin',
15
+ 'loop',
16
+ 'muted',
17
+ 'preload',
18
+ 'src'
19
+ ],
20
+ b: [],
21
+ bdi: ['dir'],
22
+ bdo: ['dir'],
23
+ big: [],
24
+ blockquote: ['cite'],
25
+ br: [],
26
+ button: ['class'],
27
+ caption: [],
28
+ center: [],
29
+ cite: [],
30
+ code: [],
31
+ col: ['align', 'span', 'valign', 'width'],
32
+ colgroup: ['align', 'span', 'valign', 'width'],
33
+ data: [],
34
+ dd: [],
35
+ del: ['datetime'],
36
+ dfn: [],
37
+ details: ['open'],
38
+ div: [
39
+ 'aria-describedby',
40
+ 'aria-description',
41
+ 'aria-label',
42
+ 'aria-hidden',
43
+ 'class',
44
+ 'role',
45
+ 'tabindex'
46
+ ],
47
+ dl: [],
48
+ dt: [],
49
+ em: [],
50
+ figcaption: [],
51
+ figure: ['class'],
52
+ font: ['color', 'face', 'size'],
53
+ footer: [],
54
+ h1: ['class'],
55
+ h2: ['class'],
56
+ h3: ['class'],
57
+ h4: ['class'],
58
+ h5: ['class'],
59
+ h6: ['class'],
60
+ header: [],
61
+ hr: [],
62
+ i: [],
63
+ img: [
64
+ 'alt',
65
+ 'aria-hidden',
66
+ 'aria-label',
67
+ 'class',
68
+ 'height',
69
+ 'loading',
70
+ 'src',
71
+ 'title',
72
+ 'width'
73
+ ],
74
+ ins: ['datetime'],
75
+ kbd: [],
76
+ li: ['class'],
77
+ mark: [],
78
+ math: [],
79
+ mfrac: [],
80
+ mi: [],
81
+ mn: [],
82
+ mo: [],
83
+ mover: [],
84
+ mrow: [],
85
+ ms: [],
86
+ mspace: [],
87
+ msub: [],
88
+ msubsup: [],
89
+ msup: [],
90
+ mtext: [],
91
+ munder: [],
92
+ munderover: [],
93
+ nav: [],
94
+ ol: ['class'],
95
+ p: ['lang'],
96
+ pre: [],
97
+ q: [],
98
+ rp: [],
99
+ rt: [],
100
+ ruby: [],
101
+ s: [],
102
+ samp: [],
103
+ section: [],
104
+ small: [],
105
+ span: [
106
+ 'aria-describedby',
107
+ 'aria-description',
108
+ 'aria-label',
109
+ 'aria-hidden',
110
+ 'class',
111
+ 'role',
112
+ 'tabindex'
113
+ ],
114
+ sub: [],
115
+ summary: [],
116
+ sup: [],
117
+ strong: [],
118
+ strike: [],
119
+ table: ['align', 'border', 'width', 'valign'],
120
+ tbody: ['align', 'valign'],
121
+ td: ['align', 'colspan', 'rowspan', 'valign', 'width'],
122
+ tfoot: ['align', 'valign'],
123
+ th: ['align', 'colspan', 'rowspan', 'valign', 'width'],
124
+ thead: ['align', 'valign'],
125
+ time: [],
126
+ tr: ['align', 'rowspan', 'valign'],
127
+ tt: [],
128
+ u: [],
129
+ ul: ['class'],
130
+ var: [],
131
+ video: [
132
+ 'autoplay',
133
+ 'controls',
134
+ 'crossorigin',
135
+ 'loop',
136
+ 'muted',
137
+ 'playsinline',
138
+ 'poster',
139
+ 'preload',
140
+ 'src',
141
+ 'height',
142
+ 'width'
143
+ ],
144
+ wbr: []
145
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "adapt-schemas",
3
+ "version": "1.0.0",
4
+ "description": "Standalone JSON Schema library for the Adapt framework",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./Schemas": "./lib/Schemas.js",
10
+ "./Schema": "./lib/Schema.js",
11
+ "./Keywords": "./lib/Keywords.js",
12
+ "./XSSDefaults": "./lib/XSSDefaults.js"
13
+ },
14
+ "scripts": {
15
+ "test": "node test.js"
16
+ },
17
+ "keywords": [
18
+ "adapt",
19
+ "json-schema",
20
+ "schema",
21
+ "validation"
22
+ ],
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "ajv": "^8.12.0",
26
+ "bytes": "^3.1.2",
27
+ "glob": "^10.0.0",
28
+ "lodash": "^4.17.21",
29
+ "ms": "^2.1.3",
30
+ "safe-regex": "^2.1.1",
31
+ "xss": "^1.0.14"
32
+ }
33
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$anchor": "base",
4
+ "description": "The base schema inherited by all other schemas",
5
+ "type": "object",
6
+ "properties": {
7
+ "_id": {
8
+ "description": "Unique identifier",
9
+ "type": "string",
10
+ "isObjectId": true
11
+ }
12
+ }
13
+ }