adapt-schemas 3.1.0 → 3.1.2

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/Schema.js CHANGED
@@ -45,12 +45,6 @@ class Schema extends EventEmitter {
45
45
  */
46
46
  this.enableCache = enableCache
47
47
 
48
- /**
49
- * List of extensions for this schema
50
- * @type {string[]}
51
- */
52
- this.extensions = []
53
-
54
48
  /**
55
49
  * File path to the schema
56
50
  * @type {string}
@@ -195,14 +189,13 @@ class Schema extends EventEmitter {
195
189
  parent = parent.getParent()
196
190
  }
197
191
 
198
- if (this.extensions.length) {
199
- this.extensions.forEach(s => {
200
- const applyPatch = typeof extensionFilter === 'function' ? extensionFilter(s) : applyExtensions !== false
201
- if (!applyPatch) return
202
- const extSchema = this.schemaLibrary.getSchema(s)
203
- this.patch(built, extSchema.raw, { extendAnnotations: false })
204
- })
205
- }
192
+ const extensions = this.schemaLibrary.schemaExtensions?.[this.name] ?? []
193
+ extensions.forEach(s => {
194
+ const applyPatch = typeof extensionFilter === 'function' ? extensionFilter(s) : applyExtensions !== false
195
+ if (!applyPatch) return
196
+ const extSchema = this.schemaLibrary.getSchema(s)
197
+ this.patch(built, extSchema.raw, { extendAnnotations: false })
198
+ })
206
199
 
207
200
  this.built = built
208
201
 
@@ -355,13 +348,14 @@ class Schema extends EventEmitter {
355
348
  }
356
349
 
357
350
  /**
358
- * Adds an extension schema
359
- * @param {string} extSchemaName Extension schema name
351
+ * Discards any cached build/compiled state. Called when an extension is added
352
+ * or removed against this schema so the next `build()` reflects the change.
360
353
  */
361
- addExtension (extSchemaName) {
362
- if (!this.extensions.includes(extSchemaName)) {
363
- this.extensions.push(extSchemaName)
364
- }
354
+ invalidateBuild () {
355
+ this.built = undefined
356
+ this.compiled = undefined
357
+ this.compiledWithDefaults = undefined
358
+ this.lastBuildTime = undefined
365
359
  }
366
360
 
367
361
  /**
package/lib/Schemas.js CHANGED
@@ -42,7 +42,7 @@ class Schemas extends EventEmitter {
42
42
  this.schemas = {}
43
43
 
44
44
  /**
45
- * Temporary store of extension schemas
45
+ * Maps base schema name to the names of schemas that extend it (via $patch).
46
46
  * @type {Object<string, string[]>}
47
47
  */
48
48
  this.schemaExtensions = {}
@@ -110,9 +110,16 @@ class Schemas extends EventEmitter {
110
110
  * Empties the schema registry (with the exception of the base schema)
111
111
  */
112
112
  resetSchemaRegistry () {
113
+ // Remove previously registered schemas from AJV instances
114
+ for (const name of Object.keys(this.schemas)) {
115
+ for (const v of [this.validator, this.validatorWithDefaults]) {
116
+ if (v.getSchema(name)) v.removeSchema(name)
117
+ }
118
+ }
113
119
  this.schemas = {
114
120
  base: this.createSchema(path.resolve(__dirname, BASE_SCHEMA_PATH), { enableCache: true })
115
121
  }
122
+ this.schemaExtensions = {}
116
123
  this.emit('reset')
117
124
  }
118
125
 
@@ -224,7 +231,15 @@ class Schemas extends EventEmitter {
224
231
  }
225
232
 
226
233
  this.schemas[schema.name] = schema
227
- this.schemaExtensions?.[schema.name]?.forEach(s => schema.addExtension(s))
234
+
235
+ // AJV's sync compile() can only resolve $refs against schemas in its internal store to allow `$ref: "schemaName`"
236
+ // Note we only add simple schemas (no $merge/$patch)
237
+ if (!schema.raw.$merge && !schema.raw.$patch) {
238
+ for (const v of [this.validator, this.validatorWithDefaults]) {
239
+ if (v.getSchema(schema.name)) v.removeSchema(schema.name)
240
+ v.addSchema(schema.raw, schema.name)
241
+ }
242
+ }
228
243
 
229
244
  if (schema.raw.$patch) {
230
245
  this.extendSchema(schema.raw.$patch?.source?.$ref, schema.name)
@@ -239,13 +254,16 @@ class Schemas extends EventEmitter {
239
254
  * @param {string} name Schema name to deregister
240
255
  */
241
256
  deregisterSchema (name) {
242
- if (this.schemas[name]) {
243
- delete this.schemas[name]
257
+ delete this.schemas[name]
258
+ for (const v of [this.validator, this.validatorWithDefaults]) {
259
+ if (v.getSchema(name)) v.removeSchema(name)
244
260
  }
245
- // Remove schema from any extensions lists
246
- Object.entries(this.schemaExtensions).forEach(([base, extensions]) => {
261
+ delete this.schemaExtensions[name]
262
+ for (const [base, extensions] of Object.entries(this.schemaExtensions)) {
263
+ if (!extensions.includes(name)) continue
247
264
  this.schemaExtensions[base] = extensions.filter(s => s !== name)
248
- })
265
+ this.schemas[base]?.invalidateBuild()
266
+ }
249
267
  this.emit('schemaDeregistered', name)
250
268
  }
251
269
 
@@ -266,9 +284,6 @@ class Schemas extends EventEmitter {
266
284
  ...options
267
285
  })
268
286
 
269
- this.schemaExtensions?.[schema.name]?.forEach(s => schema.addExtension(s))
270
- delete this.schemaExtensions?.[schema.name]
271
-
272
287
  return schema.load()
273
288
  }
274
289
 
@@ -278,14 +293,12 @@ class Schemas extends EventEmitter {
278
293
  * @param {string} extSchemaName The name of the schema to extend with
279
294
  */
280
295
  extendSchema (baseSchemaName, extSchemaName) {
281
- const baseSchema = this.schemas[baseSchemaName]
282
- if (baseSchema) {
283
- baseSchema.addExtension(extSchemaName)
284
- } else {
285
- if (!this.schemaExtensions[baseSchemaName]) {
286
- this.schemaExtensions[baseSchemaName] = []
287
- }
296
+ if (!this.schemaExtensions[baseSchemaName]) {
297
+ this.schemaExtensions[baseSchemaName] = []
298
+ }
299
+ if (!this.schemaExtensions[baseSchemaName].includes(extSchemaName)) {
288
300
  this.schemaExtensions[baseSchemaName].push(extSchemaName)
301
+ this.schemas[baseSchemaName]?.invalidateBuild()
289
302
  }
290
303
  this.emit('schemaExtended', baseSchemaName, extSchemaName)
291
304
  }
@@ -294,7 +307,7 @@ class Schemas extends EventEmitter {
294
307
  * Retrieves the specified schema
295
308
  * @param {string} schemaName The name of the schema to return
296
309
  * @param {Object} options
297
- * @param {boolean} options.useCache Use cached build if available
310
+ * @param {boolean} options.useCache When false, builds into a fresh isolated Schema instance so per-call options (extensionFilter, applyExtensions) don't mutate the registry's build state (default: true)
298
311
  * @param {boolean} options.compile Compile the schema (default: true)
299
312
  * @param {boolean} options.applyExtensions Apply extension schemas (default: true)
300
313
  * @param {function} options.extensionFilter Filter function for extensions
@@ -305,7 +318,10 @@ class Schemas extends EventEmitter {
305
318
  if (!schema) {
306
319
  throw new SchemaError('MISSING_SCHEMA', `Schema '${schemaName}' not found`, { schemaName })
307
320
  }
308
- return schema.build(options)
321
+ if (options.useCache !== false) return schema.build(options)
322
+ // build into a fresh instance so per-call options (e.g. extensionFilter) don't mutate the registry
323
+ const isolated = this.createSchema(schema.filePath, { enableCache: false })
324
+ return isolated.build(options)
309
325
  }
310
326
 
311
327
  /**
@@ -347,7 +363,7 @@ class Schemas extends EventEmitter {
347
363
  return Object.entries(this.schemas).reduce((info, [name, schema]) => {
348
364
  info[name] = {
349
365
  filePath: schema.filePath,
350
- extensions: schema.extensions,
366
+ extensions: this.schemaExtensions[name] ?? [],
351
367
  hasParent: !!schema.raw?.$merge?.source?.$ref,
352
368
  isPatch: !!schema.raw?.$patch
353
369
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "type": "git",
5
5
  "url": "https://github.com/cgkineo/adapt-schemas"
6
6
  },
7
- "version": "3.1.0",
7
+ "version": "3.1.2",
8
8
  "description": "Standalone JSON Schema library for the Adapt framework",
9
9
  "type": "module",
10
10
  "main": "index.js",
package/test.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Test script for the Adapt Schema Library
3
3
  */
4
- import Schemas from './index.js'
4
+ import Schemas, { XSSDefaults } from './index.js'
5
5
  import path from 'path'
6
6
  import { fileURLToPath } from 'url'
7
7
  import fs from 'fs/promises'
@@ -222,6 +222,35 @@ async function setupTestSchemas () {
222
222
  JSON.stringify(languagePickerConfigSchema, null, 2)
223
223
  )
224
224
 
225
+ // Schema used for XSS sanitization tests — string fields at top level
226
+ // and nested inside an object so sanitise() recursion is exercised.
227
+ const xssTestSchema = {
228
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
229
+ $anchor: 'xss-test',
230
+ $merge: {
231
+ source: { $ref: 'base' },
232
+ with: {
233
+ properties: {
234
+ body: { type: 'string', default: '' },
235
+ title: { type: 'string', default: '' },
236
+ count: { type: 'number', default: 0 },
237
+ meta: {
238
+ type: 'object',
239
+ properties: {
240
+ description: { type: 'string', default: '' },
241
+ author: { type: 'string', default: '' }
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+ }
248
+
249
+ await fs.writeFile(
250
+ path.join(testSchemaDir, 'xss-test.schema.json'),
251
+ JSON.stringify(xssTestSchema, null, 2)
252
+ )
253
+
225
254
  // Create a patch schema that extends course
226
255
  const coursePatchSchema = {
227
256
  $schema: 'https://json-schema.org/draft/2020-12/schema',
@@ -574,6 +603,311 @@ async function runTests () {
574
603
  }
575
604
  console.log('')
576
605
 
606
+ // Test 19: XSS sanitization — dangerous content is stripped or escaped
607
+ console.log('Test 19: XSS sanitization — dangerous payloads')
608
+ const xssSchema = await library.getSchema('xss-test')
609
+ const sanitiseBody = input => xssSchema.sanitise({ body: input }, { sanitiseHtml: true, strict: false }).body
610
+
611
+ const dangerousCases = [
612
+ {
613
+ desc: '<script> tag escaped',
614
+ input: '<script>alert(1)</script>',
615
+ check: out => !/<script/i.test(out) && !out.includes('alert(1)</script>')
616
+ },
617
+ {
618
+ desc: 'onerror handler stripped from <img>',
619
+ input: '<img src=x onerror="alert(1)">',
620
+ check: out => !/onerror/i.test(out)
621
+ },
622
+ {
623
+ desc: 'onclick handler stripped from allowed <a>',
624
+ input: '<a href="https://example.com" onclick="evil()">link</a>',
625
+ check: out => !/onclick/i.test(out) && out.includes('example.com')
626
+ },
627
+ {
628
+ desc: 'javascript: URL stripped from <a href>',
629
+ input: '<a href="javascript:alert(1)">click</a>',
630
+ check: out => !/javascript:/i.test(out)
631
+ },
632
+ {
633
+ desc: '<iframe> (not whitelisted) escaped',
634
+ input: '<iframe src="https://evil.com"></iframe>',
635
+ check: out => !/<iframe/i.test(out)
636
+ },
637
+ {
638
+ desc: '<svg onload> (not whitelisted) escaped',
639
+ input: '<svg onload="alert(1)"></svg>',
640
+ check: out => !/<svg/i.test(out)
641
+ },
642
+ {
643
+ desc: '<object> (not whitelisted) escaped',
644
+ input: '<object data="evil.swf"></object>',
645
+ check: out => !/<object/i.test(out)
646
+ },
647
+ {
648
+ desc: 'style attribute (not whitelisted) stripped from <div>',
649
+ input: '<div style="background:url(javascript:alert(1))">x</div>',
650
+ check: out => !/style=/i.test(out) && !/javascript:/i.test(out)
651
+ },
652
+ {
653
+ desc: 'data: URI in href stripped',
654
+ input: '<a href="data:text/html,<script>alert(1)</script>">x</a>',
655
+ check: out => !/data:text\/html/i.test(out) && !/<script/i.test(out)
656
+ }
657
+ ]
658
+
659
+ const dangerousResults = dangerousCases.map(({ desc, input, check }) => {
660
+ const out = sanitiseBody(input)
661
+ const ok = check(out)
662
+ return { desc, input, out, ok }
663
+ })
664
+ dangerousResults.forEach(({ desc, input, out, ok }) => {
665
+ console.log(` ${ok ? '✓' : '✗'} ${desc}`)
666
+ if (!ok) console.log(` input: ${JSON.stringify(input)}\n output: ${JSON.stringify(out)}`)
667
+ })
668
+ const dangerousFailed = dangerousResults.filter(r => !r.ok)
669
+ if (dangerousFailed.length) {
670
+ throw new Error(`Dangerous-payload failures: ${dangerousFailed.map(r => r.desc).join(', ')}`)
671
+ }
672
+ console.log('')
673
+
674
+ // Test 20: XSS sanitization — safe content passes through
675
+ console.log('Test 20: XSS sanitization — safe content preserved')
676
+ const safeCases = [
677
+ { desc: 'plain text unchanged', input: 'Hello, world.', check: out => out === 'Hello, world.' },
678
+ { desc: 'empty string unchanged', input: '', check: out => out === '' },
679
+ { desc: '<b> preserved', input: '<b>bold</b>', check: out => /<b>bold<\/b>/.test(out) },
680
+ { desc: '<strong> preserved', input: '<strong>x</strong>', check: out => /<strong>x<\/strong>/.test(out) },
681
+ { desc: '<em> preserved', input: '<em>x</em>', check: out => /<em>x<\/em>/.test(out) },
682
+ {
683
+ desc: '<a href=https://> preserved with href',
684
+ input: '<a href="https://example.com">link</a>',
685
+ check: out => /<a[^>]+href="https:\/\/example\.com"[^>]*>link<\/a>/.test(out)
686
+ },
687
+ {
688
+ desc: '<a href=http://> (http) preserved',
689
+ input: '<a href="http://example.com">link</a>',
690
+ check: out => /href="http:\/\/example\.com"/.test(out)
691
+ },
692
+ {
693
+ desc: 'nested allowed tags preserved',
694
+ input: '<p><strong>bold</strong> and <em>italic</em></p>',
695
+ check: out => /<strong>bold<\/strong>/.test(out) && /<em>italic<\/em>/.test(out)
696
+ },
697
+ {
698
+ desc: 'text with ampersand entities preserved',
699
+ input: 'Fish &amp; chips',
700
+ check: out => out.includes('&amp;') || out.includes('&')
701
+ }
702
+ ]
703
+
704
+ const safeResults = safeCases.map(({ desc, input, check }) => {
705
+ const out = sanitiseBody(input)
706
+ return { desc, input, out, ok: check(out) }
707
+ })
708
+ safeResults.forEach(({ desc, input, out, ok }) => {
709
+ console.log(` ${ok ? '✓' : '✗'} ${desc}`)
710
+ if (!ok) console.log(` input: ${JSON.stringify(input)}\n output: ${JSON.stringify(out)}`)
711
+ })
712
+ const safeFailed = safeResults.filter(r => !r.ok)
713
+ if (safeFailed.length) {
714
+ throw new Error(`Safe-content failures: ${safeFailed.map(r => r.desc).join(', ')}`)
715
+ }
716
+ console.log('')
717
+
718
+ // Test 21: XSS sanitization — nested objects and non-string fields
719
+ console.log('Test 21: XSS sanitization — recursion + type handling')
720
+ const nestedInput = {
721
+ body: '<script>alert("top")</script><b>safe</b>',
722
+ title: '<img src=x onerror=alert(1)>',
723
+ count: 42,
724
+ meta: {
725
+ description: '<iframe src="evil"></iframe>clean',
726
+ author: '<a href="javascript:bad()">me</a>'
727
+ }
728
+ }
729
+ const nestedOut = xssSchema.sanitise(nestedInput, { sanitiseHtml: true, strict: false })
730
+ const nestedChecks = [
731
+ ['top-level string sanitized (script removed)', !/<script/i.test(nestedOut.body)],
732
+ ['top-level string sanitized (b preserved)', /<b>safe<\/b>/.test(nestedOut.body)],
733
+ ['top-level string sanitized (onerror removed)', !/onerror/i.test(nestedOut.title)],
734
+ ['number field passed through untouched', nestedOut.count === 42],
735
+ ['nested string sanitized (iframe escaped)', !/<iframe/i.test(nestedOut.meta.description)],
736
+ ['nested string sanitized (javascript: stripped)', !/javascript:/i.test(nestedOut.meta.author)]
737
+ ]
738
+ nestedChecks.forEach(([desc, ok]) => console.log(` ${ok ? '✓' : '✗'} ${desc}: ${ok}`))
739
+ const nestedFailed = nestedChecks.filter(([, ok]) => !ok)
740
+ if (nestedFailed.length) {
741
+ throw new Error(`Nested sanitization failures: ${nestedFailed.map(([d]) => d).join(', ')}`)
742
+ }
743
+ console.log('')
744
+
745
+ // Test 22: xssWhitelistOverride replaces defaults at construction
746
+ console.log('Test 22: xssWhitelistOverride replaces defaults at construction')
747
+ const restrictiveLibrary = new Schemas({
748
+ enableCache: false,
749
+ xssWhitelistOverride: true,
750
+ xssWhitelist: { strong: [] } // only <strong> allowed, no attributes
751
+ })
752
+ restrictiveLibrary.init()
753
+ await restrictiveLibrary.loadSchemas('xss-test.schema.json', { cwd: testSchemaDir })
754
+ const restrictiveSchema = await restrictiveLibrary.getSchema('xss-test')
755
+ const restrictiveSanitise = input =>
756
+ restrictiveSchema.sanitise({ body: input }, { sanitiseHtml: true, strict: false }).body
757
+
758
+ const overrideChecks = [
759
+ ['<strong> (in custom list) preserved', /<strong>x<\/strong>/.test(restrictiveSanitise('<strong>x</strong>'))],
760
+ ['<b> (in defaults, not custom) escaped', !/<b>x<\/b>/.test(restrictiveSanitise('<b>x</b>'))],
761
+ ['<a> (in defaults, not custom) escaped', !/<a /i.test(restrictiveSanitise('<a href="https://example.com">x</a>'))],
762
+ ['<script> still escaped', !/<script/i.test(restrictiveSanitise('<script>alert(1)</script>'))]
763
+ ]
764
+ overrideChecks.forEach(([desc, ok]) => console.log(` ${ok ? '✓' : '✗'} ${desc}: ${ok}`))
765
+ const overrideFailed = overrideChecks.filter(([, ok]) => !ok)
766
+ if (overrideFailed.length) {
767
+ throw new Error(`xssWhitelistOverride failures: ${overrideFailed.map(([d]) => d).join(', ')}`)
768
+ }
769
+ console.log('')
770
+
771
+ // Test 23: Exhaustive round-trip of XSSDefaults — every allowed tag and
772
+ // every allowed attribute on that tag survives; a disallowed attribute
773
+ // (onclick, not in any default list) is stripped from every tag.
774
+ console.log('Test 23: Exhaustive XSSDefaults coverage')
775
+ const voidTags = new Set(['area', 'br', 'col', 'hr', 'img', 'wbr'])
776
+ const urlAttrs = new Set(['href', 'src', 'cite', 'poster'])
777
+ const numericAttrs = new Set([
778
+ 'colspan', 'coords', 'height', 'rowspan', 'size', 'span', 'tabindex', 'width'
779
+ ])
780
+ const booleanAttrs = new Set([
781
+ 'autoplay', 'controls', 'loop', 'muted', 'open', 'playsinline', 'preload'
782
+ ])
783
+
784
+ const safeAttrValue = attr => {
785
+ if (urlAttrs.has(attr)) return 'https://example.com/x'
786
+ if (numericAttrs.has(attr)) return '1'
787
+ if (booleanAttrs.has(attr)) return attr
788
+ if (attr === 'datetime') return '2024-01-01T00:00:00Z'
789
+ if (attr === 'dir') return 'ltr'
790
+ if (attr === 'align' || attr === 'valign') return 'left'
791
+ if (attr === 'shape') return 'rect'
792
+ if (attr === 'lang') return 'en'
793
+ if (attr === 'role') return 'button'
794
+ if (attr === 'color') return 'red'
795
+ return 'test'
796
+ }
797
+
798
+ const openTag = (tag, attrs) => {
799
+ const attrStr = attrs ? ` ${attrs}` : ''
800
+ return voidTags.has(tag) ? `<${tag}${attrStr}>` : `<${tag}${attrStr}>x</${tag}>`
801
+ }
802
+
803
+ const exhaustiveLibrary = new Schemas({ enableCache: false })
804
+ exhaustiveLibrary.init()
805
+ await exhaustiveLibrary.loadSchemas('xss-test.schema.json', { cwd: testSchemaDir })
806
+ const exhaustiveSchema = await exhaustiveLibrary.getSchema('xss-test')
807
+ const san = input => exhaustiveSchema.sanitise({ body: input }, { sanitiseHtml: true, strict: false }).body
808
+
809
+ const tagFailures = []
810
+ const attrFailures = []
811
+ const disallowedFailures = []
812
+ let tagCount = 0
813
+ let attrCount = 0
814
+
815
+ Object.entries(XSSDefaults).forEach(([tag, attrs]) => {
816
+ tagCount++
817
+
818
+ const tagOut = san(openTag(tag))
819
+ if (!tagOut.includes(`<${tag}`)) {
820
+ tagFailures.push({ tag, output: tagOut })
821
+ }
822
+
823
+ const badInput = openTag(tag, 'onclick="evil()"')
824
+ const badOut = san(badInput)
825
+ if (/onclick/i.test(badOut)) {
826
+ disallowedFailures.push({ tag, output: badOut })
827
+ }
828
+
829
+ attrs.forEach(attr => {
830
+ attrCount++
831
+ const val = safeAttrValue(attr)
832
+ const out = san(openTag(tag, `${attr}="${val}"`))
833
+ if (!out.toLowerCase().includes(`${attr.toLowerCase()}=`)) {
834
+ attrFailures.push({ tag, attr, val, output: out })
835
+ }
836
+ })
837
+ })
838
+
839
+ console.log(` ${tagFailures.length === 0 ? '✓' : '✗'} ${tagCount} tags preserved (${tagFailures.length} failures)`)
840
+ tagFailures.forEach(({ tag, output }) => console.log(` - <${tag}> → ${JSON.stringify(output)}`))
841
+ console.log(` ${attrFailures.length === 0 ? '✓' : '✗'} ${attrCount} (tag, attr) pairs preserved (${attrFailures.length} failures)`)
842
+ attrFailures.forEach(({ tag, attr, val, output }) =>
843
+ console.log(` - <${tag} ${attr}="${val}"> → ${JSON.stringify(output)}`)
844
+ )
845
+ console.log(` ${disallowedFailures.length === 0 ? '✓' : '✗'} ${tagCount} onclick-strip checks (${disallowedFailures.length} failures)`)
846
+ disallowedFailures.forEach(({ tag, output }) => console.log(` - <${tag} onclick> → ${JSON.stringify(output)}`))
847
+
848
+ const totalExhaustiveFailures = tagFailures.length + attrFailures.length + disallowedFailures.length
849
+ if (totalExhaustiveFailures > 0) {
850
+ throw new Error(`XSSDefaults exhaustive check: ${totalExhaustiveFailures} total failures`)
851
+ }
852
+ console.log('')
853
+
854
+ // Test 24: deregisterSchema cleans up extension references (issue #37)
855
+ console.log('Test 24: deregisterSchema cleans up extension references (issue #37)')
856
+ const extPatchPath = path.join(testSchemaDir, 'patch-ext.schema.json')
857
+ await fs.writeFile(extPatchPath, JSON.stringify({
858
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
859
+ $anchor: 'patch-ext',
860
+ type: 'object',
861
+ $patch: { source: { $ref: 'config' }, with: { properties: { _patchAdded: { type: 'string', default: 'added' } } } }
862
+ }, null, 2))
863
+ library.registerSchema(extPatchPath)
864
+ const beforeDeregister = library.schemaExtensions.config?.includes('patch-ext')
865
+ console.log(` ✓ Extension registered against base: ${beforeDeregister}`)
866
+ const builtWithExt = (await library.getSchema('config')).built
867
+ const hasPatchedProperty = '_patchAdded' in (builtWithExt.properties ?? {})
868
+ console.log(` ✓ Patched property visible in cached build: ${hasPatchedProperty}`)
869
+ library.deregisterSchema('patch-ext')
870
+ const afterDeregister = !library.schemaExtensions.config?.includes('patch-ext')
871
+ console.log(` ✓ Extension removed from base after deregister: ${afterDeregister}`)
872
+ const cacheInvalidated = library.schemas.config?.built === undefined
873
+ console.log(` ✓ Base cached build invalidated on deregister: ${cacheInvalidated}`)
874
+ let buildSucceeded = true
875
+ let postRebuildHasPatch = true
876
+ try {
877
+ const rebuilt = (await library.getSchema('config')).built
878
+ postRebuildHasPatch = '_patchAdded' in (rebuilt.properties ?? {})
879
+ } catch (e) {
880
+ buildSucceeded = false
881
+ }
882
+ console.log(` ✓ Cached rebuild of base after deregister succeeds: ${buildSucceeded}`)
883
+ console.log(` ✓ Patched property gone from rebuilt schema: ${!postRebuildHasPatch}`)
884
+ if (!beforeDeregister || !hasPatchedProperty || !afterDeregister || !cacheInvalidated || !buildSucceeded || postRebuildHasPatch) {
885
+ throw new Error('deregisterSchema did not clean up extension references and cached builds correctly')
886
+ }
887
+ console.log('')
888
+
889
+ // Test 25: useCache:false isolates filtered builds from the registry instance
890
+ console.log('Test 25: useCache:false isolates filtered builds from the registry instance')
891
+ const includeLP = s => s === 'languagePicker-config'
892
+ const excludeLP = () => false
893
+ const registrySchema = library.schemas.config
894
+ const withLPSchema = await library.getSchema('config', { useCache: false, extensionFilter: includeLP })
895
+ const withoutLPSchema = await library.getSchema('config', { useCache: false, extensionFilter: excludeLP })
896
+ const withLPHas = '_languagePicker' in (withLPSchema.built.properties ?? {})
897
+ const withoutLPHas = '_languagePicker' in (withoutLPSchema.built.properties ?? {})
898
+ console.log(` ✓ Filter-include build has _languagePicker: ${withLPHas}`)
899
+ console.log(` ✓ Filter-exclude build lacks _languagePicker: ${!withoutLPHas}`)
900
+ // earlier build's properties must not be mutated by the later build (the original race)
901
+ const withLPStillHas = '_languagePicker' in (withLPSchema.built.properties ?? {})
902
+ console.log(` ✓ Earlier filter-include build is unaffected by later call: ${withLPStillHas}`)
903
+ // each filtered call must return a fresh instance, not the registry one
904
+ const isolatedFromRegistry = withLPSchema !== registrySchema && withoutLPSchema !== registrySchema && withLPSchema !== withoutLPSchema
905
+ console.log(` ✓ Filtered builds return isolated Schema instances: ${isolatedFromRegistry}`)
906
+ if (!withLPHas || withoutLPHas || !withLPStillHas || !isolatedFromRegistry) {
907
+ throw new Error('useCache:false did not isolate filtered builds from the registry')
908
+ }
909
+ console.log('')
910
+
577
911
  console.log('=== All tests passed! ===')
578
912
  } finally {
579
913
  if (!hasSpecifiedPath) {