Skip to content
1,136 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

g1t's agents only for listed workspaces, whatever the state of billing1//! The identity service: accounts, sessions, SSH keys and access tokens.
2//!
3//! Reached only through service bindings; see `g1t_contracts::identity` for
4//! the methods and their arguments.
5
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look6mod access;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)7mod account_deletion;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace8mod admin;
Merge branch 'worktree-agent-a8385d293d42c913a'9mod aliases;
Workspace names and icons, and a component kit for every control10mod avatars;
g1t's agents only for listed workspaces, whatever the state of billing11mod crypto;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look12mod deletion;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca13mod deploy_keys;
g1t's agents only for listed workspaces, whatever the state of billing14mod device;
Search across all of g1t, Explore, and a command palette15mod directory;
g1t's agents only for listed workspaces, whatever the state of billing16mod email;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look17mod emails;
18mod github;
19mod invites;
Merge main (membership, two-factor, GitHub repo roles) into tokens20mod members;
g1t's agents only for listed workspaces, whatever the state of billing21mod oauth;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person22mod paid;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains23mod profiles;
24mod rename;
Merge branch 'worktree-agent-a3abfcce648e87dca'25mod job_tokens;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API26mod run_credentials;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look27mod security;
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)28mod shared_invites;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar29mod teams;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look30mod throttle;
Fine-grained personal tokens, workspace token rules and approvals in identity31mod token_reach;
g1t's agents only for listed workspaces, whatever the state of billing32mod tokens;
Merge main (membership, two-factor, GitHub repo roles) into tokens33mod two_factor;
g1t's agents only for listed workspaces, whatever the state of billing34mod workspaces;
35
36use g1t_contracts::identity::*;
37use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers38use g1t_contracts::{FailureCode, Outcome, User, Viewer, claimable_username, new_id};
g1t's agents only for listed workspaces, whatever the state of billing39use g1t_kit::{args, now_ms, reply, rpc_method};
40use serde::Deserialize;
41use tokens::TOKEN_PREFIX;
42use worker::wasm_bindgen::JsValue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas43use worker::{Context, D1Database, Env, Request, Response, Result, ScheduleContext, ScheduledEvent, event};
g1t's agents only for listed workspaces, whatever the state of billing44
45const SESSION_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
46const RESET_TTL_SECONDS: u64 = 60 * 60;
47const MIN_PASSWORD_LENGTH: usize = 10;
48const PASSWORD_TOO_SHORT: &str = "Use a password of at least 10 characters.";
49
50/// A user as selected from the database; `verified` arrives as 0 or 1.
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers51/// What a username may be, as registration and GitHub sign-up say when one is refused.
52pub(crate) const USERNAME_RULES: &str =
53 "Usernames use letters of either case, digits and single hyphens, up to 39 characters, not starting or ending with a hyphen, and cannot be a reserved word.";
54
g1t's agents only for listed workspaces, whatever the state of billing55#[derive(Deserialize)]
56struct Account {
57 id: String,
58 username: String,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers59 /// Selected where the person is shown: their username as they wrote it.
60 #[serde(default)]
61 display_username: Option<String>,
g1t's agents only for listed workspaces, whatever the state of billing62 verified: u8,
Workspace names and icons, and a component kit for every control63 /// Selected only where the person is being shown to themselves.
64 #[serde(default)]
65 avatar: Option<String>,
g1t's agents only for listed workspaces, whatever the state of billing66}
67
68impl From<Account> for User {
69 fn from(row: Account) -> Self {
70 User {
71 id: row.id,
72 username: row.username,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers73 display_username: row.display_username,
g1t's agents only for listed workspaces, whatever the state of billing74 verified: row.verified != 0,
Workspace names and icons, and a component kit for every control75 avatar: row.avatar,
g1t's agents only for listed workspaces, whatever the state of billing76 ..User::default()
77 }
78 }
79}
80
81#[derive(Deserialize)]
82struct UserRow {
83 id: String,
84 username: String,
85 password_hash: String,
86 verified: u8,
87}
88
89/// The owner of an emailed token.
90#[derive(Deserialize)]
91struct TokenOwner {
92 id: String,
93 username: String,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look94 /// The address a link was sent to; null on links from before accounts
95 /// had several, which are for the primary.
96 #[serde(default)]
97 email_id: Option<String>,
g1t's agents only for listed workspaces, whatever the state of billing98}
99
100#[derive(Deserialize)]
101struct KeyRow {
102 id: String,
103 title: String,
104 fingerprint: String,
105 created_at: String,
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca106 #[serde(default)]
107 last_used_at: Option<String>,
g1t's agents only for listed workspaces, whatever the state of billing108}
109
110impl From<KeyRow> for SshKey {
111 fn from(row: KeyRow) -> Self {
112 SshKey {
113 id: row.id,
114 title: row.title,
115 fingerprint: row.fingerprint,
116 created_at: row.created_at,
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca117 last_used_at: row.last_used_at,
g1t's agents only for listed workspaces, whatever the state of billing118 }
119 }
120}
121
122struct Identity {
123 db: D1Database,
124 env: Env,
125}
126
127impl Identity {
128 /// Runs a query that returns at most one user, for showing to others:
129 /// without their workspaces.
130 async fn find_public_user(&self, sql: &str, param: &str) -> Result<Viewer> {
131 Ok(self
132 .db
133 .prepare(sql)
134 .bind(&[JsValue::from(param)])?
135 .first::<Account>(None)
136 .await?
137 .map(User::from))
138 }
139
140 /// Attaches the workspaces a user belongs to, so that any service can
141 /// authorize them without asking again.
142 async fn with_workspaces(&self, user: Viewer) -> Result<Viewer> {
143 let Some(mut user) = user else {
144 return Ok(None);
145 };
Merge main (membership, two-factor, GitHub repo roles) into tokens146 let memberships = self.memberships_and_policies(&user.id).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look147 // Roles on single repositories, under the same policy (access.rs).
148 let grants = self.grants_of(&user.id).await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens149 // Access to a workspace is used only within its policy; see security.rs.
150 let within = self.within_policy(&user.id, memberships, grants).await?;
151 user.workspaces = within.memberships;
152 user.grants = within.grants;
153 user.held = within.held;
g1t's agents only for listed workspaces, whatever the state of billing154 Ok(Some(user))
155 }
156
157 /// Runs a query that resolves credentials to at most one user.
158 async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> {
159 let user = self.find_public_user(sql, param).await?;
160 self.with_workspaces(user).await
161 }
162
163 /// Consumes a token of `kind`, returning its owner if it was valid.
164 async fn redeem_email_token(&self, token: &str, kind: &str) -> Result<Option<TokenOwner>> {
165 let id = crypto::sha256_hex(token);
166 let owner = self
167 .db
168 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look169 "SELECT users.id, users.username, email_tokens.email_id FROM email_tokens
g1t's agents only for listed workspaces, whatever the state of billing170 JOIN users ON users.id = email_tokens.user_id
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)171 WHERE email_tokens.id = ? AND email_tokens.kind = ? AND users.deleted_at IS NULL
g1t's agents only for listed workspaces, whatever the state of billing172 AND email_tokens.expires_at > {SQL_NOW}"
173 ))
174 .bind(&[id.as_str().into(), kind.into()])?
175 .first::<TokenOwner>(None)
176 .await?;
177 if let Some(owner) = &owner {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look178 // Every outstanding token of this kind dies with the one used:
179 // every reset link, and every confirmation link for the same
180 // address (another address's links still work).
g1t's agents only for listed workspaces, whatever the state of billing181 self.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look182 .prepare(
183 "DELETE FROM email_tokens WHERE user_id = ?1 AND kind = ?2
184 AND (?2 = 'reset' OR email_id IS ?3)",
185 )
186 .bind(&[
187 owner.id.as_str().into(),
188 kind.into(),
189 owner.email_id.as_deref().map_or(JsValue::NULL, Into::into),
190 ])?
g1t's agents only for listed workspaces, whatever the state of billing191 .run()
192 .await?;
193 }
194 Ok(owner)
195 }
196
197 async fn resend_verification(&self, a: UserArgs) -> Result<Outcome<bool>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look198 if !self.allow(throttle::CONFIRM_ACCOUNT, &a.user.id).await? {
199 return Ok(Outcome::fail(FailureCode::Conflict, "Too many confirmation emails this hour. Check your inbox, or try again later."));
200 }
201 self.resend_primary(&a.user).await
g1t's agents only for listed workspaces, whatever the state of billing202 }
203
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)204 /// The link in a confirmation email, followed: signed in or not. It
205 /// ends the code sent with it (emails.rs).
206 async fn verify_email(&self, a: EmailTokenArgs) -> Result<Outcome<g1t_contracts::accounts::EmailConfirmed>> {
g1t's agents only for listed workspaces, whatever the state of billing207 let Some(owner) = self.redeem_email_token(&a.token, "verify").await? else {
208 return Ok(Outcome::fail(
209 FailureCode::Invalid,
210 "This confirmation link is not valid or has expired.",
211 ));
212 };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)213 self.confirm_address(&owner.id, owner.email_id.as_deref()).await
g1t's agents only for listed workspaces, whatever the state of billing214 }
215
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look216 /// Any confirmed address of an account can ask for a reset; so can the
217 /// unconfirmed address a new account signed up with. See emails.rs.
g1t's agents only for listed workspaces, whatever the state of billing218 async fn request_password_reset(&self, a: EmailArgs) -> Result<bool> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look219 let allowed = self.allow(throttle::RESET_EMAIL, &a.email).await?
220 && match a.client.as_deref() {
221 Some(client) => self.allow(throttle::RESET_CLIENT, client).await?,
222 None => true,
223 };
224 if allowed && let Some(target) = self.reset_target(&a.email).await? {
Identity: a password reset for a known address that fails to save or send is logged, never answered, so the reply never says an account exists225 // A failure from here on happens only for a real account, so it
226 // is logged, never answered: the reply below stays the same.
227 if let Err(error) = self.send_reset(&target).await {
228 worker::console_error!("password reset for a known address failed: {error}");
229 }
230 }
231 // The same answer either way, so addresses cannot be probed.
232 Ok(true)
233 }
234
235 /// Saves a reset link for `target` and mails it, telling the account's
236 /// other addresses.
237 async fn send_reset(&self, target: &emails::ResetTarget) -> Result<()> {
238 {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look239 let token = crypto::random_hex(32);
240 self.db
241 .prepare(format!(
242 "INSERT INTO email_tokens (id, user_id, kind, expires_at, email_id)
243 VALUES (?, ?, 'reset', {}, ?)",
244 sql_after(RESET_TTL_SECONDS)
245 ))
246 .bind(&[
247 crypto::sha256_hex(&token).into(),
248 target.user_id.as_str().into(),
249 target.email_id.as_str().into(),
250 ])?
251 .run()
g1t's agents only for listed workspaces, whatever the state of billing252 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look253 email::send_password_reset(&self.env, &target.display, &target.username, &token).await?;
254 // The primary and the backup hear of it when it went elsewhere.
255 let elsewhere = self.notice_recipients(&target.user_id, false).await?;
256 for address in elsewhere.iter().filter(|address| !address.eq_ignore_ascii_case(&target.display)) {
257 let change = format!("A password reset was asked for through {}", target.display);
258 if let Err(error) = email::send_security_notice(&self.env, address, &target.username, &change).await {
259 worker::console_error!("security notice failed: {error}");
260 }
261 }
g1t's agents only for listed workspaces, whatever the state of billing262 }
Identity: a password reset for a known address that fails to save or send is logged, never answered, so the reply never says an account exists263 Ok(())
g1t's agents only for listed workspaces, whatever the state of billing264 }
265
266 async fn reset_password(&self, a: ResetPasswordArgs) -> Result<Outcome<User>> {
267 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
268 return Ok(Outcome::fail(FailureCode::Invalid, PASSWORD_TOO_SHORT));
269 }
270 let Some(owner) = self.redeem_email_token(&a.token, "reset").await? else {
271 return Ok(Outcome::fail(
272 FailureCode::Invalid,
273 "This reset link is not valid or has expired.",
274 ));
275 };
276 self.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look277 .prepare("UPDATE users SET password_hash = ? WHERE id = ?")
g1t's agents only for listed workspaces, whatever the state of billing278 .bind(&[
279 crypto::hash_password(&a.password).into(),
280 owner.id.as_str().into(),
281 ])?
282 .run()
283 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look284 // Following an emailed link also proves the address it went to
285 // (unless another account confirmed it first).
286 let _ = self.confirm_address(&owner.id, owner.email_id.as_deref()).await?;
287 // Anyone signed in with the old password is signed out, and nobody
288 // stays locked out by the wrong guesses before it.
g1t's agents only for listed workspaces, whatever the state of billing289 self.db
290 .prepare("DELETE FROM sessions WHERE user_id = ?")
291 .bind(&[owner.id.as_str().into()])?
292 .run()
293 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look294 self.clear(&throttle::key(throttle::PASSWORD_ACCOUNT, &owner.id)).await?;
295 self.log_security(&owner.id, "password_changed", None, None).await;
296 self.tell_primary_and_backup(&owner.id, &owner.username, "Your password was changed").await;
297 let verified = self
298 .find_public_user(
299 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?",
300 &owner.id,
301 )
302 .await?
303 .is_some_and(|user| user.verified);
g1t's agents only for listed workspaces, whatever the state of billing304 Ok(Outcome::Ok(User {
305 id: owner.id,
306 username: owner.username,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look307 verified,
g1t's agents only for listed workspaces, whatever the state of billing308 ..User::default()
309 }))
310 }
311
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look312 /// The account a login names: a username, or any confirmed address.
313 async fn password_row(&self, login: &str) -> Result<Option<UserRow>> {
314 let login = login.trim().to_lowercase();
315 let (column, value) = if login.contains('@') {
316 match self.user_with_verified_email(&login).await? {
317 Some(id) => ("id", id),
318 None => return Ok(None),
319 }
320 } else {
321 ("username", login)
322 };
323 self.db
324 .prepare(format!(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)325 "SELECT id, username, password_hash, email_verified_at IS NOT NULL AS verified FROM users
326 WHERE {column} = ? AND deleted_at IS NULL"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look327 ))
328 .bind(&[JsValue::from(value)])?
g1t's agents only for listed workspaces, whatever the state of billing329 .first::<UserRow>(None)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look330 .await
331 }
332
333 /// Checks a password for a login, throttled (see throttle.rs). The
334 /// refusal is one of two messages, the same for every account.
335 async fn checked_password(
336 &self,
337 login: &str,
338 password: &str,
339 client: Option<&str>,
340 ) -> Result<std::result::Result<User, &'static str>> {
341 let row = self.password_row(login).await?;
342 let subject = row.as_ref().map_or_else(|| login.trim().to_lowercase(), |row| row.id.clone());
343 let (account_key, client_key) = Identity::password_keys(&subject, client);
344 if self.password_locked(&account_key, client_key.as_deref()).await? {
345 return Ok(Err(throttle::THROTTLED));
346 }
347 let owner = row.as_ref().map(|row| (row.id.clone(), row.username.clone()));
348 match row.filter(|row| !row.password_hash.is_empty() && crypto::verify_password(password, &row.password_hash)) {
349 Some(row) => {
350 self.clear(&account_key).await?;
351 Ok(Ok(User {
352 id: row.id,
353 username: row.username,
354 verified: row.verified != 0,
355 ..User::default()
356 }))
357 }
358 None => {
359 let owner = owner.as_ref().map(|(id, name)| (id.as_str(), name.as_str()));
360 self.password_failed(&account_key, client_key.as_deref(), owner).await?;
361 Ok(Err("Incorrect username or password."))
362 }
363 }
364 }
365
Merge main (membership, two-factor, GitHub repo roles) into tokens366 /// Git over HTTPS with the account's password. With two-factor
367 /// authentication on, a password alone is never enough: use an access
368 /// token (two_factor.rs).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look369 async fn user_for_password(&self, login: &str, password: &str) -> Result<Viewer> {
370 let user = self.checked_password(login, password, None).await?.ok();
Merge main (membership, two-factor, GitHub repo roles) into tokens371 if let Some(user) = &user
372 && self.two_factor_enabled(&user.id).await?
373 {
374 return Ok(None);
375 }
g1t's agents only for listed workspaces, whatever the state of billing376 self.with_workspaces(user).await
377 }
378
379 async fn register(&self, a: RegisterArgs) -> Result<Outcome<SignedIn>> {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers380 // Kept as typed for showing; found, linked and mentioned lowercased.
381 let chosen = claimable_username(&a.username);
382 let username = chosen.as_ref().map_or_else(|| a.username.trim().to_lowercase(), |name| name.canonical.clone());
383 let claimable = chosen.is_some();
g1t's agents only for listed workspaces, whatever the state of billing384 let email = a.email.trim().to_lowercase();
385 let invalid = |message: &str| Ok(Outcome::fail(FailureCode::Invalid, message));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look386 let invite_code = a.invite_code.as_deref().map(str::trim).filter(|code| !code.is_empty());
387 // The invite first: without one, nothing else on the form matters.
388 if self.invites_required() && invite_code.is_none() {
389 return Ok(Outcome::fail(FailureCode::Forbidden, invites::MISSING));
390 }
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent391 if !claimable {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers392 return invalid(USERNAME_RULES);
g1t's agents only for listed workspaces, whatever the state of billing393 }
394 let well_formed_email = email
395 .split_once('@')
396 .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
397 && !email.contains(char::is_whitespace);
398 if !well_formed_email {
399 return invalid("Enter a valid email address.");
400 }
401 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
402 return invalid(PASSWORD_TOO_SHORT);
403 }
404 let taken = self
405 .db
406 // Usernames and workspaces share one namespace, so that a name
407 // means the same thing wherever it appears.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look408 // An address is taken once an account has confirmed it; an
409 // unconfirmed one goes to whoever confirms it first (emails.rs).
g1t's agents only for listed workspaces, whatever the state of billing410 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look411 "SELECT username FROM users WHERE username = ?
412 UNION ALL SELECT email FROM user_emails WHERE email = ? AND verified_at IS NOT NULL
g1t's agents only for listed workspaces, whatever the state of billing413 UNION ALL SELECT slug FROM workspaces WHERE slug = ?",
414 )
415 .bind(&[
416 username.as_str().into(),
417 email.as_str().into(),
418 username.as_str().into(),
419 ])?
420 .first::<serde_json::Value>(None)
421 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look422 // A renamed workspace's old slug stays reserved for it a while, and
423 // a deleted workspace's for good.
424 if taken.is_some() || self.slug_held(&username).await? || self.slug_deleted(&username).await? {
g1t's agents only for listed workspaces, whatever the state of billing425 return Ok(Outcome::fail(
426 FailureCode::Conflict,
427 "That username or email is already registered.",
428 ));
429 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look430 let password_hash = crypto::hash_password(&a.password);
431 let user = match self
432 .create_account(invites::NewAccount {
433 username: &username,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers434 display_username: chosen.as_ref().and_then(|name| name.display_if_cased()),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look435 email: &email,
436 password_hash: &password_hash,
437 verified: false,
438 invite_code,
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm439 email_proof: a.email_proof.as_deref(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look440 client: a.client.as_deref(),
441 })
442 .await?
443 {
444 Outcome::Ok(user) => user,
445 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
g1t's agents only for listed workspaces, whatever the state of billing446 };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)447 // The account exists either way; the email can be sent again from
448 // the confirmation page. It carries a code and a link (emails.rs).
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas449 if !user.verified
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)450 && let Err(error) = self.send_primary_confirmation(&user.id, &user.username).await
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas451 {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)452 worker::console_error!("confirmation email failed: {error}");
g1t's agents only for listed workspaces, whatever the state of billing453 }
454 self.start_session(user).await
455 }
456
457 async fn sign_in(&self, a: SignInArgs) -> Result<Outcome<SignedIn>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look458 let user = match self.checked_password(&a.username, &a.password, a.client.as_deref()).await? {
459 Ok(user) => user,
460 Err(message) => return Ok(Outcome::fail(FailureCode::Unauthenticated, message)),
g1t's agents only for listed workspaces, whatever the state of billing461 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look462 let user = self.with_workspaces(Some(user)).await?.unwrap_or_default();
g1t's agents only for listed workspaces, whatever the state of billing463 self.start_session(user).await
464 }
465
Merge main (membership, two-factor, GitHub repo roles) into tokens466 /// Starts a session for someone who just proved their password (or
467 /// GitHub account). With two-factor authentication on, it starts none:
468 /// it returns a challenge for `two_factor_sign_in` (two_factor.rs).
g1t's agents only for listed workspaces, whatever the state of billing469 async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> {
Merge main (membership, two-factor, GitHub repo roles) into tokens470 if self.two_factor_enabled(&user.id).await? {
471 let challenge = self.issue_challenge(&user.id).await?;
472 return Ok(Outcome::Ok(SignedIn {
473 user: User { workspaces: Vec::new(), grants: Vec::new(), held: Vec::new(), ..user },
474 session_token: String::new(),
475 two_factor_challenge: Some(challenge),
476 }));
477 }
478 self.session_for(user).await
479 }
480
481 /// A new session for `user`, who has proved who they are in full.
482 async fn session_for(&self, user: User) -> Result<Outcome<SignedIn>> {
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)483 // Whichever way it was proved, a deleted account starts none
484 // (account_deletion.rs).
485 if !self.account_live(&user.id).await? {
486 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Incorrect username or password."));
487 }
g1t's agents only for listed workspaces, whatever the state of billing488 let session_token = crypto::random_hex(32);
489 self.db
490 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look491 // Signing in is proof it is the person: see security.rs.
492 "INSERT INTO sessions (id, user_id, expires_at, authenticated_at) VALUES (?, ?, {}, {SQL_NOW})",
g1t's agents only for listed workspaces, whatever the state of billing493 sql_after(SESSION_TTL_SECONDS)
494 ))
495 .bind(&[
496 crypto::sha256_hex(&session_token).into(),
497 user.id.as_str().into(),
498 ])?
499 .run()
500 .await?;
501 Ok(Outcome::Ok(SignedIn {
502 user,
503 session_token,
Merge main (membership, two-factor, GitHub repo roles) into tokens504 two_factor_challenge: None,
g1t's agents only for listed workspaces, whatever the state of billing505 }))
506 }
507
508 async fn sign_out(&self, a: SessionArgs) -> Result<()> {
509 self.db
510 .prepare("DELETE FROM sessions WHERE id = ?")
511 .bind(&[crypto::sha256_hex(&a.session_token).into()])?
512 .run()
513 .await?;
514 Ok(())
515 }
516
517 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
518 self.find_user(
519 &format!(
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers520 "SELECT users.id, users.username, users.display_username, users.email_verified_at IS NOT NULL AS verified,
Workspace names and icons, and a component kit for every control521 users.avatar
g1t's agents only for listed workspaces, whatever the state of billing522 FROM sessions JOIN users ON users.id = sessions.user_id
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)523 WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW} AND users.deleted_at IS NULL"
g1t's agents only for listed workspaces, whatever the state of billing524 ),
525 &crypto::sha256_hex(&a.session_token),
526 )
527 .await
528 }
529
530 async fn user_for_git_credentials(&self, a: GitCredentialsArgs) -> Result<Viewer> {
531 // Like GitHub, a token alone identifies its user.
532 if a.secret.starts_with(TOKEN_PREFIX) {
533 self.user_for_access_token(&a.secret).await
534 } else {
535 self.user_for_password(&a.username, &a.secret).await
536 }
537 }
538
539 async fn user_for_ssh_key(&self, a: FingerprintArgs) -> Result<Viewer> {
540 self.find_user(
541 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM ssh_keys
542 JOIN users ON users.id = ssh_keys.user_id
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)543 WHERE fingerprint = ? AND users.deleted_at IS NULL",
g1t's agents only for listed workspaces, whatever the state of billing544 &a.fingerprint,
545 )
546 .await
547 }
548
549 async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
550 self.find_public_user(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)551 // A deleted account is nobody's to find, mention or add.
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers552 "SELECT id, username, display_username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ? AND deleted_at IS NULL",
g1t's agents only for listed workspaces, whatever the state of billing553 &a.username.to_lowercase(),
554 )
555 .await
556 }
557
Inbox: threads, reasons, subscriptions and watching558 /// `notify_by_email`: an inbox item, emailed to the person it is for,
559 /// only at a confirmed address and only while they can still read the
560 /// repository it is about. Returns whether it was sent.
561 async fn notify_by_email(&self, a: g1t_contracts::inbox::NotifyByEmailArgs) -> Result<bool> {
562 #[derive(Deserialize)]
563 struct Address {
564 email: Option<String>,
565 }
566 let user = self
567 .find_user(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)568 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ? AND deleted_at IS NULL",
Inbox: threads, reasons, subscriptions and watching569 &a.username.to_lowercase(),
570 )
571 .await?;
572 let Some(user) = user.filter(|user| user.verified) else {
573 return Ok(false);
574 };
575 let readable: Vec<g1t_contracts::repos::Repo> = g1t_kit::call(
576 &self.env.service("REPOS")?,
577 "readable",
578 &g1t_contracts::repos::ReadableArgs {
579 ids: vec![a.repo_id.clone()],
580 viewer: Some(user.clone()),
581 },
582 )
583 .await?;
584 if readable.is_empty() {
585 return Ok(false);
586 }
587 let address = self
588 .db
589 .prepare("SELECT email FROM users WHERE id = ?")
590 .bind(&[user.id.as_str().into()])?
591 .first::<Address>(None)
592 .await?
593 .and_then(|row| row.email)
594 .filter(|email| !email.trim().is_empty());
595 let Some(address) = address else {
596 return Ok(false);
597 };
598 email::send_notification(&self.env, &address, &a).await?;
599 Ok(true)
600 }
601
g1t's agents only for listed workspaces, whatever the state of billing602 async fn usernames(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
603 #[derive(serde::Deserialize)]
604 struct Named {
605 id: String,
606 name: String,
607 }
608 let ids: Vec<String> = a.ids.into_iter().take(200).collect();
609 let mut names = std::collections::HashMap::new();
610 if ids.is_empty() {
611 return Ok(names);
612 }
613 let marks = vec!["?"; ids.len()].join(", ");
614 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| id.as_str().into()).collect();
615 for sql in [
616 format!("SELECT id, username AS name FROM users WHERE id IN ({marks})"),
617 format!("SELECT id, slug AS name FROM workspaces WHERE id IN ({marks})"),
618 ] {
619 for row in self.db.prepare(sql).bind(&bind)?.all().await?.results::<Named>()? {
620 names.insert(row.id, row.name);
621 }
622 }
623 Ok(names)
624 }
625
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)626 /// `users_for_audience`: the people behind these ids (at most 50), each
627 /// with their workspaces, roles, base permissions and repository grants,
628 /// under each workspace's policy, as a signed-in viewer would have them.
629 /// For the agents service, which answers only with what every person
630 /// who will read the answer may see (docs/WORKSPACE.md, "What an agent
631 /// can and can't know"). Ids of no live account are left out, so the
632 /// caller can tell someone it could not resolve. Reached only by service
633 /// binding.
634 async fn users_for_audience(&self, a: UsernamesArgs) -> Result<Vec<User>> {
635 let mut found = Vec::new();
636 for id in a.ids.iter().take(50) {
637 let user = self
638 .find_user(
639 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ? AND deleted_at IS NULL",
640 id,
641 )
642 .await?;
643 if let Some(user) = user {
644 found.push(user);
645 }
646 }
647 Ok(found)
648 }
649
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97650 /// `accounts`: the accounts behind these ids (at most 200), each with
651 /// its username and avatar, for lists that keep ids, such as who
652 /// starred a repository. Ids of no account are left out.
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar653 /// `display_usernames`: each lowercased username's chosen case, for the
654 /// API, which shows it beside every username it answers with.
655 async fn display_usernames(&self, a: DisplayUsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
656 #[derive(serde::Deserialize)]
657 struct Row {
658 username: String,
659 display_username: String,
660 }
661 let mut names: Vec<String> = a.usernames.iter().map(|name| name.trim().to_lowercase()).filter(|name| !name.is_empty()).collect();
662 names.sort();
663 names.dedup();
664 names.truncate(200);
665 let mut found = std::collections::HashMap::new();
666 if names.is_empty() {
667 return Ok(found);
668 }
669 let marks = vec!["?"; names.len()].join(", ");
670 let bind: Vec<worker::wasm_bindgen::JsValue> = names.iter().map(|name| name.as_str().into()).collect();
671 let rows = self
672 .db
673 .prepare(format!(
674 "SELECT username, display_username FROM users WHERE username IN ({marks}) AND display_username IS NOT NULL AND deleted_at IS NULL"
675 ))
676 .bind(&bind)?
677 .all()
678 .await?
679 .results::<Row>()?;
680 for row in rows {
681 if row.display_username.eq_ignore_ascii_case(&row.username) && row.display_username != row.username {
682 found.insert(row.username, row.display_username);
683 }
684 }
685 Ok(found)
686 }
687
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97688 async fn accounts(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, g1t_contracts::accounts::EmailOwner>> {
689 #[derive(serde::Deserialize)]
690 struct Row {
691 id: String,
692 username: String,
693 avatar: Option<String>,
694 }
695 let ids: Vec<String> = a.ids.into_iter().take(200).collect();
696 let mut found = std::collections::HashMap::new();
697 if ids.is_empty() {
698 return Ok(found);
699 }
700 let marks = vec!["?"; ids.len()].join(", ");
701 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| id.as_str().into()).collect();
702 let rows = self
703 .db
704 .prepare(format!("SELECT id, username, avatar FROM users WHERE id IN ({marks})"))
705 .bind(&bind)?
706 .all()
707 .await?
708 .results::<Row>()?;
709 for row in rows {
710 found.insert(row.id.clone(), g1t_contracts::accounts::EmailOwner { id: row.id, username: row.username, avatar: row.avatar });
711 }
712 Ok(found)
713 }
714
g1t's agents only for listed workspaces, whatever the state of billing715 async fn list_ssh_keys(&self, a: UserArgs) -> Result<Vec<SshKey>> {
716 let rows = self
717 .db
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca718 .prepare("SELECT id, title, fingerprint, created_at, last_used_at FROM ssh_keys WHERE user_id = ? ORDER BY id")
g1t's agents only for listed workspaces, whatever the state of billing719 .bind(&[a.user.id.into()])?
720 .all()
721 .await?
722 .results::<KeyRow>()?;
723 Ok(rows.into_iter().map(SshKey::from).collect())
724 }
725
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge726 /// The account (user id) that registered each key, by fingerprint
727 /// (`SHA256:…`). At most 100; unknown keys are left out.
728 async fn ssh_key_owners(&self, a: SshKeyOwnersArgs) -> Result<std::collections::HashMap<String, String>> {
729 #[derive(serde::Deserialize)]
730 struct Row {
731 fingerprint: String,
732 user_id: String,
733 }
734 let fingerprints: Vec<&String> = a.fingerprints.iter().take(100).collect();
735 if fingerprints.is_empty() {
736 return Ok(std::collections::HashMap::new());
737 }
738 let marks = vec!["?"; fingerprints.len()].join(", ");
739 let binds: Vec<JsValue> = fingerprints.iter().map(|fingerprint| fingerprint.as_str().into()).collect();
740 Ok(self
741 .db
742 .prepare(format!("SELECT fingerprint, user_id FROM ssh_keys WHERE fingerprint IN ({marks})"))
743 .bind(&binds)?
744 .all()
745 .await?
746 .results::<Row>()?
747 .into_iter()
748 .map(|row| (row.fingerprint, row.user_id))
749 .collect())
750 }
751
g1t's agents only for listed workspaces, whatever the state of billing752 async fn add_ssh_key(&self, a: AddSshKeyArgs) -> Result<Outcome<SshKey>> {
753 let Some(key) = crypto::parse_ssh_key(&a.public_key) else {
754 return Ok(Outcome::fail(
755 FailureCode::Invalid,
756 "That is not a valid OpenSSH public key.",
757 ));
758 };
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca759 // Someone's SSH key, or a repository's deploy key (deploy_keys.rs).
760 if self.key_in_use(&key.fingerprint).await? {
761 return Ok(Outcome::fail(FailureCode::Conflict, g1t_contracts::deploy_keys::KEY_IN_USE));
g1t's agents only for listed workspaces, whatever the state of billing762 }
763 let now = now_ms();
764 let title = [a.title.trim(), key.comment.as_str(), "SSH key"]
765 .into_iter()
766 .find(|candidate| !candidate.is_empty())
767 .unwrap_or_default()
768 .to_owned();
769 let row = KeyRow {
770 id: new_id("key", now),
771 title,
772 fingerprint: key.fingerprint,
773 created_at: rfc3339(now),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca774 last_used_at: None,
g1t's agents only for listed workspaces, whatever the state of billing775 };
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca776 let inserted = self.db
g1t's agents only for listed workspaces, whatever the state of billing777 .prepare(
778 "INSERT INTO ssh_keys (id, user_id, title, public_key, fingerprint, created_at)
779 VALUES (?, ?, ?, ?, ?, ?)",
780 )
781 .bind(&[
782 row.id.as_str().into(),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca783 a.user.id.as_str().into(),
g1t's agents only for listed workspaces, whatever the state of billing784 row.title.as_str().into(),
785 key.public_key.into(),
786 row.fingerprint.as_str().into(),
787 row.created_at.as_str().into(),
788 ])?
789 .run()
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca790 .await;
791 // Added at the same moment elsewhere: the trigger or the unique
792 // index refused it.
793 if let Err(error) = inserted {
794 if self.key_in_use(&row.fingerprint).await? {
795 return Ok(Outcome::fail(FailureCode::Conflict, g1t_contracts::deploy_keys::KEY_IN_USE));
796 }
797 return Err(error);
798 }
Merge main (membership, two-factor, GitHub repo roles) into tokens799 let shown = format!("{} ({})", row.title, row.fingerprint);
800 self.log_security(&a.user.id, "ssh_key_added", Some(&shown), None).await;
801 self.audit_account(&a.user, "ssh_key.added", &format!("Added SSH key {shown}")).await;
g1t's agents only for listed workspaces, whatever the state of billing802 Ok(Outcome::Ok(row.into()))
803 }
804
Merge main (membership, two-factor, GitHub repo roles) into tokens805 /// Deletes one of the person's SSH keys.
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca806 async fn remove_ssh_key(&self, a: RemoveArgs) -> Result<()> {
Merge main (membership, two-factor, GitHub repo roles) into tokens807 #[derive(Deserialize)]
808 struct Removed {
809 title: String,
810 fingerprint: String,
811 }
812 let removed = self
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca813 .db
Merge main (membership, two-factor, GitHub repo roles) into tokens814 .prepare("DELETE FROM ssh_keys WHERE id = ? AND user_id = ? RETURNING title, fingerprint")
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca815 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?
Merge main (membership, two-factor, GitHub repo roles) into tokens816 .first::<Removed>(None)
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca817 .await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens818 if let Some(removed) = removed {
819 let shown = format!("{} ({})", removed.title, removed.fingerprint);
820 self.log_security(&a.user.id, "ssh_key_removed", Some(&shown), None).await;
821 self.audit_account(&a.user, "ssh_key.removed", &format!("Removed SSH key {shown}")).await;
822 }
g1t's agents only for listed workspaces, whatever the state of billing823 Ok(())
824 }
825}
826
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas827/// Every 15 minutes: staff hear about waitlist requests that arrived while
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member828/// the last summary's window was still open, so none waits on a later one;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)829/// and deleted workspaces and accounts past their restore window are purged
830/// (deletion.rs, account_deletion.rs).
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas831#[event(scheduled)]
832async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
833 let Ok(db) = env.d1("DB") else { return };
834 let identity = Identity { db, env };
835 if let Err(error) = identity.notify_staff_of_requests().await {
836 worker::console_error!("waitlist summary: {error}");
837 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member838 if let Err(error) = identity.purge_due_workspaces().await {
839 worker::console_error!("workspace purge: {error}");
840 }
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)841 // And deleted accounts past theirs (account_deletion.rs).
842 if let Err(error) = identity.purge_due_accounts().await {
843 worker::console_error!("account purge: {error}");
844 }
Merge main (membership, two-factor, GitHub repo roles) into tokens845 // Once: creators of repositories made before they got Admin (members.rs).
846 if let Err(error) = identity.backfill_creator_grants().await {
847 worker::console_error!("creator grants: {error}");
848 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas849}
850
g1t's agents only for listed workspaces, whatever the state of billing851#[event(fetch)]
852async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
853 let Some(method) = rpc_method(&request) else {
854 return Response::error("Not found", 404);
855 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents856 // A replica near the caller when it asks for one (crates/kit/src/d1.rs).
857 let (db, served) = g1t_kit::d1::open(&env, "DB", &request)?;
g1t's agents only for listed workspaces, whatever the state of billing858 let body: serde_json::Value = request.json().await?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents859 let identity = Identity { db, env };
g1t's agents only for listed workspaces, whatever the state of billing860
Fast pages, required checks on the branch, self-hosted runners, honest incidents861 let answered = match method.as_str() {
Search across all of g1t, Explore, and a command palette862 "register" => {
863 let outcome = identity.register(args(body)?).await?;
864 if let Outcome::Ok(signed_in) = &outcome {
865 identity.announce_user(&signed_in.user.username, Some(&signed_in.user.id)).await;
866 }
867 reply(&outcome)
868 }
g1t's agents only for listed workspaces, whatever the state of billing869 "sign_in" => reply(&identity.sign_in(args(body)?).await?),
Search across all of g1t, Explore, and a command palette870 "create_workspace" => {
871 let outcome = identity.create_workspace(args(body)?).await?;
872 if let Outcome::Ok(workspace) = &outcome {
873 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
874 }
875 reply(&outcome)
876 }
g1t's agents only for listed workspaces, whatever the state of billing877 "get_workspace" => reply(&identity.get_workspace(args(body)?).await?),
878 "list_members" => reply(&identity.list_members(args(body)?).await?),
879 "add_member" => reply(&identity.add_member(args(body)?).await?),
880 "remove_member" => reply(&identity.remove_member(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens881 // Owners, roles, leaving and member privileges; see members.rs.
882 "update_member" => reply(&identity.update_member(args(body)?).await?),
883 "transfer_ownership" => reply(&identity.transfer_ownership(args(body)?).await?),
884 "leave_workspace" => reply(&identity.leave_workspace(args(body)?).await?),
885 "set_member_privileges" => reply(&identity.set_member_privileges(args(body)?).await?),
886 "set_two_factor_requirement" => reply(&identity.set_two_factor_requirement(args(body)?).await?),
887 "grant_creator" => reply(&identity.grant_creator(args(body)?).await?),
Search across all of g1t, Explore, and a command palette888 "update_workspace" => {
889 let outcome = identity.update_workspace(args(body)?).await?;
890 if let Outcome::Ok(workspace) = &outcome {
891 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
892 }
893 reply(&outcome)
894 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains895 "rename_workspace" => reply(&identity.rename_workspace(args(body)?).await?),
896 "check_workspace_rename" => reply(&identity.check_workspace_rename(args(body)?).await?),
897 "resolve_slug" => reply(&identity.resolve_slug(args(body)?).await?),
Merge branch 'worktree-agent-a8385d293d42c913a'898 "resolve_alias" => reply(&identity.resolve_alias(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look899 "check_workspace_deletion" => reply(&identity.check_workspace_deletion(args(body)?).await?),
900 "delete_workspace" => reply(&identity.delete_workspace(args(body)?).await?),
901 "transfer_repo_scopes" => reply(&identity.transfer_repo_scopes(args(body)?).await?),
Search across all of g1t, Explore, and a command palette902 "set_workspace_avatar" => {
903 let outcome = identity.set_workspace_avatar(args(body)?).await?;
904 if let Outcome::Ok(workspace) = &outcome {
905 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
906 }
907 reply(&outcome)
908 }
909 "set_user_avatar" => {
910 let a: SetUserAvatarArgs = args(body)?;
911 let (username, id) = (a.user.username.clone(), a.user.id.clone());
912 let outcome = identity.set_user_avatar(a).await?;
913 if matches!(outcome, Outcome::Ok(_)) {
914 identity.announce_user(&username, Some(&id)).await;
915 }
916 reply(&outcome)
917 }
g1t's agents only for listed workspaces, whatever the state of billing918 "list_workspace_tokens" => reply(&identity.list_workspace_tokens(args(body)?).await?),
919 "remove_workspace_token" => reply(&identity.remove_workspace_token(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look920 // Signing in with GitHub; see github.rs.
921 "github_enabled" => reply(&identity.github_enabled()),
922 "github_start" => reply(&identity.github_start(args(body)?).await?),
923 "github_finish" => reply(&identity.github_finish(args(body)?).await?),
924 "github_pending" => reply(&identity.github_pending(args(body)?).await?),
925 "github_sign_up" => reply(&identity.github_sign_up(args(body)?).await?),
926 "github_claim" => reply(&identity.github_claim(args(body)?).await?),
927 "github_account" => reply(&identity.github_account(args(body)?).await?),
928 "github_unlink" => reply(&identity.github_unlink(args(body)?).await?),
929 "github_user_token" => reply(&identity.github_user_token(args(body)?).await?),
930 "github_revoked" => reply(&identity.github_revoked(args(body)?).await?),
931 "github_usernames" => reply(&identity.github_usernames(args(body)?).await?),
g1t's agents only for listed workspaces, whatever the state of billing932 "oauth_authorize" => reply(&identity.oauth_authorize(args(body)?).await?),
933 "oauth_exchange" => reply(&identity.oauth_exchange(args(body)?).await?),
934 "oauth_refresh" => reply(&identity.oauth_refresh(args(body)?).await?),
935 "list_oauth_grants" => reply(&identity.list_oauth_grants(args(body)?).await?),
936 "revoke_oauth_grant" => reply(&identity.revoke_oauth_grant(args(body)?).await?),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step937 "update_oauth_grant" => reply(&identity.update_oauth_grant(args(body)?).await?),
g1t's agents only for listed workspaces, whatever the state of billing938 "device_start" => reply(&identity.device_start(args(body)?).await?),
939 "device_lookup" => reply(&identity.device_lookup(args(body)?).await?),
940 "device_resolve" => reply(&identity.device_resolve(args(body)?).await?),
941 "device_claim" => reply(&identity.device_claim(args(body)?).await?),
942 "resend_verification" => reply(&identity.resend_verification(args(body)?).await?),
943 "verify_email" => reply(&identity.verify_email(args(body)?).await?),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)944 "confirm_email_code" => reply(&identity.confirm_email_code(args(body)?).await?),
945 "change_pending_email" => reply(&identity.change_pending_email(args(body)?).await?),
g1t's agents only for listed workspaces, whatever the state of billing946 "request_password_reset" => reply(&identity.request_password_reset(args(body)?).await?),
947 "reset_password" => reply(&identity.reset_password(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look948 // A person's email addresses; see emails.rs and security.rs.
949 "list_emails" => reply(&identity.list_emails(args(body)?).await?),
950 "add_email" => reply(&identity.add_email(args(body)?).await?),
951 "remove_email" => reply(&identity.remove_email(args(body)?).await?),
952 "resend_email_verification" => reply(&identity.resend_email_verification(args(body)?).await?),
953 "update_email_settings" => reply(&identity.update_email_settings(args(body)?).await?),
954 "reauthenticate" => reply(&identity.reauthenticate(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens955 // Two-factor authentication; see two_factor.rs.
956 "two_factor_status" => reply(&identity.two_factor_status(args(body)?).await?),
957 "two_factor_start" => reply(&identity.two_factor_start(args(body)?).await?),
958 "two_factor_enable" => reply(&identity.two_factor_enable(args(body)?).await?),
959 "two_factor_disable" => reply(&identity.two_factor_disable(args(body)?).await?),
960 "two_factor_recovery_codes" => reply(&identity.two_factor_recovery_codes(args(body)?).await?),
961 "two_factor_sign_in" => reply(&identity.two_factor_sign_in(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look962 "security_log" => reply(&identity.security_log(args(body)?).await?),
963 "email_owners" => reply(&identity.email_owners(args(body)?).await?),
964 "commit_identity" => reply(&identity.commit_identity(args(body)?).await?),
965 "push_email_guard" => reply(&identity.push_email_guard(args(body)?).await?),
966 "admin_user" => reply(&identity.admin_user(args(body)?).await?),
967 "admin_remove_email" => reply(&identity.admin_remove_email(args(body)?).await?),
g1t's agents only for listed workspaces, whatever the state of billing968 "sign_out" => reply(&identity.sign_out(args(body)?).await?),
969 "user_for_session" => reply(&identity.user_for_session(args(body)?).await?),
970 "user_for_git_credentials" => reply(&identity.user_for_git_credentials(args(body)?).await?),
971 "user_for_access_token" => {
972 let a: TokenArgs = args(body)?;
973 reply(&identity.user_for_access_token(&a.token).await?)
974 }
975 "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?),
976 "user_by_username" => reply(&identity.user_by_username(args(body)?).await?),
977 "usernames" => reply(&identity.usernames(args(body)?).await?),
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar978 // The API: each username's chosen case, shown beside it.
979 "display_usernames" => reply(&identity.display_usernames(args(body)?).await?),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97980 "accounts" => reply(&identity.accounts(args(body)?).await?),
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)981 "users_for_audience" => reply(&identity.users_for_audience(args(body)?).await?),
Inbox: threads, reasons, subscriptions and watching982 "notify_by_email" => reply(&identity.notify_by_email(args(body)?).await?),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains983 "profile" => reply(&identity.profile(args(body)?).await?),
Search across all of g1t, Explore, and a command palette984 "update_profile" => {
985 let outcome = identity.update_profile(args(body)?).await?;
986 if let Outcome::Ok(profile) = &outcome {
987 identity.announce_user(&profile.username, None).await;
988 }
989 reply(&outcome)
990 }
991 "directory" => reply(&identity.directory(args(body)?).await?),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains992 "profile_workspaces" => reply(&identity.profile_workspaces(args(body)?).await?),
g1t's agents only for listed workspaces, whatever the state of billing993 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge994 // Services only: who registered each key, for verifying commit
995 // signatures (repos' signatures.rs).
996 "ssh_key_owners" => reply(&identity.ssh_key_owners(args(body)?).await?),
g1t's agents only for listed workspaces, whatever the state of billing997 "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca998 "remove_ssh_key" => reply(&identity.remove_ssh_key(args(body)?).await?),
999 // A repository's deploy keys, and who an SSH key signs in as; see
1000 // deploy_keys.rs.
1001 "list_deploy_keys" => reply(&identity.list_deploy_keys(args(body)?).await?),
1002 "get_deploy_key" => reply(&identity.get_deploy_key(args(body)?).await?),
1003 "add_deploy_key" => reply(&identity.add_deploy_key(args(body)?).await?),
1004 "remove_deploy_key" => reply(&identity.remove_deploy_key(args(body)?).await?),
1005 "principal_for_ssh_key" => reply(&identity.principal_for_ssh_key(args(body)?).await?),
g1t's agents only for listed workspaces, whatever the state of billing1006 "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?),
1007 "create_access_token" => reply(&identity.create_access_token(args(body)?).await?),
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1008 // Making and changing a person's or a workspace's token, and
1009 // workspaces' rules for tokens; see token_reach.rs.
1010 "create_token" => reply(&identity.create_token(args(body)?).await?),
1011 "update_token" => reply(&identity.update_token(args(body)?).await?),
Fine-grained personal tokens, workspace token rules and approvals in identity1012 "get_token_policy" => reply(&identity.get_token_policy(args(body)?).await?),
1013 "set_token_policy" => reply(&identity.set_token_policy(args(body)?).await?),
1014 "list_member_tokens" => reply(&identity.list_member_tokens(args(body)?).await?),
1015 "review_token_request" => reply(&identity.review_token_request(args(body)?).await?),
1016 "revoke_member_token" => reply(&identity.revoke_member_token(args(body)?).await?),
g1t's agents only for listed workspaces, whatever the state of billing1017 "create_agent_token" => reply(&identity.create_agent_token(args(body)?).await?),
1018 "agent_scope" => reply(&identity.agent_scope(args(body)?).await?),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1019 "create_run_credential" => reply(&identity.create_run_credential(args(body)?).await?),
1020 "bind_run_credentials" => reply(&identity.bind_run_credentials(args(body)?).await?),
1021 "revoke_run_credentials" => reply(&identity.revoke_run_credentials(args(body)?).await?),
Merge branch 'worktree-agent-a3abfcce648e87dca'1022 "create_job_token" => reply(&identity.create_job_token(args(body)?).await?),
1023 "revoke_job_tokens" => reply(&identity.revoke_job_tokens(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens1024 "remove_access_token" => reply(&identity.remove_access_token(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1025 // Invites and the waitlist; see invites.rs.
1026 "registration" => reply(&identity.registration_mode()),
1027 "list_invites" => reply(&identity.list_invites(args(body)?).await?),
1028 "create_invite" => reply(&identity.create_invite(args(body)?).await?),
1029 "revoke_invite" => reply(&identity.revoke_invite(args(body)?).await?),
1030 "check_invite" => reply(&identity.check_invite(args(body)?).await?),
1031 "accept_invite" => reply(&identity.accept_invite(args(body)?).await?),
1032 "invite_member" => reply(&identity.invite_member(args(body)?).await?),
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)1033 "list_invitations" => reply(&identity.list_invitations(args(body)?).await?),
1034 "accept_invitation" => reply(&identity.accept_invitation(args(body)?).await?),
1035 "decline_invitation" => reply(&identity.decline_invitation(args(body)?).await?),
1036 "find_people" => reply(&identity.find_people(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1037 "workspace_invites" => reply(&identity.workspace_invites(args(body)?).await?),
1038 "revoke_workspace_invite" => reply(&identity.revoke_workspace_invite(args(body)?).await?),
1039 "request_access" => reply(&identity.request_access(args(body)?).await?),
1040 // Who has access to a repository; see access.rs.
1041 "repo_access" => reply(&identity.repo_access(args(body)?).await?),
1042 "add_collaborator" => reply(&identity.add_collaborator(args(body)?).await?),
1043 "set_collaborator_role" => reply(&identity.set_collaborator_role(args(body)?).await?),
1044 "remove_collaborator" => reply(&identity.remove_collaborator(args(body)?).await?),
1045 "collaborator_permission" => reply(&identity.collaborator_permission(args(body)?).await?),
1046 "my_repo_invitations" => reply(&identity.my_repo_invitations(args(body)?).await?),
1047 "respond_repo_invitation" => reply(&identity.respond_repo_invitation(args(body)?).await?),
1048 "revoke_repo_invitation" => reply(&identity.revoke_repo_invitation(args(body)?).await?),
1049 "set_base_permission" => reply(&identity.set_base_permission(args(body)?).await?),
Merge branch 'worktree-agent-a2013627e5ea4ab13'1050 // Where a workspace keeps its repositories' git data (EU residency).
1051 "workspace_residency" => reply(&identity.workspace_residency(args(body)?).await?),
1052 "set_workspace_residency" => reply(&identity.set_workspace_residency(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1053 "outside_collaborators" => reply(&identity.outside_collaborators(args(body)?).await?),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1054 "forget_repo_access" => {
1055 let a: g1t_contracts::access::ForgetRepoAccessArgs = args(body)?;
1056 // A purged repository's deploy keys go with its access.
1057 identity.forget_deploy_keys(&a.repo_id).await?;
1058 reply(&identity.forget_repo_access(a).await?)
1059 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1060 // Teams (teams.rs).
1061 "list_teams" => reply(&identity.list_teams(args(body)?).await?),
1062 "get_team" => reply(&identity.get_team(args(body)?).await?),
1063 "create_team" => reply(&identity.create_team(args(body)?).await?),
Merge branch 'worktree-agent-ad7c6d88d93adc817'1064 "set_team_creation" => reply(&identity.set_team_creation(args(body)?).await?),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1065 "update_team" => reply(&identity.update_team(args(body)?).await?),
1066 "delete_team" => reply(&identity.delete_team(args(body)?).await?),
1067 "team_members" => reply(&identity.team_members(args(body)?).await?),
1068 "set_team_member" => reply(&identity.set_team_member(args(body)?).await?),
1069 "remove_team_member" => reply(&identity.remove_team_member(args(body)?).await?),
1070 "child_teams" => reply(&identity.child_teams(args(body)?).await?),
1071 "team_repos" => reply(&identity.team_repos(args(body)?).await?),
1072 "set_team_repo" => reply(&identity.set_team_repo(args(body)?).await?),
1073 "remove_team_repo" => reply(&identity.remove_team_repo(args(body)?).await?),
1074 "user_teams" => reply(&identity.user_teams(args(body)?).await?),
1075 "team_memberships" => reply(&identity.team_memberships(args(body)?).await?),
1076 "resolve_teams" => reply(&identity.resolve_teams(args(body)?).await?),
1077 "resolve_owners" => reply(&identity.resolve_owners(args(body)?).await?),
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1078 // Staff only: sudo.g1t.sh, over its service binding. See admin.rs.
1079 "notify_owners" => reply(&identity.notify_owners(args(body)?).await?),
1080 "admin_workspaces" => reply(&identity.admin_workspaces(args(body)?).await?),
1081 "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1082 "admin_waitlist" => reply(&identity.admin_waitlist(args(body)?).await?),
1083 "admin_decide_waitlist" => reply(&identity.admin_decide_waitlist(args(body)?).await?),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1084 "admin_waitlist_pending" => reply(&identity.admin_waitlist_pending().await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1085 "admin_invites" => reply(&identity.admin_invites(args(body)?).await?),
1086 "admin_revoke_invite" => reply(&identity.admin_revoke_invite(args(body)?).await?),
1087 "admin_mint_invite" => reply(&identity.admin_mint_invite(args(body)?).await?),
1088 "admin_grant_invites" => reply(&identity.admin_grant_invites(args(body)?).await?),
1089 "admin_invite_tree" => reply(&identity.admin_invite_tree(args(body)?).await?),
1090 "admin_workspace_invites" => reply(&identity.admin_workspace_invites(args(body)?).await?),
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)1091 // Shared invite links for a group; see shared_invites.rs.
1092 "admin_shared_invites" => reply(&identity.admin_shared_invites().await?),
1093 "admin_create_shared_invite" => reply(&identity.admin_create_shared_invite(args(body)?).await?),
1094 "admin_revoke_shared_invite" => reply(&identity.admin_revoke_shared_invite(args(body)?).await?),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1095 // Deleted workspaces, restored or purged by staff; see deletion.rs.
1096 "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?),
1097 "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?),
1098 "admin_purge_workspace" => reply(&identity.admin_purge_workspace(args(body)?).await?),
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)1099 // Deleting accounts (account_deletion.rs): the person from the
1100 // site, staff from sudo. There is no API route for it.
1101 "check_account_deletion" => reply(&identity.check_account_deletion(args(body)?).await?),
1102 "delete_account" => reply(&identity.delete_account(args(body)?).await?),
1103 "admin_delete_account" => reply(&identity.admin_delete_account(args(body)?).await?),
1104 "admin_deleted_accounts" => reply(&identity.admin_deleted_accounts().await?),
1105 "admin_restore_account" => reply(&identity.admin_restore_account(args(body)?).await?),
1106 "admin_purge_account" => reply(&identity.admin_purge_account(args(body)?).await?),
Merge branch 'worktree-agent-a8385d293d42c913a'1107 // Workspace aliases, set by staff only; see aliases.rs.
1108 "admin_aliases" => reply(&identity.admin_aliases().await?),
1109 "admin_set_alias" => reply(&identity.admin_set_alias(args(body)?).await?),
1110 "admin_remove_alias" => reply(&identity.admin_remove_alias(args(body)?).await?),
g1t's agents only for listed workspaces, whatever the state of billing1111 _ => Response::error("Unknown method", 404),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1112 };
1113 served.finish(answered)
g1t's agents only for listed workspaces, whatever the state of billing1114}
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1115
1116#[cfg(test)]
1117mod register_tests {
1118 use super::*;
1119
1120 #[test]
1121 fn nobody_registers_as_g1t() {
1122 // What register checks the username with, whatever its case.
1123 for username in ["g1t", "G1T", "g1t-agent", "G1t-Agent"] {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1124 assert!(claimable_username(username).is_none(), "{username}");
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1125 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1126 assert_eq!(claimable_username("ana").map(|name| name.canonical).as_deref(), Some("ana"));
1127 }
1128
1129 #[test]
1130 fn a_username_keeps_the_case_it_was_chosen_in() {
1131 let name = claimable_username("Ana-Lopez").unwrap();
1132 assert_eq!(name.canonical, "ana-lopez");
1133 assert_eq!(name.display_if_cased(), Some("Ana-Lopez"));
1134 assert!(USERNAME_RULES.contains("either case"));
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1135 }
1136}

This file's history is long; its oldest lines are credited to the oldest commit read.