Skip to content

Commit

Identity: one free workspace per person, and a free workspace adds no one

create_workspace refuses a second free workspace (those owned from before are kept); add_member, invite_member, add_collaborator for outsiders, and accepting an invite or invitation are refused with payment_required until the workspace starts the plan. @g1t never counts. Asked of billing's free_workspaces; refused, never let through, when billing cannot answer.

syntaqxcommitted Parent8f9eaa9Browse files
5 files+202−10/5 viewed
+23−0
658658 let Invitee::Email(email) = invitee else {
659659 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
660660 };
661+ // An address is always someone from outside: a free workspace
662+ // invites no one (paid.rs).
663+ if let Some(refused) = self.free_workspace_refusal(&repo.namespace).await? {
664+ return Ok(refused);
665+ }
661666 return self.invite_address(&a.actor, &repo, &workspace_id, &email, a.role, surface).await;
662667 };
663668 // What the workspace asks of anyone with access to it (security.rs).
680685 format!("{username} already has access to {}. Change their role instead.", full_name(&repo)),
681686 ));
682687 }
688+ // An outside collaborator is someone added: a free workspace adds
689+ // no one (paid.rs). Its members' roles above are its own business,
690+ // and g1t's agent is never someone added.
691+ if !crate::paid::is_g1t(&username)
692+ && let Some(refused) = self.free_workspace_refusal(&repo.namespace).await?
693+ {
694+ return Ok(refused);
695+ }
683696 let pending = self
684697 .pending_invitations(
685698 "WHERE ri.repo_id = ? AND ri.invitee_id = ?",
10381051 if let Some(why) = self.policy_refusal(&a.user.id, &row.workspace).await? {
10391052 return Ok(Outcome::fail(FailureCode::Forbidden, why));
10401053 }
1054+ // Sent before the workspace was free, or before this rule: it waits
1055+ // until the workspace starts the plan (paid.rs).
1056+ if let Some(refused) = self.free_workspace_refusal(&row.workspace).await? {
1057+ return Ok(refused);
1058+ }
10411059 self.accept(&row, &a.user).await?;
10421060 let mut shown = row.shown(&now, false);
10431061 shown.status = RepoInvitationStatus::Accepted;
11021120 if self.policy_refusal(&user.id, &row.workspace).await?.is_some() {
11031121 continue;
11041122 }
1123+ // A free workspace adds no one (paid.rs): the invitation stays
1124+ // pending until it starts the plan.
1125+ if self.is_free_workspace(&row.workspace).await {
1126+ continue;
1127+ }
11051128 self.accept(&row, user).await?;
11061129 }
11071130 Ok(())
+21−1
730730 /// Joins the invite's workspace, and tells the event log and audit log.
731731 async fn after_redeemed(&self, row: &InviteRow, user: &User, created_account: bool) -> Result<()> {
732732 let mut joined = None;
733− if let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) {
733+ // A free workspace adds no one (paid.rs): a sign-up with an invite
734+ // from one sent before still makes the account, without joining.
735+ let free = match &row.workspace {
736+ Some(slug) => self.is_free_workspace(slug).await,
737+ None => false,
738+ };
739+ if let (Some(workspace_id), Some(slug), false) = (&row.workspace_id, &row.workspace, free) {
734740 self.db
735741 .prepare(
736742 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
12001206 {
12011207 return Ok(Outcome::fail(FailureCode::Forbidden, why));
12021208 }
1209+ // An invite sent before the workspace was free waits until it
1210+ // starts the plan (paid.rs); the code is not used up.
1211+ let joins_slug = row.workspace.clone().or_else(|| {
1212+ repository.as_ref().and_then(|r| r.name.split_once('/').map(|(workspace, _)| workspace.to_owned()))
1213+ });
1214+ if let Some(slug) = joins_slug.as_deref()
1215+ && let Some(refused) = self.free_workspace_refusal(slug).await?
1216+ {
1217+ return Ok(refused);
1218+ }
12031219 let claimed = self
12041220 .db
12051221 .prepare(format!(
12381254 let Some(workspace_id) = self.workspace_id(&slug).await? else {
12391255 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
12401256 };
1257+ // A free workspace invites no one until it starts the plan (paid.rs).
1258+ if let Some(refused) = self.free_workspace_refusal(&slug).await? {
1259+ return Ok(refused);
1260+ }
12411261 if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? {
12421262 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
12431263 }
+1−0
1515 mod github;
1616 mod invites;
1717 mod oauth;
18+mod paid;
1819 mod profiles;
1920 mod rename;
2021 mod run_credentials;
+146−0
1+//! What a free workspace may not do, asked of billing (`free_workspaces`).
2+//!
3+//! - **One free workspace per person.** A person may own at most one
4+//! workspace on no paid plan. Paid is the g1t plan, an enterprise's terms
5+//! or a 100% discount (g1t's own workspaces). Making a second free one is
6+//! refused with the way forward: start the plan on the one they have, or
7+//! delete it. People who own several from before keep them, and cannot
8+//! make more until all but one are paid for.
9+//! - **No one added to a free workspace.** It cannot add members, send
10+//! invites, or invite outside collaborators to its repositories, and an
11+//! invite sent before cannot be accepted, until it starts the plan. Its
12+//! members stay. g1t's own agent (@g1t) is never a member for this.
13+//!
14+//! Without billing bound (a g1t that does not take payments) nothing is
15+//! free and nothing is refused. When billing cannot be asked, the change
16+//! is refused for now, never let through unchecked.
17+
18+use g1t_contracts::billing::FreeWorkspacesArgs;
19+use g1t_contracts::{FailureCode, Outcome, Role};
20+use worker::Result;
21+
22+use crate::Identity;
23+
24+/// Why a person cannot make another free workspace: they own `free`
25+/// already. None when they own none.
26+pub(crate) fn second_free_refusal(free: &[String]) -> Option<String> {
27+ let first = free.first()?;
28+ let (owned, them) = if free.len() == 1 {
29+ (format!("You already own a free workspace, {first}"), "it")
30+ } else {
31+ (format!("You already own {} free workspaces ({})", free.len(), free.join(", ")), "each of them")
32+ };
33+ Some(format!(
34+ "{owned}, and each person can own one workspace that is not on the g1t plan. A new workspace starts free: to make one, start the plan on {them} (/{first}/-/billing#plan), or delete a free workspace you no longer use (in its settings, /{first}/-/settings)."
35+ ))
36+}
37+
38+/// Why no one can be added to the free workspace `slug`.
39+pub(crate) fn invite_refusal(slug: &str) -> String {
40+ format!(
41+ "{slug} is a free workspace, so it cannot add people. Start the plan to invite people: an owner can start it at /{slug}/-/billing#plan. Its members stay as they are."
42+ )
43+}
44+
45+/// Whether `username` is g1t's own agent, which is never counted as a
46+/// person added to a workspace.
47+pub(crate) fn is_g1t(username: &str) -> bool {
48+ username.trim().eq_ignore_ascii_case(g1t_contracts::identity::AGENT_NAME)
49+}
50+
51+/// What to say when billing could not be asked.
52+const UNCHECKED: &str = "g1t could not check the workspace's plan just now. Nothing was changed; try again in a minute.";
53+
54+impl Identity {
55+ /// The free ones of `workspaces`, as billing says. Empty without
56+ /// billing bound.
57+ async fn free_of(&self, workspaces: Vec<String>) -> Result<Vec<String>> {
58+ if workspaces.is_empty() {
59+ return Ok(vec![]);
60+ }
61+ let Ok(billing) = self.env.service("BILLING") else {
62+ return Ok(vec![]);
63+ };
64+ g1t_kit::call(&billing, "free_workspaces", &FreeWorkspacesArgs { workspaces }).await
65+ }
66+
67+ /// A refusal if the person already owns a free workspace, for
68+ /// `create_workspace`.
69+ pub(crate) async fn second_free_workspace<T>(&self, user_id: &str) -> Result<Option<Outcome<T>>> {
70+ let owned: Vec<String> = self
71+ .memberships(user_id)
72+ .await?
73+ .into_iter()
74+ .filter(|m| m.role == Role::Owner)
75+ .map(|m| m.slug)
76+ .collect();
77+ Ok(match self.free_of(owned).await {
78+ Ok(free) => second_free_refusal(&free).map(|why| Outcome::fail(FailureCode::PaymentRequired, why)),
79+ Err(error) => {
80+ worker::console_error!("free workspaces of {user_id} not checked: {error}");
81+ Some(Outcome::fail(FailureCode::Conflict, UNCHECKED))
82+ }
83+ })
84+ }
85+
86+ /// A refusal if `slug` is a free workspace, before anyone is added to
87+ /// it: as a member, by an invite, or as an outside collaborator.
88+ pub(crate) async fn free_workspace_refusal<T>(&self, slug: &str) -> Result<Option<Outcome<T>>> {
89+ let slug = slug.trim().to_lowercase();
90+ Ok(match self.free_of(vec![slug.clone()]).await {
91+ Ok(free) if free.contains(&slug) => Some(Outcome::fail(FailureCode::PaymentRequired, invite_refusal(&slug))),
92+ Ok(_) => None,
93+ Err(error) => {
94+ worker::console_error!("the plan of {slug} not checked before adding someone: {error}");
95+ Some(Outcome::fail(FailureCode::Conflict, UNCHECKED))
96+ }
97+ })
98+ }
99+
100+ /// Whether `slug` is free, for paths that skip rather than refuse (a
101+ /// sign-up whose invite names a workspace). Free when billing cannot
102+ /// be asked: nobody joins unchecked.
103+ pub(crate) async fn is_free_workspace(&self, slug: &str) -> bool {
104+ let slug = slug.trim().to_lowercase();
105+ match self.free_of(vec![slug.clone()]).await {
106+ Ok(free) => free.contains(&slug),
107+ Err(error) => {
108+ worker::console_error!("the plan of {slug} not checked: {error}");
109+ true
110+ }
111+ }
112+ }
113+}
114+
115+#[cfg(test)]
116+mod tests {
117+ use super::*;
118+
119+ #[test]
120+ fn a_second_free_workspace_is_refused_with_the_way_forward() {
121+ assert_eq!(second_free_refusal(&[]), None);
122+ let one = second_free_refusal(&["acme".to_owned()]).unwrap();
123+ assert!(one.starts_with("You already own a free workspace, acme"));
124+ assert!(one.contains("/acme/-/billing#plan"));
125+ assert!(one.contains("delete"));
126+ // Grandfathered: several from before are named, and still no more.
127+ let several = second_free_refusal(&["acme".to_owned(), "side".to_owned()]).unwrap();
128+ assert!(several.starts_with("You already own 2 free workspaces (acme, side)"));
129+ }
130+
131+ #[test]
132+ fn a_free_workspace_is_told_to_start_the_plan_to_invite() {
133+ let why = invite_refusal("acme");
134+ assert!(why.contains("Start the plan to invite people"));
135+ assert!(why.contains("/acme/-/billing#plan"));
136+ assert!(why.contains("members stay"));
137+ }
138+
139+ #[test]
140+ fn g1ts_agent_is_never_a_person_added() {
141+ assert!(is_g1t("g1t"));
142+ assert!(is_g1t(" G1T "));
143+ assert!(!is_g1t("ada"));
144+ assert!(!is_g1t("g1t-fan"));
145+ }
146+}
+11−0
115115 "You belong to the maximum number of workspaces.",
116116 ));
117117 }
118+ // One free workspace per person (paid.rs): a new one starts free.
119+ if let Some(refused) = self.second_free_workspace(&a.user.id).await? {
120+ return Ok(refused);
121+ }
118122 // Usernames and workspaces share one namespace: a person's username
119123 // is theirs to use for a workspace, and nobody else's.
120124 let someone_elses_username = self
303307 if let Some(why) = self.policy_refusal(&user.id, &a.slug.to_lowercase()).await? {
304308 return Ok(Outcome::fail(FailureCode::Forbidden, why));
305309 }
310+ // A free workspace adds no one until it starts the plan (paid.rs);
311+ // g1t's agent is never someone added.
312+ if !crate::paid::is_g1t(&user.username)
313+ && let Some(refused) = self.free_workspace_refusal(&a.slug).await?
314+ {
315+ return Ok(refused);
316+ }
306317 self.db
307318 .prepare(
308319 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)