From 0a5dc81c24062cf56f0d437884ab6a67a50f89e7 Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:45:57 +0530 Subject: [PATCH 01/15] Site: make the terms, the policy and the pricing page say the same thing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terms section 1 said local use is free and paid plans add remote control, which contradicts section 5 and the free plan the whole funnel rests on. The wrong version came first, in the operative agreement. The privacy policy described the relay briefly buffering messages for offline recipients. It does not: server.ts refuses the frame with PEER_OFFLINE and drops it. The uniform refusal is deliberate — an unauthorized sender must not learn which devices are online — so the policy was describing a weaker relay than the one we ship. Support named Gemini CLI, which is not in the agent registry at all; Cursor is. SSO, audit log and IP allowlist were claimed as shipped on the marketing pricing page, in web's plan UI and in support: the capability flags exist on the plan model but nothing reads them, so all three now say roadmap and cross-reference each other. Enterprise leads went to /support, which put a budget holder on the troubleshooting page. --- site/src/config.ts | 8 ++++++++ site/src/pages/pricing.astro | 8 ++++++-- site/src/pages/privacy.md | 2 +- site/src/pages/support.md | 4 ++-- site/src/pages/terms.md | 2 +- web/src/ui/pricing.tsx | 5 ++++- 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/site/src/config.ts b/site/src/config.ts index ae2b5c52..cc7d4631 100644 --- a/site/src/config.ts +++ b/site/src/config.ts @@ -21,6 +21,14 @@ export const links = { downloadWindows: "https://get.microsoft.com/installer/download/9N0P7ZRL4D9W?referrer=appbadge&cid=site", downloadLinux: `${RELEASES_URL}/releases/latest/download/antgrid-linux.AppImage`, support: "/support", + // Enterprise leads go straight to a human. Pointing them at /support put a + // budget holder on the troubleshooting page; the subject line sorts them out + // of general support mail on arrival. + enterprise: "mailto:contact@radhaai.com?subject=Antgrid%20for%20teams", + // The mobile apps ship through TestFlight and Play internal testing today, so + // the hero has to route the ask somewhere. "Coming to the App Store" read as + // "you can't have it yet" while invites were in fact open — see get-started. + mobileInvite: "mailto:contact@radhaai.com?subject=Antgrid%20mobile%20invite", privacy: "/privacy", terms: "/terms", refunds: "/refunds", diff --git a/site/src/pages/pricing.astro b/site/src/pages/pricing.astro index cd39dbb1..4161aa31 100644 --- a/site/src/pages/pricing.astro +++ b/site/src/pages/pricing.astro @@ -32,9 +32,13 @@ import { links } from "../config";
Enterprise
-
Unlimited seats · SSO, audit log & IP allowlist · invoiced annually
+ {/* SSO, audit log and IP allowlist are named as roadmap, not as shipped: + the capability flags exist in web's plan model but nothing reads them + yet. Asking for the buyer's requirements is also the better opener — + it starts a conversation where a feature list ends one. */} +
Unlimited seats · invoiced annually · SSO, audit log and IP allowlist on the roadmap — tell us your requirements and your timeline.
- Talk to us + Talk to sales

All plans include end-to-end encryption. The relay never sees your code.

diff --git a/site/src/pages/privacy.md b/site/src/pages/privacy.md index c19b6e44..c04ad71c 100644 --- a/site/src/pages/privacy.md +++ b/site/src/pages/privacy.md @@ -60,7 +60,7 @@ Because of our end-to-end encryption, we do **not** have access to, and do **not All agent-to-app traffic is end-to-end encrypted after a handshake using X25519 key exchange and AES-256-GCM authenticated encryption. Encryption keys are generated on your devices, are per-connection, and are never persisted by us. As a result, **we cannot read the contents of the data you transmit through the Services.** -For full transparency about what the relay *can* see: the relay authenticates devices using their public keys and routes messages by device identity, so it processes device identifiers and the public keys exchanged during the handshake. When a recipient device is temporarily offline, the relay briefly buffers a small number of still-encrypted messages in memory so they can be delivered on reconnect; these buffers are never written to disk and remain encrypted. The relay does **not** store your IP address in a database or link it to your account; IP addresses on the relay are held only in memory for the duration of a connection (for rate limiting) and may appear in short-lived operational logs. +For full transparency about what the relay *can* see: the relay authenticates devices using their public keys and routes messages by device identity, so it processes device identifiers and the public keys exchanged during the handshake. When a recipient device is not connected, the relay does not hold the message: the frame is refused and dropped. Nothing is queued, buffered, or written to disk. The relay returns the same response whether the recipient is offline or the sender is not permitted to reach it, so it never discloses which of your devices are online. The relay does **not** store your IP address in a database or link it to your account; IP addresses on the relay are held only in memory for the duration of a connection (for rate limiting) and may appear in short-lived operational logs. ## 4. Analytics, Crash Reporting, and Tracking diff --git a/site/src/pages/support.md b/site/src/pages/support.md index e0cd59a3..78499188 100644 --- a/site/src/pages/support.md +++ b/site/src/pages/support.md @@ -13,7 +13,7 @@ Need a hand? Email **[contact@radhaai.com](mailto:contact@radhaai.com)** and we' ## What is Antgrid? -Antgrid lets you run AI coding agents such as **Claude Code**, **Codex**, and **Gemini CLI** on your own computer, then monitor and control them from your phone, tablet, or desktop over an end-to-end encrypted connection. You see the agent's live terminal output, browse the project's files, and stay in control from anywhere — and Handler, the built-in assistant, marks nothing done without evidence. +Antgrid lets you run AI coding agents such as **Claude Code**, **Codex**, and **Cursor** on your own computer, then monitor and control them from your phone, tablet, or desktop over an end-to-end encrypted connection. You see the agent's live terminal output, browse the project's files, and stay in control from anywhere — and Handler, the built-in assistant, marks nothing done without evidence. ## Supported platforms @@ -44,7 +44,7 @@ Antgrid supports three sign-in options: **Antgrid is currently free while in beta** — every feature is included, Handler and remote control alike, on the house. Paid plans activate when the beta ends; the prices on [Pricing](/pricing) are the launch prices. -Once plans are live: **Antgrid is free on one worker machine**, with end-to-end encrypted **remote control**, fleet view and browser preview included. **Pro is billed per seat — one seat per person** — and gives every person up to **10 worker machines** of their own, plus **Handler** — the AI assistant that watches your sessions, takes instructions mid-run, and judges every item against evidence — and priority support. Pro includes a **7-day free trial** and covers up to **25 seats**. Larger teams, SSO, audit logs and IP allowlisting are **Enterprise** — email us at [contact@radhaai.com](mailto:contact@radhaai.com). See [Pricing](/pricing) for current details. +Once plans are live: **Antgrid is free on one worker machine**, with end-to-end encrypted **remote control**, fleet view and browser preview included. **Pro is billed per seat — one seat per person** — and gives every person up to **10 worker machines** of their own, plus **Handler** — the AI assistant that watches your sessions, takes instructions mid-run, and judges every item against evidence — and priority support. Pro includes a **7-day free trial** and covers up to **25 seats**. Larger teams are **Enterprise**, where SSO, audit logs and IP allowlisting are on the roadmap — email us at [contact@radhaai.com](mailto:contact@radhaai.com). See [Pricing](/pricing) for current details. **Manage or cancel your subscription:** diff --git a/site/src/pages/terms.md b/site/src/pages/terms.md index ee644009..c754d37e 100644 --- a/site/src/pages/terms.md +++ b/site/src/pages/terms.md @@ -25,7 +25,7 @@ The Services are intended for users who are at least 18 years old. ## 1. Our Services -Antgrid is a command centre for AI coding agents that you run on your own machine and can monitor and control remotely from your other devices. **Local use of Antgrid is free.** Paid plans add encrypted remote control and related features (see Section 5). +Antgrid is a command centre for AI coding agents that you run on your own machine and can monitor and control remotely from your other devices. **Antgrid has a free plan, and encrypted remote control is included on it.** Paid plans add more worker machines per person, the Handler assistant, and team seats (see Section 5). The Services are not intended for use in any jurisdiction where such use would be contrary to law or would subject us to any registration requirement. You access the Services on your own initiative and are responsible for compliance with applicable local laws. diff --git a/web/src/ui/pricing.tsx b/web/src/ui/pricing.tsx index a3a1fedd..3a057a31 100644 --- a/web/src/ui/pricing.tsx +++ b/web/src/ui/pricing.tsx @@ -46,7 +46,10 @@ const PRO_YEARLY_FEATURES = [ const ENTERPRISE_FEATURES = [ "Unlimited seats", "Run agents on up to {workers} — per person", - "SSO, audit log & IP allowlist", + // Roadmap, not shipped: the capability flags exist on the plan model but + // nothing reads them yet. Keep in lockstep with the Enterprise strip on the + // marketing site (site/src/pages/pricing.astro) and support.md. + "SSO, audit log & IP allowlist — on the roadmap", "Invoiced annually", ] as const; From f0b283059f9de8e2528e2eef687d05c8d4cc237f Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:46:10 +0530 Subject: [PATCH 02/15] Site: answer "does this work with my agent" before selling the gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline spent the largest type on the overview ("One screen."), which is a watching claim for a product that acts, while the argument the ProofCard beneath it makes went unstated in anything above body copy. The kicker now carries scope and the search terms; the headline carries the wedge. Fleet still owns the overview claim, being the section that proves it. Leading with the gate needs the roster to be honest about who gets it. The new band under the hero lists every supported agent and reserves signal for the three handlerObservable answers true for — a terminal session needs the integration to POST /handler-event, a chat session needs a driver. In this palette the accent means the system is doing something (see .live-cells, and why Eyebrow gave the colour up), so ten glowing marks would promise the paid feature to six agents that cannot run it. The sentence below names the three in words as well: colour reinforces, it never carries alone. The roster left CrossAgent because section seven is too late to answer a question that decides whether someone keeps scrolling. Typography: the kicker's first sentence needed 783px in a 672px box and stranded a two-word tail above a display headline; the availability line was four clauses in a 21rem column and split "iOS &" from "Android". Both are now one sentence per line. Mobile availability read as "you can't have it yet" while TestFlight and Play internal testing invites were in fact open. --- site/bun.lock | 3 + site/package.json | 1 + site/src/components/sections/Compat.astro | 61 +++++++++++++++++++ site/src/components/sections/CrossAgent.astro | 15 +---- site/src/components/sections/Hero.astro | 38 +++++++----- site/src/pages/index.astro | 2 + 6 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 site/src/components/sections/Compat.astro diff --git a/site/bun.lock b/site/bun.lock index bdddff5d..524a8c7d 100644 --- a/site/bun.lock +++ b/site/bun.lock @@ -9,6 +9,7 @@ "@fontsource-variable/archivo": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@iconify-json/simple-icons": "^1.2.94", "@iconify-json/tabler": "^1.2.0", "astro": "^5.0.0", "astro-icon": "^1.1.5", @@ -129,6 +130,8 @@ "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="], + "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.94", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-l8UWzVxKaqZd9ABsE/M/9p6NyGkQnmCnOoZyhQmjlXCtY5PuL2rcWxOFk2l9pk7ux3ERMPkTLE4jl6kQpTkwxA=="], + "@iconify-json/tabler": ["@iconify-json/tabler@1.2.35", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-/sJMqHvh5ZWrEERVfDCT5NjVDeKJdhosFtKjJofAVl+P/3AzLiryOQw7WvrfDF25Xa5N/eoOQ15Y1jnhYXxBoQ=="], "@iconify/tools": ["@iconify/tools@4.2.0", "", { "dependencies": { "@iconify/types": "^2.0.0", "@iconify/utils": "^2.3.0", "cheerio": "^1.1.2", "domhandler": "^5.0.3", "extract-zip": "^2.0.1", "local-pkg": "^1.1.2", "pathe": "^2.0.3", "svgo": "^3.3.2", "tar": "^7.5.2" } }, "sha512-WRxPva/ipxYkqZd1+CkEAQmd86dQmrwH0vwK89gmp2Kh2WyyVw57XbPng0NehP3x4V1LzLsXUneP1uMfTMZmUA=="], diff --git a/site/package.json b/site/package.json index 0518f97a..c94fe576 100644 --- a/site/package.json +++ b/site/package.json @@ -15,6 +15,7 @@ "@fontsource-variable/archivo": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@iconify-json/simple-icons": "^1.2.94", "@iconify-json/tabler": "^1.2.0", "astro": "^5.0.0", "astro-icon": "^1.1.5" diff --git a/site/src/components/sections/Compat.astro b/site/src/components/sections/Compat.astro new file mode 100644 index 00000000..90d5ae79 --- /dev/null +++ b/site/src/components/sections/Compat.astro @@ -0,0 +1,61 @@ +--- +import { Icon } from "astro-icon/components"; +import Chip from "../ui/Chip.astro"; +import Eyebrow from "../ui/Eyebrow.astro"; + +// Keep in lockstep with bridge/src/agents/registry.ts. `handler: true` is what +// `handlerObservable` answers true for — a terminal session needs the agent's +// integration to POST /handler-event, a chat session needs a driver, and only +// these three clear either bar. +// +// They are also the only three that get signal. In this palette the accent means +// the system is doing something (see .live-cells in global.css, and the note in +// Eyebrow.astro on why section labels gave the colour up), so a row where all +// ten marks glow would promise the paid feature to six agents that cannot run +// it. The sentence below names the three in words too: colour reinforces here, +// it never carries alone. +const agents = [ + { name: "Claude Code", icon: "simple-icons:claudecode", handler: true }, + { name: "Codex", icon: "simple-icons:openai", handler: true }, + { name: "opencode", icon: "simple-icons:opencode", handler: true }, + { name: "Cursor", icon: "simple-icons:cursor" }, + { name: "GitHub Copilot", icon: "simple-icons:githubcopilot" }, + { name: "Antigravity" }, + { name: "Kilo" }, + { name: "Kimi", icon: "simple-icons:kimi" }, + { name: "Mistral Vibe", icon: "simple-icons:mistralai" }, +]; + +// Grouped into two rows rather than left to wrap: the ten chips are ~100px wider +// than the shell at every desktop width, so a single flex row always broke 9 + 1 +// and stranded the dashed chip alone on the second line. Splitting on the +// distinction the row already encodes costs no words and fixes the orphan. +const groups = [agents.filter((a) => a.handler), agents.filter((a) => !a.handler)]; +--- +
+
+ The agent you already run + +
+ {groups.map((group, gi) => ( +
+ {group.map((a) => ( + + {a.icon + ? + : } + {a.name} + + ))} + {gi === 1 && ( + any terminal agent + )} +
+ ))} +
+ +

