diff --git a/.env.example b/.env.example index b190ac66..da0e14e1 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,7 @@ # cloud-api backend (Go service) # Default is the production endpoint; override if running cloud-api locally. CLOUD_API_URL=https://cloud-api.nan.builders -# Public signup (/api/signup) proxies to ${CLOUD_API_URL}/api/auth/signup. -# No API key: the endpoint is public and allowlists the nan.builders Origin. +# Used by the events pages (SSR + /api/events proxy) and the events login. # Per-key rate limits shown in the docs and served to the Discord bot. # Not secrets: the deployed values live in wrangler.jsonc under `vars`, and diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6d8d2089..c8e5d154 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,18 +7,18 @@ NaN is a shared GPU infrastructure project for a paid community. The website covers: -1. **Landing** — Presentation, models, pricing, and account signup (open signup) +1. **Landing** — Presentation, models, pricing, and links to account creation (open signup) 2. **Documentation** — Public API docs for connecting tools (Cursor, Cline, OpenCode, etc.) All community activity (chat, forums, events, model voting) lives in Discord. -Account creation is open: anyone can create a free account from the home form, and every product (community, inference, premium, workspaces, spaces) unlocks with a purchase on the [NaN Platform](https://cloud.nan.builders/), which also handles payment, onboarding and API keys. Spec: `platform/cloud-api/docs/OPEN-SIGNUP.md`. +Account creation is open: anyone can create a free account on cloud.nan.builders (the site only links there: hero, nav, pricing, footer), and every product (community, inference, premium, workspaces, spaces) unlocks with a purchase on the [NaN Platform](https://cloud.nan.builders/), which also handles payment, onboarding and API keys. Spec: `platform/cloud-api/docs/OPEN-SIGNUP.md`. ## Tech Stack | Layer | Technology | Why | |-------|-----------|-----| | Framework | Astro v6.0.8 (SSR mode, `output: 'server'`) | Static by default, SSR where needed | -| Interactive bits | Vanilla TS scripts (home, nav, signup form); Preact islands only where needed (events) | Zero framework JS on the marketing pages | +| Interactive bits | Vanilla TS scripts (home effects, nav); Preact islands only where needed (events) | Zero framework JS on the marketing pages | | Styling | Tailwind CSS v4.2.2 (Vite plugin) | Dark theme, technical typography | | Code highlighting | rehype-pretty-code + shiki | Syntax highlighting in docs | | Deployment | Cloudflare Workers (static assets + SSR) | Assets from CDN, SSR/API from Workers edge | @@ -40,7 +40,6 @@ nan.builders (Cloudflare Worker) │ └── Static assets (CSS, JS, fonts, images, favicon) │ └── Worker (Edge Functions) - ├── /api/signup POST — account signup (validates, proxies to cloud-api /api/auth/signup) ├── /api/auth/login-request POST — magic-link login from the events pages (proxy) ├── /api/events/* events API proxy ├── /api/docs/* docs manifest + markdown for the Discord bot @@ -56,10 +55,9 @@ nan.builders (Cloudflare Worker) | Route | Mode | Client JS | |-------|------|-----------| -| `/`, `/es` | SSR (not prerendered) | Vanilla scripts: hero dither, signup form (`scripts/signup.ts`), reveal | +| `/`, `/es` | SSR (not prerendered) | Vanilla scripts: hero dither, reveal (the hero CTA's `?plan=` is resolved in SSR, no JS) | | `/community`, `/es/community` | SSR (not prerendered) | None beyond the shared shell (CTA links to the platform signup) | | `/docs*` | SSR (not prerendered) | Minimal (sidebar mobile menu, TOC, copy-to-clipboard) | -| `/api/signup` | Server endpoint | None | ## Pricing (Current) @@ -105,7 +103,7 @@ Static sections, SSR-rendered (`src/pages/_home.astro`, components in `src/compo | Section | Component | Client JS | |---------|-----------|-----------| | Nav (Login + Create account) | `nan/Nav.astro` | Small inline script (mobile menu) | -| Hero + signup form (`#signup`) | `Hero.astro` | `scripts/signup.ts` (vanilla) + canvas dither | +| Hero: Create account + Log in buttons | `Hero.astro` | Canvas dither only | | What is NaN | `WhyNan.astro` | None | | Built with NaN | `BuiltWithNan.astro` | None | | Models | `Models.astro` | None | @@ -130,25 +128,9 @@ Sidebar navigation comes from each guide's frontmatter (`src/content.config.ts`) ### API Endpoints -#### `POST /api/signup` - -Creates a NaN account through cloud-api's public `POST /api/auth/signup` (`OPEN-SIGNUP.md` §2.3). The browser cannot call cloud-api directly because the CSP is `connect-src 'self'`. - -- **Input (JSON):** `{ fullName, email, jobRole, jobRoleOther, xUrl, linkedinUrl, plan?, _hp }` -- **Validation** (`validateSignup()` in `src/lib/signup.ts`, shared with the form): - - `fullName` 1-120 chars, `email` valid and not on the blocked-domain list, `jobRole` in `software_engineer | ai_engineer | data_scientist | devops_sre | founder | product | designer | researcher | student | other`; `jobRoleOther` 1-80 chars, required only when `jobRole` is `other`. - - `xUrl` optional, `https://x.com/...` or `https://twitter.com/...`; `linkedinUrl` optional, `https://(www.)linkedin.com/in/...`. Missing scheme, `http://`, `www.` and LinkedIn country subdomains are normalized before sending. - - `plan` optional (`community | inference | premium | workspace | space`); an unknown plan is dropped, never an error. The form reads it from `?plan=` (and the legacy `?premium=1`). -- **Honeypot:** `_hp` filled → same `202` as a real signup, nothing forwarded. -- **Rate limit:** 5 valid submissions per IP per 10 minutes, in memory per isolate (best effort; cloud-api limits per IP and per email behind it). Invalid submissions do not count. -- **Upstream call:** `POST ${CLOUD_API_URL}/api/auth/signup` with `Origin: https://nan.builders`, the client IP in `x-forwarded-for` / `cf-connecting-ip`, and the visitor country in `cf-ipcountry` (cloud-api derives the billing region from it). No API key: the endpoint is public. -- **Content type:** the media type must be exactly `application/json` (parameters such as `charset` allowed). The hero form has `method="post" action="/api/signup"`, so without JS the browser posts form data instead of putting it in a URL; that post gets a `415` static HTML page (dark, no JS, own CSP; Spanish when posted from `/es` or, without a same-origin Referer, when Accept-Language prefers it) linking to `https://cloud.nan.builders/signup`, and the body is never read (`src/lib/signupNoJs.ts`). -- **Responses:** - - `202`: `{ ok: true }` for every accepted signup, new or known email (no account enumeration). The form shows "Check your inbox to confirm and sign in"; cloud-api sends the English magic link. - - `400`: `{ ok: false, error: 'invalid_input', fields: { : 'required' | 'invalid' | 'too_long' } }` - - `413` / `415`: oversized body / not `application/json` (`{ ok: false, error: 'unsupported_media_type' }`, or the HTML page for a no-JS form post) - - `429`: `{ ok: false, error: 'rate_limited' }` - - `502`: `{ ok: false, error: 'server_error' }` (upstream error or unreachable; details only in the Worker log) +#### Account creation + +There is no signup route on the site. The hero's **Create account** button links to `https://cloud.nan.builders/signup`, carrying the landing's `?plan=` when it is one of `community | inference | premium | workspace | space` (the legacy `?premium=1` maps to `plan=premium`; anything else is dropped). **Log in** links to `https://cloud.nan.builders/`. Models, footer and 404 CTAs link to the bare signup; pricing cards add their plan. Links are built with `signupUrl()` / `heroSignupHref()` in `src/lib/signup.ts`. The platform collects the profile, sends the magic link and runs checkout. ## Internationalization @@ -159,7 +141,7 @@ The site is bilingual: English at the root (`/`) and Spanish under `/es`. Catalo | Mechanism | Implementation | |-----------|---------------| | CSP | Strict policy in `NanBase.astro` (`connect-src 'self'`, no external scripts, no frames, no objects) | -| Bot protection | Honeypot field (`_hp`) on the signup form + per-IP rate limiting at the edge + cloud-api per-IP/per-email limits + Origin allowlist | +| Bot protection | No public write form on the marketing pages; signup lives on cloud.nan.builders (cloud-api per-IP/per-email limits). Events forms go through `/api/events/*` | | IP detection | `cf-connecting-ip` only (never `x-forwarded-for` on Cloudflare) | | Secrets | All in Cloudflare Pages dashboard via `wrangler secret put`, never in code | | Error handling | Intentionally opaque error responses (no internal details leaked) | @@ -174,7 +156,6 @@ website/ │ │ ├── _community.astro, community.astro # Community page (CTA to platform signup) │ │ ├── privacy.astro, terms.astro, cookies.astro # Legal (+ es/ versions) │ │ ├── api/ -│ │ │ ├── signup.ts # POST — account signup proxy │ │ │ ├── auth/login-request.ts # POST — magic-link login proxy (events) │ │ │ ├── events/[...path].ts # Events API proxy │ │ │ └── docs/ # Docs manifest + markdown @@ -183,10 +164,9 @@ website/ │ │ ├── nan/ # Nav, Footer, CookieBanner, home/ sections │ │ ├── docs/ # Docs shell components │ │ └── events/ # Events islands (Preact) -│ ├── scripts/ # Vanilla client scripts (signup, reveal, dither, ...) +│ ├── scripts/ # Vanilla client scripts (reveal, dither, ...) │ ├── lib/ -│ │ ├── signup.ts # Signup validation/normalization/parsing (client + server) -│ │ ├── signupServer.ts # Edge rate limit + cloud-api call (server only) +│ │ ├── signup.ts # Links to the platform signup (plans, ?plan= carry-through) │ │ ├── i18n.ts # Translation helpers │ │ └── ... # docs, events, rate limits, models │ ├── layouts/ # NanBase (CSP, head), NanPage (shell), Docs, NanLegal @@ -204,7 +184,7 @@ website/ | Variable | Type | Purpose | |----------|------|---------| -| `CLOUD_API_URL` | `string` | Backend API base URL (e.g. `https://cloud-api.nan.builders`), used by `/api/signup`, the events proxy and login | +| `CLOUD_API_URL` | `string` | Backend API base URL (e.g. `https://cloud-api.nan.builders`), used by the events proxy, events SSR and the events login | | `RATE_LIMIT_RPM` / `RATE_LIMIT_PARALLEL` | `string` (optional) | Per-key limits shown in the docs (defaults in `src/lib/rateLimits.ts`) | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` and `CLOUD_API_WAITLIST_KEY` are no longer used by the site (the waitlist and its confirmation email are gone; cloud-api sends the sign-in email). @@ -249,9 +229,9 @@ npm run test:watch # Watch mode ``` Tests are in `src/**/*.test.ts` (Node) and `src/**/*.render.test.ts` (Astro Container API). Open-signup suites: -- `src/tests/lib/signup.test.ts` — validation, normalization, plan, response parsing -- `src/tests/api/signup.test.ts` — `/api/signup` proxy, validation, honeypot, rate limit, error mapping -- `src/tests/landing/signupForm.test.ts` — hero form markup, script wiring, copy in both locales +- `src/tests/lib/signup.test.ts` — plans, `?plan=` parsing, signup URLs +- `src/tests/landing/heroCta.test.ts` — hero has no form, CTA hrefs and copy, dead code gone, no `#signup` links +- `src/tests/landing/heroCta.server.test.ts` — SSR hero hrefs with `?plan=` / `?premium=1`, `POST /api/signup` is a 404 - `src/tests/landing/openSignup.test.ts` — CTA targets, no waitlist copy, legal pages - `src/tests/layouts/noWebSearch.test.ts` — web search stays removed diff --git a/README.md b/README.md index 2745e9e1..54eff4a0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NaN Website -Marketing site and public documentation for [nan.builders](https://nan.builders). Built with Astro 6 (SSR mode) on Cloudflare Workers, vanilla scripts (and a few Preact islands on the events pages), Tailwind CSS v4, and an EN/ES i18n catalog. Signup is open: the home form creates a free NaN account through the `cloud-api` Go service, which sends the sign-in email; every product unlocks with a plan on cloud.nan.builders. +Marketing site and public documentation for [nan.builders](https://nan.builders). Built with Astro 6 (SSR mode) on Cloudflare Workers, vanilla scripts (and a few Preact islands on the events pages), Tailwind CSS v4, and an EN/ES i18n catalog. Signup is open and lives on cloud.nan.builders: the site links there (hero, nav, pricing, footer), and every product unlocks with a plan. ## Tech stack @@ -16,7 +16,7 @@ Marketing site and public documentation for [nan.builders](https://nan.builders) ## Architecture -The site is a single Astro app deployed as a Cloudflare Worker. The landing (`/`, `/es`), the community page, events, projects and the docs (`/docs/*`) are SSR-rendered at the edge. The hero signup form is a vanilla script (`src/scripts/signup.ts`) that posts to the same-origin route `/api/signup`, which validates with the shared rules in `src/lib/signup.ts`, rate limits per IP, and proxies to cloud-api's public `POST /api/auth/signup`. Pricing and nav CTAs link to `https://cloud.nan.builders/signup?plan=`. Static assets are served from the `ASSETS` binding configured in `wrangler.jsonc`. +The site is a single Astro app deployed as a Cloudflare Worker. The landing (`/`, `/es`), the community page, events, projects and the docs (`/docs/*`) are SSR-rendered at the edge. There is no signup form or route on the site: the hero's Create account button links to `https://cloud.nan.builders/signup` (carrying a valid `?plan=` from the landing URL; the legacy `?premium=1` maps to `plan=premium`), Log in links to `https://cloud.nan.builders/`, and pricing cards link to `signup?plan=`. Links are built in `src/lib/signup.ts`. Static assets are served from the `ASSETS` binding configured in `wrangler.jsonc`. See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the full breakdown of rendering modes, route inventory, security model, and member lifecycle. @@ -26,11 +26,11 @@ See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the full breakdown of rendering m website/ ├── src/ │ ├── pages/ # Landing, community, legal, events, projects, docs (EN root, ES under /es) -│ │ └── api/ # signup, auth/login-request, events proxy, docs manifest +│ │ └── api/ # auth/login-request, events proxy, docs manifest │ ├── components/ # nan/ (shell + home sections), docs/, events/ -│ ├── scripts/ # Vanilla client scripts (signup form, reveal, dither, ...) +│ ├── scripts/ # Vanilla client scripts (reveal, dither, ...) │ ├── layouts/ # NanBase, NanPage, Docs, NanLegal -│ ├── lib/ # signup, signupServer, i18n, docs, events, rate limits +│ ├── lib/ # signup links, i18n, docs, events, rate limits │ ├── styles/ # Tokens, system CSS, Tailwind theme │ ├── tests/ # Vitest suites │ └── env.d.ts # Cloudflare env typings @@ -50,7 +50,7 @@ website/ - Node.js `>=22.12.0` (uses native `--env-file=` in scripts) - npm (the lockfile is `package-lock.json`; do not switch package managers) -- Access to a `cloud-api` instance (default: `https://cloud-api.nan.builders`) for the signup route to work end to end. Without `CLOUD_API_URL` the form answers with a generic error, but the site renders fine for visual work. +- Access to a `cloud-api` instance (default: `https://cloud-api.nan.builders`) for the events pages. Without `CLOUD_API_URL` the events pages fail to load, but the landing and docs render fine. ### Setup @@ -83,7 +83,7 @@ Runtime variables are declared in `src/env.d.ts` and consumed via `Astro.locals. | Name | Required | Where it's used | Description | |---|---|---|---| -| `CLOUD_API_URL` | runtime | `src/lib/signupServer.ts`, `src/lib/events.ts` | Base URL of the `cloud-api` backend (e.g. `https://cloud-api.nan.builders`). | +| `CLOUD_API_URL` | runtime | `src/lib/events.ts`, `src/pages/api/events/[...path].ts`, `src/pages/api/auth/login-request.ts` | Base URL of the `cloud-api` backend (e.g. `https://cloud-api.nan.builders`). | | `RATE_LIMIT_RPM` / `RATE_LIMIT_PARALLEL` | optional | `src/lib/rateLimits.ts` | Per-key limits shown in the docs; defaults apply when unset. | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` and `CLOUD_API_WAITLIST_KEY` are no longer read by the site and can be removed from the Worker. None of these values should ever be committed. @@ -95,7 +95,7 @@ npm test # run all Vitest suites npm run test:watch ``` -Vitest config: [`vitest.config.ts`](./vitest.config.ts) (a `unit` project for `src/**/*.test.ts` and a `render` project for `src/**/*.render.test.ts`). The open-signup suites are `src/tests/lib/signup.test.ts`, `src/tests/api/signup.test.ts`, `src/tests/landing/signupForm.test.ts` and `src/tests/landing/openSignup.test.ts`. +Vitest config: [`vitest.config.ts`](./vitest.config.ts) (a `unit` project for `src/**/*.test.ts` and a `render` project for `src/**/*.render.test.ts`). The signup-link suites are `src/tests/lib/signup.test.ts`, `src/tests/landing/heroCta.test.ts`, `src/tests/landing/heroCta.server.test.ts` and `src/tests/landing/openSignup.test.ts`. ## Build and local preview diff --git a/i18n/en.json b/i18n/en.json index 752d97ed..b314bc3d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -289,52 +289,8 @@ "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" }, "why": { "label": "what is nan", diff --git a/i18n/es.json b/i18n/es.json index 76a1879d..2732302c 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -289,52 +289,8 @@ "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" }, "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..795db4a6 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,10 @@ const signupMsgs = JSON.stringify({

{tt.body}

-

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

- - - - - - - - -

- +
@@ -214,73 +119,10 @@ const signupMsgs = JSON.stringify({ font-size: clamp(18px, 1.5vw, 22px); line-height: 1.5; max-width: 660px; - margin-bottom: 52px; - } - - .hero__lead { - display: flex; align-items: center; flex-wrap: wrap; - font-family: var(--font-sans); - 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%; - box-shadow: 0 0 0 0 var(--pulse); animation: spots-pulse 2.2s infinite; + margin-bottom: 40px; } - @keyframes spots-pulse { - 0% { box-shadow: 0 0 0 0 var(--pulse); } - 70% { box-shadow: 0 0 0 12px transparent; } - 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; } + .hero__ctas { display: flex; flex-wrap: wrap; gap: 14px; } @media (max-width: 860px) { .hero__fx { opacity: 0.5; } @@ -293,7 +135,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(/