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.
| Merge branch 'worktree-agent-a8385d293d42c913a' | 1 | //! Workspace aliases: a name g1t's staff point at a workspace, so that its |
| 2 | //! addresses lead to the workspace under its own name. `g1t` is the | |
| 3 | //! product Flagon, Inc. builds, and leads to `flagon-io`, the organization | |
| 4 | //! (migration 0029), so nobody is confused by the trading name. | |
| 5 | //! | |
| 6 | //! Staff set and remove them from sudo; there is no way for a workspace to | |
| 7 | //! make one. An alias is a reserved or unclaimed name, never a person's or | |
| 8 | //! a workspace's, and points at the workspace's id, so it follows the | |
| 9 | //! workspace through renames. While it exists nobody can register or | |
| 10 | //! rename a workspace to it. | |
| 11 | //! | |
| 12 | //! An alias is resolved wherever an old slug is (`resolve_slug`): the site | |
| 13 | //! and the API redirect or run again under the workspace's slug. Git over | |
| 14 | //! HTTPS resolves it in place (`resolve_alias`), as pushes do not follow | |
| 15 | //! redirects. | |
| 16 | ||
| 17 | use g1t_contracts::identity::*; | |
| 18 | use g1t_contracts::time::rfc3339; | |
| 19 | use g1t_contracts::{FailureCode, Outcome, aliasable_name}; | |
| 20 | use g1t_kit::now_ms; | |
| 21 | use serde::Deserialize; | |
| 22 | use worker::Result; | |
| 23 | ||
| 24 | use crate::Identity; | |
| 25 | ||
| 26 | /// The longest note or reason staff may give. | |
| 27 | pub const MAX_NOTE_LENGTH: usize = 500; | |
| 28 | ||
| 29 | /// Everything about a wanted alias that decides whether staff may set it, | |
| 30 | /// as read from the database. | |
| 31 | #[derive(Debug, Default)] | |
| 32 | pub struct Facts<'a> { | |
| 33 | /// The workspace it would lead to, if there is one by that slug: its id. | |
| 34 | pub target: Option<&'a str>, | |
| 35 | /// A person has the name as their username. | |
| 36 | pub username: bool, | |
| 37 | /// A workspace has it as its slug, deleted or not. | |
| 38 | pub workspace: bool, | |
| 39 | /// A renamed workspace's old slug still held: the workspace it is held for. | |
| 40 | pub held_for: Option<&'a str>, | |
| 41 | /// A purged workspace had it; it is never given to anyone else. | |
| 42 | pub purged: bool, | |
| 43 | /// It is already an alias: of which workspace's slug. | |
| 44 | pub alias_of: Option<&'a str>, | |
| 45 | } | |
| 46 | ||
| 47 | /// The alias and note to store, or why not, in words for staff. | |
| 48 | pub fn check(alias: &str, note: &str, facts: &Facts) -> std::result::Result<(String, String), (FailureCode, String)> { | |
| 49 | let refuse = |code, message: String| Err((code, message)); | |
| 50 | let Some(alias) = aliasable_name(alias) else { | |
| 51 | return refuse( | |
| 52 | FailureCode::Invalid, | |
| 53 | "An alias uses lowercase letters, digits and single hyphens, up to 39 characters, and cannot be one of the site's routes.".into(), | |
| 54 | ); | |
| 55 | }; | |
| 56 | let note = note.trim(); | |
| 57 | if note.is_empty() { | |
| 58 | return refuse(FailureCode::Invalid, "Say why the alias exists.".into()); | |
| 59 | } | |
| 60 | if note.chars().count() > MAX_NOTE_LENGTH { | |
| 61 | return refuse(FailureCode::Invalid, format!("Keep the note to {MAX_NOTE_LENGTH} characters.")); | |
| 62 | } | |
| 63 | let Some(target) = facts.target else { | |
| 64 | return refuse(FailureCode::NotFound, "There is no workspace with that slug.".into()); | |
| 65 | }; | |
| 66 | if let Some(slug) = facts.alias_of { | |
| 67 | return refuse(FailureCode::Conflict, format!("{alias} is already an alias of {slug}.")); | |
| 68 | } | |
| 69 | if facts.username { | |
| 70 | return refuse(FailureCode::Conflict, format!("{alias} is someone's username.")); | |
| 71 | } | |
| 72 | if facts.workspace { | |
| 73 | return refuse(FailureCode::Conflict, format!("{alias} is a workspace's slug.")); | |
| 74 | } | |
| 75 | if facts.purged { | |
| 76 | return refuse(FailureCode::Conflict, format!("{alias} belonged to a deleted workspace.")); | |
| 77 | } | |
| 78 | // A workspace's own old slug can become its alias for good. | |
| 79 | if facts.held_for.is_some_and(|holder| holder != target) { | |
| 80 | return refuse(FailureCode::Conflict, format!("{alias} is held for a renamed workspace.")); | |
| 81 | } | |
| 82 | Ok((alias, note.to_owned())) | |
| 83 | } | |
| 84 | ||
| 85 | /// Where a first path segment leads, in the order `resolve_slug` asks: a | |
| 86 | /// workspace that has it leads nowhere else; then an alias, which is for | |
| 87 | /// good; then a renamed workspace's old slug, while it is held. | |
| 88 | pub fn resolve(in_use: bool, alias: Option<String>, renamed: impl FnOnce() -> Option<String>) -> Option<String> { | |
| 89 | if in_use { | |
| 90 | return None; | |
| 91 | } | |
| 92 | alias.or_else(renamed) | |
| 93 | } | |
| 94 | ||
| 95 | #[derive(Deserialize)] | |
| 96 | struct AliasRow { | |
| 97 | alias: String, | |
| 98 | workspace_id: String, | |
| 99 | slug: String, | |
| 100 | name: String, | |
| 101 | note: String, | |
| 102 | created_by: String, | |
| 103 | created_at: String, | |
| 104 | } | |
| 105 | ||
| 106 | impl From<AliasRow> for WorkspaceAlias { | |
| 107 | fn from(row: AliasRow) -> Self { | |
| 108 | WorkspaceAlias { | |
| 109 | alias: row.alias, | |
| 110 | workspace_id: row.workspace_id, | |
| 111 | workspace: row.slug, | |
| 112 | workspace_name: row.name, | |
| 113 | note: row.note, | |
| 114 | created_by: row.created_by, | |
| 115 | created_at: row.created_at, | |
| 116 | } | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | /// Aliases with their workspace as it is now. Never a deleted one's. | |
| 121 | const ALIAS_ROWS: &str = "SELECT a.alias, a.workspace_id, w.slug, w.name, a.note, a.created_by, a.created_at | |
| 122 | FROM workspace_aliases a JOIN workspaces w ON w.id = a.workspace_id AND w.deleted_at IS NULL"; | |
| 123 | ||
| 124 | #[derive(Deserialize)] | |
| 125 | struct Id { | |
| 126 | id: String, | |
| 127 | } | |
| 128 | ||
| 129 | impl Identity { | |
| 130 | async fn alias_row(&self, alias: &str) -> Result<Option<AliasRow>> { | |
| 131 | self.db | |
| 132 | .prepare(format!("{ALIAS_ROWS} WHERE a.alias = ?")) | |
| 133 | .bind(&[alias.into()])? | |
| 134 | .first::<AliasRow>(None) | |
| 135 | .await | |
| 136 | } | |
| 137 | ||
| 138 | /// `resolve_alias`: the slug now of the workspace `slug` is an alias of. | |
| 139 | pub async fn resolve_alias(&self, a: SlugArgs) -> Result<Option<String>> { | |
| 140 | let slug = a.slug.trim().to_lowercase(); | |
| 141 | Ok(self.alias_row(&slug).await?.map(|row| row.slug)) | |
| 142 | } | |
| 143 | ||
| 144 | /// Whether `slug` is an alias, of a workspace deleted or not, so nobody | |
| 145 | /// may register or rename a workspace to it. | |
| 146 | pub async fn is_alias(&self, slug: &str) -> Result<bool> { | |
| 147 | Ok(self | |
| 148 | .db | |
| 149 | .prepare("SELECT 1 AS held FROM workspace_aliases WHERE alias = ?") | |
| 150 | .bind(&[slug.trim().to_lowercase().into()])? | |
| 151 | .first::<serde_json::Value>(None) | |
| 152 | .await? | |
| 153 | .is_some()) | |
| 154 | } | |
| 155 | ||
| 156 | /// `admin_aliases`: every alias, by name. Staff only. | |
| 157 | pub async fn admin_aliases(&self) -> Result<Vec<WorkspaceAlias>> { | |
| 158 | Ok(self | |
| 159 | .db | |
| 160 | .prepare(format!("{ALIAS_ROWS} ORDER BY a.alias")) | |
| 161 | .all() | |
| 162 | .await? | |
| 163 | .results::<AliasRow>()? | |
| 164 | .into_iter() | |
| 165 | .map(WorkspaceAlias::from) | |
| 166 | .collect()) | |
| 167 | } | |
| 168 | ||
| 169 | /// `admin_set_alias`: staff only. Recorded in sudo's audit log. | |
| 170 | pub async fn admin_set_alias(&self, a: AdminSetAliasArgs) -> Result<Outcome<WorkspaceAlias>> { | |
| 171 | let staff = a.staff.trim(); | |
| 172 | if staff.is_empty() { | |
| 173 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is setting it.")); | |
| 174 | } | |
| 175 | let alias = a.alias.trim().to_lowercase(); | |
| 176 | let workspace = a.workspace.trim().to_lowercase(); | |
| 177 | let target = self | |
| 178 | .db | |
| 179 | .prepare("SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL") | |
| 180 | .bind(&[workspace.as_str().into()])? | |
| 181 | .first::<Id>(None) | |
| 182 | .await?; | |
| 183 | let username = self | |
| 184 | .db | |
| 185 | .prepare("SELECT 1 AS taken FROM users WHERE username = ?") | |
| 186 | .bind(&[alias.as_str().into()])? | |
| 187 | .first::<serde_json::Value>(None) | |
| 188 | .await? | |
| 189 | .is_some(); | |
| 190 | #[derive(Deserialize)] | |
| 191 | struct Held { | |
| 192 | workspace_id: String, | |
| 193 | created_at: String, | |
| 194 | } | |
| 195 | let held = self | |
| 196 | .db | |
| 197 | .prepare("SELECT workspace_id, created_at FROM workspace_redirects WHERE old_slug = ?") | |
| 198 | .bind(&[alias.as_str().into()])? | |
| 199 | .first::<Held>(None) | |
| 200 | .await? | |
| 201 | .filter(|row| row.created_at >= crate::rename::hold_cutoff(now_ms())); | |
| 202 | let existing = self.alias_row(&alias).await?; | |
| 203 | // An alias of a deleted workspace is not listed, but still holds. | |
| 204 | let alias_of = match &existing { | |
| 205 | Some(row) => Some(row.slug.clone()), | |
| 206 | None => self.is_alias(&alias).await?.then(|| "a deleted workspace".to_owned()), | |
| 207 | }; | |
| 208 | let facts = Facts { | |
| 209 | target: target.as_ref().map(|row| row.id.as_str()), | |
| 210 | username, | |
| 211 | workspace: self.slug_in_use(&alias).await?, | |
| 212 | held_for: held.as_ref().map(|row| row.workspace_id.as_str()), | |
| 213 | purged: self.slug_deleted(&alias).await?, | |
| 214 | alias_of: alias_of.as_deref(), | |
| 215 | }; | |
| 216 | let (alias, note) = match check(&alias, &a.note, &facts) { | |
| 217 | Ok(checked) => checked, | |
| 218 | Err((code, message)) => return Ok(Outcome::fail(code, message)), | |
| 219 | }; | |
| 220 | let Some(target) = target else { | |
| 221 | return Ok(Outcome::fail(FailureCode::NotFound, "There is no workspace with that slug.")); | |
| 222 | }; | |
| 223 | self.db | |
| 224 | .batch(vec![ | |
| 225 | // Its own old slug, made its alias: the redirect gives way. | |
| 226 | self.db | |
| 227 | .prepare("DELETE FROM workspace_redirects WHERE old_slug = ? AND workspace_id = ?") | |
| 228 | .bind(&[alias.as_str().into(), target.id.as_str().into()])?, | |
| 229 | self.db | |
| 230 | .prepare( | |
| 231 | "INSERT INTO workspace_aliases (alias, workspace_id, created_by, created_at, note) | |
| 232 | VALUES (?, ?, ?, ?, ?)", | |
| 233 | ) | |
| 234 | .bind(&[ | |
| 235 | alias.as_str().into(), | |
| 236 | target.id.as_str().into(), | |
| 237 | staff.into(), | |
| 238 | rfc3339(now_ms()).into(), | |
| 239 | note.as_str().into(), | |
| 240 | ])?, | |
| 241 | ]) | |
| 242 | .await?; | |
| 243 | self.record_for_staff(&workspace, "alias_added", &format!("Alias {alias} leads to {workspace}: {note}"), staff) | |
| 244 | .await; | |
| 245 | Ok(match self.alias_row(&alias).await? { | |
| 246 | Some(row) => Outcome::Ok(row.into()), | |
| 247 | None => Outcome::fail(FailureCode::NotFound, "There is no workspace with that slug."), | |
| 248 | }) | |
| 249 | } | |
| 250 | ||
| 251 | /// `admin_remove_alias`: staff only. Recorded in sudo's audit log. | |
| 252 | pub async fn admin_remove_alias(&self, a: AdminRemoveAliasArgs) -> Result<Outcome<bool>> { | |
| 253 | let staff = a.staff.trim(); | |
| 254 | if staff.is_empty() { | |
| 255 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is removing it.")); | |
| 256 | } | |
| 257 | let reason = a.reason.trim(); | |
| 258 | if reason.is_empty() { | |
| 259 | return Ok(Outcome::fail(FailureCode::Invalid, "Say why the alias is being removed.")); | |
| 260 | } | |
| 261 | if reason.chars().count() > MAX_NOTE_LENGTH { | |
| 262 | return Ok(Outcome::fail( | |
| 263 | FailureCode::Invalid, | |
| 264 | format!("Keep the reason to {MAX_NOTE_LENGTH} characters."), | |
| 265 | )); | |
| 266 | } | |
| 267 | let alias = a.alias.trim().to_lowercase(); | |
| 268 | let Some(row) = self.alias_row(&alias).await? else { | |
| 269 | return Ok(Outcome::fail(FailureCode::NotFound, "There is no alias by that name.")); | |
| 270 | }; | |
| 271 | self.db | |
| 272 | .prepare("DELETE FROM workspace_aliases WHERE alias = ?") | |
| 273 | .bind(&[alias.as_str().into()])? | |
| 274 | .run() | |
| 275 | .await?; | |
| 276 | self.record_for_staff( | |
| 277 | &row.slug, | |
| 278 | "alias_removed", | |
| 279 | &format!("Alias {alias} no longer leads to {}: {reason}", row.slug), | |
| 280 | staff, | |
| 281 | ) | |
| 282 | .await; | |
| 283 | Ok(Outcome::Ok(true)) | |
| 284 | } | |
| 285 | } | |
| 286 | ||
| 287 | #[cfg(test)] | |
| 288 | mod tests { | |
| 289 | use super::*; | |
| 290 | use std::collections::HashMap; | |
| 291 | ||
| 292 | fn facts<'a>() -> Facts<'a> { | |
| 293 | Facts { | |
| 294 | target: Some("wsp_flagon"), | |
| 295 | ..Facts::default() | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | fn refused(alias: &str, facts: &Facts) -> FailureCode { | |
| 300 | check(alias, "The product's name", facts).unwrap_err().0 | |
| 301 | } | |
| 302 | ||
| 303 | #[test] | |
| 304 | fn a_reserved_or_unclaimed_name_can_be_an_alias() { | |
| 305 | assert_eq!( | |
| 306 | check(" G1T ", " The product's name, for Flagon, Inc. ", &facts()).unwrap(), | |
| 307 | ("g1t".to_owned(), "The product's name, for Flagon, Inc.".to_owned()) | |
| 308 | ); | |
| 309 | assert!(check("flagon", "Short name", &facts()).is_ok()); | |
| 310 | } | |
| 311 | ||
| 312 | #[test] | |
| 313 | fn routes_and_malformed_names_are_never_aliases() { | |
| 314 | for bad in ["settings", "api", "login", "-g1t", "g1t-", "g--1t", "g1t_inc", "", "a.b"] { | |
| 315 | assert_eq!(refused(bad, &facts()), FailureCode::Invalid, "{bad}"); | |
| 316 | } | |
| 317 | } | |
| 318 | ||
| 319 | #[test] | |
| 320 | fn a_reason_is_required_and_bounded() { | |
| 321 | assert_eq!(check("g1t", " ", &facts()).unwrap_err().0, FailureCode::Invalid); | |
| 322 | let long = "x".repeat(MAX_NOTE_LENGTH + 1); | |
| 323 | assert_eq!(check("g1t", &long, &facts()).unwrap_err().0, FailureCode::Invalid); | |
| 324 | } | |
| 325 | ||
| 326 | #[test] | |
| 327 | fn a_persons_or_workspaces_name_is_never_an_alias() { | |
| 328 | let missing = Facts { target: None, ..facts() }; | |
| 329 | assert_eq!(refused("g1t", &missing), FailureCode::NotFound); | |
| 330 | let person = Facts { username: true, ..facts() }; | |
| 331 | assert_eq!(refused("ana", &person), FailureCode::Conflict); | |
| 332 | let workspace = Facts { workspace: true, ..facts() }; | |
| 333 | assert_eq!(refused("acme", &workspace), FailureCode::Conflict); | |
| 334 | let purged = Facts { purged: true, ..facts() }; | |
| 335 | assert_eq!(refused("initech", &purged), FailureCode::Conflict); | |
| 336 | let aliased = Facts { | |
| 337 | alias_of: Some("globex"), | |
| 338 | ..facts() | |
| 339 | }; | |
| 340 | let (code, message) = check("g1t", "x", &aliased).unwrap_err(); | |
| 341 | assert_eq!(code, FailureCode::Conflict); | |
| 342 | assert_eq!(message, "g1t is already an alias of globex."); | |
| 343 | } | |
| 344 | ||
| 345 | #[test] | |
| 346 | fn only_its_own_old_slug_can_become_a_workspaces_alias() { | |
| 347 | let others = Facts { | |
| 348 | held_for: Some("wsp_other"), | |
| 349 | ..facts() | |
| 350 | }; | |
| 351 | assert_eq!(refused("acme", &others), FailureCode::Conflict); | |
| 352 | let own = Facts { | |
| 353 | held_for: Some("wsp_flagon"), | |
| 354 | ..facts() | |
| 355 | }; | |
| 356 | assert!(check("flagon", "Its old name, for good", &own).is_ok()); | |
| 357 | } | |
| 358 | ||
| 359 | #[test] | |
| 360 | fn a_workspace_in_use_is_never_resolved_elsewhere() { | |
| 361 | let alias = || Some("flagon-io".to_owned()); | |
| 362 | assert_eq!(resolve(true, alias(), || Some("x".into())), None); | |
| 363 | assert_eq!(resolve(false, alias(), || Some("x".into())).as_deref(), Some("flagon-io")); | |
| 364 | assert_eq!(resolve(false, None, || Some("acme-inc".into())).as_deref(), Some("acme-inc")); | |
| 365 | assert_eq!(resolve(false, None, || None), None); | |
| 366 | } | |
| 367 | ||
| 368 | /// Aliases point at ids, as the table does; renames change the slug. | |
| 369 | #[test] | |
| 370 | fn an_alias_follows_its_workspace_through_renames() { | |
| 371 | let mut slugs: HashMap<&str, &str> = HashMap::from([("wsp_flagon", "flagon-io")]); | |
| 372 | let aliases: HashMap<&str, &str> = HashMap::from([("g1t", "wsp_flagon")]); | |
| 373 | let lookup = |slugs: &HashMap<&str, &str>, alias: &str| { | |
| 374 | aliases.get(alias).and_then(|id| slugs.get(id)).map(|slug| (*slug).to_owned()) | |
| 375 | }; | |
| 376 | assert_eq!(lookup(&slugs, "g1t").as_deref(), Some("flagon-io")); | |
| 377 | slugs.insert("wsp_flagon", "flagon"); | |
| 378 | assert_eq!(lookup(&slugs, "g1t").as_deref(), Some("flagon")); | |
| 379 | slugs.insert("wsp_flagon", "flagon-inc"); | |
| 380 | assert_eq!(lookup(&slugs, "g1t").as_deref(), Some("flagon-inc")); | |
| 381 | assert_eq!(lookup(&slugs, "acme"), None); | |
| 382 | } | |
| 383 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.