@mikrojs/schema 0.0.0 → 0.19.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/dist/config.js ADDED
@@ -0,0 +1,980 @@
1
+ /* Host-side helpers for OTA config schemas, used by the CLI and by registries.
2
+ * Nothing here reaches a device: it never validates a schema AST (its manifest
3
+ * copy was written by the CLI) and never derives an overlay. `format` lives
4
+ * here rather than in core.ts because the expressions are a denial-of-service
5
+ * surface a device should not carry, and it has no regex engine to carry them
6
+ * with. Imports core.ts only, so this stays dependency-free; results are plain
7
+ * {ok} shapes for the same reason. */
8
+ import { applyDefaults, SchemaError, validate, } from './core.js';
9
+ /* The format expressions live here, not in core.ts, because core.ts is bundled
10
+ * into the device and a config schema is never validated there. Not a
11
+ * caller-supplied `pattern`: a registry runs these against operator input, so a
12
+ * publisher-supplied regular expression would be a denial-of-service vector. */
13
+ const FORMAT_PATTERNS = {
14
+ url: /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s/?#]+\S*$/,
15
+ hostname: /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
16
+ ipv4: /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/,
17
+ // Separators do not mix: aa:bb-cc:dd:ee:ff is not an address.
18
+ mac: /^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$|^([0-9a-fA-F]{2}-){5}[0-9a-fA-F]{2}$/,
19
+ email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
20
+ };
21
+ export const FORMATS = Object.keys(FORMAT_PATTERNS);
22
+ /**
23
+ * Validates a value against a config schema, `format` included. `validate()` in
24
+ * core.ts covers shape and every numeric and length bound; this adds the one
25
+ * annotation that stays host-side, so every path that validates an operator's
26
+ * value goes through here rather than calling validate() directly.
27
+ */
28
+ export function validateConfig(schema, value) {
29
+ const structural = validate(schema, value, '');
30
+ if (structural !== null)
31
+ return { ok: false, error: structural.error };
32
+ const constraint = checkValueConstraints(schema, value, '');
33
+ if (constraint !== null)
34
+ return constraint;
35
+ return { ok: true, value };
36
+ }
37
+ /* Mirrors validate()'s walk, applying only `format`. Runs after validate(), so
38
+ * every value here is already the right shape and within its bounds. Kept as a
39
+ * second walk rather than folded into core.ts because the expressions below
40
+ * are a denial-of-service surface a device should not carry, and a device has
41
+ * no regex engine to carry them with. */
42
+ function checkValueConstraints(schema, value, path) {
43
+ switch (schema.kind) {
44
+ case 'string': {
45
+ const text = value;
46
+ const { format } = schema;
47
+ if (format !== undefined && !FORMAT_PATTERNS[format].test(text)) {
48
+ return fail(`not a valid ${format}`, path);
49
+ }
50
+ // The pattern bounds each label at 63 characters; the whole name has its
51
+ // own limit that no per-label rule can express.
52
+ if (format === 'hostname' && text.length > 253) {
53
+ return fail('not a valid hostname', path);
54
+ }
55
+ return null;
56
+ }
57
+ case 'array': {
58
+ const items = value;
59
+ for (let i = 0; i < items.length; i++) {
60
+ const result = checkValueConstraints(schema.element, items[i], `${path}[${i}]`);
61
+ if (result !== null)
62
+ return result;
63
+ }
64
+ return null;
65
+ }
66
+ case 'object': {
67
+ const obj = value;
68
+ for (const key of Object.keys(schema.shape)) {
69
+ if (!Object.hasOwn(obj, key))
70
+ continue;
71
+ const result = checkValueConstraints(schema.shape[key], obj[key], `${path}.${key}`);
72
+ if (result !== null)
73
+ return result;
74
+ }
75
+ return null;
76
+ }
77
+ case 'optional':
78
+ return value === undefined ? null : checkValueConstraints(schema.inner, value, path);
79
+ case 'tuple': {
80
+ const items = value;
81
+ for (let i = 0; i < schema.elements.length; i++) {
82
+ const result = checkValueConstraints(schema.elements[i], items[i], `${path}[${i}]`);
83
+ if (result !== null)
84
+ return result;
85
+ }
86
+ return null;
87
+ }
88
+ case 'union': {
89
+ /* A union accepts what ANY member accepts, so the format pass has to
90
+ * agree with validate(). Applying only the first matching member's
91
+ * format would reject a value a later member allows. validate() has
92
+ * already ruled out members of the wrong shape or out of range, so
93
+ * anything reached here differs only in format. */
94
+ let firstFailure = null;
95
+ for (const member of schema.members) {
96
+ if (validate(member, value, '') !== null)
97
+ continue;
98
+ const result = checkValueConstraints(member, value, path);
99
+ if (result === null)
100
+ return null;
101
+ firstFailure ??= result;
102
+ }
103
+ return firstFailure;
104
+ }
105
+ case 'taggedUnion': {
106
+ const obj = value;
107
+ const branch = schema.branches[obj[schema.key]];
108
+ return branch === undefined ? null : checkValueConstraints(branch, value, path);
109
+ }
110
+ default:
111
+ return null;
112
+ }
113
+ }
114
+ /* Declared as Record<Unit, ...> on purpose: the compiler then refuses a table
115
+ * that is missing a member of the union or carries one that is not in it, so
116
+ * the names are declared once in core.ts and cannot drift from this. */
117
+ export const UNITS = {
118
+ m: { primary: 'm', scale: 1, offset: 0 },
119
+ kg: { primary: 'kg', scale: 1, offset: 0 },
120
+ s: { primary: 's', scale: 1, offset: 0 },
121
+ A: { primary: 'A', scale: 1, offset: 0 },
122
+ K: { primary: 'K', scale: 1, offset: 0 },
123
+ cd: { primary: 'cd', scale: 1, offset: 0 },
124
+ mol: { primary: 'mol', scale: 1, offset: 0 },
125
+ Hz: { primary: 'Hz', scale: 1, offset: 0 },
126
+ rad: { primary: 'rad', scale: 1, offset: 0 },
127
+ sr: { primary: 'sr', scale: 1, offset: 0 },
128
+ N: { primary: 'N', scale: 1, offset: 0 },
129
+ Pa: { primary: 'Pa', scale: 1, offset: 0 },
130
+ J: { primary: 'J', scale: 1, offset: 0 },
131
+ W: { primary: 'W', scale: 1, offset: 0 },
132
+ C: { primary: 'C', scale: 1, offset: 0 },
133
+ V: { primary: 'V', scale: 1, offset: 0 },
134
+ F: { primary: 'F', scale: 1, offset: 0 },
135
+ Ohm: { primary: 'Ohm', scale: 1, offset: 0, symbol: 'Ω' },
136
+ S: { primary: 'S', scale: 1, offset: 0 },
137
+ Wb: { primary: 'Wb', scale: 1, offset: 0 },
138
+ T: { primary: 'T', scale: 1, offset: 0 },
139
+ H: { primary: 'H', scale: 1, offset: 0 },
140
+ Cel: { primary: 'Cel', scale: 1, offset: 0, symbol: '°C' },
141
+ lm: { primary: 'lm', scale: 1, offset: 0 },
142
+ lx: { primary: 'lx', scale: 1, offset: 0 },
143
+ Bq: { primary: 'Bq', scale: 1, offset: 0 },
144
+ Gy: { primary: 'Gy', scale: 1, offset: 0 },
145
+ Sv: { primary: 'Sv', scale: 1, offset: 0 },
146
+ kat: { primary: 'kat', scale: 1, offset: 0 },
147
+ m2: { primary: 'm2', scale: 1, offset: 0, symbol: 'm²' },
148
+ m3: { primary: 'm3', scale: 1, offset: 0, symbol: 'm³' },
149
+ 'm/s': { primary: 'm/s', scale: 1, offset: 0 },
150
+ 'm/s2': { primary: 'm/s2', scale: 1, offset: 0, symbol: 'm/s²' },
151
+ 'm3/s': { primary: 'm3/s', scale: 1, offset: 0, symbol: 'm³/s' },
152
+ 'W/m2': { primary: 'W/m2', scale: 1, offset: 0, symbol: 'W/m²' },
153
+ 'cd/m2': { primary: 'cd/m2', scale: 1, offset: 0, symbol: 'cd/m²' },
154
+ bit: { primary: 'bit', scale: 1, offset: 0 },
155
+ 'bit/s': { primary: 'bit/s', scale: 1, offset: 0 },
156
+ lat: { primary: 'lat', scale: 1, offset: 0 },
157
+ lon: { primary: 'lon', scale: 1, offset: 0 },
158
+ pH: { primary: 'pH', scale: 1, offset: 0 },
159
+ dB: { primary: 'dB', scale: 1, offset: 0 },
160
+ dBW: { primary: 'dBW', scale: 1, offset: 0 },
161
+ count: { primary: 'count', scale: 1, offset: 0, symbol: '' },
162
+ '/': { primary: '/', scale: 1, offset: 0, symbol: '' },
163
+ '%RH': { primary: '%RH', scale: 1, offset: 0 },
164
+ '%EL': { primary: '%EL', scale: 1, offset: 0 },
165
+ EL: { primary: 'EL', scale: 1, offset: 0 },
166
+ '1/s': { primary: '1/s', scale: 1, offset: 0 },
167
+ 'S/m': { primary: 'S/m', scale: 1, offset: 0 },
168
+ B: { primary: 'B', scale: 1, offset: 0 },
169
+ VA: { primary: 'VA', scale: 1, offset: 0 },
170
+ VAs: { primary: 'VAs', scale: 1, offset: 0 },
171
+ var: { primary: 'var', scale: 1, offset: 0 },
172
+ vars: { primary: 'vars', scale: 1, offset: 0 },
173
+ 'J/m': { primary: 'J/m', scale: 1, offset: 0 },
174
+ 'kg/m3': { primary: 'kg/m3', scale: 1, offset: 0, symbol: 'kg/m³' },
175
+ deg: { primary: 'deg', scale: 1, offset: 0, symbol: '°' },
176
+ NTU: { primary: 'NTU', scale: 1, offset: 0 },
177
+ ms: { primary: 's', scale: 1 / 1000, offset: 0 },
178
+ min: { primary: 's', scale: 60, offset: 0 },
179
+ h: { primary: 's', scale: 3600, offset: 0 },
180
+ MHz: { primary: 'Hz', scale: 1000000, offset: 0 },
181
+ kW: { primary: 'W', scale: 1000, offset: 0 },
182
+ kVA: { primary: 'VA', scale: 1000, offset: 0 },
183
+ kvar: { primary: 'var', scale: 1000, offset: 0 },
184
+ Ah: { primary: 'C', scale: 3600, offset: 0 },
185
+ Wh: { primary: 'J', scale: 3600, offset: 0 },
186
+ kWh: { primary: 'J', scale: 3600000, offset: 0 },
187
+ varh: { primary: 'vars', scale: 3600, offset: 0 },
188
+ kvarh: { primary: 'vars', scale: 3600000, offset: 0 },
189
+ kVAh: { primary: 'VAs', scale: 3600000, offset: 0 },
190
+ 'Wh/km': { primary: 'J/m', scale: 3.6, offset: 0 },
191
+ KiB: { primary: 'B', scale: 1024, offset: 0 },
192
+ GB: { primary: 'B', scale: 1e9, offset: 0 },
193
+ 'Mbit/s': { primary: 'bit/s', scale: 1000000, offset: 0 },
194
+ 'B/s': { primary: 'bit/s', scale: 8, offset: 0 },
195
+ 'MB/s': { primary: 'bit/s', scale: 8000000, offset: 0 },
196
+ mV: { primary: 'V', scale: 1 / 1000, offset: 0 },
197
+ mA: { primary: 'A', scale: 1 / 1000, offset: 0 },
198
+ dBm: { primary: 'dBW', scale: 1, offset: -30 },
199
+ 'ug/m3': { primary: 'kg/m3', scale: 1e-9, offset: 0, symbol: 'µg/m³' },
200
+ 'mm/h': { primary: 'm/s', scale: 1 / 3600000, offset: 0 },
201
+ 'm/h': { primary: 'm/s', scale: 1 / 3600, offset: 0 },
202
+ ppm: { primary: '/', scale: 1e-6, offset: 0 },
203
+ '/100': { primary: '/', scale: 1 / 100, offset: 0, symbol: '%' },
204
+ '/1000': { primary: '/', scale: 1 / 1000, offset: 0, symbol: '‰' },
205
+ hPa: { primary: 'Pa', scale: 100, offset: 0 },
206
+ mm: { primary: 'm', scale: 1 / 1000, offset: 0 },
207
+ cm: { primary: 'm', scale: 1 / 100, offset: 0 },
208
+ km: { primary: 'm', scale: 1000, offset: 0 },
209
+ 'km/h': { primary: 'm/s', scale: 1 / 3.6, offset: 0 },
210
+ ppb: { primary: '/', scale: 1e-9, offset: 0 },
211
+ ppt: { primary: '/', scale: 1e-12, offset: 0 },
212
+ VAh: { primary: 'VAs', scale: 3600, offset: 0 },
213
+ 'mg/l': { primary: 'kg/m3', scale: 1 / 1000, offset: 0 },
214
+ 'ug/l': { primary: 'kg/m3', scale: 1e-6, offset: 0, symbol: 'µg/l' },
215
+ 'g/l': { primary: 'kg/m3', scale: 1, offset: 0 },
216
+ us: { primary: 's', scale: 1 / 1000000, offset: 0, symbol: 'µs' },
217
+ kHz: { primary: 'Hz', scale: 1000, offset: 0 },
218
+ GHz: { primary: 'Hz', scale: 1000000000, offset: 0 },
219
+ mW: { primary: 'W', scale: 1 / 1000, offset: 0 },
220
+ uA: { primary: 'A', scale: 1 / 1000000, offset: 0, symbol: 'µA' },
221
+ uV: { primary: 'V', scale: 1 / 1000000, offset: 0, symbol: 'µV' },
222
+ mAh: { primary: 'C', scale: 3.6, offset: 0 },
223
+ MiB: { primary: 'B', scale: 1048576, offset: 0 },
224
+ kB: { primary: 'B', scale: 1000, offset: 0 },
225
+ MB: { primary: 'B', scale: 1000000, offset: 0 },
226
+ 'kbit/s': { primary: 'bit/s', scale: 1000, offset: 0 },
227
+ 'KiB/s': { primary: 'bit/s', scale: 8192, offset: 0 },
228
+ kohm: { primary: 'Ohm', scale: 1000, offset: 0, symbol: 'kΩ' },
229
+ Mohm: { primary: 'Ohm', scale: 1000000, offset: 0, symbol: 'MΩ' },
230
+ kPa: { primary: 'Pa', scale: 1000, offset: 0 },
231
+ bar: { primary: 'Pa', scale: 100000, offset: 0 },
232
+ Bd: { primary: '1/s', scale: 1, offset: 0 },
233
+ };
234
+ const MAX_DEPTH = 8;
235
+ /* Caps on operator-visible annotation strings. A title is a field label and a
236
+ * description a sentence or two; both count toward the caller's encoded-size
237
+ * cap, so bound them here rather than letting one field crowd out a schema. */
238
+ const MAX_TITLE_LENGTH = 80;
239
+ const MAX_DESCRIPTION_LENGTH = 500;
240
+ const KINDS = new Set([
241
+ 'string',
242
+ 'number',
243
+ 'boolean',
244
+ 'unknown',
245
+ 'literal',
246
+ 'array',
247
+ 'object',
248
+ 'optional',
249
+ 'tuple',
250
+ 'union',
251
+ 'taggedUnion',
252
+ ]);
253
+ function fail(message, path) {
254
+ return { ok: false, error: SchemaError.ValidationFailed(message, path) };
255
+ }
256
+ /* JSON.parse creates `__proto__` as an own key, and downstream walks assign
257
+ * overlay values through `out[key] = …` — with these keys that assignment
258
+ * writes the prototype, not a property. The AST is untrusted, so refuse them
259
+ * outright. */
260
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
261
+ /**
262
+ * Validates an untrusted serialized schema AST as a config schema: well-formed
263
+ * nodes only, an object at the root, no `unknown()`, no `optional()` around an
264
+ * object or array (an overlay needs every absence to mean exactly one thing),
265
+ * defaults that match their own node, and nesting of at most 8 levels.
266
+ * The size cap is the caller's, since only the caller sees encoded bytes.
267
+ */
268
+ export function parseConfigSchema(value) {
269
+ if (!isPlainObject(value) || value.kind !== 'object') {
270
+ return fail('config schema root must be an object()', '');
271
+ }
272
+ const result = walk(value, '', 1);
273
+ if (result !== null)
274
+ return result;
275
+ return { ok: true, value: value };
276
+ }
277
+ function isPlainObject(value) {
278
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
279
+ }
280
+ /* A default below a wholesale unit never applies: applyDefaults replaces the
281
+ * unit whole, so only the unit's own whole-value default fills anything. The
282
+ * constructors reject it where it is written (core.ts, rejectInnerDefaults);
283
+ * a schema that arrived as JSON ran none of them, so the same rule is enforced
284
+ * here. The whole subtree is walked, not just the plain-object path, because
285
+ * nothing checked a nested unit's contents on the way in. Structure is already
286
+ * validated by `walk`, so a non-node here is left for it to report. */
287
+ function rejectInnerDefaults(value, path, unit, self) {
288
+ if (!isPlainObject(value))
289
+ return null;
290
+ if (value.default !== undefined) {
291
+ return fail(`a default under ${unit} never applies; give ${self} itself a whole-value default instead`, path);
292
+ }
293
+ const children = [];
294
+ for (const key of ['shape', 'branches']) {
295
+ const map = value[key];
296
+ if (isPlainObject(map)) {
297
+ for (const name of Object.keys(map))
298
+ children.push([map[name], `${path}.${name}`]);
299
+ }
300
+ }
301
+ for (const key of ['element', 'inner']) {
302
+ if (value[key] !== undefined)
303
+ children.push([value[key], path]);
304
+ }
305
+ for (const key of ['elements', 'members']) {
306
+ const items = value[key];
307
+ if (Array.isArray(items)) {
308
+ for (let i = 0; i < items.length; i++)
309
+ children.push([items[i], `${path}[${i}]`]);
310
+ }
311
+ }
312
+ for (const [child, childPath] of children) {
313
+ const result = rejectInnerDefaults(child, childPath, unit, self);
314
+ if (result !== null)
315
+ return result;
316
+ }
317
+ return null;
318
+ }
319
+ function walk(value, path, depth) {
320
+ if (depth > MAX_DEPTH)
321
+ return fail(`nesting deeper than ${MAX_DEPTH} levels`, path);
322
+ if (!isPlainObject(value))
323
+ return fail('expected a schema node', path);
324
+ const node = value;
325
+ const kind = node.kind;
326
+ if (typeof kind !== 'string' || !KINDS.has(kind)) {
327
+ return fail(`unknown schema kind ${JSON.stringify(kind)}`, path);
328
+ }
329
+ if (kind === 'unknown')
330
+ return fail('unknown() is not allowed in a config schema', path);
331
+ switch (kind) {
332
+ case 'literal': {
333
+ const t = typeof node.value;
334
+ if (t !== 'string' && t !== 'number' && t !== 'boolean') {
335
+ return fail('literal value must be a primitive', path);
336
+ }
337
+ break;
338
+ }
339
+ case 'array': {
340
+ const result = walk(node.element, `${path}.element`, depth + 1);
341
+ if (result !== null)
342
+ return result;
343
+ const inner = rejectInnerDefaults(node.element, `${path}.element`, 'an array', 'the array');
344
+ if (inner !== null)
345
+ return inner;
346
+ break;
347
+ }
348
+ case 'object': {
349
+ if (!isPlainObject(node.shape))
350
+ return fail('object shape must be a map', path);
351
+ if (node.default !== undefined)
352
+ return fail('object() cannot carry a default', path);
353
+ for (const key of Object.keys(node.shape)) {
354
+ if (UNSAFE_KEYS.has(key))
355
+ return fail(`unsafe field name ${JSON.stringify(key)}`, path);
356
+ const result = walk(node.shape[key], `${path}.${key}`, depth + 1);
357
+ if (result !== null)
358
+ return result;
359
+ }
360
+ break;
361
+ }
362
+ case 'optional': {
363
+ const inner = node.inner;
364
+ if (isPlainObject(inner) && (inner.kind === 'object' || inner.kind === 'array')) {
365
+ return fail(`optional() cannot wrap an ${inner.kind} in a config schema`, path);
366
+ }
367
+ if (node.default !== undefined) {
368
+ // The wrapper never carries one (the constructor forbids it); in an
369
+ // untrusted AST it would validate here and then be ignored by
370
+ // applyDefaults, a default that silently never applies.
371
+ return fail('optional() cannot carry a default', path);
372
+ }
373
+ if (isPlainObject(inner) && inner.default !== undefined) {
374
+ return fail('optional() cannot wrap a schema with a default', path);
375
+ }
376
+ const result = walk(inner, path, depth + 1);
377
+ if (result !== null)
378
+ return result;
379
+ break;
380
+ }
381
+ case 'tuple':
382
+ case 'union': {
383
+ const items = kind === 'tuple' ? node.elements : node.members;
384
+ if (!Array.isArray(items))
385
+ return fail(`${kind} items must be an array`, path);
386
+ if (kind === 'union' && items.length === 0) {
387
+ // Unsatisfiable: no value matches an empty union, so it would only
388
+ // fail later, at serve, one device at a time.
389
+ return fail('union needs at least one member', path);
390
+ }
391
+ for (let i = 0; i < items.length; i++) {
392
+ const result = walk(items[i], `${path}[${i}]`, depth + 1);
393
+ if (result !== null)
394
+ return result;
395
+ const unit = kind === 'tuple' ? 'a tuple' : 'a union';
396
+ const inner = rejectInnerDefaults(items[i], `${path}[${i}]`, unit, `the ${kind}`);
397
+ if (inner !== null)
398
+ return inner;
399
+ }
400
+ break;
401
+ }
402
+ case 'taggedUnion': {
403
+ if (typeof node.key !== 'string')
404
+ return fail('taggedUnion key must be a string', path);
405
+ if (!isPlainObject(node.branches))
406
+ return fail('taggedUnion branches must be a map', path);
407
+ if (Object.keys(node.branches).length === 0) {
408
+ // Unsatisfiable, like an empty union: it would only fail at serve.
409
+ return fail('taggedUnion needs at least one branch', path);
410
+ }
411
+ for (const tag of Object.keys(node.branches)) {
412
+ if (UNSAFE_KEYS.has(tag))
413
+ return fail(`unsafe branch tag ${JSON.stringify(tag)}`, path);
414
+ const branch = node.branches[tag];
415
+ if (!isPlainObject(branch) || branch.kind !== 'object') {
416
+ return fail('taggedUnion branch must be an object()', `${path}.${tag}`);
417
+ }
418
+ const result = walk(branch, `${path}.${tag}`, depth + 1);
419
+ if (result !== null)
420
+ return result;
421
+ const inner = rejectInnerDefaults(branch, `${path}.${tag}`, 'a taggedUnion', 'the union');
422
+ if (inner !== null)
423
+ return inner;
424
+ }
425
+ break;
426
+ }
427
+ }
428
+ const annotations = checkAnnotations(node, kind, path);
429
+ if (annotations !== null)
430
+ return annotations;
431
+ if (node.default !== undefined) {
432
+ // Constraint-aware on purpose: the constructors cannot do this any more,
433
+ // since core.ts no longer carries the checks, so a default that breaks its
434
+ // own bound must be caught here, at pack, which is moments later.
435
+ const check = validateConfig(node, node.default);
436
+ if (!check.ok) {
437
+ return fail(`default does not match the schema: ${check.error.message}`, path);
438
+ }
439
+ }
440
+ return null;
441
+ }
442
+ /* The constructors' own TypeErrors never run against a JSON-sourced AST, so
443
+ * every annotation the constructors accept is re-checked here. optional() is
444
+ * the wrapper the annotations do not belong on: it expresses absence, the node
445
+ * it wraps expresses identity. */
446
+ function checkAnnotations(node, kind, path) {
447
+ const onWrapper = kind === 'optional' || kind === 'unknown';
448
+ const text = (key, max) => {
449
+ const value = node[key];
450
+ if (value === undefined)
451
+ return null;
452
+ if (onWrapper)
453
+ return fail(`${kind}() cannot carry a ${key}; annotate what it wraps`, path);
454
+ if (typeof value !== 'string')
455
+ return fail(`${key} must be a string`, path);
456
+ if (value.length === 0)
457
+ return fail(`${key} must not be empty; omit it instead`, path);
458
+ if (value.length > max)
459
+ return fail(`${key} is longer than ${max} characters`, path);
460
+ return null;
461
+ };
462
+ const title = text('title', MAX_TITLE_LENGTH);
463
+ if (title !== null)
464
+ return title;
465
+ const description = text('description', MAX_DESCRIPTION_LENGTH);
466
+ if (description !== null)
467
+ return description;
468
+ if (node.mask !== undefined) {
469
+ if (kind !== 'string' && kind !== 'number') {
470
+ return fail(`mask is only allowed on string() and number()`, path);
471
+ }
472
+ if (typeof node.mask !== 'boolean')
473
+ return fail('mask must be a boolean', path);
474
+ if (node.mask === true && node.default !== undefined) {
475
+ // A masked field with a default ships the same placeholder credential to
476
+ // every device, which is the opposite of what masking is for. Only when
477
+ // it is actually masked: `mask: false` is the ordinary state and says
478
+ // nothing about defaults.
479
+ return fail('a masked field cannot carry a default', path);
480
+ }
481
+ }
482
+ return checkConstraints(node, kind, path);
483
+ }
484
+ /* Which constraints each kind accepts, and whether the value must be a
485
+ * non-negative whole number (a count) or merely finite (a bound). */
486
+ const CONSTRAINTS_BY_KIND = {
487
+ string: ['minLength', 'maxLength'],
488
+ number: ['min', 'max'],
489
+ array: ['minItems', 'maxItems'],
490
+ };
491
+ const COUNT_CONSTRAINTS = ['minLength', 'maxLength', 'minItems', 'maxItems'];
492
+ const ALL_CONSTRAINTS = ['minLength', 'maxLength', 'min', 'max', 'minItems', 'maxItems'];
493
+ function checkConstraints(node, kind, path) {
494
+ const allowed = CONSTRAINTS_BY_KIND[kind];
495
+ for (const key of ALL_CONSTRAINTS) {
496
+ const value = node[key];
497
+ if (value === undefined)
498
+ continue;
499
+ if (allowed === undefined || !allowed.includes(key)) {
500
+ return fail(`${key} is not allowed on ${kind}()`, path);
501
+ }
502
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
503
+ return fail(`${key} must be a finite number`, path);
504
+ }
505
+ if (COUNT_CONSTRAINTS.includes(key) && (!Number.isInteger(value) || value < 0)) {
506
+ return fail(`${key} must be a non-negative whole number`, path);
507
+ }
508
+ }
509
+ if (allowed !== undefined) {
510
+ const [lower, upper] = allowed;
511
+ const low = node[lower];
512
+ const high = node[upper];
513
+ if (typeof low === 'number' && typeof high === 'number' && low > high) {
514
+ return fail(`${lower} is greater than ${upper}`, path);
515
+ }
516
+ }
517
+ if (node.integer !== undefined) {
518
+ if (kind !== 'number')
519
+ return fail('integer is not allowed on ' + kind + '()', path);
520
+ if (typeof node.integer !== 'boolean')
521
+ return fail('integer must be a boolean', path);
522
+ }
523
+ if (node.unit !== undefined) {
524
+ if (kind !== 'number')
525
+ return fail(`unit is not allowed on ${kind}()`, path);
526
+ if (typeof node.unit !== 'string' || !Object.hasOwn(UNITS, node.unit)) {
527
+ return fail(`unknown unit ${JSON.stringify(node.unit)}`, path);
528
+ }
529
+ }
530
+ if (node.format !== undefined) {
531
+ if (kind !== 'string')
532
+ return fail(`format is not allowed on ${kind}()`, path);
533
+ // Fail closed. An unrecognised display annotation may be ignored; an
534
+ // unrecognised constraint may not, since ignoring it means accepting a
535
+ // value the author ruled out. Rejecting at publish puts it in front of the
536
+ // one person who can fix it.
537
+ if (typeof node.format !== 'string' || !FORMATS.includes(node.format)) {
538
+ return fail(`unknown format ${JSON.stringify(node.format)} (known: ${FORMATS.join(', ')})`, path);
539
+ }
540
+ }
541
+ return null;
542
+ }
543
+ /**
544
+ * Derives the overlay to store or serve from operator-supplied values: drops
545
+ * keys the schema does not know, strips values structurally equal to the
546
+ * schema default, and prunes empty objects and arrays. Returns undefined when
547
+ * nothing deviates from the defaults. Wholesale nodes (arrays, tuples, unions)
548
+ * are compared and kept as units; nothing inside them is stripped.
549
+ */
550
+ export function deriveOverlay(schema, values) {
551
+ switch (schema.kind) {
552
+ case 'object': {
553
+ if (values === undefined)
554
+ return undefined;
555
+ // A defined value of the wrong kind is kept, not dropped: dropping it
556
+ // would derive a clean overlay from garbage, so a typo'd save would
557
+ // succeed while storing nothing and rule 5 would never gate on it.
558
+ // Kept, it fails the merge-validate that every caller runs next.
559
+ if (!isPlainObject(values))
560
+ return values;
561
+ const out = {};
562
+ for (const key of Object.keys(schema.shape)) {
563
+ const field = schema.shape[key];
564
+ const value = values[key];
565
+ if (value === undefined)
566
+ continue;
567
+ const derived = field.kind === 'optional'
568
+ ? deriveOverlay(field.inner, value)
569
+ : deriveOverlay(field, value);
570
+ if (derived !== undefined)
571
+ out[key] = derived;
572
+ }
573
+ return Object.keys(out).length > 0 ? out : undefined;
574
+ }
575
+ case 'array': {
576
+ if (values === undefined)
577
+ return undefined;
578
+ // Wrong kind kept for the same reason as objects above; only a real
579
+ // empty array prunes (emptiness is never a deliberate overlay state).
580
+ if (!Array.isArray(values))
581
+ return values;
582
+ if (values.length === 0)
583
+ return undefined;
584
+ if (schema.default !== undefined && structuralEquals(values, schema.default)) {
585
+ return undefined;
586
+ }
587
+ return stripDangerousKeys(values);
588
+ }
589
+ default: {
590
+ const fallback = schema.default;
591
+ if (fallback !== undefined && structuralEquals(values, fallback))
592
+ return undefined;
593
+ return stripDangerousKeys(values);
594
+ }
595
+ }
596
+ }
597
+ /** Wholesale units travel with their unknown keys intact, but the three
598
+ * prototype-writing names never survive into a stored or served value:
599
+ * downstream walks and consumers assign through `out[key]`. */
600
+ function stripDangerousKeys(value) {
601
+ if (Array.isArray(value))
602
+ return value.map(stripDangerousKeys);
603
+ if (isPlainObject(value)) {
604
+ const out = {};
605
+ for (const key of Object.keys(value)) {
606
+ if (UNSAFE_KEYS.has(key))
607
+ continue;
608
+ out[key] = stripDangerousKeys(value[key]);
609
+ }
610
+ return out;
611
+ }
612
+ return value;
613
+ }
614
+ export function structuralEquals(a, b) {
615
+ if (a === b)
616
+ return true;
617
+ if (Array.isArray(a) && Array.isArray(b)) {
618
+ if (a.length !== b.length)
619
+ return false;
620
+ for (let i = 0; i < a.length; i++) {
621
+ if (!structuralEquals(a[i], b[i]))
622
+ return false;
623
+ }
624
+ return true;
625
+ }
626
+ if (isPlainObject(a) && isPlainObject(b)) {
627
+ const keysA = Object.keys(a);
628
+ const keysB = Object.keys(b);
629
+ if (keysA.length !== keysB.length)
630
+ return false;
631
+ for (const key of keysA) {
632
+ if (!Object.hasOwn(b, key) || !structuralEquals(a[key], b[key]))
633
+ return false;
634
+ }
635
+ return true;
636
+ }
637
+ return false;
638
+ }
639
+ /**
640
+ * The effective config for an overlay: defaults filled in, then validated.
641
+ * What a registry runs before serving and what `ota.config()` runs on read.
642
+ */
643
+ export function parseEffective(schema, overlay) {
644
+ const effective = applyDefaults(schema, overlay);
645
+ // validateConfig, not validate: this is the gate an operator's config passes
646
+ // through, and constraints only bind if they are checked here.
647
+ const result = validateConfig(schema, effective);
648
+ return result.ok ? { ok: true, value: effective } : result;
649
+ }
650
+ /**
651
+ * The partial defaults a schema materializes with no overrides: every field a
652
+ * default covers, and nothing else. Unlike parseEffective it never fails on a
653
+ * required defaultless field, it omits it. This is what pack bakes into the
654
+ * manifest and what a device reads when it holds no served document.
655
+ *
656
+ * Plain objects compose, so a nested one is included only when defaults fill
657
+ * it completely: a half-filled object would not validate, and the read type
658
+ * makes that field optional anyway. Wholesale units (array, tuple, union,
659
+ * taggedUnion) need a whole-value default on the node itself, matching
660
+ * applyDefaults, where a default inside an element or branch is a form hint.
661
+ * Optional fields rest on absence, so they are omitted too.
662
+ */
663
+ export function materializeDefaults(schema) {
664
+ return schema.kind === 'object' ? fillObject(schema).value : {};
665
+ }
666
+ /** What defaults cover in an object shape. `complete` (every field covered or
667
+ * optional) is what makes a NESTED object safe to include. */
668
+ function fillObject(node) {
669
+ const out = {};
670
+ let complete = true;
671
+ for (const key of Object.keys(node.shape)) {
672
+ const field = node.shape[key];
673
+ if (field.kind === 'optional')
674
+ continue;
675
+ const filled = fillField(field);
676
+ if (filled === undefined)
677
+ complete = false;
678
+ else
679
+ out[key] = filled.value;
680
+ }
681
+ return { value: out, complete };
682
+ }
683
+ /** The value defaults give one field, or undefined when nothing covers it. */
684
+ function fillField(node) {
685
+ if (node.kind === 'object') {
686
+ const filled = fillObject(node);
687
+ return filled.complete ? { value: filled.value } : undefined;
688
+ }
689
+ const fallback = node.default;
690
+ return fallback === undefined ? undefined : { value: fallback };
691
+ }
692
+ /** A node with its annotations removed, recursively, so two schemas can be
693
+ * compared on structure alone. */
694
+ /* Cosmetic: a change to one of these must never read as a structural change. */
695
+ const DISPLAY_KEYS = ['default', 'title', 'description', 'mask'];
696
+ /* Semantic, so they survive stripAnnotations and are reported on their own
697
+ * terms. Stripped only for the type comparison, where a tightened bound must
698
+ * not masquerade as a changed type. */
699
+ const CONSTRAINT_KEYS = [
700
+ 'minLength',
701
+ 'maxLength',
702
+ 'min',
703
+ 'max',
704
+ 'integer',
705
+ 'minItems',
706
+ 'maxItems',
707
+ 'format',
708
+ ];
709
+ /* `unit` renders as a suffix, but it is not cosmetic: it reinterprets every
710
+ * stored value, since `interval: 30` means one thing under `s` and another
711
+ * under `ms`. Nothing fails validation, the device just behaves differently. So
712
+ * it survives stripAnnotations and is reported on its own terms, and is
713
+ * stripped only for the type comparison. */
714
+ const SHAPE_KEYS = [...DISPLAY_KEYS, ...CONSTRAINT_KEYS, 'unit'];
715
+ function stripKeys(node, keys) {
716
+ if (!isPlainObject(node))
717
+ return node;
718
+ const out = {};
719
+ for (const key of Object.keys(node)) {
720
+ if (keys.includes(key))
721
+ continue;
722
+ const value = node[key];
723
+ if (key === 'shape' || key === 'branches') {
724
+ const map = value;
725
+ const stripped = {};
726
+ for (const k of Object.keys(map))
727
+ stripped[k] = stripKeys(map[k], keys);
728
+ out[key] = stripped;
729
+ }
730
+ else if (key === 'element' || key === 'inner') {
731
+ out[key] = stripKeys(value, keys);
732
+ }
733
+ else if (key === 'elements' || key === 'members') {
734
+ out[key] = value.map((item) => stripKeys(item, keys));
735
+ }
736
+ else {
737
+ out[key] = value;
738
+ }
739
+ }
740
+ return out;
741
+ }
742
+ /** A node reduced to its shape alone, for asking "did the type change?" without
743
+ * a tightened bound answering yes.
744
+ *
745
+ * This is now the only comparison the diff makes. Constraints used to be kept
746
+ * in some comparisons so that a change to one registered as a difference, but
747
+ * every constraint is reported explicitly by constraintWarnings, and keeping
748
+ * them here only made a changed bound masquerade as a changed type. */
749
+ function stripToShape(node) {
750
+ return stripKeys(node, SHAPE_KEYS);
751
+ }
752
+ /* Tightening a bound can invalidate a value an operator already stored, so it
753
+ * gates the same way a removed union member does. Loosening cannot, and is
754
+ * silent. Reported at release time on the schemas alone; rule 5 catches which
755
+ * devices are actually affected when an offer is considered. */
756
+ function constraintWarnings(prev, curr, path, out) {
757
+ if (!isPlainObject(prev) || !isPlainObject(curr))
758
+ return;
759
+ const report = (key, lowerIsLooser) => {
760
+ const before = prev[key];
761
+ const after = curr[key];
762
+ if (after === undefined)
763
+ return;
764
+ const gate = (how) => {
765
+ out.push(`requires an operator: ${path} ${how} ${key} (stored overrides may no longer validate)`);
766
+ };
767
+ if (before === undefined)
768
+ return gate('added');
769
+ if (typeof before !== 'number' || typeof after !== 'number')
770
+ return;
771
+ if (lowerIsLooser ? after > before : after < before)
772
+ gate(lowerIsLooser ? 'raised' : 'lowered');
773
+ };
774
+ for (const key of ['min', 'minLength', 'minItems'])
775
+ report(key, true);
776
+ for (const key of ['max', 'maxLength', 'maxItems'])
777
+ report(key, false);
778
+ if (curr.integer === true && prev.integer !== true) {
779
+ out.push(`requires an operator: ${path} now requires a whole number ` +
780
+ `(stored overrides may no longer validate)`);
781
+ }
782
+ if (curr.format !== undefined && prev.format !== curr.format) {
783
+ out.push(`requires an operator: ${path} now requires format ${JSON.stringify(curr.format)} ` +
784
+ `(stored overrides may no longer validate)`);
785
+ }
786
+ if (prev.unit !== curr.unit) {
787
+ out.push(`requires an operator: ${path} changed unit from ${JSON.stringify(prev.unit ?? null)} ` +
788
+ `to ${JSON.stringify(curr.unit ?? null)} (stored values are reinterpreted)`);
789
+ }
790
+ /* Descend where the outer walk does not. It recurses through object shapes
791
+ * only, so without this a tightened bound on an array element or a tuple
792
+ * position is silent: stripToShape removes constraints recursively, so the
793
+ * type comparison sees no change, and the checks above only read this node's
794
+ * own keys. A stranded override with no operator gate is exactly what the
795
+ * taxonomy exists to prevent. */
796
+ if (prev.kind === 'array' && curr.kind === 'array') {
797
+ constraintWarnings(prev.element, curr.element, `${path}[]`, out);
798
+ }
799
+ else if (prev.kind === 'tuple' && curr.kind === 'tuple') {
800
+ const elements = curr.elements;
801
+ const previous = prev.elements;
802
+ for (let i = 0; i < Math.min(previous.length, elements.length); i++) {
803
+ constraintWarnings(previous[i], elements[i], `${path}[${i}]`, out);
804
+ }
805
+ }
806
+ else if (prev.kind === 'optional' && curr.kind === 'optional') {
807
+ constraintWarnings(prev.inner, curr.inner, path, out);
808
+ }
809
+ else if (prev.kind === 'object' && curr.kind === 'object') {
810
+ /* array(object({port: number({max: 65535})})) is an ordinary config shape,
811
+ * and without this the port's tightened bound is silent: the outer walk
812
+ * hands the array to stripToShape, which is equal, and the descent above
813
+ * reaches the element object and stops.
814
+ *
815
+ * No double-reporting with the outer walk: its object branch recurses per
816
+ * field and returns before reaching constraintWarnings, so an object is
817
+ * either walked or descended here, never both. */
818
+ const prevShape = prev.shape;
819
+ const currShape = curr.shape;
820
+ for (const key of Object.keys(currShape)) {
821
+ if (Object.hasOwn(prevShape, key)) {
822
+ constraintWarnings(prevShape[key], currShape[key], `${path}.${key}`, out);
823
+ }
824
+ }
825
+ }
826
+ else if (prev.kind === 'taggedUnion' && curr.kind === 'taggedUnion') {
827
+ const prevBranches = prev.branches;
828
+ const currBranches = curr.branches;
829
+ for (const tag of Object.keys(currBranches)) {
830
+ if (Object.hasOwn(prevBranches, tag)) {
831
+ constraintWarnings(prevBranches[tag], currBranches[tag], `${path}.${tag}`, out);
832
+ }
833
+ }
834
+ }
835
+ }
836
+ /** required / defaulted / optional, per the spec's scalar-leaf classes.
837
+ * Containers have no class; objects report 'object' so the walk descends. */
838
+ function leafClass(node) {
839
+ if (node.kind === 'optional')
840
+ return 'optional';
841
+ if (node.kind === 'object')
842
+ return 'object';
843
+ if (node.kind === 'array')
844
+ return 'array';
845
+ return node.default !== undefined ? 'defaulted' : 'required';
846
+ }
847
+ /** Every required scalar leaf reachable in `node`, for reporting an added
848
+ * subtree that will gate offers until an operator supplies values. */
849
+ function requiredLeaves(node, path, out) {
850
+ if (node.kind === 'object') {
851
+ for (const key of Object.keys(node.shape)) {
852
+ requiredLeaves(node.shape[key], `${path}.${key}`, out);
853
+ }
854
+ return;
855
+ }
856
+ if (leafClass(node) === 'required')
857
+ out.push(path);
858
+ }
859
+ /**
860
+ * Human-readable warnings for what changed between two releases' config
861
+ * schemas, per the spec's change taxonomy (registry-spec.md, "Schema changes
862
+ * between releases"). Safe changes (new defaulted or optional fields, added
863
+ * union members, loosened requirements) produce nothing. "requires an
864
+ * operator" lines gate offers under rule 5 until someone supplies or fixes a
865
+ * value; "note" lines are compatible but worth telling the operator about.
866
+ */
867
+ export function diffConfigSchemas(previous, next) {
868
+ const warnings = [];
869
+ function walk(prev, curr, path) {
870
+ const prevInner = prev.kind === 'optional' ? prev.inner : prev;
871
+ const currInner = curr.kind === 'optional' ? curr.inner : curr;
872
+ if (prevInner.kind === 'object' && currInner.kind === 'object') {
873
+ const prevShape = prevInner.shape;
874
+ const currShape = currInner.shape;
875
+ for (const key of Object.keys(currShape)) {
876
+ const fieldPath = `${path}.${key}`;
877
+ if (Object.hasOwn(prevShape, key)) {
878
+ walk(prevShape[key], currShape[key], fieldPath);
879
+ }
880
+ else {
881
+ const added = [];
882
+ requiredLeaves(currShape[key], fieldPath, added);
883
+ for (const leaf of added) {
884
+ warnings.push(`requires an operator: new required field ${leaf} (devices are not offered ` +
885
+ `this release until a value is set)`);
886
+ }
887
+ }
888
+ }
889
+ for (const key of Object.keys(prevShape)) {
890
+ if (!Object.hasOwn(currShape, key)) {
891
+ warnings.push(`note: removed field ${path}.${key} (stored overrides for it stay, but are no ` +
892
+ `longer served)`);
893
+ }
894
+ }
895
+ return;
896
+ }
897
+ if (prevInner.kind === 'union' && currInner.kind === 'union') {
898
+ // Member sets, not wholesale structure: adding a member (the common
899
+ // safe widening) must not read as a type change. Only removals can
900
+ // invalidate a stored override.
901
+ /* Counted by shape group, not tested for membership. Comparing on shape
902
+ * is what stops a merely loosened bound reading as a removal, but it also
903
+ * makes two differently bounded members of the same kind indistinguishable
904
+ * here: asking "does a member of this shape still exist?" answers yes when
905
+ * one of the two has gone. Dropping one of
906
+ * union([number({max: 10}), number({min: 100})]) would then be silent,
907
+ * stranding any override only the removed range accepted.
908
+ *
909
+ * A group whose count fell has lost a member. Counting keeps the loosened
910
+ * bound safe, since that leaves the count unchanged. */
911
+ const countOfShape = (members, shape) => members.filter((member) => structuralEquals(stripToShape(member), shape)).length;
912
+ let removed = 0;
913
+ const groups = [];
914
+ for (const prevMember of prevInner.members) {
915
+ const shape = stripToShape(prevMember);
916
+ if (groups.some((seen) => structuralEquals(seen, shape)))
917
+ continue;
918
+ groups.push(shape);
919
+ const before = countOfShape(prevInner.members, shape);
920
+ const after = countOfShape(currInner.members, shape);
921
+ if (after < before)
922
+ removed += before - after;
923
+ }
924
+ if (removed > 0) {
925
+ warnings.push(`requires an operator: ${path} removed ${removed} union member(s) ` +
926
+ `(stored overrides using them no longer validate)`);
927
+ }
928
+ // Constraint changes within members, index-wise. Members are ordered and
929
+ // an edit that also reorders them is not something this can attribute, so
930
+ // only compare when the lists still line up.
931
+ if (prevInner.members.length === currInner.members.length) {
932
+ for (let i = 0; i < prevInner.members.length; i++) {
933
+ constraintWarnings(prevInner.members[i], currInner.members[i], `${path}|${i}`, warnings);
934
+ }
935
+ }
936
+ }
937
+ else if (prevInner.kind === 'taggedUnion' &&
938
+ currInner.kind === 'taggedUnion' &&
939
+ prevInner.key === currInner.key) {
940
+ // Same idea per branch: added branches are safe, removed or reshaped
941
+ // ones can strand a stored override.
942
+ for (const tag of Object.keys(prevInner.branches)) {
943
+ const prevBranch = prevInner.branches[tag];
944
+ const currBranch = currInner.branches[tag];
945
+ if (currBranch === undefined || !Object.hasOwn(currInner.branches, tag)) {
946
+ warnings.push(`requires an operator: ${path} removed branch ${JSON.stringify(tag)} ` +
947
+ `(stored overrides using it no longer validate)`);
948
+ }
949
+ else if (!structuralEquals(stripToShape(prevBranch), stripToShape(currBranch))) {
950
+ // Shape, not constraints: a branch whose bound merely changed has not
951
+ // changed type, and the descent in constraintWarnings reports it on
952
+ // its own terms rather than as a reshaped branch.
953
+ warnings.push(`requires an operator: ${path}.${tag} changed type ` +
954
+ `(stored overrides may no longer validate)`);
955
+ }
956
+ }
957
+ }
958
+ else if (!structuralEquals(stripToShape(prevInner), stripToShape(currInner))) {
959
+ warnings.push(`requires an operator: ${path} changed type (stored overrides may no longer validate)`);
960
+ return;
961
+ }
962
+ constraintWarnings(prevInner, currInner, path, warnings);
963
+ const prevClass = leafClass(prev);
964
+ const currClass = leafClass(curr);
965
+ if (currClass === 'required' && prevClass !== 'required') {
966
+ warnings.push(`requires an operator: ${path} is now required (devices without a value are not ` +
967
+ `offered this release)`);
968
+ }
969
+ const prevDefault = prevInner.default;
970
+ const currDefault = currInner.default;
971
+ // A removed default is the required-transition above, not also a default
972
+ // change.
973
+ if (currDefault !== undefined && !structuralEquals(prevDefault, currDefault)) {
974
+ warnings.push(`note: default of ${path} changed (takes effect on every device without an override)`);
975
+ }
976
+ }
977
+ walk(previous, next, '');
978
+ return warnings;
979
+ }
980
+ //# sourceMappingURL=config.js.map