@taprootio/site-authoring 0.12.5 → 0.12.7

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,715 @@
1
+ // Generated from shared/field-validation.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ /**
3
+ * Field definitions and submission validation for Taproot forms (TR01084).
4
+ *
5
+ * The definition is the one owner of the rules. This file is the browser
6
+ * interpretation; the API carries a second interpretation in C#, and both must
7
+ * pass `shared/field-validation-corpus.json`. It returns stable error codes and
8
+ * never wording: the browser owns the words shown to a visitor or an author.
9
+ *
10
+ * It is pure and dependency-free. It uses no regular expressions, because
11
+ * JavaScript and .NET disagree at the edges of their engines, and it measures
12
+ * lengths in UTF-16 code units so `string.length` here and `string.Length` in
13
+ * .NET agree. Keep `shared/field-schema.json` in step with the constants below;
14
+ * a test compares them.
15
+ *
16
+ * The generator's client and the published site-authoring package cannot import
17
+ * this file, so `node scripts/sync-field-validation.mjs` writes their copies;
18
+ * edit only this file and re-run it.
19
+ */
20
+ export const FIELD_TYPES = [
21
+ "text",
22
+ "long_text",
23
+ "email",
24
+ "tel",
25
+ "url",
26
+ "number",
27
+ "date",
28
+ "single_choice",
29
+ "multi_choice",
30
+ "checkbox",
31
+ "consent",
32
+ ];
33
+ export const CONSTRAINT_NAMES = [
34
+ "required",
35
+ "min_length",
36
+ "max_length",
37
+ "min",
38
+ "max",
39
+ "choices",
40
+ "format",
41
+ ];
42
+ /** The constraints each type accepts. Anything else is `unknown_constraint`. */
43
+ export const CONSTRAINTS_BY_TYPE = {
44
+ text: ["required", "min_length", "max_length", "format"],
45
+ long_text: ["required", "min_length", "max_length"],
46
+ email: ["required", "max_length"],
47
+ tel: ["required", "max_length"],
48
+ url: ["required", "max_length"],
49
+ number: ["required", "min", "max", "format"],
50
+ date: ["required", "min", "max"],
51
+ single_choice: ["required", "choices"],
52
+ multi_choice: ["required", "choices", "min", "max"],
53
+ checkbox: ["required"],
54
+ // Consent is always required to be true; it takes no constraints.
55
+ consent: [],
56
+ };
57
+ /** The closed list of `format` values, by the type that accepts them. */
58
+ export const FORMATS_BY_TYPE = {
59
+ text: ["digits", "alphanumeric"],
60
+ number: ["integer"],
61
+ };
62
+ export const MAX_FIELDS = 30;
63
+ export const MAX_SUBMISSION_BYTES = 16 * 1024;
64
+ export const MAX_LABEL_LENGTH = 200;
65
+ /** A consent field's label is the agreement the visitor accepts, so it may run to a short paragraph. */
66
+ export const MAX_CONSENT_LABEL_LENGTH = 1000;
67
+ /** Helper text under a field; two short sentences, the same ceiling as a label. */
68
+ export const MAX_HINT_LENGTH = 200;
69
+ /** The text on the submit button; one short line, so it fits a button on a phone. */
70
+ export const MAX_SUBMIT_LABEL_LENGTH = 40;
71
+ /** The confirmation shown in place of the form: a short paragraph, longer than a hint and shorter than a consent. */
72
+ export const MAX_AFTER_SUBMIT_MESSAGE_LENGTH = 500;
73
+ export const MAX_ID_LENGTH = 40;
74
+ export const MAX_CHOICES = 50;
75
+ export const MAX_CHOICE_LENGTH = 100;
76
+ /** The longest any single-line field may be; `max_length` can only lower it. */
77
+ export const MAX_SHORT_TEXT_LENGTH = 500;
78
+ export const MAX_LONG_TEXT_LENGTH = 5000;
79
+ /** The dates a relative date bound can start from, in the site's time zone. A closed list: no expressions. */
80
+ export const DATE_ANCHORS = [
81
+ "today",
82
+ "start_of_month",
83
+ "end_of_month",
84
+ "start_of_next_month",
85
+ "end_of_next_month",
86
+ ];
87
+ /** A year either side of the anchor, a leap year included; anything further out is a fixed date. */
88
+ export const MAX_DATE_OFFSET_DAYS = 366;
89
+ /**
90
+ * How far back a relative minimum reaches when a submission arrives, so a
91
+ * visitor who loaded the page before midnight and submits after it is not
92
+ * refused for a date the page offered. The maximum is never loosened.
93
+ */
94
+ export const RELATIVE_BOUND_GRACE_DAYS = 1;
95
+ /**
96
+ * The id of the field whose value is the visitor's contact address: the named
97
+ * `contact_field`, or the form's only email field, or none. The one rule every
98
+ * reader of a definition uses; a validated definition is expected.
99
+ */
100
+ export function contactFieldId(definition) {
101
+ if (definition.contact_field !== undefined)
102
+ return definition.contact_field;
103
+ const emails = definition.fields.filter((field) => field.type === "email");
104
+ return emails.length === 1 ? emails[0].id : undefined;
105
+ }
106
+ const DEFINITION_KEYS = ["fields", "contact_field", "submit_label", "after_submit"];
107
+ const BASE_FIELD_KEYS = ["id", "type", "label", "hint"];
108
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
109
+ const isInteger = (value) => typeof value === "number" && Number.isInteger(value);
110
+ /** Exactly `from` and, optionally, a whole `offset_days` within a year either way. */
111
+ export function isRelativeDateBound(value) {
112
+ if (!isPlainObject(value))
113
+ return false;
114
+ if (Object.keys(value).some((key) => key !== "from" && key !== "offset_days"))
115
+ return false;
116
+ if (typeof value.from !== "string" || !DATE_ANCHORS.includes(value.from))
117
+ return false;
118
+ return !("offset_days" in value)
119
+ || (isInteger(value.offset_days) && Math.abs(value.offset_days) <= MAX_DATE_OFFSET_DAYS);
120
+ }
121
+ const isDigit = (c) => c >= "0" && c <= "9";
122
+ const isLowerAscii = (c) => c >= "a" && c <= "z";
123
+ const isAlnumAscii = (c) => isDigit(c) || isLowerAscii(c) || (c >= "A" && c <= "Z");
124
+ const allChars = (value, test) => {
125
+ for (let i = 0; i < value.length; i++) {
126
+ if (!test(value[i]))
127
+ return false;
128
+ }
129
+ return value.length > 0;
130
+ };
131
+ /** Keys by UTF-16 code unit, so error order does not depend on the JSON engine's key order. */
132
+ const sortedKeys = (value) => Object.keys(value).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
133
+ const encoder = new TextEncoder();
134
+ const utf8Length = (value) => encoder.encode(value).length;
135
+ /** Only these four characters are ignored when deciding a value is empty. */
136
+ function isBlank(value) {
137
+ for (let i = 0; i < value.length; i++) {
138
+ const c = value[i];
139
+ if (c !== " " && c !== "\t" && c !== "\n" && c !== "\r")
140
+ return false;
141
+ }
142
+ return true;
143
+ }
144
+ function isValidId(id) {
145
+ if (id.length === 0 || id.length > MAX_ID_LENGTH || !isLowerAscii(id[0]))
146
+ return false;
147
+ return allChars(id, (c) => isLowerAscii(c) || isDigit(c) || c === "_");
148
+ }
149
+ /** A lowercase `8-4-4-4-12` hexadecimal id, the one spelling a stored resource id has. */
150
+ export function isResourceId(value) {
151
+ if (value.length !== 36)
152
+ return false;
153
+ for (let i = 0; i < 36; i++) {
154
+ const c = value[i];
155
+ if (i === 8 || i === 13 || i === 18 || i === 23) {
156
+ if (c !== "-")
157
+ return false;
158
+ }
159
+ else if (!isDigit(c) && !(c >= "a" && c <= "f"))
160
+ return false;
161
+ }
162
+ return true;
163
+ }
164
+ /** A real calendar date written `YYYY-MM-DD`, years 0001 to 9999. */
165
+ export function isIsoDate(value) {
166
+ if (value.length !== 10 || value[4] !== "-" || value[7] !== "-")
167
+ return false;
168
+ const digits = value.slice(0, 4) + value.slice(5, 7) + value.slice(8);
169
+ if (!allChars(digits, isDigit))
170
+ return false;
171
+ const year = Number(value.slice(0, 4));
172
+ const month = Number(value.slice(5, 7));
173
+ const day = Number(value.slice(8));
174
+ if (year < 1 || month < 1 || month > 12 || day < 1)
175
+ return false;
176
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
177
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
178
+ return day <= days;
179
+ }
180
+ const daysInMonth = (year, month) => [31, (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
181
+ /** Days since 1970-01-01 of a proleptic Gregorian date, by Howard Hinnant's civil-days algorithm. */
182
+ function daysFromCivil(year, month, day) {
183
+ const y = month <= 2 ? year - 1 : year;
184
+ const era = Math.floor(y / 400);
185
+ const yoe = y - era * 400;
186
+ const doy = Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1;
187
+ const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
188
+ return era * 146097 + doe - 719468;
189
+ }
190
+ function civilFromDays(days) {
191
+ const z = days + 719468;
192
+ const era = Math.floor(z / 146097);
193
+ const doe = z - era * 146097;
194
+ const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365);
195
+ const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
196
+ const mp = Math.floor((5 * doy + 2) / 153);
197
+ const day = doy - Math.floor((153 * mp + 2) / 5) + 1;
198
+ const month = mp < 10 ? mp + 3 : mp - 9;
199
+ return [yoe + era * 400 + (month <= 2 ? 1 : 0), month, day];
200
+ }
201
+ const FIRST_DAY = daysFromCivil(1, 1, 1);
202
+ const LAST_DAY = daysFromCivil(9999, 12, 31);
203
+ const pad = (value, width) => String(value).padStart(width, "0");
204
+ /** The calendar date written `YYYY-MM-DD`; a day outside years 0001 to 9999 stops at the nearer end. */
205
+ function isoFromDays(days) {
206
+ const [year, month, day] = civilFromDays(Math.min(Math.max(days, FIRST_DAY), LAST_DAY));
207
+ return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`;
208
+ }
209
+ /**
210
+ * The fixed date a bound stands for on a given calendar date. A fixed bound is
211
+ * itself. A relative bound is measured from `today`, which the caller resolves
212
+ * in the site's time zone and passes in, so no validator reads a clock.
213
+ * `graceDays` moves a relative bound's starting date back, and a fixed bound
214
+ * ignores it. `today` must be an ISO date.
215
+ */
216
+ export function resolveDateBound(bound, today, graceDays = 0) {
217
+ if (typeof bound === "string")
218
+ return bound;
219
+ if (!isIsoDate(today))
220
+ throw new Error("today must be an ISO date");
221
+ const start = daysFromCivil(Number(today.slice(0, 4)), Number(today.slice(5, 7)), Number(today.slice(8))) - graceDays;
222
+ const [year, month, day] = civilFromDays(Math.min(Math.max(start, FIRST_DAY), LAST_DAY));
223
+ const nextYear = month === 12 ? year + 1 : year;
224
+ const nextMonth = month === 12 ? 1 : month + 1;
225
+ let anchor;
226
+ switch (bound.from) {
227
+ case "today":
228
+ anchor = daysFromCivil(year, month, day);
229
+ break;
230
+ case "start_of_month":
231
+ anchor = daysFromCivil(year, month, 1);
232
+ break;
233
+ case "end_of_month":
234
+ anchor = daysFromCivil(year, month, daysInMonth(year, month));
235
+ break;
236
+ case "start_of_next_month":
237
+ anchor = nextYear > 9999 ? LAST_DAY : daysFromCivil(nextYear, nextMonth, 1);
238
+ break;
239
+ default:
240
+ anchor = nextYear > 9999 ? LAST_DAY : daysFromCivil(nextYear, nextMonth, daysInMonth(nextYear, nextMonth));
241
+ }
242
+ return isoFromDays(anchor + (bound.offset_days ?? 0));
243
+ }
244
+ /**
245
+ * The calendar date at an instant in an IANA time zone. It is the one place a
246
+ * zone becomes a date, for the page and the tests; the server does the same
247
+ * with .NET's zone database. An id the browser does not know falls back to UTC,
248
+ * the setting's default, and the server stays authoritative.
249
+ */
250
+ export function todayInZone(timeZone, now = new Date()) {
251
+ const read = (zone) => {
252
+ const parts = new Intl.DateTimeFormat("en-US", {
253
+ timeZone: zone,
254
+ year: "numeric",
255
+ month: "numeric",
256
+ day: "numeric",
257
+ })
258
+ .formatToParts(now);
259
+ const part = (type) => Number(parts.find((candidate) => candidate.type === type)?.value);
260
+ return `${pad(part("year"), 4)}-${pad(part("month"), 2)}-${pad(part("day"), 2)}`;
261
+ };
262
+ try {
263
+ return read(timeZone);
264
+ }
265
+ catch {
266
+ return read("UTC");
267
+ }
268
+ }
269
+ /** ASCII DNS labels; internationalized hosts must arrive as punycode. */
270
+ function isValidHost(host) {
271
+ if (host.length === 0 || host.length > 253)
272
+ return false;
273
+ const labels = host.split(".");
274
+ if (labels.length < 2)
275
+ return false;
276
+ return labels.every((label) => label.length >= 1 && label.length <= 63 && label[0] !== "-" && label[label.length - 1] !== "-"
277
+ && allChars(label, (c) => isAlnumAscii(c) || c === "-"));
278
+ }
279
+ /** The shortest value each format check accepts: `a@b.c`, seven digits and `http://a.b`. */
280
+ const SHORTEST_VALID_LENGTH = { email: 5, tel: 7, url: 10 };
281
+ const EMAIL_LOCAL_CHARS = "!#$%&'*+/=?^_`{|}~-";
282
+ export function isValidEmail(value) {
283
+ if (value.length > 254)
284
+ return false;
285
+ const at = value.indexOf("@");
286
+ if (at < 1 || at !== value.lastIndexOf("@"))
287
+ return false;
288
+ const local = value.slice(0, at);
289
+ const domain = value.slice(at + 1);
290
+ if (local.length > 64 || local[0] === "." || local[local.length - 1] === "." || local.includes("..")) {
291
+ return false;
292
+ }
293
+ if (!allChars(local, (c) => isAlnumAscii(c) || c === "." || EMAIL_LOCAL_CHARS.includes(c)))
294
+ return false;
295
+ return isValidHost(domain);
296
+ }
297
+ /** Digits with optional separators; a `+` may only lead. 7 to 15 digits. */
298
+ export function isValidTel(value) {
299
+ let digits = 0;
300
+ for (let i = 0; i < value.length; i++) {
301
+ const c = value[i];
302
+ if (isDigit(c))
303
+ digits++;
304
+ else if (c === "+") {
305
+ if (i !== 0)
306
+ return false;
307
+ }
308
+ else if (c !== " " && c !== "-" && c !== "." && c !== "(" && c !== ")")
309
+ return false;
310
+ }
311
+ return digits >= 7 && digits <= 15;
312
+ }
313
+ /** `http` or `https` with a dotted ASCII host, an optional port, no userinfo. */
314
+ export function isValidUrl(value) {
315
+ const lower = value.toLowerCase();
316
+ let rest;
317
+ if (lower.startsWith("https://"))
318
+ rest = value.slice(8);
319
+ else if (lower.startsWith("http://"))
320
+ rest = value.slice(7);
321
+ else
322
+ return false;
323
+ for (let i = 0; i < value.length; i++) {
324
+ const code = value.charCodeAt(i);
325
+ if (code <= 0x20 || code === 0x7f)
326
+ return false;
327
+ }
328
+ let end = rest.length;
329
+ for (const stop of ["/", "?", "#"]) {
330
+ const index = rest.indexOf(stop);
331
+ if (index !== -1 && index < end)
332
+ end = index;
333
+ }
334
+ const authority = rest.slice(0, end);
335
+ if (authority.includes("@"))
336
+ return false;
337
+ const colon = authority.indexOf(":");
338
+ const host = colon === -1 ? authority : authority.slice(0, colon);
339
+ if (colon !== -1) {
340
+ const port = authority.slice(colon + 1);
341
+ if (!allChars(port, isDigit) || port.length > 5 || Number(port) < 1 || Number(port) > 65535)
342
+ return false;
343
+ }
344
+ return isValidHost(host);
345
+ }
346
+ /**
347
+ * Checks a field definition list and returns stable codes with JSON-path-like
348
+ * locations. A constraint written as JSON `null` counts as present and wrong.
349
+ * A number is an integer when it has no fractional part, however it is written.
350
+ */
351
+ export function validateDefinition(definition) {
352
+ const errors = [];
353
+ if (!isPlainObject(definition))
354
+ return [{ path: "", code: "invalid_definition" }];
355
+ for (const key of sortedKeys(definition)) {
356
+ if (!DEFINITION_KEYS.includes(key))
357
+ errors.push({ path: key, code: "unknown_property" });
358
+ }
359
+ const fields = definition.fields;
360
+ if (!Array.isArray(fields) || fields.length === 0) {
361
+ errors.push({ path: "fields", code: "invalid_fields" });
362
+ return errors;
363
+ }
364
+ if (fields.length > MAX_FIELDS) {
365
+ errors.push({ path: "fields", code: "too_many_fields" });
366
+ return errors;
367
+ }
368
+ const seen = new Set();
369
+ const types = new Map();
370
+ fields.forEach((field, index) => {
371
+ const at = `fields[${index}]`;
372
+ if (!isPlainObject(field)) {
373
+ errors.push({ path: at, code: "invalid_field" });
374
+ return;
375
+ }
376
+ const id = field.id;
377
+ if (typeof id !== "string" || !isValidId(id))
378
+ errors.push({ path: `${at}.id`, code: "invalid_id" });
379
+ else if (seen.has(id))
380
+ errors.push({ path: `${at}.id`, code: "duplicate_id" });
381
+ else {
382
+ seen.add(id);
383
+ types.set(id, field.type);
384
+ }
385
+ const type = field.type;
386
+ if (typeof type !== "string" || !FIELD_TYPES.includes(type)) {
387
+ errors.push({ path: `${at}.type`, code: "unknown_type" });
388
+ return;
389
+ }
390
+ const label = field.label;
391
+ const maxLabelLength = type === "consent" ? MAX_CONSENT_LABEL_LENGTH : MAX_LABEL_LENGTH;
392
+ if (typeof label !== "string" || isBlank(label) || label.length > maxLabelLength) {
393
+ errors.push({ path: `${at}.label`, code: "invalid_label" });
394
+ }
395
+ const hint = field.hint;
396
+ if (hint !== undefined && (typeof hint !== "string" || isBlank(hint) || hint.length > MAX_HINT_LENGTH)) {
397
+ errors.push({ path: `${at}.hint`, code: "invalid_hint" });
398
+ }
399
+ const allowed = CONSTRAINTS_BY_TYPE[type];
400
+ for (const key of sortedKeys(field)) {
401
+ if (BASE_FIELD_KEYS.includes(key))
402
+ continue;
403
+ if (!CONSTRAINT_NAMES.includes(key)) {
404
+ errors.push({ path: `${at}.${key}`, code: "unknown_property" });
405
+ }
406
+ else if (!allowed.includes(key)) {
407
+ errors.push({ path: `${at}.${key}`, code: "unknown_constraint" });
408
+ }
409
+ }
410
+ validateConstraints(field, type, at, errors);
411
+ });
412
+ const contact = definition.contact_field;
413
+ if (contact !== undefined && (typeof contact !== "string" || types.get(contact) !== "email")) {
414
+ errors.push({ path: "contact_field", code: "invalid_contact_field" });
415
+ }
416
+ else if (contact === undefined && [...types.values()].filter((type) => type === "email").length > 1) {
417
+ errors.push({ path: "contact_field", code: "contact_field_required" });
418
+ }
419
+ validateFormSettings(definition, errors);
420
+ if (errors.length === 0 && minimumContentBytes(fields) > MAX_SUBMISSION_BYTES) {
421
+ errors.push({ path: "fields", code: "unsatisfiable_constraint" });
422
+ }
423
+ return errors;
424
+ }
425
+ /** The button text and the after-submit choice, which sit beside the fields in a definition. */
426
+ function validateFormSettings(definition, errors) {
427
+ const label = definition.submit_label;
428
+ if (label !== undefined && (typeof label !== "string" || isBlank(label) || label.length > MAX_SUBMIT_LABEL_LENGTH)) {
429
+ errors.push({ path: "submit_label", code: "invalid_submit_label" });
430
+ }
431
+ const after = definition.after_submit;
432
+ if (after === undefined)
433
+ return;
434
+ if (!isPlainObject(after) || (after.show !== "message" && after.show !== "page")) {
435
+ errors.push({ path: "after_submit", code: "invalid_after_submit" });
436
+ return;
437
+ }
438
+ const allowed = after.show === "message" ? ["show", "message"] : ["show", "page_resource_id"];
439
+ for (const key of sortedKeys(after)) {
440
+ if (!allowed.includes(key))
441
+ errors.push({ path: `after_submit.${key}`, code: "unknown_property" });
442
+ }
443
+ if (after.show === "message") {
444
+ const message = after.message;
445
+ if (message !== undefined
446
+ && (typeof message !== "string" || isBlank(message) || message.length > MAX_AFTER_SUBMIT_MESSAGE_LENGTH)) {
447
+ errors.push({ path: "after_submit.message", code: "invalid_after_submit_message" });
448
+ }
449
+ }
450
+ else if (typeof after.page_resource_id !== "string" || !isResourceId(after.page_resource_id)) {
451
+ errors.push({ path: "after_submit.page_resource_id", code: "invalid_after_submit_page" });
452
+ }
453
+ }
454
+ /** The fewest content bytes any valid submission carries: required keys plus their shortest values. */
455
+ function minimumContentBytes(fields) {
456
+ let total = 0;
457
+ for (const field of fields) {
458
+ if (field.type !== "consent" && field.required !== true)
459
+ continue;
460
+ total += utf8Length(field.id);
461
+ switch (field.type) {
462
+ case "text":
463
+ case "long_text":
464
+ total += Math.max(field.min_length ?? 0, 1);
465
+ break;
466
+ case "email":
467
+ case "tel":
468
+ case "url":
469
+ total += SHORTEST_VALID_LENGTH[field.type] ?? 0;
470
+ break;
471
+ case "date":
472
+ total += 10;
473
+ break;
474
+ case "single_choice":
475
+ total += Math.min(...(field.choices ?? []).map(utf8Length));
476
+ break;
477
+ case "multi_choice": {
478
+ const sizes = (field.choices ?? []).map(utf8Length).sort((a, b) => a - b);
479
+ const count = Math.max(typeof field.min === "number" ? field.min : 0, 1);
480
+ total += sizes.slice(0, count).reduce((sum, size) => sum + size, 0);
481
+ break;
482
+ }
483
+ default:
484
+ break;
485
+ }
486
+ }
487
+ return total;
488
+ }
489
+ function validateConstraints(field, type, at, errors) {
490
+ const allowed = CONSTRAINTS_BY_TYPE[type];
491
+ const bad = (name) => errors.push({ path: `${at}.${name}`, code: "invalid_constraint" });
492
+ const has = (name) => allowed.includes(name) && field[name] !== undefined;
493
+ if (has("required") && typeof field.required !== "boolean")
494
+ bad("required");
495
+ const ceiling = type === "long_text" ? MAX_LONG_TEXT_LENGTH : MAX_SHORT_TEXT_LENGTH;
496
+ for (const name of ["min_length", "max_length"]) {
497
+ if (has(name)) {
498
+ const value = field[name];
499
+ if (!isInteger(value) || value < (name === "min_length" ? 0 : 1) || value > ceiling)
500
+ bad(name);
501
+ }
502
+ }
503
+ if (has("min_length") && has("max_length") && isInteger(field.min_length) && isInteger(field.max_length)
504
+ && field.min_length > field.max_length) {
505
+ errors.push({ path: `${at}.min_length`, code: "min_exceeds_max" });
506
+ }
507
+ const shortest = SHORTEST_VALID_LENGTH[type];
508
+ if (shortest !== undefined && has("max_length") && isInteger(field.max_length) && field.max_length >= 1
509
+ && field.max_length < shortest) {
510
+ errors.push({ path: `${at}.max_length`, code: "unsatisfiable_constraint" });
511
+ }
512
+ if (type === "number" || type === "date" || type === "multi_choice") {
513
+ let bothValid = has("min") && has("max");
514
+ for (const name of ["min", "max"]) {
515
+ if (!has(name))
516
+ continue;
517
+ const value = field[name];
518
+ const ok = type === "number"
519
+ ? typeof value === "number" && Number.isFinite(value)
520
+ : type === "date"
521
+ ? (typeof value === "string" && isIsoDate(value)) || isRelativeDateBound(value)
522
+ : isInteger(value) && value >= 0 && value <= MAX_CHOICES;
523
+ if (!ok) {
524
+ bad(name);
525
+ bothValid = false;
526
+ }
527
+ }
528
+ const { min, max } = field;
529
+ if (bothValid
530
+ && ((typeof min === "number" && typeof max === "number" && min > max)
531
+ || (typeof min === "string" && typeof max === "string" && min > max)
532
+ || (isRelativeDateBound(min) && isRelativeDateBound(max) && min.from === max.from
533
+ && (min.offset_days ?? 0) > (max.offset_days ?? 0)))) {
534
+ errors.push({ path: `${at}.min`, code: "min_exceeds_max" });
535
+ }
536
+ else if (bothValid && type === "number" && field.format === "integer" && typeof min === "number" && typeof max === "number"
537
+ && Math.ceil(min) > Math.floor(max)) {
538
+ errors.push({ path: `${at}.min`, code: "unsatisfiable_constraint" });
539
+ }
540
+ }
541
+ if (type === "single_choice" || type === "multi_choice") {
542
+ const choices = field.choices;
543
+ if (choices === undefined)
544
+ errors.push({ path: `${at}.choices`, code: "choices_required" });
545
+ else if (!Array.isArray(choices) || choices.length < 1 || choices.length > MAX_CHOICES
546
+ || !choices.every((c) => typeof c === "string" && c.length >= 1 && c.length <= MAX_CHOICE_LENGTH && !isBlank(c))
547
+ || new Set(choices).size !== choices.length)
548
+ bad("choices");
549
+ else if (type === "multi_choice") {
550
+ // A bound no submission can meet would make the form unsubmittable.
551
+ const { min, max } = field;
552
+ if (isInteger(min) && min >= 0 && min <= MAX_CHOICES && min > choices.length) {
553
+ errors.push({ path: `${at}.min`, code: "unsatisfiable_constraint" });
554
+ }
555
+ if (field.required === true && max === 0) {
556
+ errors.push({ path: `${at}.max`, code: "unsatisfiable_constraint" });
557
+ }
558
+ }
559
+ }
560
+ if (has("format")) {
561
+ const formats = FORMATS_BY_TYPE[type] ?? [];
562
+ if (typeof field.format !== "string" || !formats.includes(field.format)) {
563
+ errors.push({ path: `${at}.format`, code: "unknown_format" });
564
+ }
565
+ }
566
+ }
567
+ /**
568
+ * A blank value counts as absent, so the API must store it as null rather than
569
+ * as submitted. Content size is the UTF-8 bytes of every key plus every string value and
570
+ * string array item; numbers, booleans and other array items count as zero.
571
+ * Validates one submission against a definition that already passed
572
+ * `validateDefinition`. At most one error per field. Order: whole-submission
573
+ * errors (field ""), then definition order, then unknown keys sorted by code unit.
574
+ * `context.today` is required even for a definition without relative date bounds,
575
+ * so a caller can never forget to resolve it.
576
+ */
577
+ export function validateSubmission(definition, data, context) {
578
+ if (!isIsoDate(context.today))
579
+ throw new Error("today must be an ISO date");
580
+ if (!isPlainObject(data))
581
+ return [{ field: "", code: "invalid_submission" }];
582
+ const errors = [];
583
+ const known = new Set(definition.fields.map((f) => f.id));
584
+ const perField = [];
585
+ let size = 0;
586
+ for (const [key, value] of Object.entries(data)) {
587
+ size += utf8Length(key);
588
+ if (typeof value === "string")
589
+ size += utf8Length(value);
590
+ else if (Array.isArray(value)) {
591
+ for (const item of value)
592
+ if (typeof item === "string")
593
+ size += utf8Length(item);
594
+ }
595
+ }
596
+ if (size > MAX_SUBMISSION_BYTES)
597
+ errors.push({ field: "", code: "submission_too_large" });
598
+ for (const field of definition.fields) {
599
+ const code = validateValue(field, Object.prototype.hasOwnProperty.call(data, field.id) ? data[field.id] : undefined, context);
600
+ if (code)
601
+ perField.push({ field: field.id, code });
602
+ }
603
+ const extras = sortedKeys(data).filter((key) => !known.has(key));
604
+ return [...errors, ...perField, ...extras.map((field) => ({ field, code: "unknown_field" }))];
605
+ }
606
+ function isEmpty(value, type) {
607
+ return value === undefined || value === null || (typeof value === "string" && isBlank(value))
608
+ || (type === "multi_choice" && Array.isArray(value) && value.length === 0);
609
+ }
610
+ function validateValue(field, value, context) {
611
+ if (field.type === "consent") {
612
+ return value === true
613
+ ? null
614
+ : isEmpty(value, field.type) || value === false
615
+ ? "required"
616
+ : "invalid_type";
617
+ }
618
+ if (field.type === "checkbox") {
619
+ if (value === true)
620
+ return null;
621
+ if (isEmpty(value, field.type) || value === false)
622
+ return field.required ? "required" : null;
623
+ return "invalid_type";
624
+ }
625
+ if (isEmpty(value, field.type)) {
626
+ if (field.required)
627
+ return "required";
628
+ // A blank value is skipped by every other check but still counts toward the
629
+ // length ceiling, because it is stored as submitted.
630
+ return typeof value === "string" && isTextType(field.type) && value.length > maxLengthOf(field) ? "too_long" : null;
631
+ }
632
+ switch (field.type) {
633
+ case "text":
634
+ case "long_text":
635
+ case "email":
636
+ case "tel":
637
+ case "url":
638
+ return typeof value === "string" ? validateString(field, value) : "invalid_type";
639
+ case "number":
640
+ return validateNumber(field, value);
641
+ case "date":
642
+ return validateDate(field, value, context);
643
+ case "single_choice":
644
+ if (typeof value !== "string")
645
+ return "invalid_type";
646
+ return field.choices?.includes(value) ? null : "not_an_option";
647
+ case "multi_choice":
648
+ return validateMulti(field, value);
649
+ default:
650
+ return "invalid_type";
651
+ }
652
+ }
653
+ const isTextType = (type) => type === "text" || type === "long_text" || type === "email" || type === "tel" || type === "url";
654
+ function maxLengthOf(field) {
655
+ const ceiling = field.type === "long_text" ? MAX_LONG_TEXT_LENGTH : MAX_SHORT_TEXT_LENGTH;
656
+ return Math.min(field.max_length ?? ceiling, ceiling);
657
+ }
658
+ function validateString(field, value) {
659
+ if (field.min_length !== undefined && value.length < field.min_length)
660
+ return "too_short";
661
+ if (value.length > maxLengthOf(field))
662
+ return "too_long";
663
+ if (field.type === "email" && !isValidEmail(value))
664
+ return "not_an_email";
665
+ if (field.type === "tel" && !isValidTel(value))
666
+ return "not_a_phone";
667
+ if (field.type === "url" && !isValidUrl(value))
668
+ return "not_a_url";
669
+ if (field.type === "text" && field.format) {
670
+ const ok = field.format === "digits" ? allChars(value, isDigit) : allChars(value, isAlnumAscii);
671
+ if (!ok)
672
+ return "invalid_format";
673
+ }
674
+ return null;
675
+ }
676
+ function validateNumber(field, value) {
677
+ if (typeof value !== "number")
678
+ return "invalid_type";
679
+ if (!Number.isFinite(value))
680
+ return "not_a_number";
681
+ if (field.format === "integer" && !Number.isInteger(value))
682
+ return "invalid_format";
683
+ if (typeof field.min === "number" && value < field.min)
684
+ return "too_small";
685
+ if (typeof field.max === "number" && value > field.max)
686
+ return "too_large";
687
+ return null;
688
+ }
689
+ function validateDate(field, value, context) {
690
+ if (typeof value !== "string")
691
+ return "invalid_type";
692
+ if (!isIsoDate(value))
693
+ return "not_a_date";
694
+ const { today, graceDays = 0 } = context;
695
+ if (typeof field.min !== "number" && field.min !== undefined && value < resolveDateBound(field.min, today, graceDays)) {
696
+ return "too_small";
697
+ }
698
+ if (typeof field.max !== "number" && field.max !== undefined && value > resolveDateBound(field.max, today)) {
699
+ return "too_large";
700
+ }
701
+ return null;
702
+ }
703
+ function validateMulti(field, value) {
704
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string"))
705
+ return "invalid_type";
706
+ if (new Set(value).size !== value.length)
707
+ return "duplicate_choice";
708
+ if (!value.every((v) => field.choices?.includes(v)))
709
+ return "not_an_option";
710
+ if (typeof field.min === "number" && value.length < field.min)
711
+ return "too_few";
712
+ if (typeof field.max === "number" && value.length > field.max)
713
+ return "too_many";
714
+ return null;
715
+ }