diff --git a/.env.example b/.env.example index 626855b2..b190ac66 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,8 @@ # cloud-api backend (Go service) # Default is the production endpoint; override if running cloud-api locally. CLOUD_API_URL=https://cloud-api.nan.builders -# Server-side only. Required for /api/waitlist to write to the backend. -CLOUD_API_WAITLIST_KEY= - -# Resend (transactional email) -# Server-side only. Optional — if either RESEND_API_KEY or RESEND_FROM_EMAIL -# is missing, /api/waitlist still succeeds but skips the confirmation email. -RESEND_API_KEY= -RESEND_FROM_EMAIL=no-reply@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. # 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 214a1671..6d8d2089 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,23 +7,22 @@ NaN is a shared GPU infrastructure project for a paid community. The website covers: -1. **Landing** — Presentation, server specs, pricing, and waitlist signup +1. **Landing** — Presentation, models, pricing, and account signup (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. -Payment processing, member onboarding, and API key management are handled by the [NaN Platform](https://cloud.nan.builders/). +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`. ## Tech Stack | Layer | Technology | Why | |-------|-----------|-----| | Framework | Astro v6.0.8 (SSR mode, `output: 'server'`) | Static by default, SSR where needed | -| Interactive islands | Preact v10.29.0 | Zero JS on static pages, hydration only where needed | +| Interactive bits | Vanilla TS scripts (home, nav, signup form); 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 | -| Backend | NaN Platform cloud-api (Go + PostgreSQL) | Waitlist data, member lifecycle, billing | -| Transactional email | Resend | Waitlist confirmation emails | +| Backend | NaN Platform cloud-api (Go + PostgreSQL) | Accounts, member lifecycle, billing, sign-in emails (English magic link) | | Language | TypeScript (strict mode) | Type safety across the stack | **Node requirement:** `>=22.12.0` @@ -41,9 +40,11 @@ nan.builders (Cloudflare Worker) │ └── Static assets (CSS, JS, fonts, images, favicon) │ └── Worker (Edge Functions) - ├── /api/waitlist POST — waitlist signup (forwards to cloud-api backend) - ├── /api/community-signup POST — community-tier signup (forwards to cloud-api, returns Stripe URL) - └── SSR pages /, /community, /docs/* + ├── /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 + └── SSR pages /, /es, /community, /events, /projects, /docs/* ``` - **Static requests** (assets, images, favicon): served directly from CDN. @@ -55,20 +56,21 @@ nan.builders (Cloudflare Worker) | Route | Mode | Client JS | |-------|------|-----------| -| `/` | SSR (not prerendered) | Minimal (Preact islands: WaitlistForm, FaqAccordion) | -| `/community` | SSR (not prerendered) | Minimal (Preact island: CommunitySignupForm) | +| `/`, `/es` | SSR (not prerendered) | Vanilla scripts: hero dither, signup form (`scripts/signup.ts`), reveal | +| `/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/waitlist` | Server endpoint | None | -| `/api/community-signup` | Server endpoint | None | +| `/api/signup` | Server endpoint | None | ## Pricing (Current) -| Region | Price | Currency | -|--------|-------|----------| -| EU | 70 | €/mes (IVA incluido) | -| USA / LATAM | 75 | $/mes (impuestos incluidos) | +| Product | Price | Signup link | +|---------|-------|-------------| +| `nan_member · glm 5.3 premium` | 200€/mo | `https://cloud.nan.builders/signup?plan=premium` | +| `nan_member` (inference) | 70€/mo | `https://cloud.nan.builders/signup?plan=inference` | +| `nan_community` | 14,99€/mo | `https://cloud.nan.builders/signup?plan=community` | +| Workspace slots | from 4,99€/mo | `https://cloud.nan.builders/signup?plan=workspace` | -One tier per region. No early-bird pricing. No free trial. Monthly, no commitment. +Billed in euros from every region, taxes included, month to month. Links are built with `signupUrl()` in `src/lib/signup.ts`. ## Server Hardware @@ -87,36 +89,32 @@ One tier per region. No early-bird pricing. No free trial. Monthly, no commitmen | State | Description | |-------|-------------| -| `waitlist` (default/absent) | Signed up, not invited yet | -| `invited` | Onboarding email sent, waiting for subscription | -| `subscribed` | Active member with subscription | +| `free` | Account created (open signup), no product purchased yet | +| `subscribed` | Active member with a community, inference or premium subscription | | `declined` | Manual: test/junk entries kept for audit | | `banned` | Manual: membership revoked | -Note: State transitions beyond `waitlist` are managed by the [NaN Platform](https://cloud.nan.builders/), not by this website. +The legacy `waitlist`/`invited` rows are migrated to `free` by cloud-api. All transitions are managed by the [NaN Platform](https://cloud.nan.builders/), not by this website. ## Routes ### Landing Page (`/`) -Static sections, SSR-rendered: +Static sections, SSR-rendered (`src/pages/_home.astro`, components in `src/components/nan/home/`): -| Section | Component | Hydration | +| Section | Component | Client JS | |---------|-----------|-----------| -| Animated background | `AnimatedBackground.astro` | None (CSS-only) | -| Language switcher | `LanguageSwitcher.astro` | None (link-based ES/EN toggle) | -| Login button | `LoginButton.astro` | None (link to cloud.nan.builders) | -| Hero | `Hero.astro` | None | -| About | `About.astro` | None | -| Server specs | `Server.astro` | None | +| Nav (Login + Create account) | `nan/Nav.astro` | Small inline script (mobile menu) | +| Hero + signup form (`#signup`) | `Hero.astro` | `scripts/signup.ts` (vanilla) + canvas dither | +| What is NaN | `WhyNan.astro` | None | +| Built with NaN | `BuiltWithNan.astro` | None | +| Models | `Models.astro` | None | | Privacy | `Privacy.astro` | None | -| Model voting | `ModelVoting.astro` | None | -| Founder | `Founder.astro` | None | -| Pricing | `Pricing.astro` | None | -| CTA + waitlist form | `Cta.astro` + `WaitlistForm.tsx` | `client:load` | -| FAQ | `Faq.astro` + `FaqAccordion.tsx` | `client:visible` | +| Pricing + workspaces | `Pricing.astro` | None | +| Who is behind | `WhoBehind.astro` | None | +| FAQ | `Faq.astro` | None (`
`) | -The community-tier signup page (`/community`) reuses `LanguageSwitcher.astro` and embeds `CommunitySignupForm.tsx` (Preact island, `client:load`) which posts to `/api/community-signup` and redirects to Stripe Checkout on success. +`/community` (`src/pages/_community.astro`) has no form of its own: its CTAs link to `signup?plan=community`, and the platform runs the per-user Stripe Checkout. ### Documentation (`/docs*`) @@ -132,52 +130,36 @@ Sidebar navigation comes from each guide's frontmatter (`src/content.config.ts`) ### API Endpoints -#### `POST /api/waitlist` +#### `POST /api/signup` -Accepts waitlist signups. +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:** `{ email, region, _hp }` (JSON) -- **Validation:** email format, blocked domains/TLDs, region enum -- **Honeypot:** `_hp` field filled → 200 OK without persisting (bot protection) -- **Rate limit:** 60s per IP via in-memory map (best-effort, resets on isolate eviction) -- **Flow:** `validateWaitlistInput()` → rate limit check → `registerViaBackend()` → cloud-api POST → send confirmation email via Resend (best-effort) -- **Regions:** EU, LATAM and USA are all open; every valid signup is a regular waitlist entry and gets the same "you're on the waitlist" message (a duplicate email answers the same way) -- **Position:** the backend's arrival counter is not a queue rank, so it is neither forwarded in the response nor shown on the site +- **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:** - - `200`: `{ ok: true, status: 'registered', region }` - - `400`: `{ ok: false, error: 'invalid_email' | 'invalid_region' }` + - `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' }` - - `500`: `{ ok: false, error: 'server_error' }` - -#### `POST /api/community-signup` - -Accepts community-tier signups ($14.99/mes, billed as 14,99€ for `region=EU`) -and returns a Stripe Checkout URL. The currency split exists so an EU member's -Stripe customer isn't locked to USD, which would block a later upgrade to the -EUR-priced inference tier. - -- **Input:** `{ email, region, _hp }` (JSON) -- **Validation:** email format, blocked domains/TLDs, region enum (`EU` | `LATAM` | `USA`) -- **Honeypot:** `_hp` field filled → 200 OK with a benign URL, nothing persisted -- **Rate limit:** 60s per IP via in-memory map (best-effort, resets on isolate eviction) -- **Flow:** `validateCommunityInput()` → rate limit check → `signupViaBackend()` → cloud-api `POST /api/community/signup` (public, no API key) → backend creates a Stripe Checkout Session and returns its URL -- **Responses:** - - `200`: `{ ok: true, url }` (Stripe Checkout URL — frontend redirects here) - - `400`: `{ ok: false, error: 'invalid_email' | 'invalid_region' }` - - `409`: `{ ok: false, error: 'already_subscribed' }` - - `429`: `{ ok: false, error: 'rate_limited' }` - - `500`: `{ ok: false, error: 'server_error' }` + - `502`: `{ ok: false, error: 'server_error' }` (upstream error or unreachable; details only in the Worker log) ## Internationalization -The landing supports Spanish (default) and English. Catalogs live as JSON files in `i18n/es.json` and `i18n/en.json` and are imported as static modules by `src/lib/i18n.ts`, which exposes `t`, `tArr`, `tObj`, and `getLocale` helpers. Locale is resolved per request from the `?lang=` query parameter (`en` or `es`); anything else falls back to `es`. The `LanguageSwitcher.astro` component is a plain link that toggles the query parameter, so switching languages is a full SSR navigation with zero client JS. +The site is bilingual: English at the root (`/`) and Spanish under `/es`. Catalogs live in `i18n/en.json` and `i18n/es.json` (same shape, enforced by `i18nParity.test.ts`) and are read through `src/lib/i18n.ts` (`useT`, `t`, `tArr`, `tObj`, `getLang`, `switchLocalePath`). Emails sent by the platform are English only. ## Security | Mechanism | Implementation | |-----------|---------------| -| CSP | Strict policy in `Base.astro` — no external scripts, no frames, no objects | -| Bot protection | Honeypot field (`_hp`) on waitlist form + in-memory rate limiting + backend API key + origin restriction | +| 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 | | 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) | @@ -188,66 +170,32 @@ The landing supports Spanish (default) and English. Catalogs live as JSON files website/ ├── src/ │ ├── pages/ -│ │ ├── index.astro # Landing page (SSR) -│ │ ├── community.astro # Community-tier signup page (SSR) +│ │ ├── _home.astro, index.astro, es/index.astro # Landing (EN root, ES under /es) +│ │ ├── _community.astro, community.astro # Community page (CTA to platform signup) +│ │ ├── privacy.astro, terms.astro, cookies.astro # Legal (+ es/ versions) │ │ ├── api/ -│ │ │ ├── waitlist.ts # POST — waitlist signup -│ │ │ └── community-signup.ts # POST — community-tier signup (Stripe Checkout) -│ │ └── docs/ -│ │ ├── index.astro # Docs intro -│ │ ├── getting-started.astro # Getting started guide -│ │ ├── models.astro # Model specs -│ │ ├── examples.astro # Code snippets -│ │ ├── api.astro # OpenAI-compatible API reference +│ │ │ ├── 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 +│ │ ├── docs/, events/, projects.astro, gauntlet.astro, survey.astro │ ├── components/ -│ │ ├── ui/ -│ │ │ └── Button.astro # Primary/secondary button variants -│ │ ├── landing/ -│ │ │ ├── AnimatedBackground.astro # CSS-only dot grid + orbs -│ │ │ ├── LanguageSwitcher.astro # Fixed-position ES/EN toggle link -│ │ │ ├── LoginButton.astro # Fixed-position link to cloud.nan.builders -│ │ │ ├── Hero.astro # Title, tagline, CTA -│ │ │ ├── About.astro # What is NaN -│ │ │ ├── Server.astro # Hardware specs -│ │ │ ├── Privacy.astro # Privacy guarantees section -│ │ │ ├── ModelVoting.astro # Community model voting -│ │ │ ├── Founder.astro # Founder profile -│ │ │ ├── Pricing.astro # Regional pricing cards -│ │ │ ├── Cta.astro # CTA section (embeds WaitlistForm) -│ │ │ ├── Faq.astro # FAQ section (embeds FaqAccordion) -│ │ │ ├── WaitlistForm.tsx # Preact island — waitlist signup form -│ │ │ ├── CommunitySignupForm.tsx # Preact island — community-tier signup form -│ │ │ ├── FaqAccordion.tsx # Preact island — FAQ accordion -│ │ │ ├── waitlistForm.helpers.ts # Client-side validation + messages -│ │ │ └── waitlistForm.helpers.test.ts # Vitest tests for helpers -│ │ └── docs/ -│ │ ├── CodeBlock.astro # Code blocks with copy button -│ │ └── RateLimits.astro # Rate limit display component +│ │ ├── nan/ # Nav, Footer, CookieBanner, home/ sections +│ │ ├── docs/ # Docs shell components +│ │ └── events/ # Events islands (Preact) +│ ├── scripts/ # Vanilla client scripts (signup, reveal, dither, ...) │ ├── lib/ -│ │ ├── waitlist.ts # Waitlist validation + backend registration -│ │ ├── communitySignup.ts # Community-tier validation + Stripe Checkout backend call -│ │ ├── i18n.ts # Translation helpers (`t`, `tArr`, `tObj`, `getLocale`) -│ │ └── email.ts # Resend confirmation email -│ ├── layouts/ -│ │ ├── Base.astro # Base layout (head, meta, CSP, landing sections) -│ │ └── Docs.astro # Docs layout (sidebar, TOC, nav) -│ ├── styles/ -│ │ └── global.css # Tailwind @theme, docs typography, utilities +│ │ ├── signup.ts # Signup validation/normalization/parsing (client + server) +│ │ ├── signupServer.ts # Edge rate limit + cloud-api call (server only) +│ │ ├── i18n.ts # Translation helpers +│ │ └── ... # docs, events, rate limits, models +│ ├── layouts/ # NanBase (CSP, head), NanPage (shell), Docs, NanLegal +│ ├── tests/ # Vitest suites (api/, lib/, landing/, layouts/, legal/, pages/) │ └── env.d.ts # Cloudflare env type declarations -├── i18n/ -│ ├── es.json # Spanish catalog (default) -│ └── en.json # English catalog -├── public/ -│ ├── _routes.json # Cloudflare routing rules -│ ├── favicon.svg -│ ├── favicon.ico -│ └── founder.jpg -├── astro.config.mjs # Astro config (SSR, Cloudflare adapter, Preact, Tailwind) -├── wrangler.jsonc # Cloudflare Workers config -├── package.json -├── tsconfig.json -├── vitest.config.ts -└── .env.example # Template for local dev (.env is gitignored) +├── i18n/ # en.json, es.json +├── public/ # Static assets, _routes.json +├── astro.config.mjs, wrangler.jsonc, vitest.config.ts, tsconfig.json, package.json +└── .env.example ``` ## Environment Variables @@ -256,10 +204,10 @@ website/ | Variable | Type | Purpose | |----------|------|---------| -| `RESEND_API_KEY` | `string` | Resend transactional email API key | -| `RESEND_FROM_EMAIL` | `string` | Email sender address | -| `CLOUD_API_URL` | `string` | Backend API base URL (e.g. `https://cloud-api.nan.builders`) | -| `CLOUD_API_WAITLIST_KEY` | `string` | API key for waitlist registration endpoint | +| `CLOUD_API_URL` | `string` | Backend API base URL (e.g. `https://cloud-api.nan.builders`), used by `/api/signup`, the events proxy and 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). All secrets are stored in Cloudflare via `wrangler secret put`. Never committed to the repository. @@ -300,13 +248,12 @@ npm test # Run all tests (Vitest) npm run test:watch # Watch mode ``` -Tests are in `src/**/*.test.ts` files: -- `src/tests/api/waitlist.test.ts` — waitlist API endpoint tests -- `src/tests/api/community-signup.test.ts` — community signup API endpoint tests -- `src/tests/lib/waitlist.test.ts` — waitlist core logic tests -- `src/tests/lib/email.test.ts` — email helper tests -- `src/lib/communitySignup.test.ts` — community signup core logic tests -- `src/components/landing/waitlistForm.helpers.test.ts` — client-side form helpers tests +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/landing/openSignup.test.ts` — CTA targets, no waitlist copy, legal pages +- `src/tests/layouts/noWebSearch.test.ts` — web search stays removed ## Cost Estimate @@ -314,7 +261,6 @@ Tests are in `src/**/*.test.ts` files: |----------|-----------|---------------| | Cloudflare Pages | Unlimited deploys, 500 builds/month | More than enough | | Workers | 100K requests/day | Negligible usage | -| Resend | 3,000 emails/month | A few per month | | Domain | — | Already owned (nan.builders) | **Estimated infrastructure cost: $0/month** diff --git a/README.md b/README.md index f08378e9..2745e9e1 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,22 @@ # NaN Website -Marketing site and public documentation for [nan.builders](https://nan.builders). Built with Astro 6 (SSR mode) on Cloudflare Workers, Preact islands for interactive components, Tailwind CSS v4, and an ES/EN i18n catalog. The site backs into the `cloud-api` Go service for waitlist registration and community signup, and uses Resend for transactional email. +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. ## Tech stack - Astro 6 (`output: 'server'`, Cloudflare adapter) -- Preact 10 for client-side islands +- Preact 10 for the few client-side islands (events) - TypeScript (strict) - Tailwind CSS v4 (Vite plugin) - `rehype-pretty-code` + Shiki for code highlighting in docs - `astro-i18n` plus a local helper (`src/lib/i18n.ts`) for ES/EN strings - Vitest 4 for unit tests - Cloudflare Workers (assets binding + SSR entrypoint) -- Resend for transactional email - Node.js `>=22.12.0`, npm (lockfile is `package-lock.json`) ## Architecture -The site is a single Astro app deployed as a Cloudflare Worker. The landing (`/`), the community page (`/community`), and the docs (`/docs/*`) are SSR-rendered at the edge. Two API routes (`/api/waitlist`, `/api/community-signup`) validate input, forward to the `cloud-api` backend, and trigger Resend confirmation email. Interactive bits (waitlist form, community signup form, FAQ accordion) are Preact islands hydrated with `client:load` / `client:visible`. 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. 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`. See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the full breakdown of rendering modes, route inventory, security model, and member lifecycle. @@ -26,23 +25,14 @@ See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the full breakdown of rendering m ``` website/ ├── src/ -│ ├── pages/ -│ │ ├── index.astro # Landing (SSR) -│ │ ├── community.astro # Community tier page (SSR) -│ │ ├── api/ -│ │ │ ├── waitlist.ts # POST — waitlist signup -│ │ │ └── community-signup.ts # POST — community tier signup -│ │ └── docs/ # SSR docs: index, getting-started, models, -│ │ # examples, api, agents -│ ├── components/ -│ │ ├── landing/ # Hero, Pricing, WaitlistForm, FaqAccordion, -│ │ │ # LanguageSwitcher, LoginButton, etc. -│ │ ├── docs/ # CodeBlock, RateLimits -│ │ └── ui/ # Button -│ ├── layouts/ # Base, Docs, Page -│ ├── lib/ # waitlist, communitySignup, email, i18n -│ ├── styles/ # Tailwind theme + global CSS -│ ├── tests/ # Vitest suites (api/, lib/) +│ ├── pages/ # Landing, community, legal, events, projects, docs (EN root, ES under /es) +│ │ └── api/ # signup, auth/login-request, events proxy, docs manifest +│ ├── components/ # nan/ (shell + home sections), docs/, events/ +│ ├── scripts/ # Vanilla client scripts (signup form, reveal, dither, ...) +│ ├── layouts/ # NanBase, NanPage, Docs, NanLegal +│ ├── lib/ # signup, signupServer, i18n, docs, events, rate limits +│ ├── styles/ # Tokens, system CSS, Tailwind theme +│ ├── tests/ # Vitest suites │ └── env.d.ts # Cloudflare env typings ├── i18n/ # en.json, es.json translation catalogs ├── public/ # Static assets, favicons, _routes.json @@ -60,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`). Without `CLOUD_API_WAITLIST_KEY` the signup form will fail at the API call, but the site will still render fine for visual work. +- 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. ### Setup @@ -93,12 +83,10 @@ Runtime variables are declared in `src/env.d.ts` and consumed via `Astro.locals. | Name | Required | Where it's used | Description | |---|---|---|---| -| `RESEND_API_KEY` | runtime | `src/lib/email.ts` | Resend API key for transactional email (waitlist + community signup confirmation). | -| `RESEND_FROM_EMAIL` | runtime | `src/lib/email.ts` | `From` address used by Resend. | -| `CLOUD_API_URL` | runtime | `src/lib/waitlist.ts`, `src/lib/communitySignup.ts` | Base URL of the `cloud-api` backend (e.g. `https://cloud-api.nan.builders`). | -| `CLOUD_API_WAITLIST_KEY` | runtime | `src/lib/waitlist.ts`, `src/lib/communitySignup.ts` | API key for the `cloud-api` waitlist/community registration endpoints. | +| `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`). | +| `RATE_LIMIT_RPM` / `RATE_LIMIT_PARALLEL` | optional | `src/lib/rateLimits.ts` | Per-key limits shown in the docs; defaults apply when unset. | -All four are listed (without values) in `.env.example`. You must fill them locally if you want the API routes to work end-to-end. None of these secrets should ever be committed. +`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. ## Testing @@ -107,14 +95,7 @@ npm test # run all Vitest suites npm run test:watch ``` -Vitest config: [`vitest.config.ts`](./vitest.config.ts). Tests are picked up from `src/**/*.test.ts`. Current suites: - -- `src/tests/api/waitlist.test.ts` -- `src/tests/api/community-signup.test.ts` -- `src/tests/lib/waitlist.test.ts` -- `src/tests/lib/email.test.ts` -- `src/lib/communitySignup.test.ts` -- `src/components/landing/waitlistForm.helpers.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 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`. ## Build and local preview diff --git a/i18n/en.json b/i18n/en.json index 55114f44..e7d218a7 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,42 +1,8 @@ { "community": { - "label": "// community", - "pageTitle": "NaN Community", - "title": "Join the community while you wait for an inference seat.", - "subtitle": "Discord access, conversations with builders, and a reserved spot in line for GPU access when capacity frees up.", "priceLabel": "14,99€ / month", "priceTaxNote": "Taxes included · billed in euros from any region", - "includesLabel": "What's included", - "notIncludedLabel": "Not included", - "notIncluded": { - "0": "Inference service access (GPUs)", - "1": "An included workspace (workspace slots are sold separately)", - "2": "API key for model calls" - }, - "faqUpgradeQ": "How do I move to the inference plan later?", - "faqUpgradeA": "When GPU capacity frees up, we'll invite you manually. In the meantime you can request the upgrade from your member dashboard.", - "faqCancelQ": "Can I cancel?", - "faqCancelA": "Yes, anytime from your member dashboard. No commitment.", - "backToLanding": "← Back to nan.builders", - "emailLabel": "email", - "emailPlaceholder": "you@email.com", - "regionLabel": "region", - "regionDefault": "Select your region", - "regionEU": "Europe", - "regionUSA": "USA", - "regionLATAM": "LATAM", - "submit": "Join the community", - "submitting": "Processing…", - "redirecting": "Redirecting to checkout…", - "alreadySubscribed": "You're already in. If you didn't get a receipt, write to us at", - "honeypot": "Do not fill this field", - "errorInvalidEmail": "Invalid email. Check the format.", - "errorInvalidRegion": "Select a region.", - "errorRateLimited": "Too many attempts. Wait a minute.", - "errorServer": "Something went wrong. Try again in a moment.", - "errorNetwork": "No connection. Try again.", - "footer": "Cancel anytime.", - "alreadyMemberLabel": "// already a member" + "footer": "Cancel anytime." }, "events": { "backToEvents": "← Events", @@ -278,7 +244,7 @@ "nan": { "meta": { "title": "NaN — Build without limits", - "description": "A community of builders sharing GPUs to run open models at a flat rate. OpenAI-compatible API, zero logs, spots limited by compute.", + "description": "A community of builders sharing GPUs to run open models at a flat rate. OpenAI-compatible API, zero logs, open signup.", "siteName": "NaN", "ogLocale": "en_US" }, @@ -311,26 +277,60 @@ "events": "Events", "projects": "Projects", "docs": "Docs", - "login": "Login" + "login": "Login", + "signup": "Create account" }, "hero": { "line1": "Build without", "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": "Limited spots", - "waitline": "Join the waitlist to unlock your subscription. Just leave your email.", - "emailPlaceholder": "you@email.dev", - "regionLabel": "Select your Region", - "submit": "Join waitlist", + "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…", - "okRegistered": "You're on the waitlist.", - "okText": "We'll approve your spot in the next few days. Keep an eye on your inbox ^__^", + "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.", - "errRegion": "Pick a region.", + "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 minute." + "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" }, "why": { "label": "what is nan", @@ -340,7 +340,7 @@ "p": [ "Closed-model APIs are convenient but expensive and limited. Open models are free but running them seriously requires hardware almost no one has at home.", "NaN solves this by sharing powerful GPUs among builders who actually use them, splitting the real cost of infrastructure.", - "A closed community by design, limited to the real capacity of shared GPUs. When it fills up, we open a waitlist.", + "Open to anyone who wants to build. Creating an account is free; the cluster, the community and the workspaces switch on with a plan.", "The community lives on Discord. The website only manages access. Every member pays to be here. Only those who truly want to build make it in." ] }, @@ -360,7 +360,7 @@ "sub": "The full open-model inference stack, voted by the community every quarter. Data stays in the EU, with nothing logged.", "badgePremium": "premium tier", "unmetered": "unmetered", - "cta": "Join the waitlist" + "cta": "Create your account" }, "privacy": { "label": "privacy", @@ -394,14 +394,14 @@ "Events, workshops and hackathons", "Voice in the quarterly model vote" ], - "memberCond": "Month to month, no commitment. Billed in euros from any region. Limited spots: when it fills up, waitlist.", - "memberCta": "Join the waitlist", + "memberCond": "Month to month, no commitment. Billed in euros from any region.", + "memberCta": "Get inference", "communityIncludes": [ "Discord access", "Member-only channels", "Live discussions, builds, AMAs", "Events, workshops and hackathons", - "A reserved spot in line for inference" + "Upgrade to inference whenever you want" ], "communityCond": "No commitment. Cancel anytime. Billed in euros from any region.", "communityCta": "Join the community", @@ -414,13 +414,13 @@ "Everything in nan_member", "Access switches on a few minutes after payment" ], - "premiumCond": "GLM 5.3 premium (200€/mo) is offered to members as seats open. Join the waitlist and we will flag you for this tier.", - "premiumCta": "Join the waitlist", + "premiumCond": "Month to month, no commitment. Billed in euros from any region.", + "premiumCta": "Go premium", "premiumBadge": "glm 5.3 · premium", "workspaces": { "label": "workspaces · add-on", "title": "Your own cloud machine.", - "sub": "A dedicated, isolated dev box reachable over SSH and from the browser, with Pi, Hermes, gentle-shell and Herdr ready to use. Any paying member can buy slots; each slot keeps one workspace running.", + "sub": "A dedicated, isolated dev box reachable over SSH and from the browser, with Pi, Hermes, gentle-shell and Herdr ready to use. Anyone with a NaN account can buy slots; each slot keeps one workspace running.", "per": "/mo", "headSize": "size", "headSpecs": "vCPU · RAM · disk", @@ -432,7 +432,8 @@ "Backups are optional: one snapshot a day, kept 7 days, restore it yourself.", "Monthly, billed in euros. Cancel a slot anytime." ], - "cta": "Read the workspaces guide" + "cta": "Read the workspaces guide", + "signupCta": "Get a workspace" } }, "who": { @@ -491,7 +492,7 @@ }, { "q": "What is a workspace?", - "a": "Your own cloud machine for development: a dedicated, isolated box you reach over SSH or from the browser, with coding agents such as Pi and Hermes ready to use. Any paying member can buy workspace slots from 4,99€ a month, and the premium plan includes one free Micro workspace. A slot does not include inference: agents use NaN models only on an inference plan. The workspaces guide in the docs has sizes, prices, network rules and how backups work." + "a": "Your own cloud machine for development: a dedicated, isolated box you reach over SSH or from the browser, with coding agents such as Pi and Hermes ready to use. Anyone with a NaN account can buy workspace slots from 4,99€ a month, and the premium plan includes one free Micro workspace. A slot does not include inference: agents use NaN models only on an inference plan. The workspaces guide in the docs has sizes, prices, network rules and how backups work." }, { "q": "What payment methods do you accept?", @@ -499,14 +500,14 @@ }, { "q": "How do I get in?", - "a": "Leave your email on the waitlist and we'll write when a spot opens. There's a waitlist because there are no more GPUs: when compute comes in, people come in." + "a": "Create your account with your email: it's free and takes a minute. Then pick what you need (community, inference, premium or a workspace) and it switches on right after payment." } ] }, "footer": { "cta1": "Start building.", "cta2": "Start burning tokens.", - "go": "Join the waitlist", + "go": "Create your account", "privacy": "Privacy", "terms": "Terms", "cookies": "Cookies", @@ -533,7 +534,7 @@ "title2": "out of tokens.", "body": "The address you typed doesn't exist, or it never did. Either way, there's nothing to render here. You, though, still fit here.", "back": "Back to home", - "join": "Join the waitlist" + "join": "Create your account" }, "projects": { "metaTitle": "Projects — NaN", diff --git a/i18n/es.json b/i18n/es.json index b0275b82..a932b8ed 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -1,42 +1,8 @@ { "community": { - "label": "// community", - "pageTitle": "Comunidad NaN", - "title": "Únete a la comunidad mientras esperas tu plaza de inferencia.", - "subtitle": "Acceso al Discord, charlas con builders y plaza reservada en la cola para acceso a las GPUs cuando se libere capacidad.", "priceLabel": "14,99€ / mes", "priceTaxNote": "Impuestos incluidos · se factura en euros desde cualquier región", - "includesLabel": "Qué incluye", - "notIncludedLabel": "Qué NO incluye", - "notIncluded": { - "0": "Acceso al servicio de inferencia (GPUs)", - "1": "Un workspace incluido (los slots de workspace se venden aparte)", - "2": "API key para llamadas a modelos" - }, - "faqUpgradeQ": "¿Cómo paso al plan de inferencia más adelante?", - "faqUpgradeA": "Cuando se libere capacidad de GPU, te invitaremos manualmente. Mientras tanto puedes pedir el upgrade desde tu panel de miembro.", - "faqCancelQ": "¿Puedo cancelar?", - "faqCancelA": "Sí, en cualquier momento desde tu panel de miembro. Sin permanencia.", - "backToLanding": "← Volver a nan.builders", - "emailLabel": "email", - "emailPlaceholder": "tu@email.com", - "regionLabel": "región", - "regionDefault": "Selecciona tu región", - "regionEU": "Europa", - "regionUSA": "USA", - "regionLATAM": "LATAM", - "submit": "Únete a la comunidad", - "submitting": "Procesando…", - "redirecting": "Redirigiendo al pago…", - "alreadySubscribed": "Ya estás dentro. Si no recibiste el recibo, escríbenos a", - "honeypot": "Do not fill this field", - "errorInvalidEmail": "Email inválido. Revisa el formato.", - "errorInvalidRegion": "Selecciona una región.", - "errorRateLimited": "Demasiados intentos. Espera un minuto.", - "errorServer": "Algo falló. Vuelve a intentarlo en un momento.", - "errorNetwork": "Sin conexión. Vuelve a intentarlo.", - "footer": "Cancela cuando quieras.", - "alreadyMemberLabel": "// ya eres miembro" + "footer": "Cancela cuando quieras." }, "events": { "backToEvents": "← Eventos", @@ -280,7 +246,7 @@ "title": "NaN — Construye sin límites", "siteName": "NaN", "ogLocale": "es_ES", - "description": "Una comunidad de builders que comparten GPUs para correr modelos abiertos a tarifa fija. API compatible con OpenAI, cero logs, plazas limitadas por cómputo." + "description": "Una comunidad de builders que comparten GPUs para correr modelos abiertos a tarifa fija. API compatible con OpenAI, cero logs, registro abierto." }, "a11y": { "skip": "Saltar al contenido", @@ -311,26 +277,60 @@ "events": "Eventos", "projects": "Proyectos", "docs": "Docs", - "login": "Entrar" + "login": "Entrar", + "signup": "Crear cuenta" }, "hero": { "line1": "Construye sin", "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": "Plazas limitadas", - "waitline": "Únete a la waitlist para activar tu suscripción. Solo deja tu email.", - "emailPlaceholder": "tu@email.dev", - "regionLabel": "Elige tu región", - "submit": "Únete a la waitlist", + "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…", - "okRegistered": "Ya estás en la waitlist.", - "okText": "Aprobaremos tu plaza en los próximos días. No pierdas de vista tu bandeja ^__^", + "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.", - "errRegion": "Elige una región.", + "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 un minuto." + "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" }, "why": { "label": "qué es nan", @@ -340,7 +340,7 @@ "p": [ "Las APIs de modelos cerrados son cómodas, pero caras y limitadas. Los modelos abiertos son libres, pero correrlos en serio exige un hardware que casi nadie tiene en casa.", "NaN lo resuelve compartiendo GPUs potentes entre builders que de verdad las usan, repartiendo el coste real de la infraestructura.", - "Una comunidad cerrada por diseño, limitada a la capacidad real de las GPUs compartidas. Cuando se llena, abrimos waitlist.", + "Abierta a cualquiera que quiera construir. Crear la cuenta es gratis; el clúster, la comunidad y los workspaces se activan con un plan.", "La comunidad vive en Discord. La web solo gestiona el acceso. Cada miembro paga por estar aquí. Solo entra quien de verdad quiere construir." ] }, @@ -360,7 +360,7 @@ "sub": "Todo el stack de inferencia de modelos abiertos, votado por la comunidad cada trimestre. Los datos se quedan en la UE, sin registrar nada.", "badgePremium": "tier premium", "unmetered": "sin contador", - "cta": "Únete a la waitlist" + "cta": "Crea tu cuenta" }, "privacy": { "label": "privacidad", @@ -394,14 +394,14 @@ "Eventos, workshops y hackathons", "Voto en la votación trimestral de modelos" ], - "memberCond": "Mes a mes, sin compromiso. Se factura en euros desde cualquier región. Plazas limitadas: cuando se llena, waitlist.", - "memberCta": "Únete a la waitlist", + "memberCond": "Mes a mes, sin compromiso. Se factura en euros desde cualquier región.", + "memberCta": "Activar inferencia", "communityIncludes": [ "Acceso a Discord", "Canales solo para miembros", "Charlas en directo, builds, AMAs", "Eventos, workshops y hackathons", - "Un sitio reservado en la cola de inferencia" + "Pasa a inferencia cuando quieras" ], "communityCond": "Sin compromiso. Cancela cuando quieras. Se factura en euros desde cualquier región.", "communityCta": "Únete a la comunidad", @@ -414,13 +414,13 @@ "Todo lo incluido en nan_member", "El acceso se activa pocos minutos después del pago" ], - "premiumCond": "GLM 5.3 premium (200€/mes) se ofrece a miembros según haya plazas. Únete a la waitlist y te marcaremos para este tier.", - "premiumCta": "Únete a la waitlist", + "premiumCond": "Mes a mes, sin compromiso. Se factura en euros desde cualquier región.", + "premiumCta": "Pasar a premium", "premiumBadge": "glm 5.3 · premium", "workspaces": { "label": "workspaces · complemento", "title": "Tu propia máquina en la nube.", - "sub": "Una máquina de desarrollo dedicada y aislada, accesible por SSH y desde el navegador, con Pi, Hermes, gentle-shell y Herdr listos para usar. Cualquier miembro de pago puede comprar slots; cada slot mantiene un workspace en marcha.", + "sub": "Una máquina de desarrollo dedicada y aislada, accesible por SSH y desde el navegador, con Pi, Hermes, gentle-shell y Herdr listos para usar. Cualquiera con cuenta en NaN puede comprar slots; cada slot mantiene un workspace en marcha.", "per": "/mes", "headSize": "tamaño", "headSpecs": "vCPU · RAM · disco", @@ -432,7 +432,8 @@ "Los backups son opcionales: una instantánea al día, guardada 7 días, y la restauras tú.", "Mensual, se factura en euros. Cancela un slot cuando quieras." ], - "cta": "Lee la guía de workspaces" + "cta": "Lee la guía de workspaces", + "signupCta": "Consigue un workspace" } }, "who": { @@ -491,7 +492,7 @@ }, { "q": "¿Qué es un workspace?", - "a": "Tu propia máquina en la nube para desarrollar: una máquina dedicada y aislada a la que entras por SSH o desde el navegador, con agentes de código como Pi y Hermes listos para usar. Cualquier miembro de pago puede comprar slots de workspace desde 4,99€ al mes, y el plan premium incluye un workspace Micro gratis. Un slot no incluye inferencia: los agentes usan los modelos de NaN solo con un plan de inferencia. La guía de workspaces de la documentación tiene los tamaños, los precios, las reglas de red y cómo funcionan los backups." + "a": "Tu propia máquina en la nube para desarrollar: una máquina dedicada y aislada a la que entras por SSH o desde el navegador, con agentes de código como Pi y Hermes listos para usar. Cualquiera con cuenta en NaN puede comprar slots de workspace desde 4,99€ al mes, y el plan premium incluye un workspace Micro gratis. Un slot no incluye inferencia: los agentes usan los modelos de NaN solo con un plan de inferencia. La guía de workspaces de la documentación tiene los tamaños, los precios, las reglas de red y cómo funcionan los backups." }, { "q": "¿Qué métodos de pago aceptáis?", @@ -499,14 +500,14 @@ }, { "q": "¿Cómo entro?", - "a": "Deja tu email en la waitlist y te escribimos cuando se abra una plaza. Hay waitlist porque no hay más GPUs: cuando entra cómputo, entra gente." + "a": "Crea tu cuenta con tu email: es gratis y se tarda un minuto. Después elige lo que necesitas (comunidad, inferencia, premium o un workspace) y se activa en cuanto pagas." } ] }, "footer": { "cta1": "Empieza a construir.", "cta2": "Empieza a quemar tokens.", - "go": "Únete a la waitlist", + "go": "Crea tu cuenta", "privacy": "Privacidad", "terms": "Términos", "cookies": "Cookies", @@ -533,7 +534,7 @@ "title2": "sin tokens.", "body": "La dirección que escribiste no existe, o nunca existió. En cualquier caso, aquí no hay nada que renderizar. Tú, en cambio, sí encajas aquí.", "back": "Volver al inicio", - "join": "Únete a la waitlist" + "join": "Crea tu cuenta" }, "projects": { "metaTitle": "Proyectos — NaN", diff --git a/src/components/landing/CommunitySignupForm.tsx b/src/components/landing/CommunitySignupForm.tsx deleted file mode 100644 index d24ab2d5..00000000 --- a/src/components/landing/CommunitySignupForm.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import { useState } from 'preact/hooks'; -import type { TargetedSubmitEvent } from 'preact'; -import { resolveSignupResponse, errorMessageFor } from '../../lib/communitySignup'; - -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -const BLOCKED_EMAIL_DOMAINS: ReadonlySet = new Set([ - 'example.com', - 'example.net', - 'example.org', - 'test.com', - 'mail.com', -]); - -const BLOCKED_EMAIL_TLDS: readonly string[] = [ - 'test', - 'invalid', - 'localhost', - 'example', -]; - -function isBlockedEmailDomain(email: string): boolean { - const at = email.lastIndexOf('@'); - if (at === -1) return false; - const domain = email.slice(at + 1); - if (BLOCKED_EMAIL_DOMAINS.has(domain)) return true; - const lastDot = domain.lastIndexOf('.'); - const tld = lastDot === -1 ? domain : domain.slice(lastDot + 1); - return BLOCKED_EMAIL_TLDS.includes(tld); -} - -function normalizeEmail(value: string): string { - return value.trim().toLowerCase(); -} - -function isValidEmail(value: string): boolean { - const email = normalizeEmail(value); - if (email.length === 0 || email.length > 254) return false; - if (!EMAIL_REGEX.test(email)) return false; - if (isBlockedEmailDomain(email)) return false; - return true; -} - -const REGIONS = ['EU', 'LATAM', 'USA'] as const; -type Region = (typeof REGIONS)[number]; - -function isRegion(value: string): value is Region { - return (REGIONS as readonly string[]).includes(value); -} - -export interface CommunityTranslations { - emailLabel: string; - emailPlaceholder: string; - regionLabel: string; - regionEU: string; - regionUSA: string; - regionLATAM: string; - regionDefault: string; - submit: string; - submitting: string; - redirecting: string; - alreadySubscribed: string; - alreadyMemberLabel: string; - honeypot: string; - errorInvalidEmail: string; - errorInvalidRegion: string; - errorRateLimited: string; - errorServer: string; - errorNetwork: string; -} - -interface Props { - t: CommunityTranslations; -} - -type Status = - | { kind: 'idle' } - | { kind: 'submitting' } - | { kind: 'redirecting' } - | { kind: 'already' } - | { kind: 'error'; message: string }; - -export default function CommunitySignupForm({ t }: Props) { - const [email, setEmail] = useState(''); - const [region, setRegion] = useState(''); - const [status, setStatus] = useState({ kind: 'idle' }); - - async function onSubmit(e: TargetedSubmitEvent) { - e.preventDefault(); - - // No client-side honeypot field: a hidden _hp risks autofill by a - // password manager, and the server rate-limiter + the server-side honeypot - // (when _hp is sent) are the real bot defence. The form sends no _hp. - - if (!isValidEmail(email)) { - setStatus({ kind: 'error', message: t.errorInvalidEmail }); - return; - } - if (!isRegion(region)) { - setStatus({ kind: 'error', message: t.errorInvalidRegion }); - return; - } - - setStatus({ kind: 'submitting' }); - - let response: Response; - try { - response = await fetch('/api/community-signup', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: normalizeEmail(email), - region, - }), - }); - } catch { - setStatus({ kind: 'error', message: t.errorNetwork }); - return; - } - - let body: unknown = null; - try { - body = await response.json(); - } catch { - body = null; - } - - const outcome = resolveSignupResponse(response.status, body); - switch (outcome.kind) { - case 'redirect': - setStatus({ kind: 'redirecting' }); - window.location.href = outcome.url; - return; - case 'already': - setStatus({ kind: 'already' }); - return; - case 'error': - setStatus({ kind: 'error', message: errorMessageFor(outcome.code, t) }); - return; - } - } - - const submitting = status.kind === 'submitting'; - const redirecting = status.kind === 'redirecting'; - const disabled = submitting || redirecting; - const errorMsg = status.kind === 'error' ? status.message : null; - const already = status.kind === 'already'; - - return ( -
- {already && ( -
-

- {t.alreadyMemberLabel} -

-

- {t.alreadySubscribed}{' '} - - hello@nan.builders - -

-
- )} -
-
-
- - setEmail((e.currentTarget as HTMLInputElement).value)} - placeholder={t.emailPlaceholder} - class="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-4 py-3 text-sm text-white font-mono placeholder-neutral-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/40 transition-colors" - /> -
- -
- - -
- - {errorMsg && ( - - )} - - -
-
-
- ); -} diff --git a/src/components/nan/CookieBanner.astro b/src/components/nan/CookieBanner.astro index e44b582a..e182ab4a 100644 --- a/src/components/nan/CookieBanner.astro +++ b/src/components/nan/CookieBanner.astro @@ -45,7 +45,7 @@ const a11y = useT(lang).a11y; } .cwin[hidden] { display: none; } - /* En móvil ocupaba el centro-abajo y tapaba el formulario de waitlist del + /* En móvil ocupaba el centro-abajo y tapaba el formulario de alta del hero. Pegado al borde y a todo el ancho estorba mucho menos. */ @media (max-width: 620px) { .cwin { diff --git a/src/components/nan/Footer.astro b/src/components/nan/Footer.astro index c69d6e4b..3cec2099 100644 --- a/src/components/nan/Footer.astro +++ b/src/components/nan/Footer.astro @@ -16,7 +16,7 @@ const year = new Date().getFullYear(); {tt.cta1}
{tt.cta2} - {tt.go} → + {tt.go} → diff --git a/src/components/nan/Nav.astro b/src/components/nan/Nav.astro index ddd6af46..17cb30cf 100644 --- a/src/components/nan/Nav.astro +++ b/src/components/nan/Nav.astro @@ -1,8 +1,10 @@ --- // Nav del sistema definitivo. Wordmark = logotipo SVG (mask para recolorear a -// violeta al hover). Enlaces mono con subrayado wipe. Login = botón secundario. +// violeta al hover). Enlaces mono con subrayado wipe. Login = botón secundario, +// Crear cuenta = primario a su lado. // En móvil (<=860px) los enlaces pasan a un panel desplegable (hamburguesa). import { getLang, useT, switchLocalePath } from '../../lib/i18n'; +import { signupUrl } from '../../lib/signup'; const lang = getLang(Astro.url); const t = useT(lang); @@ -51,7 +53,10 @@ const pathname = Astro.url.pathname; ES - {tt.login} + + {tt.login} + {tt.signup} + @@ -163,6 +168,9 @@ const pathname = Astro.url.pathname; .nav__lang-opt.is-active { color: var(--color-text); font-weight: 700; } .nav__lang-sep { color: var(--color-line); } + /* Entrar + Crear cuenta, juntos: el primario es el que convierte */ + .nav__auth { display: inline-flex; align-items: center; gap: 10px; } + /* ---------- hamburguesa (oculta en desktop) ---------- */ .nav__toggle { display: none; @@ -183,14 +191,18 @@ const pathname = Astro.url.pathname; /* Con seis enlaces la barra horizontal ya no cabe en la franja estrecha de escritorio: en español desbordaba 87px a 861 y 50px a 900 (el inglés, 53 y - 16). Se aprieta el espaciado y el cuerpo solo en esa franja; el min-height - de 28px mantiene el área táctil por encima del mínimo de 24. Query acotada - a propósito, para no filtrar nada al panel de móvil. */ - @media (min-width: 861px) and (max-width: 1100px) { - .nav__links { gap: 18px; } - .nav__links > a:not(.btn) { font-size: 13px; } + 16). Con el botón de Crear cuenta junto a Entrar, la franja llega hasta + 1240px (a 1101 el español desbordaba 28px) y se aprieta algo más. Se aprieta + el espaciado y el cuerpo solo en esa franja; el min-height de 28px mantiene + el área táctil por encima del mínimo de 24. Query acotada a propósito, para + no filtrar nada al panel de móvil. */ + @media (min-width: 861px) and (max-width: 1240px) { + .nav__links { gap: 12px; } + .nav__links > a:not(.btn) { font-size: 12.5px; } .nav__lang { font-size: 12px; } .nav__lang-opt { padding: 0 5px; } + .nav__auth { gap: 6px; } + .nav__auth .btn { padding: 0 10px; font-size: 12px; height: 40px; } } @media (max-width: 860px) { @@ -213,6 +225,6 @@ const pathname = Astro.url.pathname; .nav.is-open .nav__links { display: flex; } .nav__links > a:not(.btn) { font-size: 18px; } .nav__lang { font-size: 15px; margin-top: var(--space-2); } - .nav__links .btn-secondary { margin-top: var(--space-2); } + .nav__auth { margin-top: var(--space-2); flex-wrap: wrap; } } diff --git a/src/components/nan/home/Hero.astro b/src/components/nan/home/Hero.astro index 986efadb..57289b43 100644 --- a/src/components/nan/home/Hero.astro +++ b/src/components/nan/home/Hero.astro @@ -1,31 +1,38 @@ --- // Hero del sistema definitivo. Ilustración dithered en canvas (blanco->violeta -// al hover), glitch en el H1, formulario waitlist cableado (data-waitlist). +// 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. import { getLang, useT } from '../../../lib/i18n'; +import { JOB_ROLES } from '../../../lib/signup'; const lang = getLang(Astro.url); const tt = useT(lang).hero; -const a11y = useT(lang).a11y; +const p = (path: string) => (lang === 'es' ? `/es${path}` : path); // 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 waitlist.ts desde el
. -const waitlistMsgs = JSON.stringify({ +// Mensajes de estado del formulario: los lee scripts/signup.ts desde el . +const signupMsgs = JSON.stringify({ sending: tt.sending, - okRegistered: tt.okRegistered, + okTitle: tt.okTitle, okText: tt.okText, + errFullName: tt.errFullName, errEmail: tt.errEmail, - errRegion: tt.errRegion, + errJobRole: tt.errJobRole, + errJobRoleOther: tt.errJobRoleOther, + errXUrl: tt.errXUrl, + errLinkedinUrl: tt.errLinkedinUrl, + errFields: tt.errFields, errRateLimited: tt.errRateLimited, errNetwork: tt.errNetwork, errGeneric: tt.errGeneric, }); --- -
+
@@ -38,33 +45,88 @@ const waitlistMsgs = JSON.stringify({

{tt.body}

-

+

{tt.spots} - {tt.waitline} + {tt.signupLine}

- + - -
- -
+
diff --git a/src/components/nan/home/Pricing.astro b/src/components/nan/home/Pricing.astro index a4981087..ef8baa43 100644 --- a/src/components/nan/home/Pricing.astro +++ b/src/components/nan/home/Pricing.astro @@ -11,10 +11,10 @@ // still exist and keep their price; that is explained in the payment-methods // FAQ entry, not here. import { getLang, useT } from '../../../lib/i18n'; +import { signupUrl } from '../../../lib/signup'; const lang = getLang(Astro.url); const tt = useT(lang).pricing; -const home = lang === 'es' ? '/es' : '/'; const tiers = [ // GLM 5.3 premium — price swap on the existing nan_member @@ -28,7 +28,7 @@ const tiers = [ includes: tt.premiumIncludes, cond: tt.premiumCond, cta: tt.premiumCta, - href: `${home}?premium=1#waitlist`, + href: signupUrl('premium'), primary: true, }, { @@ -40,7 +40,7 @@ const tiers = [ includes: tt.memberIncludes, cond: tt.memberCond, cta: tt.memberCta, - href: `${home}#waitlist`, + href: signupUrl('inference'), primary: true, }, { @@ -52,7 +52,7 @@ const tiers = [ includes: tt.communityIncludes, cond: tt.communityCond, cta: tt.communityCta, - href: lang === 'es' ? '/es/community#signup' : '/community#signup', + href: signupUrl('community'), primary: false, }, ]; @@ -148,7 +148,10 @@ const wsDocs = lang === 'es' ? '/es/docs/workspaces' : '/docs/workspaces'; {ws.notes.map((note) =>
  • {note}
  • )} - {ws.cta} + @@ -337,7 +340,7 @@ const wsDocs = lang === 'es' ? '/es/docs/workspaces' : '/docs/workspaces'; border-radius: 50%; background: var(--color-violet); } - .pws__cta { margin-top: var(--space-6); } + .pws__ctas { margin-top: var(--space-6); display: flex; flex-wrap: wrap; gap: var(--space-4); } @media (max-width: 900px) { .pricing { padding-block: var(--space-16); } diff --git a/src/env.d.ts b/src/env.d.ts index 4addc10b..587b1a98 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -6,10 +6,7 @@ // and TS will merge both declarations when it IS present locally. declare namespace Cloudflare { interface Env { - RESEND_API_KEY: string; - RESEND_FROM_EMAIL: string; CLOUD_API_URL: string; - CLOUD_API_WAITLIST_KEY: string; // Optional: both fall back to the defaults in src/lib/rateLimits.ts. RATE_LIMIT_RPM?: string; RATE_LIMIT_PARALLEL?: string; diff --git a/src/layouts/NanBase.astro b/src/layouts/NanBase.astro index 002fa0d9..6008efa2 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 waitlist llama a /api. + * - `connect-src 'self'` sigue: el formulario de alta llama a /api/signup. */ const csp = [ "default-src 'self'", diff --git a/src/layouts/NanPage.astro b/src/layouts/NanPage.astro index e86de4d6..fc6207b5 100644 --- a/src/layouts/NanPage.astro +++ b/src/layouts/NanPage.astro @@ -40,13 +40,13 @@ const { title, description, noindex, nofollow, image, scale } = Astro.props; diff --git a/src/lib/communitySignup.test.ts b/src/lib/communitySignup.test.ts deleted file mode 100644 index 2cea128a..00000000 --- a/src/lib/communitySignup.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - __resetRateLimitForTests, - checkRateLimit, - isCommunityRegion, - signupViaBackend, - validateCommunityInput, -} from './communitySignup'; - -beforeEach(() => { - __resetRateLimitForTests(); -}); - -describe('validateCommunityInput', () => { - it('accepts a valid EU input', () => { - const result = validateCommunityInput({ email: 'alice@acme.co', region: 'EU' }); - expect(result).toEqual({ - ok: true, - input: { email: 'alice@acme.co', region: 'EU', honeypot: false }, - }); - }); - - it('accepts USA + LATAM regions and lowercases the email', () => { - expect(validateCommunityInput({ email: 'BOB@Acme.Co', region: 'USA' })).toEqual({ - ok: true, - input: { email: 'bob@acme.co', region: 'USA', honeypot: false }, - }); - expect(validateCommunityInput({ email: 'luis@acme.co', region: 'LATAM' })).toEqual({ - ok: true, - input: { email: 'luis@acme.co', region: 'LATAM', honeypot: false }, - }); - }); - - it.each([ - [{}, 'invalid_email'], - [null, 'invalid_email'], - [[], 'invalid_email'], - [{ email: 'not-an-email', region: 'EU' }, 'invalid_email'], - [{ email: '', region: 'EU' }, 'invalid_email'], - [{ email: 'alice@example.com', region: 'EU' }, 'invalid_email'], - [{ email: 'alice@svc.localhost', region: 'EU' }, 'invalid_email'], - [{ email: 'alice@acme.co', region: 'AF' }, 'invalid_region'], - [{ email: 'alice@acme.co' }, 'invalid_region'], - [{ email: 'alice@acme.co', region: 'eu' }, 'invalid_region'], - ])('rejects %j with %s', (input, expected) => { - const result = validateCommunityInput(input); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toBe(expected); - } - }); - - it('flags the honeypot when filled but still validates the rest', () => { - const result = validateCommunityInput({ - email: 'bot@acme.co', - region: 'EU', - _hp: 'spam', - }); - expect(result.ok).toBe(true); - if (result.ok) expect(result.input.honeypot).toBe(true); - }); -}); - -describe('isCommunityRegion', () => { - it.each(['EU', 'LATAM', 'USA'])('accepts %s', (value) => { - expect(isCommunityRegion(value)).toBe(true); - }); - - it.each(['', 'eu', 'AF', ' EU '])('rejects %s', (value) => { - expect(isCommunityRegion(value)).toBe(false); - }); -}); - -describe('signupViaBackend', () => { - const originalFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('returns the Stripe URL on 200', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - status: 200, - json: () => Promise.resolve({ url: 'https://checkout.stripe.com/c/pay/cs_test_123' }), - }) as unknown as typeof fetch; - - const result = await signupViaBackend('https://api.test', { - email: 'alice@acme.co', - region: 'EU', - }); - expect(result).toEqual({ ok: true, url: 'https://checkout.stripe.com/c/pay/cs_test_123' }); - }); - - it('does NOT send X-API-Key (community signup is public)', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - status: 200, - json: () => Promise.resolve({ url: 'https://checkout.stripe.com/c/x' }), - }) as unknown as typeof fetch; - globalThis.fetch = fetchMock; - - await signupViaBackend('https://api.test', { email: 'alice@acme.co', region: 'EU' }); - - const call = (fetchMock as unknown as ReturnType).mock.calls[0]; - expect(call[0]).toBe('https://api.test/api/community/signup'); - const headers = (call[1] as RequestInit).headers as Record; - expect(headers['Content-Type']).toBe('application/json'); - expect(headers['X-API-Key']).toBeUndefined(); - }); - - it('maps backend 409 to already_subscribed', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - status: 409, - json: () => Promise.resolve({ error: 'already_subscribed' }), - }) as unknown as typeof fetch; - - const result = await signupViaBackend('https://api.test', { - email: 'alice@acme.co', - region: 'EU', - }); - expect(result).toEqual({ ok: false, error: 'already_subscribed' }); - }); - - it('maps backend 429 to rate_limited', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - status: 429, - json: () => Promise.resolve({ error: 'rate limit exceeded' }), - }) as unknown as typeof fetch; - - const result = await signupViaBackend('https://api.test', { - email: 'alice@acme.co', - region: 'EU', - }); - expect(result).toEqual({ ok: false, error: 'rate_limited' }); - }); - - it('maps a 400 with email message to invalid_email', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - status: 400, - json: () => Promise.resolve({ error: 'valid email is required' }), - }) as unknown as typeof fetch; - - const result = await signupViaBackend('https://api.test', { - email: 'alice@acme.co', - region: 'EU', - }); - expect(result).toEqual({ ok: false, error: 'invalid_email' }); - }); - - it('maps a 400 with region message to invalid_region', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - status: 400, - json: () => Promise.resolve({ error: 'region must be EU, USA, or LATAM' }), - }) as unknown as typeof fetch; - - const result = await signupViaBackend('https://api.test', { - email: 'alice@acme.co', - region: 'EU', - }); - expect(result).toEqual({ ok: false, error: 'invalid_region' }); - }); - - it('maps a 500 to server_error', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - status: 500, - json: () => Promise.resolve({ error: 'failed to create checkout session' }), - }) as unknown as typeof fetch; - - const result = await signupViaBackend('https://api.test', { - email: 'alice@acme.co', - region: 'EU', - }); - expect(result).toEqual({ ok: false, error: 'server_error' }); - }); - - it('maps a network failure to server_error', async () => { - globalThis.fetch = vi.fn().mockRejectedValue(new Error('boom')) as unknown as typeof fetch; - - const result = await signupViaBackend('https://api.test', { - email: 'alice@acme.co', - region: 'EU', - }); - expect(result).toEqual({ ok: false, error: 'server_error' }); - }); - - it('maps a 200 without url to server_error', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - status: 200, - json: () => Promise.resolve({}), - }) as unknown as typeof fetch; - - const result = await signupViaBackend('https://api.test', { - email: 'alice@acme.co', - region: 'EU', - }); - expect(result).toEqual({ ok: false, error: 'server_error' }); - }); -}); - -describe('checkRateLimit', () => { - it('allows the first request and blocks the second from the same IP', () => { - expect(checkRateLimit('1.2.3.4')).toBe(true); - expect(checkRateLimit('1.2.3.4')).toBe(false); - }); - - it('does not block different IPs', () => { - expect(checkRateLimit('5.5.5.5')).toBe(true); - expect(checkRateLimit('6.6.6.6')).toBe(true); - }); - - it('fails open when no IP is provided', () => { - expect(checkRateLimit('')).toBe(true); - expect(checkRateLimit('')).toBe(true); - }); -}); diff --git a/src/lib/communitySignup.ts b/src/lib/communitySignup.ts deleted file mode 100644 index 3e96ecaa..00000000 --- a/src/lib/communitySignup.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * Community-tier signup logic. The Astro endpoint validates input on the edge - * and forwards valid signups to the cloud-api backend, which creates a Stripe - * Checkout Session and returns its URL. The frontend then redirects the user - * to Stripe. - * - * The backend endpoint (POST /api/community/signup) is PUBLIC (no API key), - * fronted by chi middleware that already enforces CORS/origin. We therefore - * do NOT send X-API-Key here — verified by reading - * cmd/server/main.go and internal/handlers/community.go on 2026-05-07. - */ - -const RATE_LIMIT_TTL_MS = 60_000; - -const EMAIL_MAX_LENGTH = 254; - -// Same regex as waitlist.ts — kept in sync intentionally. -const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; - -const BLOCKED_EMAIL_DOMAINS: ReadonlySet = new Set([ - 'example.com', - 'example.net', - 'example.org', - 'test.com', - 'mail.com', -]); - -const BLOCKED_EMAIL_TLDS: readonly string[] = [ - 'test', - 'invalid', - 'localhost', - 'example', -]; - -function isBlockedEmailDomain(email: string): boolean { - const at = email.lastIndexOf('@'); - if (at === -1) return false; - const domain = email.slice(at + 1); - if (BLOCKED_EMAIL_DOMAINS.has(domain)) return true; - const lastDot = domain.lastIndexOf('.'); - const tld = lastDot === -1 ? domain : domain.slice(lastDot + 1); - return BLOCKED_EMAIL_TLDS.includes(tld); -} - -export const COMMUNITY_REGIONS = ['EU', 'LATAM', 'USA'] as const; -export type CommunityRegion = (typeof COMMUNITY_REGIONS)[number]; - -export type CommunityErrorCode = - | 'invalid_email' - | 'invalid_region' - | 'rate_limited' - | 'already_subscribed' - | 'server_error'; - -export type CommunityValidatedInput = { - email: string; - region: CommunityRegion; - honeypot: boolean; -}; - -export type CommunityValidationResult = - | { ok: true; input: CommunityValidatedInput } - | { ok: false; error: Extract }; - -export function validateCommunityInput(raw: unknown): CommunityValidationResult { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return { ok: false, error: 'invalid_email' }; - } - const obj = raw as Record; - - if (typeof obj.email !== 'string') { - return { ok: false, error: 'invalid_email' }; - } - const email = obj.email.trim().toLowerCase(); - if (email.length === 0 || email.length > EMAIL_MAX_LENGTH || !EMAIL_RE.test(email)) { - return { ok: false, error: 'invalid_email' }; - } - if (isBlockedEmailDomain(email)) { - return { ok: false, error: 'invalid_email' }; - } - - if (typeof obj.region !== 'string' || !isCommunityRegion(obj.region)) { - return { ok: false, error: 'invalid_region' }; - } - const region = obj.region; - - // Honeypot trap: if a bot fills the hidden `_hp` field we still return - // 200 OK from the API layer but never persist. Defensive only. - const honeypot = typeof obj._hp === 'string' && obj._hp.trim().length > 0; - - return { - ok: true, - input: { email, region, honeypot }, - }; -} - -export function isCommunityRegion(value: string): value is CommunityRegion { - return (COMMUNITY_REGIONS as readonly string[]).includes(value); -} - -/** - * In-memory rate limiter. Best-effort: state is lost when the Worker isolate - * is evicted, which is acceptable — the backend has its own per-IP limiter. - */ -const rateLimitMap = new Map(); - -export function checkRateLimit(ip: string): boolean { - if (!ip) return true; // cannot identify client — fail open - const now = Date.now(); - - const expiresAt = rateLimitMap.get(ip); - if (expiresAt !== undefined && expiresAt > now) { - return false; - } - - rateLimitMap.set(ip, now + RATE_LIMIT_TTL_MS); - - if (rateLimitMap.size > 1000) { - for (const [key, exp] of rateLimitMap) { - if (exp <= now) rateLimitMap.delete(key); - } - } - - return true; -} - -export type CommunitySignupSuccess = { ok: true; url: string }; -export type CommunitySignupFailure = { ok: false; error: CommunityErrorCode }; -export type CommunitySignupResult = CommunitySignupSuccess | CommunitySignupFailure; - -export type SignupOutcome = - | { kind: 'redirect'; url: string } - | { kind: 'already' } - | { kind: 'error'; code: CommunityErrorCode }; - -/** - * Maps a raw response (status + parsed body) from the community-signup edge - * endpoint to a structured outcome the form component can act on. Pure so it - * can be unit-tested in node without a DOM. - * - * The fetch-throws case (no response at all) is handled by the caller as a - * network error; this function only classifies responses that did arrive. - */ -export function resolveSignupResponse(status: number, body: unknown): SignupOutcome { - if ( - status === 200 && - body && typeof body === 'object' && - (body as { ok?: unknown }).ok === true && - typeof (body as { url?: unknown }).url === 'string' - ) { - return { kind: 'redirect', url: (body as { url: string }).url }; - } - - if (status === 409) { - return { kind: 'already' }; - } - - const errStr = - body && typeof body === 'object' && typeof (body as { error?: unknown }).error === 'string' - ? (body as { error: string }).error - : ''; - - const known: CommunityErrorCode[] = [ - 'invalid_email', 'invalid_region', 'rate_limited', 'already_subscribed', 'server_error', - ]; - if (known.includes(errStr as CommunityErrorCode)) { - return { kind: 'error', code: errStr as CommunityErrorCode }; - } - return { kind: 'error', code: 'server_error' }; -} - -/** Localizes an error code using the form's translation table. */ -export function errorMessageFor( - code: CommunityErrorCode, - t: { errorInvalidEmail: string; errorInvalidRegion: string; errorRateLimited: string; errorServer: string }, -): string { - switch (code) { - case 'invalid_email': return t.errorInvalidEmail; - case 'invalid_region': return t.errorInvalidRegion; - case 'rate_limited': return t.errorRateLimited; - default: return t.errorServer; - } -} - - -/** - * Calls the cloud-api community signup endpoint. On success, the backend - * returns a Stripe Checkout Session URL the user should be redirected to. - * - * Note: the backend endpoint is PUBLIC and does NOT require X-API-Key. - * Sending the key would be harmless but unnecessary; we omit it to keep the - * call minimal. - */ -export async function signupViaBackend( - apiURL: string, - input: { email: string; region: CommunityRegion }, -): Promise { - let response: Response; - try { - response = await fetch(`${apiURL}/api/community/signup`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Origin': 'https://nan.builders', - }, - body: JSON.stringify({ email: input.email, region: input.region }), - }); - } catch { - return { ok: false, error: 'server_error' }; - } - - if (response.status === 200) { - let data: unknown; - try { - data = await response.json(); - } catch { - return { ok: false, error: 'server_error' }; - } - if (data && typeof data === 'object' && typeof (data as { url?: unknown }).url === 'string') { - return { ok: true, url: (data as { url: string }).url }; - } - return { ok: false, error: 'server_error' }; - } - - if (response.status === 409) { - return { ok: false, error: 'already_subscribed' }; - } - - if (response.status === 429) { - return { ok: false, error: 'rate_limited' }; - } - - if (response.status === 400) { - let payload: unknown = null; - try { - payload = await response.json(); - } catch { - payload = null; - } - const errStr = - payload && typeof payload === 'object' && typeof (payload as { error?: unknown }).error === 'string' - ? (payload as { error: string }).error - : ''; - if (errStr.includes('email')) return { ok: false, error: 'invalid_email' }; - if (errStr.includes('region')) return { ok: false, error: 'invalid_region' }; - return { ok: false, error: 'invalid_email' }; - } - - return { ok: false, error: 'server_error' }; -} - -// Test-only helper: clear the rate-limit map between tests. -export function __resetRateLimitForTests(): void { - rateLimitMap.clear(); -} diff --git a/src/lib/email.ts b/src/lib/email.ts deleted file mode 100644 index c2de6237..00000000 --- a/src/lib/email.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Email sending via Resend API — storage-agnostic. - * Receives API key and sender config as parameters so it can be - * unit-tested without runtime bindings. - */ - -import type { WaitlistRegion } from './waitlist'; - -const RESEND_ENDPOINT = 'https://api.resend.com/emails'; - -export function buildConfirmationBody(region: WaitlistRegion): string { - const regionLabel = - region === 'EU' ? 'Europa' : region === 'USA' ? 'Estados Unidos' : 'Latinoamérica'; - - return [ - 'Hola,', - '', - '¡Gracias por apuntarte a la lista de espera de NaN Community!', - '', - `Tu solicitud para la región ${regionLabel} ha sido registrada correctamente.`, - 'Te avisaremos por email en cuanto haya una plaza disponible para ti.', - '', - 'Si tienes cualquier duda, responde directo a este correo.', - '', - '— Cristian', - 'nan.builders', - ].join('\n'); -} - -export interface SendEmailParams { - to: string; - region: WaitlistRegion; - apiKey: string; - from: string; -} - -export type SendEmailOutcome = - | { ok: true; id: string } - | { ok: false; error: string; status?: number }; - -/** - * Sends a waitlist confirmation email via Resend. - */ -export async function sendConfirmationEmail( - params: SendEmailParams, -): Promise { - const { to, region, apiKey, from } = params; - const subject = 'NaN Community — Estás en la lista de espera'; - const text = buildConfirmationBody(region); - - try { - const response = await fetch(RESEND_ENDPOINT, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ from, to, subject, text }), - }); - - if (!response.ok) { - const body = await response.text(); - return { ok: false, error: body, status: response.status }; - } - - const data = (await response.json()) as { id: string }; - return { ok: true, id: data.id }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : String(err), - }; - } -} diff --git a/src/lib/rateLimits.ts b/src/lib/rateLimits.ts index 425525f0..5acf89f8 100644 --- a/src/lib/rateLimits.ts +++ b/src/lib/rateLimits.ts @@ -7,7 +7,7 @@ * 60 rpm while the extractor hardcoded 100 rpm. * * Receives the env as a parameter so it can be unit-tested without runtime - * bindings, mirroring src/lib/email.ts. + * bindings. */ export interface PerKeyRateLimits { diff --git a/src/lib/signup.ts b/src/lib/signup.ts new file mode 100644 index 00000000..7afb84b5 --- /dev/null +++ b/src/lib/signup.ts @@ -0,0 +1,280 @@ +/** + * 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). + * + * 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. + */ + +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 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; +} + +/** + * Plan carried in the page URL. `?premium=1` is the old premium pricing card + * link, still out there in shared links and bookmarks. + */ +export function planFromSearch(search: string): Plan | undefined { + const params = new URLSearchParams(search); + const plan = params.get('plan'); + if (isPlan(plan)) return plan; + if (params.get('premium') === '1') return 'premium'; + 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; + } +} diff --git a/src/lib/signupNoJs.ts b/src/lib/signupNoJs.ts new file mode 100644 index 00000000..5a6e7fcf --- /dev/null +++ b/src/lib/signupNoJs.ts @@ -0,0 +1,111 @@ +/** + * 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 new file mode 100644 index 00000000..d13be1a9 --- /dev/null +++ b/src/lib/signupServer.ts @@ -0,0 +1,99 @@ +/** + * 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/lib/waitlist.ts b/src/lib/waitlist.ts deleted file mode 100644 index 1c9a3c67..00000000 --- a/src/lib/waitlist.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Waitlist core logic — all member data lives in PostgreSQL via the cloud-api - * backend. The landing page validates input on the edge and forwards valid - * signups to the backend. - * - * Region model: - * EU, LATAM and USA are all open: every signup is a regular waitlist entry - * and gets the same "you're on the waitlist" answer. The backend keeps its - * own arrival counter, but it is not a queue rank, so the site neither - * forwards nor shows it. - */ - -const RATE_LIMIT_TTL_MS = 60_000; - -// Practical upper bound per RFC 5321. -const EMAIL_MAX_LENGTH = 254; - -// Intentionally simple — we're not reimplementing RFC 5322. No catastrophic -// backtracking: bounded character classes, no nested quantifiers. -const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; - -// Reserved / documentation / test domains that should never appear in a real -// signup. Covers RFC 2606 (example.com/net/org, .test/.invalid/.localhost/ -// .example) plus a couple of common disposable "throwaway" domains we've seen -// hit the form. Server-side is the source of truth; the client mirrors this -// list only to show an early error without a round-trip. -const BLOCKED_EMAIL_DOMAINS: ReadonlySet = new Set([ - 'example.com', - 'example.net', - 'example.org', - 'test.com', - 'mail.com', -]); - -const BLOCKED_EMAIL_TLDS: readonly string[] = [ - 'test', - 'invalid', - 'localhost', - 'example', -]; - -function isBlockedEmailDomain(email: string): boolean { - const at = email.lastIndexOf('@'); - if (at === -1) return false; - const domain = email.slice(at + 1); - if (BLOCKED_EMAIL_DOMAINS.has(domain)) return true; - const lastDot = domain.lastIndexOf('.'); - const tld = lastDot === -1 ? domain : domain.slice(lastDot + 1); - return BLOCKED_EMAIL_TLDS.includes(tld); -} - -export const REGIONS = ['EU', 'LATAM', 'USA'] as const; -export type WaitlistRegion = (typeof REGIONS)[number]; - -export type WaitlistErrorCode = - | 'invalid_email' - | 'invalid_region' - | 'rate_limited' - | 'server_error'; - -/** - * Member lifecycle states. - * - * waitlist Default. Signed up but not invited yet. - * invited Onboarding email sent, waiting for subscription. - * subscribed Active member with subscription. - * declined Manual: test/junk entries we keep for audit instead of deleting. - * banned Manual: membership revoked. - */ -export const MEMBER_STATES = [ - 'waitlist', - 'invited', - 'subscribed', - 'declined', - 'banned', -] as const; -export type MemberState = (typeof MEMBER_STATES)[number]; - -export interface JoinResult { - ok: true; - /** Always 'registered': kept on the wire for clients that still read it. */ - status: 'registered'; - region: WaitlistRegion; -} - -export type ValidatedInput = { - email: string; - region: WaitlistRegion; - honeypot: boolean; -}; - -export type ValidationResult = - | { ok: true; input: ValidatedInput } - | { ok: false; error: Extract }; - -export function validateWaitlistInput(raw: unknown): ValidationResult { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return { ok: false, error: 'invalid_email' }; - } - const obj = raw as Record; - - if (typeof obj.email !== 'string') { - return { ok: false, error: 'invalid_email' }; - } - const email = obj.email.trim().toLowerCase(); - if (email.length === 0 || email.length > EMAIL_MAX_LENGTH || !EMAIL_RE.test(email)) { - return { ok: false, error: 'invalid_email' }; - } - if (isBlockedEmailDomain(email)) { - return { ok: false, error: 'invalid_email' }; - } - - if (typeof obj.region !== 'string' || !isWaitlistRegion(obj.region)) { - return { ok: false, error: 'invalid_region' }; - } - const region = obj.region; - - // Honeypot trap: if a bot fills the hidden `_hp` field we still return - // 200 OK from the API layer but never persist. Defensive only — this is - // not primary bot protection, just a cheap filter. - const honeypot = typeof obj._hp === 'string' && obj._hp.trim().length > 0; - - return { - ok: true, - input: { email, region, honeypot }, - }; -} - -function isWaitlistRegion(value: string): value is WaitlistRegion { - return (REGIONS as readonly string[]).includes(value); -} - -/** - * In-memory rate limiter. Entries auto-expire after RATE_LIMIT_TTL_MS. - * Best-effort: state is lost when the Worker isolate is evicted, which is - * acceptable — the backend has its own duplicate-email protection. - */ -const rateLimitMap = new Map(); - -export function checkRateLimit(ip: string): boolean { - if (!ip) return true; // cannot identify client — fail open - const now = Date.now(); - - const expiresAt = rateLimitMap.get(ip); - if (expiresAt !== undefined && expiresAt > now) { - return false; - } - - rateLimitMap.set(ip, now + RATE_LIMIT_TTL_MS); - - // Lazy cleanup: purge expired entries when map grows large - if (rateLimitMap.size > 1000) { - for (const [key, exp] of rateLimitMap) { - if (exp <= now) rateLimitMap.delete(key); - } - } - - return true; -} - -/** - * Registers a waitlist member via the cloud-api backend (PostgreSQL). - * Returns a JoinResult-compatible response or throws on network/server errors. - */ -export async function registerViaBackend( - apiURL: string, - apiKey: string, - input: { email: string; region: WaitlistRegion; wantsPremium?: boolean }, -): Promise { - const response = await fetch(`${apiURL}/api/waitlist/register`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': apiKey, - 'Origin': 'https://nan.builders', - }, - body: JSON.stringify({ email: input.email, region: input.region, wantsPremium: input.wantsPremium ?? false }), - }); - - if (response.status === 409) { - // Already registered — return a synthetic success so the frontend - // shows the same "you're in" message rather than an error. - return { - ok: true, - status: 'registered', - region: input.region, - }; - } - - if (!response.ok) { - const body = await response.text(); - throw new Error(`cloud-api register failed: ${response.status} ${body}`); - } - - const data = (await response.json()) as { email: string; region: string }; - return { - ok: true, - status: 'registered', - region: data.region as WaitlistRegion, - }; -} diff --git a/src/lib/waitlistClient.ts b/src/lib/waitlistClient.ts deleted file mode 100644 index 4b493e85..00000000 --- a/src/lib/waitlistClient.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Validación y parseo de la waitlist en el CLIENTE. - * - * Antes vivía en `components/landing/waitlistForm.helpers.ts`, nombre heredado - * de una isla Preact que ya no existe: quedaba un fichero en `components/` - * que no era ningún componente. El par natural es `lib/waitlist.ts`, que es el - * lado servidor. - * - * REGLA: el espejo de dominios y TLD bloqueados de aquí tiene que coincidir con - * el de `lib/waitlist.ts`. Si divergen, el formulario acepta cosas que el - * servidor rechaza (o al revés) y el usuario ve un error que no entiende. - * - * OJO, deuda conocida: `components/landing/CommunitySignupForm.tsx` tiene su - * PROPIA copia de `isValidEmail` y `REGIONS`, así que hoy hay tres. Unificarlas - * es trabajo aparte, pero conviene no añadir una cuarta. - */ - -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -const BLOCKED_EMAIL_DOMAINS: ReadonlySet = new Set([ - 'example.com', - 'example.net', - 'example.org', - 'test.com', - 'mail.com', -]); - -const BLOCKED_EMAIL_TLDS: readonly string[] = [ - 'test', - 'invalid', - 'localhost', - 'example', -]; - -function isBlockedEmailDomain(email: string): boolean { - const at = email.lastIndexOf('@'); - if (at === -1) return false; - const domain = email.slice(at + 1); - if (BLOCKED_EMAIL_DOMAINS.has(domain)) return true; - const lastDot = domain.lastIndexOf('.'); - const tld = lastDot === -1 ? domain : domain.slice(lastDot + 1); - return BLOCKED_EMAIL_TLDS.includes(tld); -} -export const REGIONS = ['EU', 'LATAM', 'USA'] as const; -export type WaitlistRegion = (typeof REGIONS)[number]; - -export function normalizeEmail(value: string): string { - return value.trim().toLowerCase(); -} - -export function isValidEmail(value: string): boolean { - const email = normalizeEmail(value); - if (email.length === 0 || email.length > 254) return false; - if (!EMAIL_REGEX.test(email)) return false; - if (isBlockedEmailDomain(email)) return false; - return true; -} - -export function isWaitlistRegion(value: string): value is WaitlistRegion { - return (REGIONS as readonly string[]).includes(value); -} - -export type WaitlistSuccess = { - ok: true; - region: WaitlistRegion; -}; - -export type WaitlistErrorCode = - | 'invalid_email' - | 'invalid_region' - | 'rate_limited' - | 'server_error' - | 'network_error'; - -export type WaitlistError = { - ok: false; - error: WaitlistErrorCode; -}; - -export type WaitlistResponse = WaitlistSuccess | WaitlistError; - -export function parseWaitlistResponse( - status: number, - body: unknown, -): WaitlistResponse { - if (typeof body !== 'object' || body === null) { - return { ok: false, error: 'server_error' }; - } - const data = body as Record; - - if (status === 200 && data.ok === true) { - const rawRegion = typeof data.region === 'string' ? data.region : 'EU'; - const region: WaitlistRegion = isWaitlistRegion(rawRegion) ? rawRegion : 'EU'; - return { - ok: true, - region, - }; - } - - const errorCode = typeof data.error === 'string' ? data.error : ''; - switch (errorCode) { - case 'invalid_email': - case 'invalid_region': - case 'rate_limited': - return { ok: false, error: errorCode }; - case 'server_error': - return { ok: false, error: 'server_error' }; - default: - return { ok: false, error: 'server_error' }; - } -} - -// --------------------------------------------------------------------------- -// Mensajes -// -// Los TEXTOS los pone el componente en `data-msgs` (serializados del -// diccionario, para no arrastrar todo el i18n al bundle de cliente). Lo que vive -// aquí es la DECISIÓN de qué texto toca y con qué forma, que es lo que tiene -// reglas: el rate limit no es un error genérico, y un alta correcta es la -// misma en todas las regiones. -// -// Antes esto estaba dentro del manejador de `submit`, o sea intestable sin DOM. -// --------------------------------------------------------------------------- - -/** Los textos que el componente serializa en data-msgs. */ -export interface WaitlistMessages { - okRegistered: string; - okText: string; - errEmail: string; - errRegion: string; - errRateLimited: string; - errNetwork: string; - errGeneric: string; -} - -/** - * Texto para un fallo, y si el foco debe volver al campo de email. - * - * El backend distingue el rate limit del error genérico: mostrar "algo se ha - * roto" para todo es mentira y no dice qué hacer. - */ -export function waitlistErrorText( - error: WaitlistErrorCode, - m: WaitlistMessages, -): { text: string; focusEmail: boolean } { - switch (error) { - case 'invalid_email': - return { text: m.errEmail, focusEmail: true }; - case 'invalid_region': - return { text: m.errRegion, focusEmail: false }; - case 'rate_limited': - return { text: m.errRateLimited, focusEmail: false }; - case 'network_error': - return { text: m.errNetwork, focusEmail: false }; - default: - return { text: m.errGeneric, focusEmail: false }; - } -} - -/** - * Texto para un alta correcta. - * - * Las tres regiones están abiertas y reciben el mismo mensaje, siempre. No se - * enseña posición: el número del backend es un contador acumulado de llegadas, - * no un puesto en la cola, y enseñarlo confunde. - */ -export function waitlistSuccessText(m: WaitlistMessages): string { - return `${m.okRegistered} ${m.okText}`; -} diff --git a/src/pages/404.astro b/src/pages/404.astro index 2eb461df..56167fbf 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -27,7 +27,7 @@ const home = lang === 'es' ? '/es' : '/';

    {tt.body}

    > NaN === NaN // false

    diff --git a/src/pages/_community.astro b/src/pages/_community.astro index dd73c9c4..97272ac9 100644 --- a/src/pages/_community.astro +++ b/src/pages/_community.astro @@ -1,39 +1,19 @@ --- import NanPage from '../layouts/NanPage.astro'; -import CommunitySignupForm from '../components/landing/CommunitySignupForm.tsx'; +import { signupUrl } from '../lib/signup'; import { getLang, useT, t } from '../lib/i18n'; -// La isla del formulario sigue escrita con utilidades de Tailwind; el tema de -// Tailwind está apuntado a nuestros tokens (ver styles/global.css). +// Tailwind (tema apuntado a nuestros tokens, ver styles/global.css) sigue +// cargado: el reset base de esta página depende de él. import '../styles/global.css'; import { MEMBERS_NOW, MEMBERS_GOAL, membersPct } from '../lib/members'; const lang = getLang(Astro.url); const tt = useT(lang).community; -// El formulario de alta es de este repo, no venía en el rediseño: se conserva -// tal cual, con sus strings, porque es el punto de conversión de la página. -const formStrings = { - emailLabel: t('community.emailLabel', lang), - emailPlaceholder: t('community.emailPlaceholder', lang), - regionLabel: t('community.regionLabel', lang), - regionEU: t('community.regionEU', lang), - regionUSA: t('community.regionUSA', lang), - regionLATAM: t('community.regionLATAM', lang), - regionDefault: t('community.regionDefault', lang), - submit: t('community.submit', lang), - submitting: t('community.submitting', lang), - redirecting: t('community.redirecting', lang), - alreadySubscribed: t('community.alreadySubscribed', lang), - alreadyMemberLabel: t('community.alreadyMemberLabel', lang), - honeypot: t('community.honeypot', lang), - errorInvalidEmail: t('community.errorInvalidEmail', lang), - errorInvalidRegion: t('community.errorInvalidRegion', lang), - errorRateLimited: t('community.errorRateLimited', lang), - errorServer: t('community.errorServer', lang), - errorNetwork: t('community.errorNetwork', lang), -}; - -const joinHref = '#signup'; +// Sin formulario propio: el alta es la de la plataforma (crear cuenta, y la +// comunidad se compra desde ahí con su Checkout por usuario). Ver +// platform/cloud-api/docs/OPEN-SIGNUP.md §4. +const joinHref = signupUrl('community'); // La cuenta de miembros se actualiza a mano al alcanzar un hito y NO se // traduce, así que vive en lib/members y no en el diccionario, donde estaba @@ -49,7 +29,7 @@ const pct = membersPct();

    {tt.heroTitle}

    {tt.heroSub}

    @@ -211,7 +191,7 @@ const pct = membersPct(); {t('community.priceLabel', lang)} {t('community.priceTaxNote', lang)} - + {tt.closeCta} → @@ -353,7 +333,7 @@ const pct = membersPct(); .cclose__price { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-4); } .cclose__amount { font-family: var(--font-mono); font-size: 18px; font-weight: 600; color: var(--color-text); } .cclose__note { font-family: var(--font-mono); font-size: 12px; color: var(--color-text-dim); } - .cclose__footer { margin-top: var(--space-3); font-family: var(--font-mono); font-size: 11px; color: var(--color-muted); text-align: center; } + .cclose__footer { margin-top: var(--space-3); font-family: var(--font-mono); font-size: 11px; color: var(--color-muted); } @media (max-width: 760px) { .pillars { grid-template-columns: 1fr; } diff --git a/src/pages/api/community-signup.ts b/src/pages/api/community-signup.ts deleted file mode 100644 index e4f9f4dd..00000000 --- a/src/pages/api/community-signup.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { APIRoute } from 'astro'; -import { env } from 'cloudflare:workers'; -import { - checkRateLimit, - signupViaBackend, - validateCommunityInput, - type CommunityErrorCode, -} from '../../lib/communitySignup'; - -export const prerender = false; - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { - 'content-type': 'application/json', - 'cache-control': 'no-store', - }, - }); -} - -function errorResponse(code: CommunityErrorCode, status: number): Response { - return json({ ok: false, error: code }, status); -} - -function getClientIp(request: Request): string { - return request.headers.get('cf-connecting-ip') ?? ''; -} - -export const POST: APIRoute = async ({ request }) => { - try { - const contentType = request.headers.get('content-type') ?? ''; - if (!contentType.includes('application/json')) { - return errorResponse('invalid_email', 415); - } - - let raw: unknown; - try { - raw = await request.json(); - } catch { - return errorResponse('invalid_email', 400); - } - - const validation = validateCommunityInput(raw); - if (!validation.ok) { - return errorResponse(validation.error, 400); - } - - const { email, region, honeypot } = validation.input; - - // Honeypot trap: reply 200 OK with a benign URL so bots get no signal. - // Real users never reach this branch. The URL is a no-op landing page. - if (honeypot) { - return json({ - ok: true, - url: 'https://nan.builders/?signup=processed', - }); - } - - const ip = getClientIp(request); - if (!checkRateLimit(ip)) { - return errorResponse('rate_limited', 429); - } - - const result = await signupViaBackend(env.CLOUD_API_URL, { email, region }); - if (!result.ok) { - const status = - result.error === 'already_subscribed' - ? 409 - : result.error === 'rate_limited' - ? 429 - : result.error === 'invalid_email' || result.error === 'invalid_region' - ? 400 - : 500; - return errorResponse(result.error, status); - } - - return json({ ok: true, url: result.url }); - } catch (err) { - // Intentionally opaque — do not leak internals in the response body. - console.error('[api/community-signup] unexpected error', err); - return errorResponse('server_error', 500); - } -}; diff --git a/src/pages/api/signup.ts b/src/pages/api/signup.ts new file mode 100644 index 00000000..fe0a21b1 --- /dev/null +++ b/src/pages/api/signup.ts @@ -0,0 +1,72 @@ +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/pages/api/waitlist.ts b/src/pages/api/waitlist.ts deleted file mode 100644 index 6ccbf790..00000000 --- a/src/pages/api/waitlist.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { APIRoute } from 'astro'; -import { env } from 'cloudflare:workers'; -import { - checkRateLimit, - registerViaBackend, - validateWaitlistInput, - type WaitlistErrorCode, -} from '../../lib/waitlist'; -import { sendConfirmationEmail } from '../../lib/email'; - -export const prerender = false; - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { - 'content-type': 'application/json', - 'cache-control': 'no-store', - }, - }); -} - -function errorResponse(code: WaitlistErrorCode, status: number): Response { - return json({ ok: false, error: code }, status); -} - -function getClientIp(request: Request): string { - return request.headers.get('cf-connecting-ip') ?? ''; -} - -export const POST: APIRoute = async ({ request }) => { - try { - const contentType = request.headers.get('content-type') ?? ''; - if (!contentType.includes('application/json')) { - return errorResponse('invalid_email', 415); - } - - let raw: unknown; - try { - raw = await request.json(); - } catch { - return errorResponse('invalid_email', 400); - } - - const validation = validateWaitlistInput(raw); - if (!validation.ok) { - return errorResponse(validation.error, 400); - } - - const { email, region, honeypot } = validation.input; - // GLM 5.3 premium interest: signups from the premium pricing card - // (?premium=1) carry the flag through to the member row so the admin - // panel can distinguish and invite them for the premium tier. - const wantsPremium: boolean = !!(raw && typeof raw === 'object' && (raw as Record).wantsPremium === true); - - // Honeypot trap: reply 200 OK without persisting so bots get no signal. - if (honeypot) { - return json({ - ok: true, - status: 'registered', - region, - }); - } - - const ip = getClientIp(request); - if (!checkRateLimit(ip)) { - return errorResponse('rate_limited', 429); - } - - const result = await registerViaBackend(env.CLOUD_API_URL, env.CLOUD_API_WAITLIST_KEY, { email, region, wantsPremium }); - - // Send confirmation email (best-effort — don't fail the signup if email fails). - // Must await: Workers terminate after the response, killing in-flight fetches. - if (result.ok && env.RESEND_API_KEY && env.RESEND_FROM_EMAIL) { - try { - await sendConfirmationEmail({ - to: email, - region, - apiKey: env.RESEND_API_KEY, - from: env.RESEND_FROM_EMAIL, - }); - } catch (err) { - console.error('[api/waitlist] confirmation email failed', err); - } - } - - return json(result); - } catch (err) { - // Intentionally opaque — do not leak internals in the response body. - console.error('[api/waitlist] unexpected error', err); - return errorResponse('server_error', 500); - } -}; diff --git a/src/pages/es/privacy.astro b/src/pages/es/privacy.astro index e8f7c40e..c7d80150 100644 --- a/src/pages/es/privacy.astro +++ b/src/pages/es/privacy.astro @@ -2,7 +2,7 @@ import Legal from '../../layouts/NanLegal.astro'; --- - +

    NaN es la comunidad de inferencia operada por Helmcode S.L. (NIF B44788511), Avenida Quinto Centenario 27, Santiago del Teide, España. Contacto del responsable del tratamiento: @@ -12,7 +12,11 @@ import Legal from '../../layouts/NanLegal.astro';

    Qué recopilamos

      -
    • Lista de espera: tu email y tu región, para avisarte cuando se libere una plaza.
    • +
    • + Cuenta: tu nombre completo, email, rol profesional y, solo si los añades, los enlaces a tus perfiles de X + y LinkedIn. Los usamos para crear y gestionar tu cuenta, enviarte enlaces de acceso y saber quién construye + con NaN. Crear la cuenta es gratis; nunca vendemos estos datos. +
    • Membresía: email, handle y datos de facturación, gestionados por nuestro procesador de pagos.
    @@ -26,7 +30,7 @@ import Legal from '../../layouts/NanLegal.astro';

    Base legal

    - Consentimiento (lista de espera), ejecución de un contrato (tu membresía) y nuestro + Ejecución de un contrato (tu cuenta y tu membresía) y nuestro interés legítimo en operar y proteger el servicio.

    @@ -35,10 +39,15 @@ import Legal from '../../layouts/NanLegal.astro';
  • Procesador de pagos, para gestionar las suscripciones bajo su propia política de privacidad.
  • Discord, donde vive la comunidad, bajo sus propios términos.
  • Cloudflare, para seguridad y distribución de contenido.
  • +
  • Un proveedor de envío de email, para mandarte los enlaces de acceso y los emails de tu cuenta.
  • Conservación

    -

    Conservamos los datos personales solo durante el tiempo necesario para la finalidad para la que se recopilaron y para cumplir con nuestras obligaciones legales.

    +

    + Conservamos los datos personales solo durante el tiempo necesario para la finalidad para la que se recopilaron + y para cumplir con nuestras obligaciones legales. Una cuenta cuyo email nunca se confirma se borra automáticamente + a los 7 días. Puedes pedirnos que borremos tu cuenta en cualquier momento. +

    Tus derechos

    diff --git a/src/pages/es/terms.astro b/src/pages/es/terms.astro index 686b0f33..02b71af6 100644 --- a/src/pages/es/terms.astro +++ b/src/pages/es/terms.astro @@ -2,7 +2,7 @@ import Legal from '../../layouts/NanLegal.astro'; --- - +

    NaN está operada por Helmcode S.L. (NIF B44788511), Avenida Quinto Centenario 27, Santiago del Teide, España. Contacto: info@helmcode.com. @@ -11,9 +11,9 @@ import Legal from '../../layouts/NanLegal.astro';

    La comunidad

    - NaN es una comunidad cerrada y de pago de builders que comparten GPUs dedicadas para ejecutar modelos abiertos. El acceso está - limitado a la capacidad real del clúster: cuando se llena, abrimos una lista de espera. Cada miembro recibe una - API key personal e intransferible. + NaN es una comunidad de pago de builders que comparten GPUs dedicadas para ejecutar modelos abiertos. Cualquiera puede + crear una cuenta gratis; la comunidad, la inferencia, los workspaces y el resto de productos solo se activan con un plan + de pago. Cada miembro con inferencia recibe una API key personal e intransferible.

    Uso aceptable

    diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro index 3b50eea5..5637a6d1 100644 --- a/src/pages/privacy.astro +++ b/src/pages/privacy.astro @@ -2,7 +2,7 @@ import Legal from '../layouts/NanLegal.astro'; --- - +

    NaN is the inference community operated by Helmcode S.L. (NIF B44788511), Avenida Quinto Centenario 27, Santiago del Teide, Spain. Data controller contact: @@ -12,7 +12,11 @@ import Legal from '../layouts/NanLegal.astro';

    What we collect

      -
    • Waitlist: your email and region, to let you know when a spot opens.
    • +
    • + Account: your full name, email, job role and, only if you add them, your X and LinkedIn profile + links. We use them to create and run your account, send you sign-in links, and understand who builds + with NaN. Creating an account is free; we never sell this data. +
    • Membership: email, handle and billing details, handled by our payment processor.
    @@ -26,7 +30,7 @@ import Legal from '../layouts/NanLegal.astro';

    Legal basis

    - Consent (waitlist), performance of a contract (your membership) and our + Performance of a contract (your account and your membership) and our legitimate interest in running and securing the service.

    @@ -35,10 +39,15 @@ import Legal from '../layouts/NanLegal.astro';
  • Payment processor, to handle subscriptions under its own privacy policy.
  • Discord, where the community lives, under its own terms.
  • Cloudflare, for security and content delivery.
  • +
  • An email delivery provider, to send your sign-in links and account emails.
  • Retention

    -

    We keep personal data only as long as needed for the purpose collected and to meet our legal obligations.

    +

    + We keep personal data only as long as needed for the purpose collected and to meet our legal obligations. + An account whose email is never confirmed is deleted automatically after 7 days. You can ask us to + delete your account at any time. +

    Your rights

    diff --git a/src/pages/terms.astro b/src/pages/terms.astro index 42f5b85e..e695dac4 100644 --- a/src/pages/terms.astro +++ b/src/pages/terms.astro @@ -2,7 +2,7 @@ import Legal from '../layouts/NanLegal.astro'; --- - +

    NaN is operated by Helmcode S.L. (NIF B44788511), Avenida Quinto Centenario 27, Santiago del Teide, Spain. Contact: info@helmcode.com. @@ -11,9 +11,9 @@ import Legal from '../layouts/NanLegal.astro';

    The community

    - NaN is a closed, paid community of builders sharing dedicated GPUs to run open models. Access is - limited to the real capacity of the cluster: when it fills up, we open a waitlist. Every member gets a - personal, non-transferable API key. + NaN is a paid community of builders sharing dedicated GPUs to run open models. Anyone can create a free + account; the community, inference, workspaces and the rest of the products only switch on with a paid + plan. Every inference member gets a personal, non-transferable API key.

    Acceptable use

    diff --git a/src/scripts/signup.ts b/src/scripts/signup.ts new file mode 100644 index 00000000..306cc750 --- /dev/null +++ b/src/scripts/signup.ts @@ -0,0 +1,159 @@ +/** + * 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/scripts/waitlist.ts b/src/scripts/waitlist.ts deleted file mode 100644 index 20c0e39a..00000000 --- a/src/scripts/waitlist.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * NaN — envío de la waitlist. Cablea todos los . - * POST /api/waitlist {email, region, _hp} → ver src/pages/api/waitlist.ts - * - * La VALIDACIÓN y el PARSEO no viven aquí: se reutilizan los helpers que ya - * usaba la isla Preact (lib/waitlistClient), para que haya - * una sola implementación y la sigan cubriendo sus tests. En particular el - * espejo de dominios bloqueados, que debe coincidir con el de lib/waitlist.ts - * (el servidor es la autoridad; el cliente solo evita un viaje de ida y vuelta). - * - * Los TEXTOS sí son de aquí: el componente los serializa en data-msgs desde el - * diccionario, para no arrastrar todo el i18n al bundle de cliente. - */ -import { - isValidEmail, - isWaitlistRegion, - normalizeEmail, - parseWaitlistResponse, - waitlistErrorText, - waitlistSuccessText, -} from '../lib/waitlistClient'; - -interface Msgs { - sending: string; - okRegistered: string; - okText: string; - errEmail: string; - errRegion: string; - errRateLimited: string; - errNetwork: string; - errGeneric: string; -} - -const FALLBACK: Msgs = { - sending: 'Sending…', - okRegistered: "You're on the waitlist.", - okText: "We'll approve your spot in the next few days.", - errEmail: 'That email does not look valid.', - errRegion: 'Pick a region.', - errRateLimited: 'Too many attempts. Wait a minute.', - 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); - const email = form.querySelector('input[name="email"]'); - - /** Error: lo anuncia el live region, marca el campo y le devuelve el foco. */ - const fail = (msg: string, focus?: HTMLElement | null) => { - status.textContent = msg; - submit.disabled = false; - if (focus) { - focus.setAttribute('aria-invalid', 'true'); - focus.focus(); - } - }; - - form.addEventListener('submit', async (e) => { - e.preventDefault(); - const data = new FormData(form); - const value = normalizeEmail(String(data.get('email') ?? '')); - const region = String(data.get('region') ?? ''); - const hp = String(data.get('_hp') ?? ''); - - email?.removeAttribute('aria-invalid'); - - // Mismo criterio que la isla Preact: formato, longitud y dominios de - // ejemplo o desechables. - if (!isValidEmail(value)) { - fail(t.errEmail, email); - return; - } - if (form.querySelector('[name="region"]') && !isWaitlistRegion(region)) { - fail(t.errRegion, form.querySelector('[data-region] button')); - return; - } - - submit.disabled = true; - status.textContent = t.sending; - - let res: Response; - try { - // Premium pricing card lands here with ?premium=1: carry the interest - // flag so the member row is marked glm52_interested (the admin panel - // distinguishes and invites them for the premium tier; the backend field - // keeps the 5.2 name from when that was the model it served). - const wantsPremium = new URLSearchParams(window.location.search).get('premium') === '1'; - res = await fetch('/api/waitlist', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: value, region, _hp: hp, wantsPremium }), - }); - } catch { - fail(t.errNetwork); - return; - } - - const body = await res.json().catch(() => null); - const result = parseWaitlistResponse(res.status, body); - - if (!result.ok) { - // Qué texto toca y si el foco vuelve al campo se decide en - // lib/waitlistClient, que sí tiene tests. Aquí queda el cableado del DOM. - const { text, focusEmail } = waitlistErrorText(result.error, t); - fail(text, focusEmail ? email : null); - return; - } - - form.querySelectorAll('input, select, button').forEach((el) => ((el as HTMLInputElement).disabled = true)); - - status.textContent = waitlistSuccessText(t); - }); -} - -export function initWaitlist(): void { - document.querySelectorAll('form[data-waitlist]').forEach(wire); -} diff --git a/src/tests/api/community-signup.test.ts b/src/tests/api/community-signup.test.ts deleted file mode 100644 index 80b163ae..00000000 --- a/src/tests/api/community-signup.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('cloudflare:workers', () => ({ - env: { - CLOUD_API_URL: 'https://cloud-api.nan.builders', - }, -})); - -import { POST } from '../../pages/api/community-signup'; -import { __resetRateLimitForTests } from '../../lib/communitySignup'; - -let ipCounter = 0; - -function nextIp(): string { - ipCounter++; - return `10.${(ipCounter >> 16) & 255}.${(ipCounter >> 8) & 255}.${ipCounter & 255}`; -} - -function jsonRequest(body: unknown, headers: Record = {}): Request { - return new Request('https://nan.builders/api/community-signup', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'cf-connecting-ip': nextIp(), - ...headers, - }, - body: JSON.stringify(body), - }); -} - -async function callPost(body: unknown, headers: Record = {}) { - const request = jsonRequest(body, headers); - const response = await (POST as any)({ request }); - const payload = await response.json(); - return { status: response.status, payload }; -} - -function mockBackend(status: number, payload: unknown) { - return vi.fn().mockResolvedValue({ - status, - json: () => Promise.resolve(payload), - }) as unknown as typeof fetch; -} - -describe('POST /api/community-signup', () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - __resetRateLimitForTests(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('returns the Stripe Checkout URL on success', async () => { - globalThis.fetch = mockBackend(200, { url: 'https://checkout.stripe.com/c/pay/abc' }); - - const { status, payload } = await callPost({ email: 'alice@acme.co', region: 'EU' }); - expect(status).toBe(200); - expect(payload).toEqual({ ok: true, url: 'https://checkout.stripe.com/c/pay/abc' }); - }); - - it('returns 409 when the backend says already_subscribed', async () => { - globalThis.fetch = mockBackend(409, { error: 'already_subscribed' }); - - const { status, payload } = await callPost({ email: 'alice@acme.co', region: 'EU' }); - expect(status).toBe(409); - expect(payload).toEqual({ ok: false, error: 'already_subscribed' }); - }); - - it('returns invalid_email for malformed addresses', async () => { - globalThis.fetch = mockBackend(200, { url: 'unused' }); - - const { status, payload } = await callPost({ email: 'not-an-email', region: 'EU' }); - expect(status).toBe(400); - expect(payload).toEqual({ ok: false, error: 'invalid_email' }); - }); - - it('returns invalid_region for unknown region', async () => { - globalThis.fetch = mockBackend(200, { url: 'unused' }); - - const { status, payload } = await callPost({ email: 'alice@acme.co', region: 'AF' }); - expect(status).toBe(400); - expect(payload).toEqual({ ok: false, error: 'invalid_region' }); - }); - - it('rejects non-JSON content-type with 415', async () => { - const request = new Request('https://nan.builders/api/community-signup', { - method: 'POST', - headers: { 'cf-connecting-ip': nextIp() }, - body: 'email=alice@acme.co', - }); - const response = await (POST as any)({ request }); - expect(response.status).toBe(415); - }); - - it('rejects invalid JSON body with 400', async () => { - const request = new Request('https://nan.builders/api/community-signup', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'cf-connecting-ip': nextIp(), - }, - body: '{not json', - }); - const response = await (POST as any)({ request }); - expect(response.status).toBe(400); - }); - - it('traps honeypot submissions without calling backend', async () => { - const fetchMock = mockBackend(200, { url: 'real-url' }); - globalThis.fetch = fetchMock; - - const { status, payload } = await callPost({ - email: 'bot@acme.co', - region: 'EU', - _hp: 'spam', - }); - expect(status).toBe(200); - expect(payload.ok).toBe(true); - // The honeypot URL is a benign nan.builders URL, not the Stripe one. - expect(payload.url).toContain('nan.builders'); - // Backend should not have been called. - expect((fetchMock as unknown as ReturnType).mock.calls.length).toBe(0); - }); - - it('rate-limits repeat attempts from the same IP', async () => { - globalThis.fetch = mockBackend(200, { url: 'https://checkout.stripe.com/c/x' }); - - const sameIp = '99.99.99.99'; - const first = await callPost({ email: 'first@acme.co', region: 'EU' }, { 'cf-connecting-ip': sameIp }); - expect(first.status).toBe(200); - - const second = await callPost({ email: 'second@acme.co', region: 'EU' }, { 'cf-connecting-ip': sameIp }); - expect(second.status).toBe(429); - expect(second.payload).toEqual({ ok: false, error: 'rate_limited' }); - }); - - it('forwards content-type and origin to the backend (no X-API-Key)', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - status: 200, - json: () => Promise.resolve({ url: 'https://checkout.stripe.com/c/x' }), - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - await callPost({ email: 'check@acme.co', region: 'EU' }); - - const call = (fetchMock as unknown as ReturnType).mock.calls[0]; - expect(call[0]).toBe('https://cloud-api.nan.builders/api/community/signup'); - const headers = (call[1] as RequestInit).headers as Record; - expect(headers['Content-Type']).toBe('application/json'); - expect(headers['X-API-Key']).toBeUndefined(); - }); - - it('returns server_error when the backend fails', async () => { - globalThis.fetch = mockBackend(500, { error: 'internal' }); - - const { status, payload } = await callPost({ email: 'fail@acme.co', region: 'EU' }); - expect(status).toBe(500); - expect(payload).toEqual({ ok: false, error: 'server_error' }); - }); -}); diff --git a/src/tests/api/signup.test.ts b/src/tests/api/signup.test.ts new file mode 100644 index 00000000..039f6d7d --- /dev/null +++ b/src/tests/api/signup.test.ts @@ -0,0 +1,338 @@ +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(/