feat: Reinitialize frontend with SvelteKit and TypeScript

- Delete old Vite+Svelte frontend
- Initialize new SvelteKit project with TypeScript
- Configure Tailwind CSS v4 + DaisyUI
- Implement JWT authentication with auto-refresh
- Create login page with form validation (Zod)
- Add protected route guards
- Update Docker configuration for single-stage build
- Add E2E tests with Playwright (6/11 passing)
- Fix Svelte 5 reactivity with $state() runes

Known issues:
- 5 E2E tests failing (timing/async issues)
- Token refresh implementation needs debugging
- Validation error display timing
This commit is contained in:
2026-02-17 16:19:59 -05:00
parent 54df6018f5
commit de2d83092e
28274 changed files with 3816354 additions and 90 deletions

1392
frontend/node_modules/@exodus/schemasafe/src/compile.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

208
frontend/node_modules/@exodus/schemasafe/src/formats.js generated vendored Normal file
View File

@@ -0,0 +1,208 @@
'use strict'
const core = {
// matches ajv + length checks + does not start with a dot
// note that quoted emails are deliberately unsupported (as in ajv), who would want \x01 in email
// first check is an additional fast path with lengths: 20+(1+21)*2 = 64, (1+61+1)+((1+60+1)+1)*3 = 252 < 253, that should cover most valid emails
// max length is 64 (name) + 1 (@) + 253 (host), we want to ensure that prior to feeding to the fast regex
// the second regex checks for quoted, starting-leading dot in name, and two dots anywhere
email: (input) => {
if (input.length > 318) return false
const fast = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]{1,20}(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]{1,21}){0,2}@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,60}[a-z0-9])?){0,3}$/i
if (fast.test(input)) return true
if (!input.includes('@') || /(^\.|^"|\.@|\.\.)/.test(input)) return false
const [name, host, ...rest] = input.split('@')
if (!name || !host || rest.length !== 0 || name.length > 64 || host.length > 253) return false
if (!/^[a-z0-9.-]+$/i.test(host) || !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/i.test(name)) return false
return host.split('.').every((part) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(part))
},
// matches ajv + length checks
hostname: (input) => {
if (input.length > (input.endsWith('.') ? 254 : 253)) return false
const hostname = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\.?$/i
return hostname.test(input)
},
// 'time' matches ajv + length checks, 'date' matches ajv full
// date: https://tools.ietf.org/html/rfc3339#section-5.6
// date-time: https://tools.ietf.org/html/rfc3339#section-5.6
// leap year: https://tools.ietf.org/html/rfc3339#appendix-C
// 11: 1990-01-01, 1: T, 9: 00:00:00., 12: maxiumum fraction length (non-standard), 6: +00:00
date: (input) => {
if (input.length !== 10) return false
if (input[5] === '0' && input[6] === '2') {
if (/^\d\d\d\d-02-(?:[012][1-8]|[12]0|[01]9)$/.test(input)) return true
const matches = input.match(/^(\d\d\d\d)-02-29$/)
if (!matches) return false
const year = matches[1] | 0
return year % 16 === 0 || (year % 4 === 0 && year % 25 !== 0)
}
if (input.endsWith('31')) return /^\d\d\d\d-(?:0[13578]|1[02])-31$/.test(input)
return /^\d\d\d\d-(?:0[13-9]|1[012])-(?:[012][1-9]|[123]0)$/.test(input)
},
// leap second handling is special, we check it's 23:59:60.*
time: (input) => {
if (input.length > 9 + 12 + 6) return false
const time = /^(?:2[0-3]|[0-1]\d):[0-5]\d:(?:[0-5]\d|60)(?:\.\d+)?(?:z|[+-](?:2[0-3]|[0-1]\d)(?::?[0-5]\d)?)?$/i
if (!time.test(input)) return false
if (!/:60/.test(input)) return true
const p = input.match(/([0-9.]+|[^0-9.])/g)
let hm = Number(p[0]) * 60 + Number(p[2])
if (p[5] === '+') hm += 24 * 60 - Number(p[6] || 0) * 60 - Number(p[8] || 0)
else if (p[5] === '-') hm += Number(p[6] || 0) * 60 + Number(p[8] || 0)
return hm % (24 * 60) === 23 * 60 + 59
},
// first two lines specific to date-time, then tests for unanchored (at end) date, code identical to 'date' above
// input[17] === '6' is a check for :60
'date-time': (input) => {
if (input.length > 10 + 1 + 9 + 12 + 6) return false
const full = /^\d\d\d\d-(?:0[1-9]|1[0-2])-(?:[0-2]\d|3[01])[t\s](?:2[0-3]|[0-1]\d):[0-5]\d:(?:[0-5]\d|60)(?:\.\d+)?(?:z|[+-](?:2[0-3]|[0-1]\d)(?::?[0-5]\d)?)$/i
const feb = input[5] === '0' && input[6] === '2'
if ((feb && input[8] === '3') || !full.test(input)) return false
if (input[17] === '6') {
const p = input.slice(11).match(/([0-9.]+|[^0-9.])/g)
let hm = Number(p[0]) * 60 + Number(p[2])
if (p[5] === '+') hm += 24 * 60 - Number(p[6] || 0) * 60 - Number(p[8] || 0)
else if (p[5] === '-') hm += Number(p[6] || 0) * 60 + Number(p[8] || 0)
if (hm % (24 * 60) !== 23 * 60 + 59) return false
}
if (feb) {
if (/^\d\d\d\d-02-(?:[012][1-8]|[12]0|[01]9)/.test(input)) return true
const matches = input.match(/^(\d\d\d\d)-02-29/)
if (!matches) return false
const year = matches[1] | 0
return year % 16 === 0 || (year % 4 === 0 && year % 25 !== 0)
}
if (input[8] === '3' && input[9] === '1') return /^\d\d\d\d-(?:0[13578]|1[02])-31/.test(input)
return /^\d\d\d\d-(?:0[13-9]|1[012])-(?:[012][1-9]|[123]0)/.test(input)
},
/* ipv4 and ipv6 are from ajv with length restriction */
// optimized https://www.safaribooksonline.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html
ipv4: (ip) =>
ip.length <= 15 &&
/^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d\d?)$/.test(ip),
// optimized http://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses
// max length: 1000:1000:1000:1000:1000:1000:255.255.255.255
// we parse ip6 format with a simple scan, leaving embedded ipv4 validation to a regex
// s0=count(:), s1=count(.), hex=count(a-zA-Z0-9), short=count(::)>0
// 48-57: '0'-'9', 97-102, 65-70: 'a'-'f', 'A'-'F', 58: ':', 46: '.'
/* eslint-disable one-var */
// prettier-ignore
ipv6: (input) => {
if (input.length > 45 || input.length < 2) return false
let s0 = 0, s1 = 0, hex = 0, short = false, letters = false, last = 0, start = true
for (let i = 0; i < input.length; i++) {
const c = input.charCodeAt(i)
if (i === 1 && last === 58 && c !== 58) return false
if (c >= 48 && c <= 57) {
if (++hex > 4) return false
} else if (c === 46) {
if (s0 > 6 || s1 >= 3 || hex === 0 || letters) return false
s1++
hex = 0
} else if (c === 58) {
if (s1 > 0 || s0 >= 7) return false
if (last === 58) {
if (short) return false
short = true
} else if (i === 0) start = false
s0++
hex = 0
letters = false
} else if ((c >= 97 && c <= 102) || (c >= 65 && c <= 70)) {
if (s1 > 0) return false
if (++hex > 4) return false
letters = true
} else return false
last = c
}
if (s0 < 2 || (s1 > 0 && (s1 !== 3 || hex === 0))) return false
if (short && input.length === 2) return true
if (s1 > 0 && !/(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/.test(input)) return false
const spaces = s1 > 0 ? 6 : 7
if (!short) return s0 === spaces && start && hex > 0
return (start || hex > 0) && s0 < spaces
},
/* eslint-enable one-var */
// matches ajv with optimization
uri: /^[a-z][a-z0-9+\-.]*:(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|v[0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/?(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i,
// matches ajv with optimization
'uri-reference': /^(?:[a-z][a-z0-9+\-.]*:)?(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|v[0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/?(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?)?(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i,
// ajv has /^(([^\x00-\x20"'<>%\\^`{|}]|%[0-9a-f]{2})|\{[+#./;?&=,!@|]?([a-z0-9_]|%[0-9a-f]{2})+(:[1-9][0-9]{0,3}|\*)?(,([a-z0-9_]|%[0-9a-f]{2})+(:[1-9][0-9]{0,3}|\*)?)*\})*$/i
// this is equivalent
// uri-template: https://tools.ietf.org/html/rfc6570
// eslint-disable-next-line no-control-regex
'uri-template': /^(?:[^\x00-\x20"'<>%\\^`{|}]|%[0-9a-f]{2}|\{[+#./;?&=,!@|]?(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?(?:,(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?)*\})*$/i,
// ajv has /^(\/([^~/]|~0|~1)*)*$/, this is equivalent
// JSON-pointer: https://tools.ietf.org/html/rfc6901
'json-pointer': /^(?:|\/(?:[^~]|~0|~1)*)$/,
// ajv has /^(0|[1-9][0-9]*)(#|(\/([^~/]|~0|~1)*)*)$/, this is equivalent
// relative JSON-pointer: http://tools.ietf.org/html/draft-luff-relative-json-pointer-00
'relative-json-pointer': /^(?:0|[1-9][0-9]*)(?:|#|\/(?:[^~]|~0|~1)*)$/,
// uuid: http://tools.ietf.org/html/rfc4122
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
// length restriction is an arbitrary safeguard
// first regex checks if this a week duration (can't be combined with others)
// second regex verifies symbols, no more than one fraction, at least 1 block is present, and T is not last
// third regex verifies structure
duration: (input) =>
input.length > 1 &&
input.length < 80 &&
(/^P\d+([.,]\d+)?W$/.test(input) ||
(/^P[\dYMDTHS]*(\d[.,]\d+)?[YMDHS]$/.test(input) &&
/^P([.,\d]+Y)?([.,\d]+M)?([.,\d]+D)?(T([.,\d]+H)?([.,\d]+M)?([.,\d]+S)?)?$/.test(input))),
// TODO: iri, iri-reference, idn-email, idn-hostname
}
const extra = {
// basic
alpha: /^[a-zA-Z]+$/,
alphanumeric: /^[a-zA-Z0-9]+$/,
// hex
'hex-digits': /^[0-9a-f]+$/i,
'hex-digits-prefixed': /^0x[0-9a-f]+$/i,
'hex-bytes': /^([0-9a-f][0-9a-f])+$/i,
'hex-bytes-prefixed': /^0x([0-9a-f][0-9a-f])+$/i,
base64: (input) => input.length % 4 === 0 && /^[a-z0-9+/]*={0,3}$/i.test(input),
// ajv has /^#(\/([a-z0-9_\-.!$&'()*+,;:=@]|%[0-9a-f]{2}|~0|~1)*)*$/i, this is equivalent
// uri fragment: https://tools.ietf.org/html/rfc3986#appendix-A
'json-pointer-uri-fragment': /^#(|\/(\/|[a-z0-9_\-.!$&'()*+,;:=@]|%[0-9a-f]{2}|~0|~1)*)$/i,
// draft3 backwards compat
'host-name': core.hostname,
'ip-address': core.ipv4,
// manually cleaned up from is-my-json-valid, CSS 2.1 colors only per draft03 spec
color: /^(#[0-9A-Fa-f]{3,6}|aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|white|yellow|rgb\(\s*([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\s*,\s*([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\s*,\s*([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\s*\)|rgb\(\s*(\d?\d%|100%)\s*,\s*(\d?\d%|100%)\s*,\s*(\d?\d%|100%)\s*\))$/,
// style is deliberately unsupported, don't accept untrusted styles
}
const weak = {
// In weak because don't accept regexes from untrusted sources, using them can cause DoS
// matches ajv + length checks
// eslint comment outside because we don't want comments in functions, those affect output
/* eslint-disable no-new */
regex: (str) => {
if (str.length > 1e5) return false
const Z_ANCHOR = /[^\\]\\Z/
if (Z_ANCHOR.test(str)) return false
try {
new RegExp(str, 'u')
return true
} catch (e) {
return false
}
},
/* eslint-enable no-new */
}
module.exports = { core, extra, weak }

View File

@@ -0,0 +1,104 @@
'use strict'
const { format, safe, safenot } = require('./safe-format')
const { jaystring } = require('./javascript')
/*
* Construct a function from lines/blocks/if conditions.
*
* Returns a Function instance (makeFunction) or code in text format (makeModule).
*/
const INDENT_START = /[{[]/
const INDENT_END = /[}\]]/
module.exports = () => {
const lines = []
let indent = 0
const pushLine = (line) => {
if (INDENT_END.test(line.trim()[0])) indent--
lines.push({ indent, code: line })
if (INDENT_START.test(line[line.length - 1])) indent++
}
const build = () => {
if (indent !== 0) throw new Error('Unexpected indent at build()')
const joined = lines.map((line) => format('%w%s', line.indent * 2, line.code)).join('\n')
return /^[a-z][a-z0-9]*$/i.test(joined) ? `return ${joined}` : `return (${joined})`
}
const processScope = (scope) => {
const entries = Object.entries(scope)
for (const [key, value] of entries) {
if (!/^[a-z][a-z0-9]*$/i.test(key)) throw new Error('Unexpected scope key!')
if (!(typeof value === 'function' || value instanceof RegExp))
throw new Error('Unexpected scope value!')
}
return entries
}
return {
optimizedOut: false, // some branch of code has been optimized out
size: () => lines.length,
write(fmt, ...args) {
if (typeof fmt !== 'string') throw new Error('Format must be a string!')
if (fmt.includes('\n')) throw new Error('Only single lines are supported')
pushLine(format(fmt, ...args))
return true // code was written
},
block(prefix, writeBody, noInline = false) {
const oldIndent = indent
this.write('%s {', prefix)
const length = lines.length
writeBody()
if (length === lines.length) {
// no lines inside block, unwind the block
lines.pop()
indent = oldIndent
return false // nothing written
} else if (length === lines.length - 1 && !noInline) {
// a single line has been written, inline it if opt-in allows
const { code } = lines[lines.length - 1]
// check below is just for generating more readable code, it's safe to inline all !noInline
if (!/^(if|for) /.test(code)) {
lines.length -= 2
indent = oldIndent
return this.write('%s %s', prefix, code)
}
}
return this.write('}')
},
if(condition, writeBody, writeElse) {
if (`${condition}` === 'false') {
if (writeElse) writeElse()
if (writeBody) this.optimizedOut = true
} else if (`${condition}` === 'true') {
if (writeBody) writeBody()
if (writeElse) this.optimizedOut = true
} else if (writeBody && this.block(format('if (%s)', condition), writeBody, !!writeElse)) {
if (writeElse) this.block(format('else'), writeElse) // !!writeElse above ensures {} wrapping before `else`
} else if (writeElse) {
this.if(safenot(condition), writeElse)
}
},
makeModule(scope = {}) {
const scopeDefs = processScope(scope).map(
([key, val]) => `const ${safe(key)} = ${jaystring(val)};`
)
return `(function() {\n'use strict'\n${scopeDefs.join('\n')}\n${build()}})()`
},
makeFunction(scope = {}) {
const scopeEntries = processScope(scope)
const keys = scopeEntries.map((entry) => entry[0])
const vals = scopeEntries.map((entry) => entry[1])
// eslint-disable-next-line no-new-func
return Function(...keys, `'use strict'\n${build()}`)(...vals)
},
}
}

98
frontend/node_modules/@exodus/schemasafe/src/index.js generated vendored Normal file
View File

@@ -0,0 +1,98 @@
'use strict'
const genfun = require('./generate-function')
const { buildSchemas } = require('./pointer')
const { compile } = require('./compile')
const { deepEqual } = require('./scope-functions')
const jsonCheckWithErrors = (validate) =>
function validateIsJSON(data) {
if (!deepEqual(data, JSON.parse(JSON.stringify(data)))) {
validateIsJSON.errors = [{ instanceLocation: '#', error: 'not JSON compatible' }]
return false
}
const res = validate(data)
validateIsJSON.errors = validate.errors
return res
}
const jsonCheckWithoutErrors = (validate) => (data) =>
deepEqual(data, JSON.parse(JSON.stringify(data))) && validate(data)
const validator = (
schema,
{ parse = false, multi = false, jsonCheck = false, isJSON = false, schemas = [], ...opts } = {}
) => {
if (jsonCheck && isJSON) throw new Error('Can not specify both isJSON and jsonCheck options')
if (parse && (jsonCheck || isJSON))
throw new Error('jsonCheck and isJSON options are not applicable in parser mode')
const mode = parse ? 'strong' : 'default' // strong mode is default in parser, can be overriden
const willJSON = isJSON || jsonCheck || parse
const arg = multi ? schema : [schema]
const options = { mode, ...opts, schemas: buildSchemas(schemas, arg), isJSON: willJSON }
const { scope, refs } = compile(arg, options) // only a single ref
if (opts.dryRun) return
if (opts.lint) return scope.lintErrors
const fun = genfun()
if (parse) {
scope.parseWrap = opts.includeErrors ? parseWithErrors : parseWithoutErrors
} else if (jsonCheck) {
scope.deepEqual = deepEqual
scope.jsonCheckWrap = opts.includeErrors ? jsonCheckWithErrors : jsonCheckWithoutErrors
}
if (multi) {
fun.write('[')
for (const ref of refs.slice(0, -1)) fun.write('%s,', ref)
if (refs.length > 0) fun.write('%s', refs[refs.length - 1])
fun.write(']')
if (parse) fun.write('.map(parseWrap)')
else if (jsonCheck) fun.write('.map(jsonCheckWrap)')
} else {
if (parse) fun.write('parseWrap(%s)', refs[0])
else if (jsonCheck) fun.write('jsonCheckWrap(%s)', refs[0])
else fun.write('%s', refs[0])
}
const validate = fun.makeFunction(scope)
validate.toModule = ({ semi = true } = {}) => fun.makeModule(scope) + (semi ? ';' : '')
validate.toJSON = () => schema
return validate
}
const parseWithErrors = (validate) => (src) => {
if (typeof src !== 'string') return { valid: false, error: 'Input is not a string' }
try {
const value = JSON.parse(src)
if (!validate(value)) {
const { keywordLocation, instanceLocation } = validate.errors[0]
const keyword = keywordLocation.slice(keywordLocation.lastIndexOf('/') + 1)
const error = `JSON validation failed for ${keyword} at ${instanceLocation}`
return { valid: false, error, errors: validate.errors }
}
return { valid: true, value }
} catch ({ message }) {
return { valid: false, error: message }
}
}
const parseWithoutErrors = (validate) => (src) => {
if (typeof src !== 'string') return { valid: false }
try {
const value = JSON.parse(src)
if (!validate(value)) return { valid: false }
return { valid: true, value }
} catch (e) {
return { valid: false }
}
}
const parser = function(schema, { parse = true, ...opts } = {}) {
if (!parse) throw new Error('can not disable parse in parser')
return validator(schema, { parse, ...opts })
}
const lint = function(schema, { lint: lintOption = true, ...opts } = {}) {
if (!lintOption) throw new Error('can not disable lint option in lint()')
return validator(schema, { lint: lintOption, ...opts })
}
module.exports = { validator, parser, lint }

View File

@@ -0,0 +1,170 @@
'use strict'
const { format, safe } = require('./safe-format')
const { scopeMethods } = require('./scope-utils')
const functions = require('./scope-functions')
// for building into the validation function
const types = new Map(
Object.entries({
null: (name) => format('%s === null', name),
boolean: (name) => format('typeof %s === "boolean"', name),
array: (name) => format('Array.isArray(%s)', name),
object: (n) => format('typeof %s === "object" && %s && !Array.isArray(%s)', n, n, n),
number: (name) => format('typeof %s === "number"', name),
integer: (name) => format('Number.isInteger(%s)', name),
string: (name) => format('typeof %s === "string"', name),
})
)
const buildName = ({ name, parent, keyval, keyname }) => {
if (name) {
if (parent || keyval || keyname) throw new Error('name can be used only stand-alone')
return name // top-level
}
if (!parent) throw new Error('Can not use property of undefined parent!')
const parentName = buildName(parent)
if (keyval !== undefined) {
if (keyname) throw new Error('Can not use key value and name together')
if (!['string', 'number'].includes(typeof keyval)) throw new Error('Invalid property path')
if (/^[a-z][a-z0-9_]*$/i.test(keyval)) return format('%s.%s', parentName, safe(keyval))
return format('%s[%j]', parentName, keyval)
} else if (keyname) {
return format('%s[%s]', parentName, keyname)
}
/* c8 ignore next */
throw new Error('Unreachable')
}
const jsonProtoKeys = new Set(
[].concat(
...[Object, Array, String, Number, Boolean].map((c) => Object.getOwnPropertyNames(c.prototype))
)
)
const jsHelpers = (fun, scope, propvar, { unmodifiedPrototypes, isJSON }, noopRegExps) => {
const { gensym, genpattern, genloop } = scopeMethods(scope, propvar)
const present = (obj) => {
const name = buildName(obj) // also checks for coherence, do not remove
const { parent, keyval, keyname, inKeys, checked } = obj
/* c8 ignore next */
if (checked || (inKeys && isJSON)) throw new Error('Unreachable: useless check for undefined')
if (inKeys) return format('%s !== undefined', name)
if (parent && keyname) {
scope.hasOwn = functions.hasOwn
const pname = buildName(parent)
if (isJSON) return format('%s !== undefined && hasOwn(%s, %s)', name, pname, keyname)
return format('%s in %s && hasOwn(%s, %s)', keyname, pname, pname, keyname)
} else if (parent && keyval !== undefined) {
// numbers must be converted to strings for this check, hence `${keyval}` in check below
if (unmodifiedPrototypes && isJSON && !jsonProtoKeys.has(`${keyval}`))
return format('%s !== undefined', name)
scope.hasOwn = functions.hasOwn
const pname = buildName(parent)
if (isJSON) return format('%s !== undefined && hasOwn(%s, %j)', name, pname, keyval)
return format('%j in %s && hasOwn(%s, %j)', keyval, pname, pname, keyval)
}
/* c8 ignore next */
throw new Error('Unreachable: present() check without parent')
}
const forObjectKeys = (obj, writeBody) => {
const key = gensym('key')
fun.block(format('for (const %s of Object.keys(%s))', key, buildName(obj)), () => {
writeBody(propvar(obj, key, true), key) // always own property here
})
}
const forArray = (obj, start, writeBody) => {
const i = genloop()
const name = buildName(obj)
fun.block(format('for (let %s = %s; %s < %s.length; %s++)', i, start, i, name, i), () => {
writeBody(propvar(obj, i, unmodifiedPrototypes, true), i) // own property in Array if proto not mangled
})
}
const patternTest = (pat, key) => {
// Convert common patterns to string checks, makes generated code easier to read (and a tiny perf bump)
const r = pat.replace(/[.^$|*+?(){}[\]\\]/gu, '') // Special symbols: .^$|*+?(){}[]\
if (pat === `^${r}$`) return format('(%s === %j)', key, pat.slice(1, -1)) // ^abc$ -> === abc
if (noopRegExps.has(pat)) return format('true') // known noop
// All of the below will cause warnings in enforced string validation mode, but let's make what they actually do more visible
// note that /^.*$/u.test('\n') is false, so don't combine .* with anchors here!
if ([r, `${r}+`, `${r}.*`, `.*${r}.*`].includes(pat)) return format('%s.includes(%j)', key, r)
if ([`^${r}`, `^${r}+`, `^${r}.*`].includes(pat)) return format('%s.startsWith(%j)', key, r)
if ([`${r}$`, `.*${r}$`].includes(pat)) return format('%s.endsWith(%j)', key, r)
const subr = [...r].slice(0, -1).join('') // without the last symbol, astral plane aware
if ([`${r}*`, `${r}?`].includes(pat))
return subr.length === 0 ? format('true') : format('%s.includes(%j)', key, subr) // abc*, abc? -> includes(ab)
if ([`^${r}*`, `^${r}?`].includes(pat))
return subr.length === 0 ? format('true') : format('%s.startsWith(%j)', key, subr) // ^abc*, ^abc? -> startsWith(ab)
// A normal reg-exp test
return format('%s.test(%s)', genpattern(pat), key)
}
const compare = (name, val) => {
if (!val || typeof val !== 'object') return format('%s === %j', name, val)
let type // type is needed for speedup only, deepEqual rechecks that
// small plain object/arrays are fast cases and we inline those instead of calling deepEqual
const shouldInline = (arr) => arr.length <= 3 && arr.every((x) => !x || typeof x !== 'object')
if (Array.isArray(val)) {
type = types.get('array')(name)
if (shouldInline(val)) {
let k = format('%s.length === %d', name, val.length)
for (let i = 0; i < val.length; i++) k = format('%s && %s[%d] === %j', k, name, i, val[i])
return format('%s && %s', type, k)
}
} else {
type = types.get('object')(name)
const [keys, values] = [Object.keys(val), Object.values(val)]
if (shouldInline(values)) {
let k = format('Object.keys(%s).length === %d', name, keys.length)
if (keys.length > 0) scope.hasOwn = functions.hasOwn
for (const key of keys) k = format('%s && hasOwn(%s, %j)', k, name, key)
for (const key of keys) k = format('%s && %s[%j] === %j', k, name, key, val[key])
return format('%s && %s', type, k)
}
}
scope.deepEqual = functions.deepEqual
return format('%s && deepEqual(%s, %j)', type, name, val)
}
return { present, forObjectKeys, forArray, patternTest, compare, propvar }
}
// Stringifcation of functions and regexps, for scope
const isArrowFnWithParensRegex = /^\([^)]*\) *=>/
const isArrowFnWithoutParensRegex = /^[^=]*=>/
const toJayString = Symbol.for('toJayString')
function jaystring(item) {
if (typeof item === 'function') {
if (item[toJayString]) return item[toJayString] // this is supported only for functions
if (Object.getPrototypeOf(item) !== Function.prototype)
throw new Error('Can not stringify: a function with unexpected prototype')
const stringified = `${item}`
if (item.prototype) {
if (!/^function[ (]/.test(stringified)) throw new Error('Unexpected function')
return stringified // normal function
}
if (isArrowFnWithParensRegex.test(stringified) || isArrowFnWithoutParensRegex.test(stringified))
return stringified // Arrow function
// Shortened ES6 object method declaration
throw new Error('Can not stringify: only either normal or arrow functions are supported')
} else if (typeof item === 'object') {
const proto = Object.getPrototypeOf(item)
if (item instanceof RegExp && proto === RegExp.prototype) return format('%r', item)
throw new Error('Can not stringify: an object with unexpected prototype')
}
throw new Error(`Can not stringify: unknown type ${typeof item}`)
}
module.exports = { types, buildName, jsHelpers, jaystring }

View File

@@ -0,0 +1,46 @@
'use strict'
const knownKeywords = [
...['$schema', '$vocabulary'], // version
...['id', '$id', '$anchor', '$ref', 'definitions', '$defs'], // pointers
...['$recursiveRef', '$recursiveAnchor', '$dynamicAnchor', '$dynamicRef'],
...['type', 'required', 'default'], // generic
...['enum', 'const'], // constant values
...['not', 'allOf', 'anyOf', 'oneOf', 'if', 'then', 'else'], // logical checks
...['maximum', 'minimum', 'exclusiveMaximum', 'exclusiveMinimum', 'multipleOf', 'divisibleBy'], // numbers
...['items', 'maxItems', 'minItems', 'additionalItems', 'prefixItems'], // arrays, basic
...['contains', 'minContains', 'maxContains', 'uniqueItems'], // arrays, complex
...['maxLength', 'minLength', 'format', 'pattern'], // strings
...['contentEncoding', 'contentMediaType', 'contentSchema'], // strings content
...['properties', 'maxProperties', 'minProperties', 'additionalProperties', 'patternProperties'], // objects
...['propertyNames'], // objects
...['dependencies', 'dependentRequired', 'dependentSchemas', 'propertyDependencies'], // objects (dependencies)
...['unevaluatedProperties', 'unevaluatedItems'], // see-through
// Unused meta keywords not affecting validation (annotations and comments)
// https://json-schema.org/understanding-json-schema/reference/generic.html
// https://json-schema.org/draft/2019-09/json-schema-validation.html#rfc.section.9
...['title', 'description', 'deprecated', 'readOnly', 'writeOnly', 'examples', '$comment'], // unused meta
...['example'], // unused meta, OpenAPI
'discriminator', // optimization hint and error filtering only, does not affect validation result
'removeAdditional', // optional keyword for { removeAdditional: 'keyword' } config, to target specific objects
]
// Order is important, newer first!
const schemaDrafts = [
...['draft/next'], // not recommended to use, might change / break in an unexpected way
...['draft/2020-12', 'draft/2019-09'], // new
...['draft-07', 'draft-06', 'draft-04', 'draft-03'], // historic
]
const schemaVersions = schemaDrafts.map((draft) => `https://json-schema.org/${draft}/schema`)
const vocab2019 = ['core', 'applicator', 'validation', 'meta-data', 'format', 'content']
const vocab2020 = [
...['core', 'applicator', 'unevaluated', 'validation'],
...['meta-data', 'format-annotation', 'format-assertion', 'content'],
]
const knownVocabularies = [
...vocab2019.map((v) => `https://json-schema.org/draft/2019-09/vocab/${v}`),
...vocab2020.map((v) => `https://json-schema.org/draft/2020-12/vocab/${v}`),
]
module.exports = { knownKeywords, schemaVersions, knownVocabularies }

194
frontend/node_modules/@exodus/schemasafe/src/pointer.js generated vendored Normal file
View File

@@ -0,0 +1,194 @@
'use strict'
const { knownKeywords } = require('./known-keywords')
/*
* JSON pointer collection/resolution logic
*/
function safeSet(map, key, value, comment = 'keys') {
if (!map.has(key)) return map.set(key, value)
if (map.get(key) !== value) throw new Error(`Conflicting duplicate ${comment}: ${key}`)
}
function untilde(string) {
if (!string.includes('~')) return string
return string.replace(/~[01]/g, (match) => {
switch (match) {
case '~1':
return '/'
case '~0':
return '~'
}
/* c8 ignore next */
throw new Error('Unreachable')
})
}
function get(obj, pointer, objpath) {
if (typeof obj !== 'object') throw new Error('Invalid input object')
if (typeof pointer !== 'string') throw new Error('Invalid JSON pointer')
const parts = pointer.split('/')
if (!['', '#'].includes(parts.shift())) throw new Error('Invalid JSON pointer')
if (parts.length === 0) return obj
let curr = obj
for (const part of parts) {
if (typeof part !== 'string') throw new Error('Invalid JSON pointer')
if (objpath) objpath.push(curr) // does not include target itself, but includes head
const prop = untilde(part)
if (typeof curr !== 'object') return undefined
if (!Object.prototype.hasOwnProperty.call(curr, prop)) return undefined
curr = curr[prop]
}
return curr
}
const protocolRegex = /^https?:\/\//
function joinPath(baseFull, sub) {
if (typeof baseFull !== 'string' || typeof sub !== 'string') throw new Error('Unexpected path!')
if (sub.length === 0) return baseFull
const base = baseFull.replace(/#.*/, '')
if (sub.startsWith('#')) return `${base}${sub}`
if (!base.includes('/') || protocolRegex.test(sub)) return sub
if (protocolRegex.test(base)) return `${new URL(sub, base)}`
if (sub.startsWith('/')) return sub
return [...base.split('/').slice(0, -1), sub].join('/')
}
function objpath2path(objpath) {
const ids = objpath.map((obj) => (obj && (obj.$id || obj.id)) || '')
return ids.filter((id) => id && typeof id === 'string').reduce(joinPath, '')
}
const withSpecialChilds = ['properties', 'patternProperties', '$defs', 'definitions']
const skipChilds = ['const', 'enum', 'examples', 'example', 'comment']
const sSkip = Symbol('skip')
function traverse(schema, work) {
const visit = (sub, specialChilds = false) => {
if (!sub || typeof sub !== 'object') return
const res = work(sub)
if (res !== undefined) return res === sSkip ? undefined : res
for (const k of Object.keys(sub)) {
if (!specialChilds && !Array.isArray(sub) && !knownKeywords.includes(k)) continue
if (!specialChilds && skipChilds.includes(k)) continue
const kres = visit(sub[k], !specialChilds && withSpecialChilds.includes(k))
if (kres !== undefined) return kres
}
}
return visit(schema)
}
// Returns a list of resolved entries, in a form: [schema, root, basePath]
// basePath doesn't contain the target object $id itself
function resolveReference(root, schemas, ref, base = '') {
const ptr = joinPath(base, ref)
const results = []
const [main, hash = ''] = ptr.split('#')
const local = decodeURI(hash)
// Find in self by id path
const visit = (sub, oldPath, specialChilds = false, dynamic = false) => {
if (!sub || typeof sub !== 'object') return
const id = sub.$id || sub.id
let path = oldPath
if (id && typeof id === 'string') {
path = joinPath(path, id)
if (path === ptr || (path === main && local === '')) {
results.push([sub, root, oldPath])
} else if (path === main && local[0] === '/') {
const objpath = []
const res = get(sub, local, objpath)
if (res !== undefined) results.push([res, root, joinPath(oldPath, objpath2path(objpath))])
}
}
const anchor = dynamic ? sub.$dynamicAnchor : sub.$anchor
if (anchor && typeof anchor === 'string') {
if (anchor.includes('#')) throw new Error("$anchor can't include '#'")
if (anchor.startsWith('/')) throw new Error("$anchor can't start with '/'")
path = joinPath(path, `#${anchor}`)
if (path === ptr) results.push([sub, root, oldPath])
}
for (const k of Object.keys(sub)) {
if (!specialChilds && !Array.isArray(sub) && !knownKeywords.includes(k)) continue
if (!specialChilds && skipChilds.includes(k)) continue
visit(sub[k], path, !specialChilds && withSpecialChilds.includes(k))
}
if (!dynamic && sub.$dynamicAnchor) visit(sub, oldPath, specialChilds, true)
}
visit(root, main)
// Find in self by pointer
if (main === base.replace(/#$/, '') && (local[0] === '/' || local === '')) {
const objpath = []
const res = get(root, local, objpath)
if (res !== undefined) results.push([res, root, objpath2path(objpath)])
}
// Find in additional schemas
if (schemas.has(main) && schemas.get(main) !== root) {
const additional = resolveReference(schemas.get(main), schemas, `#${hash}`, main)
results.push(...additional.map(([res, rRoot, rPath]) => [res, rRoot, joinPath(main, rPath)]))
}
// Full refs to additional schemas
if (schemas.has(ptr)) results.push([schemas.get(ptr), schemas.get(ptr), ptr])
return results
}
function getDynamicAnchors(schema) {
const results = new Map()
traverse(schema, (sub) => {
if (sub !== schema && (sub.$id || sub.id)) return sSkip // base changed, no longer in the same resource
const anchor = sub.$dynamicAnchor
if (anchor && typeof anchor === 'string') {
if (anchor.includes('#')) throw new Error("$dynamicAnchor can't include '#'")
if (!/^[a-zA-Z0-9_-]+$/.test(anchor)) throw new Error(`Unsupported $dynamicAnchor: ${anchor}`)
safeSet(results, anchor, sub, '$dynamicAnchor')
}
})
return results
}
const hasKeywords = (schema, keywords) =>
traverse(schema, (s) => Object.keys(s).some((k) => keywords.includes(k)) || undefined) || false
const addSchemasArrayToMap = (schemas, input, optional = false) => {
if (!Array.isArray(input)) throw new Error('Expected an array of schemas')
// schema ids are extracted from the schemas themselves
for (const schema of input) {
traverse(schema, (sub) => {
const idRaw = sub.$id || sub.id
const id = idRaw && typeof idRaw === 'string' ? idRaw.replace(/#$/, '') : null // # is allowed only as the last symbol here
if (id && id.includes('://') && !id.includes('#')) {
safeSet(schemas, id, sub, "schema $id in 'schemas'")
} else if (sub === schema && !optional) {
throw new Error("Schema with missing or invalid $id in 'schemas'")
}
})
}
return schemas
}
const buildSchemas = (input, extra) => {
if (extra) return addSchemasArrayToMap(buildSchemas(input), extra, true)
if (input) {
switch (Object.getPrototypeOf(input)) {
case Object.prototype:
return new Map(Object.entries(input))
case Map.prototype:
return new Map(input)
case Array.prototype:
return addSchemasArrayToMap(new Map(), input)
}
}
throw new Error("Unexpected value for 'schemas' option")
}
module.exports = { get, joinPath, resolveReference, getDynamicAnchors, hasKeywords, buildSchemas }

View File

@@ -0,0 +1,98 @@
'use strict'
class SafeString extends String {} // used for instanceof checks
const compares = new Set(['<', '>', '<=', '>='])
const escapeCode = (code) => `\\u${code.toString(16).padStart(4, '0')}`
// Supports simple js variables only, i.e. constants and JSON-stringifiable
// Converts a variable to be safe for inclusion in JS context
// This works on top of JSON.stringify with minor fixes to negate the JS/JSON parsing differences
const jsval = (val) => {
if ([Infinity, -Infinity, NaN, undefined, null].includes(val)) return `${val}`
const primitive = ['string', 'boolean', 'number'].includes(typeof val)
if (!primitive) {
if (typeof val !== 'object') throw new Error('Unexpected value type')
const proto = Object.getPrototypeOf(val)
const ok = (proto === Array.prototype && Array.isArray(val)) || proto === Object.prototype
if (!ok) throw new Error('Unexpected object given as value')
}
return (
JSON.stringify(val)
// JSON context and JS eval context have different handling of __proto__ property name
// Refs: https://www.ecma-international.org/ecma-262/#sec-json.parse
// Refs: https://www.ecma-international.org/ecma-262/#sec-__proto__-property-names-in-object-initializers
// Replacement is safe because it's the only way that encodes __proto__ property in JSON and
// it can't occur inside strings or other properties, due to the leading `"` and traling `":`
.replace(/([{,])"__proto__":/g, '$1["__proto__"]:')
// The above line should cover all `"__proto__":` occurances except for `"...\"__proto__":`
.replace(/[^\\]"__proto__":/g, () => {
/* c8 ignore next */
throw new Error('Unreachable')
})
// https://v8.dev/features/subsume-json#security, e.g. {'\u2028':0} on Node.js 8
.replace(/[\u2028\u2029]/g, (char) => escapeCode(char.charCodeAt(0)))
)
}
const format = (fmt, ...args) => {
const res = fmt.replace(/%[%drscjw]/g, (match) => {
if (match === '%%') return '%'
if (args.length === 0) throw new Error('Unexpected arguments count')
const val = args.shift()
switch (match) {
case '%d':
if (typeof val === 'number') return val
throw new Error('Expected a number')
case '%r':
// String(regex) is not ok on Node.js 10 and below: console.log(String(new RegExp('\n')))
if (val instanceof RegExp) return format('new RegExp(%j, %j)', val.source, val.flags)
throw new Error('Expected a RegExp instance')
case '%s':
if (val instanceof SafeString) return val
throw new Error('Expected a safe string')
case '%c':
if (compares.has(val)) return val
throw new Error('Expected a compare op')
case '%j':
return jsval(val)
case '%w':
if (Number.isInteger(val) && val >= 0) return ' '.repeat(val)
throw new Error('Expected a non-negative integer for indentation')
}
/* c8 ignore next */
throw new Error('Unreachable')
})
if (args.length !== 0) throw new Error('Unexpected arguments count')
return new SafeString(res)
}
const safe = (string) => {
if (!/^[a-z][a-z0-9_]*$/i.test(string)) throw new Error('Does not look like a safe id')
return new SafeString(string)
}
// too dangereous to export, use with care
const safewrap = (fun) => (...args) => {
if (!args.every((arg) => arg instanceof SafeString)) throw new Error('Unsafe arguments')
return new SafeString(fun(...args))
}
const safepriority = (arg) =>
// simple expression and single brackets can not break priority
/^[a-z][a-z0-9_().]*$/i.test(arg) || /^\([^()]+\)$/i.test(arg) ? arg : format('(%s)', arg)
const safeor = safewrap(
(...args) => (args.some((arg) => `${arg}` === 'true') ? 'true' : args.join(' || ') || 'false')
)
const safeand = safewrap(
(...args) => (args.some((arg) => `${arg}` === 'false') ? 'false' : args.join(' && ') || 'true')
)
const safenot = (arg) => {
if (`${arg}` === 'true') return safe('false')
if (`${arg}` === 'false') return safe('true')
return format('!%s', safepriority(arg))
}
// this function is priority-safe, unlike safeor, hence it's exported and safeor is not atm
const safenotor = (...args) => safenot(safeor(...args))
module.exports = { format, safe, safeand, safenot, safenotor }

View File

@@ -0,0 +1,95 @@
'use strict'
// for correct Unicode code points processing
// https://mathiasbynens.be/notes/javascript-unicode#accounting-for-astral-symbols
const stringLength = (string) =>
/[\uD800-\uDFFF]/.test(string) ? [...string].length : string.length
// A isMultipleOf B: shortest decimal denoted as A % shortest decimal denoted as B === 0
// Optimized, coherence checks and precomputation are outside of this method
// If we get an Infinity when we multiply by the factor (which is always a power of 10), we just undo that instead of always returning false
const isMultipleOf = (value, divisor, factor, factorMultiple) => {
if (value % divisor === 0) return true
let multiple = value * factor
if (multiple === Infinity || multiple === -Infinity) multiple = value
if (multiple % factorMultiple === 0) return true
const normal = Math.floor(multiple + 0.5)
return normal / factor === value && normal % factorMultiple === 0
}
// supports only JSON-stringifyable objects, defaults to false for unsupported
// also uses ===, not Object.is, i.e. 0 === -0, NaN !== NaN
// symbols and non-enumerable properties are ignored!
const deepEqual = (obj, obj2) => {
if (obj === obj2) return true
if (!obj || !obj2 || typeof obj !== typeof obj2) return false
if (obj !== obj2 && typeof obj !== 'object') return false
const proto = Object.getPrototypeOf(obj)
if (proto !== Object.getPrototypeOf(obj2)) return false
if (proto === Array.prototype) {
if (!Array.isArray(obj) || !Array.isArray(obj2)) return false
if (obj.length !== obj2.length) return false
return obj.every((x, i) => deepEqual(x, obj2[i]))
} else if (proto === Object.prototype) {
const [keys, keys2] = [Object.keys(obj), Object.keys(obj2)]
if (keys.length !== keys2.length) return false
const keyset2 = new Set([...keys, ...keys2])
return keyset2.size === keys.length && keys.every((key) => deepEqual(obj[key], obj2[key]))
}
return false
}
const unique = (array) => {
if (array.length < 2) return true
if (array.length === 2) return !deepEqual(array[0], array[1])
const objects = []
const primitives = array.length > 20 ? new Set() : null
let primitivesCount = 0
let pos = 0
for (const item of array) {
if (typeof item === 'object') {
objects.push(item)
} else if (primitives) {
primitives.add(item)
if (primitives.size !== ++primitivesCount) return false
} else {
if (array.indexOf(item, pos + 1) !== -1) return false
}
pos++
}
for (let i = 1; i < objects.length; i++)
for (let j = 0; j < i; j++) if (deepEqual(objects[i], objects[j])) return false
return true
}
const deBase64 = (string) => {
if (typeof Buffer !== 'undefined') return Buffer.from(string, 'base64').toString('utf-8')
const b = atob(string)
return new TextDecoder('utf-8').decode(new Uint8Array(b.length).map((_, i) => b.charCodeAt(i)))
}
const hasOwn = Function.prototype.call.bind(Object.prototype.hasOwnProperty)
// special handling for stringification
hasOwn[Symbol.for('toJayString')] = 'Function.prototype.call.bind(Object.prototype.hasOwnProperty)'
// Used for error generation. Affects error performance, optimized
const pointerPart = (s) => (/~\//.test(s) ? `${s}`.replace(/~/g, '~0').replace(/\//g, '~1') : s)
const toPointer = (path) => (path.length === 0 ? '#' : `#/${path.map(pointerPart).join('/')}`)
const errorMerge = ({ keywordLocation, instanceLocation }, schemaBase, dataBase) => ({
keywordLocation: `${schemaBase}${keywordLocation.slice(1)}`,
instanceLocation: `${dataBase}${instanceLocation.slice(1)}`,
})
const propertyIn = (key, [properties, patterns]) =>
properties.includes(true) ||
properties.some((prop) => prop === key) ||
patterns.some((pattern) => new RegExp(pattern, 'u').test(key))
// id is verified to start with '#' at compile time, hence using plain objects is safe
const dynamicResolve = (anchors, id) => (anchors.filter((x) => x[id])[0] || {})[id]
const extraUtils = { toPointer, pointerPart, errorMerge, propertyIn, dynamicResolve }
module.exports = { stringLength, isMultipleOf, deepEqual, unique, deBase64, hasOwn, ...extraUtils }

View File

@@ -0,0 +1,63 @@
'use strict'
const { safe } = require('./safe-format')
const caches = new WeakMap()
// Given a scope object, generates new symbol/loop/pattern/format/ref variable names,
// also stores in-scope format/ref mapping to variable names
const scopeMethods = (scope) => {
// cache meta info for known scope variables, per meta type
if (!caches.has(scope))
caches.set(scope, { sym: new Map(), ref: new Map(), format: new Map(), pattern: new Map() })
const cache = caches.get(scope)
// Generic variable names, requires a base name aka prefix
const gensym = (name) => {
if (!cache.sym.get(name)) cache.sym.set(name, 0)
const index = cache.sym.get(name)
cache.sym.set(name, index + 1)
return safe(`${name}${index}`)
}
// Regexp pattern names
const genpattern = (p) => {
if (cache.pattern.has(p)) return cache.pattern.get(p)
const n = gensym('pattern')
scope[n] = new RegExp(p, 'u')
cache.pattern.set(p, n)
return n
}
// Loop variable names
if (!cache.loop) cache.loop = 'ijklmnopqrstuvxyz'.split('')
const genloop = () => {
const v = cache.loop.shift()
cache.loop.push(`${v}${v[0]}`)
return safe(v)
}
// Reference (validator function) names
const getref = (sub) => cache.ref.get(sub)
const genref = (sub) => {
const n = gensym('ref')
cache.ref.set(sub, n)
return n
}
// Format validation function names
const genformat = (impl) => {
let n = cache.format.get(impl)
if (!n) {
n = gensym('format')
scope[n] = impl
cache.format.set(impl, n)
}
return n
}
return { gensym, genpattern, genloop, getref, genref, genformat }
}
module.exports = { scopeMethods }

128
frontend/node_modules/@exodus/schemasafe/src/tracing.js generated vendored Normal file
View File

@@ -0,0 +1,128 @@
'use strict'
/* This file implements operations for static tracing of evaluated items/properties, which is also
* used to determine whether dynamic evaluated tracing is required or the schema can be compiled
* with only statical checks.
*
* That is done by keeping track of evaluated and potentially evaluated and accounting to that
* while doing merges and intersections.
*
* isDynamic() checks that all potentially evaluated are also definitely evaluated, seperately
* for items and properties, for use with unevaluatedItems and unevaluatedProperties.
*
* WARNING: it is important that this doesn't produce invalid information. i.e.:
* * Extra properties or patterns, too high items
* * Missing dyn.properties or dyn.patterns, too low dyn.items
* * Extra fullstring flag or required entries
* * Missing types, if type is present
* * Missing unknown or dyn.item
*
* The other way around is non-optimal but safe.
*
* null means any type (i.e. any type is possible, not validated)
* true in properties means any property (i.e. all properties were evaluated)
* fullstring means that the object is not an unvalidated string (i.e. is either validated or not a string)
* unknown means that there could be evaluated items or properties unknown to both top-level or dyn
* dyn.item (bool) means there could be possible specific evaluated items, e.g. from "contains".
*
* For normalization:
* 1. If type is applicable:
* * dyn.items >= items,
* * dyn.properties includes properties
* * dyn.patterns includes patterns.
* 2. If type is not applicable, the following rules apply:
* * `fullstring = true` if `string` type is not applicable
* * `items = Infinity`, `dyn.item = false`, `dyn.items = 0` if `array` type is not applicable
* * `properties = [true]`, `dyn.properties = []` if `object` type is not applicable
* * `patterns = dyn.patterns = []` if `object` type is not applicable
* * `required = []` if `object` type is not applicable
*
* That allows to simplify the `or` operation.
*/
const merge = (a, b) => [...new Set([...a, ...b])].sort()
const intersect = (a, b) => a.filter((x) => b.includes(x))
const wrapArgs = (f) => (...args) => f(...args.map(normalize))
const wrapFull = (f) => (...args) => normalize(f(...args.map(normalize)))
const typeIsNot = (type, t) => type && !type.includes(t) // type=null means any and includes anything
const normalize = ({ type = null, dyn: d = {}, ...A }) => ({
type: type ? [...type].sort() : type,
items: typeIsNot(type, 'array') ? Infinity : A.items || 0,
properties: typeIsNot(type, 'object') ? [true] : [...(A.properties || [])].sort(),
patterns: typeIsNot(type, 'object') ? [] : [...(A.patterns || [])].sort(),
required: typeIsNot(type, 'object') ? [] : [...(A.required || [])].sort(),
fullstring: typeIsNot(type, 'string') || A.fullstring || false,
dyn: {
item: typeIsNot(type, 'array') ? false : d.item || false,
items: typeIsNot(type, 'array') ? 0 : Math.max(A.items || 0, d.items || 0),
properties: typeIsNot(type, 'object') ? [] : merge(A.properties || [], d.properties || []),
patterns: typeIsNot(type, 'object') ? [] : merge(A.patterns || [], d.patterns || []),
},
unknown: (A.unknown && !(typeIsNot(type, 'object') && typeIsNot(type, 'array'))) || false,
})
const initTracing = () => normalize({})
// Result means that both sets A and B are correct
// type is intersected, lists of known properties are merged
const andDelta = wrapFull((A, B) => ({
type: A.type && B.type ? intersect(A.type, B.type) : A.type || B.type || null,
items: Math.max(A.items, B.items),
properties: merge(A.properties, B.properties),
patterns: merge(A.patterns, B.patterns),
required: merge(A.required, B.required),
fullstring: A.fullstring || B.fullstring,
dyn: {
item: A.dyn.item || B.dyn.item,
items: Math.max(A.dyn.items, B.dyn.items),
properties: merge(A.dyn.properties, B.dyn.properties),
patterns: merge(A.dyn.patterns, B.dyn.patterns),
},
unknown: A.unknown || B.unknown,
}))
const regtest = (pattern, value) => value !== true && new RegExp(pattern, 'u').test(value)
const intersectProps = ({ properties: a, patterns: rega }, { properties: b, patterns: regb }) => {
// properties
const af = a.filter((x) => b.includes(x) || b.includes(true) || regb.some((p) => regtest(p, x)))
const bf = b.filter((x) => a.includes(x) || a.includes(true) || rega.some((p) => regtest(p, x)))
// patterns
const ar = rega.filter((x) => regb.includes(x) || b.includes(true))
const br = regb.filter((x) => rega.includes(x) || a.includes(true))
return { properties: merge(af, bf), patterns: merge(ar, br) }
}
const inProperties = ({ properties: a, patterns: rega }, { properties: b, patterns: regb }) =>
b.every((x) => a.includes(x) || a.includes(true) || rega.some((p) => regtest(p, x))) &&
regb.every((x) => rega.includes(x) || a.includes(true))
// Result means that at least one of sets A and B is correct
// type is merged, lists of known properties are intersected, lists of dynamic properties are merged
const orDelta = wrapFull((A, B) => ({
type: A.type && B.type ? merge(A.type, B.type) : null,
items: Math.min(A.items, B.items),
...intersectProps(A, B),
required:
(typeIsNot(A.type, 'object') && B.required) ||
(typeIsNot(B.type, 'object') && A.required) ||
intersect(A.required, B.required),
fullstring: A.fullstring && B.fullstring,
dyn: {
item: A.dyn.item || B.dyn.item,
items: Math.max(A.dyn.items, B.dyn.items),
properties: merge(A.dyn.properties, B.dyn.properties),
patterns: merge(A.dyn.patterns, B.dyn.patterns),
},
unknown: A.unknown || B.unknown,
}))
const applyDelta = (stat, delta) => Object.assign(stat, andDelta(stat, delta))
const isDynamic = wrapArgs(({ unknown, items, dyn, ...stat }) => ({
items: items !== Infinity && (unknown || dyn.items > items || dyn.item),
properties: !stat.properties.includes(true) && (unknown || !inProperties(stat, dyn)),
}))
module.exports = { initTracing, andDelta, orDelta, applyDelta, isDynamic, inProperties }