@empyria/common 0.1.9 → 0.2.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/README.md CHANGED
@@ -41,14 +41,14 @@ import { defineSchema } from '@empyria/common/lib/Ata.js'
41
41
 
42
42
  ## Modules
43
43
 
44
- | Module | Purpose |
45
- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
- | [index.js](./index.js) | Package entry point; re-exports every module below plus Moleculer meta helpers (`moleculerMeta`, `moleculerEmpyriaMeta`) and workflow ID helpers (`workflowName`, `workflowId`, `identifyWorkflow`). |
47
- | [lib/Errors.js](./lib/Errors.js) | `EmpyriaError`, the `ErrorCreator` factory, and `BaseErrors` — Empyria's catalog of pre-built, templated errors. |
48
- | [lib/Env.js](./lib/Env.js) | `createEnv` — builds a service's environment-variable JSON schema from Empyria-wide defaults. |
49
- | [lib/Ata.js](./lib/Ata.js) | JSON schema building blocks (`string`, `number`, `bool`, `enumType`, `email`, `password`, `ip4`, `ip6`, ...) and validation — throwing (`defineSchema`, `createValidator`, `validate`) or non-throwing/structured (`createChecker`, `checkSchema`) — built on [ata-validator](https://www.npmjs.com/package/ata-validator). |
50
- | [lib/Codogram.js](./lib/Codogram.js) | `generateCodogram` — one-time challenge codes (PIN, alphanumeric, alphabetic) for SMS/TOTP-style verification flows. |
51
- | [lib/Defaulter.js](./lib/Defaulter.js) | `defaultsFromSchema` — builds a fully-populated default object for a JSON schema. |
44
+ | Module | Purpose |
45
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
+ | [index.js](./index.js) | Package entry point; re-exports every module below plus Moleculer meta helpers (`moleculerMeta`, `moleculerEmpyriaMeta`) and workflow ID helpers (`workflowName`, `workflowId`, `identifyWorkflow`). |
47
+ | [lib/Errors.js](./lib/Errors.js) | `EmpyriaError`, the `ErrorCreator` factory, and `BaseErrors` — Empyria's catalog of pre-built, templated errors. |
48
+ | [lib/Env.js](./lib/Env.js) | `createEnv` — builds a service's environment-variable JSON schema from Empyria-wide defaults; pass `{ closed: true }` to reject undeclared vars and bind `{ coerceTypes, removeAdditional }` to the schema. |
49
+ | [lib/Ata.js](./lib/Ata.js) | JSON schema building blocks (`string`, `number`, `bool`, `enumType`, `email`, `password`, `ip4`, `ip6`, ...) and validation — throwing (`defineSchema`, `createValidator`, `validate`), transform-intent aliases (`parse`, `createParser`), or non-throwing/structured (`createChecker`, `checkSchema`, now returning the parsed `data`) — built on [ata-validator](https://www.npmjs.com/package/ata-validator). See _Validation options_ below. |
50
+ | [lib/Codogram.js](./lib/Codogram.js) | `generateCodogram` — one-time challenge codes (PIN, alphanumeric, alphabetic) for SMS/TOTP-style verification flows. |
51
+ | [lib/Defaulter.js](./lib/Defaulter.js) | `defaultsFromSchema` — builds a fully-populated default object for a JSON schema. |
52
52
 
53
53
  Every exported function is documented with JSDoc directly in its source file — hovering
54
54
  a function in VSCode or Zed shows its parameters and return type without any extra
@@ -56,6 +56,40 @@ tooling, since both editors read JSDoc from plain `.js` files automatically.
56
56
 
57
57
  Tests live under [test/](./test/), one file per module, separate from the `lib/` sources.
58
58
 
59
+ ## Validation options
60
+
61
+ `createValidator` / `validate` / `parse` / `createChecker` / `checkSchema` forward
62
+ [`ata-validator`'s options](https://www.npmjs.com/package/ata-validator) to the underlying
63
+ `Validator`. Options are resolved in three layers, **last wins**:
64
+
65
+ 1. **library defaults** — `setAtaDefaults({ ... })`, called once at startup for a
66
+ deployment-wide policy; `getAtaDefaults()` reads it back, `setAtaDefaults()` clears it.
67
+ 2. **schema-bound** — `defineSchema(map, { validation: { ... } })` stores options on a
68
+ non-enumerable key, so a schema carries its own parsing policy (`schemaOptions(schema)`
69
+ reads it). The schema still serialises and compares as plain JSON Schema.
70
+ 3. **per-call** — the `options` argument to any of the functions above.
71
+
72
+ `formats` is merged one level deep, with `EMPYRIA_FORMATS` always underneath.
73
+
74
+ | Option | What it does (Empyria relevance) |
75
+ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
76
+ | `coerceTypes` | `"4040"` → `4040` before validation. The reason `env.js` files work — every `process.env` value is a string. |
77
+ | `useDefaults` | Fill missing props from their schema `default`. **On by default**; every `string()/number()/bool()/enumType()` emits one. |
78
+ | `removeAdditional` | Strip keys not covered by the schema. Only visible with `additionalProperties: false` (i.e. `defineSchema({ strict: true })`). |
79
+ | `assertFormat` | Whether a `format` mismatch is a hard error. On by default. |
80
+ | `abortEarly` | Stop at the first error, skip enrichment. For hot verdict-only paths (pair with `createChecker`, not the throwing path). |
81
+ | `richErrors` | Keep `ATA####` codes / doc URLs / hints that `renderPretty` & co. consume. On by default; leave it on. |
82
+ | `verbose` | Add `parentSchema` to each error (ajv's `verbose`). |
83
+ | `formats` | Custom `format` checkers. `EMPYRIA_FORMATS` (`empyria-email`, `empyria-password`, `empyria-ipv4`, `empyria-ipv6`) is folded in automatically. |
84
+
85
+ **Return value / mutation.** `createValidator` & `validate` return `ata-validator`'s
86
+ `result.data` — the same object as the input unless `coerceTypes` / `removeAdditional` /
87
+ `useDefaults` applied. Those passes also **mutate the input object in place**, so hand them a
88
+ copy when the caller must keep its input pristine (as the `env.js` files do with
89
+ `{ ...process.env }`). `parse` / `createParser` are behavioural aliases for call sites whose
90
+ intent is to transform rather than merely assert. `createChecker` / `checkSchema` now also
91
+ return `data` (the parsed value on success, `undefined` on failure).
92
+
59
93
  ## Scripts
60
94
 
61
95
  ```bash
package/lib/Ata.js CHANGED
@@ -31,6 +31,98 @@ export const P_IPv6 = `
31
31
  .replace(/\n/g, '')
32
32
  .trim()
33
33
 
34
+ const RE_EMAIL = new RegExp(`^${P_EMAIL}`, 'i')
35
+ const RE_PASS = new RegExp(P_PASS)
36
+ const RE_IPv4 = new RegExp(`^${P_IPv4}`)
37
+ const RE_IPv6 = new RegExp(`^${P_IPv6}`)
38
+
39
+ /**
40
+ * Empyria's custom `format` checkers, keyed by the `format` name a schema references.
41
+ * Registered automatically on every validator this module builds (see `resolveOptions`), so
42
+ * a schema carrying `{ type: 'string', format: 'empyria-email' }` — as produced by
43
+ * {@link email}, {@link password}, {@link ip4}, {@link ip6} — is enforced with no per-call
44
+ * wiring.
45
+ *
46
+ * The names are deliberately prefixed rather than reusing `email` / `ipv4` / `ipv6`:
47
+ * `ata-validator`'s compiled path only lets a custom `formats` entry override a *built-in*
48
+ * name at the schema root, not on a nested property, whereas an unknown name is honoured
49
+ * everywhere. A caller's own `options.formats` entry for the same key still wins.
50
+ *
51
+ * A schema from {@link email} & co. is only enforced when validated through this module (or
52
+ * through a validator the caller seeded with this map); a bare `new Validator(schema)` sees
53
+ * these as unknown (ignored) formats.
54
+ * @type {Record<string, (value: unknown) => boolean>}
55
+ */
56
+ export const EMPYRIA_FORMATS = {
57
+ 'empyria-email': (s) => typeof s === 'string' && RE_EMAIL.test(s),
58
+ 'empyria-password': (s) => typeof s === 'string' && RE_PASS.test(s),
59
+ 'empyria-ipv4': (s) => typeof s === 'string' && RE_IPv4.test(s),
60
+ 'empyria-ipv6': (s) => typeof s === 'string' && RE_IPv6.test(s),
61
+ }
62
+
63
+ /** Non-enumerable key under which {@link defineSchema} stashes a schema's bound options. */
64
+ const SCHEMA_OPTIONS = Symbol('ataOptions')
65
+
66
+ /** Process-wide default `ata-validator` options; the base layer for every call here. */
67
+ let libraryDefaults = {}
68
+
69
+ /**
70
+ * Sets this module's process-wide default `ata-validator` options — the base layer every
71
+ * {@link createValidator} / {@link validate} / {@link parse} / {@link createChecker} call in
72
+ * this process starts from. Meant to be called once at startup to express a deployment-wide
73
+ * policy (e.g. `{ coerceTypes: true }`). Individual calls stay free to override any key, and
74
+ * a schema built by {@link defineSchema} with a `validation` bag overrides these too.
75
+ *
76
+ * Merge order, lowest to highest precedence: **library defaults → schema-bound → per-call**.
77
+ * `formats` is merged one level deep, with {@link EMPYRIA_FORMATS} always underneath.
78
+ * @param {import('ata-validator').ValidatorOptions} [options={}] - Replaces the current
79
+ * defaults wholesale. Pass `{}` (or call with no argument) to clear them.
80
+ * @returns {void}
81
+ */
82
+ export function setAtaDefaults(options = {}) {
83
+ libraryDefaults = { ...options }
84
+ }
85
+
86
+ /**
87
+ * Returns a shallow copy of the current process-wide default options (see {@link setAtaDefaults}).
88
+ * @returns {import('ata-validator').ValidatorOptions}
89
+ */
90
+ export function getAtaDefaults() {
91
+ return { ...libraryDefaults }
92
+ }
93
+
94
+ /**
95
+ * Returns a shallow copy of the `ata-validator` options bound to `schema` by
96
+ * {@link defineSchema}'s `validation` option, or `{}` if none were bound.
97
+ * @param {Object} schema
98
+ * @returns {import('ata-validator').ValidatorOptions}
99
+ */
100
+ export function schemaOptions(schema) {
101
+ const bound = schema?.[SCHEMA_OPTIONS]
102
+ return bound ? { ...bound } : {}
103
+ }
104
+
105
+ /**
106
+ * Merges the three option layers for a single call and folds in {@link EMPYRIA_FORMATS}.
107
+ * @param {Object} schema - Schema being validated (may carry bound options).
108
+ * @param {import('ata-validator').ValidatorOptions} [perCall] - Options passed to this call.
109
+ * @returns {import('ata-validator').ValidatorOptions}
110
+ */
111
+ function resolveOptions(schema, perCall) {
112
+ const bound = schema?.[SCHEMA_OPTIONS] ?? {}
113
+ return {
114
+ ...libraryDefaults,
115
+ ...bound,
116
+ ...perCall,
117
+ formats: {
118
+ ...EMPYRIA_FORMATS,
119
+ ...libraryDefaults.formats,
120
+ ...bound.formats,
121
+ ...(perCall && perCall.formats),
122
+ },
123
+ }
124
+ }
125
+
34
126
  /**
35
127
  * Builds a function that clones only the keys declared on a schema's `properties`.
36
128
  * @param {Object} schema - JSON schema with a `properties` map.
@@ -45,12 +137,22 @@ export function cloneBySchema(schema) {
45
137
  * Builds a JSON schema object from a flat map of property schemas.
46
138
  * @param {Object} schemaDef - Map of property name to its JSON schema.
47
139
  * @param {Object} [options={}]
48
- * @param {boolean} [options.strict] - If true, disallows properties not listed in `schemaDef`.
140
+ * @param {boolean} [options.strict] - If true, disallows properties not listed in `schemaDef`
141
+ * (`additionalProperties: false`). Combine with `validation: { removeAdditional: true }` to
142
+ * *strip* unknown keys instead of rejecting them. Ignored when `options.additionalProperties`
143
+ * is given.
144
+ * @param {boolean|Object} [options.additionalProperties] - Explicit `additionalProperties`
145
+ * value (a boolean, or a sub-schema for the extra keys). Overrides `options.strict`.
49
146
  * @param {boolean} [options.optional] - If true, a property whose own schema carries
50
147
  * `optional: true` is excluded from `required` (that marker is then stripped from the
51
148
  * property itself — never leaked into the resulting schema, since it isn't a real JSON
52
149
  * Schema keyword). Off by default: every declared property is required, exactly as before —
53
150
  * a caller that never sets `optional: true` on any property sees no difference either way.
151
+ * @param {import('ata-validator').ValidatorOptions} [options.validation] - `ata-validator`
152
+ * options bound to the returned schema: every {@link createValidator} / {@link validate} /
153
+ * {@link parse} / {@link createChecker} call for it starts from these (below per-call
154
+ * options, above {@link setAtaDefaults}). Stored on a non-enumerable key, so the schema
155
+ * still serialises and compares as a plain JSON Schema.
54
156
  * @returns {Object} A JSON schema: `{ type: 'object', properties, required, additionalProperties }`.
55
157
  */
56
158
  export function defineSchema(schemaDef, options = {}) {
@@ -70,7 +172,16 @@ export function defineSchema(schemaDef, options = {}) {
70
172
  type: 'object',
71
173
  properties,
72
174
  required,
73
- additionalProperties: !options.strict,
175
+ additionalProperties: defined(options.additionalProperties)
176
+ ? options.additionalProperties
177
+ : !options.strict,
178
+ }
179
+
180
+ if (options.validation) {
181
+ Object.defineProperty(schema, SCHEMA_OPTIONS, {
182
+ value: { ...options.validation },
183
+ enumerable: false,
184
+ })
74
185
  }
75
186
 
76
187
  return schema
@@ -78,17 +189,26 @@ export function defineSchema(schemaDef, options = {}) {
78
189
 
79
190
  /**
80
191
  * Builds a validating function for a JSON schema.
192
+ *
193
+ * Options are resolved in three layers — {@link setAtaDefaults} → the schema's bound
194
+ * `validation` (see {@link defineSchema}) → the `options` argument here — with the last
195
+ * winning, and {@link EMPYRIA_FORMATS} always folded into `formats`.
196
+ *
197
+ * The returned function returns the schema-shaped value: `ata-validator`'s `result.data`,
198
+ * which is `data` itself unless `coerceTypes` / `removeAdditional` / `useDefaults` (the last
199
+ * on by default) applied. Those passes also mutate `data` in place, so pass a copy when the
200
+ * caller must keep its input pristine (see `createEnv` / the `env.js` files).
81
201
  * @param {Object} schema - JSON schema to validate against.
82
- * @param {Object} [options] - Forwarded to `ata-validator`'s `Validator` constructor
83
- * (`coerceTypes`, `removeAdditional`, `useDefaults`, ...). Left unset, validation is
84
- * strict: a schema expecting `type: 'number'` rejects `"32"`. Pass `{ coerceTypes: true }`
85
- * for input that's inherently string-typed, such as `process.env` (see `createEnv`).
86
- * @returns {(data: *) => *} Function that returns `data` unchanged if valid.
202
+ * @param {import('ata-validator').ValidatorOptions} [options] - Per-call options (see above):
203
+ * `coerceTypes`, `removeAdditional`, `useDefaults`, `assertFormat`, `abortEarly`,
204
+ * `richErrors`, `verbose`, `formats`, ... Left unset, validation is strict: a schema
205
+ * expecting `type: 'number'` rejects `"32"`.
206
+ * @returns {(data: *) => *} Function that returns the (possibly coerced/defaulted) value.
87
207
  * @throws {EmpyriaError} `BaseErrors.ValidationError` (with a pretty-rendered violation list
88
208
  * in its `validation` param) if `data` doesn't satisfy the schema.
89
209
  */
90
210
  export function createValidator(schema, options) {
91
- const v = new Validator(schema, options)
211
+ const v = new Validator(schema, resolveOptions(schema, options))
92
212
 
93
213
  return (data) => {
94
214
  const result = v.validate(data)
@@ -99,7 +219,7 @@ export function createValidator(schema, options) {
99
219
  validation: renderPretty(result.errors),
100
220
  })
101
221
  }
102
- return data
222
+ return result.data ?? data
103
223
  }
104
224
  }
105
225
 
@@ -107,8 +227,8 @@ export function createValidator(schema, options) {
107
227
  * Validates `input` against `schema` in one call.
108
228
  * @param {Object} schema - JSON schema to validate against.
109
229
  * @param {*} input - Value to validate.
110
- * @param {Object} [options] - Forwarded to {@link createValidator} (see its docs).
111
- * @returns {*} `input` unchanged, if valid.
230
+ * @param {import('ata-validator').ValidatorOptions} [options] - Forwarded to {@link createValidator}.
231
+ * @returns {*} `input` — coerced/defaulted/stripped per the resolved options — if valid.
112
232
  * @throws {EmpyriaError} `BaseErrors.ValidationError` if `input` doesn't satisfy the schema.
113
233
  */
114
234
  export function validate(schema, input, options) {
@@ -118,25 +238,57 @@ export function validate(schema, input, options) {
118
238
  return validatedInput
119
239
  }
120
240
 
241
+ /**
242
+ * {@link createValidator} under an intent-revealing name, for call sites whose purpose is to
243
+ * *transform* input — coerce string-typed values, strip unknown keys, apply schema defaults —
244
+ * rather than merely assert its shape. Behaviour is identical; the name documents that the
245
+ * returned value is expected to differ from the input.
246
+ * @param {Object} schema - JSON schema to validate/parse against.
247
+ * @param {import('ata-validator').ValidatorOptions} [options] - Typically `{ coerceTypes: true }`
248
+ * and/or `{ removeAdditional: true }` (with a closed schema).
249
+ * @returns {(data: *) => *} Function returning the parsed value.
250
+ * @throws {EmpyriaError} `BaseErrors.ValidationError` on invalid input.
251
+ */
252
+ export function createParser(schema, options) {
253
+ return createValidator(schema, options)
254
+ }
255
+
256
+ /**
257
+ * {@link validate} under an intent-revealing name — see {@link createParser}.
258
+ * @param {Object} schema - JSON schema to parse against.
259
+ * @param {*} input - Value to parse.
260
+ * @param {import('ata-validator').ValidatorOptions} [options] - Forwarded to {@link createParser}.
261
+ * @returns {*} The parsed (coerced/defaulted/stripped) value, if valid.
262
+ * @throws {EmpyriaError} `BaseErrors.ValidationError` on invalid input.
263
+ */
264
+ export function parse(schema, input, options) {
265
+ return createParser(schema, options)(input)
266
+ }
267
+
121
268
  /**
122
269
  * Builds a non-throwing checking function for a JSON schema — the structured-result
123
270
  * counterpart to {@link createValidator}. Use it when a caller needs `ata-validator`'s own
124
271
  * error list (to build its own error type, count violations, or render them itself with
125
272
  * {@link renderCompact} / {@link renderJSON}) rather than a `BaseErrors.ValidationError`
126
- * carrying a pre-rendered string.
273
+ * carrying a pre-rendered string, or when it wants the parsed value without a `try`/`catch`.
127
274
  * @param {Object} schema - JSON schema to validate against.
128
- * @param {Object} [options] - Forwarded to `ata-validator`'s `Validator` constructor
129
- * (see {@link createValidator} for the common flags).
130
- * @returns {(data: *) => { valid: boolean, errors: Array<Object> }} Function that returns the
131
- * verdict and, when invalid, `ata-validator`'s raw error objects (empty array when valid).
132
- * Never throws on a validation failure.
275
+ * @param {import('ata-validator').ValidatorOptions} [options] - Resolved the same way as for
276
+ * {@link createValidator} (see its docs for the layers and the common flags).
277
+ * @returns {(data: *) => { valid: boolean, data: *, errors: Array<Object> }} Function that
278
+ * returns the verdict, the parsed value (`data` when valid — coerced/defaulted/stripped per
279
+ * the resolved options; `undefined` when invalid), and, when invalid, `ata-validator`'s raw
280
+ * error objects (empty array when valid). Never throws on a validation failure.
133
281
  */
134
282
  export function createChecker(schema, options) {
135
- const v = new Validator(schema, options)
283
+ const v = new Validator(schema, resolveOptions(schema, options))
136
284
 
137
285
  return (data) => {
138
286
  const result = v.validate(data)
139
- return { valid: result.valid, errors: result.valid ? [] : result.errors }
287
+ return {
288
+ valid: result.valid,
289
+ data: result.valid ? (result.data ?? data) : undefined,
290
+ errors: result.valid ? [] : result.errors,
291
+ }
140
292
  }
141
293
  }
142
294
 
@@ -144,9 +296,9 @@ export function createChecker(schema, options) {
144
296
  * Checks `input` against `schema` in one call, without throwing.
145
297
  * @param {Object} schema - JSON schema to validate against.
146
298
  * @param {*} input - Value to validate.
147
- * @param {Object} [options] - Forwarded to {@link createChecker} (see its docs).
148
- * @returns {{ valid: boolean, errors: Array<Object> }} The verdict, plus `ata-validator`'s raw
149
- * error objects when invalid (empty array when valid).
299
+ * @param {import('ata-validator').ValidatorOptions} [options] - Forwarded to {@link createChecker}.
300
+ * @returns {{ valid: boolean, data: *, errors: Array<Object> }} The verdict, the parsed value
301
+ * (when valid), plus `ata-validator`'s raw error objects when invalid.
150
302
  */
151
303
  export function checkSchema(schema, input, options) {
152
304
  return createChecker(schema, options)(input)
@@ -250,50 +402,54 @@ export function number(defaultValue) {
250
402
  }
251
403
 
252
404
  /**
253
- * JSON schema for an email-address string, pattern-matched against {@link P_EMAIL}.
254
- * @returns {Object} String schema with `pattern` and `description` set.
405
+ * JSON schema for an email-address string, checked against the `email` format
406
+ * ({@link EMPYRIA_FORMATS}, which mirrors {@link P_EMAIL}).
407
+ * @returns {Object} String schema with `format` and `description` set.
255
408
  */
256
409
  export function email() {
257
410
  return {
258
411
  type: 'string',
259
- pattern: P_EMAIL,
412
+ format: 'empyria-email',
260
413
  description: 'Email address',
261
414
  }
262
415
  }
263
416
 
264
417
  /**
265
- * JSON schema for a password string, pattern-matched against {@link P_PASS}
266
- * (requires lower-case, upper-case, a digit, and at least 8 characters).
267
- * @returns {Object} String schema with `pattern` and `description` set.
418
+ * JSON schema for a password string, checked against the `password` format
419
+ * ({@link EMPYRIA_FORMATS}, which mirrors {@link P_PASS}: lower-case, upper-case, a digit,
420
+ * and at least 8 characters).
421
+ * @returns {Object} String schema with `format` and `description` set.
268
422
  */
269
423
  export function password() {
270
424
  return {
271
425
  type: 'string',
272
- pattern: P_PASS,
426
+ format: 'empyria-password',
273
427
  description: 'password',
274
428
  }
275
429
  }
276
430
 
277
431
  /**
278
- * JSON schema for an IPv4-address string, pattern-matched against {@link P_IPv4}.
279
- * @returns {Object} String schema with `pattern` and `description` set.
432
+ * JSON schema for an IPv4-address string, checked against the `ipv4` format
433
+ * ({@link EMPYRIA_FORMATS}, which mirrors {@link P_IPv4}).
434
+ * @returns {Object} String schema with `format` and `description` set.
280
435
  */
281
436
  export function ip4() {
282
437
  return {
283
438
  type: 'string',
284
- pattern: P_IPv4,
439
+ format: 'empyria-ipv4',
285
440
  description: 'IP address v4',
286
441
  }
287
442
  }
288
443
 
289
444
  /**
290
- * JSON schema for an IPv6-address string, pattern-matched against {@link P_IPv6}.
291
- * @returns {Object} String schema with `pattern` and `description` set.
445
+ * JSON schema for an IPv6-address string, checked against the `ipv6` format
446
+ * ({@link EMPYRIA_FORMATS}, which mirrors {@link P_IPv6}).
447
+ * @returns {Object} String schema with `format` and `description` set.
292
448
  */
293
449
  export function ip6() {
294
450
  return {
295
451
  type: 'string',
296
- pattern: P_IPv6,
452
+ format: 'empyria-ipv6',
297
453
  description: 'IP address v6',
298
454
  }
299
455
  }
package/lib/Env.js CHANGED
@@ -27,8 +27,17 @@ const DEFAULT_SERVICE_SCHEMA = {
27
27
  * Builds a JSON schema for a microservice's environment variables, merging the
28
28
  * Empyria-wide defaults with any service-specific additions/overrides.
29
29
  * @param {Object} [specificSchema] - Additional or overriding property schemas, keyed by env var name.
30
+ * @param {Object} [options={}]
31
+ * @param {boolean} [options.closed] - When true, the schema rejects the whole of
32
+ * `process.env` beyond its declared keys and binds `{ coerceTypes: true, removeAdditional:
33
+ * true }` to itself, so `validate`/`parse` return an object holding *only* the declared
34
+ * vars — no undeclared environment riding along into `settings`. Off by default:
35
+ * `additionalProperties: true`, every extra var passes through unchanged.
30
36
  * @returns {Object} A JSON schema object (see `defineSchema` in `./Ata.js`).
31
37
  */
32
- export function createEnv(specificSchema) {
33
- return defineSchema(Object.assign({}, DEFAULT_SERVICE_SCHEMA, specificSchema))
38
+ export function createEnv(specificSchema, { closed = false } = {}) {
39
+ return defineSchema(
40
+ Object.assign({}, DEFAULT_SERVICE_SCHEMA, specificSchema),
41
+ closed ? { strict: true, validation: { coerceTypes: true, removeAdditional: true } } : {},
42
+ )
34
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/common",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "Shared backend goodies for Empyria",
5
5
  "license": "MIT",
6
6
  "author": "Imre Fazekas <imre.fazekas@icloud.com>",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@empyria/classification": "0.1.6",
23
- "ata-validator": "1.13.2",
23
+ "ata-validator": "1.14.1",
24
24
  "clerobee": "1.0.8",
25
25
  "ms": "2.1.3"
26
26
  },
@@ -29,7 +29,7 @@
29
29
  "oxlint": "1.82.0"
30
30
  },
31
31
  "engines": {
32
- "bun": ">=1.4.0",
32
+ "bun": ">=1.4.2",
33
33
  "node": ">=26"
34
34
  },
35
35
  "packageManager": "bun@1.4.2"
package/test/Ata.test.js CHANGED
@@ -7,8 +7,15 @@ import {
7
7
  cloneBySchema,
8
8
  defineSchema,
9
9
  validate,
10
+ parse,
11
+ createParser,
12
+ createValidator,
10
13
  createChecker,
11
14
  checkSchema,
15
+ schemaOptions,
16
+ setAtaDefaults,
17
+ getAtaDefaults,
18
+ EMPYRIA_FORMATS,
12
19
  bool,
13
20
  string,
14
21
  enumType,
@@ -89,6 +96,31 @@ describe('defineSchema', () => {
89
96
  test('disallows extra properties when strict', () => {
90
97
  expect(defineSchema({ a: string() }, { strict: true }).additionalProperties).toBe(false)
91
98
  })
99
+
100
+ test('an explicit additionalProperties overrides strict', () => {
101
+ expect(
102
+ defineSchema({ a: string() }, { strict: true, additionalProperties: true })
103
+ .additionalProperties,
104
+ ).toBe(true)
105
+ expect(
106
+ defineSchema({ a: string() }, { additionalProperties: false }).additionalProperties,
107
+ ).toBe(false)
108
+ })
109
+
110
+ test('binds validation options on a non-enumerable key, invisible to JSON/toEqual', () => {
111
+ const schema = defineSchema(
112
+ { a: string() },
113
+ { validation: { coerceTypes: true, removeAdditional: true } },
114
+ )
115
+ expect(schema).toEqual({
116
+ type: 'object',
117
+ properties: { a: { type: 'string' } },
118
+ required: ['a'],
119
+ additionalProperties: true,
120
+ })
121
+ expect(JSON.stringify(schema)).not.toContain('coerceTypes')
122
+ expect(schemaOptions(schema)).toEqual({ coerceTypes: true, removeAdditional: true })
123
+ })
92
124
  })
93
125
 
94
126
  describe('cloneBySchema', () => {
@@ -171,11 +203,11 @@ describe('schema builders', () => {
171
203
  })
172
204
  })
173
205
 
174
- test('email/password/ip4/ip6 reference their patterns', () => {
175
- expect(email().pattern).toBe(P_EMAIL)
176
- expect(password().pattern).toBe(P_PASS)
177
- expect(ip4().pattern).toBe(P_IPv4)
178
- expect(ip6().pattern).toBe(P_IPv6)
206
+ test('email/password/ip4/ip6 reference their named formats', () => {
207
+ expect(email().format).toBe('empyria-email')
208
+ expect(password().format).toBe('empyria-password')
209
+ expect(ip4().format).toBe('empyria-ipv4')
210
+ expect(ip6().format).toBe('empyria-ipv6')
179
211
  })
180
212
  })
181
213
 
@@ -189,8 +221,16 @@ describe('renderCompact / renderJSON re-exports', () => {
189
221
  describe('createChecker / checkSchema', () => {
190
222
  const schema = defineSchema({ name: string() })
191
223
 
192
- test('checkSchema returns valid:true and no errors for good input', () => {
193
- expect(checkSchema(schema, { name: 'Bob' })).toEqual({ valid: true, errors: [] })
224
+ test('checkSchema returns valid:true, the parsed value, and no errors for good input', () => {
225
+ expect(checkSchema(schema, { name: 'Bob' })).toEqual({
226
+ valid: true,
227
+ data: { name: 'Bob' },
228
+ errors: [],
229
+ })
230
+ })
231
+
232
+ test('checkSchema reports data:undefined for bad input', () => {
233
+ expect(checkSchema(schema, {}).data).toBeUndefined()
194
234
  })
195
235
 
196
236
  test('checkSchema returns valid:false and a non-empty error list for bad input, without throwing', () => {
@@ -205,8 +245,87 @@ describe('createChecker / checkSchema', () => {
205
245
  expect(renderCompact(errors)).toContain('name')
206
246
  })
207
247
 
208
- test('createChecker builds a reusable checker and forwards options (coerceTypes)', () => {
248
+ test('createChecker builds a reusable checker, forwards options, and returns the parsed value', () => {
209
249
  const check = createChecker(defineSchema({ port: number() }), { coerceTypes: true })
210
- expect(check({ port: '4040' })).toEqual({ valid: true, errors: [] })
250
+ expect(check({ port: '4040' })).toEqual({ valid: true, data: { port: 4040 }, errors: [] })
251
+ })
252
+ })
253
+
254
+ describe('named formats', () => {
255
+ test('EMPYRIA_FORMATS enforce their patterns', () => {
256
+ expect(EMPYRIA_FORMATS['empyria-email']('user@example.com')).toBe(true)
257
+ expect(EMPYRIA_FORMATS['empyria-email']('user@example!com')).toBe(false)
258
+ expect(EMPYRIA_FORMATS['empyria-password']('Abcdefg1')).toBe(true)
259
+ expect(EMPYRIA_FORMATS['empyria-password']('Abcdefgh')).toBe(false)
260
+ expect(EMPYRIA_FORMATS['empyria-ipv4']('192.168.0.1')).toBe(true)
261
+ expect(EMPYRIA_FORMATS['empyria-ipv4']('999.0.0.1')).toBe(false)
262
+ })
263
+
264
+ test('a schema from email() is enforced through this module without per-call wiring', () => {
265
+ const schema = defineSchema({ addr: email() })
266
+ expect(validate(schema, { addr: 'user@example.com' })).toEqual({ addr: 'user@example.com' })
267
+ expect(() => validate(schema, { addr: 'nope' })).toThrow(EmpyriaError)
268
+ })
269
+
270
+ test('a schema from password() is enforced (no ata-validator built-in for it)', () => {
271
+ const schema = defineSchema({ pw: password() })
272
+ expect(() => validate(schema, { pw: 'weak' })).toThrow(EmpyriaError)
273
+ expect(validate(schema, { pw: 'Abcdefg1' })).toEqual({ pw: 'Abcdefg1' })
274
+ })
275
+
276
+ test('a per-call formats entry overrides the Empyria default', () => {
277
+ const schema = defineSchema({ addr: email() })
278
+ const only = { formats: { 'empyria-email': (s) => s === 'ok@ok.io' } }
279
+ expect(validate(schema, { addr: 'ok@ok.io' }, only)).toEqual({ addr: 'ok@ok.io' })
280
+ expect(() => validate(schema, { addr: 'user@example.com' }, only)).toThrow(EmpyriaError)
281
+ })
282
+ })
283
+
284
+ describe('option layering: setAtaDefaults / schema-bound / per-call', () => {
285
+ test('getAtaDefaults starts empty', () => {
286
+ expect(getAtaDefaults()).toEqual({})
287
+ })
288
+
289
+ test('a library default is applied when no per-call option is given', () => {
290
+ const schema = defineSchema({ port: number() })
291
+ expect(() => validate(schema, { port: '8080' })).toThrow(EmpyriaError)
292
+ setAtaDefaults({ coerceTypes: true })
293
+ try {
294
+ expect(validate(schema, { port: '8080' })).toEqual({ port: 8080 })
295
+ } finally {
296
+ setAtaDefaults()
297
+ }
298
+ expect(getAtaDefaults()).toEqual({})
299
+ })
300
+
301
+ test('schema-bound validation options beat library defaults, per-call beats both', () => {
302
+ setAtaDefaults({ coerceTypes: false })
303
+ try {
304
+ const bound = defineSchema({ port: number() }, { validation: { coerceTypes: true } })
305
+ // schema-bound coerceTypes:true wins over the library default
306
+ expect(validate(bound, { port: '80' })).toEqual({ port: 80 })
307
+ // per-call coerceTypes:false wins over the schema-bound value
308
+ expect(() => validate(bound, { port: '80' }, { coerceTypes: false })).toThrow(
309
+ EmpyriaError,
310
+ )
311
+ } finally {
312
+ setAtaDefaults()
313
+ }
314
+ })
315
+ })
316
+
317
+ describe('parse / createParser', () => {
318
+ test('parse coerces and strips against a closed schema', () => {
319
+ const schema = defineSchema(
320
+ { port: number() },
321
+ { strict: true, validation: { coerceTypes: true, removeAdditional: true } },
322
+ )
323
+ expect(parse(schema, { port: '3000', stray: 'x' })).toEqual({ port: 3000 })
324
+ })
325
+
326
+ test('createParser is a reusable createValidator under an intent-revealing name', () => {
327
+ const p = createParser(defineSchema({ n: number() }), { coerceTypes: true })
328
+ expect(p({ n: '7' })).toEqual({ n: 7 })
329
+ expect(typeof createValidator).toBe('function')
211
330
  })
212
331
  })
package/test/Env.test.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect } from 'bun:test'
2
2
  import { createEnv } from '../lib/Env.js'
3
+ import { validate, schemaOptions } from '../lib/Ata.js'
3
4
 
4
5
  describe('createEnv', () => {
5
6
  test('includes the shared service defaults plus service-specific additions', () => {
@@ -15,4 +16,33 @@ describe('createEnv', () => {
15
16
  const schema = createEnv()
16
17
  expect(schema.properties.EMPYRIA_MONGO_URI).toBeUndefined()
17
18
  })
19
+
20
+ test('is open by default: undeclared vars pass through untouched', () => {
21
+ const schema = createEnv()
22
+ expect(schema.additionalProperties).toBe(true)
23
+ expect(schemaOptions(schema)).toEqual({})
24
+ const out = validate(
25
+ schema,
26
+ { NODE_ENV: 'test', UNDECLARED: 'ride-along' },
27
+ { coerceTypes: true },
28
+ )
29
+ expect(out.UNDECLARED).toBe('ride-along')
30
+ })
31
+
32
+ test('closed: rejects the wider environment and returns only the declared keys', () => {
33
+ const schema = createEnv({}, { closed: true })
34
+ expect(schema.additionalProperties).toBe(false)
35
+ expect(schemaOptions(schema)).toEqual({ coerceTypes: true, removeAdditional: true })
36
+
37
+ const out = validate(schema, {
38
+ NODE_ENV: 'test',
39
+ EMPYRIA_ID_LENGTH: '48',
40
+ PATH: '/usr/bin',
41
+ HOME: '/root',
42
+ })
43
+ expect(out.EMPYRIA_ID_LENGTH).toBe(48)
44
+ expect(out.NODE_ENV).toBe('test')
45
+ expect(out.PATH).toBeUndefined()
46
+ expect(out.HOME).toBeUndefined()
47
+ })
18
48
  })