Skip to content

Commit

Identity: workspace aliases, set by staff, seeded with g1t for flagon-io

A workspace alias is a name staff point at a workspace (workspace_aliases, migration 0029), by its id so it follows renames. It must have a namespace's shape, must not be a site route, a username, a workspace's slug or another alias; reserved names such as g1t can be. resolve_slug now also resolves aliases, resolve_alias answers git, admin_aliases/admin_set_alias/admin_remove_alias are staff only and logged in sudo's audit log. An alias holds its name against registration and renames, and goes when its workspace is purged. Seeds g1t -> flagon-io.

syntaqxcommitted Parent81aa307Browse files
8 files+546−270/8 viewed
+53−1
560560 // `resolve_slug` takes `SlugArgs` and returns `Option<String>`: the
561561 // workspace's current slug when `slug` is one it was renamed from within
562562 // the last `SLUG_HOLD_DAYS`, and null otherwise (including for a slug that
563−// is in use).
563+// is in use), or the workspace's slug when `slug` is one of its aliases.
564+
565+// `resolve_alias` takes `SlugArgs` and returns `Option<String>`: the slug
566+// now of the workspace `slug` is an alias of, and null when it is none.
567+// Aliases are set by g1t's staff only: `g1t` is Flagon, Inc.'s `flagon-io`.
568+// An alias follows its workspace through renames.
569+
570+/// `admin_aliases` takes no arguments (`{}`) and returns
571+/// `Vec<WorkspaceAlias>`, by alias. Staff only.
572+///
573+/// A name staff point at a workspace, so that its addresses (pages, git,
574+/// the API, packages) lead to the workspace under its own name.
575+#[derive(Clone, Debug, Serialize, Deserialize)]
576+#[serde(rename_all = "camelCase")]
577+pub struct WorkspaceAlias {
578+ pub alias: String,
579+ pub workspace_id: String,
580+ /// The workspace's slug and name now.
581+ pub workspace: String,
582+ pub workspace_name: String,
583+ /// Why it exists, as staff wrote it.
584+ pub note: String,
585+ /// The staff member who set it, or `migration`.
586+ pub created_by: String,
587+ /// RFC 3339.
588+ pub created_at: String,
589+}
590+
591+/// `admin_set_alias`: points `alias` at the workspace whose slug is
592+/// `workspace`. The alias must have a namespace's shape, must not be one of
593+/// the site's routes, and must not be anyone's username, a workspace's slug
594+/// (deleted, or held after a rename) or another alias. `note` is required:
595+/// it is the reason, kept with the alias and in sudo's audit log. Staff
596+/// only. Returns `Outcome<WorkspaceAlias>`.
597+#[derive(Debug, Serialize, Deserialize)]
598+#[serde(rename_all = "camelCase")]
599+pub struct AdminSetAliasArgs {
600+ pub alias: String,
601+ pub workspace: String,
602+ pub note: String,
603+ pub staff: String,
604+}
605+
606+/// `admin_remove_alias`: the alias stops leading anywhere, and the name is
607+/// nobody's again unless it is reserved. `reason` goes in sudo's audit log.
608+/// Staff only. Returns `Outcome<bool>`.
609+#[derive(Debug, Serialize, Deserialize)]
610+#[serde(rename_all = "camelCase")]
611+pub struct AdminRemoveAliasArgs {
612+ pub alias: String,
613+ pub reason: String,
614+ pub staff: String,
615+}
564616
565617 /// `set_workspace_avatar`: owners only. `image` is the file's bytes in
566618 /// base64: PNG, JPEG, WebP or GIF, at most `MAX_AVATAR_BYTES`. Null removes
+4−1
3838 pub mod work;
3939
4040 pub use ids::new_id;
41−pub use names::{claimable_namespace, is_reserved_name, is_valid_namespace, is_valid_repo_name};
41+pub use names::{
42+ aliasable_name, claimable_namespace, is_namespace_shaped, is_reserved_name, is_route_name, is_valid_namespace,
43+ is_valid_repo_name,
44+};
4245 pub use outcome::{Failure, FailureCode, Outcome};
4346
4447 use serde::{Deserialize, Serialize};
+51−17
1−/// Routes and reserved words that may not be registered as usernames.
2−const RESERVED: &[&str] = &[
1+/// The site's own routes: first path segments that are never a workspace's,
2+/// so nobody may register them, and no alias can be reached at them.
3+const ROUTES: &[&str] = &[
34 "api",
45 "mcp",
56 "login",
1920 "avatars",
2021 "docs",
2122 "explore",
22− // g1t itself, and the name its agent once went by: everything g1t
23− // does is shown as `g1t`, so nobody else may be called either.
24− "g1t",
25− "g1t-agent",
2623 "about",
2724 "pricing",
2825 "terms",
5047 "notifications",
5148 ];
5249
50+/// g1t itself, and the name its agent once went by: everything g1t does is
51+/// shown as `g1t`, so nobody else may be called either. Not routes: staff
52+/// may point one at a workspace as an alias (identity's `aliases.rs`).
53+const OWN: &[&str] = &["g1t", "g1t-agent"];
54+
5355 /// Whether `value`, whatever its case, is a name nobody can register or
5456 /// rename a workspace to: a route, or g1t's own.
5557 pub fn is_reserved_name(value: &str) -> bool {
58+ is_route_name(value) || OWN.iter().any(|own| own.eq_ignore_ascii_case(value.trim()))
59+}
60+
61+/// Whether `value`, whatever its case, is one of the site's own routes.
62+pub fn is_route_name(value: &str) -> bool {
5663 let value = value.trim();
57− RESERVED.iter().any(|reserved| reserved.eq_ignore_ascii_case(value))
64+ ROUTES.iter().any(|route| route.eq_ignore_ascii_case(value))
65+}
66+
67+/// Whether `value` has a namespace's shape: letters, digits and single
68+/// hyphens, not starting or ending with a hyphen, at most 39 characters.
69+/// Reserved names have it too; see [`is_valid_namespace`].
70+pub fn is_namespace_shaped(value: &str) -> bool {
71+ let bytes = value.as_bytes();
72+ !bytes.is_empty()
73+ && bytes.len() <= 39
74+ && bytes
75+ .iter()
76+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
77+ && !value.starts_with('-')
78+ && !value.ends_with('-')
79+ && !value.contains("--")
5880 }
5981
82+/// A workspace alias as staff typed it, trimmed and lowercased, if it can
83+/// be one: shaped like a namespace and not one of the site's routes, which
84+/// would always answer first. Reserved names such as `g1t` can be; whether
85+/// a person or workspace already has it is identity's to check.
86+pub fn aliasable_name(value: &str) -> Option<String> {
87+ let value = value.trim().to_lowercase();
88+ (is_namespace_shaped(&value) && !is_route_name(&value)).then_some(value)
89+}
90+
6091 /// A username or workspace slug as someone typed it, trimmed and
6192 /// lowercased, if it can be registered: what signing up, signing up with
6293 /// GitHub and creating a workspace each take. None when it is malformed or
69100 /// Namespaces follow GitHub's rules: letters, digits and single hyphens,
70101 /// not starting or ending with a hyphen, at most 39 characters.
71102 pub fn is_valid_namespace(value: &str) -> bool {
72− let bytes = value.as_bytes();
73− !bytes.is_empty()
74− && bytes.len() <= 39
75− && bytes
76− .iter()
77− .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
78− && !value.starts_with('-')
79− && !value.ends_with('-')
80− && !value.contains("--")
81− && !is_reserved_name(value)
103+ is_namespace_shaped(value) && !is_reserved_name(value)
82104 }
83105
84106 pub fn is_valid_repo_name(value: &str) -> bool {
114136 assert_eq!(claimable_namespace(" Ana ").as_deref(), Some("ana"));
115137 assert_eq!(claimable_namespace("an--a"), None);
116138 }
139+
140+ #[test]
141+ fn g1t_can_be_an_alias_but_a_route_cannot() {
142+ assert_eq!(aliasable_name(" G1T ").as_deref(), Some("g1t"));
143+ assert_eq!(aliasable_name("acme-corp").as_deref(), Some("acme-corp"));
144+ for name in ["settings", "api", "login", "Explore", "-acme", "ac--me", "acme_inc", "", "a.b"] {
145+ assert_eq!(aliasable_name(name), None, "{name}");
146+ }
147+ assert_eq!(aliasable_name(&"a".repeat(40)), None);
148+ // Still nobody's to register.
149+ assert!(is_reserved_name("g1t") && !is_route_name("g1t"));
150+ }
117151 }
+21−0
1+-- Workspace aliases: a name g1t's staff point at a workspace, so its
2+-- addresses lead there under the workspace's own name. Staff-managed only,
3+-- from sudo; not a feature workspaces can use. An alias is a reserved or
4+-- unclaimed name, never a person's or a workspace's, and points at the
5+-- workspace's id, so it follows the workspace through renames. See
6+-- src/aliases.rs.
7+CREATE TABLE workspace_aliases (
8+ alias TEXT PRIMARY KEY,
9+ workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
10+ created_by TEXT NOT NULL,
11+ created_at TEXT NOT NULL,
12+ note TEXT NOT NULL DEFAULT ''
13+);
14+CREATE INDEX workspace_aliases_by_workspace ON workspace_aliases (workspace_id);
15+
16+-- g1t is the product Flagon, Inc. builds; flagon-io is the organization.
17+-- Nothing is added where flagon-io does not exist (a fresh self-hosted
18+-- install, a local database).
19+INSERT OR IGNORE INTO workspace_aliases (alias, workspace_id, created_by, created_at, note)
20+SELECT 'g1t', id, 'migration', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 'The product''s name, for Flagon, Inc.'
21+FROM workspaces WHERE slug = 'flagon-io' AND deleted_at IS NULL;
+383−0
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+}
+6−2
608608 self.db
609609 .prepare("DELETE FROM workspace_redirects WHERE workspace_id = ?")
610610 .bind(&[id.into()])?,
611+ // Aliases staff pointed at it lead nowhere now (aliases.rs).
612+ self.db
613+ .prepare("DELETE FROM workspace_aliases WHERE workspace_id = ?")
614+ .bind(&[id.into()])?,
611615 // Only while it is still deleted: a restore a moment ago wins.
612616 self.db
613617 .prepare("DELETE FROM workspaces WHERE id = ? AND deleted_at IS NOT NULL")
627631 }
628632
629633 /// An entry in the workspace's audit log, which outlives it.
630− async fn record_on_workspace(
634+ pub(crate) async fn record_on_workspace(
631635 &self,
632636 slug: &str,
633637 actor: AuditActor,
668672
669673 /// A line in sudo's audit log (billing keeps it), naming the staff
670674 /// member.
671− async fn record_for_staff(&self, slug: &str, action: &str, detail: &str, staff: &str) {
675+ pub(crate) async fn record_for_staff(&self, slug: &str, action: &str, detail: &str, staff: &str) {
672676 let Ok(billing) = self.env.service("BILLING") else {
673677 return;
674678 };
+6−0
55
66 mod access;
77 mod admin;
8+mod aliases;
89 mod avatars;
910 mod crypto;
1011 mod deletion;
736737 "rename_workspace" => reply(&identity.rename_workspace(args(body)?).await?),
737738 "check_workspace_rename" => reply(&identity.check_workspace_rename(args(body)?).await?),
738739 "resolve_slug" => reply(&identity.resolve_slug(args(body)?).await?),
740+ "resolve_alias" => reply(&identity.resolve_alias(args(body)?).await?),
739741 "check_workspace_deletion" => reply(&identity.check_workspace_deletion(args(body)?).await?),
740742 "delete_workspace" => reply(&identity.delete_workspace(args(body)?).await?),
741743 "transfer_repo_scopes" => reply(&identity.transfer_repo_scopes(args(body)?).await?),
891893 "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?),
892894 "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?),
893895 "admin_purge_workspace" => reply(&identity.admin_purge_workspace(args(body)?).await?),
896+ // Workspace aliases, set by staff only; see aliases.rs.
897+ "admin_aliases" => reply(&identity.admin_aliases().await?),
898+ "admin_set_alias" => reply(&identity.admin_set_alias(args(body)?).await?),
899+ "admin_remove_alias" => reply(&identity.admin_remove_alias(args(body)?).await?),
894900 _ => Response::error("Unknown method", 404),
895901 };
896902 served.finish(answered)
+22−6
5656 pub last_renamed_at: Option<&'a str>,
5757 /// A deleted workspace had `wanted`; it is never given to another.
5858 pub deleted: bool,
59+ /// Staff made `wanted` an alias (aliases.rs); it stays theirs.
60+ pub aliased: bool,
5961 pub now_ms: u64,
6062 }
6163
8183 );
8284 }
8385 }
84− if facts.someone_elses_username || facts.another_workspace || facts.deleted {
86+ if facts.someone_elses_username || facts.another_workspace || facts.deleted || facts.aliased {
8587 return refuse(FailureCode::Conflict, TAKEN);
8688 }
8789 if let Some((holder, created_at)) = facts.redirect
131133 }
132134
133135 /// Whether `slug` is an old slug still reserved for the workspace that
134− /// had it, so nobody else may register or create it.
136+ /// had it, or an alias staff set, so nobody else may register or create
137+ /// it.
135138 pub async fn slug_held(&self, slug: &str) -> Result<bool> {
136− Ok(resolve(self.redirect(slug).await?, now_ms()).is_some())
139+ Ok(resolve(self.redirect(slug).await?, now_ms()).is_some() || self.is_alias(slug).await?)
137140 }
138141
139− /// `resolve_slug`: the current slug for an old one still redirecting.
142+ /// `resolve_slug`: the current slug for an old one still redirecting,
143+ /// or for an alias (aliases.rs).
140144 pub async fn resolve_slug(&self, a: SlugArgs) -> Result<Option<String>> {
141145 let slug = a.slug.trim().to_lowercase();
142− if self.get_workspace(SlugArgs { slug: slug.clone() }).await?.is_some() {
146+ let in_use = self.get_workspace(SlugArgs { slug: slug.clone() }).await?.is_some();
147+ if in_use {
143148 return Ok(None);
144149 }
145− Ok(resolve(self.redirect(&slug).await?, now_ms()))
150+ let alias = self.resolve_alias(SlugArgs { slug: slug.clone() }).await?;
151+ let redirect = match alias {
152+ Some(_) => None,
153+ None => self.redirect(&slug).await?,
154+ };
155+ Ok(crate::aliases::resolve(in_use, alias, || resolve(redirect, now_ms())))
146156 }
147157
148158 /// Checks a rename, returning the workspace's id when it is allowed.
203213 .map(|row| (row.workspace_id.as_str(), row.created_at.as_str())),
204214 last_renamed_at: last_renamed_at.as_deref(),
205215 deleted: self.slug_deleted(&wanted).await?,
216+ aliased: self.is_alias(&wanted).await?,
206217 now_ms: now_ms(),
207218 };
208219 Ok(match check(&facts) {
364375 ..facts("acme", "initech")
365376 };
366377 assert_eq!(refused(&deleted), FailureCode::Conflict);
378+ let aliased = Facts {
379+ aliased: true,
380+ ..facts("acme", "flagon")
381+ };
382+ assert_eq!(refused(&aliased), FailureCode::Conflict);
367383 }
368384
369385 #[test]