+ Handler supervises Claude Code, Codex and opencode today. Every other agent runs as a named session — terminal, files, git and alerts. +

+
+
diff --git a/site/src/components/sections/CrossAgent.astro b/site/src/components/sections/CrossAgent.astro index bee900a2..f93ed2ef 100644 --- a/site/src/components/sections/CrossAgent.astro +++ b/site/src/components/sections/CrossAgent.astro @@ -1,13 +1,8 @@ --- import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; -import Chip from "../ui/Chip.astro"; import UseCase from "../ui/UseCase.astro"; -// Keep in lockstep with AGENTS in bridge/src/agents/registry.ts — these are the -// agents wired for notifications and session naming. Anything else still runs (the -// "any terminal agent" chip), it just gets no integration, so it must not be named here. -const agents = ["Claude Code", "Codex", "opencode", "Cursor", "GitHub Copilot", "Kilo", "Kimi", "Mistral Vibe"]; const steps = [ { n: 1, icon: "tabler:download", title: "Install on your machine", body: "The desktop app runs your agents in real terminals and links them to your phone — Windows, macOS, Linux.", accent: false }, { n: 2, icon: "tabler:terminal-2", title: "Run any agent", body: "Start Claude Code, Codex or Cursor exactly how you do now. antgrid wraps them — no workflow change.", accent: false }, @@ -23,13 +18,9 @@ const steps = [ antgrid doesn't replace your coding agent — it's the command centre over the ones you already run. No new model, no new CLI to learn.

-
- {agents.map((a) => ( - {a} - ))} - any terminal agent -
- + {/* The roster moved to Compat.astro, directly under the hero: "does this work + with my agent" gates whether a reader keeps scrolling, so it cannot be + answered in section seven. This section keeps the promise and the setup. */} Pick the agent per task, not per tool — the command centre stays the same.

How it works

diff --git a/site/src/components/sections/Hero.astro b/site/src/components/sections/Hero.astro index 3575fb22..f1bd51d5 100644 --- a/site/src/components/sections/Hero.astro +++ b/site/src/components/sections/Hero.astro @@ -70,17 +70,19 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100); type, where the default wrap strands a two-word tail ("stuck since 2am.", "machine.") on its own line at some widths and not others. A hard
fixes the one width you tested and makes narrow viewports worse. */} - - Agents on your laptop, your workstation, a cloud box.{" "} + + Claude Code on your laptop, Codex on your workstation, another on a cloud box.{" "} One's been stuck since 2am. - {/* The headline claims the overview, so the tension has to sit in the kicker - above it — on its own "One screen." is a watching claim, and the product - acts. The stuck agent is what the lede's proof loop then answers. */} + {/* The kicker carries scope — every agent, every machine — so the headline + is free to carry the wedge, and the ProofCard below is its evidence. It + used to claim the overview ("One screen."), which is a watching claim + for a product that acts, and left the argument the card was making + unstated in anything larger than body copy. Fleet.astro still owns the + overview claim; it is the section that proves it. */} - Every agent.{" "} - Every machine.{" "} - One screen. + Your agent says it's done.{" "} + Make it prove it. @@ -88,20 +90,26 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100); rather than pinned to the shell's edges — pushed apart, the buttons read as stranded rather than as a deliberate second column. */}
- {/* Carries the proof loop, because the headline no longer does — it claims - the overview instead. Does not re-open on "your agents run on your own - machines": the kicker and headline directly above have just said that, - and the paragraph runs long on a phone as it is. */} + {/* Carries the mechanism behind the headline's claim — the headline asserts + the gate, this says how it closes. Names "your agents" rather than + opening on "They": the nearest noun above is now the singular "your + agent" in the headline, so the pronoun read as a reference to it. */}

- They run on your own machines — your repos, your branches, your existing subscriptions. Hand antgrid the sequence in plain text: it reads every result, refuses a "done" that arrives with no test output, exit code or diff, and moves to the next phase. + Your agents run on your own machines — your repos, your branches, your existing subscriptions. Hand antgrid the sequence in plain text: it reads every result, refuses a "done" that arrives with no test output, exit code or diff, and moves to the next phase.

-

- No VPN or port forwarding · End-to-end encrypted · Desktop out now, iOS & Android coming to the App Store & Play. + {/* Two lines because there are two jobs here: the guarantees are a + dot-separated list of properties, availability is a sentence. Run + together they made a four-clause line that wrapped to three in this + 21rem column and split "iOS &" from "Android" across rows. */} +

No VPN or port forwarding · End-to-end encrypted

+

+ Desktop out now on Windows, macOS & Linux.{" "} + iOS & Android in private beta — request an invite.

diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index d553e28b..a61458b1 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -1,6 +1,7 @@ --- import Base from "../layouts/Base.astro"; import Hero from "../components/sections/Hero.astro"; +import Compat from "../components/sections/Compat.astro"; import Phases from "../components/sections/Phases.astro"; import Fleet from "../components/sections/Fleet.astro"; import Reach from "../components/sections/Reach.astro"; @@ -22,6 +23,7 @@ const DESCRIPTION = path="/" > + From b8536e03b9cb318f8506e15a99f6cf72682b86de Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:27:32 +0530 Subject: [PATCH 03/15] Site: publish the security page, and let the reader check it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo is public, SECURITY.md exists, the handshake spec is written down and the relay client is Apache-2.0 and auditable. None of that was reachable from the site, so the strongest evidence we have was also the least visible. The page claims architecture only: what the crypto is, what has to be true before a phone can drive a machine, and — the section that does the most work — exactly what metadata the relay does see. Every claim that has a limit prints the limit beside it, including the ones that are unflattering. No SOC 2, no DPA, no residency, no compliance language of any kind, because none of it is true today and a reader can check. Adds .well-known/security.txt per RFC 9116, and the text/plain MIME type Azure needs to serve it: the global X-Content-Type-Options: nosniff means a wrong content type would have been fatal rather than cosmetic. home.spec.ts asserted nothing links to /security. It now asserts the opposite. --- site/.env.example | 1 + site/public/.well-known/security.txt | 6 + site/public/staticwebapp.config.json | 1 + site/src/components/Footer.astro | 1 + site/src/config.ts | 22 ++ site/src/pages/security.astro | 397 +++++++++++++++++++++++++++ site/tests/home.spec.ts | 2 +- site/tests/security.spec.ts | 90 ++++++ 8 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 site/public/.well-known/security.txt create mode 100644 site/src/pages/security.astro create mode 100644 site/tests/security.spec.ts diff --git a/site/.env.example b/site/.env.example index 664ec636..1a83f475 100644 --- a/site/.env.example +++ b/site/.env.example @@ -1,2 +1,3 @@ PUBLIC_SITE_URL=https://antgrid.ai PUBLIC_APP_URL=https://app.antgrid.ai +PUBLIC_WEB_URL=https://app.antgrid.ai diff --git a/site/public/.well-known/security.txt b/site/public/.well-known/security.txt new file mode 100644 index 00000000..4e60fd12 --- /dev/null +++ b/site/public/.well-known/security.txt @@ -0,0 +1,6 @@ +Contact: mailto:contact@radhaai.com +Contact: https://github.com/antgrid-ai/antgrid/security/advisories/new +Preferred-Languages: en +Canonical: https://antgrid.ai/.well-known/security.txt +Expires: 2027-06-30T23:59:59.000Z +Policy: https://github.com/antgrid-ai/antgrid/blob/HEAD/SECURITY.md diff --git a/site/public/staticwebapp.config.json b/site/public/staticwebapp.config.json index 9bb43526..53dcfdc1 100644 --- a/site/public/staticwebapp.config.json +++ b/site/public/staticwebapp.config.json @@ -12,6 +12,7 @@ { "route": "/og/*", "headers": { "Cache-Control": "public, max-age=86400" } } ], "mimeTypes": { + ".txt": "text/plain", ".webmanifest": "application/manifest+json", ".svg": "image/svg+xml" } diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro index 38da3e49..1a669ef0 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -25,6 +25,7 @@ import Wordmark from "./ui/Wordmark.astro"; Download Get started Support + Security
diff --git a/site/src/config.ts b/site/src/config.ts index cc7d4631..af14803e 100644 --- a/site/src/config.ts +++ b/site/src/config.ts @@ -1,5 +1,11 @@ export const SITE_URL = import.meta.env.PUBLIC_SITE_URL ?? "https://antgrid.ai"; export const APP_URL = import.meta.env.PUBLIC_APP_URL ?? "https://app.antgrid.ai"; +// The web service's API origin. Same deployment as APP_URL today, but declared +// separately because it is overridden for a different reason: pointing a preview +// build's waitlist POST at a local web server must not also move sign-in and +// checkout off production. The site is a static build on another origin, so +// anything under here is a cross-origin request the web service must allow. +export const WEB_URL = import.meta.env.PUBLIC_WEB_URL ?? "https://app.antgrid.ai"; // Public releases repo. `releases/latest/download/` redirects to the // newest stable release's asset of that exact filename, so these URLs never @@ -21,6 +27,19 @@ export const links = { downloadWindows: "https://get.microsoft.com/installer/download/9N0P7ZRL4D9W?referrer=appbadge&cid=site", downloadLinux: `${RELEASES_URL}/releases/latest/download/antgrid-linux.AppImage`, support: "/support", + security: "/security", + // Verification surfaces for /security. `HEAD` rather than a branch name: + // GitHub resolves it to whatever the repo's default branch is, so renaming + // that branch never turns these into 404s under a page whose whole argument + // is that the reader can go and check. + repo: RELEASES_URL, + securityPolicyFile: `${RELEASES_URL}/blob/HEAD/SECURITY.md`, + securityAdvisory: `${RELEASES_URL}/security/advisories/new`, + handshakeSpec: `${RELEASES_URL}/blob/HEAD/docs/protocol/e2e-handshake.md`, + handshakeVectors: `${RELEASES_URL}/blob/HEAD/evals/fixtures/e2e-handshake-vectors.json`, + relayClient: `${RELEASES_URL}/tree/HEAD/packages/antgrid_relay_client`, + wirePackage: `${RELEASES_URL}/tree/HEAD/packages/antgrid-wire`, + securityEmail: "mailto:contact@radhaai.com?subject=Security", // Enterprise leads go straight to a human. Pointing them at /support put a // budget holder on the troubleshooting page; the subject line sorts them out // of general support mail on arrival. @@ -33,5 +52,8 @@ export const links = { terms: "/terms", refunds: "/refunds", company: "https://radhaai.com", + // Interest capture for founding pricing. Posted to by the inline script in + // WaitlistCta.astro — never rendered as an href, since a GET on it does nothing. + waitlist: `${WEB_URL}/api/waitlist`, checkout: (planId: string) => `${APP_URL}/checkout?planId=${planId}`, }; diff --git a/site/src/pages/security.astro b/site/src/pages/security.astro new file mode 100644 index 00000000..5e3848d0 --- /dev/null +++ b/site/src/pages/security.astro @@ -0,0 +1,397 @@ +--- +import { Icon } from "astro-icon/components"; +import Base from "../layouts/Base.astro"; +import Chip from "../components/ui/Chip.astro"; +import Eyebrow from "../components/ui/Eyebrow.astro"; +import Readout from "../components/ui/Readout.astro"; +import { links } from "../config"; + +// Written for someone deciding whether to run this on a work machine, so it +// leads with mechanism and puts the source links above the fold: the page is +// only worth anything to a reader who does not take its word for it. Two rules +// hold the whole file together — every claim here is one the public repository +// proves, and no strong claim ships without the limit that bounds it in the +// same block. A sentence that cannot be traced to source does not go on this +// page, and a gap is never written as if it were a feature. +// +// Colour discipline (see Readout.astro and Eyebrow.astro): amber means a human +// is needed, so the gaps below deliberately do NOT use it — they are facts, not +// alerts. Signal stays on the outbound source links, which are the one thing on +// the page a reader is meant to act on. + +const relayHolds = [ + { it: "Your account id", why: "taken from the verified token — it is the routing key" }, + { it: "Device ids", why: "one per machine, one per app slot" }, + { it: "The device name in the hello frame", why: "on a machine this defaults to its OS hostname" }, + { it: "Each device's Ed25519 public key", why: "and its connection epoch" }, + { it: "The licence credential id presented", why: "kept so a revocation can find the socket" }, + { it: "Your IP address", why: "in memory only, for per-IP connection caps and rate limits" }, + { it: "The Host header of the upgrade", why: "rebuilt into the signature body so a hello cannot be replayed elsewhere" }, + { it: "The hello timestamp and nonce", why: "for replay rejection and equal-epoch arbitration" }, + { it: "Connect time and last-seen time", why: "per open connection" }, + { it: "Who is sending to whom", why: "the destination in each frame's cleartext route header" }, + { it: "Which channel a frame is on", why: "control or preview — it keys the rate-limit bucket" }, + { it: "The size and timing of every frame", why: "and its kind byte, forwarded without interpretation" }, + { it: "Which of your devices are online", why: "and it tells your other live connections" }, + { it: "Push routing", why: "the destination token and provider transit the relay" }, + { it: "Device ids in operational logs", why: "a ping timeout names the device it dropped" }, +]; + +const relayNeverHolds = [ + { it: "Message payloads", why: "sealed on the sending device" }, + { it: "The stream envelope inside them", why: "including stream ids" }, + { it: "Project names and project ids", why: "" }, + { it: "File paths and file contents", why: "" }, + { it: "Terminal output", why: "" }, + { it: "Your prompts and the agent's replies", why: "" }, + { it: "The text of a push notification", why: "it forwards a sealed blob and a placeholder" }, +]; + +const gaps = [ + { + title: "No second factor.", + body: "Sign-in is single factor: an emailed magic link, GitHub, Google, or email and password. There is no TOTP, no passkey and no hardware-key support. The primary path is the magic link, which has no password to steal, and a password set on an unverified address is dropped as soon as someone proves that address another way.", + }, + { + title: "No audit, no penetration test, no certification.", + body: "What exists is a published protocol specification, cross-language test vectors that both implementations must pass, and a security policy. None of those is an external review, and we are not going to describe them as one. We hold no SOC 2 report and no ISO certification, and this page makes no compliance claim of any kind.", + }, + { + title: "Telemetry is on by default.", + body: "It is opt-out, in app settings. Events carry no account id, no device id and no content — an event name, your platform, the app version, and a random install-scoped id that goes only to our own backend and never to the analytics host. Switching it off stops new events and discards anything still queued; crash reporting follows the same toggle and picks the change up at the next launch.", + }, + { + title: "Crash reports are scrubbed on the device, not on receipt.", + body: "File paths are redacted throughout a report before it is sent, and raw source lines and local variables are dropped rather than redacted. That is a scrubber, and a scrubber is a best effort against a stack trace it has not seen before.", + }, + { + title: "Denial of service is out of scope.", + body: "There is per-IP and per-channel rate limiting, but it protects the relay, not your availability. A rate-limited frame is dropped unrecoverably and only the sender is told. Do not read it as an uptime guarantee.", + }, + { + title: "A machine that is already compromised is out of scope.", + body: "Physical access, a malicious local user, and an attacker who already has your shell are all outside what the bridge defends against. That user already has everything your agent has.", + }, +]; + +const sources = [ + { label: "Repository", href: links.repo, note: "the whole product, source-available" }, + { label: "SECURITY.md", href: links.securityPolicyFile, note: "scope, reporting, what to expect" }, + { label: "packages/antgrid_relay_client", href: links.relayClient, note: "the client-side encryption, Apache-2.0" }, + { label: "packages/antgrid-wire", href: links.wirePackage, note: "the wire protocol, Apache-2.0" }, + { label: "docs/protocol/e2e-handshake.md", href: links.handshakeSpec, note: "the handshake, specified to the byte" }, + { label: "e2e-handshake-vectors.json", href: links.handshakeVectors, note: "the vectors both implementations must pass" }, +]; +--- + +
+
+
+ Security +

