Skip to content
961 linesCodeBlameRaw

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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1//! Deleting a workspace.
2//!
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member3//! Only an owner can, only a person, typing the slug to confirm, and only
4//! once billing can settle it (`close_workspace`: nothing owed that cannot
5//! be charged now, no failed invoice, no prepaid credit left). Some
6//! workspaces can never be deleted, by anyone: those in
7//! `PROTECTED_WORKSPACES`, Flagon's whatever that says
8//! (`g1t_contracts::identity::protected_names`), and any whose row is
9//! marked protected, which a rename of a protected workspace sets so the
10//! protection follows it.
11//!
12//! Everything in it goes in that one step, softly. The row gets
13//! `deleted_at`, `deleted_by` and `purge_after` ([`WORKSPACE_RESTORE_DAYS`]
14//! on), and every read that resolves a workspace leaves it out: nobody's
15//! memberships list it, its tokens are refused, its pages are not found.
16//! Its members and tokens are kept as they were, and its slug stays held by
17//! its row. `workspace.deleting` tells every service to put away what it
18//! keeps for it: repos deletes its repositories softly, marked as gone with
19//! it, deployments pauses its apps, projects and search hide it.
20//!
21//! Until `purge_after`, g1t's staff can restore it from sudo: the columns
22//! are cleared, `workspace.restored` undoes exactly what the deletion did,
23//! and it is back with its members and tokens. A malicious or mistaken
24//! deletion is undone this way, through support.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look25//!
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member26//! The purge, by the scheduled sweep once `purge_after` passes or by staff
27//! from sudo, is what deleting used to do at once: the row, memberships,
28//! tokens and old-slug redirects go, the slug is kept in
29//! `deleted_workspaces` so it is never given to another workspace or
30//! account, and `workspace.deleted` tells services to drop what they keep.
31//! Billing's ledger and invoices, and the audit log, keep its history under
32//! its slug. The one exception to the slug is the person whose username it
33//! is: usernames and workspaces share one namespace, so the name is theirs
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look34//! anyway, and they may make a workspace of it again (it starts empty).
35//!
36//! A person keeps their account whatever workspaces they lose: an account
37//! with no workspace, or with only other people's, works as any other.
Merge sudo: delete an account with the workspaces it alone owns, purge each38//!
39//! g1t's staff delete a workspace only together with the account that is
40//! its only owner (account_deletion.rs, `with_sole_workspaces`), through
41//! [`Identity::staff_delete_workspace`]: the same steps, the same refusals
42//! (protected, billing that cannot settle), with the staff member as
43//! `deleted_by` and a line in sudo's audit log with the reason.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look44
45use g1t_contracts::audit::{
46 AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface,
47};
48use g1t_contracts::billing::CloseWorkspaceArgs;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member49use g1t_contracts::events::{WorkspaceDeleted, WorkspaceDeleting, WorkspaceRestored};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look50use g1t_contracts::identity::*;
51use g1t_contracts::repos::NamespaceCountArgs;
52use g1t_contracts::time::rfc3339;
Merge sudo: delete an account with the workspaces it alone owns, purge each53use g1t_contracts::account_deletion::WorkspaceDeletedWith;
54use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, Role, User, new_id};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look55use g1t_kit::now_ms;
56use serde::Deserialize;
57use serde_json::json;
58use worker::Result;
59
60use crate::Identity;
61
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member62type Refusal = (FailureCode, String);
63
64/// How many workspaces one sweep purges.
65const PURGES_PER_SWEEP: u32 = 25;
66
67/// Who may delete, decided from the request and whether the workspace is
68/// protected: `Ok`, or why not. A protected workspace is refused to
69/// everyone, owners included.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look70pub fn may_delete(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member71 protected: bool,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look72 person: bool,
73 verified: bool,
74 role: Option<Role>,
75 slug: &str,
76 confirm: Option<&str>,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member77) -> std::result::Result<(), Refusal> {
78 if protected {
79 return Err((FailureCode::Forbidden, protected_refusal(slug)));
80 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look81 if !person || role != Some(Role::Owner) {
82 return Err((
83 FailureCode::Forbidden,
84 "Only an owner can delete a workspace.".into(),
85 ));
86 }
87 if !verified {
88 return Err((
89 FailureCode::Forbidden,
90 "Confirm your email address before deleting a workspace.".into(),
91 ));
92 }
93 if let Some(typed) = confirm
94 && typed.trim().to_lowercase() != slug
95 {
96 return Err((
97 FailureCode::Invalid,
98 format!("Type {slug} to confirm."),
99 ));
100 }
101 Ok(())
102}
103
104/// Whether `slug`, once a deleted workspace's, may be taken by the person
105/// whose username is `username`: only when it is that very name.
106pub fn may_reclaim(slug: &str, username: &str) -> bool {
107 slug.eq_ignore_ascii_case(username)
108}
109
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member110/// When a workspace deleted at `now_ms` is purged.
111pub fn purge_after(now_ms: u64) -> String {
112 rfc3339(now_ms + WORKSPACE_RESTORE_DAYS * 86_400_000)
113}
114
115/// Whether a workspace to be purged at `purge_after` can still be restored
116/// at `now` (both RFC 3339, which compare as text). Once it cannot, the
117/// next sweep purges it.
118pub fn restorable(purge_after: &str, now: &str) -> bool {
119 now < purge_after
120}
121
122/// Whether the workspace `id`, now at `slug`, is protected: its row says
123/// so (`flagged`), or `names` (from [`protected_names`]) holds its id, its
124/// slug or a slug it was renamed from (`old_slugs`).
125pub fn is_protected(names: &[String], id: &str, slug: &str, flagged: bool, old_slugs: &[String]) -> bool {
126 let named = |name: &str| names.iter().any(|protected| protected.eq_ignore_ascii_case(name));
127 flagged || named(id) || named(slug) || old_slugs.iter().any(|old| named(old))
128}
129
130/// Whether staff may restore a deleted workspace, now `now`.
131pub fn may_restore(slug: &str, purge_after: &str, now: &str) -> std::result::Result<(), Refusal> {
132 if !restorable(purge_after, now) {
133 return Err((
134 FailureCode::Conflict,
135 format!("{slug} is being purged and can no longer be restored."),
136 ));
137 }
138 Ok(())
139}
140
141/// Whether a deleted workspace may be purged, by staff (`confirm` is what
142/// they typed) or by the sweep (`None`). Never a protected one.
143pub fn may_purge(protected: bool, slug: &str, confirm: Option<&str>) -> std::result::Result<(), Refusal> {
144 if protected {
145 return Err((FailureCode::Forbidden, protected_refusal(slug)));
146 }
147 if let Some(typed) = confirm
148 && typed.trim().to_lowercase() != slug
149 {
150 return Err((FailureCode::Invalid, format!("Type {slug} to confirm.")));
151 }
152 Ok(())
153}
154
Merge sudo: delete an account with the workspaces it alone owns, purge each155/// Who billing's `close_workspace` sees when staff delete a workspace with
156/// the account that is its only owner: the account, as owner of `slug`
157/// (billing closes only for an owner), named by the staff member so
158/// billing's record says who closed it.
159pub fn staff_billing_actor(owner_id: &str, staff: &str, slug: &str) -> User {
160 User {
161 id: owner_id.to_owned(),
162 username: staff.to_owned(),
163 kind: PrincipalKind::User,
164 verified: true,
165 workspaces: vec![Membership { role: Role::Owner, ..Membership::member(slug) }],
166 ..User::default()
167 }
168}
169
170/// Who deletes a workspace, for its row, its audit log and the event.
171struct Deleter<'a> {
172 /// Who billing closes it for: an owner.
173 billing: &'a User,
174 /// `deleted_by` on its row: the owner's id, or the staff member.
175 deleted_by: &'a str,
176 /// `by` on `workspace.deleting`: the owner's username, or the staff
177 /// member.
178 by: &'a str,
179 audit: AuditActor,
180 surface: Surface,
181 /// The audit log's rule: `owner` or `staff`.
182 rule: &'static str,
183 /// What the audit log says, given when it can be restored until.
184 message: Box<dyn Fn(&str) -> String + 'a>,
185 /// The event's actor.
186 actor_id: Option<&'a str>,
187}
188
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member189/// What `deleted_went` holds: what went with the workspace.
190fn went_json(went: &WorkspaceDeletion) -> String {
191 json!({
192 "repositories": went.repositories,
193 "projects": went.projects,
194 "members": went.members,
195 })
196 .to_string()
197}
198
199fn went_of(stored: Option<&str>) -> WorkspaceDeletion {
200 stored
201 .and_then(|text| serde_json::from_str::<WorkspaceDeletion>(text).ok())
202 .unwrap_or_default()
203}
204
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look205#[derive(Deserialize)]
206struct Target {
207 id: String,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member208 #[serde(default)]
209 protected: u8,
210}
211
212/// A deleted workspace's row.
213#[derive(Deserialize)]
214struct DeletedRow {
215 id: String,
216 slug: String,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look217 name: String,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member218 deleted_at: String,
219 #[serde(default)]
220 deleted_by: Option<String>,
221 purge_after: String,
222 #[serde(default)]
223 deleted_went: Option<String>,
224 #[serde(default)]
225 protected: u8,
226 /// The deleter's username, when their account is still there.
227 #[serde(default)]
228 deleter: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look229}
230
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member231const DELETED_COLUMNS: &str = "w.id, w.slug, w.name, w.deleted_at, w.deleted_by, w.purge_after,
232 w.deleted_went, w.protected, u.username AS deleter
233 FROM workspaces w LEFT JOIN users u ON u.id = w.deleted_by
234 WHERE w.deleted_at IS NOT NULL";
235
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look236impl Identity {
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)237 /// Whether `slug` belonged to a workspace that was deleted and purged,
238 /// or is the username of an account that was (account_deletion.rs):
239 /// neither is ever given to anyone again.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look240 pub async fn slug_deleted(&self, slug: &str) -> Result<bool> {
241 Ok(self
242 .db
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)243 .prepare(
244 "SELECT 1 AS held FROM deleted_workspaces WHERE slug = ?1
245 UNION ALL SELECT 1 FROM deleted_users WHERE username = ?1",
246 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look247 .bind(&[slug.to_lowercase().into()])?
248 .first::<serde_json::Value>(None)
249 .await?
250 .is_some())
251 }
252
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member253 /// Whether a workspace holds `slug`, deleted or not: one deleted and
254 /// not yet purged keeps it for a restore.
255 pub async fn slug_in_use(&self, slug: &str) -> Result<bool> {
256 Ok(self
257 .db
258 .prepare("SELECT 1 AS held FROM workspaces WHERE slug = ?")
259 .bind(&[slug.to_lowercase().into()])?
260 .first::<serde_json::Value>(None)
261 .await?
262 .is_some())
263 }
264
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look265 /// A workspace made again under a deleted slug is a workspace again.
266 pub async fn forget_deleted(&self, slug: &str) -> Result<()> {
267 self.db
268 .prepare("DELETE FROM deleted_workspaces WHERE slug = ?")
269 .bind(&[slug.into()])?
270 .run()
271 .await?;
272 Ok(())
273 }
274
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member275 /// `PROTECTED_WORKSPACES`, with Flagon's whatever it says.
276 fn protected_names(&self) -> Vec<String> {
277 let configured = self.env.var("PROTECTED_WORKSPACES").ok().map(|v| v.to_string());
278 protected_names(configured.as_deref())
279 }
280
281 /// Whether the workspace `id`, now at `slug`, can never be deleted.
282 /// Asked each time, of its row, the variable and the slugs it was
283 /// renamed from, so a rename cannot take the protection away.
284 pub(crate) async fn is_protected(&self, id: &str, slug: &str, flagged: bool) -> Result<bool> {
285 let names = self.protected_names();
286 if is_protected(&names, id, slug, flagged, &[]) {
287 return Ok(true);
288 }
289 #[derive(Deserialize)]
290 struct Old {
291 old_slug: String,
292 }
293 let old: Vec<String> = self
294 .db
295 .prepare("SELECT old_slug FROM workspace_redirects WHERE workspace_id = ?")
296 .bind(&[id.into()])?
297 .all()
298 .await?
299 .results::<Old>()?
300 .into_iter()
301 .map(|row| row.old_slug)
302 .collect();
303 Ok(is_protected(&names, id, slug, flagged, &old))
304 }
305
Merge sudo: delete an account with the workspaces it alone owns, purge each306 /// Why billing could not close `slug` now for `actor` (an owner), or
307 /// `None` when it could. Changes nothing.
308 pub(crate) async fn billing_refusal(&self, actor: &User, slug: &str) -> Result<Option<String>> {
309 let closing: Outcome<bool> = g1t_kit::call(
310 &self.env.service("BILLING")?,
311 "close_workspace",
312 &CloseWorkspaceArgs {
313 actor: actor.clone(),
314 workspace: slug.to_owned(),
315 dry_run: true,
316 },
317 )
318 .await?;
319 Ok(match closing {
320 Outcome::Ok(_) => None,
321 Outcome::Fail(failure) => Some(failure.message),
322 })
323 }
324
325 /// What would go with the workspace, and whether billing can close it
326 /// for `actor`.
327 async fn deletion_facts(&self, actor: &User, slug: &str, target: &Target) -> Result<WorkspaceDeletion> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look328 let repositories: u32 = g1t_kit::call(
329 &self.env.service("REPOS")?,
330 "namespace_count",
331 &NamespaceCountArgs {
332 namespace: slug.to_owned(),
333 },
334 )
335 .await?;
336 let projects: u32 = g1t_kit::call(
337 &self.env.service("PROJECTS")?,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member338 "count",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look339 &json!({ "workspace": slug }),
340 )
341 .await?;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member342 #[derive(Deserialize)]
343 struct Count {
344 n: u32,
345 }
346 let members = self
347 .db
348 .prepare("SELECT count(*) AS n FROM workspace_members WHERE workspace_id = ?")
349 .bind(&[target.id.as_str().into()])?
350 .first::<Count>(None)
351 .await?
352 .map_or(0, |count| count.n);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look353 Ok(WorkspaceDeletion {
354 repositories,
355 projects,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member356 members,
Merge sudo: delete an account with the workspaces it alone owns, purge each357 billing: self.billing_refusal(actor, slug).await?,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member358 protected: self.is_protected(&target.id, slug, target.protected != 0).await?,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look359 })
360 }
361
362 /// The workspace, if the actor may delete it.
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member363 async fn deletable(&self, a: &DeleteWorkspaceArgs, confirm: bool) -> Result<Outcome<(Target, bool)>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look364 let slug = a.slug.trim().to_lowercase();
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member365 let Some(target) = self
366 .db
367 .prepare("SELECT id, protected FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
368 .bind(&[slug.as_str().into()])?
369 .first::<Target>(None)
370 .await?
371 else {
372 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
373 };
374 let protected = self.is_protected(&target.id, &slug, target.protected != 0).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look375 if let Err((code, message)) = may_delete(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member376 // Only the actual deletion is refused for it; the check says
377 // so in `protected`, for the page to show.
378 protected && confirm,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look379 a.actor.kind == PrincipalKind::User,
380 a.actor.verified,
381 a.actor.role_in(&slug),
382 &slug,
383 confirm.then_some(a.confirm.as_str()),
384 ) {
385 return Ok(Outcome::fail(code, message));
386 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member387 Ok(Outcome::Ok((target, protected)))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look388 }
389
390 pub async fn check_workspace_deletion(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<WorkspaceDeletion>> {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member391 let target = match self.deletable(&a, false).await? {
392 Outcome::Ok((target, _)) => target,
393 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
394 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look395 let slug = a.slug.trim().to_lowercase();
Merge sudo: delete an account with the workspaces it alone owns, purge each396 Ok(Outcome::Ok(self.deletion_facts(&a.actor, &slug, &target).await?))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look397 }
398
399 pub async fn delete_workspace(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<bool>> {
400 let workspace = match self.deletable(&a, true).await? {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member401 Outcome::Ok((workspace, _)) => workspace,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look402 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
403 };
404 let slug = a.slug.trim().to_lowercase();
Merge sudo: delete an account with the workspaces it alone owns, purge each405 let deleter = Deleter {
406 billing: &a.actor,
407 deleted_by: &a.actor.id,
408 by: &a.actor.username,
409 audit: AuditActor::of(&a.actor),
410 surface: a.surface.unwrap_or(Surface::Web),
411 rule: "owner",
412 message: Box::new(|purge| format!("Deleted {slug}; restorable by g1t's staff until {purge}")),
413 actor_id: Some(&a.actor.id),
414 };
415 self.soft_delete_workspace(&workspace, &slug, &deleter).await
416 }
417
418 /// g1t's staff delete a live workspace that `owner_id` (`owner`) is the
419 /// only owner of, as part of deleting that account (account_deletion.rs):
420 /// exactly as its owner would, refused the same way, with the staff
421 /// member as `deleted_by` and in sudo's audit log with `reason`.
422 pub(crate) async fn staff_delete_workspace(
423 &self,
424 slug: &str,
425 owner_id: &str,
426 owner: &str,
427 staff: &str,
428 reason: &str,
429 ) -> Result<Outcome<WorkspaceDeletedWith>> {
430 let slug = slug.trim().to_lowercase();
431 let Some(workspace) = self
432 .db
433 .prepare("SELECT id, protected FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
434 .bind(&[slug.as_str().into()])?
435 .first::<Target>(None)
436 .await?
437 else {
438 return Ok(Outcome::fail(FailureCode::NotFound, format!("{slug} is not a live workspace any more.")));
439 };
440 let billing = staff_billing_actor(owner_id, staff, &slug);
441 let deleter = Deleter {
442 billing: &billing,
443 deleted_by: staff,
444 by: staff,
445 // The workspace's members read its audit log: it says g1t's
446 // staff did it, and sudo's log says who and why.
447 audit: AuditActor::system(),
448 surface: Surface::Web,
449 rule: "staff",
450 message: Box::new(|purge| {
451 format!("Deleted {slug} by g1t's staff, with the account {owner} that was its only owner; restorable by g1t's staff until {purge}")
452 }),
453 actor_id: None,
454 };
455 let id = workspace.id.clone();
456 match self.soft_delete_workspace(&workspace, &slug, &deleter).await? {
457 Outcome::Ok(_) => {}
458 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
459 }
460 self.record_for_staff(
461 &slug,
462 "workspace_deleted",
463 &format!("Deleted {slug} with the account {owner}, its only owner: {reason}"),
464 staff,
465 )
466 .await;
467 Ok(Outcome::Ok(WorkspaceDeletedWith { workspace_id: id, slug: slug.clone() }))
468 }
469
470 /// Deletes a live workspace softly, as every deletion does: refused if
471 /// it is protected or billing cannot settle it; billing closes it for
472 /// real first; then its row is marked, its audit log says so and
473 /// `workspace.deleting` tells every service.
474 async fn soft_delete_workspace(&self, workspace: &Target, slug: &str, deleter: &Deleter<'_>) -> Result<Outcome<bool>> {
475 let went = self.deletion_facts(deleter.billing, slug, workspace).await?;
476 if let Some(reason) = went.reason(slug) {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member477 let code = if went.protected { FailureCode::Forbidden } else { FailureCode::PaymentRequired };
478 return Ok(Outcome::fail(code, reason));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look479 }
480 // Money first: if billing cannot settle it after all (a card
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member481 // declined a moment ago), nothing is deleted. Its plan ends now, so
482 // nothing more is charged while it waits to be purged.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look483 let closed: Outcome<bool> = g1t_kit::call(
484 &self.env.service("BILLING")?,
485 "close_workspace",
486 &CloseWorkspaceArgs {
Merge sudo: delete an account with the workspaces it alone owns, purge each487 actor: deleter.billing.clone(),
488 workspace: slug.to_owned(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look489 dry_run: false,
490 },
491 )
492 .await?;
493 if let Outcome::Fail(failure) = closed {
494 return Ok(Outcome::Fail(failure));
495 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member496 let now = now_ms();
497 let purge = purge_after(now);
498 self.db
499 .prepare(
500 "UPDATE workspaces SET deleted_at = ?, deleted_by = ?, purge_after = ?, deleted_went = ?
501 WHERE id = ? AND deleted_at IS NULL",
502 )
503 .bind(&[
504 rfc3339(now).into(),
Merge sudo: delete an account with the workspaces it alone owns, purge each505 deleter.deleted_by.into(),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member506 purge.as_str().into(),
507 went_json(&went).into(),
508 workspace.id.as_str().into(),
509 ])?
510 .run()
511 .await?;
512 self.record_on_workspace(
Merge sudo: delete an account with the workspaces it alone owns, purge each513 slug,
514 deleter.audit.clone(),
515 deleter.surface,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member516 "workspace.deleted",
Merge sudo: delete an account with the workspaces it alone owns, purge each517 deleter.rule,
518 (deleter.message)(&purge),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member519 )
520 .await;
521 self.announce(
522 "workspace.deleting",
Merge sudo: delete an account with the workspaces it alone owns, purge each523 deleter.actor_id,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member524 WorkspaceDeleting {
Merge sudo: delete an account with the workspaces it alone owns, purge each525 workspace_id: workspace.id.clone(),
526 slug: slug.to_owned(),
527 by: deleter.by.to_owned(),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member528 purge_after: purge,
529 },
530 )
531 .await;
532 Ok(Outcome::Ok(true))
533 }
534
535 /// Deleted workspaces not purged yet, newest first. Staff only.
536 pub async fn admin_deleted_workspaces(&self) -> Result<Vec<DeletedWorkspace>> {
537 let rows = self
538 .db
539 .prepare(format!("SELECT {DELETED_COLUMNS} ORDER BY w.deleted_at DESC LIMIT 500"))
540 .all()
541 .await?
542 .results::<DeletedRow>()?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look543 let now = rfc3339(now_ms());
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member544 let mut listed = Vec::with_capacity(rows.len());
545 for row in rows {
546 let mut went = went_of(row.deleted_went.as_deref());
547 went.protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
548 listed.push(DeletedWorkspace {
549 restorable: restorable(&row.purge_after, &now),
550 workspace_id: row.id,
551 slug: row.slug,
552 name: row.name,
553 deleted_at: row.deleted_at,
554 deleted_by: row.deleter.or(row.deleted_by).unwrap_or_default(),
555 purge_after: row.purge_after,
556 went,
557 });
558 }
559 Ok(listed)
560 }
561
562 async fn deleted_row(&self, id: &str) -> Result<Option<DeletedRow>> {
563 self.db
564 .prepare(format!("SELECT {DELETED_COLUMNS} AND w.id = ?"))
565 .bind(&[id.into()])?
566 .first::<DeletedRow>(None)
567 .await
568 }
569
570 /// Staff bring a deleted workspace back within its window, with its
571 /// members and tokens; `workspace.restored` brings back what went with
572 /// it. Staff only.
573 pub async fn admin_restore_workspace(&self, a: AdminDeletedWorkspaceArgs) -> Result<Outcome<bool>> {
574 let staff = a.staff.trim();
575 if staff.is_empty() {
576 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is restoring it."));
577 }
578 let Some(row) = self.deleted_row(&a.workspace_id).await? else {
579 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted workspace with that id."));
580 };
581 if let Err((code, message)) = may_restore(&row.slug, &row.purge_after, &rfc3339(now_ms())) {
582 return Ok(Outcome::fail(code, message));
583 }
584 self.db
585 .prepare(
586 "UPDATE workspaces SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL, deleted_went = NULL
587 WHERE id = ? AND deleted_at IS NOT NULL",
588 )
589 .bind(&[row.id.as_str().into()])?
590 .run()
591 .await?;
592 let message = format!(
593 "Restored by g1t's staff; deleted by {} at {}",
594 row.deleter.as_deref().or(row.deleted_by.as_deref()).unwrap_or("an owner"),
595 row.deleted_at
596 );
597 self.record_on_workspace(&row.slug, AuditActor::system(), Surface::Web, "workspace.restored", "staff", message)
598 .await;
599 self.record_for_staff(&row.slug, "workspace_restored", &format!("Restored {}", row.slug), staff)
600 .await;
601 self.announce(
602 "workspace.restored",
603 None,
604 WorkspaceRestored {
605 workspace_id: row.id,
606 slug: row.slug,
607 },
608 )
609 .await;
610 Ok(Outcome::Ok(true))
611 }
612
613 /// Staff purge a deleted workspace now rather than at `purge_after`.
614 /// Staff only.
615 pub async fn admin_purge_workspace(&self, a: AdminDeletedWorkspaceArgs) -> Result<Outcome<bool>> {
616 let staff = a.staff.trim();
617 if staff.is_empty() {
618 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is purging it."));
619 }
620 let Some(row) = self.deleted_row(&a.workspace_id).await? else {
621 return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted workspace with that id."));
622 };
623 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
624 if let Err((code, message)) = may_purge(protected, &row.slug, Some(&a.confirm)) {
625 return Ok(Outcome::fail(code, message));
626 }
627 self.purge(&row).await?;
628 self.record_on_workspace(
629 &row.slug,
630 AuditActor::system(),
631 Surface::Web,
632 "workspace.purged",
633 "staff",
634 "Purged by g1t's staff before its restore window ended".to_owned(),
635 )
636 .await;
637 self.record_for_staff(&row.slug, "workspace_purged", &format!("Purged {} now", row.slug), staff)
638 .await;
639 Ok(Outcome::Ok(true))
640 }
641
642 /// The sweep: purges deleted workspaces whose restore window has
643 /// passed. A protected one is never purged, however it came to be
644 /// deleted.
645 pub async fn purge_due_workspaces(&self) -> Result<u32> {
646 let due = self
647 .db
648 .prepare(format!(
649 "SELECT {DELETED_COLUMNS} AND w.purge_after <= ? ORDER BY w.purge_after LIMIT {PURGES_PER_SWEEP}"
650 ))
651 .bind(&[rfc3339(now_ms()).into()])?
652 .all()
653 .await?
654 .results::<DeletedRow>()?;
655 let mut purged = 0;
656 for row in due {
657 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
658 if let Err((_, why)) = may_purge(protected, &row.slug, None) {
659 worker::console_error!("{} not purged: {why}", row.slug);
660 continue;
661 }
662 match self.purge(&row).await {
663 Ok(()) => {
664 purged += 1;
665 self.record_on_workspace(
666 &row.slug,
667 AuditActor::system(),
668 Surface::Web,
669 "workspace.purged",
670 "schedule",
671 format!("Purged {WORKSPACE_RESTORE_DAYS} days after it was deleted"),
672 )
673 .await;
674 }
675 Err(error) => worker::console_error!("{} not purged: {error}", row.slug),
676 }
677 }
678 Ok(purged)
679 }
680
681 /// Removes a deleted workspace for good, as deleting one always did:
682 /// its row, memberships, tokens and redirects go, its slugs are kept in
683 /// `deleted_workspaces`, and `workspace.deleted` tells services to drop
684 /// what they keep for it.
685 async fn purge(&self, row: &DeletedRow) -> Result<()> {
686 let id = row.id.as_str();
687 let by = row.deleted_by.as_deref().unwrap_or_default();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look688 self.db
689 .batch(vec![
690 self.db
691 .prepare(
692 "INSERT OR REPLACE INTO deleted_workspaces (slug, workspace_id, name, deleted_by, deleted_at)
693 VALUES (?, ?, ?, ?, ?)",
694 )
695 .bind(&[
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member696 row.slug.as_str().into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look697 id.into(),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member698 row.name.as_str().into(),
699 by.into(),
700 row.deleted_at.as_str().into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look701 ])?,
702 // Slugs it was renamed from, still redirecting, are kept
703 // the same way.
704 self.db
705 .prepare(
706 "INSERT OR IGNORE INTO deleted_workspaces (slug, workspace_id, name, deleted_by, deleted_at)
707 SELECT old_slug, workspace_id, ?, ?, ? FROM workspace_redirects WHERE workspace_id = ?",
708 )
709 .bind(&[
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member710 row.name.as_str().into(),
711 by.into(),
712 row.deleted_at.as_str().into(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look713 id.into(),
714 ])?,
715 self.db
716 .prepare("DELETE FROM access_tokens WHERE workspace_id = ?")
717 .bind(&[id.into()])?,
718 self.db
719 .prepare("DELETE FROM workspace_members WHERE workspace_id = ?")
720 .bind(&[id.into()])?,
The apps you pin to your dock are kept with your account, per workspace and in your order, so the dock is the same on every device: identity keeps them in dock_pins and answers dock_pins and set_dock_pins, the dock reads them with the rest of the page, pins kept only on this device carry over with your first change, this device's copy still draws the dock when identity can't be reached, a pin that can't be saved says so, and they go when you or the workspace do; the workspaces guide says how.721 // Each member's dock pins in it (dock.rs).
722 self.db
723 .prepare("DELETE FROM dock_pins WHERE workspace_id = ?")
724 .bind(&[id.into()])?,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar725 // Its teams, their people and the roles they gave (teams.rs).
726 self.db
727 .prepare("DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?)")
728 .bind(&[id.into()])?,
729 self.db
730 .prepare("DELETE FROM repo_grants WHERE principal_kind = 'team' AND principal_id IN (SELECT id FROM teams WHERE workspace_id = ?)")
731 .bind(&[id.into()])?,
732 self.db
733 .prepare("DELETE FROM teams WHERE workspace_id = ?")
734 .bind(&[id.into()])?,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look735 self.db
736 .prepare("DELETE FROM workspace_redirects WHERE workspace_id = ?")
737 .bind(&[id.into()])?,
Merge branch 'worktree-agent-a8385d293d42c913a'738 // Aliases staff pointed at it lead nowhere now (aliases.rs).
739 self.db
740 .prepare("DELETE FROM workspace_aliases WHERE workspace_id = ?")
741 .bind(&[id.into()])?,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member742 // Only while it is still deleted: a restore a moment ago wins.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look743 self.db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member744 .prepare("DELETE FROM workspaces WHERE id = ? AND deleted_at IS NOT NULL")
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look745 .bind(&[id.into()])?,
746 ])
747 .await?;
748 self.announce(
749 "workspace.deleted",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member750 None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look751 WorkspaceDeleted {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member752 workspace_id: row.id.clone(),
753 slug: row.slug.clone(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look754 },
755 )
756 .await;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member757 Ok(())
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look758 }
759
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member760 /// An entry in the workspace's audit log, which outlives it.
Merge branch 'worktree-agent-a8385d293d42c913a'761 pub(crate) async fn record_on_workspace(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member762 &self,
763 slug: &str,
764 actor: AuditActor,
765 surface: Surface,
766 action: &str,
767 rule: &str,
768 message: String,
769 ) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look770 let Ok(events) = self.env.service("EVENTS") else {
771 return;
772 };
773 let entry = NewAuditEntry {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member774 actor,
775 action: action.to_owned(),
776 surface,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look777 target: AuditTarget {
778 workspace: slug.to_owned(),
779 ..AuditTarget::default()
780 },
781 outcome: AuditOutcome::Allowed,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member782 rule: rule.to_owned(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look783 result: Some("ok".to_owned()),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member784 message: Some(message),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look785 request_id: new_id("req", now_ms()),
786 };
787 let recorded: Result<u32> = g1t_kit::call(
788 &events,
789 "audit_record",
790 &RecordAuditArgs {
791 entries: vec![entry],
792 },
793 )
794 .await;
795 if let Err(error) = recorded {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member796 worker::console_error!("{action} of {slug} not recorded: {error}");
797 }
798 }
799
800 /// A line in sudo's audit log (billing keeps it), naming the staff
801 /// member.
Merge branch 'worktree-agent-a8385d293d42c913a'802 pub(crate) async fn record_for_staff(&self, slug: &str, action: &str, detail: &str, staff: &str) {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member803 let Ok(billing) = self.env.service("BILLING") else {
804 return;
805 };
806 let recorded: Result<bool> = g1t_kit::call(
807 &billing,
808 "admin_log",
809 &json!({ "workspace": slug, "action": action, "detail": detail, "by": staff }),
810 )
811 .await;
812 if let Err(error) = recorded {
813 worker::console_error!("{action} of {slug} by {staff} not recorded for sudo: {error}");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look814 }
815 }
816
817 /// `transfer_repo_scopes`: agents at work on a transferred repository
818 /// keep their scope, which names it by path.
819 pub async fn transfer_repo_scopes(&self, a: TransferRepoScopesArgs) -> Result<bool> {
820 self.db
821 .prepare(
822 "UPDATE access_tokens
823 SET agent_scope = json_set(agent_scope, '$.repo.namespace', ?1, '$.repo.name', ?2)
824 WHERE agent_scope IS NOT NULL
825 AND json_extract(agent_scope, '$.repo.namespace') = ?3
826 AND json_extract(agent_scope, '$.repo.name') = ?4",
827 )
828 .bind(&[
829 a.to.namespace.as_str().into(),
830 a.to.name.as_str().into(),
831 a.from.namespace.as_str().into(),
832 a.from.name.as_str().into(),
833 ])?
834 .run()
835 .await?;
836 // Who has access to it follows it too (access.rs).
837 self.move_repo_access(&a.from, &a.to).await?;
838 Ok(true)
839 }
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845
846 #[test]
847 fn only_a_verified_owner_who_types_the_name() {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member848 assert!(may_delete(false, true, true, Some(Role::Owner), "acme", Some(" Acme ")).is_ok());
849 assert!(may_delete(false, true, true, Some(Role::Owner), "acme", None).is_ok());
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look850 assert_eq!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member851 may_delete(false, true, true, Some(Role::Member), "acme", Some("acme")).unwrap_err().0,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look852 FailureCode::Forbidden
853 );
854 assert_eq!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member855 may_delete(false, false, true, Some(Role::Owner), "acme", Some("acme")).unwrap_err().0,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look856 FailureCode::Forbidden
857 );
858 assert_eq!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member859 may_delete(false, true, false, Some(Role::Owner), "acme", Some("acme")).unwrap_err().0,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look860 FailureCode::Forbidden
861 );
862 assert_eq!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member863 may_delete(false, true, true, Some(Role::Owner), "acme", Some("acme-inc")).unwrap_err().0,
864 FailureCode::Invalid
865 );
866 // Nothing typed is no confirmation.
867 assert_eq!(
868 may_delete(false, true, true, Some(Role::Owner), "acme", Some("")).unwrap_err().0,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look869 FailureCode::Invalid
870 );
871 }
872
873 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member874 fn a_protected_workspace_is_refused_to_every_caller() {
875 let refused = |person: bool, verified: bool, role: Option<Role>| {
876 may_delete(true, person, verified, role, "flagon-io", Some("flagon-io")).unwrap_err()
877 };
878 // An owner who types the name, a workspace's token, a member, anyone.
879 for (person, verified, role) in [
880 (true, true, Some(Role::Owner)),
881 (false, true, Some(Role::Owner)),
882 (true, true, Some(Role::Member)),
883 (true, false, None),
884 ] {
885 let (code, message) = refused(person, verified, role);
886 assert_eq!(code, FailureCode::Forbidden);
887 assert_eq!(message, "flagon-io is protected and can never be deleted.");
888 }
889 // Neither the sweep nor staff purge it, typed or not.
890 assert_eq!(may_purge(true, "flagon-io", None).unwrap_err().0, FailureCode::Forbidden);
891 assert_eq!(may_purge(true, "flagon-io", Some("flagon-io")).unwrap_err().0, FailureCode::Forbidden);
892 }
893
894 #[test]
895 fn protection_follows_the_workspace_through_a_rename() {
896 let names = protected_names(Some(""));
897 assert!(is_protected(&names, "wsp_1", "flagon-io", false, &[]));
898 assert!(is_protected(&names, "wsp_1", "FLAGON-IO", false, &[]));
899 // Renamed away: its old slug, or the mark on its row, still holds.
900 assert!(is_protected(&names, "wsp_1", "flagon", false, &["flagon-io".into()]));
901 assert!(is_protected(&names, "wsp_1", "flagon", true, &[]));
902 // Named by id in the variable.
903 assert!(is_protected(&protected_names(Some("wsp_9")), "wsp_9", "anything", false, &[]));
904 assert!(!is_protected(&names, "wsp_2", "acme", false, &["acme-old".into()]));
905 }
906
907 #[test]
908 fn staff_purge_only_with_the_name_typed() {
909 assert!(may_purge(false, "acme", None).is_ok());
910 assert!(may_purge(false, "acme", Some(" ACME ")).is_ok());
911 assert_eq!(may_purge(false, "acme", Some("")).unwrap_err().0, FailureCode::Invalid);
912 assert_eq!(may_purge(false, "acme", Some("acme-inc")).unwrap_err().0, FailureCode::Invalid);
913 }
914
915 #[test]
916 fn a_deleted_workspace_is_restorable_for_thirty_days_then_due() {
917 let deleted = 1_790_000_000_000;
918 let purge = purge_after(deleted);
919 assert_eq!(purge, rfc3339(deleted + 30 * 86_400_000));
920 assert!(restorable(&purge, &rfc3339(deleted)));
921 assert!(restorable(&purge, &rfc3339(deleted + 29 * 86_400_000)));
922 assert!(!restorable(&purge, &purge));
923 assert!(!restorable(&purge, &rfc3339(deleted + 31 * 86_400_000)));
924 assert!(may_restore("acme", &purge, &rfc3339(deleted + 86_400_000)).is_ok());
925 assert_eq!(
926 may_restore("acme", &purge, &rfc3339(deleted + 30 * 86_400_000)).unwrap_err(),
927 (FailureCode::Conflict, "acme is being purged and can no longer be restored.".to_owned())
928 );
929 }
930
931 #[test]
932 fn what_went_is_kept_and_read_back() {
933 let went = WorkspaceDeletion {
934 repositories: 4,
935 projects: 2,
936 members: 3,
937 billing: None,
938 protected: false,
939 };
940 assert_eq!(went_of(Some(&went_json(&went))), went);
941 assert_eq!(went_of(None), WorkspaceDeletion::default());
942 assert_eq!(went_of(Some("not json")), WorkspaceDeletion::default());
943 }
944
945 #[test]
Merge sudo: delete an account with the workspaces it alone owns, purge each946 fn billing_sees_staff_as_the_owner_closing_it_named_by_the_staff_member() {
947 let actor = staff_billing_actor("usr_ada", "staff@g1t.sh", "ada");
948 assert_eq!(actor.role_in("ada"), Some(Role::Owner));
949 assert_eq!(actor.role_in("globex"), None);
950 assert_eq!(actor.username, "staff@g1t.sh");
951 assert_eq!(actor.id, "usr_ada");
952 assert_eq!(actor.kind, PrincipalKind::User);
953 }
954
955 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look956 fn a_deleted_slug_is_reclaimed_only_by_its_namesake() {
957 assert!(may_reclaim("syntaqx", "syntaqx"));
958 assert!(may_reclaim("syntaqx", "Syntaqx"));
959 assert!(!may_reclaim("flagon-io", "syntaqx"));
960 }
961}

This file's history is long; its oldest lines are credited to the oldest commit read.