@wildo-ai/saas-website 1.1.5 → 1.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/astro/blog-post-bridge.d.ts +9 -0
- package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
- package/dist/esm/astro/blog-post-bridge.js +10 -5
- package/dist/esm/astro/blog-post-bridge.js.map +1 -1
- package/dist/esm/astro/blog-post-head.renderer.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.d.ts +25 -6
- package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.js +10 -6
- package/dist/esm/astro/bridge-runtime.js.map +1 -1
- package/dist/esm/astro/collect-expected-label-keys.d.ts.map +1 -1
- package/dist/esm/astro/headers-renderer.d.ts.map +1 -1
- package/dist/esm/astro/i18n-routing.helper.d.ts.map +1 -1
- package/dist/esm/astro/label-pack-loader.d.ts +4 -4
- package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
- package/dist/esm/astro/label-pack-loader.js +9 -4
- package/dist/esm/astro/label-pack-loader.js.map +1 -1
- package/dist/esm/astro/llms-txt-server.d.ts.map +1 -1
- package/dist/esm/astro/pick-labels-for-page.d.ts.map +1 -1
- package/dist/esm/astro/robots-renderer.d.ts +9 -8
- package/dist/esm/astro/robots-renderer.d.ts.map +1 -1
- package/dist/esm/astro/robots-renderer.js +15 -15
- package/dist/esm/astro/robots-renderer.js.map +1 -1
- package/dist/esm/astro/sitemap-coverage.d.ts.map +1 -1
- package/dist/esm/astro/structured-data.renderer.d.ts.map +1 -1
- package/dist/esm/astro/website-page-head.renderer.d.ts +1 -1
- package/dist/esm/astro/website-page-head.renderer.d.ts.map +1 -1
- package/dist/esm/astro/website-page-head.renderer.js +2 -1
- package/dist/esm/astro/website-page-head.renderer.js.map +1 -1
- package/dist/esm/astro/website-page-runtime.helper.d.ts.map +1 -1
- package/dist/esm/astro/website-site-context.d.ts +5 -2
- package/dist/esm/astro/website-site-context.d.ts.map +1 -1
- package/dist/esm/astro/website-site-context.js.map +1 -1
- package/dist/esm/astro-island.d.ts.map +1 -1
- package/dist/esm/astro.d.ts.map +1 -1
- package/dist/esm/companion-exports.d.ts +8 -15
- package/dist/esm/companion-exports.d.ts.map +1 -1
- package/dist/esm/companion-exports.js +8 -15
- package/dist/esm/companion-exports.js.map +1 -1
- package/dist/esm/components/low-level/WebsiteInternalButton.d.ts.map +1 -1
- package/dist/esm/components/low-level/WebsiteInternalLink.d.ts.map +1 -1
- package/dist/esm/config/define-website-config.d.ts +3 -2
- package/dist/esm/config/define-website-config.d.ts.map +1 -1
- package/dist/esm/config/define-website-config.js +10 -10
- package/dist/esm/config/define-website-config.js.map +1 -1
- package/dist/esm/config/index.d.ts.map +1 -1
- package/dist/esm/config/load-website-config.d.ts +6 -0
- package/dist/esm/config/load-website-config.d.ts.map +1 -1
- package/dist/esm/config/load-website-config.js +18 -9
- package/dist/esm/config/load-website-config.js.map +1 -1
- package/dist/esm/config/load-website-pricing-catalog.d.ts +13 -0
- package/dist/esm/config/load-website-pricing-catalog.d.ts.map +1 -0
- package/dist/esm/config/load-website-pricing-catalog.js +34 -0
- package/dist/esm/config/load-website-pricing-catalog.js.map +1 -0
- package/dist/esm/config/load-website-site-context.d.ts +53 -0
- package/dist/esm/config/load-website-site-context.d.ts.map +1 -0
- package/dist/esm/config/load-website-site-context.js +98 -0
- package/dist/esm/config/load-website-site-context.js.map +1 -0
- package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -1
- package/dist/esm/config-loader.d.ts +2 -0
- package/dist/esm/config-loader.d.ts.map +1 -1
- package/dist/esm/config-loader.js +2 -0
- package/dist/esm/config-loader.js.map +1 -1
- package/dist/esm/core/anonymous-session/InboundContactForm.d.ts +26 -6
- package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/InboundContactForm.js +51 -33
- package/dist/esm/core/anonymous-session/InboundContactForm.js.map +1 -1
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +11 -0
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +15 -4
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +14 -2
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +34 -4
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
- package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts +36 -0
- package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts.map +1 -0
- package/dist/esm/core/anonymous-session/website-submission-challenge.js +69 -0
- package/dist/esm/core/anonymous-session/website-submission-challenge.js.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -1
- package/dist/esm/core/consent/WebsiteConsentBanner.js +11 -10
- package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -1
- package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -1
- package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -1
- package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -1
- package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +25 -5
- package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
- package/dist/esm/core/contexts/WebsiteSectionContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/useWebsitePage.d.ts.map +1 -1
- package/dist/esm/core/contexts/useWebsiteRuntime.d.ts +18 -0
- package/dist/esm/core/contexts/useWebsiteRuntime.d.ts.map +1 -1
- package/dist/esm/core/contexts/useWebsiteRuntime.js +28 -2
- package/dist/esm/core/contexts/useWebsiteRuntime.js.map +1 -1
- package/dist/esm/core/contexts/useWebsiteSection.d.ts.map +1 -1
- package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts +57 -0
- package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts.map +1 -0
- package/dist/esm/core/external-providers/WebsiteProviderComponent.js +56 -0
- package/dist/esm/core/external-providers/WebsiteProviderComponent.js.map +1 -0
- package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
- package/dist/esm/core/external-providers/provider-component-registry.website.d.ts +63 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.d.ts.map +1 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.js +68 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.js.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts +36 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.js +59 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.js.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +15 -5
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +39 -24
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
- package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts +11 -0
- package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts.map +1 -0
- package/dist/esm/core/external-providers/website-provider-sdk-activations.js +14 -0
- package/dist/esm/core/external-providers/website-provider-sdk-activations.js.map +1 -0
- package/dist/esm/core/factories/define-website-page-manifest.d.ts.map +1 -1
- package/dist/esm/core/factories/define-website-section.d.ts +3 -3
- package/dist/esm/core/factories/define-website-section.d.ts.map +1 -1
- package/dist/esm/core/factories/define-website-section.js +3 -3
- package/dist/esm/core/factories/define-website-section.js.map +1 -1
- package/dist/esm/core/hooks/useWebsiteDesignTokens.d.ts.map +1 -1
- package/dist/esm/core/hooks/useWebsiteLabel.d.ts.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.js +10 -7
- package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
- package/dist/esm/core/layouts/WebsiteSection.d.ts.map +1 -1
- package/dist/esm/core/routing/internal-routing.utils.d.ts.map +1 -1
- package/dist/esm/index.d.ts +30 -65
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +30 -65
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/mdx/BlogPost.d.ts.map +1 -1
- package/dist/esm/mdx/blog-post-collection.config.d.ts.map +1 -1
- package/dist/esm/mdx/blog-post-frontmatter-source.d.ts.map +1 -1
- package/dist/esm/mdx/index.d.ts.map +1 -1
- package/dist/esm/mdx.d.ts.map +1 -1
- package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts +35 -6
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js +39 -5
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/label-keys/website-label-key.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts +32 -0
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts.map +1 -0
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js +36 -0
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js.map +1 -0
- package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts +9 -8
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js +8 -7
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/refs/page-ref.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/refs/section-ref.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/sections/website-section-category.shared.d.ts.map +1 -1
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts +14 -7
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js +14 -7
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts +57 -0
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts.map +1 -0
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js +76 -0
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js.map +1 -0
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts +23 -0
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts.map +1 -1
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js +20 -2
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js.map +1 -1
- package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts +7 -5
- package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/validators/label-pack.validator.d.ts.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/__tests__/bundle-isolation.test.ts +19 -22
- package/src/astro/__tests__/blog-post-bridge.test.tsx +18 -2
- package/src/astro/__tests__/bridge-runtime.test.tsx +21 -3
- package/src/astro/__tests__/label-pack-loader.test.ts +7 -4
- package/src/astro/blog-post-bridge.tsx +19 -5
- package/src/astro/bridge-runtime.tsx +35 -11
- package/src/astro/label-pack-loader.ts +8 -4
- package/src/astro/robots-renderer.ts +15 -15
- package/src/astro/website-page-head.renderer.ts +2 -2
- package/src/astro/website-site-context.ts +5 -2
- package/src/companion-exports.ts +8 -15
- package/src/config/__tests__/website-build-origins.test.ts +52 -0
- package/src/config/define-website-config.ts +10 -10
- package/src/config/load-website-config.ts +25 -14
- package/src/config/load-website-pricing-catalog.ts +52 -0
- package/src/config/load-website-site-context.ts +145 -0
- package/src/config-loader.ts +2 -0
- package/src/core/__tests__/contexts.test.tsx +20 -1
- package/src/core/__tests__/website-consent.test.tsx +2 -1
- package/src/core/anonymous-session/InboundContactForm.tsx +100 -45
- package/src/core/anonymous-session/__tests__/InboundContactForm.labels.test.tsx +108 -0
- package/src/core/anonymous-session/__tests__/InboundContactForm.submission-challenge.test.tsx +139 -0
- package/src/core/anonymous-session/inbound-contact-form.schema.ts +16 -4
- package/src/core/anonymous-session/website-anonymous-session-client.ts +39 -4
- package/src/core/anonymous-session/website-submission-challenge.ts +99 -0
- package/src/core/consent/WebsiteConsentBanner.tsx +11 -10
- package/src/core/contexts/WebsiteRuntimeContext.tsx +25 -5
- package/src/core/contexts/useWebsiteRuntime.ts +33 -2
- package/src/core/external-providers/WebsiteProviderComponent.tsx +153 -0
- package/src/core/external-providers/__tests__/frontend-provider-registry.website.test.ts +4 -0
- package/src/core/external-providers/__tests__/website-provider-component.test.tsx +238 -0
- package/src/core/external-providers/__tests__/website-provider-sdks.test.tsx +72 -0
- package/src/core/external-providers/provider-component-registry.website.ts +115 -0
- package/src/core/external-providers/useWebsiteProviderComponent.ts +76 -0
- package/src/core/external-providers/useWebsiteProviderScripts.ts +56 -37
- package/src/core/external-providers/website-provider-sdk-activations.ts +23 -0
- package/src/core/factories/define-website-section.ts +3 -3
- package/src/core/layouts/WebsitePageLayout.tsx +11 -6
- package/src/index.ts +31 -65
- package/src/schemas/__tests__/design-token-css-property.test.ts +72 -0
- package/src/schemas/__tests__/pricing-offer-structured-data.test.ts +122 -0
- package/src/schemas/__tests__/structured-data-reconciliation.test.ts +35 -0
- package/src/schemas/design-tokens/website-design-tokens.shared.schemas.ts +45 -5
- package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +12 -0
- package/src/schemas/label-keys/website-provider-component-label-keys.schemas.ts +40 -0
- package/src/schemas/manifests/website-root-config.shared.schemas.ts +10 -9
- package/src/schemas/sections/website-section-definition.shared.schemas.ts +14 -7
- package/src/schemas/structured-data/pricing-offer-structured-data.shared.ts +144 -0
- package/src/schemas/structured-data/structured-data-reconciliation.shared.ts +46 -1
- package/src/schemas/structured-data/website-structured-data.shared.schemas.ts +7 -5
|
@@ -9,23 +9,22 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
* the anonymous session (init header) and the backend stamps the lead's `anonymousUserId`
|
|
10
10
|
* so it can later transpose to the visitor's account on signup/login.
|
|
11
11
|
*
|
|
12
|
-
* Labels are injected
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Labels are injected rather than read from the website runtime context, so the component stays framework-light and
|
|
13
|
+
* unit-testable; the section wrapper passes the strings it resolved through `useWebsiteLabel`. EVERY label is required
|
|
14
|
+
* and the form carries no words of its own (#1986): a fallback would be English in every locale and would hide the
|
|
15
|
+
* missing label, while a required prop makes an omission a type error in the section, and a key the pack does not
|
|
16
|
+
* carry reaches the page as the key itself — which is what `useWebsiteLabel` returns, so somebody authors it.
|
|
17
|
+
*
|
|
18
|
+
* Submission challenge (#1735): when the application protects the form with a captcha, each submission carries an
|
|
19
|
+
* answer — the consented vendor's token when its browser part runs on the page, otherwise the engine's proof of work,
|
|
20
|
+
* which needs no consent (see `website-submission-challenge.ts`). Nothing for the visitor to click or read: both are
|
|
21
|
+
* invisible, so the form stays operable by keyboard and screen reader exactly as before. A vendor token the backend
|
|
22
|
+
* refuses is retried once with the proof of work; a refusal that survives that shows `challengeRefusedMessage`.
|
|
15
23
|
*/
|
|
16
|
-
import { useCallback,
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
firstNameLabel: "First name",
|
|
21
|
-
lastNameLabel: "Last name",
|
|
22
|
-
phoneLabel: "Phone",
|
|
23
|
-
marketingConsentLabel: "Keep me updated about news and offers",
|
|
24
|
-
submitLabel: "Get in touch",
|
|
25
|
-
submittingLabel: "Sending…",
|
|
26
|
-
successMessage: "Thanks — we'll be in touch shortly.",
|
|
27
|
-
errorMessage: "Something went wrong. Please try again.",
|
|
28
|
-
};
|
|
24
|
+
import { useCallback, useState } from "react";
|
|
25
|
+
import { SubmissionChallengeAction, WildoHeaderKeys } from "@wildo-ai/saas-models/public-runtime";
|
|
26
|
+
import { INBOUND_CONTACT_NAME_MAX_LENGTH, INBOUND_CONTACT_PHONE_MAX_LENGTH, InboundContactFormSchema, buildInboundContactCreateBody, } from "./inbound-contact-form.schema.js";
|
|
27
|
+
import { isSubmissionChallengeRefusal, obtainSubmissionChallengeAnswer, useWebsiteSubmissionChallengeSource, } from "./website-submission-challenge.js";
|
|
29
28
|
const EMPTY_VALUES = {
|
|
30
29
|
email: "",
|
|
31
30
|
firstName: "",
|
|
@@ -33,45 +32,64 @@ const EMPTY_VALUES = {
|
|
|
33
32
|
phone: "",
|
|
34
33
|
marketingConsent: false,
|
|
35
34
|
};
|
|
36
|
-
export function InboundContactForm({ client, labels, onSubmitted }) {
|
|
37
|
-
const
|
|
35
|
+
export function InboundContactForm({ client, labels, onSubmitted, challengeSource }) {
|
|
36
|
+
const pageChallengeSource = useWebsiteSubmissionChallengeSource();
|
|
37
|
+
const source = challengeSource ?? pageChallengeSource;
|
|
38
38
|
const [values, setValues] = useState(EMPTY_VALUES);
|
|
39
|
-
const [
|
|
39
|
+
const [emailInvalid, setEmailInvalid] = useState(false);
|
|
40
40
|
const [submit, setSubmit] = useState({ status: "idle" });
|
|
41
41
|
const setField = useCallback((key, value) => {
|
|
42
42
|
setValues((prev) => ({ ...prev, [key]: value }));
|
|
43
43
|
}, []);
|
|
44
44
|
const handleSubmit = useCallback(async (event) => {
|
|
45
45
|
event.preventDefault();
|
|
46
|
-
|
|
46
|
+
setEmailInvalid(false);
|
|
47
47
|
const parsed = InboundContactFormSchema.safeParse(values);
|
|
48
48
|
if (!parsed.success) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
// The schema's own issue messages are never shown: they are English and nobody authors them (#1986). The email
|
|
50
|
+
// is the one field a visitor can get wrong, and it has its label. The other fields' only rule is a length the
|
|
51
|
+
// inputs already enforce with `maxLength`, so reaching a failure there means the values did not come from
|
|
52
|
+
// typing, and the form answers with its generic error rather than inventing a sentence.
|
|
53
|
+
const emailFailed = parsed.error.issues.some((issue) => issue.path[0] === "email");
|
|
54
|
+
setEmailInvalid(emailFailed);
|
|
55
|
+
if (!emailFailed)
|
|
56
|
+
setSubmit({ status: "error", message: labels.errorMessage });
|
|
56
57
|
return;
|
|
57
58
|
}
|
|
58
59
|
setSubmit({ status: "submitting" });
|
|
60
|
+
const body = buildInboundContactCreateBody(parsed.data);
|
|
61
|
+
const send = async (proofOfWorkOnly) => {
|
|
62
|
+
const answer = await obtainSubmissionChallengeAnswer({ source, action: SubmissionChallengeAction.INBOUND_CONTACT, client, proofOfWorkOnly });
|
|
63
|
+
await client.postAnonymous("/inbound-contacts", body, answer === undefined ? undefined : { [WildoHeaderKeys.SUBMISSION_CHALLENGE_RESPONSE]: answer });
|
|
64
|
+
};
|
|
59
65
|
try {
|
|
60
|
-
|
|
66
|
+
try {
|
|
67
|
+
await send(false);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
// A refused challenge is answered ONCE more, with the proof of work: a vendor that scored a real person low,
|
|
71
|
+
// could not be reached, or a site that did not know the backend protects the form must not lock anyone out.
|
|
72
|
+
if (!isSubmissionChallengeRefusal(error))
|
|
73
|
+
throw error;
|
|
74
|
+
await send(true);
|
|
75
|
+
}
|
|
61
76
|
setSubmit({ status: "success" });
|
|
62
77
|
setValues(EMPTY_VALUES);
|
|
63
78
|
onSubmitted?.();
|
|
64
79
|
}
|
|
65
|
-
catch {
|
|
80
|
+
catch (error) {
|
|
66
81
|
// The backend-only `emailUserMatch` triage signal is never returned here, so a
|
|
67
82
|
// failure exposes no account-existence information — just a generic retry.
|
|
68
|
-
setSubmit({
|
|
83
|
+
setSubmit({
|
|
84
|
+
status: "error",
|
|
85
|
+
message: isSubmissionChallengeRefusal(error) ? labels.challengeRefusedMessage : labels.errorMessage,
|
|
86
|
+
});
|
|
69
87
|
}
|
|
70
|
-
}, [client, values, onSubmitted,
|
|
88
|
+
}, [client, values, onSubmitted, source, labels.errorMessage, labels.challengeRefusedMessage]);
|
|
71
89
|
if (submit.status === "success") {
|
|
72
|
-
return (_jsx("div", { role: "status", "aria-live": "polite", children:
|
|
90
|
+
return (_jsx("div", { role: "status", "aria-live": "polite", children: labels.successMessage }));
|
|
73
91
|
}
|
|
74
92
|
const isSubmitting = submit.status === "submitting";
|
|
75
|
-
return (_jsxs("form", { onSubmit: handleSubmit, noValidate: true, children: [_jsxs("label", { children: [
|
|
93
|
+
return (_jsxs("form", { onSubmit: handleSubmit, noValidate: true, children: [_jsxs("label", { children: [labels.emailLabel, _jsx("input", { type: "email", name: "email", required: true, value: values.email, onChange: (e) => setField("email", e.target.value), "aria-invalid": emailInvalid ? true : undefined }), emailInvalid ? _jsx("span", { role: "alert", children: labels.invalidEmailMessage }) : null] }), _jsxs("label", { children: [labels.firstNameLabel, _jsx("input", { type: "text", name: "firstName", maxLength: INBOUND_CONTACT_NAME_MAX_LENGTH, value: values.firstName ?? "", onChange: (e) => setField("firstName", e.target.value) })] }), _jsxs("label", { children: [labels.lastNameLabel, _jsx("input", { type: "text", name: "lastName", maxLength: INBOUND_CONTACT_NAME_MAX_LENGTH, value: values.lastName ?? "", onChange: (e) => setField("lastName", e.target.value) })] }), _jsxs("label", { children: [labels.phoneLabel, _jsx("input", { type: "tel", name: "phone", maxLength: INBOUND_CONTACT_PHONE_MAX_LENGTH, value: values.phone ?? "", onChange: (e) => setField("phone", e.target.value) })] }), _jsxs("label", { children: [_jsx("input", { type: "checkbox", name: "marketingConsent", checked: values.marketingConsent, onChange: (e) => setField("marketingConsent", e.target.checked) }), labels.marketingConsentLabel] }), submit.status === "error" ? _jsx("div", { role: "alert", children: submit.message }) : null, _jsx("button", { type: "submit", disabled: isSubmitting, children: isSubmitting ? labels.submittingLabel : labels.submitLabel })] }));
|
|
76
94
|
}
|
|
77
95
|
//# sourceMappingURL=InboundContactForm.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"InboundContactForm.js","sourceRoot":"","sources":["../../../../../src/core/anonymous-session/InboundContactForm.tsx"],"names":[],"mappings":"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","sourcesContent":["/**\n * InboundContactForm — the marketing site's real lead-capture form (React island).\n *\n * Self-contained + bundle-safe: uses only `react`, the website-local input schema, and\n * a `WebsiteAnonymousSessionClient` instance (constructed by the section/bridge wiring\n * from the site's API-origin config). On submit it validates client-side, then POSTs\n * `/inbound-contacts` through the anonymous-session client — the FIRST submission mints\n * the anonymous session (init header) and the backend stamps the lead's `anonymousUserId`\n * so it can later transpose to the visitor's account on signup/login.\n *\n * Labels are injected (optional props with English fallbacks) rather than read from the\n * website runtime context, so the component stays framework-light and unit-testable; the\n * section wrapper passes i18n-resolved strings.\n */\n\nimport React, { useCallback, useMemo, useState } from \"react\";\nimport { InboundContactFormSchema, buildInboundContactCreateBody, type InboundContactFormValues } from \"./inbound-contact-form.schema\";\nimport type { WebsiteAnonymousSessionClient } from \"./website-anonymous-session-client\";\n\nexport interface InboundContactFormLabels {\n emailLabel: string;\n firstNameLabel: string;\n lastNameLabel: string;\n phoneLabel: string;\n /** OPTIONAL marketing opt-in checkbox. */\n marketingConsentLabel: string;\n submitLabel: string;\n submittingLabel: string;\n successMessage: string;\n errorMessage: string;\n}\n\nconst DEFAULT_LABELS: InboundContactFormLabels = {\n emailLabel: \"Email\",\n firstNameLabel: \"First name\",\n lastNameLabel: \"Last name\",\n phoneLabel: \"Phone\",\n marketingConsentLabel: \"Keep me updated about news and offers\",\n submitLabel: \"Get in touch\",\n submittingLabel: \"Sending…\",\n successMessage: \"Thanks — we'll be in touch shortly.\",\n errorMessage: \"Something went wrong. Please try again.\",\n};\n\ntype SubmitState = { status: \"idle\" } | { status: \"submitting\" } | { status: \"success\" } | { status: \"error\"; message: string };\n\nexport interface InboundContactFormProps {\n /** The anonymous-session client (constructed by the section wiring from site config). */\n client: WebsiteAnonymousSessionClient;\n /** Optional i18n-resolved labels; English fallbacks are used per-field when omitted. */\n labels?: Partial<InboundContactFormLabels>;\n /** Optional callback fired after a successful submission (e.g. analytics). */\n onSubmitted?: () => void;\n}\n\nconst EMPTY_VALUES: InboundContactFormValues = {\n email: \"\",\n firstName: \"\",\n lastName: \"\",\n phone: \"\",\n marketingConsent: false,\n};\n\nexport function InboundContactForm({ client, labels, onSubmitted }: InboundContactFormProps): React.ReactElement {\n const resolvedLabels = useMemo(() => ({ ...DEFAULT_LABELS, ...labels }), [labels]);\n const [values, setValues] = useState<InboundContactFormValues>(EMPTY_VALUES);\n const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof InboundContactFormValues, string>>>({});\n const [submit, setSubmit] = useState<SubmitState>({ status: \"idle\" });\n\n const setField = useCallback(<K extends keyof InboundContactFormValues>(key: K, value: InboundContactFormValues[K]) => {\n setValues((prev) => ({ ...prev, [key]: value }));\n }, []);\n\n const handleSubmit = useCallback(\n async (event: React.FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n setFieldErrors({});\n\n const parsed = InboundContactFormSchema.safeParse(values);\n if (!parsed.success) {\n const nextErrors: Partial<Record<keyof InboundContactFormValues, string>> = {};\n for (const issue of parsed.error.issues) {\n const key = issue.path[0] as keyof InboundContactFormValues | undefined;\n if (key && !nextErrors[key]) nextErrors[key] = issue.message;\n }\n setFieldErrors(nextErrors);\n return;\n }\n\n setSubmit({ status: \"submitting\" });\n try {\n await client.postAnonymous(\"/inbound-contacts\", buildInboundContactCreateBody(parsed.data));\n setSubmit({ status: \"success\" });\n setValues(EMPTY_VALUES);\n onSubmitted?.();\n } catch {\n // The backend-only `emailUserMatch` triage signal is never returned here, so a\n // failure exposes no account-existence information — just a generic retry.\n setSubmit({ status: \"error\", message: resolvedLabels.errorMessage });\n }\n },\n [client, values, onSubmitted, resolvedLabels.errorMessage],\n );\n\n if (submit.status === \"success\") {\n return (\n <div role=\"status\" aria-live=\"polite\">\n {resolvedLabels.successMessage}\n </div>\n );\n }\n\n const isSubmitting = submit.status === \"submitting\";\n\n return (\n <form onSubmit={handleSubmit} noValidate>\n <label>\n {resolvedLabels.emailLabel}\n <input\n type=\"email\"\n name=\"email\"\n required\n value={values.email}\n onChange={(e) => setField(\"email\", e.target.value)}\n aria-invalid={fieldErrors.email ? true : undefined}\n />\n {fieldErrors.email ? <span role=\"alert\">{fieldErrors.email}</span> : null}\n </label>\n\n <label>\n {resolvedLabels.firstNameLabel}\n <input type=\"text\" name=\"firstName\" value={values.firstName ?? \"\"} onChange={(e) => setField(\"firstName\", e.target.value)} />\n </label>\n\n <label>\n {resolvedLabels.lastNameLabel}\n <input type=\"text\" name=\"lastName\" value={values.lastName ?? \"\"} onChange={(e) => setField(\"lastName\", e.target.value)} />\n </label>\n\n <label>\n {resolvedLabels.phoneLabel}\n <input type=\"tel\" name=\"phone\" value={values.phone ?? \"\"} onChange={(e) => setField(\"phone\", e.target.value)} />\n </label>\n\n <label>\n <input type=\"checkbox\" name=\"marketingConsent\" checked={values.marketingConsent} onChange={(e) => setField(\"marketingConsent\", e.target.checked)} />\n {resolvedLabels.marketingConsentLabel}\n </label>\n\n {submit.status === \"error\" ? <div role=\"alert\">{submit.message}</div> : null}\n\n <button type=\"submit\" disabled={isSubmitting}>\n {isSubmitting ? resolvedLabels.submittingLabel : resolvedLabels.submitLabel}\n </button>\n </form>\n );\n}\n"]}
|
|
1
|
+
{"version":3,"file":"InboundContactForm.js","sourceRoot":"","sources":["../../../../src/core/anonymous-session/InboundContactForm.tsx"],"names":[],"mappings":"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","sourcesContent":["/**\n * InboundContactForm — the marketing site's real lead-capture form (React island).\n *\n * Self-contained + bundle-safe: uses only `react`, the website-local input schema, and\n * a `WebsiteAnonymousSessionClient` instance (constructed by the section/bridge wiring\n * from the site's API-origin config). On submit it validates client-side, then POSTs\n * `/inbound-contacts` through the anonymous-session client — the FIRST submission mints\n * the anonymous session (init header) and the backend stamps the lead's `anonymousUserId`\n * so it can later transpose to the visitor's account on signup/login.\n *\n * Labels are injected rather than read from the website runtime context, so the component stays framework-light and\n * unit-testable; the section wrapper passes the strings it resolved through `useWebsiteLabel`. EVERY label is required\n * and the form carries no words of its own (#1986): a fallback would be English in every locale and would hide the\n * missing label, while a required prop makes an omission a type error in the section, and a key the pack does not\n * carry reaches the page as the key itself — which is what `useWebsiteLabel` returns, so somebody authors it.\n *\n * Submission challenge (#1735): when the application protects the form with a captcha, each submission carries an\n * answer — the consented vendor's token when its browser part runs on the page, otherwise the engine's proof of work,\n * which needs no consent (see `website-submission-challenge.ts`). Nothing for the visitor to click or read: both are\n * invisible, so the form stays operable by keyboard and screen reader exactly as before. A vendor token the backend\n * refuses is retried once with the proof of work; a refusal that survives that shows `challengeRefusedMessage`.\n */\n\nimport React, { useCallback, useState } from \"react\";\nimport { SubmissionChallengeAction, WildoHeaderKeys } from \"@wildo-ai/saas-models/public-runtime\";\nimport {\n INBOUND_CONTACT_NAME_MAX_LENGTH,\n INBOUND_CONTACT_PHONE_MAX_LENGTH,\n InboundContactFormSchema,\n buildInboundContactCreateBody,\n type InboundContactFormValues,\n} from \"./inbound-contact-form.schema\";\nimport type { WebsiteAnonymousSessionClient } from \"./website-anonymous-session-client\";\nimport {\n isSubmissionChallengeRefusal,\n obtainSubmissionChallengeAnswer,\n useWebsiteSubmissionChallengeSource,\n type WebsiteSubmissionChallengeSource,\n} from \"./website-submission-challenge\";\n\n/**\n * Every word the form shows. All required (#1986): the section resolves each through `useWebsiteLabel` and lists its\n * key in `expectedLabelKeys`, so a missing label is a type error in the section or a visible key on the page, never a\n * silent English substitute.\n */\nexport interface InboundContactFormLabels {\n emailLabel: string;\n firstNameLabel: string;\n lastNameLabel: string;\n phoneLabel: string;\n /** OPTIONAL marketing opt-in checkbox. */\n marketingConsentLabel: string;\n submitLabel: string;\n submittingLabel: string;\n successMessage: string;\n errorMessage: string;\n /** The anti-spam check did not pass, even with the proof of work (#1735). */\n challengeRefusedMessage: string;\n /** Shown beside the email field when what was typed is not an email address (#1986). */\n invalidEmailMessage: string;\n}\n\ntype SubmitState = { status: \"idle\" } | { status: \"submitting\" } | { status: \"success\" } | { status: \"error\"; message: string };\n\nexport interface InboundContactFormProps {\n /** The anonymous-session client (constructed by the section wiring from site config). */\n client: WebsiteAnonymousSessionClient;\n /** The section's i18n-resolved labels — every one of them (#1986). */\n labels: InboundContactFormLabels;\n /** Optional callback fired after a successful submission (e.g. analytics). */\n onSubmitted?: () => void;\n /** The page's captcha, for a form rendered outside a page layout (tests). Defaults to the page's own. */\n challengeSource?: WebsiteSubmissionChallengeSource;\n}\n\nconst EMPTY_VALUES: InboundContactFormValues = {\n email: \"\",\n firstName: \"\",\n lastName: \"\",\n phone: \"\",\n marketingConsent: false,\n};\n\nexport function InboundContactForm({ client, labels, onSubmitted, challengeSource }: InboundContactFormProps): React.ReactElement {\n const pageChallengeSource = useWebsiteSubmissionChallengeSource();\n const source = challengeSource ?? pageChallengeSource;\n const [values, setValues] = useState<InboundContactFormValues>(EMPTY_VALUES);\n const [emailInvalid, setEmailInvalid] = useState(false);\n const [submit, setSubmit] = useState<SubmitState>({ status: \"idle\" });\n\n const setField = useCallback(<K extends keyof InboundContactFormValues>(key: K, value: InboundContactFormValues[K]) => {\n setValues((prev) => ({ ...prev, [key]: value }));\n }, []);\n\n const handleSubmit = useCallback(\n async (event: React.FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n setEmailInvalid(false);\n\n const parsed = InboundContactFormSchema.safeParse(values);\n if (!parsed.success) {\n // The schema's own issue messages are never shown: they are English and nobody authors them (#1986). The email\n // is the one field a visitor can get wrong, and it has its label. The other fields' only rule is a length the\n // inputs already enforce with `maxLength`, so reaching a failure there means the values did not come from\n // typing, and the form answers with its generic error rather than inventing a sentence.\n const emailFailed = parsed.error.issues.some((issue) => issue.path[0] === \"email\");\n setEmailInvalid(emailFailed);\n if (!emailFailed) setSubmit({ status: \"error\", message: labels.errorMessage });\n return;\n }\n\n setSubmit({ status: \"submitting\" });\n const body = buildInboundContactCreateBody(parsed.data);\n const send = async (proofOfWorkOnly: boolean): Promise<void> => {\n const answer = await obtainSubmissionChallengeAnswer({ source, action: SubmissionChallengeAction.INBOUND_CONTACT, client, proofOfWorkOnly });\n await client.postAnonymous(\"/inbound-contacts\", body, answer === undefined ? undefined : { [WildoHeaderKeys.SUBMISSION_CHALLENGE_RESPONSE]: answer });\n };\n try {\n try {\n await send(false);\n } catch (error) {\n // A refused challenge is answered ONCE more, with the proof of work: a vendor that scored a real person low,\n // could not be reached, or a site that did not know the backend protects the form must not lock anyone out.\n if (!isSubmissionChallengeRefusal(error)) throw error;\n await send(true);\n }\n setSubmit({ status: \"success\" });\n setValues(EMPTY_VALUES);\n onSubmitted?.();\n } catch (error) {\n // The backend-only `emailUserMatch` triage signal is never returned here, so a\n // failure exposes no account-existence information — just a generic retry.\n setSubmit({\n status: \"error\",\n message: isSubmissionChallengeRefusal(error) ? labels.challengeRefusedMessage : labels.errorMessage,\n });\n }\n },\n [client, values, onSubmitted, source, labels.errorMessage, labels.challengeRefusedMessage],\n );\n\n if (submit.status === \"success\") {\n return (\n <div role=\"status\" aria-live=\"polite\">\n {labels.successMessage}\n </div>\n );\n }\n\n const isSubmitting = submit.status === \"submitting\";\n\n return (\n <form onSubmit={handleSubmit} noValidate>\n <label>\n {labels.emailLabel}\n <input\n type=\"email\"\n name=\"email\"\n required\n value={values.email}\n onChange={(e) => setField(\"email\", e.target.value)}\n aria-invalid={emailInvalid ? true : undefined}\n />\n {emailInvalid ? <span role=\"alert\">{labels.invalidEmailMessage}</span> : null}\n </label>\n\n <label>\n {labels.firstNameLabel}\n <input\n type=\"text\"\n name=\"firstName\"\n maxLength={INBOUND_CONTACT_NAME_MAX_LENGTH}\n value={values.firstName ?? \"\"}\n onChange={(e) => setField(\"firstName\", e.target.value)}\n />\n </label>\n\n <label>\n {labels.lastNameLabel}\n <input\n type=\"text\"\n name=\"lastName\"\n maxLength={INBOUND_CONTACT_NAME_MAX_LENGTH}\n value={values.lastName ?? \"\"}\n onChange={(e) => setField(\"lastName\", e.target.value)}\n />\n </label>\n\n <label>\n {labels.phoneLabel}\n <input\n type=\"tel\"\n name=\"phone\"\n maxLength={INBOUND_CONTACT_PHONE_MAX_LENGTH}\n value={values.phone ?? \"\"}\n onChange={(e) => setField(\"phone\", e.target.value)}\n />\n </label>\n\n <label>\n <input type=\"checkbox\" name=\"marketingConsent\" checked={values.marketingConsent} onChange={(e) => setField(\"marketingConsent\", e.target.checked)} />\n {labels.marketingConsentLabel}\n </label>\n\n {submit.status === \"error\" ? <div role=\"alert\">{submit.message}</div> : null}\n\n <button type=\"submit\" disabled={isSubmitting}>\n {isSubmitting ? labels.submittingLabel : labels.submitLabel}\n </button>\n </form>\n );\n}\n"]}
|
|
@@ -19,10 +19,21 @@
|
|
|
19
19
|
* resource (sales/admin-only, never client-authored).
|
|
20
20
|
*/
|
|
21
21
|
import { z } from "zod";
|
|
22
|
+
/**
|
|
23
|
+
* Length ceilings of the optional text fields. Exported so the form puts the SAME numbers on its inputs as
|
|
24
|
+
* `maxLength`: the browser then stops a visitor at the ceiling, and a length failure is not something the form ever
|
|
25
|
+
* has to word (#1986).
|
|
26
|
+
*/
|
|
27
|
+
export declare const INBOUND_CONTACT_NAME_MAX_LENGTH = 200;
|
|
28
|
+
export declare const INBOUND_CONTACT_PHONE_MAX_LENGTH = 64;
|
|
22
29
|
/**
|
|
23
30
|
* The wire payload POSTed to `/api/v1/inbound-contacts`. `privacy` matches the
|
|
24
31
|
* resource's `InboundContactPrivacySchema` shape (a nested consent object) so the
|
|
25
32
|
* backend accepts it verbatim.
|
|
33
|
+
*
|
|
34
|
+
* No issue carries a message (#1986). A visitor reads the application's own words, which the form takes from its
|
|
35
|
+
* section's label pack (`InboundContactFormLabels.invalidEmailMessage`); a message written here would be English in
|
|
36
|
+
* every locale, and nobody authoring the pack could find it.
|
|
26
37
|
*/
|
|
27
38
|
export declare const InboundContactFormSchema: z.ZodObject<{
|
|
28
39
|
email: z.ZodEmail;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inbound-contact-form.schema.d.ts","sourceRoot":"","sources":["
|
|
1
|
+
{"version":3,"file":"inbound-contact-form.schema.d.ts","sourceRoot":"","sources":["../../../../src/core/anonymous-session/inbound-contact-form.schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;GAIG;AACH,eAAO,MAAM,+BAA+B,MAAM,CAAC;AACnD,eAAO,MAAM,gCAAgC,KAAK,CAAC;AAEnD;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB;;;;;;iBAcnC,CAAC;AAEH,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAEhF;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,wBAAwB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAcvG"}
|
|
@@ -20,18 +20,29 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { z } from "zod";
|
|
22
22
|
import { ConsentPurpose } from "@wildo-ai/saas-models/public-runtime";
|
|
23
|
+
/**
|
|
24
|
+
* Length ceilings of the optional text fields. Exported so the form puts the SAME numbers on its inputs as
|
|
25
|
+
* `maxLength`: the browser then stops a visitor at the ceiling, and a length failure is not something the form ever
|
|
26
|
+
* has to word (#1986).
|
|
27
|
+
*/
|
|
28
|
+
export const INBOUND_CONTACT_NAME_MAX_LENGTH = 200;
|
|
29
|
+
export const INBOUND_CONTACT_PHONE_MAX_LENGTH = 64;
|
|
23
30
|
/**
|
|
24
31
|
* The wire payload POSTed to `/api/v1/inbound-contacts`. `privacy` matches the
|
|
25
32
|
* resource's `InboundContactPrivacySchema` shape (a nested consent object) so the
|
|
26
33
|
* backend accepts it verbatim.
|
|
34
|
+
*
|
|
35
|
+
* No issue carries a message (#1986). A visitor reads the application's own words, which the form takes from its
|
|
36
|
+
* section's label pack (`InboundContactFormLabels.invalidEmailMessage`); a message written here would be English in
|
|
37
|
+
* every locale, and nobody authoring the pack could find it.
|
|
27
38
|
*/
|
|
28
39
|
export const InboundContactFormSchema = z.object({
|
|
29
40
|
// Zod 4: top-level `z.email()` (not the deprecated `z.string().email()`), matching
|
|
30
41
|
// the resource schema. An empty string fails it, so it also covers "required".
|
|
31
|
-
email: z.email(
|
|
32
|
-
firstName: z.string().trim().max(
|
|
33
|
-
lastName: z.string().trim().max(
|
|
34
|
-
phone: z.string().trim().max(
|
|
42
|
+
email: z.email(),
|
|
43
|
+
firstName: z.string().trim().max(INBOUND_CONTACT_NAME_MAX_LENGTH).optional(),
|
|
44
|
+
lastName: z.string().trim().max(INBOUND_CONTACT_NAME_MAX_LENGTH).optional(),
|
|
45
|
+
phone: z.string().trim().max(INBOUND_CONTACT_PHONE_MAX_LENGTH).optional(),
|
|
35
46
|
/**
|
|
36
47
|
* OPTIONAL marketing opt-in — maps into the resource's `privacy.consentedPurposes` set.
|
|
37
48
|
* It is the only privacy field on this form, and it gates nothing: sending the form is the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inbound-contact-form.schema.js","sourceRoot":"","sources":["
|
|
1
|
+
{"version":3,"file":"inbound-contact-form.schema.js","sourceRoot":"","sources":["../../../../src/core/anonymous-session/inbound-contact-form.schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAEtE;;;;GAIG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,GAAG,CAAC;AACnD,MAAM,CAAC,MAAM,gCAAgC,GAAG,EAAE,CAAC;AAEnD;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC/C,mFAAmF;IACnF,+EAA+E;IAC/E,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE;IAChB,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,+BAA+B,CAAC,CAAC,QAAQ,EAAE;IAC5E,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,+BAA+B,CAAC,CAAC,QAAQ,EAAE;IAC3E,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,gCAAgC,CAAC,CAAC,QAAQ,EAAE;IACzE;;;;;OAKG;IACH,gBAAgB,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC;CAC7C,CAAC,CAAC;AAIH;;;;GAIG;AACH,MAAM,UAAU,6BAA6B,CAAC,MAAgC;IAC5E,OAAO;QACL,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE;QACxC,GAAG,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5D,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzD,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAChD,OAAO,EAAE;YACP,6FAA6F;YAC7F,0FAA0F;YAC1F,4FAA4F;YAC5F,iBAAiB,EAAE,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,EAAE;YAC5E,gBAAgB,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;SAC3C;KACF,CAAC;AACJ,CAAC","sourcesContent":["/**\n * Inbound-contact form input schema (website-local, bundle-safe).\n *\n * Deliberately a SEPARATE plain-Zod schema, NOT `InboundContactSchema` from\n * `@wildo-ai/saas-models`: that schema is decorator-bound (`.isPrimaryKey()`,\n * `.excludeFromCreate()`, `.isBackendOnly()`, …), so importing it would boot the\n * decorator framework into the browser bundle — forbidden by bundle discipline. The\n * website only needs to validate the handful of client-authored CREATE fields; the\n * backend is the authority on the full contract (it re-validates + injects the\n * server-set fields: `_id`, `anonymousUserId`, `userId`, `emailUserMatch`, timestamps).\n *\n * Mirrors the non-excluded, non-backend-only CREATE inputs of `InboundContact`\n * (`email` required; `firstName` / `lastName` / `phone` optional), plus ONE privacy\n * signal: an OPTIONAL marketing opt-in (mapped to the `privacy.consentedPurposes` set).\n * There is no second, required agreement field: a governed notice, contract or\n * consent interaction must use the separate exact-release receipt contract, because this\n * DTO cannot manufacture legal evidence from a checkbox and a client timestamp.\n * `relatedUserId` is intentionally absent — it is `.excludeFromCreate()` on the\n * resource (sales/admin-only, never client-authored).\n */\n\nimport { z } from \"zod\";\n\nimport { ConsentPurpose } from \"@wildo-ai/saas-models/public-runtime\";\n\n/**\n * Length ceilings of the optional text fields. Exported so the form puts the SAME numbers on its inputs as\n * `maxLength`: the browser then stops a visitor at the ceiling, and a length failure is not something the form ever\n * has to word (#1986).\n */\nexport const INBOUND_CONTACT_NAME_MAX_LENGTH = 200;\nexport const INBOUND_CONTACT_PHONE_MAX_LENGTH = 64;\n\n/**\n * The wire payload POSTed to `/api/v1/inbound-contacts`. `privacy` matches the\n * resource's `InboundContactPrivacySchema` shape (a nested consent object) so the\n * backend accepts it verbatim.\n *\n * No issue carries a message (#1986). A visitor reads the application's own words, which the form takes from its\n * section's label pack (`InboundContactFormLabels.invalidEmailMessage`); a message written here would be English in\n * every locale, and nobody authoring the pack could find it.\n */\nexport const InboundContactFormSchema = z.object({\n // Zod 4: top-level `z.email()` (not the deprecated `z.string().email()`), matching\n // the resource schema. An empty string fails it, so it also covers \"required\".\n email: z.email(),\n firstName: z.string().trim().max(INBOUND_CONTACT_NAME_MAX_LENGTH).optional(),\n lastName: z.string().trim().max(INBOUND_CONTACT_NAME_MAX_LENGTH).optional(),\n phone: z.string().trim().max(INBOUND_CONTACT_PHONE_MAX_LENGTH).optional(),\n /**\n * OPTIONAL marketing opt-in — maps into the resource's `privacy.consentedPurposes` set.\n * It is the only privacy field on this form, and it gates nothing: sending the form is the\n * essential act and never depends on optional consent, so an unchecked box still submits,\n * granting nothing.\n */\n marketingConsent: z.boolean().default(false),\n});\n\nexport type InboundContactFormValues = z.infer<typeof InboundContactFormSchema>;\n\n/**\n * Build the exact JSON body the backend CREATE expects from validated form values.\n * Keeps the `privacy` nesting + the consent purpose mapping in ONE place so the form\n * component stays declarative.\n */\nexport function buildInboundContactCreateBody(values: InboundContactFormValues): Record<string, unknown> {\n return {\n email: values.email.trim().toLowerCase(),\n ...(values.firstName ? { firstName: values.firstName } : {}),\n ...(values.lastName ? { lastName: values.lastName } : {}),\n ...(values.phone ? { phone: values.phone } : {}),\n privacy: {\n // The visitor SAW the opt-in and submitted, so this is a decision either way (#534): the set\n // they granted — MARKETING or nothing — plus a CLAIM that they decided now. The backend's\n // CREATE prefix replaces the instant with its own clock; a browser clock is never recorded.\n consentedPurposes: values.marketingConsent ? [ConsentPurpose.MARKETING] : [],\n consentDecidedAt: new Date().toISOString(),\n },\n };\n}\n"]}
|
|
@@ -39,6 +39,11 @@ export declare class WebsiteAnonymousRequestError extends Error {
|
|
|
39
39
|
readonly status: number;
|
|
40
40
|
readonly bodyText: string;
|
|
41
41
|
constructor(status: number, bodyText: string);
|
|
42
|
+
/**
|
|
43
|
+
* The refusal's stable `error.code` — what a client branches on (the engine hoists an error's `context.code` there) —
|
|
44
|
+
* or `undefined` when the body is not the engine's error envelope or carries no code.
|
|
45
|
+
*/
|
|
46
|
+
get errorCode(): string | undefined;
|
|
42
47
|
}
|
|
43
48
|
export declare class WebsiteAnonymousSessionClient {
|
|
44
49
|
private anonymousUserId;
|
|
@@ -71,7 +76,12 @@ export declare class WebsiteAnonymousSessionClient {
|
|
|
71
76
|
* service-name headers, captures any returned session token, and returns the parsed
|
|
72
77
|
* JSON response. Throws `WebsiteAnonymousRequestError` on a non-2xx status.
|
|
73
78
|
*/
|
|
74
|
-
postAnonymous<TResponse = unknown>(path: string, body: unknown): Promise<TResponse>;
|
|
79
|
+
postAnonymous<TResponse = unknown>(path: string, body: unknown, extraHeaders?: Readonly<Record<string, string>>): Promise<TResponse>;
|
|
80
|
+
/**
|
|
81
|
+
* GET a PUBLIC endpoint — one that needs no session (a proof-of-work challenge, #1735). Starts no anonymous session
|
|
82
|
+
* and sends no session header or cookie: asking for a challenge must not mint an identity for the visitor.
|
|
83
|
+
*/
|
|
84
|
+
getPublic<TResponse = unknown>(path: string): Promise<TResponse>;
|
|
75
85
|
/**
|
|
76
86
|
* Send `body` to an anonymous endpoint with an explicit METHOD.
|
|
77
87
|
*
|
|
@@ -82,7 +92,9 @@ export declare class WebsiteAnonymousSessionClient {
|
|
|
82
92
|
* the same place it reads the path (`ANONYMOUS_CONSENT_RECORD_METHOD` beside
|
|
83
93
|
* `ANONYMOUS_CONSENT_RECORD_PATH`) rather than inferring it from a method name.
|
|
84
94
|
*/
|
|
85
|
-
requestAnonymous<TResponse = unknown>(method: string, path: string, body: unknown
|
|
95
|
+
requestAnonymous<TResponse = unknown>(method: string, path: string, body: unknown,
|
|
96
|
+
/** Request-specific headers, such as a public form's challenge answer (#1735). Never overrides the session headers. */
|
|
97
|
+
extraHeaders?: Readonly<Record<string, string>>): Promise<TResponse>;
|
|
86
98
|
private ensureSessionStarted;
|
|
87
99
|
private buildHeaders;
|
|
88
100
|
private captureSessionToken;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"website-anonymous-session-client.d.ts","sourceRoot":"","sources":["
|
|
1
|
+
{"version":3,"file":"website-anonymous-session-client.d.ts","sourceRoot":"","sources":["../../../../src/core/anonymous-session/website-anonymous-session-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAeH,MAAM,WAAW,oCAAoC;IACnD,mFAAmF;IACnF,SAAS,EAAE,MAAM,CAAC;IAClB,sFAAsF;IACtF,mBAAmB,EAAE,MAAM,CAAC;CAC7B;AAED,iEAAiE;AACjE,qBAAa,4BAA6B,SAAQ,KAAK;aAEnC,MAAM,EAAE,MAAM;aACd,QAAQ,EAAE,MAAM;gBADhB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM;IAMlC;;;OAGG;IACH,IAAI,SAAS,IAAI,MAAM,GAAG,SAAS,CAOlC;CACF;AAED,qBAAa,6BAA6B;IACxC,OAAO,CAAC,eAAe,CAAuB;IAC9C,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;gBAEjC,OAAO,EAAE,oCAAoC;IAMzD,8FAA8F;IAC9F,kBAAkB,IAAI,MAAM,GAAG,IAAI;IAInC;;;;;;;;;;;;OAYG;IACH,yBAAyB,IAAI,MAAM;IAMnC,6EAA6E;IAC7E,eAAe,IAAI,OAAO;IAI1B;;;;;;OAMG;IACG,aAAa,CAAC,SAAS,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,OAAO,CAAC,SAAS,CAAC;IAI1I;;;OAGG;IACG,SAAS,CAAC,SAAS,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;IAYtE;;;;;;;;;OASG;IACG,gBAAgB,CAAC,SAAS,GAAG,OAAO,EACxC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,OAAO;IACb,uHAAuH;IACvH,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAC9C,OAAO,CAAC,SAAS,CAAC;IAoBrB,OAAO,CAAC,oBAAoB;IAQ5B,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,mBAAmB;IAS3B,OAAO,CAAC,kBAAkB;IAY1B,OAAO,CAAC,OAAO;CAYhB"}
|
|
@@ -49,6 +49,19 @@ export class WebsiteAnonymousRequestError extends Error {
|
|
|
49
49
|
this.bodyText = bodyText;
|
|
50
50
|
this.name = 'WebsiteAnonymousRequestError';
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The refusal's stable `error.code` — what a client branches on (the engine hoists an error's `context.code` there) —
|
|
54
|
+
* or `undefined` when the body is not the engine's error envelope or carries no code.
|
|
55
|
+
*/
|
|
56
|
+
get errorCode() {
|
|
57
|
+
try {
|
|
58
|
+
const code = JSON.parse(this.bodyText).error?.code;
|
|
59
|
+
return typeof code === 'string' ? code : undefined;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
52
65
|
}
|
|
53
66
|
export class WebsiteAnonymousSessionClient {
|
|
54
67
|
anonymousUserId = null;
|
|
@@ -93,8 +106,23 @@ export class WebsiteAnonymousSessionClient {
|
|
|
93
106
|
* service-name headers, captures any returned session token, and returns the parsed
|
|
94
107
|
* JSON response. Throws `WebsiteAnonymousRequestError` on a non-2xx status.
|
|
95
108
|
*/
|
|
96
|
-
async postAnonymous(path, body) {
|
|
97
|
-
return this.requestAnonymous('POST', path, body);
|
|
109
|
+
async postAnonymous(path, body, extraHeaders) {
|
|
110
|
+
return this.requestAnonymous('POST', path, body, extraHeaders);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* GET a PUBLIC endpoint — one that needs no session (a proof-of-work challenge, #1735). Starts no anonymous session
|
|
114
|
+
* and sends no session header or cookie: asking for a challenge must not mint an identity for the visitor.
|
|
115
|
+
*/
|
|
116
|
+
async getPublic(path) {
|
|
117
|
+
const response = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
118
|
+
method: 'GET',
|
|
119
|
+
headers: { [WildoHeaderKeys.FRONTEND_SERVICE_NAME]: this.frontendServiceName },
|
|
120
|
+
credentials: 'omit',
|
|
121
|
+
});
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
throw new WebsiteAnonymousRequestError(response.status, await safeReadText(response));
|
|
124
|
+
}
|
|
125
|
+
return (await response.json());
|
|
98
126
|
}
|
|
99
127
|
/**
|
|
100
128
|
* Send `body` to an anonymous endpoint with an explicit METHOD.
|
|
@@ -106,11 +134,13 @@ export class WebsiteAnonymousSessionClient {
|
|
|
106
134
|
* the same place it reads the path (`ANONYMOUS_CONSENT_RECORD_METHOD` beside
|
|
107
135
|
* `ANONYMOUS_CONSENT_RECORD_PATH`) rather than inferring it from a method name.
|
|
108
136
|
*/
|
|
109
|
-
async requestAnonymous(method, path, body
|
|
137
|
+
async requestAnonymous(method, path, body,
|
|
138
|
+
/** Request-specific headers, such as a public form's challenge answer (#1735). Never overrides the session headers. */
|
|
139
|
+
extraHeaders) {
|
|
110
140
|
this.ensureSessionStarted();
|
|
111
141
|
const response = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
112
142
|
method,
|
|
113
|
-
headers: this.buildHeaders(),
|
|
143
|
+
headers: { ...extraHeaders, ...this.buildHeaders() },
|
|
114
144
|
body: JSON.stringify(body),
|
|
115
145
|
// Carry the parent-domain `__wildo_anon_session` cookie to the API so the
|
|
116
146
|
// session is continuous across the apex/app/api origins.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"website-anonymous-session-client.js","sourceRoot":"","sources":["../../../../../src/core/anonymous-session/website-anonymous-session-client.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * WebsiteAnonymousSessionClient\n *\n * A self-contained, browser-only anonymous-session HTTP client for the marketing\n * website. It MIRRORS the SaaS app's `AnonymousSessionContext` protocol WITHOUT\n * importing `@wildo-ai/saas-frontend-lib` — the website ships into a browser bundle\n * that must not pull the decorator framework / heavy frontend graph (bundle\n * discipline). It uses ONLY browser globals (`fetch` / `crypto` / `localStorage`) and\n * the bundle-safe `@wildo-ai/saas-models/public-runtime` atoms. No axios (axios is the\n * SSR tier), no React — a React island context can wrap this class.\n *\n * Protocol (W7.3a):\n * 1. The first anonymous request sends `x-anonymous-session-init: <clientUuid>` — the\n * backend mints an `AnonymousUser` (uuidRef = clientUuid) and returns a signed JWT\n * in the `x-anonymous-session-token` RESPONSE header.\n * 2. Subsequent requests send `x-anonymous-session: <jwt>`.\n * Every request also sends `X-Frontend-Service-Name` (required by the anon backend\n * to accept the request and resolve the frontend identity).\n *\n * CORS (critical): the website is a DIFFERENT origin from the backend API, so the\n * `x-anonymous-session-token` response header is readable by `fetch` ONLY if the\n * backend lists it in `Access-Control-Expose-Headers` (non-safelisted response headers\n * are hidden from cross-origin `fetch`). Without that, the captured token is `null`,\n * the session never advances past init, and every request re-mints a session.\n *\n * Cross-origin continuity: the anonymous session created here (uuidRef) is what the\n * SaaS app transposes on signup/login. It is carried across the apex↔app boundary by\n * the backend-set parent-domain `__wildo_anon_session` cookie — hence `credentials:\n * 'include'`, so that cookie rides same-site requests to the API.\n */\n\nimport { WildoHeaderKeys, MAIN_API_BASE_PATH } from '@wildo-ai/saas-models/public-runtime';\n\n/**\n * localStorage keys. The website is self-contained (mirror-don't-import), so it pins\n * the stable literal values rather than importing the decorator-adjacent shared\n * contract. Cross-origin continuity flows through the backend cookie, NOT this\n * localStorage, so these keys need not match the app's byte-for-byte.\n */\nconst STORAGE_KEYS = {\n anonymousUserId: 'anonymousUserId',\n anonymousSessionToken: 'anonymousSessionToken',\n} as const;\n\nexport interface WebsiteAnonymousSessionClientOptions {\n /** Backend API origin WITHOUT a trailing slash, e.g. `https://api.example.com`. */\n apiOrigin: string;\n /** `X-Frontend-Service-Name` value identifying this marketing site to the backend. */\n frontendServiceName: string;\n}\n\n/** Thrown when an anonymous request returns a non-2xx status. */\nexport class WebsiteAnonymousRequestError extends Error {\n constructor(\n public readonly status: number,\n public readonly bodyText: string,\n ) {\n super(`Anonymous request failed with status ${status}`);\n this.name = 'WebsiteAnonymousRequestError';\n }\n}\n\nexport class WebsiteAnonymousSessionClient {\n private anonymousUserId: string | null = null;\n private sessionToken: string | null = null;\n private readonly apiBaseUrl: string;\n private readonly frontendServiceName: string;\n\n constructor(options: WebsiteAnonymousSessionClientOptions) {\n this.apiBaseUrl = `${options.apiOrigin.replace(/\\/+$/, '')}${MAIN_API_BASE_PATH}`;\n this.frontendServiceName = options.frontendServiceName;\n this.restoreFromStorage();\n }\n\n /** The anonymous session's relational identity (client uuidRef), or `null` if not started. */\n getAnonymousUserId(): string | null {\n return this.anonymousUserId;\n }\n\n /**\n * Return the anonymous session's relational identity (client uuidRef),\n * STARTING the session first if it has not begun yet. Unlike\n * `getAnonymousUserId()` (which may return `null`), this always yields\n * a stable uuidRef.\n *\n * Used by NESTED anonymous routes that must embed the id in the URL\n * BEFORE the first request — e.g. draft-notes POSTs to\n * `/user-anonymous-users/<uuidRef>/draft-notes`. The id generated here\n * is the SAME one the next `postAnonymous(...)` sends as\n * `x-anonymous-session-init`, so the URL scope and the minted session\n * refer to one anonymous user (no split identity).\n */\n getOrStartAnonymousUserId(): string {\n this.ensureSessionStarted();\n // ensureSessionStarted() guarantees anonymousUserId is non-null.\n return this.anonymousUserId as string;\n }\n\n /** `true` once an init request has succeeded and a session token is held. */\n isSessionActive(): boolean {\n return this.anonymousUserId !== null && this.sessionToken !== null;\n }\n\n /**\n * POST `body` to an anonymous endpoint. `path` is relative to `/api/v1`\n * (e.g. `/inbound-contacts`, or a nested `/anonymous-users/<uuidRef>/draft-notes`).\n * Lazily starts the session on the first call, attaches the anon-session +\n * service-name headers, captures any returned session token, and returns the parsed\n * JSON response. Throws `WebsiteAnonymousRequestError` on a non-2xx status.\n */\n async postAnonymous<TResponse = unknown>(path: string, body: unknown): Promise<TResponse> {\n return this.requestAnonymous<TResponse>('POST', path, body);\n }\n\n /**\n * Send `body` to an anonymous endpoint with an explicit METHOD.\n *\n * `postAnonymous` above is the POST case and delegates here. This exists because not every anonymous\n * door is a POST and the method is not guessable from the path: the visitor consent door declares\n * `resourceOperationLike: UPDATE_MANY`, which the engine maps to PUT, so a POST to it answers 404 —\n * and this client's one silent caller swallowed exactly that. A caller reads the door's method from\n * the same place it reads the path (`ANONYMOUS_CONSENT_RECORD_METHOD` beside\n * `ANONYMOUS_CONSENT_RECORD_PATH`) rather than inferring it from a method name.\n */\n async requestAnonymous<TResponse = unknown>(method: string, path: string, body: unknown): Promise<TResponse> {\n this.ensureSessionStarted();\n\n const response = await fetch(`${this.apiBaseUrl}${path}`, {\n method,\n headers: this.buildHeaders(),\n body: JSON.stringify(body),\n // Carry the parent-domain `__wildo_anon_session` cookie to the API so the\n // session is continuous across the apex/app/api origins.\n credentials: 'include',\n });\n\n this.captureSessionToken(response);\n\n if (!response.ok) {\n throw new WebsiteAnonymousRequestError(response.status, await safeReadText(response));\n }\n return (await response.json()) as TResponse;\n }\n\n private ensureSessionStarted(): void {\n if (!this.anonymousUserId) {\n this.anonymousUserId = crypto.randomUUID();\n this.sessionToken = null;\n this.persist();\n }\n }\n\n private buildHeaders(): Record<string, string> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n [WildoHeaderKeys.FRONTEND_SERVICE_NAME]: this.frontendServiceName,\n };\n // Once we hold the JWT, restore the session; before that, the raw UUID inits it.\n if (this.sessionToken) {\n headers[WildoHeaderKeys.ANONYMOUS_SESSION] = this.sessionToken;\n } else if (this.anonymousUserId) {\n headers[WildoHeaderKeys.ANONYMOUS_SESSION_INIT] = this.anonymousUserId;\n }\n return headers;\n }\n\n private captureSessionToken(response: Response): void {\n // Requires the backend to CORS-expose this header (see the class-level CORS note).\n const token = response.headers.get(WildoHeaderKeys.ANONYMOUS_SESSION_TOKEN);\n if (token && token !== this.sessionToken) {\n this.sessionToken = token;\n this.persist();\n }\n }\n\n private restoreFromStorage(): void {\n try {\n const userId = localStorage.getItem(STORAGE_KEYS.anonymousUserId);\n const token = localStorage.getItem(STORAGE_KEYS.anonymousSessionToken);\n if (userId) this.anonymousUserId = userId;\n if (token) this.sessionToken = token;\n } catch {\n // localStorage unavailable (SSR build / privacy mode) — the session stays\n // in-memory for this page load only.\n }\n }\n\n private persist(): void {\n try {\n if (this.anonymousUserId) {\n localStorage.setItem(STORAGE_KEYS.anonymousUserId, this.anonymousUserId);\n }\n if (this.sessionToken) {\n localStorage.setItem(STORAGE_KEYS.anonymousSessionToken, this.sessionToken);\n }\n } catch {\n // Best-effort persistence; a failure only means the session won't survive reload.\n }\n }\n}\n\nasync function safeReadText(response: Response): Promise<string> {\n try {\n return await response.text();\n } catch {\n return '';\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"website-anonymous-session-client.js","sourceRoot":"","sources":["../../../../src/core/anonymous-session/website-anonymous-session-client.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * WebsiteAnonymousSessionClient\n *\n * A self-contained, browser-only anonymous-session HTTP client for the marketing\n * website. It MIRRORS the SaaS app's `AnonymousSessionContext` protocol WITHOUT\n * importing `@wildo-ai/saas-frontend-lib` — the website ships into a browser bundle\n * that must not pull the decorator framework / heavy frontend graph (bundle\n * discipline). It uses ONLY browser globals (`fetch` / `crypto` / `localStorage`) and\n * the bundle-safe `@wildo-ai/saas-models/public-runtime` atoms. No axios (axios is the\n * SSR tier), no React — a React island context can wrap this class.\n *\n * Protocol (W7.3a):\n * 1. The first anonymous request sends `x-anonymous-session-init: <clientUuid>` — the\n * backend mints an `AnonymousUser` (uuidRef = clientUuid) and returns a signed JWT\n * in the `x-anonymous-session-token` RESPONSE header.\n * 2. Subsequent requests send `x-anonymous-session: <jwt>`.\n * Every request also sends `X-Frontend-Service-Name` (required by the anon backend\n * to accept the request and resolve the frontend identity).\n *\n * CORS (critical): the website is a DIFFERENT origin from the backend API, so the\n * `x-anonymous-session-token` response header is readable by `fetch` ONLY if the\n * backend lists it in `Access-Control-Expose-Headers` (non-safelisted response headers\n * are hidden from cross-origin `fetch`). Without that, the captured token is `null`,\n * the session never advances past init, and every request re-mints a session.\n *\n * Cross-origin continuity: the anonymous session created here (uuidRef) is what the\n * SaaS app transposes on signup/login. It is carried across the apex↔app boundary by\n * the backend-set parent-domain `__wildo_anon_session` cookie — hence `credentials:\n * 'include'`, so that cookie rides same-site requests to the API.\n */\n\nimport { WildoHeaderKeys, MAIN_API_BASE_PATH } from '@wildo-ai/saas-models/public-runtime';\n\n/**\n * localStorage keys. The website is self-contained (mirror-don't-import), so it pins\n * the stable literal values rather than importing the decorator-adjacent shared\n * contract. Cross-origin continuity flows through the backend cookie, NOT this\n * localStorage, so these keys need not match the app's byte-for-byte.\n */\nconst STORAGE_KEYS = {\n anonymousUserId: 'anonymousUserId',\n anonymousSessionToken: 'anonymousSessionToken',\n} as const;\n\nexport interface WebsiteAnonymousSessionClientOptions {\n /** Backend API origin WITHOUT a trailing slash, e.g. `https://api.example.com`. */\n apiOrigin: string;\n /** `X-Frontend-Service-Name` value identifying this marketing site to the backend. */\n frontendServiceName: string;\n}\n\n/** Thrown when an anonymous request returns a non-2xx status. */\nexport class WebsiteAnonymousRequestError extends Error {\n constructor(\n public readonly status: number,\n public readonly bodyText: string,\n ) {\n super(`Anonymous request failed with status ${status}`);\n this.name = 'WebsiteAnonymousRequestError';\n }\n\n /**\n * The refusal's stable `error.code` — what a client branches on (the engine hoists an error's `context.code` there) —\n * or `undefined` when the body is not the engine's error envelope or carries no code.\n */\n get errorCode(): string | undefined {\n try {\n const code = (JSON.parse(this.bodyText) as { error?: { code?: unknown } }).error?.code;\n return typeof code === 'string' ? code : undefined;\n } catch {\n return undefined;\n }\n }\n}\n\nexport class WebsiteAnonymousSessionClient {\n private anonymousUserId: string | null = null;\n private sessionToken: string | null = null;\n private readonly apiBaseUrl: string;\n private readonly frontendServiceName: string;\n\n constructor(options: WebsiteAnonymousSessionClientOptions) {\n this.apiBaseUrl = `${options.apiOrigin.replace(/\\/+$/, '')}${MAIN_API_BASE_PATH}`;\n this.frontendServiceName = options.frontendServiceName;\n this.restoreFromStorage();\n }\n\n /** The anonymous session's relational identity (client uuidRef), or `null` if not started. */\n getAnonymousUserId(): string | null {\n return this.anonymousUserId;\n }\n\n /**\n * Return the anonymous session's relational identity (client uuidRef),\n * STARTING the session first if it has not begun yet. Unlike\n * `getAnonymousUserId()` (which may return `null`), this always yields\n * a stable uuidRef.\n *\n * Used by NESTED anonymous routes that must embed the id in the URL\n * BEFORE the first request — e.g. draft-notes POSTs to\n * `/user-anonymous-users/<uuidRef>/draft-notes`. The id generated here\n * is the SAME one the next `postAnonymous(...)` sends as\n * `x-anonymous-session-init`, so the URL scope and the minted session\n * refer to one anonymous user (no split identity).\n */\n getOrStartAnonymousUserId(): string {\n this.ensureSessionStarted();\n // ensureSessionStarted() guarantees anonymousUserId is non-null.\n return this.anonymousUserId as string;\n }\n\n /** `true` once an init request has succeeded and a session token is held. */\n isSessionActive(): boolean {\n return this.anonymousUserId !== null && this.sessionToken !== null;\n }\n\n /**\n * POST `body` to an anonymous endpoint. `path` is relative to `/api/v1`\n * (e.g. `/inbound-contacts`, or a nested `/anonymous-users/<uuidRef>/draft-notes`).\n * Lazily starts the session on the first call, attaches the anon-session +\n * service-name headers, captures any returned session token, and returns the parsed\n * JSON response. Throws `WebsiteAnonymousRequestError` on a non-2xx status.\n */\n async postAnonymous<TResponse = unknown>(path: string, body: unknown, extraHeaders?: Readonly<Record<string, string>>): Promise<TResponse> {\n return this.requestAnonymous<TResponse>('POST', path, body, extraHeaders);\n }\n\n /**\n * GET a PUBLIC endpoint — one that needs no session (a proof-of-work challenge, #1735). Starts no anonymous session\n * and sends no session header or cookie: asking for a challenge must not mint an identity for the visitor.\n */\n async getPublic<TResponse = unknown>(path: string): Promise<TResponse> {\n const response = await fetch(`${this.apiBaseUrl}${path}`, {\n method: 'GET',\n headers: { [WildoHeaderKeys.FRONTEND_SERVICE_NAME]: this.frontendServiceName },\n credentials: 'omit',\n });\n if (!response.ok) {\n throw new WebsiteAnonymousRequestError(response.status, await safeReadText(response));\n }\n return (await response.json()) as TResponse;\n }\n\n /**\n * Send `body` to an anonymous endpoint with an explicit METHOD.\n *\n * `postAnonymous` above is the POST case and delegates here. This exists because not every anonymous\n * door is a POST and the method is not guessable from the path: the visitor consent door declares\n * `resourceOperationLike: UPDATE_MANY`, which the engine maps to PUT, so a POST to it answers 404 —\n * and this client's one silent caller swallowed exactly that. A caller reads the door's method from\n * the same place it reads the path (`ANONYMOUS_CONSENT_RECORD_METHOD` beside\n * `ANONYMOUS_CONSENT_RECORD_PATH`) rather than inferring it from a method name.\n */\n async requestAnonymous<TResponse = unknown>(\n method: string,\n path: string,\n body: unknown,\n /** Request-specific headers, such as a public form's challenge answer (#1735). Never overrides the session headers. */\n extraHeaders?: Readonly<Record<string, string>>,\n ): Promise<TResponse> {\n this.ensureSessionStarted();\n\n const response = await fetch(`${this.apiBaseUrl}${path}`, {\n method,\n headers: { ...extraHeaders, ...this.buildHeaders() },\n body: JSON.stringify(body),\n // Carry the parent-domain `__wildo_anon_session` cookie to the API so the\n // session is continuous across the apex/app/api origins.\n credentials: 'include',\n });\n\n this.captureSessionToken(response);\n\n if (!response.ok) {\n throw new WebsiteAnonymousRequestError(response.status, await safeReadText(response));\n }\n return (await response.json()) as TResponse;\n }\n\n private ensureSessionStarted(): void {\n if (!this.anonymousUserId) {\n this.anonymousUserId = crypto.randomUUID();\n this.sessionToken = null;\n this.persist();\n }\n }\n\n private buildHeaders(): Record<string, string> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n [WildoHeaderKeys.FRONTEND_SERVICE_NAME]: this.frontendServiceName,\n };\n // Once we hold the JWT, restore the session; before that, the raw UUID inits it.\n if (this.sessionToken) {\n headers[WildoHeaderKeys.ANONYMOUS_SESSION] = this.sessionToken;\n } else if (this.anonymousUserId) {\n headers[WildoHeaderKeys.ANONYMOUS_SESSION_INIT] = this.anonymousUserId;\n }\n return headers;\n }\n\n private captureSessionToken(response: Response): void {\n // Requires the backend to CORS-expose this header (see the class-level CORS note).\n const token = response.headers.get(WildoHeaderKeys.ANONYMOUS_SESSION_TOKEN);\n if (token && token !== this.sessionToken) {\n this.sessionToken = token;\n this.persist();\n }\n }\n\n private restoreFromStorage(): void {\n try {\n const userId = localStorage.getItem(STORAGE_KEYS.anonymousUserId);\n const token = localStorage.getItem(STORAGE_KEYS.anonymousSessionToken);\n if (userId) this.anonymousUserId = userId;\n if (token) this.sessionToken = token;\n } catch {\n // localStorage unavailable (SSR build / privacy mode) — the session stays\n // in-memory for this page load only.\n }\n }\n\n private persist(): void {\n try {\n if (this.anonymousUserId) {\n localStorage.setItem(STORAGE_KEYS.anonymousUserId, this.anonymousUserId);\n }\n if (this.sessionToken) {\n localStorage.setItem(STORAGE_KEYS.anonymousSessionToken, this.sessionToken);\n }\n } catch {\n // Best-effort persistence; a failure only means the session won't survive reload.\n }\n }\n}\n\nasync function safeReadText(response: Response): Promise<string> {\n try {\n return await response.text();\n } catch {\n return '';\n }\n}\n"]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type FrontendCaptchaSink } from '@wildo-ai/saas-models/public-runtime';
|
|
2
|
+
import { type WebsiteAnonymousSessionClient } from './website-anonymous-session-client';
|
|
3
|
+
/** @wildo_source:part:start saas.website.submission-challenge facet:layer:frontend facet:audience:app-developer */
|
|
4
|
+
/** What a form knows about the page's captcha when it submits. */
|
|
5
|
+
export interface WebsiteSubmissionChallengeSource {
|
|
6
|
+
/** This website declares a captcha provider, so its public forms answer a challenge. */
|
|
7
|
+
readonly captchaDeclared: boolean;
|
|
8
|
+
/** The captcha vendor RUNNING on the page — present only once the visitor consented and it started. */
|
|
9
|
+
readonly runningCaptcha?: {
|
|
10
|
+
readonly providerRef: string;
|
|
11
|
+
readonly sink: FrontendCaptchaSink;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** The challenge source of the current page: its declared captcha provider and, if started, its running client. */
|
|
15
|
+
export declare function useWebsiteSubmissionChallengeSource(): WebsiteSubmissionChallengeSource;
|
|
16
|
+
/**
|
|
17
|
+
* The encoded answer to send, or `undefined` when this site asks for none. `proofOfWorkOnly` skips the vendor (the
|
|
18
|
+
* retry after a refused token). Throws only when the proof-of-work challenge cannot be fetched or solved; the caller
|
|
19
|
+
* then shows its ordinary failure and does not submit unanswered (`InboundContactForm` shows its `errorMessage`).
|
|
20
|
+
*/
|
|
21
|
+
export declare function obtainSubmissionChallengeAnswer(params: {
|
|
22
|
+
readonly source: WebsiteSubmissionChallengeSource;
|
|
23
|
+
/**
|
|
24
|
+
* The action the operation the form posts to DECLARES in its `submissionChallenge` (#1985): the engine's
|
|
25
|
+
* `SubmissionChallengeAction.INBOUND_CONTACT` for `/inbound-contacts`, your own action for your own operation. A token
|
|
26
|
+
* minted for any other action is refused.
|
|
27
|
+
*/
|
|
28
|
+
readonly action: string;
|
|
29
|
+
readonly client: Pick<WebsiteAnonymousSessionClient, 'getPublic'>;
|
|
30
|
+
readonly proofOfWorkOnly?: boolean;
|
|
31
|
+
readonly signal?: AbortSignal;
|
|
32
|
+
}): Promise<string | undefined>;
|
|
33
|
+
/** Whether a failed submission was refused at its challenge — the one refusal a form answers again, with the proof of work. */
|
|
34
|
+
export declare function isSubmissionChallengeRefusal(error: unknown): boolean;
|
|
35
|
+
/** @wildo_source:part:end saas.website.submission-challenge */
|
|
36
|
+
//# sourceMappingURL=website-submission-challenge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"website-submission-challenge.d.ts","sourceRoot":"","sources":["../../../../src/core/anonymous-session/website-submission-challenge.ts"],"names":[],"mappings":"AAgBA,OAAO,EAQL,KAAK,mBAAmB,EAEzB,MAAM,sCAAsC,CAAC;AAI9C,OAAO,EAAgC,KAAK,6BAA6B,EAAE,MAAM,oCAAoC,CAAC;AAEtH,mHAAmH;AACnH,kEAAkE;AAClE,MAAM,WAAW,gCAAgC;IAC/C,wFAAwF;IACxF,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;IAClC,uGAAuG;IACvG,QAAQ,CAAC,cAAc,CAAC,EAAE;QAAE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAA;KAAE,CAAC;CAChG;AAED,mHAAmH;AACnH,wBAAgB,mCAAmC,IAAI,gCAAgC,CAatF;AAED;;;;GAIG;AACH,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,CAAC,MAAM,EAAE,gCAAgC,CAAC;IAClD;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC,6BAA6B,EAAE,WAAW,CAAC,CAAC;IAClE,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IACnC,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAiB9B;AAED,+HAA+H;AAC/H,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIpE;AACD,+DAA+D"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The answer a public website form sends with its submission when the application protects it with a captcha (#1735), to
|
|
3
|
+
* any operation that declares a submission challenge (#1985).
|
|
4
|
+
*
|
|
5
|
+
* Two answers, one header (`x-submission-challenge-response`), chosen in the visitor's browser:
|
|
6
|
+
*
|
|
7
|
+
* - the consented captcha vendor's TOKEN, when its browser part is running on the page (it starts only once the
|
|
8
|
+
* visitor grants the purpose the vendor rests on — Google reCAPTCHA rests on ANALYTICS);
|
|
9
|
+
* - otherwise the engine's PROOF OF WORK: a challenge fetched from the backend and solved here. Consent-exempt —
|
|
10
|
+
* nothing is stored in the browser and nothing reaches a third party — so a visitor who declined still submits.
|
|
11
|
+
*
|
|
12
|
+
* The backend requires one of the two whenever it declares a captcha provider, and refuses a submission with neither.
|
|
13
|
+
* A site that declares none sends nothing. A refused vendor token is retried ONCE with the proof of work (a low score or
|
|
14
|
+
* an unreachable vendor must not lock a real person out); see {@link isSubmissionChallengeRefusal}.
|
|
15
|
+
*/
|
|
16
|
+
import { useContext, useMemo } from 'react';
|
|
17
|
+
import { BUILTIN_PROVIDER_CAPABILITY, SUBMISSION_CHALLENGE_PROOF_OF_WORK_PATH, SubmissionChallengeRefusalCode, SubmissionChallengeResponseKind, encodeSubmissionChallengeResponse, isFrontendCaptchaSink, solveProofOfWorkChallenge, } from '@wildo-ai/saas-models/public-runtime';
|
|
18
|
+
import { WebsiteRuntimeContext } from '../contexts/WebsiteRuntimeContext.js';
|
|
19
|
+
import { useWebsiteProviderSdkActivations } from '../external-providers/website-provider-sdk-activations.js';
|
|
20
|
+
import { WebsiteAnonymousRequestError } from './website-anonymous-session-client.js';
|
|
21
|
+
/** The challenge source of the current page: its declared captcha provider and, if started, its running client. */
|
|
22
|
+
export function useWebsiteSubmissionChallengeSource() {
|
|
23
|
+
// Read without `useWebsiteRuntime`'s throw: a form rendered outside a page (a test, a storybook) simply has no captcha.
|
|
24
|
+
const runtime = useContext(WebsiteRuntimeContext);
|
|
25
|
+
const running = useWebsiteProviderSdkActivations();
|
|
26
|
+
const captchaDeclared = runtime?.frontendProviderRegistry?.findProviderByProviderCapability(BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA) !== undefined;
|
|
27
|
+
return useMemo(() => {
|
|
28
|
+
const captcha = running.find((entry) => entry.providerCapabilities.includes(BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA)
|
|
29
|
+
&& isFrontendCaptchaSink(entry.activation.handle));
|
|
30
|
+
return {
|
|
31
|
+
captchaDeclared,
|
|
32
|
+
...(captcha === undefined ? {} : { runningCaptcha: { providerRef: captcha.providerRef, sink: captcha.activation.handle } }),
|
|
33
|
+
};
|
|
34
|
+
}, [captchaDeclared, running]);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The encoded answer to send, or `undefined` when this site asks for none. `proofOfWorkOnly` skips the vendor (the
|
|
38
|
+
* retry after a refused token). Throws only when the proof-of-work challenge cannot be fetched or solved; the caller
|
|
39
|
+
* then shows its ordinary failure and does not submit unanswered (`InboundContactForm` shows its `errorMessage`).
|
|
40
|
+
*/
|
|
41
|
+
export async function obtainSubmissionChallengeAnswer(params) {
|
|
42
|
+
const { source, action, client, proofOfWorkOnly = false, signal } = params;
|
|
43
|
+
if (!proofOfWorkOnly && source.runningCaptcha !== undefined) {
|
|
44
|
+
const token = await source.runningCaptcha.sink.execute(action);
|
|
45
|
+
if (token !== undefined) {
|
|
46
|
+
return encodeSubmissionChallengeResponse({
|
|
47
|
+
kind: SubmissionChallengeResponseKind.PROVIDER_TOKEN,
|
|
48
|
+
providerRef: source.runningCaptcha.providerRef,
|
|
49
|
+
token,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!source.captchaDeclared && !proofOfWorkOnly)
|
|
54
|
+
return undefined;
|
|
55
|
+
const challenge = await client.getPublic(SUBMISSION_CHALLENGE_PROOF_OF_WORK_PATH);
|
|
56
|
+
const answer = await solveProofOfWorkChallenge(challenge, { signal });
|
|
57
|
+
if (answer === undefined)
|
|
58
|
+
throw new Error('The proof-of-work challenge could not be solved.');
|
|
59
|
+
return encodeSubmissionChallengeResponse(answer);
|
|
60
|
+
}
|
|
61
|
+
/** Whether a failed submission was refused at its challenge — the one refusal a form answers again, with the proof of work. */
|
|
62
|
+
export function isSubmissionChallengeRefusal(error) {
|
|
63
|
+
if (!(error instanceof WebsiteAnonymousRequestError))
|
|
64
|
+
return false;
|
|
65
|
+
const code = error.errorCode;
|
|
66
|
+
return code === SubmissionChallengeRefusalCode.REQUIRED || code === SubmissionChallengeRefusalCode.REFUSED;
|
|
67
|
+
}
|
|
68
|
+
/** @wildo_source:part:end saas.website.submission-challenge */
|
|
69
|
+
//# sourceMappingURL=website-submission-challenge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"website-submission-challenge.js","sourceRoot":"","sources":["../../../../src/core/anonymous-session/website-submission-challenge.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The answer a public website form sends with its submission when the application protects it with a captcha (#1735), to\n * any operation that declares a submission challenge (#1985).\n *\n * Two answers, one header (`x-submission-challenge-response`), chosen in the visitor's browser:\n *\n * - the consented captcha vendor's TOKEN, when its browser part is running on the page (it starts only once the\n * visitor grants the purpose the vendor rests on — Google reCAPTCHA rests on ANALYTICS);\n * - otherwise the engine's PROOF OF WORK: a challenge fetched from the backend and solved here. Consent-exempt —\n * nothing is stored in the browser and nothing reaches a third party — so a visitor who declined still submits.\n *\n * The backend requires one of the two whenever it declares a captcha provider, and refuses a submission with neither.\n * A site that declares none sends nothing. A refused vendor token is retried ONCE with the proof of work (a low score or\n * an unreachable vendor must not lock a real person out); see {@link isSubmissionChallengeRefusal}.\n */\nimport { useContext, useMemo } from 'react';\nimport {\n BUILTIN_PROVIDER_CAPABILITY,\n SUBMISSION_CHALLENGE_PROOF_OF_WORK_PATH,\n SubmissionChallengeRefusalCode,\n SubmissionChallengeResponseKind,\n encodeSubmissionChallengeResponse,\n isFrontendCaptchaSink,\n solveProofOfWorkChallenge,\n type FrontendCaptchaSink,\n type ProofOfWorkChallenge,\n} from '@wildo-ai/saas-models/public-runtime';\n\nimport { WebsiteRuntimeContext } from '../contexts/WebsiteRuntimeContext';\nimport { useWebsiteProviderSdkActivations } from '../external-providers/website-provider-sdk-activations';\nimport { WebsiteAnonymousRequestError, type WebsiteAnonymousSessionClient } from './website-anonymous-session-client';\n\n/** @wildo_source:part:start saas.website.submission-challenge facet:layer:frontend facet:audience:app-developer */\n/** What a form knows about the page's captcha when it submits. */\nexport interface WebsiteSubmissionChallengeSource {\n /** This website declares a captcha provider, so its public forms answer a challenge. */\n readonly captchaDeclared: boolean;\n /** The captcha vendor RUNNING on the page — present only once the visitor consented and it started. */\n readonly runningCaptcha?: { readonly providerRef: string; readonly sink: FrontendCaptchaSink };\n}\n\n/** The challenge source of the current page: its declared captcha provider and, if started, its running client. */\nexport function useWebsiteSubmissionChallengeSource(): WebsiteSubmissionChallengeSource {\n // Read without `useWebsiteRuntime`'s throw: a form rendered outside a page (a test, a storybook) simply has no captcha.\n const runtime = useContext(WebsiteRuntimeContext);\n const running = useWebsiteProviderSdkActivations();\n const captchaDeclared = runtime?.frontendProviderRegistry?.findProviderByProviderCapability(BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA) !== undefined;\n return useMemo(() => {\n const captcha = running.find((entry) => entry.providerCapabilities.includes(BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA)\n && isFrontendCaptchaSink(entry.activation.handle));\n return {\n captchaDeclared,\n ...(captcha === undefined ? {} : { runningCaptcha: { providerRef: captcha.providerRef, sink: captcha.activation.handle as FrontendCaptchaSink } }),\n };\n }, [captchaDeclared, running]);\n}\n\n/**\n * The encoded answer to send, or `undefined` when this site asks for none. `proofOfWorkOnly` skips the vendor (the\n * retry after a refused token). Throws only when the proof-of-work challenge cannot be fetched or solved; the caller\n * then shows its ordinary failure and does not submit unanswered (`InboundContactForm` shows its `errorMessage`).\n */\nexport async function obtainSubmissionChallengeAnswer(params: {\n readonly source: WebsiteSubmissionChallengeSource;\n /**\n * The action the operation the form posts to DECLARES in its `submissionChallenge` (#1985): the engine's\n * `SubmissionChallengeAction.INBOUND_CONTACT` for `/inbound-contacts`, your own action for your own operation. A token\n * minted for any other action is refused.\n */\n readonly action: string;\n readonly client: Pick<WebsiteAnonymousSessionClient, 'getPublic'>;\n readonly proofOfWorkOnly?: boolean;\n readonly signal?: AbortSignal;\n}): Promise<string | undefined> {\n const { source, action, client, proofOfWorkOnly = false, signal } = params;\n if (!proofOfWorkOnly && source.runningCaptcha !== undefined) {\n const token = await source.runningCaptcha.sink.execute(action);\n if (token !== undefined) {\n return encodeSubmissionChallengeResponse({\n kind: SubmissionChallengeResponseKind.PROVIDER_TOKEN,\n providerRef: source.runningCaptcha.providerRef,\n token,\n });\n }\n }\n if (!source.captchaDeclared && !proofOfWorkOnly) return undefined;\n const challenge = await client.getPublic<ProofOfWorkChallenge>(SUBMISSION_CHALLENGE_PROOF_OF_WORK_PATH);\n const answer = await solveProofOfWorkChallenge(challenge, { signal });\n if (answer === undefined) throw new Error('The proof-of-work challenge could not be solved.');\n return encodeSubmissionChallengeResponse(answer);\n}\n\n/** Whether a failed submission was refused at its challenge — the one refusal a form answers again, with the proof of work. */\nexport function isSubmissionChallengeRefusal(error: unknown): boolean {\n if (!(error instanceof WebsiteAnonymousRequestError)) return false;\n const code = error.errorCode;\n return code === SubmissionChallengeRefusalCode.REQUIRED || code === SubmissionChallengeRefusalCode.REFUSED;\n}\n/** @wildo_source:part:end saas.website.submission-challenge */\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"WebsiteConsentBanner.d.ts","sourceRoot":"","sources":["
|
|
1
|
+
{"version":3,"file":"WebsiteConsentBanner.d.ts","sourceRoot":"","sources":["../../../../src/core/consent/WebsiteConsentBanner.tsx"],"names":[],"mappings":"AAAA,OAAc,EAA8B,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAyF1E,wBAAgB,oBAAoB,IAAI,SAAS,CAyHhD;AAED,sGAAsG;AACtG,wBAAgB,4BAA4B,IAAI,SAAS,CAQxD;AACD,yDAAyD"}
|