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.
| API reference examples use projects | 1 | //! Workspaces and their members. |
| 2 | //! | |
| 3 | //! A workspace owns repositories and is the first segment of their URLs. | |
| 4 | //! There is one kind: a person's own space and a company's differ only in | |
| 5 | //! how many members they have. Nothing can be created outside one. | |
| 6 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 7 | use g1t_contracts::access::BasePermission; |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 8 | use g1t_contracts::audit::Surface; |
| API reference examples use projects | 9 | use g1t_contracts::identity::*; |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 10 | use g1t_contracts::members::{LeaveWorkspaceArgs, last_owner_refusal}; |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 11 | use g1t_contracts::teams::TeamCreation; |
| API reference examples use projects | 12 | use g1t_contracts::time::rfc3339; |
| 13 | use g1t_contracts::{ | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 14 | FailureCode, MemberPrivileges, Membership, OrgRole, Outcome, PrincipalKind, Role, User, claimable_namespace, new_id, |
| API reference examples use projects | 15 | }; |
| 16 | use g1t_kit::now_ms; | |
| 17 | use serde::Deserialize; | |
| 18 | use worker::Result; | |
| 19 | ||
| 20 | use crate::Identity; | |
| 21 | ||
| 22 | /// Enough for a person and their teams; stops one account claiming names in | |
| 23 | /// bulk. | |
| 24 | const MAX_WORKSPACES_PER_USER: usize = 10; | |
| 25 | ||
| 26 | const MAX_NAME_LENGTH: usize = 80; | |
| 27 | const MAX_DESCRIPTION_LENGTH: usize = 160; | |
| 28 | ||
| 29 | const WORKSPACE_COLUMNS: &str = "workspaces.id, workspaces.slug, workspaces.name, | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 30 | workspaces.description, workspaces.avatar, workspaces.created_at, workspaces.base_permission, workspaces.team_creation, |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 31 | workspaces.member_privileges, workspaces.require_two_factor, |
| API reference examples use projects | 32 | (SELECT count(*) FROM workspace_members |
| 33 | WHERE workspace_members.workspace_id = workspaces.id) AS member_count"; | |
| 34 | ||
| 35 | #[derive(Deserialize)] | |
| 36 | struct WorkspaceRow { | |
| 37 | id: String, | |
| 38 | slug: String, | |
| 39 | name: String, | |
| 40 | description: Option<String>, | |
| Workspace names and icons, and a component kit for every control | 41 | avatar: Option<String>, |
| API reference examples use projects | 42 | created_at: String, |
| 43 | member_count: u32, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 44 | #[serde(default)] |
| 45 | base_permission: Option<String>, | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 46 | #[serde(default)] |
| 47 | team_creation: Option<String>, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 48 | #[serde(default)] |
| 49 | member_privileges: Option<String>, | |
| 50 | #[serde(default)] | |
| 51 | require_two_factor: Option<u8>, | |
| API reference examples use projects | 52 | } |
| 53 | ||
| 54 | impl From<WorkspaceRow> for Workspace { | |
| 55 | fn from(row: WorkspaceRow) -> Self { | |
| 56 | Workspace { | |
| 57 | id: row.id, | |
| 58 | slug: row.slug, | |
| 59 | name: row.name, | |
| 60 | description: row.description, | |
| 61 | created_at: row.created_at, | |
| 62 | member_count: row.member_count, | |
| Workspace names and icons, and a component kit for every control | 63 | avatar: row.avatar, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 64 | base_permission: row |
| 65 | .base_permission | |
| 66 | .as_deref() | |
| 67 | .and_then(BasePermission::parse) | |
| 68 | .unwrap_or_default(), | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 69 | team_creation: row |
| 70 | .team_creation | |
| 71 | .as_deref() | |
| 72 | .and_then(TeamCreation::parse) | |
| 73 | .unwrap_or_default(), | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 74 | privileges: MemberPrivileges::from_stored(row.member_privileges.as_deref()), |
| 75 | two_factor_requirement_enabled: row.require_two_factor.unwrap_or(0) != 0, | |
| API reference examples use projects | 76 | } |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 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) | 80 | /// The name a new account's own workspace takes: its username, when that |
| 81 | /// can name a workspace at all (g1t's own names cannot). | |
| 82 | pub(crate) fn own_workspace_slug(username: &str) -> Option<String> { | |
| 83 | claimable_namespace(username) | |
| 84 | } | |
| 85 | ||
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 86 | /// One of a person's memberships, as stored. |
| API reference examples use projects | 87 | #[derive(Deserialize)] |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 88 | struct MembershipRow { |
| 89 | slug: String, | |
| API reference examples use projects | 90 | role: Role, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 91 | name: Option<String>, |
| 92 | avatar: Option<String>, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 93 | base_permission: Option<String>, |
| 94 | team_creation: Option<String>, | |
| 95 | member_privileges: Option<String>, | |
| 96 | #[serde(default)] | |
| 97 | billing_manager: u8, | |
| 98 | #[serde(default)] | |
| 99 | security_manager: u8, | |
| 100 | #[serde(default)] | |
| 101 | require_two_factor: u8, | |
| API reference examples use projects | 102 | } |
| 103 | ||
| 104 | impl Identity { | |
| 105 | /// The workspaces a user belongs to, attached to every user resolved | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 106 | /// from credentials, with what the site needs to show each one, what |
| 107 | /// members get on its repositories (access.rs), the roles the person | |
| 108 | /// holds besides member (members.rs), and its member privileges. | |
| API reference examples use projects | 109 | pub async fn memberships(&self, user_id: &str) -> Result<Vec<Membership>> { |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 110 | Ok(self.memberships_and_policies(user_id).await?.into_iter().map(|(membership, _)| membership).collect()) |
| 111 | } | |
| 112 | ||
| 113 | /// The same, each with whether its workspace requires two-factor | |
| 114 | /// authentication (security.rs). | |
| 115 | pub async fn memberships_and_policies(&self, user_id: &str) -> Result<Vec<(Membership, bool)>> { | |
| 116 | let rows = self | |
| 117 | .db | |
| API reference examples use projects | 118 | .prepare( |
| Workspace names and icons, and a component kit for every control | 119 | "SELECT workspaces.slug, workspace_members.role, workspaces.name, |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 120 | workspaces.avatar, workspaces.base_permission, workspaces.team_creation, |
| 121 | workspaces.member_privileges, workspaces.require_two_factor, | |
| 122 | workspace_members.billing_manager, workspace_members.security_manager | |
| Workspace names and icons, and a component kit for every control | 123 | FROM workspace_members |
| API reference examples use projects | 124 | JOIN workspaces ON workspaces.id = workspace_members.workspace_id |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 125 | WHERE workspace_members.user_id = ? AND workspaces.deleted_at IS NULL |
| 126 | ORDER BY workspaces.slug", | |
| API reference examples use projects | 127 | ) |
| 128 | .bind(&[user_id.into()])? | |
| 129 | .all() | |
| 130 | .await? | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 131 | .results::<MembershipRow>()?; |
| 132 | Ok(rows | |
| 133 | .into_iter() | |
| 134 | .map(|row| { | |
| 135 | let mut org_roles = Vec::new(); | |
| 136 | if row.billing_manager != 0 { | |
| 137 | org_roles.push(OrgRole::BillingManager); | |
| 138 | } | |
| 139 | if row.security_manager != 0 { | |
| 140 | org_roles.push(OrgRole::SecurityManager); | |
| 141 | } | |
| 142 | let membership = Membership { | |
| 143 | slug: row.slug, | |
| 144 | role: row.role, | |
| 145 | name: row.name, | |
| 146 | avatar: row.avatar, | |
| 147 | base_permission: row.base_permission.as_deref().and_then(BasePermission::parse), | |
| 148 | team_creation: row.team_creation.as_deref().and_then(TeamCreation::parse), | |
| 149 | org_roles, | |
| 150 | privileges: Some(MemberPrivileges::from_stored(row.member_privileges.as_deref())), | |
| 151 | }; | |
| 152 | (membership, row.require_two_factor != 0) | |
| 153 | }) | |
| 154 | .collect()) | |
| API reference examples use projects | 155 | } |
| 156 | ||
| 157 | pub async fn create_workspace(&self, a: CreateWorkspaceArgs) -> Result<Outcome<Workspace>> { | |
| 158 | if a.user.kind != PrincipalKind::User { | |
| 159 | return Ok(Outcome::fail( | |
| 160 | FailureCode::Forbidden, | |
| 161 | "A workspace's access token cannot create workspaces. Sign in as a person.", | |
| 162 | )); | |
| 163 | } | |
| 164 | if !a.user.verified { | |
| 165 | return Ok(Outcome::fail( | |
| 166 | FailureCode::Forbidden, | |
| 167 | "Confirm your email address before creating a workspace.", | |
| 168 | )); | |
| 169 | } | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 170 | let Some(slug) = claimable_namespace(&a.slug) else { |
| API reference examples use projects | 171 | return Ok(Outcome::fail( |
| 172 | FailureCode::Invalid, | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 173 | "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.", |
| API reference examples use projects | 174 | )); |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 175 | }; |
| API reference examples use projects | 176 | if self.memberships(&a.user.id).await?.len() >= MAX_WORKSPACES_PER_USER { |
| 177 | return Ok(Outcome::fail( | |
| 178 | FailureCode::Conflict, | |
| 179 | "You belong to the maximum number of workspaces.", | |
| 180 | )); | |
| 181 | } | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 182 | // One free workspace per person (paid.rs): a new one starts free. |
| 183 | if let Some(refused) = self.second_free_workspace(&a.user.id).await? { | |
| 184 | return Ok(refused); | |
| 185 | } | |
| API reference examples use projects | 186 | // Usernames and workspaces share one namespace: a person's username |
| 187 | // is theirs to use for a workspace, and nobody else's. | |
| 188 | let someone_elses_username = self | |
| 189 | .db | |
| 190 | .prepare("SELECT id FROM users WHERE username = ? AND id != ?") | |
| 191 | .bind(&[slug.as_str().into(), a.user.id.as_str().into()])? | |
| 192 | .first::<serde_json::Value>(None) | |
| 193 | .await? | |
| 194 | .is_some(); | |
| 195 | if someone_elses_username | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 196 | // A deleted workspace still holds its slug until it is purged. |
| 197 | || self.slug_in_use(&slug).await? | |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 198 | // A renamed workspace's old slug stays reserved for it a while. |
| 199 | || self.slug_held(&slug).await? | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 200 | // A deleted workspace's slug is never given to anyone else; the |
| 201 | // person whose username it is may use it again. | |
| 202 | || (self.slug_deleted(&slug).await? | |
| 203 | && !crate::deletion::may_reclaim(&slug, &a.user.username)) | |
| API reference examples use projects | 204 | { |
| 205 | return Ok(Outcome::fail( | |
| 206 | FailureCode::Conflict, | |
| 207 | "That workspace name is taken.", | |
| 208 | )); | |
| 209 | } | |
| 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) | 210 | let name = match a.name.trim() { |
| 211 | "" => slug.clone(), | |
| 212 | name => name.chars().take(MAX_NAME_LENGTH).collect(), | |
| 213 | }; | |
| 214 | Ok(Outcome::Ok(self.insert_workspace(&a.user.id, slug, name).await?)) | |
| 215 | } | |
| 216 | ||
| 217 | /// A new account's own workspace, named for its username, on the free | |
| 218 | /// plan as every new workspace is: so nobody is left without one. Made | |
| 219 | /// only when the username is free to use as a workspace's name (it | |
| 220 | /// usually is: usernames and workspaces share one namespace). None | |
| 221 | /// when it is not, and the site asks the person to make one. | |
| 222 | pub(crate) async fn create_own_workspace(&self, user: &User) -> Result<Option<Workspace>> { | |
| 223 | let Some(slug) = own_workspace_slug(&user.username) else { | |
| 224 | return Ok(None); | |
| 225 | }; | |
| 226 | if self.slug_in_use(&slug).await? | |
| 227 | || self.slug_held(&slug).await? | |
| 228 | || (self.slug_deleted(&slug).await? && !crate::deletion::may_reclaim(&slug, &user.username)) | |
| 229 | { | |
| 230 | return Ok(None); | |
| 231 | } | |
| 232 | let made = self.insert_workspace(&user.id, slug.clone(), slug).await; | |
| 233 | match made { | |
| 234 | Ok(workspace) => Ok(Some(workspace)), | |
| 235 | // Taken a moment ago: the person makes one themselves. | |
| 236 | Err(error) if error.to_string().contains("UNIQUE") => Ok(None), | |
| 237 | Err(error) => Err(error), | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 241 | /// Makes a workspace `user_id` owns, once every check has passed. | |
| 242 | async fn insert_workspace(&self, user_id: &str, slug: String, name: String) -> Result<Workspace> { | |
| API reference examples use projects | 243 | let now = now_ms(); |
| 244 | let workspace = Workspace { | |
| 245 | id: new_id("wsp", now), | |
| 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) | 246 | name, |
| API reference examples use projects | 247 | description: None, |
| 248 | slug, | |
| 249 | created_at: rfc3339(now), | |
| 250 | member_count: 1, | |
| Workspace names and icons, and a component kit for every control | 251 | avatar: None, |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 252 | // Read, as on GitHub: an owner widens it on People. |
| 253 | base_permission: BasePermission::FOR_NEW_WORKSPACES, | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 254 | team_creation: TeamCreation::default(), |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 255 | privileges: MemberPrivileges::default(), |
| 256 | two_factor_requirement_enabled: false, | |
| API reference examples use projects | 257 | }; |
| 258 | self.db | |
| 259 | .batch(vec![ | |
| 260 | self.db | |
| 261 | .prepare( | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 262 | "INSERT INTO workspaces (id, slug, name, created_by, created_at, base_permission) |
| 263 | VALUES (?, ?, ?, ?, ?, ?)", | |
| API reference examples use projects | 264 | ) |
| 265 | .bind(&[ | |
| 266 | workspace.id.as_str().into(), | |
| 267 | workspace.slug.as_str().into(), | |
| 268 | workspace.name.as_str().into(), | |
| 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) | 269 | user_id.into(), |
| API reference examples use projects | 270 | workspace.created_at.as_str().into(), |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 271 | workspace.base_permission.as_str().into(), |
| API reference examples use projects | 272 | ])?, |
| 273 | self.db | |
| 274 | .prepare( | |
| 275 | "INSERT INTO workspace_members (workspace_id, user_id, role, created_at) | |
| 276 | VALUES (?, ?, 'owner', ?)", | |
| 277 | ) | |
| 278 | .bind(&[ | |
| 279 | workspace.id.as_str().into(), | |
| 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) | 280 | user_id.into(), |
| API reference examples use projects | 281 | workspace.created_at.as_str().into(), |
| 282 | ])?, | |
| 283 | ]) | |
| 284 | .await?; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 285 | self.forget_deleted(&workspace.slug).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) | 286 | Ok(workspace) |
| API reference examples use projects | 287 | } |
| 288 | ||
| 289 | pub async fn get_workspace(&self, a: SlugArgs) -> Result<Option<Workspace>> { | |
| 290 | Ok(self | |
| 291 | .db | |
| 292 | .prepare(format!( | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 293 | "SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE slug = ? AND deleted_at IS NULL" |
| API reference examples use projects | 294 | )) |
| 295 | .bind(&[a.slug.to_lowercase().into()])? | |
| 296 | .first::<WorkspaceRow>(None) | |
| 297 | .await? | |
| 298 | .map(Workspace::from)) | |
| 299 | } | |
| 300 | ||
| 301 | pub async fn update_workspace(&self, a: UpdateWorkspaceArgs) -> Result<Outcome<Workspace>> { | |
| 302 | let slug = a.slug.to_lowercase(); | |
| 303 | if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) { | |
| 304 | return Ok(Outcome::fail( | |
| 305 | FailureCode::Forbidden, | |
| 306 | "Only an owner can change a workspace's details.", | |
| 307 | )); | |
| 308 | } | |
| 309 | let name: String = match a.name.trim() { | |
| 310 | "" => slug.clone(), | |
| 311 | name => name.chars().take(MAX_NAME_LENGTH).collect(), | |
| 312 | }; | |
| 313 | let description: String = a | |
| 314 | .description | |
| 315 | .trim() | |
| 316 | .chars() | |
| 317 | .take(MAX_DESCRIPTION_LENGTH) | |
| 318 | .collect(); | |
| 319 | self.db | |
| 320 | .prepare("UPDATE workspaces SET name = ?, description = ? WHERE slug = ?") | |
| 321 | .bind(&[ | |
| 322 | name.into(), | |
| 323 | if description.is_empty() { | |
| 324 | worker::wasm_bindgen::JsValue::NULL | |
| 325 | } else { | |
| 326 | description.into() | |
| 327 | }, | |
| 328 | slug.as_str().into(), | |
| 329 | ])? | |
| 330 | .run() | |
| 331 | .await?; | |
| 332 | Ok(match self.get_workspace(SlugArgs { slug }).await? { | |
| 333 | Some(workspace) => Outcome::Ok(workspace), | |
| 334 | None => Outcome::fail(FailureCode::NotFound, "Workspace not found."), | |
| 335 | }) | |
| 336 | } | |
| 337 | ||
| 338 | pub async fn list_members(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Member>>> { | |
| 339 | let slug = a.slug.to_lowercase(); | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 340 | let Some(viewer) = a.viewer.filter(|viewer| viewer.is_member(&slug)) else { |
| API reference examples use projects | 341 | return Ok(Outcome::fail( |
| 342 | FailureCode::Forbidden, | |
| 343 | "Only members can see who is in a workspace.", | |
| 344 | )); | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 345 | }; |
| 346 | // Owners see who has two-factor authentication on, as they need to | |
| 347 | // before requiring it. | |
| 348 | let owner = viewer.role_in(&slug) == Some(Role::Owner); | |
| 349 | #[derive(Deserialize)] | |
| 350 | struct Row { | |
| 351 | #[serde(flatten)] | |
| 352 | member: crate::members::MemberRow, | |
| 353 | #[serde(default)] | |
| 354 | two_factor: u8, | |
| API reference examples use projects | 355 | } |
| 356 | let rows = self | |
| 357 | .db | |
| 358 | .prepare( | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 359 | "SELECT workspace_members.user_id, users.username, users.display_username, workspace_members.role, users.display_name AS name, users.avatar, |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 360 | workspace_members.billing_manager, workspace_members.security_manager, |
| 361 | EXISTS (SELECT 1 FROM two_factor WHERE two_factor.user_id = users.id AND two_factor.enabled_at IS NOT NULL) AS two_factor | |
| 362 | FROM workspace_members | |
| API reference examples use projects | 363 | JOIN users ON users.id = workspace_members.user_id |
| 364 | JOIN workspaces ON workspaces.id = workspace_members.workspace_id | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 365 | WHERE workspaces.slug = ? AND workspaces.deleted_at IS NULL |
| API reference examples use projects | 366 | ORDER BY workspace_members.role DESC, users.username", |
| 367 | ) | |
| 368 | .bind(&[slug.into()])? | |
| 369 | .all() | |
| 370 | .await? | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 371 | .results::<Row>()?; |
| API reference examples use projects | 372 | Ok(Outcome::Ok( |
| 373 | rows.into_iter() | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 374 | .map(|row| row.member.member(owner.then_some(row.two_factor != 0))) |
| API reference examples use projects | 375 | .collect(), |
| 376 | )) | |
| 377 | } | |
| 378 | ||
| 379 | /// The ids needed to change a workspace's members, if `actor` owns it | |
| 380 | /// and `username` exists. | |
| 381 | async fn member_target(&self, a: &MemberArgs) -> Result<Outcome<(String, User)>> { | |
| 382 | let slug = a.slug.to_lowercase(); | |
| 383 | if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) { | |
| 384 | return Ok(Outcome::fail( | |
| 385 | FailureCode::Forbidden, | |
| 386 | "Only an owner can change a workspace's members.", | |
| 387 | )); | |
| 388 | } | |
| 389 | let Some(workspace) = self.get_workspace(SlugArgs { slug }).await? else { | |
| 390 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); | |
| 391 | }; | |
| 392 | let Some(user) = self | |
| 393 | .find_public_user( | |
| 394 | "SELECT id, username, email_verified_at IS NOT NULL AS verified | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 395 | FROM users WHERE username = ? AND deleted_at IS NULL", |
| API reference examples use projects | 396 | &a.username.trim().to_lowercase(), |
| 397 | ) | |
| 398 | .await? | |
| 399 | else { | |
| 400 | return Ok(Outcome::fail( | |
| 401 | FailureCode::NotFound, | |
| 402 | "There is no account with that username.", | |
| 403 | )); | |
| 404 | }; | |
| 405 | Ok(Outcome::Ok((workspace.id, user))) | |
| 406 | } | |
| 407 | ||
| 408 | pub async fn add_member(&self, a: MemberArgs) -> Result<Outcome<bool>> { | |
| 409 | let (workspace_id, user) = match self.member_target(&a).await? { | |
| 410 | Outcome::Ok(target) => target, | |
| 411 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 412 | }; | |
| 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) | 413 | // A person is never added without saying yes: they get a workspace |
| 414 | // invitation to accept or decline (invites/invitations.rs). Only | |
| 415 | // g1t's own agent is added at once. | |
| 416 | if !crate::paid::is_g1t(&user.username) { | |
| 417 | let invited = self | |
| 418 | .invite_member(InviteMemberArgs { | |
| 419 | actor: a.actor, | |
| 420 | slug: a.slug, | |
| 421 | email: String::new(), | |
| 422 | username: Some(user.username), | |
| 423 | role: None, | |
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 424 | message: None, |
| 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) | 425 | surface: a.surface, |
| 426 | }) | |
| 427 | .await?; | |
| 428 | return Ok(match invited { | |
| 429 | Outcome::Ok(_) => Outcome::Ok(true), | |
| 430 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 431 | }); | |
| 432 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 433 | // What the workspace asks of its members (security.rs); nothing yet. |
| 434 | if let Some(why) = self.policy_refusal(&user.id, &a.slug.to_lowercase()).await? { | |
| 435 | return Ok(Outcome::fail(FailureCode::Forbidden, why)); | |
| 436 | } | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 437 | // A free workspace adds no one until it starts the plan (paid.rs); |
| 438 | // g1t's agent is never someone added. | |
| 439 | if !crate::paid::is_g1t(&user.username) | |
| 440 | && let Some(refused) = self.free_workspace_refusal(&a.slug).await? | |
| 441 | { | |
| 442 | return Ok(refused); | |
| 443 | } | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 444 | let added = self |
| 445 | .db | |
| API reference examples use projects | 446 | .prepare( |
| 447 | "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at) | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 448 | VALUES (?, ?, 'member', ?) RETURNING user_id", |
| API reference examples use projects | 449 | ) |
| 450 | .bind(&[ | |
| 451 | workspace_id.into(), | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 452 | user.id.as_str().into(), |
| API reference examples use projects | 453 | rfc3339(now_ms()).into(), |
| 454 | ])? | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 455 | .first::<serde_json::Value>(None) |
| 456 | .await? | |
| 457 | .is_some(); | |
| 458 | if added { | |
| 459 | self.audit_workspace( | |
| 460 | &a.actor, | |
| 461 | "member.added", | |
| 462 | &a.slug.to_lowercase(), | |
| 463 | a.surface.unwrap_or(Surface::Web), | |
| 464 | format!("Added {} as a member", user.username), | |
| 465 | ) | |
| 466 | .await; | |
| 467 | } | |
| API reference examples use projects | 468 | Ok(Outcome::Ok(true)) |
| 469 | } | |
| 470 | ||
| 471 | pub async fn remove_member(&self, a: MemberArgs) -> Result<Outcome<bool>> { | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 472 | // Removing yourself is leaving, which anyone may do. |
| 473 | if a.username.trim().trim_start_matches('@').eq_ignore_ascii_case(&a.actor.username) { | |
| 474 | return self | |
| 475 | .leave_workspace(LeaveWorkspaceArgs { user: a.actor, slug: a.slug, surface: a.surface }) | |
| 476 | .await; | |
| 477 | } | |
| API reference examples use projects | 478 | let (workspace_id, user) = match self.member_target(&a).await? { |
| 479 | Outcome::Ok(target) => target, | |
| 480 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 481 | }; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 482 | let slug = a.slug.to_lowercase(); |
| 483 | let Some(row) = self.member_row(&workspace_id, &user.username).await? else { | |
| 484 | return Ok(Outcome::Ok(true)); | |
| 485 | }; | |
| 486 | if row.role == Role::Owner | |
| 487 | && let Some(why) = last_owner_refusal(self.owner_count(&workspace_id).await?, true) | |
| 488 | { | |
| 489 | return Ok(Outcome::fail(FailureCode::Conflict, why)); | |
| API reference examples use projects | 490 | } |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 491 | self.drop_member(&workspace_id, &user.id).await?; |
| 492 | self.audit_workspace( | |
| 493 | &a.actor, | |
| 494 | "member.removed", | |
| 495 | &slug, | |
| 496 | a.surface.unwrap_or(Surface::Web), | |
| 497 | format!("Removed {} from the workspace", user.username), | |
| 498 | ) | |
| 499 | .await; | |
| API reference examples use projects | 500 | Ok(Outcome::Ok(true)) |
| 501 | } | |
| 502 | } | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 503 | |
| 504 | #[cfg(test)] | |
| 505 | mod tests { | |
| 506 | use super::*; | |
| 507 | ||
| 508 | #[test] | |
| 509 | fn no_workspace_is_created_with_g1ts_names() { | |
| 510 | // What create_workspace takes the slug through, whatever its case. | |
| 511 | for slug in ["g1t", "G1T", " g1t-agent ", "G1T-Agent"] { | |
| 512 | assert_eq!(claimable_namespace(slug), None, "{slug}"); | |
| 513 | } | |
| 514 | assert_eq!(claimable_namespace("Acme").as_deref(), Some("acme")); | |
| 515 | } | |
| 516 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.