What runs where, and who can reach it.

+

+ antgrid runs your coding agents on your own machines and carries your traffic to your phone through a relay that holds no key to it. This page is the architecture, not a set of assurances — every claim below is one you can check in the public repository, and every claim that has a limit is printed next to it. +

+

+ If you are evaluating this for a work machine, the three sections that matter are what the relay sees, what has to be true before a phone can drive a machine, and what doesn't exist yet. Nothing here is a compliance statement. +

+
+ {sources.slice(0, 3).map((s) => ( + + + {s.label} + + ))} +
+
+
+ +
+ The wire +

The relay forwards bytes it holds no key for.

+

+ Traffic between your devices and your machines is end-to-end encrypted. Keys are made on the two devices, and the relay is never given one. +

+ +
+ +
    +
  1. + 1 Both sides generate an ephemeral X25519 keypair, fresh for this connection, and exchange the public halves. +
  2. +
  3. + 2 Both sign a canonical transcript with their long-term Ed25519 identity key. The transcript binds both device identities, both ephemeral public keys, a 32-byte fresh nonce, the machine binding and the protocol version. +
  4. +
  5. + 3 Each side verifies the other's signature against the key it already holds for that device. A key exchange someone tampered with in transit produces no signature that verifies. +
  6. +
  7. + 4 Both sides check an HMAC key-confirmation tag, compared in constant time. No application data is sent or accepted before that passes. +
  8. +
  9. + 5 Transport is AES-256-GCM, with a separate key for each direction. +
  10. +
+
+ +
+

+ Because the session keys are ephemeral, a long-term signing key stolen later does not decrypt sessions that already happened. Session keys live in memory for the life of one connection, are never written to disk, and every teardown path overwrites their buffers. +

+
+

Limit

+

+ That overwrite is best effort. Both implementations run in garbage-collected runtimes, so residual key material in a process dump is expected and is not treated as a vulnerability. +

+
+
+

Scope

+

+ This covers app-to-machine traffic that crosses the network. The desktop app driving the machine it is running on does not use this channel at all: it talks to its own bridge over a loopback socket on 127.0.0.1, authenticated by a per-process token compared in constant time. That traffic never leaves the machine. The file carrying that token is written owner-only on POSIX, as are the machine's device inventory, its phone list, its remote-access switch and its session records. +

+
+
+ ephemeral X25519 per connection + Ed25519 transcript signatures + AES-256-GCM, one key per direction + constant-time key confirmation +
+
+
+
+ +
+
+ The relay +

What the relay does see.

+

+ Zero-knowledge is a claim about content, and only about content. Everything the relay needs in cleartext to admit a socket and route a frame, it has. Here is that list in full, beside the list of what it never holds. +

+ +
+ +
    + {relayHolds.map((r) => ( +
  • + {r.it} + {r.why} +
  • + ))} +
+
+ + +
    + {relayNeverHolds.map((r) => ( +
  • + {r.it} + {r.why && {r.why}} +
  • + ))} +
+ + + + no database, no file, no queue anywhere in the relay — an entry exists only while a socket is open + + +
+
+ +
+
+

Routing

+

+ The relay routes only between devices that belong to the same account, and that check runs fresh on every frame. It is the only routing authorization there is. A blocked route and an offline device get the identical answer, so the relay cannot be used to discover which of your machines are up, and presence is announced only to other live connections on your own account. +

+
+
+

Admission

+

+ Every connection is admitted by a single signed hello frame, verified in a fixed order: schema, clock window, Ed25519 signature over a body that binds the relay hostname, replay cache, licence, then connection arbitration. Nothing is queued for a device that is not connected — a frame addressed to one is refused and dropped. +

+
+
+

Push notifications

+

+ The notification body is sealed to your phone's own key on the machine — ephemeral X25519, HKDF, AES-256-GCM — before it ever reaches the relay, and the relay hands Apple or Google a generic placeholder that your phone replaces once it decrypts. Apple and Google see delivery metadata and an opaque blob. The destination push token and provider do transit the relay, which is why they are in the list above. +

+
+
+

What we are still trusted with

+

+ This design takes the relay out of the trust boundary. It does not take our account service out of it. A relay operator who tampers with the key exchange cannot produce a signature your device accepts — but your phone learns a machine's Ed25519 identity key from your account's device inventory, served by app.antgrid.ai, so a compromised account service could hand a device that has not yet cached the real key one of its choosing. The relay is not trusted with identity. The account service is. +

+
+
+
+
+ +
+ Remote access +

Three things have to be true before a phone can drive a machine.

+

+ They are independent and they are checked together. Any one of them false and the command does not run. +

+ +
+
+

01

+

The device is on your signed-in account.

+

+ A device is admitted from your account's own inventory — there is no pairing code or QR ceremony to intercept — and its Ed25519 identity must verify against the handshake transcript before anything proceeds. +

+
+
+

02

+

That machine's remote-access switch is on.

+

+ Off on a fresh install. One boolean for the whole machine, read live at every check, so turning it off takes effect immediately without restarting anything. +

+
+
+

03

+

The project is one that machine already knows.

+

+ A shape check plus a lookup in that machine's own catalog of projects bounds which project a remote device is allowed to name. +

+
+
+ +

+ With the switch off, a remote device sees nothing: every inbound verb is dropped at a single chokepoint, the preview and HTTP tunnel is gated separately because it bypasses that path, and outbound streaming stops at the send. +

+ +
+
+

Not per-project permissions

+

+ The switch is machine-wide. The project catalog is a bound on what a remote device may name, not a grant you issue per project. +

+
+
+

A known device is identity, not permission

+

+ The machine's phone list holds labels, push routing and last-seen. Removing a phone from it revokes nothing. Revocation is deleting the device on your account — which closes its live relay sockets immediately — or turning the machine's switch off. +

+
+
+

The desktop is exempt by design

+

+ The app driving its own machine goes over the loopback socket and never depends on the relay, so it keeps working with the switch off. +

+
+
+
+ +
+
+ Your agents +

Your agents talk to their providers, not to us.

+

+ Coding agents run on your machine as ordinary local processes, launched from your own environment, and reach their model providers directly under your own logins. antgrid operates no model endpoint and holds no model-provider credentials — there is no provider API key anywhere in the bridge, the relay or the web service. +

+

+ That is not the same sentence as "your code never leaves your machine", and we are not going to write that one. Your agent sends your code to its provider; that is what it is for. The claim is that antgrid adds no hop of its own and no key of its own. +

+ +
+
+

Handler runs your agent again

+

+ When you arm Handler on a session, it runs the agent you already picked for that session headlessly, with read-only tools over that working tree. So working-tree content and transcript excerpts reach the vendor you already chose, on your own account, without a fresh action from you for each call. +

+

+ It never borrows a different vendor's agent to supervise a session, and those runs are kept out of your own session history. +

+
+
+

antgrid does not sandbox your agent

+

+ An agent runs with your environment and your permissions, and an agent you have configured to skip approvals will skip them. Handler's destructive-path floor is an advisory floor, not a sandbox. +

+

+ In an isolated session, the branch's own setup steps run as shell lines — the same trust class as the commands you run yourself. Escapes out of the checkout path are refused, but the commands themselves are branch content. +

+
+
+
+
+ +
+ Honest inventory +

What doesn't exist yet.

+

+ Every item here is something a reasonable reviewer will ask for and we do not have. None of it is written as a feature. +

+
+ {gaps.map((g) => ( +
+

{g.title}

+

{g.body}

+
+ ))} +
+

+ What we collect, and for how long, is set out on the privacy page. +

+
+ +
+
+ Verify it +

Check the crypto claims yourself.

+

+ The handshake is specified in public down to the byte layout of the signed transcript, with cross-language test vectors that both the TypeScript and the Dart implementation must pass. The wire protocol and the client-side encryption are Apache-2.0 and carry their own licence files, so you can read, reimplement and publish work built on them without asking us. +

+

+ The rest of antgrid is source-available under the Elastic License 2.0. That is not OSI-approved open source and we do not call it that. +

+ +
+
+ +
+ Disclosure +

Reporting a vulnerability.

+

+ If you can read traffic the relay is not supposed to read, or run something on a machine without its owner's account and consent, we want to hear about it before anyone else does. Please don't open a public issue, discussion or pull request for it. +

+
+
+

Email

+ contact@radhaai.com +

Put "Security" in the subject line.

+
+
+

GitHub private advisory

+ + + Report a vulnerability + +

Preferred, and it keeps the thread on the repository.

+
+
+

+ We acknowledge reports within three business days, tell you what we found, keep you updated while we fix it, and credit you in the release notes if you want it. We ask for a reasonable window before public disclosure. Please test against your own account and your own machines. +

+

+ What is in scope and what is not is listed in SECURITY.md. Machine-readable contact details are at /.well-known/security.txt. +

