flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/identity/src/rename.rs

451 lines17,544 bytesCodeBlame

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.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1//! Renaming a workspace: changing its slug, the first segment of its URLs,
2//! the way GitHub renames an organization.
3//!
4//! The workspace keeps its id, members, tokens and display name. Its old
5//! slug is recorded in `workspace_redirects`, pointing at the workspace's
6//! id, so that old addresses resolve to whatever the slug is now: renaming
7//! twice chains, because every old slug points at the same id. An old slug
8//! stays reserved for the workspace that had it for [`SLUG_HOLD_DAYS`], so
9//! nobody else can take it while links to it still redirect; the workspace
10//! itself can rename back to it. Renames are limited to one per
11//! [`RENAME_COOLDOWN_HOURS`] to stop churn.
12//!
13//! The rename publishes `workspace.renamed`; every other service moves the
14//! rows it keeps under the slug when it hears it.
15
16use g1t_contracts::events::{NewEvent, Publish, WorkspaceRenamed};
17use g1t_contracts::identity::*;
18use g1t_contracts::time::rfc3339;
19use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, is_valid_namespace};
20use g1t_kit::now_ms;
21use serde::Deserialize;
22use worker::Result;
23
24use crate::Identity;
25
26const HOUR_MS: u64 = 60 * 60 * 1000;
27const DAY_MS: u64 = 24 * HOUR_MS;
28const SOURCE: &str = "identity";
29const TAKEN: &str = "That workspace name is taken.";
30/// How many times publishing the event is tried before giving up.
31const PUBLISH_ATTEMPTS: u32 = 3;
32
33/// The earliest `created_at` of a redirect that still holds its slug.
34pub fn hold_cutoff(now_ms: u64) -> String {
35 rfc3339(now_ms.saturating_sub(SLUG_HOLD_DAYS * DAY_MS))
36}
37
38/// Everything about a wanted slug that decides whether a workspace may
39/// take it, as read from the database.
40#[derive(Debug, Default)]
41pub struct Facts<'a> {
42 /// The workspace's id and its slug now.
43 pub workspace_id: &'a str,
44 pub current: &'a str,
45 /// The slug asked for, lowercased and trimmed.
46 pub wanted: &'a str,
47 /// Another person's username is `wanted`. The actor's own is theirs
48 /// to use, as when creating a workspace.
49 pub someone_elses_username: bool,
50 /// Another workspace's slug is `wanted`.
51 pub another_workspace: bool,
52 /// A redirect holds `wanted`: the workspace it points at, and when it
53 /// was made.
54 pub redirect: Option<(&'a str, &'a str)>,
55 /// When the workspace was last renamed, if ever.
56 pub last_renamed_at: Option<&'a str>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look57 /// A deleted workspace had `wanted`; it is never given to another.
58 pub deleted: bool,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains59 pub now_ms: u64,
60}
61
62/// Whether the rename `facts` describe is allowed: `Ok`, or why not, in
63/// words for the owner.
64pub fn check(facts: &Facts) -> std::result::Result<(), (FailureCode, String)> {
65 let refuse = |code, message: &str| Err((code, message.to_owned()));
66 if !is_valid_namespace(facts.wanted) {
67 return refuse(
68 FailureCode::Invalid,
69 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.",
70 );
71 }
72 if facts.wanted == facts.current {
73 return refuse(FailureCode::Invalid, "That is already this workspace's name.");
74 }
75 if let Some(last) = facts.last_renamed_at {
76 let cooldown_from = rfc3339(facts.now_ms.saturating_sub(RENAME_COOLDOWN_HOURS * HOUR_MS));
77 if last > cooldown_from.as_str() {
78 return refuse(
79 FailureCode::Conflict,
80 "A workspace can be renamed once a day. Try again tomorrow.",
81 );
82 }
83 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look84 if facts.someone_elses_username || facts.another_workspace || facts.deleted {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains85 return refuse(FailureCode::Conflict, TAKEN);
86 }
87 if let Some((holder, created_at)) = facts.redirect
88 && holder != facts.workspace_id
89 && created_at >= hold_cutoff(facts.now_ms).as_str()
90 {
91 return refuse(FailureCode::Conflict, TAKEN);
92 }
93 Ok(())
94}
95
96/// A redirect as read with the slug its workspace has now.
97#[derive(Debug, Deserialize)]
98pub struct RedirectRow {
99 pub workspace_id: String,
100 /// The workspace's slug now.
101 pub slug: String,
102 pub created_at: String,
103}
104
105/// Where an old slug leads: the workspace's current slug, while the
106/// redirect still holds.
107pub fn resolve(row: Option<RedirectRow>, now_ms: u64) -> Option<String> {
108 row.filter(|row| row.created_at >= hold_cutoff(now_ms))
109 .map(|row| row.slug)
110}
111
112#[derive(Deserialize)]
113struct Target {
114 id: String,
115}
116
117impl Identity {
118 /// The redirect holding `slug`, if any, with its workspace's slug now.
119 async fn redirect(&self, slug: &str) -> Result<Option<RedirectRow>> {
120 self.db
121 .prepare(
122 "SELECT workspace_redirects.workspace_id, workspaces.slug,
123 workspace_redirects.created_at
124 FROM workspace_redirects
125 JOIN workspaces ON workspaces.id = workspace_redirects.workspace_id
126 WHERE workspace_redirects.old_slug = ?",
127 )
128 .bind(&[slug.into()])?
129 .first::<RedirectRow>(None)
130 .await
131 }
132
133 /// Whether `slug` is an old slug still reserved for the workspace that
134 /// had it, so nobody else may register or create it.
135 pub async fn slug_held(&self, slug: &str) -> Result<bool> {
136 Ok(resolve(self.redirect(slug).await?, now_ms()).is_some())
137 }
138
139 /// `resolve_slug`: the current slug for an old one still redirecting.
140 pub async fn resolve_slug(&self, a: SlugArgs) -> Result<Option<String>> {
141 let slug = a.slug.trim().to_lowercase();
142 if self.get_workspace(SlugArgs { slug: slug.clone() }).await?.is_some() {
143 return Ok(None);
144 }
145 Ok(resolve(self.redirect(&slug).await?, now_ms()))
146 }
147
148 /// Checks a rename, returning the workspace's id when it is allowed.
149 async fn rename_allowed(&self, a: &RenameWorkspaceArgs) -> Result<Outcome<(String, String)>> {
150 let current = a.slug.trim().to_lowercase();
151 let wanted = a.new_slug.trim().to_lowercase();
152 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&current) != Some(Role::Owner) {
153 return Ok(Outcome::fail(
154 FailureCode::Forbidden,
155 "Only an owner can rename a workspace.",
156 ));
157 }
158 if !a.actor.verified {
159 return Ok(Outcome::fail(
160 FailureCode::Forbidden,
161 "Confirm your email address before renaming a workspace.",
162 ));
163 }
164 let Some(workspace) = self
165 .db
166 .prepare("SELECT id FROM workspaces WHERE slug = ?")
167 .bind(&[current.as_str().into()])?
168 .first::<Target>(None)
169 .await?
170 else {
171 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
172 };
173 let someone_elses_username = self
174 .db
175 .prepare("SELECT id FROM users WHERE username = ? AND id != ?")
176 .bind(&[wanted.as_str().into(), a.actor.id.as_str().into()])?
177 .first::<serde_json::Value>(None)
178 .await?
179 .is_some();
180 let another_workspace = self
181 .db
182 .prepare("SELECT id FROM workspaces WHERE slug = ? AND id != ?")
183 .bind(&[wanted.as_str().into(), workspace.id.as_str().into()])?
184 .first::<serde_json::Value>(None)
185 .await?
186 .is_some();
187 let redirect = self.redirect(&wanted).await?;
188 let last_renamed_at = self
189 .db
190 .prepare("SELECT max(created_at) AS at FROM workspace_redirects WHERE workspace_id = ?")
191 .bind(&[workspace.id.as_str().into()])?
192 .first::<Option<String>>(Some("at"))
193 .await?
194 .flatten();
195 let facts = Facts {
196 workspace_id: &workspace.id,
197 current: &current,
198 wanted: &wanted,
199 someone_elses_username,
200 another_workspace,
201 redirect: redirect
202 .as_ref()
203 .map(|row| (row.workspace_id.as_str(), row.created_at.as_str())),
204 last_renamed_at: last_renamed_at.as_deref(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look205 deleted: self.slug_deleted(&wanted).await?,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains206 now_ms: now_ms(),
207 };
208 Ok(match check(&facts) {
209 Ok(()) => Outcome::Ok((workspace.id, wanted)),
210 Err((code, message)) => Outcome::fail(code, message),
211 })
212 }
213
214 pub async fn check_workspace_rename(&self, a: RenameWorkspaceArgs) -> Result<Outcome<bool>> {
215 Ok(match self.rename_allowed(&a).await? {
216 Outcome::Ok(_) => Outcome::Ok(true),
217 Outcome::Fail(failure) => Outcome::Fail(failure),
218 })
219 }
220
221 pub async fn rename_workspace(&self, a: RenameWorkspaceArgs) -> Result<Outcome<Workspace>> {
222 let (workspace_id, wanted) = match self.rename_allowed(&a).await? {
223 Outcome::Ok(allowed) => allowed,
224 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
225 };
226 let from = a.slug.trim().to_lowercase();
227 let now = rfc3339(now_ms());
228 self.db
229 .batch(vec![
230 // A redirect the workspace is renaming back to, or one whose
231 // hold has ended, gives way to the slug in use.
232 self.db
233 .prepare("DELETE FROM workspace_redirects WHERE old_slug = ?")
234 .bind(&[wanted.as_str().into()])?,
235 self.db
236 .prepare("UPDATE workspaces SET slug = ? WHERE id = ? AND slug = ?")
237 .bind(&[
238 wanted.as_str().into(),
239 workspace_id.as_str().into(),
240 from.as_str().into(),
241 ])?,
242 self.db
243 .prepare(
244 "INSERT OR REPLACE INTO workspace_redirects (old_slug, workspace_id, created_at)
245 VALUES (?, ?, ?)",
246 )
247 .bind(&[
248 from.as_str().into(),
249 workspace_id.as_str().into(),
250 now.as_str().into(),
251 ])?,
252 // Agents at work keep their scope: it names the repository
253 // by its path.
254 self.db
255 .prepare(
256 "UPDATE access_tokens SET agent_scope = json_set(agent_scope, '$.repo.namespace', ?)
257 WHERE agent_scope IS NOT NULL
258 AND json_extract(agent_scope, '$.repo.namespace') = ?",
259 )
260 .bind(&[wanted.as_str().into(), from.as_str().into()])?,
261 ])
262 .await?;
263 self.publish_renamed(WorkspaceRenamed {
264 workspace_id,
265 from,
266 to: wanted.clone(),
267 }, &a.actor.id)
268 .await;
269 Ok(match self.get_workspace(SlugArgs { slug: wanted }).await? {
270 Some(workspace) => Outcome::Ok(workspace),
271 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
272 })
273 }
274
275 /// Tells every other service. The rename has happened by now, so a
276 /// failure is logged rather than undoing it.
277 async fn publish_renamed(&self, renamed: WorkspaceRenamed, actor: &str) {
278 let events = match self.env.service("EVENTS") {
279 Ok(events) => events,
280 Err(error) => {
281 worker::console_error!("workspace.renamed not published: {error}");
282 return;
283 }
284 };
285 let publish = Publish {
286 events: vec![NewEvent {
287 kind: "workspace.renamed",
288 source: SOURCE,
289 repo_id: None,
290 actor: Some(actor.to_owned()),
291 data: renamed,
292 }],
293 };
294 for attempt in 1..=PUBLISH_ATTEMPTS {
295 match g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await {
296 Ok(_) => return,
297 Err(error) => worker::console_error!(
298 "workspace.renamed publish attempt {attempt} failed: {error}"
299 ),
300 }
301 }
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use std::collections::HashMap;
309
310 const NOW: u64 = 1_790_918_179_123;
311
312 fn facts<'a>(current: &'a str, wanted: &'a str) -> Facts<'a> {
313 Facts {
314 workspace_id: "wsp_a",
315 current,
316 wanted,
317 now_ms: NOW,
318 ..Facts::default()
319 }
320 }
321
322 fn refused(facts: &Facts) -> FailureCode {
323 check(facts).unwrap_err().0
324 }
325
326 #[test]
327 fn validates_like_creation() {
328 assert!(check(&facts("acme", "acme-inc")).is_ok());
329 for bad in ["", "-acme", "acme-", "ac--me", "Acme", "acme_inc", "api", "settings", "pulls"] {
330 assert_eq!(refused(&facts("acme", bad)), FailureCode::Invalid, "{bad}");
331 }
332 assert_eq!(refused(&facts("acme", &"a".repeat(40))), FailureCode::Invalid);
333 assert_eq!(refused(&facts("acme", "acme")), FailureCode::Invalid);
334 }
335
336 #[test]
337 fn refuses_names_in_use() {
338 let taken = Facts {
339 someone_elses_username: true,
340 ..facts("acme", "bob")
341 };
342 assert_eq!(refused(&taken), FailureCode::Conflict);
343 let taken = Facts {
344 another_workspace: true,
345 ..facts("acme", "globex")
346 };
347 assert_eq!(refused(&taken), FailureCode::Conflict);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look348 let deleted = Facts {
349 deleted: true,
350 ..facts("acme", "initech")
351 };
352 assert_eq!(refused(&deleted), FailureCode::Conflict);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains353 }
354
355 #[test]
356 fn an_old_slug_is_held_for_its_workspace_until_the_hold_ends() {
357 let recently = rfc3339(NOW - 10 * DAY_MS);
358 let long_ago = rfc3339(NOW - (SLUG_HOLD_DAYS + 1) * DAY_MS);
359 let held_by_other = Facts {
360 redirect: Some(("wsp_b", recently.as_str())),
361 ..facts("acme", "globex")
362 };
363 assert_eq!(refused(&held_by_other), FailureCode::Conflict);
364 let held_by_self = Facts {
365 redirect: Some(("wsp_a", recently.as_str())),
366 last_renamed_at: Some(recently.as_str()),
367 ..facts("acme-inc", "acme")
368 };
369 assert!(check(&held_by_self).is_ok(), "a workspace can rename back");
370 let expired = Facts {
371 redirect: Some(("wsp_b", long_ago.as_str())),
372 ..facts("acme", "globex")
373 };
374 assert!(check(&expired).is_ok(), "after the hold anyone can take it");
375 }
376
377 #[test]
378 fn renames_are_limited_to_one_a_day() {
379 let an_hour_ago = rfc3339(NOW - HOUR_MS);
380 let two_days_ago = rfc3339(NOW - 2 * DAY_MS);
381 let soon = Facts {
382 last_renamed_at: Some(an_hour_ago.as_str()),
383 ..facts("acme", "acme-inc")
384 };
385 assert_eq!(refused(&soon), FailureCode::Conflict);
386 let later = Facts {
387 last_renamed_at: Some(two_days_ago.as_str()),
388 ..facts("acme", "acme-inc")
389 };
390 assert!(check(&later).is_ok());
391 }
392
393 #[test]
394 fn hold_ends_after_the_hold_period() {
395 assert_eq!(hold_cutoff(NOW), rfc3339(NOW - SLUG_HOLD_DAYS * DAY_MS));
396 }
397
398 /// The tables, as `rename_workspace` changes them: slug by workspace
399 /// id, and old slug → (workspace id, when).
400 #[derive(Default)]
401 struct Tables {
402 workspaces: HashMap<&'static str, String>,
403 redirects: HashMap<String, (&'static str, String)>,
404 }
405
406 impl Tables {
407 /// The same steps, in the same order, as the batch.
408 fn rename(&mut self, id: &'static str, to: &str, at: u64) {
409 self.redirects.remove(to);
410 let from = self.workspaces.insert(id, to.to_owned()).unwrap();
411 self.redirects.insert(from, (id, rfc3339(at)));
412 }
413
414 /// As `redirect` + `resolve`.
415 fn resolve(&self, slug: &str, now: u64) -> Option<String> {
416 let row = self.redirects.get(slug).map(|(id, created_at)| RedirectRow {
417 workspace_id: (*id).to_owned(),
418 slug: self.workspaces[id].clone(),
419 created_at: created_at.clone(),
420 });
421 resolve(row, now)
422 }
423 }
424
425 #[test]
426 fn renames_chain_to_the_current_slug() {
427 let mut tables = Tables::default();
428 tables.workspaces.insert("wsp_a", "acme".into());
429 tables.rename("wsp_a", "acme-inc", NOW);
430 tables.rename("wsp_a", "acme-corp", NOW + 2 * DAY_MS);
431 let later = NOW + 3 * DAY_MS;
432 assert_eq!(tables.resolve("acme", later).as_deref(), Some("acme-corp"));
433 assert_eq!(tables.resolve("acme-inc", later).as_deref(), Some("acme-corp"));
434 assert_eq!(tables.resolve("unknown", later), None);
435 // Past the hold, the first old slug stops redirecting.
436 let much_later = NOW + (SLUG_HOLD_DAYS + 1) * DAY_MS;
437 assert_eq!(tables.resolve("acme", much_later), None);
438 assert_eq!(tables.resolve("acme-inc", much_later).as_deref(), Some("acme-corp"));
439 }
440
441 #[test]
442 fn renaming_back_drops_the_redirect_for_the_slug_in_use() {
443 let mut tables = Tables::default();
444 tables.workspaces.insert("wsp_a", "acme".into());
445 tables.rename("wsp_a", "acme-inc", NOW);
446 tables.rename("wsp_a", "acme", NOW + 2 * DAY_MS);
447 assert!(!tables.redirects.contains_key("acme"));
448 let later = NOW + 3 * DAY_MS;
449 assert_eq!(tables.resolve("acme-inc", later).as_deref(), Some("acme"));
450 }
451}