Skip to content

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.sh
411 linesCodeBlameRaw
1//! Invite a friend: any person invites someone to g1t by email.
2//!
3//! Anyone may sign up without an invite; this one is word of mouth. The
4//! friend gets an email from the person who invited them, whose link opens
5//! sign-up with their address filled in and, because it came from that
6//! email, confirmed (invites.rs, `proves_email`). The account it makes
7//! starts with a workspace of its own, and the invite records who invited
8//! whom (`redeemed_by`), so the inviter sees who joined.
9//!
10//! Each person has `INVITES_PER_USER` (20) invites to g1t for good, plus
11//! any staff grant (`invite_grants`, read with [`GRANTED`]). Pending and
12//! used invites count; one revoked or expired before it was used comes
13//! back. An invite to an address that already has an account, or to one
14//! the person already has a pending invite for, is refused and costs
15//! nothing. Inviting into a workspace or onto a repository never uses one.
16//!
17//! "Also invite them to a workspace" is only for a workspace the person
18//! owns on a paid plan: a free workspace invites no one (paid.rs).
19//!
20//! The statements are constants written out in full, `SQL_NOW` included,
21//! so scripts/deploy/friend-invites.test.mjs runs them as they are against
22//! identity's migrations.
23
24use g1t_contracts::audit::Surface;
25use g1t_contracts::identity::*;
26use g1t_contracts::time::rfc3339;
27use g1t_contracts::{FailureCode, Outcome, Role, User, new_id};
28use g1t_kit::now_ms;
29use worker::Result;
30use worker::wasm_bindgen::JsValue;
31
32use super::{BAD_EMAIL, CREATES_PER_HOUR, Count, Draft, Id, LIST_LIMIT, TOO_MANY, invite_note, normalize_email, resendable};
33use crate::Identity;
34
35/// Stores an invite (`?1`…`?14`, in the columns' order).
36pub(crate) const INSERT: &str = r#"INSERT INTO invites (id, code_hash, hint, sealed_code, email, kind, workspace_id, inviter_id,
37 staff, charged_to, created_at, expires_at, invitee_id, role)
38 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
39 RETURNING id"#;
40
41/// [`INSERT`], only while the inviter `?15` has fewer than `?16` invites
42/// to g1t counted against them: checked in the statement that writes, so
43/// two made at once cannot both take the last one.
44pub(crate) const INSERT_WITHIN_ALLOWANCE: &str = r#"INSERT INTO invites (id, code_hash, hint, sealed_code, email, kind, workspace_id, inviter_id,
45 staff, charged_to, created_at, expires_at, invitee_id, role)
46 SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14
47 WHERE (SELECT count(*) FROM invites i WHERE i.inviter_id = ?15 AND i.charged_to = 'user'
48 AND (i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))) < ?16
49 RETURNING id"#;
50
51/// How many invites to g1t count against the person `?1`: used ones, and
52/// ones neither revoked nor expired.
53pub(crate) const USED: &str = r#"SELECT count(*) AS n FROM invites i WHERE i.inviter_id = ?1 AND i.charged_to = 'user'
54 AND (i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"#;
55
56/// The invites staff granted the person `?1`, together (negative takes back).
57pub(crate) const GRANTED: &str =
58 r#"SELECT COALESCE(SUM(amount), 0) AS n FROM invite_grants WHERE target_kind = 'user' AND target_id = ?1"#;
59
60/// Whether the person `?1` already has a pending invite to g1t for `?2`.
61pub(crate) const PENDING_FOR: &str = r#"SELECT count(*) AS n FROM invites WHERE inviter_id = ?1 AND email = ?2 AND charged_to = 'user'
62 AND redeemed_at IS NULL AND revoked_at IS NULL AND expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')"#;
63
64const ALREADY_ON_G1T: &str =
65 "That address already has a g1t account, so there is nothing to invite them to. To work with them, invite them to a workspace from its People page.";
66const ALREADY_PENDING: &str = "You already have a pending invite for that address. Send it again, or revoke it to make a new one.";
67const ONLY_PENDING: &str = "Only a pending invite can be sent again.";
68const NOT_YOURS: &str = "There is no invite to g1t of yours with that id.";
69
70/// The message when a person has no invites left.
71pub fn out_of_invites(limit: u32) -> String {
72 format!("You have used all {limit} of your invites to g1t. Anyone can still sign up at g1t.sh without one.")
73}
74
75/// How many invites to g1t a person may have: the default plus staff
76/// grants, never below zero.
77pub fn limit_for(default: u32, granted: i64) -> u32 {
78 (i64::from(default) + granted).clamp(0, i64::from(u32::MAX)) as u32
79}
80
81/// Whether an invite in this state uses up one of an allowance: pending
82/// and used ones do; a revoked or expired one never used gives it back.
83/// [`USED`] says the same in SQL.
84pub fn counts_against_allowance(status: InviteStatus, used: bool) -> bool {
85 used || status == InviteStatus::Pending
86}
87
88/// Why an invite to g1t cannot be made, checked in this order: the
89/// address has an account, the person has a pending invite for it, or
90/// they have none left. The first two cost nothing.
91pub fn refusal(has_account: bool, pending: bool, allowance: Allowance) -> Option<(FailureCode, String)> {
92 if has_account {
93 return Some((FailureCode::Conflict, ALREADY_ON_G1T.to_owned()));
94 }
95 if pending {
96 return Some((FailureCode::Conflict, ALREADY_PENDING.to_owned()));
97 }
98 if allowance.exhausted() {
99 return Some((FailureCode::Limit, out_of_invites(allowance.limit)));
100 }
101 None
102}
103
104impl Identity {
105 /// INVITES_PER_USER, or [`INVITES_PER_USER`] (20) when unset or not a
106 /// number. A self-hoster may set it to 0 to turn invites to g1t off.
107 fn invites_per_user(&self) -> u32 {
108 self.var_number("INVITES_PER_USER").map_or(INVITES_PER_USER, |n| n.min(u64::from(u32::MAX)) as u32)
109 }
110
111 async fn sql_count(&self, sql: &str, binds: &[JsValue]) -> Result<f64> {
112 Ok(self.db.prepare(sql).bind(binds)?.first::<Count>(None).await?.map_or(0.0, |count| count.n))
113 }
114
115 /// A person's invites to g1t: how many they may have and have used.
116 pub async fn user_allowance(&self, user_id: &str) -> Result<Allowance> {
117 let granted = self.sql_count(GRANTED, &[user_id.into()]).await? as i64;
118 let used = self.sql_count(USED, &[user_id.into()]).await? as u32;
119 Ok(Allowance::new(limit_for(self.invites_per_user(), granted), used))
120 }
121
122 /// The workspace an invite to g1t also invites its person to, from
123 /// `join` (a slug): one `user` owns, on a paid plan. None for none.
124 async fn joinable_workspace(&self, user: &User, join: Option<&str>) -> Result<Outcome<Option<(String, String)>>> {
125 let Some(slug) = join.map(str::trim).filter(|slug| !slug.is_empty()).map(str::to_lowercase) else {
126 return Ok(Outcome::Ok(None));
127 };
128 if user.role_in(&slug) != Some(Role::Owner) {
129 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a workspace's owners can invite people to it."));
130 }
131 let Some(id) = self.workspace_id(&slug).await? else {
132 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
133 };
134 // A free workspace adds no one until it starts the plan (paid.rs).
135 if let Some(refused) = self.free_workspace_refusal(&slug).await? {
136 return Ok(refused);
137 }
138 Ok(Outcome::Ok(Some((id, slug))))
139 }
140
141 pub async fn create_invite(&self, a: CreateInviteArgs) -> Result<Outcome<Invite>> {
142 if let Some(reason) = Self::draft_allowed(&a.user) {
143 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
144 }
145 let Some(email) = normalize_email(&a.email) else {
146 return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL));
147 };
148 if !self.hit(&format!("invite.create:{}", a.user.id), CREATES_PER_HOUR).await? {
149 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
150 }
151 let has_account = self.email_has_account(&email).await?;
152 let pending = !has_account && self.sql_count(PENDING_FOR, &[a.user.id.as_str().into(), email.as_str().into()]).await? > 0.0;
153 let allowance = self.user_allowance(&a.user.id).await?;
154 if let Some((code, message)) = refusal(has_account, pending, allowance) {
155 return Ok(Outcome::fail(code, message));
156 }
157 let joins = match self.joinable_workspace(&a.user, a.join.as_deref()).await? {
158 Outcome::Ok(joins) => joins,
159 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
160 };
161 let draft = Draft {
162 email: Some(&email),
163 kind: "account",
164 workspace_id: joins.as_ref().map(|(id, _)| id.as_str()),
165 inviter: Some(&a.user),
166 staff: None,
167 invitee_id: None,
168 role: joins.as_ref().map(|_| if a.join_role == Some(Role::Owner) { "owner" } else { "member" }),
169 charged_to: "user",
170 limit: Some(allowance.limit),
171 };
172 let Some(invite) = self.insert_invite(draft).await? else {
173 return Ok(Outcome::fail(FailureCode::Limit, out_of_invites(allowance.limit)));
174 };
175 if let Some(code) = &invite.code {
176 let from = self.display_name(&a.user).await;
177 let workspace = match &joins {
178 Some((id, slug)) => Some(self.workspace_name(id, slug).await),
179 None => None,
180 };
181 let note = invite_note(a.message.as_deref());
182 self.send_invite_email(&email, Some(&from), workspace.as_deref(), false, code, &invite.id, note.as_deref()).await;
183 }
184 // A workspace it invites them to keeps it in its audit log; an
185 // invite to g1t alone is the person's own business.
186 if let Some((_, slug)) = &joins {
187 let surface = a.surface.unwrap_or(Surface::Web);
188 self.audit_invites(&a.user, "invite.created", vec![slug.clone()], surface, format!("Invited {email} to g1t and {slug}"))
189 .await;
190 }
191 Ok(Outcome::Ok(invite))
192 }
193
194 pub async fn list_invites(&self, a: UserArgs) -> Result<InvitesOverview> {
195 let invites = self
196 .rows("WHERE i.inviter_id = ? AND i.charged_to = 'user'", &[a.user.id.as_str().into()], LIST_LIMIT)
197 .await?
198 .into_iter()
199 .map(|row| self.shown(row, true, false))
200 .collect();
201 Ok(InvitesOverview { allowance: self.user_allowance(&a.user.id).await?, invites })
202 }
203
204 /// Sends a pending invite to g1t again: the same email, with the same
205 /// link, to the same address. Counted with the invites made in the
206 /// hour, so a list cannot be used to flood an inbox.
207 pub async fn resend_invite(&self, a: RemoveArgs) -> Result<Outcome<Invite>> {
208 if let Some(reason) = Self::draft_allowed(&a.user) {
209 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
210 }
211 let row = self.invite_by_id(a.id.trim()).await?.filter(|row| {
212 row.inviter_id.as_deref() == Some(a.user.id.as_str()) && row.charged_to.as_deref() == Some("user")
213 });
214 let Some(row) = row else {
215 return Ok(Outcome::fail(FailureCode::NotFound, NOT_YOURS));
216 };
217 if !resendable(row.status(&rfc3339(now_ms()))) {
218 return Ok(Outcome::fail(FailureCode::Conflict, ONLY_PENDING));
219 }
220 if !self.hit(&format!("invite.create:{}", a.user.id), CREATES_PER_HOUR).await? {
221 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
222 }
223 let code = row.sealed_code.as_deref().and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id));
224 if let (Some(to), Some(code)) = (row.email.as_deref(), code.as_deref()) {
225 let from = self.display_name(&a.user).await;
226 let workspace = match (&row.workspace_id, &row.workspace) {
227 (Some(id), Some(slug)) => Some(self.workspace_name(id, slug).await),
228 _ => None,
229 };
230 self.send_invite_email(to, Some(&from), workspace.as_deref(), false, code, &row.id, None).await;
231 }
232 Ok(Outcome::Ok(self.shown(row, true, false)))
233 }
234
235 // --- Staff ---
236
237 pub async fn admin_grant_invites(&self, a: AdminGrantInvitesArgs) -> Result<Outcome<Allowance>> {
238 if a.amount == 0 || a.amount.abs() > MAX_INVITE_GRANT {
239 return Ok(Outcome::fail(
240 FailureCode::Invalid,
241 format!("Grant between 1 and {MAX_INVITE_GRANT} invites, or take some back with a negative number."),
242 ));
243 }
244 let staff = a.staff.trim();
245 if staff.is_empty() {
246 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member granted them."));
247 }
248 let name = a.username.trim().trim_start_matches('@').to_lowercase();
249 let user = self
250 .db
251 .prepare("SELECT id FROM users WHERE username = ? AND deleted_at IS NULL")
252 .bind(&[name.as_str().into()])?
253 .first::<Id>(None)
254 .await?;
255 let Some(Id { id: user_id }) = user else {
256 return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no person named {name}.")));
257 };
258 let note = a.note.trim();
259 self.db
260 .prepare(
261 "INSERT INTO invite_grants (id, target_kind, target_id, amount, note, granted_by, created_at)
262 VALUES (?, 'user', ?, ?, ?, ?, ?)",
263 )
264 .bind(&[
265 new_id("igr", now_ms()).into(),
266 user_id.as_str().into(),
267 f64::from(a.amount).into(),
268 if note.is_empty() { JsValue::NULL } else { note.into() },
269 staff.into(),
270 rfc3339(now_ms()).into(),
271 ])?
272 .run()
273 .await?;
274 worker::console_log!("{} invites granted to {name} by staff {staff}", a.amount);
275 Ok(Outcome::Ok(self.user_allowance(&user_id).await?))
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use g1t_contracts::time::SQL_NOW;
283
284 #[test]
285 fn everyone_has_twenty_invites_to_g1t_plus_what_staff_grant() {
286 assert_eq!(INVITES_PER_USER, 20);
287 assert_eq!(limit_for(INVITES_PER_USER, 0), 20);
288 assert_eq!(limit_for(20, 10), 30);
289 assert_eq!(limit_for(20, -5), 15);
290 assert_eq!(limit_for(20, -50), 0);
291 // A self-hoster's 0 turns them off.
292 assert_eq!(limit_for(0, 0), 0);
293 let fresh = Allowance::new(20, 0);
294 assert_eq!((fresh.remaining, fresh.exhausted()), (20, false));
295 let last = Allowance::new(20, 19);
296 assert_eq!((last.remaining, last.exhausted()), (1, false));
297 let full = Allowance::new(20, 20);
298 assert_eq!((full.remaining, full.exhausted()), (0, true));
299 // Grants taken back below what was used: none left, never negative.
300 assert_eq!(Allowance::new(15, 18).remaining, 0);
301 }
302
303 #[test]
304 fn a_revoked_or_expired_invite_comes_back_and_a_used_one_is_spent() {
305 assert!(counts_against_allowance(InviteStatus::Pending, false));
306 assert!(!counts_against_allowance(InviteStatus::Revoked, false));
307 assert!(!counts_against_allowance(InviteStatus::Expired, false));
308 // Used: spent, whatever became of it after (revoked while the
309 // account confirmed its address, or the workspace declined).
310 for status in [
311 InviteStatus::Redeemed,
312 InviteStatus::AwaitingConfirmation,
313 InviteStatus::AwaitingAnswer,
314 InviteStatus::Revoked,
315 InviteStatus::Declined,
316 ] {
317 assert!(counts_against_allowance(status, true), "{status:?}");
318 }
319 // The SQL says the same: used, or neither revoked nor expired.
320 for sql in [USED, INSERT_WITHIN_ALLOWANCE] {
321 assert!(sql.contains("i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at >"), "{sql}");
322 assert!(sql.contains("i.charged_to = 'user'"), "{sql}");
323 }
324 }
325
326 #[test]
327 fn an_address_with_an_account_or_a_pending_invite_is_refused_without_costing_one() {
328 let some = Allowance::new(20, 3);
329 let none = Allowance::new(20, 20);
330 let (code, message) = refusal(true, false, some).unwrap();
331 assert_eq!(code, FailureCode::Conflict);
332 assert!(message.contains("already has a g1t account"));
333 // Even with none left, the account is what is said.
334 assert_eq!(refusal(true, false, none).unwrap().1, ALREADY_ON_G1T);
335 let (code, message) = refusal(false, true, some).unwrap();
336 assert_eq!(code, FailureCode::Conflict);
337 assert!(message.contains("already have a pending invite"));
338 let (code, message) = refusal(false, false, none).unwrap();
339 assert_eq!(code, FailureCode::Limit);
340 assert!(message.contains("all 20"));
341 // Never a gate: running out still says anyone can sign up.
342 assert!(message.contains("Anyone can still sign up"));
343 assert_eq!(refusal(false, false, some), None);
344 }
345
346 #[test]
347 fn signing_up_from_a_friends_invite_confirms_the_address_and_makes_a_workspace_of_ones_own() {
348 use super::super::{AwaitingJoin, InviteRow, admits, awaiting_join, email_proof, starts_confirmed};
349 use super::super::invitations::makes_own_workspace;
350 const KEY: &[u8] = b"identity key";
351 let row = InviteRow {
352 id: "inv_1".into(),
353 hint: "g1t-k7m2".into(),
354 sealed_code: None,
355 email: Some("ada@example.com".into()),
356 kind: "account".into(),
357 workspace_id: None,
358 workspace: None,
359 inviter_id: Some("usr_bo".into()),
360 inviter: Some("bo".into()),
361 staff: None,
362 charged_to: Some("user".into()),
363 created_at: "2026-10-01T12:00:00.000Z".into(),
364 expires_at: "2026-10-31T12:00:00.000Z".into(),
365 revoked_at: None,
366 redeemer: None,
367 redeemed_at: None,
368 applied_at: None,
369 invitee_id: None,
370 invitee: None,
371 role: None,
372 accepted_at: None,
373 declined_at: None,
374 };
375 let now = "2026-10-13T12:00:00.000Z";
376 // It admits its own address, and only while pending.
377 assert_eq!(admits(Some(&row.admits(now)), "ada@example.com", true), Ok(()));
378 assert!(admits(Some(&row.admits(now)), "eve@example.com", true).is_err());
379 // Opened from its email, the address starts confirmed; the code alone does not.
380 let proof = email_proof(KEY, &row.id, row.email.as_deref()).unwrap();
381 assert!(starts_confirmed(KEY, false, Some(&row), "ada@example.com", Some(&proof)));
382 assert!(!starts_confirmed(KEY, false, Some(&row), "ada@example.com", None));
383 // It names no workspace: the account makes its own, and confirming
384 // joins nothing.
385 assert!(makes_own_workspace(row.workspace.as_deref(), false));
386 assert_eq!(awaiting_join(&row, now), AwaitingJoin::Nothing);
387 // Its inviter sees it used, by whom.
388 let used = InviteRow {
389 redeemer: Some("ada".into()),
390 redeemed_at: Some(now.into()),
391 applied_at: Some(now.into()),
392 ..row
393 };
394 assert_eq!(used.status(now), InviteStatus::Redeemed);
395 assert_eq!(super::super::charge_of(used.charged_to.as_deref()), InviteCharge::User);
396 }
397
398 #[test]
399 fn the_statements_use_the_same_now_as_everything_else() {
400 let now = format!("> {SQL_NOW}");
401 for sql in [INSERT_WITHIN_ALLOWANCE, USED, PENDING_FOR, super::super::SPEND_CODE] {
402 assert!(sql.contains(&now), "{sql}");
403 }
404 for sql in [super::super::REVOKE, super::super::INSERT_USER_WITH_CODE] {
405 assert!(sql.contains(SQL_NOW), "{sql}");
406 }
407 // The guarded insert writes the same columns as the plain one.
408 let columns = |sql: &str| sql.split(')').next().unwrap().to_owned();
409 assert_eq!(columns(INSERT), columns(INSERT_WITHIN_ALLOWANCE));
410 }
411}