@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.
- package/dist/consent/express/index.d.ts +53 -0
- package/dist/consent/express/index.js +159 -0
- package/dist/consent/express/response.d.ts +21 -0
- package/dist/consent/express/response.js +89 -0
- package/dist/consent/html/client.d.ts +6 -0
- package/dist/consent/html/client.js +135 -0
- package/dist/consent/html/gate.d.ts +29 -0
- package/dist/consent/html/gate.js +104 -0
- package/dist/consent/html/index.d.ts +4 -0
- package/dist/consent/html/index.js +3 -0
- package/dist/consent/html/render.d.ts +27 -0
- package/dist/consent/html/render.js +62 -0
- package/dist/consent/html/static.d.ts +7 -0
- package/dist/consent/html/static.js +25 -0
- package/dist/consent/html/styles.d.ts +3 -0
- package/dist/consent/html/styles.js +22 -0
- package/dist/consent/index.d.ts +3 -0
- package/dist/consent/index.js +3 -0
- package/dist/consent/node/index.d.ts +47 -0
- package/dist/consent/node/index.js +169 -0
- package/dist/consent/policy.d.ts +47 -0
- package/dist/consent/policy.js +62 -0
- package/dist/consent/types.d.ts +40 -0
- package/dist/consent/types.js +5 -0
- package/dist/content-types/agent-tools.js +1 -1
- package/dist/content-types/tool-registrations.d.ts +2 -0
- package/dist/content-types/tool-registrations.js +1 -0
- package/dist/content-types/write-service.d.ts +2 -0
- package/dist/content-types/write-service.js +3 -0
- package/dist/forms/descriptor-rules.d.ts +9 -0
- package/dist/forms/descriptor-rules.js +112 -0
- package/dist/forms/email-format.d.ts +7 -0
- package/dist/forms/email-format.js +11 -0
- package/dist/forms/express/handler.js +4 -8
- package/dist/forms/field-registry.d.ts +193 -0
- package/dist/forms/field-registry.js +231 -0
- package/dist/forms/forms.d.ts +8 -5
- package/dist/forms/forms.js +29 -46
- package/dist/forms/html/html-authoring.js +63 -13
- package/dist/forms/html/html-submission-adapter.d.ts +5 -5
- package/dist/forms/html/html-submission-adapter.js +6 -13
- package/dist/forms/html-render.d.ts +10 -3
- package/dist/forms/html-render.js +116 -14
- package/dist/forms/index.d.ts +1 -0
- package/dist/forms/index.js +1 -0
- package/dist/forms/repo.memory.js +4 -4
- package/dist/forms/submission-attempts.d.ts +1 -1
- package/dist/forms/submission-attempts.js +13 -2
- package/dist/forms/submission-email-validation.js +2 -4
- package/dist/forms/submit-service.d.ts +3 -0
- package/dist/forms/submit-service.js +38 -5
- package/dist/forms/types.d.ts +50 -2
- package/dist/listing/batch.d.ts +53 -0
- package/dist/listing/batch.js +57 -0
- package/dist/listing/html.d.ts +72 -0
- package/dist/listing/html.js +109 -0
- package/dist/listing/index.d.ts +5 -0
- package/dist/listing/index.js +5 -0
- package/dist/listing/paths.d.ts +32 -0
- package/dist/listing/paths.js +37 -0
- package/dist/listing/see-more-script.d.ts +16 -0
- package/dist/listing/see-more-script.js +62 -0
- package/dist/media/content-type-sniffer.d.ts +1 -1
- package/dist/media/content-type-sniffer.js +15 -0
- package/dist/media/image-transformer.sharp.js +4 -1
- package/dist/media/media-service.d.ts +4 -0
- package/dist/media/media-service.js +8 -0
- package/dist/media/node.d.ts +1 -1
- package/dist/media/node.js +1 -1
- package/dist/media/transform-registry.js +1 -1
- package/dist/media/transform-types.d.ts +2 -0
- package/dist/media/transform-types.js +3 -0
- package/dist/media/video-frames.ffmpeg.d.ts +15 -2
- package/dist/media/video-frames.ffmpeg.js +28 -13
- package/dist/navigation/dropdown.d.ts +41 -0
- package/dist/navigation/dropdown.js +85 -0
- package/dist/navigation/index.d.ts +2 -0
- package/dist/navigation/index.js +2 -0
- package/dist/redirects/capture.d.ts +2 -0
- package/dist/redirects/capture.js +4 -0
- package/dist/redirects/redirects.d.ts +8 -0
- package/dist/redirects/redirects.js +14 -0
- package/dist/routing/index.d.ts +5 -0
- package/dist/routing/index.js +2 -0
- package/dist/routing/ports.d.ts +51 -0
- package/dist/routing/ports.js +2 -0
- package/dist/routing/routing.d.ts +138 -0
- package/dist/routing/routing.js +443 -0
- package/dist/routing/types.d.ts +164 -0
- package/dist/routing/types.js +2 -0
- package/dist/seo/media.d.ts +23 -0
- package/dist/seo/media.js +30 -5
- package/dist/seo/page-head.js +8 -1
- package/dist/seo/seo.d.ts +2 -0
- package/dist/seo/seo.js +11 -6
- package/dist/seo/types.d.ts +8 -0
- package/dist/seo/xml-escape.d.ts +1 -3
- package/dist/seo/xml-escape.js +1 -6
- package/dist/widgets/concurrency.d.ts +27 -1
- package/dist/widgets/concurrency.js +12 -23
- package/dist/widgets/config-validation.js +6 -0
- package/dist/widgets/gallery-resolver.d.ts +23 -0
- package/dist/widgets/gallery-resolver.js +80 -0
- package/dist/widgets/html/gallery-render.d.ts +24 -0
- package/dist/widgets/html/gallery-render.js +65 -0
- package/dist/widgets/html/html-embeds.d.ts +4 -0
- package/dist/widgets/html/html-embeds.js +7 -1
- package/dist/widgets/html/index.d.ts +3 -0
- package/dist/widgets/html/index.js +3 -0
- package/dist/widgets/html/server-fragment.d.ts +9 -0
- package/dist/widgets/html/server-fragment.js +38 -0
- package/dist/widgets/markers/gallery.d.ts +50 -0
- package/dist/widgets/markers/gallery.js +164 -0
- package/dist/widgets/markers/html-attributes.d.ts +6 -1
- package/dist/widgets/markers/html-attributes.js +24 -9
- package/dist/widgets/markers/index.d.ts +2 -0
- package/dist/widgets/markers/index.js +2 -0
- package/dist/widgets/markers/marker.d.ts +4 -0
- package/dist/widgets/markers/marker.js +6 -0
- package/dist/widgets/markers/media-reference.d.ts +11 -0
- package/dist/widgets/markers/media-reference.js +11 -0
- package/dist/widgets/page-embed-types.d.ts +1 -1
- package/dist/widgets/page-embed-types.js +1 -1
- package/dist/widgets/registry.d.ts +144 -10
- package/dist/widgets/registry.js +40 -0
- package/dist/widgets/resolver-service.d.ts +10 -0
- package/dist/widgets/resolver-service.js +73 -29
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
- package/dist/widgets/resolvers/gallery.d.ts +12 -0
- package/dist/widgets/resolvers/gallery.js +33 -0
- package/dist/widgets/resolvers/index.d.ts +1 -0
- package/dist/widgets/resolvers/index.js +1 -0
- package/dist/widgets/types.d.ts +3 -1
- package/package.json +51 -6
package/dist/forms/forms.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { FIELD_TYPE_REGISTRY, resolveFormField } from "./field-registry.js";
|
|
2
|
+
import { commonDescriptorErrors } from "./descriptor-rules.js";
|
|
1
3
|
// Domain validation stays independent of feature manifests, and returns decisions without I/O
|
|
2
4
|
// or exceptions so write and submission services can choose their own error presentation.
|
|
3
5
|
// Published tool schemas and render-time defensive checks use these exported bounds/patterns
|
|
@@ -9,7 +11,7 @@ export const MAX_LABEL_LENGTH = 200;
|
|
|
9
11
|
export const MIN_MAX_LENGTH = 1;
|
|
10
12
|
export const MAX_MAX_LENGTH = 5000;
|
|
11
13
|
export const FIELD_ID_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
12
|
-
export const FIELD_TYPES = new Set(
|
|
14
|
+
export const FIELD_TYPES = new Set(Object.keys(FIELD_TYPE_REGISTRY));
|
|
13
15
|
export const MAX_CLASS_NAME_LENGTH = 300;
|
|
14
16
|
export const MAX_ATTRIBUTES_PER_FIELD = 12;
|
|
15
17
|
export const MAX_ATTRIBUTE_VALUE_LENGTH = 300;
|
|
@@ -25,7 +27,7 @@ export const HONEYPOT_KEY = "_hp";
|
|
|
25
27
|
// Malformed IDs are never claimed: repeated malformed IDs must each report their shape error
|
|
26
28
|
// rather than misleading the second field's author with a duplicate-ID diagnosis.
|
|
27
29
|
function fieldIdErrors(descriptor, label, claimedIds) {
|
|
28
|
-
if (!descriptor.id || !FIELD_ID_PATTERN.test(descriptor.id)) {
|
|
30
|
+
if (typeof descriptor.id !== "string" || !descriptor.id || !FIELD_ID_PATTERN.test(descriptor.id)) {
|
|
29
31
|
return [{ field: label, reason: "id must match ^[a-z][a-z0-9_]*$" }];
|
|
30
32
|
}
|
|
31
33
|
if (claimedIds.has(descriptor.id)) {
|
|
@@ -41,6 +43,8 @@ function fieldTypeErrors(descriptor, label) {
|
|
|
41
43
|
return [];
|
|
42
44
|
}
|
|
43
45
|
function labelLengthErrors(descriptor, label) {
|
|
46
|
+
if (typeof descriptor.label !== "string")
|
|
47
|
+
return [{ field: label, reason: "label must be a string" }];
|
|
44
48
|
if (descriptor.label.length < MIN_LABEL_LENGTH || descriptor.label.length > MAX_LABEL_LENGTH) {
|
|
45
49
|
return [{ field: label, reason: `label must be ${MIN_LABEL_LENGTH}-${MAX_LABEL_LENGTH} characters` }];
|
|
46
50
|
}
|
|
@@ -117,7 +121,9 @@ function attributeErrors(descriptor, label) {
|
|
|
117
121
|
* Empty helper results mean a satisfied rule. Bounds are 1-20 fields, labels 1-200 characters,
|
|
118
122
|
* and maxLength 1-5000 (never on checkboxes), with unique lowercase field IDs.
|
|
119
123
|
*/
|
|
120
|
-
export function validateFieldDescriptors({ fields }) {
|
|
124
|
+
export function validateFieldDescriptors({ fields }, _optional = {}) {
|
|
125
|
+
if (!Array.isArray(fields))
|
|
126
|
+
return { valid: false, fieldErrors: [{ field: "fields", reason: "must be an array" }] };
|
|
121
127
|
const fieldErrors = [];
|
|
122
128
|
if (fields.length < MIN_FIELDS) {
|
|
123
129
|
fieldErrors.push({ field: "fields", reason: `at least ${MIN_FIELDS} field is required` });
|
|
@@ -127,85 +133,62 @@ export function validateFieldDescriptors({ fields }) {
|
|
|
127
133
|
}
|
|
128
134
|
const claimedIds = new Set();
|
|
129
135
|
for (const descriptor of fields) {
|
|
136
|
+
if (!descriptor || typeof descriptor !== "object" || Array.isArray(descriptor)) {
|
|
137
|
+
fieldErrors.push({ field: "fields", reason: "each field must be an object" });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
130
140
|
const label = descriptor.id || "(unknown)";
|
|
131
|
-
fieldErrors.push(...fieldIdErrors(descriptor, label, claimedIds), ...fieldTypeErrors(descriptor, label), ...labelLengthErrors(descriptor, label), ...maxLengthErrors(descriptor, label), ...checkboxValueErrors(descriptor, label), ...classNameErrors(descriptor, label), ...attributeErrors(descriptor, label));
|
|
141
|
+
fieldErrors.push(...fieldIdErrors(descriptor, label, claimedIds), ...fieldTypeErrors(descriptor, label), ...labelLengthErrors(descriptor, label), ...maxLengthErrors(descriptor, label), ...checkboxValueErrors(descriptor, label), ...classNameErrors(descriptor, label), ...attributeErrors(descriptor, label), ...commonDescriptorErrors({ field: descriptor }));
|
|
132
142
|
}
|
|
133
143
|
if (fieldErrors.length > 0)
|
|
134
144
|
return { valid: false, fieldErrors };
|
|
135
145
|
return { valid: true };
|
|
136
146
|
}
|
|
147
|
+
// Absence is distinct from acceptance: treating an omitted optional field as an accepted value
|
|
148
|
+
// would write undefined and violate the guarantee that stored answers contain only declared keys.
|
|
137
149
|
// The honeypot is tolerated in the raw body, but is excluded from stored answers.
|
|
138
|
-
function unregisteredKeyErrors(body, fieldsById) {
|
|
139
|
-
const errors = [];
|
|
140
|
-
for (const key of Object.keys(body)) {
|
|
141
|
-
if (key === HONEYPOT_KEY)
|
|
142
|
-
continue;
|
|
143
|
-
if (!fieldsById.has(key)) {
|
|
144
|
-
errors.push({ field: key, reason: "unregistered_key" });
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return errors;
|
|
148
|
-
}
|
|
149
150
|
// Whitespace is rejected only for required text; optional whitespace is an answer the definition
|
|
150
151
|
// permits. Preserve the submitted string instead of silently trimming its stored value.
|
|
151
|
-
function resolveTextValue(field, value) {
|
|
152
|
-
if (typeof value !== "string")
|
|
153
|
-
return { kind: "rejected", reason: "must be a string" };
|
|
154
|
-
if (field.required && value.trim() === "")
|
|
155
|
-
return { kind: "rejected", reason: "required" };
|
|
156
|
-
if (field.maxLength != null && value.length > field.maxLength)
|
|
157
|
-
return { kind: "rejected", reason: "too_long" };
|
|
158
|
-
return { kind: "accepted", value };
|
|
159
|
-
}
|
|
160
152
|
// Native URL-encoded forms submit "on" for checked boxes without a value attribute; accepting
|
|
161
153
|
// only JSON booleans would reject every checked checkbox when client JavaScript is disabled.
|
|
162
154
|
// An unticked native checkbox omits its key; submission resolution treats that as absence.
|
|
163
|
-
const HTML_CHECKBOX_ON_VALUE = "on";
|
|
164
|
-
function resolveCheckboxValue(field, value) {
|
|
165
|
-
if (field.checkboxValue !== undefined && typeof value === "string")
|
|
166
|
-
return { kind: "accepted", value };
|
|
167
|
-
if (typeof value === "boolean")
|
|
168
|
-
return { kind: "accepted", value };
|
|
169
|
-
if (value === HTML_CHECKBOX_ON_VALUE)
|
|
170
|
-
return { kind: "accepted", value: true };
|
|
171
|
-
return { kind: "rejected", reason: "must be a boolean" };
|
|
172
|
-
}
|
|
173
155
|
// JSON and native forms differ in how they represent missing values, so null/undefined both
|
|
174
|
-
// mean absence. Boolean false is a real checkbox answer,
|
|
175
|
-
function
|
|
176
|
-
|
|
177
|
-
return field.required ? { kind: "rejected", reason: "required" } : { kind: "absent" };
|
|
178
|
-
}
|
|
179
|
-
if (field.type === "checkbox")
|
|
180
|
-
return resolveCheckboxValue(field, value);
|
|
181
|
-
return resolveTextValue(field, value);
|
|
156
|
+
// mean absence. Boolean false is a real checkbox answer, only for optional native checkboxes; required consent must be checked.
|
|
157
|
+
function unregisteredKeyErrors(body, fieldsById) {
|
|
158
|
+
return Object.keys(body).filter((key) => key !== HONEYPOT_KEY && !fieldsById.has(key)).map((field) => ({ field, reason: "unregistered_key" }));
|
|
182
159
|
}
|
|
183
160
|
/**
|
|
184
161
|
* Only declared, accepted fields reach data; the reserved honeypot never does. Report unknown
|
|
185
162
|
* body keys first, then declared-field errors in definition order, independent of body key order.
|
|
186
163
|
* This keeps the declared-field portion of the error list stable across equivalent submissions.
|
|
187
164
|
*/
|
|
188
|
-
export function validateSubmissionPayload(input) {
|
|
165
|
+
export function validateSubmissionPayload(input, optional = {}) {
|
|
189
166
|
const { definition, body } = input;
|
|
167
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
168
|
+
return { valid: false, fieldErrors: [{ field: "body", reason: "must be an object" }] };
|
|
190
169
|
const fieldsById = new Map(definition.fields.map((f) => [f.id, f]));
|
|
191
170
|
const fieldErrors = unregisteredKeyErrors(body, fieldsById);
|
|
192
171
|
const data = {};
|
|
172
|
+
const files = {};
|
|
193
173
|
for (const field of definition.fields) {
|
|
194
|
-
const resolved =
|
|
174
|
+
const resolved = resolveFormField({ field, value: Object.hasOwn(body, field.id) ? body[field.id] : undefined }, optional);
|
|
195
175
|
if (resolved.kind === "rejected") {
|
|
196
176
|
fieldErrors.push({ field: field.id, reason: resolved.reason });
|
|
197
177
|
}
|
|
198
178
|
else if (resolved.kind === "accepted") {
|
|
199
179
|
data[field.id] = resolved.value;
|
|
200
180
|
}
|
|
181
|
+
else if (resolved.kind === "upload") {
|
|
182
|
+
files[field.id] = resolved.file;
|
|
183
|
+
}
|
|
201
184
|
}
|
|
202
185
|
if (fieldErrors.length > 0)
|
|
203
186
|
return { valid: false, fieldErrors };
|
|
204
|
-
return { valid: true, data };
|
|
187
|
+
return Object.keys(files).length ? { valid: true, data, files } : { valid: true, data };
|
|
205
188
|
}
|
|
206
189
|
// Only a non-empty trimmed string trips the trap; absent, non-string or whitespace-only values
|
|
207
190
|
// do not reject an otherwise valid submission.
|
|
208
|
-
export function isHoneypotTripped(input) {
|
|
191
|
+
export function isHoneypotTripped(input, _optional = {}) {
|
|
209
192
|
if (typeof input.hp !== "string")
|
|
210
193
|
return false;
|
|
211
194
|
return input.hp.trim().length > 0;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Prototype belongs in @jini-ai/cms/forms later; its published release lacks HTML authoring. */
|
|
4
4
|
import { parseFragment, serialize } from "parse5";
|
|
5
5
|
import { FormFieldValidationError } from "../errors.js";
|
|
6
|
+
import { FIELD_TYPE_REGISTRY } from "../field-registry.js";
|
|
6
7
|
export { decodeFormFields, encodeFormFields } from "../fields-json.js";
|
|
7
8
|
function invalid(reason) {
|
|
8
9
|
throw new FormFieldValidationError({ message: reason, fieldErrors: [{ field: "html", reason }] });
|
|
@@ -37,6 +38,18 @@ function labelText(node) {
|
|
|
37
38
|
}
|
|
38
39
|
return text.trim();
|
|
39
40
|
}
|
|
41
|
+
/** Read only the nearest fieldset's direct legend; blank/missing text keeps control-label fallback.
|
|
42
|
+
* No I/O or mutation; O(d + c + t) time for ancestor depth, fieldset children and legend text,
|
|
43
|
+
* with labelText's O(t) traversal space. */
|
|
44
|
+
function groupLabel({ element }, _optional = {}) {
|
|
45
|
+
for (let parent = element.parentNode; parent && "tagName" in parent; parent = parent.parentNode) {
|
|
46
|
+
if (parent.tagName !== "fieldset")
|
|
47
|
+
continue;
|
|
48
|
+
const legend = parent.childNodes.find((child) => "tagName" in child && child.tagName === "legend");
|
|
49
|
+
return legend ? labelText(legend) || undefined : undefined;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
40
53
|
/** Keep the author's required-attribute spelling while retaining parser normalization that
|
|
41
54
|
* discards dangling outer form closers. Both attribute ranges come from parse5, not a regex
|
|
42
55
|
* that could mistake a script string for a control. No attribute value is synthesized here. */
|
|
@@ -97,14 +110,23 @@ export function deriveHtmlForm({ html }, _optional = {}) {
|
|
|
97
110
|
element.attrs = element.attrs.filter((attribute) => attribute.name !== "name");
|
|
98
111
|
continue;
|
|
99
112
|
}
|
|
100
|
-
if (element.tagName === "input" &&
|
|
101
|
-
invalid(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
113
|
+
if (element.tagName === "input" && inputType === "password")
|
|
114
|
+
invalid("Password fields are not supported");
|
|
115
|
+
const existing = fields.get(name);
|
|
116
|
+
if (existing) {
|
|
117
|
+
// Radio/checkbox controls share a successful-control name by design; collect their allowed
|
|
118
|
+
// values rather than admitting arbitrary strings as the old HTML compatibility view did.
|
|
119
|
+
if (inputType === "radio" && existing.type === "radio" || inputType === "checkbox" && ["checkbox", "checkbox-group"].includes(existing.type)) {
|
|
120
|
+
const value = attr(element, "value") ?? "on";
|
|
121
|
+
if (existing.type === "checkbox") {
|
|
122
|
+
existing.type = "checkbox-group";
|
|
123
|
+
existing.options = [{ value: existing.checkboxValue ?? "on", label: existing.label }];
|
|
124
|
+
// Seed option one before replacing its standalone label with the group's legend.
|
|
125
|
+
existing.label = groupLabel({ element }) ?? existing.label;
|
|
126
|
+
delete existing.checkboxValue;
|
|
127
|
+
}
|
|
128
|
+
existing.options.push({ value, label: element.parentNode && "tagName" in element.parentNode && element.parentNode.tagName === "label" ? labelText(element.parentNode) || value : value });
|
|
129
|
+
existing.required = existing.required || attr(element, "required") !== undefined;
|
|
108
130
|
continue;
|
|
109
131
|
}
|
|
110
132
|
invalid(`Duplicate field name '${name}'`);
|
|
@@ -117,14 +139,42 @@ export function deriveHtmlForm({ html }, _optional = {}) {
|
|
|
117
139
|
? element.parentNode : undefined;
|
|
118
140
|
const label = labels.find((item) => id !== undefined && attr(item, "for") === id) ?? parentLabel;
|
|
119
141
|
const maxLength = attr(element, "maxlength");
|
|
120
|
-
|
|
142
|
+
const type = element.tagName === "select" ? (attr(element, "multiple") !== undefined ? "multi-select" : "select")
|
|
143
|
+
: element.tagName === "textarea" ? "textarea"
|
|
144
|
+
: inputType === "checkbox" && attr(element, "role") === "switch" ? "toggle"
|
|
145
|
+
: Object.entries(FIELD_TYPE_REGISTRY).find(([, kind]) => kind.control === "input" && kind.htmlType === inputType)?.[0] ?? (inputType === "radio" ? "radio" : "text");
|
|
146
|
+
const choices = element.tagName === "select" ? elements(element.childNodes).filter((child) => child.tagName === "option").map((option) => ({ value: attr(option, "value") ?? labelText(option), label: labelText(option) })).filter((option) => option.value !== "")
|
|
147
|
+
: inputType === "radio" ? [{ value: attr(element, "value") ?? "on", label: label ? labelText(label) || name : attr(element, "value") ?? "on" }] : undefined;
|
|
148
|
+
const numeric = type === "number" || type === "range";
|
|
149
|
+
const descriptor = {
|
|
121
150
|
id: name,
|
|
122
|
-
label: label ? labelText(label) || name : attr(element, "aria-label") || name,
|
|
123
|
-
type
|
|
151
|
+
label: (type === "radio" ? groupLabel({ element }) : undefined) ?? (label ? labelText(label) || name : attr(element, "aria-label") || name),
|
|
152
|
+
type,
|
|
124
153
|
required: attr(element, "required") !== undefined,
|
|
125
|
-
...(checkboxValue !== undefined ? { checkboxValue } : {}),
|
|
154
|
+
...(checkboxValue !== undefined && type === "checkbox" ? { checkboxValue } : {}),
|
|
126
155
|
...(maxLength !== undefined ? { maxLength: Number(maxLength) } : {}),
|
|
127
|
-
|
|
156
|
+
...(choices ? { options: choices } : {}),
|
|
157
|
+
};
|
|
158
|
+
for (const key of ["placeholder", "pattern"]) {
|
|
159
|
+
const value = attr(element, key);
|
|
160
|
+
if (value !== undefined)
|
|
161
|
+
descriptor[key] = value;
|
|
162
|
+
}
|
|
163
|
+
for (const key of ["min", "max"]) {
|
|
164
|
+
const value = attr(element, key);
|
|
165
|
+
if (value !== undefined)
|
|
166
|
+
descriptor[key] = numeric ? Number(value) : value;
|
|
167
|
+
}
|
|
168
|
+
const minLength = attr(element, "minlength");
|
|
169
|
+
if (minLength !== undefined)
|
|
170
|
+
descriptor.minLength = Number(minLength);
|
|
171
|
+
const step = attr(element, "step");
|
|
172
|
+
if (step !== undefined)
|
|
173
|
+
descriptor.step = Number(step);
|
|
174
|
+
const accept = attr(element, "accept");
|
|
175
|
+
if (accept !== undefined && type === "file")
|
|
176
|
+
descriptor.accept = accept.split(",").map((item) => item.trim());
|
|
177
|
+
fields.set(name, descriptor);
|
|
128
178
|
}
|
|
129
179
|
// Re-serialization also discards ignored/dangling </form> tokens: they must never close our
|
|
130
180
|
// server-owned wrapper early. Raw-text script contents are preserved by the HTML serializer.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { FormDefinitionRecord } from "../index.js";
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
export declare function htmlSubmissionDefinition({ definition
|
|
2
|
+
/** Earlier published releases accepted boolean checkbox answers only, so their HTML view
|
|
3
|
+
* presented authored strings as text. The field registry now natively validates checkboxValue,
|
|
4
|
+
* choice options and file types. Keep this public adapter as identity for host compatibility:
|
|
5
|
+
* changing a choice to text here would bypass the server's declared-value allowlist. */
|
|
6
|
+
export declare function htmlSubmissionDefinition({ definition }: {
|
|
7
7
|
definition: FormDefinitionRecord;
|
|
8
8
|
body: Record<string, unknown>;
|
|
9
9
|
}, _optional?: {}): FormDefinitionRecord;
|
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
export function htmlSubmissionDefinition({ definition
|
|
6
|
-
|
|
7
|
-
return definition;
|
|
8
|
-
return { ...definition, fields: definition.fields.map((field) => {
|
|
9
|
-
const checkbox = field;
|
|
10
|
-
if (field.type !== "checkbox" || checkbox.checkboxValue === undefined || typeof body[field.id] !== "string")
|
|
11
|
-
return field;
|
|
12
|
-
return { ...field, type: "text", required: false };
|
|
13
|
-
}) };
|
|
1
|
+
/** Earlier published releases accepted boolean checkbox answers only, so their HTML view
|
|
2
|
+
* presented authored strings as text. The field registry now natively validates checkboxValue,
|
|
3
|
+
* choice options and file types. Keep this public adapter as identity for host compatibility:
|
|
4
|
+
* changing a choice to text here would bypass the server's declared-value allowlist. */
|
|
5
|
+
export function htmlSubmissionDefinition({ definition }, _optional = {}) {
|
|
6
|
+
return definition;
|
|
14
7
|
}
|
|
15
8
|
//# sourceMappingURL=html-submission-adapter.js.map
|
|
@@ -12,12 +12,19 @@ export interface FormHtmlHooks {
|
|
|
12
12
|
export declare function escapeFormHtml({ value }: {
|
|
13
13
|
value: string;
|
|
14
14
|
}, _optional?: {}): string;
|
|
15
|
+
/** Renderer hooks retain the CMS's established styling/error anchors without defining a theme. */
|
|
16
|
+
export interface FormBodyOptions {
|
|
17
|
+
submitLabel?: string;
|
|
18
|
+
idPrefix?: string;
|
|
19
|
+
fieldClass?: string;
|
|
20
|
+
errorClass?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Shared body for authored and builder forms; every field's HTML semantics come from the registry.
|
|
23
|
+
* @complexity O(f + o) for fields and their bounded option lists, no I/O. */
|
|
15
24
|
export declare function renderHtmlFormBody({ fields, hooks }: {
|
|
16
25
|
fields: readonly FieldDescriptor[];
|
|
17
26
|
hooks: Pick<FormHtmlHooks, "field">;
|
|
18
|
-
},
|
|
19
|
-
submitLabel?: string;
|
|
20
|
-
}): string;
|
|
27
|
+
}, options?: FormBodyOptions): string;
|
|
21
28
|
/** `className` is the host's styling hook (the host passes its form CSS class so the active theme's form rules
|
|
22
29
|
* apply); author CSS stays free to override it. Whitespace-separated, de-duplicated, never replaced. */
|
|
23
30
|
export declare function renderHtmlForm({ slug, fields, action, hooks }: {
|
|
@@ -1,18 +1,86 @@
|
|
|
1
|
+
import { FIELD_TYPE_REGISTRY, isSafeFieldPattern } from "./field-registry.js";
|
|
2
|
+
import { ATTRIBUTE_NAME_PATTERN } from "./forms.js";
|
|
1
3
|
export function escapeFormHtml({ value }, _optional = {}) {
|
|
2
4
|
return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]);
|
|
3
5
|
}
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
const attr = (name, value) => ` ${name}="${escapeFormHtml({ value: String(value) })}"`;
|
|
7
|
+
/** Extra attributes cannot override structural or server-backed constraints. Values are escaped at this sink. */
|
|
8
|
+
function extraAttributes(field, claimed) {
|
|
9
|
+
return Object.entries(field.attributes ?? {}).filter(([name, value]) => ATTRIBUTE_NAME_PATTERN.test(name) && !claimed.has(name) && typeof value === "string" && (name !== "pattern" || isSafeFieldPattern({ pattern: value }))).map(([name, value]) => attr(name, value)).join("");
|
|
10
|
+
}
|
|
11
|
+
/** One registry control rendered with label/legend, help/error associations and escaped defaults. */
|
|
12
|
+
function renderField(field, hooks, options) {
|
|
13
|
+
if (!field || typeof field.id !== "string" || typeof field.label !== "string" || !Object.hasOwn(FIELD_TYPE_REGISTRY, field.type))
|
|
14
|
+
return "";
|
|
15
|
+
const kind = FIELD_TYPE_REGISTRY[field.type];
|
|
16
|
+
const id = `${options.idPrefix ?? "form"}-${field.id}`;
|
|
17
|
+
const helpId = `${id}-help`;
|
|
18
|
+
const errorId = `${id}-error`;
|
|
19
|
+
const label = escapeFormHtml({ value: field.label });
|
|
20
|
+
const describedBy = [field.helpText ? helpId : "", errorId, field.attributes?.["aria-describedby"] ?? ""].filter(Boolean).join(" ");
|
|
21
|
+
const required = field.required ? " required" : "";
|
|
22
|
+
const base = attr("name", field.name ?? field.id) + attr(hooks.field, field.id) + attr("toolparamtitle", field.label) + attr("toolparamdescription", field.helpText ?? field.label) + attr("aria-describedby", describedBy);
|
|
23
|
+
const constraints = [
|
|
24
|
+
["placeholder", field.placeholder], ["minlength", field.minLength], ["maxlength", field.maxLength], ["pattern", field.pattern],
|
|
25
|
+
["min", field.min], ["max", field.max], ["step", field.step],
|
|
26
|
+
];
|
|
27
|
+
const claimed = new Set(["aria-describedby"]);
|
|
28
|
+
let extra = field.className ? attr("class", field.className) : "";
|
|
29
|
+
for (const [name, value] of constraints) {
|
|
30
|
+
if (value == null)
|
|
31
|
+
continue;
|
|
32
|
+
if (name === "maxlength" && (!Number.isInteger(value) || Number(value) <= 0))
|
|
33
|
+
continue;
|
|
34
|
+
if (name === "minlength" && (!Number.isInteger(value) || Number(value) < 0))
|
|
35
|
+
continue;
|
|
36
|
+
if (name === "pattern" && !isSafeFieldPattern({ pattern: String(value) }))
|
|
37
|
+
continue;
|
|
38
|
+
extra += attr(name, value);
|
|
39
|
+
claimed.add(name);
|
|
40
|
+
}
|
|
41
|
+
extra += extraAttributes(field, claimed);
|
|
42
|
+
const help = field.helpText ? `<div${attr("id", helpId)} class="widget-form-field-help">${escapeFormHtml({ value: field.helpText })}</div>` : "";
|
|
43
|
+
const error = `<div${attr("class", options.errorClass ?? "widget-form-field-error")}${attr("data-field", field.id)}${attr("id", errorId)} hidden></div>`;
|
|
44
|
+
const defaultValues = Array.isArray(field.default) ? field.default : [field.default];
|
|
45
|
+
let control;
|
|
46
|
+
if (kind.control === "group") {
|
|
47
|
+
const inputs = (field.options ?? []).map((option, index) => {
|
|
48
|
+
const optionId = `${id}-${index}`;
|
|
49
|
+
const checked = defaultValues.includes(option.value) ? " checked" : "";
|
|
50
|
+
// Native checkbox-group required on each box means ALL, so server enforces one-or-more;
|
|
51
|
+
// aria-required communicates the group's requirement without changing that invariant.
|
|
52
|
+
const optionRequired = kind.multiple ? "" : required;
|
|
53
|
+
return `<label${attr("for", optionId)}><input${attr("id", optionId)}${attr("type", kind.htmlType)}${base}${attr("value", option.value)}${checked}${optionRequired}${extra}>${escapeFormHtml({ value: option.label })}</label>`;
|
|
54
|
+
}).join("");
|
|
55
|
+
return `<fieldset${attr("id", id)}${attr("class", options.fieldClass ?? "widget-form-field")}${attr("aria-describedby", describedBy)}${field.required ? ' aria-required="true"' : ""}><legend>${label}${field.required ? " *" : ""}</legend>${inputs}${help}${error}</fieldset>`;
|
|
56
|
+
}
|
|
57
|
+
if (kind.control === "select") {
|
|
58
|
+
const placeholder = kind.multiple ? "" : `<option value="">${escapeFormHtml({ value: field.placeholder ?? "Choose an option" })}</option>`;
|
|
59
|
+
const choices = (field.options ?? []).map((option) => `<option${attr("value", option.value)}${defaultValues.includes(option.value) ? " selected" : ""}>${escapeFormHtml({ value: option.label })}</option>`).join("");
|
|
60
|
+
control = `<select${attr("id", id)}${base}${kind.multiple ? " multiple" : ""}${required}${extra}>${placeholder}${choices}</select>`;
|
|
61
|
+
}
|
|
62
|
+
else if (kind.control === "textarea") {
|
|
63
|
+
control = `<textarea${attr("id", id)}${base}${required}${extra}>${escapeFormHtml({ value: String(field.default ?? "") })}</textarea>`;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
let value = field.default !== undefined ? attr("value", String(field.default)) : "";
|
|
67
|
+
if (kind.htmlType === "checkbox")
|
|
68
|
+
value = (field.checkboxValue !== undefined ? attr("value", field.checkboxValue) : "") + (field.default === true || field.default === field.checkboxValue && field.default !== undefined ? " checked" : "");
|
|
69
|
+
if (field.type === "file")
|
|
70
|
+
value = field.accept ? attr("accept", field.accept.join(",")) : "";
|
|
71
|
+
control = `<input${attr("type", kind.htmlType)}${attr("id", id)}${base}${field.type === "toggle" ? ' role="switch"' : ""}${required}${value}${extra}>`;
|
|
72
|
+
}
|
|
73
|
+
if (field.type === "hidden")
|
|
74
|
+
return control;
|
|
75
|
+
// The checkbox modifier lets the baseline put the box before its label on one line (owner report
|
|
76
|
+
// 2026-10-05: stacked and stretched like a text input, its glyph drew centred under the label).
|
|
77
|
+
const fieldClass = (options.fieldClass ?? "widget-form-field") + (kind.htmlType === "checkbox" ? " widget-form-field-checkbox" : "");
|
|
78
|
+
return `<div${attr("class", fieldClass)}><label${attr("for", id)}>${label}${field.required ? " *" : ""}</label>${control}${help}${error}</div>`;
|
|
79
|
+
}
|
|
80
|
+
/** Shared body for authored and builder forms; every field's HTML semantics come from the registry.
|
|
81
|
+
* @complexity O(f + o) for fields and their bounded option lists, no I/O. */
|
|
82
|
+
export function renderHtmlFormBody({ fields, hooks }, options = {}) {
|
|
83
|
+
return fields.map((field) => renderField(field, hooks, options)).join("\n") + `\n<button type="submit">${escapeFormHtml({ value: options.submitLabel ?? "Send" })}</button>`;
|
|
16
84
|
}
|
|
17
85
|
/** `className` is the host's styling hook (the host passes its form CSS class so the active theme's form rules
|
|
18
86
|
* apply); author CSS stays free to override it. Whitespace-separated, de-duplicated, never replaced. */
|
|
@@ -22,10 +90,44 @@ export function renderHtmlForm({ slug, fields, action, hooks }, { html, successM
|
|
|
22
90
|
const classes = [...new Set((className ?? "").split(/\s+/).filter(Boolean))].join(" ");
|
|
23
91
|
const classAttr = classes ? `class="${escapeFormHtml({ value: classes })}" ` : "";
|
|
24
92
|
return `<div ${hooks.success} ${slugHook} role="status" hidden>${escapeFormHtml({ value: successMessage })}</div>` +
|
|
25
|
-
`<form ${classAttr}${hooks.form}="${escapedSlug}" ${slugHook} toolname="form_${escapeFormHtml({ value: slug.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 123) })}" tooldescription="${escapedSlug}" method="post" action="${escapeFormHtml({ value: action })}">` +
|
|
93
|
+
`<form ${classAttr}${hooks.form}="${escapedSlug}" ${slugHook} toolname="form_${escapeFormHtml({ value: slug.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 123) })}" tooldescription="${escapedSlug}" method="post"${fields.some((field) => field.type === "file") ? ' enctype="multipart/form-data"' : ""} action="${escapeFormHtml({ value: action })}">` +
|
|
26
94
|
`<div ${hooks.error} ${slugHook} role="alert" hidden></div>` +
|
|
27
|
-
(html ?? renderHtmlFormBody({ fields, hooks })) +
|
|
95
|
+
(html ?? renderHtmlFormBody({ fields, hooks }, { idPrefix: `form-${slug}` })) +
|
|
28
96
|
// Hidden container requires neither a theme class nor inline CSS; bots still see the input.
|
|
29
97
|
`<div hidden aria-hidden="true"><input type="text" name="_hp" tabindex="-1" autocomplete="off"></div></form>`;
|
|
30
98
|
}
|
|
99
|
+
/* Migration rationale retained from the host renderer:
|
|
100
|
+
|
|
101
|
+
* Renders one field descriptor's `class`/extra-attribute string, both attached to the SAME
|
|
102
|
+
* rendered input element the field's own `id`/`name`/`required`/`type` already get. Re-checks
|
|
103
|
+
* `ATTRIBUTE_NAME_PATTERN` here rather than trusting that every stored field passed through
|
|
104
|
+
* `forms.ts`'s `validateFieldDescriptors` (this is the public render path, REQ-37/ADR-047) —
|
|
105
|
+
* defense in depth, since an attribute NAME is not something `escapeHtml` can make safe the way it
|
|
106
|
+
* can a value (`onclick` is structurally dangerous regardless of how its own text is escaped).
|
|
107
|
+
* `className` carries no such risk once escaped, so it is not re-validated, only escaped.
|
|
108
|
+
|
|
109
|
+
One `<textarea>`/checkbox/text-or-email `<input>` for a contact-form field descriptor — pulled out
|
|
110
|
+
* of {@link renderField}'s old three-way nested ternary so each shape is a flat, separately
|
|
111
|
+
* readable branch rather than one expression whose nesting depth drove the field-renderer's cognitive
|
|
112
|
+
* complexity. `requiredAttr` is computed once and reused across all three shapes, same value the old
|
|
113
|
+
* nested ternary recomputed per-branch. `describedByAttr` wires the field to its own (initially empty
|
|
114
|
+
* and hidden) error slot for assistive tech — see {@link renderField}'s own doc for why
|
|
115
|
+
* that slot exists even though nothing fills it in on the very first render.
|
|
116
|
+
The field's own `maxLength` as a `maxlength` attribute, so the browser stops input at the same
|
|
117
|
+
* limit `forms.ts`'s `validateSubmissionPayload` enforces (`too_long`) instead of letting a visitor
|
|
118
|
+
* type past it and lose the submission to a server rejection. Only a positive integer renders —
|
|
119
|
+
* the value comes from stored JSON, so anything else is dropped rather than interpolated.
|
|
120
|
+
* @complexity O(1).
|
|
121
|
+
One contact-form field descriptor -> its `<div class="widget-form-field">` markup — extracted from
|
|
122
|
+
* {@link renderWidgetContactForm}'s old inline `.map()` callback so the field's own render logic is a
|
|
123
|
+
* named, independently measured function rather than an anonymous closure.
|
|
124
|
+
*
|
|
125
|
+
* Carries its own `<div class="widget-form-field-error" data-field="ID" hidden></div>` slot, hidden
|
|
126
|
+
* and empty on every ordinary render — `renderWidgetContactForm` has no request in scope (this is a
|
|
127
|
+
* pure prop-to-HTML function, no I/O), so it cannot know at render time whether THIS submission
|
|
128
|
+
* failed validation on THIS field. The slot exists purely as a stable, pre-built anchor
|
|
129
|
+
* {@link injectFormSubmissionResultIntoHtml} fills in and un-hides after the fact, on the
|
|
130
|
+
* Post/Redirect/Get reload that follows a failed submission (see that function's own doc for the
|
|
131
|
+
* full mechanism).
|
|
132
|
+
*/
|
|
31
133
|
//# sourceMappingURL=html-render.js.map
|
package/dist/forms/index.d.ts
CHANGED
package/dist/forms/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { FormSlugConflictError } from "./errors.js";
|
|
|
4
4
|
function cloneStored(row) {
|
|
5
5
|
return {
|
|
6
6
|
...row,
|
|
7
|
-
fields: row.fields
|
|
7
|
+
fields: structuredClone(row.fields),
|
|
8
8
|
notify: { ...row.notify, recipients: [...row.notify.recipients] },
|
|
9
9
|
};
|
|
10
10
|
}
|
|
@@ -95,12 +95,12 @@ export class InMemoryFormDefinitionRepo {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
function cloneSubmission(row) {
|
|
98
|
-
return { ...row, data:
|
|
98
|
+
return { ...row, data: structuredClone(row.data) };
|
|
99
99
|
}
|
|
100
100
|
/** The port's view of a stored row: the Trash columns stay inside this adapter. */
|
|
101
101
|
function toSubmissionRecord(row) {
|
|
102
102
|
const { deletedAt: _deletedAt, version: _version, ...record } = row;
|
|
103
|
-
return { ...record, data:
|
|
103
|
+
return { ...record, data: structuredClone(record.data) };
|
|
104
104
|
}
|
|
105
105
|
export class InMemoryFormSubmissionRepo {
|
|
106
106
|
rows = new Map();
|
|
@@ -113,7 +113,7 @@ export class InMemoryFormSubmissionRepo {
|
|
|
113
113
|
return toSubmissionRecord(row);
|
|
114
114
|
}
|
|
115
115
|
async create(record) {
|
|
116
|
-
this.rows.set(record.id, { ...record, data:
|
|
116
|
+
this.rows.set(record.id, { ...record, data: structuredClone(record.data), deletedAt: null, version: 1 });
|
|
117
117
|
}
|
|
118
118
|
/** Rolls this repo's rows back when `work` throws, as the SQL adapter's transaction does. Calls
|
|
119
119
|
* take turns (as the SQLite kernel's do) so one rollback cannot erase another call's row; a
|
|
@@ -49,7 +49,7 @@ export interface SubmissionAttempts {
|
|
|
49
49
|
* The key one attempt is remembered under: a SHA-256 of workspace, slug, source IP, attempt token
|
|
50
50
|
* (null when the request carried none) and the body. Body keys are sorted so their order cannot
|
|
51
51
|
* change the key. Held only in memory, only for the window.
|
|
52
|
-
* @complexity O(b log b) for b body keys.
|
|
52
|
+
* @complexity O(b log b + n) for b body keys and n uploaded bytes, hashed in 8 KiB chunks.
|
|
53
53
|
*/
|
|
54
54
|
export declare function submissionAttemptKey({ input, attemptToken, hash }: {
|
|
55
55
|
input: SubmitFormInput;
|
|
@@ -10,11 +10,22 @@ export const DUPLICATE_ATTEMPT = Symbol("duplicate form submission attempt");
|
|
|
10
10
|
* The key one attempt is remembered under: a SHA-256 of workspace, slug, source IP, attempt token
|
|
11
11
|
* (null when the request carried none) and the body. Body keys are sorted so their order cannot
|
|
12
12
|
* change the key. Held only in memory, only for the window.
|
|
13
|
-
* @complexity O(b log b) for b body keys.
|
|
13
|
+
* @complexity O(b log b + n) for b body keys and n uploaded bytes, hashed in 8 KiB chunks.
|
|
14
14
|
*/
|
|
15
15
|
export function submissionAttemptKey({ input, attemptToken, hash }, _optional = {}) {
|
|
16
16
|
// Object keys are unique, so the comparator never sees a tie.
|
|
17
|
-
const body = Object.entries(input.body).
|
|
17
|
+
const body = Object.entries(input.body).map(([key, value]) => {
|
|
18
|
+
if (!value || typeof value !== "object" || !("bytes" in value) || !(value.bytes instanceof Uint8Array))
|
|
19
|
+
return [key, value];
|
|
20
|
+
// Hash bounded chunks rather than JSON-encoding millions of Uint8Array index properties.
|
|
21
|
+
// Chunk order and byte length remain bound to the digest; no uploaded bytes are remembered.
|
|
22
|
+
const digests = [];
|
|
23
|
+
for (let offset = 0; offset < value.bytes.byteLength; offset += 8192) {
|
|
24
|
+
digests.push(hash(String.fromCharCode(...value.bytes.subarray(offset, offset + 8192))));
|
|
25
|
+
}
|
|
26
|
+
const { bytes, ...metadata } = value;
|
|
27
|
+
return [key, { ...metadata, byteLength: bytes.byteLength, bytesHash: hash(JSON.stringify(digests)) }];
|
|
28
|
+
}).sort(([a], [b]) => (a < b ? -1 : 1));
|
|
18
29
|
return hash(JSON.stringify([input.workspaceId, input.slug, input.sourceIp, attemptToken ?? null, body]));
|
|
19
30
|
}
|
|
20
31
|
/**
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { FormSubmissionValidationError } from "./errors.js";
|
|
2
2
|
import { isHoneypotTripped, validateSubmissionPayload } from "./forms.js";
|
|
3
|
-
|
|
4
|
-
// Do not trim or rewrite answers: Jini's payload contract preserves submitted strings.
|
|
5
|
-
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])?)*$/;
|
|
3
|
+
import { isValidFormEmail } from "./email-format.js";
|
|
6
4
|
/** Compatibility guard until Jini validates email fields itself. Lookup remains the first step;
|
|
7
5
|
* disabled forms and honeypots retain the package's response before any validation or writes. */
|
|
8
6
|
export function assertSubmissionEmails({ definition, body }, _optional = {}) {
|
|
@@ -17,7 +15,7 @@ export function assertSubmissionEmails({ definition, body }, _optional = {}) {
|
|
|
17
15
|
if (existing.some((error) => error.field === field.id))
|
|
18
16
|
return false;
|
|
19
17
|
// JS's $ can match before a final newline; comparing the full match refuses that bypass.
|
|
20
|
-
return
|
|
18
|
+
return !isValidFormEmail({ value });
|
|
21
19
|
}).map((field) => field.id));
|
|
22
20
|
if (invalid.size === 0)
|
|
23
21
|
return;
|
|
@@ -27,6 +27,7 @@ import type { Clock, IdGenerator, UUID } from "@jini-ai/core/primitives";
|
|
|
27
27
|
import type { FormDefinitionRepoPort, FormSubmissionRepoPort } from "./ports.js";
|
|
28
28
|
import type { FormDefinitionViewPort } from "./ports.js";
|
|
29
29
|
import { type SubmissionAttempts } from "./submission-attempts.js";
|
|
30
|
+
import type { FormFileStoragePort } from "./types.js";
|
|
30
31
|
export interface SubmitFormDeps {
|
|
31
32
|
definitionRepo: FormDefinitionRepoPort;
|
|
32
33
|
submissionRepo: FormSubmissionRepoPort;
|
|
@@ -36,6 +37,8 @@ export interface SubmitFormDeps {
|
|
|
36
37
|
clock: Clock;
|
|
37
38
|
idGen: IdGenerator;
|
|
38
39
|
rateLimiter: RateLimiterPort;
|
|
40
|
+
/** Anonymous uploads go through the host media owner only after validation/rate/dedupe. */
|
|
41
|
+
files?: FormFileStoragePort;
|
|
39
42
|
/** Omitted: the two writes run back to back with no rollback between them. */
|
|
40
43
|
transaction?: SubmissionTransactionPort;
|
|
41
44
|
}
|