@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.
- package/CHANGELOG.md +15 -0
- package/README.md +14 -8
- package/dist/comments/errors.d.ts +23 -0
- package/dist/comments/errors.js +25 -0
- package/dist/comments/express/index.d.ts +2 -0
- package/dist/comments/express/index.js +2 -0
- package/dist/comments/express/submit-handler.d.ts +30 -0
- package/dist/comments/express/submit-handler.js +85 -0
- package/dist/comments/hooks.d.ts +39 -0
- package/dist/comments/hooks.js +41 -0
- package/dist/comments/index.d.ts +11 -0
- package/dist/comments/index.js +11 -0
- package/dist/comments/ingress.d.ts +57 -0
- package/dist/comments/ingress.js +173 -0
- package/dist/comments/module.d.ts +94 -0
- package/dist/comments/module.js +73 -0
- package/dist/comments/ports.d.ts +184 -0
- package/dist/comments/ports.js +2 -0
- package/dist/comments/repo.memory.d.ts +70 -0
- package/dist/comments/repo.memory.js +120 -0
- package/dist/comments/sanitize.d.ts +15 -0
- package/dist/comments/sanitize.js +31 -0
- package/dist/comments/spam.heuristic.d.ts +8 -0
- package/dist/comments/spam.heuristic.js +54 -0
- package/dist/comments/sql/index.d.ts +3 -0
- package/dist/comments/sql/index.js +3 -0
- package/dist/comments/sql/repo.d.ts +77 -0
- package/dist/comments/sql/repo.js +190 -0
- package/dist/comments/sql/repo.rows.d.ts +49 -0
- package/dist/comments/sql/repo.rows.js +71 -0
- package/dist/comments/types.d.ts +187 -0
- package/dist/comments/types.js +39 -0
- package/dist/comments/write-service.d.ts +121 -0
- package/dist/comments/write-service.js +144 -0
- package/dist/core/commands/command.d.ts +2 -9
- package/dist/core/commands/command.js +2 -9
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +1 -0
- package/dist/core/keyset-cursor.d.ts +25 -0
- package/dist/core/keyset-cursor.js +20 -0
- package/dist/core/ports.d.ts +30 -0
- package/dist/core/ports.js +0 -1
- package/dist/core/tools/args.d.ts +1 -46
- package/dist/core/tools/args.js +1 -44
- package/dist/forms/delete-submission.d.ts +38 -0
- package/dist/forms/delete-submission.js +25 -0
- package/dist/forms/duplicate-slug.d.ts +81 -0
- package/dist/forms/duplicate-slug.js +115 -0
- package/dist/forms/errors.d.ts +53 -0
- package/dist/forms/errors.js +25 -0
- package/dist/forms/express/handler.d.ts +59 -0
- package/dist/forms/express/handler.js +217 -0
- package/dist/forms/express/index.d.ts +2 -0
- package/dist/forms/express/index.js +2 -0
- package/dist/forms/fields-json.d.ts +14 -0
- package/dist/forms/fields-json.js +11 -0
- package/dist/forms/forms.d.ts +59 -0
- package/dist/forms/forms.js +213 -0
- package/dist/forms/html/authoring-adapter.d.ts +34 -0
- package/dist/forms/html/authoring-adapter.js +48 -0
- package/dist/forms/html/html-authoring.d.ts +12 -0
- package/dist/forms/html/html-authoring.js +133 -0
- package/dist/forms/html/html-submission-adapter.d.ts +10 -0
- package/dist/forms/html/html-submission-adapter.js +15 -0
- package/dist/forms/html/index.d.ts +4 -0
- package/dist/forms/html/index.js +4 -0
- package/dist/forms/html-render.d.ts +33 -0
- package/dist/forms/html-render.js +31 -0
- package/dist/forms/index.d.ts +17 -0
- package/dist/forms/index.js +17 -0
- package/dist/forms/notify-subscriber.d.ts +32 -0
- package/dist/forms/notify-subscriber.js +60 -0
- package/dist/forms/ports.d.ts +168 -0
- package/dist/forms/ports.js +2 -0
- package/dist/forms/rate-limit-key.d.ts +8 -0
- package/dist/forms/rate-limit-key.js +7 -0
- package/dist/forms/repo.memory.d.ts +120 -0
- package/dist/forms/repo.memory.js +188 -0
- package/dist/forms/sql/index.d.ts +5 -0
- package/dist/forms/sql/index.js +5 -0
- package/dist/forms/sql/repo.d.ts +94 -0
- package/dist/forms/sql/repo.js +175 -0
- package/dist/forms/sql/repo.rows.d.ts +33 -0
- package/dist/forms/sql/repo.rows.js +64 -0
- package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
- package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
- package/dist/forms/sql/tables.d.ts +32 -0
- package/dist/forms/sql/tables.js +2 -0
- package/dist/forms/submission-attempts.d.ts +70 -0
- package/dist/forms/submission-attempts.js +70 -0
- package/dist/forms/submission-email-validation.d.ts +8 -0
- package/dist/forms/submission-email-validation.js +34 -0
- package/dist/forms/submission-ip-retention.d.ts +30 -0
- package/dist/forms/submission-ip-retention.js +27 -0
- package/dist/forms/submit-service.d.ts +78 -0
- package/dist/forms/submit-service.js +109 -0
- package/dist/forms/types.d.ts +49 -0
- package/dist/forms/types.js +2 -0
- package/dist/forms/write-service.d.ts +83 -0
- package/dist/forms/write-service.js +271 -0
- package/dist/media/index.d.ts +0 -3
- package/dist/media/index.js +1 -4
- package/dist/media/node.d.ts +5 -0
- package/dist/media/node.js +6 -0
- package/dist/redirects/capture.d.ts +80 -0
- package/dist/redirects/capture.js +66 -0
- package/dist/redirects/express/index.d.ts +27 -0
- package/dist/redirects/express/index.js +40 -0
- package/dist/redirects/hit-sink.d.ts +56 -0
- package/dist/redirects/hit-sink.js +44 -0
- package/dist/redirects/host-config/index.d.ts +40 -0
- package/dist/redirects/host-config/index.js +145 -0
- package/dist/redirects/index.d.ts +14 -0
- package/dist/redirects/index.js +14 -0
- package/dist/redirects/matcher.d.ts +58 -0
- package/dist/redirects/matcher.js +144 -0
- package/dist/redirects/order.d.ts +30 -0
- package/dist/redirects/order.js +26 -0
- package/dist/redirects/ports.d.ts +242 -0
- package/dist/redirects/ports.internal.d.ts +57 -0
- package/dist/redirects/ports.internal.js +14 -0
- package/dist/redirects/ports.js +2 -0
- package/dist/redirects/redirects.d.ts +226 -0
- package/dist/redirects/redirects.js +590 -0
- package/dist/redirects/referrer-alias.d.ts +31 -0
- package/dist/redirects/referrer-alias.js +33 -0
- package/dist/redirects/repo.memory.d.ts +51 -0
- package/dist/redirects/repo.memory.js +98 -0
- package/dist/redirects/reserved-destination.d.ts +58 -0
- package/dist/redirects/reserved-destination.js +59 -0
- package/dist/redirects/resolver.d.ts +100 -0
- package/dist/redirects/resolver.js +87 -0
- package/dist/redirects/spa/index.d.ts +30 -0
- package/dist/redirects/spa/index.js +27 -0
- package/dist/redirects/sql/index.d.ts +4 -0
- package/dist/redirects/sql/index.js +4 -0
- package/dist/redirects/sql/repo.d.ts +82 -0
- package/dist/redirects/sql/repo.js +143 -0
- package/dist/redirects/sql/repo.rows.d.ts +49 -0
- package/dist/redirects/sql/repo.rows.js +79 -0
- package/dist/redirects/sql/row-types.d.ts +34 -0
- package/dist/redirects/sql/row-types.js +2 -0
- package/dist/redirects/target-gate.d.ts +30 -0
- package/dist/redirects/target-gate.js +85 -0
- package/dist/redirects/types.d.ts +213 -0
- package/dist/redirects/types.js +42 -0
- package/dist/seo/absolute-url.d.ts +58 -0
- package/dist/seo/absolute-url.js +117 -0
- package/dist/seo/dom/index.d.ts +14 -0
- package/dist/seo/dom/index.js +52 -0
- package/dist/seo/errors.d.ts +35 -0
- package/dist/seo/errors.js +35 -0
- package/dist/seo/feed.d.ts +49 -0
- package/dist/seo/feed.js +61 -0
- package/dist/seo/index.d.ts +13 -0
- package/dist/seo/index.js +13 -0
- package/dist/seo/media.d.ts +57 -0
- package/dist/seo/media.js +134 -0
- package/dist/seo/page-head.d.ts +18 -0
- package/dist/seo/page-head.js +114 -0
- package/dist/seo/ports.d.ts +221 -0
- package/dist/seo/ports.js +27 -0
- package/dist/seo/seo.d.ts +73 -0
- package/dist/seo/seo.js +230 -0
- package/dist/seo/sitemap.d.ts +139 -0
- package/dist/seo/sitemap.js +217 -0
- package/dist/seo/types.d.ts +278 -0
- package/dist/seo/types.js +2 -0
- package/dist/seo/write-service.d.ts +79 -0
- package/dist/seo/write-service.js +273 -0
- package/dist/seo/xml-escape.d.ts +16 -0
- package/dist/seo/xml-escape.js +19 -0
- package/dist/settings/express/cms-adapter.d.ts +20 -0
- package/dist/settings/express/cms-adapter.js +124 -0
- package/dist/settings/express/contracts.d.ts +135 -0
- package/dist/settings/express/contracts.js +2 -0
- package/dist/settings/express/http.d.ts +10 -0
- package/dist/settings/express/http.js +211 -0
- package/dist/settings/express/index.d.ts +5 -0
- package/dist/settings/express/index.js +5 -0
- package/dist/settings/express/shared.d.ts +68 -0
- package/dist/settings/express/shared.js +54 -0
- package/dist/settings/express/stream.d.ts +24 -0
- package/dist/settings/express/stream.js +147 -0
- package/dist/settings/index.d.ts +3 -38
- package/dist/settings/index.js +3 -35
- package/dist/taxonomy/write-service.d.ts +2 -30
- package/dist/widgets/authorize-helper.d.ts +42 -0
- package/dist/widgets/authorize-helper.js +16 -0
- package/dist/widgets/concurrency.d.ts +12 -0
- package/dist/widgets/concurrency.js +44 -0
- package/dist/widgets/config-validation.d.ts +34 -0
- package/dist/widgets/config-validation.js +125 -0
- package/dist/widgets/embed-service.d.ts +133 -0
- package/dist/widgets/embed-service.js +460 -0
- package/dist/widgets/embed-validation.d.ts +23 -0
- package/dist/widgets/embed-validation.js +38 -0
- package/dist/widgets/entry-payload.d.ts +103 -0
- package/dist/widgets/entry-payload.js +160 -0
- package/dist/widgets/errors.d.ts +136 -0
- package/dist/widgets/errors.js +136 -0
- package/dist/widgets/host-ports.d.ts +149 -0
- package/dist/widgets/host-ports.js +2 -0
- package/dist/widgets/html/html-embeds.d.ts +247 -0
- package/dist/widgets/html/html-embeds.js +408 -0
- package/dist/widgets/html/index.d.ts +5 -0
- package/dist/widgets/html/index.js +4 -0
- package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
- package/dist/widgets/html/legacy-widget-markers.js +45 -0
- package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
- package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/index.js +19 -0
- package/dist/widgets/markers/html-attributes.d.ts +98 -0
- package/dist/widgets/markers/html-attributes.js +216 -0
- package/dist/widgets/markers/index.d.ts +3 -0
- package/dist/widgets/markers/index.js +3 -0
- package/dist/widgets/markers/marker.d.ts +496 -0
- package/dist/widgets/markers/marker.js +780 -0
- package/dist/widgets/page-embed-types.d.ts +42 -0
- package/dist/widgets/page-embed-types.js +42 -0
- package/dist/widgets/ports.d.ts +142 -0
- package/dist/widgets/ports.js +2 -0
- package/dist/widgets/read-service.d.ts +81 -0
- package/dist/widgets/read-service.js +79 -0
- package/dist/widgets/region-area-service.d.ts +110 -0
- package/dist/widgets/region-area-service.js +180 -0
- package/dist/widgets/registry.d.ts +217 -0
- package/dist/widgets/registry.js +172 -0
- package/dist/widgets/repo.memory.d.ts +41 -0
- package/dist/widgets/repo.memory.js +48 -0
- package/dist/widgets/resolver-service.d.ts +209 -0
- package/dist/widgets/resolver-service.js +909 -0
- package/dist/widgets/resolvers/contact-form.d.ts +26 -0
- package/dist/widgets/resolvers/contact-form.js +54 -0
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
- package/dist/widgets/resolvers/index.d.ts +65 -0
- package/dist/widgets/resolvers/index.js +155 -0
- package/dist/widgets/resolvers/menu.d.ts +42 -0
- package/dist/widgets/resolvers/menu.js +43 -0
- package/dist/widgets/resolvers/ports.d.ts +82 -0
- package/dist/widgets/resolvers/ports.js +2 -0
- package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
- package/dist/widgets/resolvers/recent-entries.js +165 -0
- package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
- package/dist/widgets/resolvers/taxonomy.js +34 -0
- package/dist/widgets/sql/index.d.ts +3 -0
- package/dist/widgets/sql/index.js +3 -0
- package/dist/widgets/sql/repo.d.ts +41 -0
- package/dist/widgets/sql/repo.js +82 -0
- package/dist/widgets/sql/tables.d.ts +13 -0
- package/dist/widgets/sql/tables.js +2 -0
- package/dist/widgets/types.d.ts +213 -0
- package/dist/widgets/types.js +17 -0
- package/dist/widgets/where-used.d.ts +41 -0
- package/dist/widgets/where-used.js +11 -0
- package/dist/widgets/write-service.d.ts +204 -0
- package/dist/widgets/write-service.js +332 -0
- package/dist/workspace/delete.d.ts +1 -1
- 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,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
|