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
24 files+640−270/24 viewed
| 1 | 1 | # g1t | |
| 2 | 2 | ||
| 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. | |
| 4 | 5 | ||
| 5 | 6 | A pull request assumes one author and one change. g1t assumes many agents | |
| 6 | 7 | working at once: you state a goal as an **intent**, any number of agents |
| 81 | 81 | <ArrowRight size={12} /> | |
| 82 | 82 | </Link> | |
| 83 | 83 | <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 | |
| 85 | 85 | <br /> | |
| 86 | − | <span className="text-accent">One ships.</span> | |
| 86 | + | <span className="text-accent">AI scale.</span> | |
| 87 | 87 | </h1> | |
| 88 | 88 | <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. | |
| 92 | 93 | </p> | |
| 93 | 94 | <div className="mt-8 flex animate-fade-up flex-wrap items-center justify-center gap-3"> | |
| 94 | 95 | <ButtonLink to="/register" variant="accent" large> | |
| 128 | 129 | Claude Code and other MCP clients connect to mcp.g1t.sh with one | |
| 129 | 130 | command. Everything is also a plain REST call at api.g1t.sh. | |
| 130 | 131 | </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. | |
| 134 | 136 | </FeatureCard> | |
| 135 | 137 | </div> | |
| 136 | 138 | </section> |
| 12 | 12 | useRouteLoaderData, | |
| 13 | 13 | } from "react-router"; | |
| 14 | 14 | ||
| 15 | + | import type { User } from "@g1t/contracts"; | |
| 16 | + | ||
| 15 | 17 | import type { Route } from "./+types/root"; | |
| 16 | 18 | import "./app.css"; | |
| 17 | 19 | import { Logo } from "./components/logo"; | |
| 53 | 55 | ); | |
| 54 | 56 | } | |
| 55 | 57 | ||
| 56 | − | function Header({ user }: { user: { username: string } | null | undefined }) { | |
| 58 | + | function Header({ user }: { user: User | null | undefined }) { | |
| 57 | 59 | return ( | |
| 58 | 60 | <header className="sticky top-0 z-40 border-b border-line bg-surface/85 backdrop-blur"> | |
| 59 | 61 | <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4"> | |
| 179 | 181 | </head> | |
| 180 | 182 | <body className="flex min-h-screen flex-col"> | |
| 181 | 183 | <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 | + | )} | |
| 182 | 199 | <div className="grow">{children}</div> | |
| 183 | 200 | <Footer /> | |
| 184 | 201 | <ScrollRestoration /> |
| 5 | 5 | route("login", "routes/login.tsx"), | |
| 6 | 6 | route("register", "routes/register.tsx"), | |
| 7 | 7 | route("logout", "routes/logout.tsx"), | |
| 8 | + | route("verify", "routes/verify.tsx"), | |
| 9 | + | route("forgot", "routes/forgot.tsx"), | |
| 10 | + | route("reset", "routes/reset.tsx"), | |
| 8 | 11 | route("new", "routes/new.tsx"), | |
| 9 | 12 | route("settings", "routes/settings.tsx"), | |
| 10 | 13 | route("explore", "routes/explore.tsx", { id: "explore" }), |
| 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 | + | } |
| 17 | 17 | ||
| 18 | 18 | export function meta({}: Route.MetaArgs) { | |
| 19 | 19 | return [ | |
| 20 | − | { title: "g1t — a git forge built for agents" }, | |
| 20 | + | { title: "g1t — Git for AI scale" }, | |
| 21 | 21 | { | |
| 22 | 22 | name: "description", | |
| 23 | 23 | 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.", | |
| 25 | 25 | }, | |
| 26 | 26 | ]; | |
| 27 | 27 | } |
| 63 | 63 | <div className="pt-2 *:w-full"> | |
| 64 | 64 | <Button type="submit">Sign in</Button> | |
| 65 | 65 | </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> | |
| 66 | 71 | </Form> | |
| 67 | 72 | </AuthCard> | |
| 68 | 73 | ); |
| 37 | 37 | return ( | |
| 38 | 38 | <AuthCard | |
| 39 | 39 | title="Create your account" | |
| 40 | − | subtitle="Put your agents to work" | |
| 40 | + | subtitle="Git for AI scale" | |
| 41 | 41 | footer={ | |
| 42 | 42 | <> | |
| 43 | 43 | Already have an account?{" "} |
| 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 | + | } |
| 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 | + | } |
| 117 | 117 | pub email: String, | |
| 118 | 118 | pub password: String, | |
| 119 | 119 | } | |
| 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 | + | } |
| 19 | 19 | pub struct User { | |
| 20 | 20 | pub id: String, | |
| 21 | 21 | 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, | |
| 22 | 26 | } | |
| 23 | 27 | ||
| 24 | 28 | /// Who is asking. Every read and write in every service takes one. |
| 2 | 2 | const RESERVED: &[&str] = &[ | |
| 3 | 3 | "api", "mcp", "login", "logout", "register", "new", "settings", "search", "admin", "auth", | |
| 4 | 4 | "attempts", "oauth", "assets", "docs", "explore", "g1t", "about", "pricing", "terms", | |
| 5 | − | "privacy", "help", "support", "status", "blog", | |
| 5 | + | "privacy", "help", "support", "status", "blog", "verify", "forgot", "reset", | |
| 6 | 6 | ]; | |
| 7 | 7 | ||
| 8 | 8 | /// Namespaces follow GitHub's rules: letters, digits and single hyphens, |
| 59 | 59 | } | |
| 60 | 60 | response.json().await | |
| 61 | 61 | } | |
| 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 | + | } |
| 33 | 33 | call("register", { username, email, password }), | |
| 34 | 34 | signIn: (username, password) => call("sign_in", { username, password }), | |
| 35 | 35 | 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 }), | |
| 36 | 41 | userForSession: (sessionToken) => call("user_for_session", { sessionToken }), | |
| 37 | 42 | userForGitCredentials: (username, secret) => | |
| 38 | 43 | call("user_for_git_credentials", { username, secret }), |
| 1 | 1 | import type { Result } from "./result"; | |
| 2 | 2 | ||
| 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 | + | }; | |
| 4 | 12 | ||
| 5 | 13 | /** Who is asking. Every read and write in every service takes one. */ | |
| 6 | 14 | export type Viewer = User | null; | |
| 21 | 29 | /** Verifies a username and password for website sign-in. */ | |
| 22 | 30 | signIn(username: string, password: string): Promise<Result<{ user: User; sessionToken: string }>>; | |
| 23 | 31 | 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 | + | ||
| 24 | 42 | userForSession(sessionToken: string): Promise<Viewer>; | |
| 25 | 43 | ||
| 26 | 44 | /** Verifies git credentials: the account password or an access token. */ |
| 6 | 6 | /** Routes and reserved words that may not be registered as usernames. */ | |
| 7 | 7 | const RESERVED = new Set([ | |
| 8 | 8 | "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", | |
| 10 | 10 | ]); | |
| 11 | 11 | ||
| 12 | 12 | export function isValidNamespace(value: string): boolean { |
| 33 | 33 | export function httpStatus(failure: Failure): number { | |
| 34 | 34 | return HTTP_STATUS[failure.code]; | |
| 35 | 35 | } | |
| 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 | + | ); |
| 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); |
| 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 | + | } |
| 4 | 4 | //! the methods and their arguments. | |
| 5 | 5 | ||
| 6 | 6 | mod crypto; | |
| 7 | + | mod email; | |
| 7 | 8 | ||
| 8 | 9 | use g1t_contracts::identity::*; | |
| 9 | 10 | use g1t_contracts::{FailureCode, Outcome, User, Viewer, is_valid_namespace, new_id}; | |
| 13 | 14 | use worker::{Context, D1Database, Env, Request, Response, Result, event}; | |
| 14 | 15 | ||
| 15 | 16 | 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; | |
| 16 | 19 | const TOKEN_PREFIX: &str = "g1t_"; | |
| 17 | 20 | 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 | + | } | |
| 18 | 40 | ||
| 19 | 41 | #[derive(Deserialize)] | |
| 20 | 42 | struct UserRow { | |
| 21 | 43 | id: String, | |
| 22 | 44 | username: String, | |
| 23 | 45 | password_hash: String, | |
| 46 | + | verified: u8, | |
| 24 | 47 | } | |
| 25 | 48 | ||
| 49 | + | /// The owner of an emailed token. | |
| 26 | 50 | #[derive(Deserialize)] | |
| 51 | + | struct TokenOwner { | |
| 52 | + | id: String, | |
| 53 | + | username: String, | |
| 54 | + | email: Option<String>, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | #[derive(Deserialize)] | |
| 27 | 58 | struct KeyRow { | |
| 28 | 59 | id: String, | |
| 29 | 60 | title: String, | |
| 61 | 92 | ||
| 62 | 93 | struct Identity { | |
| 63 | 94 | db: D1Database, | |
| 95 | + | env: Env, | |
| 64 | 96 | } | |
| 65 | 97 | ||
| 66 | 98 | impl Identity { | |
| 67 | 99 | /// Runs a query that returns at most one user. | |
| 68 | 100 | async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> { | |
| 69 | − | self.db | |
| 101 | + | Ok(self | |
| 102 | + | .db | |
| 70 | 103 | .prepare(sql) | |
| 71 | 104 | .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) | |
| 74 | 127 | } | |
| 75 | 128 | ||
| 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 | + | ||
| 76 | 260 | async fn user_for_password(&self, username: &str, password: &str) -> Result<Viewer> { | |
| 77 | 261 | let row = self | |
| 78 | 262 | .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 = ?") | |
| 80 | 264 | .bind(&[JsValue::from(username.to_lowercase())])? | |
| 81 | 265 | .first::<UserRow>(None) | |
| 82 | 266 | .await?; | |
| 85 | 269 | .map(|row| User { | |
| 86 | 270 | id: row.id, | |
| 87 | 271 | username: row.username, | |
| 272 | + | verified: row.verified != 0, | |
| 88 | 273 | })) | |
| 89 | 274 | } | |
| 90 | 275 | ||
| 105 | 290 | return invalid("Enter a valid email address."); | |
| 106 | 291 | } | |
| 107 | 292 | 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); | |
| 109 | 294 | } | |
| 110 | 295 | let taken = self | |
| 111 | 296 | .db | |
| 122 | 307 | let user = User { | |
| 123 | 308 | id: new_id("usr", now_ms()), | |
| 124 | 309 | username, | |
| 310 | + | verified: false, | |
| 125 | 311 | }; | |
| 126 | 312 | self.db | |
| 127 | 313 | .prepare("INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)") | |
| 128 | 314 | .bind(&[ | |
| 129 | 315 | user.id.as_str().into(), | |
| 130 | 316 | user.username.as_str().into(), | |
| 131 | − | email.into(), | |
| 317 | + | email.as_str().into(), | |
| 132 | 318 | crypto::hash_password(&a.password).into(), | |
| 133 | 319 | ])? | |
| 134 | 320 | .run() | |
| 135 | 321 | .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 | + | } | |
| 136 | 326 | self.start_session(user).await | |
| 137 | 327 | } | |
| 138 | 328 | ||
| 176 | 366 | ||
| 177 | 367 | async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> { | |
| 178 | 368 | 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 | |
| 180 | 370 | JOIN users ON users.id = sessions.user_id | |
| 181 | 371 | WHERE sessions.id = ? AND sessions.expires_at > unixepoch()", | |
| 182 | 372 | &crypto::sha256_hex(&a.session_token), | |
| 189 | 379 | return Ok(None); | |
| 190 | 380 | } | |
| 191 | 381 | 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 | |
| 193 | 383 | JOIN users ON users.id = access_tokens.user_id | |
| 194 | 384 | WHERE token_hash = ?", | |
| 195 | 385 | &crypto::sha256_hex(token), | |
| 208 | 398 | ||
| 209 | 399 | async fn user_for_ssh_key(&self, a: FingerprintArgs) -> Result<Viewer> { | |
| 210 | 400 | 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 | |
| 212 | 402 | JOIN users ON users.id = ssh_keys.user_id | |
| 213 | 403 | WHERE fingerprint = ?", | |
| 214 | 404 | &a.fingerprint, | |
| 218 | 408 | ||
| 219 | 409 | async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> { | |
| 220 | 410 | 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 = ?", | |
| 222 | 412 | &a.username.to_lowercase(), | |
| 223 | 413 | ) | |
| 224 | 414 | .await | |
| 344 | 534 | return Response::error("Not found", 404); | |
| 345 | 535 | }; | |
| 346 | 536 | 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 | + | }; | |
| 348 | 541 | ||
| 349 | 542 | match method.as_str() { | |
| 350 | 543 | "register" => reply(&identity.register(args(body)?).await?), | |
| 351 | 544 | "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?), | |
| 352 | 549 | "sign_out" => reply(&identity.sign_out(args(body)?).await?), | |
| 353 | 550 | "user_for_session" => reply(&identity.user_for_session(args(body)?).await?), | |
| 354 | 551 | "user_for_git_credentials" => reply(&identity.user_for_git_credentials(args(body)?).await?), |
| 15 | 15 | "migrations_dir": "migrations" | |
| 16 | 16 | } | |
| 17 | 17 | ], | |
| 18 | + | "send_email": [{ "name": "EMAIL", "remote": true }], | |
| 18 | 19 | "observability": { "enabled": true } | |
| 19 | 20 | } |
| 16 | 16 | type TreeView, | |
| 17 | 17 | type User, | |
| 18 | 18 | type Viewer, | |
| 19 | + | UNVERIFIED, | |
| 19 | 20 | fail, | |
| 20 | 21 | identityClient, | |
| 21 | 22 | isValidNamespace, | |
| 88 | 89 | } | |
| 89 | 90 | ||
| 90 | 91 | async create(owner: User, input: CreateRepoInput): Promise<Result<Repo>> { | |
| 92 | + | if (!owner.verified) return UNVERIFIED; | |
| 91 | 93 | const name = input.name.trim().toLowerCase(); | |
| 92 | 94 | if (!isValidRepoName(name)) { | |
| 93 | 95 | return fail("invalid", "Use letters, digits, dots, hyphens and underscores only."); | |
| 247 | 249 | service: GitService, | |
| 248 | 250 | ): Promise<Result<GitAccess>> { | |
| 249 | 251 | const write = service === "git-receive-pack"; | |
| 252 | + | if (write && viewer && !viewer.verified) return UNVERIFIED; | |
| 250 | 253 | let repo = await this.registry.byPath(path); | |
| 251 | 254 | if (!repo) { | |
| 252 | 255 | // Push to create, in the pusher's own namespace only. |
| 18 | 18 | type User, | |
| 19 | 19 | type Viewer, | |
| 20 | 20 | type WorkApi, | |
| 21 | + | UNVERIFIED, | |
| 21 | 22 | fail, | |
| 22 | 23 | newId, | |
| 23 | 24 | ok, | |
| 96 | 97 | repoPath: RepoPath, | |
| 97 | 98 | input: OpenIntentInput, | |
| 98 | 99 | ): Promise<Result<Intent>> { | |
| 100 | + | if (!actor.verified) return UNVERIFIED; | |
| 99 | 101 | const title = input.title.trim(); | |
| 100 | 102 | const brief = input.brief.trim(); | |
| 101 | 103 | if (!title) return fail("invalid", "An intent needs a title."); | |
| 210 | 212 | intentId: string, | |
| 211 | 213 | input: StartAttemptInput, | |
| 212 | 214 | ): Promise<Result<Attempt>> { | |
| 215 | + | if (!actor.verified) return UNVERIFIED; | |
| 213 | 216 | const intent = await this.intentById(intentId); | |
| 214 | 217 | if (!intent) return NO_INTENT; | |
| 215 | 218 | if (intent.status !== "open") { |