@uniweb/api 1.1.1 → 1.2.1

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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * A Model's definition, as the backend stores and answers it — lowered from a seed's
3
+ * declaration the way a registered schema is lowered (MEASURED 2026-10-14; `SCHEMA` in
4
+ * `../wire.js` is the shape).
5
+ *
6
+ * ⭐ **A field keeps what the backend keeps, and nothing else.** A key the backend's
7
+ * catalog does not hold for the field's kind never reaches a stored declaration — a
8
+ * schema carrying one is refused when it is registered — so the mock's definition
9
+ * leaves it out rather than show a form a rule no backend applies. The catalog, per
10
+ * kind, beside what every field may declare (`label`, `description`, `required`,
11
+ * `multiple`, `localized`, `compact`):
12
+ *
13
+ * | kind | its own keys |
14
+ * |---|---|
15
+ * | `string` · `text` | `max_length`, `languages`, `format` |
16
+ * | `int` | `min`, `max` |
17
+ * | `decimal` | `precision`, `scale`, `min`, `max` |
18
+ * | `item_ref` | `options` (a schema's) → `section_uuid`; `entity_uuid` |
19
+ * | `entity_ref` | `model` (a schema's) → `model_uuid`; `model_uuids`, `contains`, `readable_with` |
20
+ * | `file` | `mime_types`, `max_size` |
21
+ * | `json` | `languages`, `format` |
22
+ * | `bool` · `date` · `datetime` · `secret` | none |
23
+ *
24
+ * And what moves: an `enum` becomes its section's `one_of` rule; `multiple: true` is
25
+ * `multi`; a nested section is a section of its own.
26
+ */
27
+
28
+ /** The keys a kind declares beyond the ones every field may (see the table above). */
29
+ const KIND_KEYS = {
30
+ string: ['max_length', 'languages', 'format'],
31
+ text: ['max_length', 'languages', 'format'],
32
+ int: ['min', 'max'],
33
+ decimal: ['precision', 'scale', 'min', 'max'],
34
+ item_ref: ['entity_uuid'],
35
+ entity_ref: ['model_uuids', 'contains', 'readable_with'],
36
+ file: ['mime_types', 'max_size'],
37
+ json: ['languages', 'format'],
38
+ }
39
+
40
+ /** Prose about a field — a plain string. */
41
+ const DOC_KEYS = ['label', 'description']
42
+
43
+ /** Modifiers any kind that supports them declares. */
44
+ const MODIFIER_KEYS = ['localized', 'compact']
45
+
46
+ /** A field's kind: its `type`, or a list's element type in the normalizer's `array` spelling. */
47
+ export function kindOf(field) {
48
+ if (field?.type === 'array') return field.items?.type ?? 'json'
49
+ return field?.type ?? 'json'
50
+ }
51
+
52
+ /** Is this field a list — `multiple: true`, or the normalizer's `type: 'array'`? */
53
+ export const isList = (field) => field?.multiple === true || field?.type === 'array'
54
+
55
+ /**
56
+ * A field as the definition answers it: `{ key, required, multi, type: { id: -1, name,
57
+ * kind, data } }`, `data` its stored declaration.
58
+ *
59
+ * @param {string} key
60
+ * @param {object} field - the seed's declaration
61
+ * @param {{ section?: string|null, model?: string|null }} pins - an `item_ref`'s section
62
+ * uuid and an `entity_ref`'s Model uuid, resolved by the caller
63
+ */
64
+ export function fieldRow(key, field, pins = {}) {
65
+ const kind = kindOf(field)
66
+ const data = { key, kind }
67
+ if (field?.required === true) data.required = true
68
+ if (isList(field)) data.multi = true
69
+ for (const k of DOC_KEYS) if (typeof field?.[k] === 'string') data[k] = field[k]
70
+ for (const k of MODIFIER_KEYS) if (field?.[k] !== undefined) data[k] = field[k]
71
+ for (const k of KIND_KEYS[kind] ?? []) if (field?.[k] !== undefined) data[k] = field[k]
72
+ if (kind === 'item_ref' && pins.section) data.section_uuid = pins.section
73
+ if (kind === 'entity_ref' && pins.model) data.model_uuid = pins.model
74
+ return {
75
+ key,
76
+ required: data.required === true,
77
+ multi: data.multi === true,
78
+ type: { id: -1, name: kind, kind, data },
79
+ }
80
+ }
81
+
82
+ /**
83
+ * A section's rules: its own (a bare array, or `{ rules: [...] }`), then a `one_of` for
84
+ * each field that declares an `enum`, in field order.
85
+ *
86
+ * @param {object|null} section - the seed's declaration
87
+ * @param {Object<string, object>|null} leaves - its fields, by key
88
+ */
89
+ export function rulesOf(section, leaves) {
90
+ const own = section?.constraints
91
+ const rules = Array.isArray(own) ? [...own] : Array.isArray(own?.rules) ? [...own.rules] : []
92
+ for (const [key, field] of Object.entries(leaves || {})) {
93
+ if (Array.isArray(field?.enum) && field.enum.length) rules.push({ kind: 'one_of', field: key, values: field.enum })
94
+ }
95
+ return rules
96
+ }
97
+
98
+ /** What a section's `other_data` holds: what the schema declares of the section. */
99
+ export function otherDataOf(section, { appendOnly = false, nests = false } = {}) {
100
+ const other = {}
101
+ for (const k of DOC_KEYS) if (typeof section?.[k] === 'string') other[k] = section[k]
102
+ if (appendOnly) other.append_only = true
103
+ if (nests) other.allow_self_nesting = true
104
+ if (typeof section?.date_field === 'string') other.date_field = section.date_field
105
+ return other
106
+ }
107
+
108
+ /**
109
+ * `options: '@scope/name/section/path'` — the Model named by its first two segments, and
110
+ * the section path after them — or null when it has the wrong shape.
111
+ */
112
+ export function parseOptions(options) {
113
+ if (typeof options !== 'string') return null
114
+ const parts = options.split('/')
115
+ if (parts.length < 3 || !parts[0].startsWith('@')) return null
116
+ return { model: `${parts[0]}/${parts[1]}`, path: parts.slice(2).join('/') }
117
+ }
package/src/mock/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AUTH, ENTITIES, MODELS, OFFERS, INVITATIONS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS, HANDOFF_REFUSAL } from '../wire.js'
1
+ import { AUTH, ENTITIES, MODELS, SECTIONS, OFFERS, INVITATIONS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS, HANDOFF_REFUSAL, ELEVATION } from '../wire.js'
2
2
  import { MockStore, isUuid, refuse } from './store.js'
3
3
  import { DEFAULT_SEED } from './seed.js'
4
4
 
@@ -17,6 +17,24 @@ export function printMail(message, url) {
17
17
  */
18
18
  const ADMIN_PAGE = '/admin'
19
19
 
20
+ /**
21
+ * A page's cursor, opaque as the backend's is: hex of the JSON of where the page ended.
22
+ * Byte work by hand, so the mock stays free of Node's `Buffer`.
23
+ */
24
+ const encodeCursor = (key) =>
25
+ [...new TextEncoder().encode(JSON.stringify(key))].map((b) => b.toString(16).padStart(2, '0')).join('')
26
+
27
+ /** The key a cursor names, or null for one this mock did not hand out — "expired". */
28
+ function decodeCursor(token) {
29
+ if (!/^(?:[0-9a-f]{2})+$/.test(token)) return null
30
+ try {
31
+ const key = JSON.parse(new TextDecoder().decode(Uint8Array.from(token.match(/../g).map((h) => parseInt(h, 16)))))
32
+ return Array.isArray(key) && key.length === 5 && key.every(Number.isFinite) ? key : null
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
20
38
  const escapeHtml = (value) =>
21
39
  String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
22
40
 
@@ -53,7 +71,7 @@ function adminPage(account) {
53
71
  *
54
72
  * ## Where it answers
55
73
  *
56
- * Its routes are the API's own — `/auth/me`, `/entities` — under whatever the
74
+ * Its routes are the API's own — `/auth/me`, `/entities`, `/sections` — under whatever the
57
75
  * site's `backend` address is. It accepts a request with the site's `/_api` prefix
58
76
  * still on, and one with an extra `/api` after it (how clients before 0.4
59
77
  * composed URLs), so it answers the same whether or not the server mounting it
@@ -69,9 +87,11 @@ function adminPage(account) {
69
87
  * account the backend's administration admits, so its Admin link answers — with a
70
88
  * redirect to a page of the mock's own, since the administration itself is not
71
89
  * modelled. An offer, claimed or bound by confirming its address, leaves its
72
- * invitations waiting, each to answer once. It does not model entitlements behind
73
- * `via`, nested sections, second factors, password rules, an offer's grants, what
74
- * joining an organization gives, or an organization withdrawing an invitation.
90
+ * invitations waiting, each to answer once. A field that picks from a list stores an
91
+ * item's uuid, checked against its list, and the list's items are read a page at a time
92
+ * from `/sections/{uuid}/items`. It does not model entitlements behind `via`, second
93
+ * factors, password rules, an offer's grants, what joining an organization gives, or an
94
+ * organization withdrawing an invitation.
75
95
  *
76
96
  * ## The mail it sends
77
97
  *
@@ -255,8 +275,21 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
255
275
  const token = q.get('token') || ''
256
276
  return token ? answer(store.verify(token)) : badRequest('verify requires `token`')
257
277
  }
278
+ if (path.startsWith(`${AUTH.elevate}/`) && method === 'GET') {
279
+ // The provider's elevation — a navigation. The mock has no sign-in providers.
280
+ return problem({ status: 404, title: 'Not Found', detail: 'the mock has no sign-in providers — elevate with the password' })
281
+ }
258
282
  const bad = unexpected(q, [])
259
283
  if (bad) return bad
284
+ if (path === AUTH.elevate && method === 'POST') {
285
+ if (!store.account) return unauthorized()
286
+ const b = await body(request)
287
+ return fields(b, [ELEVATION.password], '{"password": ..., "code": ...}') ?? answer(store.elevate(b))
288
+ }
289
+ if (path === AUTH.elevate && method === 'DELETE') {
290
+ if (!store.account) return unauthorized()
291
+ return answer(store.lower())
292
+ }
260
293
  if (path === AUTH.me && method === 'GET') {
261
294
  const viewer = store.viewer()
262
295
  return viewer ? json(200, viewer) : unauthorized()
@@ -339,18 +372,52 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
339
372
  return model && store.maySeeSchema(model) ? json(200, store.schemaOf(model)) : notFound('model', name)
340
373
  }
341
374
 
375
+ // ── A Section's items ────────────────────────────────────────────────────
376
+ const listed = path.startsWith(`${SECTIONS}/`) ? path.slice(SECTIONS.length).match(/^\/([^/]+)\/items$/) : null
377
+ if (listed && method === 'GET') {
378
+ const bad = unexpected(q, [PARAM.entity, PARAM.parent, PARAM.field, PARAM.q, PARAM.locale, PARAM.cursor, PARAM.limit])
379
+ if (bad) return bad
380
+ const limit = int(q, PARAM.limit, PAGE.size)
381
+ if (limit === undefined) return badRequest('invalid parameter value')
382
+ if (!store.account) return unauthorized()
383
+ const sectionUuid = listed[1]
384
+ if (!isUuid(sectionUuid)) return badRequest(`invalid section uuid: ${sectionUuid}`)
385
+ const token = q.get(PARAM.cursor) || ''
386
+ const after = token ? decodeCursor(token) : null
387
+ if (token && !after) return badRequest('cursor expired, restart pagination')
388
+ for (const name of [PARAM.entity, PARAM.parent]) {
389
+ const value = q.get(name)
390
+ if (value && !isUuid(value)) return badRequest(`invalid ${name} uuid: ${value}`)
391
+ }
392
+ const found = store.sectionByUuid(sectionUuid)
393
+ if (!found) return notFound('section', sectionUuid)
394
+ const outcome = store.sectionItems(found.model, found.section, {
395
+ entity: q.get(PARAM.entity) || null,
396
+ parent: q.get(PARAM.parent) || null,
397
+ field: q.get(PARAM.field) || '',
398
+ q: q.get(PARAM.q) || '',
399
+ locales: locales(q),
400
+ after,
401
+ limit: Math.min(Math.max(limit, 1), PAGE.max),
402
+ })
403
+ if (outcome.problem) return answer(outcome)
404
+ const { next_cursor: next, ...rest } = outcome.body
405
+ return json(200, { ...rest, next_cursor: next ? encodeCursor(next) : null })
406
+ }
407
+
342
408
  // ── Entities ────────────────────────────────────────────────────────────
343
409
  if (path === ENTITIES || path.startsWith(`${ENTITIES}/`)) {
344
410
  const rest = path.slice(ENTITIES.length)
345
411
 
346
412
  if (rest === '' && method === 'GET') {
347
- const bad = unexpected(q, [PARAM.model, PARAM.scope, PARAM.limit, PARAM.offset, PARAM.paginate, PARAM.locale, PARAM.includeDisabled])
413
+ // ⛔ No `include_disabled`: the backend refuses it, and lists a disabled entity like any other.
414
+ const bad = unexpected(q, [PARAM.model, PARAM.scope, PARAM.limit, PARAM.offset, PARAM.paginate, PARAM.locale])
348
415
  if (bad) return bad
349
416
  if (!q.get(PARAM.model)) return badRequest(`Missing required parameter: ${PARAM.model}`)
350
417
  const limit = int(q, PARAM.limit, PAGE.size)
351
418
  const offset = int(q, PARAM.offset, 0)
352
419
  const paginate = bool(q, PARAM.paginate, true)
353
- if (limit === undefined || offset === undefined || paginate === undefined || bool(q, PARAM.includeDisabled, false) === undefined) {
420
+ if (limit === undefined || offset === undefined || paginate === undefined) {
354
421
  return badRequest('invalid parameter value')
355
422
  }
356
423
  const scope = q.get(PARAM.scope) || SCOPE.accessible
@@ -148,6 +148,20 @@ function listElement(field) {
148
148
  return null
149
149
  }
150
150
 
151
+ /**
152
+ * A section of the declaration by name — or by `parent/child` path for a nested one,
153
+ * declared as a field of `type: 'section'` inside its parent.
154
+ */
155
+ function sectionAt(sections, path) {
156
+ const [first, ...rest] = String(path).split('/')
157
+ let at = sections[first]
158
+ for (const name of rest) {
159
+ const next = at?.fields?.[name]
160
+ at = next?.type === 'section' ? next : undefined
161
+ }
162
+ return at
163
+ }
164
+
151
165
  /**
152
166
  * Is this section a multi section of the model's declaration?
153
167
  *
@@ -158,7 +172,7 @@ function listElement(field) {
158
172
  export function classifySection(decl, section) {
159
173
  const sections = decl?.sections
160
174
  if (!sections || typeof sections !== 'object') return null
161
- const found = sections[section]
175
+ const found = sectionAt(sections, section)
162
176
 
163
177
  // ⭐ The debt list is consulted FIRST and is an exact `(model, section)` match, so
164
178
  // it can excuse a declared section whose records diverge as well as an undeclared
@@ -225,8 +239,69 @@ function satisfiesType(type, value) {
225
239
  }
226
240
  }
227
241
 
242
+ /** The text kinds — what a `localized` value, a `max_length` and a search apply to. */
243
+ const TEXT_TYPES = new Set(['string', 'text', 'html', 'markdown', 'email', 'url'])
244
+
245
+ /** Characters, as the backend counts them — code points, not UTF-16 units. */
246
+ const charCount = (s) => [...s].length
247
+
248
+ /** `YYYY-MM-DD`, a day the calendar has — the one form a `date` holds (MEASURED 2026-10-14: `2024-5-3` passes, `2024-05` and `2024-02-30` do not). */
249
+ function isDate(value) {
250
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value)
251
+ if (!m) return false
252
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]
253
+ const day = new Date(Date.UTC(y, mo - 1, d))
254
+ return day.getUTCFullYear() === y && day.getUTCMonth() === mo - 1 && day.getUTCDate() === d
255
+ }
256
+
257
+ /**
258
+ * A `format` the backend checks, by its own rules — `email`: one `@`, something before it,
259
+ * a dotted domain after it, no space; `url`: a scheme, `://`, something after it, no space.
260
+ * ⚠️ `currency` is checked there too, against ISO 4217's codes, and not here. Every other
261
+ * `format` marks what the text is and checks nothing.
262
+ */
263
+ function matchesFormat(value, format) {
264
+ if (format === 'email') {
265
+ const parts = value.split('@')
266
+ return parts.length === 2 && parts[0] !== '' && parts[1].includes('.') && !parts[1].startsWith('.') && !parts[1].endsWith('.') && !/\s/.test(value)
267
+ }
268
+ if (format === 'url') {
269
+ const at = value.indexOf('://')
270
+ return at > 0 && /^[A-Za-z0-9+.-]+$/.test(value.slice(0, at)) && value.length > at + 3 && !/\s/.test(value)
271
+ }
272
+ return true
273
+ }
274
+
275
+ /**
276
+ * The checks on one text value — a field's own, or one language of a localized one —
277
+ * as the backend makes them: `max_length` in characters, then the `format`. `at` names
278
+ * the value: the field, or `field.lang`.
279
+ */
280
+ function checkText(field, value, at) {
281
+ if (typeof value !== 'string') return { field: at, rule: 'type', detail: `item.data.${at} must be a string` }
282
+ if (Number.isInteger(field.max_length) && charCount(value) > field.max_length) {
283
+ return {
284
+ field: at,
285
+ rule: 'max_length',
286
+ detail: `item.data.${at} exceeds max_length=${field.max_length}`,
287
+ params: { max_length: field.max_length },
288
+ }
289
+ }
290
+ if (typeof field.format === 'string' && value !== '' && !matchesFormat(value, field.format)) {
291
+ return {
292
+ field: at,
293
+ rule: 'format',
294
+ detail: `item.data.${at} = ${JSON.stringify(value)} is not a valid ${field.format}`,
295
+ params: { format: field.format },
296
+ }
297
+ }
298
+ return null
299
+ }
300
+
228
301
  /**
229
- * Check a record against a lowered `fields` map. Returns a list of violations.
302
+ * Check a record against a lowered `fields` map. Returns a list of violations, each
303
+ * `{ field, rule, detail, params? }` — `rule` named as the backend names it, `params` the
304
+ * declaration's bound it carries beside it (`max_length`, `min`, `max`, `format`).
230
305
  *
231
306
  * ⛔ **A key the schema does not declare is NOT a violation.** We have no evidence
232
307
  * that the real store rejects extras, and inventing that rule here would refuse
@@ -239,16 +314,32 @@ export function checkFields(fields, data, path = '') {
239
314
  const record = data && typeof data === 'object' ? data : {}
240
315
 
241
316
  for (const [name, field] of Object.entries(fields)) {
317
+ // A nested section is a section of its own, with items of its own — not a field.
318
+ if (field?.type === 'section') continue
242
319
  const at = path ? `${path}.${name}` : name
243
320
  const value = record[name]
244
321
  const missing = value === undefined || value === null
245
322
 
246
323
  if (field.required && missing) {
247
- problems.push({ field: at, rule: 'required', detail: `'${at}' is required` })
324
+ problems.push({ field: at, rule: 'required', detail: `item.data.${at} is required` })
248
325
  continue
249
326
  }
250
327
  if (missing) continue
251
328
 
329
+ // ⭐ A localized text field holds a string — its value in every language — or a
330
+ // per-language object, each language's value checked as the field's text is
331
+ // (MEASURED 2026-10-14). `@` is the object's metadata, not a language.
332
+ if (field.localized === true && TEXT_TYPES.has(field.type) && field.multiple !== true) {
333
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
334
+ const found = Object.entries(value)
335
+ .filter(([lang]) => lang !== '@')
336
+ .map(([lang, v]) => checkText(field, v, `${at}.${lang}`))
337
+ .find(Boolean)
338
+ if (found) problems.push(found)
339
+ continue
340
+ }
341
+ }
342
+
252
343
  // A list, in either spelling. The VALUE must be an array; each entry is then
253
344
  // checked against the element shape, indexed the way `uniweb validate` names it
254
345
  // (`outcomes[1]`), so one vocabulary covers both lanes.
@@ -260,14 +351,28 @@ export function checkFields(fields, data, path = '') {
260
351
  }
261
352
  value.forEach((entry, i) => {
262
353
  const entryAt = `${at}[${i}]`
354
+ // A localized list's entry is a string or a per-language object, as a single value is.
355
+ if (field.localized === true && TEXT_TYPES.has(element.type) && entry && typeof entry === 'object' && !Array.isArray(entry)) {
356
+ const found = Object.entries(entry)
357
+ .filter(([lang]) => lang !== '@')
358
+ .map(([lang, v]) => checkText(field, v, `${entryAt}.${lang}`))
359
+ .find(Boolean)
360
+ if (found) problems.push(found)
361
+ return
362
+ }
263
363
  if (satisfiesType(element.type, entry) === false) {
264
364
  problems.push({
265
365
  field: entryAt,
266
366
  rule: 'type',
267
- detail: `'${entryAt}' expects ${element.type}, got ${Array.isArray(entry) ? 'array' : typeof entry}`,
367
+ detail: `item.data.${entryAt} expects ${element.type}, got ${Array.isArray(entry) ? 'array' : typeof entry}`,
268
368
  })
269
369
  return
270
370
  }
371
+ const bounded = boundOf({ ...field, type: element.type }, entry, entryAt)
372
+ if (bounded) {
373
+ problems.push(bounded)
374
+ return
375
+ }
271
376
  if (element.fields) problems.push(...checkFields(element.fields, entry, entryAt))
272
377
  })
273
378
  continue
@@ -277,13 +382,20 @@ export function checkFields(fields, data, path = '') {
277
382
  problems.push({
278
383
  field: at,
279
384
  rule: 'type',
280
- detail: `'${at}' expects ${field.type}, got ${Array.isArray(value) ? 'array' : typeof value}`,
385
+ detail: `item.data.${at} expects ${field.type}, got ${Array.isArray(value) ? 'array' : typeof value}`,
281
386
  })
282
387
  continue
283
388
  }
284
389
 
390
+ const bounded = boundOf(field, value, at)
391
+ if (bounded) {
392
+ problems.push(bounded)
393
+ continue
394
+ }
395
+
396
+ // An `enum` is the backend's `one_of` rule — refused under that name.
285
397
  if (Array.isArray(field.enum) && field.enum.length && !field.enum.includes(value)) {
286
- problems.push({ field: at, rule: 'enum', detail: `'${at}' must be one of ${field.enum.join(', ')}` })
398
+ problems.push({ field: at, rule: 'one_of', detail: `item.${at} = ${JSON.stringify(value)} is not one of the allowed values` })
287
399
  continue
288
400
  }
289
401
 
@@ -292,6 +404,27 @@ export function checkFields(fields, data, path = '') {
292
404
  return problems
293
405
  }
294
406
 
407
+ /**
408
+ * The bound a value of a field's type breaks, as the backend checks it — `max_length`
409
+ * on text, `min` and `max` on a number, the one form a `date` takes — or null.
410
+ * (MEASURED 2026-10-14, each with the bound beside the rule.)
411
+ */
412
+ function boundOf(field, value, at) {
413
+ if (TEXT_TYPES.has(field.type) && typeof value === 'string') return checkText(field, value, at)
414
+ if (typeof value === 'number' && ['int', 'integer', 'decimal', 'number'].includes(field.type)) {
415
+ if (typeof field.min === 'number' && value < field.min) {
416
+ return { field: at, rule: 'min', detail: `item.data.${at}=${value} below min=${field.min}`, params: { min: field.min } }
417
+ }
418
+ if (typeof field.max === 'number' && value > field.max) {
419
+ return { field: at, rule: 'max', detail: `item.data.${at}=${value} above max=${field.max}`, params: { max: field.max } }
420
+ }
421
+ }
422
+ if (field.type === 'date' && typeof value === 'string' && !isDate(value)) {
423
+ return { field: at, rule: 'format', detail: `item.data.${at} is not a valid date (\`${value}\`)`, params: { format: 'date' } }
424
+ }
425
+ return null
426
+ }
427
+
295
428
  /** Keys present in the write that the section does not declare. Diagnostic only — see checkFields. */
296
429
  function undeclaredKeys(fields, data) {
297
430
  if (!fields || !data || typeof data !== 'object') return []