Skip to content
1,140 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.

Webhooks: every event, to your own addresses, signed and retried1//! 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;
Webhooks: every event, to your own addresses, signed and retried11mod 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;
Webhooks: every event, to your own addresses, signed and retried14mod device;
Search across all of g1t, Explore, and a command palette15mod directory;
The apps you pin to your dock are kept with your account, per workspace and in your order, so the dock is the same on every device: identity keeps them in dock_pins and answers dock_pins and set_dock_pins, the dock reads them with the rest of the page, pins kept only on this device carry over with your first change, this device's copy still draws the dock when identity can't be reached, a pin that can't be saved says so, and they go when you or the workspace do; the workspaces guide says how.16mod dock;
Webhooks: every event, to your own addresses, signed and retried17mod email;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look18mod emails;
19mod github;
20mod invites;
Merge main (membership, two-factor, GitHub repo roles) into tokens21mod members;
Webhooks: every event, to your own addresses, signed and retried22mod oauth;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person23mod paid;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains24mod profiles;
25mod rename;
Merge branch 'worktree-agent-a3abfcce648e87dca'26mod job_tokens;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API27mod run_credentials;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look28mod security;
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)29mod shared_invites;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar30mod teams;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look31mod throttle;
Fine-grained personal tokens, workspace token rules and approvals in identity32mod token_reach;
Webhooks: every event, to your own addresses, signed and retried33mod tokens;
Merge main (membership, two-factor, GitHub repo roles) into tokens34mod two_factor;
Webhooks: every event, to your own addresses, signed and retried35mod workspaces;
36
37use g1t_contracts::identity::*;
38use 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 managers39use g1t_contracts::{FailureCode, Outcome, User, Viewer, claimable_username, new_id};
Webhooks: every event, to your own addresses, signed and retried40use g1t_kit::{args, now_ms, reply, rpc_method};
41use serde::Deserialize;
42use tokens::TOKEN_PREFIX;
43use worker::wasm_bindgen::JsValue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas44use worker::{Context, D1Database, Env, Request, Response, Result, ScheduleContext, ScheduledEvent, event};
Webhooks: every event, to your own addresses, signed and retried45
46const SESSION_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
47const RESET_TTL_SECONDS: u64 = 60 * 60;
48const MIN_PASSWORD_LENGTH: usize = 10;
49const PASSWORD_TOO_SHORT: &str = "Use a password of at least 10 characters.";
50
51/// 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 managers52/// What a username may be, as registration and GitHub sign-up say when one is refused.
53pub(crate) const USERNAME_RULES: &str =
54 "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.";
55
Webhooks: every event, to your own addresses, signed and retried56#[derive(Deserialize)]
57struct Account {
58 id: String,
59 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 managers60 /// Selected where the person is shown: their username as they wrote it.
61 #[serde(default)]
62 display_username: Option<String>,
Webhooks: every event, to your own addresses, signed and retried63 verified: u8,
Workspace names and icons, and a component kit for every control64 /// Selected only where the person is being shown to themselves.
65 #[serde(default)]
66 avatar: Option<String>,
Webhooks: every event, to your own addresses, signed and retried67}
68
69impl From<Account> for User {
70 fn from(row: Account) -> Self {
71 User {
72 id: row.id,
73 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 managers74 display_username: row.display_username,
Webhooks: every event, to your own addresses, signed and retried75 verified: row.verified != 0,
Workspace names and icons, and a component kit for every control76 avatar: row.avatar,
Webhooks: every event, to your own addresses, signed and retried77 ..User::default()
78 }
79 }
80}
81
82#[derive(Deserialize)]
83struct UserRow {
84 id: String,
85 username: String,
86 password_hash: String,
87 verified: u8,
88}
89
90/// The owner of an emailed token.
91#[derive(Deserialize)]
92struct TokenOwner {
93 id: String,
94 username: String,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look95 /// The address a link was sent to; null on links from before accounts
96 /// had several, which are for the primary.
97 #[serde(default)]
98 email_id: Option<String>,
Webhooks: every event, to your own addresses, signed and retried99}
100
101#[derive(Deserialize)]
102struct KeyRow {
103 id: String,
104 title: String,
105 fingerprint: String,
106 created_at: String,
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca107 #[serde(default)]
108 last_used_at: Option<String>,
Webhooks: every event, to your own addresses, signed and retried109}
110
111impl From<KeyRow> for SshKey {
112 fn from(row: KeyRow) -> Self {
113 SshKey {
114 id: row.id,
115 title: row.title,
116 fingerprint: row.fingerprint,
117 created_at: row.created_at,
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca118 last_used_at: row.last_used_at,
Webhooks: every event, to your own addresses, signed and retried119 }
120 }
121}
122
123struct Identity {
124 db: D1Database,
125 env: Env,
126}
127
128impl Identity {
129 /// Runs a query that returns at most one user, for showing to others:
130 /// without their workspaces.
131 async fn find_public_user(&self, sql: &str, param: &str) -> Result<Viewer> {
132 Ok(self
133 .db
134 .prepare(sql)
135 .bind(&[JsValue::from(param)])?
136 .first::<Account>(None)
137 .await?
138 .map(User::from))
139 }
140
141 /// Attaches the workspaces a user belongs to, so that any service can
142 /// authorize them without asking again.
143 async fn with_workspaces(&self, user: Viewer) -> Result<Viewer> {
144 let Some(mut user) = user else {
145 return Ok(None);
146 };
Merge main (membership, two-factor, GitHub repo roles) into tokens147 let memberships = self.memberships_and_policies(&user.id).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look148 // Roles on single repositories, under the same policy (access.rs).
149 let grants = self.grants_of(&user.id).await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens150 // Access to a workspace is used only within its policy; see security.rs.
151 let within = self.within_policy(&user.id, memberships, grants).await?;
152 user.workspaces = within.memberships;
153 user.grants = within.grants;
154 user.held = within.held;
Webhooks: every event, to your own addresses, signed and retried155 Ok(Some(user))
156 }
157
158 /// Runs a query that resolves credentials to at most one user.
159 async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> {
160 let user = self.find_public_user(sql, param).await?;
161 self.with_workspaces(user).await
162 }
163
164 /// Consumes a token of `kind`, returning its owner if it was valid.
165 async fn redeem_email_token(&self, token: &str, kind: &str) -> Result<Option<TokenOwner>> {
166 let id = crypto::sha256_hex(token);
167 let owner = self
168 .db
169 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look170 "SELECT users.id, users.username, email_tokens.email_id FROM email_tokens
Webhooks: every event, to your own addresses, signed and retried171 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)172 WHERE email_tokens.id = ? AND email_tokens.kind = ? AND users.deleted_at IS NULL
Webhooks: every event, to your own addresses, signed and retried173 AND email_tokens.expires_at > {SQL_NOW}"
174 ))
175 .bind(&[id.as_str().into(), kind.into()])?
176 .first::<TokenOwner>(None)
177 .await?;
178 if let Some(owner) = &owner {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look179 // Every outstanding token of this kind dies with the one used:
180 // every reset link, and every confirmation link for the same
181 // address (another address's links still work).
Webhooks: every event, to your own addresses, signed and retried182 self.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look183 .prepare(
184 "DELETE FROM email_tokens WHERE user_id = ?1 AND kind = ?2
185 AND (?2 = 'reset' OR email_id IS ?3)",
186 )
187 .bind(&[
188 owner.id.as_str().into(),
189 kind.into(),
190 owner.email_id.as_deref().map_or(JsValue::NULL, Into::into),
191 ])?
Webhooks: every event, to your own addresses, signed and retried192 .run()
193 .await?;
194 }
195 Ok(owner)
196 }
197
198 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 look199 if !self.allow(throttle::CONFIRM_ACCOUNT, &a.user.id).await? {
200 return Ok(Outcome::fail(FailureCode::Conflict, "Too many confirmation emails this hour. Check your inbox, or try again later."));
201 }
202 self.resend_primary(&a.user).await
Webhooks: every event, to your own addresses, signed and retried203 }
204
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)205 /// The link in a confirmation email, followed: signed in or not. It
206 /// ends the code sent with it (emails.rs).
207 async fn verify_email(&self, a: EmailTokenArgs) -> Result<Outcome<g1t_contracts::accounts::EmailConfirmed>> {
Webhooks: every event, to your own addresses, signed and retried208 let Some(owner) = self.redeem_email_token(&a.token, "verify").await? else {
209 return Ok(Outcome::fail(
210 FailureCode::Invalid,
211 "This confirmation link is not valid or has expired.",
212 ));
213 };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)214 self.confirm_address(&owner.id, owner.email_id.as_deref()).await
Webhooks: every event, to your own addresses, signed and retried215 }
216
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look217 /// Any confirmed address of an account can ask for a reset; so can the
218 /// unconfirmed address a new account signed up with. See emails.rs.
Webhooks: every event, to your own addresses, signed and retried219 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 look220 let allowed = self.allow(throttle::RESET_EMAIL, &a.email).await?
221 && match a.client.as_deref() {
222 Some(client) => self.allow(throttle::RESET_CLIENT, client).await?,
223 None => true,
224 };
225 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 exists226 // A failure from here on happens only for a real account, so it
227 // is logged, never answered: the reply below stays the same.
228 if let Err(error) = self.send_reset(&target).await {
229 worker::console_error!("password reset for a known address failed: {error}");
230 }
231 }
232 // The same answer either way, so addresses cannot be probed.
233 Ok(true)
234 }
235
236 /// Saves a reset link for `target` and mails it, telling the account's
237 /// other addresses.
238 async fn send_reset(&self, target: &emails::ResetTarget) -> Result<()> {
239 {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look240 let token = crypto::random_hex(32);
241 self.db
242 .prepare(format!(
243 "INSERT INTO email_tokens (id, user_id, kind, expires_at, email_id)
244 VALUES (?, ?, 'reset', {}, ?)",
245 sql_after(RESET_TTL_SECONDS)
246 ))
247 .bind(&[
248 crypto::sha256_hex(&token).into(),
249 target.user_id.as_str().into(),
250 target.email_id.as_str().into(),
251 ])?
252 .run()
Webhooks: every event, to your own addresses, signed and retried253 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look254 email::send_password_reset(&self.env, &target.display, &target.username, &token).await?;
255 // The primary and the backup hear of it when it went elsewhere.
256 let elsewhere = self.notice_recipients(&target.user_id, false).await?;
257 for address in elsewhere.iter().filter(|address| !address.eq_ignore_ascii_case(&target.display)) {
258 let change = format!("A password reset was asked for through {}", target.display);
259 if let Err(error) = email::send_security_notice(&self.env, address, &target.username, &change).await {
260 worker::console_error!("security notice failed: {error}");
261 }
262 }
Webhooks: every event, to your own addresses, signed and retried263 }
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 exists264 Ok(())
Webhooks: every event, to your own addresses, signed and retried265 }
266
267 async fn reset_password(&self, a: ResetPasswordArgs) -> Result<Outcome<User>> {
268 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
269 return Ok(Outcome::fail(FailureCode::Invalid, PASSWORD_TOO_SHORT));
270 }
271 let Some(owner) = self.redeem_email_token(&a.token, "reset").await? else {
272 return Ok(Outcome::fail(
273 FailureCode::Invalid,
274 "This reset link is not valid or has expired.",
275 ));
276 };
277 self.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look278 .prepare("UPDATE users SET password_hash = ? WHERE id = ?")
Webhooks: every event, to your own addresses, signed and retried279 .bind(&[
280 crypto::hash_password(&a.password).into(),
281 owner.id.as_str().into(),
282 ])?
283 .run()
284 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look285 // Following an emailed link also proves the address it went to
286 // (unless another account confirmed it first).
287 let _ = self.confirm_address(&owner.id, owner.email_id.as_deref()).await?;
288 // Anyone signed in with the old password is signed out, and nobody
289 // stays locked out by the wrong guesses before it.
Webhooks: every event, to your own addresses, signed and retried290 self.db
291 .prepare("DELETE FROM sessions WHERE user_id = ?")
292 .bind(&[owner.id.as_str().into()])?
293 .run()
294 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look295 self.clear(&throttle::key(throttle::PASSWORD_ACCOUNT, &owner.id)).await?;
296 self.log_security(&owner.id, "password_changed", None, None).await;
297 self.tell_primary_and_backup(&owner.id, &owner.username, "Your password was changed").await;
298 let verified = self
299 .find_public_user(
300 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?",
301 &owner.id,
302 )
303 .await?
304 .is_some_and(|user| user.verified);
Webhooks: every event, to your own addresses, signed and retried305 Ok(Outcome::Ok(User {
306 id: owner.id,
307 username: owner.username,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look308 verified,
Webhooks: every event, to your own addresses, signed and retried309 ..User::default()
310 }))
311 }
312
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look313 /// The account a login names: a username, or any confirmed address.
314 async fn password_row(&self, login: &str) -> Result<Option<UserRow>> {
315 let login = login.trim().to_lowercase();
316 let (column, value) = if login.contains('@') {
317 match self.user_with_verified_email(&login).await? {
318 Some(id) => ("id", id),
319 None => return Ok(None),
320 }
321 } else {
322 ("username", login)
323 };
324 self.db
325 .prepare(format!(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)326 "SELECT id, username, password_hash, email_verified_at IS NOT NULL AS verified FROM users
327 WHERE {column} = ? AND deleted_at IS NULL"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look328 ))
329 .bind(&[JsValue::from(value)])?
Webhooks: every event, to your own addresses, signed and retried330 .first::<UserRow>(None)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look331 .await
332 }
333
334 /// Checks a password for a login, throttled (see throttle.rs). The
335 /// refusal is one of two messages, the same for every account.
336 async fn checked_password(
337 &self,
338 login: &str,
339 password: &str,
340 client: Option<&str>,
341 ) -> Result<std::result::Result<User, &'static str>> {
342 let row = self.password_row(login).await?;
343 let subject = row.as_ref().map_or_else(|| login.trim().to_lowercase(), |row| row.id.clone());
344 let (account_key, client_key) = Identity::password_keys(&subject, client);
345 if self.password_locked(&account_key, client_key.as_deref()).await? {
346 return Ok(Err(throttle::THROTTLED));
347 }
348 let owner = row.as_ref().map(|row| (row.id.clone(), row.username.clone()));
349 match row.filter(|row| !row.password_hash.is_empty() && crypto::verify_password(password, &row.password_hash)) {
350 Some(row) => {
351 self.clear(&account_key).await?;
352 Ok(Ok(User {
353 id: row.id,
354 username: row.username,
355 verified: row.verified != 0,
356 ..User::default()
357 }))
358 }
359 None => {
360 let owner = owner.as_ref().map(|(id, name)| (id.as_str(), name.as_str()));
361 self.password_failed(&account_key, client_key.as_deref(), owner).await?;
362 Ok(Err("Incorrect username or password."))
363 }
364 }
365 }
366
Merge main (membership, two-factor, GitHub repo roles) into tokens367 /// Git over HTTPS with the account's password. With two-factor
368 /// authentication on, a password alone is never enough: use an access
369 /// token (two_factor.rs).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look370 async fn user_for_password(&self, login: &str, password: &str) -> Result<Viewer> {
371 let user = self.checked_password(login, password, None).await?.ok();
Merge main (membership, two-factor, GitHub repo roles) into tokens372 if let Some(user) = &user
373 && self.two_factor_enabled(&user.id).await?
374 {
375 return Ok(None);
376 }
Webhooks: every event, to your own addresses, signed and retried377 self.with_workspaces(user).await
378 }
379
380 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 managers381 // Kept as typed for showing; found, linked and mentioned lowercased.
382 let chosen = claimable_username(&a.username);
383 let username = chosen.as_ref().map_or_else(|| a.username.trim().to_lowercase(), |name| name.canonical.clone());
384 let claimable = chosen.is_some();
Webhooks: every event, to your own addresses, signed and retried385 let email = a.email.trim().to_lowercase();
386 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 look387 let invite_code = a.invite_code.as_deref().map(str::trim).filter(|code| !code.is_empty());
388 // The invite first: without one, nothing else on the form matters.
389 if self.invites_required() && invite_code.is_none() {
390 return Ok(Outcome::fail(FailureCode::Forbidden, invites::MISSING));
391 }
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent392 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 managers393 return invalid(USERNAME_RULES);
Webhooks: every event, to your own addresses, signed and retried394 }
395 let well_formed_email = email
396 .split_once('@')
397 .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
398 && !email.contains(char::is_whitespace);
399 if !well_formed_email {
400 return invalid("Enter a valid email address.");
401 }
402 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
403 return invalid(PASSWORD_TOO_SHORT);
404 }
405 let taken = self
406 .db
407 // Usernames and workspaces share one namespace, so that a name
408 // means the same thing wherever it appears.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look409 // An address is taken once an account has confirmed it; an
410 // unconfirmed one goes to whoever confirms it first (emails.rs).
Webhooks: every event, to your own addresses, signed and retried411 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look412 "SELECT username FROM users WHERE username = ?
413 UNION ALL SELECT email FROM user_emails WHERE email = ? AND verified_at IS NOT NULL
Webhooks: every event, to your own addresses, signed and retried414 UNION ALL SELECT slug FROM workspaces WHERE slug = ?",
415 )
416 .bind(&[
417 username.as_str().into(),
418 email.as_str().into(),
419 username.as_str().into(),
420 ])?
421 .first::<serde_json::Value>(None)
422 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look423 // A renamed workspace's old slug stays reserved for it a while, and
424 // a deleted workspace's for good.
425 if taken.is_some() || self.slug_held(&username).await? || self.slug_deleted(&username).await? {
Webhooks: every event, to your own addresses, signed and retried426 return Ok(Outcome::fail(
427 FailureCode::Conflict,
428 "That username or email is already registered.",
429 ));
430 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look431 let password_hash = crypto::hash_password(&a.password);
432 let user = match self
433 .create_account(invites::NewAccount {
434 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 managers435 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 look436 email: &email,
437 password_hash: &password_hash,
438 verified: false,
439 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 confirm440 email_proof: a.email_proof.as_deref(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look441 client: a.client.as_deref(),
442 })
443 .await?
444 {
445 Outcome::Ok(user) => user,
446 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
Webhooks: every event, to your own addresses, signed and retried447 };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)448 // The account exists either way; the email can be sent again from
449 // 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 quotas450 if !user.verified
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)451 && 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 quotas452 {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)453 worker::console_error!("confirmation email failed: {error}");
Webhooks: every event, to your own addresses, signed and retried454 }
455 self.start_session(user).await
456 }
457
458 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 look459 let user = match self.checked_password(&a.username, &a.password, a.client.as_deref()).await? {
460 Ok(user) => user,
461 Err(message) => return Ok(Outcome::fail(FailureCode::Unauthenticated, message)),
Webhooks: every event, to your own addresses, signed and retried462 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look463 let user = self.with_workspaces(Some(user)).await?.unwrap_or_default();
Webhooks: every event, to your own addresses, signed and retried464 self.start_session(user).await
465 }
466
Merge main (membership, two-factor, GitHub repo roles) into tokens467 /// Starts a session for someone who just proved their password (or
468 /// GitHub account). With two-factor authentication on, it starts none:
469 /// it returns a challenge for `two_factor_sign_in` (two_factor.rs).
Webhooks: every event, to your own addresses, signed and retried470 async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> {
Merge main (membership, two-factor, GitHub repo roles) into tokens471 if self.two_factor_enabled(&user.id).await? {
472 let challenge = self.issue_challenge(&user.id).await?;
473 return Ok(Outcome::Ok(SignedIn {
474 user: User { workspaces: Vec::new(), grants: Vec::new(), held: Vec::new(), ..user },
475 session_token: String::new(),
476 two_factor_challenge: Some(challenge),
477 }));
478 }
479 self.session_for(user).await
480 }
481
482 /// A new session for `user`, who has proved who they are in full.
483 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)484 // Whichever way it was proved, a deleted account starts none
485 // (account_deletion.rs).
486 if !self.account_live(&user.id).await? {
487 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Incorrect username or password."));
488 }
Webhooks: every event, to your own addresses, signed and retried489 let session_token = crypto::random_hex(32);
490 self.db
491 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look492 // Signing in is proof it is the person: see security.rs.
493 "INSERT INTO sessions (id, user_id, expires_at, authenticated_at) VALUES (?, ?, {}, {SQL_NOW})",
Webhooks: every event, to your own addresses, signed and retried494 sql_after(SESSION_TTL_SECONDS)
495 ))
496 .bind(&[
497 crypto::sha256_hex(&session_token).into(),
498 user.id.as_str().into(),
499 ])?
500 .run()
501 .await?;
502 Ok(Outcome::Ok(SignedIn {
503 user,
504 session_token,
Merge main (membership, two-factor, GitHub repo roles) into tokens505 two_factor_challenge: None,
Webhooks: every event, to your own addresses, signed and retried506 }))
507 }
508
509 async fn sign_out(&self, a: SessionArgs) -> Result<()> {
510 self.db
511 .prepare("DELETE FROM sessions WHERE id = ?")
512 .bind(&[crypto::sha256_hex(&a.session_token).into()])?
513 .run()
514 .await?;
515 Ok(())
516 }
517
518 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
519 self.find_user(
520 &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 managers521 "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 control522 users.avatar
Webhooks: every event, to your own addresses, signed and retried523 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)524 WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW} AND users.deleted_at IS NULL"
Webhooks: every event, to your own addresses, signed and retried525 ),
526 &crypto::sha256_hex(&a.session_token),
527 )
528 .await
529 }
530
531 async fn user_for_git_credentials(&self, a: GitCredentialsArgs) -> Result<Viewer> {
532 // Like GitHub, a token alone identifies its user.
533 if a.secret.starts_with(TOKEN_PREFIX) {
534 self.user_for_access_token(&a.secret).await
535 } else {
536 self.user_for_password(&a.username, &a.secret).await
537 }
538 }
539
540 async fn user_for_ssh_key(&self, a: FingerprintArgs) -> Result<Viewer> {
541 self.find_user(
542 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM ssh_keys
543 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)544 WHERE fingerprint = ? AND users.deleted_at IS NULL",
Webhooks: every event, to your own addresses, signed and retried545 &a.fingerprint,
546 )
547 .await
548 }
549
550 async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
551 self.find_public_user(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)552 // 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 managers553 "SELECT id, username, display_username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ? AND deleted_at IS NULL",
Webhooks: every event, to your own addresses, signed and retried554 &a.username.to_lowercase(),
555 )
556 .await
557 }
558
Inbox: threads, reasons, subscriptions and watching559 /// `notify_by_email`: an inbox item, emailed to the person it is for,
560 /// only at a confirmed address and only while they can still read the
561 /// repository it is about. Returns whether it was sent.
562 async fn notify_by_email(&self, a: g1t_contracts::inbox::NotifyByEmailArgs) -> Result<bool> {
563 #[derive(Deserialize)]
564 struct Address {
565 email: Option<String>,
566 }
567 let user = self
568 .find_user(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)569 "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 watching570 &a.username.to_lowercase(),
571 )
572 .await?;
573 let Some(user) = user.filter(|user| user.verified) else {
574 return Ok(false);
575 };
576 let readable: Vec<g1t_contracts::repos::Repo> = g1t_kit::call(
577 &self.env.service("REPOS")?,
578 "readable",
579 &g1t_contracts::repos::ReadableArgs {
580 ids: vec![a.repo_id.clone()],
581 viewer: Some(user.clone()),
582 },
583 )
584 .await?;
585 if readable.is_empty() {
586 return Ok(false);
587 }
588 let address = self
589 .db
590 .prepare("SELECT email FROM users WHERE id = ?")
591 .bind(&[user.id.as_str().into()])?
592 .first::<Address>(None)
593 .await?
594 .and_then(|row| row.email)
595 .filter(|email| !email.trim().is_empty());
596 let Some(address) = address else {
597 return Ok(false);
598 };
599 email::send_notification(&self.env, &address, &a).await?;
600 Ok(true)
601 }
602
Webhooks: every event, to your own addresses, signed and retried603 async fn usernames(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
604 #[derive(serde::Deserialize)]
605 struct Named {
606 id: String,
607 name: String,
608 }
609 let ids: Vec<String> = a.ids.into_iter().take(200).collect();
610 let mut names = std::collections::HashMap::new();
611 if ids.is_empty() {
612 return Ok(names);
613 }
614 let marks = vec!["?"; ids.len()].join(", ");
615 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| id.as_str().into()).collect();
616 for sql in [
617 format!("SELECT id, username AS name FROM users WHERE id IN ({marks})"),
618 format!("SELECT id, slug AS name FROM workspaces WHERE id IN ({marks})"),
619 ] {
620 for row in self.db.prepare(sql).bind(&bind)?.all().await?.results::<Named>()? {
621 names.insert(row.id, row.name);
622 }
623 }
624 Ok(names)
625 }
626
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)627 /// `users_for_audience`: the people behind these ids (at most 50), each
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.628 /// with their workspaces, roles, base permissions and repository
629 /// grants, under each workspace's policy, as a signed-in viewer would
630 /// have them. For the agents service, which answers only with what
631 /// every person who will read the answer may see
632 /// (docs.g1t.sh/guides/agent-access/, "What an agent can and can't
633 /// know"). Ids of no live account are left out, so the caller can tell
634 /// someone it could not resolve. Reached only by service binding.
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)635 async fn users_for_audience(&self, a: UsernamesArgs) -> Result<Vec<User>> {
636 let mut found = Vec::new();
637 for id in a.ids.iter().take(50) {
638 let user = self
639 .find_user(
640 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ? AND deleted_at IS NULL",
641 id,
642 )
643 .await?;
644 if let Some(user) = user {
645 found.push(user);
646 }
647 }
648 Ok(found)
649 }
650
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97651 /// `accounts`: the accounts behind these ids (at most 200), each with
652 /// its username and avatar, for lists that keep ids, such as who
653 /// 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 calendar654 /// `display_usernames`: each lowercased username's chosen case, for the
655 /// API, which shows it beside every username it answers with.
656 async fn display_usernames(&self, a: DisplayUsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
657 #[derive(serde::Deserialize)]
658 struct Row {
659 username: String,
660 display_username: String,
661 }
662 let mut names: Vec<String> = a.usernames.iter().map(|name| name.trim().to_lowercase()).filter(|name| !name.is_empty()).collect();
663 names.sort();
664 names.dedup();
665 names.truncate(200);
666 let mut found = std::collections::HashMap::new();
667 if names.is_empty() {
668 return Ok(found);
669 }
670 let marks = vec!["?"; names.len()].join(", ");
671 let bind: Vec<worker::wasm_bindgen::JsValue> = names.iter().map(|name| name.as_str().into()).collect();
672 let rows = self
673 .db
674 .prepare(format!(
675 "SELECT username, display_username FROM users WHERE username IN ({marks}) AND display_username IS NOT NULL AND deleted_at IS NULL"
676 ))
677 .bind(&bind)?
678 .all()
679 .await?
680 .results::<Row>()?;
681 for row in rows {
682 if row.display_username.eq_ignore_ascii_case(&row.username) && row.display_username != row.username {
683 found.insert(row.username, row.display_username);
684 }
685 }
686 Ok(found)
687 }
688
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97689 async fn accounts(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, g1t_contracts::accounts::EmailOwner>> {
690 #[derive(serde::Deserialize)]
691 struct Row {
692 id: String,
693 username: String,
694 avatar: Option<String>,
695 }
696 let ids: Vec<String> = a.ids.into_iter().take(200).collect();
697 let mut found = std::collections::HashMap::new();
698 if ids.is_empty() {
699 return Ok(found);
700 }
701 let marks = vec!["?"; ids.len()].join(", ");
702 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| id.as_str().into()).collect();
703 let rows = self
704 .db
705 .prepare(format!("SELECT id, username, avatar FROM users WHERE id IN ({marks})"))
706 .bind(&bind)?
707 .all()
708 .await?
709 .results::<Row>()?;
710 for row in rows {
711 found.insert(row.id.clone(), g1t_contracts::accounts::EmailOwner { id: row.id, username: row.username, avatar: row.avatar });
712 }
713 Ok(found)
714 }
715
Webhooks: every event, to your own addresses, signed and retried716 async fn list_ssh_keys(&self, a: UserArgs) -> Result<Vec<SshKey>> {
717 let rows = self
718 .db
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca719 .prepare("SELECT id, title, fingerprint, created_at, last_used_at FROM ssh_keys WHERE user_id = ? ORDER BY id")
Webhooks: every event, to your own addresses, signed and retried720 .bind(&[a.user.id.into()])?
721 .all()
722 .await?
723 .results::<KeyRow>()?;
724 Ok(rows.into_iter().map(SshKey::from).collect())
725 }
726
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge727 /// The account (user id) that registered each key, by fingerprint
728 /// (`SHA256:…`). At most 100; unknown keys are left out.
729 async fn ssh_key_owners(&self, a: SshKeyOwnersArgs) -> Result<std::collections::HashMap<String, String>> {
730 #[derive(serde::Deserialize)]
731 struct Row {
732 fingerprint: String,
733 user_id: String,
734 }
735 let fingerprints: Vec<&String> = a.fingerprints.iter().take(100).collect();
736 if fingerprints.is_empty() {
737 return Ok(std::collections::HashMap::new());
738 }
739 let marks = vec!["?"; fingerprints.len()].join(", ");
740 let binds: Vec<JsValue> = fingerprints.iter().map(|fingerprint| fingerprint.as_str().into()).collect();
741 Ok(self
742 .db
743 .prepare(format!("SELECT fingerprint, user_id FROM ssh_keys WHERE fingerprint IN ({marks})"))
744 .bind(&binds)?
745 .all()
746 .await?
747 .results::<Row>()?
748 .into_iter()
749 .map(|row| (row.fingerprint, row.user_id))
750 .collect())
751 }
752
Webhooks: every event, to your own addresses, signed and retried753 async fn add_ssh_key(&self, a: AddSshKeyArgs) -> Result<Outcome<SshKey>> {
754 let Some(key) = crypto::parse_ssh_key(&a.public_key) else {
755 return Ok(Outcome::fail(
756 FailureCode::Invalid,
757 "That is not a valid OpenSSH public key.",
758 ));
759 };
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca760 // Someone's SSH key, or a repository's deploy key (deploy_keys.rs).
761 if self.key_in_use(&key.fingerprint).await? {
762 return Ok(Outcome::fail(FailureCode::Conflict, g1t_contracts::deploy_keys::KEY_IN_USE));
Webhooks: every event, to your own addresses, signed and retried763 }
764 let now = now_ms();
765 let title = [a.title.trim(), key.comment.as_str(), "SSH key"]
766 .into_iter()
767 .find(|candidate| !candidate.is_empty())
768 .unwrap_or_default()
769 .to_owned();
770 let row = KeyRow {
771 id: new_id("key", now),
772 title,
773 fingerprint: key.fingerprint,
774 created_at: rfc3339(now),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca775 last_used_at: None,
Webhooks: every event, to your own addresses, signed and retried776 };
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca777 let inserted = self.db
Webhooks: every event, to your own addresses, signed and retried778 .prepare(
779 "INSERT INTO ssh_keys (id, user_id, title, public_key, fingerprint, created_at)
780 VALUES (?, ?, ?, ?, ?, ?)",
781 )
782 .bind(&[
783 row.id.as_str().into(),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca784 a.user.id.as_str().into(),
Webhooks: every event, to your own addresses, signed and retried785 row.title.as_str().into(),
786 key.public_key.into(),
787 row.fingerprint.as_str().into(),
788 row.created_at.as_str().into(),
789 ])?
790 .run()
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca791 .await;
792 // Added at the same moment elsewhere: the trigger or the unique
793 // index refused it.
794 if let Err(error) = inserted {
795 if self.key_in_use(&row.fingerprint).await? {
796 return Ok(Outcome::fail(FailureCode::Conflict, g1t_contracts::deploy_keys::KEY_IN_USE));
797 }
798 return Err(error);
799 }
Merge main (membership, two-factor, GitHub repo roles) into tokens800 let shown = format!("{} ({})", row.title, row.fingerprint);
801 self.log_security(&a.user.id, "ssh_key_added", Some(&shown), None).await;
802 self.audit_account(&a.user, "ssh_key.added", &format!("Added SSH key {shown}")).await;
Webhooks: every event, to your own addresses, signed and retried803 Ok(Outcome::Ok(row.into()))
804 }
805
Merge main (membership, two-factor, GitHub repo roles) into tokens806 /// Deletes one of the person's SSH keys.
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca807 async fn remove_ssh_key(&self, a: RemoveArgs) -> Result<()> {
Merge main (membership, two-factor, GitHub repo roles) into tokens808 #[derive(Deserialize)]
809 struct Removed {
810 title: String,
811 fingerprint: String,
812 }
813 let removed = self
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca814 .db
Merge main (membership, two-factor, GitHub repo roles) into tokens815 .prepare("DELETE FROM ssh_keys WHERE id = ? AND user_id = ? RETURNING title, fingerprint")
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca816 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?
Merge main (membership, two-factor, GitHub repo roles) into tokens817 .first::<Removed>(None)
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca818 .await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens819 if let Some(removed) = removed {
820 let shown = format!("{} ({})", removed.title, removed.fingerprint);
821 self.log_security(&a.user.id, "ssh_key_removed", Some(&shown), None).await;
822 self.audit_account(&a.user, "ssh_key.removed", &format!("Removed SSH key {shown}")).await;
823 }
Webhooks: every event, to your own addresses, signed and retried824 Ok(())
825 }
826}
827
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas828/// 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 member829/// 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)830/// and deleted workspaces and accounts past their restore window are purged
831/// (deletion.rs, account_deletion.rs).
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas832#[event(scheduled)]
833async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
834 let Ok(db) = env.d1("DB") else { return };
835 let identity = Identity { db, env };
836 if let Err(error) = identity.notify_staff_of_requests().await {
837 worker::console_error!("waitlist summary: {error}");
838 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member839 if let Err(error) = identity.purge_due_workspaces().await {
840 worker::console_error!("workspace purge: {error}");
841 }
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)842 // And deleted accounts past theirs (account_deletion.rs).
843 if let Err(error) = identity.purge_due_accounts().await {
844 worker::console_error!("account purge: {error}");
845 }
Merge main (membership, two-factor, GitHub repo roles) into tokens846 // Once: creators of repositories made before they got Admin (members.rs).
847 if let Err(error) = identity.backfill_creator_grants().await {
848 worker::console_error!("creator grants: {error}");
849 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas850}
851
Webhooks: every event, to your own addresses, signed and retried852#[event(fetch)]
853async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
854 let Some(method) = rpc_method(&request) else {
855 return Response::error("Not found", 404);
856 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents857 // A replica near the caller when it asks for one (crates/kit/src/d1.rs).
858 let (db, served) = g1t_kit::d1::open(&env, "DB", &request)?;
Webhooks: every event, to your own addresses, signed and retried859 let body: serde_json::Value = request.json().await?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents860 let identity = Identity { db, env };
Webhooks: every event, to your own addresses, signed and retried861
Fast pages, required checks on the branch, self-hosted runners, honest incidents862 let answered = match method.as_str() {
Search across all of g1t, Explore, and a command palette863 "register" => {
864 let outcome = identity.register(args(body)?).await?;
865 if let Outcome::Ok(signed_in) = &outcome {
866 identity.announce_user(&signed_in.user.username, Some(&signed_in.user.id)).await;
867 }
868 reply(&outcome)
869 }
Webhooks: every event, to your own addresses, signed and retried870 "sign_in" => reply(&identity.sign_in(args(body)?).await?),
Search across all of g1t, Explore, and a command palette871 "create_workspace" => {
872 let outcome = identity.create_workspace(args(body)?).await?;
873 if let Outcome::Ok(workspace) = &outcome {
874 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
875 }
876 reply(&outcome)
877 }
Webhooks: every event, to your own addresses, signed and retried878 "get_workspace" => reply(&identity.get_workspace(args(body)?).await?),
879 "list_members" => reply(&identity.list_members(args(body)?).await?),
880 "add_member" => reply(&identity.add_member(args(body)?).await?),
881 "remove_member" => reply(&identity.remove_member(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens882 // Owners, roles, leaving and member privileges; see members.rs.
883 "update_member" => reply(&identity.update_member(args(body)?).await?),
884 "transfer_ownership" => reply(&identity.transfer_ownership(args(body)?).await?),
885 "leave_workspace" => reply(&identity.leave_workspace(args(body)?).await?),
886 "set_member_privileges" => reply(&identity.set_member_privileges(args(body)?).await?),
887 "set_two_factor_requirement" => reply(&identity.set_two_factor_requirement(args(body)?).await?),
888 "grant_creator" => reply(&identity.grant_creator(args(body)?).await?),
Search across all of g1t, Explore, and a command palette889 "update_workspace" => {
890 let outcome = identity.update_workspace(args(body)?).await?;
891 if let Outcome::Ok(workspace) = &outcome {
892 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
893 }
894 reply(&outcome)
895 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains896 "rename_workspace" => reply(&identity.rename_workspace(args(body)?).await?),
897 "check_workspace_rename" => reply(&identity.check_workspace_rename(args(body)?).await?),
898 "resolve_slug" => reply(&identity.resolve_slug(args(body)?).await?),
Merge branch 'worktree-agent-a8385d293d42c913a'899 "resolve_alias" => reply(&identity.resolve_alias(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look900 "check_workspace_deletion" => reply(&identity.check_workspace_deletion(args(body)?).await?),
901 "delete_workspace" => reply(&identity.delete_workspace(args(body)?).await?),
902 "transfer_repo_scopes" => reply(&identity.transfer_repo_scopes(args(body)?).await?),
Search across all of g1t, Explore, and a command palette903 "set_workspace_avatar" => {
904 let outcome = identity.set_workspace_avatar(args(body)?).await?;
905 if let Outcome::Ok(workspace) = &outcome {
906 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
907 }
908 reply(&outcome)
909 }
910 "set_user_avatar" => {
911 let a: SetUserAvatarArgs = args(body)?;
912 let (username, id) = (a.user.username.clone(), a.user.id.clone());
913 let outcome = identity.set_user_avatar(a).await?;
914 if matches!(outcome, Outcome::Ok(_)) {
915 identity.announce_user(&username, Some(&id)).await;
916 }
917 reply(&outcome)
918 }
Webhooks: every event, to your own addresses, signed and retried919 "list_workspace_tokens" => reply(&identity.list_workspace_tokens(args(body)?).await?),
920 "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 look921 // Signing in with GitHub; see github.rs.
922 "github_enabled" => reply(&identity.github_enabled()),
923 "github_start" => reply(&identity.github_start(args(body)?).await?),
924 "github_finish" => reply(&identity.github_finish(args(body)?).await?),
925 "github_pending" => reply(&identity.github_pending(args(body)?).await?),
926 "github_sign_up" => reply(&identity.github_sign_up(args(body)?).await?),
927 "github_claim" => reply(&identity.github_claim(args(body)?).await?),
928 "github_account" => reply(&identity.github_account(args(body)?).await?),
929 "github_unlink" => reply(&identity.github_unlink(args(body)?).await?),
930 "github_user_token" => reply(&identity.github_user_token(args(body)?).await?),
931 "github_revoked" => reply(&identity.github_revoked(args(body)?).await?),
932 "github_usernames" => reply(&identity.github_usernames(args(body)?).await?),
Webhooks: every event, to your own addresses, signed and retried933 "oauth_authorize" => reply(&identity.oauth_authorize(args(body)?).await?),
934 "oauth_exchange" => reply(&identity.oauth_exchange(args(body)?).await?),
935 "oauth_refresh" => reply(&identity.oauth_refresh(args(body)?).await?),
936 "list_oauth_grants" => reply(&identity.list_oauth_grants(args(body)?).await?),
937 "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 step938 "update_oauth_grant" => reply(&identity.update_oauth_grant(args(body)?).await?),
Webhooks: every event, to your own addresses, signed and retried939 "device_start" => reply(&identity.device_start(args(body)?).await?),
940 "device_lookup" => reply(&identity.device_lookup(args(body)?).await?),
941 "device_resolve" => reply(&identity.device_resolve(args(body)?).await?),
942 "device_claim" => reply(&identity.device_claim(args(body)?).await?),
943 "resend_verification" => reply(&identity.resend_verification(args(body)?).await?),
944 "verify_email" => reply(&identity.verify_email(args(body)?).await?),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)945 "confirm_email_code" => reply(&identity.confirm_email_code(args(body)?).await?),
946 "change_pending_email" => reply(&identity.change_pending_email(args(body)?).await?),
Webhooks: every event, to your own addresses, signed and retried947 "request_password_reset" => reply(&identity.request_password_reset(args(body)?).await?),
948 "reset_password" => reply(&identity.reset_password(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look949 // A person's email addresses; see emails.rs and security.rs.
950 "list_emails" => reply(&identity.list_emails(args(body)?).await?),
951 "add_email" => reply(&identity.add_email(args(body)?).await?),
952 "remove_email" => reply(&identity.remove_email(args(body)?).await?),
953 "resend_email_verification" => reply(&identity.resend_email_verification(args(body)?).await?),
954 "update_email_settings" => reply(&identity.update_email_settings(args(body)?).await?),
955 "reauthenticate" => reply(&identity.reauthenticate(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens956 // Two-factor authentication; see two_factor.rs.
957 "two_factor_status" => reply(&identity.two_factor_status(args(body)?).await?),
958 "two_factor_start" => reply(&identity.two_factor_start(args(body)?).await?),
959 "two_factor_enable" => reply(&identity.two_factor_enable(args(body)?).await?),
960 "two_factor_disable" => reply(&identity.two_factor_disable(args(body)?).await?),
961 "two_factor_recovery_codes" => reply(&identity.two_factor_recovery_codes(args(body)?).await?),
962 "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 look963 "security_log" => reply(&identity.security_log(args(body)?).await?),
964 "email_owners" => reply(&identity.email_owners(args(body)?).await?),
965 "commit_identity" => reply(&identity.commit_identity(args(body)?).await?),
966 "push_email_guard" => reply(&identity.push_email_guard(args(body)?).await?),
967 "admin_user" => reply(&identity.admin_user(args(body)?).await?),
968 "admin_remove_email" => reply(&identity.admin_remove_email(args(body)?).await?),
Webhooks: every event, to your own addresses, signed and retried969 "sign_out" => reply(&identity.sign_out(args(body)?).await?),
970 "user_for_session" => reply(&identity.user_for_session(args(body)?).await?),
971 "user_for_git_credentials" => reply(&identity.user_for_git_credentials(args(body)?).await?),
972 "user_for_access_token" => {
973 let a: TokenArgs = args(body)?;
974 reply(&identity.user_for_access_token(&a.token).await?)
975 }
976 "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?),
977 "user_by_username" => reply(&identity.user_by_username(args(body)?).await?),
978 "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 calendar979 // The API: each username's chosen case, shown beside it.
980 "display_usernames" => reply(&identity.display_usernames(args(body)?).await?),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97981 "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)982 "users_for_audience" => reply(&identity.users_for_audience(args(body)?).await?),
Inbox: threads, reasons, subscriptions and watching983 "notify_by_email" => reply(&identity.notify_by_email(args(body)?).await?),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains984 "profile" => reply(&identity.profile(args(body)?).await?),
Search across all of g1t, Explore, and a command palette985 "update_profile" => {
986 let outcome = identity.update_profile(args(body)?).await?;
987 if let Outcome::Ok(profile) = &outcome {
988 identity.announce_user(&profile.username, None).await;
989 }
990 reply(&outcome)
991 }
992 "directory" => reply(&identity.directory(args(body)?).await?),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains993 "profile_workspaces" => reply(&identity.profile_workspaces(args(body)?).await?),
The apps you pin to your dock are kept with your account, per workspace and in your order, so the dock is the same on every device: identity keeps them in dock_pins and answers dock_pins and set_dock_pins, the dock reads them with the rest of the page, pins kept only on this device carry over with your first change, this device's copy still draws the dock when identity can't be reached, a pin that can't be saved says so, and they go when you or the workspace do; the workspaces guide says how.994 // Each person's dock pins, per workspace; see dock.rs.
995 "dock_pins" => reply(&identity.dock_pins(args(body)?).await?),
996 "set_dock_pins" => reply(&identity.set_dock_pins(args(body)?).await?),
Webhooks: every event, to your own addresses, signed and retried997 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge998 // Services only: who registered each key, for verifying commit
999 // signatures (repos' signatures.rs).
1000 "ssh_key_owners" => reply(&identity.ssh_key_owners(args(body)?).await?),
Webhooks: every event, to your own addresses, signed and retried1001 "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1002 "remove_ssh_key" => reply(&identity.remove_ssh_key(args(body)?).await?),
1003 // A repository's deploy keys, and who an SSH key signs in as; see
1004 // deploy_keys.rs.
1005 "list_deploy_keys" => reply(&identity.list_deploy_keys(args(body)?).await?),
1006 "get_deploy_key" => reply(&identity.get_deploy_key(args(body)?).await?),
1007 "add_deploy_key" => reply(&identity.add_deploy_key(args(body)?).await?),
1008 "remove_deploy_key" => reply(&identity.remove_deploy_key(args(body)?).await?),
1009 "principal_for_ssh_key" => reply(&identity.principal_for_ssh_key(args(body)?).await?),
Webhooks: every event, to your own addresses, signed and retried1010 "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?),
1011 "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 managers1012 // Making and changing a person's or a workspace's token, and
1013 // workspaces' rules for tokens; see token_reach.rs.
1014 "create_token" => reply(&identity.create_token(args(body)?).await?),
1015 "update_token" => reply(&identity.update_token(args(body)?).await?),
Fine-grained personal tokens, workspace token rules and approvals in identity1016 "get_token_policy" => reply(&identity.get_token_policy(args(body)?).await?),
1017 "set_token_policy" => reply(&identity.set_token_policy(args(body)?).await?),
1018 "list_member_tokens" => reply(&identity.list_member_tokens(args(body)?).await?),
1019 "review_token_request" => reply(&identity.review_token_request(args(body)?).await?),
1020 "revoke_member_token" => reply(&identity.revoke_member_token(args(body)?).await?),
Webhooks: every event, to your own addresses, signed and retried1021 "create_agent_token" => reply(&identity.create_agent_token(args(body)?).await?),
1022 "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 API1023 "create_run_credential" => reply(&identity.create_run_credential(args(body)?).await?),
1024 "bind_run_credentials" => reply(&identity.bind_run_credentials(args(body)?).await?),
1025 "revoke_run_credentials" => reply(&identity.revoke_run_credentials(args(body)?).await?),
Merge branch 'worktree-agent-a3abfcce648e87dca'1026 "create_job_token" => reply(&identity.create_job_token(args(body)?).await?),
1027 "revoke_job_tokens" => reply(&identity.revoke_job_tokens(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens1028 "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 look1029 // Invites and the waitlist; see invites.rs.
1030 "registration" => reply(&identity.registration_mode()),
1031 "list_invites" => reply(&identity.list_invites(args(body)?).await?),
1032 "create_invite" => reply(&identity.create_invite(args(body)?).await?),
1033 "revoke_invite" => reply(&identity.revoke_invite(args(body)?).await?),
1034 "check_invite" => reply(&identity.check_invite(args(body)?).await?),
1035 "accept_invite" => reply(&identity.accept_invite(args(body)?).await?),
1036 "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)1037 "list_invitations" => reply(&identity.list_invitations(args(body)?).await?),
1038 "accept_invitation" => reply(&identity.accept_invitation(args(body)?).await?),
1039 "decline_invitation" => reply(&identity.decline_invitation(args(body)?).await?),
1040 "find_people" => reply(&identity.find_people(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1041 "workspace_invites" => reply(&identity.workspace_invites(args(body)?).await?),
1042 "revoke_workspace_invite" => reply(&identity.revoke_workspace_invite(args(body)?).await?),
1043 "request_access" => reply(&identity.request_access(args(body)?).await?),
1044 // Who has access to a repository; see access.rs.
1045 "repo_access" => reply(&identity.repo_access(args(body)?).await?),
1046 "add_collaborator" => reply(&identity.add_collaborator(args(body)?).await?),
1047 "set_collaborator_role" => reply(&identity.set_collaborator_role(args(body)?).await?),
1048 "remove_collaborator" => reply(&identity.remove_collaborator(args(body)?).await?),
1049 "collaborator_permission" => reply(&identity.collaborator_permission(args(body)?).await?),
1050 "my_repo_invitations" => reply(&identity.my_repo_invitations(args(body)?).await?),
1051 "respond_repo_invitation" => reply(&identity.respond_repo_invitation(args(body)?).await?),
1052 "revoke_repo_invitation" => reply(&identity.revoke_repo_invitation(args(body)?).await?),
1053 "set_base_permission" => reply(&identity.set_base_permission(args(body)?).await?),
Merge branch 'worktree-agent-a2013627e5ea4ab13'1054 // Where a workspace keeps its repositories' git data (EU residency).
1055 "workspace_residency" => reply(&identity.workspace_residency(args(body)?).await?),
1056 "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 look1057 "outside_collaborators" => reply(&identity.outside_collaborators(args(body)?).await?),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1058 "forget_repo_access" => {
1059 let a: g1t_contracts::access::ForgetRepoAccessArgs = args(body)?;
1060 // A purged repository's deploy keys go with its access.
1061 identity.forget_deploy_keys(&a.repo_id).await?;
1062 reply(&identity.forget_repo_access(a).await?)
1063 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1064 // Teams (teams.rs).
1065 "list_teams" => reply(&identity.list_teams(args(body)?).await?),
1066 "get_team" => reply(&identity.get_team(args(body)?).await?),
1067 "create_team" => reply(&identity.create_team(args(body)?).await?),
Merge branch 'worktree-agent-ad7c6d88d93adc817'1068 "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 bar1069 "update_team" => reply(&identity.update_team(args(body)?).await?),
1070 "delete_team" => reply(&identity.delete_team(args(body)?).await?),
1071 "team_members" => reply(&identity.team_members(args(body)?).await?),
1072 "set_team_member" => reply(&identity.set_team_member(args(body)?).await?),
1073 "remove_team_member" => reply(&identity.remove_team_member(args(body)?).await?),
1074 "child_teams" => reply(&identity.child_teams(args(body)?).await?),
1075 "team_repos" => reply(&identity.team_repos(args(body)?).await?),
1076 "set_team_repo" => reply(&identity.set_team_repo(args(body)?).await?),
1077 "remove_team_repo" => reply(&identity.remove_team_repo(args(body)?).await?),
1078 "user_teams" => reply(&identity.user_teams(args(body)?).await?),
1079 "team_memberships" => reply(&identity.team_memberships(args(body)?).await?),
1080 "resolve_teams" => reply(&identity.resolve_teams(args(body)?).await?),
1081 "resolve_owners" => reply(&identity.resolve_owners(args(body)?).await?),
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1082 // Staff only: sudo.g1t.sh, over its service binding. See admin.rs.
1083 "notify_owners" => reply(&identity.notify_owners(args(body)?).await?),
1084 "admin_workspaces" => reply(&identity.admin_workspaces(args(body)?).await?),
1085 "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1086 "admin_waitlist" => reply(&identity.admin_waitlist(args(body)?).await?),
1087 "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 quotas1088 "admin_waitlist_pending" => reply(&identity.admin_waitlist_pending().await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1089 "admin_invites" => reply(&identity.admin_invites(args(body)?).await?),
1090 "admin_revoke_invite" => reply(&identity.admin_revoke_invite(args(body)?).await?),
1091 "admin_mint_invite" => reply(&identity.admin_mint_invite(args(body)?).await?),
1092 "admin_grant_invites" => reply(&identity.admin_grant_invites(args(body)?).await?),
1093 "admin_invite_tree" => reply(&identity.admin_invite_tree(args(body)?).await?),
1094 "admin_workspace_invites" => reply(&identity.admin_workspace_invites(args(body)?).await?),
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)1095 // Shared invite links for a group; see shared_invites.rs.
1096 "admin_shared_invites" => reply(&identity.admin_shared_invites().await?),
1097 "admin_create_shared_invite" => reply(&identity.admin_create_shared_invite(args(body)?).await?),
1098 "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 member1099 // Deleted workspaces, restored or purged by staff; see deletion.rs.
1100 "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?),
1101 "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?),
1102 "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)1103 // Deleting accounts (account_deletion.rs): the person from the
1104 // site, staff from sudo. There is no API route for it.
1105 "check_account_deletion" => reply(&identity.check_account_deletion(args(body)?).await?),
1106 "delete_account" => reply(&identity.delete_account(args(body)?).await?),
1107 "admin_delete_account" => reply(&identity.admin_delete_account(args(body)?).await?),
1108 "admin_deleted_accounts" => reply(&identity.admin_deleted_accounts().await?),
1109 "admin_restore_account" => reply(&identity.admin_restore_account(args(body)?).await?),
1110 "admin_purge_account" => reply(&identity.admin_purge_account(args(body)?).await?),
Merge branch 'worktree-agent-a8385d293d42c913a'1111 // Workspace aliases, set by staff only; see aliases.rs.
1112 "admin_aliases" => reply(&identity.admin_aliases().await?),
1113 "admin_set_alias" => reply(&identity.admin_set_alias(args(body)?).await?),
1114 "admin_remove_alias" => reply(&identity.admin_remove_alias(args(body)?).await?),
Webhooks: every event, to your own addresses, signed and retried1115 _ => Response::error("Unknown method", 404),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1116 };
1117 served.finish(answered)
Webhooks: every event, to your own addresses, signed and retried1118}
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1119
1120#[cfg(test)]
1121mod register_tests {
1122 use super::*;
1123
1124 #[test]
1125 fn nobody_registers_as_g1t() {
1126 // What register checks the username with, whatever its case.
1127 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 managers1128 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-agent1129 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1130 assert_eq!(claimable_username("ana").map(|name| name.canonical).as_deref(), Some("ana"));
1131 }
1132
1133 #[test]
1134 fn a_username_keeps_the_case_it_was_chosen_in() {
1135 let name = claimable_username("Ana-Lopez").unwrap();
1136 assert_eq!(name.canonical, "ana-lopez");
1137 assert_eq!(name.display_if_cased(), Some("Ana-Lopez"));
1138 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-agent1139 }
1140}

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