Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
A Cloudflare Worker, at `api.floodwall.ai`, behind the site's `/login`,
`/signup`, `/account`, `/waitlist` and `/app` pages. You sign in with a
six-digit code emailed through [Resend](https://resend.com). There is no
password, and the first sign-in with an address makes the account. During the
beta, new accounts wait for an admin to approve them.
password, and the first sign-in with an address makes the account. Sign-up is
open, so a new account goes straight to the console; the service can instead
hold new accounts on a waitlist for an admin to approve.

The site uses the service when it is built with `VITE_AUTH_API_URL` set
(`site/.env.production`). Without it, the site runs the same flow as an
Expand All @@ -20,7 +21,7 @@ in-browser demo that shows the code on the page instead of emailing it.
starts a session.
3. **Stay signed in.** The session is a random token in a cookie that page
scripts can't read. The service keeps only the token's SHA-256.
4. **Wait, during the beta.** While `SIGNUP_MODE` is `waitlist`, a new account
4. **Wait, if the waitlist is on.** While `SIGNUP_MODE` is `waitlist`, a new account
is signed in but waiting: the site shows it `/waitlist` instead of the
console. An admin approves it at `/app/admin`, which emails its owner
"You're in", and the console opens for them from then on.
Expand All @@ -31,13 +32,13 @@ in-browser demo that shows the code on the page instead of emailing it.

| `SIGNUP_MODE` | A new account | Accounts already waiting |
|---|---|---|
| `waitlist` (deployed) | waits for an admin | wait for an admin |
| `open` | goes straight to the console | go straight to the console too |
| `waitlist` | waits for an admin | wait for an admin |
| `open` (deployed) | goes straight to the console | go straight to the console too |

Any value but `open` keeps the waitlist, so a typo can't open sign-up. To open
it once the beta is over, set `"SIGNUP_MODE": "open"` and deploy. Setting it
back to `waitlist` closes it again: approved accounts keep their access, and
accounts that joined while it was open stay approved.
Any value but `open` keeps the waitlist, so a typo can't open sign-up. To put
the waitlist back, set `"SIGNUP_MODE": "waitlist"` and deploy: approved
accounts keep their access, and accounts that joined while it was open stay
approved. Accounts that were waiting when sign-up opened wait again.

The admins are the addresses in the `ADMIN_EMAILS` secret, comma-separated
(kept out of the repository). They never wait, and only they can use
Expand All @@ -47,7 +48,8 @@ The admins are the addresses in the `ADMIN_EMAILS` secret, comma-separated
console on the site the admin approved from. If that email can't be sent,
the approval still stands and the page says so.
- **Remove** an account. It is deleted and signed out everywhere; signing in
again puts it back on the waitlist. Admins can't be removed there.
again makes a new account, which waits if the waitlist is on. Admins can't be
removed there.

| Rule | |
|---|---|
Expand Down Expand Up @@ -198,8 +200,9 @@ Once, by someone with access to the Cloudflare account that has the

6. **Try it.** Sign in at `https://preview.floodwall.ai/login` with an
admin's address: the console opens, with Admin in the menu. Sign up with
another address you can read: it lands on the waitlist and shows up at
`/app/admin` to approve.
another address you can read: the console opens for it too, and it shows
up at `/app/admin`. (With the waitlist on, it lands on `/waitlist`
instead, and waits there to be approved.)

Later deploys are `npm run deploy`. To change who is an admin, run
`npx wrangler secret put ADMIN_EMAILS` again; it takes effect at once. To
Expand Down
4 changes: 2 additions & 2 deletions api/test/auth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ const WRONG = "That code is wrong or has expired. Check the newest code, or ask
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// A new account, as the service describes it while sign-up is on the waitlist.
const ADA = { user: { email: "ada@example.com", waiting: true, admin: false } };
// A new account, as the service describes it with sign-up open, as deployed.
const ADA = { user: { email: "ada@example.com", waiting: false, admin: false } };

let w;
before(async () => {
Expand Down
11 changes: 7 additions & 4 deletions api/test/waitlist.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ const session = async (w, cookie) => (await w.call("/auth/session", { method: "G
const status = async (w, email) =>
(await w.db.prepare("SELECT status FROM users WHERE email = ?").bind(email).first())?.status;

// The waitlist is off as deployed (wrangler.jsonc), so these turn it on.
const WAITLIST = { ADMIN_EMAILS: ADMINS, SIGNUP_MODE: "waitlist" };

describe("while sign-up is on the waitlist", () => {
let w;
before(async () => {
w = await start({ bindings: { ADMIN_EMAILS: ADMINS } });
w = await start({ bindings: WAITLIST });
});
after(() => w.stop());
beforeEach(() => w.reset());
Expand Down Expand Up @@ -146,7 +149,7 @@ describe("while sign-up is on the waitlist", () => {

test("an approval stands when its email can't be sent", async () => {
const failing = await start({
bindings: { ADMIN_EMAILS: ADMINS },
bindings: WAITLIST,
resend: ({ body }) => (body.subject.startsWith("You're in") ? new Response("down", { status: 500 }) : null),
});
try {
Expand Down Expand Up @@ -240,9 +243,9 @@ describe("the SIGNUP_MODE flag", () => {
}
});

test("the deployed setting is the waitlist", async () => {
test("the deployed setting is open sign-up", async () => {
const { readFileSync } = await import("node:fs");
const config = readFileSync(new URL("../wrangler.jsonc", import.meta.url), "utf8");
assert.match(config, /"SIGNUP_MODE": "waitlist"/);
assert.match(config, /"SIGNUP_MODE": "open"/);
});
});
2 changes: 1 addition & 1 deletion api/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"ALLOWED_ORIGINS": "https://floodwall.ai,https://preview.floodwall.ai",
// "waitlist": new accounts wait for an admin to approve them at /app/admin.
// "open": anyone can sign up and use the console, the waitlist included.
"SIGNUP_MODE": "waitlist"
"SIGNUP_MODE": "open"
},
// Clears out expired codes, sessions and rate-limit counts once a day.
"triggers": { "crons": ["17 3 * * *"] },
Expand Down
6 changes: 3 additions & 3 deletions docs/404.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
<link rel="manifest" href="/site.webmanifest" />
<link rel="preload" href="/fonts/geist-variable.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/geist-mono-variable.woff2" as="font" type="font/woff2" crossorigin />
<script type="module" crossorigin src="/assets/index-CWghM7vV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DEUrhNBj.css">
<script type="module" crossorigin src="/assets/index-DkifYvpT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B8lidDyU.css">
<meta name="description" content="A Rust control plane for high-volume, agent-driven DevOps: admission control, policy gates that admit, defer or reject each intent, and a tamper-evident ledger.">
<link rel="canonical" href="https://floodwall.ai/">
<meta property="og:type" content="website">
Expand All @@ -59,7 +59,7 @@
<body class="min-w-[320px] overflow-x-clip bg-paper text-base leading-normal text-ink [-webkit-font-smoothing:antialiased]">
<noscript>
<p class="noscript-note page-width">
The diagram, the waitlist sign-up and the console need JavaScript.
The diagram, sign-up and the console need JavaScript.
</p>
</noscript>
<div id="root"></div>
Expand Down

Large diffs are not rendered by default.

24 changes: 12 additions & 12 deletions docs/assets/index-CWghM7vV.js → docs/assets/index-DkifYvpT.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions docs/index.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
<body class="min-w-[320px] overflow-x-clip bg-paper text-base leading-normal text-ink [-webkit-font-smoothing:antialiased]">
<noscript>
<p class="noscript-note page-width">
The diagram, the waitlist sign-up and the console need JavaScript.
The diagram, sign-up and the console need JavaScript.
</p>
</noscript>
<div id="root"></div>
Expand Down
4 changes: 2 additions & 2 deletions site/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { LandingGateSection } from "./components/landing/LandingGateSection";
import { LandingGetStartedSection } from "./components/landing/LandingGetStartedSection";
import { LandingHero } from "./components/landing/LandingHero";
import { LandingLedgerSection } from "./components/landing/LandingLedgerSection";
import { LandingLineSection } from "./components/landing/LandingLineSection";
import { LandingModelSection } from "./components/landing/LandingModelSection";
import { LandingRoadmapSection } from "./components/landing/LandingRoadmapSection";
import { LandingWaitlistSection } from "./components/landing/LandingWaitlistSection";
import { SiteFooter } from "./components/shared/SiteFooter";
import { SiteHeader } from "./components/shared/SiteHeader";
import { useSectionLinks } from "./hooks/useSectionLinks";
Expand All @@ -28,7 +28,7 @@ export default function App() {
<LandingGateSection />
<LandingLedgerSection />
<LandingRoadmapSection />
<LandingWaitlistSection />
<LandingGetStartedSection />
</main>
<SiteFooter />
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,26 @@ import { EARLY_ACCESS_URL } from "../../constants/links";
import { Icon } from "../shared/Icon";
import { LandingSectionHeading } from "./LandingSectionHeading";

/** Early access, as /pricing offers it: signing up puts an account on the site's own waitlist. */
export function LandingWaitlistSection() {
/** The Free plan, as /pricing offers it: signing up makes an account that opens the console. */
export function LandingGetStartedSection() {
return (
<section className="section" id="waitlist" aria-labelledby="waitlist-title">
<section className="section" id="get-started" aria-labelledby="get-started-title">
<div className="section-drawing">
<LandingSectionHeading id="waitlist-title" kicker="Early access">
Gate your agents. <strong>Join the waitlist.</strong>
<LandingSectionHeading id="get-started-title" kicker="Free plan">
Gate your agents. <strong>Start for free.</strong>
</LandingSectionHeading>
<div className="section-body">
<div className="card feature-card">
<p className="feature-copy">
Sign up with your email, and we will let you in as early access opens.
Create a free account with your email, then open the console with a plane of example intents. No
credit card is needed.
</p>
</div>
<div className="card waitlist-card">
<div className="card get-started-card">
<a className="button button-lg" href={EARLY_ACCESS_URL}>
Join the waitlist <Icon icon={ArrowRight02Icon} className="button-arrow" />
Get started free <Icon icon={ArrowRight02Icon} className="button-arrow" />
</a>
<p className="waitlist-fallback">
<p className="get-started-fallback">
Already have an account? <a href={AUTH_PATHS.login}>Sign in</a>.
</p>
</div>
Expand Down
4 changes: 2 additions & 2 deletions site/src/components/pricing/PricingHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ export function PricingHero() {
and console for you, billed by the decisions your agents&rsquo; changes need.
</p>
<div className="pricing-actions">
<p className="pricing-note">Hosted plans by invitation during early access</p>
<p className="pricing-note">Start on Free. Paid plans aren&rsquo;t billed yet</p>
<div className="pricing-buttons">
<a className="button button-lg" href={EARLY_ACCESS_URL}>
Request early access <Icon icon={ArrowRight02Icon} className="button-arrow" />
Get started free <Icon icon={ArrowRight02Icon} className="button-arrow" />
</a>
<a className="hero-link" href={REPO_URL}>
<Icon icon={GithubIcon} /> View the Crate <Icon icon={ArrowRight02Icon} />
Expand Down
2 changes: 1 addition & 1 deletion site/src/components/shared/SiteHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function NavItemBox({ index, registerItem, label }: { index: number; registerIte

/**
* The sticky header on the landing page and /pricing: the logo (which goes home), a "How it works"
* menu over the sections that explain the wall, then Roadmap, Pricing and Waitlist. One hover
* menu over the sections that explain the wall, then Roadmap, Pricing and Sign up. One hover
* highlight glides between the top-level items, and another between the panel's sections. On the
* landing page the current item follows the section in view; /pricing (`onLanding` false) is the
* only other page with this header, so there Pricing is current and section links lead to "/#…".
Expand Down
7 changes: 5 additions & 2 deletions site/src/constants/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ export const ADMIN_COPY = {
approving: "Approving…",
remove: "Remove",
removing: "Removing…",
confirmRemove: (email: string) =>
`Remove ${email}? They are signed out everywhere. Signing in again puts them back on the waitlist.`,
/** Signing in again makes a new account, which waits only while sign-up is on the waitlist. */
confirmRemove: (email: string, signup: SignupMode) =>
`Remove ${email}? They are signed out everywhere. ${
signup === "open" ? "Signing in again makes a new account." : "Signing in again puts them back on the waitlist."
}`,
approved: (email: string) => `Approved ${email}. We emailed them.`,
approvedNoEmail: (email: string) =>
`Approved ${email}, but the email to them could not be sent. They can sign in now.`,
Expand Down
2 changes: 1 addition & 1 deletion site/src/constants/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const AUTH_COPY = {
signup: {
kicker: "Free · $0/month",
title: "Gate your first agent change.",
lead: "Create your free account with just your email. Floodwall is in beta, so new accounts join the waitlist, and we let people into the console a few at a time. No credit card or API key is needed.",
lead: "Create your free account, then open the console with a plane of example intents. No credit card or API key is needed to try it.",
note: "We will email you a six-digit sign-in code. There is no password. Your account starts on the Free plan.",
terms: "Creating an account means you accept the terms. The privacy notice lists what we store.",
switchPrompt: "Already have an account?",
Expand Down
13 changes: 7 additions & 6 deletions site/src/constants/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ export const REPO_URL = "https://github.com/erphq/floodwall";
export const PRICING_PATH = "/pricing";

/**
* Hosted plans open by invitation: signing up at /signup (AUTH_PATHS.signup) puts a new account on
* the site's own waitlist until an admin approves it. A plain path, not a router link: the landing
* page is prerendered without the router.
* Where every "Get started" goes: /signup (AUTH_PATHS.signup) makes a free account that opens the
* console. Sign-up is open; were the service's SIGNUP_MODE set back to "waitlist", a new account
* would wait for an admin instead. A plain path, not a router link: the landing page is prerendered
* without the router.
*/
export const EARLY_ACCESS_URL = "/signup";

Expand All @@ -34,11 +35,11 @@ export const NAV_EXPLAINERS = [
export const NAV_LINKS = [
{ id: "roadmap", label: "Roadmap", href: "#roadmap" },
{ id: "pricing", label: "Pricing", href: PRICING_PATH },
{ id: "waitlist", label: "Waitlist", href: "#waitlist" },
{ id: "get-started", label: "Sign up", href: "#get-started" },
] as const;

/** Every landing section the header tracks, in page order; Home is the logo's. */
export const NAV_SECTION_IDS = ["home", "line", "model", "gate", "ledger", "roadmap", "waitlist"] as const;
export const NAV_SECTION_IDS = ["home", "line", "model", "gate", "ledger", "roadmap", "get-started"] as const;

export const FOOTER_GROUPS: { title: string; links: Link[] }[] = [
{
Expand All @@ -48,7 +49,7 @@ export const FOOTER_GROUPS: { title: string; links: Link[] }[] = [
{ label: "Model", href: "#model" },
{ label: "Gate", href: "#gate" },
{ label: "Ledger", href: "#ledger" },
{ label: "Waitlist", href: "#waitlist" },
{ label: "Get started", href: "#get-started" },
],
},
{
Expand Down
11 changes: 6 additions & 5 deletions site/src/constants/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export type PricingPlan = {
featured?: boolean;
};

const EARLY_ACCESS = { label: "Request early access", href: EARLY_ACCESS_URL };
/** Every hosted plan starts as a free account; paid plans aren't billed yet. */
const GET_STARTED = { label: "Get started", href: EARLY_ACCESS_URL };

export const PRICING_PLANS: PricingPlan[] = [
{
Expand All @@ -41,7 +42,7 @@ export const PRICING_PLANS: PricingPlan[] = [
priceNote: "per month",
summary: "Floodwall Cloud for a team's first agents.",
features: ["1 plane", "25,000 decisions a month", "7-day hosted ledger", "Up to 3 approvers", "Community support"],
cta: EARLY_ACCESS,
cta: GET_STARTED,
},
{
name: "Launch",
Expand All @@ -56,7 +57,7 @@ export const PRICING_PLANS: PricingPlan[] = [
"Approvals and alerts in Slack",
"Email support",
],
cta: EARLY_ACCESS,
cta: GET_STARTED,
featured: true,
},
{
Expand All @@ -72,7 +73,7 @@ export const PRICING_PLANS: PricingPlan[] = [
"Signed checkpoints, verified for you",
"99.9% uptime SLA and priority support",
],
cta: EARLY_ACCESS,
cta: GET_STARTED,
},
];

Expand Down Expand Up @@ -156,6 +157,6 @@ export const PRICING_FAQ = [
{
question: "When can I sign up?",
answer:
"Hosted plans open by invitation during early access. The prices here are for planning and may change before general availability.",
"Now: create a free account with your email and open the console. Paid plans aren't billed yet, and the prices here are for planning and may change before general availability.",
},
];
10 changes: 6 additions & 4 deletions site/src/pages/console/AdminPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ const STATUS_TONE: Record<AdminAccount["status"], string> = { waiting: "warn", a
/**
* The waitlist for admins: every account, the waiting ones first, with
* Approve and Remove. The service refuses anyone who isn't an admin, and the
* page shows its answer.
* page shows its answer. While sign-up is open nobody waits, so the page
* opens on All instead of Waiting.
*/
export function AdminPage() {
const [params, setParams] = useSearchParams();
const filter = ADMIN_FILTERS.find((f) => f.key === params.get("status")) ?? ADMIN_FILTERS[0];
const [listing, setListing] = useState<Listing | null>(null);
const defaultFilter = ADMIN_FILTERS.find((f) => f.key === (listing?.signup === "open" ? "all" : "waiting"))!;
const filter = ADMIN_FILTERS.find((f) => f.key === params.get("status")) ?? defaultFilter;
const [loadError, setLoadError] = useState("");
const [busy, setBusy] = useState<{ email: string; action: "approve" | "remove" } | null>(null);
const [status, setStatus] = useState("");
Expand All @@ -45,7 +47,7 @@ export function AdminPage() {

async function act(email: string, action: "approve" | "remove") {
if (busy) return;
if (action === "remove" && !window.confirm(ADMIN_COPY.confirmRemove(email))) return;
if (action === "remove" && !window.confirm(ADMIN_COPY.confirmRemove(email, listing?.signup ?? "waitlist"))) return;
setBusy({ email, action });
setStatus("");
try {
Expand Down Expand Up @@ -86,7 +88,7 @@ export function AdminPage() {

<ConsolePanel flush>
<div className="border-b border-line px-5 py-3 console-narrow:px-4">
<Tabs value={filter.key} onValueChange={(key) => setParams(key === ADMIN_FILTERS[0].key ? {} : { status: key })}>
<Tabs value={filter.key} onValueChange={(key) => setParams(key === defaultFilter.key ? {} : { status: key })}>
<TabsList className="scroll-fade-x max-w-full overflow-x-auto group-data-horizontal/tabs:h-9">
{ADMIN_FILTERS.map((f) => (
<TabsTrigger key={f.key} value={f.key} className="px-3">
Expand Down
Loading
Loading