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 retried | 1 | //! 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 look | 6 | mod access; |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 7 | mod account_deletion; |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 8 | mod admin; |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 9 | mod aliases; |
| Workspace names and icons, and a component kit for every control | 10 | mod avatars; |
| Webhooks: every event, to your own addresses, signed and retried | 11 | mod crypto; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 12 | mod deletion; |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 13 | mod deploy_keys; |
| Webhooks: every event, to your own addresses, signed and retried | 14 | mod device; |
| Search across all of g1t, Explore, and a command palette | 15 | mod 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. | 16 | mod dock; |
| Webhooks: every event, to your own addresses, signed and retried | 17 | mod email; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 18 | mod emails; |
| 19 | mod github; | |
| 20 | mod invites; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 21 | mod members; |
| Webhooks: every event, to your own addresses, signed and retried | 22 | mod oauth; |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 23 | mod paid; |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 24 | mod profiles; |
| 25 | mod rename; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 26 | mod job_tokens; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 27 | mod run_credentials; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 28 | mod security; |
| Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038) | 29 | mod shared_invites; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 30 | mod teams; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 31 | mod throttle; |
| Fine-grained personal tokens, workspace token rules and approvals in identity | 32 | mod token_reach; |
| Webhooks: every event, to your own addresses, signed and retried | 33 | mod tokens; |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 34 | mod two_factor; |
| Webhooks: every event, to your own addresses, signed and retried | 35 | mod workspaces; |
| 36 | ||
| 37 | use g1t_contracts::identity::*; | |
| 38 | use 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 managers | 39 | use g1t_contracts::{FailureCode, Outcome, User, Viewer, claimable_username, new_id}; |
| Webhooks: every event, to your own addresses, signed and retried | 40 | use g1t_kit::{args, now_ms, reply, rpc_method}; |
| 41 | use serde::Deserialize; | |
| 42 | use tokens::TOKEN_PREFIX; | |
| 43 | use worker::wasm_bindgen::JsValue; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 44 | use worker::{Context, D1Database, Env, Request, Response, Result, ScheduleContext, ScheduledEvent, event}; |
| Webhooks: every event, to your own addresses, signed and retried | 45 | |
| 46 | const SESSION_TTL_SECONDS: u64 = 30 * 24 * 60 * 60; | |
| 47 | const RESET_TTL_SECONDS: u64 = 60 * 60; | |
| 48 | const MIN_PASSWORD_LENGTH: usize = 10; | |
| 49 | const 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 managers | 52 | /// What a username may be, as registration and GitHub sign-up say when one is refused. |
| 53 | pub(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 retried | 56 | #[derive(Deserialize)] |
| 57 | struct 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 managers | 60 | /// 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 retried | 63 | verified: u8, |
| Workspace names and icons, and a component kit for every control | 64 | /// 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 retried | 67 | } |
| 68 | ||
| 69 | impl 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 managers | 74 | display_username: row.display_username, |
| Webhooks: every event, to your own addresses, signed and retried | 75 | verified: row.verified != 0, |
| Workspace names and icons, and a component kit for every control | 76 | avatar: row.avatar, |
| Webhooks: every event, to your own addresses, signed and retried | 77 | ..User::default() |
| 78 | } | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | #[derive(Deserialize)] | |
| 83 | struct 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)] | |
| 92 | struct TokenOwner { | |
| 93 | id: String, | |
| 94 | username: String, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 95 | /// 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 retried | 99 | } |
| 100 | ||
| 101 | #[derive(Deserialize)] | |
| 102 | struct KeyRow { | |
| 103 | id: String, | |
| 104 | title: String, | |
| 105 | fingerprint: String, | |
| 106 | created_at: String, | |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 107 | #[serde(default)] |
| 108 | last_used_at: Option<String>, | |
| Webhooks: every event, to your own addresses, signed and retried | 109 | } |
| 110 | ||
| 111 | impl 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-a16631325aecf58ca | 118 | last_used_at: row.last_used_at, |
| Webhooks: every event, to your own addresses, signed and retried | 119 | } |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | struct Identity { | |
| 124 | db: D1Database, | |
| 125 | env: Env, | |
| 126 | } | |
| 127 | ||
| 128 | impl 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 tokens | 147 | let memberships = self.memberships_and_policies(&user.id).await?; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 148 | // 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 tokens | 150 | // 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 retried | 155 | 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 look | 170 | "SELECT users.id, users.username, email_tokens.email_id FROM email_tokens |
| Webhooks: every event, to your own addresses, signed and retried | 171 | 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 retried | 173 | 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 look | 179 | // 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 retried | 182 | self.db |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 183 | .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 retried | 192 | .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 look | 199 | 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 retried | 203 | } |
| 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 retried | 208 | 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 retried | 215 | } |
| 216 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 217 | /// 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 retried | 219 | 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 look | 220 | 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 exists | 226 | // 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 look | 240 | 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 retried | 253 | .await?; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 254 | 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 retried | 263 | } |
| 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 exists | 264 | Ok(()) |
| Webhooks: every event, to your own addresses, signed and retried | 265 | } |
| 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 look | 278 | .prepare("UPDATE users SET password_hash = ? WHERE id = ?") |
| Webhooks: every event, to your own addresses, signed and retried | 279 | .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 look | 285 | // 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 retried | 290 | 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 look | 295 | 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 retried | 305 | 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 look | 308 | verified, |
| Webhooks: every event, to your own addresses, signed and retried | 309 | ..User::default() |
| 310 | })) | |
| 311 | } | |
| 312 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 313 | /// 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 look | 328 | )) |
| 329 | .bind(&[JsValue::from(value)])? | |
| Webhooks: every event, to your own addresses, signed and retried | 330 | .first::<UserRow>(None) |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 331 | .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 tokens | 367 | /// 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 look | 370 | 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 tokens | 372 | 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 retried | 377 | 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 managers | 381 | // 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 retried | 385 | 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 look | 387 | 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-agent | 392 | 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 managers | 393 | return invalid(USERNAME_RULES); |
| Webhooks: every event, to your own addresses, signed and retried | 394 | } |
| 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 look | 409 | // 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 retried | 411 | .prepare( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 412 | "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 retried | 414 | 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 look | 423 | // 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 retried | 426 | 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 look | 431 | 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 managers | 435 | 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 look | 436 | 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 confirm | 440 | email_proof: a.email_proof.as_deref(), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 441 | 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 retried | 447 | }; |
| 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 quotas | 450 | 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 quotas | 452 | { |
| 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 retried | 454 | } |
| 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 look | 459 | 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 retried | 462 | }; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 463 | let user = self.with_workspaces(Some(user)).await?.unwrap_or_default(); |
| Webhooks: every event, to your own addresses, signed and retried | 464 | self.start_session(user).await |
| 465 | } | |
| 466 | ||
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 467 | /// 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 retried | 470 | async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> { |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 471 | 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 retried | 489 | 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 look | 492 | // 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 retried | 494 | 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 tokens | 505 | two_factor_challenge: None, |
| Webhooks: every event, to your own addresses, signed and retried | 506 | })) |
| 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 managers | 521 | "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 control | 522 | users.avatar |
| Webhooks: every event, to your own addresses, signed and retried | 523 | 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 retried | 525 | ), |
| 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 retried | 545 | &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 managers | 553 | "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 retried | 554 | &a.username.to_lowercase(), |
| 555 | ) | |
| 556 | .await | |
| 557 | } | |
| 558 | ||
| Inbox: threads, reasons, subscriptions and watching | 559 | /// `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 watching | 570 | &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 retried | 603 | 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-a69aeabc4b0deeb97 | 651 | /// `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 calendar | 654 | /// `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-a69aeabc4b0deeb97 | 689 | 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 retried | 716 | 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-a16631325aecf58ca | 719 | .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 retried | 720 | .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 merge | 727 | /// 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 retried | 753 | 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-a16631325aecf58ca | 760 | // 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 retried | 763 | } |
| 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-a16631325aecf58ca | 775 | last_used_at: None, |
| Webhooks: every event, to your own addresses, signed and retried | 776 | }; |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 777 | let inserted = self.db |
| Webhooks: every event, to your own addresses, signed and retried | 778 | .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-a16631325aecf58ca | 784 | a.user.id.as_str().into(), |
| Webhooks: every event, to your own addresses, signed and retried | 785 | 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-a16631325aecf58ca | 791 | .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 tokens | 800 | 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 retried | 803 | Ok(Outcome::Ok(row.into())) |
| 804 | } | |
| 805 | ||
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 806 | /// Deletes one of the person's SSH keys. |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 807 | async fn remove_ssh_key(&self, a: RemoveArgs) -> Result<()> { |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 808 | #[derive(Deserialize)] |
| 809 | struct Removed { | |
| 810 | title: String, | |
| 811 | fingerprint: String, | |
| 812 | } | |
| 813 | let removed = self | |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 814 | .db |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 815 | .prepare("DELETE FROM ssh_keys WHERE id = ? AND user_id = ? RETURNING title, fingerprint") |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 816 | .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])? |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 817 | .first::<Removed>(None) |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 818 | .await?; |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 819 | 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 retried | 824 | Ok(()) |
| 825 | } | |
| 826 | } | |
| 827 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 828 | /// 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 member | 829 | /// 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 quotas | 832 | #[event(scheduled)] |
| 833 | async 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 member | 839 | 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 tokens | 846 | // 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 quotas | 850 | } |
| 851 | ||
| Webhooks: every event, to your own addresses, signed and retried | 852 | #[event(fetch)] |
| 853 | async 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 incidents | 857 | // 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 retried | 859 | let body: serde_json::Value = request.json().await?; |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 860 | let identity = Identity { db, env }; |
| Webhooks: every event, to your own addresses, signed and retried | 861 | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 862 | let answered = match method.as_str() { |
| Search across all of g1t, Explore, and a command palette | 863 | "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 retried | 870 | "sign_in" => reply(&identity.sign_in(args(body)?).await?), |
| Search across all of g1t, Explore, and a command palette | 871 | "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 retried | 878 | "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 tokens | 882 | // 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 palette | 889 | "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 domains | 896 | "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 look | 900 | "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 palette | 903 | "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 retried | 919 | "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 look | 921 | // 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 retried | 933 | "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 step | 938 | "update_oauth_grant" => reply(&identity.update_oauth_grant(args(body)?).await?), |
| Webhooks: every event, to your own addresses, signed and retried | 939 | "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 retried | 947 | "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 look | 949 | // 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 tokens | 956 | // 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 look | 963 | "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 retried | 969 | "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 calendar | 979 | // 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-a69aeabc4b0deeb97 | 981 | "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 watching | 983 | "notify_by_email" => reply(&identity.notify_by_email(args(body)?).await?), |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 984 | "profile" => reply(&identity.profile(args(body)?).await?), |
| Search across all of g1t, Explore, and a command palette | 985 | "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 domains | 993 | "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 retried | 997 | "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?), |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 998 | // 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 retried | 1001 | "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?), |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 1002 | "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 retried | 1010 | "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 managers | 1012 | // 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 identity | 1016 | "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 retried | 1021 | "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 API | 1023 | "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 tokens | 1028 | "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 look | 1029 | // 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 look | 1041 | "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 look | 1057 | "outside_collaborators" => reply(&identity.outside_collaborators(args(body)?).await?), |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 1058 | "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 bar | 1064 | // 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 bar | 1069 | "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 workspace | 1082 | // 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 look | 1086 | "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 quotas | 1088 | "admin_waitlist_pending" => reply(&identity.admin_waitlist_pending().await?), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1089 | "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 member | 1099 | // 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 retried | 1115 | _ => Response::error("Unknown method", 404), |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1116 | }; |
| 1117 | served.finish(answered) | |
| Webhooks: every event, to your own addresses, signed and retried | 1118 | } |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 1119 | |
| 1120 | #[cfg(test)] | |
| 1121 | mod 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 managers | 1128 | 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-agent | 1129 | } |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 1130 | 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-agent | 1139 | } |
| 1140 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.