Commit

Email verification, password reset, and Git for AI scale positioning

- identity: verification and reset tokens, emails through Cloudflare Email Sending; unverified accounts cannot create or change anything - site: verify, forgot and reset pages, and a banner for unconfirmed accounts - kit: helpers for JavaScript bindings without a typed Rust wrapper - marketing copy repositioned around scale

syntaqxcommitted Parent030813aBrowse files
24 files+640−270/24 viewed
+2−1
11 # g1t
22
3−A git forge built for agents, running on Cloudflare Workers and Artifacts.
3+Git for AI scale: a forge for thousands of agents working on the same code at
4+once, running on Cloudflare Workers and Artifacts.
45
56 A pull request assumes one author and one change. g1t assumes many agents
67 working at once: you state a goal as an **intent**, any number of agents
+10−8
8181 <ArrowRight size={12} />
8282 </Link>
8383 <h1 className="mx-auto mt-6 max-w-3xl animate-fade-up text-5xl leading-[1.05] font-semibold tracking-[-0.035em] text-balance sm:text-7xl">
84− Many attempts.
84+ Git for
8585 <br />
86− <span className="text-accent">One ships.</span>
86+ <span className="text-accent">AI scale.</span>
8787 </h1>
8888 <p className="mx-auto mt-6 max-w-xl animate-fade-up text-lg leading-7 text-muted text-balance">
89− g1t is a git forge built for agents. State a goal, let any number
90− of agents attempt it in parallel, each in its own fork, and land
91− the one that works.
89+ Git was built for people taking turns. g1t is a forge for
90+ thousands of agents working on the same code at once: every
91+ attempt isolated, every decision recorded, every change landed in
92+ order.
9293 </p>
9394 <div className="mt-8 flex animate-fade-up flex-wrap items-center justify-center gap-3">
9495 <ButtonLink to="/register" variant="accent" large>
128129 Claude Code and other MCP clients connect to mcp.g1t.sh with one
129130 command. Everything is also a plain REST call at api.g1t.sh.
130131 </FeatureCard>
131− <FeatureCard title="Ship through a queue" illustration={<ShipIllustration />} soon wide>
132− Pick the attempt that passes. A landing queue merges it into main
133− and brings the others up to date.
132+ <FeatureCard title="Converge on main" illustration={<ShipIllustration />} soon wide>
133+ However many attempts are in flight, changes reach main one at a
134+ time and in order. An attempt that has fallen behind is told, and
135+ catches up before it lands.
134136 </FeatureCard>
135137 </div>
136138 </section>
+18−1
1212 useRouteLoaderData,
1313 } from "react-router";
1414
15+import type { User } from "@g1t/contracts";
16+
1517 import type { Route } from "./+types/root";
1618 import "./app.css";
1719 import { Logo } from "./components/logo";
5355 );
5456 }
5557
56−function Header({ user }: { user: { username: string } | null | undefined }) {
58+function Header({ user }: { user: User | null | undefined }) {
5759 return (
5860 <header className="sticky top-0 z-40 border-b border-line bg-surface/85 backdrop-blur">
5961 <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4">
179181 </head>
180182 <body className="flex min-h-screen flex-col">
181183 <Header user={user} />
184+ {user && !user.verified && (
185+ <Form
186+ method="post"
187+ action="/verify"
188+ className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warn/30 bg-warn/10 px-4 py-2 text-sm"
189+ >
190+ <span>
191+ Confirm your email address to create repositories and push. We
192+ sent you a link.
193+ </span>
194+ <button type="submit" className="font-medium underline underline-offset-4">
195+ Send it again
196+ </button>
197+ </Form>
198+ )}
182199 <div className="grow">{children}</div>
183200 <Footer />
184201 <ScrollRestoration />
+3−0
55 route("login", "routes/login.tsx"),
66 route("register", "routes/register.tsx"),
77 route("logout", "routes/logout.tsx"),
8+ route("verify", "routes/verify.tsx"),
9+ route("forgot", "routes/forgot.tsx"),
10+ route("reset", "routes/reset.tsx"),
811 route("new", "routes/new.tsx"),
912 route("settings", "routes/settings.tsx"),
1013 route("explore", "routes/explore.tsx", { id: "explore" }),
+48−0
1+import { Form, Link } from "react-router";
2+
3+import type { Route } from "./+types/forgot";
4+import { AuthCard } from "../components/auth-card";
5+import { Button, Field, Input } from "../components/ui";
6+import { identity } from "../lib/services.server";
7+import { assertSameOrigin } from "../lib/session.server";
8+
9+export function meta({}: Route.MetaArgs) {
10+ return [{ title: "Reset your password · g1t" }];
11+}
12+
13+export async function action({ request }: Route.ActionArgs) {
14+ assertSameOrigin(request);
15+ const form = await request.formData();
16+ await identity.requestPasswordReset(String(form.get("email") ?? ""));
17+ return { sent: true };
18+}
19+
20+export default function Forgot({ actionData }: Route.ComponentProps) {
21+ return (
22+ <AuthCard
23+ title="Forgot your password?"
24+ subtitle="We will email you a link"
25+ footer={
26+ <Link to="/login" className="text-fg underline underline-offset-4">
27+ Back to sign in
28+ </Link>
29+ }
30+ >
31+ {actionData?.sent ? (
32+ <p className="rounded-lg border border-line bg-surface p-4 text-sm leading-6">
33+ If that address has an account, a reset link is on its way. It works
34+ for one hour.
35+ </p>
36+ ) : (
37+ <Form method="post" className="space-y-4">
38+ <Field label="Email">
39+ <Input name="email" type="email" autoComplete="email" required autoFocus />
40+ </Field>
41+ <div className="pt-2 *:w-full">
42+ <Button type="submit">Send reset link</Button>
43+ </div>
44+ </Form>
45+ )}
46+ </AuthCard>
47+ );
48+}
+2−2
1717
1818 export function meta({}: Route.MetaArgs) {
1919 return [
20− { title: "g1t — a git forge built for agents" },
20+ { title: "g1t — Git for AI scale" },
2121 {
2222 name: "description",
2323 content:
24− "State a goal, let any number of agents attempt it in parallel, each in its own fork, and land the one that works. Open source, built on Cloudflare.",
24+ "A git forge for thousands of agents working on the same code at once: every attempt isolated, every decision recorded, every change landed in order. Open source, built on Cloudflare.",
2525 },
2626 ];
2727 }
+5−0
6363 <div className="pt-2 *:w-full">
6464 <Button type="submit">Sign in</Button>
6565 </div>
66+ <p className="text-center text-sm">
67+ <Link to="/forgot" className="text-muted hover:text-fg">
68+ Forgot your password?
69+ </Link>
70+ </p>
6671 </Form>
6772 </AuthCard>
6873 );
+1−1
3737 return (
3838 <AuthCard
3939 title="Create your account"
40− subtitle="Put your agents to work"
40+ subtitle="Git for AI scale"
4141 footer={
4242 <>
4343 Already have an account?{" "}
+58−0
1+import { Form, Link, redirect } from "react-router";
2+
3+import type { Route } from "./+types/reset";
4+import { AuthCard } from "../components/auth-card";
5+import { Button, ErrorText, Field, Input } from "../components/ui";
6+import { identity } from "../lib/services.server";
7+import { assertSameOrigin } from "../lib/session.server";
8+
9+export function meta({}: Route.MetaArgs) {
10+ return [{ title: "Choose a new password · g1t" }];
11+}
12+
13+export function loader({ request }: Route.LoaderArgs) {
14+ return { token: new URL(request.url).searchParams.get("token") ?? "" };
15+}
16+
17+export async function action({ request }: Route.ActionArgs) {
18+ assertSameOrigin(request);
19+ const form = await request.formData();
20+ const result = await identity.resetPassword(
21+ String(form.get("token") ?? ""),
22+ String(form.get("password") ?? ""),
23+ );
24+ if (!result.ok) return { error: result.error.message };
25+ throw redirect("/login?reset=1");
26+}
27+
28+export default function Reset({ loaderData, actionData }: Route.ComponentProps) {
29+ return (
30+ <AuthCard
31+ title="Choose a new password"
32+ subtitle="You will be signed out everywhere"
33+ footer={
34+ <Link to="/login" className="text-fg underline underline-offset-4">
35+ Back to sign in
36+ </Link>
37+ }
38+ >
39+ <Form method="post" className="space-y-4">
40+ <input type="hidden" name="token" value={loaderData.token} />
41+ <Field label="New password" hint="At least 10 characters.">
42+ <Input
43+ name="password"
44+ type="password"
45+ autoComplete="new-password"
46+ required
47+ minLength={10}
48+ autoFocus
49+ />
50+ </Field>
51+ <ErrorText>{actionData?.error}</ErrorText>
52+ <div className="pt-2 *:w-full">
53+ <Button type="submit">Set password</Button>
54+ </div>
55+ </Form>
56+ </AuthCard>
57+ );
58+}
+64−0
1+import { CircleCheck, MailWarning } from "lucide-react";
2+import { redirect } from "react-router";
3+
4+import type { Route } from "./+types/verify";
5+import { ButtonLink } from "../components/ui";
6+import { identity } from "../lib/services.server";
7+import { assertSameOrigin, requireUser } from "../lib/session.server";
8+
9+export function meta({}: Route.MetaArgs) {
10+ return [{ title: "Confirm your email · g1t" }];
11+}
12+
13+/** Follows the link from the confirmation email. */
14+export async function loader({ request }: Route.LoaderArgs) {
15+ const token = new URL(request.url).searchParams.get("token") ?? "";
16+ const result = await identity.verifyEmail(token);
17+ return result.ok
18+ ? { ok: true as const, username: result.value.username }
19+ : { ok: false as const, message: result.error.message };
20+}
21+
22+/** "Resend" from the banner shown to unconfirmed accounts. */
23+export async function action({ request, context }: Route.ActionArgs) {
24+ assertSameOrigin(request);
25+ await identity.resendVerification(requireUser(context, request));
26+ throw redirect("/?sent=1");
27+}
28+
29+export default function Verify({ loaderData }: Route.ComponentProps) {
30+ return (
31+ <main className="mx-auto max-w-md px-4 py-32 text-center">
32+ {loaderData.ok ? (
33+ <>
34+ <CircleCheck size={40} className="mx-auto text-accent" />
35+ <h1 className="mt-6 text-2xl font-semibold tracking-tight">
36+ Email confirmed
37+ </h1>
38+ <p className="mt-2 text-muted">
39+ Your account {loaderData.username} is ready to use.
40+ </p>
41+ <div className="mt-8">
42+ <ButtonLink to="/">Go to g1t</ButtonLink>
43+ </div>
44+ </>
45+ ) : (
46+ <>
47+ <MailWarning size={40} className="mx-auto text-warn" />
48+ <h1 className="mt-6 text-2xl font-semibold tracking-tight">
49+ That link did not work
50+ </h1>
51+ <p className="mt-2 text-muted">{loaderData.message}</p>
52+ <p className="mt-2 text-sm text-muted">
53+ Sign in and use the banner at the top to send a new one.
54+ </p>
55+ <div className="mt-8">
56+ <ButtonLink to="/login" variant="quiet">
57+ Sign in
58+ </ButtonLink>
59+ </div>
60+ </>
61+ )}
62+ </main>
63+ );
64+}
+21−0
117117 pub email: String,
118118 pub password: String,
119119 }
120+
121+/// `verify_email`: the token from the emailed link. Returns `Outcome<User>`.
122+#[derive(Debug, Serialize, Deserialize)]
123+pub struct EmailTokenArgs {
124+ pub token: String,
125+}
126+
127+/// `request_password_reset`. Always succeeds, so it cannot be used to find
128+/// out which addresses have accounts.
129+#[derive(Debug, Serialize, Deserialize)]
130+pub struct EmailArgs {
131+ pub email: String,
132+}
133+
134+/// `reset_password`: sets a new password and ends every session.
135+/// Returns `Outcome<User>`.
136+#[derive(Debug, Serialize, Deserialize)]
137+pub struct ResetPasswordArgs {
138+ pub token: String,
139+ pub password: String,
140+}
+4−0
1919 pub struct User {
2020 pub id: String,
2121 pub username: String,
22+ /// Whether the account's email address has been confirmed. Unverified
23+ /// accounts can sign in but cannot create or change anything.
24+ #[serde(default)]
25+ pub verified: bool,
2226 }
2327
2428 /// Who is asking. Every read and write in every service takes one.
+1−1
22 const RESERVED: &[&str] = &[
33 "api", "mcp", "login", "logout", "register", "new", "settings", "search", "admin", "auth",
44 "attempts", "oauth", "assets", "docs", "explore", "g1t", "about", "pricing", "terms",
5− "privacy", "help", "support", "status", "blog",
5+ "privacy", "help", "support", "status", "blog", "verify", "forgot", "reset",
66 ];
77
88 /// Namespaces follow GitHub's rules: letters, digits and single hyphens,
+71−0
5959 }
6060 response.json().await
6161 }
62+
63+/// Helpers for bindings that workers-rs has no typed wrapper for, such as
64+/// Artifacts and Email Sending. Values cross the boundary as JSON.
65+pub mod js {
66+ use serde::Serialize;
67+ use serde::de::DeserializeOwned;
68+ use worker::js_sys::{Function, JSON, Promise, Reflect};
69+ use worker::wasm_bindgen::{JsCast, JsValue};
70+ use worker::wasm_bindgen_futures::JsFuture;
71+ use worker::{Env, Error, Result};
72+
73+ fn error(context: &str, value: JsValue) -> Error {
74+ let message = JSON::stringify(&value)
75+ .ok()
76+ .and_then(|text| text.as_string())
77+ .filter(|text| text != "{}")
78+ .or_else(|| {
79+ Reflect::get(&value, &"message".into())
80+ .ok()
81+ .and_then(|message| message.as_string())
82+ })
83+ .unwrap_or_else(|| format!("{value:?}"));
84+ Error::RustError(format!("{context}: {message}"))
85+ }
86+
87+ /// The binding called `name`, as a raw JavaScript value.
88+ pub fn binding(env: &Env, name: &str) -> Result<JsValue> {
89+ let value = Reflect::get(env.as_ref(), &name.into()).map_err(|e| error(name, e))?;
90+ if value.is_undefined() {
91+ return Err(Error::RustError(format!(
92+ "binding {name} is not configured"
93+ )));
94+ }
95+ Ok(value)
96+ }
97+
98+ pub fn to_js<T: Serialize>(value: &T) -> Result<JsValue> {
99+ JSON::parse(&serde_json::to_string(value)?).map_err(|e| error("to_js", e))
100+ }
101+
102+ pub fn from_js<T: DeserializeOwned>(value: &JsValue) -> Result<T> {
103+ if value.is_undefined() {
104+ return Ok(serde_json::from_value(serde_json::Value::Null)?);
105+ }
106+ let text = JSON::stringify(value)
107+ .map_err(|e| error("from_js", e))?
108+ .as_string()
109+ .unwrap_or_else(|| "null".to_owned());
110+ Ok(serde_json::from_str(&text)?)
111+ }
112+
113+ /// Calls `target.method(...args)` and awaits the result if it is a
114+ /// promise.
115+ pub async fn call(target: &JsValue, method: &str, args: &[JsValue]) -> Result<JsValue> {
116+ let function: Function = Reflect::get(target, &method.into())
117+ .map_err(|e| error(method, e))?
118+ .dyn_into()
119+ .map_err(|_| Error::RustError(format!("{method} is not a function")))?;
120+ let arguments = worker::js_sys::Array::new();
121+ for arg in args {
122+ arguments.push(arg);
123+ }
124+ let returned = function
125+ .apply(target, &arguments)
126+ .map_err(|e| error(method, e))?;
127+ match returned.dyn_into::<Promise>() {
128+ Ok(promise) => JsFuture::from(promise).await.map_err(|e| error(method, e)),
129+ Err(value) => Ok(value),
130+ }
131+ }
132+}
+5−0
3333 call("register", { username, email, password }),
3434 signIn: (username, password) => call("sign_in", { username, password }),
3535 signOut: (sessionToken) => call("sign_out", { sessionToken }),
36+ resendVerification: (user) => call("resend_verification", { user }),
37+ verifyEmail: (token) => call("verify_email", { token }),
38+ requestPasswordReset: (email) => call("request_password_reset", { email }),
39+ resetPassword: (token, password) =>
40+ call("reset_password", { token, password }),
3641 userForSession: (sessionToken) => call("user_for_session", { sessionToken }),
3742 userForGitCredentials: (username, secret) =>
3843 call("user_for_git_credentials", { username, secret }),
+19−1
11 import type { Result } from "./result";
22
3−export type User = { id: string; username: string };
3+export type User = {
4+ id: string;
5+ username: string;
6+ /**
7+ * Whether the account's email address is confirmed. Only set on users
8+ * resolved from credentials; unverified accounts cannot change anything.
9+ */
10+ verified?: boolean;
11+};
412
513 /** Who is asking. Every read and write in every service takes one. */
614 export type Viewer = User | null;
2129 /** Verifies a username and password for website sign-in. */
2230 signIn(username: string, password: string): Promise<Result<{ user: User; sessionToken: string }>>;
2331 signOut(sessionToken: string): Promise<void>;
32+
33+ /** Sends the confirmation email again. */
34+ resendVerification(user: User): Promise<Result<boolean>>;
35+ /** Confirms the address the emailed token was sent to. */
36+ verifyEmail(token: string): Promise<Result<User>>;
37+ /** Emails a reset link if the address has an account. Always resolves. */
38+ requestPasswordReset(email: string): Promise<boolean>;
39+ /** Sets a new password from an emailed token and ends every session. */
40+ resetPassword(token: string, password: string): Promise<Result<User>>;
41+
2442 userForSession(sessionToken: string): Promise<Viewer>;
2543
2644 /** Verifies git credentials: the account password or an access token. */
+1−1
66 /** Routes and reserved words that may not be registered as usernames. */
77 const RESERVED = new Set([
88 "api", "mcp", "login", "logout", "register", "new", "settings", "search",
9− "admin", "auth", "attempts", "oauth", "assets", "docs", "explore", "g1t", "about",
9+ "admin", "auth", "attempts", "verify", "forgot", "reset", "oauth", "assets", "docs", "explore", "g1t", "about",
1010 ]);
1111
1212 export function isValidNamespace(value: string): boolean {
+6−0
3333 export function httpStatus(failure: Failure): number {
3434 return HTTP_STATUS[failure.code];
3535 }
36+
37+/** Returned when an account with an unconfirmed email tries to change something. */
38+export const UNVERIFIED = fail(
39+ "forbidden",
40+ "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh.",
41+);
+14−0
1+ALTER TABLE users ADD COLUMN email_verified_at INTEGER;
2+
3+-- Accounts that existed before verification was required.
4+UPDATE users SET email_verified_at = unixepoch();
5+
6+-- One-time links sent by email. id is the SHA-256 of the token in the link.
7+CREATE TABLE email_tokens (
8+ id TEXT PRIMARY KEY,
9+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
10+ -- 'verify' or 'reset'
11+ kind TEXT NOT NULL,
12+ expires_at INTEGER NOT NULL
13+);
14+CREATE INDEX email_tokens_user ON email_tokens (user_id, kind);
+72−0
1+//! Transactional email through Cloudflare Email Sending.
2+
3+use g1t_kit::js;
4+use serde::Serialize;
5+use worker::{Env, Result};
6+
7+const FROM: &str = "g1t <noreply@g1t.sh>";
8+const SITE: &str = "https://g1t.sh";
9+
10+#[derive(Serialize)]
11+struct Message<'a> {
12+ to: &'a str,
13+ from: &'a str,
14+ subject: &'a str,
15+ text: String,
16+ html: String,
17+}
18+
19+/// A short message with one link to follow.
20+async fn send_link(
21+ env: &Env,
22+ to: &str,
23+ subject: &str,
24+ intro: &str,
25+ action: &str,
26+ link: &str,
27+ footer: &str,
28+) -> Result<()> {
29+ let message = Message {
30+ to,
31+ from: FROM,
32+ subject,
33+ text: format!("{intro}\n\n{action}: {link}\n\n{footer}\n"),
34+ html: format!(
35+ "<div style=\"font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:32px 16px;color:#16150f\">\
36+ <p style=\"font-size:20px;font-weight:600;margin:0 0 16px\">g1t</p>\
37+ <p style=\"font-size:15px;line-height:1.6\">{intro}</p>\
38+ <p style=\"margin:24px 0\"><a href=\"{link}\" style=\"background:#16150f;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;font-size:15px\">{action}</a></p>\
39+ <p style=\"font-size:13px;line-height:1.6;color:#6e6a5e\">{footer}</p>\
40+ </div>"
41+ ),
42+ };
43+ let binding = js::binding(env, "EMAIL")?;
44+ js::call(&binding, "send", &[js::to_js(&message)?]).await?;
45+ Ok(())
46+}
47+
48+pub async fn send_verification(env: &Env, to: &str, username: &str, token: &str) -> Result<()> {
49+ send_link(
50+ env,
51+ to,
52+ "Confirm your email for g1t",
53+ &format!("Welcome to g1t, {username}. Confirm this address to finish creating your account."),
54+ "Confirm email",
55+ &format!("{SITE}/verify?token={token}"),
56+ "This link works for 24 hours. If you did not create a g1t account, you can ignore this message.",
57+ )
58+ .await
59+}
60+
61+pub async fn send_password_reset(env: &Env, to: &str, username: &str, token: &str) -> Result<()> {
62+ send_link(
63+ env,
64+ to,
65+ "Reset your g1t password",
66+ &format!("Someone asked to reset the password for the g1t account {username}."),
67+ "Choose a new password",
68+ &format!("{SITE}/reset?token={token}"),
69+ "This link works for 1 hour. If this was not you, ignore this message and your password stays the same.",
70+ )
71+ .await
72+}
+208−11
44 //! the methods and their arguments.
55
66 mod crypto;
7+mod email;
78
89 use g1t_contracts::identity::*;
910 use g1t_contracts::{FailureCode, Outcome, User, Viewer, is_valid_namespace, new_id};
1314 use worker::{Context, D1Database, Env, Request, Response, Result, event};
1415
1516 const SESSION_TTL_SECONDS: u32 = 30 * 24 * 60 * 60;
17+const VERIFY_TTL_SECONDS: u32 = 24 * 60 * 60;
18+const RESET_TTL_SECONDS: u32 = 60 * 60;
1619 const TOKEN_PREFIX: &str = "g1t_";
1720 const MIN_PASSWORD_LENGTH: usize = 10;
21+const PASSWORD_TOO_SHORT: &str = "Use a password of at least 10 characters.";
22+
23+/// A user as selected from the database; `verified` arrives as 0 or 1.
24+#[derive(Deserialize)]
25+struct Account {
26+ id: String,
27+ username: String,
28+ verified: u8,
29+}
30+
31+impl From<Account> for User {
32+ fn from(row: Account) -> Self {
33+ User {
34+ id: row.id,
35+ username: row.username,
36+ verified: row.verified != 0,
37+ }
38+ }
39+}
1840
1941 #[derive(Deserialize)]
2042 struct UserRow {
2143 id: String,
2244 username: String,
2345 password_hash: String,
46+ verified: u8,
2447 }
2548
49+/// The owner of an emailed token.
2650 #[derive(Deserialize)]
51+struct TokenOwner {
52+ id: String,
53+ username: String,
54+ email: Option<String>,
55+}
56+
57+#[derive(Deserialize)]
2758 struct KeyRow {
2859 id: String,
2960 title: String,
6192
6293 struct Identity {
6394 db: D1Database,
95+ env: Env,
6496 }
6597
6698 impl Identity {
6799 /// Runs a query that returns at most one user.
68100 async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> {
69− self.db
101+ Ok(self
102+ .db
70103 .prepare(sql)
71104 .bind(&[JsValue::from(param)])?
72− .first::<User>(None)
73− .await
105+ .first::<Account>(None)
106+ .await?
107+ .map(User::from))
108+ }
109+
110+ /// Stores a one-time token of `kind` for the user and returns it.
111+ async fn issue_email_token(&self, user_id: &str, kind: &str, ttl: u32) -> Result<String> {
112+ let token = crypto::random_hex(32);
113+ self.db
114+ .prepare(
115+ "INSERT INTO email_tokens (id, user_id, kind, expires_at)
116+ VALUES (?, ?, ?, unixepoch() + ?)",
117+ )
118+ .bind(&[
119+ crypto::sha256_hex(&token).into(),
120+ user_id.into(),
121+ kind.into(),
122+ ttl.into(),
123+ ])?
124+ .run()
125+ .await?;
126+ Ok(token)
74127 }
75128
129+ /// Consumes a token of `kind`, returning its owner if it was valid.
130+ async fn redeem_email_token(&self, token: &str, kind: &str) -> Result<Option<TokenOwner>> {
131+ let id = crypto::sha256_hex(token);
132+ let owner = self
133+ .db
134+ .prepare(
135+ "SELECT users.id, users.username, users.email FROM email_tokens
136+ JOIN users ON users.id = email_tokens.user_id
137+ WHERE email_tokens.id = ? AND email_tokens.kind = ?
138+ AND email_tokens.expires_at > unixepoch()",
139+ )
140+ .bind(&[id.as_str().into(), kind.into()])?
141+ .first::<TokenOwner>(None)
142+ .await?;
143+ if let Some(owner) = &owner {
144+ // Every outstanding token of this kind dies with the one used.
145+ self.db
146+ .prepare("DELETE FROM email_tokens WHERE user_id = ? AND kind = ?")
147+ .bind(&[owner.id.as_str().into(), kind.into()])?
148+ .run()
149+ .await?;
150+ }
151+ Ok(owner)
152+ }
153+
154+ async fn send_verification(&self, user: &User, email: &str) -> Result<()> {
155+ let token = self
156+ .issue_email_token(&user.id, "verify", VERIFY_TTL_SECONDS)
157+ .await?;
158+ email::send_verification(&self.env, email, &user.username, &token).await
159+ }
160+
161+ async fn resend_verification(&self, a: UserArgs) -> Result<Outcome<bool>> {
162+ let row = self
163+ .db
164+ .prepare(
165+ "SELECT id, username, email FROM users WHERE id = ? AND email_verified_at IS NULL",
166+ )
167+ .bind(&[a.user.id.as_str().into()])?
168+ .first::<TokenOwner>(None)
169+ .await?;
170+ let Some(TokenOwner {
171+ email: Some(email), ..
172+ }) = row
173+ else {
174+ return Ok(Outcome::fail(
175+ FailureCode::Conflict,
176+ "This account's email is already confirmed.",
177+ ));
178+ };
179+ self.send_verification(&a.user, &email).await?;
180+ Ok(Outcome::Ok(true))
181+ }
182+
183+ async fn verify_email(&self, a: EmailTokenArgs) -> Result<Outcome<User>> {
184+ let Some(owner) = self.redeem_email_token(&a.token, "verify").await? else {
185+ return Ok(Outcome::fail(
186+ FailureCode::Invalid,
187+ "This confirmation link is not valid or has expired.",
188+ ));
189+ };
190+ self.db
191+ .prepare("UPDATE users SET email_verified_at = unixepoch() WHERE id = ?")
192+ .bind(&[owner.id.as_str().into()])?
193+ .run()
194+ .await?;
195+ Ok(Outcome::Ok(User {
196+ id: owner.id,
197+ username: owner.username,
198+ verified: true,
199+ }))
200+ }
201+
202+ async fn request_password_reset(&self, a: EmailArgs) -> Result<bool> {
203+ let row = self
204+ .db
205+ .prepare("SELECT id, username, email FROM users WHERE email = ?")
206+ .bind(&[a.email.trim().to_lowercase().into()])?
207+ .first::<TokenOwner>(None)
208+ .await?;
209+ if let Some(TokenOwner {
210+ id,
211+ username,
212+ email: Some(email),
213+ }) = row
214+ {
215+ let token = self
216+ .issue_email_token(&id, "reset", RESET_TTL_SECONDS)
217+ .await?;
218+ email::send_password_reset(&self.env, &email, &username, &token).await?;
219+ }
220+ // The same answer either way, so addresses cannot be probed.
221+ Ok(true)
222+ }
223+
224+ async fn reset_password(&self, a: ResetPasswordArgs) -> Result<Outcome<User>> {
225+ if a.password.chars().count() < MIN_PASSWORD_LENGTH {
226+ return Ok(Outcome::fail(FailureCode::Invalid, PASSWORD_TOO_SHORT));
227+ }
228+ let Some(owner) = self.redeem_email_token(&a.token, "reset").await? else {
229+ return Ok(Outcome::fail(
230+ FailureCode::Invalid,
231+ "This reset link is not valid or has expired.",
232+ ));
233+ };
234+ // Following an emailed link also proves the address.
235+ self.db
236+ .prepare(
237+ "UPDATE users SET password_hash = ?,
238+ email_verified_at = COALESCE(email_verified_at, unixepoch())
239+ WHERE id = ?",
240+ )
241+ .bind(&[
242+ crypto::hash_password(&a.password).into(),
243+ owner.id.as_str().into(),
244+ ])?
245+ .run()
246+ .await?;
247+ // Anyone signed in with the old password is signed out.
248+ self.db
249+ .prepare("DELETE FROM sessions WHERE user_id = ?")
250+ .bind(&[owner.id.as_str().into()])?
251+ .run()
252+ .await?;
253+ Ok(Outcome::Ok(User {
254+ id: owner.id,
255+ username: owner.username,
256+ verified: true,
257+ }))
258+ }
259+
76260 async fn user_for_password(&self, username: &str, password: &str) -> Result<Viewer> {
77261 let row = self
78262 .db
79− .prepare("SELECT id, username, password_hash FROM users WHERE username = ?")
263+ .prepare("SELECT id, username, password_hash, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ?")
80264 .bind(&[JsValue::from(username.to_lowercase())])?
81265 .first::<UserRow>(None)
82266 .await?;
85269 .map(|row| User {
86270 id: row.id,
87271 username: row.username,
272+ verified: row.verified != 0,
88273 }))
89274 }
90275
105290 return invalid("Enter a valid email address.");
106291 }
107292 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
108− return invalid("Use a password of at least 10 characters.");
293+ return invalid(PASSWORD_TOO_SHORT);
109294 }
110295 let taken = self
111296 .db
122307 let user = User {
123308 id: new_id("usr", now_ms()),
124309 username,
310+ verified: false,
125311 };
126312 self.db
127313 .prepare("INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)")
128314 .bind(&[
129315 user.id.as_str().into(),
130316 user.username.as_str().into(),
131− email.into(),
317+ email.as_str().into(),
132318 crypto::hash_password(&a.password).into(),
133319 ])?
134320 .run()
135321 .await?;
322+ // The account exists either way; the email can be sent again later.
323+ if let Err(error) = self.send_verification(&user, &email).await {
324+ worker::console_error!("verification email failed: {error}");
325+ }
136326 self.start_session(user).await
137327 }
138328
176366
177367 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
178368 self.find_user(
179− "SELECT users.id, users.username FROM sessions
369+ "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM sessions
180370 JOIN users ON users.id = sessions.user_id
181371 WHERE sessions.id = ? AND sessions.expires_at > unixepoch()",
182372 &crypto::sha256_hex(&a.session_token),
189379 return Ok(None);
190380 }
191381 self.find_user(
192− "SELECT users.id, users.username FROM access_tokens
382+ "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM access_tokens
193383 JOIN users ON users.id = access_tokens.user_id
194384 WHERE token_hash = ?",
195385 &crypto::sha256_hex(token),
208398
209399 async fn user_for_ssh_key(&self, a: FingerprintArgs) -> Result<Viewer> {
210400 self.find_user(
211− "SELECT users.id, users.username FROM ssh_keys
401+ "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM ssh_keys
212402 JOIN users ON users.id = ssh_keys.user_id
213403 WHERE fingerprint = ?",
214404 &a.fingerprint,
218408
219409 async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
220410 self.find_user(
221− "SELECT id, username FROM users WHERE username = ?",
411+ "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ?",
222412 &a.username.to_lowercase(),
223413 )
224414 .await
344534 return Response::error("Not found", 404);
345535 };
346536 let body: serde_json::Value = request.json().await?;
347− let identity = Identity { db: env.d1("DB")? };
537+ let identity = Identity {
538+ db: env.d1("DB")?,
539+ env,
540+ };
348541
349542 match method.as_str() {
350543 "register" => reply(&identity.register(args(body)?).await?),
351544 "sign_in" => reply(&identity.sign_in(args(body)?).await?),
545+ "resend_verification" => reply(&identity.resend_verification(args(body)?).await?),
546+ "verify_email" => reply(&identity.verify_email(args(body)?).await?),
547+ "request_password_reset" => reply(&identity.request_password_reset(args(body)?).await?),
548+ "reset_password" => reply(&identity.reset_password(args(body)?).await?),
352549 "sign_out" => reply(&identity.sign_out(args(body)?).await?),
353550 "user_for_session" => reply(&identity.user_for_session(args(body)?).await?),
354551 "user_for_git_credentials" => reply(&identity.user_for_git_credentials(args(body)?).await?),
+1−0
1515 "migrations_dir": "migrations"
1616 }
1717 ],
18+ "send_email": [{ "name": "EMAIL", "remote": true }],
1819 "observability": { "enabled": true }
1920 }
+3−0
1616 type TreeView,
1717 type User,
1818 type Viewer,
19+ UNVERIFIED,
1920 fail,
2021 identityClient,
2122 isValidNamespace,
8889 }
8990
9091 async create(owner: User, input: CreateRepoInput): Promise<Result<Repo>> {
92+ if (!owner.verified) return UNVERIFIED;
9193 const name = input.name.trim().toLowerCase();
9294 if (!isValidRepoName(name)) {
9395 return fail("invalid", "Use letters, digits, dots, hyphens and underscores only.");
247249 service: GitService,
248250 ): Promise<Result<GitAccess>> {
249251 const write = service === "git-receive-pack";
252+ if (write && viewer && !viewer.verified) return UNVERIFIED;
250253 let repo = await this.registry.byPath(path);
251254 if (!repo) {
252255 // Push to create, in the pusher's own namespace only.
+3−0
1818 type User,
1919 type Viewer,
2020 type WorkApi,
21+ UNVERIFIED,
2122 fail,
2223 newId,
2324 ok,
9697 repoPath: RepoPath,
9798 input: OpenIntentInput,
9899 ): Promise<Result<Intent>> {
100+ if (!actor.verified) return UNVERIFIED;
99101 const title = input.title.trim();
100102 const brief = input.brief.trim();
101103 if (!title) return fail("invalid", "An intent needs a title.");
210212 intentId: string,
211213 input: StartAttemptInput,
212214 ): Promise<Result<Attempt>> {
215+ if (!actor.verified) return UNVERIFIED;
213216 const intent = await this.intentById(intentId);
214217 if (!intent) return NO_INTENT;
215218 if (intent.status !== "open") {