From 8685c41aa9db5b269765905248197ec827d25ed8 Mon Sep 17 00:00:00 2001 From: barckcode Date: Thu, 15 Oct 2026 02:08:33 +0200 Subject: [PATCH 1/8] feat(signup): /api/signup Worker route proxying to cloud-api open signup Shared validation in lib/signup.ts mirrors POST /api/auth/signup (OPEN-SIGNUP.md 2.3): full name, email, job role enum with 'other' free text, optional X/LinkedIn profile URLs normalized to the accepted shapes, plan from the enum. The route validates, drops honeypot hits with the same 202, rate limits per IP (5 per 10 min, valid submissions only) and proxies to the public endpoint with Origin https://nan.builders and the client IP. Every upstream success is the same 202; 400 field errors are sanitized; 429 passes through; anything else is an opaque 502. Co-Authored-By: Claude Opus 5.5 --- src/lib/signup.ts | 280 +++++++++++++++++++++++++++++++++++ src/lib/signupServer.ts | 80 ++++++++++ src/pages/api/signup.ts | 54 +++++++ src/tests/api/signup.test.ts | 246 ++++++++++++++++++++++++++++++ src/tests/lib/signup.test.ts | 256 ++++++++++++++++++++++++++++++++ 5 files changed, 916 insertions(+) create mode 100644 src/lib/signup.ts create mode 100644 src/lib/signupServer.ts create mode 100644 src/pages/api/signup.ts create mode 100644 src/tests/api/signup.test.ts create mode 100644 src/tests/lib/signup.test.ts diff --git a/src/lib/signup.ts b/src/lib/signup.ts new file mode 100644 index 00000000..b80b3d49 --- /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. +const BLOCKED_EMAIL_DOMAINS: ReadonlySet = new Set([ + 'example.com', + 'example.net', + 'example.org', + 'test.com', + 'mail.com', +]); +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/signupServer.ts b/src/lib/signupServer.ts new file mode 100644 index 00000000..5a1265d0 --- /dev/null +++ b/src/lib/signupServer.ts @@ -0,0 +1,80 @@ +/** + * 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; +} + +/** 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. + */ +export async function forwardSignup(apiBase: string, input: SignupInput, ip: string): Promise { + const headers = new Headers({ 'content-type': 'application/json', origin: 'https://nan.builders' }); + if (ip) { + headers.set('x-forwarded-for', ip); + headers.set('cf-connecting-ip', ip); + } + + let res: Response; + try { + if (!apiBase) throw new Error('CLOUD_API_URL is not set'); + res = await fetch(`${apiBase.replace(/\/$/, '')}/api/auth/signup`, { + method: 'POST', + headers, + body: JSON.stringify({ ...input, _hp: '' }), + }); + } catch (err) { + console.error('[api/signup] upstream unreachable', err instanceof Error ? err.message : 'error'); + return { kind: 'error' }; + } + + // Every success is the same answer: the backend never tells new from known. + if (res.ok) return { kind: 'accepted' }; + if (res.status === 429) return { kind: 'rate_limited' }; + if (res.status === 400) { + const body = (await res.json().catch(() => null)) as Record | null; + const fields = sanitizeFieldErrors(body?.fields); + if (typeof body?.field === 'string') Object.assign(fields, sanitizeFieldErrors({ [body.field]: 'invalid' })); + return { kind: 'invalid', fields }; + } + console.error('[api/signup] upstream status', res.status); + return { kind: 'error' }; +} diff --git a/src/pages/api/signup.ts b/src/pages/api/signup.ts new file mode 100644 index 00000000..bc0ea2ae --- /dev/null +++ b/src/pages/api/signup.ts @@ -0,0 +1,54 @@ +import type { APIRoute } from 'astro'; +import { env } from 'cloudflare:workers'; +import { json } from '../../lib/apiResponse'; +import { validateSignup } from '../../lib/signup'; +import { checkSignupRateLimit, forwardSignup } 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); + +export const POST: APIRoute = async ({ request }) => { + if (!(request.headers.get('content-type') ?? '').includes('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 out = await forwardSignup(env.CLOUD_API_URL, v.input, ip); + 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/tests/api/signup.test.ts b/src/tests/api/signup.test.ts new file mode 100644 index 00000000..3ec85d6a --- /dev/null +++ b/src/tests/api/signup.test.ts @@ -0,0 +1,246 @@ +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 } = {}) { + return new Request('https://nan.builders/api/signup', { + method: 'POST', + headers: { + 'content-type': opts.contentType ?? 'application/json', + 'cf-connecting-ip': opts.ip ?? nextIp(), + }, + 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) }); + return { status: res.status, body: await res.json(), headers: res.headers }; +} + +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' }); + + 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'); + // 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('rejects a non-JSON content type with 415', async () => { + const fetchMock = upstream(202); + vi.stubGlobal('fetch', fetchMock); + const r = await call(valid, { contentType: 'text/plain' }); + expect(r.status).toBe(415); + expect(r.body.ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects malformed JSON with 400', async () => { + const fetchMock = upstream(202); + vi.stubGlobal('fetch', fetchMock); + const r = await call(null, { raw: '{"fullName":' }); + expect(r.status).toBe(400); + expect(r.body.error).toBe('invalid_input'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects an oversized body with 413 without parsing it', async () => { + const fetchMock = upstream(202); + vi.stubGlobal('fetch', fetchMock); + const r = await call(null, { raw: JSON.stringify({ ...valid, fullName: 'a'.repeat(20_000) }) }); + expect(r.status).toBe(413); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('POST /api/signup: honeypot', () => { + it('a filled _hp answers the same 202 and never reaches cloud-api', async () => { + const fetchMock = upstream(202); + vi.stubGlobal('fetch', fetchMock); + const r = await call({ ...valid, _hp: 'http://spam' }); + expect(r.status).toBe(202); + expect(r.body).toEqual({ ok: true }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('POST /api/signup: per-IP rate limit', () => { + it(`allows ${SIGNUP_RATE_LIMIT.limit} signups per window per IP, then 429 without calling upstream`, async () => { + const fetchMock = upstream(202); + vi.stubGlobal('fetch', fetchMock); + const ip = '198.51.100.1'; + for (let i = 0; i < SIGNUP_RATE_LIMIT.limit; i++) { + expect((await call(valid, { ip })).status).toBe(202); + } + const blocked = await call(valid, { ip }); + expect(blocked.status).toBe(429); + expect(blocked.body).toEqual({ ok: false, error: 'rate_limited' }); + expect(fetchMock).toHaveBeenCalledTimes(SIGNUP_RATE_LIMIT.limit); + + // another IP is unaffected + expect((await call(valid, { ip: '198.51.100.2' })).status).toBe(202); + }); + + it('the window expires', async () => { + vi.stubGlobal('fetch', upstream(202)); + vi.useFakeTimers(); + try { + const ip = '198.51.100.3'; + for (let i = 0; i < SIGNUP_RATE_LIMIT.limit; i++) await call(valid, { ip }); + expect((await call(valid, { ip })).status).toBe(429); + vi.advanceTimersByTime(SIGNUP_RATE_LIMIT.windowMs + 1); + expect((await call(valid, { ip })).status).toBe(202); + } finally { + vi.useRealTimers(); + } + }); + + it('invalid submissions do not burn the quota (a person fixing typos is not throttled)', async () => { + vi.stubGlobal('fetch', upstream(202)); + const ip = '198.51.100.4'; + for (let i = 0; i < SIGNUP_RATE_LIMIT.limit + 3; i++) { + expect((await call({ ...valid, email: 'typo' }, { ip })).status).toBe(400); + } + expect((await call(valid, { ip })).status).toBe(202); + }); +}); + +describe('POST /api/signup: misconfiguration', () => { + it('a missing CLOUD_API_URL is an opaque 502, not a crash', async () => { + const { forwardSignup } = await import('../../lib/signupServer'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const out = await forwardSignup('', { + fullName: 'Ada', email: 'ada@helmcode.com', jobRole: 'founder', jobRoleOther: '', xUrl: '', linkedinUrl: '', + }, ''); + expect(out).toEqual({ kind: 'error' }); + }); +}); diff --git a/src/tests/lib/signup.test.ts b/src/tests/lib/signup.test.ts new file mode 100644 index 00000000..739e0b92 --- /dev/null +++ b/src/tests/lib/signup.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from 'vitest'; +import { + JOB_ROLES, + PLANS, + SIGNUP_BASE_URL, + normalizeSocialUrl, + parseSignupResponse, + planFromSearch, + signupErrorText, + signupUrl, + validateSignup, + type SignupMessages, +} from '../../lib/signup'; + +/** + * Spec: platform/cloud-api/docs/OPEN-SIGNUP.md §2.3 (POST /api/auth/signup). + * The site mirrors the backend rules so a person sees the error next to the + * field instead of after a round-trip. The backend stays the authority. + */ + +const valid = { + fullName: 'Ada Lovelace', + email: 'ada@helmcode.com', + jobRole: 'ai_engineer', + jobRoleOther: '', + xUrl: '', + linkedinUrl: '', +}; + +function fieldsOf(raw: unknown) { + const r = validateSignup(raw); + return r.ok ? {} : r.fields; +} + +describe('enums match the spec', () => { + test('job roles', () => { + expect([...JOB_ROLES]).toEqual([ + 'software_engineer', 'ai_engineer', 'data_scientist', 'devops_sre', + 'founder', 'product', 'designer', 'researcher', 'student', 'other', + ]); + }); + + test('plans', () => { + expect([...PLANS]).toEqual(['community', 'inference', 'premium', 'workspace', 'space']); + }); +}); + +describe('validateSignup: required fields', () => { + test('a complete minimal payload is valid and normalized', () => { + const r = validateSignup({ ...valid, fullName: ' Ada Lovelace ', email: ' Ada@Helmcode.com ' }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.input).toEqual({ + fullName: 'Ada Lovelace', + email: 'ada@helmcode.com', + jobRole: 'ai_engineer', + jobRoleOther: '', + xUrl: '', + linkedinUrl: '', + }); + expect(r.honeypot).toBe(false); + }); + + test('non-object bodies fail on every required field', () => { + for (const raw of [null, 'x', 42, [], undefined]) { + expect(fieldsOf(raw)).toMatchObject({ fullName: 'required', email: 'required', jobRole: 'required' }); + } + }); + + test('fullName: required, 1-120 chars, no control chars', () => { + expect(fieldsOf({ ...valid, fullName: '' }).fullName).toBe('required'); + expect(fieldsOf({ ...valid, fullName: ' ' }).fullName).toBe('required'); + expect(fieldsOf({ ...valid, fullName: 'a'.repeat(120) }).fullName).toBeUndefined(); + expect(fieldsOf({ ...valid, fullName: 'a'.repeat(121) }).fullName).toBe('too_long'); + expect(fieldsOf({ ...valid, fullName: 'Ada\nLovelace' }).fullName).toBe('invalid'); + expect(fieldsOf({ ...valid, fullName: 42 }).fullName).toBe('required'); + // counted in characters, not UTF-16 units + expect(fieldsOf({ ...valid, fullName: 'ñ'.repeat(120) }).fullName).toBeUndefined(); + expect(fieldsOf({ ...valid, fullName: '😀'.repeat(120) }).fullName).toBeUndefined(); + }); + + test('email: required, valid format, length, blocked domains', () => { + expect(fieldsOf({ ...valid, email: '' }).email).toBe('required'); + expect(fieldsOf({ ...valid, email: 'not-an-email' }).email).toBe('invalid'); + expect(fieldsOf({ ...valid, email: 'a'.repeat(250) + '@helmcode.com' }).email).toBe('invalid'); + for (const d of ['example.com', 'example.net', 'example.org', 'test.com', 'mail.com', 'x.test', 'x.invalid', 'x.localhost', 'x.example']) { + expect(fieldsOf({ ...valid, email: `someone@${d}` }).email, d).toBe('invalid'); + } + }); + + test('jobRole: required enum', () => { + expect(fieldsOf({ ...valid, jobRole: '' }).jobRole).toBe('required'); + expect(fieldsOf({ ...valid, jobRole: 'ceo' }).jobRole).toBe('invalid'); + for (const role of JOB_ROLES.filter((r) => r !== 'other')) { + expect(validateSignup({ ...valid, jobRole: role }).ok, role).toBe(true); + } + }); + + test('jobRoleOther: required (1-80) iff jobRole is other', () => { + expect(fieldsOf({ ...valid, jobRole: 'other', jobRoleOther: '' }).jobRoleOther).toBe('required'); + expect(fieldsOf({ ...valid, jobRole: 'other', jobRoleOther: ' ' }).jobRoleOther).toBe('required'); + expect(fieldsOf({ ...valid, jobRole: 'other', jobRoleOther: 'b'.repeat(81) }).jobRoleOther).toBe('too_long'); + const ok = validateSignup({ ...valid, jobRole: 'other', jobRoleOther: ' Sound engineer ' }); + expect(ok.ok && ok.input.jobRoleOther).toBe('Sound engineer'); + }); + + test('jobRoleOther is dropped when the role is not other', () => { + const r = validateSignup({ ...valid, jobRole: 'founder', jobRoleOther: 'leftover text' }); + expect(r.ok && r.input.jobRoleOther).toBe(''); + }); + + test('reports every invalid field at once', () => { + expect(Object.keys(fieldsOf({ fullName: '', email: 'x', jobRole: 'nope', xUrl: 'https://evil.com/a' })).sort()) + .toEqual(['email', 'fullName', 'jobRole', 'xUrl']); + }); +}); + +describe('validateSignup: optional social URLs', () => { + test('empty is fine', () => { + expect(validateSignup({ ...valid, xUrl: ' ', linkedinUrl: '' }).ok).toBe(true); + }); + + test('x.com and twitter.com profiles are accepted', () => { + for (const u of ['https://x.com/ada', 'https://twitter.com/ada', 'https://x.com/ada?s=21']) { + expect(fieldsOf({ ...valid, xUrl: u }).xUrl, u).toBeUndefined(); + } + }); + + test('X: anything else is invalid', () => { + for (const u of [ + 'https://evil.com/ada', 'https://x.com', 'https://x.com/', 'https://x.com.evil.com/ada', + 'javascript:alert(1)', 'https://user:pass@x.com/ada', 'https://x.com:8443/ada', 'ftp://x.com/ada', + ]) { + expect(fieldsOf({ ...valid, xUrl: u }).xUrl, u).toBe('invalid'); + } + }); + + test('LinkedIn: only /in/ profiles on linkedin.com', () => { + for (const u of ['https://linkedin.com/in/ada', 'https://www.linkedin.com/in/ada/']) { + expect(fieldsOf({ ...valid, linkedinUrl: u }).linkedinUrl, u).toBeUndefined(); + } + for (const u of [ + 'https://linkedin.com/company/nan', 'https://linkedin.com/in/', 'https://evil.com/in/ada', + 'https://linkedin.com.evil.com/in/ada', 'data:text/html,hi', + ]) { + expect(fieldsOf({ ...valid, linkedinUrl: u }).linkedinUrl, u).toBe('invalid'); + } + }); + + test('URLs are normalized to the exact shape the backend accepts', () => { + const r = validateSignup({ + ...valid, + xUrl: 'www.twitter.com/ada', + linkedinUrl: 'http://es.linkedin.com/in/ada', + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.input.xUrl).toBe('https://twitter.com/ada'); + expect(r.input.linkedinUrl).toBe('https://www.linkedin.com/in/ada'); + }); + + test('overlong URLs are rejected', () => { + expect(fieldsOf({ ...valid, xUrl: 'https://x.com/' + 'a'.repeat(300) }).xUrl).toBe('too_long'); + }); +}); + +describe('normalizeSocialUrl', () => { + test('adds https, upgrades http, trims', () => { + expect(normalizeSocialUrl(' x.com/ada ')).toBe('https://x.com/ada'); + expect(normalizeSocialUrl('http://x.com/ada')).toBe('https://x.com/ada'); + expect(normalizeSocialUrl('')).toBe(''); + }); +}); + +describe('honeypot', () => { + test('a filled _hp is flagged, not rejected (bots get no signal)', () => { + const r = validateSignup({ ...valid, _hp: 'I am a bot' }); + expect(r.ok && r.honeypot).toBe(true); + }); +}); + +describe('plan', () => { + test('planFromSearch reads ?plan= from the enum only', () => { + expect(planFromSearch('?plan=premium')).toBe('premium'); + expect(planFromSearch('?plan=workspace')).toBe('workspace'); + expect(planFromSearch('?plan=gold')).toBeUndefined(); + expect(planFromSearch('')).toBeUndefined(); + }); + + test('legacy ?premium=1 links (old pricing card) still mean premium', () => { + expect(planFromSearch('?premium=1')).toBe('premium'); + }); + + test('validateSignup keeps a valid plan and silently drops an invalid one', () => { + const a = validateSignup({ ...valid, plan: 'community' }); + expect(a.ok && a.input.plan).toBe('community'); + const b = validateSignup({ ...valid, plan: 'gold' }); + expect(b.ok).toBe(true); + expect(b.ok && 'plan' in b.input).toBe(false); + }); + + test('signupUrl points at the platform signup', () => { + expect(SIGNUP_BASE_URL).toBe('https://cloud.nan.builders/signup'); + expect(signupUrl()).toBe('https://cloud.nan.builders/signup'); + expect(signupUrl('premium')).toBe('https://cloud.nan.builders/signup?plan=premium'); + }); +}); + +describe('parseSignupResponse', () => { + test('202 is success', () => { + expect(parseSignupResponse(202, { ok: true })).toEqual({ ok: true }); + }); + + test('400 carries field errors, sanitized', () => { + expect(parseSignupResponse(400, { ok: false, error: 'invalid_input', fields: { email: 'invalid', evil: 'x', xUrl: 'weird' } })) + .toEqual({ ok: false, error: 'invalid_input', fields: { email: 'invalid', xUrl: 'invalid' } }); + expect(parseSignupResponse(400, null)).toEqual({ ok: false, error: 'invalid_input', fields: {} }); + }); + + test('429 is rate limited', () => { + expect(parseSignupResponse(429, { ok: false, error: 'rate_limited' })).toEqual({ ok: false, error: 'rate_limited' }); + }); + + test('anything else is a server error', () => { + expect(parseSignupResponse(500, {})).toEqual({ ok: false, error: 'server_error' }); + expect(parseSignupResponse(502, null)).toEqual({ ok: false, error: 'server_error' }); + expect(parseSignupResponse(200, 'nope')).toEqual({ ok: false, error: 'server_error' }); + }); +}); + +describe('signupErrorText', () => { + const m: SignupMessages = { + errFullName: 'name', errEmail: 'email', errJobRole: 'role', errJobRoleOther: 'other', + errXUrl: 'x', errLinkedinUrl: 'li', errFields: 'fields', errRateLimited: 'rate', + errNetwork: 'net', errGeneric: 'generic', + }; + + test('maps each failure to its message', () => { + expect(signupErrorText({ ok: false, error: 'rate_limited' }, m)).toBe('rate'); + expect(signupErrorText({ ok: false, error: 'server_error' }, m)).toBe('generic'); + expect(signupErrorText({ ok: false, error: 'network_error' }, m)).toBe('net'); + expect(signupErrorText({ ok: false, error: 'invalid_input', fields: {} }, m)).toBe('fields'); + expect(signupErrorText({ ok: false, error: 'invalid_input', fields: { email: 'invalid' } }, m)).toBe('fields'); + }); +}); + +describe('fieldErrorText', () => { + test('each field has its own message', async () => { + const { fieldErrorText, SIGNUP_FIELDS } = await import('../../lib/signup'); + const m = { + errFullName: 'a', errEmail: 'b', errJobRole: 'c', errJobRoleOther: 'd', errXUrl: 'e', + errLinkedinUrl: 'f', errFields: 'g', errRateLimited: 'h', errNetwork: 'i', errGeneric: 'j', + }; + expect(SIGNUP_FIELDS.map((f) => fieldErrorText(f, m))).toEqual(['a', 'b', 'c', 'd', 'e', 'f']); + }); +}); From 9d7913b10f4ac4ee07b3858d005b35def33bdf73 Mon Sep 17 00:00:00 2001 From: barckcode Date: Thu, 15 Oct 2026 02:08:33 +0200 Subject: [PATCH 2/8] feat(home): hero signup form replaces the waitlist The hero form now creates an account: full name, email, job role (with 'Other' free text revealed on demand), optional X and LinkedIn profiles, honeypot, required markers, per-field errors and a 'check your inbox' success state. Still a vanilla script, no island; ?plan= (and the legacy ?premium=1) is carried into the payload. Copy rewritten in EN and ES. Removes the waitlist route, its client/server libs, the Resend confirmation email and their tests. cloud-api now sends the English magic link, so RESEND_* and CLOUD_API_WAITLIST_KEY are unused here. Co-Authored-By: Claude Opus 5.5 --- .env.example | 10 +- i18n/en.json | 119 ++++----- i18n/es.json | 119 ++++----- src/components/nan/CookieBanner.astro | 2 +- src/components/nan/home/Hero.astro | 183 ++++++++----- src/env.d.ts | 3 - src/layouts/NanBase.astro | 2 +- src/layouts/NanPage.astro | 4 +- src/lib/email.ts | 74 ------ src/lib/rateLimits.ts | 2 +- src/lib/waitlist.ts | 201 -------------- src/lib/waitlistClient.ts | 169 ------------ src/pages/api/waitlist.ts | 93 ------- src/scripts/signup.ts | 159 +++++++++++ src/scripts/waitlist.ts | 130 --------- src/tests/api/waitlist.test.ts | 307 ---------------------- src/tests/landing/signupForm.test.ts | 148 +++++++++++ src/tests/landing/waitlistVanilla.test.ts | 110 -------- src/tests/lib/email.test.ts | 105 -------- src/tests/lib/middleware.test.ts | 4 +- src/tests/lib/waitlist.test.ts | 247 ----------------- src/tests/lib/waitlistClient.test.ts | 192 -------------- 22 files changed, 555 insertions(+), 1828 deletions(-) delete mode 100644 src/lib/email.ts delete mode 100644 src/lib/waitlist.ts delete mode 100644 src/lib/waitlistClient.ts delete mode 100644 src/pages/api/waitlist.ts create mode 100644 src/scripts/signup.ts delete mode 100644 src/scripts/waitlist.ts delete mode 100644 src/tests/api/waitlist.test.ts create mode 100644 src/tests/landing/signupForm.test.ts delete mode 100644 src/tests/landing/waitlistVanilla.test.ts delete mode 100644 src/tests/lib/email.test.ts delete mode 100644 src/tests/lib/waitlist.test.ts delete mode 100644 src/tests/lib/waitlistClient.test.ts 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/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/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/home/Hero.astro b/src/components/nan/home/Hero.astro index 986efadb..69772fb1 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/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/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/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/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/landing/openSignup.test.ts b/src/tests/landing/openSignup.test.ts new file mode 100644 index 00000000..0d29a637 --- /dev/null +++ b/src/tests/landing/openSignup.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from 'vitest'; +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative, resolve } from 'node:path'; +import { t } from '../../lib/i18n'; +import enData from '../../../i18n/en.json' with { type: 'json' }; +import esData from '../../../i18n/es.json' with { type: 'json' }; + +/** + * Open signup (OPEN-SIGNUP.md §4): the waitlist is gone from the site. Every + * CTA goes to account creation, and no copy may promise a waitlist that no + * longer exists. The only exception is the events reserve pool, which is a + * real, separate waitlist for event seats. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const src = resolve(here, '../..'); +const read = (p: string) => readFileSync(resolve(src, p), 'utf-8'); + +const WAITLIST = /wait[\s-]?list|lista de espera|cola de espera|spot in line|plaza reservada en la cola/i; + +function flatten(node: unknown, path: string[] = [], out = new Map()) { + if (typeof node === 'string') out.set(path.join('.'), node); + else if (Array.isArray(node)) node.forEach((v, i) => flatten(v, [...path, String(i)], out)); + else if (node && typeof node === 'object') { + for (const [k, v] of Object.entries(node)) flatten(v, [...path, k], out); + } + return out; +} + +/** The events pool copy is the one legitimate waitlist on the site. */ +const isEventsPool = (key: string) => /^(nan\.)?events\./.test(key); + +describe('no waitlist copy left', () => { + test.each([['en', enData], ['es', esData]] as const)('%s dictionary', (_locale, data) => { + const offenders = [...flatten(data)].filter(([k, v]) => !isEventsPool(k) && WAITLIST.test(v)).map(([k]) => k); + expect(offenders).toEqual([]); + }); + + test('the events pool copy is untouched', () => { + expect(t('events.capacity.poolExplain', 'en')).toMatch(/waitlist/i); + expect(t('events.capacity.poolExplain', 'es')).toMatch(/lista de espera/i); + }); + + function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) { + if (['tests', 'content', '__fixtures__', 'data'].includes(name)) continue; + walk(p, out); + } else if (/\.(astro|ts|tsx)$/.test(name) && !name.endsWith('.test.ts')) { + out.push(p); + } + } + return out; + } + + test('no page, component, script or lib mentions the waitlist', () => { + const offenders = walk(src) + .filter((f) => WAITLIST.test(readFileSync(f, 'utf-8'))) + .map((f) => relative(src, f)); + expect(offenders).toEqual([]); + }); + + test('no link targets the old #waitlist anchor', () => { + const offenders = walk(src) + .filter((f) => readFileSync(f, 'utf-8').includes('#waitlist')) + .map((f) => relative(src, f)); + expect(offenders).toEqual([]); + }); +}); + +describe('CTAs go to account creation', () => { + const pricing = read('components/nan/home/Pricing.astro'); + const tiers = pricing.slice(0, pricing.indexOf('---', 4)); + + test('pricing tiers link to the platform signup with their plan', () => { + expect(tiers).toContain("href: signupUrl('premium')"); + expect(tiers).toContain("href: signupUrl('inference')"); + expect(tiers).toContain("href: signupUrl('community')"); + expect(tiers).not.toMatch(/premium=1|#signup|\/community#/); + }); + + test('the workspaces block has a signup CTA with plan=workspace', () => { + expect(pricing).toContain("signupUrl('workspace')"); + expect(t('nan.pricing.workspaces.signupCta', 'en')).not.toBe('nan.pricing.workspaces.signupCta'); + expect(t('nan.pricing.workspaces.signupCta', 'es')).not.toBe('nan.pricing.workspaces.signupCta'); + }); + + test('nav shows "Create account" next to Login', () => { + const nav = read('components/nan/Nav.astro'); + const login = nav.indexOf('{tt.login}'); + const signup = nav.indexOf('{tt.signup}'); + expect(signup).toBeGreaterThan(-1); + expect(login).toBeGreaterThan(-1); + expect(nav).toContain('href={signupUrl()}'); + expect(t('nan.nav.signup', 'en')).toBe('Create account'); + expect(t('nan.nav.signup', 'es')).toBe('Crear cuenta'); + }); + + test('models, footer and 404 point at the hero signup form', () => { + expect(read('components/nan/home/Models.astro')).toContain('${home}#signup'); + expect(read('components/nan/Footer.astro')).toContain('${home}#signup'); + expect(read('pages/404.astro')).toContain('${home}#signup'); + }); + + test('/community sends people to the platform signup with plan=community', () => { + const page = read('pages/_community.astro'); + expect(page).toContain("signupUrl('community')"); + expect(page).not.toContain('CommunitySignupForm'); + }); + + test('the payment-first community signup is deleted', () => { + for (const f of ['components/landing/CommunitySignupForm.tsx', 'pages/api/community-signup.ts', 'lib/communitySignup.ts']) { + expect(existsSync(resolve(src, f)), f).toBe(false); + } + }); +}); + +describe('legal pages cover the account data', () => { + test.each([['privacy.astro'], ['es/privacy.astro']])('%s lists name, email, job role and social profiles', (file) => { + const page = read(`pages/${file}`); + expect(page).not.toMatch(WAITLIST); + expect(page).toMatch(/LinkedIn/); + expect(page).toMatch(/\bX\b/); + if (file.startsWith('es/')) { + expect(page).toMatch(/nombre/i); + expect(page).toMatch(/rol profesional/i); + } else { + expect(page).toMatch(/full name/i); + expect(page).toMatch(/job role/i); + } + }); + + test.each([['terms.astro'], ['es/terms.astro']])('%s has no waitlist', (file) => { + expect(read(`pages/${file}`)).not.toMatch(WAITLIST); + }); +}); diff --git a/src/tests/landing/pricing.test.ts b/src/tests/landing/pricing.test.ts index 9ee2e7e4..fc5a3e55 100644 --- a/src/tests/landing/pricing.test.ts +++ b/src/tests/landing/pricing.test.ts @@ -57,13 +57,11 @@ describe('Pricing — no per-region tier', () => { expect(tiers).toMatch(/amount:\s*'14,99€'/); }); - test('the community card CTA routes to the /community signup form, not the portal', () => { - // The card must land on the restored form (with the #signup anchor), - // not on cloud.nan.builders (which has no signup form). Assert the full - // ternary so a regression on the EN branch (else '/#pricing') is caught, - // not masked by the ES substring also containing '/community#signup'. - expect(tiers).toMatch(/href: lang === 'es' \? '\/es\/community#signup' : '\/community#signup'/); - expect(tiers).not.toMatch(/cloud\.nan\.builders/); + test('every tier CTA goes to the platform signup with its plan (open signup)', () => { + expect(tiers).toContain("href: signupUrl('premium')"); + expect(tiers).toContain("href: signupUrl('inference')"); + expect(tiers).toContain("href: signupUrl('community')"); + expect(tiers).not.toMatch(/#waitlist|#signup|premium=1/); }); /** diff --git a/src/tests/legal/workspaceAcceptableUse.test.ts b/src/tests/legal/workspaceAcceptableUse.test.ts index 15a9f4ea..97478061 100644 --- a/src/tests/legal/workspaceAcceptableUse.test.ts +++ b/src/tests/legal/workspaceAcceptableUse.test.ts @@ -79,7 +79,8 @@ describe.each(Object.entries(cases))('terms: workspace acceptable use (%s)', (_l test('marks the date it applies from, and the page date moved with it', () => { expect(s).toContain(c.since); - expect(src).toContain('updated="October 6, 2026"'); + // Moved again on 2026-10-14 (open signup rewrote the community section). + expect(src).toContain('updated="October 14, 2026"'); }); test.each(c.prohibited)('prohibits: %s', (item) => { diff --git a/src/tests/lib/communitySignup.test.ts b/src/tests/lib/communitySignup.test.ts deleted file mode 100644 index 489d2013..00000000 --- a/src/tests/lib/communitySignup.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - resolveSignupResponse, - errorMessageFor, - type CommunityErrorCode, -} from '../../lib/communitySignup'; - -/** - * resolveSignupResponse is the pure response classifier the - * CommunitySignupForm island uses to turn a fetch result into a UI state. It - * used to live inline in the component (untestable without a DOM); it was - * extracted so every error path is fixed here. - * - * The form's other branch — the fetch throwing — is handled by the caller as - * errorNetwork before this function is ever called, so it has no case here. - */ - -const T = { - 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.', -}; - -describe('resolveSignupResponse', () => { - it('redirects on 200 with { ok: true, url }', () => { - const out = resolveSignupResponse(200, { ok: true, url: 'https://checkout.stripe.com/c/pay/abc' }); - expect(out).toEqual({ kind: 'redirect', url: 'https://checkout.stripe.com/c/pay/abc' }); - }); - - it('reports already on 409', () => { - const out = resolveSignupResponse(409, { ok: false, error: 'already_subscribed' }); - expect(out).toEqual({ kind: 'already' }); - }); - - it('maps rate_limited to an error', () => { - const out = resolveSignupResponse(429, { ok: false, error: 'rate_limited' }); - expect(out).toEqual({ kind: 'error', code: 'rate_limited' }); - }); - - it('maps invalid_email to an error', () => { - const out = resolveSignupResponse(400, { ok: false, error: 'invalid_email' }); - expect(out).toEqual({ kind: 'error', code: 'invalid_email' }); - }); - - it('maps invalid_region to an error', () => { - const out = resolveSignupResponse(400, { ok: false, error: 'invalid_region' }); - expect(out).toEqual({ kind: 'error', code: 'invalid_region' }); - }); - - it('falls back to server_error on an unrecognized error code', () => { - const out = resolveSignupResponse(500, { ok: false, error: 'something_unexpected' }); - expect(out).toEqual({ kind: 'error', code: 'server_error' }); - }); - - it('falls back to server_error when the body is not JSON (null)', () => { - const out = resolveSignupResponse(502, null); - expect(out).toEqual({ kind: 'error', code: 'server_error' }); - }); - - it('falls back to server_error when 200 lacks ok:true', () => { - // A 200 without the expected shape is not a redirect — treat as server error. - const out = resolveSignupResponse(200, { url: 'https://checkout.stripe.com/c/pay/abc' }); - expect(out).toEqual({ kind: 'error', code: 'server_error' }); - }); - - it('falls back to server_error when 200 url is not a string', () => { - const out = resolveSignupResponse(200, { ok: true, url: 42 }); - expect(out).toEqual({ kind: 'error', code: 'server_error' }); - }); - - it('does not treat 409 as a redirect even if ok:true sneaks in', () => { - const out = resolveSignupResponse(409, { ok: true, url: 'https://x' }); - expect(out).toEqual({ kind: 'already' }); - }); -}); - -describe('errorMessageFor', () => { - const cases: Array<[CommunityErrorCode, string]> = [ - ['invalid_email', T.errorInvalidEmail], - ['invalid_region', T.errorInvalidRegion], - ['rate_limited', T.errorRateLimited], - ['already_subscribed', T.errorServer], // no dedicated message in the form -> server fallback - ['server_error', T.errorServer], - ]; - - it.each(cases)('maps %s to the right localized string', (code, expected) => { - expect(errorMessageFor(code, T)).toBe(expected); - }); -}); diff --git a/src/tests/pages/communityPage.test.ts b/src/tests/pages/communityPage.test.ts index b51510a9..328f434a 100644 --- a/src/tests/pages/communityPage.test.ts +++ b/src/tests/pages/communityPage.test.ts @@ -6,50 +6,38 @@ import { t } from '../../lib/i18n'; /** * Guards the /community page body (src/pages/_community.astro, shared by - * /community and /es/community). These are the invariants the restored form - * depends on but that astro check and the runtime can't see: - * - * - the #signup anchor exists and is the scroll target of every CTA - * (hero, home pricing EN, home pricing ES); - * - the CommunitySignupForm island is mounted with client:load; - * - every t('community.*', lang) call in the source resolves to a real - * localized string in both locales, not the raw key path. t() returns the - * key itself when it can't resolve, so a typo ships `community.submit` as - * the button label in BOTH languages with no type or test error — this is - * exactly how the "// already a member" literal slipped through. + * /community and /es/community). With open signup the page has no form of its + * own: every CTA goes to the platform signup with plan=community, which then + * runs the per-user Checkout. Every t('community.*', lang) call must resolve + * in both locales: t() returns the raw key when it can't, with no build error. */ const here = dirname(fileURLToPath(import.meta.url)); const source = readFileSync(resolve(here, '../../pages/_community.astro'), 'utf-8'); describe('/community page body (_community.astro)', () => { - test('the signup section has id="signup" (scroll target of every CTA)', () => { + test('the closing section keeps id="signup" (old #signup links still land)', () => { expect(source).toMatch(/]*class="cclose"[^>]*id="signup"/); }); - test('the CommunitySignupForm island is mounted with client:load', () => { - expect(source).toMatch(/CommunitySignupForm[^>]*client:load/); + test('no island and no payment-first form', () => { + expect(source).not.toMatch(/client:(load|idle|visible)/); + expect(source).not.toContain('CommunitySignupForm'); }); - test('every t(\'community.*\', lang) call resolves to a real string in both locales', () => { - // Extract all t('community.SOMETHING', ...) call sites from the source. - const calls = [...source.matchAll(/t\(\s*'community\.([A-Za-z0-9_]+)'\s*,/g)].map( - (m) => `community.${m[1]}`, - ); - // Sanity: the form wires at least the core keys (submit, emailLabel, etc.). - expect(calls.length).toBeGreaterThan(10); + test('both CTAs go to the platform signup with plan=community', () => { + expect(source).toContain("const joinHref = signupUrl('community');"); + expect(source.match(/href=\{joinHref\}/g)?.length).toBe(2); + }); + test('every t(\'community.*\', lang) call resolves to a real string in both locales', () => { + const calls = [...source.matchAll(/t\(\s*'community\.([A-Za-z0-9_]+)'\s*,/g)].map((m) => `community.${m[1]}`); + expect(calls.length).toBeGreaterThan(0); const problems: string[] = []; for (const key of calls) { for (const locale of ['en', 'es'] as const) { const value = t(key, locale); - if (typeof value !== 'string' || value.trim() === '') { - problems.push(`${key} [${locale}]: not a non-empty string`); - } else if (value === key) { - // t() returns the key when it can't resolve — a typo renders the raw - // path as the visible label in both languages. - problems.push(`${key} [${locale}]: resolved to the raw key path (missing translation)`); - } + if (!value.trim() || value === key) problems.push(`${key} [${locale}]`); } } expect(problems, problems.join('\n')).toEqual([]); From 971c155a96f22fc03ecb747566a983c94136cb8b Mon Sep 17 00:00:00 2001 From: barckcode Date: Thu, 15 Oct 2026 02:08:33 +0200 Subject: [PATCH 4/8] test(docs): guard that web search stays off the site Web search (/v1/search and the MCP server that only exposed it) was removed in #100 and /docs/mcp already 301s to agent-setup. Pin it: no page, doc, dictionary, OpenAPI path or public file may mention it. Co-Authored-By: Claude Opus 5.5 --- src/tests/layouts/noWebSearch.test.ts | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/tests/layouts/noWebSearch.test.ts diff --git a/src/tests/layouts/noWebSearch.test.ts b/src/tests/layouts/noWebSearch.test.ts new file mode 100644 index 00000000..5ce7c4bf --- /dev/null +++ b/src/tests/layouts/noWebSearch.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative, resolve } from 'node:path'; +import { REMOVED_DOCS } from '../../middleware'; + +/** + * Web search is gone from NaN (the /v1/search endpoint and the MCP server + * whose only tool it was). Nothing on the site may document or sell it, and + * the old docs URL keeps redirecting instead of 404ing. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = resolve(here, '../../..'); + +const WEB_SEARCH = /web[\s_-]?search|\/v1\/search|b[uú]squeda web|search tool/i; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) { + if (['tests', 'node_modules', 'dist', '.astro', 'fixtures'].includes(name)) continue; + walk(p, out); + } else if (/\.(astro|ts|tsx|mdx?|json|txt)$/.test(name) && !name.endsWith('.test.ts')) { + out.push(p); + } + } + return out; +} + +describe('no web search on the site', () => { + test('no page, doc, dictionary, spec or public file mentions it', () => { + const files = [...walk(resolve(repo, 'src')), ...walk(resolve(repo, 'public')), ...walk(resolve(repo, 'i18n'))]; + const offenders = files + .filter((f) => !f.endsWith('middleware.ts')) // documents the redirect below + .filter((f) => WEB_SEARCH.test(readFileSync(f, 'utf-8'))) + .map((f) => relative(repo, f)); + expect(offenders).toEqual([]); + }); + + test('the OpenAPI spec has no search path', () => { + const spec = JSON.parse(readFileSync(resolve(repo, 'src/data/openapi.json'), 'utf-8')); + expect(Object.keys(spec.paths).filter((p) => /search|mcp/i.test(p))).toEqual([]); + }); + + test('the removed MCP/search docs page still redirects', () => { + expect(REMOVED_DOCS.mcp).toBeTruthy(); + }); +}); From 666ebf8495c7239fd972c0cef3347e37d26ad94d Mon Sep 17 00:00:00 2001 From: barckcode Date: Thu, 15 Oct 2026 02:42:19 +0200 Subject: [PATCH 5/8] docs: describe open signup instead of the waitlist island ARCHITECTURE.md and README.md now document /api/signup (contract, validation, honeypot, rate limit, response mapping), the vanilla hero form, the plan-aware signup links, the free lifecycle state, path-based EN/ES, the current structure, and the env vars the site no longer reads (RESEND_*, CLOUD_API_WAITLIST_KEY). Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 219 ++++++++++++++++++------------------------------ README.md | 51 ++++------- 2 files changed, 98 insertions(+), 172 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 214a1671..4b20e178 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,35 @@ 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` and the client IP in `x-forwarded-for` / `cf-connecting-ip`. No API key: the endpoint is public. - **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 / non-JSON content type - `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 +169,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 +203,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 +247,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 +260,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 From f9f5389aa0421549a253563eb410584d20baff9f Mon Sep 17 00:00:00 2001 From: barckcode Date: Thu, 15 Oct 2026 09:47:49 +0200 Subject: [PATCH 6/8] fix(nav): keep Login + Create account on one line in the narrow desktop band With the new Create account button the Spanish nav overflowed 66px at 861px, 28px at 900px and 28px right after the old 1100px band. The compact band now runs to 1240px with tighter spacing and smaller auth buttons; measured no horizontal scroll at 861-1300px in EN and ES. Co-Authored-By: Claude Opus 5.5 --- src/components/nan/Nav.astro | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/nan/Nav.astro b/src/components/nan/Nav.astro index 8ace3d74..17cb30cf 100644 --- a/src/components/nan/Nav.astro +++ b/src/components/nan/Nav.astro @@ -191,15 +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 .btn { padding: 0 14px; font-size: 13px; } + .nav__auth { gap: 6px; } + .nav__auth .btn { padding: 0 10px; font-size: 12px; height: 40px; } } @media (max-width: 860px) { From cb32a64070ce84ce0cb7356dd95955908f448a97 Mon Sep 17 00:00:00 2001 From: barckcode Date: Thu, 15 Oct 2026 10:49:35 +0200 Subject: [PATCH 7/8] fix(signup): no-JS safe form, exact JSON media type, forward visitor country - The hero form has method="post" action="/api/signup", so if the script fails the browser never GETs the data into a URL. That form post gets a plain-text 415 pointing at the platform signup; the body is never read or echoed (a redirect to cloud.nan.builders would be blocked by the page's form-action 'self'). - /api/signup parses the Content-Type media type and requires exactly application/json instead of a substring match. - cf-ipcountry is forwarded to cloud-api next to the client IP (only a well-formed two-character code), for the billing region at verify. - The blocked email domain/TLD lists are exported and pinned by a test so they stay in sync with cloud-api. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 5 ++- src/components/nan/home/Hero.astro | 2 +- src/lib/signup.ts | 5 ++- src/lib/signupServer.ts | 29 +++++++++++--- src/pages/api/signup.ts | 23 ++++++++++-- src/tests/api/signup.test.ts | 56 +++++++++++++++++++++++++--- src/tests/landing/signupForm.test.ts | 6 +++ src/tests/lib/signup.test.ts | 8 ++++ 8 files changed, 115 insertions(+), 19 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4b20e178..4ffb0793 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -141,11 +141,12 @@ Creates a NaN account through cloud-api's public `POST /api/auth/signup` (`OPEN- - `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` and the client IP in `x-forwarded-for` / `cf-connecting-ip`. No API key: the endpoint is public. +- **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 plain-text `415` pointing at the platform signup, and the body is never read. - **Responses:** - `202`: `{ ok: true }` for every accepted signup, new or known email (no account enumeration). The form shows "Check your inbox to confirm and sign in"; cloud-api sends the English magic link. - `400`: `{ ok: false, error: 'invalid_input', fields: { : 'required' | 'invalid' | 'too_long' } }` - - `413` / `415`: oversized body / non-JSON content type + - `413` / `415`: oversized body / not `application/json` (`{ ok: false, error: 'unsupported_media_type' }`, or plain text for a no-JS form post) - `429`: `{ ok: false, error: 'rate_limited' }` - `502`: `{ ok: false, error: 'server_error' }` (upstream error or unreachable; details only in the Worker log) diff --git a/src/components/nan/home/Hero.astro b/src/components/nan/home/Hero.astro index 69772fb1..57289b43 100644 --- a/src/components/nan/home/Hero.astro +++ b/src/components/nan/home/Hero.astro @@ -50,7 +50,7 @@ const signupMsgs = JSON.stringify({ {tt.signupLine}

    - +