@jini-ai/cms 0.5.5 → 0.5.6

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.
Files changed (135) hide show
  1. package/dist/consent/express/index.d.ts +53 -0
  2. package/dist/consent/express/index.js +159 -0
  3. package/dist/consent/express/response.d.ts +21 -0
  4. package/dist/consent/express/response.js +89 -0
  5. package/dist/consent/html/client.d.ts +6 -0
  6. package/dist/consent/html/client.js +135 -0
  7. package/dist/consent/html/gate.d.ts +29 -0
  8. package/dist/consent/html/gate.js +104 -0
  9. package/dist/consent/html/index.d.ts +4 -0
  10. package/dist/consent/html/index.js +3 -0
  11. package/dist/consent/html/render.d.ts +27 -0
  12. package/dist/consent/html/render.js +62 -0
  13. package/dist/consent/html/static.d.ts +7 -0
  14. package/dist/consent/html/static.js +25 -0
  15. package/dist/consent/html/styles.d.ts +3 -0
  16. package/dist/consent/html/styles.js +22 -0
  17. package/dist/consent/index.d.ts +3 -0
  18. package/dist/consent/index.js +3 -0
  19. package/dist/consent/node/index.d.ts +47 -0
  20. package/dist/consent/node/index.js +169 -0
  21. package/dist/consent/policy.d.ts +47 -0
  22. package/dist/consent/policy.js +62 -0
  23. package/dist/consent/types.d.ts +40 -0
  24. package/dist/consent/types.js +5 -0
  25. package/dist/content-types/agent-tools.js +1 -1
  26. package/dist/content-types/tool-registrations.d.ts +2 -0
  27. package/dist/content-types/tool-registrations.js +1 -0
  28. package/dist/content-types/write-service.d.ts +2 -0
  29. package/dist/content-types/write-service.js +3 -0
  30. package/dist/forms/descriptor-rules.d.ts +9 -0
  31. package/dist/forms/descriptor-rules.js +112 -0
  32. package/dist/forms/email-format.d.ts +7 -0
  33. package/dist/forms/email-format.js +11 -0
  34. package/dist/forms/express/handler.js +4 -8
  35. package/dist/forms/field-registry.d.ts +193 -0
  36. package/dist/forms/field-registry.js +231 -0
  37. package/dist/forms/forms.d.ts +8 -5
  38. package/dist/forms/forms.js +29 -46
  39. package/dist/forms/html/html-authoring.js +63 -13
  40. package/dist/forms/html/html-submission-adapter.d.ts +5 -5
  41. package/dist/forms/html/html-submission-adapter.js +6 -13
  42. package/dist/forms/html-render.d.ts +10 -3
  43. package/dist/forms/html-render.js +116 -14
  44. package/dist/forms/index.d.ts +1 -0
  45. package/dist/forms/index.js +1 -0
  46. package/dist/forms/repo.memory.js +4 -4
  47. package/dist/forms/submission-attempts.d.ts +1 -1
  48. package/dist/forms/submission-attempts.js +13 -2
  49. package/dist/forms/submission-email-validation.js +2 -4
  50. package/dist/forms/submit-service.d.ts +3 -0
  51. package/dist/forms/submit-service.js +38 -5
  52. package/dist/forms/types.d.ts +50 -2
  53. package/dist/listing/batch.d.ts +53 -0
  54. package/dist/listing/batch.js +57 -0
  55. package/dist/listing/html.d.ts +72 -0
  56. package/dist/listing/html.js +109 -0
  57. package/dist/listing/index.d.ts +5 -0
  58. package/dist/listing/index.js +5 -0
  59. package/dist/listing/paths.d.ts +32 -0
  60. package/dist/listing/paths.js +37 -0
  61. package/dist/listing/see-more-script.d.ts +16 -0
  62. package/dist/listing/see-more-script.js +62 -0
  63. package/dist/media/content-type-sniffer.d.ts +1 -1
  64. package/dist/media/content-type-sniffer.js +15 -0
  65. package/dist/media/image-transformer.sharp.js +4 -1
  66. package/dist/media/media-service.d.ts +4 -0
  67. package/dist/media/media-service.js +8 -0
  68. package/dist/media/node.d.ts +1 -1
  69. package/dist/media/node.js +1 -1
  70. package/dist/media/transform-registry.js +1 -1
  71. package/dist/media/transform-types.d.ts +2 -0
  72. package/dist/media/transform-types.js +3 -0
  73. package/dist/media/video-frames.ffmpeg.d.ts +15 -2
  74. package/dist/media/video-frames.ffmpeg.js +28 -13
  75. package/dist/navigation/dropdown.d.ts +41 -0
  76. package/dist/navigation/dropdown.js +85 -0
  77. package/dist/navigation/index.d.ts +2 -0
  78. package/dist/navigation/index.js +2 -0
  79. package/dist/redirects/capture.d.ts +2 -0
  80. package/dist/redirects/capture.js +4 -0
  81. package/dist/redirects/redirects.d.ts +8 -0
  82. package/dist/redirects/redirects.js +14 -0
  83. package/dist/routing/index.d.ts +5 -0
  84. package/dist/routing/index.js +2 -0
  85. package/dist/routing/ports.d.ts +51 -0
  86. package/dist/routing/ports.js +2 -0
  87. package/dist/routing/routing.d.ts +138 -0
  88. package/dist/routing/routing.js +443 -0
  89. package/dist/routing/types.d.ts +164 -0
  90. package/dist/routing/types.js +2 -0
  91. package/dist/seo/media.d.ts +23 -0
  92. package/dist/seo/media.js +30 -5
  93. package/dist/seo/page-head.js +8 -1
  94. package/dist/seo/seo.d.ts +2 -0
  95. package/dist/seo/seo.js +11 -6
  96. package/dist/seo/types.d.ts +8 -0
  97. package/dist/seo/xml-escape.d.ts +1 -3
  98. package/dist/seo/xml-escape.js +1 -6
  99. package/dist/widgets/concurrency.d.ts +27 -1
  100. package/dist/widgets/concurrency.js +12 -23
  101. package/dist/widgets/config-validation.js +6 -0
  102. package/dist/widgets/gallery-resolver.d.ts +23 -0
  103. package/dist/widgets/gallery-resolver.js +80 -0
  104. package/dist/widgets/html/gallery-render.d.ts +24 -0
  105. package/dist/widgets/html/gallery-render.js +65 -0
  106. package/dist/widgets/html/html-embeds.d.ts +4 -0
  107. package/dist/widgets/html/html-embeds.js +7 -1
  108. package/dist/widgets/html/index.d.ts +3 -0
  109. package/dist/widgets/html/index.js +3 -0
  110. package/dist/widgets/html/server-fragment.d.ts +9 -0
  111. package/dist/widgets/html/server-fragment.js +38 -0
  112. package/dist/widgets/markers/gallery.d.ts +50 -0
  113. package/dist/widgets/markers/gallery.js +164 -0
  114. package/dist/widgets/markers/html-attributes.d.ts +6 -1
  115. package/dist/widgets/markers/html-attributes.js +24 -9
  116. package/dist/widgets/markers/index.d.ts +2 -0
  117. package/dist/widgets/markers/index.js +2 -0
  118. package/dist/widgets/markers/marker.d.ts +4 -0
  119. package/dist/widgets/markers/marker.js +6 -0
  120. package/dist/widgets/markers/media-reference.d.ts +11 -0
  121. package/dist/widgets/markers/media-reference.js +11 -0
  122. package/dist/widgets/page-embed-types.d.ts +1 -1
  123. package/dist/widgets/page-embed-types.js +1 -1
  124. package/dist/widgets/registry.d.ts +144 -10
  125. package/dist/widgets/registry.js +40 -0
  126. package/dist/widgets/resolver-service.d.ts +10 -0
  127. package/dist/widgets/resolver-service.js +73 -29
  128. package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
  129. package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
  130. package/dist/widgets/resolvers/gallery.d.ts +12 -0
  131. package/dist/widgets/resolvers/gallery.js +33 -0
  132. package/dist/widgets/resolvers/index.d.ts +1 -0
  133. package/dist/widgets/resolvers/index.js +1 -0
  134. package/dist/widgets/types.d.ts +3 -1
  135. package/package.json +51 -6