+
+
+ diff --git a/site/tests/home.spec.ts b/site/tests/home.spec.ts index 2e894c3a..bce47fc3 100644 --- a/site/tests/home.spec.ts +++ b/site/tests/home.spec.ts @@ -64,7 +64,7 @@ test("no horizontal overflow on mobile", async ({ page }) => { }); test("internal links resolve (no dangling hrefs to missing pages)", async ({ page }) => { - const removedPages = ["/docs", "/security"]; + const removedPages = ["/docs"]; for (const startPath of ["/", "/pricing", "/get-started"]) { await page.goto(startPath); diff --git a/site/tests/security.spec.ts b/site/tests/security.spec.ts new file mode 100644 index 00000000..b9aab4d0 --- /dev/null +++ b/site/tests/security.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from "@playwright/test"; + +// The page only works if a reader can go and check it, so what is asserted here +// is the structure that makes that possible: the sections a sceptical reader is +// sent to, the outbound links that let them read the crypto themselves, and +// security.txt actually being served. Prose inside the sections is deliberately +// not asserted — see the note at the top of home.spec.ts. + +const REPO = "https://github.com/antgrid-ai/antgrid"; + +test("security page renders with one h1 and the sections it promises", async ({ page }) => { + await page.goto("/security"); + const h1 = page.getByRole("heading", { level: 1 }); + await expect(h1).toHaveCount(1); + expect((await h1.innerText()).trim().length).toBeGreaterThan(0); + + await expect(page.getByRole("heading", { name: /what the relay does see/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /three things have to be true/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /exist yet/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /reporting a vulnerability/i })).toBeVisible(); + + // The relay section is the page's central claim: both halves of the ledger + // must render, not just the flattering one. + const relay = page.locator("#relay"); + await expect(relay.getByText("in cleartext at the relay")).toBeVisible(); + await expect(relay.getByText("never at the relay")).toBeVisible(); +}); + +test("the verification links point at the public repository", async ({ page }) => { + await page.goto("/security"); + // Asserted as targets rather than fetched: these are third-party URLs, and a + // GitHub outage must not be able to fail the site suite. `.first()` because + // each of these is offered twice — once above the fold, once in the verify + // list — and a second copy appearing is not a regression. + await expect(page.getByRole("link", { name: "Repository" }).first()).toHaveAttribute("href", REPO); + await expect(page.getByRole("link", { name: "SECURITY.md" }).first()).toHaveAttribute( + "href", + `${REPO}/blob/HEAD/SECURITY.md` + ); + await expect(page.getByRole("link", { name: "packages/antgrid_relay_client" }).first()).toHaveAttribute( + "href", + `${REPO}/tree/HEAD/packages/antgrid_relay_client` + ); + await expect(page.getByRole("link", { name: /report a vulnerability/i })).toHaveAttribute( + "href", + `${REPO}/security/advisories/new` + ); + await expect(page.getByRole("link", { name: "contact@radhaai.com" })).toHaveAttribute( + "href", + /^mailto:contact@radhaai\.com/ + ); +}); + +test("every internal link on the page resolves", async ({ page }) => { + await page.goto("/security"); + const hrefs = await page.locator("a[href^='/']").evaluateAll((els) => + [...new Set(els.map((e) => (e as HTMLAnchorElement).getAttribute("href")!))].filter((h) => !h.startsWith("/#")) + ); + expect(hrefs.length).toBeGreaterThan(0); + for (const href of hrefs) { + const res = await page.request.get(href); + expect(res.status(), `dead link on /security: ${href}`).toBeLessThan(400); + } +}); + +test("security.txt is served with the fields a scanner reads", async ({ page }) => { + const res = await page.request.get("/.well-known/security.txt"); + expect(res.status()).toBe(200); + const body = await res.text(); + expect(body).toContain("Contact: mailto:contact@radhaai.com"); + expect(body).toContain("Canonical: https://antgrid.ai/.well-known/security.txt"); + expect(body).toContain("Preferred-Languages:"); + // RFC 9116 treats an expired file as stale, so the date has to stay ahead of + // the reader — bump it, never drop the field. + const expires = body.match(/^Expires: (.+)$/m); + expect(expires, "security.txt has no Expires field").toBeTruthy(); + expect(new Date(expires![1]).getTime()).toBeGreaterThan(Date.now()); +}); + +test("the footer routes readers to the security page", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("footer").getByRole("link", { name: "Security" })).toHaveAttribute("href", "/security"); +}); + +test("no horizontal overflow on mobile", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/security"); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1); + expect(overflow).toBe(false); +}); From 3c4b642e6324eb1726e018848f22faebe1ad67ad Mon Sep 17 00:00:00 2001 From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:27:44 +0530 Subject: [PATCH 04/15] Replace the dead paid buttons with a founding-price capture on all three surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every paid CTA rendered as a disabled "Available after beta" control, and the site had no email capture anywhere. That is the one instrument that produces pricing signal before launch, so the shutters become a form: POST /api/waitlist on the web service, backed by a Prisma model, Zod-validated, CORS'd for the marketing origin, and idempotent on a repeat address so it never leaks whether one is already on the list. The capture names no figure. An address is not consent to a price, and we have no pricing data yet to set one with. The card hosting it no longer strikes through $99. That price has never been charged, so rendering it as a crossed-out former price invents a reference price the product never had — the thing CCPA's dark-pattern rules and EU Omnibus Art. 6a both reach. Stated forwards as the list price at launch it is the same contrast and a true sentence. pricing.spec.ts now asserts the absence of any strike-through as well as the figures. The app's worker-cap dialog stops offering an Upgrade button that could not be pressed and points at the same list; it is copy and a link, so it degrades to a dead link rather than a broken flow if web deploys later. --- app/lib/screens/device_cap_dialog.dart | 39 +++-- site/src/components/pricing/PlanCard.astro | 20 ++- site/src/components/pricing/WaitlistCta.astro | 143 +++++++++++++++++ site/src/data/pricing.ts | 26 +-- site/src/pages/pricing.astro | 6 +- site/src/pages/support.md | 2 +- site/tests/contracts.spec.ts | 21 ++- site/tests/pricing.spec.ts | 126 ++++++++++++++- .../migration.sql | 21 +++ web/prisma/schema.prisma | 15 ++ web/src/app.ts | 2 + web/src/routes/ui.tsx | 10 +- web/src/routes/waitlist.ts | 60 +++++++ web/src/ui/asset.ts | 1 + web/src/ui/entries/waitlist.ts | 111 +++++++++++++ web/src/ui/pricing.tsx | 149 ++++++++---------- web/tests/helpers/pg.ts | 1 + web/tests/routes/waitlist.test.ts | 127 +++++++++++++++ web/vite.config.ts | 1 + 19 files changed, 752 insertions(+), 129 deletions(-) create mode 100644 site/src/components/pricing/WaitlistCta.astro create mode 100644 web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql create mode 100644 web/src/routes/waitlist.ts create mode 100644 web/src/ui/entries/waitlist.ts create mode 100644 web/tests/routes/waitlist.test.ts diff --git a/app/lib/screens/device_cap_dialog.dart b/app/lib/screens/device_cap_dialog.dart index df429078..64f13f61 100644 --- a/app/lib/screens/device_cap_dialog.dart +++ b/app/lib/screens/device_cap_dialog.dart @@ -7,13 +7,21 @@ import '../design/ab_icons.dart'; import '../design/ab_tokens.dart'; import '../design/widgets/ab_button.dart'; import '../design/widgets/ab_confirm_dialog.dart'; +import '../design/widgets/ab_icon.dart'; import '../design/widgets/ab_icon_button.dart'; import '../design/widgets/ab_list_row.dart'; -import '../design/widgets/ab_tooltip.dart'; import '../providers/auth.dart'; import '../providers/device_provisioning.dart'; import '../providers/post_signin_provisioning.dart'; import '../services/devices_api.dart'; +import '../util/detached.dart'; +import '../util/external_url.dart'; + +/// Where the worker-cap variant sends someone who wants more machines. There is +/// no checkout to send them to during the beta, so the ask is captured on the +/// site instead; no price is named here or on the way out, because none is +/// committed to yet. +const _foundingPricingUrl = 'https://antgrid.ai/pricing'; /// Shows the device-cap remediation dialog and resolves when it is dismissed. /// Always clears [deviceCapProvider] on close so a later provisioning attempt @@ -34,8 +42,7 @@ Future showDeviceCapDialog( /// same way: revoke one of the listed devices, then retry provisioning this /// machine. Only the copy differs — `appDeviceLimit` is an abuse ceiling that /// pricing never mentions, so its variant never offers upgrading, while the -/// worker cap is the paid axis and shows an upgrade affordance (disabled until -/// checkout ships). +/// worker cap is the paid axis and points at the founding-price waitlist. class DeviceCapDialog extends ConsumerStatefulWidget { const DeviceCapDialog({super.key, required this.info}); @@ -206,20 +213,30 @@ class _DeviceCapDialogState extends ConsumerState { runSpacing: AbTokens.space8, children: [ if (_isWorker) ...[ - // Checkout is not wired yet, so the paid path is shown and - // legibly shut rather than absent. The tooltip alone would - // leave the button unexplained on mobile (no hover), hence - // the inline label beside it. + // The machine slot cannot be bought during the beta, so the + // paid path leads somewhere that works instead of standing + // there disabled. The line says why the button is a + // waitlist and not a purchase; a tooltip could not, having + // no hover on mobile. Text( - 'Coming soon', + 'More machines aren\'t on sale yet.', style: AbTokens.sansStyle( fontSize: AbTokens.fontXs, color: p.textMuted, ), ), - const AbTooltip( - message: 'Coming soon', - child: AbButton(label: 'Upgrade'), + AbButton( + label: 'Join the waitlist', + leading: AbIcon( + AbIcons.openExternal, + size: AbTokens.iconButtonGlyph, + color: p.textSecondary, + ), + onTap: () => detached( + 'DeviceCapDialog', + 'open founding-pricing waitlist', + () => openExternalUrl(context, _foundingPricingUrl), + ), ), ], AbButton( diff --git a/site/src/components/pricing/PlanCard.astro b/site/src/components/pricing/PlanCard.astro index 1802ab97..703ad240 100644 --- a/site/src/components/pricing/PlanCard.astro +++ b/site/src/components/pricing/PlanCard.astro @@ -1,11 +1,11 @@ --- import { Icon } from "astro-icon/components"; import Button from "../ui/Button.astro"; +import WaitlistCta from "./WaitlistCta.astro"; import { links } from "../../config"; -import { BETA_FREE, type PlanCardData } from "../../data/pricing"; -interface Props { plan: PlanCardData } -const { plan } = Astro.props; -const discountPct = plan.listUsd ? Math.round((1 - plan.priceUsd / plan.listUsd) * 100) : null; +import { type PlanCardData } from "../../data/pricing"; +interface Props { plan: PlanCardData; waitlistSource?: string } +const { plan, waitlistSource = "pricing" } = Astro.props; ---
@@ -17,9 +17,15 @@ const discountPct = plan.listUsd ? Math.round((1 - plan.priceUsd / plan.listUsd) reach for the type class, which tied a revenue test to a font size. */} ${plan.priceUsd} {plan.unit} - {plan.listUsd && ${plan.listUsd}} - {discountPct && {discountPct}% off launch}
+ {/* A forward price, never a struck one. The higher figure has never been + charged, so rendering it as a crossed-out "was" invents a reference price + the product never had — the thing CCPA's dark-pattern rules and EU Omnibus + Art. 6a both reach. Stated as the list price at launch it is the same + contrast and a true sentence. */} + {plan.listUsd && ( +
Founding price — ${plan.listUsd} at launch
+ )}
{plan.note}
{plan.features.map((f) => ( @@ -28,7 +34,7 @@ const discountPct = plan.listUsd ? Math.round((1 - plan.priceUsd / plan.listUsd)
{plan.comingSoon ? ( - + ) : ( )} diff --git a/site/src/components/pricing/WaitlistCta.astro b/site/src/components/pricing/WaitlistCta.astro new file mode 100644 index 00000000..03881a92 --- /dev/null +++ b/site/src/components/pricing/WaitlistCta.astro @@ -0,0 +1,143 @@ +--- +import { links } from "../../config"; + +interface Props { + /** Which surface the address came from — sent as `source` to the API. */ + source: string; + /** Unique per instance: two captures can share a page (label/status ids). */ + id: string; +} +const { source, id } = Astro.props; +const inputId = `${id}-email`; +const statusId = `${id}-status`; +--- +{/* `action` is the real endpoint rather than a data-* attribute so the markup + states its own target, but the submit button ships DISABLED: without the + script a native cross-origin POST would land the reader on a raw JSON body, + and a disabled default button also blocks implicit submission from the + input. The script enables it — so scriptless readers get the noscript note + instead of a control that looks live and is not. */} +
+ +
+ + +
+ {/* Reserves its line so a status message does not shift the card under the + pointer that just clicked. */} +

+ +
+ + + + diff --git a/site/src/data/pricing.ts b/site/src/data/pricing.ts index 601e42ad..3d1b3c8f 100644 --- a/site/src/data/pricing.ts +++ b/site/src/data/pricing.ts @@ -4,21 +4,23 @@ // every seat gets its own copy of (the cap is counted per user in // checkCapAndUpsert, so a team never pools them) — the only place a machine count // is a paywall is Free. If YEARLY_OFFER_ACTIVE is turned off in web, set -// OFFER_ACTIVE=false here so the struck price/discount disappear. +// OFFER_ACTIVE=false here so the founding-price line disappears and the card +// shows list. YEARLY_LIST_USD is a price we have not charged yet, so it is only +// ever rendered forwards ("$99 at launch"), never struck through as a former one. // // Checkout is deliberately unwired this release: every card that would charge carries -// `comingSoon`, which swaps the checkout link for a disabled button. Clearing it here -// re-points the CTA at web's live `/checkout` (`web/src/routes/ui.tsx`), but the same -// shutter is duplicated on web's own pricing page (`ComingSoonCta` in -// web/src/ui/pricing.tsx) and on the app's WORKER_CAP Upgrade button +// `comingSoon`, which swaps the checkout link for the founding-pricing capture +// (`WaitlistCta.astro`). Clearing it here re-points the CTA at web's live `/checkout` +// (`web/src/routes/ui.tsx`), but the same shutter is duplicated on web's own pricing +// page (web/src/ui/pricing.tsx) and on the app's WORKER_CAP Upgrade button // (app/lib/screens/device_cap_dialog.dart) — flip all three together, or the funnel // sells a plan two of its three entry points still refuse. import { links } from "../config"; // Single switch for the beta-free period: banners the pricing page, hides the -// trial card, relabels paid CTAs to "Available after beta", and swaps the hero -// pill and closing-CTA copy. Flip to false when plans activate — and update -// support.md's beta note by hand, it is static markdown. +// trial card, and swaps the hero pill, closing-CTA and paid-card copy. Flip to +// false when plans activate — and update support.md's beta note by hand, it is +// static markdown. export const BETA_FREE = true; export const TRIAL_DAYS = 7; @@ -97,10 +99,10 @@ export const proYearly: PlanCardData = { priceUsd: seatPriceUsd, listUsd: OFFER_ACTIVE ? YEARLY_LIST_USD : undefined, unit: "/ seat / year", - // Under BETA_FREE the card's button is disabled ("Available after beta"), so the + // Under BETA_FREE the card's CTA is an interest capture, not a checkout, so the // copy must not promise a startable trial or a running subscription. note: BETA_FREE - ? "Free while the beta runs — this is the launch price" + ? "Free while the beta runs" : `${TRIAL_DAYS}-day free trial, then $${seatPriceUsd} per seat / year`, features: [ "Handler AI assistant — stack instructions, evidence-gated \"done\", one-tap undo", @@ -111,8 +113,10 @@ export const proYearly: PlanCardData = { "E2E zero-knowledge relay · priority support", ], cta: "Get Pro", + // No figure on this line while the CTA is a capture: the reader is agreeing to + // hear from us, not to a price, and the headline above already carries the number. ctaFooter: BETA_FREE - ? `$${seatPriceUsd} per seat / year when plans activate` + ? "Founding pricing at launch · no card, nothing charged during the beta" : `$${seatPriceUsd} per seat / year · renews automatically · cancel anytime`, recommended: true, }; diff --git a/site/src/pages/pricing.astro b/site/src/pages/pricing.astro index 4161aa31..7c0dfc55 100644 --- a/site/src/pages/pricing.astro +++ b/site/src/pages/pricing.astro @@ -19,13 +19,13 @@ import { links } from "../config"; {BETA_FREE && (
- antgrid is free while in beta — everything below is included. Paid plans activate when the beta ends; prices shown are launch prices. + antgrid is free while in beta — everything below is included. Paid plans activate when the beta ends; prices shown are founding prices.
)}
- {!BETA_FREE && } - + {!BETA_FREE && } +
diff --git a/site/src/pages/support.md b/site/src/pages/support.md index 78499188..d3a0777a 100644 --- a/site/src/pages/support.md +++ b/site/src/pages/support.md @@ -42,7 +42,7 @@ Antgrid supports three sign-in options: ## Plans and billing -**Antgrid is currently free while in beta** — every feature is included, Handler and remote control alike, on the house. Paid plans activate when the beta ends; the prices on [Pricing](/pricing) are the launch prices. +**Antgrid is currently free while in beta** — every feature is included, Handler and remote control alike, on the house. Paid plans activate when the beta ends; the prices on [Pricing](/pricing) are founding prices, below the list price at launch. Once plans are live: **Antgrid is free on one worker machine**, with end-to-end encrypted **remote control**, fleet view and browser preview included. **Pro is billed per seat — one seat per person** — and gives every person up to **10 worker machines** of their own, plus **Handler** — the AI assistant that watches your sessions, takes instructions mid-run, and judges every item against evidence — and priority support. Pro includes a **7-day free trial** and covers up to **25 seats**. Larger teams are **Enterprise**, where SSO, audit logs and IP allowlisting are on the roadmap — email us at [contact@radhaai.com](mailto:contact@radhaai.com). See [Pricing](/pricing) for current details. diff --git a/site/tests/contracts.spec.ts b/site/tests/contracts.spec.ts index 90eadc6b..a1df7df6 100644 --- a/site/tests/contracts.spec.ts +++ b/site/tests/contracts.spec.ts @@ -14,6 +14,9 @@ const DOWNLOADS = { linux: "https://github.com/antgrid-ai/antgrid/releases/latest/download/antgrid-linux.AppImage", }; +// The web service, which is a different origin from this static build. +const WAITLIST_ORIGIN = "https://app.antgrid.ai"; + test("desktop downloads point at the published release artifacts", async ({ page }) => { await page.goto("/#download"); const band = page.locator("#download"); @@ -59,14 +62,22 @@ test("the paid path stays closed: no checkout links anywhere", async ({ page }) } }); -test("charging plans render a disabled button, never a live CTA", async ({ page }) => { +test("charging plans capture interest, never a live checkout CTA", async ({ page }) => { await page.goto("/pricing"); - // Any card carrying `comingSoon` (pricing.ts) must swap its checkout link for a - // disabled button. Asserted by state, not by label — the label is BETA_FREE-gated. + // Any card carrying `comingSoon` (pricing.ts) must swap its checkout link for the + // founding-price capture. Asserted by state, not by label — copy is BETA_FREE-gated. const yearlyCard = page.locator("span.font-mono", { hasText: /^Pro$/ }).locator("..").locator(".."); - await expect(yearlyCard.locator("button[disabled]")).toHaveCount(1); + const capture = yearlyCard.locator("form[data-waitlist]"); + await expect(capture).toHaveCount(1); + // The address goes to the web service, cross-origin from this static site. + await expect(capture).toHaveAttribute("action", `${WAITLIST_ORIGIN}/api/waitlist`); + await expect(capture).toHaveAttribute("data-waitlist", "pricing"); await expect(yearlyCard.locator("a[href]")).toHaveCount(0); + // The capture ships disabled so a scriptless reader is told to email instead; once + // the page's script has run nothing in the card may still be inert, or the dead + // paid CTA is back under a new name. + await expect(yearlyCard.locator("button[disabled]")).toHaveCount(0); // The free card is the one plan whose CTA stays live. const freeCard = page.locator("span.font-mono", { hasText: /^Free$/ }).locator("..").locator(".."); @@ -85,7 +96,7 @@ for (const path of ["/pricing", "/terms", "/refunds", "/support"]) { // Indexed pages. og-card is excluded on purpose: it is the screenshot source for // the social card, already noindex and filtered out of the sitemap. -const INDEXED = ["/", "/pricing", "/get-started", "/support", "/privacy", "/terms", "/refunds"]; +const INDEXED = ["/", "/pricing", "/get-started", "/support", "/privacy", "/terms", "/refunds", "/security"]; test("every indexed page ships a description search engines will show whole", async ({ page }) => { // 155 is where Google starts truncating. Social previews cut earlier — mobile diff --git a/site/tests/pricing.spec.ts b/site/tests/pricing.spec.ts index 94013ca0..4a28947c 100644 --- a/site/tests/pricing.spec.ts +++ b/site/tests/pricing.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; // Prices and the tier axis. KEEP IN LOCKSTEP with src/data/pricing.ts, which is itself // pinned to the shipped catalog by web/tests/billing/site-pricing-lockstep.test.ts — @@ -23,15 +23,133 @@ test("plan cards carry the real prices, machine allowances and seat ceiling", as await expect(freeCard.locator("[data-price]", { hasText: "$0" })).toBeVisible(); await expect(freeCard.getByText("1 worker machine")).toBeVisible(); - // Yearly card: $49 offer price (the headline figure) + $99 struck list price, both - // per seat — the unit is the claim, so it is asserted beside the number. + // Yearly card: $49 founding price (the headline figure) and $99 named as the + // price at launch. The unit is part of the claim, so it is asserted beside the + // number. $99 must never render as a struck-through former price — it has never + // been charged, so a crossed-out "was" would be a reference price we invented. await expect(yearlyCard.locator("[data-price]", { hasText: "$49" })).toBeVisible(); - await expect(yearlyCard.locator("span.line-through", { hasText: "$99" })).toBeVisible(); + await expect(yearlyCard.locator("[data-list]", { hasText: "$99" })).toBeVisible(); + await expect(yearlyCard.getByText(/Founding price/)).toBeVisible(); + await expect(yearlyCard.locator("s, del, .line-through")).toHaveCount(0); await expect(yearlyCard.getByText("/ seat / year")).toBeVisible(); await expect(yearlyCard.getByText("Up to 10 worker machines per person")).toBeVisible(); await expect(yearlyCard.getByText(/Up to 25 seats/)).toBeVisible(); }); +// Founding-price capture. The paid card's CTA is an interest form, not a checkout — +// contracts.spec.ts pins its target and the closed paid path; these cover what the +// reader actually experiences at the control. + +const capture = (page: Page) => + page.locator("span.font-mono", { hasText: /^Pro$/ }).locator("..").locator("..").locator("form[data-waitlist]"); + +test("the capture asks for an address without naming a price", async ({ page }) => { + await page.goto("/pricing"); + const form = capture(page); + + // The waitlist trades on "founding pricing", never on a figure or a struck anchor — + // an address is not consent to a price. + await expect(form).not.toContainText("$"); + await expect(form.locator("s, del, .line-through")).toHaveCount(0); + + // Accessibility floor: a real label (visually hidden is fine), an email field, and a + // status line the reader's screen reader is told about. + const field = form.getByLabel(/email address/i); + await expect(field).toHaveAttribute("type", "email"); + await expect(form.locator("[aria-live]")).toHaveCount(1); + await expect(form.getByRole("button", { name: /^Join the list$/ })).toBeEnabled(); +}); + +test("joining posts the address with the surface it came from", async ({ page }) => { + const posted: unknown[] = []; + await page.route("**/api/waitlist", async (route) => { + posted.push(route.request().postDataJSON()); + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json", "access-control-allow-origin": "*" }, + body: JSON.stringify({ ok: true }), + }); + }); + + await page.goto("/pricing"); + const form = capture(page); + await form.getByLabel(/email address/i).fill("founder@example.com"); + await form.getByRole("button", { name: /^Join the list$/ }).click(); + + // One verb throughout: the button says Join, so the confirmation says joined. + await expect(form.locator("[aria-live]")).toContainText(/joined the list/i); + await expect(form.getByRole("button", { name: /^Joined$/ })).toBeVisible(); + expect(posted).toEqual([{ email: "founder@example.com", source: "pricing" }]); +}); + +test("the control says it is working while the address is in flight", async ({ page }) => { + let release = () => {}; + const held = new Promise((resolve) => (release = resolve)); + await page.route("**/api/waitlist", async (route) => { + await held; + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json", "access-control-allow-origin": "*" }, + body: JSON.stringify({ ok: true }), + }); + }); + + await page.goto("/pricing"); + const form = capture(page); + await form.getByLabel(/email address/i).fill("founder@example.com"); + await form.getByRole("button", { name: /^Join the list$/ }).click(); + + // Same verb in every state, so the reader never wonders whether a second thing + // happened: Join -> Joining -> Joined. + await expect(form.getByRole("button", { name: /^Joining/ })).toBeDisabled(); + release(); + await expect(form.getByRole("button", { name: /^Joined$/ })).toBeVisible(); +}); + +test("a malformed address is refused at the field, before anything is sent", async ({ page }) => { + let requests = 0; + await page.route("**/api/waitlist", async (route) => { + requests += 1; + await route.fulfill({ status: 200, headers: { "access-control-allow-origin": "*" }, body: "{}" }); + }); + + await page.goto("/pricing"); + const form = capture(page); + await form.getByLabel(/email address/i).fill("founder@"); + await form.getByRole("button", { name: /^Join the list$/ }).click(); + + await expect(form.locator("[aria-live]")).toContainText(/does not look like an email address/i); + // The error says what to do next and leaves the control usable, rather than + // dead-ending the way the button it replaced did. + await expect(form.locator("[aria-live]")).toContainText(/try again/i); + await expect(form.getByRole("button", { name: /^Join the list$/ })).toBeEnabled(); + expect(requests).toBe(0); +}); + +test("a rejected address explains itself and leaves the reader able to retry", async ({ page }) => { + // The API answers a rejection with a machine code, so the page owes the reader + // its own sentence — echoing "BAD_REQUEST" back at them is not an explanation. + await page.route("**/api/waitlist", async (route) => { + await route.fulfill({ + status: 400, + headers: { "content-type": "application/json", "access-control-allow-origin": "*" }, + body: JSON.stringify({ ok: false, error: "BAD_REQUEST" }), + }); + }); + + await page.goto("/pricing"); + const form = capture(page); + await form.getByLabel(/email address/i).fill("founder@example.com"); + await form.getByRole("button", { name: /^Join the list$/ }).click(); + + const status = form.locator("[aria-live]"); + await expect(status).toContainText(/not accepted/i); + await expect(status).toContainText(/try again/i); + await expect(status).not.toContainText("BAD_REQUEST"); + await expect(form.getByRole("button", { name: /^Join the list$/ })).toBeEnabled(); + await expect(form.getByLabel(/email address/i)).toBeEditable(); +}); + test("the FAQ answers the seat and machine questions in place", async ({ page }) => { await page.goto("/pricing"); await expect(page.getByRole("heading", { name: /what counts as a seat\?/i })).toBeVisible(); diff --git a/web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql b/web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql new file mode 100644 index 00000000..df5bb1ae --- /dev/null +++ b/web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql @@ -0,0 +1,21 @@ +-- Add waitlist_signup for the marketing site's launch-interest capture. +-- Rows are written by the anonymous, cross-origin POST /api/waitlist route; no +-- FK to user — a signup happens long before an account exists. +-- +-- The unique index on "email" is load-bearing, not hygiene: the route inserts +-- with ON CONFLICT DO NOTHING so a repeat submit is a silent no-op answered +-- with the same 200 as a first submit. Without it a second submit would create +-- a duplicate row, and any later de-dup would have to distinguish the two — +-- which is exactly the membership fact the endpoint must not expose. + +CREATE TABLE "waitlist_signup" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "email" TEXT NOT NULL, + "source" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + + CONSTRAINT "waitlist_signup_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "waitlist_signup_email_key" ON "waitlist_signup" ("email"); +CREATE INDEX "waitlist_signup_created_at_idx" ON "waitlist_signup" ("created_at"); diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 5a7f26ea..5fea2e22 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -367,6 +367,21 @@ model AnalyticEvent { @@map("analytic_event") } +// ---------- Marketing waitlist ---------- + +model WaitlistSignup { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + // Unique so a re-submit is an ON CONFLICT DO NOTHING rather than a duplicate + // row — POST /api/waitlist answers 200 either way and must never reveal which + // of the two happened. Stored already lowercased/trimmed by the route. + email String @unique(map: "waitlist_signup_email_key") + source String + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([createdAt], map: "waitlist_signup_created_at_idx") + @@map("waitlist_signup") +} + // ---------- Better-Auth OAuth Provider plugin ---------- model OauthClient { diff --git a/web/src/app.ts b/web/src/app.ts index 03ba8d5b..38342d10 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -14,6 +14,7 @@ import { devBillingRoutes } from "./routes/dev-billing.js"; import { oauthHandoffRoutes } from "./routes/oauth-handoff.js"; import { oauthStartRoutes } from "./routes/oauth-start.js"; import { eventsRoutes } from "./routes/events.js"; +import { waitlistRoutes } from "./routes/waitlist.js"; import { uiRoutes } from "./routes/ui.js"; import { setPublicOrigin } from "./ui/origin.js"; import type { DB } from "./db/index.js"; @@ -110,6 +111,7 @@ export function buildApp(deps: AppDeps) { }); app.route("/", health); app.route("/", eventsRoutes({ db: deps.db, clientIp })); + app.route("/", waitlistRoutes({ db: deps.db, clientIp })); app.route("/", deviceRoutes({ db: deps.db, auth: deps.auth, relay: deps.relay })); app.route("/", agentRoutes({ db: deps.db, auth: deps.auth, env: deps.env })); app.route("/", subscriptionRoutes({ db: deps.db, auth: deps.auth })); diff --git a/web/src/routes/ui.tsx b/web/src/routes/ui.tsx index 2f9a8122..848f00da 100644 --- a/web/src/routes/ui.tsx +++ b/web/src/routes/ui.tsx @@ -1535,10 +1535,10 @@ export function uiRoutes(deps: { const userId = c.get("userId"); await provisionProductAccountForUser(deps.db, userId); const plans = await listActivePlans(deps.db); - // TEMP-PROMO: every plan renders as a disabled "Coming soon" card while - // in-app purchases aren't live — grep "TEMP-PROMO" repo-wide for every - // related spot (backend grant logic in web/src/models/subscription.ts - // plus the matching disabled UI in web/src/ui/pricing.tsx). + // TEMP-PROMO: no plan can be bought while in-app purchases aren't live, so + // the Pro card takes waitlist signups instead of running a checkout — grep + // "TEMP-PROMO" repo-wide for every related spot (backend grant logic in + // web/src/models/subscription.ts plus the static UI in web/src/ui/pricing.tsx). // // TO RESTORE ONCE PAYMENT INTEGRATION SHIPS: delete the `const plans =` // line above and the `c.html(...)` call below, then uncomment the two @@ -1555,7 +1555,7 @@ export function uiRoutes(deps: { // if (plan && isPlanId(plan.slug)) currentPlanSlug = plan.slug; // } return c.html( - + // s.trim().toLowerCase()) + .pipe(z.email()), + source: z.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9_-]*$/), +}); + +// Anonymous public writer keyed on an attacker-chosen email: burst 5, refill +// 1 per 10s per IP. Far tighter than the analytics ingest — a human submits +// this form once, and the row it writes is not idempotent per-IP the way an +// event batch is. +const signupLimiter = tokenBucket(5, 0.1); + +export function waitlistRoutes(deps: { db: DB; clientIp: ClientIpResolver }) { + const r = new Hono(); + + r.post("/api/waitlist", async (c) => { + // Spoof-safe resolution (peer + trusted-proxy XFF walk); the IP is used + // only for this bucket and is deliberately never stored on the row. + const ip = deps.clientIp(c) ?? "unknown"; + if (!signupLimiter(ip)) return c.json({ ok: false, error: "RATE_LIMITED" }, 429); + + const parsed = Signup.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) { + return c.json({ ok: false, error: "BAD_REQUEST", issues: parsed.error.issues }, 400); + } + + // createMany + skipDuplicates emits INSERT ... ON CONFLICT DO NOTHING, so + // two concurrent submits of the same address cannot race into a unique + // violation — which would surface as app.onError's 500 and tell the caller + // the address was already on the list. + await deps.db.waitlistSignup.createMany({ + data: [{ email: parsed.data.email, source: parsed.data.source }], + skipDuplicates: true, + }); + + // Identical response whether the row was inserted or already existed: + // membership in the list is not something a stranger may probe for. + return c.json({ ok: true }, 200); + }); + + return r; +} diff --git a/web/src/ui/asset.ts b/web/src/ui/asset.ts index 0dc7d870..76d694e1 100644 --- a/web/src/ui/asset.ts +++ b/web/src/ui/asset.ts @@ -15,6 +15,7 @@ const ENTRIES = { checkout: "src/ui/entries/checkout.ts", dashboard: "src/ui/entries/dashboard.ts", devices: "src/ui/entries/devices.ts", + waitlist: "src/ui/entries/waitlist.ts", } as const; // Fonts reach the manifest as dependencies of styles.css, not as inputs, so diff --git a/web/src/ui/entries/waitlist.ts b/web/src/ui/entries/waitlist.ts new file mode 100644 index 00000000..9d150f64 --- /dev/null +++ b/web/src/ui/entries/waitlist.ts @@ -0,0 +1,111 @@ +/** + * Founding-price waitlist capture on /pricing. + * + * Not htmx: the target is the public JSON endpoint the marketing site posts to + * as well (POST /api/waitlist), so there is no fragment to swap and no redirect + * to follow — the same reason entries/devices.ts issues its own request rather + * than going through the vendored htmx bundle. + */ + +const IDLE_NOTE = "Founding pricing at launch."; + +/** Confirms in the button's own words. "Submitted" would leave the reader + * guessing whether the thing they joined is the thing that answered. */ +const SUCCESS_NOTE = "You're on the waitlist. Founding pricing at launch."; + +const STATUS_BASE = "text-xs text-center mt-3 min-h-10"; +const STATUS_TONE = { + idle: "text-faint", + ok: "text-ink2", + error: "text-error", +} as const; + +type Tone = keyof typeof STATUS_TONE; + +/** + * The endpoint answers a bare code, and a code is not an instruction — each + * status has to say what the server did with the address and what the reader + * does next. No apology: nothing here is broken, and "sorry" would be the only + * word in the sentence that carries no information. + */ +function messageForStatus(status: number): string { + if (status === 400) { + return "That address isn't a valid email. Correct it and submit again."; + } + if (status === 429) { + return "Too many submissions from this network. Wait a minute, then submit again."; + } + return `The server rejected the request (HTTP ${status}). Submit again in a moment.`; +} + +function setStatus(message: string, tone: Tone) { + const el = document.getElementById("waitlist-status"); + if (!el) return; + el.textContent = message; + el.className = `${STATUS_BASE} ${STATUS_TONE[tone]}`; +} + +let inFlight = false; + +async function submit(form: HTMLFormElement) { + const input = document.getElementById("waitlist-email"); + const button = document.getElementById("waitlist-submit"); + if ( + inFlight || + !(input instanceof HTMLInputElement) || + !(button instanceof HTMLButtonElement) + ) { + return; + } + + inFlight = true; + button.disabled = true; + const idleLabel = button.textContent; + button.textContent = "Joining…"; + setStatus(IDLE_NOTE, "idle"); + + try { + const res = await fetch(form.action, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: input.value, + source: form.dataset.waitlistSource, + }), + }); + if (!res.ok) { + setStatus(messageForStatus(res.status), "error"); + return; + } + // The endpoint answers identically for an address already on the list, so + // there is nothing to tell apart here — and telling them apart is exactly + // what it refuses to leak. + form.hidden = true; + setStatus(SUCCESS_NOTE, "ok"); + } catch { + setStatus( + "The request never reached the server. Check your connection and submit again.", + "error", + ); + } finally { + inFlight = false; + button.disabled = false; + button.textContent = idleLabel; + } +} + +function init() { + const form = document.getElementById("waitlist-form"); + if (!(form instanceof HTMLFormElement)) return; + // The form's own `action`/`method` name the real target, but submission is + // always intercepted: the endpoint reads a JSON body and would answer a + // urlencoded post with the 400 this handler exists to avoid. + form.addEventListener("submit", (ev) => { + ev.preventDefault(); + void submit(form); + }); +} + +init(); + +export {}; diff --git a/web/src/ui/pricing.tsx b/web/src/ui/pricing.tsx index 3a057a31..15217f38 100644 --- a/web/src/ui/pricing.tsx +++ b/web/src/ui/pricing.tsx @@ -1,25 +1,19 @@ import { Layout, PageHead } from "./layout.js"; -import { - BETA, - displayPriceCents, - formatUsd, - FREE_WORKER_LIMIT, - TRIAL_DAYS, - type BillingEnv, -} from "../billing/plans.js"; +import { asset } from "./asset.js"; +import { FREE_WORKER_LIMIT } from "../billing/plans.js"; import type { PlanRow } from "../models/plan.js"; export type PricingPageProps = { user: { email?: string | null }; plans: PlanRow[]; - env: BillingEnv; }; -/** Why a plan can't be bought, in the CTA itself. "Coming soon" reads as - * half-built to someone who arrived from a site that told them the beta is - * free; naming the beta makes the disabled button an explanation. Keep the - * beta wording identical to PlanCard.astro's on the marketing site. */ -const UNAVAILABLE_CTA_LABEL = BETA ? "Available after beta" : "Coming soon"; +/** The public waitlist endpoint (web/src/routes/waitlist.ts), which the + * marketing site posts to as well — same origin as this page, so a relative + * action reaches it. `source` is the bounded slug its schema expects, naming + * the surface that captured the signup. */ +const WAITLIST_ACTION = "/api/waitlist"; +const WAITLIST_SOURCE = "pricing"; /** Sales address for the contract-only plan. */ const ENTERPRISE_MAILTO = "mailto:contact@radhaai.com"; @@ -111,31 +105,60 @@ function FeatureList({ ); } -function UnavailableCta({ footer }: { footer: string }) { +/** The founding-price capture, in the slot a plan's buy button will take back. + * + * One per page: entries/waitlist.ts binds by id, and a second copy would + * collide on every one of them. */ +function WaitlistCta({ email }: { email?: string | null }) { return (
- -

- {footer} +

+ + {/* Prefilled with the signed-in address: /pricing is behind the session + gate, so asking for an address the page already knows reads as a + form that wasn't paying attention. Still editable — a personal + address is a fair answer to "tell me when this launches". */} + + +
+ {/* Idle note, error and confirmation all land here, on the `min-h-10` the + plan footers already reserve — so none of the three resizes the card. */} +

+ Founding pricing at launch.

); } export function PricingPage(props: PricingPageProps) { - const yearlyPrice = displayPriceCents("pro_yearly", props.env); - const trialPlan = props.plans.find((p) => p.slug === "trial"); const yearlyPlan = props.plans.find((p) => p.slug === "pro_yearly"); const enterprisePlan = props.plans.find((p) => p.slug === "enterprise"); return ( - {/* Headline and lede match PricingHeader.astro on the marketing site, - same as the beta CTA wording below — this is the same three plans for - the same reader, and "Simple, honest pricing" said nothing that the - site's line does not say better. Keep them in lockstep. */} + {/* Headline and lede match PricingHeader.astro on the marketing site — + this is the same three plans for the same reader, and "Simple, honest + pricing" said nothing that the site's line does not say better. Keep + them in lockstep. */}

Priced per person. Bring your own machines. @@ -148,62 +171,19 @@ export function PricingPage(props: PricingPageProps) {

- {trialPlan && ( - - )} -
{/* The free plan row is excluded from listActivePlans, so its worker count comes from the same constant that seeds it. */} - {yearlyPlan && } + {yearlyPlan && }
{enterprisePlan && } +