@jini-ai/cms 0.5.3 → 0.5.4

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 (58) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/forms/delete-submission.d.ts +38 -0
  3. package/dist/forms/delete-submission.js +25 -0
  4. package/dist/forms/duplicate-slug.d.ts +81 -0
  5. package/dist/forms/duplicate-slug.js +115 -0
  6. package/dist/forms/errors.d.ts +53 -0
  7. package/dist/forms/errors.js +25 -0
  8. package/dist/forms/express/handler.d.ts +59 -0
  9. package/dist/forms/express/handler.js +217 -0
  10. package/dist/forms/express/index.d.ts +2 -0
  11. package/dist/forms/express/index.js +2 -0
  12. package/dist/forms/fields-json.d.ts +14 -0
  13. package/dist/forms/fields-json.js +11 -0
  14. package/dist/forms/forms.d.ts +59 -0
  15. package/dist/forms/forms.js +213 -0
  16. package/dist/forms/html/authoring-adapter.d.ts +34 -0
  17. package/dist/forms/html/authoring-adapter.js +48 -0
  18. package/dist/forms/html/html-authoring.d.ts +12 -0
  19. package/dist/forms/html/html-authoring.js +133 -0
  20. package/dist/forms/html/html-submission-adapter.d.ts +10 -0
  21. package/dist/forms/html/html-submission-adapter.js +15 -0
  22. package/dist/forms/html/index.d.ts +4 -0
  23. package/dist/forms/html/index.js +4 -0
  24. package/dist/forms/html-render.d.ts +33 -0
  25. package/dist/forms/html-render.js +31 -0
  26. package/dist/forms/index.d.ts +17 -0
  27. package/dist/forms/index.js +17 -0
  28. package/dist/forms/notify-subscriber.d.ts +32 -0
  29. package/dist/forms/notify-subscriber.js +60 -0
  30. package/dist/forms/ports.d.ts +168 -0
  31. package/dist/forms/ports.js +2 -0
  32. package/dist/forms/rate-limit-key.d.ts +8 -0
  33. package/dist/forms/rate-limit-key.js +7 -0
  34. package/dist/forms/repo.memory.d.ts +120 -0
  35. package/dist/forms/repo.memory.js +188 -0
  36. package/dist/forms/sql/index.d.ts +5 -0
  37. package/dist/forms/sql/index.js +5 -0
  38. package/dist/forms/sql/repo.d.ts +94 -0
  39. package/dist/forms/sql/repo.js +175 -0
  40. package/dist/forms/sql/repo.rows.d.ts +33 -0
  41. package/dist/forms/sql/repo.rows.js +64 -0
  42. package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
  43. package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
  44. package/dist/forms/sql/tables.d.ts +32 -0
  45. package/dist/forms/sql/tables.js +2 -0
  46. package/dist/forms/submission-attempts.d.ts +70 -0
  47. package/dist/forms/submission-attempts.js +70 -0
  48. package/dist/forms/submission-email-validation.d.ts +8 -0
  49. package/dist/forms/submission-email-validation.js +34 -0
  50. package/dist/forms/submission-ip-retention.d.ts +30 -0
  51. package/dist/forms/submission-ip-retention.js +27 -0
  52. package/dist/forms/submit-service.d.ts +78 -0
  53. package/dist/forms/submit-service.js +109 -0
  54. package/dist/forms/types.d.ts +49 -0
  55. package/dist/forms/types.js +2 -0
  56. package/dist/forms/write-service.d.ts +83 -0
  57. package/dist/forms/write-service.js +271 -0
  58. package/package.json +3 -4
