diff --git a/CLAUDE.md b/CLAUDE.md index 102703cb6..8cf159b11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,7 @@ pnpm --filter @cleanstart/web build - Every `` needs `sizes` matching rendered widths per breakpoint. SVGs: `preserveAspectRatio="xMidYMid meet"` (never `"none"`). - Buttons: `--btn-fs-*`/`--btn-h-*`/`--btn-px-*`, never `clamp()`. Primary CTAs ≥ 44px tall. - Section vertical padding: `--spacing-section-*` tokens via `
` or `py-section-*` utilities. Do not invent `pt-Xpx sm:pt-Ypx lg:pt-Zpx` shapes. -- Form inputs ≥ 16px font-size (iOS Safari zoom rule). `FormRenderer.tsx`'s `fieldInputStyle` already sets this; never override with `text-sm` (14 px). +- Form inputs ≥ 16px font-size (iOS Safari zoom rule). The shared field surface (`src/components/forms/field-surface.ts`, used by `TextField` and `PhoneField`) sets this through `--fs-input`; never override with `text-sm` (14 px). - No `
` in prose — trust `max-width` + `text-wrap: balance` (applied globally on `h1`–`h4` via `@layer base`). - Footer CTA-card geometry is owned by `Footer.tsx`. Per-page CTAs paint inside the slot via the `cta` prop. diff --git a/apps/cms/emails/index.html b/apps/cms/emails/index.html index 5200d532d..5b730f80f 100644 --- a/apps/cms/emails/index.html +++ b/apps/cms/emails/index.html @@ -19,7 +19,7 @@

CleanStart email templates

demo-confirmationbook-a-demovisitorYour CleanStart demo request contact-confirmationcontactvisitorWe've received your message newsletter-welcomenewslettervisitorYou're subscribed to CleanStart -resource-downloadresource-capture (gated resources)visitorYour download: The 2026 Container Hardening Playbook +resource-downloadcontent-gated (gated resource downloads)visitorThanks for downloading The 2026 Container Hardening Playbook careers-applicant-confirmationjob applicationvisitorWe've received your application for Senior Platform Engineer careers-hr-notificationjob applicationinternalNew application: Senior Platform Engineer, Priya Nair partner-confirmationbecome-a-partnervisitorThanks for your interest in partnering with CleanStart diff --git a/apps/cms/emails/resource-download.html b/apps/cms/emails/resource-download.html index 9087c584c..1bb303833 100644 --- a/apps/cms/emails/resource-download.html +++ b/apps/cms/emails/resource-download.html @@ -1,7 +1,7 @@ + Form: content-gated (gated resource downloads) + Subject: Thanks for downloading The 2026 Container Hardening Playbook --> @@ -39,7 +39,7 @@ -
The 2026 Container Hardening Playbook is ready to download.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+
Your copy of The 2026 Container Hardening Playbook, and a link to download it again.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
@@ -56,12 +56,13 @@
-

Your download

+

Thank you

Thanks, Priya

-

Here is your copy of The 2026 Container Hardening Playbook.

+

Thank you for downloading The 2026 Container Hardening Playbook.

+

Your download should have started in your browser. If it did not, or you want the file again later, use the button below.

- Download it now + Download your copy
diff --git a/apps/cms/scripts/data/gsc-audit-urls.json b/apps/cms/scripts/data/gsc-audit-urls.json new file mode 100644 index 000000000..e6273aef2 --- /dev/null +++ b/apps/cms/scripts/data/gsc-audit-urls.json @@ -0,0 +1 @@ +["https://www.cleanstart.com/blogs/busting-myths-about-open-source-and-containers", "https://www.cleanstart.com/blogs/empowering-development-securing-software-supply-chain-with-cleanstart", "https://www.cleanstart.com/blogs/modern-npm-supply-chain-attacks-beat-cve-scanners-sboms", "https://www.cleanstart.com/blogs/modern-supply-chain-attacks-are-becoming-self-propagating", "https://www.cleanstart.com/blogs/securing-the-software-supply-chain-how-distroless-containers-defend-against-npm-malware-attacks", "https://www.cleanstart.com/blogs/software-supply-chain-a-practical-guide-for-security-teams", "https://www.cleanstart.com/blogs/software-supply-chain-in-2026", "https://www.cleanstart.com/blogs/software-supply-chain-security-a-technical-imperative-for-modern-ctos", "https://www.cleanstart.com/blogs/the-evolution-of-open-source-software-past-present-and-future-nb4zg", "https://www.cleanstart.com/blogs/the-expanding-software-supply-chain-lessons-from-the-hugging-face-incident", "https://www.cleanstart.com/blogs/understanding-container-sprawl", "https://www.cleanstart.com/blogs/why-you-cannot-trust-prebuilt-container-images-from-official-registries", "https://www.cleanstart.com/guide/software-supply-chain-security", "https://www.cleanstart.com/knowledge-hub/dependency-intelligence", "https://www.cleanstart.com/knowledge-hub/hardening-supply-chain", "https://www.cleanstart.com/knowledge-hub/secure-vendor-risk-assessment", "https://www.cleanstart.com/knowledge-hub/supply-chain-dr-plan", "https://www.cleanstart.com/knowledge-hub/supply-chain-incident-playbook", "https://www.cleanstart.com/knowledge-hub/supply-chain-incident-response", "https://www.cleanstart.com/knowledge-hub/the-continuous-trust-loop", "https://www.cleanstart.com/knowledge-hub/transitive-dependency-removal", "https://www.cleanstart.com/knowledge-hub/vendor-risk-assessment", "https://www.cleanstart.com/knowledge-hub/verified-source-philosophy", "https://www.cleanstart.com/knowledge-hub/what-are-transitive-dependencies", "https://www.cleanstart.com/knowledge-hub/what-is-supply-chain-security", "https://www.cleanstart.com/knowledge-hub/zero-trust-supply-chain", "https://www.cleanstart.com/resources/beyond-cnapp-building-a-complete-software-supply-chain-security-strategy", "https://www.cleanstart.com/resources/securing-the-software-supply-chain-in-2026", "https://www.cleanstart.com/resources/the-binary-trust-problem", "https://www.cleanstart.com/blogs/20m-malicious-pulls-when-automation-went-wrong", "https://www.cleanstart.com/blogs/asyncapi-compromise-and-limits-of-software-provenance", "https://www.cleanstart.com/blogs/codeql-compromised-how-public-secret-exposure-led-to-an-attack-9w4ro", "https://www.cleanstart.com/blogs/critical-nvidia-container-toolkit-vulnerability-cve-2025-23359", "https://www.cleanstart.com/blogs/cve-2026-34040-when-container-security-fails-before-it-even-starts", "https://www.cleanstart.com/blogs/fakegit-and-agentbaiting-how-7-600-malicious-github-repos-trick-ai-agents-into-installing-malware", "https://www.cleanstart.com/blogs/from-bitwarden-to-sap", "https://www.cleanstart.com/blogs/githubs-internal-breach", "https://www.cleanstart.com/blogs/graboid-worm-the-docker-container-nightmare-that-taught-us-security-lessons", "https://www.cleanstart.com/blogs/how-attackers-weaponized-trust-the-billion-download-npm-breach", "https://www.cleanstart.com/blogs/lightning-python-package-hack", "https://www.cleanstart.com/blogs/litellm-supply-chain-attack-how-two-malicious-versions-compromised-ai-infrastructure", "https://www.cleanstart.com/blogs/litellm-supply-chain-attack-why-verified-software-artifacts-matter-for-ai-security", "https://www.cleanstart.com/blogs/nginx-rift-exposes", "https://www.cleanstart.com/blogs/nx-console-compromise-reveals-about-software-trust", "https://www.cleanstart.com/blogs/openclaw-vulnerabilities-reveal-about-execution-chain-trust", "https://www.cleanstart.com/blogs/over-10-000-docker-hub-images-exposed-credentials-why-it-happened-and-how-cleanstart-prevents-it-at-the-source", "https://www.cleanstart.com/blogs/shai-hulud-revisited-evolution-of-the-november-npm-activity", "https://www.cleanstart.com/blogs/supply-chain-attack-on-lottie-player-a-wake-up-call-for-javascript-security", "https://www.cleanstart.com/blogs/the-n8n-vulnerabilities-highlight-a-growing-execution-chain-trust-problem", "https://www.cleanstart.com/blogs/the-recent-discovery-of-a-critical-vulnerability-in-nvidia", "https://www.cleanstart.com/blogs/the-tanstack-npm-compromise-proves-official-packages-are-no-longer-enough", "https://www.cleanstart.com/blogs/trapdoor-malware-shows", "https://www.cleanstart.com/blogs/trivy-supply-chain-attack-how-a-security-tool-compromised-ci-cd-pipelines", "https://www.cleanstart.com/blogs/typosquatted-alpine-a-devops-cautionary-tale", "https://www.cleanstart.com/blogs/what-if-mythos-claims-to-be-true", "https://www.cleanstart.com/blogs/what-the-latest-shai-hulud-campaign-teaches-us-about-software-trust", "https://www.cleanstart.com/blogs/when-security-infrastructure-becomes-the-attack-surface", "https://www.cleanstart.com/blogs/widespread-open-source-attack-malicious-code-identified-in-npm-pypi-and-rubygems-repositories", "https://www.cleanstart.com/knowledge-hub/shai-hulud-npm-attack", "https://www.cleanstart.com/attack-surface-reduction", "https://www.cleanstart.com/blogs/attack-surface-reduction-in-containers-measuring", "https://www.cleanstart.com/blogs/busybox-container-security-risk", "https://www.cleanstart.com/blogs/container-security-101-concepts-threats-and-best-practices", "https://www.cleanstart.com/blogs/container-security-beyond-scanning-shell-less-and-read-only-runtime-explained", "https://www.cleanstart.com/blogs/hardened-container-images-fit", "https://www.cleanstart.com/blogs/why-container-security-starts-before-cnapp", "https://www.cleanstart.com/guide/attack-surface-reduction", "https://www.cleanstart.com/guide/container-image-security", "https://www.cleanstart.com/guide/container-security", "https://www.cleanstart.com/guide/distroless-container-image", "https://www.cleanstart.com/guide/hardened-container-image", "https://www.cleanstart.com/guide/immutable-images", "https://www.cleanstart.com/guide/minimal-images", "https://www.cleanstart.com/knowledge-hub/build-stage-security", "https://www.cleanstart.com/knowledge-hub/cleanstart-utils-vs-busybox", "https://www.cleanstart.com/knowledge-hub/code-buildtime-lowlevel-security", "https://www.cleanstart.com/knowledge-hub/combined-security-model", "https://www.cleanstart.com/knowledge-hub/container-security-best-practices", "https://www.cleanstart.com/knowledge-hub/container-security-policy", "https://www.cleanstart.com/knowledge-hub/customizing-without-losing-security", "https://www.cleanstart.com/knowledge-hub/foundations-container-security", "https://www.cleanstart.com/knowledge-hub/no-shell-read-only", "https://www.cleanstart.com/knowledge-hub/prebuild-stage-security", "https://www.cleanstart.com/knowledge-hub/read-only-and-ephemeral-storage", "https://www.cleanstart.com/knowledge-hub/read-only-filesystem-guide", "https://www.cleanstart.com/knowledge-hub/rootless-readonly-guide", "https://www.cleanstart.com/knowledge-hub/shell-less-and-initialization", "https://www.cleanstart.com/knowledge-hub/shell-less-operations-guide", "https://www.cleanstart.com/knowledge-hub/the-layered-security-problem", "https://www.cleanstart.com/knowledge-hub/what-is-deterministic-security-manufacturing", "https://www.cleanstart.com/knowledge-hub/what-is-distroless", "https://www.cleanstart.com/resources/busybox-replacement", "https://www.cleanstart.com/resources/dependency-management-attack-surface-reduction", "https://www.cleanstart.com/resources/enterprise-grade-hardened-container-images", "https://www.cleanstart.com/resources/isolated-package-compilation", "https://www.cleanstart.com/resources/replacing-busybox-in-container-images", "https://www.cleanstart.com/resources/rethinking-container-builds-for-security-and-scale", "https://www.cleanstart.com/resources/retrofitting-security-vs-building-it-in", "https://www.cleanstart.com/resources/secure-lightweight-container-images", "https://www.cleanstart.com/resources/shell-less-and-read-only-container-architecture", "https://www.cleanstart.com/resources/six-layers-of-unbreakable-defense", "https://www.cleanstart.com/resources/the-architecture-of-resilience", "https://www.cleanstart.com/guide/base-image", "https://www.cleanstart.com/guide/build-image", "https://www.cleanstart.com/guide/container", "https://www.cleanstart.com/guide/container-entrypoint", "https://www.cleanstart.com/guide/container-filesystem", "https://www.cleanstart.com/guide/container-image", "https://www.cleanstart.com/guide/container-image-layering", "https://www.cleanstart.com/guide/containerization", "https://www.cleanstart.com/guide/container-lifecycle", "https://www.cleanstart.com/guide/container-runtime", "https://www.cleanstart.com/guide/dockerfile", "https://www.cleanstart.com/guide/linux-container", "https://www.cleanstart.com/guide/oci-image-format", "https://www.cleanstart.com/knowledge-hub/container-image-fundamentals", "https://www.cleanstart.com/knowledge-hub/container-image-layers-deep-dive", "https://www.cleanstart.com/knowledge-hub/container-runtimes-explained", "https://www.cleanstart.com/knowledge-hub/container-scope-vs-kernel-scope", "https://www.cleanstart.com/knowledge-hub/containers-vs-virtual-machines", "https://www.cleanstart.com/knowledge-hub/development-images-vs-application-images", "https://www.cleanstart.com/knowledge-hub/dev-vs-prod-images", "https://www.cleanstart.com/knowledge-hub/docker-and-oci-specification", "https://www.cleanstart.com/knowledge-hub/glibc-vs-musl", "https://www.cleanstart.com/knowledge-hub/how-containers-interact-with-the-kernel", "https://www.cleanstart.com/knowledge-hub/images-and-containers", "https://www.cleanstart.com/knowledge-hub/libraries-and-packages-relationship", "https://www.cleanstart.com/knowledge-hub/linux-packages-explained", "https://www.cleanstart.com/knowledge-hub/stage0-compiler-bootstrap", "https://www.cleanstart.com/knowledge-hub/standard-vs-distroless-images", "https://www.cleanstart.com/knowledge-hub/the-11-build-artifacts", "https://www.cleanstart.com/knowledge-hub/the-illusion-of-the-single-artifact", "https://www.cleanstart.com/knowledge-hub/what-is-a-container", "https://www.cleanstart.com/knowledge-hub/what-is-a-container-image", "https://www.cleanstart.com/knowledge-hub/what-is-a-container-registry", "https://www.cleanstart.com/knowledge-hub/what-is-a-package-manager", "https://www.cleanstart.com/knowledge-hub/what-is-apk-package-manager", "https://www.cleanstart.com/knowledge-hub/what-is-a-software-library", "https://www.cleanstart.com/blogs/container-image-signing-enhancing-security-in-the-software-supply-chain", "https://www.cleanstart.com/blogs/hermetic-build", "https://www.cleanstart.com/blogs/sbom-101", "https://www.cleanstart.com/blogs/strengthening-software-supply-chain-security-with-slsa", "https://www.cleanstart.com/blogs/understanding-image-provenance", "https://www.cleanstart.com/blogs/why-sboms-alone-do-not-establish-container-trust", "https://www.cleanstart.com/guide/container-image-signing-and-verification-complete-guide", "https://www.cleanstart.com/guide/go-dependency-verification-what-go-sum-proves-and-what-it-doesn-t", "https://www.cleanstart.com/guide/image-signing", "https://www.cleanstart.com/guide/python-supply-chain-security-provenance-verification", "https://www.cleanstart.com/guide/sbom", "https://www.cleanstart.com/knowledge-hub/attestation-based", "https://www.cleanstart.com/knowledge-hub/automated-attestation", "https://www.cleanstart.com/knowledge-hub/cyclonedx-sbom", "https://www.cleanstart.com/knowledge-hub/generate-slsa", "https://www.cleanstart.com/knowledge-hub/hermetic-build-slsa", "https://www.cleanstart.com/knowledge-hub/image-signing-sigstore", "https://www.cleanstart.com/knowledge-hub/image-verification-guide", "https://www.cleanstart.com/knowledge-hub/in-toto-attestation", "https://www.cleanstart.com/knowledge-hub/keyless-container", "https://www.cleanstart.com/knowledge-hub/provenance-chaining", "https://www.cleanstart.com/knowledge-hub/rekor-publishing", "https://www.cleanstart.com/knowledge-hub/runtime-evidence", "https://www.cleanstart.com/knowledge-hub/secure-vex-documents", "https://www.cleanstart.com/knowledge-hub/spdx-sbom", "https://www.cleanstart.com/knowledge-hub/verification-artifacts-walkthrough", "https://www.cleanstart.com/knowledge-hub/vex-documents", "https://www.cleanstart.com/knowledge-hub/what-are-reproducible-builds", "https://www.cleanstart.com/knowledge-hub/what-is-cosign-and-image-signing", "https://www.cleanstart.com/knowledge-hub/what-is-provenance", "https://www.cleanstart.com/knowledge-hub/what-is-sbom", "https://www.cleanstart.com/knowledge-hub/what-is-slsa", "https://www.cleanstart.com/knowledge-hub/what-is-vex", "https://www.cleanstart.com/resources/cleanstart-ai-sbom", "https://www.cleanstart.com/resources/slsa-levels-as-architectural-states-of-trust", "https://www.cleanstart.com/resources/software-bill-of-materials", "https://www.cleanstart.com/resources/vex-and-the-shift-from-vulnerability-presence-to-exploitability", "https://www.cleanstart.com/software-bill-materials", "https://www.cleanstart.com/blogs/addressing-rbis-guidelines-for-digital-payment-applications-with-cleanstart-i9e8q", "https://www.cleanstart.com/blogs/eu-cyber-resilience-act-cra-what-manufacturers-must-report-by-11-september-2026", "https://www.cleanstart.com/guide/cis-benchmarks-docker-kubernetes", "https://www.cleanstart.com/guide/fedramp-container-security-checklist", "https://www.cleanstart.com/guide/hipaa-cloud-container-compliance", "https://www.cleanstart.com/guide/iso-27001-kubernetes-container-controls", "https://www.cleanstart.com/guide/kubernetes-container-compliance", "https://www.cleanstart.com/guide/nist-800-171-cui-containers", "https://www.cleanstart.com/guide/nist-800-53-kubernetes-control-mapping", "https://www.cleanstart.com/guide/pci-dss-4-0-container-compliance", "https://www.cleanstart.com/guide/soc-2-container-saas-compliance", "https://www.cleanstart.com/knowledge-hub/ai-compliance-container-evidence", "https://www.cleanstart.com/knowledge-hub/cis-hardening", "https://www.cleanstart.com/knowledge-hub/compliance-architecture", "https://www.cleanstart.com/knowledge-hub/eu-ai-act-cra", "https://www.cleanstart.com/knowledge-hub/fedramp-high", "https://www.cleanstart.com/knowledge-hub/hipaa-compliance-mapping", "https://www.cleanstart.com/knowledge-hub/iso27001-mapping", "https://www.cleanstart.com/knowledge-hub/kyverno-policies", "https://www.cleanstart.com/knowledge-hub/opa-gatekeeper-policies", "https://www.cleanstart.com/knowledge-hub/openscap-reference", "https://www.cleanstart.com/knowledge-hub/pci-dss-mapping", "https://www.cleanstart.com/knowledge-hub/reconstructive-compliance", "https://www.cleanstart.com/knowledge-hub/soc2-type2-mapping", "https://www.cleanstart.com/knowledge-hub/stig-hardening", "https://www.cleanstart.com/knowledge-hub/what-are-container-hardening-benchmarks", "https://www.cleanstart.com/knowledge-hub/what-is-compliance-as-code", "https://www.cleanstart.com/knowledge-hub/what-is-hipaa", "https://www.cleanstart.com/knowledge-hub/what-is-iso27001", "https://www.cleanstart.com/knowledge-hub/what-is-opa", "https://www.cleanstart.com/knowledge-hub/what-is-pci-dss", "https://www.cleanstart.com/knowledge-hub/what-is-soc2", "https://www.cleanstart.com/resources/cis-hardening-as-an-architectural-property", "https://www.cleanstart.com/resources/stig-hardening-by-design", "https://www.cleanstart.com/resources/stig-openscap-compliance-architecture", "https://www.cleanstart.com/blogs/built-in-compliance-cleanstarts-fips-foundations-series", "https://www.cleanstart.com/fips", "https://www.cleanstart.com/guide/fips-140-2-vs-140-3-container-images", "https://www.cleanstart.com/guide/fips-compliance", "https://www.cleanstart.com/knowledge-hub/fips-140-overview", "https://www.cleanstart.com/knowledge-hub/fips-faq", "https://www.cleanstart.com/knowledge-hub/fips-federal-compliance", "https://www.cleanstart.com/knowledge-hub/fips-kafka", "https://www.cleanstart.com/knowledge-hub/fips-language-implementations", "https://www.cleanstart.com/knowledge-hub/fips-nginx", "https://www.cleanstart.com/knowledge-hub/fips-postgresql", "https://www.cleanstart.com/knowledge-hub/fips-redis", "https://www.cleanstart.com/knowledge-hub/fips-traces", "https://www.cleanstart.com/knowledge-hub/fips-verifier", "https://www.cleanstart.com/knowledge-hub/post-quantum-cryptography", "https://www.cleanstart.com/knowledge-hub/what-is-fips", "https://www.cleanstart.com/resources/embedding-fips-140-2-compliance-at-the-foundation", "https://www.cleanstart.com/resources/fips-140-3-and-the-quantum-imperative", "https://www.cleanstart.com/resources/fips-compliance", "https://www.cleanstart.com/resources/fips-traces", "https://www.cleanstart.com/blogs/cve-fatigue-why-most-container-vulnerabilities-never-get-fixed", "https://www.cleanstart.com/blogs/what-is-a-cve-the-naming-system-behind-every-vulnerability", "https://www.cleanstart.com/blogs/why-vulnerability-scanning-alone-is-not-enough", "https://www.cleanstart.com/blogs/why-zero-critical-cves-is-a-misleading-security-metric-for-container-security", "https://www.cleanstart.com/blogs/your-container-images-are-ticking-time-bombs-and-you-dont-even-know-it", "https://www.cleanstart.com/guide/attack-surface-reduction-vs-vulnerability-management", "https://www.cleanstart.com/guide/container-scanning", "https://www.cleanstart.com/guide/cve", "https://www.cleanstart.com/guide/cve-management", "https://www.cleanstart.com/guide/patch-management", "https://www.cleanstart.com/guide/risk-assessment", "https://www.cleanstart.com/guide/software-composition-analysis", "https://www.cleanstart.com/guide/vulnerability-assessment", "https://www.cleanstart.com/guide/vulnerability-detection", "https://www.cleanstart.com/guide/vulnerability-management", "https://www.cleanstart.com/guide/vulnerability-remediation", "https://www.cleanstart.com/guide/vulnerability-scanning", "https://www.cleanstart.com/knowledge-hub/false-positive-validation", "https://www.cleanstart.com/knowledge-hub/from-vulnerable-to-verified", "https://www.cleanstart.com/knowledge-hub/how-enterprises-patch-containers", "https://www.cleanstart.com/knowledge-hub/library-cve-maintainer-dependency", "https://www.cleanstart.com/knowledge-hub/the-remediation-trap", "https://www.cleanstart.com/knowledge-hub/time-to-fix-advantage", "https://www.cleanstart.com/knowledge-hub/total-cost-of-vulnerability", "https://www.cleanstart.com/knowledge-hub/upgrade-patching-playbook", "https://www.cleanstart.com/knowledge-hub/vulnerability-across-all-layers", "https://www.cleanstart.com/knowledge-hub/what-is-a-cve", "https://www.cleanstart.com/resources/containing-vulnerabilities-in-your-containers", "https://www.cleanstart.com/vulnerability-remediation", "https://www.cleanstart.com/blogs/insert-hardened-container-images-into-ci-pipelines", "https://www.cleanstart.com/blogs/shift-left-moved-the-problem-integrate-left-solves-it", "https://www.cleanstart.com/guide/ci-cd", "https://www.cleanstart.com/guide/devsecops", "https://www.cleanstart.com/guide/multi-stage-build", "https://www.cleanstart.com/knowledge-hub/builder-pattern-dev-prod", "https://www.cleanstart.com/knowledge-hub/builder-pattern-guide", "https://www.cleanstart.com/knowledge-hub/cicd-overview", "https://www.cleanstart.com/knowledge-hub/container-images-in-cicd", "https://www.cleanstart.com/knowledge-hub/customizing-in-cicd", "https://www.cleanstart.com/knowledge-hub/e2e-pipeline", "https://www.cleanstart.com/knowledge-hub/github-actions", "https://www.cleanstart.com/knowledge-hub/gitlab-ci", "https://www.cleanstart.com/knowledge-hub/google-cloud-build", "https://www.cleanstart.com/knowledge-hub/jenkins-azure-devops", "https://www.cleanstart.com/knowledge-hub/multi-arch-build-strategy", "https://www.cleanstart.com/knowledge-hub/validate-gateway", "https://www.cleanstart.com/knowledge-hub/what-is-cicd", "https://www.cleanstart.com/resources/eliminating-the-security-velocity-trade-off", "https://www.cleanstart.com/guide/cgroup", "https://www.cleanstart.com/guide/container-networking", "https://www.cleanstart.com/guide/container-orchestration", "https://www.cleanstart.com/guide/container-registry", "https://www.cleanstart.com/guide/disaster-recovery", "https://www.cleanstart.com/guide/environment-variables", "https://www.cleanstart.com/guide/image-repository", "https://www.cleanstart.com/guide/immutable-infrastructure", "https://www.cleanstart.com/guide/kubernetes", "https://www.cleanstart.com/knowledge-hub/air-gapped-deployment", "https://www.cleanstart.com/knowledge-hub/canary-progressive-delivery", "https://www.cleanstart.com/knowledge-hub/container-orchestration-kubernetes", "https://www.cleanstart.com/knowledge-hub/container-registries-compared", "https://www.cleanstart.com/knowledge-hub/debug-guide", "https://www.cleanstart.com/knowledge-hub/docker-compose-examples", "https://www.cleanstart.com/knowledge-hub/end-to-end-secure-deployment", "https://www.cleanstart.com/knowledge-hub/enterprise-image-governance", "https://www.cleanstart.com/knowledge-hub/helm-chart-generation-guide", "https://www.cleanstart.com/knowledge-hub/helm-chart-reference", "https://www.cleanstart.com/knowledge-hub/helm-charts-kubernetes", "https://www.cleanstart.com/knowledge-hub/helm-fundamentals", "https://www.cleanstart.com/knowledge-hub/kubectl-deployment-guide", "https://www.cleanstart.com/knowledge-hub/kubernetes-fundamentals", "https://www.cleanstart.com/knowledge-hub/kubernetes-manifests-and-deployments", "https://www.cleanstart.com/knowledge-hub/multi-cloud-registry-operations", "https://www.cleanstart.com/knowledge-hub/network-policies", "https://www.cleanstart.com/knowledge-hub/observability-monitoring", "https://www.cleanstart.com/knowledge-hub/registry-authentication", "https://www.cleanstart.com/knowledge-hub/registry-ecosystem-monitoring", "https://www.cleanstart.com/knowledge-hub/rollback-disaster-recovery", "https://www.cleanstart.com/knowledge-hub/secret-management", "https://www.cleanstart.com/blogs/eliminating-runtime-blind-spots-how-cleanstart-and-sysdig-build-continuous-trust-across-the-container-lifecycle", "https://www.cleanstart.com/blogs/security-doesnt-start-at-runtime", "https://www.cleanstart.com/guide/container-incident-response", "https://www.cleanstart.com/guide/container-monitoring", "https://www.cleanstart.com/guide/malware-detection", "https://www.cleanstart.com/guide/runtime-monitoring", "https://www.cleanstart.com/guide/runtime-security", "https://www.cleanstart.com/guide/threat-detection", "https://www.cleanstart.com/guide/threat-hunting", "https://www.cleanstart.com/knowledge-hub/behavioral-sandbox-analysis", "https://www.cleanstart.com/knowledge-hub/ebpf-falco-integration", "https://www.cleanstart.com/knowledge-hub/falco-rules-guide", "https://www.cleanstart.com/knowledge-hub/forensics-evidence-guide", "https://www.cleanstart.com/knowledge-hub/runtime-monitoring-architecture", "https://www.cleanstart.com/knowledge-hub/runtime-stage-security", "https://www.cleanstart.com/knowledge-hub/vulnerability-monitoring", "https://www.cleanstart.com/knowledge-hub/zero-day-detection", "https://www.cleanstart.com/blogs/how-to-migrate-from-alpine-or-debian-to-hardened-base-images", "https://www.cleanstart.com/knowledge-hub/bitnami-compatibility-guide", "https://www.cleanstart.com/knowledge-hub/bitnami-compatibility-matrix", "https://www.cleanstart.com/knowledge-hub/bitnami-helm-values-reference", "https://www.cleanstart.com/knowledge-hub/dockerfile-to-yaml-migration", "https://www.cleanstart.com/knowledge-hub/migrating-from-bitnami", "https://www.cleanstart.com/knowledge-hub/migration-faq", "https://www.cleanstart.com/knowledge-hub/migration-overview", "https://www.cleanstart.com/knowledge-hub/strip-down-vs-source-built", "https://www.cleanstart.com/resources/breaking-the-migration-barrier", "https://www.cleanstart.com/guide/docker-images", "https://www.cleanstart.com/guide/docker-images-vs-container", "https://www.cleanstart.com/knowledge-hub/getting-started-dotnet", "https://www.cleanstart.com/knowledge-hub/getting-started-go", "https://www.cleanstart.com/knowledge-hub/getting-started-java", "https://www.cleanstart.com/knowledge-hub/getting-started-kafka", "https://www.cleanstart.com/knowledge-hub/getting-started-nginx", "https://www.cleanstart.com/knowledge-hub/getting-started-nodejs", "https://www.cleanstart.com/knowledge-hub/getting-started-postgresql", "https://www.cleanstart.com/knowledge-hub/getting-started-python", "https://www.cleanstart.com/knowledge-hub/getting-started-redis", "https://www.cleanstart.com/knowledge-hub/getting-started-ruby", "https://www.cleanstart.com/knowledge-hub/getting-started-rust", "https://www.cleanstart.com/knowledge-hub/image-catalog", "https://www.cleanstart.com/knowledge-hub/sdk-quickstart-go", "https://www.cleanstart.com/knowledge-hub/sdk-quickstart-python", "https://www.cleanstart.com/knowledge-hub/sdk-quickstart-typescript", "https://www.cleanstart.com/knowledge-hub/yaml-image-configuration", "https://www.cleanstart.com/blogs/ai-broke-software-security-biggest-assumption", "https://www.cleanstart.com/blogs/ai-supply-chains-are-repeating-open-source-security-mistakes", "https://www.cleanstart.com/blogs/from-awareness-to-assurance-building-trust-in-a-cloud-native-ai-driven-world", "https://www.cleanstart.com/knowledge-hub/ai-container-attack-surface", "https://www.cleanstart.com/knowledge-hub/ai-ml-container-stack-explained", "https://www.cleanstart.com/knowledge-hub/ai-ml-image-reference", "https://www.cleanstart.com/knowledge-hub/ai-runtime-overview", "https://www.cleanstart.com/knowledge-hub/deploying-ai-containers-production", "https://www.cleanstart.com/knowledge-hub/lab-ai-container-builds", "https://www.cleanstart.com/knowledge-hub/machine-speed-vs-human-speed", "https://www.cleanstart.com/knowledge-hub/operating-ai-containers-at-scale", "https://www.cleanstart.com/knowledge-hub/securing-ai-ml-workloads", "https://www.cleanstart.com/knowledge-hub/securing-ai-workloads-with-cleanstart", "https://www.cleanstart.com/knowledge-hub/78-test-inspection-suite", "https://www.cleanstart.com/knowledge-hub/acceptance-testing-guide", "https://www.cleanstart.com/knowledge-hub/cluster-mode-testing", "https://www.cleanstart.com/knowledge-hub/compatibility-testing-matrix", "https://www.cleanstart.com/knowledge-hub/performance-baseline-testing", "https://www.cleanstart.com/knowledge-hub/regression-testing-strategy", "https://www.cleanstart.com/knowledge-hub/security-testing-playbook", "https://www.cleanstart.com/knowledge-hub/test-environment-setup", "https://www.cleanstart.com/blogs/are-docker-hardened-images-free-what-enterprises-should-evaluate-before-adoption", "https://www.cleanstart.com/blogs/chainguard-alternatives-comparing-hardened-container-image-vendors", "https://www.cleanstart.com/blogs/distroless-vs-hardened-vs-minimal-base-images-what-s-the-difference", "https://www.cleanstart.com/blogs/hidden-dangers-why-vulnerable-container-images-cost-more-than-you-think", "https://www.cleanstart.com/blogs/minimal-vs-hardened-vs-secure-container-images-whats-the-difference-and-why-it-matters", "https://www.cleanstart.com/blogs/minimus-alternative-what-to-look-for-after-minimus-shuts-down", "https://www.cleanstart.com/blogs/official-go-docker-image-vs-cleanstart-hardened-go-image", "https://www.cleanstart.com/blogs/official-python-image-vs-cleanstart-python-image", "https://www.cleanstart.com/blogs/official-redis-image-vs-cleanstart-redis-image", "https://www.cleanstart.com/blogs/supply-chain-security-is-now-a-business-variable", "https://www.cleanstart.com/blogs/the-evolution-of-cisos-from-network-guardians-to-product-security-leaders", "https://www.cleanstart.com/blogs/when-everyone-can-code-the-new-business-differentiation-paradigm", "https://www.cleanstart.com/compare/cleanstart-vs-docker-hardened-images", "https://www.cleanstart.com/knowledge-hub/adoption-decision-framework", "https://www.cleanstart.com/knowledge-hub/board-presentation-guide", "https://www.cleanstart.com/knowledge-hub/container-security-maturity-model", "https://www.cleanstart.com/knowledge-hub/executive-summary-one-pager", "https://www.cleanstart.com/knowledge-hub/how-cleanstart-compares", "https://www.cleanstart.com/knowledge-hub/image-size-comparison", "https://www.cleanstart.com/knowledge-hub/tco-business-case", "https://www.cleanstart.com/knowledge-hub/the-numbers", "https://www.cleanstart.com/knowledge-hub/version-matrix", "https://www.cleanstart.com/resources/the-real-cost-of-public-container-images", "https://www.cleanstart.com/clean-libraries", "https://www.cleanstart.com/cleansight", "https://www.cleanstart.com/cleanstart-images", "https://www.cleanstart.com/for-ciso", "https://www.cleanstart.com/for-developers", "https://www.cleanstart.com/impact-estimator", "https://www.cleanstart.com/knowledge-hub/api-compatibility-policy", "https://www.cleanstart.com/knowledge-hub/architecture-overview", "https://www.cleanstart.com/knowledge-hub/clean-cli-reference", "https://www.cleanstart.com/knowledge-hub/cleanimg-customize-guide", "https://www.cleanstart.com/knowledge-hub/cleanimg-customize-reference", "https://www.cleanstart.com/knowledge-hub/cleanimg-init", "https://www.cleanstart.com/knowledge-hub/cleanimg-init-operations", "https://www.cleanstart.com/knowledge-hub/cleanstart-init-guide", "https://www.cleanstart.com/knowledge-hub/cleanstart-utils-reference", "https://www.cleanstart.com/knowledge-hub/clnstrt-cli-guide", "https://www.cleanstart.com/knowledge-hub/customer-delivery-portal", "https://www.cleanstart.com/knowledge-hub/deep-code-analysis-overview", "https://www.cleanstart.com/knowledge-hub/defect-reporting-lifecycle", "https://www.cleanstart.com/knowledge-hub/how-cleanstart-is-different", "https://www.cleanstart.com/knowledge-hub/maintainer-stylometry", "https://www.cleanstart.com/knowledge-hub/sla-documentation", "https://www.cleanstart.com/knowledge-hub/sla-support-tiers", "https://www.cleanstart.com/knowledge-hub/source-intelligence-core", "https://www.cleanstart.com/knowledge-hub/source-intelligence-core-api-reference", "https://www.cleanstart.com/knowledge-hub/threat-remediation-api", "https://www.cleanstart.com/knowledge-hub/troubleshooting", "https://www.cleanstart.com/knowledge-hub/two-factory-architecture", "https://www.cleanstart.com/knowledge-hub/what-is-cleanimg", "https://www.cleanstart.com/knowledge-hub/why-cleanstart", "https://www.cleanstart.com/pricing", "https://www.cleanstart.com/resources/clean-libraries", "https://www.cleanstart.com/resources/cleansight", "https://www.cleanstart.com/teams", "https://www.cleanstart.com", "https://www.cleanstart.com/about-us", "https://www.cleanstart.com/blogs", "https://www.cleanstart.com/blogs/reflections-from-kubecon-2025", "https://www.cleanstart.com/book-a-demo", "https://www.cleanstart.com/careers", "https://www.cleanstart.com/case-studies", "https://www.cleanstart.com/community", "https://www.cleanstart.com/contact-us", "https://www.cleanstart.com/deal-registration", "https://www.cleanstart.com/events", "https://www.cleanstart.com/guide", "https://www.cleanstart.com/industries/financial-services", "https://www.cleanstart.com/industries/software-applications", "https://www.cleanstart.com/news", "https://www.cleanstart.com/partners", "https://www.cleanstart.com/podcast", "https://www.cleanstart.com/resource-center", "https://www.cleanstart.com/webinars", "https://www.cleanstart.com/news/ai-driven-code-and-the-expanding-challenge-of-release-trust", "https://www.cleanstart.com/news/ai-that-fixes-flaws-before-they-ship", "https://www.cleanstart.com/news/beyond-zero-trust-redefining-software-security-in-the-age-of-compromised-code", "https://www.cleanstart.com/news/cleanstart-achieves-350-hardened-vulnerability-free-container-images-accelerating-u-s-expansion", "https://www.cleanstart.com/news/cleanstart-announces-strategic-partnership-with-sysdig-to-deliver-continuous-software-supply-chain-verification-from", "https://www.cleanstart.com/news/cleanstart-expands-docker-hub-community-with-free-daily-updated-images", "https://www.cleanstart.com/news/cleanstart-expands-docker-hub-community-with-free-daily-updated-images-to-support-secure-development", "https://www.cleanstart.com/news/cleanstart-expands-docker-hub-offerings-with-daily-updated-secure-images-for-developers", "https://www.cleanstart.com/news/cleanstart-expands-middle-east-operations-as-demand-grows", "https://www.cleanstart.com/news/cleanstart-expands-vulnerability-free-container-image-library", "https://www.cleanstart.com/news/cleanstart-joins-the-nutanix-technology-alliance-ecosystem", "https://www.cleanstart.com/news/cleanstart-launches-busybox-free-container-foundation-for-secure-deterministic-production", "https://www.cleanstart.com/news/cleanstart-launches-clean-libraries", "https://www.cleanstart.com/news/cleanstart-launches-cleansight-to-give-enterprises-complete-visibility-into-container-risk", "https://www.cleanstart.com/news/cleanstart-launches-industrys-most-comprehensive-sbom-analyzer-to-strengthen-container-security", "https://www.cleanstart.com/news/cleanstart-named-winner-in-software-supply-chain-security-at-the-2026-cybersecurity-stars-awards", "https://www.cleanstart.com/news/cleanstart-wins-2025-cybersecurity-excellence-award", "https://www.cleanstart.com/news/cleanstart-wins-three-gold-awards-at-the-2026-cybersecurity-excellence-awards", "https://www.cleanstart.com/news/ecaps-to-distribute-cleanstart-products-to-strengthen-software-supply-chain-security", "https://www.cleanstart.com/news/enisa-warns-of-escalating-ot-threats", "https://www.cleanstart.com/news/hardened-containers-look-to-eliminate-common-source-of-vulnerabilities", "https://www.cleanstart.com/news/inside-cleanstarts-mission-to-make-software-safer-from-the-ground-up", "https://www.cleanstart.com/news/kolaborasi-baru-untuk-perkuat-keamanan-perangkat-lunak-di-indonesia-dan-asean", "https://www.cleanstart.com/news/recognition-meets-real-world-detection", "https://www.cleanstart.com/news/sboms-in-2026-some-love-some-hate-much-ambivalence", "https://www.cleanstart.com/news/software-supply-chain-posture-management", "https://www.cleanstart.com/news/triam-security-rebrands-as-cleanstart-to-reflect-product-led-focus-on-securing-the-software-supply-chain", "https://www.cleanstart.com/news/where-ai-fits-in-cybersecurity", "https://www.cleanstart.com/news/why-container-images-have-become-a-trust-boundary", "https://www.cleanstart.com/news/why-containers-drive-supply-chain-breaches", "https://www.cleanstart.com/news/why-software-supply-chain-security-is-the-next-big-cyber-threat", "https://www.cleanstart.com/news/zero-common-vulnerabilities-and-exposures-is-secure-by-default-the-future-of-cybersecurity", "https://www.cleanstart.com/event/2nd-edition-india-devsec-show-2025", "https://www.cleanstart.com/event/3rd-edition-india-devsec-show-2026", "https://www.cleanstart.com/event/7th-edition-devops-conclave-awards-2025", "https://www.cleanstart.com/event/9th-edition-india-devops-show-2025", "https://www.cleanstart.com/event/ai-security-leadership-summit", "https://www.cleanstart.com/event/aiss-2025", "https://www.cleanstart.com/event/appdevsec-show-2025", "https://www.cleanstart.com/event/cyber-ai-summit-2025", "https://www.cleanstart.com/event/cyber-security-conclave-check", "https://www.cleanstart.com/event/devops-namma-core-2026-where-devops-meets-secure-software-innovation", "https://www.cleanstart.com/event/dine-with-devops-iv", "https://www.cleanstart.com/event/etcio-annual-conclave-2026-conversations-driving-the-future-of-enterprise-technology", "https://www.cleanstart.com/event/et-ciso", "https://www.cleanstart.com/event/etciso-secufest-2026-advancing-the-future-of-cyber-resilience", "https://www.cleanstart.com/event/executive-roundtable-secure-by-design-redefining-application-security", "https://www.cleanstart.com/event/kcd-2026-connecting-the-kubernetes-community-through-secure-software", "https://www.cleanstart.com/event/kubecon-cloudnativecon-north-america-2025", "https://www.cleanstart.com/event/reflections-from-gids-2026-the-future-of-ai-driven-development", "https://www.cleanstart.com/event/shori-2025-sales-kickoff-driving-the-future-of-cybersecurity", "https://www.cleanstart.com/event/sundown-with-devops", "https://www.cleanstart.com/event/tech-fest-hong-kong-2026-conversations-that-matter", "https://www.cleanstart.com/event/techsparks-2025-16th-edition", "https://www.cleanstart.com/job/admin-operations-executive", "https://www.cleanstart.com/job/business-associate-founder-s-office", "https://www.cleanstart.com/job/business-development-executive", "https://www.cleanstart.com/job/ea-to-ceo", "https://www.cleanstart.com/job/enterprise-account-executive", "https://www.cleanstart.com/job/enterprise-account-executive-us", "https://www.cleanstart.com/job/finance-compliance-manager-global-accounting", "https://www.cleanstart.com/job/finance-executive", "https://www.cleanstart.com/job/motion-graphics-video-editor", "https://www.cleanstart.com/job/pre-sales-engineer-2", "https://www.cleanstart.com/job/pre-sales-engineer-us", "https://www.cleanstart.com/job/regional-account-manager-mumbai", "https://www.cleanstart.com/job/regional-account-manager-new-delhi", "https://www.cleanstart.com/job/regional-accounts-manager-south", "https://www.cleanstart.com/job/sales-engineer", "https://www.cleanstart.com/job/senior-devops-engineer", "https://www.cleanstart.com/job/senior-devsecops-engineer-design-maintenance", "https://www.cleanstart.com/job/senior-software-engineer", "https://www.cleanstart.com/job/zoho-developer", "https://www.cleanstart.com/legal/acceptable-use-policy", "https://www.cleanstart.com/legal/additional-third-party-terms", "https://www.cleanstart.com/legal/customer-data-processing-addendum", "https://www.cleanstart.com/legal/master-serviceand-license-agreement", "https://www.cleanstart.com/legal/policies-and-commitments", "https://www.cleanstart.com/legal/pre-general-availability-terms", "https://www.cleanstart.com/legal/vulnerability-disclosure-policies", "https://www.cleanstart.com/privacy-policy", "https://www.cleanstart.com/author/biplab-paul", "https://www.cleanstart.com/author/biswajit-de", "https://www.cleanstart.com/author/cleanstart-security", "https://www.cleanstart.com/author/dhanush-vm", "https://www.cleanstart.com/author/khushi-trivedi", "https://www.cleanstart.com/author/mayank-solanki", "https://www.cleanstart.com/author/nilesh-jain"] \ No newline at end of file diff --git a/apps/cms/scripts/gate-webflow-gated-resources.ts b/apps/cms/scripts/gate-webflow-gated-resources.ts new file mode 100644 index 000000000..d154ece5e --- /dev/null +++ b/apps/cms/scripts/gate-webflow-gated-resources.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env -S node --no-warnings --experimental-strip-types +/** + * One-shot: restore the download gate on the two resources Webflow gated. + * + * Webflow gated by resource type in its page template, not with a CMS field: + * the ebook and report templates put the PDF link only inside the form's + * success block, so it appeared after a visitor submitted. Whitepapers, + * datasheets and architecture insights rendered an open download link. The + * Webflow CMS export therefore had nothing to read, and the import transform + * wrote `gated: false, accessLevel: 'public'` for every resource, which is how + * both gates were lost in the migration. + * + * Evidence (Wayback snapshots of the live Webflow pages): + * - containing-vulnerabilities-in-your-containers (ebook, 2025-11-09) + * - securing-the-software-supply-chain-in-2026 (report, 2026-01-19) + * On both, every PDF link was either conditionally hidden or inside + * `w-form-done`; neither exposed an open link. All other 25 archived resource + * pages did. + * + * Sets `gated`, points `gateForm` at the `content-gated` form (resolved by slug, + * the only form the Resources gate picker allows), and sets `accessLevel` to + * `lead-gated`, which the field's own help text says gating implies but which no + * hook derives. + * + * Writes via `payload.update` without `draft`, so each resource stays published + * and its afterChange hooks run (including the web revalidation). + * + * Idempotent: a resource already gated behind the right form is skipped. + * + * Flags: + * --dry-run Report what would change without writing. + * + * In the prod container (env is already in the process, there is no .env): + * /app/node_modules/.bin/tsx scripts/gate-webflow-gated-resources.ts --dry-run + * /app/node_modules/.bin/tsx scripts/gate-webflow-gated-resources.ts + */ +import { getPayload } from 'payload'; + +import payloadConfig from '../src/payload.config.ts'; + +const GATE_FORM_SLUG = 'content-gated'; + +const WEBFLOW_GATED_SLUGS = [ + 'containing-vulnerabilities-in-your-containers', + 'securing-the-software-supply-chain-in-2026', +] as const; + +const args = new Set(process.argv.slice(2)); +const DRY_RUN = args.has('--dry-run'); + +const log = (msg: string): void => { + // eslint-disable-next-line no-console -- script output + console.log(msg); +}; + +const idOf = (value: unknown): number | string | null => { + if (value == null) return null; + if (typeof value === 'object' && 'id' in value) { + return (value as { id: number | string }).id; + } + return value as number | string; +}; + +const run = async (): Promise => { + const payload = await getPayload({ config: payloadConfig }); + + log(`\nMode: ${DRY_RUN ? 'DRY RUN (no writes)' : 'WRITE via payload.update'}\n`); + + const forms = await payload.find({ + collection: 'forms', + where: { slug: { equals: GATE_FORM_SLUG } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + const gateForm = forms.docs[0] as { id: number | string; _status?: string } | undefined; + if (!gateForm) { + throw new Error(`Gate form "${GATE_FORM_SLUG}" not found; refusing to gate anything.`); + } + if (gateForm._status !== 'published') { + throw new Error( + `Gate form "${GATE_FORM_SLUG}" is ${String(gateForm._status)}, not published; the modal would not render.`, + ); + } + log(`Gate form: ${GATE_FORM_SLUG} (id ${String(gateForm.id)})\n`); + + let gated = 0; + let skipped = 0; + let missing = 0; + let errors = 0; + + for (const slug of WEBFLOW_GATED_SLUGS) { + const found = await payload.find({ + collection: 'resources', + where: { slug: { equals: slug } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + const doc = found.docs[0] as + | { + id: number | string; + gated?: boolean | null; + gateForm?: unknown; + accessLevel?: string | null; + asset?: unknown; + _status?: string; + } + | undefined; + + if (!doc) { + missing += 1; + log(` MISSING ${slug}`); + continue; + } + if (idOf(doc.asset) == null) { + missing += 1; + log(` NO FILE ${slug} (nothing to gate)`); + continue; + } + + const alreadyGated = + doc.gated === true && + String(idOf(doc.gateForm)) === String(gateForm.id) && + doc.accessLevel === 'lead-gated'; + if (alreadyGated) { + skipped += 1; + log(` skip ${slug} (already gated)`); + continue; + } + + const before = `gated=${String(doc.gated)} gateForm=${String(idOf(doc.gateForm))} accessLevel=${String(doc.accessLevel)}`; + if (DRY_RUN) { + gated += 1; + log(` would gate ${slug} [${before}] status=${String(doc._status)}`); + continue; + } + + try { + await payload.update({ + collection: 'resources', + id: doc.id, + data: { gated: true, gateForm: gateForm.id, accessLevel: 'lead-gated' } as Record< + string, + unknown + >, + overrideAccess: true, + }); + gated += 1; + log(` gated ${slug} [was ${before}]`); + } catch (err) { + errors += 1; + const message = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console -- script output + console.error(` ! resources ${slug}: ${message}`); + } + } + + log(`\nDone. gated=${gated} skipped=${skipped} missing=${missing} errors=${errors}`); + if (errors > 0) process.exitCode = 1; +}; + +run() + .then(() => process.exit(process.exitCode ?? 0)) + .catch((err: unknown) => { + // eslint-disable-next-line no-console -- script output + console.error(err); + process.exit(1); + }); diff --git a/apps/cms/scripts/gsc-single-inspect.ts b/apps/cms/scripts/gsc-single-inspect.ts new file mode 100644 index 000000000..7d210b05b --- /dev/null +++ b/apps/cms/scripts/gsc-single-inspect.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env -S node --no-warnings --experimental-strip-types +/** + * Read-only single-URL GSC inspection, for the one-off case of a URL added + * to an audit list after the main batch (gsc-url-audit.ts) already ran. + * Run inside the prod cms container: + * docker exec -w /app/apps/cms cleanstart-cms-1 pnpm exec tsx scripts/gsc-single-inspect.ts + */ +import { getPayload } from 'payload'; + +import payloadConfig from '../src/payload.config.ts'; +import { resolveGscCredentials } from '../src/payload/lib/integrations/credentials.ts'; + +const run = async (): Promise => { + const url = process.argv[2]; + if (!url) throw new Error('Usage: gsc-single-inspect.ts '); + + const payload = await getPayload({ config: payloadConfig }); + const rows = await payload.find({ + collection: 'integrations', + where: { kind: { equals: 'gscSearchAnalyticsApi' } }, + limit: 1, + overrideAccess: true, + }); + const row = rows.docs[0]; + if (!row) throw new Error('No gscSearchAnalyticsApi integration row found'); + const creds = resolveGscCredentials(row as unknown as { gscConfig?: { siteUrl?: string } }); + if (!creds) throw new Error('Could not resolve GSC credentials'); + + const { google } = await import('googleapis'); + const auth = new google.auth.JWT({ + email: creds.serviceAccountJson.client_email as string, + key: creds.serviceAccountJson.private_key as string, + scopes: ['https://www.googleapis.com/auth/webmasters.readonly'], + }); + const client = google.searchconsole({ version: 'v1', auth }); + + const res = await client.urlInspection.index.inspect({ + requestBody: { siteUrl: creds.siteUrl, inspectionUrl: url }, + }); + const ir = res.data.inspectionResult?.indexStatusResult; + + const fmtDate = (d: Date): string => d.toISOString().slice(0, 10); + const now = new Date(); + const endDate = fmtDate(now); + const startDate = fmtDate(new Date(now.getTime() - 91 * 86_400_000)); + const prevEndDate = fmtDate(new Date(now.getTime() - 92 * 86_400_000)); + const prevStartDate = fmtDate(new Date(now.getTime() - 182 * 86_400_000)); + + const queryOne = async (sd: string, ed: string) => { + const r = await client.searchanalytics.query({ + siteUrl: creds.siteUrl, + requestBody: { + startDate: sd, + endDate: ed, + dimensions: ['page'], + dimensionFilterGroups: [{ filters: [{ dimension: 'page', operator: 'equals', expression: url }] }], + rowLimit: 1, + }, + }); + const row0 = r.data.rows?.[0]; + return { + clicks: row0?.clicks ?? 0, + impressions: row0?.impressions ?? 0, + ctr: row0?.ctr ?? 0, + position: row0?.position ?? 0, + }; + }; + + const current = await queryOne(startDate, endDate); + const previous = await queryOne(prevStartDate, prevEndDate); + + const result = { + url, + indexVerdict: ir?.verdict ?? null, + coverageState: ir?.coverageState ?? null, + robotsTxtState: ir?.robotsTxtState ?? null, + lastCrawlTime: ir?.lastCrawlTime ?? null, + googleCanonical: ir?.googleCanonical ?? null, + userCanonical: ir?.userCanonical ?? null, + current, + previous, + }; + + const fs = await import('node:fs'); + fs.writeFileSync('/tmp/gsc-single-output.json', JSON.stringify(result, null, 2)); + payload.logger.info(JSON.stringify(result)); + process.exit(0); +}; + +run().catch((err: unknown) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/cms/scripts/gsc-url-audit.ts b/apps/cms/scripts/gsc-url-audit.ts new file mode 100644 index 000000000..756490c5e --- /dev/null +++ b/apps/cms/scripts/gsc-url-audit.ts @@ -0,0 +1,125 @@ +#!/usr/bin/env -S node --no-warnings --experimental-strip-types +/** + * Read-only GSC audit for a fixed list of URLs (ad-hoc reporting, not a + * standing job). For each URL: indexing status via URL Inspection API, plus + * clicks/impressions/ctr/position for the trailing 91-day window vs the 91 + * days before that (matches the delta convention in + * lib/integrations/dashboards/advanced-metrics.ts computeDelta). + * + * Input: scripts/data/gsc-audit-urls.json — string[] of full URLs + * Output: /tmp/gsc-audit-output.json + * + * Run inside the prod cms container: + * docker exec -w /app/apps/cms cleanstart-cms-1 pnpm exec tsx scripts/gsc-url-audit.ts + */ +import { getPayload } from 'payload'; + +import payloadConfig from '../src/payload.config.ts'; +import { resolveGscCredentials } from '../src/payload/lib/integrations/credentials.ts'; + +const fmtDate = (d: Date): string => d.toISOString().slice(0, 10); +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +const run = async (): Promise => { + const fs = await import('node:fs'); + const urls: string[] = JSON.parse( + fs.readFileSync('scripts/data/gsc-audit-urls.json', 'utf8'), + ); + + const payload = await getPayload({ config: payloadConfig }); + const rows = await payload.find({ + collection: 'integrations', + where: { kind: { equals: 'gscSearchAnalyticsApi' } }, + limit: 1, + overrideAccess: true, + }); + const row = rows.docs[0]; + if (!row) throw new Error('No gscSearchAnalyticsApi integration row found'); + const creds = resolveGscCredentials(row as unknown as { gscConfig?: { siteUrl?: string } }); + if (!creds) throw new Error('Could not resolve GSC credentials (missing env or siteUrl)'); + + const { google } = await import('googleapis'); + const auth = new google.auth.JWT({ + email: creds.serviceAccountJson.client_email as string, + key: creds.serviceAccountJson.private_key as string, + scopes: ['https://www.googleapis.com/auth/webmasters.readonly'], + }); + const client = google.searchconsole({ version: 'v1', auth }); + + const now = new Date(); + const endDate = fmtDate(now); + const startDate = fmtDate(new Date(now.getTime() - 91 * 86_400_000)); + const prevEndDate = fmtDate(new Date(now.getTime() - 92 * 86_400_000)); + const prevStartDate = fmtDate(new Date(now.getTime() - 182 * 86_400_000)); + + payload.logger.info(`Site: ${creds.siteUrl}`); + payload.logger.info(`Current window: ${startDate} .. ${endDate}`); + payload.logger.info(`Previous window: ${prevStartDate} .. ${prevEndDate}`); + + const queryPages = async (sd: string, ed: string) => { + const res = await client.searchanalytics.query({ + siteUrl: creds.siteUrl, + requestBody: { startDate: sd, endDate: ed, dimensions: ['page'], rowLimit: 25000 }, + }); + const map = new Map(); + for (const r of res.data.rows ?? []) { + const page = r.keys?.[0]; + if (!page) continue; + map.set(page, { + clicks: r.clicks ?? 0, + impressions: r.impressions ?? 0, + ctr: r.ctr ?? 0, + position: r.position ?? 0, + }); + } + return map; + }; + + const currentMap = await queryPages(startDate, endDate); + const previousMap = await queryPages(prevStartDate, prevEndDate); + payload.logger.info(`Current window pages: ${currentMap.size}, previous: ${previousMap.size}`); + + const results: Array> = []; + for (const [i, url] of urls.entries()) { + let inspection: Awaited>['data'] | null = null; + let inspectError: string | undefined; + try { + const res = await client.urlInspection.index.inspect({ + requestBody: { siteUrl: creds.siteUrl, inspectionUrl: url }, + }); + inspection = res.data; + } catch (err) { + inspectError = err instanceof Error ? err.message : String(err); + } + + const ir = inspection?.inspectionResult?.indexStatusResult; + results.push({ + url, + indexVerdict: ir?.verdict ?? null, + coverageState: ir?.coverageState ?? null, + robotsTxtState: ir?.robotsTxtState ?? null, + lastCrawlTime: ir?.lastCrawlTime ?? null, + googleCanonical: ir?.googleCanonical ?? null, + userCanonical: ir?.userCanonical ?? null, + inspectError, + current: currentMap.get(url) ?? { clicks: 0, impressions: 0, ctr: 0, position: 0 }, + previous: previousMap.get(url) ?? { clicks: 0, impressions: 0, ctr: 0, position: 0 }, + }); + + if ((i + 1) % 25 === 0) payload.logger.info(`Inspected ${i + 1}/${urls.length}`); + // Stay well under GSC's per-minute burst limit. + await sleep(300); + } + + fs.writeFileSync( + '/tmp/gsc-audit-output.json', + JSON.stringify({ siteUrl: creds.siteUrl, startDate, endDate, prevStartDate, prevEndDate, results }, null, 2), + ); + payload.logger.info(`Wrote /tmp/gsc-audit-output.json (${results.length} URLs).`); + process.exit(0); +}; + +run().catch((err: unknown) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/cms/scripts/set-form-marketing-subscriptions.ts b/apps/cms/scripts/set-form-marketing-subscriptions.ts new file mode 100644 index 000000000..c452e7782 --- /dev/null +++ b/apps/cms/scripts/set-form-marketing-subscriptions.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env -S node --no-warnings --experimental-strip-types +/** + * One-shot: point the book-a-demo and gated-download forms at the HubSpot + * "Marketing Information" subscription, so a visitor who ticks "Keep me + * updated" is actually subscribed. + * + * Neither form had a subscription type, so the relay sent consent to process + * only and every marketing opt-in was dropped. + * + * ORDER MATTERS. Until the relay change that sends a subscription only for a + * visitor who ticked the box, setting a type here would subscribe EVERY + * visitor to marketing. This script therefore refuses to write unless the code + * it runs against contains that guard: it imports `hubspotLegalConsent` (absent + * before the fix, so the import fails) and checks that an unticked visitor gets + * no subscription. + * + * IDs verified 2026-09-17 in HubSpot (Settings > Marketing > Email > + * Subscription Types, "Used in"). The portal has exactly three types, none + * archived: + * 2258674941 Marketing Information <- used here + * 2258674944 One to One (HubSpot's default for 1:1 sales email) + * 3005083821 Newsletter (the newsletter form, left unchanged) + * The gated-download HubSpot form's own checkbox is wired to One to One, which + * is wrong for "Keep me updated", so its ID is not the one to copy. + * + * Contact is deliberately excluded: its HubSpot form has no consent + * configuration at all. + * + * Writes via `payload.update`, is idempotent, and supports --dry-run. + * + * In the prod container (env is in the process, there is no .env): + * /app/node_modules/.bin/tsx scripts/set-form-marketing-subscriptions.ts --dry-run + * /app/node_modules/.bin/tsx scripts/set-form-marketing-subscriptions.ts + */ +import { getPayload } from 'payload'; + +import payloadConfig from '../src/payload.config.ts'; +import { hubspotLegalConsent } from '../src/payload/lib/lead-handlers/hubspot.ts'; + +const MARKETING_INFORMATION = '2258674941'; + +const TARGETS: Readonly> = { + 'book-a-demo': MARKETING_INFORMATION, + 'content-gated': MARKETING_INFORMATION, +}; + +const args = new Set(process.argv.slice(2)); +const DRY_RUN = args.has('--dry-run'); + +const log = (msg: string): void => { + // eslint-disable-next-line no-console -- script output + console.log(msg); +}; + +/** Refuse to run against a relay that would subscribe unticked visitors. */ +const assertOptInGuard = (): void => { + const probe = { givenAt: '2026-01-01T00:00:00.000Z', snapshot: 'probe' }; + const unticked = hubspotLegalConsent({ ...probe, categories: ['storage'] }, 1); + const ticked = hubspotLegalConsent({ ...probe, categories: ['storage', 'marketing'] }, 1); + if (unticked?.consent.communications !== undefined) { + throw new Error('Relay would subscribe a visitor who did not opt in. Deploy the fix first.'); + } + if (ticked?.consent.communications === undefined) { + throw new Error('Relay does not subscribe a visitor who opted in. Refusing to continue.'); + } +}; + +const run = async (): Promise => { + assertOptInGuard(); + log('Opt-in guard present in the running relay.'); + + const payload = await getPayload({ config: payloadConfig }); + log(`\nMode: ${DRY_RUN ? 'DRY RUN (no writes)' : 'WRITE via payload.update'}\n`); + + let updated = 0; + let skipped = 0; + let missing = 0; + let errors = 0; + + for (const [slug, typeId] of Object.entries(TARGETS)) { + const found = await payload.find({ + collection: 'forms', + where: { slug: { equals: slug } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + const form = found.docs[0] as + | { id: number | string; hubspotSubscriptionTypeId?: string | null } + | undefined; + if (!form) { + missing += 1; + log(` MISSING ${slug}`); + continue; + } + const current = form.hubspotSubscriptionTypeId?.trim() ?? ''; + if (current === typeId) { + skipped += 1; + log(` skip ${slug} (already ${typeId})`); + continue; + } + if (DRY_RUN) { + updated += 1; + log(` would set ${slug}: ${current || '(none)'} -> ${typeId}`); + continue; + } + try { + await payload.update({ + collection: 'forms', + id: form.id, + data: { hubspotSubscriptionTypeId: typeId } as Record, + overrideAccess: true, + }); + updated += 1; + log(` set ${slug}: ${current || '(none)'} -> ${typeId}`); + } catch (err) { + errors += 1; + const message = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console -- script output + console.error(` ! forms ${slug}: ${message}`); + } + } + + log(`\nDone. updated=${updated} skipped=${skipped} missing=${missing} errors=${errors}`); + if (errors > 0) process.exitCode = 1; +}; + +run() + .then(() => process.exit(process.exitCode ?? 0)) + .catch((err: unknown) => { + // eslint-disable-next-line no-console -- script output + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); diff --git a/apps/cms/src/app/(payload)/admin/importMap.js b/apps/cms/src/app/(payload)/admin/importMap.js index 8fb11a456..db5c61cd5 100644 --- a/apps/cms/src/app/(payload)/admin/importMap.js +++ b/apps/cms/src/app/(payload)/admin/importMap.js @@ -86,6 +86,7 @@ import { EmailField as EmailField_7dd923731832c5c33b25cd573216c1bc } from '@/pay import { LegacyBioViewer as LegacyBioViewer_879b4bd183ddbb79ae3f346d2661eacc } from '@/payload/admin/components/LegacyBioViewer.tsx' import { AuthorCredibilityField as AuthorCredibilityField_0547251fb6ced865a9066cb05f501acb } from '@/payload/admin/components/AuthorCredibilityField.tsx' import { LeadContactCell as LeadContactCell_7859b912766a925a1159bc4804dbdd5b } from '@/payload/admin/components/LeadContactCell.tsx' +import { LeadSyncCell as LeadSyncCell_7ea3c85275f13edbd48d8fecc2b96e98 } from '@/payload/admin/components/LeadSyncCell.tsx' import { LeadsImmutableBanner as LeadsImmutableBanner_c4273dccee2521e4317461376b79e241 } from '@/payload/admin/components/LeadsImmutableBanner.tsx' import { LeadsCsvTruncationBanner as LeadsCsvTruncationBanner_e22b2ed864c6d0e2876f5238fb489adb } from '@/payload/admin/components/LeadsCsvTruncationBanner.tsx' import { DsarActionsPanel as DsarActionsPanel_270b8398021fd191ac147b0b8d59049c } from '@/payload/admin/components/DsarActionsPanel.tsx' @@ -239,6 +240,7 @@ export const importMap = { "@/payload/admin/components/LegacyBioViewer.tsx#LegacyBioViewer": LegacyBioViewer_879b4bd183ddbb79ae3f346d2661eacc, "@/payload/admin/components/AuthorCredibilityField.tsx#AuthorCredibilityField": AuthorCredibilityField_0547251fb6ced865a9066cb05f501acb, "@/payload/admin/components/LeadContactCell.tsx#LeadContactCell": LeadContactCell_7859b912766a925a1159bc4804dbdd5b, + "@/payload/admin/components/LeadSyncCell.tsx#LeadSyncCell": LeadSyncCell_7ea3c85275f13edbd48d8fecc2b96e98, "@/payload/admin/components/LeadsImmutableBanner.tsx#LeadsImmutableBanner": LeadsImmutableBanner_c4273dccee2521e4317461376b79e241, "@/payload/admin/components/LeadsCsvTruncationBanner.tsx#LeadsCsvTruncationBanner": LeadsCsvTruncationBanner_e22b2ed864c6d0e2876f5238fb489adb, "@/payload/admin/components/DsarActionsPanel.tsx#DsarActionsPanel": DsarActionsPanel_270b8398021fd191ac147b0b8d59049c, diff --git a/apps/cms/src/app/(payload)/styles/_paper-cuts.scss b/apps/cms/src/app/(payload)/styles/_paper-cuts.scss index 2704ae449..b03a677e8 100644 --- a/apps/cms/src/app/(payload)/styles/_paper-cuts.scss +++ b/apps/cms/src/app/(payload)/styles/_paper-cuts.scss @@ -159,6 +159,32 @@ } } +// ─── Lead sync chips (`LeadSyncCell`) ───────────────────────────── +.sync-cell { + @extend %cs-chip-sm; + font-family: inherit; + white-space: nowrap; + cursor: help; + + &[data-state='ok'] { + color: var(--color-success-500, #00c46a); + background: rgba(0, 196, 106, 0.14); + border-color: rgba(0, 196, 106, 0.28); + } + + &[data-state='pending'] { + color: var(--theme-text-soft); + background: var(--theme-elevation-150); + border-color: var(--theme-elevation-200); + } + + &[data-state='failed'] { + color: var(--color-error-500); + background: rgba(255, 77, 79, 0.12); + border-color: rgba(255, 77, 79, 0.3); + } +} + // ─── Integrations health badge — traffic-light chip ─────────────── // // `HealthBadge.tsx` renders ` { + const summary = summarizeSync(cellData); + if (!summary) { + return ; + } + return ( + + {summary.label} + + ); +}; + +export default LeadSyncCell; diff --git a/apps/cms/src/payload/collections/Leads.ts b/apps/cms/src/payload/collections/Leads.ts index 7963c73f1..cc6b53c11 100644 --- a/apps/cms/src/payload/collections/Leads.ts +++ b/apps/cms/src/payload/collections/Leads.ts @@ -220,11 +220,15 @@ export const Leads: CollectionConfig = { { name: 'syncedTo', type: 'array', - labels: { singular: 'Sync', plural: 'Sync attempts' }, + label: 'Sync status', + labels: { singular: 'Sync step', plural: 'Sync steps' }, admin: { description: - 'One row per secondary handler (HubSpot, company-from-domain). Failed rows are retryable.', + 'One row per handler step (database, company lookup, HubSpot, confirmation email). Skipped steps had nothing to do. Failed rows are retryable.', readOnly: true, + components: { + Cell: '@/payload/admin/components/LeadSyncCell.tsx#LeadSyncCell', + }, }, fields: [ { name: 'handler', type: 'text', required: true }, diff --git a/apps/cms/src/payload/lib/email/lead-emails.test.ts b/apps/cms/src/payload/lib/email/lead-emails.test.ts new file mode 100644 index 000000000..3c464d5d5 --- /dev/null +++ b/apps/cms/src/payload/lib/email/lead-emails.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { buildResourceDownloadEmail } from './lead-emails'; + +const HOUR = 3_600_000; + +const input = { + firstName: 'Elena', + resourceTitle: 'Securing the Software Supply Chain in 2026', + downloadUrl: 'https://cms.example.com/api/resources/r/download?token=t', + expiresAt: Date.now() + 24 * HOUR, +}; + +describe('buildResourceDownloadEmail', () => { + it('thanks the visitor by resource in the subject', () => { + const { subject } = buildResourceDownloadEmail(input); + expect(subject).toBe('Thanks for downloading Securing the Software Supply Chain in 2026'); + }); + + it('opens with thanks rather than just handing over a file', () => { + const { htmlContent } = buildResourceDownloadEmail(input); + expect(htmlContent).toContain( + 'Thank you for downloading Securing the Software Supply Chain in 2026.', + ); + }); + + // The download already started in the browser; the email is the way back to + // the file for a visitor who closed the tab. + it('still carries the signed link as a way back to the file', () => { + const { htmlContent } = buildResourceDownloadEmail(input); + expect(htmlContent).toContain(input.downloadUrl); + expect(htmlContent).toContain('Download your copy'); + }); + + it('states how long the link lasts', () => { + const { htmlContent } = buildResourceDownloadEmail(input); + expect(htmlContent).toContain('stops working in about 24 hours'); + }); + + it('uses the singular for a one-hour link', () => { + const { htmlContent } = buildResourceDownloadEmail({ + ...input, + expiresAt: Date.now() + HOUR, + }); + expect(htmlContent).toContain('stops working in about 1 hour.'); + }); + + // 9f0defc2 removed every response-time promise from site mail because it + // reads as a commitment the team has not agreed to. + it('makes no promise about when anyone will follow up', () => { + const { htmlContent } = buildResourceDownloadEmail(input); + expect(htmlContent).not.toMatch(/business day|within \d+ hours|get in touch|reach out/i); + }); +}); diff --git a/apps/cms/src/payload/lib/email/lead-emails.ts b/apps/cms/src/payload/lib/email/lead-emails.ts index d87d32400..a3ccb7434 100644 --- a/apps/cms/src/payload/lib/email/lead-emails.ts +++ b/apps/cms/src/payload/lib/email/lead-emails.ts @@ -91,25 +91,30 @@ export type ResourceDownloadInput = ConfirmationInput & { }; /** - * Emails the gated-resource link. + * Thanks the visitor after a gated download, and carries the link. * - * The link was previously returned only in the HTTP response, so closing the - * tab lost the asset with no way to recover it, and it removed the only - * reason for a visitor to give a real address, which is the point of gating. + * The download starts in the browser as soon as the gate form succeeds, so this + * arrives afterwards: it leads with thanks, and the link is the way back to the + * file for anyone who closed the tab. Before the link was emailed at all it + * lived only in the HTTP response, so a closed tab lost the asset for good. */ export const buildResourceDownloadEmail = ( input: ResourceDownloadInput, ): { subject: string; htmlContent: string } => { const hours = Math.max(1, Math.round((input.expiresAt - Date.now()) / 3_600_000)); return { - subject: `Your download: ${input.resourceTitle}`, + subject: `Thanks for downloading ${input.resourceTitle}`, htmlContent: renderEmail({ - preheader: `${input.resourceTitle} is ready to download.`, - eyebrow: 'Your download', + preheader: `Your copy of ${input.resourceTitle}, and a link to download it again.`, + eyebrow: 'Thank you', heading: greeting(input.firstName), blocks: [ - { kind: 'paragraph', text: `Here is your copy of ${input.resourceTitle}.` }, - { kind: 'button', label: 'Download it now', url: input.downloadUrl }, + { kind: 'paragraph', text: `Thank you for downloading ${input.resourceTitle}.` }, + { + kind: 'paragraph', + text: 'Your download should have started in your browser. If it did not, or you want the file again later, use the button below.', + }, + { kind: 'button', label: 'Download your copy', url: input.downloadUrl }, { kind: 'note', text: `This link is unique to you and stops working in about ${hours} ${hours === 1 ? 'hour' : 'hours'}. Request the resource again if it expires.`, diff --git a/apps/cms/src/payload/lib/email/registry.ts b/apps/cms/src/payload/lib/email/registry.ts index 8e06b63d8..41c5a8bd8 100644 --- a/apps/cms/src/payload/lib/email/registry.ts +++ b/apps/cms/src/payload/lib/email/registry.ts @@ -91,7 +91,7 @@ export const EMAIL_TEMPLATES: readonly EmailTemplateEntry[] = [ }, { key: 'resource-download', - form: 'resource-capture (gated resources)', + form: 'content-gated (gated resource downloads)', audience: 'visitor', sentFrom: 'endpoints/submit-lead.ts', sample: () => diff --git a/apps/cms/src/payload/lib/lead-handlers/hubspot.test.ts b/apps/cms/src/payload/lib/lead-handlers/hubspot.test.ts index 838b19e9b..9c3933f52 100644 --- a/apps/cms/src/payload/lib/lead-handlers/hubspot.test.ts +++ b/apps/cms/src/payload/lib/lead-handlers/hubspot.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { attributionHubspotFields, hubspotHandler, invalidHubspotFieldNames } from './hubspot'; +import { + attributionHubspotFields, + hubspotHandler, + hubspotLegalConsent, + invalidHubspotFieldNames, +} from './hubspot'; import type { LeadSubmission } from './types'; const submission: LeadSubmission = { @@ -68,25 +73,61 @@ describe('hubspotHandler (Forms API)', () => { expect(sent.legalConsentOptions).toBeDefined(); }); - it('includes a marketing-subscription opt-in when the form has hubspotSubscriptionTypeId', async () => { - const fetchSpy = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); - vi.stubGlobal('fetch', fetchSpy); - await hubspotHandler.run(submission, ctx('guid-1', '42')); + interface SentBody { + legalConsentOptions: { + consent: { + consentToProcess: boolean; + text: string; + communications?: { value: boolean; subscriptionTypeId: number; text: string }[]; + }; + }; + } + const sentBody = (fetchSpy: ReturnType): SentBody => { const [, init] = fetchSpy.mock.calls[0] ?? []; - const sent = JSON.parse((init as RequestInit).body as string); + return JSON.parse((init as RequestInit).body as string) as SentBody; + }; + const okFetch = (): ReturnType => { + const spy = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); + vi.stubGlobal('fetch', spy); + return spy; + }; + const ticked = (categories: string[] | undefined): LeadSubmission => ({ + ...submission, + consent: { snapshot: 'I agree…', givenAt: '2026-06-02T00:00:00Z', categories }, + }); + + it('subscribes a visitor who ticked marketing, when the form names a subscription type', async () => { + const fetchSpy = okFetch(); + await hubspotHandler.run(ticked(['storage', 'marketing']), ctx('guid-1', '42')); + const sent = sentBody(fetchSpy); expect(sent.legalConsentOptions.consent.communications).toEqual([ { value: true, subscriptionTypeId: 42, text: 'I agree…' }, ]); expect(sent.legalConsentOptions.consent.consentToProcess).toBe(true); }); - it('omits communications when the form has no hubspotSubscriptionTypeId', async () => { - const fetchSpy = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); - vi.stubGlobal('fetch', fetchSpy); - await hubspotHandler.run(submission, ctx('guid-1')); - const [, init] = fetchSpy.mock.calls[0] ?? []; - const sent = JSON.parse((init as RequestInit).body as string); + // The regression this guards: the form's subscription type used to be enough + // on its own, so every visitor was subscribed whether or not they ticked. + it('does not subscribe a visitor who left marketing unticked', async () => { + const fetchSpy = okFetch(); + await hubspotHandler.run(ticked(['storage']), ctx('guid-1', '42')); + const sent = sentBody(fetchSpy); expect(sent.legalConsentOptions.consent.communications).toBeUndefined(); + expect(sent.legalConsentOptions.consent.consentToProcess).toBe(true); + }); + + it('does not subscribe when the consent carries no categories at all', async () => { + const fetchSpy = okFetch(); + await hubspotHandler.run(submission, ctx('guid-1', '42')); + expect(sentBody(fetchSpy).legalConsentOptions.consent.communications).toBeUndefined(); + }); + + it('omits communications when the form has no hubspotSubscriptionTypeId, even if ticked', async () => { + const fetchSpy = okFetch(); + await hubspotHandler.run(ticked(['storage', 'marketing']), ctx('guid-1')); + const sent = sentBody(fetchSpy); + expect(sent.legalConsentOptions.consent.communications).toBeUndefined(); + expect(sent.legalConsentOptions.consent.consentToProcess).toBe(true); }); it('returns failed on a non-2xx response', async () => { @@ -289,3 +330,45 @@ describe('hubspotHandler — company and context', () => { expect(sent.phone).toBe('+919876543210'); }); }); + +describe('hubspotLegalConsent', () => { + const consent = (categories?: string[]) => ({ + snapshot: 'I agree…', + givenAt: '2026-06-02T00:00:00Z', + ...(categories ? { categories } : {}), + }); + + it('returns nothing when the visitor gave no consent', () => { + expect(hubspotLegalConsent(undefined, 42)).toBeUndefined(); + }); + + it('always records consent to process', () => { + expect(hubspotLegalConsent(consent(['storage']), 42)).toEqual({ + consent: { consentToProcess: true, text: 'I agree…' }, + }); + }); + + it('adds the subscription only for a marketing opt-in on a form that has one', () => { + expect(hubspotLegalConsent(consent(['storage', 'marketing']), 42)).toEqual({ + consent: { + consentToProcess: true, + text: 'I agree…', + communications: [{ value: true, subscriptionTypeId: 42, text: 'I agree…' }], + }, + }); + }); + + it('never subscribes without an opt-in, whatever the subscription type', () => { + for (const categories of [undefined, [], ['storage']]) { + expect(hubspotLegalConsent(consent(categories), 42)?.consent).not.toHaveProperty( + 'communications', + ); + } + }); + + it('ignores an opt-in when the form has no subscription type', () => { + expect(hubspotLegalConsent(consent(['marketing']), Number.NaN)?.consent).not.toHaveProperty( + 'communications', + ); + }); +}); diff --git a/apps/cms/src/payload/lib/lead-handlers/hubspot.ts b/apps/cms/src/payload/lib/lead-handlers/hubspot.ts index 1a3c66d9c..4edd78936 100644 --- a/apps/cms/src/payload/lib/lead-handlers/hubspot.ts +++ b/apps/cms/src/payload/lib/lead-handlers/hubspot.ts @@ -52,6 +52,37 @@ export const attributionHubspotFields = ( .map(([name, value]) => ({ name, value })); }; +/** The consent category a visitor ticks to ask for marketing email. */ +const MARKETING_CATEGORY = 'marketing'; + +/** + * Builds the Forms API `legalConsentOptions` for a submission. + * + * Consent to process is always recorded. A marketing subscription is added + * only when the visitor ticked the marketing box AND the form names a + * subscription type. The form's type used to be enough on its own, which + * would have subscribed every visitor whether or not they ticked, once a type + * was set; that is why most forms had none, and why real opt-ins were dropped. + * + * The newsletter is unaffected: signing up is itself the opt-in, so it always + * sends the marketing category. + */ +export const hubspotLegalConsent = ( + consent: LeadSubmission['consent'], + subscriptionTypeId: number, +): { consent: Record } | undefined => { + if (!consent) return undefined; + const out: Record = { + consentToProcess: true, + text: consent.snapshot, + }; + const optedIn = consent.categories?.includes(MARKETING_CATEGORY) === true; + if (optedIn && Number.isFinite(subscriptionTypeId)) { + out.communications = [{ value: true, subscriptionTypeId, text: consent.snapshot }]; + } + return { consent: out }; +}; + /** * HubSpot lead handler. * @@ -201,18 +232,8 @@ export const hubspotHandler: LeadHandler = { ...(submission.ip ? { ipAddress: submission.ip } : {}), }, }; - if (submission.consent) { - const consent: Record = { - consentToProcess: true, - text: submission.consent.snapshot, - }; - if (Number.isFinite(subscriptionTypeId)) { - consent.communications = [ - { value: true, subscriptionTypeId, text: submission.consent.snapshot }, - ]; - } - body.legalConsentOptions = { consent }; - } + const legalConsentOptions = hubspotLegalConsent(submission.consent, subscriptionTypeId); + if (legalConsentOptions) body.legalConsentOptions = legalConsentOptions; const post = async (payload: Record): Promise => fetch(`https://api.hsforms.com/submissions/v3/integration/submit/${portalId}/${guid}`, { diff --git a/apps/cms/src/payload/lib/lead-handlers/sync-summary.test.ts b/apps/cms/src/payload/lib/lead-handlers/sync-summary.test.ts new file mode 100644 index 000000000..cd17b83aa --- /dev/null +++ b/apps/cms/src/payload/lib/lead-handlers/sync-summary.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { summarizeSync } from './sync-summary'; + +describe('summarizeSync', () => { + it('returns null when nothing ran', () => { + expect(summarizeSync(undefined)).toBeNull(); + expect(summarizeSync(null)).toBeNull(); + expect(summarizeSync([])).toBeNull(); + }); + + it('counts skipped steps as done', () => { + const summary = summarizeSync([ + { handler: 'db-primary', status: 'synced' }, + { handler: 'company-from-domain', status: 'skipped', error: 'free-mail-or-generic' }, + { handler: 'hubspot', status: 'synced' }, + { handler: 'confirmation-email', status: 'synced' }, + ]); + expect(summary).toMatchObject({ state: 'ok', label: '4/4 synced', done: 4, total: 4 }); + }); + + it('flags failures ahead of the done count', () => { + const summary = summarizeSync([ + { handler: 'db-primary', status: 'synced' }, + { handler: 'hubspot', status: 'failed', error: 'HTTP 500' }, + { handler: 'confirmation-email', status: 'failed' }, + ]); + expect(summary).toMatchObject({ state: 'failed', label: '2 failed · 1/3 synced' }); + }); + + it('reports pending steps when nothing failed', () => { + const summary = summarizeSync([ + { handler: 'db-primary', status: 'synced' }, + { handler: 'hubspot', status: 'pending' }, + ]); + expect(summary).toMatchObject({ state: 'pending', label: '1/2 synced · 1 pending' }); + }); + + it('lists every step with its error in the detail text', () => { + const summary = summarizeSync([ + { handler: 'db-primary', status: 'synced' }, + { handler: 'hubspot', status: 'failed', error: 'HTTP 500' }, + ]); + expect(summary?.detail).toBe('db-primary: synced\nhubspot: failed (HTTP 500)'); + }); + + it('treats an unknown status as pending rather than done', () => { + const summary = summarizeSync([{ handler: 'hubspot', status: 'queued' }]); + expect(summary).toMatchObject({ state: 'pending', label: '0/1 synced · 1 pending' }); + }); +}); diff --git a/apps/cms/src/payload/lib/lead-handlers/sync-summary.ts b/apps/cms/src/payload/lib/lead-handlers/sync-summary.ts new file mode 100644 index 000000000..cd20cc6b7 --- /dev/null +++ b/apps/cms/src/payload/lib/lead-handlers/sync-summary.ts @@ -0,0 +1,52 @@ +export type SyncRow = { + handler?: string | null; + status?: string | null; + error?: string | null; +}; + +export type SyncState = 'ok' | 'pending' | 'failed'; + +export type SyncSummary = { + state: SyncState; + label: string; + detail: string; + done: number; + total: number; +}; + +/** + * Condenses a lead's `syncedTo` rows into one list-view label. + * + * `skipped` counts as done: it means a handler had nothing to do (a free-mail + * domain for company lookup, a duplicate), not that delivery was lost. + */ +export function summarizeSync(rows: readonly SyncRow[] | null | undefined): SyncSummary | null { + if (!rows || rows.length === 0) return null; + + let done = 0; + let failed = 0; + for (const row of rows) { + if (row.status === 'synced' || row.status === 'skipped') done += 1; + else if (row.status === 'failed') failed += 1; + } + const total = rows.length; + const pending = total - done - failed; + const synced = `${done}/${total} synced`; + + const state: SyncState = failed > 0 ? 'failed' : pending > 0 ? 'pending' : 'ok'; + const label = + state === 'failed' + ? `${failed} failed · ${synced}` + : state === 'pending' + ? `${synced} · ${pending} pending` + : synced; + + const detail = rows + .map((row) => { + const line = `${row.handler ?? 'unknown'}: ${row.status ?? 'unknown'}`; + return row.error ? `${line} (${row.error})` : line; + }) + .join('\n'); + + return { state, label, detail, done, total }; +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 7233d6270..0cc7e7dad 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -230,6 +230,14 @@ const nextConfig: NextConfig = { "/news/triam-security-rebrands-as-cleanstart-to-reflect-product-led-focus-on-securing-the-software-supply-chain", permanent: true, }, + // Client shared this blog URL without the /blogs/ prefix. Catch the bare + // slug and send it to the real post rather than 404ing (there is no + // root-level [slug] route in apps/web). + { + source: "/busybox-container-security-risk", + destination: "/blogs/busybox-container-security-risk", + permanent: true, + }, ]; }, images: { diff --git a/apps/web/public/images/tricorder/hero-intelligence-cube.webp b/apps/web/public/images/tricorder/hero-intelligence-cube.webp new file mode 100644 index 000000000..0a6397b8d Binary files /dev/null and b/apps/web/public/images/tricorder/hero-intelligence-cube.webp differ diff --git a/apps/web/public/images/tricorder/hub-intelligence-cube.webp b/apps/web/public/images/tricorder/hub-intelligence-cube.webp new file mode 100644 index 000000000..3b6d12729 Binary files /dev/null and b/apps/web/public/images/tricorder/hub-intelligence-cube.webp differ diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index 535a03f26..2b0f7220d 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -9,6 +9,7 @@ - [Attack Surface Reduction](https://www.cleanstart.com/attack-surface-reduction): Shrink your container attack surface by removing unnecessary packages and layers. - [Vulnerability Remediation](https://www.cleanstart.com/vulnerability-remediation): Automated CVE remediation workflows for container images. - [Clean Libraries](https://www.cleanstart.com/clean-libraries): Hardened open-source library builds with security patches backported. +- [Tricorder](https://www.cleanstart.com/tricorder): The intelligence layer behind CleanStart — behavioral analysis, package history, cross-package correlation and threat intelligence combined into an evidence-backed verdict for every component. ## Solutions by Role - [For CISOs](https://www.cleanstart.com/for-ciso): Executive-level container security posture, compliance reporting, and risk reduction metrics. diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 8472e705f..f4b8aebb4 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -6366,3 +6366,224 @@ body { .cs-thank-you-secondary:hover svg { transform: translateX(3px); } + +/* Tricorder page — the intelligence cube (TricorderHero, TricorderSubstrate) + drifts on a slow float. Transform only; off under reduced motion. */ +@keyframes cs-tri-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} +.cs-tri-float { + will-change: transform; + animation: cs-tri-float 6s ease-in-out infinite; +} + +/* Tricorder page — the verdict engine (TricorderPipeline). Signal particles + travel the rail (the wrapper spans the rail, the particle sits on its far + edge, so -100% to 0 is one full crossing), and the active station's podium + sends out a ping. Transform / opacity only. */ +@keyframes cs-tri-signal-x { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} +@keyframes cs-tri-signal-y { + from { + transform: translateY(-100%); + } + to { + transform: translateY(0); + } +} +@keyframes cs-tri-ping { + from { + transform: scale(0.9); + opacity: 0.9; + } + to { + transform: scale(1.7); + opacity: 0; + } +} +.cs-tri-signal-x { + will-change: transform; + animation: cs-tri-signal-x 3.1s linear infinite; +} +.cs-tri-signal-y { + will-change: transform; + animation: cs-tri-signal-y 3.1s linear infinite; +} +.cs-tri-ping { + animation: cs-tri-ping 1.1s cubic-bezier(0.16, 1, 0.3, 1) both; +} +@media (prefers-reduced-motion: reduce) { + .cs-tri-float, + .cs-tri-signal-x, + .cs-tri-signal-y, + .cs-tri-ping { + animation: none; + } +} + +/* Vulnerability Remediation hero: "remediate at the source" panorama + (VulnHeroScene.tsx). CVE chips ride `--dx`/`--dy` into the gate and a + `cs-vuln-flash` with the same duration and delay fires as each one lands. + Transform / opacity / offset-path / dash only; everything stops under + reduced motion, where the chips, gate, stack and attestations sit as a + static frame and the transient flashes and comets are hidden. */ +@keyframes cs-vuln-scene-in { + from { opacity: 0; transform: translateY(28px); } + to { opacity: 1; transform: none; } +} +@keyframes cs-vuln-chip { + 0% { transform: translate(0, 0) scale(1); opacity: 0; } + 10% { opacity: 1; } + 70% { transform: translate(calc(var(--dx) * 0.8), calc(var(--dy) * 0.8)) scale(0.88); opacity: 1; } + 86%, 100% { transform: translate(var(--dx), var(--dy)) scale(0.28); opacity: 0; } +} +@keyframes cs-vuln-flash { + 0%, 80% { transform: scale(0.35); opacity: 0; } + 86% { transform: scale(1); opacity: 1; } + 100% { transform: scale(1.7); opacity: 0; } +} +@keyframes cs-vuln-scan { + from { transform: translateY(-170px); } + to { transform: translateY(170px); } +} +@keyframes cs-vuln-rim { + to { stroke-dashoffset: -1280; } +} +@keyframes cs-vuln-breathe { + 0%, 100% { opacity: 0.6; transform: scale(0.96); } + 50% { opacity: 1; transform: scale(1.04); } +} +@keyframes cs-vuln-twinkle { + 0%, 100% { opacity: 0.2; } + 50% { opacity: 0.95; } +} +@keyframes cs-vuln-comet { + 0% { offset-distance: 0%; opacity: 0; } + 12%, 82% { opacity: 1; } + 100% { offset-distance: 100%; opacity: 0; } +} +@keyframes cs-vuln-layer { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(var(--lift)); } +} +@keyframes cs-vuln-ring { + 0% { transform: scale(0.6); opacity: 0; } + 25% { opacity: 0.55; } + 100% { transform: scale(1.35); opacity: 0; } +} +@keyframes cs-vuln-attest { + 0% { opacity: 0.3; transform: translateX(-10px); } + 10%, 86% { opacity: 1; transform: none; } + 100% { opacity: 0.3; transform: translateX(-10px); } +} +@keyframes cs-vuln-check { + 0%, 6% { opacity: 0; transform: scale(0.4); } + 14%, 86% { opacity: 1; transform: none; } + 100% { opacity: 0; transform: scale(0.4); } +} +.cs-vuln-scene { + animation: cs-vuln-scene-in 1.1s cubic-bezier(0.2, 0.7, 0.2, 1) 0.4s both; +} +.cs-vuln-chip { + transform-box: fill-box; + transform-origin: 100% 50%; + animation: cs-vuln-chip 7s cubic-bezier(0.45, 0, 0.8, 0.55) infinite; +} +.cs-vuln-flash { + transform-box: fill-box; + transform-origin: center; + opacity: 0; + animation: cs-vuln-flash 7s linear infinite; +} +.cs-vuln-scan { + animation: cs-vuln-scan 3.6s ease-in-out infinite alternate; +} +.cs-vuln-rim { + animation: cs-vuln-rim 6s linear infinite; +} +.cs-vuln-breathe { + transform-box: fill-box; + transform-origin: center; + animation: cs-vuln-breathe 5.5s ease-in-out infinite; +} +.cs-vuln-twinkle { + animation: cs-vuln-twinkle 3.4s ease-in-out infinite; +} +.cs-vuln-comet { + offset-rotate: auto; + animation: cs-vuln-comet 2.4s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} +.cs-vuln-layer { + animation: cs-vuln-layer 6s ease-in-out infinite; +} +.cs-vuln-ring { + transform-box: fill-box; + transform-origin: center; + animation: cs-vuln-ring 3.5s ease-out infinite; +} +.cs-vuln-attest { + animation: cs-vuln-attest 7s ease-out infinite; +} +.cs-vuln-check { + transform-box: fill-box; + transform-origin: center; + animation: cs-vuln-check 7s ease-out infinite; +} +@media (prefers-reduced-motion: reduce) { + .cs-vuln-scene, + .cs-vuln-chip, + .cs-vuln-flash, + .cs-vuln-scan, + .cs-vuln-rim, + .cs-vuln-breathe, + .cs-vuln-twinkle, + .cs-vuln-comet, + .cs-vuln-layer, + .cs-vuln-ring, + .cs-vuln-attest, + .cs-vuln-check { + animation: none !important; + } + .cs-vuln-flash, + .cs-vuln-comet, + .cs-vuln-ring { + opacity: 0 !important; + } +} + +/* Vulnerability Remediation "Verification You Can Trust" chain: a pulse runs + along the rail from Source Built to Security Policies. The mover spans the + rail, so percentage translates travel the rail's own length. */ +@keyframes cs-vr-rail-x { + from { transform: translateX(-18%); } + to { transform: translateX(100%); } +} +@keyframes cs-vr-rail-y { + from { transform: translateY(-18%); } + to { transform: translateY(100%); } +} +.cs-vr-rail-x { + animation: cs-vr-rail-x 4.8s cubic-bezier(0.45, 0, 0.55, 1) infinite; +} +.cs-vr-rail-y { + animation: cs-vr-rail-y 4.8s cubic-bezier(0.45, 0, 0.55, 1) infinite; +} +@media (prefers-reduced-motion: reduce) { + .cs-vr-rail-x, + .cs-vr-rail-y { + animation: none; + opacity: 0; + } +} diff --git a/apps/web/src/app/industries/financial-services/page.tsx b/apps/web/src/app/industries/financial-services/page.tsx index d4840f94e..2930e300f 100644 --- a/apps/web/src/app/industries/financial-services/page.tsx +++ b/apps/web/src/app/industries/financial-services/page.tsx @@ -44,8 +44,8 @@ import { getPageGraph } from "@/lib/seo/compose-page"; * * Launched: the noindex,nofollow pair is dropped and the path is listed in the * sitemap's STATIC_ROUTES. The breadcrumb, JsonLdGraph and pageRegistry row - * were already in place. Its sibling /industries/software-applications stays - * noindex,nofollow and unlisted, pending sign-off on its copy. + * were already in place. Its sibling /industries/software-applications has + * launched the same way: indexable, sitemap-listed and nav-linked. */ export const metadata = buildPageMetadata({ title: "Container Security for Financial Services | CleanStart", diff --git a/apps/web/src/app/resources/[slug]/page.tsx b/apps/web/src/app/resources/[slug]/page.tsx index c6f48a92f..c335630bb 100644 --- a/apps/web/src/app/resources/[slug]/page.tsx +++ b/apps/web/src/app/resources/[slug]/page.tsx @@ -13,7 +13,6 @@ import { resourceTypeLabel, } from "@/lib/resources"; import { highlightLexical } from "@/lib/highlightLexical"; -import { getFormById, type Form } from "@/lib/forms"; import { buildPageMetadata } from "@/lib/seo/canonical"; import { resolveCmsSeo } from "@/lib/seo/cms-seo"; import { effectivePublishedAt } from "@/lib/published-date"; @@ -55,7 +54,10 @@ export async function generateMetadata({ noindex: true, }); } - const assetAbsolute = mediaUrl(resource.asset?.url); + // The share image is the hero image. It used to fall back to `asset`, which + // is the downloadable file: a PDF is not an image, so social cards broke, and + // on a gated resource it published the direct link to the file. + const heroAbsolute = mediaUrl(resource.heroImage?.url); const seo = resolveCmsSeo(resource.seo, { absolutize: mediaUrl }); return buildPageMetadata({ title: seo.title ?? resource.title, @@ -71,13 +73,13 @@ export async function generateMetadata({ ...(seo.canonicalUrl ? { canonicalUrl: seo.canonicalUrl } : {}), ...(seo.image ? { image: seo.image } - : assetAbsolute && resource.asset + : heroAbsolute && resource.heroImage ? { image: { - url: assetAbsolute, - width: resource.asset.width, - height: resource.asset.height, - alt: resource.asset.alt ?? resource.title, + url: heroAbsolute, + width: resource.heroImage.width, + height: resource.heroImage.height, + alt: resource.heroImage.alt ?? resource.title, }, } : {}), @@ -96,22 +98,11 @@ export async function renderResourceDetail({ : await getResourceBySlug(slug).catch(() => null); if (!resource) notFound(); - const assetAbsolute = mediaUrl(resource.asset?.url); + const heroAbsolute = mediaUrl(resource.heroImage?.url); const highlightedBody = await highlightLexical(resource.body ?? null); const resourceWithHighlighted = { ...resource, body: highlightedBody ?? null }; - let gateForm: Form | null = null; - if (resource.gated === true && resource.gateForm != null) { - const gateFormId = - typeof resource.gateForm === "object" - ? resource.gateForm.id - : resource.gateForm; - if (gateFormId != null) { - gateForm = await getFormById(gateFormId); - } - } - return ( <> - + diff --git a/apps/web/src/app/tricorder/page.tsx b/apps/web/src/app/tricorder/page.tsx new file mode 100644 index 000000000..351c6e583 --- /dev/null +++ b/apps/web/src/app/tricorder/page.tsx @@ -0,0 +1,70 @@ +import { Header } from "@/components/nav/Header"; +import { Footer } from "@/components/sections/Footer"; +import { FadeUp } from "@/components/ui/FadeUp"; +import { TricorderHero } from "@/components/sections/tricorder/TricorderHero"; +import { TricorderThreatGap } from "@/components/sections/tricorder/TricorderThreatGap"; +import { TricorderContext } from "@/components/sections/tricorder/TricorderContext"; +import { TricorderPipeline } from "@/components/sections/tricorder/TricorderPipeline"; +import { TricorderSubstrate } from "@/components/sections/tricorder/TricorderSubstrate"; +import { TricorderCTA } from "@/components/sections/tricorder/TricorderCTA"; +import { buildPageMetadata } from "@/lib/seo/canonical"; +import { breadcrumbSchema, softwareApplicationSchema } from "@/lib/seo/jsonld"; +import { JsonLdGraph } from "@/components/JsonLdGraph"; +import { getPageGraph } from "@/lib/seo/compose-page"; + +const DESCRIPTION = + "Tricorder analyzes, compares, correlates, and enriches every software component to produce an evidence-backed verdict before you trust it."; + +// On hold pending sign-off on the redesign: noindex,nofollow and left out of +// the sitemap. To launch, drop both flags and re-add the path to STATIC_ROUTES +// in app/sitemap.ts. +export const metadata = buildPageMetadata({ + title: "Tricorder: The Intelligence Layer for Software Trust | CleanStart", + absoluteTitle: true, + description: DESCRIPTION, + path: "/tricorder", + variant: "hero", + eyebrow: "Products", + ogTitle: "The Intelligence Layer for Software Trust", + titleAccent: "Software Trust", + noindex: true, + nofollow: true, +}); + +export const revalidate = 21600; // 6h ISR fallback — on-demand publish revalidation keeps this fresh + +export default async function TricorderPage(): Promise { + const graph = await getPageGraph("/tricorder", [ + breadcrumbSchema([ + { name: "Home", path: "/" }, + { name: "Tricorder" }, + ]), + softwareApplicationSchema({ + name: "Tricorder", + description: DESCRIPTION, + path: "/tricorder", + }), + ]); + return ( + <> + +
+
+ + + + + + + + + + + + + +
+
} /> + + ); +} diff --git a/apps/web/src/app/vulnerability-remediation/page.tsx b/apps/web/src/app/vulnerability-remediation/page.tsx index 369d265ec..7874fe623 100644 --- a/apps/web/src/app/vulnerability-remediation/page.tsx +++ b/apps/web/src/app/vulnerability-remediation/page.tsx @@ -2,11 +2,9 @@ import { Header } from "@/components/nav/Header"; import { Footer } from "@/components/sections/Footer"; import { FadeUp } from "@/components/ui/FadeUp"; import { VulnHero } from "@/components/sections/vulnerability-remediation/VulnHero"; -import { VulnWhyEliminate } from "@/components/sections/vulnerability-remediation/VulnWhyEliminate"; -import { VulnSecurityClean } from "@/components/sections/vulnerability-remediation/VulnSecurityClean"; -import { VulnBeforeAfter } from "@/components/sections/vulnerability-remediation/VulnBeforeAfter"; -import { VulnAdvantage } from "@/components/sections/vulnerability-remediation/VulnAdvantage"; -import { VulnBlogsResources } from "@/components/sections/vulnerability-remediation/VulnBlogsResources"; +import { VulnLifecycle } from "@/components/sections/vulnerability-remediation/VulnLifecycle"; +import { VulnBuildVerify } from "@/components/sections/vulnerability-remediation/VulnBuildVerify"; +import { VulnVerificationTrust } from "@/components/sections/vulnerability-remediation/VulnVerificationTrust"; import { VulnCTA } from "@/components/sections/vulnerability-remediation/VulnCTA"; import { buildPageMetadata } from "@/lib/seo/canonical"; import { breadcrumbSchema } from "@/lib/seo/jsonld"; @@ -38,19 +36,13 @@ export default async function VulnerabilityRemediationPage(): Promise - + - + - - - - - - - +
} /> diff --git a/apps/web/src/components/forms/FormRenderer.tsx b/apps/web/src/components/forms/FormRenderer.tsx deleted file mode 100644 index dea872622..000000000 --- a/apps/web/src/components/forms/FormRenderer.tsx +++ /dev/null @@ -1,500 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import type { - Form, - FormField, - FormFieldConditionRule, -} from "@/lib/forms"; -import { StatusBanner, useFormStatus } from "@/components/forms/StatusBanner"; -import { TurnstileWidget } from "@/components/TurnstileWidget"; -import { useAttribution } from "@/components/attribution/AttributionProvider"; -import { trackEvent } from "@/lib/analytics/track"; -import { emailError } from "@/lib/forms/validate"; -import { useDetectedCountry } from "@/lib/forms/useDetectedCountry"; -import { - emptyPhoneValue, - toE164, - validatePhone, - type PhoneValue, -} from "@/lib/forms/phone-value"; -import { PhoneField } from "@/components/forms/PhoneField"; - -export interface FormRendererSubmitResult { - duplicate?: boolean; - download?: { url: string; expiresAt: number }; -} - -export interface FormRendererContext { - sourceUrl?: string; - resourceId?: string | number; -} - -interface FormRendererProps { - form: Form; - context?: FormRendererContext; - onSuccess?: (result: FormRendererSubmitResult) => void; - className?: string; -} - -type FieldValue = string | boolean | PhoneValue | undefined; - -const isPhoneValue = (value: FieldValue): value is PhoneValue => - typeof value === "object" && value !== null && "country" in value; - -const CMS_URL = process.env.NEXT_PUBLIC_CMS_URL ?? "http://localhost:3000"; - -const evaluateConditions = ( - field: FormField, - values: Record, -): boolean => { - const rules = field.conditions?.rules ?? []; - if (rules.length === 0) return true; - const mode = field.conditions?.mode ?? "all"; - const check = (rule: FormFieldConditionRule): boolean => { - const actual = values[rule.fieldName]; - const actualStr = isPhoneValue(actual) - ? (toE164(actual) ?? "") - : actual == null - ? "" - : String(actual); - switch (rule.operator) { - case "equals": - return actualStr === rule.value; - case "notEquals": - return actualStr !== rule.value; - case "contains": - return actualStr.includes(rule.value); - default: - return true; - } - }; - return mode === "any" ? rules.some(check) : rules.every(check); -}; - -const validateField = ( - field: FormField, - value: FieldValue, -): string | null => { - if (field.type === "tel") { - const phone = isPhoneValue(value) ? value : emptyPhoneValue(); - return validatePhone(phone, { required: Boolean(field.required) }); - } - - const isConsentOrCheckbox = field.type === "consent" || field.type === "checkbox"; - if (field.required) { - if (isConsentOrCheckbox) { - if (value !== true) { - return field.errorMessage ?? "This must be checked to continue."; - } - } else { - if (typeof value !== "string" || value.trim().length === 0) { - return field.errorMessage ?? `${field.label ?? field.name} is required.`; - } - } - } - if (typeof value === "string" && value.length > 0) { - if (field.type === "email") { - // Shape always; company-only when the form definition asks for it, which - // the newsletter and gated-download forms deliberately do not. - const invalid = emailError(value, { - requireBusiness: field.requireBusinessEmail === true, - }); - if (invalid) return field.errorMessage ?? invalid; - } - const v = field.validation; - if (v?.minLength != null && value.length < v.minLength) { - return field.errorMessage ?? `Minimum ${v.minLength} characters.`; - } - if (v?.maxLength != null && value.length > v.maxLength) { - return field.errorMessage ?? `Maximum ${v.maxLength} characters.`; - } - if (v?.pattern) { - try { - const re = new RegExp(v.pattern, "u"); - if (!re.test(value)) return field.errorMessage ?? "Doesn't match the expected format."; - } catch { - // Bad pattern shape — server will catch it. Skip client-side. - } - } - } - return null; -}; - -const fieldInputStyle: React.CSSProperties = { - borderRadius: "8px", - border: "1px solid rgba(17, 17, 17, 0.2)", - paddingLeft: "16px", - paddingRight: "16px", - color: "#111", - background: "white", - height: "44px", - width: "100%", - // 1rem (16px) — iOS Safari zooms in on focus for inputs sized below - // 16px. Stay at ≥ 16px to avoid the zoom shift. - fontSize: "var(--fs-input)", -}; - -export function FormRenderer({ - form, - context, - onSuccess, - className, -}: FormRendererProps): React.ReactElement { - const initialValues = useMemo>(() => { - const out: Record = {}; - for (const f of form.fields) { - if (f.type === "checkbox" || f.type === "consent") out[f.name] = false; - else if (f.type === "tel") out[f.name] = emptyPhoneValue(); - else out[f.name] = f.defaultValue ?? ""; - } - return out; - }, [form.fields]); - - const [values, setValues] = useState>(initialValues); - const [errors, setErrors] = useState>({}); - const [submitting, setSubmitting] = useState(false); - const { status, setStatus, statusRef } = useFormStatus(); - const { getAttribution } = useAttribution(); - // Honeypot — never rendered visibly, but its existence is what bots fill. - const [honeypot, setHoneypot] = useState(""); - // Cloudflare Turnstile token. Required server-side for every form except the - // exempt low-friction slugs (newsletter, resource-capture); harmless to send - // for those. The gate modal renders any gateForm through this component, so - // the widget lives here rather than in each caller. - const [turnstileToken, setTurnstileToken] = useState(""); - const { country: detectedCountry, detected } = useDetectedCountry(); - const touchedCountryRef = useRef(false); - - useEffect(() => { - if (!detected || touchedCountryRef.current) return; - setValues((prev) => { - const next = { ...prev }; - for (const field of form.fields) { - const current = next[field.name]; - if (field.type === "tel" && isPhoneValue(current)) { - next[field.name] = { ...current, country: detectedCountry }; - } - } - return next; - }); - }, [detected, detectedCountry, form.fields]); - - const visibleFields = useMemo( - () => form.fields.filter((f) => evaluateConditions(f, values)), - [form.fields, values], - ); - - const setValue = (name: string, v: FieldValue): void => { - setValues((prev) => ({ ...prev, [name]: v })); - if (errors[name]) { - setErrors((prev) => { - const { [name]: _, ...rest } = prev; - return rest; - }); - } - }; - - const buildConsentPayload = (): { - snapshot: string; - givenAt: string; - } | undefined => { - const consentField = form.fields.find((f) => f.type === "consent"); - if (!consentField) return undefined; - if (values[consentField.name] !== true) return undefined; - return { - snapshot: consentField.consentText ?? consentField.label ?? "Consent given.", - givenAt: new Date().toISOString(), - }; - }; - - const buildAnswers = (): Record => { - const out: Record = {}; - for (const f of visibleFields) { - if (f.type === "consent") continue; - const value = values[f.name]; - // Phone fields go over the wire as E.164, never as the country/digits - // pair the field holds internally. - out[f.name] = isPhoneValue(value) ? (toE164(value) ?? "") : (value ?? ""); - } - return out; - }; - - const onSubmit = async (e: React.FormEvent): Promise => { - e.preventDefault(); - if (submitting) return; - setStatus(null); - - const nextErrors: Record = {}; - for (const f of visibleFields) { - const err = validateField(f, values[f.name]); - if (err) nextErrors[f.name] = err; - } - if (Object.keys(nextErrors).length > 0) { - setErrors(nextErrors); - return; - } - - setSubmitting(true); - try { - const attribution = getAttribution(); - const body = { - formId: form.id, - formSchemaVersion: form.schemaVersion, - fields: buildAnswers(), - source: context?.sourceUrl, - consent: buildConsentPayload(), - website: honeypot, - ...(turnstileToken ? { turnstileToken } : {}), - ...(attribution.utm ? { utm: attribution.utm } : {}), - ...(attribution.attribution ? { attribution: attribution.attribution } : {}), - ...(context?.resourceId != null - ? { context: { resourceId: context.resourceId } } - : {}), - }; - - const res = await fetch(`${CMS_URL}/api/leads/submit`, { - method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - - const json = (await res.json().catch(() => null)) as { - ok?: boolean; - error?: string; - duplicate?: boolean; - download?: { url: string; expiresAt: number }; - } | null; - - if (!res.ok || !json?.ok) { - setStatus({ - tone: "error", - title: "Submission failed", - message: - json?.error === "rate_limited" - ? "Too many submissions. Please wait a minute and try again." - : "We couldn't submit the form. Please try again.", - }); - return; - } - - const successPayload: FormRendererSubmitResult = {}; - if (json.duplicate != null) successPayload.duplicate = json.duplicate; - if (json.download) { - successPayload.download = { - url: json.download.url.startsWith("http") - ? json.download.url - : `${CMS_URL}${json.download.url}`, - expiresAt: json.download.expiresAt, - }; - } - trackEvent("generate_lead", { - form_id: form.id, - form_name: form.slug ?? String(form.id), - gated: Boolean(json.download), - }); - onSuccess?.(successPayload); - } catch { - setStatus({ - tone: "error", - title: "Network error", - message: "Network error. Please try again.", - }); - } finally { - setSubmitting(false); - } - }; - - return ( -
- {/* Honeypot — kept off-screen, never tab-reachable */} -
- -
- - {status ? : null} - -
- {visibleFields.map((f) => { - const id = `frm-${form.id}-${f.name}`; - const err = errors[f.name]; - const labelEl = - f.label && f.type !== "consent" ? ( - - ) : null; - - const helpEl = f.helpText ? ( -

{f.helpText}

- ) : null; - - const errorEl = err ? ( -

- {err} -

- ) : null; - - if (f.type === "textarea") { - return ( -
- {labelEl} -