@jini-ai/cms 0.5.2 → 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 (261) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +14 -8
  3. package/dist/comments/errors.d.ts +23 -0
  4. package/dist/comments/errors.js +25 -0
  5. package/dist/comments/express/index.d.ts +2 -0
  6. package/dist/comments/express/index.js +2 -0
  7. package/dist/comments/express/submit-handler.d.ts +30 -0
  8. package/dist/comments/express/submit-handler.js +85 -0
  9. package/dist/comments/hooks.d.ts +39 -0
  10. package/dist/comments/hooks.js +41 -0
  11. package/dist/comments/index.d.ts +11 -0
  12. package/dist/comments/index.js +11 -0
  13. package/dist/comments/ingress.d.ts +57 -0
  14. package/dist/comments/ingress.js +173 -0
  15. package/dist/comments/module.d.ts +94 -0
  16. package/dist/comments/module.js +73 -0
  17. package/dist/comments/ports.d.ts +184 -0
  18. package/dist/comments/ports.js +2 -0
  19. package/dist/comments/repo.memory.d.ts +70 -0
  20. package/dist/comments/repo.memory.js +120 -0
  21. package/dist/comments/sanitize.d.ts +15 -0
  22. package/dist/comments/sanitize.js +31 -0
  23. package/dist/comments/spam.heuristic.d.ts +8 -0
  24. package/dist/comments/spam.heuristic.js +54 -0
  25. package/dist/comments/sql/index.d.ts +3 -0
  26. package/dist/comments/sql/index.js +3 -0
  27. package/dist/comments/sql/repo.d.ts +77 -0
  28. package/dist/comments/sql/repo.js +190 -0
  29. package/dist/comments/sql/repo.rows.d.ts +49 -0
  30. package/dist/comments/sql/repo.rows.js +71 -0
  31. package/dist/comments/types.d.ts +187 -0
  32. package/dist/comments/types.js +39 -0
  33. package/dist/comments/write-service.d.ts +121 -0
  34. package/dist/comments/write-service.js +144 -0
  35. package/dist/core/commands/command.d.ts +2 -9
  36. package/dist/core/commands/command.js +2 -9
  37. package/dist/core/index.d.ts +2 -1
  38. package/dist/core/index.js +1 -0
  39. package/dist/core/keyset-cursor.d.ts +25 -0
  40. package/dist/core/keyset-cursor.js +20 -0
  41. package/dist/core/ports.d.ts +30 -0
  42. package/dist/core/ports.js +0 -1
  43. package/dist/core/tools/args.d.ts +1 -46
  44. package/dist/core/tools/args.js +1 -44
  45. package/dist/forms/delete-submission.d.ts +38 -0
  46. package/dist/forms/delete-submission.js +25 -0
  47. package/dist/forms/duplicate-slug.d.ts +81 -0
  48. package/dist/forms/duplicate-slug.js +115 -0
  49. package/dist/forms/errors.d.ts +53 -0
  50. package/dist/forms/errors.js +25 -0
  51. package/dist/forms/express/handler.d.ts +59 -0
  52. package/dist/forms/express/handler.js +217 -0
  53. package/dist/forms/express/index.d.ts +2 -0
  54. package/dist/forms/express/index.js +2 -0
  55. package/dist/forms/fields-json.d.ts +14 -0
  56. package/dist/forms/fields-json.js +11 -0
  57. package/dist/forms/forms.d.ts +59 -0
  58. package/dist/forms/forms.js +213 -0
  59. package/dist/forms/html/authoring-adapter.d.ts +34 -0
  60. package/dist/forms/html/authoring-adapter.js +48 -0
  61. package/dist/forms/html/html-authoring.d.ts +12 -0
  62. package/dist/forms/html/html-authoring.js +133 -0
  63. package/dist/forms/html/html-submission-adapter.d.ts +10 -0
  64. package/dist/forms/html/html-submission-adapter.js +15 -0
  65. package/dist/forms/html/index.d.ts +4 -0
  66. package/dist/forms/html/index.js +4 -0
  67. package/dist/forms/html-render.d.ts +33 -0
  68. package/dist/forms/html-render.js +31 -0
  69. package/dist/forms/index.d.ts +17 -0
  70. package/dist/forms/index.js +17 -0
  71. package/dist/forms/notify-subscriber.d.ts +32 -0
  72. package/dist/forms/notify-subscriber.js +60 -0
  73. package/dist/forms/ports.d.ts +168 -0
  74. package/dist/forms/ports.js +2 -0
  75. package/dist/forms/rate-limit-key.d.ts +8 -0
  76. package/dist/forms/rate-limit-key.js +7 -0
  77. package/dist/forms/repo.memory.d.ts +120 -0
  78. package/dist/forms/repo.memory.js +188 -0
  79. package/dist/forms/sql/index.d.ts +5 -0
  80. package/dist/forms/sql/index.js +5 -0
  81. package/dist/forms/sql/repo.d.ts +94 -0
  82. package/dist/forms/sql/repo.js +175 -0
  83. package/dist/forms/sql/repo.rows.d.ts +33 -0
  84. package/dist/forms/sql/repo.rows.js +64 -0
  85. package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
  86. package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
  87. package/dist/forms/sql/tables.d.ts +32 -0
  88. package/dist/forms/sql/tables.js +2 -0
  89. package/dist/forms/submission-attempts.d.ts +70 -0
  90. package/dist/forms/submission-attempts.js +70 -0
  91. package/dist/forms/submission-email-validation.d.ts +8 -0
  92. package/dist/forms/submission-email-validation.js +34 -0
  93. package/dist/forms/submission-ip-retention.d.ts +30 -0
  94. package/dist/forms/submission-ip-retention.js +27 -0
  95. package/dist/forms/submit-service.d.ts +78 -0
  96. package/dist/forms/submit-service.js +109 -0
  97. package/dist/forms/types.d.ts +49 -0
  98. package/dist/forms/types.js +2 -0
  99. package/dist/forms/write-service.d.ts +83 -0
  100. package/dist/forms/write-service.js +271 -0
  101. package/dist/media/index.d.ts +0 -3
  102. package/dist/media/index.js +1 -4
  103. package/dist/media/node.d.ts +5 -0
  104. package/dist/media/node.js +6 -0
  105. package/dist/redirects/capture.d.ts +80 -0
  106. package/dist/redirects/capture.js +66 -0
  107. package/dist/redirects/express/index.d.ts +27 -0
  108. package/dist/redirects/express/index.js +40 -0
  109. package/dist/redirects/hit-sink.d.ts +56 -0
  110. package/dist/redirects/hit-sink.js +44 -0
  111. package/dist/redirects/host-config/index.d.ts +40 -0
  112. package/dist/redirects/host-config/index.js +145 -0
  113. package/dist/redirects/index.d.ts +14 -0
  114. package/dist/redirects/index.js +14 -0
  115. package/dist/redirects/matcher.d.ts +58 -0
  116. package/dist/redirects/matcher.js +144 -0
  117. package/dist/redirects/order.d.ts +30 -0
  118. package/dist/redirects/order.js +26 -0
  119. package/dist/redirects/ports.d.ts +242 -0
  120. package/dist/redirects/ports.internal.d.ts +57 -0
  121. package/dist/redirects/ports.internal.js +14 -0
  122. package/dist/redirects/ports.js +2 -0
  123. package/dist/redirects/redirects.d.ts +226 -0
  124. package/dist/redirects/redirects.js +590 -0
  125. package/dist/redirects/referrer-alias.d.ts +31 -0
  126. package/dist/redirects/referrer-alias.js +33 -0
  127. package/dist/redirects/repo.memory.d.ts +51 -0
  128. package/dist/redirects/repo.memory.js +98 -0
  129. package/dist/redirects/reserved-destination.d.ts +58 -0
  130. package/dist/redirects/reserved-destination.js +59 -0
  131. package/dist/redirects/resolver.d.ts +100 -0
  132. package/dist/redirects/resolver.js +87 -0
  133. package/dist/redirects/spa/index.d.ts +30 -0
  134. package/dist/redirects/spa/index.js +27 -0
  135. package/dist/redirects/sql/index.d.ts +4 -0
  136. package/dist/redirects/sql/index.js +4 -0
  137. package/dist/redirects/sql/repo.d.ts +82 -0
  138. package/dist/redirects/sql/repo.js +143 -0
  139. package/dist/redirects/sql/repo.rows.d.ts +49 -0
  140. package/dist/redirects/sql/repo.rows.js +79 -0
  141. package/dist/redirects/sql/row-types.d.ts +34 -0
  142. package/dist/redirects/sql/row-types.js +2 -0
  143. package/dist/redirects/target-gate.d.ts +30 -0
  144. package/dist/redirects/target-gate.js +85 -0
  145. package/dist/redirects/types.d.ts +213 -0
  146. package/dist/redirects/types.js +42 -0
  147. package/dist/seo/absolute-url.d.ts +58 -0
  148. package/dist/seo/absolute-url.js +117 -0
  149. package/dist/seo/dom/index.d.ts +14 -0
  150. package/dist/seo/dom/index.js +52 -0
  151. package/dist/seo/errors.d.ts +35 -0
  152. package/dist/seo/errors.js +35 -0
  153. package/dist/seo/feed.d.ts +49 -0
  154. package/dist/seo/feed.js +61 -0
  155. package/dist/seo/index.d.ts +13 -0
  156. package/dist/seo/index.js +13 -0
  157. package/dist/seo/media.d.ts +57 -0
  158. package/dist/seo/media.js +134 -0
  159. package/dist/seo/page-head.d.ts +18 -0
  160. package/dist/seo/page-head.js +114 -0
  161. package/dist/seo/ports.d.ts +221 -0
  162. package/dist/seo/ports.js +27 -0
  163. package/dist/seo/seo.d.ts +73 -0
  164. package/dist/seo/seo.js +230 -0
  165. package/dist/seo/sitemap.d.ts +139 -0
  166. package/dist/seo/sitemap.js +217 -0
  167. package/dist/seo/types.d.ts +278 -0
  168. package/dist/seo/types.js +2 -0
  169. package/dist/seo/write-service.d.ts +79 -0
  170. package/dist/seo/write-service.js +273 -0
  171. package/dist/seo/xml-escape.d.ts +16 -0
  172. package/dist/seo/xml-escape.js +19 -0
  173. package/dist/settings/express/cms-adapter.d.ts +20 -0
  174. package/dist/settings/express/cms-adapter.js +124 -0
  175. package/dist/settings/express/contracts.d.ts +135 -0
  176. package/dist/settings/express/contracts.js +2 -0
  177. package/dist/settings/express/http.d.ts +10 -0
  178. package/dist/settings/express/http.js +211 -0
  179. package/dist/settings/express/index.d.ts +5 -0
  180. package/dist/settings/express/index.js +5 -0
  181. package/dist/settings/express/shared.d.ts +68 -0
  182. package/dist/settings/express/shared.js +54 -0
  183. package/dist/settings/express/stream.d.ts +24 -0
  184. package/dist/settings/express/stream.js +147 -0
  185. package/dist/settings/index.d.ts +3 -38
  186. package/dist/settings/index.js +3 -35
  187. package/dist/taxonomy/write-service.d.ts +2 -30
  188. package/dist/widgets/authorize-helper.d.ts +42 -0
  189. package/dist/widgets/authorize-helper.js +16 -0
  190. package/dist/widgets/concurrency.d.ts +12 -0
  191. package/dist/widgets/concurrency.js +44 -0
  192. package/dist/widgets/config-validation.d.ts +34 -0
  193. package/dist/widgets/config-validation.js +125 -0
  194. package/dist/widgets/embed-service.d.ts +133 -0
  195. package/dist/widgets/embed-service.js +460 -0
  196. package/dist/widgets/embed-validation.d.ts +23 -0
  197. package/dist/widgets/embed-validation.js +38 -0
  198. package/dist/widgets/entry-payload.d.ts +103 -0
  199. package/dist/widgets/entry-payload.js +160 -0
  200. package/dist/widgets/errors.d.ts +136 -0
  201. package/dist/widgets/errors.js +136 -0
  202. package/dist/widgets/host-ports.d.ts +149 -0
  203. package/dist/widgets/host-ports.js +2 -0
  204. package/dist/widgets/html/html-embeds.d.ts +247 -0
  205. package/dist/widgets/html/html-embeds.js +408 -0
  206. package/dist/widgets/html/index.d.ts +5 -0
  207. package/dist/widgets/html/index.js +4 -0
  208. package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
  209. package/dist/widgets/html/legacy-widget-markers.js +45 -0
  210. package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
  211. package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
  212. package/dist/widgets/index.d.ts +20 -0
  213. package/dist/widgets/index.js +19 -0
  214. package/dist/widgets/markers/html-attributes.d.ts +98 -0
  215. package/dist/widgets/markers/html-attributes.js +216 -0
  216. package/dist/widgets/markers/index.d.ts +3 -0
  217. package/dist/widgets/markers/index.js +3 -0
  218. package/dist/widgets/markers/marker.d.ts +496 -0
  219. package/dist/widgets/markers/marker.js +780 -0
  220. package/dist/widgets/page-embed-types.d.ts +42 -0
  221. package/dist/widgets/page-embed-types.js +42 -0
  222. package/dist/widgets/ports.d.ts +142 -0
  223. package/dist/widgets/ports.js +2 -0
  224. package/dist/widgets/read-service.d.ts +81 -0
  225. package/dist/widgets/read-service.js +79 -0
  226. package/dist/widgets/region-area-service.d.ts +110 -0
  227. package/dist/widgets/region-area-service.js +180 -0
  228. package/dist/widgets/registry.d.ts +217 -0
  229. package/dist/widgets/registry.js +172 -0
  230. package/dist/widgets/repo.memory.d.ts +41 -0
  231. package/dist/widgets/repo.memory.js +48 -0
  232. package/dist/widgets/resolver-service.d.ts +209 -0
  233. package/dist/widgets/resolver-service.js +909 -0
  234. package/dist/widgets/resolvers/contact-form.d.ts +26 -0
  235. package/dist/widgets/resolvers/contact-form.js +54 -0
  236. package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
  237. package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
  238. package/dist/widgets/resolvers/index.d.ts +65 -0
  239. package/dist/widgets/resolvers/index.js +155 -0
  240. package/dist/widgets/resolvers/menu.d.ts +42 -0
  241. package/dist/widgets/resolvers/menu.js +43 -0
  242. package/dist/widgets/resolvers/ports.d.ts +82 -0
  243. package/dist/widgets/resolvers/ports.js +2 -0
  244. package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
  245. package/dist/widgets/resolvers/recent-entries.js +165 -0
  246. package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
  247. package/dist/widgets/resolvers/taxonomy.js +34 -0
  248. package/dist/widgets/sql/index.d.ts +3 -0
  249. package/dist/widgets/sql/index.js +3 -0
  250. package/dist/widgets/sql/repo.d.ts +41 -0
  251. package/dist/widgets/sql/repo.js +82 -0
  252. package/dist/widgets/sql/tables.d.ts +13 -0
  253. package/dist/widgets/sql/tables.js +2 -0
  254. package/dist/widgets/types.d.ts +213 -0
  255. package/dist/widgets/types.js +17 -0
  256. package/dist/widgets/where-used.d.ts +41 -0
  257. package/dist/widgets/where-used.js +11 -0
  258. package/dist/widgets/write-service.d.ts +204 -0
  259. package/dist/widgets/write-service.js +332 -0
  260. package/dist/workspace/delete.d.ts +1 -1
  261. package/package.json +220 -12
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @file `content_duplicate`'s `"form"` resource needs a slug for the copy, and Forms — unlike Posts —
3
+ * has nowhere to get one.
4
+ *
5
+ * `createPost` derives and disambiguates a slug itself whenever the caller omits one, which is why
6
+ * `duplicatePostOrPage` can just pass `slug: undefined` and be done. `createFormDefinition` is the
7
+ * opposite: `slug` is required, is immutable after creation (`updateFormDefinition` always re-passes
8
+ * `existing.slug`), and is validated against `@jini-ai/cms/forms`' `SLUG_PATTERN` plus the adapter's
9
+ * reserved set. Duplicating a form therefore has to arrive with a valid, free slug in hand — otherwise every
10
+ * copy of "Contact Us" collides with the previous one and the operator sees a raw
11
+ * `FormSlugConflictError` instead of a copy.
12
+ *
13
+ * Kept as its own pure module (mirroring `features/post/duplicate-embeds.ts`) rather than inlined in
14
+ * `tool-registrations.ts`: the character-class, truncation and suffix-search edges are exactly the
15
+ * kind of logic that is cheap to assert directly and expensive to reach through a repo, a command
16
+ * gateway and an outbox.
17
+ *
18
+ * Deliberately NOT exported as a general-purpose Forms slug helper: `forms_create_definition` still
19
+ * takes an explicit caller-supplied slug, and giving it a silent derivation would change a published
20
+ * tool contract this task has no mandate to change.
21
+ *
22
+ * The bounded suffix-search LOOP itself was first shared with
23
+ * `../content-duplication/derive-available-name.ts` (2026-09-08), generalized from this file's own
24
+ * original loop. Its implementation and rationale now live in `@jini-ai/core/naming`'s
25
+ * `deriveAvailableName`, keeping the collision-search shape shared with the NAME derivation every
26
+ * `content_duplicate` resource needs, rather than reinvented a second time. This file keeps
27
+ * everything SLUG-specific: the character class (`slugifyFormName`), the length ceiling and
28
+ * base-shortening (`withSuffix`), and the reserved-word list (`RESERVED_SLUGS`).
29
+ */
30
+ /** The ceiling `SLUG_PATTERN` (`^[a-z0-9][a-z0-9-]{0,63}$`) itself imposes: 1 + 63 characters. */
31
+ export declare const FORM_SLUG_MAX_LENGTH = 64;
32
+ /**
33
+ * Route sentinel the admin Forms editor reserves for its own create-mode URL — re-stated from
34
+ * `write-service.ts`'s private `RESERVED_SLUGS` because it is private there. A derived slug landing
35
+ * on it is treated as taken rather than rejected: the caller never chose it, so suffixing is the
36
+ * right repair, and `createFormDefinition` would reject it outright a moment later anyway.
37
+ */
38
+ /**
39
+ * Converts a form name into the longest `SLUG_PATTERN`-satisfying slug it can, or `""` when the name
40
+ * contains nothing sluggable.
41
+ *
42
+ * Returns `""` rather than a fallback so a caller can tell "nothing to work with" apart from a real
43
+ * derivation — {@link deriveAvailableFormSlug} is where the fallback is applied. Truncation trims any
44
+ * hyphen the cut exposes, since a trailing hyphen is legal under the pattern but reads as a mistake.
45
+ *
46
+ * @param name - The (possibly already numbered, e.g. "Landing sample — xai 2") form name to derive
47
+ * from.
48
+ * @returns A slug of at most {@link FORM_SLUG_MAX_LENGTH} characters, or `""`.
49
+ * @complexity O(n) in the name's length.
50
+ */
51
+ export declare function slugifyFormName({ name, slugify }: {
52
+ name: string;
53
+ slugify: (name: string) => string;
54
+ }, _optional?: Record<string, never>): string;
55
+ /**
56
+ * Derives a slug for a duplicated form that is both valid under `SLUG_PATTERN` and free.
57
+ *
58
+ * `isTaken` is injected rather than a repo handle so the search is directly testable and so this
59
+ * module carries no port dependency; production passes a `formDefinitionRepo.findBySlug` lookup.
60
+ * Note this is advisory, not a lock: the DB unique index remains the real tie-break, so a genuine
61
+ * race still surfaces as `FormSlugConflictError` from `createFormDefinition`. That is the intended
62
+ * split (`write-service.ts`'s own header states it), not a gap this function is trying to close.
63
+ *
64
+ * The suffix is appended within the length ceiling, truncating the base as needed, so a maximum
65
+ * length name still disambiguates instead of producing an over-long slug create would reject.
66
+ *
67
+ * @param required.name - The copy's name, already defaulted by the caller.
68
+ * @param options.isTaken - Whether a candidate slug is already used in this workspace.
69
+ * @returns A slug satisfying `SLUG_PATTERN` that `isTaken` reported free.
70
+ * @throws {Error} If {@link MAX_SUFFIX_ATTEMPTS} candidates are all taken.
71
+ * @complexity O(k) `isTaken` calls where k is the number of colliding candidates, bounded at
72
+ * {@link MAX_SUFFIX_ATTEMPTS}.
73
+ */
74
+ export declare function deriveAvailableFormSlug(required: {
75
+ name: string;
76
+ slugify: (name: string) => string;
77
+ maxAttempts: number;
78
+ reservedSlugs: ReadonlySet<string>;
79
+ isTaken: (slug: string) => Promise<boolean>;
80
+ }, _optional?: Record<string, never>): Promise<string>;
81
+ //# sourceMappingURL=duplicate-slug.d.ts.map
@@ -0,0 +1,115 @@
1
+ import { SLUG_PATTERN } from "./write-service.js";
2
+ import { deriveAvailableName } from "@jini-ai/core/naming";
3
+ /**
4
+ * @file `content_duplicate`'s `"form"` resource needs a slug for the copy, and Forms — unlike Posts —
5
+ * has nowhere to get one.
6
+ *
7
+ * `createPost` derives and disambiguates a slug itself whenever the caller omits one, which is why
8
+ * `duplicatePostOrPage` can just pass `slug: undefined` and be done. `createFormDefinition` is the
9
+ * opposite: `slug` is required, is immutable after creation (`updateFormDefinition` always re-passes
10
+ * `existing.slug`), and is validated against `@jini-ai/cms/forms`' `SLUG_PATTERN` plus the adapter's
11
+ * reserved set. Duplicating a form therefore has to arrive with a valid, free slug in hand — otherwise every
12
+ * copy of "Contact Us" collides with the previous one and the operator sees a raw
13
+ * `FormSlugConflictError` instead of a copy.
14
+ *
15
+ * Kept as its own pure module (mirroring `features/post/duplicate-embeds.ts`) rather than inlined in
16
+ * `tool-registrations.ts`: the character-class, truncation and suffix-search edges are exactly the
17
+ * kind of logic that is cheap to assert directly and expensive to reach through a repo, a command
18
+ * gateway and an outbox.
19
+ *
20
+ * Deliberately NOT exported as a general-purpose Forms slug helper: `forms_create_definition` still
21
+ * takes an explicit caller-supplied slug, and giving it a silent derivation would change a published
22
+ * tool contract this task has no mandate to change.
23
+ *
24
+ * The bounded suffix-search LOOP itself was first shared with
25
+ * `../content-duplication/derive-available-name.ts` (2026-09-08), generalized from this file's own
26
+ * original loop. Its implementation and rationale now live in `@jini-ai/core/naming`'s
27
+ * `deriveAvailableName`, keeping the collision-search shape shared with the NAME derivation every
28
+ * `content_duplicate` resource needs, rather than reinvented a second time. This file keeps
29
+ * everything SLUG-specific: the character class (`slugifyFormName`), the length ceiling and
30
+ * base-shortening (`withSuffix`), and the reserved-word list (`RESERVED_SLUGS`).
31
+ */
32
+ /** The ceiling `SLUG_PATTERN` (`^[a-z0-9][a-z0-9-]{0,63}$`) itself imposes: 1 + 63 characters. */
33
+ export const FORM_SLUG_MAX_LENGTH = 64;
34
+ /**
35
+ * The slug used when a name has no sluggable characters at all (e.g. `"!!! ---"`, or a name written
36
+ * entirely in a script this ASCII-only pattern cannot represent). `SLUG_PATTERN` has no "empty"
37
+ * form, so something concrete has to stand in; the suffix search below then disambiguates it the
38
+ * same way it disambiguates any other collision.
39
+ */
40
+ const FALLBACK_SLUG = "form";
41
+ /**
42
+ * Route sentinel the admin Forms editor reserves for its own create-mode URL — re-stated from
43
+ * `write-service.ts`'s private `RESERVED_SLUGS` because it is private there. A derived slug landing
44
+ * on it is treated as taken rather than rejected: the caller never chose it, so suffixing is the
45
+ * right repair, and `createFormDefinition` would reject it outright a moment later anyway.
46
+ */
47
+ // The host supplies this reserved set; its admin create-route sentinel remains host policy.
48
+ /**
49
+ * Converts a form name into the longest `SLUG_PATTERN`-satisfying slug it can, or `""` when the name
50
+ * contains nothing sluggable.
51
+ *
52
+ * Returns `""` rather than a fallback so a caller can tell "nothing to work with" apart from a real
53
+ * derivation — {@link deriveAvailableFormSlug} is where the fallback is applied. Truncation trims any
54
+ * hyphen the cut exposes, since a trailing hyphen is legal under the pattern but reads as a mistake.
55
+ *
56
+ * @param name - The (possibly already numbered, e.g. "Landing sample — xai 2") form name to derive
57
+ * from.
58
+ * @returns A slug of at most {@link FORM_SLUG_MAX_LENGTH} characters, or `""`.
59
+ * @complexity O(n) in the name's length.
60
+ */
61
+ export function slugifyFormName({ name, slugify }, _optional = {}) {
62
+ return slugify(name).slice(0, FORM_SLUG_MAX_LENGTH).replace(/-+$/g, "");
63
+ }
64
+ /**
65
+ * Derives a slug for a duplicated form that is both valid under `SLUG_PATTERN` and free.
66
+ *
67
+ * `isTaken` is injected rather than a repo handle so the search is directly testable and so this
68
+ * module carries no port dependency; production passes a `formDefinitionRepo.findBySlug` lookup.
69
+ * Note this is advisory, not a lock: the DB unique index remains the real tie-break, so a genuine
70
+ * race still surfaces as `FormSlugConflictError` from `createFormDefinition`. That is the intended
71
+ * split (`write-service.ts`'s own header states it), not a gap this function is trying to close.
72
+ *
73
+ * The suffix is appended within the length ceiling, truncating the base as needed, so a maximum
74
+ * length name still disambiguates instead of producing an over-long slug create would reject.
75
+ *
76
+ * @param required.name - The copy's name, already defaulted by the caller.
77
+ * @param options.isTaken - Whether a candidate slug is already used in this workspace.
78
+ * @returns A slug satisfying `SLUG_PATTERN` that `isTaken` reported free.
79
+ * @throws {Error} If {@link MAX_SUFFIX_ATTEMPTS} candidates are all taken.
80
+ * @complexity O(k) `isTaken` calls where k is the number of colliding candidates, bounded at
81
+ * {@link MAX_SUFFIX_ATTEMPTS}.
82
+ */
83
+ export async function deriveAvailableFormSlug(required, _optional = {}) {
84
+ const base = slugifyFormName({ name: required.name, slugify: required.slugify }) || FALLBACK_SLUG;
85
+ return deriveAvailableName({
86
+ base,
87
+ withSuffix: ({ base, suffix }) => withSuffix(base, suffix),
88
+ // RESERVED_SLUGS/SLUG_PATTERN are "skip this candidate", not "this candidate is taken" — but
89
+ // the generic loop makes no distinction between the two (see its own doc), so folding both
90
+ // checks in here reproduces this function's original `continue`-then-try-the-next-suffix
91
+ // behavior exactly.
92
+ isTaken: async ({ candidate }) => {
93
+ if (required.reservedSlugs.has(candidate))
94
+ return true;
95
+ if (!SLUG_PATTERN.test(candidate))
96
+ return true;
97
+ return required.isTaken(candidate);
98
+ },
99
+ exhaustionMessage: ({ maxAttempts }) => `no free slug could be derived from '${required.name}' after ${maxAttempts} attempts — ` +
100
+ "give the copy an explicit slug",
101
+ }, { maxAttempts: required.maxAttempts });
102
+ }
103
+ /**
104
+ * Appends `-<suffix>` while keeping the result within {@link FORM_SLUG_MAX_LENGTH}, shortening the
105
+ * base (never the suffix) when the two together would overflow — the suffix is what makes the slug
106
+ * unique, so it is the half that must survive.
107
+ *
108
+ * @complexity O(1).
109
+ */
110
+ function withSuffix(base, suffix) {
111
+ const tail = `-${suffix}`;
112
+ const room = FORM_SLUG_MAX_LENGTH - tail.length;
113
+ return `${base.slice(0, room).replace(/-+$/g, "")}${tail}`;
114
+ }
115
+ //# sourceMappingURL=duplicate-slug.js.map
@@ -0,0 +1,53 @@
1
+ /** Stable domain error types; callers map them to their own transport errors. */
2
+ export declare class FormFieldValidationError extends Error {
3
+ readonly fieldErrors: Array<{
4
+ field: string;
5
+ reason: string;
6
+ }>;
7
+ constructor({ message, fieldErrors }: {
8
+ message: string;
9
+ fieldErrors?: Array<{
10
+ field: string;
11
+ reason: string;
12
+ }> | undefined;
13
+ });
14
+ }
15
+ export declare class FormSubmissionValidationError extends Error {
16
+ readonly fieldErrors: Array<{
17
+ field: string;
18
+ reason: string;
19
+ }>;
20
+ constructor({ message, fieldErrors }: {
21
+ message: string;
22
+ fieldErrors?: Array<{
23
+ field: string;
24
+ reason: string;
25
+ }> | undefined;
26
+ });
27
+ }
28
+ export declare class FormSlugConflictError extends Error {
29
+ readonly slug: string;
30
+ constructor({ message, slug }: {
31
+ message: string;
32
+ slug: string;
33
+ });
34
+ }
35
+ /** Disabled definitions deliberately use the same not-found error as missing definitions. */
36
+ export declare class FormDefinitionNotFoundError extends Error {
37
+ constructor({ message }: {
38
+ message: string;
39
+ });
40
+ }
41
+ export declare class FormSubmissionNotFoundError extends Error {
42
+ constructor({ message }: {
43
+ message: string;
44
+ });
45
+ }
46
+ export declare class FormRateLimitExceededError extends Error {
47
+ readonly retryAfterSeconds: number;
48
+ constructor({ message, retryAfterSeconds }: {
49
+ message: string;
50
+ retryAfterSeconds: number;
51
+ });
52
+ }
53
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1,25 @@
1
+ /** Stable domain error types; callers map them to their own transport errors. */
2
+ export class FormFieldValidationError extends Error {
3
+ fieldErrors;
4
+ constructor({ message, fieldErrors = [] }) { super(message); this.fieldErrors = fieldErrors; }
5
+ }
6
+ export class FormSubmissionValidationError extends Error {
7
+ fieldErrors;
8
+ constructor({ message, fieldErrors = [] }) { super(message); this.fieldErrors = fieldErrors; }
9
+ }
10
+ export class FormSlugConflictError extends Error {
11
+ slug;
12
+ constructor({ message, slug }) { super(message); this.slug = slug; }
13
+ }
14
+ /** Disabled definitions deliberately use the same not-found error as missing definitions. */
15
+ export class FormDefinitionNotFoundError extends Error {
16
+ constructor({ message }) { super(message); }
17
+ }
18
+ export class FormSubmissionNotFoundError extends Error {
19
+ constructor({ message }) { super(message); }
20
+ }
21
+ export class FormRateLimitExceededError extends Error {
22
+ retryAfterSeconds;
23
+ constructor({ message, retryAfterSeconds }) { super(message); this.retryAfterSeconds = retryAfterSeconds; }
24
+ }
25
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,59 @@
1
+ import type { Request, Response } from "express";
2
+ import type { FormDefinitionRepoPort } from "../ports.js";
3
+ import type { SubmitFormInput } from "../submit-service.js";
4
+ export type FormSubmissionRedirectResult = {
5
+ kind: "success";
6
+ slug: string;
7
+ } | {
8
+ kind: "validation";
9
+ slug: string;
10
+ fieldErrors: ReadonlyArray<{
11
+ field: string;
12
+ reason: string;
13
+ }>;
14
+ values?: Readonly<Record<string, string>>;
15
+ } | {
16
+ kind: "rate-limited";
17
+ slug: string;
18
+ retryAfterSeconds: number;
19
+ } | {
20
+ kind: "error";
21
+ slug: string;
22
+ };
23
+ /** The host owns the existing query/cookie wire format and safe site target policy. */
24
+ export interface FormSubmitResultCodec {
25
+ encodeResultQuery: (result: FormSubmissionRedirectResult) => Iterable<[string, string]>;
26
+ clearResultQueryParams: (params: URLSearchParams) => void;
27
+ encodeFlashCookie: (payload: {
28
+ slug: string;
29
+ values: Record<string, string>;
30
+ }) => string | undefined;
31
+ flashCookieName: string;
32
+ }
33
+ /** Anonymous HTTP composition; the definition read is used only to repopulate failed HTML POSTs. */
34
+ export interface CreateFormSubmitHandlerRequired {
35
+ workspaceId: string;
36
+ submit: (required: {
37
+ input: SubmitFormInput;
38
+ }, optional?: Record<string, never>) => Promise<{
39
+ status: "accepted";
40
+ }>;
41
+ definitionRepo: Pick<FormDefinitionRepoPort, "findBySlug">;
42
+ resolveClientIp: (req: Request) => string;
43
+ isHttpsRequest: (req: Request) => boolean;
44
+ resultCodec: FormSubmitResultCodec;
45
+ targetRefusalReason: (target: string) => string | null;
46
+ }
47
+ /** Registers `POST /forms/:slug/submit`. Must be registered BEFORE the site `/:slug` catch-all.
48
+ *
49
+ * `express.urlencoded` is mounted on THIS route's own POST registration (not a global `app.ts`
50
+ * mount — same scoping precedent as `template-preview.ts`'s `POST` handler) because a real,
51
+ * JavaScript-disabled `<form method="post">` — the exact case this file's Post/Redirect/Get fix
52
+ * exists for — always encodes its body as `application/x-www-form-urlencoded`, which `app.ts`'s
53
+ * blanket `express.json()` cannot parse; without this, every field on a genuine browser submission
54
+ * would silently arrive as missing, failing REQUIRED-field validation on every real, non-JS
55
+ * submission regardless of the redirect fix. A `fetch`/JSON caller (every pre-existing test in this
56
+ * file) is unaffected — Express dispatches to whichever parser matches the request's actual
57
+ * `Content-Type`, so mounting both here is additive, never a double-parse of the same request. */
58
+ export declare function createFormSubmitHandler(deps: CreateFormSubmitHandlerRequired, _optional?: Record<string, never>): (req: Request, res: Response) => Promise<void>;
59
+ //# sourceMappingURL=handler.d.ts.map
@@ -0,0 +1,217 @@
1
+ import { FormDefinitionNotFoundError, FormRateLimitExceededError, FormSubmissionValidationError } from "../errors.js";
2
+ /**
3
+ * @file Public submission route for `forms` (SPEC-010 `FORMS_POST_SUBMIT`, REQ-05).
4
+ *
5
+ * Mirrors `routes/site/analytics-ingest.ts`'s public-route shape: no session required, registered
6
+ * before the site `/:slug` catch-all (see `server/app.ts`'s wiring). Reuses `resolveClientIp`
7
+ * (`core/rate-limit/rate-limit.ts`) rather than re-deriving IP resolution — all real validation
8
+ * happens inside `submitForm` (C-008); this file owns only the HTTP boundary.
9
+ *
10
+ * A native `<form>` POST navigates the browser to whatever this route returns — the ORIGINAL bug
11
+ * here (2026-08-31 fix) was that every branch replied with raw JSON, which a JS-disabled visitor saw
12
+ * as a literal `{"status":"accepted"}` screen instead of their page. `wantsHtmlResponse` splits the
13
+ * two audiences this endpoint actually has by content negotiation: a fetch/XHR/API caller (this
14
+ * file's own existing tests, none of which set an `Accept` header, land here) keeps the untouched
15
+ * JSON contract; a real browser form submission (`Accept: text/html,...`) gets a Post/Redirect/Get
16
+ * 303 back to the page that hosted the form, with the outcome threaded through the query string via
17
+ * {@link encodeFormSubmissionResultQuery} — `routes/site/pages.ts` decodes and splices it back into
18
+ * that page (`http/site/render.ts`'s `injectFormSubmissionResultIntoHtml`) on the way back down.
19
+ *
20
+ * A validation failure ALSO sets the host's form flash cookie ({@link setFormFlashCookieForValidationFailure})
21
+ * so the OTHER fields the visitor already typed survive that same round trip instead of coming back
22
+ * wiped (2026-08-31 fix) — see `http/site/form-render.ts`'s "Validation flash cookie" section for why
23
+ * this travels via a cookie rather than the query string.
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`. */
27
+ 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;
34
+ }
35
+ /** True for a real browser's form-POST navigation (`Accept: text/html,...`); false for the
36
+ * fetch/XHR/API caller shape every pre-existing test in this file already sends (`*\/*`, or no
37
+ * header at all), which must keep getting the untouched JSON contract. */
38
+ function wantsHtmlResponse(req) {
39
+ return (req.get("accept") ?? "").includes("text/html");
40
+ }
41
+ /**
42
+ * Resolves the safe base URL {@link redirectWithFormResult} rebuilds its Location around. `Referer`
43
+ * is the only signal available for "which page hosted this form" — `render.ts`'s
44
+ * `renderWidgetContactForm` has no request in scope to stash a return path in (see that file's own
45
+ * module doc) — and it is entirely visitor-controlled: a hostile page anywhere on the internet can
46
+ * point a plain `<form>` at this exact endpoint and set its own Referer to whatever it likes.
47
+ * Redirecting there unchecked would turn this endpoint into an open redirect (secure-input-handling:
48
+ * untrusted header). Comparing `refUrl.host` against this REQUEST's own `Host` header closes that
49
+ * off — an absent, unparsable, or cross-origin Referer all fall back to the site root instead, a
50
+ * real and safe page rather than a raw JSON blob, just not necessarily the one the visitor submitted
51
+ * from.
52
+ *
53
+ * The host match alone is not enough (t91 open-redirect sweep, 2026-09-16): the Location is rebuilt
54
+ * from the Referer's `pathname` + `search` only, and `http://site//evil.example/x` (or the `/\` and
55
+ * `/.//` spellings a URL parser folds into it) has the right host but a pathname of
56
+ * `//evil.example/x` — a protocol-relative Location. The pathname must also pass the redirects write
57
+ * gate's site-relative check, which refuses that shape (and the admin surface) the same way.
58
+ */
59
+ function resolveSameOriginRedirectBase(req, targetRefusalReason) {
60
+ const fallback = new URL("/", `${req.protocol}://${req.get("host") ?? "localhost"}`);
61
+ const referer = req.get("referer");
62
+ if (!referer)
63
+ return fallback;
64
+ try {
65
+ const refUrl = new URL(referer);
66
+ const onThisSite = refUrl.host === req.get("host") && targetRefusalReason(refUrl.pathname) === null;
67
+ return onThisSite ? refUrl : fallback;
68
+ }
69
+ catch {
70
+ return fallback;
71
+ }
72
+ }
73
+ /** Issues the Post/Redirect/Get 303 a JS-disabled form submission needs: back to the same-origin
74
+ * page {@link resolveSameOriginRedirectBase} resolved, with any PRIOR `form_*` result params
75
+ * cleared (so resubmitting doesn't accumulate stale ones from an earlier visit) and `result`'s own
76
+ * freshly encoded in their place. */
77
+ function redirectWithFormResult(req, res, result, deps) {
78
+ const target = resolveSameOriginRedirectBase(req, deps.targetRefusalReason);
79
+ deps.resultCodec.clearResultQueryParams(target.searchParams);
80
+ for (const [key, value] of deps.resultCodec.encodeResultQuery(result))
81
+ target.searchParams.set(key, value);
82
+ res.redirect(303, `${target.pathname}${target.search}`);
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // Validation flash cookie (2026-08-31 field-wipe fix) — see `http/site/form-render.ts`'s own
86
+ // "Validation flash cookie" section header for the full mechanism this half feeds.
87
+ // ---------------------------------------------------------------------------
88
+ const FORM_FLASH_COOKIE_MAX_AGE_SECONDS = 120;
89
+ /** Field types eligible to be echoed back into the flash cookie — an ALLOWLIST, not a denylist, so a
90
+ * future `FieldType` this codebase has not added yet (a `password` type, say) is excluded the
91
+ * moment it exists, with zero code change required here: only a type explicitly added to this set
92
+ * is ever repopulated. `checkbox` is excluded too — its stored value is a boolean-ish flag, never
93
+ * text a visitor "typed" and could lose, so the bug this fixes never applied to it. */
94
+ const REPOPULATABLE_FIELD_TYPES = new Set(["text", "email", "textarea"]);
95
+ /** Extracts the submitted values worth flashing back: only fields BOTH declared on this form AND of
96
+ * a {@link REPOPULATABLE_FIELD_TYPES} type — iterating `fields` (not `body`'s own keys) means a
97
+ * non-field key like the honeypot (`_hp`) is excluded automatically, never by a separate denylist
98
+ * check. Per-value/total-size bounding for the cookie itself is `encodeFormFlashCookieValue`'s own
99
+ * job (`form-render.ts`), not duplicated here.
100
+ * @complexity O(f) in the form's own declared field count (bounded by forms' `MAX_FIELDS`). */
101
+ function buildFormFlashValues(fields, body) {
102
+ const values = {};
103
+ for (const field of fields) {
104
+ if (!REPOPULATABLE_FIELD_TYPES.has(field.type))
105
+ continue;
106
+ const raw = body[field.id];
107
+ if (typeof raw === "string" && raw !== "")
108
+ values[field.id] = raw;
109
+ }
110
+ return values;
111
+ }
112
+ /** Sets the short-lived, read-once flash cookie a validation-failure redirect uses to survive the
113
+ * 303's fresh GET with the visitor's OTHER field values intact (2026-08-31 fix — see this route's
114
+ * own module doc header, and `form-render.ts`'s "Validation flash cookie" section, for the full
115
+ * round trip). `HttpOnly` — this mechanism exists so the JS-DISABLED path works, there is no reason
116
+ * to also expose it to page JS. `SameSite=Lax`, not `Strict` — the cookie's only read happens on
117
+ * this SAME redirect's own follow-up GET, a top-level same-site navigation `Lax` already allows,
118
+ * mirroring `complete-sign-in.ts`'s identical `SameSite=Lax` rationale. `Secure` only when
119
+ * {@link isHttpsRequest} — unlike `dev-auth.ts`'s/`complete-sign-in.ts`'s always-on `Secure` (both
120
+ * gate a real login session), an unconditional `Secure` here would silently break this exact feature
121
+ * on a plain `http://localhost` dev/test run.
122
+ *
123
+ * `encodeFormFlashCookieValue` returning `undefined` (the submission's own values did not fit even
124
+ * after truncation/dropping) is a silent no-op — the redirect still happens; the visitor just sees
125
+ * the pre-fix wiped-fields behavior for that one oversized submission rather than a broken
126
+ * `Set-Cookie` header. */
127
+ function setFormFlashCookie(req, res, payload, deps) {
128
+ const json = deps.resultCodec.encodeFlashCookie(payload);
129
+ if (!json)
130
+ return;
131
+ res.cookie(deps.resultCodec.flashCookieName, json, {
132
+ httpOnly: true, path: "/", maxAge: FORM_FLASH_COOKIE_MAX_AGE_SECONDS * 1000,
133
+ sameSite: "lax", secure: deps.isHttpsRequest(req),
134
+ });
135
+ }
136
+ /** Builds and sets the flash cookie for a validation failure reached via a real browser form POST.
137
+ * Looks up the form's field descriptors a SECOND time — `submitForm` already did once, internally,
138
+ * to build `err.fieldErrors`, but that lookup's own `FormDefinitionRecord` never escapes
139
+ * `submit-service.ts` — so this route can filter by field `type` without widening `submitForm`'s
140
+ * own return/throw contract for a single caller's cookie concern. The extra read only happens on
141
+ * the HTML/validation-failure path, never on every submission. A workspace/slug with no matching
142
+ * definition (should not happen — `submitForm` just resolved the same slug successfully enough to
143
+ * reach validation) degrades to an empty field list rather than throwing. */
144
+ async function setFormFlashCookieForValidationFailure(req, res, deps, slug, body) {
145
+ const definition = await deps.definitionRepo.findBySlug({ workspaceId: deps.workspaceId, slug });
146
+ const values = buildFormFlashValues(definition?.fields ?? [], body);
147
+ if (Object.keys(values).length === 0)
148
+ return;
149
+ setFormFlashCookie(req, res, { slug, values }, deps);
150
+ }
151
+ function outcomeForSuccess(slug, result) {
152
+ return { redirectResult: { kind: "success", slug }, json: { status: 201, body: result } };
153
+ }
154
+ /** Maps `submitForm`'s three typed failure modes (REQ-05/07's error taxonomy) to both response
155
+ * shapes at once; an error `submitForm` never actually raises today still degrades to a generic
156
+ * 500/`"error"` pair rather than throwing out of this mapper. */
157
+ function outcomeForError(slug, err) {
158
+ if (err instanceof FormDefinitionNotFoundError) {
159
+ return { redirectResult: { kind: "error", slug }, json: { status: 404, body: { error: err.message, code: "FORMS_DEFINITION_NOT_FOUND" } } };
160
+ }
161
+ if (err instanceof FormSubmissionValidationError) {
162
+ return {
163
+ redirectResult: { kind: "validation", slug, fieldErrors: err.fieldErrors },
164
+ json: { status: 400, body: { error: err.message, code: "FORMS_SUBMISSION_VALIDATION_ERROR", details: { fieldErrors: err.fieldErrors } } },
165
+ };
166
+ }
167
+ if (err instanceof FormRateLimitExceededError) {
168
+ return {
169
+ redirectResult: { kind: "rate-limited", slug, retryAfterSeconds: err.retryAfterSeconds },
170
+ json: { status: 429, body: { error: err.message, code: "FORMS_RATE_LIMIT_EXCEEDED", details: { retryAfterSeconds: err.retryAfterSeconds } } },
171
+ };
172
+ }
173
+ return { redirectResult: { kind: "error", slug }, json: { status: 500, body: { error: "internal error", code: "INTERNAL_ERROR" } } };
174
+ }
175
+ function sendFormSubmitOutcome(req, res, outcome, deps) {
176
+ if (wantsHtmlResponse(req)) {
177
+ redirectWithFormResult(req, res, outcome.redirectResult, deps);
178
+ return;
179
+ }
180
+ res.status(outcome.json.status).json(outcome.json.body);
181
+ }
182
+ /** Registers `POST /forms/:slug/submit`. Must be registered BEFORE the site `/:slug` catch-all.
183
+ *
184
+ * `express.urlencoded` is mounted on THIS route's own POST registration (not a global `app.ts`
185
+ * mount — same scoping precedent as `template-preview.ts`'s `POST` handler) because a real,
186
+ * JavaScript-disabled `<form method="post">` — the exact case this file's Post/Redirect/Get fix
187
+ * exists for — always encodes its body as `application/x-www-form-urlencoded`, which `app.ts`'s
188
+ * blanket `express.json()` cannot parse; without this, every field on a genuine browser submission
189
+ * would silently arrive as missing, failing REQUIRED-field validation on every real, non-JS
190
+ * submission regardless of the redirect fix. A `fetch`/JSON caller (every pre-existing test in this
191
+ * file) is unaffected — Express dispatches to whichever parser matches the request's actual
192
+ * `Content-Type`, so mounting both here is additive, never a double-parse of the same request. */
193
+ export function createFormSubmitHandler(deps, _optional = {}) {
194
+ return async (req, res) => {
195
+ const slug = String(req.params.slug ?? "");
196
+ const body = boundBody(req.body);
197
+ const sourceIp = deps.resolveClientIp(req);
198
+ try {
199
+ const result = await deps.submit({
200
+ input: { workspaceId: deps.workspaceId, slug, body, sourceIp },
201
+ });
202
+ sendFormSubmitOutcome(req, res, outcomeForSuccess(slug, result), deps);
203
+ }
204
+ catch (err) {
205
+ if (err instanceof FormSubmissionValidationError && wantsHtmlResponse(req)) {
206
+ try {
207
+ await setFormFlashCookieForValidationFailure(req, res, deps, slug, body);
208
+ }
209
+ catch (flashErr) {
210
+ console.error(`[site/forms-submit] failed to set validation flash cookie (slug=${slug})`, flashErr);
211
+ }
212
+ }
213
+ sendFormSubmitOutcome(req, res, outcomeForError(slug, err), deps);
214
+ }
215
+ };
216
+ }
217
+ //# sourceMappingURL=handler.js.map
@@ -0,0 +1,2 @@
1
+ export * from "./handler.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export * from "./handler.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,14 @@
1
+ import type { FormDefinitionRecord } from "./types.js";
2
+ export type FormAuthoring = {
3
+ mode?: "builder" | "html";
4
+ html?: string | undefined;
5
+ };
6
+ export type HtmlFormDefinitionRecord = FormDefinitionRecord & FormAuthoring;
7
+ /** Old rows remain arrays; authored forms use the same JSON-in-text column, without a migration. */
8
+ export declare function decodeFormFields({ json }: {
9
+ json: string;
10
+ }, _optional?: {}): Pick<HtmlFormDefinitionRecord, "fields" | "mode" | "html">;
11
+ export declare function encodeFormFields({ definition }: {
12
+ definition: HtmlFormDefinitionRecord;
13
+ }, _optional?: {}): string;
14
+ //# sourceMappingURL=fields-json.d.ts.map
@@ -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