diff --git a/.env.example b/.env.example index ae7f9f2..757cd65 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,29 @@ SENTRY_AUTH_TOKEN= # Optional. Defaults to VERCEL_GIT_COMMIT_SHA on Vercel. SENTRY_RELEASE= + +# Resend (newsletter capture) +RESEND_API_KEY= +RESEND_SEGMENT_ID= + +# Upstash Redis (newsletter rate limiting) +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= + +# Cloudflare Turnstile (newsletter verification) +# Both keys must be set together. The client widget needs the public site key; +# server-side Siteverify (required by Cloudflare) needs the secret key. +# Local development default: Cloudflare test keys that always pass. +NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA +TURNSTILE_ALLOWED_HOSTNAMES=localhost,127.0.0.1,0.0.0.0 + +# Production: replace the local test pair with real widget keys and hostnames. +# Vercel preview deployments also accept the current deployment hostname from VERCEL_URL. +# NEXT_PUBLIC_TURNSTILE_SITE_KEY= +# TURNSTILE_SECRET_KEY= +# TURNSTILE_ALLOWED_HOSTNAMES=cortexglobal.xyz,www.cortexglobal.xyz,*.vercel.app + +# Failure testing: temporarily replace the local test pair with this always-fail pair. +# NEXT_PUBLIC_TURNSTILE_SITE_KEY=2x00000000000000000000AB +# TURNSTILE_SECRET_KEY=2x0000000000000000000000000000000AA diff --git a/bun.lock b/bun.lock index 5308735..4dc7a99 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,8 @@ "@react-three/drei": "10.7.7", "@react-three/fiber": "9.6.1", "@sentry/nextjs": "^10.58.0", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -18,13 +20,16 @@ "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", + "resend": "^6.14.0", "shadcn": "^4.7.0", "tailwind-merge": "^3.6.0", "three": "0.182.0", "tw-animate-css": "^1.4.0", + "zod": "^4.4.3", }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bun": "^1.3.14", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -523,6 +528,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], @@ -561,6 +568,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], @@ -655,6 +664,12 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], + "@upstash/core-analytics": ["@upstash/core-analytics@0.0.10", "", { "dependencies": { "@upstash/redis": "^1.28.3" } }, "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ=="], + + "@upstash/ratelimit": ["@upstash/ratelimit@2.0.8", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w=="], + + "@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="], + "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="], "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], @@ -773,6 +788,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -987,6 +1004,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -1431,6 +1450,8 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], @@ -1493,6 +1514,8 @@ "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "resend": ["resend@6.14.0", "", { "dependencies": { "postal-mime": "2.7.4", "standardwebhooks": "1.0.0" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-jVdpUgOoWGLjaP64lo8KwzHT9gY4w6Dl8c36CIb2F+ayYOMLr3khqs8xrNjXM2k19b+lPoj0VWQFhVNLiToBjA=="], + "resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -1571,6 +1594,8 @@ "stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], @@ -1687,6 +1712,8 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -1765,7 +1792,7 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -1787,8 +1814,6 @@ "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], @@ -1835,8 +1860,6 @@ "eslint-plugin-import/tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], - "eslint-plugin-react-hooks/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1875,6 +1898,8 @@ "schema-utils/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "sharp/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "stats-gl/@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], diff --git a/package.json b/package.json index 35560da..02f8a64 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint .", + "test": "bun test", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -14,6 +15,8 @@ "@react-three/drei": "10.7.7", "@react-three/fiber": "9.6.1", "@sentry/nextjs": "^10.58.0", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -23,13 +26,16 @@ "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", + "resend": "^6.14.0", "shadcn": "^4.7.0", "tailwind-merge": "^3.6.0", "three": "0.182.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bun": "^1.3.14", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/skills/context/architecture-context.md b/skills/context/architecture-context.md index 47f4e26..c2dc94c 100644 --- a/skills/context/architecture-context.md +++ b/skills/context/architecture-context.md @@ -18,6 +18,7 @@ Use this stack when the production website is scaffolded or consolidated: - Zod for all input validation (form schemas, server action parsing, future env-var validation) - Resend (`resend` package) for newsletter capture — create a Contact and assign to the newsletter Segment/Topic (Audiences API is being migrated; see https://resend.com/docs/dashboard/segments/migrating-from-audiences-to-segments). Sending deferred to post-launch. - Cloudflare Turnstile for invisible bot prevention on public forms +- Upstash Redis (`@upstash/ratelimit`, `@upstash/redis`) for server-side rate limiting on public form endpoints (newsletter). Sliding window, 5 requests per minute per IP. Skipped in local dev when env vars are absent; required in production-like runtimes. If the selected app uses a newer Next.js version, read the relevant docs in `node_modules/next/dist/docs/` before implementation. Do not rely only on older Next.js memory. diff --git a/skills/context/progress-tracker.md b/skills/context/progress-tracker.md index d4a73e9..3626ec2 100644 --- a/skills/context/progress-tracker.md +++ b/skills/context/progress-tracker.md @@ -15,6 +15,7 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran - Shared hooks: `use-reduced-motion`, `use-is-desktop`, `use-on-mount`. - Five Pattern Studio animated SVG starters in `skills/examples/` (algorithmic generation, no static SVG deps). - CI workflow: Bun setup, frozen lockfile, lint, typecheck, build. +- Local unit tests use Bun's built-in runner via `bun run test`. - Typed content modules in `src/lib/content/` for all sections + `nav.ts`. - Vercel Web Analytics wired through `@vercel/analytics` in the root app layout. - Sentry wired through `@sentry/nextjs` using Next 16 instrumentation entrypoints. @@ -39,13 +40,15 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran - ScrollTrigger-triggered automatic line drawing for History and Team: History draws the desktop timeline line and mobile stems after their triggers enter; Team draws the desktop SVG rules and mobile divider separately from the content reveal. Both respect reduced-motion by setting lines directly to their resting drawn state. - Slightly slowed the Hero SplitText word reveal and Team line-draw timing after visual tuning feedback. - Aligned the History timeline line with milestone reveal timing: desktop line advances one segment per milestone trigger; mobile stems use the same trigger start/duration as milestone reveals. +- History ScrollTrigger setup now uses one trigger per milestone; each trigger owns milestone reveal, desktop line advance, mobile stem draw, and the final summary reveal. This avoids the previous batch-plus-separate-summary-trigger mix that could leave GSAP refresh with an undefined internal trigger. ### Layout - `SiteHeader` refactored from monolithic client component into server component + client islands (`SiteHeaderShell`, `HeaderMegaNav`, `SiteHeaderMobileMenu`). Reduces client JS bundle. - Header upcoming-event promo: `HeaderUpcomingEvent` / `HeaderUpcomingEventLink` render the next event (from `src/lib/events/upcoming.ts`, shared with the Events section; hourly ISR via `EVENTS_DATE_REVALIDATE_SECONDS`) in the desktop header and mobile hero. Header scroll state lives in `src/lib/layout/site-header-scroll.ts`. - Same-page hash navigation: `HashLink` + `HashScrollSync` (`src/lib/hash-navigation.ts`) fix `/#section` links when already on `/`; wired through hero CTAs, header, mobile nav, footer, and services CTA. Root layout wraps `{children}` in `Suspense` with `RouteLoadingShell`. -- `SiteFooter` server component: CTA quote block, newsletter input (UI only), contact mailto, About/Programs/Legal columns, social glyphs (X/Instagram/TikTok/LinkedIn/YouTube), copyright. Module-scope hoisted constants (`QUOTE_LINES`, `TAGLINE_LINES`, `SOCIAL_LINKS`). +- `SiteFooter` server component: CTA quote block, newsletter form island, About/Programs/Legal columns, social glyphs (X/Instagram/TikTok/LinkedIn/YouTube), copyright. Module-scope hoisted constants (`QUOTE_LINES`, `TAGLINE_LINES`, `SOCIAL_LINKS`). +- Footer newsletter capture: `NewsletterForm` client island runs an invisible Cloudflare Turnstile challenge when `NEXT_PUBLIC_TURNSTILE_SITE_KEY` is present, loads `api.js` with the existing page script nonce when a CSP nonce is present, then calls a thin server action with Zod email validation, honeypot (checked before rate limiting/Turnstile so bot traffic stays silent and does not consume Upstash quota), Upstash Redis-backed per-IP rate limiting (`UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`; local dev skips if absent, production-like runtimes fail closed; unknown client IPs fail closed in production-like runtimes instead of sharing a global fallback bucket), server-side Turnstile Siteverify via `TURNSTILE_SECRET_KEY` after rate limiting, and Resend global Contact + Segment assignment via `RESEND_API_KEY` / `RESEND_SEGMENT_ID`; new contacts are created with the newsletter segment in one Resend call (`contacts.create` + `segments`), existing contacts are re-subscribed via update + segment list/add with one inline retry on transient failures. Submit decisions, missing/invalid Turnstile tokens, rate-limit behavior, and Resend subscribe logic are covered by Bun unit tests. A localhost Turnstile submit has been verified to create the test email in Resend. - `(site)` route group: `page.tsx` and site-specific layout (with `SiteFooter` + `flex-1` wrapper) live under `src/app/(site)/`. Root `layout.tsx` has header only — `not-found.tsx` stays at root so the 404 page renders without footer. - `/privacy` and `/terms` ship as Server Component routes in the `(site)` group with copy in `src/lib/content/legal.ts`; footer legal links point to the live routes. - `SectionDivider` shared component replaces per-section divider markup in team, monad, and services sections. @@ -82,12 +85,18 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran ## Next Steps -1. Integrate Resend for newsletter form. Cloudflare Turnstile for prevent spam. -2. Preformance review and optimization. -3. og image and description. -4. SEO, tracking validation, and launch metadata. -5. Staking page (remove `TEMP(staking-page)` comments when shipping). +1. Performance review and optimization. +2. og image and description. +3. SEO, tracking validation, and launch metadata. +4. Staking page (remove `TEMP(staking-page)` comments when shipping). ## Latest Handoff -- Mission graph placement tuning: moved expanded-card illustrations into the Mission card content grid so each graph centers in the remaining space between the wrapped title and body instead of relying on fixed vertical offsets. Added equal vertical inset and max sizing for the illustration row to preserve breathing room at short desktop heights such as 1440x670. Touched `src/components/sections/mission/mission-cards-client.tsx` and `src/app/globals.css`. Verification: `bun run typecheck`, `bun run lint` (passes with existing warnings in `site-footer.tsx` and `monad-topic-enter.ts`), `bun run build`, browser geometry check at 1440x670 showing equal title/graph and graph/body gaps. Open: existing unrelated lint warnings remain. Next: continue Mission card-by-card visual tuning if requested. +- **Newsletter form refactor** — Turnstile logic extracted to `use-turnstile.ts`; `NewsletterForm` handles submit UX. Hidden client **submission id** echoed by `handleNewsletterSignup`; UI only shows feedback for the **active submission** (fixes stale success/error on resubmit). Email input **clears on success**; Turnstile **tokens reset after each response** (single-use). +- **Newsletter pipeline order** — honeypot (silent, before rate limit/Turnstile) → **Upstash** per-IP rate limit → **Cloudflare Siteverify** → **Resend** (`contacts.create` + segments; existing contacts re-subscribed via `contacts.update` when `unsubscribed: true`). Rate limiting runs **before Siteverify** to reduce forged-token abuse. +- **Turnstile config** — Client reads `NEXT_PUBLIC_TURNSTILE_SITE_KEY`; server Siteverify gated by `isNewsletterTurnstileEnabled()` (both keys required). Secret **trimmed** before Siteverify. **Hostname validation**: `TURNSTILE_ALLOWED_HOSTNAMES`, `*.vercel.app` wildcards, `VERCEL_URL` merged for previews; action must match `newsletter`. Script loader stores **loading/loaded/error** on shared script element, uses `turnstile.ready()`, handles remount/retry; copies page **CSP nonce** onto `api.js`. +- **Rate-limit IP hardening** — Parses `x-vercel-forwarded-for`, `Forwarded`, IPv4-with-port, bracketed IPv6; **skips loopback** in production-like runtimes. Unknown IP returns `null` identifier; **fail closed** in production (no shared `local` bucket). +- **Footer hash nav** — `hash-navigation.ts` keeps `#footer` while footer is visible and while focus is in the newsletter/contact region (prevents demotion to `#events` during footer interaction). +- **Resend re-subscribe** — Reactivates unsubscribed contacts only; reactivated members get success message, not already-submitted. +- **Tests** — `submit.test.ts`, `turnstile.test.ts`, `rate-limit.test.ts`, `resend.test.ts`. +- **Verified** — `bun run test`, `bun run typecheck`, `bun run build`. Localhost `#footer` Turnstile submit confirmed against Resend. diff --git a/src/app/globals.css b/src/app/globals.css index 84e69c6..10b1fa9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1955,15 +1955,45 @@ } .site-footer-email-shell { - @apply flex flex-1 flex-col lg:w-83.5 lg:flex-none; + @apply relative flex flex-1 flex-col lg:w-83.5 lg:flex-none; } .site-footer-email-input { - @apply h-6.75 w-full border-0 border-b border-action-primary bg-transparent pb-1 font-open text-[16px] leading-normal text-brand-cortex-orange placeholder:text-neutral-neural-gray/40 focus:outline-none focus-visible:border-action-primary-hover sm:text-body lg:h-9.75 lg:pb-3.5 lg:font-mona lg:text-[25px] lg:leading-none; + @apply h-6.75 w-full border-0 border-b border-neutral-silver-gray bg-transparent pb-1 font-open text-[16px] leading-normal text-neutral-silver-gray placeholder:text-neutral-neural-gray/40 focus:outline-none focus-visible:border-neutral-silver-gray sm:text-body lg:h-9.75 lg:pb-3.5 lg:font-mona lg:text-[25px] lg:leading-none; + } + + .site-footer-honeypot { + @apply absolute top-auto left-[-10000px] h-px w-px overflow-hidden; + } + + .site-footer-newsletter-message { + @apply mt-2 font-open text-[0.75rem] leading-normal text-text-muted md:absolute md:top-full; + animation: site-footer-newsletter-message-hide 3s ease forwards; + } + + .site-footer-newsletter-message[data-status="success"] { + @apply text-action-primary; + } + + .site-footer-newsletter-message[data-status="error"] { + @apply text-destructive; } .site-footer-newsletter-button { - @apply w-fit shrink-0 px-6 text-brand-cortex-carbon hover:text-brand-cortex-carbon; + @apply w-fit min-w-44 shrink-0 px-6 text-brand-cortex-carbon hover:text-brand-cortex-carbon; + } + + @keyframes site-footer-newsletter-message-hide { + 0%, + 82% { + opacity: 1; + visibility: visible; + } + + 100% { + opacity: 0; + visibility: hidden; + } } .site-footer-body { @@ -2548,7 +2578,6 @@ .event-location-icon { @apply size-4 shrink-0 md:size-4.5; } - } @layer base { diff --git a/src/components/layout/newsletter-form.tsx b/src/components/layout/newsletter-form.tsx new file mode 100644 index 0000000..2e63677 --- /dev/null +++ b/src/components/layout/newsletter-form.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useActionState, useEffect, useRef, useState } from "react"; +import type { SubmitEvent } from "react"; +import { CortexButton } from "@/components/cortex-button"; +import { useTurnstile } from "@/hooks/use-turnstile"; +import { submitNewsletterSignup } from "@/lib/newsletter/actions"; +import { + NEWSLETTER_TURNSTILE_ACTION, + NEWSLETTER_TURNSTILE_ERROR_MESSAGE, + NEWSLETTER_TURNSTILE_FIELD, +} from "@/lib/newsletter/turnstile"; +import { + NEWSLETTER_SUBMISSION_ID_FIELD, + newsletterInitialState, +} from "@/lib/newsletter/types"; + +const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY?.trim(); + +type NewsletterFormProps = { + emailInputId: string; + placeholder: string; + submitLabel: string; +}; + +export default function NewsletterForm({ + emailInputId, + placeholder, + submitLabel, +}: NewsletterFormProps) { + const [state, formAction, pending] = useActionState( + submitNewsletterSignup, + newsletterInitialState, + ); + const formRef = useRef(null); + const emailInputRef = useRef(null); + const submissionIdInputRef = useRef(null); + const turnstileReentryRef = useRef(false); + const { + containerRef: turnstileContainerRef, + enabled: turnstileEnabled, + error: turnstileError, + execute: executeTurnstile, + pending: turnstilePending, + reset: resetTurnstile, + tokenRef: turnstileTokenRef, + } = useTurnstile({ + action: NEWSLETTER_TURNSTILE_ACTION, + errorMessage: NEWSLETTER_TURNSTILE_ERROR_MESSAGE, + siteKey: TURNSTILE_SITE_KEY, + }); + const [emailValue, setEmailValue] = useState(""); + const [submittedEmail, setSubmittedEmail] = useState(""); + const [activeSubmissionId, setActiveSubmissionId] = useState(""); + const [clearedSuccessSubmissionId, setClearedSuccessSubmissionId] = useState< + string | undefined + >(); + const [messageDismissed, setMessageDismissed] = useState(false); + + const messageId = `${emailInputId}-message`; + const activeMessage = turnstileError || state.message; + const activeStatus = turnstileError ? "error" : state.status; + const messageKey = turnstileError || state.submissionId || activeMessage; + const hasMessage = activeMessage.length > 0; + const stateBelongsToActiveSubmission = + Boolean(state.submissionId) && state.submissionId === activeSubmissionId; + const showConfirmedMessage = + stateBelongsToActiveSubmission && + state.status === "success" && + state.subscribedEmail === submittedEmail; + const showMessage = + !messageDismissed && + hasMessage && + (Boolean(turnstileError) || + (stateBelongsToActiveSubmission && state.status === "error") || + (state.status === "success" && showConfirmedMessage)); + const showEmailError = + !messageDismissed && + stateBelongsToActiveSubmission && + Boolean(state.emailError); + const submitPending = pending || turnstilePending; + + if ( + state.status === "success" && + state.subscribedEmail && + state.submissionId && + state.submissionId !== clearedSuccessSubmissionId + ) { + setClearedSuccessSubmissionId(state.submissionId); + setEmailValue(""); + setMessageDismissed(false); + } + + useEffect(() => { + if (state.status === "idle" || pending) return; + resetTurnstile(); + }, [pending, state.status, resetTurnstile]); + + function handleEmailChange(value: string) { + setMessageDismissed(true); + setEmailValue(value); + } + + function handleSubmit(event: SubmitEvent) { + const submissionId = crypto.randomUUID(); + + if (submissionIdInputRef.current) { + submissionIdInputRef.current.value = submissionId; + } + + setActiveSubmissionId(submissionId); + setSubmittedEmail(emailValue.trim().toLowerCase()); + setMessageDismissed(false); + + if (!turnstileEnabled) return; + + if (turnstileReentryRef.current) { + turnstileReentryRef.current = false; + + if (pending) { + event.preventDefault(); + } + + return; + } + + if (submitPending) { + event.preventDefault(); + return; + } + + event.preventDefault(); + + void executeTurnstile() + .then(() => { + turnstileReentryRef.current = true; + formRef.current?.requestSubmit(); + }) + .catch(() => { + setMessageDismissed(false); + emailInputRef.current?.focus(); + }); + } + + return ( + <> +
+
+ + handleEmailChange(event.target.value)} + aria-invalid={showEmailError ? "true" : undefined} + aria-describedby={showMessage ? messageId : undefined} + suppressHydrationWarning + className="site-footer-email-input" + /> + + + + + {submitPending ? ( + + ) : ( + submitLabel + )} + + + + ); +} diff --git a/src/components/layout/site-footer.tsx b/src/components/layout/site-footer.tsx index e77954b..007ed76 100644 --- a/src/components/layout/site-footer.tsx +++ b/src/components/layout/site-footer.tsx @@ -1,10 +1,9 @@ -import Link from "next/link"; import { HashLink } from "@/components/hash-link"; import { HomeLink } from "@/components/home-link"; -import { CortexButton } from "@/components/cortex-button"; import { CortexMark } from "@/components/logos/cortex-mark"; import { CortexWordmark } from "@/components/logos/cortex-wordmark"; import { SOCIAL_GLYPHS } from "@/components/icons/social-glyphs"; +import NewsletterForm from "@/components/layout/newsletter-form"; import { footerContent } from "@/lib/content/footer"; import type { ExternalLinkChannel } from "@/lib/content/links"; import { splitTrailingAccent } from "@/lib/split-trailing-accent"; @@ -88,33 +87,12 @@ export function SiteFooter() { )}
- {/* Newsletter — input UI only, no submit behavior. Mobile: input above button. Desktop: input and button inline. */}
-
-
- - {/* TODO: add validation */} - -
- - {footerContent.newsletterCta.label} - -
+
diff --git a/src/hooks/use-turnstile.ts b/src/hooks/use-turnstile.ts new file mode 100644 index 0000000..27ec46f --- /dev/null +++ b/src/hooks/use-turnstile.ts @@ -0,0 +1,364 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +const ONLOAD_CALLBACK = "cortexTurnstileOnLoad"; +const SCRIPT_SRC = `https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=${ONLOAD_CALLBACK}`; +const SCRIPT_ID = "turnstile-script"; +const CHALLENGE_TIMEOUT_MS = 15_000; +const SCRIPT_STATUS_KEY = "turnstileStatus"; + +type ScriptStatus = "error" | "loaded" | "loading"; +type WidgetId = string; + +type Challenge = { + reject: (reason?: unknown) => void; + resolve: (token: string) => void; + timeoutId: number; +}; + +type TurnstileApi = { + execute(container: HTMLElement | string): void; + remove(widgetId: WidgetId): void; + render( + container: HTMLElement, + options: { + action: string; + appearance: "always" | "execute" | "interaction-only"; + callback: (token: string) => void; + "error-callback": () => void; + "expired-callback": () => void; + execution: "render" | "execute"; + sitekey: string; + "timeout-callback": () => void; + }, + ): WidgetId | undefined; + reset(widgetId: WidgetId): void; +}; + +declare global { + interface Window { + cortexTurnstileOnLoad?: () => void; + turnstile?: TurnstileApi; + } +} + +function isCompatibleScript(script: HTMLScriptElement): boolean { + return ( + !script.async && !script.defer && script.src.includes(ONLOAD_CALLBACK) + ); +} + +function removeScript(): void { + document.getElementById(SCRIPT_ID)?.remove(); + delete window.turnstile; +} + +export type UseTurnstileOptions = { + action: string; + errorMessage: string; + siteKey: string | undefined; +}; + +export type UseTurnstileReturn = { + containerRef: React.RefObject; + enabled: boolean; + error: string | null; + execute: () => Promise; + pending: boolean; + reset: () => void; + tokenRef: React.RefObject; +}; + +export function useTurnstile({ + action, + errorMessage, + siteKey, +}: UseTurnstileOptions): UseTurnstileReturn { + const containerRef = useRef(null); + const tokenRef = useRef(null); + const widgetIdRef = useRef(null); + const challengeRef = useRef(null); + const [scriptReady, setScriptReady] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const clearToken = useCallback(() => { + if (tokenRef.current) { + tokenRef.current.value = ""; + } + }, []); + + const resolveChallenge = useCallback((token: string) => { + const challenge = challengeRef.current; + + if (!challenge) return; + + window.clearTimeout(challenge.timeoutId); + challengeRef.current = null; + setPending(false); + setError(null); + + const normalizedToken = token.trim(); + + if (tokenRef.current) { + tokenRef.current.value = normalizedToken; + } + + challenge.resolve(normalizedToken); + }, []); + + const rejectChallenge = useCallback(() => { + const challenge = challengeRef.current; + + if (!challenge) return; + + window.clearTimeout(challenge.timeoutId); + challenge.reject(new Error(errorMessage)); + challengeRef.current = null; + clearToken(); + setPending(false); + setError(errorMessage); + }, [clearToken, errorMessage]); + + const resetWidget = useCallback(() => { + const widgetId = widgetIdRef.current; + + if (widgetId && window.turnstile) { + window.turnstile.reset(widgetId); + } + }, []); + + const reset = useCallback(() => { + clearToken(); + resetWidget(); + setError(null); + }, [clearToken, resetWidget]); + + const execute = useCallback(() => { + const turnstile = window.turnstile; + const widgetId = widgetIdRef.current; + const container = containerRef.current; + + if (!turnstile || !widgetId || !container) { + setError(errorMessage); + return Promise.reject(new Error(errorMessage)); + } + + clearToken(); + setPending(true); + setError(null); + + return new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + challengeRef.current = null; + clearToken(); + setPending(false); + setError(errorMessage); + reject(new Error(errorMessage)); + }, CHALLENGE_TIMEOUT_MS); + + challengeRef.current = { reject, resolve, timeoutId }; + + try { + turnstile.reset(widgetId); + turnstile.execute(container); + } catch (err) { + window.clearTimeout(timeoutId); + challengeRef.current = null; + clearToken(); + setPending(false); + setError(errorMessage); + reject(err); + } + }); + }, [clearToken, errorMessage]); + + useEffect(() => { + if (!siteKey) return; + + let cancelled = false; + + function setScriptStatus( + script: HTMLScriptElement | null, + status: ScriptStatus, + ) { + if (script) { + script.dataset[SCRIPT_STATUS_KEY] = status; + } + } + + function markReady(script: HTMLScriptElement | null) { + if (cancelled) return; + + if (!window.turnstile) { + setScriptStatus(script, "error"); + handleError(script); + return; + } + + setScriptStatus(script, "loaded"); + + if (!cancelled) { + setScriptReady(true); + setError(null); + } + } + + function handleLoad(event: Event) { + markReady(event.currentTarget as HTMLScriptElement | null); + } + + function handleError(script: HTMLScriptElement | null = null) { + if (cancelled) return; + setScriptStatus(script, "error"); + setScriptReady(false); + setError(errorMessage); + } + + function handleScriptError(event: Event) { + handleError(event.currentTarget as HTMLScriptElement | null); + } + + window[ONLOAD_CALLBACK] = () => { + const script = document.getElementById( + SCRIPT_ID, + ) as HTMLScriptElement | null; + + markReady(script); + }; + + const existingScript = document.getElementById( + SCRIPT_ID, + ) as HTMLScriptElement | null; + + if (existingScript && !isCompatibleScript(existingScript)) { + removeScript(); + } + + const compatibleScript = document.getElementById( + SCRIPT_ID, + ) as HTMLScriptElement | null; + + if (window.turnstile && compatibleScript) { + const loadedScript = compatibleScript; + + window.queueMicrotask(() => markReady(loadedScript)); + + return () => { + cancelled = true; + delete window[ONLOAD_CALLBACK]; + }; + } + + if (compatibleScript) { + const existingStatus = compatibleScript.dataset[ + SCRIPT_STATUS_KEY + ] as ScriptStatus | undefined; + + if (existingStatus === "loaded") { + const loadedScript = compatibleScript; + + window.queueMicrotask(() => markReady(loadedScript)); + + return () => { + cancelled = true; + delete window[ONLOAD_CALLBACK]; + }; + } + + if (existingStatus === "error") { + removeScript(); + } + } + + const loadingScript = document.getElementById( + SCRIPT_ID, + ) as HTMLScriptElement | null; + + if (loadingScript) { + loadingScript.addEventListener("load", handleLoad, { once: true }); + loadingScript.addEventListener("error", handleScriptError, { + once: true, + }); + + return () => { + cancelled = true; + delete window[ONLOAD_CALLBACK]; + loadingScript.removeEventListener("load", handleLoad); + loadingScript.removeEventListener("error", handleScriptError); + }; + } + + const script = document.createElement("script"); + const nonce = + document.querySelector("script[nonce]")?.nonce; + + script.id = SCRIPT_ID; + script.src = SCRIPT_SRC; + script.async = false; + script.defer = false; + setScriptStatus(script, "loading"); + + if (nonce) { + script.nonce = nonce; + } + + script.addEventListener("load", handleLoad, { once: true }); + script.addEventListener("error", handleScriptError, { once: true }); + document.head.append(script); + + return () => { + cancelled = true; + delete window[ONLOAD_CALLBACK]; + script.removeEventListener("load", handleLoad); + script.removeEventListener("error", handleScriptError); + }; + }, [siteKey, errorMessage]); + + useEffect(() => { + if ( + !siteKey || + !scriptReady || + !containerRef.current || + widgetIdRef.current || + !window.turnstile + ) { + return; + } + + const widgetId = window.turnstile.render(containerRef.current, { + action, + appearance: "execute", + callback: resolveChallenge, + "error-callback": rejectChallenge, + "expired-callback": rejectChallenge, + execution: "execute", + sitekey: siteKey, + "timeout-callback": rejectChallenge, + }); + + if (widgetId) { + widgetIdRef.current = widgetId; + } + + return () => { + const renderedWidgetId = widgetIdRef.current; + + if (renderedWidgetId && window.turnstile) { + window.turnstile.remove(renderedWidgetId); + widgetIdRef.current = null; + } + }; + }, [action, siteKey, scriptReady, resolveChallenge, rejectChallenge]); + + return { + containerRef, + enabled: Boolean(siteKey), + error, + execute, + pending, + reset, + tokenRef, + }; +} diff --git a/src/lib/content/footer.ts b/src/lib/content/footer.ts index 0e8b59f..bcc33a7 100644 --- a/src/lib/content/footer.ts +++ b/src/lib/content/footer.ts @@ -1,11 +1,10 @@ import { - contactEmail, cortexSocialLinkKeys, externalLinks, siteLinks, type ExternalLinkKey, } from "./links"; -import type { ContentCta, InternalHref, SectionContent } from "./types"; +import type { InternalHref, SectionContent } from "./types"; export type FooterColumnLink = { label: string; @@ -24,11 +23,8 @@ export const footerContent = { description: "Get Cortex updates on local hubs, education tracks, events, services, and ways to support the network.", tagline: "Open innovation, made local. Open to all.", - email: contactEmail, - newsletterCta: { - label: "Subscribe", - href: contactEmail.href, - } satisfies ContentCta, + newsletterPlaceholder: "youremail@example.com", + newsletterSubmitLabel: "Subscribe", columns: [ { title: "About", @@ -62,8 +58,8 @@ export const footerContent = { } as const satisfies SectionContent & { tagline: string; emailInputId: string; - email: typeof contactEmail; - newsletterCta: ContentCta; + newsletterPlaceholder: string; + newsletterSubmitLabel: string; columns: readonly FooterColumn[]; socialLinks: readonly ((typeof externalLinks)[ExternalLinkKey] & { key: ExternalLinkKey; diff --git a/src/lib/content/links.ts b/src/lib/content/links.ts index 0c20c1f..b76ecc9 100644 --- a/src/lib/content/links.ts +++ b/src/lib/content/links.ts @@ -2,7 +2,7 @@ import type { ExternalHref, InternalHref } from "./types"; export type SiteLink = { label: string; - href: InternalHref; + href?: InternalHref; }; export const siteLinks = { @@ -45,7 +45,7 @@ export type ExternalLinkChannel = | "tiktok" | "linkedin" | "youtube" - | "github" + | "github"; export type ExternalLink = { label: string; @@ -182,9 +182,3 @@ export const monadLinkKeys = [ "mipland", "categoryLabs", ] as const satisfies readonly ExternalLinkKey[]; - -// TODO: Need to verify this -export const contactEmail = { - label: "becool@cortexglobal.xyz", - href: "mailto:becool@cortexglobal.xyz", -} as const satisfies SiteLink; diff --git a/src/lib/integrations/resend.test.ts b/src/lib/integrations/resend.test.ts new file mode 100644 index 0000000..a075fc2 --- /dev/null +++ b/src/lib/integrations/resend.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { handleNewsletterSignup } from "@/lib/newsletter/submit"; + +type ResendApiError = { + message: string; + name?: string; +}; + +type ResendContact = { + id: string; + unsubscribed?: boolean; +}; + +type ResendSegmentAddResult = { + data: Record | null; + error: ResendApiError | null; +}; + +type ResendSegmentListResult = { + data: { data: Array<{ id: string }> }; + error: ResendApiError | null; +}; + +type ResendContactResult = { + data: ResendContact | null; + error: ResendApiError | null; +}; + +const segmentsAddMock = mock(async (): Promise => ({ + data: {}, + error: null, +})); + +const segmentsListMock = mock(async (): Promise => ({ + data: { data: [] }, + error: null, +})); + +const contactsGetMock = mock(async (): Promise => ({ + data: null, + error: { name: "not_found", message: "not found" }, +})); + +const contactsCreateMock = mock(async (): Promise => ({ + data: { id: "contact_1", unsubscribed: false }, + error: null, +})); + +const contactsUpdateMock = mock(async (): Promise => ({ + data: { id: "contact_1", unsubscribed: false }, + error: null, +})); + +mock.module("resend", () => ({ + Resend: class MockResend { + contacts = { + get: contactsGetMock, + create: contactsCreateMock, + update: contactsUpdateMock, + segments: { + list: segmentsListMock, + add: segmentsAddMock, + }, + }; + }, +})); + +const { subscribeNewsletterContact } = await import("@/lib/integrations/resend"); + +const SEGMENT_ID = "seg_test"; +const EMAIL = "person@example.com"; + +function createFormData(fields: Record): FormData { + const formData = new FormData(); + + for (const [key, value] of Object.entries(fields)) { + formData.set(key, value); + } + + return formData; +} + +function mockExistingContactNotInSegmentFlow() { + contactsGetMock.mockImplementation(async () => ({ + data: { id: "contact_1", unsubscribed: false }, + error: null, + })); + segmentsListMock.mockImplementation(async () => ({ + data: { data: [] }, + error: null, + })); +} + +function resetResendMocks() { + segmentsAddMock.mockReset(); + segmentsListMock.mockReset(); + contactsGetMock.mockReset(); + contactsCreateMock.mockReset(); + contactsUpdateMock.mockReset(); + + segmentsAddMock.mockImplementation(async () => ({ data: {}, error: null })); + segmentsListMock.mockImplementation(async () => ({ + data: { data: [] }, + error: null, + })); + contactsGetMock.mockImplementation(async () => ({ + data: null, + error: { name: "not_found", message: "not found" }, + })); + contactsCreateMock.mockImplementation(async () => ({ + data: { id: "contact_1", unsubscribed: false }, + error: null, + })); + contactsUpdateMock.mockImplementation(async () => ({ + data: { id: "contact_1", unsubscribed: false }, + error: null, + })); +} + +describe("subscribeNewsletterContact", () => { + beforeEach(() => { + process.env.RESEND_API_KEY = "re_test"; + process.env.RESEND_SEGMENT_ID = SEGMENT_ID; + resetResendMocks(); + }); + + test("existing contact already in segment returns alreadySubscribed: true", async () => { + contactsGetMock.mockImplementation(async () => ({ + data: { id: "contact_1", unsubscribed: false }, + error: null, + })); + segmentsListMock.mockImplementation(async () => ({ + data: { data: [{ id: SEGMENT_ID }] }, + error: null, + })); + + const result = await subscribeNewsletterContact(EMAIL); + + expect(result).toEqual({ ok: true, alreadySubscribed: true }); + expect(contactsCreateMock).not.toHaveBeenCalled(); + expect(contactsUpdateMock).not.toHaveBeenCalled(); + expect(segmentsAddMock).not.toHaveBeenCalled(); + }); + + test("unsubscribed existing contact already in segment is reactivated", async () => { + contactsGetMock.mockImplementation(async () => ({ + data: { id: "contact_1", unsubscribed: true }, + error: null, + })); + segmentsListMock.mockImplementation(async () => ({ + data: { data: [{ id: SEGMENT_ID }] }, + error: null, + })); + + const result = await subscribeNewsletterContact(EMAIL); + + expect(result).toEqual({ ok: true, alreadySubscribed: false }); + expect(contactsUpdateMock).toHaveBeenCalledWith({ + email: EMAIL, + unsubscribed: false, + }); + expect(segmentsAddMock).not.toHaveBeenCalled(); + }); + + test("new contact is created with the newsletter segment in one call", async () => { + const result = await subscribeNewsletterContact(EMAIL); + + expect(result).toEqual({ ok: true, alreadySubscribed: false }); + expect(contactsCreateMock).toHaveBeenCalledWith({ + email: EMAIL, + unsubscribed: false, + segments: [{ id: SEGMENT_ID }], + }); + expect(segmentsListMock).not.toHaveBeenCalled(); + expect(segmentsAddMock).not.toHaveBeenCalled(); + }); + + test("existing contact not in segment succeeds when first segment add succeeds", async () => { + mockExistingContactNotInSegmentFlow(); + + const result = await subscribeNewsletterContact(EMAIL); + + expect(result).toEqual({ ok: true, alreadySubscribed: false }); + expect(contactsUpdateMock).not.toHaveBeenCalled(); + expect(contactsCreateMock).not.toHaveBeenCalled(); + expect(segmentsListMock).toHaveBeenCalledTimes(1); + expect(segmentsAddMock).toHaveBeenCalledTimes(1); + }); + + test("unsubscribed existing contact already in segment gets the success message", async () => { + contactsGetMock.mockImplementation(async () => ({ + data: { id: "contact_1", unsubscribed: true }, + error: null, + })); + segmentsListMock.mockImplementation(async () => ({ + data: { data: [{ id: SEGMENT_ID }] }, + error: null, + })); + + const result = await handleNewsletterSignup( + createFormData({ + email: EMAIL, + company: "", + }), + { + subscribe: subscribeNewsletterContact, + }, + ); + + expect(result).toEqual({ + status: "success", + message: "You have successfully subscribed to the Cortex update list.", + subscribedEmail: EMAIL, + }); + }); + + test("segment add fails once, retry succeeds, result is success", async () => { + mockExistingContactNotInSegmentFlow(); + segmentsAddMock + .mockImplementationOnce(async () => ({ + data: null, + error: { message: "transient failure" }, + })) + .mockImplementationOnce(async () => ({ data: {}, error: null })); + + const result = await subscribeNewsletterContact(EMAIL); + + expect(result).toEqual({ ok: true, alreadySubscribed: false }); + expect(segmentsAddMock).toHaveBeenCalledTimes(2); + }); + + test("segment add fails twice, result is ok: false", async () => { + mockExistingContactNotInSegmentFlow(); + segmentsAddMock.mockImplementation(async () => ({ + data: null, + error: { message: "persistent failure" }, + })); + + const result = await subscribeNewsletterContact(EMAIL); + + expect(result).toEqual({ + ok: false, + code: "resend", + message: "persistent failure", + }); + expect(segmentsAddMock).toHaveBeenCalledTimes(2); + }); + + test("segment add fails twice and signup returns the generic retry message", async () => { + mockExistingContactNotInSegmentFlow(); + segmentsAddMock.mockImplementation(async () => ({ + data: null, + error: { message: "persistent failure" }, + })); + + const result = await handleNewsletterSignup( + createFormData({ + email: EMAIL, + company: "", + }), + { + subscribe: subscribeNewsletterContact, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "We could not subscribe that email right now. Please try again.", + }); + expect(segmentsAddMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/integrations/resend.ts b/src/lib/integrations/resend.ts new file mode 100644 index 0000000..0975891 --- /dev/null +++ b/src/lib/integrations/resend.ts @@ -0,0 +1,220 @@ +import { Resend } from "resend"; + +type ResendNewsletterConfig = + | { + ok: true; + apiKey: string; + segmentId: string; + } + | { + ok: false; + message: string; + }; + +export type NewsletterSubscribeResult = + | { + ok: true; + alreadySubscribed: boolean; + } + | { + ok: false; + code: "configuration" | "resend"; + message: string; + }; + +function getNewsletterConfig(): ResendNewsletterConfig { + const apiKey = process.env.RESEND_API_KEY; + const segmentId = process.env.RESEND_SEGMENT_ID; + + if (!apiKey || !segmentId) { + return { + ok: false, + message: "Newsletter capture is missing Resend configuration.", + }; + } + + return { + ok: true, + apiKey, + segmentId, + }; +} + +function isMissingContactError(errorName: string | undefined): boolean { + return errorName === "not_found"; +} + +function getResendErrorMessage(error: { message: string } | null): string { + return error?.message || "Resend rejected the newsletter subscription."; +} + +type SegmentAddClient = { + contacts: { + segments: { + add: (params: { + email: string; + segmentId: string; + }) => Promise<{ + error: { message: string } | null; + }>; + }; + }; +}; + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function addContactToNewsletterSegment( + client: SegmentAddClient, + email: string, + segmentId: string, +): Promise<{ ok: true } | { ok: false; message: string }> { + const firstAttempt = await client.contacts.segments.add({ email, segmentId }); + + if (!firstAttempt.error) { + return { ok: true }; + } + + await wait(1_000); + + const secondAttempt = await client.contacts.segments.add({ email, segmentId }); + + if (!secondAttempt.error) { + return { ok: true }; + } + + return { + ok: false, + message: getResendErrorMessage(secondAttempt.error), + }; +} + +export async function subscribeNewsletterContact( + email: string, +): Promise { + const config = getNewsletterConfig(); + + if (!config.ok) { + return { + ok: false, + code: "configuration", + message: config.message, + }; + } + + try { + return await subscribeWithResend(email, config); + } catch { + return { + ok: false, + code: "resend", + message: "Resend is not reachable right now.", + }; + } +} + +async function subscribeExistingContact( + resend: Resend, + email: string, + segmentId: string, + wasUnsubscribed: boolean, +): Promise { + if (wasUnsubscribed) { + const updateContact = await resend.contacts.update({ + email, + unsubscribed: false, + }); + + if (updateContact.error) { + return { + ok: false, + code: "resend", + message: getResendErrorMessage(updateContact.error), + }; + } + } + + const contactSegments = await resend.contacts.segments.list({ + email, + limit: 100, + }); + + if (contactSegments.error) { + return { + ok: false, + code: "resend", + message: getResendErrorMessage(contactSegments.error), + }; + } + + const alreadyInSegment = contactSegments.data.data.some( + (segment) => segment.id === segmentId, + ); + + if (alreadyInSegment) { + return { + ok: true, + alreadySubscribed: !wasUnsubscribed, + }; + } + + const addSegment = await addContactToNewsletterSegment(resend, email, segmentId); + + if (!addSegment.ok) { + return { + ok: false, + code: "resend", + message: addSegment.message, + }; + } + + return { + ok: true, + alreadySubscribed: false, + }; +} + +async function subscribeWithResend( + email: string, + config: { apiKey: string; segmentId: string }, +): Promise { + const resend = new Resend(config.apiKey); + const existingContact = await resend.contacts.get({ email }); + + if (existingContact.error && !isMissingContactError(existingContact.error.name)) { + return { + ok: false, + code: "resend", + message: getResendErrorMessage(existingContact.error), + }; + } + + if (existingContact.data) { + return subscribeExistingContact( + resend, + email, + config.segmentId, + existingContact.data.unsubscribed, + ); + } + + const createContact = await resend.contacts.create({ + email, + unsubscribed: false, + segments: [{ id: config.segmentId }], + }); + + if (createContact.error) { + return { + ok: false, + code: "resend", + message: getResendErrorMessage(createContact.error), + }; + } + + return { + ok: true, + alreadySubscribed: false, + }; +} diff --git a/src/lib/integrations/turnstile.test.ts b/src/lib/integrations/turnstile.test.ts new file mode 100644 index 0000000..6fd7c3b --- /dev/null +++ b/src/lib/integrations/turnstile.test.ts @@ -0,0 +1,300 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + isNewsletterTurnstileEnabled, + resolveTurnstileAllowedHostnames, + verifyTurnstileToken, +} from "@/lib/integrations/turnstile"; + +describe("verifyTurnstileToken", () => { + test("requires allowed hostnames before calling Siteverify", async () => { + const fetcher = mock(async () => Response.json({ success: true })); + + const result = await verifyTurnstileToken({ + allowedHostnames: "", + deploymentHostname: "", + fetcher, + secretKey: "secret_test", + token: "token_test", + }); + + expect(result).toEqual({ + ok: false, + code: "configuration", + message: "Verification failed. Please try again.", + }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + test("rejects a missing token before calling Siteverify", async () => { + const fetcher = mock(async () => Response.json({ success: true })); + + const result = await verifyTurnstileToken({ + fetcher, + secretKey: "secret_test", + token: "", + }); + + expect(result).toEqual({ + ok: false, + code: "missing-token", + message: "Verification failed. Please try again.", + }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + test("rejects whitespace-only secret keys before calling Siteverify", async () => { + const fetcher = mock(async () => Response.json({ success: true })); + + const result = await verifyTurnstileToken({ + fetcher, + secretKey: " ", + token: "token_test", + }); + + expect(result).toEqual({ + ok: false, + code: "configuration", + message: "Verification failed. Please try again.", + }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + test("trims secret keys before calling Siteverify", async () => { + const fetcher = mock(async (_input, init) => { + const body = init?.body; + + expect(body).toBeInstanceOf(URLSearchParams); + expect((body as URLSearchParams).get("secret")).toBe("secret_test"); + + return Response.json({ + action: "newsletter", + hostname: "www.cortexglobal.xyz", + success: true, + }); + }); + + const result = await verifyTurnstileToken({ + allowedHostnames: ["www.cortexglobal.xyz"], + fetcher, + secretKey: " secret_test ", + token: "token_test", + }); + + expect(result).toEqual({ ok: true }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + test("accepts a valid Siteverify response for the newsletter action and hostname", async () => { + const fetcher = mock(async () => + Response.json({ + action: "newsletter", + hostname: "www.cortexglobal.xyz", + success: true, + }), + ); + + const result = await verifyTurnstileToken({ + allowedHostnames: "cortexglobal.xyz,www.cortexglobal.xyz", + fetcher, + remoteIp: "203.0.113.10", + secretKey: "secret_test", + token: "token_test", + }); + + expect(result).toEqual({ ok: true }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + test("maps a failed Siteverify response to an invalid result", async () => { + const fetcher = mock(async () => + Response.json({ + success: false, + "error-codes": ["invalid-input-response"], + }), + ); + + const result = await verifyTurnstileToken({ + allowedHostnames: ["www.cortexglobal.xyz"], + fetcher, + remoteIp: "203.0.113.10", + secretKey: "secret_test", + token: "token_test", + }); + + expect(result).toEqual({ + ok: false, + code: "invalid", + message: "Verification failed. Please try again.", + errorCodes: ["invalid-input-response"], + }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + test("rejects a Siteverify response with a mismatched action", async () => { + const fetcher = mock(async () => + Response.json({ + action: "login", + hostname: "www.cortexglobal.xyz", + success: true, + }), + ); + + const result = await verifyTurnstileToken({ + allowedHostnames: ["www.cortexglobal.xyz"], + fetcher, + secretKey: "secret_test", + token: "token_test", + }); + + expect(result).toEqual({ + ok: false, + code: "invalid", + message: "Verification failed. Please try again.", + errorCodes: ["action-mismatch"], + }); + }); + + test("rejects a Siteverify response with a mismatched hostname", async () => { + const fetcher = mock(async () => + Response.json({ + action: "newsletter", + hostname: "attacker.example", + success: true, + }), + ); + + const result = await verifyTurnstileToken({ + allowedHostnames: ["www.cortexglobal.xyz"], + fetcher, + secretKey: "secret_test", + token: "token_test", + }); + + expect(result).toEqual({ + ok: false, + code: "invalid", + message: "Verification failed. Please try again.", + errorCodes: ["hostname-mismatch"], + }); + }); + + test("accepts Cloudflare test-secret responses with the dummy test action", async () => { + const fetcher = mock(async () => + Response.json({ + action: "test", + hostname: "localhost", + success: true, + }), + ); + + const result = await verifyTurnstileToken({ + allowedHostnames: ["localhost"], + fetcher, + secretKey: "1x0000000000000000000000000000000AA", + token: "XXXX.DUMMY.TOKEN.XXXX", + }); + + expect(result).toEqual({ ok: true }); + }); + + test("accepts a preview hostname through a wildcard allowlist entry", async () => { + const fetcher = mock(async () => + Response.json({ + action: "newsletter", + hostname: "cortex-website-git-main.vercel.app", + success: true, + }), + ); + + const result = await verifyTurnstileToken({ + allowedHostnames: ["cortexglobal.xyz", "*.vercel.app"], + fetcher, + secretKey: "secret_test", + token: "token_test", + }); + + expect(result).toEqual({ ok: true }); + }); + + test("accepts the current Vercel deployment hostname when it is not listed explicitly", async () => { + const fetcher = mock(async () => + Response.json({ + action: "newsletter", + hostname: "cortex-website-preview.vercel.app", + success: true, + }), + ); + + const result = await verifyTurnstileToken({ + allowedHostnames: ["cortexglobal.xyz", "www.cortexglobal.xyz"], + deploymentHostname: "cortex-website-preview.vercel.app", + fetcher, + secretKey: "secret_test", + token: "token_test", + }); + + expect(result).toEqual({ ok: true }); + }); +}); + +describe("resolveTurnstileAllowedHostnames", () => { + test("merges configured hostnames with the deployment hostname", () => { + expect( + resolveTurnstileAllowedHostnames( + "cortexglobal.xyz, www.cortexglobal.xyz", + "cortex-website-preview.vercel.app", + ), + ).toEqual([ + "cortexglobal.xyz", + "www.cortexglobal.xyz", + "cortex-website-preview.vercel.app", + ]); + }); +}); + +describe("isNewsletterTurnstileEnabled", () => { + const originalSiteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; + const originalSecretKey = process.env.TURNSTILE_SECRET_KEY; + + afterEach(() => { + if (originalSiteKey === undefined) { + delete process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; + } else { + process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY = originalSiteKey; + } + + if (originalSecretKey === undefined) { + delete process.env.TURNSTILE_SECRET_KEY; + } else { + process.env.TURNSTILE_SECRET_KEY = originalSecretKey; + } + }); + + test("requires both the public site key and secret key", () => { + process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY = "site-key"; + process.env.TURNSTILE_SECRET_KEY = "secret-key"; + + expect(isNewsletterTurnstileEnabled()).toBe(true); + }); + + test("rejects a secret-only configuration", () => { + delete process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; + process.env.TURNSTILE_SECRET_KEY = "secret-key"; + + expect(isNewsletterTurnstileEnabled()).toBe(false); + }); + + test("rejects a site-key-only configuration", () => { + process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY = "site-key"; + delete process.env.TURNSTILE_SECRET_KEY; + + expect(isNewsletterTurnstileEnabled()).toBe(false); + }); + + test("ignores whitespace-only env values", () => { + process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY = " "; + process.env.TURNSTILE_SECRET_KEY = "secret-key"; + + expect(isNewsletterTurnstileEnabled()).toBe(false); + }); +}); diff --git a/src/lib/integrations/turnstile.ts b/src/lib/integrations/turnstile.ts new file mode 100644 index 0000000..84541f9 --- /dev/null +++ b/src/lib/integrations/turnstile.ts @@ -0,0 +1,241 @@ +import * as Sentry from "@sentry/nextjs"; +import { + NEWSLETTER_TURNSTILE_ACTION, + NEWSLETTER_TURNSTILE_ERROR_MESSAGE, +} from "@/lib/newsletter/turnstile"; + +const TURNSTILE_SITEVERIFY_URL = + "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + +export function isNewsletterTurnstileEnabled(): boolean { + return Boolean( + process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY?.trim() && + process.env.TURNSTILE_SECRET_KEY?.trim(), + ); +} +const TURNSTILE_TEST_ACTION = "test"; +const TURNSTILE_TEST_SECRET_KEYS = new Set([ + "1x0000000000000000000000000000000AA", + "2x0000000000000000000000000000000AA", +]); + +type TurnstileFetch = (input: string, init?: RequestInit) => Promise; + +type TurnstileSiteverifyResponse = { + action?: string; + success?: boolean; + "error-codes"?: string[]; + hostname?: string; +}; + +export type TurnstileVerificationResult = + | { + ok: true; + } + | { + ok: false; + code: "configuration" | "invalid" | "missing-token" | "network"; + message: string; + errorCodes?: string[]; + }; + +type VerifyTurnstileTokenOptions = { + allowedHostnames?: readonly string[] | string; + deploymentHostname?: string; + expectedAction?: string; + fetcher?: TurnstileFetch; + remoteIp?: string | null; + secretKey?: string; + token: FormDataEntryValue | null; +}; + +function normalizeTurnstileHostname(hostname: string): string { + const withoutProtocol = hostname + .trim() + .replace(/^[a-z][a-z\d+.-]*:\/\//i, ""); + const withoutPath = withoutProtocol.split("/")[0] || ""; + const withoutPort = withoutPath.split(":")[0] || ""; + + return withoutPort.toLowerCase().replace(/\.$/, ""); +} + +const LOCAL_DEV_TURNSTILE_HOSTNAMES = [ + "localhost", + "127.0.0.1", + "0.0.0.0", +] as const; + +function isLocalDevelopmentRuntime(): boolean { + return process.env.NODE_ENV === "development"; +} + +function parseAllowedHostnames( + allowedHostnames: readonly string[] | string | undefined, +): string[] { + const values = + typeof allowedHostnames === "string" + ? allowedHostnames.split(/[,\s]+/) + : allowedHostnames || []; + + return values + .map((hostname) => normalizeTurnstileHostname(hostname)) + .filter(Boolean); +} + +export function resolveTurnstileAllowedHostnames( + configuredHostnames: readonly string[] | string | undefined = process.env + .TURNSTILE_ALLOWED_HOSTNAMES, + deploymentHostname: string | undefined = process.env.VERCEL_URL, +): string[] { + const hostnames = new Set(parseAllowedHostnames(configuredHostnames)); + + if (deploymentHostname) { + const normalizedDeploymentHostname = + normalizeTurnstileHostname(deploymentHostname); + + if (normalizedDeploymentHostname) { + hostnames.add(normalizedDeploymentHostname); + } + } + + if (hostnames.size === 0 && isLocalDevelopmentRuntime()) { + return [...LOCAL_DEV_TURNSTILE_HOSTNAMES]; + } + + return [...hostnames]; +} + +function isAllowedTurnstileHostname( + hostname: string, + allowedHostnames: readonly string[], +): boolean { + return allowedHostnames.some((allowed) => { + if (allowed.startsWith("*.")) { + const suffix = allowed.slice(1); + + return hostname.endsWith(suffix); + } + + return hostname === allowed; + }); +} + +function getExpectedActions( + secretKey: string, + expectedAction: string, +): Set { + const actions = new Set([expectedAction]); + + if (TURNSTILE_TEST_SECRET_KEYS.has(secretKey)) { + actions.add(TURNSTILE_TEST_ACTION); + } + + return actions; +} + +export async function verifyTurnstileToken({ + allowedHostnames = process.env.TURNSTILE_ALLOWED_HOSTNAMES, + deploymentHostname = process.env.VERCEL_URL, + expectedAction = NEWSLETTER_TURNSTILE_ACTION, + fetcher = fetch, + remoteIp, + secretKey = process.env.TURNSTILE_SECRET_KEY, + token, +}: VerifyTurnstileTokenOptions): Promise { + const responseToken = typeof token === "string" ? token.trim() : ""; + const normalizedSecretKey = secretKey?.trim() ?? ""; + const normalizedAllowedHostnames = resolveTurnstileAllowedHostnames( + allowedHostnames, + deploymentHostname, + ); + + if (!normalizedSecretKey) { + return { + ok: false, + code: "configuration", + message: NEWSLETTER_TURNSTILE_ERROR_MESSAGE, + }; + } + + if (!responseToken) { + return { + ok: false, + code: "missing-token", + message: NEWSLETTER_TURNSTILE_ERROR_MESSAGE, + }; + } + + if (normalizedAllowedHostnames.length === 0) { + return { + ok: false, + code: "configuration", + message: NEWSLETTER_TURNSTILE_ERROR_MESSAGE, + }; + } + + const body = new URLSearchParams({ + response: responseToken, + secret: normalizedSecretKey, + }); + + if (remoteIp) { + body.set("remoteip", remoteIp); + } + + try { + const response = await fetcher(TURNSTILE_SITEVERIFY_URL, { + body, + method: "POST", + }); + const result = (await response.json()) as TurnstileSiteverifyResponse; + + if (!response.ok || !result.success) { + return { + ok: false, + code: "invalid", + message: NEWSLETTER_TURNSTILE_ERROR_MESSAGE, + errorCodes: result["error-codes"], + }; + } + + const expectedActions = getExpectedActions( + normalizedSecretKey, + expectedAction, + ); + + if (!result.action || !expectedActions.has(result.action)) { + return { + ok: false, + code: "invalid", + message: NEWSLETTER_TURNSTILE_ERROR_MESSAGE, + errorCodes: ["action-mismatch"], + }; + } + + const hostname = result.hostname + ? normalizeTurnstileHostname(result.hostname) + : ""; + + if ( + !hostname || + !isAllowedTurnstileHostname(hostname, normalizedAllowedHostnames) + ) { + return { + ok: false, + code: "invalid", + message: NEWSLETTER_TURNSTILE_ERROR_MESSAGE, + errorCodes: ["hostname-mismatch"], + }; + } + + return { ok: true }; + } catch (error) { + Sentry.captureException(error); + + return { + ok: false, + code: "network", + message: NEWSLETTER_TURNSTILE_ERROR_MESSAGE, + }; + } +} diff --git a/src/lib/newsletter/actions.ts b/src/lib/newsletter/actions.ts new file mode 100644 index 0000000..a3d9f7e --- /dev/null +++ b/src/lib/newsletter/actions.ts @@ -0,0 +1,32 @@ +"use server"; + +import { headers } from "next/headers"; +import { subscribeNewsletterContact } from "@/lib/integrations/resend"; +import { + isNewsletterTurnstileEnabled, + verifyTurnstileToken, +} from "@/lib/integrations/turnstile"; +import { + checkNewsletterRateLimit, + getNewsletterClientIp, + getNewsletterRateLimitIdentifier, +} from "@/lib/newsletter/rate-limit"; +import { handleNewsletterSignup } from "@/lib/newsletter/submit"; +import type { NewsletterFormState } from "@/lib/newsletter/types"; + +export async function submitNewsletterSignup( + _prevState: NewsletterFormState, + formData: FormData, +): Promise { + const headersList = await headers(); + const clientIp = getNewsletterClientIp(headersList); + const rateLimitIdentifier = getNewsletterRateLimitIdentifier(headersList); + + return handleNewsletterSignup(formData, { + rateLimit: () => checkNewsletterRateLimit(rateLimitIdentifier), + subscribe: subscribeNewsletterContact, + turnstile: isNewsletterTurnstileEnabled() + ? (token) => verifyTurnstileToken({ remoteIp: clientIp, token }) + : undefined, + }); +} diff --git a/src/lib/newsletter/rate-limit.test.ts b/src/lib/newsletter/rate-limit.test.ts new file mode 100644 index 0000000..43903c2 --- /dev/null +++ b/src/lib/newsletter/rate-limit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; +import { + checkNewsletterRateLimit, + getNewsletterRateLimitIdentifier, +} from "@/lib/newsletter/rate-limit"; + +function headersFrom(values: Record): Headers { + const headers = new Headers(); + + for (const [key, value] of Object.entries(values)) { + headers.set(key, value); + } + + return headers; +} + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + return; + } + + process.env[name] = value; +} + +describe("getNewsletterRateLimitIdentifier", () => { + test("uses the first x-forwarded-for IP", () => { + const identifier = getNewsletterRateLimitIdentifier( + headersFrom({ + "x-forwarded-for": "203.0.113.10, 198.51.100.20", + }), + ); + + expect(identifier).toBe("203.0.113.10"); + }); + + test("prefers the Vercel forwarded IP over a generic forwarded loopback", () => { + const identifier = getNewsletterRateLimitIdentifier( + headersFrom({ + "x-forwarded-for": "::ffff:127.0.0.1:29698569", + "x-vercel-forwarded-for": "203.0.113.10", + }), + ); + + expect(identifier).toBe("203.0.113.10"); + }); + + test("normalizes a local IPv6-mapped IPv4 address with a port", () => { + const identifier = getNewsletterRateLimitIdentifier( + headersFrom({ + "x-forwarded-for": "::ffff:127.0.0.1:29698569", + }), + ); + + expect(identifier).toBe("127.0.0.1"); + }); + + test("skips loopback values in production-like forwarded chains", () => { + const previousVercelEnv = process.env.VERCEL_ENV; + process.env.VERCEL_ENV = "preview"; + + try { + const identifier = getNewsletterRateLimitIdentifier( + headersFrom({ + "x-forwarded-for": "::ffff:127.0.0.1:29698569, 203.0.113.10", + }), + ); + + expect(identifier).toBe("203.0.113.10"); + } finally { + restoreEnv("VERCEL_ENV", previousVercelEnv); + } + }); + + test("returns null when proxy headers are absent", () => { + const identifier = getNewsletterRateLimitIdentifier(headersFrom({})); + + expect(identifier).toBeNull(); + }); +}); + +describe("checkNewsletterRateLimit", () => { + test("fails closed in production-like runtimes when the client IP is unknown", async () => { + const previousEnv = { + NODE_ENV: process.env.NODE_ENV, + UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, + UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, + VERCEL_ENV: process.env.VERCEL_ENV, + }; + + process.env.UPSTASH_REDIS_REST_TOKEN = "token_test"; + process.env.UPSTASH_REDIS_REST_URL = "https://upstash.example"; + process.env.VERCEL_ENV = "production"; + + try { + const result = await checkNewsletterRateLimit(null); + + expect(result).toEqual({ + ok: false, + code: "identifier", + message: "We could not accept that request right now. Please try again.", + }); + } finally { + restoreEnv("NODE_ENV", previousEnv.NODE_ENV); + restoreEnv( + "UPSTASH_REDIS_REST_TOKEN", + previousEnv.UPSTASH_REDIS_REST_TOKEN, + ); + restoreEnv("UPSTASH_REDIS_REST_URL", previousEnv.UPSTASH_REDIS_REST_URL); + restoreEnv("VERCEL_ENV", previousEnv.VERCEL_ENV); + } + }); +}); diff --git a/src/lib/newsletter/rate-limit.ts b/src/lib/newsletter/rate-limit.ts new file mode 100644 index 0000000..47dc5ad --- /dev/null +++ b/src/lib/newsletter/rate-limit.ts @@ -0,0 +1,221 @@ +import * as Sentry from "@sentry/nextjs"; +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { isIP } from "node:net"; + +const NEWSLETTER_RATE_LIMIT_REQUESTS = 5; +const NEWSLETTER_RATE_LIMIT_WINDOW = "1 m"; +const NEWSLETTER_RATE_LIMIT_PREFIX = "cortex:newsletter"; +const LOCAL_RATE_LIMIT_IDENTIFIER = "local"; + +type HeaderReader = { + get(name: string): string | null; +}; + +type ForwardedIpOptions = { + skipLoopback: boolean; +}; + +export type NewsletterRateLimitResult = + | { + ok: true; + skipped?: boolean; + } + | { + ok: false; + code: "configuration" | "identifier" | "limited" | "storage"; + message: string; + reset?: number; + }; + +let newsletterRateLimiter: Ratelimit | null = null; + +function isProductionLikeRuntime(): boolean { + return ( + process.env.NODE_ENV === "production" || + process.env.VERCEL_ENV === "preview" || + process.env.VERCEL_ENV === "production" + ); +} + +function hasUpstashConfig(): boolean { + return Boolean( + process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN, + ); +} + +function getNewsletterRateLimiter(): Ratelimit { + newsletterRateLimiter ??= new Ratelimit({ + redis: Redis.fromEnv({ enableTelemetry: false }), + limiter: Ratelimit.slidingWindow( + NEWSLETTER_RATE_LIMIT_REQUESTS, + NEWSLETTER_RATE_LIMIT_WINDOW, + ), + analytics: false, + prefix: NEWSLETTER_RATE_LIMIT_PREFIX, + }); + + return newsletterRateLimiter; +} + +function normalizeForwardedIp(value: string | null): string | null { + const trimmed = value?.trim(); + + if (!trimmed || trimmed.toLowerCase() === "unknown") return null; + + let candidate = + trimmed.startsWith('"') && trimmed.endsWith('"') + ? trimmed.slice(1, -1).trim() + : trimmed; + + const bracketedAddress = candidate.match(/^\[([^\]]+)\](?::\d+)?$/); + + if (bracketedAddress) { + candidate = bracketedAddress[1]; + } + + const mappedIpv4Address = candidate.match( + /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?$/i, + ); + + if (mappedIpv4Address && isIP(mappedIpv4Address[1]) === 4) { + return mappedIpv4Address[1]; + } + + const ipv4Address = candidate.match( + /^(\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?$/, + ); + + if (ipv4Address && isIP(ipv4Address[1]) === 4) { + return ipv4Address[1]; + } + + return isIP(candidate) ? candidate.toLowerCase() : null; +} + +function isLoopbackIp(ipAddress: string): boolean { + return ( + ipAddress === "::1" || + ipAddress === "0:0:0:0:0:0:0:1" || + ipAddress === "127.0.0.1" || + ipAddress.startsWith("127.") + ); +} + +function firstUsableForwardedIp( + values: (string | null)[], + { skipLoopback }: ForwardedIpOptions, +): string | null { + const normalizedIps = values + .map((value) => normalizeForwardedIp(value)) + .filter((value): value is string => Boolean(value)); + + if (skipLoopback) { + return normalizedIps.find((ipAddress) => !isLoopbackIp(ipAddress)) ?? null; + } + + return normalizedIps[0] ?? null; +} + +function firstForwardedIp( + value: string | null, + options: ForwardedIpOptions, +): string | null { + if (!value) return null; + + return firstUsableForwardedIp(value.split(","), options); +} + +function firstStandardForwardedIp( + value: string | null, + options: ForwardedIpOptions, +): string | null { + if (!value) return null; + + const forwardedForValues = value.split(",").map((entry) => { + const forPart = entry + .split(";") + .find((part) => part.trim().toLowerCase().startsWith("for=")); + + return forPart?.split("=").slice(1).join("=") ?? null; + }); + + return firstUsableForwardedIp(forwardedForValues, options); +} + +function headerIp( + value: string | null, + options: ForwardedIpOptions, +): string | null { + return firstUsableForwardedIp([value], options); +} + +export function getNewsletterClientIp(headersList: HeaderReader): string | null { + const options = { skipLoopback: isProductionLikeRuntime() }; + + return ( + firstForwardedIp(headersList.get("x-vercel-forwarded-for"), options) ?? + headerIp(headersList.get("cf-connecting-ip"), options) ?? + headerIp(headersList.get("x-real-ip"), options) ?? + firstStandardForwardedIp(headersList.get("forwarded"), options) ?? + firstForwardedIp(headersList.get("x-forwarded-for"), options) + ); +} + +export function getNewsletterRateLimitIdentifier( + headersList: HeaderReader, +): string | null { + return getNewsletterClientIp(headersList); +} + +export async function checkNewsletterRateLimit( + identifier: string | null, +): Promise { + const rateLimitIdentifier = identifier?.trim() || null; + + if (!hasUpstashConfig()) { + if (!isProductionLikeRuntime()) { + return { ok: true, skipped: true }; + } + + return { + ok: false, + code: "configuration", + message: + "Newsletter rate limiting is missing required Upstash environment variables.", + }; + } + + if (!rateLimitIdentifier && isProductionLikeRuntime()) { + return { + ok: false, + code: "identifier", + message: "We could not accept that request right now. Please try again.", + }; + } + + try { + const result = await getNewsletterRateLimiter().limit( + rateLimitIdentifier ?? LOCAL_RATE_LIMIT_IDENTIFIER, + ); + + if (!result.success) { + return { + ok: false, + code: "limited", + message: "Too many attempts. Please try again in a few minutes.", + reset: result.reset, + }; + } + + return { ok: true }; + } catch (error) { + Sentry.captureException(error); + + return { + ok: false, + code: "storage", + message: "We could not accept that request right now. Please try again.", + }; + } +} diff --git a/src/lib/newsletter/submit.test.ts b/src/lib/newsletter/submit.test.ts new file mode 100644 index 0000000..1733190 --- /dev/null +++ b/src/lib/newsletter/submit.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, test } from "bun:test"; +import type { NewsletterSubscribeResult } from "@/lib/integrations/resend"; +import type { TurnstileVerificationResult } from "@/lib/integrations/turnstile"; +import type { NewsletterRateLimitResult } from "@/lib/newsletter/rate-limit"; +import { handleNewsletterSignup } from "@/lib/newsletter/submit"; +import { NEWSLETTER_TURNSTILE_FIELD } from "@/lib/newsletter/turnstile"; +import { NEWSLETTER_SUBMISSION_ID_FIELD } from "@/lib/newsletter/types"; + +function createFormData(fields: Record): FormData { + const formData = new FormData(); + + for (const [key, value] of Object.entries(fields)) { + formData.set(key, value); + } + + return formData; +} + +function createSubscribeSpy(result: NewsletterSubscribeResult) { + const calls: string[] = []; + + return { + calls, + subscribe: async (email: string) => { + calls.push(email); + return result; + }, + }; +} + +function createRateLimitSpy(result: NewsletterRateLimitResult) { + let calls = 0; + + return { + get calls() { + return calls; + }, + rateLimit: async () => { + calls += 1; + return result; + }, + }; +} + +function createTurnstileSpy(result: TurnstileVerificationResult) { + const calls: Array = []; + + return { + calls, + turnstile: async (token: FormDataEntryValue | null) => { + calls.push(token); + return result; + }, + }; +} + +const subscribedResult: NewsletterSubscribeResult = { + ok: true, + alreadySubscribed: false, +}; + +describe("handleNewsletterSignup", () => { + test("rejects invalid email before subscribing", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const result = await handleNewsletterSignup( + createFormData({ + email: "not-an-email", + company: "", + }), + { + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "Enter a valid email address.", + emailError: "Enter a valid email address.", + }); + expect(subscribeSpy.calls).toEqual([]); + }); + + test("silently accepts honeypot submissions without subscribing", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const rateLimitSpy = createRateLimitSpy({ + ok: false, + code: "limited", + message: "Too many attempts. Please try again in a few minutes.", + reset: 20_000, + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "Bot Corp", + }), + { + rateLimit: rateLimitSpy.rateLimit, + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "success", + message: "You have successfully subscribed to the Cortex update list.", + }); + expect(rateLimitSpy.calls).toBe(0); + expect(subscribeSpy.calls).toEqual([]); + }); + + test("normalizes valid email and subscribes it", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const rateLimitSpy = createRateLimitSpy({ ok: true }); + const result = await handleNewsletterSignup( + createFormData({ + email: " Person@Example.COM ", + company: "", + }), + { + rateLimit: rateLimitSpy.rateLimit, + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "success", + message: "You have successfully subscribed to the Cortex update list.", + subscribedEmail: "person@example.com", + }); + expect(rateLimitSpy.calls).toBe(1); + expect(subscribeSpy.calls).toEqual(["person@example.com"]); + }); + + test("preserves the client submission id in the returned state", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + [NEWSLETTER_SUBMISSION_ID_FIELD]: "submission-123", + }), + { + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "success", + message: "You have successfully subscribed to the Cortex update list.", + subscribedEmail: "person@example.com", + submissionId: "submission-123", + }); + }); + + test("rate limits before rejecting a missing Turnstile token", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const rateLimitSpy = createRateLimitSpy({ ok: true }); + const turnstileSpy = createTurnstileSpy({ + ok: false, + code: "missing-token", + message: "Verification failed. Please try again.", + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + }), + { + rateLimit: rateLimitSpy.rateLimit, + subscribe: subscribeSpy.subscribe, + turnstile: turnstileSpy.turnstile, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "Verification failed. Please try again.", + }); + expect(turnstileSpy.calls).toEqual([null]); + expect(rateLimitSpy.calls).toBe(1); + expect(subscribeSpy.calls).toEqual([]); + }); + + test("rate limits before rejecting an invalid Turnstile token", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const rateLimitSpy = createRateLimitSpy({ ok: true }); + const turnstileSpy = createTurnstileSpy({ + ok: false, + code: "invalid", + message: "Verification failed. Please try again.", + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + [NEWSLETTER_TURNSTILE_FIELD]: "invalid-token", + }), + { + rateLimit: rateLimitSpy.rateLimit, + subscribe: subscribeSpy.subscribe, + turnstile: turnstileSpy.turnstile, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "Verification failed. Please try again.", + }); + expect(turnstileSpy.calls).toEqual(["invalid-token"]); + expect(rateLimitSpy.calls).toBe(1); + expect(subscribeSpy.calls).toEqual([]); + }); + + test("does not subscribe when the rate limit blocks the request", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const turnstileSpy = createTurnstileSpy({ ok: true }); + const rateLimitSpy = createRateLimitSpy({ + ok: false, + code: "limited", + message: "Too many attempts. Please try again in a few minutes.", + reset: 20_000, + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + }), + { + rateLimit: rateLimitSpy.rateLimit, + subscribe: subscribeSpy.subscribe, + turnstile: turnstileSpy.turnstile, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "Too many attempts. Please try again in a few minutes.", + }); + expect(rateLimitSpy.calls).toBe(1); + expect(turnstileSpy.calls).toEqual([]); + expect(subscribeSpy.calls).toEqual([]); + }); + + test("does not consume a rate-limit token when the email is invalid", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const rateLimitSpy = createRateLimitSpy({ ok: true }); + const result = await handleNewsletterSignup( + createFormData({ + email: "not-an-email", + company: "", + }), + { + rateLimit: rateLimitSpy.rateLimit, + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "Enter a valid email address.", + emailError: "Enter a valid email address.", + }); + expect(rateLimitSpy.calls).toBe(0); + expect(subscribeSpy.calls).toEqual([]); + }); + + test("does not subscribe when production rate-limit configuration is missing", async () => { + const subscribeSpy = createSubscribeSpy(subscribedResult); + const turnstileSpy = createTurnstileSpy({ ok: true }); + const rateLimitSpy = createRateLimitSpy({ + ok: false, + code: "configuration", + message: + "Newsletter rate limiting is missing required Upstash environment variables.", + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + }), + { + rateLimit: rateLimitSpy.rateLimit, + subscribe: subscribeSpy.subscribe, + turnstile: turnstileSpy.turnstile, + }, + ); + + expect(result).toEqual({ + status: "error", + message: + "Newsletter rate limiting is missing required Upstash environment variables.", + }); + expect(rateLimitSpy.calls).toBe(1); + expect(turnstileSpy.calls).toEqual([]); + expect(subscribeSpy.calls).toEqual([]); + }); + + test("returns already-submitted message when Resend confirms an existing segment member", async () => { + const subscribeSpy = createSubscribeSpy({ + ok: true, + alreadySubscribed: true, + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + }), + { + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "success", + message: "Your email has already been submitted.", + subscribedEmail: "person@example.com", + }); + expect(subscribeSpy.calls).toEqual(["person@example.com"]); + }); + + test("returns configuration errors without exposing Resend internals", async () => { + const subscribeSpy = createSubscribeSpy({ + ok: false, + code: "configuration", + message: "missing env", + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + }), + { + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "Newsletter setup is missing required Resend environment variables.", + }); + expect(subscribeSpy.calls).toEqual(["person@example.com"]); + }); + + test("returns a generic message for Resend API failures", async () => { + const subscribeSpy = createSubscribeSpy({ + ok: false, + code: "resend", + message: "upstream detail", + }); + const result = await handleNewsletterSignup( + createFormData({ + email: "person@example.com", + company: "", + }), + { + subscribe: subscribeSpy.subscribe, + }, + ); + + expect(result).toEqual({ + status: "error", + message: "We could not subscribe that email right now. Please try again.", + }); + expect(subscribeSpy.calls).toEqual(["person@example.com"]); + }); +}); diff --git a/src/lib/newsletter/submit.ts b/src/lib/newsletter/submit.ts new file mode 100644 index 0000000..ec49fd4 --- /dev/null +++ b/src/lib/newsletter/submit.ts @@ -0,0 +1,111 @@ +import { newsletterSignupSchema } from "@/lib/schemas/newsletter"; +import type { TurnstileVerificationResult } from "@/lib/integrations/turnstile"; +import type { NewsletterSubscribeResult } from "@/lib/integrations/resend"; +import type { NewsletterRateLimitResult } from "@/lib/newsletter/rate-limit"; +import { NEWSLETTER_TURNSTILE_FIELD } from "@/lib/newsletter/turnstile"; +import { + NEWSLETTER_SUBMISSION_ID_FIELD, + type NewsletterFormState, +} from "@/lib/newsletter/types"; + +type NewsletterSubscribe = (email: string) => Promise; +type NewsletterRateLimit = () => Promise; +type NewsletterTurnstileVerify = ( + token: FormDataEntryValue | null, +) => Promise; + +type HandleNewsletterSignupOptions = { + rateLimit?: NewsletterRateLimit; + subscribe: NewsletterSubscribe; + turnstile?: NewsletterTurnstileVerify; +}; + +const SUBSCRIBED_MESSAGE = + "You have successfully subscribed to the Cortex update list."; +const ALREADY_SUBSCRIBED_MESSAGE = "Your email has already been submitted."; +const GENERIC_ERROR_MESSAGE = + "We could not subscribe that email right now. Please try again."; + +function getSubmissionId(formData: FormData): string | undefined { + const submissionId = formData.get(NEWSLETTER_SUBMISSION_ID_FIELD); + + return typeof submissionId === "string" && submissionId.trim() + ? submissionId.trim() + : undefined; +} + +export async function handleNewsletterSignup( + formData: FormData, + { rateLimit, subscribe, turnstile }: HandleNewsletterSignupOptions, +): Promise { + const submissionId = getSubmissionId(formData); + const withSubmissionId = ( + state: Omit, + ): NewsletterFormState => + submissionId ? { ...state, submissionId } : state; + const company = formData.get("company"); + + if (typeof company === "string" && company.trim()) { + return withSubmissionId({ + status: "success", + message: SUBSCRIBED_MESSAGE, + }); + } + + const parsed = newsletterSignupSchema.safeParse({ + email: formData.get("email"), + company: formData.get("company"), + }); + + if (!parsed.success) { + const errors = parsed.error.flatten().fieldErrors; + + return withSubmissionId({ + status: "error", + message: errors.email?.[0] || GENERIC_ERROR_MESSAGE, + emailError: errors.email?.[0], + }); + } + + const rateLimitResult = await rateLimit?.(); + + if (rateLimitResult && !rateLimitResult.ok) { + return withSubmissionId({ + status: "error", + message: rateLimitResult.message, + }); + } + + const turnstileResult = await turnstile?.( + formData.get(NEWSLETTER_TURNSTILE_FIELD), + ); + + if (turnstileResult && !turnstileResult.ok) { + return withSubmissionId({ + status: "error", + message: turnstileResult.message, + }); + } + + const result = await subscribe(parsed.data.email); + + if (!result.ok) { + const configurationMessage = + result.code === "configuration" + ? "Newsletter setup is missing required Resend environment variables." + : GENERIC_ERROR_MESSAGE; + + return withSubmissionId({ + status: "error", + message: configurationMessage, + }); + } + + return withSubmissionId({ + status: "success", + message: result.alreadySubscribed + ? ALREADY_SUBSCRIBED_MESSAGE + : SUBSCRIBED_MESSAGE, + subscribedEmail: parsed.data.email, + }); +} diff --git a/src/lib/newsletter/turnstile.ts b/src/lib/newsletter/turnstile.ts new file mode 100644 index 0000000..53e2b6e --- /dev/null +++ b/src/lib/newsletter/turnstile.ts @@ -0,0 +1,4 @@ +export const NEWSLETTER_TURNSTILE_FIELD = "cf-turnstile-response"; +export const NEWSLETTER_TURNSTILE_ACTION = "newsletter"; +export const NEWSLETTER_TURNSTILE_ERROR_MESSAGE = + "Verification failed. Please try again."; diff --git a/src/lib/newsletter/types.ts b/src/lib/newsletter/types.ts new file mode 100644 index 0000000..1cfdb54 --- /dev/null +++ b/src/lib/newsletter/types.ts @@ -0,0 +1,14 @@ +export type NewsletterFormState = { + status: "idle" | "success" | "error"; + message: string; + emailError?: string; + submissionId?: string; + subscribedEmail?: string; +}; + +export const NEWSLETTER_SUBMISSION_ID_FIELD = "newsletterSubmissionId"; + +export const newsletterInitialState = { + status: "idle", + message: "", +} satisfies NewsletterFormState; diff --git a/src/lib/schemas/newsletter.ts b/src/lib/schemas/newsletter.ts new file mode 100644 index 0000000..92964fa --- /dev/null +++ b/src/lib/schemas/newsletter.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +export const newsletterSignupSchema = z.object({ + email: z.preprocess( + (value) => (typeof value === "string" ? value : ""), + z + .string() + .trim() + .toLowerCase() + .min(1, "Enter an email address.") + .max(254, "Email address is too long.") + .pipe(z.email("Enter a valid email address.")), + ), + company: z.preprocess( + (value) => (typeof value === "string" ? value : ""), + z.string().trim(), + ), +}); + +export type NewsletterSignupInput = z.infer; diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..8c5628d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ ], "paths": { "@/*": ["./src/*"] - } + }, + "types": ["bun-types"] }, "include": [ "next-env.d.ts",