@@ -0,0 +1,112 @@
1
+ import { FIELD_TYPE_REGISTRY, isBrowserSafeFieldPattern, isSafeFieldPattern, MAX_FORM_FILE_BYTES, resolveFormField, SAFE_FORM_FILE_TYPES } from "./field-registry.js";
2
+ const KEYS = new Set(["id", "name", "label", "type", "required", "placeholder", "helpText", "default", "options", "minLength", "maxLength", "pattern", "min", "max", "step", "accept", "maxFileSize", "checkboxValue", "className", "attributes"]);
3
+ const TEMPORAL = new Set(["date", "time", "datetime"]);
4
+ const fail = (field, reason) => ({ field, reason });
5
+ /** Text rules apply to explicit descriptor properties and their legacy attributes alike. */
6
+ function textConstraints(field) {
7
+ const errors = [];
8
+ const kind = FIELD_TYPE_REGISTRY[field.type];
9
+ const minLength = field.minLength ?? (field.attributes?.minlength !== undefined ? Number(field.attributes.minlength) : undefined);
10
+ const pattern = field.pattern ?? field.attributes?.pattern;
11
+ if (minLength !== undefined && (!kind?.textual || !Number.isInteger(minLength) || minLength < 0 || minLength > (field.maxLength ?? 5000)))
12
+ errors.push("minLength must be an integer within the text length bounds");
13
+ if (field.type !== "checkbox" && field.maxLength != null && (!kind?.textual || !Number.isInteger(field.maxLength)))
14
+ errors.push("maxLength is only supported on text fields and must be an integer");
15
+ if (pattern !== undefined && (!kind?.textual || typeof pattern !== "string" || !isSafeFieldPattern({ pattern })))
16
+ errors.push("pattern must be a safe text pattern (no groups, alternation, backreferences or multiple repetitions)");
17
+ else if (typeof pattern === "string" && !isBrowserSafeFieldPattern({ pattern }))
18
+ errors.push("pattern is ignored by browsers: escape '-' and other symbols inside [...] with a backslash, e.g. [0-9 +.\\-]");
19
+ return errors;
20
+ }
21
+ /** Choice options are bounded, unique and non-empty; scalar/array defaults use the same validator. */
22
+ function choiceConstraints(field) {
23
+ const kind = FIELD_TYPE_REGISTRY[field.type];
24
+ if (!kind?.choices)
25
+ return field.options === undefined ? [] : ["options are only supported on choice fields"];
26
+ if (!Array.isArray(field.options) || field.options.length === 0 || field.options.length > 100)
27
+ return ["choice fields require 1-100 options"];
28
+ const values = new Set();
29
+ const errors = [];
30
+ for (const option of field.options) {
31
+ if (!option || typeof option.value !== "string" || option.value.length === 0 || option.value.length > 200 || typeof option.label !== "string" || option.label.length === 0 || option.label.length > 200 || Object.keys(option).some((key) => key !== "value" && key !== "label")) {
32
+ errors.push("options must contain only a non-empty value and label (up to 200 characters)");
33
+ continue;
34
+ }
35
+ if (values.has(option.value))
36
+ errors.push("option values must be unique");
37
+ values.add(option.value);
38
+ }
39
+ return errors;
40
+ }
41
+ /** Numeric/date bounds must use native formats; reversed intervals and non-positive steps fail. */
42
+ function boundConstraints(field) {
43
+ const errors = [];
44
+ const numeric = field.type === "number" || field.type === "range";
45
+ const temporal = TEMPORAL.has(field.type);
46
+ for (const key of ["min", "max"]) {
47
+ const value = field[key] ?? field.attributes?.[key];
48
+ if (value === undefined)
49
+ continue;
50
+ if (numeric) {
51
+ if (typeof value !== "number" && (typeof value !== "string" || value.trim() === ""))
52
+ errors.push(`${key} must be a finite number`);
53
+ else if (!Number.isFinite(Number(value)))
54
+ errors.push(`${key} must be a finite number`);
55
+ }
56
+ else if (temporal) {
57
+ if (typeof value !== "string" || resolveFormField({ field: { id: "bound", label: "Bound", type: field.type, required: true }, value }).kind !== "accepted")
58
+ errors.push(`${key} must match the field's native date/time format`);
59
+ }
60
+ else
61
+ errors.push(`${key} is only supported on numeric and date/time fields`);
62
+ }
63
+ const min = field.min ?? field.attributes?.min;
64
+ const max = field.max ?? field.attributes?.max;
65
+ if (min !== undefined && max !== undefined) {
66
+ // Reuse the submission bound validator so equivalent local-time spellings stay equivalent.
67
+ const comparison = temporal ? resolveFormField({ field: { id: "bound", label: "Bound", type: field.type, required: true, min }, value: max }) : undefined;
68
+ if (numeric ? Number(min) > Number(max) : comparison?.kind === "rejected" && comparison.reason === "below_min")
69
+ errors.push("min must not exceed max");
70
+ }
71
+ const step = field.step ?? (field.attributes?.step !== undefined ? Number(field.attributes.step) : undefined);
72
+ if (step !== undefined && (!numeric || !Number.isFinite(step) || step <= 0))
73
+ errors.push("step must be a positive finite number on a numeric field");
74
+ return errors;
75
+ }
76
+ /** File fields may narrow the safe list/size, and cannot synthesize a default upload. */
77
+ function fileConstraints(field) {
78
+ if (field.type !== "file")
79
+ return field.accept !== undefined || field.maxFileSize !== undefined ? ["accept/maxFileSize are only supported on file fields"] : [];
80
+ const errors = [];
81
+ if (field.maxFileSize !== undefined && (!Number.isInteger(field.maxFileSize) || field.maxFileSize < 1 || field.maxFileSize > MAX_FORM_FILE_BYTES))
82
+ errors.push(`maxFileSize must be 1-${MAX_FORM_FILE_BYTES} bytes`);
83
+ if (field.accept !== undefined && (!Array.isArray(field.accept) || field.accept.length === 0 || field.accept.length > 20 || field.accept.some((mime) => !Object.hasOwn(SAFE_FORM_FILE_TYPES, mime) && !["image/*", "video/*", "*/*"].includes(mime))))
84
+ errors.push("accept must contain recognized safe MIME types or image/*, video/*, */*");
85
+ if (field.default !== undefined)
86
+ errors.push("file fields cannot have defaults");
87
+ return errors;
88
+ }
89
+ /** Collect definition errors without coercing an untrusted descriptor into a valid one.
90
+ * @complexity O(k + o) for descriptor keys and at most 100 options. */
91
+ export function commonDescriptorErrors({ field }, _optional = {}) {
92
+ const errors = [];
93
+ for (const key of Object.keys(field))
94
+ if (!KEYS.has(key))
95
+ errors.push(`property '${key}' is not allowed`);
96
+ if (typeof field.required !== "boolean")
97
+ errors.push("required must be a boolean");
98
+ if (field.name !== undefined && field.name !== field.id)
99
+ errors.push("name must equal the stable field id");
100
+ for (const key of ["placeholder", "helpText"])
101
+ if (field[key] !== undefined && (typeof field[key] !== "string" || field[key].length > 500))
102
+ errors.push(`${key} must be a string of at most 500 characters`);
103
+ if (Object.hasOwn(FIELD_TYPE_REGISTRY, field.type))
104
+ errors.push(...textConstraints(field), ...choiceConstraints(field), ...boundConstraints(field), ...fileConstraints(field));
105
+ if (field.default !== undefined && field.type !== "file" && errors.length === 0) {
106
+ const result = resolveFormField({ field: { ...field, required: false }, value: field.default });
107
+ if (result.kind === "rejected")
108
+ errors.push(`default: ${result.reason}`);
109
+ }
110
+ return errors.map((reason) => fail(field.id || "(unknown)", reason));
111
+ }
112
+ //# sourceMappingURL=descriptor-rules.js.map
@@ -0,0 +1,7 @@
1
+ /** Returns whether the entire value matches native single-address email syntax.
2
+ * JS's $ can match before a final newline; checking the full match refuses that bypass.
3
+ * @complexity O(n) in the address length, bounded by the caller's 5000-character text cap. */
4
+ export declare function isValidFormEmail({ value }: {
5
+ value: string;
6
+ }, _optional?: {}): boolean;
7
+ //# sourceMappingURL=email-format.d.ts.map
@@ -0,0 +1,11 @@
1
+ /** Native email syntax shared by the registry and the compatibility submission guard. */
2
+ // Match native type=email syntax (WHATWG HTML email state), including single-label domains.
3
+ // Do not trim or rewrite answers: Jini's payload contract preserves submitted strings.
4
+ const EMAIL_PATTERN = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[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])?)*$/;
5
+ /** Returns whether the entire value matches native single-address email syntax.
6
+ * JS's $ can match before a final newline; checking the full match refuses that bypass.
7
+ * @complexity O(n) in the address length, bounded by the caller's 5000-character text cap. */
8
+ export function isValidFormEmail({ value }, _optional = {}) {
9
+ return EMAIL_PATTERN.exec(value)?.[0] === value;
10
+ }
11
+ //# sourceMappingURL=email-format.js.map
@@ -22,15 +22,11 @@ import { FormDefinitionNotFoundError, FormRateLimitExceededError, FormSubmission
22
22
  * wiped (2026-08-31 fix) — see `http/site/form-render.ts`'s "Validation flash cookie" section for why
23
23
  * this travels via a cookie rather than the query string.
24
24
  */
25
- const MAX_BODY_STRING_LENGTH = 5000;
26
- /** Truncates string values in an untrusted body before it reaches `submitForm`; other types pass through unchanged so `validateSubmissionPayload` can reject them by type, mirroring `analytics-ingest.ts`'s `parseBeacon`. */
25
+ /** Leave values intact so the server validator rejects oversize input instead of silently
26
+ * accepting truncated answers. The transport's JSON/urlencoded/multipart parsers bound memory.
27
+ * Unknown keys and duplicate scalar controls remain visible to the domain validator. */
27
28
  function boundBody(body) {
28
- const raw = (body ?? {});
29
- const bounded = {};
30
- for (const [key, value] of Object.entries(raw)) {
31
- bounded[key] = typeof value === "string" ? value.slice(0, MAX_BODY_STRING_LENGTH) : value;
32
- }
33
- return bounded;
29
+ return (body ?? {});
34
30
  }
35
31
  /** True for a real browser's form-POST navigation (`Accept: text/html,...`); false for the
36
32
  * fetch/XHR/API caller shape every pre-existing test in this file already sends (`*\/*`, or no
@@ -0,0 +1,193 @@
1
+ /** The form field vocabulary and control semantics. Validation and rendering both read this registry. */
2
+ import type { FieldDescriptor, FormAnswer, FormFileInput, FormFileStoragePort } from "./types.js";
3
+ export type FieldResolution = {
4
+ kind: "absent";
5
+ } | {
6
+ kind: "rejected";
7
+ reason: string;
8
+ } | {
9
+ kind: "accepted";
10
+ value: FormAnswer;
11
+ } | {
12
+ kind: "upload";
13
+ file: FormFileInput;
14
+ };
15
+ type FieldValidator = (required: {
16
+ field: FieldDescriptor;
17
+ value: unknown;
18
+ }, optional?: Record<string, never>) => FieldResolution;
19
+ /** Input length is capped before any format/pattern work, even on optional unconstrained text. */
20
+ declare function textValue({ field, value }: {
21
+ field: FieldDescriptor;
22
+ value: unknown;
23
+ }, _optional?: {}): FieldResolution;
24
+ /** Reuse the native email format owner after the shared text constraints. */
25
+ declare function emailValue({ field, value }: {
26
+ field: FieldDescriptor;
27
+ value: unknown;
28
+ }, _optional?: {}): FieldResolution;
29
+ /** Accept a bounded international telephone spelling without rewriting the answer. */
30
+ declare function phoneValue({ field, value }: {
31
+ field: FieldDescriptor;
32
+ value: unknown;
33
+ }, _optional?: {}): FieldResolution;
34
+ /** Parse absolute HTTP(S) URLs with the platform URL API; refuse active schemes and whitespace. */
35
+ declare function urlValue({ field, value }: {
36
+ field: FieldDescriptor;
37
+ value: unknown;
38
+ }, _optional?: {}): FieldResolution;
39
+ /** Native numeric text is parsed strictly, retaining finite JSON numbers as numbers in storage. */
40
+ declare function numberValue({ field, value }: {
41
+ field: FieldDescriptor;
42
+ value: unknown;
43
+ }, _optional?: {}): FieldResolution;
44
+ /** Recheck native calendar/clock syntax and configured bounds on the server. */
45
+ declare function temporalValue({ field, value }: {
46
+ field: FieldDescriptor;
47
+ value: unknown;
48
+ }, _optional?: {}): FieldResolution;
49
+ /** A scalar choice must be one of the stored option values; arrays never collapse silently. */
50
+ declare function singleChoice({ field, value }: {
51
+ field: FieldDescriptor;
52
+ value: unknown;
53
+ }, _optional?: {}): FieldResolution;
54
+ /** Normalize native one-value groups to arrays; duplicate and unlisted values are invalid. */
55
+ declare function multipleChoice({ field, value }: {
56
+ field: FieldDescriptor;
57
+ value: unknown;
58
+ }, _optional?: {}): FieldResolution;
59
+ /** Native boxes submit on or an authored value; required consent must be checked. */
60
+ declare function booleanValue({ field, value }: {
61
+ field: FieldDescriptor;
62
+ value: unknown;
63
+ }, _optional?: {}): FieldResolution;
64
+ export declare const FIELD_TYPE_REGISTRY: {
65
+ readonly text: {
66
+ readonly control: "input";
67
+ readonly htmlType: "text";
68
+ readonly validate: typeof textValue;
69
+ readonly textual: true;
70
+ };
71
+ readonly textarea: {
72
+ readonly control: "textarea";
73
+ readonly htmlType: "textarea";
74
+ readonly validate: typeof textValue;
75
+ readonly textual: true;
76
+ };
77
+ readonly email: {
78
+ readonly control: "input";
79
+ readonly htmlType: "email";
80
+ readonly validate: typeof emailValue;
81
+ readonly textual: true;
82
+ };
83
+ readonly phone: {
84
+ readonly control: "input";
85
+ readonly htmlType: "tel";
86
+ readonly validate: typeof phoneValue;
87
+ readonly textual: true;
88
+ };
89
+ readonly url: {
90
+ readonly control: "input";
91
+ readonly htmlType: "url";
92
+ readonly validate: typeof urlValue;
93
+ readonly textual: true;
94
+ };
95
+ readonly number: {
96
+ readonly control: "input";
97
+ readonly htmlType: "number";
98
+ readonly validate: typeof numberValue;
99
+ };
100
+ readonly range: {
101
+ readonly control: "input";
102
+ readonly htmlType: "range";
103
+ readonly validate: typeof numberValue;
104
+ };
105
+ readonly date: {
106
+ readonly control: "input";
107
+ readonly htmlType: "date";
108
+ readonly validate: typeof temporalValue;
109
+ };
110
+ readonly time: {
111
+ readonly control: "input";
112
+ readonly htmlType: "time";
113
+ readonly validate: typeof temporalValue;
114
+ };
115
+ readonly datetime: {
116
+ readonly control: "input";
117
+ readonly htmlType: "datetime-local";
118
+ readonly validate: typeof temporalValue;
119
+ };
120
+ readonly select: {
121
+ readonly control: "select";
122
+ readonly htmlType: "select";
123
+ readonly validate: typeof singleChoice;
124
+ readonly choices: true;
125
+ };
126
+ readonly "multi-select": {
127
+ readonly control: "select";
128
+ readonly htmlType: "select";
129
+ readonly validate: typeof multipleChoice;
130
+ readonly choices: true;
131
+ readonly multiple: true;
132
+ };
133
+ readonly radio: {
134
+ readonly control: "group";
135
+ readonly htmlType: "radio";
136
+ readonly validate: typeof singleChoice;
137
+ readonly choices: true;
138
+ };
139
+ readonly checkbox: {
140
+ readonly control: "input";
141
+ readonly htmlType: "checkbox";
142
+ readonly validate: typeof booleanValue;
143
+ };
144
+ readonly "checkbox-group": {
145
+ readonly control: "group";
146
+ readonly htmlType: "checkbox";
147
+ readonly validate: typeof multipleChoice;
148
+ readonly choices: true;
149
+ readonly multiple: true;
150
+ };
151
+ readonly toggle: {
152
+ readonly control: "input";
153
+ readonly htmlType: "checkbox";
154
+ readonly validate: typeof booleanValue;
155
+ };
156
+ readonly file: {
157
+ readonly control: "input";
158
+ readonly htmlType: "file";
159
+ readonly validate: FieldValidator;
160
+ };
161
+ readonly hidden: {
162
+ readonly control: "input";
163
+ readonly htmlType: "hidden";
164
+ readonly validate: typeof textValue;
165
+ readonly textual: true;
166
+ };
167
+ };
168
+ /** Safe patterns support literals/classes and one repetition; no user-defined backtracking trees.
169
+ * @returns False for unsupported or invalid regex syntax, never throws. */
170
+ export declare function isSafeFieldPattern({ pattern }: {
171
+ pattern: string;
172
+ }, _optional?: {}): boolean;
173
+ /** Browsers compile the `pattern` attribute with the `v` flag, which rejects some `u`-valid classes
174
+ * (an unescaped `-` as in `[a-z-]`, or `(`, `)`, `{`, `}`, `/`, `|` inside `[...]`). The browser
175
+ * then drops the attribute without a word and only the server enforces it (2026-10-11), so new
176
+ * authoring must pass both; submissions still validate with {@link isSafeFieldPattern} alone.
177
+ * @returns False when the pattern is unsafe or a browser would ignore it, never throws. */
178
+ export declare function isBrowserSafeFieldPattern({ pattern }: {
179
+ pattern: string;
180
+ }, _optional?: {}): boolean;
181
+ /** Public forms accept only known inert media/document formats; wildcard accept never expands this list. */
182
+ export declare const SAFE_FORM_FILE_TYPES: Readonly<Record<string, readonly string[]>>;
183
+ export declare const MAX_FORM_FILE_BYTES: number;
184
+ /** Checks byte size, MIME, filename and detected magic before any upload side effect.
185
+ * @complexity O(n) host sniffing, bounded by a 10 MiB per-file ceiling. */
186
+ export declare function resolveFormField({ field, value }: {
187
+ field: FieldDescriptor;
188
+ value: unknown;
189
+ }, { files }?: {
190
+ files?: Pick<FormFileStoragePort, "detectContentType">;
191
+ }): FieldResolution;
192
+ export {};
193
+ //# sourceMappingURL=field-registry.d.ts.map
@@ -0,0 +1,231 @@
1
+ import { isValidFormEmail } from "./email-format.js";
2
+ const accepted = (value) => ({ kind: "accepted", value });
3
+ const rejected = (reason) => ({ kind: "rejected", reason });
4
+ const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
5
+ const fullMatch = (pattern, value) => pattern.exec(value)?.[0] === value;
6
+ /** Input length is capped before any format/pattern work, even on optional unconstrained text. */
7
+ function textValue({ field, value }, _optional = {}) {
8
+ if (typeof value !== "string")
9
+ return rejected("must be a string");
10
+ if (field.required && value.trim() === "")
11
+ return rejected("required");
12
+ const maxLength = field.maxLength ?? 5000;
13
+ if (value.length > maxLength)
14
+ return rejected("too_long");
15
+ if (value !== "" && value.length < (field.minLength ?? Number(field.attributes?.minlength ?? 0)))
16
+ return rejected("too_short");
17
+ const pattern = field.pattern ?? field.attributes?.pattern;
18
+ if (value !== "" && pattern !== undefined) {
19
+ if (!isSafeFieldPattern({ pattern }) || !fullMatch(new RegExp(`^(?:${pattern})$`, "u"), value))
20
+ return rejected("pattern_mismatch");
21
+ }
22
+ return accepted(value);
23
+ }
24
+ /** Reuse the native email format owner after the shared text constraints. */
25
+ function emailValue({ field, value }, _optional = {}) {
26
+ const result = textValue({ field, value });
27
+ if (result.kind !== "accepted" || value === "")
28
+ return result;
29
+ return isValidFormEmail({ value: value }) ? result : rejected("invalid_email");
30
+ }
31
+ /** Accept a bounded international telephone spelling without rewriting the answer. */
32
+ function phoneValue({ field, value }, _optional = {}) {
33
+ const result = textValue({ field, value });
34
+ if (result.kind !== "accepted" || value === "")
35
+ return result;
36
+ return fullMatch(/^\+?[0-9() .-]{3,50}$/, value) && /\d/.test(value) ? result : rejected("invalid_phone");
37
+ }
38
+ /** Parse absolute HTTP(S) URLs with the platform URL API; refuse active schemes and whitespace. */
39
+ function urlValue({ field, value }, _optional = {}) {
40
+ const result = textValue({ field, value });
41
+ if (result.kind !== "accepted" || value === "")
42
+ return result;
43
+ try {
44
+ const url = new URL(value);
45
+ return ["http:", "https:"].includes(url.protocol) && url.hostname !== "" && value.trim() === value ? result : rejected("invalid_url");
46
+ }
47
+ catch {
48
+ return rejected("invalid_url");
49
+ }
50
+ }
51
+ /** Native numeric text is parsed strictly, retaining finite JSON numbers as numbers in storage. */
52
+ function numberValue({ field, value }, _optional = {}) {
53
+ if (value === "")
54
+ return field.required ? rejected("required") : { kind: "absent" };
55
+ if (typeof value !== "number" && (typeof value !== "string" || value.length > 100 || !fullMatch(NUMBER, value)))
56
+ return rejected("invalid_number");
57
+ const number = Number(value);
58
+ if (!Number.isFinite(number))
59
+ return rejected("invalid_number");
60
+ const min = field.min ?? field.attributes?.min ?? (field.type === "range" ? 0 : undefined);
61
+ const max = field.max ?? field.attributes?.max ?? (field.type === "range" ? 100 : undefined);
62
+ if (min !== undefined && number < Number(min))
63
+ return rejected("below_min");
64
+ if (max !== undefined && number > Number(max))
65
+ return rejected("above_max");
66
+ const step = field.step ?? Number(field.attributes?.step ?? 1);
67
+ const units = (number - Number(min ?? 0)) / step;
68
+ if (!Number.isFinite(units) || Math.abs(units - Math.round(units)) > 1e-8)
69
+ return rejected("step_mismatch");
70
+ return accepted(number);
71
+ }
72
+ /** Calendar validation compares components so Date's rollover cannot accept February 30. */
73
+ function validDate(value) {
74
+ if (!fullMatch(/^\d{4}-\d{2}-\d{2}$/, value))
75
+ return false;
76
+ const [year, month, day] = value.split("-").map(Number);
77
+ if (!year || !month || !day)
78
+ return false;
79
+ const date = new Date(`${value}T00:00:00Z`);
80
+ return date.getUTCFullYear() === year && date.getUTCMonth() + 1 === month && date.getUTCDate() === day;
81
+ }
82
+ const validTime = (value) => fullMatch(/(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?/, value);
83
+ /** Native controls carry local wall-clock values; UTC is used only as a comparison axis. */
84
+ function temporalTimestamp(type, value) {
85
+ if (type === "date")
86
+ return Date.parse(`${value}T00:00:00Z`);
87
+ return Date.parse(type === "time" ? `1970-01-01T${value}Z` : `${value}Z`);
88
+ }
89
+ /** Recheck native calendar/clock syntax and configured bounds on the server. */
90
+ function temporalValue({ field, value }, _optional = {}) {
91
+ if (typeof value !== "string" || value.length > 30)
92
+ return rejected("invalid_datetime");
93
+ if (value === "")
94
+ return field.required ? rejected("required") : { kind: "absent" };
95
+ const [date, time, extra] = value.split("T");
96
+ const valid = field.type === "date" ? validDate(value) : field.type === "time" ? validTime(value) : extra === undefined && validDate(date) && time !== undefined && validTime(time);
97
+ if (!valid)
98
+ return rejected(`invalid_${field.type}`);
99
+ const min = field.min ?? field.attributes?.min;
100
+ const max = field.max ?? field.attributes?.max;
101
+ // Compare normalized instants: 12:30 and 12:30:00, or .1 and .100 seconds, are equal.
102
+ const timestamp = temporalTimestamp(field.type, value);
103
+ if (min !== undefined && timestamp < temporalTimestamp(field.type, String(min)))
104
+ return rejected("below_min");
105
+ if (max !== undefined && timestamp > temporalTimestamp(field.type, String(max)))
106
+ return rejected("above_max");
107
+ return accepted(value);
108
+ }
109
+ /** A scalar choice must be one of the stored option values; arrays never collapse silently. */
110
+ function singleChoice({ field, value }, _optional = {}) {
111
+ if (value === "")
112
+ return field.required ? rejected("required") : { kind: "absent" };
113
+ if (typeof value !== "string")
114
+ return rejected("must be a string");
115
+ return field.options?.some((option) => option.value === value) ? accepted(value) : rejected("invalid_option");
116
+ }
117
+ /** Normalize native one-value groups to arrays; duplicate and unlisted values are invalid. */
118
+ function multipleChoice({ field, value }, _optional = {}) {
119
+ const values = typeof value === "string" ? [value] : value;
120
+ if (!Array.isArray(values) || values.length > 100 || !values.every((item) => typeof item === "string"))
121
+ return rejected("must be a string array");
122
+ if (field.required && values.length === 0)
123
+ return rejected("required");
124
+ const allowed = new Set(field.options?.map((option) => option.value));
125
+ if (new Set(values).size !== values.length || values.some((item) => !allowed.has(item)))
126
+ return rejected("invalid_option");
127
+ return accepted([...values]);
128
+ }
129
+ /** Native boxes submit on or an authored value; required consent must be checked. */
130
+ function booleanValue({ field, value }, _optional = {}) {
131
+ if (field.checkboxValue !== undefined) {
132
+ if (typeof value !== "string")
133
+ return rejected("must be a boolean");
134
+ return value === field.checkboxValue ? accepted(value) : rejected("invalid_option");
135
+ }
136
+ if (typeof value === "boolean")
137
+ return field.required && !value ? rejected("required") : accepted(value);
138
+ return value === "on" ? accepted(true) : rejected("must be a boolean");
139
+ }
140
+ // File validation needs a host sniffer. The ordinary resolver refuses files without that port.
141
+ const fileValue = (_required, _optional = {}) => rejected("file_storage_unavailable");
142
+ export const FIELD_TYPE_REGISTRY = {
143
+ text: { control: "input", htmlType: "text", validate: textValue, textual: true },
144
+ textarea: { control: "textarea", htmlType: "textarea", validate: textValue, textual: true },
145
+ email: { control: "input", htmlType: "email", validate: emailValue, textual: true },
146
+ phone: { control: "input", htmlType: "tel", validate: phoneValue, textual: true },
147
+ url: { control: "input", htmlType: "url", validate: urlValue, textual: true },
148
+ number: { control: "input", htmlType: "number", validate: numberValue },
149
+ range: { control: "input", htmlType: "range", validate: numberValue },
150
+ date: { control: "input", htmlType: "date", validate: temporalValue },
151
+ time: { control: "input", htmlType: "time", validate: temporalValue },
152
+ datetime: { control: "input", htmlType: "datetime-local", validate: temporalValue },
153
+ select: { control: "select", htmlType: "select", validate: singleChoice, choices: true },
154
+ "multi-select": { control: "select", htmlType: "select", validate: multipleChoice, choices: true, multiple: true },
155
+ radio: { control: "group", htmlType: "radio", validate: singleChoice, choices: true },
156
+ checkbox: { control: "input", htmlType: "checkbox", validate: booleanValue },
157
+ "checkbox-group": { control: "group", htmlType: "checkbox", validate: multipleChoice, choices: true, multiple: true },
158
+ toggle: { control: "input", htmlType: "checkbox", validate: booleanValue },
159
+ file: { control: "input", htmlType: "file", validate: fileValue },
160
+ hidden: { control: "input", htmlType: "hidden", validate: textValue, textual: true },
161
+ };
162
+ /** Safe patterns support literals/classes and one repetition; no user-defined backtracking trees.
163
+ * @returns False for unsupported or invalid regex syntax, never throws. */
164
+ export function isSafeFieldPattern({ pattern }, _optional = {}) {
165
+ if (pattern.length > 300 || /[()|]|\\[1-9]|\\[pPkK]/.test(pattern))
166
+ return false;
167
+ // Multiple repetitions can still backtrack exponentially without parentheses; refuse them too.
168
+ const outsideClasses = pattern.replace(/\\./g, "x").replace(/\[[^\]]*\]/g, "x");
169
+ if ((outsideClasses.match(/[+*?]|\{/g) ?? []).length > 1 || /\.\*/.test(outsideClasses))
170
+ return false;
171
+ try {
172
+ new RegExp(`^(?:${pattern})$`, "u");
173
+ return true;
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ }
179
+ /** Browsers compile the `pattern` attribute with the `v` flag, which rejects some `u`-valid classes
180
+ * (an unescaped `-` as in `[a-z-]`, or `(`, `)`, `{`, `}`, `/`, `|` inside `[...]`). The browser
181
+ * then drops the attribute without a word and only the server enforces it (2026-10-11), so new
182
+ * authoring must pass both; submissions still validate with {@link isSafeFieldPattern} alone.
183
+ * @returns False when the pattern is unsafe or a browser would ignore it, never throws. */
184
+ export function isBrowserSafeFieldPattern({ pattern }, _optional = {}) {
185
+ if (!isSafeFieldPattern({ pattern }))
186
+ return false;
187
+ try {
188
+ new RegExp(`^(?:${pattern})$`, "v");
189
+ return true;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ }
195
+ /** Public forms accept only known inert media/document formats; wildcard accept never expands this list. */
196
+ export const SAFE_FORM_FILE_TYPES = {
197
+ "image/png": ["png"], "image/jpeg": ["jpg", "jpeg"], "image/gif": ["gif"], "image/webp": ["webp"], "image/avif": ["avif"],
198
+ "video/mp4": ["mp4"], "video/webm": ["webm"], "application/pdf": ["pdf"],
199
+ };
200
+ export const MAX_FORM_FILE_BYTES = 10 * 1024 * 1024;
201
+ const DANGEROUS_EXTENSION = /\.(?:exe|dll|com|bat|cmd|msi|sh|bash|ps1|js|mjs|cjs|ts|tsx|jsx|py|rb|pl|php\d*|phtml|html?|svg|xml|jar|zip|scr|vbs)(?:\.|$)/i;
202
+ /** Checks byte size, MIME, filename and detected magic before any upload side effect.
203
+ * @complexity O(n) host sniffing, bounded by a 10 MiB per-file ceiling. */
204
+ export function resolveFormField({ field, value }, { files } = {}) {
205
+ if (value === undefined || value === null)
206
+ return field.required ? rejected("required") : { kind: "absent" };
207
+ const kind = Object.hasOwn(FIELD_TYPE_REGISTRY, field.type) ? FIELD_TYPE_REGISTRY[field.type] : undefined;
208
+ if (!kind)
209
+ return rejected("invalid_type");
210
+ if (field.type !== "file")
211
+ return kind.validate({ field, value });
212
+ if (!files)
213
+ return rejected("file_storage_unavailable");
214
+ const file = value;
215
+ if (typeof file !== "object" || !(file.bytes instanceof Uint8Array) || typeof file.filename !== "string" || typeof file.contentType !== "string")
216
+ return rejected("invalid_file");
217
+ if (file.bytes.byteLength === 0 || file.bytes.byteLength > Math.min(field.maxFileSize ?? MAX_FORM_FILE_BYTES, MAX_FORM_FILE_BYTES))
218
+ return rejected("file_size");
219
+ if (file.filename.length > 200 || /[\\/\x00-\x1f\x7f]/.test(file.filename) || DANGEROUS_EXTENSION.test(file.filename))
220
+ return rejected("file_type");
221
+ const contentType = files.detectContentType({ bytes: file.bytes });
222
+ if (file.contentType !== "" && file.contentType !== contentType)
223
+ return rejected("file_type");
224
+ const extensions = Object.hasOwn(SAFE_FORM_FILE_TYPES, contentType) ? SAFE_FORM_FILE_TYPES[contentType] : undefined;
225
+ if (!extensions || !extensions.includes(file.filename.split(".").at(-1).toLowerCase()))
226
+ return rejected("file_type");
227
+ if (field.accept && !field.accept.some((accept) => accept === contentType || accept === "*/*" || accept === `${contentType.split("/")[0]}/*`))
228
+ return rejected("file_type");
229
+ return { kind: "upload", file: { filename: file.filename, contentType, bytes: file.bytes } };
230
+ }
231
+ //# sourceMappingURL=field-registry.js.map
@@ -1,5 +1,5 @@
1
1
  /** Pure field/submission validation and honeypot decisions. Error order and stored value normalization are stable. */
2
- import type { FieldDescriptor, FormDefinitionRecord } from "./types.js";
2
+ import type { FieldDescriptor, FormDefinitionRecord, FormAnswer, FormFileInput, FormFileStoragePort } from "./types.js";
3
3
  export declare const MAX_FIELDS = 20;
4
4
  export declare const MAX_LABEL_LENGTH = 200;
5
5
  export declare const MIN_MAX_LENGTH = 1;
@@ -35,14 +35,15 @@ export type FieldValidationResult = {
35
35
  */
36
36
  export declare function validateFieldDescriptors({ fields }: {
37
37
  fields: FieldDescriptor[];
38
- }): FieldValidationResult;
38
+ }, _optional?: {}): FieldValidationResult;
39
39
  export interface ValidateSubmissionPayloadInput {
40
40
  definition: FormDefinitionRecord;
41
41
  body: Record<string, unknown>;
42
42
  }
43
43
  export type SubmissionValidationResult = {
44
44
  valid: true;
45
- data: Record<string, string | boolean>;
45
+ data: Record<string, FormAnswer>;
46
+ files?: Record<string, FormFileInput>;
46
47
  } | {
47
48
  valid: false;
48
49
  fieldErrors: FieldError[];
@@ -52,8 +53,10 @@ export type SubmissionValidationResult = {
52
53
  * body keys first, then declared-field errors in definition order, independent of body key order.
53
54
  * This keeps the declared-field portion of the error list stable across equivalent submissions.
54
55
  */
55
- export declare function validateSubmissionPayload(input: ValidateSubmissionPayloadInput): SubmissionValidationResult;
56
+ export declare function validateSubmissionPayload(input: ValidateSubmissionPayloadInput, optional?: {
57
+ files?: Pick<FormFileStoragePort, "detectContentType">;
58
+ }): SubmissionValidationResult;
56
59
  export declare function isHoneypotTripped(input: {
57
60
  hp: unknown;
58
- }): boolean;
61
+ }, _optional?: {}): boolean;
59
62
  //# sourceMappingURL=forms.d.ts.map