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
46 changes: 29 additions & 17 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 @@ -95,8 +97,9 @@ which the site shows as it is.
| Request | Body | Answer |
|---|---|---|
| `POST /auth/code` | `{ "email": "ada@example.com" }` | `200 { "sent": true }`; `400` not an address; `429` too soon or too many, with `Retry-After`; `502` the email couldn't be sent |
| `POST /auth/verify` | `{ "email": "...", "code": "123456" }` | `200 { "user": <user> }` and the session cookie; `400` or `401` wrong or expired code; `429` too many checks |
| `POST /auth/verify` | `{ "email": "...", "code": "123456", "name"?: "..." }` | `200 { "user": <user> }` and the session cookie; `400` or `401` wrong or expired code, or `400` a name that can't be kept; `429` too many checks |
| `GET /auth/session` | | `200 { "user": <user> }`, or `{ "user": null }` when signed out |
| `POST /auth/profile` | `{ "name": "..." }` | `200 { "user": <user> }`; an empty name clears it; `400` a name that can't be kept; `401` signed out |
| `POST /auth/signout` | `{}` | `200 { "signedOut": true }`, and the cookie is cleared |
| `GET /admin/users` | | `200 { "signup": "waitlist" \| "open", "users": [...] }`, the waiting ones first; `401` signed out; `403` not an admin |
| `POST /admin/approve` | `{ "email": "..." }` | `200 { "approved": true, "emailed": true \| false }`; `404` no such account; `401`, `403` as above |
Expand All @@ -109,10 +112,18 @@ which the site shows as it is.

`<plan>` is `{ "plan": "free" | "team" | "business", "status", "features", "periodEnd", "cancelAtPeriodEnd" }`.

`<user>` is `{ "email": "...", "waiting": false, "admin": false }`. `waiting` is
true while the account is on the waitlist; the site then shows `/waitlist`
instead of the console. Anything added to the service later that only
approved accounts may use should check `waiting` the same way.
`<user>` is `{ "email": "...", "name": "..." | null, "waiting": false, "admin": false }`.
`waiting` is true while the account is on the waitlist; the site then shows
`/waitlist` instead of the console. Anything added to the service later that
only approved accounts may use should check `waiting` the same way.

`name` is what the account goes by, or `null` until one is given. Sign-up can
send it with the code (`/auth/verify`): it names a new account, or one with no
name yet, and never replaces a name the account has. `/auth/profile` changes
it. A name is trimmed and its runs of spaces made one; it can be up to 80
characters, and can't hold control characters or the bidirectional overrides
that would make it display as something else. The admin listing carries each
account's `name` too.

Without `RESEND_API_KEY`, or an `AUTH_SECRET` of at least 32 characters,
`/auth/code` and `/auth/verify` answer `503`.
Expand Down Expand Up @@ -246,8 +257,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: 4 additions & 0 deletions api/migrations/0004_profile_name.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- The name an account goes by, given at sign-up or on the account page. NULL
-- until it is given: the site shows the address instead.

