From 1f1d58c2ee5b2232a7ed6b2243cbd0f3216a5853 Mon Sep 17 00:00:00 2001 From: barckcode Date: Thu, 15 Oct 2026 19:59:14 +0200 Subject: [PATCH 1/3] feat(home): hero CTA to the platform signup instead of the inline form Owner feedback: the hero signup form took too much space. The hero now has a primary 'Create account' button to cloud.nan.builders/signup (carrying a valid ?plan= from the landing URL, legacy ?premium=1 -> plan=premium, resolved in SSR, no JS), a secondary 'Log in' button to cloud.nan.builders/ and one reassurance line. The models, footer and 404 CTAs link straight to the platform signup. Removes what only the form used: the hero form markup and script, the /api/signup Worker route (now a 404), lib/signupServer.ts, the no-JS 415 page, the client validation, their tests and i18n keys. lib/signup.ts keeps only the plan/link helpers used by hero, nav, pricing and the community page. Co-Authored-By: Claude Opus 5.5 --- i18n/en.json | 49 +--- i18n/es.json | 49 +--- src/components/nan/Footer.astro | 3 +- src/components/nan/home/Hero.astro | 185 ++----------- src/components/nan/home/Models.astro | 4 +- src/layouts/NanBase.astro | 2 +- src/layouts/NanPage.astro | 2 - src/lib/signup.ts | 261 +---------------- src/lib/signupNoJs.ts | 111 -------- src/lib/signupServer.ts | 99 ------- src/pages/404.astro | 3 +- src/pages/api/signup.ts | 72 ----- src/scripts/signup.ts | 159 ----------- src/tests/api/signup.test.ts | 338 ----------------------- src/tests/landing/heroCta.server.test.ts | 66 +++++ src/tests/landing/heroCta.test.ts | 112 ++++++++ src/tests/landing/openSignup.test.ts | 8 +- src/tests/landing/signupForm.test.ts | 154 ----------- src/tests/lib/middleware.test.ts | 4 +- src/tests/lib/signup.test.ts | 255 +---------------- 20 files changed, 241 insertions(+), 1695 deletions(-) delete mode 100644 src/lib/signupNoJs.ts delete mode 100644 src/lib/signupServer.ts delete mode 100644 src/pages/api/signup.ts delete mode 100644 src/scripts/signup.ts delete mode 100644 src/tests/api/signup.test.ts create mode 100644 src/tests/landing/heroCta.server.test.ts create mode 100644 src/tests/landing/heroCta.test.ts delete mode 100644 src/tests/landing/signupForm.test.ts diff --git a/i18n/en.json b/i18n/en.json index 752d97ed..69a4524b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -289,52 +289,9 @@ "line2": "limits.", "sub": "Don't run out of tokens. Ever.", "body": "NaN is the community of builders sharing GPUs to run AI open models. Build whatever you need, without paying per token. You can just do things.", - "spots": "Open signup", - "signupLine": "Create your free account. Every product unlocks with a plan, when you're ready.", - "labelFullName": "Full name", - "labelEmail": "Email", - "labelJobRole": "Role", - "jobRolePlaceholder": "Select your role", - "labelJobRoleOther": "Your role", - "labelXUrl": "X profile", - "labelLinkedinUrl": "LinkedIn profile", - "optional": "optional", - "requiredNote": "required", - "phFullName": "Ada Lovelace", - "phEmail": "you@email.dev", - "phJobRoleOther": "What you do", - "phXUrl": "https://x.com/you", - "phLinkedinUrl": "https://linkedin.com/in/you", - "jobRoles": { - "software_engineer": "Software engineer", - "ai_engineer": "AI engineer", - "data_scientist": "Data scientist", - "devops_sre": "DevOps / SRE", - "founder": "Founder", - "product": "Product", - "designer": "Designer", - "researcher": "Researcher", - "student": "Student", - "other": "Other" - }, - "submit": "Create account", - "sending": "Sending…", - "okTitle": "Check your inbox to confirm and sign in.", - "okText": "If the email doesn't arrive in a couple of minutes, check your spam folder.", - "errFullName": "Tell us your name (up to 120 characters).", - "errEmail": "That email does not look valid.", - "errJobRole": "Pick your role.", - "errJobRoleOther": "Tell us your role (up to 80 characters).", - "errXUrl": "Use your X profile link, like https://x.com/you.", - "errLinkedinUrl": "Use your LinkedIn profile link, like https://linkedin.com/in/you.", - "errFields": "Check the highlighted fields.", - "errNetwork": "The network failed, not you. Try again.", - "errGeneric": "Something broke on our side. Try again in a bit.", - "errRateLimited": "Too many attempts. Wait a few minutes and try again.", - "legalPre": "By creating an account you accept the", - "legalTerms": "Terms", - "legalAnd": "and the", - "legalPrivacy": "Privacy Policy" + "ctaPrimary": "Create account", + "ctaSecondary": "Log in", + "reassure": "Free account. Pay only for what you use: community, inference, workspaces or Spaces." }, "why": { "label": "what is nan", diff --git a/i18n/es.json b/i18n/es.json index 76a1879d..347b0c36 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -289,52 +289,9 @@ "line2": "límites.", "sub": "No te quedes sin tokens. Nunca.", "body": "NaN es la comunidad de builders que comparten GPUs para correr modelos de IA abiertos. Construye lo que necesites, sin pagar por token. You can just do things.", - "spots": "Registro abierto", - "signupLine": "Crea tu cuenta gratis. Cada producto se activa con un plan, cuando quieras.", - "labelFullName": "Nombre completo", - "labelEmail": "Email", - "labelJobRole": "Rol", - "jobRolePlaceholder": "Elige tu rol", - "labelJobRoleOther": "Tu rol", - "labelXUrl": "Perfil de X", - "labelLinkedinUrl": "Perfil de LinkedIn", - "optional": "opcional", - "requiredNote": "obligatorio", - "phFullName": "Ada Lovelace", - "phEmail": "tu@email.dev", - "phJobRoleOther": "A qué te dedicas", - "phXUrl": "https://x.com/tu-usuario", - "phLinkedinUrl": "https://linkedin.com/in/tu-usuario", - "jobRoles": { - "software_engineer": "Ingeniería de software", - "ai_engineer": "Ingeniería de IA", - "data_scientist": "Ciencia de datos", - "devops_sre": "DevOps / SRE", - "founder": "Fundador/a", - "product": "Producto", - "designer": "Diseño", - "researcher": "Investigación", - "student": "Estudiante", - "other": "Otro" - }, - "submit": "Crear cuenta", - "sending": "Enviando…", - "okTitle": "Revisa tu bandeja de entrada para confirmar y entrar.", - "okText": "El email llega en inglés. Si no aparece en un par de minutos, mira en spam.", - "errFullName": "Dinos tu nombre (hasta 120 caracteres).", - "errEmail": "Ese email no parece válido.", - "errJobRole": "Elige tu rol.", - "errJobRoleOther": "Dinos tu rol (hasta 80 caracteres).", - "errXUrl": "Usa el enlace a tu perfil de X, como https://x.com/tu-usuario.", - "errLinkedinUrl": "Usa el enlace a tu perfil de LinkedIn, como https://linkedin.com/in/tu-usuario.", - "errFields": "Revisa los campos marcados.", - "errNetwork": "Ha fallado la red, no tú. Prueba otra vez.", - "errGeneric": "Algo se ha roto por nuestro lado. Prueba en un rato.", - "errRateLimited": "Demasiados intentos. Espera unos minutos y vuelve a probar.", - "legalPre": "Al crear una cuenta aceptas los", - "legalTerms": "Términos", - "legalAnd": "y la", - "legalPrivacy": "Política de privacidad" + "ctaPrimary": "Crear cuenta", + "ctaSecondary": "Entrar", + "reassure": "Cuenta gratis. Pagas solo lo que uses: comunidad, inferencia, workspaces o Spaces." }, "why": { "label": "qué es nan", diff --git a/src/components/nan/Footer.astro b/src/components/nan/Footer.astro index 3cec2099..dce08996 100644 --- a/src/components/nan/Footer.astro +++ b/src/components/nan/Footer.astro @@ -1,5 +1,6 @@ --- import { getLang, useT } from '../../lib/i18n'; +import { signupUrl } from '../../lib/signup'; const lang = getLang(Astro.url); const t = useT(lang); @@ -16,7 +17,7 @@ const year = new Date().getFullYear(); {tt.cta1}
{tt.cta2} - {tt.go} → + {tt.go} → diff --git a/src/components/nan/home/Hero.astro b/src/components/nan/home/Hero.astro index 57289b43..19dd9858 100644 --- a/src/components/nan/home/Hero.astro +++ b/src/components/nan/home/Hero.astro @@ -1,38 +1,24 @@ --- // Hero del sistema definitivo. Ilustración dithered en canvas (blanco->violeta -// al hover), glitch en el H1 y el formulario de alta (data-signup), cableado -// por scripts/signup.ts con la validación compartida de lib/signup.ts. +// al hover), glitch en el H1 y dos botones hacia la plataforma: el alta vive en +// cloud.nan.builders, no aquí (decisión del owner, 2026-10-15: el formulario +// ocupaba demasiado). Sin JS propio: el ?plan= se resuelve en el SSR. import { getLang, useT } from '../../../lib/i18n'; -import { JOB_ROLES } from '../../../lib/signup'; +import { LOGIN_URL, heroSignupHref } from '../../../lib/signup'; const lang = getLang(Astro.url); const tt = useT(lang).hero; -const p = (path: string) => (lang === 'es' ? `/es${path}` : path); + +// ?plan= de la landing (y el ?premium=1 antiguo) viaja al alta de la plataforma. +const signupHref = heroSignupHref(Astro.url.search); // Las capas de glitch del H1 son pseudoelementos, así que su texto tiene que // viajar por CSS. Se lo pasamos como custom property para que siga al idioma // (antes iba hardcodeado en inglés y se veía sobre el titular español). const glitchText = `'${`${tt.line1}\\A ${tt.line2}`.replace(/'/g, "\\'")}'`; - -// Mensajes de estado del formulario: los lee scripts/signup.ts desde el
. -const signupMsgs = JSON.stringify({ - sending: tt.sending, - okTitle: tt.okTitle, - okText: tt.okText, - errFullName: tt.errFullName, - errEmail: tt.errEmail, - errJobRole: tt.errJobRole, - errJobRoleOther: tt.errJobRoleOther, - errXUrl: tt.errXUrl, - errLinkedinUrl: tt.errLinkedinUrl, - errFields: tt.errFields, - errRateLimited: tt.errRateLimited, - errNetwork: tt.errNetwork, - errGeneric: tt.errGeneric, -}); --- -
+
@@ -45,91 +31,14 @@ const signupMsgs = JSON.stringify({

{tt.body}

-

- {tt.spots} - {tt.signupLine} -

- - - - - - - - -

- +

+ {tt.reassure} +

@@ -214,24 +123,19 @@ const signupMsgs = JSON.stringify({ font-size: clamp(18px, 1.5vw, 22px); line-height: 1.5; max-width: 660px; - margin-bottom: 52px; + margin-bottom: 40px; } - .hero__lead { - display: flex; align-items: center; flex-wrap: wrap; - font-family: var(--font-sans); + .hero__ctas { display: flex; flex-wrap: wrap; gap: 14px; } + .hero__reassure { + display: flex; align-items: flex-start; gap: 10px; + margin-top: 20px; max-width: 660px; + font-family: var(--font-mono); font-size: 13px; line-height: 1.5; letter-spacing: 0.02em; color: var(--color-muted); - font-size: 15px; - margin-bottom: 22px; - } - .spots { - display: inline-flex; align-items: center; gap: 9px; - font-family: var(--font-sans); - font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; - color: var(--color-text); white-space: nowrap; margin-right: 12px; } .spots__dot { - width: 9px; height: 9px; background: var(--color-violet); border-radius: 50%; + flex: 0 0 auto; margin-top: 0.45em; + width: 8px; height: 8px; background: var(--color-violet); border-radius: 50%; box-shadow: 0 0 0 0 var(--pulse); animation: spots-pulse 2.2s infinite; } @keyframes spots-pulse { @@ -240,48 +144,6 @@ const signupMsgs = JSON.stringify({ 100% { box-shadow: 0 0 0 0 transparent; } } - .hero__form { display: flex; flex-direction: column; gap: 18px; align-items: flex-start; width: 100%; max-width: 640px; } - .hero__hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; } - - /* formulario de alta: rejilla de 2 columnas que pasa a 1 en móvil */ - .signup__grid { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 14px 16px; } - .signup__row { display: flex; flex-direction: column; gap: 6px; min-width: 0; } - .signup__row[hidden] { display: none; } - .signup__label { - font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; - color: var(--color-muted); - } - .signup__req { color: var(--color-violet-2); } - .signup__opt { color: #6a6a6e; } - .signup__input { width: 100%; min-width: 0; caret-color: var(--color-violet); } - .signup__input[aria-invalid='true'] { border-color: #ff6b6b; } - .signup__select { - appearance: none; cursor: pointer; padding-right: 40px; - background-image: linear-gradient(45deg, transparent 50%, var(--color-muted) 50%), - linear-gradient(135deg, var(--color-muted) 50%, transparent 50%); - background-position: calc(100% - 20px) 50%, calc(100% - 15px) 50%; - background-size: 5px 5px, 5px 5px; - background-repeat: no-repeat; - } - .signup__select:invalid { color: #6a6a6e; } - .signup__select option { background: var(--color-surface); color: var(--color-text); } - .signup__err { min-height: 0; font-family: var(--font-mono); font-size: 12px; color: #ff8a8a; } - .signup__err:empty { display: none; } - .signup__actions { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 20px; } - .signup__legal { max-width: 40ch; font-size: 12.5px; line-height: 1.5; color: var(--color-muted); } - .signup__legal a { color: var(--color-link); text-decoration: underline; text-decoration-color: var(--color-link-line); } - .signup[data-done='true'] .signup__grid, - .signup[data-done='true'] .signup__actions { opacity: 0.45; } - - .hero__status { - min-height: 1.2em; margin-top: 2px; - font-family: var(--font-mono); font-size: 12.5px; letter-spacing: 0.02em; - color: var(--color-violet-2); - } - .hero__status[data-state='error'] { color: #ff8a8a; } - .hero__status[data-state='ok'] { font-size: 14px; color: var(--color-text); } - .hero__status[data-state='ok'] strong { color: var(--color-violet-2); font-weight: 600; } - @media (max-width: 860px) { .hero__fx { opacity: 0.5; } .hero__col { transform: none; } @@ -293,7 +155,6 @@ const signupMsgs = JSON.stringify({ .hero { min-height: 0; padding-block: var(--space-12) var(--space-16); } .hero__title { white-space: normal; } .hero__title::before, .hero__title::after { display: none; } - .signup__grid { grid-template-columns: 1fr; } } diff --git a/src/components/nan/home/Models.astro b/src/components/nan/home/Models.astro index 1fd604a4..7bf4540e 100644 --- a/src/components/nan/home/Models.astro +++ b/src/components/nan/home/Models.astro @@ -1,11 +1,11 @@ --- import modelos from '../../../data/modelos.json'; import { getLang, useT } from '../../../lib/i18n'; +import { signupUrl } from '../../../lib/signup'; import { homeQuotaLabel } from '../../../lib/modelCatalog'; const lang = getLang(Astro.url); const tt = useT(lang).models; -const home = lang === 'es' ? '/es' : '/'; // la data es la fuente de verdad (ES); en EN traducimos la cuota al vuelo. // La funcion vive en modelCatalog.ts, donde un test la mira: aqui dentro no @@ -57,7 +57,7 @@ const total = modelos.categorias.reduce((n, c) => n + c.modelos.length, 0); diff --git a/src/layouts/NanBase.astro b/src/layouts/NanBase.astro index 6008efa2..5587c6a6 100644 --- a/src/layouts/NanBase.astro +++ b/src/layouts/NanBase.astro @@ -77,7 +77,7 @@ const ogLocale = locale === 'es' ? 'es_ES' : 'en_US'; * - `font-src` se queda en 'self': las fuentes van autohospedadas, no vienen * de Google. `style-src` no lista fonts.googleapis.com por lo mismo, pero * mantiene 'unsafe-inline' porque Astro emite estilos scoped en línea. - * - `connect-src 'self'` sigue: el formulario de alta llama a /api/signup. + * - `connect-src 'self'` sigue: los formularios de eventos llaman a /api. */ const csp = [ "default-src 'self'", diff --git a/src/layouts/NanPage.astro b/src/layouts/NanPage.astro index fc6207b5..eb6e4e26 100644 --- a/src/layouts/NanPage.astro +++ b/src/layouts/NanPage.astro @@ -40,13 +40,11 @@ const { title, description, noindex, nofollow, image, scale } = Astro.props; diff --git a/src/lib/signup.ts b/src/lib/signup.ts index 7afb84b5..49b9571a 100644 --- a/src/lib/signup.ts +++ b/src/lib/signup.ts @@ -1,86 +1,22 @@ /** - * Open signup: validation, normalization and response parsing shared by the - * hero form (scripts/signup.ts, in the browser) and the Worker route - * (pages/api/signup.ts, at the edge). + * Links to account creation on the NaN platform. * - * One implementation on purpose: the client and the server must accept - * exactly the same input, or a person sees an error they cannot understand. - * The rules mirror cloud-api's POST /api/auth/signup (see - * platform/cloud-api/docs/OPEN-SIGNUP.md §2.3), which stays the authority. - * - * No i18n and no DOM here, so it is cheap to ship to the browser and easy to - * test. + * Signup itself lives on cloud.nan.builders (profile form, magic link, + * checkout); the site only points there. `?plan=` preselects a product on the + * platform's /signup and mirrors its enum (platform/cloud-api/docs/OPEN-SIGNUP.md + * §2.3). A signed-in account landing on /signup is sent on to /welcome. */ export const SIGNUP_BASE_URL = 'https://cloud.nan.builders/signup'; - -export const JOB_ROLES = [ - 'software_engineer', - 'ai_engineer', - 'data_scientist', - 'devops_sre', - 'founder', - 'product', - 'designer', - 'researcher', - 'student', - 'other', -] as const; -export type JobRole = (typeof JOB_ROLES)[number]; +export const LOGIN_URL = 'https://cloud.nan.builders/'; export const PLANS = ['community', 'inference', 'premium', 'workspace', 'space'] as const; export type Plan = (typeof PLANS)[number]; -export const SIGNUP_FIELDS = ['fullName', 'email', 'jobRole', 'jobRoleOther', 'xUrl', 'linkedinUrl'] as const; -export type SignupField = (typeof SIGNUP_FIELDS)[number]; -export type FieldError = 'required' | 'invalid' | 'too_long'; -export type FieldErrors = Partial>; - -const FULL_NAME_MAX = 120; -const JOB_ROLE_OTHER_MAX = 80; -const EMAIL_MAX = 254; -const URL_MAX = 200; - -// Simple on purpose, no nested quantifiers (no catastrophic backtracking). -const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; -// RFC 2606 reserved names plus throwaway domains seen on the old form. -// Keep in sync with cloud-api's signup blocklist. -export const BLOCKED_EMAIL_DOMAINS: ReadonlySet = new Set([ - 'example.com', - 'example.net', - 'example.org', - 'test.com', -]); -export const BLOCKED_EMAIL_TLDS: ReadonlySet = new Set(['test', 'invalid', 'localhost', 'example']); -// eslint-disable-next-line no-control-regex -const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; - -export interface SignupInput { - fullName: string; - email: string; - jobRole: JobRole; - jobRoleOther: string; - xUrl: string; - linkedinUrl: string; - plan?: Plan; -} - -export type SignupValidation = - | { ok: true; input: SignupInput; honeypot: boolean } - | { ok: false; fields: FieldErrors }; - -export function isJobRole(v: unknown): v is JobRole { - return typeof v === 'string' && (JOB_ROLES as readonly string[]).includes(v); -} - export function isPlan(v: unknown): v is Plan { return typeof v === 'string' && (PLANS as readonly string[]).includes(v); } -export function isSignupField(v: unknown): v is SignupField { - return typeof v === 'string' && (SIGNUP_FIELDS as readonly string[]).includes(v); -} - /** Platform signup URL, with the plan the person picked (if any). */ export function signupUrl(plan?: Plan): string { return plan ? `${SIGNUP_BASE_URL}?plan=${plan}` : SIGNUP_BASE_URL; @@ -88,7 +24,8 @@ export function signupUrl(plan?: Plan): string { /** * Plan carried in the page URL. `?premium=1` is the old premium pricing card - * link, still out there in shared links and bookmarks. + * link, still out there in shared links and bookmarks. Anything outside the + * enum is dropped, so nothing from the query string reaches a link verbatim. */ export function planFromSearch(search: string): Plan | undefined { const params = new URLSearchParams(search); @@ -98,183 +35,7 @@ export function planFromSearch(search: string): Plan | undefined { return undefined; } -const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : ''); -const chars = (s: string): number => Array.from(s).length; - -function isBlockedEmailDomain(email: string): boolean { - const domain = email.slice(email.lastIndexOf('@') + 1); - if (BLOCKED_EMAIL_DOMAINS.has(domain)) return true; - return BLOCKED_EMAIL_TLDS.has(domain.slice(domain.lastIndexOf('.') + 1)); -} - -/** Trim, upgrade http to https and add the scheme people usually leave out. */ -export function normalizeSocialUrl(value: string): string { - const v = value.trim(); - if (!v) return ''; - if (/^http:\/\//i.test(v)) return `https://${v.slice(7)}`; - if (!/^[a-z][a-z0-9+.-]*:/i.test(v)) return `https://${v}`; - return v; -} - -function parseHttps(value: string): URL | null { - try { - const u = new URL(value); - if (u.protocol !== 'https:' || u.username || u.password || u.port) return null; - return u; - } catch { - return null; - } -} - -type UrlCheck = { ok: true; value: string } | { ok: false; error: FieldError }; - -/** - * X profile. The backend accepts `https://x.com/...` and - * `https://twitter.com/...` only, so `www.` is folded away here instead of - * bouncing a perfectly good link. - */ -function checkXUrl(raw: string): UrlCheck { - const u = parseHttps(raw); - if (!u) return { ok: false, error: 'invalid' }; - const host = u.hostname.toLowerCase().replace(/^(www|mobile)\./, ''); - if (host !== 'x.com' && host !== 'twitter.com') return { ok: false, error: 'invalid' }; - if (!/^\/[^/]+/.test(u.pathname)) return { ok: false, error: 'invalid' }; - return { ok: true, value: `https://${host}${u.pathname}${u.search}` }; -} - -/** - * LinkedIn profile: `https://(www.)linkedin.com/in/...`. Country subdomains - * (`es.linkedin.com`) are what LinkedIn itself hands out, so they become `www`. - */ -function checkLinkedinUrl(raw: string): UrlCheck { - const u = parseHttps(raw); - if (!u) return { ok: false, error: 'invalid' }; - const host = u.hostname.toLowerCase(); - if (host !== 'linkedin.com' && !/^[a-z]{2,3}\.linkedin\.com$/.test(host)) return { ok: false, error: 'invalid' }; - if (!/^\/in\/[^/]+/.test(u.pathname)) return { ok: false, error: 'invalid' }; - const path = u.pathname.replace(/\/$/, ''); - return { ok: true, value: `https://www.linkedin.com${path}${u.search}` }; -} - -function checkOptionalUrl(value: unknown, check: (raw: string) => UrlCheck): UrlCheck { - const v = normalizeSocialUrl(typeof value === 'string' ? value : ''); - if (!v) return { ok: true, value: '' }; - if (chars(v) > URL_MAX) return { ok: false, error: 'too_long' }; - return check(v); -} - -export function validateSignup(raw: unknown): SignupValidation { - const obj: Record = - raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; - const fields: FieldErrors = {}; - - const fullName = str(obj.fullName); - if (!fullName) fields.fullName = 'required'; - else if (chars(fullName) > FULL_NAME_MAX) fields.fullName = 'too_long'; - else if (CONTROL_CHARS.test(fullName)) fields.fullName = 'invalid'; - - const email = str(obj.email).toLowerCase(); - if (!email) fields.email = 'required'; - else if (email.length > EMAIL_MAX || !EMAIL_RE.test(email) || isBlockedEmailDomain(email)) fields.email = 'invalid'; - - const jobRoleRaw = str(obj.jobRole); - let jobRoleOther = ''; - if (!jobRoleRaw) fields.jobRole = 'required'; - else if (!isJobRole(jobRoleRaw)) fields.jobRole = 'invalid'; - else if (jobRoleRaw === 'other') { - jobRoleOther = str(obj.jobRoleOther); - if (!jobRoleOther) fields.jobRoleOther = 'required'; - else if (chars(jobRoleOther) > JOB_ROLE_OTHER_MAX) fields.jobRoleOther = 'too_long'; - else if (CONTROL_CHARS.test(jobRoleOther)) fields.jobRoleOther = 'invalid'; - } - - const x = checkOptionalUrl(obj.xUrl, checkXUrl); - if (!x.ok) fields.xUrl = x.error; - const li = checkOptionalUrl(obj.linkedinUrl, checkLinkedinUrl); - if (!li.ok) fields.linkedinUrl = li.error; - - if (Object.keys(fields).length > 0) return { ok: false, fields }; - - const input: SignupInput = { - fullName, - email, - jobRole: jobRoleRaw as JobRole, - jobRoleOther, - xUrl: x.ok ? x.value : '', - linkedinUrl: li.ok ? li.value : '', - }; - // A plan comes from the URL, not from the person: an unknown one is dropped, - // never an error they cannot fix. - if (isPlan(obj.plan)) input.plan = obj.plan; - - // Honeypot: flagged, not rejected, so a bot gets the same answer as a person. - const honeypot = str(obj._hp).length > 0; - return { ok: true, input, honeypot }; -} - -/** Keeps only known fields and known error codes from an untrusted body. */ -export function sanitizeFieldErrors(value: unknown): FieldErrors { - const out: FieldErrors = {}; - if (!value || typeof value !== 'object' || Array.isArray(value)) return out; - for (const [k, v] of Object.entries(value as Record)) { - if (!isSignupField(k)) continue; - out[k] = v === 'required' || v === 'too_long' ? v : 'invalid'; - } - return out; -} - -export type SignupResult = - | { ok: true } - | { ok: false; error: 'invalid_input'; fields: FieldErrors } - | { ok: false; error: 'rate_limited' | 'server_error' | 'network_error' }; - -/** Response of the same-origin /api/signup route, as the form sees it. */ -export function parseSignupResponse(status: number, body: unknown): SignupResult { - const data = body && typeof body === 'object' ? (body as Record) : null; - if (status === 400) return { ok: false, error: 'invalid_input', fields: sanitizeFieldErrors(data?.fields) }; - if (status === 429) return { ok: false, error: 'rate_limited' }; - if ((status === 202 || status === 200) && data?.ok === true) return { ok: true }; - return { ok: false, error: 'server_error' }; -} - -/** The strings the hero serializes into data-msgs. */ -export interface SignupMessages { - errFullName: string; - errEmail: string; - errJobRole: string; - errJobRoleOther: string; - errXUrl: string; - errLinkedinUrl: string; - errFields: string; - errRateLimited: string; - errNetwork: string; - errGeneric: string; -} - -const FIELD_MESSAGE: Record = { - fullName: 'errFullName', - email: 'errEmail', - jobRole: 'errJobRole', - jobRoleOther: 'errJobRoleOther', - xUrl: 'errXUrl', - linkedinUrl: 'errLinkedinUrl', -}; - -/** Message shown under one field. */ -export function fieldErrorText(field: SignupField, m: SignupMessages): string { - return m[FIELD_MESSAGE[field]]; -} - -/** Message for the form status line after a failed submit. */ -export function signupErrorText(result: Exclude, m: SignupMessages): string { - switch (result.error) { - case 'invalid_input': - return m.errFields; - case 'rate_limited': - return m.errRateLimited; - case 'network_error': - return m.errNetwork; - default: - return m.errGeneric; - } +/** The hero's Create account link, carrying the landing's ?plan= through. */ +export function heroSignupHref(search: string): string { + return signupUrl(planFromSearch(search)); } diff --git a/src/lib/signupNoJs.ts b/src/lib/signupNoJs.ts deleted file mode 100644 index 5a6e7fcf..00000000 --- a/src/lib/signupNoJs.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * The page a browser gets when the hero signup form is submitted without our - * script (blocked or failed). The form has method="post", so the data never - * lands in a URL; this page never reads the request body, so it cannot echo it. - * - * A redirect to cloud.nan.builders is not an option: the site's CSP has - * `form-action 'self'`, which also applies to redirects after a form post. - * So the page explains and links instead. Static HTML, no JS, own CSP. - */ -import { signupUrl } from './signup'; - -export type NoJsLang = 'en' | 'es'; - -/** - * Language of the page: the path the form was posted from (same origin only), - * then Accept-Language, then English. - */ -export function noJsLang(requestUrl: string, referer: string | null, acceptLanguage: string | null): NoJsLang { - if (referer) { - try { - const ref = new URL(referer); - if (ref.origin === new URL(requestUrl).origin) { - return ref.pathname === '/es' || ref.pathname.startsWith('/es/') ? 'es' : 'en'; - } - } catch { - // unusable Referer: fall through - } - } - // Highest-weighted language wins; ties keep header order. - const ranked = (acceptLanguage ?? '') - .split(',') - .map((part, i) => { - const [tag, ...params] = part.trim().toLowerCase().split(';'); - const q = params.map((p) => p.trim()).find((p) => p.startsWith('q=')); - const weight = q ? Number(q.slice(2)) : 1; - return { tag, weight: Number.isFinite(weight) ? weight : 0, i }; - }) - .filter((x) => x.tag && x.weight > 0) - .sort((a, b) => b.weight - a.weight || a.i - b.i); - for (const { tag } of ranked) { - if (tag === 'es' || tag.startsWith('es-')) return 'es'; - if (tag === 'en' || tag.startsWith('en-')) return 'en'; - } - return 'en'; -} - -const COPY: Record = { - en: { - title: 'Create your account — NaN', - heading: 'This form needs JavaScript.', - body: 'The signup form on nan.builders runs with JavaScript, and it did not load in your browser. Nothing you typed was saved. You can create your account directly on the NaN platform.', - cta: 'Create your account', - back: 'Back to nan.builders', - home: '/', - }, - es: { - title: 'Crea tu cuenta — NaN', - heading: 'Este formulario necesita JavaScript.', - body: 'El formulario de alta de nan.builders funciona con JavaScript y no se ha cargado en tu navegador. No se ha guardado nada de lo que escribiste. Puedes crear tu cuenta directamente en la plataforma de NaN.', - cta: 'Crea tu cuenta', - back: 'Volver a nan.builders', - home: '/es', - }, -}; - -export function noJsPage(lang: NoJsLang): string { - const c = COPY[lang]; - return ` - - - - - - -${c.title} - - - -
-

nan.builders

-

${c.heading}

-

${c.body}

- -
- - -`; -} diff --git a/src/lib/signupServer.ts b/src/lib/signupServer.ts deleted file mode 100644 index d13be1a9..00000000 --- a/src/lib/signupServer.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Server side of the open signup (Worker only, never shipped to the browser): - * per-IP rate limit and the call to cloud-api's public POST /api/auth/signup. - * - * The validation itself lives in lib/signup.ts, shared with the form. - */ -import { sanitizeFieldErrors, type FieldErrors, type SignupInput } from './signup'; - -/** - * Per-IP fixed window. In-memory and per isolate, so it is best effort: it - * stops one browser tab or a naive script from hammering the endpoint, while - * cloud-api enforces its own per-IP and per-email limits behind it. - */ -export const SIGNUP_RATE_LIMIT = { limit: 5, windowMs: 10 * 60_000 } as const; - -const windows = new Map(); - -export function checkSignupRateLimit(ip: string, now = Date.now()): boolean { - if (!ip) return true; // cannot identify the client: the backend still limits - const w = windows.get(ip); - if (!w || w.resetAt <= now) { - windows.set(ip, { count: 1, resetAt: now + SIGNUP_RATE_LIMIT.windowMs }); - if (windows.size > 5000) { - for (const [k, v] of windows) if (v.resetAt <= now) windows.delete(k); - } - return true; - } - if (w.count >= SIGNUP_RATE_LIMIT.limit) return false; - w.count++; - return true; -} - -/** The media type of a Content-Type header, without parameters, lowercased. */ -export function mediaType(header: string | null): string { - return (header ?? '').split(';')[0].trim().toLowerCase(); -} - -/** Test hook. */ -export function resetSignupRateLimit(): void { - windows.clear(); -} - -export type UpstreamOutcome = - | { kind: 'accepted' } - | { kind: 'invalid'; fields: FieldErrors } - | { kind: 'rate_limited' } - | { kind: 'error' }; - -/** - * Forwards a validated signup. Public endpoint: no API key. The Origin is the - * one cloud-api allowlists for the site, and the client IP is propagated so - * the backend's per-IP limit sees the person and not the Worker. The visitor - * country goes along so the backend can pick the billing region. - */ -export interface ClientInfo { - ip: string; - /** Cloudflare's `cf-ipcountry`; cloud-api derives the billing region from it. */ - country: string; -} - -/** Two-letter country code as Cloudflare sends it (`ES`, `XX`, `T1`), or ''. */ -export function clientCountry(value: string | null): string { - const v = (value ?? '').trim().toUpperCase(); - return /^[A-Z0-9]{2}$/.test(v) ? v : ''; -} - -export async function forwardSignup(apiBase: string, input: SignupInput, client: ClientInfo): Promise { - const headers = new Headers({ 'content-type': 'application/json', origin: 'https://nan.builders' }); - if (client.ip) { - headers.set('x-forwarded-for', client.ip); - headers.set('cf-connecting-ip', client.ip); - } - if (client.country) headers.set('cf-ipcountry', client.country); - - let res: Response; - try { - if (!apiBase) throw new Error('CLOUD_API_URL is not set'); - res = await fetch(`${apiBase.replace(/\/$/, '')}/api/auth/signup`, { - method: 'POST', - headers, - body: JSON.stringify({ ...input, _hp: '' }), - }); - } catch (err) { - console.error('[api/signup] upstream unreachable', err instanceof Error ? err.message : 'error'); - return { kind: 'error' }; - } - - // Every success is the same answer: the backend never tells new from known. - if (res.ok) return { kind: 'accepted' }; - if (res.status === 429) return { kind: 'rate_limited' }; - if (res.status === 400) { - const body = (await res.json().catch(() => null)) as Record | null; - const fields = sanitizeFieldErrors(body?.fields); - if (typeof body?.field === 'string') Object.assign(fields, sanitizeFieldErrors({ [body.field]: 'invalid' })); - return { kind: 'invalid', fields }; - } - console.error('[api/signup] upstream status', res.status); - return { kind: 'error' }; -} diff --git a/src/pages/404.astro b/src/pages/404.astro index 56167fbf..11968d88 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -10,6 +10,7 @@ */ import NanPage from '../layouts/NanPage.astro'; import { getLang, useT } from '../lib/i18n'; +import { signupUrl } from '../lib/signup'; const lang = getLang(Astro.url); const t = useT(lang); @@ -27,7 +28,7 @@ const home = lang === 'es' ? '/es' : '/';

{tt.body}

> NaN === NaN // false

diff --git a/src/pages/api/signup.ts b/src/pages/api/signup.ts deleted file mode 100644 index fe0a21b1..00000000 --- a/src/pages/api/signup.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { APIRoute } from 'astro'; -import { env } from 'cloudflare:workers'; -import { json } from '../../lib/apiResponse'; -import { validateSignup } from '../../lib/signup'; -import { noJsLang, noJsPage } from '../../lib/signupNoJs'; -import { checkSignupRateLimit, clientCountry, forwardSignup, mediaType } from '../../lib/signupServer'; - -export const prerender = false; - -// The form posts here and not to cloud-api directly because the site's CSP is -// `connect-src 'self'`. This route validates with the same rules as the form, -// rate limits per IP, drops honeypot hits, and proxies to the public -// POST /api/auth/signup (platform/cloud-api/docs/OPEN-SIGNUP.md §2.3), which -// sends the English magic link. - -const MAX_BODY_BYTES = 8 * 1024; - -const accepted = () => json({ ok: true }, 202); - -// What a browser sends when the form is submitted without our script. -const FORM_TYPES = new Set(['application/x-www-form-urlencoded', 'multipart/form-data']); - -export const POST: APIRoute = async ({ request }) => { - const type = mediaType(request.headers.get('content-type')); - if (FORM_TYPES.has(type)) { - // No JS (script blocked or failed): the hero form has method="post" so the - // data never lands in a URL. The body is never read nor echoed; the browser - // gets a small page in the visitor's language linking to the platform - // signup (see lib/signupNoJs.ts for why it is not a redirect). 415 stays: - // it is what happened, and browsers render the page all the same. - const lang = noJsLang(request.url, request.headers.get('referer'), request.headers.get('accept-language')); - return new Response(noJsPage(lang), { - status: 415, - headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, - }); - } - if (type !== 'application/json') { - return json({ ok: false, error: 'unsupported_media_type' }, 415); - } - - const text = await request.text(); - if (text.length > MAX_BODY_BYTES) return json({ ok: false, error: 'payload_too_large' }, 413); - - let raw: unknown; - try { - raw = JSON.parse(text); - } catch { - return json({ ok: false, error: 'invalid_input', fields: {} }, 400); - } - - const v = validateSignup(raw); - if (!v.ok) return json({ ok: false, error: 'invalid_input', fields: v.fields }, 400); - - // Same answer as a real signup, so a bot learns nothing. - if (v.honeypot) return accepted(); - - const ip = request.headers.get('cf-connecting-ip') ?? ''; - if (!checkSignupRateLimit(ip)) return json({ ok: false, error: 'rate_limited' }, 429); - - const country = clientCountry(request.headers.get('cf-ipcountry')); - const out = await forwardSignup(env.CLOUD_API_URL, v.input, { ip, country }); - switch (out.kind) { - case 'accepted': - return accepted(); - case 'invalid': - return json({ ok: false, error: 'invalid_input', fields: out.fields }, 400); - case 'rate_limited': - return json({ ok: false, error: 'rate_limited' }, 429); - default: - return json({ ok: false, error: 'server_error' }, 502); - } -}; diff --git a/src/scripts/signup.ts b/src/scripts/signup.ts deleted file mode 100644 index 306cc750..00000000 --- a/src/scripts/signup.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * NaN: the home signup form. Wires every
. - * POST /api/signup → src/pages/api/signup.ts → cloud-api /api/auth/signup - * - * Validation, normalization and response parsing come from lib/signup.ts, the - * same module the Worker route uses, so the browser and the edge accept - * exactly the same input. The strings come from the component (data-msgs), - * so the i18n dictionary is not shipped to the client. - */ -import { - SIGNUP_FIELDS, - fieldErrorText, - parseSignupResponse, - planFromSearch, - signupErrorText, - validateSignup, - type FieldErrors, - type SignupMessages, -} from '../lib/signup'; - -interface Msgs extends SignupMessages { - sending: string; - okTitle: string; - okText: string; -} - -const FALLBACK: Msgs = { - sending: 'Sending…', - okTitle: 'Check your inbox to confirm and sign in.', - okText: "If it doesn't arrive in a couple of minutes, check your spam folder.", - errFullName: 'Tell us your name.', - errEmail: 'That email does not look valid.', - errJobRole: 'Pick your role.', - errJobRoleOther: 'Tell us your role.', - errXUrl: 'Use your profile link, like https://x.com/you.', - errLinkedinUrl: 'Use your profile link, like https://linkedin.com/in/you.', - errFields: 'Check the highlighted fields.', - errRateLimited: 'Too many attempts. Wait a few minutes.', - errNetwork: 'The network failed, not you. Try again.', - errGeneric: 'Something broke on our side. Try again in a bit.', -}; - -function readMsgs(form: HTMLFormElement): Msgs { - try { - return { ...FALLBACK, ...JSON.parse(form.dataset.msgs ?? '{}') }; - } catch { - return FALLBACK; - } -} - -function wire(form: HTMLFormElement): void { - const status = form.querySelector('[data-status]'); - const submit = form.querySelector('[data-submit]'); - if (!status || !submit) return; - const t = readMsgs(form); - - // Typed as inputs: the select shares every member used here (value, - // required, focus, attributes). - const control = (name: string) => form.querySelector(`[name="${name}"]`); - const role = control('jobRole'); - const otherWrap = form.querySelector('[data-other]'); - - // "Other" reveals its free-text field; any other role hides and clears it. - const syncOther = () => { - if (!otherWrap || !role) return; - const show = role.value === 'other'; - otherWrap.hidden = !show; - const input = control('jobRoleOther'); - if (input) { - input.required = show; - if (!show) input.value = ''; - } - }; - role?.addEventListener('change', syncOther); - syncOther(); - - const clearErrors = () => { - for (const name of SIGNUP_FIELDS) { - control(name)?.removeAttribute('aria-invalid'); - const err = form.querySelector(`[data-error="${name}"]`); - if (err) err.textContent = ''; - } - }; - - /** Marks each failing field, writes its message, focuses the first one. */ - const showFieldErrors = (fields: FieldErrors) => { - let first: HTMLElement | null = null; - for (const name of SIGNUP_FIELDS) { - if (!fields[name]) continue; - const el = control(name); - el?.setAttribute('aria-invalid', 'true'); - const err = form.querySelector(`[data-error="${name}"]`); - if (err) err.textContent = fieldErrorText(name, t); - if (!first && el) first = el; - } - (first as HTMLElement | null)?.focus(); - }; - - const fail = (msg: string) => { - status.textContent = msg; - status.dataset.state = 'error'; - submit.disabled = false; - }; - - form.addEventListener('submit', async (e) => { - e.preventDefault(); - clearErrors(); - const data = new FormData(form); - const raw: Record = {}; - for (const name of [...SIGNUP_FIELDS, '_hp']) raw[name] = String(data.get(name) ?? ''); - const plan = planFromSearch(window.location.search); - if (plan) raw.plan = plan; - - const v = validateSignup(raw); - if (!v.ok) { - showFieldErrors(v.fields); - fail(t.errFields); - return; - } - - submit.disabled = true; - status.dataset.state = ''; - status.textContent = t.sending; - - let res: Response; - try { - res = await fetch('/api/signup', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - // the normalized values, plus the honeypot exactly as it was filled - body: JSON.stringify({ ...v.input, _hp: raw._hp }), - }); - } catch { - fail(t.errNetwork); - return; - } - - const body = await res.json().catch(() => null); - const result = parseSignupResponse(res.status, body); - if (!result.ok) { - if (result.error === 'invalid_input') showFieldErrors(result.fields); - fail(signupErrorText(result, t)); - return; - } - - form.querySelectorAll('input, select, button').forEach((el) => (el.disabled = true)); - form.dataset.done = 'true'; - status.dataset.state = 'ok'; - status.textContent = ''; - const strong = document.createElement('strong'); - strong.textContent = t.okTitle; - status.appendChild(strong); - status.appendChild(document.createTextNode(` ${t.okText}`)); - }); -} - -export function initSignup(): void { - document.querySelectorAll('form[data-signup]').forEach(wire); -} diff --git a/src/tests/api/signup.test.ts b/src/tests/api/signup.test.ts deleted file mode 100644 index 039f6d7d..00000000 --- a/src/tests/api/signup.test.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// Lives in src/tests/api/ (not src/pages/api/) so Astro does not bundle it as a route. - -vi.mock('cloudflare:workers', () => ({ - env: { CLOUD_API_URL: 'https://cloud-api.nan.builders/' }, -})); - -import { POST } from '../../pages/api/signup'; -import { resetSignupRateLimit, SIGNUP_RATE_LIMIT } from '../../lib/signupServer'; - -const UPSTREAM = 'https://cloud-api.nan.builders/api/auth/signup'; - -const valid = { - fullName: 'Ada Lovelace', - email: 'ada@helmcode.com', - jobRole: 'ai_engineer', - jobRoleOther: '', - xUrl: 'https://x.com/ada', - linkedinUrl: '', - plan: 'premium', - _hp: '', -}; - -let ipCounter = 0; -const nextIp = () => `10.0.${(++ipCounter >> 8) & 255}.${ipCounter & 255}`; - -function request( - body: unknown, - opts: { ip?: string; contentType?: string; raw?: string; headers?: Record } = {}, -) { - return new Request('https://nan.builders/api/signup', { - method: 'POST', - headers: { - 'content-type': opts.contentType ?? 'application/json', - 'cf-connecting-ip': opts.ip ?? nextIp(), - ...opts.headers, - }, - body: opts.raw ?? JSON.stringify(body), - }); -} - -async function call(body: unknown, opts: Parameters[1] = {}) { - const res: Response = await (POST as any)({ request: request(body, opts) }); - const text = await res.text(); - return { status: res.status, body: text ? JSON.parse(text) : null, headers: res.headers, text }; -} - -function upstream(status: number, body: unknown = {}) { - return vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })); -} - -beforeEach(() => { - resetSignupRateLimit(); -}); - -afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -describe('POST /api/signup: proxy', () => { - it('forwards a valid signup to cloud-api /api/auth/signup and answers 202', async () => { - const fetchMock = upstream(202, { ok: true }); - vi.stubGlobal('fetch', fetchMock); - - const r = await call(valid, { ip: '203.0.113.7', headers: { 'cf-ipcountry': 'ES' } }); - - expect(r.status).toBe(202); - expect(r.body).toEqual({ ok: true }); - expect(r.headers.get('cache-control')).toBe('no-store'); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe(UPSTREAM); - expect(init.method).toBe('POST'); - const h = new Headers(init.headers); - expect(h.get('origin')).toBe('https://nan.builders'); - expect(h.get('content-type')).toBe('application/json'); - expect(h.get('x-forwarded-for')).toBe('203.0.113.7'); - expect(h.get('cf-connecting-ip')).toBe('203.0.113.7'); - // the backend derives the billing region from the visitor country - expect(h.get('cf-ipcountry')).toBe('ES'); - // public endpoint: no API key of any kind - expect(h.get('x-api-key')).toBeNull(); - expect(h.get('authorization')).toBeNull(); - expect(JSON.parse(init.body)).toEqual({ - fullName: 'Ada Lovelace', - email: 'ada@helmcode.com', - jobRole: 'ai_engineer', - jobRoleOther: '', - xUrl: 'https://x.com/ada', - linkedinUrl: '', - plan: 'premium', - _hp: '', - }); - }); - - it('forwards the normalized values, not the raw ones', async () => { - const fetchMock = upstream(202); - vi.stubGlobal('fetch', fetchMock); - await call({ ...valid, email: ' ADA@Helmcode.com', xUrl: 'x.com/ada', plan: 'gold' }); - const sent = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(sent.email).toBe('ada@helmcode.com'); - expect(sent.xUrl).toBe('https://x.com/ada'); - expect(sent).not.toHaveProperty('plan'); - }); - - it('any 2xx upstream is the same 202 (no account enumeration)', async () => { - vi.stubGlobal('fetch', upstream(200, { created: false, state: 'subscribed' })); - const r = await call(valid); - expect(r.status).toBe(202); - expect(r.body).toEqual({ ok: true }); - }); - - it('upstream 400 becomes a 400 with sanitized field errors', async () => { - vi.stubGlobal('fetch', upstream(400, { error: 'invalid_input', fields: { email: 'invalid', secret: 'x' } })); - const r = await call(valid); - expect(r.status).toBe(400); - expect(r.body).toEqual({ ok: false, error: 'invalid_input', fields: { email: 'invalid' } }); - }); - - it('upstream 400 naming a single field is understood too', async () => { - vi.stubGlobal('fetch', upstream(400, { error: 'invalid_email', field: 'email' })); - const r = await call(valid); - expect(r.body).toEqual({ ok: false, error: 'invalid_input', fields: { email: 'invalid' } }); - }); - - it('upstream 429 is a 429 rate_limited', async () => { - vi.stubGlobal('fetch', upstream(429, { error: 'too many' })); - const r = await call(valid); - expect(r.status).toBe(429); - expect(r.body).toEqual({ ok: false, error: 'rate_limited' }); - }); - - it('upstream 5xx / 404 / 410 is an opaque 502', async () => { - for (const s of [500, 503, 404, 410]) { - vi.stubGlobal('fetch', upstream(s, { error: 'internal detail' })); - const r = await call(valid); - expect(r.status, String(s)).toBe(502); - expect(r.body).toEqual({ ok: false, error: 'server_error' }); - } - }); - - it('a network failure is an opaque 502', async () => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNRESET at 10.0.0.1'))); - vi.spyOn(console, 'error').mockImplementation(() => {}); - const r = await call(valid); - expect(r.status).toBe(502); - expect(r.body).toEqual({ ok: false, error: 'server_error' }); - }); -}); - -describe('POST /api/signup: validation (mirrors the backend)', () => { - it('rejects invalid input with 400 and never calls upstream', async () => { - const fetchMock = upstream(202); - vi.stubGlobal('fetch', fetchMock); - const r = await call({ ...valid, fullName: '', email: 'nope', jobRole: 'other', jobRoleOther: '', linkedinUrl: 'https://evil.com/in/a' }); - expect(r.status).toBe(400); - expect(r.body).toEqual({ - ok: false, - error: 'invalid_input', - fields: { fullName: 'required', email: 'invalid', jobRoleOther: 'required', linkedinUrl: 'invalid' }, - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('does not forward a malformed cf-ipcountry', async () => { - const fetchMock = upstream(202); - vi.stubGlobal('fetch', fetchMock); - await call(valid, { headers: { 'cf-ipcountry': 'ES\r\nx-evil: 1' } }).catch(() => null); - await call(valid, { headers: { 'cf-ipcountry': 'spain' } }); - for (const [, init] of fetchMock.mock.calls) { - expect(new Headers(init.headers).get('cf-ipcountry')).toBeNull(); - } - }); - - it('accepts application/json with parameters, matched exactly on the media type', async () => { - const fetchMock = upstream(202); - vi.stubGlobal('fetch', fetchMock); - expect((await call(valid, { contentType: 'Application/JSON; charset=utf-8' })).status).toBe(202); - for (const ct of ['application/jsonx', 'text/plain; x=application/json', 'application/json-patch+json']) { - expect((await call(valid, { contentType: ct })).status, ct).toBe(415); - } - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - describe('no-JS form post (urlencoded or multipart)', () => { - const body = 'fullName=Ada+Secret&email=ada.secret%40helmcode.com&_hp='; - - async function post(ct: string, headers: Record = {}) { - const res: Response = await (POST as any)({ request: request(null, { contentType: ct, raw: body, headers }) }); - return { res, html: await res.text() }; - } - - it('answers a 415 HTML page, no JS, linking to the platform signup, without reading or echoing the body', async () => { - const fetchMock = upstream(202); - vi.stubGlobal('fetch', fetchMock); - for (const ct of ['application/x-www-form-urlencoded', 'multipart/form-data; boundary=x']) { - const { res, html } = await post(ct); - expect(res.status, ct).toBe(415); - expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8'); - expect(res.headers.get('cache-control')).toBe('no-store'); - expect(res.headers.get('location')).toBeNull(); - expect(html).toMatch(/^/i); - expect(html).toContain('href="https://cloud.nan.builders/signup"'); - expect(html).not.toMatch(/