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