ALTER TABLE users ADD COLUMN name TEXT;
4 changes: 3 additions & 1 deletion api/src/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ async function emailFrom(request: Request) {

type AccountRow = {
email: string;
name: string | null;
status: string;
created_at: number;
last_sign_in_at: number;
Expand All @@ -40,14 +41,15 @@ export async function listUsers(request: Request, env: Env, now: number) {
await requireAdmin(request, env, now);
const admins = adminEmails(env);
const { results } = await env.DB.prepare(
`SELECT email, status, created_at, last_sign_in_at, approved_at, approved_by FROM users
`SELECT email, name, status, created_at, last_sign_in_at, approved_at, approved_by FROM users
ORDER BY status = 'waiting' DESC, created_at DESC LIMIT ?`,
)
.bind(LIST_LIMIT)
.all<AccountRow>();
const open = isOpen(env);
const users = results.map((row) => ({
email: row.email,
name: row.name,
// The status the account has now: an admin never waits, even one added to
// ADMIN_EMAILS after joining the waitlist, and nobody waits while sign-up
// is open. A row stored as waiting waits again if the waitlist comes back.
Expand Down
56 changes: 46 additions & 10 deletions api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// POST /auth/code { email } emails a code
// POST /auth/verify { email, code } checks it and starts a session
// GET /auth/session who the session cookie belongs to
// POST /auth/profile { name } changes the signed-in account's name
// POST /auth/signout ends the session

import type { Env } from "./env";
Expand All @@ -19,15 +20,16 @@ import {
DAY,
LIMITS,
MESSAGES,
NAME_MAX_LENGTH,
RESEND_COOLDOWN_MS,
SESSION_TTL_MS,
} from "./rules";
import { codeHash, isToken, newCode, newToken, sameHash, tokenHash } from "./tokens";

/** What the site is told about the signed-in account. */
export type PublicUser = { email: string; waiting: boolean; admin: boolean };
/** What the site is told about the signed-in account. `name` is null until one is given. */
export type PublicUser = { email: string; name: string | null; waiting: boolean; admin: boolean };

type UserRow = { id: string; email: string; status: string };
type UserRow = { id: string; email: string; status: string; name: string | null };

/** Whether anyone who signs in may use the console; anything but "open" keeps the waitlist. */
export function isOpen(env: Env) {
Expand All @@ -42,7 +44,23 @@ export function adminEmails(env: Env) {
/** An account waits only while sign-up is on the waitlist, until approved; admins never wait. */
function publicUser(row: UserRow, env: Env): PublicUser {
const admin = adminEmails(env).has(row.email);
return { email: row.email, waiting: !admin && !isOpen(env) && row.status !== "approved", admin };
return { email: row.email, name: row.name, waiting: !admin && !isOpen(env) && row.status !== "approved", admin };
}

// Characters a name can't hold: control characters, and the bidirectional
// overrides and isolates that would let a name display as something else.
const NOT_IN_A_NAME = /[\p{Cc}\u202a-\u202e\u2066-\u2069]/u;

/**
* A name as it is kept: trimmed, with runs of spaces made one, or null for none (absent or empty).
* Anything else, or a name longer than NAME_MAX_LENGTH characters, is refused.
*/
export function readName(value: unknown) {
if (value === undefined || value === null) return null;
if (typeof value !== "string") throw new HttpError(400, MESSAGES.badName);
const name = value.normalize("NFC").replace(/\s+/gu, " ").trim();
if ([...name].length > NAME_MAX_LENGTH || NOT_IN_A_NAME.test(name)) throw new HttpError(400, MESSAGES.badName);
return name || null;
}

/** An address in the form accounts are kept under, or null if it isn't one. */
Expand Down Expand Up @@ -148,6 +166,9 @@ export async function verifyCode(request: Request, env: Env, now: number) {
const email = normalizeEmail(body.email);
const code = typeof body.code === "string" ? body.code.trim() : "";
if (!email || code.length !== CODE_LENGTH || !/^\d+$/.test(code)) throw new HttpError(400, MESSAGES.wrongCode);
// Sign-up may send the name to start the account with; it is checked before
// the code, so a name that can't be kept doesn't use up a try.
const name = readName(body.name);

// Every check uses up one of the code's tries, right or wrong.
const live = await db
Expand Down Expand Up @@ -176,14 +197,16 @@ export async function verifyCode(request: Request, env: Env, now: number) {
// A new account joins the waitlist unless sign-up is open or it is an
// admin's; an existing one keeps its status. Either way the address is
// proven, so the session starts: a waiting account sees the waitlist and
// nothing else.
// nothing else. A name sent along names a new account, or one without a
// name yet; it never replaces a name the account already has.
const user = await db
.prepare(
`INSERT INTO users (id, email, created_at, last_sign_in_at, status) VALUES (?1, ?2, ?3, ?3, ?4)
ON CONFLICT (email) DO UPDATE SET last_sign_in_at = excluded.last_sign_in_at
RETURNING id, email, status`,
`INSERT INTO users (id, email, created_at, last_sign_in_at, status, name) VALUES (?1, ?2, ?3, ?3, ?4, ?5)
ON CONFLICT (email) DO UPDATE SET
last_sign_in_at = excluded.last_sign_in_at, name = COALESCE(users.name, excluded.name)
RETURNING id, email, status, name`,
)
.bind(crypto.randomUUID(), email, now, isOpen(env) || adminEmails(env).has(email) ? "approved" : "waiting")
.bind(crypto.randomUUID(), email, now, isOpen(env) || adminEmails(env).has(email) ? "approved" : "waiting", name)
.first<UserRow>();
const token = newToken();
await db
Expand All @@ -206,7 +229,7 @@ export async function sessionAccount(request: Request, env: Env, now: number) {
const token = readSessionCookie(request);
if (!token || !isToken(token)) return null;
const row = await env.DB.prepare(
`SELECT users.id, users.email, users.status FROM sessions JOIN users ON users.id = sessions.user_id
`SELECT users.id, users.email, users.status, users.name FROM sessions JOIN users ON users.id = sessions.user_id
WHERE sessions.id = ? AND sessions.expires_at > ?`,
)
.bind(await tokenHash(token), now)
Expand All @@ -218,6 +241,19 @@ export async function currentSession(request: Request, env: Env, now: number) {
return json({ user: await sessionUser(request, env, now) });
}

/** Sets or clears (an empty name) the signed-in account's name, and answers with the account. */
export async function updateProfile(request: Request, env: Env, now: number) {
const name = readName((await readJson(request)).name);
const signedIn = await sessionUser(request, env, now);
if (!signedIn) throw new HttpError(401, MESSAGES.signedOut);
const row = await env.DB.prepare("UPDATE users SET name = ? WHERE email = ? RETURNING id, email, status, name")
.bind(name, signedIn.email)
.first<UserRow>();
// Removed by an admin between the two steps: signed out as far as anyone knows.
if (!row) throw new HttpError(401, MESSAGES.signedOut);
return json({ user: publicUser(row, env) });
}

export async function signOut(request: Request, env: Env) {
const token = readSessionCookie(request);
if (token && isToken(token)) {
Expand Down
4 changes: 3 additions & 1 deletion api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { approveUser, listUsers, removeUser } from "./admin";
import { currentPlan, manageBilling, receiveWebhook, startCheckout } from "./billing";
import { currentSession, sendCode, signOut, sweep, verifyCode } from "./auth";
import { currentSession, sendCode, signOut, sweep, updateProfile, verifyCode } from "./auth";
import type { Env } from "./env";
import { allowedOrigin, HttpError, json, preflight, withCors } from "./http";
import { MESSAGES } from "./rules";
Expand All @@ -24,6 +24,8 @@ async function route(request: Request, env: Env, origin: string | null) {
return verifyCode(request, env, now);
case "GET /auth/session":
return currentSession(request, env, now);
case "POST /auth/profile":
return updateProfile(request, env, now);
case "POST /auth/signout":
return signOut(request, env);
case "GET /admin/users":
Expand Down
3 changes: 3 additions & 0 deletions api/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const CODE_TTL_MS = 10 * 60_000;
export const CODE_MAX_ATTEMPTS = 5;
export const RESEND_COOLDOWN_MS = 60_000;
export const SESSION_TTL_MS = 30 * 24 * 60 * 60_000;
/** The longest name an account can go by, in characters. */
export const NAME_MAX_LENGTH = 80;

const HOUR = 60 * 60_000;
export const DAY = 24 * HOUR;
Expand Down Expand Up @@ -34,6 +36,7 @@ export function waitText(ms: number) {
export const MESSAGES = {
badRequest: "That request could not be read.",
badEmail: "Enter a valid email address.",
badName: `Enter a name of up to ${NAME_MAX_LENGTH} characters, or leave it empty.`,
wrongCode: "That code is wrong or has expired. Check the newest code, or ask for a new one.",
wait: (seconds: number) => `You can ask for a new code in ${seconds} s.`,
tooManyFromNetwork: (wait: string) => `Too many sign-in attempts from this network. Try again in ${wait}.`,
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", name: null, waiting: false, admin: false } };

let w;
before(async () => {
Expand Down
3 changes: 2 additions & 1 deletion api/test/billing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ describe("billing through Paygate", () => {
let w;
let answer; // what the fake Paygate says next
before(async () => {
w = await start({ bindings: { ADMIN_EMAILS: ADMIN }, paygate: (call) => answer?.(call) });
// On the waitlist, so a new non-admin account waits: "signed-out and waiting accounts can't buy".
w = await start({ bindings: { ADMIN_EMAILS: ADMIN, SIGNUP_MODE: "waitlist" }, paygate: (call) => answer?.(call) });
});
after(() => w.stop());
beforeEach(async () => {
Expand Down
Loading
Loading