@@ -0,0 +1,11 @@
1
+ /** Old rows remain arrays; authored forms use the same JSON-in-text column, without a migration. */
2
+ export function decodeFormFields({ json }, _optional = {}) {
3
+ const stored = JSON.parse(json);
4
+ return Array.isArray(stored) ? { fields: stored } : { fields: stored.fields, mode: stored.mode, html: stored.html };
5
+ }
6
+ export function encodeFormFields({ definition }, _optional = {}) {
7
+ return JSON.stringify(definition.mode === "html"
8
+ ? { fields: definition.fields, mode: "html", html: definition.html ?? "" }
9
+ : definition.fields);
10
+ }
11
+ //# sourceMappingURL=fields-json.js.map
@@ -0,0 +1,59 @@
1
+ /** Pure field/submission validation and honeypot decisions. Error order and stored value normalization are stable. */
2
+ import type { FieldDescriptor, FormDefinitionRecord } from "./types.js";
3
+ export declare const MAX_FIELDS = 20;
4
+ export declare const MAX_LABEL_LENGTH = 200;
5
+ export declare const MIN_MAX_LENGTH = 1;
6
+ export declare const MAX_MAX_LENGTH = 5000;
7
+ export declare const FIELD_ID_PATTERN: RegExp;
8
+ export declare const FIELD_TYPES: Set<string>;
9
+ export declare const MAX_CLASS_NAME_LENGTH = 300;
10
+ export declare const MAX_ATTRIBUTES_PER_FIELD = 12;
11
+ export declare const MAX_ATTRIBUTE_VALUE_LENGTH = 300;
12
+ /**
13
+ * Escaping makes an attribute value inert, but cannot make a name such as onclick safe. Names
14
+ * are deny-by-default: only aria-* and data-* namespaces and a fixed closed list are accepted.
15
+ * Event handlers, style/formaction/href/src/srcdoc can inject behavior; id/name/type overrides
16
+ * can break label association, submission keys or the renderer's declared field vocabulary.
17
+ */
18
+ export declare const ATTRIBUTE_NAME_PATTERN: RegExp;
19
+ export declare const HONEYPOT_KEY = "_hp";
20
+ export interface FieldError {
21
+ field: string;
22
+ reason: string;
23
+ }
24
+ export type FieldValidationResult = {
25
+ valid: true;
26
+ } | {
27
+ valid: false;
28
+ fieldErrors: FieldError[];
29
+ };
30
+ /**
31
+ * Collect every violation, not just the first: arity errors, then descriptors in array order,
32
+ * then each rule in the fixed order below. Callers display this list as-is, so order is observable.
33
+ * Empty helper results mean a satisfied rule. Bounds are 1-20 fields, labels 1-200 characters,
34
+ * and maxLength 1-5000 (never on checkboxes), with unique lowercase field IDs.
35
+ */
36
+ export declare function validateFieldDescriptors({ fields }: {
37
+ fields: FieldDescriptor[];
38
+ }): FieldValidationResult;
39
+ export interface ValidateSubmissionPayloadInput {
40
+ definition: FormDefinitionRecord;
41
+ body: Record<string, unknown>;
42
+ }
43
+ export type SubmissionValidationResult = {
44
+ valid: true;
45
+ data: Record<string, string | boolean>;
46
+ } | {
47
+ valid: false;
48
+ fieldErrors: FieldError[];
49
+ };
50
+ /**
51
+ * Only declared, accepted fields reach data; the reserved honeypot never does. Report unknown
52
+ * body keys first, then declared-field errors in definition order, independent of body key order.
53
+ * This keeps the declared-field portion of the error list stable across equivalent submissions.
54
+ */
55
+ export declare function validateSubmissionPayload(input: ValidateSubmissionPayloadInput): SubmissionValidationResult;
56
+ export declare function isHoneypotTripped(input: {
57
+ hp: unknown;
58
+ }): boolean;
59
+ //# sourceMappingURL=forms.d.ts.map
@@ -0,0 +1,213 @@
1
+ // Domain validation stays independent of feature manifests, and returns decisions without I/O
2
+ // or exceptions so write and submission services can choose their own error presentation.
3
+ // Published tool schemas and render-time defensive checks use these exported bounds/patterns
4
+ // instead of restating them; validation owns the enforcement rule.
5
+ const MIN_FIELDS = 1;
6
+ export const MAX_FIELDS = 20;
7
+ const MIN_LABEL_LENGTH = 1;
8
+ export const MAX_LABEL_LENGTH = 200;
9
+ export const MIN_MAX_LENGTH = 1;
10
+ export const MAX_MAX_LENGTH = 5000;
11
+ export const FIELD_ID_PATTERN = /^[a-z][a-z0-9_]*$/;
12
+ export const FIELD_TYPES = new Set(["text", "email", "textarea", "checkbox"]);
13
+ export const MAX_CLASS_NAME_LENGTH = 300;
14
+ export const MAX_ATTRIBUTES_PER_FIELD = 12;
15
+ export const MAX_ATTRIBUTE_VALUE_LENGTH = 300;
16
+ /**
17
+ * Escaping makes an attribute value inert, but cannot make a name such as onclick safe. Names
18
+ * are deny-by-default: only aria-* and data-* namespaces and a fixed closed list are accepted.
19
+ * Event handlers, style/formaction/href/src/srcdoc can inject behavior; id/name/type overrides
20
+ * can break label association, submission keys or the renderer's declared field vocabulary.
21
+ */
22
+ export const ATTRIBUTE_NAME_PATTERN = /^(aria-[a-z0-9-]+|data-[a-z0-9-]+|placeholder|autocomplete|inputmode|pattern|title|min|max|step|minlength|spellcheck|readonly)$/;
23
+ // Accepted as submission metadata, never as a declared field or stored answer.
24
+ export const HONEYPOT_KEY = "_hp";
25
+ // Malformed IDs are never claimed: repeated malformed IDs must each report their shape error
26
+ // rather than misleading the second field's author with a duplicate-ID diagnosis.
27
+ function fieldIdErrors(descriptor, label, claimedIds) {
28
+ if (!descriptor.id || !FIELD_ID_PATTERN.test(descriptor.id)) {
29
+ return [{ field: label, reason: "id must match ^[a-z][a-z0-9_]*$" }];
30
+ }
31
+ if (claimedIds.has(descriptor.id)) {
32
+ return [{ field: descriptor.id, reason: "duplicate field id" }];
33
+ }
34
+ claimedIds.add(descriptor.id);
35
+ return [];
36
+ }
37
+ function fieldTypeErrors(descriptor, label) {
38
+ if (!FIELD_TYPES.has(descriptor.type)) {
39
+ return [{ field: label, reason: `type '${descriptor.type}' is not in the registered vocabulary` }];
40
+ }
41
+ return [];
42
+ }
43
+ function labelLengthErrors(descriptor, label) {
44
+ if (descriptor.label.length < MIN_LABEL_LENGTH || descriptor.label.length > MAX_LABEL_LENGTH) {
45
+ return [{ field: label, reason: `label must be ${MIN_LABEL_LENGTH}-${MAX_LABEL_LENGTH} characters` }];
46
+ }
47
+ return [];
48
+ }
49
+ // A checkbox carries a boolean, so no character cap can be honored. Report that prohibition
50
+ // before any numeric range error, rather than asking the author to fix a number they must remove.
51
+ function maxLengthErrors(descriptor, label) {
52
+ if (descriptor.maxLength == null)
53
+ return [];
54
+ if (descriptor.type === "checkbox") {
55
+ return [{ field: label, reason: "maxLength is forbidden for checkbox fields" }];
56
+ }
57
+ if (descriptor.maxLength < MIN_MAX_LENGTH || descriptor.maxLength > MAX_MAX_LENGTH) {
58
+ return [{ field: label, reason: `maxLength must be ${MIN_MAX_LENGTH}-${MAX_MAX_LENGTH}` }];
59
+ }
60
+ return [];
61
+ }
62
+ function checkboxValueErrors(descriptor, label) {
63
+ if (descriptor.checkboxValue === undefined)
64
+ return [];
65
+ if (descriptor.type !== "checkbox" || typeof descriptor.checkboxValue !== "string") {
66
+ return [{ field: label, reason: "checkboxValue must be a string on a checkbox field" }];
67
+ }
68
+ return [];
69
+ }
70
+ // HTML-escaped class names are inert; only their length is bounded. Check type defensively
71
+ // because real inputs include parsed request bodies, regardless of the descriptor's static type.
72
+ function classNameErrors(descriptor, label) {
73
+ const { className } = descriptor;
74
+ if (className == null)
75
+ return [];
76
+ if (typeof className !== "string") {
77
+ return [{ field: label, reason: "className must be a string" }];
78
+ }
79
+ if (className.length > MAX_CLASS_NAME_LENGTH) {
80
+ return [{ field: label, reason: `className must be at most ${MAX_CLASS_NAME_LENGTH} characters` }];
81
+ }
82
+ return [];
83
+ }
84
+ // A refused name will never render, so its value is moot. Reporting value errors as well would
85
+ // imply that correcting a value could make a structurally unsafe attribute acceptable.
86
+ function attributeEntryErrors(label, attrName, attrValue) {
87
+ if (!ATTRIBUTE_NAME_PATTERN.test(attrName)) {
88
+ return [{ field: label, reason: `attribute '${attrName}' is not allowed` }];
89
+ }
90
+ if (typeof attrValue !== "string") {
91
+ return [{ field: label, reason: `attribute '${attrName}' value must be a string` }];
92
+ }
93
+ if (attrValue.length > MAX_ATTRIBUTE_VALUE_LENGTH) {
94
+ return [{ field: label, reason: `attribute '${attrName}' value must be at most ${MAX_ATTRIBUTE_VALUE_LENGTH} characters` }];
95
+ }
96
+ return [];
97
+ }
98
+ // Exceeding the count cap still checks every attribute so the author sees unsafe names in one
99
+ // round trip. This visits O(a) caller-supplied attributes; the reported cap does not truncate work.
100
+ function attributeErrors(descriptor, label) {
101
+ const { attributes } = descriptor;
102
+ if (attributes == null)
103
+ return [];
104
+ const entries = Object.entries(attributes);
105
+ const errors = [];
106
+ if (entries.length > MAX_ATTRIBUTES_PER_FIELD) {
107
+ errors.push({ field: label, reason: `at most ${MAX_ATTRIBUTES_PER_FIELD} attributes are allowed` });
108
+ }
109
+ for (const [attrName, attrValue] of entries) {
110
+ errors.push(...attributeEntryErrors(label, attrName, attrValue));
111
+ }
112
+ return errors;
113
+ }
114
+ /**
115
+ * Collect every violation, not just the first: arity errors, then descriptors in array order,
116
+ * then each rule in the fixed order below. Callers display this list as-is, so order is observable.
117
+ * Empty helper results mean a satisfied rule. Bounds are 1-20 fields, labels 1-200 characters,
118
+ * and maxLength 1-5000 (never on checkboxes), with unique lowercase field IDs.
119
+ */
120
+ export function validateFieldDescriptors({ fields }) {
121
+ const fieldErrors = [];
122
+ if (fields.length < MIN_FIELDS) {
123
+ fieldErrors.push({ field: "fields", reason: `at least ${MIN_FIELDS} field is required` });
124
+ }
125
+ if (fields.length > MAX_FIELDS) {
126
+ fieldErrors.push({ field: "fields", reason: `at most ${MAX_FIELDS} fields are allowed` });
127
+ }
128
+ const claimedIds = new Set();
129
+ for (const descriptor of fields) {
130
+ 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));
132
+ }
133
+ if (fieldErrors.length > 0)
134
+ return { valid: false, fieldErrors };
135
+ return { valid: true };
136
+ }
137
+ // 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
+ // Whitespace is rejected only for required text; optional whitespace is an answer the definition
150
+ // 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
+ // Native URL-encoded forms submit "on" for checked boxes without a value attribute; accepting
161
+ // only JSON booleans would reject every checked checkbox when client JavaScript is disabled.
162
+ // 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
+ // 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, including for a required checkbox.
175
+ function resolveSubmittedValue(field, value) {
176
+ if (value === undefined || value === null) {
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);
182
+ }
183
+ /**
184
+ * Only declared, accepted fields reach data; the reserved honeypot never does. Report unknown
185
+ * body keys first, then declared-field errors in definition order, independent of body key order.
186
+ * This keeps the declared-field portion of the error list stable across equivalent submissions.
187
+ */
188
+ export function validateSubmissionPayload(input) {
189
+ const { definition, body } = input;
190
+ const fieldsById = new Map(definition.fields.map((f) => [f.id, f]));
191
+ const fieldErrors = unregisteredKeyErrors(body, fieldsById);
192
+ const data = {};
193
+ for (const field of definition.fields) {
194
+ const resolved = resolveSubmittedValue(field, body[field.id]);
195
+ if (resolved.kind === "rejected") {
196
+ fieldErrors.push({ field: field.id, reason: resolved.reason });
197
+ }
198
+ else if (resolved.kind === "accepted") {
199
+ data[field.id] = resolved.value;
200
+ }
201
+ }
202
+ if (fieldErrors.length > 0)
203
+ return { valid: false, fieldErrors };
204
+ return { valid: true, data };
205
+ }
206
+ // Only a non-empty trimmed string trips the trap; absent, non-string or whitespace-only values
207
+ // do not reject an otherwise valid submission.
208
+ export function isHoneypotTripped(input) {
209
+ if (typeof input.hp !== "string")
210
+ return false;
211
+ return input.hp.trim().length > 0;
212
+ }
213
+ //# sourceMappingURL=forms.js.map
@@ -0,0 +1,34 @@
1
+ import type { FormDefinitionRepoPort } from "../ports.js";
2
+ import { type FormAuthoring, type HtmlFormDefinitionRecord } from "./html-authoring.js";
3
+ import type { FormWriteServiceDeps } from "../write-service.js";
4
+ export declare function prepareFormAuthoring({ deps, input, existing, permission }: {
5
+ deps: Pick<FormWriteServiceDeps, "authorize">;
6
+ /** Host permission for trusted, unsanitized HTML; no product policy is built in. */
7
+ permission: string;
8
+ input: FormAuthoring & {
9
+ workspaceId: string;
10
+ actor: {
11
+ id: string;
12
+ };
13
+ };
14
+ existing?: HtmlFormDefinitionRecord;
15
+ }, _optional?: {}): Promise<(FormAuthoring & {
16
+ fields?: HtmlFormDefinitionRecord["fields"];
17
+ }) | undefined>;
18
+ /** A bounded decorator, with every repository method forwarded explicitly (class methods do not
19
+ * survive object spread). HTML names may be removed: they are the current markup's allowlist. */
20
+ export declare function withFormAuthoring({ repo, authoring }: {
21
+ repo: FormDefinitionRepoPort;
22
+ authoring: FormAuthoring & {
23
+ fields?: HtmlFormDefinitionRecord["fields"];
24
+ };
25
+ }, { replaceFields }?: {
26
+ replaceFields?: boolean;
27
+ }): FormDefinitionRepoPort;
28
+ /** Bind a host's raw-HTML permission to the audited authoring port.
29
+ * @returns Prepare/decorate operations; markup and fields persist in the same mutation.
30
+ * @complexity O(1) binding; prepare inherits parser cost. */
31
+ export declare function createFormAuthoring({ permission }: {
32
+ permission: string;
33
+ }, _optional?: Record<string, never>): import("../ports.js").FormAuthoringPort;
34
+ //# sourceMappingURL=authoring-adapter.d.ts.map
@@ -0,0 +1,48 @@
1
+ /** Host adapter around Jini's audited builder writes, pending upstream HTML authoring support.
2
+ * Stores derived fields and markup in the SAME mutation; never a follow-up unaudited write. */
3
+ import { ForbiddenError } from "../../core/index.js";
4
+ import { FormFieldValidationError } from "../errors.js";
5
+ import { deriveHtmlForm } from "./html-authoring.js";
6
+ export async function prepareFormAuthoring({ deps, input, existing, permission }, _optional = {}) {
7
+ const authored = input.mode !== undefined || input.html !== undefined;
8
+ if (!authored)
9
+ return existing?.mode === "html" ? deriveHtmlForm({ html: existing.html ?? "" }) : undefined;
10
+ // Match HTML Pages' trust boundary, including writes that switch out of authored HTML.
11
+ // Check before parsing so an editor cannot use validation as a way around raw-HTML authority.
12
+ if (input.mode === "builder" && input.html === undefined && existing?.mode !== "html")
13
+ return { mode: "builder", html: undefined };
14
+ const result = await deps.authorize({ principalId: input.actor.id, permission, workspaceId: input.workspaceId });
15
+ if (!result.allowed)
16
+ throw new ForbiddenError({ message: "HTML form authoring is restricted to admins and owners", permission, reason: result.reason });
17
+ if (input.mode !== undefined && input.mode !== "html" && input.mode !== "builder") {
18
+ throw new FormFieldValidationError({ message: "Invalid form mode", fieldErrors: [{ field: "mode", reason: "must be builder or html" }] });
19
+ }
20
+ if (input.mode === "builder")
21
+ return { mode: "builder", html: undefined };
22
+ return deriveHtmlForm({ html: input.html ?? existing?.html ?? "" });
23
+ }
24
+ /** A bounded decorator, with every repository method forwarded explicitly (class methods do not
25
+ * survive object spread). HTML names may be removed: they are the current markup's allowlist. */
26
+ export function withFormAuthoring({ repo, authoring }, { replaceFields = false } = {}) {
27
+ return {
28
+ findById: async (target) => {
29
+ const record = await repo.findById(target);
30
+ return record && replaceFields ? { ...record, fields: [] } : record;
31
+ },
32
+ findBySlug: (target) => repo.findBySlug(target),
33
+ list: (target) => repo.list(target),
34
+ isSlugTaken: (target) => repo.isSlugTaken(target),
35
+ create: async (record) => { Object.assign(record, authoring); await repo.create(record); },
36
+ update: async (record) => { Object.assign(record, authoring); await repo.update(record); },
37
+ };
38
+ }
39
+ /** Bind a host's raw-HTML permission to the audited authoring port.
40
+ * @returns Prepare/decorate operations; markup and fields persist in the same mutation.
41
+ * @complexity O(1) binding; prepare inherits parser cost. */
42
+ export function createFormAuthoring({ permission }, _optional = {}) {
43
+ return {
44
+ prepare: (required, optional = {}) => prepareFormAuthoring({ ...required, permission }, optional),
45
+ with: withFormAuthoring,
46
+ };
47
+ }
48
+ //# sourceMappingURL=authoring-adapter.js.map
@@ -0,0 +1,12 @@
1
+ import type { FieldDescriptor } from "../types.js";
2
+ export { decodeFormFields, encodeFormFields, type FormAuthoring, type HtmlFormDefinitionRecord } from "../fields-json.js";
3
+ /** Derives the submission allowlist at save time and removes server-owned transport overrides.
4
+ * Parsing normalizes HTML syntax; scripts and authored styling remain unsanitized. */
5
+ export declare function deriveHtmlForm({ html }: {
6
+ html: string;
7
+ }, _optional?: Record<string, never>): {
8
+ mode: "html";
9
+ html: string;
10
+ fields: FieldDescriptor[];
11
+ };
12
+ //# sourceMappingURL=html-authoring.d.ts.map
@@ -0,0 +1,133 @@
1
+ /** HTML authoring adapter. Unsanitized, admin/owner trusted markup, like HTML Pages.
2
+ * parse5 supplies real HTML semantics (comments/scripts are never mistaken for inputs).
3
+ * Prototype belongs in @jini-ai/cms/forms later; its published release lacks HTML authoring. */
4
+ import { parseFragment, serialize } from "parse5";
5
+ import { FormFieldValidationError } from "../errors.js";
6
+ export { decodeFormFields, encodeFormFields } from "../fields-json.js";
7
+ function invalid(reason) {
8
+ throw new FormFieldValidationError({ message: reason, fieldErrors: [{ field: "html", reason }] });
9
+ }
10
+ function elements(nodes) {
11
+ const found = [];
12
+ const stack = [...nodes].reverse();
13
+ while (stack.length) {
14
+ const node = stack.pop();
15
+ if (!("tagName" in node))
16
+ continue;
17
+ found.push(node);
18
+ // Templates are inert and do not contribute successful controls.
19
+ if (node.tagName !== "template")
20
+ stack.push(...[...node.childNodes].reverse());
21
+ }
22
+ return found;
23
+ }
24
+ function attr(element, name) {
25
+ return element.attrs.find((item) => item.name === name)?.value;
26
+ }
27
+ function labelText(node) {
28
+ const stack = [...node.childNodes].reverse();
29
+ let text = "";
30
+ while (stack.length) {
31
+ const child = stack.pop();
32
+ if ("value" in child)
33
+ text += child.value;
34
+ else if ("tagName" in child && !["input", "select", "textarea", "script", "style"].includes(child.tagName)) {
35
+ stack.push(...[...child.childNodes].reverse());
36
+ }
37
+ }
38
+ return text.trim();
39
+ }
40
+ /** Keep the author's required-attribute spelling while retaining parser normalization that
41
+ * discards dangling outer form closers. Both attribute ranges come from parse5, not a regex
42
+ * that could mistake a script string for a control. No attribute value is synthesized here. */
43
+ function serializeAuthoredRequired({ fragment, html }) {
44
+ const required = elements(fragment.childNodes).filter((element) => attr(element, "required") !== undefined)
45
+ .map((element) => element.sourceCodeLocation?.attrs?.required)
46
+ .map((location) => location ? html.slice(location.startOffset, location.endOffset) : undefined);
47
+ let output = serialize(fragment);
48
+ const generated = elements(parseFragment(output, { sourceCodeLocationInfo: true }).childNodes)
49
+ .filter((element) => attr(element, "required") !== undefined);
50
+ for (let index = generated.length - 1; index >= 0; index--) {
51
+ // The reverse loop indexes only elements in the generated array.
52
+ const location = generated[index].sourceCodeLocation?.attrs?.required;
53
+ if (location && required[index])
54
+ output = output.slice(0, location.startOffset) + required[index] + output.slice(location.endOffset);
55
+ }
56
+ return output;
57
+ }
58
+ /** Derives the submission allowlist at save time and removes server-owned transport overrides.
59
+ * Parsing normalizes HTML syntax; scripts and authored styling remain unsanitized. */
60
+ export function deriveHtmlForm({ html }, _optional = {}) {
61
+ if (typeof html !== "string" || html.length > 200_000)
62
+ invalid("HTML must be a string of at most 200000 characters");
63
+ const fragment = parseFragment(html, { sourceCodeLocationInfo: true });
64
+ const all = elements(fragment.childNodes);
65
+ const forms = all.filter((element) => element.tagName === "form");
66
+ if (forms.length > 1)
67
+ invalid("Use a form body or one outer form wrapper");
68
+ for (const form of forms) {
69
+ const parent = form.parentNode;
70
+ if (!parent)
71
+ continue;
72
+ const index = parent.childNodes.indexOf(form);
73
+ parent.childNodes.splice(index, 1, ...form.childNodes);
74
+ for (const child of form.childNodes)
75
+ child.parentNode = parent;
76
+ }
77
+ const fields = new Map();
78
+ const labels = all.filter((element) => element.tagName === "label");
79
+ for (const element of all) {
80
+ // A submit button's overrides or a control's `form` attribute must not bypass our endpoint.
81
+ for (const name of ["form", "formaction", "formmethod", "formenctype", "formtarget"]) {
82
+ element.attrs = element.attrs.filter((attribute) => attribute.name !== name);
83
+ }
84
+ if (!["input", "textarea", "select", "button"].includes(element.tagName))
85
+ continue;
86
+ if (element.tagName === "button") {
87
+ element.attrs = element.attrs.filter((attribute) => attribute.name !== "name"); // Buttons are actions, not submission columns.
88
+ continue;
89
+ }
90
+ const name = attr(element, "name");
91
+ if (!name)
92
+ continue;
93
+ if (name === "_hp" || ["__proto__", "constructor", "prototype"].includes(name))
94
+ invalid(`Reserved field name '${name}'`);
95
+ const inputType = (attr(element, "type") ?? "text").toLowerCase();
96
+ if (element.tagName === "input" && ["submit", "reset", "button", "image"].includes(inputType)) {
97
+ element.attrs = element.attrs.filter((attribute) => attribute.name !== "name");
98
+ continue;
99
+ }
100
+ if (element.tagName === "input" && ["file", "password"].includes(inputType))
101
+ invalid(`Input type '${inputType}' is not supported`);
102
+ if (element.tagName === "select" && attr(element, "multiple") !== undefined)
103
+ invalid("Multiple selections are not supported");
104
+ if (fields.has(name)) {
105
+ if (inputType === "radio" && fields.get(name)?.type === "text") {
106
+ const group = fields.get(name);
107
+ group.required = group.required || attr(element, "required") !== undefined;
108
+ continue;
109
+ }
110
+ invalid(`Duplicate field name '${name}'`);
111
+ }
112
+ // Earlier Jini releases stored only boolean checkboxes and forced the native "on" wire value.
113
+ // Record attribute presence instead: authored strings survive while no-value boxes stay boolean.
114
+ const checkboxValue = inputType === "checkbox" ? attr(element, "value") : undefined;
115
+ const id = attr(element, "id");
116
+ const parentLabel = element.parentNode && "tagName" in element.parentNode && element.parentNode.tagName === "label"
117
+ ? element.parentNode : undefined;
118
+ const label = labels.find((item) => id !== undefined && attr(item, "for") === id) ?? parentLabel;
119
+ const maxLength = attr(element, "maxlength");
120
+ fields.set(name, {
121
+ id: name,
122
+ label: label ? labelText(label) || name : attr(element, "aria-label") || name,
123
+ type: element.tagName === "textarea" ? "textarea" : inputType === "email" ? "email" : inputType === "checkbox" ? "checkbox" : "text",
124
+ required: attr(element, "required") !== undefined,
125
+ ...(checkboxValue !== undefined ? { checkboxValue } : {}),
126
+ ...(maxLength !== undefined ? { maxLength: Number(maxLength) } : {}),
127
+ });
128
+ }
129
+ // Re-serialization also discards ignored/dangling </form> tokens: they must never close our
130
+ // server-owned wrapper early. Raw-text script contents are preserved by the HTML serializer.
131
+ return { mode: "html", html: serializeAuthoredRequired({ fragment, html }), fields: [...fields.values()] };
132
+ }
133
+ //# sourceMappingURL=html-authoring.js.map
@@ -0,0 +1,10 @@
1
+ import type { FormDefinitionRecord } from "../index.js";
2
+ /** Compatibility for the installed Jini release, which accepts boolean checkbox answers only.
3
+ * Jini's next release natively reads checkboxValue; until then this CMS HTML adapter presents
4
+ * authored checkbox strings as text to the same validator. It changes no durable descriptor.
5
+ * Empty authored values are successful controls; missing required controls must still fail. */
6
+ export declare function htmlSubmissionDefinition({ definition, body }: {
7
+ definition: FormDefinitionRecord;
8
+ body: Record<string, unknown>;
9
+ }, _optional?: {}): FormDefinitionRecord;
10
+ //# sourceMappingURL=html-submission-adapter.d.ts.map
@@ -0,0 +1,15 @@
1
+ /** Compatibility for the installed Jini release, which accepts boolean checkbox answers only.
2
+ * Jini's next release natively reads checkboxValue; until then this CMS HTML adapter presents
3
+ * authored checkbox strings as text to the same validator. It changes no durable descriptor.
4
+ * Empty authored values are successful controls; missing required controls must still fail. */
5
+ export function htmlSubmissionDefinition({ definition, body }, _optional = {}) {
6
+ if (definition.mode !== "html")
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
+ }) };
14
+ }
15
+ //# sourceMappingURL=html-submission-adapter.js.map
@@ -0,0 +1,4 @@
1
+ export * from "./html-authoring.js";
2
+ export * from "./authoring-adapter.js";
3
+ export * from "./html-submission-adapter.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ export * from "./html-authoring.js";
2
+ export * from "./authoring-adapter.js";
3
+ export * from "./html-submission-adapter.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,33 @@
1
+ /** Framework-free HTML renderer, kept separate for extraction into @jini-ai/cms/forms.
2
+ * Local prototype authorized 2026-10-04; that original dispatch kept Jini read-only.
3
+ * The host owns the endpoint and the form's styling hook; author markup and CSS refine it. */
4
+ import type { FieldDescriptor } from "./types.js";
5
+ /** Attribute names are trusted host constants, not visitor input. Values are always escaped. */
6
+ export interface FormHtmlHooks {
7
+ field: string;
8
+ form: string;
9
+ success: string;
10
+ error: string;
11
+ }
12
+ export declare function escapeFormHtml({ value }: {
13
+ value: string;
14
+ }, _optional?: {}): string;
15
+ export declare function renderHtmlFormBody({ fields, hooks }: {
16
+ fields: readonly FieldDescriptor[];
17
+ hooks: Pick<FormHtmlHooks, "field">;
18
+ }, { submitLabel }?: {
19
+ submitLabel?: string;
20
+ }): string;
21
+ /** `className` is the host's styling hook (the host passes its form CSS class so the active theme's form rules
22
+ * apply); author CSS stays free to override it. Whitespace-separated, de-duplicated, never replaced. */
23
+ export declare function renderHtmlForm({ slug, fields, action, hooks }: {
24
+ slug: string;
25
+ fields: readonly FieldDescriptor[];
26
+ action: string;
27
+ hooks: FormHtmlHooks;
28
+ }, { html, successMessage, className }?: {
29
+ html?: string;
30
+ successMessage?: string;
31
+ className?: string;
32
+ }): string;
33
+ //# sourceMappingURL=html-render.d.ts.map
@@ -0,0 +1,31 @@
1
+ export function escapeFormHtml({ value }, _optional = {}) {
2
+ return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
3
+ }
4
+ export function renderHtmlFormBody({ fields, hooks }, { submitLabel = "Send" } = {}) {
5
+ return fields.map((field) => {
6
+ const name = escapeFormHtml({ value: field.id });
7
+ const label = escapeFormHtml({ value: field.label });
8
+ const required = field.required ? " required" : "";
9
+ const maxLength = field.maxLength != null ? ` maxlength="${field.maxLength}"` : "";
10
+ const attributes = `name="${name}" ${hooks.field}="${name}" toolparamtitle="${label}" toolparamdescription="${label}"${required}${maxLength}`;
11
+ const control = field.type === "textarea"
12
+ ? `<textarea ${attributes}></textarea>`
13
+ : `<input type="${field.type}" ${attributes}>`;
14
+ return `<label>${label}${control}</label>`;
15
+ }).join("\n") + `\n<button type="submit">${escapeFormHtml({ value: submitLabel })}</button>`;
16
+ }
17
+ /** `className` is the host's styling hook (the host passes its form CSS class so the active theme's form rules
18
+ * apply); author CSS stays free to override it. Whitespace-separated, de-duplicated, never replaced. */
19
+ export function renderHtmlForm({ slug, fields, action, hooks }, { html, successMessage = "Thanks — your message has been sent.", className } = {}) {
20
+ const escapedSlug = escapeFormHtml({ value: slug });
21
+ const slugHook = `data-form-slug="${escapedSlug}"`;
22
+ const classes = [...new Set((className ?? "").split(/\s+/).filter(Boolean))].join(" ");
23
+ const classAttr = classes ? `class="${escapeFormHtml({ value: classes })}" ` : "";
24
+ 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 })}">` +
26
+ `<div ${hooks.error} ${slugHook} role="alert" hidden></div>` +
27
+ (html ?? renderHtmlFormBody({ fields, hooks })) +
28
+ // Hidden container requires neither a theme class nor inline CSS; bots still see the input.
29
+ `<div hidden aria-hidden="true"><input type="text" name="_hp" tabindex="-1" autocomplete="off"></div></form>`;
30
+ }
31
+ //# sourceMappingURL=html-render.js.map
@@ -0,0 +1,17 @@
1
+ export * from "./types.js";
2
+ export * from "./ports.js";
3
+ export * from "./errors.js";
4
+ export * from "./forms.js";
5
+ export * from "./rate-limit-key.js";
6
+ export * from "./write-service.js";
7
+ export * from "./submit-service.js";
8
+ export * from "./notify-subscriber.js";
9
+ export * from "./submission-ip-retention.js";
10
+ export * from "./fields-json.js";
11
+ export * from "./html-render.js";
12
+ export * from "./submission-attempts.js";
13
+ export * from "./submission-email-validation.js";
14
+ export * from "./delete-submission.js";
15
+ export * from "./duplicate-slug.js";
16
+ export * from "./repo.memory.js";
17
+ //# sourceMappingURL=index.d.ts.map