Skip to content

Commit

Workspace invitations are answered, and new accounts get a workspace of their own (identity)

syntaqxcommitted Parentfe7d232Browse files
8 files+1120−1140/8 viewed
+5−0
246246 /// by slug, now that the account is confirmed.
247247 #[serde(default)]
248248 pub joined: Option<String>,
249+ /// The workspace the invite the account signed up with invites it to,
250+ /// by slug: a workspace invitation now waits for its answer
251+ /// (`accept_invitation`). Nobody joins a workspace without saying yes.
252+ #[serde(default)]
253+ pub invited_to: Option<String>,
249254 /// Why the invite the account signed up with no longer applies, when it
250255 /// was revoked, expired or its workspace deleted while the account
251256 /// waited. The address is confirmed all the same.
+76−1
463463 pub viewer: crate::Viewer,
464464 }
465465
466−/// `add_member` and `remove_member`: owners only. Removing yourself is
466+/// `add_member` and `remove_member`: owners only. `add_member` never adds a
467+/// person at once: it sends them a workspace invitation to accept or
468+/// decline, as `invite_member` with a username does. Only g1t's own agent
469+/// is added at once. Removing yourself is
467470 /// leaving (`members::LeaveWorkspaceArgs`); removing an owner is refused
468471 /// when they are the last. Each returns `Outcome<bool>`.
469472 #[derive(Debug, Serialize, Deserialize)]
11311134 /// yet. The code is spent; the workspace (or repository) it gives is
11321135 /// joined when the address is confirmed, unless it is revoked first.
11331136 AwaitingConfirmation,
1137+ /// Used to make an account that has confirmed its address, for a
1138+ /// workspace it has not yet joined or declined: the workspace
1139+ /// invitation waits for the person's answer (`accept_invitation`).
1140+ AwaitingAnswer,
11341141 Redeemed,
1142+ /// Its person declined the workspace it invited them to.
1143+ Declined,
11351144 Expired,
11361145 Revoked,
11371146 }
11891198 pub redeemed_at: Option<String>,
11901199 /// RFC 3339.
11911200 pub revoked_at: Option<String>,
1201+ /// The account a workspace invitation is for, by username: someone
1202+ /// invited by username, or the account the invite made.
1203+ #[serde(default)]
1204+ pub invitee: Option<String>,
1205+ /// The role `workspace` is joined with. Null when it names none.
1206+ #[serde(default)]
1207+ pub role: Option<crate::Role>,
11921208 /// The staff member who minted it. Only in staff views.
11931209 #[serde(default, skip_serializing_if = "Option::is_none")]
11941210 pub staff: Option<String>,
12561272 /// Use this workspace's granted invites, by slug.
12571273 #[serde(default)]
12581274 pub workspace: Option<String>,
1275+ /// The workspace the new account is invited to, by slug: one the
1276+ /// person owns that can add members (not on the free plan). Once the
1277+ /// account is confirmed it gets a workspace invitation to accept, as a
1278+ /// member, and no workspace of its own is made for it.
1279+ #[serde(default)]
1280+ pub join: Option<String>,
12591281 /// Where the request came in, for the audit log; g1t.sh when absent.
12601282 #[serde(default)]
12611283 pub surface: Option<crate::audit::Surface>,
13551377 pub struct InviteMemberArgs {
13561378 pub actor: User,
13571379 pub slug: String,
1380+ /// An email address. Give this or `username`.
1381+ #[serde(default)]
13581382 pub email: String,
1383+ /// A g1t username: that account gets a workspace invitation to accept
1384+ /// or decline, in its inbox and by email. Nobody joins without saying
1385+ /// yes.
1386+ #[serde(default)]
1387+ pub username: Option<String>,
1388+ /// The role they join with; member when absent.
1389+ #[serde(default)]
1390+ pub role: Option<crate::Role>,
13591391 /// Where the request came in, for the audit log; g1t.sh when absent.
13601392 #[serde(default)]
13611393 pub surface: Option<crate::audit::Surface>,
13621394 }
13631395
1396+/// A workspace invitation waiting for its person's answer, as they see it.
1397+/// `list_invitations` (takes `UserArgs`) returns `Vec<WorkspaceInvitation>`,
1398+/// newest first: pending ones only, never expired, revoked or answered.
1399+#[derive(Clone, Debug, Serialize, Deserialize)]
1400+#[serde(rename_all = "camelCase")]
1401+pub struct WorkspaceInvitation {
1402+ pub id: String,
1403+ pub workspace: ProfileWorkspace,
1404+ /// The role accepting joins with.
1405+ pub role: crate::Role,
1406+ /// Null when g1t staff sent it.
1407+ pub invited_by: Option<InviteFrom>,
1408+ /// RFC 3339.
1409+ pub created_at: String,
1410+ /// RFC 3339.
1411+ pub expires_at: String,
1412+}
1413+
1414+/// `accept_invitation`: the person it is for joins the workspace with the
1415+/// role it names. Returns `Outcome<String>`, the workspace's slug.
1416+///
1417+/// `decline_invitation`: they say no; whoever sent it is told in their
1418+/// inbox. Returns `Outcome<bool>`.
1419+#[derive(Debug, Serialize, Deserialize)]
1420+pub struct InvitationArgs {
1421+ pub user: User,
1422+ pub id: String,
1423+ /// Where the request came in, for the audit log; g1t.sh when absent.
1424+ #[serde(default)]
1425+ pub surface: Option<crate::audit::Surface>,
1426+}
1427+
1428+/// `find_people`: accounts whose username starts with `query`, or whose
1429+/// name contains it, for picking someone to invite. Only what a profile
1430+/// shows: a username, a name and an avatar, never an email address.
1431+/// Returns `Vec<InviteFrom>`, at most `limit` (10 at most, 8 when absent).
1432+#[derive(Debug, Serialize, Deserialize)]
1433+pub struct FindPeopleArgs {
1434+ pub query: String,
1435+ #[serde(default)]
1436+ pub limit: Option<u32>,
1437+}
1438+
13641439 /// `workspace_invites` (takes `ListMembersArgs`): a workspace's invites,
13651440 /// newest first. Owners only. Returns `Outcome<Vec<Invite>>`.
13661441 ///
+29−0
1+-- Workspace invitations are answered: nobody joins a workspace without
2+-- saying yes. An invite that names a workspace (invites.workspace_id) is a
3+-- workspace invitation for one account, which accepts or declines it
4+-- (src/invites.rs, `accept_invitation` and `decline_invitation`). An
5+-- account invite that names one makes the account first; the invitation
6+-- waits for its answer once the account confirms its address.
7+
8+-- The account it is for: the one invited by username or address, or the
9+-- one the invite made. Null until there is one.
10+ALTER TABLE invites ADD COLUMN invitee_id TEXT;
11+-- The role accepting joins with: member or owner. Null is member.
12+ALTER TABLE invites ADD COLUMN role TEXT;
13+-- The answer, RFC 3339 UTC. At most one is set.
14+ALTER TABLE invites ADD COLUMN accepted_at TEXT;
15+ALTER TABLE invites ADD COLUMN declined_at TEXT;
16+
17+-- Every invite used before now is for the account that used it, and one
18+-- that named a workspace and was applied joined it then: accepted.
19+UPDATE invites SET invitee_id = redeemed_by WHERE invitee_id IS NULL AND redeemed_by IS NOT NULL;
20+UPDATE invites SET accepted_at = applied_at
21+ WHERE accepted_at IS NULL AND workspace_id IS NOT NULL AND applied_at IS NOT NULL;
22+-- A pending invitation already sent to an existing account's confirmed
23+-- address is for that account, so it shows among its invitations.
24+UPDATE invites SET invitee_id = (
25+ SELECT e.user_id FROM user_emails e WHERE e.email = invites.email AND e.verified_at IS NOT NULL LIMIT 1)
26+ WHERE invitee_id IS NULL AND kind = 'workspace' AND email IS NOT NULL AND redeemed_at IS NULL AND revoked_at IS NULL;
27+
28+CREATE INDEX IF NOT EXISTS invites_invitee ON invites (invitee_id)
29+ WHERE workspace_id IS NOT NULL AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL;
+10−8
3131 //!
3232 //! An account with no confirmed primary is pending: the site, the API and
3333 //! git let it do nothing but confirm its address, change it, or sign out.
34−//! The invite it signed up with was spent then, and what it gives (its
35−//! workspace) is applied in the same transaction that confirms the address
36−//! (invites.rs, `apply_invite_statements`).
34+//! The invite it signed up with was spent then, and what it gives is
35+//! applied in the same transaction that confirms the address (invites.rs,
36+//! `apply_invite_statements`): the workspace it names becomes a workspace
37+//! invitation the person accepts or declines (invitations.rs).
3738
3839 use std::collections::HashMap;
3940
693694 self.log_security(user_id, "email_verified", Some(&row.display), None).await;
694695 self.announce_email("user.email_verified", user_id, false).await;
695696 let verified = self.account_confirmed(user_id).await?;
696− let (mut joined, mut invite_lapsed) = (None, None);
697+ let (mut invited_to, mut invite_lapsed) = (None, None);
697698 if let (Some(invite), Some(join), true) = (&awaiting, &join, verified) {
698699 let user = g1t_contracts::User {
699700 id: user_id.to_owned(),
701702 verified: true,
702703 ..g1t_contracts::User::default()
703704 };
704− joined = self.after_applied(invite, &user, join).await?;
705+ invited_to = self.after_applied(invite, &user, join).await?;
705706 invite_lapsed = match join {
706707 AwaitingJoin::Lapsed(why) => Some(why.clone()),
707708 // Revoked between the read and the transaction.
708− AwaitingJoin::Join { .. } if joined.is_none() => Some(
709− "Your email address is confirmed. The invite you signed up with no longer applies, so it did not join you to a workspace."
709+ AwaitingJoin::Invited { .. } if invited_to.is_none() => Some(
710+ "Your email address is confirmed. The invite you signed up with no longer applies, so it does not invite you to a workspace."
710711 .to_owned(),
711712 ),
712713 _ => None,
716717 username: account.username,
717718 email: row.display,
718719 verified,
719− joined,
720+ joined: None,
721+ invited_to,
720722 invite_lapsed,
721723 }))
722724 }
+254−98
4242 use crate::shared_invites::{SharedAdmits, shared_admits, wrong_domain};
4343 use crate::{Identity, crypto};
4444
45+mod invitations;
46+
4547 /// Crockford base32, as ids use: no i, l, o or u.
4648 const ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
4749 /// 32 characters of 5 bits: 160 random bits.
175177 }
176178 }
177179
180+/// Where an invite stands once the workspace invitation in it is counted:
181+/// `base` from [`status_of`]. A declined one is declined; an account
182+/// invite whose account is confirmed but has not answered the workspace it
183+/// names (`names_workspace`: one that still exists) awaits that answer
184+/// until it expires.
185+pub fn answered_status(
186+ base: InviteStatus,
187+ kind: &str,
188+ names_workspace: bool,
189+ accepted_at: Option<&str>,
190+ declined_at: Option<&str>,
191+ expires_at: &str,
192+ now: &str,
193+) -> InviteStatus {
194+ if declined_at.is_some() && base != InviteStatus::Revoked {
195+ return InviteStatus::Declined;
196+ }
197+ if base == InviteStatus::Redeemed && kind == "account" && names_workspace && accepted_at.is_none() {
198+ return if expires_at <= now { InviteStatus::Expired } else { InviteStatus::AwaitingAnswer };
199+ }
200+ base
201+}
202+
178203 /// Whether an invite in this state uses up one of an allowance: pending
179204 /// and used ones do; a revoked or expired one never used gives it back.
180205 #[cfg(test)]
181206 pub fn counts_against_allowance(status: InviteStatus) -> bool {
182− matches!(status, InviteStatus::Pending | InviteStatus::AwaitingConfirmation | InviteStatus::Redeemed)
207+ matches!(
208+ status,
209+ InviteStatus::Pending | InviteStatus::AwaitingConfirmation | InviteStatus::AwaitingAnswer | InviteStatus::Redeemed
210+ )
183211 }
184212
185213 /// The SQL condition that matches [`counts_against_allowance`] for rows of
237265 /// address.
238266 #[derive(Clone, Debug, PartialEq, Eq)]
239267 pub enum AwaitingJoin {
240− /// Join this workspace.
241− Join { workspace_id: String, slug: String },
268+ /// It invites the account to this workspace: a workspace invitation
269+ /// now waits for its answer. Nothing is joined without one.
270+ Invited { workspace_id: String, slug: String },
242271 /// It names no workspace; repository invitations sent with it are
243272 /// accepted.
244273 Nothing,
247276 }
248277
249278 /// [`AwaitingJoin`] for an invite's row at `now`. `row.workspace` is the
250−/// workspace's slug, None once it was deleted; `free`: it is on the free
251−/// plan, which adds no members (paid.rs).
252−pub fn awaiting_join(row: &InviteRow, now: &str, free: bool) -> AwaitingJoin {
279+/// workspace's slug, None once it was deleted. A workspace on the free
280+/// plan still invites: accepting waits until it starts the plan (paid.rs).
281+pub fn awaiting_join(row: &InviteRow, now: &str) -> AwaitingJoin {
253282 let what = match &row.workspace {
254− Some(slug) => format!("did not join you to {slug}"),
283+ Some(slug) => format!("no longer invites you to {slug}"),
255284 None => "no longer applies".to_owned(),
256285 };
257286 if row.revoked_at.is_some() {
261290 }
262291 if row.expires_at.as_str() <= now {
263292 return AwaitingJoin::Lapsed(format!(
264− "Your email address is confirmed. The invite you signed up with expired before you confirmed it, so it {what}. Ask whoever invited you to add you again."
293+ "Your email address is confirmed. The invite you signed up with expired before you confirmed it, so it {what}. Ask whoever invited you to invite you again."
265294 ));
266295 }
267296 match (&row.workspace_id, &row.workspace) {
269298 (Some(_), None) => AwaitingJoin::Lapsed(
270299 "Your email address is confirmed. The workspace your invite was for has been deleted, so the invite no longer applies.".to_owned(),
271300 ),
272− (Some(_), Some(slug)) if free => AwaitingJoin::Lapsed(format!(
273− "Your email address is confirmed. {slug} is on the free plan, which adds no members, so the invite did not join you to it. Ask its owners to add you once it starts the g1t plan."
274− )),
275− (Some(workspace_id), Some(slug)) => AwaitingJoin::Join { workspace_id: workspace_id.clone(), slug: slug.clone() },
301+ (Some(workspace_id), Some(slug)) => AwaitingJoin::Invited { workspace_id: workspace_id.clone(), slug: slug.clone() },
276302 }
277303 }
278304
313339
314340 const COLUMNS: &str = "i.id, i.hint, i.sealed_code, i.email, i.kind, i.workspace_id, w.slug AS workspace,
315341 i.inviter_id, iu.username AS inviter, i.staff, i.charged_to, i.charged_workspace_id, i.created_at, i.expires_at,
316− i.revoked_at, i.redeemed_by, ru.username AS redeemer, i.redeemed_at, i.applied_at
342+ i.revoked_at, i.redeemed_by, ru.username AS redeemer, i.redeemed_at, i.applied_at,
343+ i.invitee_id, vu.username AS invitee, i.role, i.accepted_at, i.declined_at
317344 FROM invites i
318345 LEFT JOIN workspaces w ON w.id = i.workspace_id AND w.deleted_at IS NULL
319346 LEFT JOIN users iu ON iu.id = i.inviter_id
320− LEFT JOIN users ru ON ru.id = i.redeemed_by";
347+ LEFT JOIN users ru ON ru.id = i.redeemed_by
348+ LEFT JOIN users vu ON vu.id = i.invitee_id";
321349
322350 #[derive(Debug, Deserialize)]
323351 pub struct InviteRow {
339367 pub redeemed_at: Option<String>,
340368 #[serde(default)]
341369 pub applied_at: Option<String>,
370+ /// The account a workspace invitation is for (invitations.rs).
371+ #[serde(default)]
372+ pub invitee_id: Option<String>,
373+ #[serde(default)]
374+ pub invitee: Option<String>,
375+ /// `owner` or `member`; null is member.
376+ #[serde(default)]
377+ pub role: Option<String>,
378+ #[serde(default)]
379+ pub accepted_at: Option<String>,
380+ #[serde(default)]
381+ pub declined_at: Option<String>,
342382 }
343383
344384 impl InviteRow {
345385 pub fn status(&self, now: &str) -> InviteStatus {
346− status_of(
347− self.revoked_at.as_deref(),
348− self.redeemed_at.as_deref(),
349− self.applied_at.as_deref(),
386+ answered_status(
387+ status_of(
388+ self.revoked_at.as_deref(),
389+ self.redeemed_at.as_deref(),
390+ self.applied_at.as_deref(),
391+ &self.expires_at,
392+ now,
393+ ),
394+ &self.kind,
395+ self.workspace.is_some(),
396+ self.accepted_at.as_deref(),
397+ self.declined_at.as_deref(),
350398 &self.expires_at,
351399 now,
352400 )
353401 }
354402
403+ /// The role accepting it joins with.
404+ pub fn joins_as(&self) -> Role {
405+ if self.role.as_deref() == Some("owner") { Role::Owner } else { Role::Member }
406+ }
407+
355408 fn admits(&self, now: &str) -> Admits<'_> {
356409 Admits {
357410 kind: &self.kind,
456509 charged_to: &'a str,
457510 charged_workspace_id: Option<&'a str>,
458511 limit: Option<u32>,
512+ /// The account a workspace invitation is for, when it has one already.
513+ invitee_id: Option<&'a str>,
514+ /// The role joining `workspace_id` gives: `member` or `owner`.
515+ role: Option<&'a str>,
459516 }
460517
461518 impl Identity {
578635 fn shown(&self, row: InviteRow, reveal: bool, staff_view: bool) -> Invite {
579636 let now = rfc3339(now_ms());
580637 let status = row.status(&now);
638+ let role = row.workspace_id.is_some().then(|| row.joins_as());
639+ // An invite sent to an address never says which account has it,
640+ // until that account uses it.
641+ let invitee = row.invitee.clone().filter(|_| row.email.is_none() || row.redeemed_at.is_some());
581642 let code = if reveal && status == InviteStatus::Pending {
582643 row.sealed_code
583644 .as_deref()
600661 expires_at: row.expires_at,
601662 redeemed_at: row.redeemed_at,
602663 revoked_at: row.revoked_at,
664+ invitee,
665+ role,
603666 staff: if staff_view { row.staff } else { None },
604667 }
605668 }
786849 .batch(vec![
787850 self.db
788851 .prepare(format!(
789− "UPDATE invites SET redeemed_by = ?, redeemed_at = {SQL_NOW}, sealed_code = NULL
790− WHERE id = ? AND kind = 'account' AND redeemed_at IS NULL AND revoked_at IS NULL
852+ "UPDATE invites SET redeemed_by = ?1, invitee_id = ?1, redeemed_at = {SQL_NOW}, sealed_code = NULL
853+ WHERE id = ?2 AND kind = 'account' AND redeemed_at IS NULL AND revoked_at IS NULL
791854 AND expires_at > {SQL_NOW}"
792855 ))
793856 .bind(&[user.id.as_str().into(), row.id.as_str().into()])?,
823886 self.count_failure(new.client).await?;
824887 return Ok(Outcome::fail(FailureCode::Forbidden, INVALID));
825888 }
889+ // Nobody is left without a workspace: one of its own, unless its
890+ // invite brings it into one (invitations.rs).
891+ self.give_own_workspace(&user, invite.as_ref()).await;
826892 // Confirmed already (GitHub): what the invite gives, now. Otherwise
827893 // it waits, spent, for the address to be confirmed.
828894 if let Some(row) = invite
849915 Ok(Outcome::Ok(user))
850916 }
851917
852− /// Joins the invite's workspace, and tells the event log and audit log.
918+ /// What using an invite gives, once its account is confirmed, and tells
919+ /// the event log and audit log. A new account (`created_account`) is
920+ /// invited to the workspace the invite names, to accept or decline
921+ /// (invitations.rs): nobody joins a workspace without saying yes. An
922+ /// existing account that opened the invite and accepted it
923+ /// (`accept_invite`) joins now, with the role it names.
853924 async fn after_redeemed(&self, row: &InviteRow, user: &User, created_account: bool) -> Result<()> {
854925 let mut joined = None;
855− // A free workspace adds no one (paid.rs): a sign-up with an invite
856− // from one sent before still makes the account, without joining.
857− let free = match &row.workspace {
858− Some(slug) => self.is_free_workspace(slug).await,
859− None => false,
860− };
861− if let (Some(workspace_id), Some(slug), false) = (&row.workspace_id, &row.workspace, free) {
862− self.db
863− .prepare(
864− "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
865− VALUES (?, ?, 'member', ?)",
866− )
867− .bind(&[workspace_id.as_str().into(), user.id.as_str().into(), rfc3339(now_ms()).into()])?
868− .run()
869− .await?;
870− joined = Some(slug.clone());
926+ if let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) {
927+ if created_account {
928+ self.db
929+ .prepare("UPDATE invites SET expires_at = max(expires_at, ?) WHERE id = ? AND accepted_at IS NULL")
930+ .bind(&[self.answer_by().into(), row.id.as_str().into()])?
931+ .run()
932+ .await?;
933+ self.invitation_sent(row, &user.username).await;
934+ } else {
935+ let role = if row.joins_as() == Role::Owner { "owner" } else { "member" };
936+ self.db
937+ .batch(vec![
938+ self.db
939+ .prepare(
940+ "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
941+ VALUES (?, ?, ?, ?)",
942+ )
943+ .bind(&[workspace_id.as_str().into(), user.id.as_str().into(), role.into(), rfc3339(now_ms()).into()])?,
944+ self.db
945+ .prepare(format!("UPDATE invites SET accepted_at = {SQL_NOW} WHERE id = ? AND accepted_at IS NULL"))
946+ .bind(&[row.id.as_str().into()])?,
947+ ])
948+ .await?;
949+ joined = Some(slug.clone());
950+ }
871951 }
872952 self.db
873953 .prepare(format!("UPDATE invites SET applied_at = {SQL_NOW} WHERE id = ? AND applied_at IS NULL"))
878958 Ok(())
879959 }
880960
961+ /// When a workspace invitation made now, or handed to a new account
962+ /// now, stops working: the invite TTL from now, RFC 3339.
963+ pub(crate) fn answer_by(&self) -> String {
964+ rfc3339(now_ms() + self.invite_ttl_days() * 24 * HOUR_MS)
965+ }
966+
881967 /// What follows an invite's workspace being joined (`joined`, by slug)
882968 /// or not: repository invitations sent with its code are accepted, and
883969 /// the event log and the workspace's audit log are told.
884− async fn settled(&self, row: &InviteRow, user: &User, created_account: bool, joined: Option<String>) {
970+ pub(crate) async fn settled(&self, row: &InviteRow, user: &User, created_account: bool, joined: Option<String>) {
885971 // A code sent with an invitation to collaborate on a repository:
886972 // using it accepts (access.rs).
887973 if let Err(error) = self.accept_invitations_of_code(&row.id, user).await {
904990 Some(inviter) => format!("Joined with an invite from {inviter}"),
905991 None => "Joined with an invite from g1t".to_owned(),
906992 };
993+ let role = if row.joins_as() == Role::Owner { "an owner" } else { "a member" };
907994 self.audit_invites(user, "invite.redeemed", vec![slug.clone()], Surface::Web, message).await;
908− self.audit_invites(user, "member.added", vec![slug], Surface::Web, format!("{} joined as a member", user.username)).await;
995+ self.audit_invites(user, "member.added", vec![slug], Surface::Web, format!("{} joined as {role}", user.username)).await;
909996 }
910997 }
911998
9271014 /// confirmed, worked out before the batch that confirms it (which
9281015 /// checks the same again).
9291016 pub(crate) async fn awaiting_join(&self, row: &InviteRow) -> AwaitingJoin {
930− // A free workspace adds no one (paid.rs).
931− let free = match &row.workspace {
932− Some(slug) => self.is_free_workspace(slug).await,
933− None => false,
934− };
935− awaiting_join(row, &rfc3339(now_ms()), free)
1017+ awaiting_join(row, &rfc3339(now_ms()))
9361018 }
9371019
9381020 /// The statements that apply an awaiting invite, for the batch that
9391021 /// confirms `user_id`'s address, after the statement that marks the
940− /// account confirmed: join the workspace, only if the account is
941− /// confirmed now and the invite and workspace are still good; then mark
942− /// the invite settled, whatever it gave.
1022+ /// account confirmed: give a workspace invitation the invite TTL from
1023+ /// now to be answered in, only if the account is confirmed now; then
1024+ /// mark the invite settled, whatever it gave. Nothing is joined here:
1025+ /// the person accepts the invitation (invitations.rs).
9431026 pub(crate) fn apply_invite_statements(
9441027 &self,
9451028 user_id: &str,
9481031 ) -> Result<Vec<worker::D1PreparedStatement>> {
9491032 let confirmed = "EXISTS (SELECT 1 FROM users WHERE id = ?1 AND email_verified_at IS NOT NULL)";
9501033 let mut statements = Vec::new();
951− if let AwaitingJoin::Join { workspace_id, .. } = join {
1034+ if let AwaitingJoin::Invited { .. } = join {
9521035 statements.push(
9531036 self.db
9541037 .prepare(format!(
955− "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
956− SELECT ?3, ?1, 'member', {SQL_NOW}
957− WHERE {confirmed}
958− AND EXISTS (SELECT 1 FROM workspaces WHERE id = ?3 AND deleted_at IS NULL)
959− AND EXISTS (SELECT 1 FROM invites WHERE id = ?2 AND redeemed_by = ?1
960− AND applied_at IS NULL AND revoked_at IS NULL AND expires_at > {SQL_NOW})"
1038+ "UPDATE invites SET expires_at = max(expires_at, ?3)
1039+ WHERE id = ?2 AND redeemed_by = ?1 AND applied_at IS NULL AND revoked_at IS NULL AND {confirmed}"
9611040 ))
962− .bind(&[user_id.into(), row.id.as_str().into(), workspace_id.as_str().into()])?,
1041+ .bind(&[user_id.into(), row.id.as_str().into(), self.answer_by().into()])?,
9631042 );
9641043 }
9651044 statements.push(
9731052 Ok(statements)
9741053 }
9751054
976− /// After the batch: the workspace joined, by slug, if the account is in
977− /// it now; and, unless the invite lapsed, the repository invitations,
978− /// event and audit entries that follow using it.
1055+ /// After the batch: the workspace the account is invited to, by slug,
1056+ /// if the invitation still waits for its answer (and it is told in
1057+ /// its inbox); and, unless the invite lapsed, the repository
1058+ /// invitations, event and audit entries that follow using it.
9791059 pub(crate) async fn after_applied(&self, row: &InviteRow, user: &User, join: &AwaitingJoin) -> Result<Option<String>> {
980− let joined = match join {
981− AwaitingJoin::Join { workspace_id, slug } => self
1060+ let invited = match join {
1061+ AwaitingJoin::Invited { slug, .. } => self
9821062 .db
983− .prepare("SELECT 1 AS n FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
984− .bind(&[workspace_id.as_str().into(), user.id.as_str().into()])?
1063+ .prepare(format!(
1064+ "SELECT 1 AS n FROM invites WHERE id = ? AND applied_at IS NOT NULL AND revoked_at IS NULL
1065+ AND accepted_at IS NULL AND declined_at IS NULL AND expires_at > {SQL_NOW}"
1066+ ))
1067+ .bind(&[row.id.as_str().into()])?
9851068 .first::<Count>(None)
9861069 .await?
9871070 .map(|_| slug.clone()),
9881071 _ => None,
9891072 };
1073+ if invited.is_some() {
1074+ self.invitation_sent(row, &user.username).await;
1075+ }
9901076 if !matches!(join, AwaitingJoin::Lapsed(_)) {
991− self.settled(row, user, true, joined.clone()).await;
1077+ self.settled(row, user, true, None).await;
9921078 }
993− Ok(joined)
1079+ Ok(invited)
9941080 }
9951081
9961082 // --- People's invites ---
10301116 opt(draft.charged_workspace_id),
10311117 created_at.as_str().into(),
10321118 expires_at.as_str().into(),
1119+ opt(draft.invitee_id),
1120+ opt(draft.role),
10331121 ];
10341122 // The allowance is checked in the insert itself, so two invites made
10351123 // at once cannot both take the last one.
10541142 .db
10551143 .prepare(format!(
10561144 "INSERT INTO invites (id, code_hash, hint, sealed_code, email, kind, workspace_id, inviter_id,
1057− staff, charged_to, charged_workspace_id, created_at, expires_at)
1058− SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? {guard}
1145+ staff, charged_to, charged_workspace_id, created_at, expires_at, invitee_id, role)
1146+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? {guard}
10591147 RETURNING id"
10601148 ))
10611149 .bind(&binds)?
11541242 (None, "user", allowance.limit)
11551243 }
11561244 };
1245+ // The workspace it brings them into, if any (invitations.rs).
1246+ let joins = match self.joinable_workspace(&a.user, a.join.as_deref()).await? {
1247+ Outcome::Ok(joins) => joins,
1248+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1249+ };
11571250 let draft = Draft {
11581251 email: email.as_deref(),
11591252 kind: "account",
1160− workspace_id: None,
1253+ workspace_id: joins.as_ref().map(|(id, _)| id.as_str()),
11611254 inviter: Some(&a.user),
11621255 staff: None,
11631256 charged_to,
11641257 charged_workspace_id: workspace_id.as_deref(),
11651258 limit,
1259+ invitee_id: None,
1260+ role: joins.as_ref().map(|_| "member"),
11661261 };
11671262 let Some(invite) = self.insert_invite(draft).await? else {
11681263 return Ok(Self::out_of_invites());
11691264 };
11701265 if let (Some(email), Some(code)) = (&email, &invite.code) {
11711266 let from = self.display_name(&a.user).await;
1172− self.send_invite_email(email, Some(&from), None, false, code, None).await;
1267+ let workspace = match &joins {
1268+ Some((id, slug)) => Some(self.workspace_name(id, slug).await),
1269+ None => None,
1270+ };
1271+ self.send_invite_email(email, Some(&from), workspace.as_deref(), false, code, None).await;
11731272 }
1174− let logs: Vec<String> = a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect();
1273+ let mut logs: Vec<String> = a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect();
1274+ if let Some((_, slug)) = &joins {
1275+ logs = vec![slug.clone()];
1276+ }
11751277 self.audit_invites(&a.user, "invite.created", logs, a.surface.unwrap_or(Surface::Web), format!("Created invite {}", invite.hint))
11761278 .await;
11771279 Ok(Outcome::Ok(invite))
12801382 .db
12811383 .prepare(format!(
12821384 "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL
1283− WHERE id = ?2 AND (redeemed_at IS NULL OR applied_at IS NULL) AND revoked_at IS NULL
1385+ WHERE id = ?2 AND revoked_at IS NULL AND declined_at IS NULL
1386+ AND (redeemed_at IS NULL OR applied_at IS NULL
1387+ -- A workspace invitation not yet answered.
1388+ OR (workspace_id IS NOT NULL AND accepted_at IS NULL))
12841389 AND (inviter_id = ?1
12851390 OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner')
12861391 OR charged_workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner'))
13001405 None => a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect(),
13011406 };
13021407 self.audit_invites(&a.user, "invite.revoked", logs, Surface::Web, format!("Revoked invite {}", row.hint)).await;
1408+ self.invitation_revoked(&a.user, &row).await;
13031409 Ok(Outcome::Ok(self.shown(row, false, false)))
13041410 }
13051411
13401446 let mine = self.verified_emails(&viewer.id).await?;
13411447 Some(mine.iter().any(|address| address.eq_ignore_ascii_case(bound.trim())))
13421448 }
1343− (None, _) => None,
1449+ // An invitation to someone by username is theirs alone.
1450+ (None, _) => row.invitee_id.as_ref().map(|invitee| *invitee == viewer.id),
13441451 },
13451452 _ => None,
13461453 };
14321539 "You already have a g1t account, so this invite has nothing more to give you. Pass it on to someone who needs it.",
14331540 ));
14341541 };
1542+ // An invitation to one account works for that account only.
1543+ if row.invitee_id.as_deref().is_some_and(|invitee| invitee != a.user.id) {
1544+ return Ok(Outcome::fail(
1545+ FailureCode::Forbidden,
1546+ "This invitation is for a different g1t account. Sign in as the account it was sent to.",
1547+ ));
1548+ }
14351549 // What the workspace asks of its members (security.rs); nothing yet.
14361550 if let Some(slug) = row.workspace.as_deref()
14371551 && let Some(why) = self.policy_refusal(&a.user.id, slug).await?
14511565 let claimed = self
14521566 .db
14531567 .prepare(format!(
1454− "UPDATE invites SET redeemed_by = ?, redeemed_at = {SQL_NOW}, sealed_code = NULL
1455− WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL AND expires_at > {SQL_NOW}
1568+ "UPDATE invites SET redeemed_by = ?1, invitee_id = COALESCE(invitee_id, ?1), redeemed_at = {SQL_NOW}, sealed_code = NULL
1569+ WHERE id = ?2 AND redeemed_at IS NULL AND revoked_at IS NULL AND declined_at IS NULL AND expires_at > {SQL_NOW}
14561570 RETURNING id"
14571571 ))
14581572 .bind(&[a.user.id.as_str().into(), row.id.as_str().into()])?
14801594 if a.actor.role_in(&slug) != Some(Role::Owner) {
14811595 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can invite people to a workspace."));
14821596 }
1483− let Some(email) = normalize_email(&a.email) else {
1484− return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL));
1597+ // A username, or an address; an address typed in the username's
1598+ // place is an address.
1599+ let username = a
1600+ .username
1601+ .as_deref()
1602+ .map(|name| name.trim().trim_start_matches('@').to_lowercase())
1603+ .filter(|name| !name.is_empty() && !name.contains('@'));
1604+ let email = match (&username, normalize_email(a.username.as_deref().unwrap_or(&a.email))) {
1605+ (Some(_), _) => None,
1606+ (None, Some(email)) => Some(email),
1607+ (None, None) => return Ok(Outcome::fail(FailureCode::Invalid, "Enter a g1t username or a valid email address.")),
14851608 };
1609+ let role = a.role.unwrap_or(Role::Member);
1610+ let role_name = if role == Role::Owner { "owner" } else { "member" };
14861611 let Some(workspace_id) = self.workspace_id(&slug).await? else {
14871612 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
14881613 };
14901615 if let Some(refused) = self.free_workspace_refusal(&slug).await? {
14911616 return Ok(refused);
14921617 }
1618+ let surface = a.surface.unwrap_or(Surface::Web);
1619+ // Someone on g1t, by username: an invitation to accept or decline
1620+ // (invites/invitations.rs).
1621+ let Some(email) = email else {
1622+ let username = username.unwrap_or_default();
1623+ return self.invite_account(&a.actor, &slug, &workspace_id, &username, role, surface).await;
1624+ };
14931625 if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? {
14941626 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
14951627 }
14971629 .rows(
14981630 &format!(
14991631 "WHERE i.workspace_id = ? AND i.email = ?
1500− AND i.redeemed_at IS NULL AND i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}"
1632+ AND i.redeemed_at IS NULL AND i.revoked_at IS NULL AND i.declined_at IS NULL AND i.expires_at > {SQL_NOW}"
15011633 ),
15021634 &[workspace_id.as_str().into(), email.as_str().into()],
15031635 1,
15101642 ));
15111643 }
15121644 let has_account = self.email_has_account(&email).await?;
1645+ // The account that has confirmed the address, which the invitation
1646+ // is for. Never shown to the inviter: the answer does not say
1647+ // whether the address has an account.
1648+ let invitee = self.user_with_verified_email(&email).await?;
15131649 let draft = if has_account {
15141650 // Costs nothing: the person is on g1t already.
15151651 Draft {
15211657 charged_to: "none",
15221658 charged_workspace_id: None,
15231659 limit: None,
1660+ invitee_id: invitee.as_deref(),
1661+ role: Some(role_name),
15241662 }
15251663 } else {
15261664 let shared = self.workspace_allowance(&workspace_id).await?;
15421680 charged_to,
15431681 charged_workspace_id,
15441682 limit,
1683+ invitee_id: None,
1684+ role: Some(role_name),
15451685 }
15461686 };
15471687 let Some(invite) = self.insert_invite(draft).await? else {
15521692 let workspace = self.workspace_name(&workspace_id, &slug).await;
15531693 self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, None).await;
15541694 }
1555− self.audit_invites(
1556− &a.actor,
1557− "invite.created",
1558− vec![slug.clone()],
1559− a.surface.unwrap_or(Surface::Web),
1560− format!("Invited {email} to {slug}"),
1561− )
1562− .await;
1695+ // Someone on g1t hears of it in their inbox too.
1696+ if invitee.is_some()
1697+ && let Some(row) = self.invite_by_id(&invite.id).await?
1698+ && let Some(username) = row.invitee.clone()
1699+ {
1700+ self.invitation_sent(&row, &username).await;
1701+ }
1702+ self.audit_invites(&a.actor, "invite.created", vec![slug.clone()], surface, format!("Invited {email} to {slug} as {role_name}"))
1703+ .await;
15631704 Ok(Outcome::Ok(invite))
15641705 }
15651706
15941735 charged_to,
15951736 charged_workspace_id,
15961737 limit,
1738+ invitee_id: None,
1739+ role: None,
15971740 };
15981741 Ok(match self.insert_invite(draft).await? {
15991742 Some(invite) => Outcome::Ok(invite),
19482091 charged_to: "none",
19492092 charged_workspace_id: None,
19502093 limit: None,
2094+ invitee_id: None,
2095+ role: None,
19512096 };
19522097 let Some(mut invite) = self.insert_invite(draft).await? else {
19532098 return Ok(Outcome::fail(FailureCode::Conflict, "The invite could not be made. Try again."));
22942439 redeemer: Some("ada".into()),
22952440 redeemed_at: Some(EARLIER.into()),
22962441 applied_at: None,
2442+ invitee_id: Some("usr_ada".into()),
2443+ invitee: Some("ada".into()),
2444+ role: None,
2445+ accepted_at: None,
2446+ declined_at: None,
22972447 }
22982448 }
22992449
23002450 #[test]
23012451 fn confirming_joins_the_workspace_the_invite_named_while_it_still_applies() {
2452+ // Confirming no longer joins anything by itself: the workspace the
2453+ // invite named becomes an invitation the person accepts or declines
2454+ // (invites/invitations.rs), and accepting joins it.
23022455 let good = row(Some(("wsp_1", Some("acme"))), false, LATER);
2303− assert_eq!(
2304− awaiting_join(&good, NOW, false),
2305− AwaitingJoin::Join { workspace_id: "wsp_1".into(), slug: "acme".into() }
2306− );
2456+ assert_eq!(awaiting_join(&good, NOW), AwaitingJoin::Invited { workspace_id: "wsp_1".into(), slug: "acme".into() });
23072457 // No workspace: nothing to join, and what came with it is accepted.
2308− assert_eq!(awaiting_join(&row(None, false, LATER), NOW, false), AwaitingJoin::Nothing);
2458+ assert_eq!(awaiting_join(&row(None, false, LATER), NOW), AwaitingJoin::Nothing);
2459+ // Confirmed and not yet answered: awaiting the answer.
2460+ let confirmed = InviteRow { applied_at: Some(NOW.into()), ..good };
2461+ assert_eq!(confirmed.status(NOW), InviteStatus::AwaitingAnswer);
2462+ // Accepted: used.
2463+ let accepted = InviteRow { accepted_at: Some(NOW.into()), ..confirmed };
2464+ assert_eq!(accepted.status(NOW), InviteStatus::Redeemed);
23092465 }
23102466
23112467 #[test]
23142470 AwaitingJoin::Lapsed(why) => why,
23152471 other => panic!("expected a lapse, got {other:?}"),
23162472 };
2317− let revoked = lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), true, LATER), NOW, false));
2473+ let revoked = lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), true, LATER), NOW));
23182474 assert!(revoked.starts_with("Your email address is confirmed."));
2319− assert!(revoked.contains("was revoked") && revoked.contains("did not join you to acme"));
2320− let expired = lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), false, EARLIER), NOW, false));
2475+ assert!(revoked.contains("was revoked") && revoked.contains("no longer invites you to acme"));
2476+ let expired = lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), false, EARLIER), NOW));
23212477 assert!(expired.contains("expired before you confirmed it"));
2322− assert!(lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), false, NOW), NOW, false)).contains("expired"));
2478+ assert!(lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), false, NOW), NOW)).contains("expired"));
23232479 // The workspace was deleted: its row no longer joins a slug.
2324− let deleted = lapsed(awaiting_join(&row(Some(("wsp_1", None)), false, LATER), NOW, false));
2480+ let deleted = lapsed(awaiting_join(&row(Some(("wsp_1", None)), false, LATER), NOW));
23252481 assert!(deleted.contains("has been deleted"));
2326− let free = lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), false, LATER), NOW, true));
2327− assert!(free.contains("free plan"));
2482+ // A free workspace still invites; accepting waits for its plan.
2483+ assert!(matches!(awaiting_join(&row(Some(("wsp_1", Some("acme"))), false, LATER), NOW), AwaitingJoin::Invited { .. }));
23282484 // Revoked beats expired; an invite without a workspace lapses too.
2329− assert!(lapsed(awaiting_join(&row(None, true, EARLIER), NOW, false)).contains("no longer applies"));
2485+ assert!(lapsed(awaiting_join(&row(None, true, EARLIER), NOW)).contains("no longer applies"));
23302486 }
23312487
23322488 #[test]
+680−0
1+//! Workspace invitations: nobody joins a workspace without saying yes.
2+//!
3+//! An invite that names a workspace (`invites.workspace_id`) is an
4+//! invitation for one account (`invitee_id`), which accepts or declines it:
5+//!
6+//! - **Someone with an account**, invited from a workspace's People page by
7+//! username or by address, gets the invitation at once: an item in their
8+//! inbox and an email, both leading to `/invitations`. Accepting joins
9+//! with the role chosen when they were invited; declining tells whoever
10+//! invited them, in their inbox.
11+//! - **Someone without one** gets an invite to make an account, which can
12+//! name a workspace the inviter owns (Settings → Invites, "Bring them
13+//! into"). Once the new account confirms its address, the invitation
14+//! waits for its answer the same way, for the invite TTL from then.
15+//!
16+//! An invitation works for the invite TTL (`INVITE_TTL_DAYS`, 30 days),
17+//! and the workspace's owners can revoke it from People → Pending
18+//! invitations until it is answered. A workspace on the free plan adds no
19+//! one (paid.rs): its invitations cannot be accepted until it starts the
20+//! plan, so they cannot be made either.
21+//!
22+//! Every new account that its invite does not bring into a workspace gets
23+//! a workspace of its own, named for its username, on the free plan
24+//! (workspaces.rs, `create_own_workspace`), so nobody is left without one.
25+
26+use g1t_contracts::audit::Surface;
27+use g1t_contracts::identity::*;
28+use g1t_contracts::time::{SQL_NOW, rfc3339};
29+use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User};
30+use g1t_kit::now_ms;
31+use serde::{Deserialize, Serialize};
32+use worker::Result;
33+use worker::wasm_bindgen::JsValue;
34+
35+use super::{CREATES_PER_HOUR, Draft, InviteRow, TOO_MANY};
36+use crate::Identity;
37+
38+/// What an invitation that is not someone's, or no longer open, gets.
39+const NOT_OPEN: &str = "That invitation is not open: it may have been answered, revoked or expired. Ask the workspace's owners to invite you again.";
40+/// The most people `find_people` returns.
41+const MAX_PEOPLE: u32 = 10;
42+/// The most invitations one person's list shows.
43+const MAX_INVITATIONS: u32 = 50;
44+
45+/// Whether an invitation can still be answered at `now`: it names a
46+/// workspace that exists, is neither answered nor revoked, has not
47+/// expired, and is ready for its person: an existing account's at once
48+/// (unused), a new account's once that account confirmed its address.
49+pub fn answerable(row: &InviteRow, now: &str) -> std::result::Result<(), &'static str> {
50+ let ready = match row.kind.as_str() {
51+ "workspace" => row.redeemed_at.is_none(),
52+ _ => row.redeemed_at.is_some() && row.applied_at.is_some(),
53+ };
54+ if row.workspace_id.is_none()
55+ || row.workspace.is_none()
56+ || row.accepted_at.is_some()
57+ || row.declined_at.is_some()
58+ || row.revoked_at.is_some()
59+ || row.expires_at.as_str() <= now
60+ || !ready
61+ {
62+ return Err(NOT_OPEN);
63+ }
64+ Ok(())
65+}
66+
67+/// Whether a new account gets a workspace of its own: unless its invite
68+/// brings it into one (`invited_to`, a workspace that still exists) that
69+/// can take it, which a workspace on the free plan (`free`) cannot.
70+pub fn makes_own_workspace(invited_to: Option<&str>, free: bool) -> bool {
71+ invited_to.is_none() || free
72+}
73+
74+/// The LIKE patterns `find_people` matches: a username starting with the
75+/// query, a name containing it. None for an empty query. `%`, `_` and `\`
76+/// match only themselves.
77+pub fn people_patterns(query: &str) -> Option<(String, String)> {
78+ let query = query.trim().trim_start_matches('@').to_lowercase();
79+ if query.is_empty() {
80+ return None;
81+ }
82+ let escaped: String = query
83+ .chars()
84+ .flat_map(|c| match c {
85+ '%' | '_' | '\\' => vec!['\\', c],
86+ c => vec![c],
87+ })
88+ .collect();
89+ Some((format!("{escaped}%"), format!("%{escaped}%")))
90+}
91+
92+/// How an invitation's role reads in a sentence.
93+fn as_role(role: Role) -> &'static str {
94+ match role {
95+ Role::Owner => "an owner",
96+ Role::Member => "a member",
97+ }
98+}
99+
100+/// `workspace_invitation.created`, `.accepted`, `.declined` and `.revoked`:
101+/// told in the inbox of the people named in `notify`, with a link (events'
102+/// inbox.rs). The inbox closes the invitee's item once it is answered or
103+/// revoked.
104+#[derive(Serialize)]
105+#[serde(rename_all = "camelCase")]
106+struct InvitationNotice<'a> {
107+ workspace: &'a str,
108+ invitation_id: &'a str,
109+ thread: String,
110+ notify: Vec<String>,
111+ title: String,
112+ body: String,
113+ link: String,
114+}
115+
116+#[derive(Deserialize)]
117+struct Person {
118+ id: String,
119+ username: String,
120+ email: Option<String>,
121+ verified: u8,
122+}
123+
124+#[derive(Deserialize)]
125+struct Pending {
126+ id: String,
127+ slug: String,
128+ name: String,
129+ avatar: Option<String>,
130+ role: Option<String>,
131+ created_at: String,
132+ expires_at: String,
133+ inviter: Option<String>,
134+ inviter_name: Option<String>,
135+ inviter_avatar: Option<String>,
136+}
137+
138+impl Identity {
139+ /// The workspace an own invite brings its person into, from `join` (a
140+ /// slug): one `user` owns that can add members. None for none.
141+ pub(crate) async fn joinable_workspace(&self, user: &User, join: Option<&str>) -> Result<Outcome<Option<(String, String)>>> {
142+ let Some(slug) = join.map(str::trim).filter(|slug| !slug.is_empty()).map(str::to_lowercase) else {
143+ return Ok(Outcome::Ok(None));
144+ };
145+ if user.role_in(&slug) != Some(Role::Owner) {
146+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only a workspace's owners can bring people into it."));
147+ }
148+ let Some(id) = self.workspace_id(&slug).await? else {
149+ return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
150+ };
151+ // A free workspace adds no one until it starts the plan (paid.rs).
152+ if let Some(refused) = self.free_workspace_refusal(&slug).await? {
153+ return Ok(refused);
154+ }
155+ Ok(Outcome::Ok(Some((id, slug))))
156+ }
157+
158+ /// Gives a new account a workspace of its own unless `invite` brings
159+ /// it into one. Never fails the sign-up: an account left without one
160+ /// is asked to make one by the site.
161+ pub(crate) async fn give_own_workspace(&self, user: &User, invite: Option<&InviteRow>) {
162+ let invited_to = invite.and_then(|row| row.workspace.as_deref());
163+ let free = match invited_to {
164+ Some(slug) => self.is_free_workspace(slug).await,
165+ None => false,
166+ };
167+ if !makes_own_workspace(invited_to, free) {
168+ return;
169+ }
170+ match self.create_own_workspace(user).await {
171+ Ok(Some(_)) => {}
172+ Ok(None) => worker::console_log!("no workspace of its own for {}: its name is taken", user.id),
173+ Err(error) => worker::console_error!("no workspace of its own for {}: {error}", user.id),
174+ }
175+ }
176+
177+ /// `invite_member` with a username: that account gets an invitation
178+ /// to `slug` (already checked: the actor owns it, it can add people).
179+ pub(crate) async fn invite_account(
180+ &self,
181+ actor: &User,
182+ slug: &str,
183+ workspace_id: &str,
184+ username: &str,
185+ role: Role,
186+ surface: Surface,
187+ ) -> Result<Outcome<Invite>> {
188+ let person = self
189+ .db
190+ .prepare(
191+ "SELECT id, username, email, email_verified_at IS NOT NULL AS verified
192+ FROM users WHERE username = ? AND deleted_at IS NULL",
193+ )
194+ .bind(&[username.into()])?
195+ .first::<Person>(None)
196+ .await?;
197+ let Some(person) = person.filter(|person| !crate::paid::is_g1t(&person.username)) else {
198+ return Ok(Outcome::fail(FailureCode::NotFound, "There is no account with that username."));
199+ };
200+ if person.id == actor.id {
201+ return Ok(Outcome::fail(FailureCode::Conflict, format!("You are already in {slug}.")));
202+ }
203+ let member = self
204+ .db
205+ .prepare("SELECT 1 AS n FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
206+ .bind(&[workspace_id.into(), person.id.as_str().into()])?
207+ .first::<serde_json::Value>(None)
208+ .await?;
209+ if member.is_some() {
210+ return Ok(Outcome::fail(FailureCode::Conflict, format!("@{} is already in {slug}.", person.username)));
211+ }
212+ if !self.hit(&format!("invite.create:{}", actor.id), CREATES_PER_HOUR).await? {
213+ return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
214+ }
215+ let pending = self
216+ .rows(
217+ &format!(
218+ "WHERE i.workspace_id = ? AND i.invitee_id = ? AND i.accepted_at IS NULL AND i.declined_at IS NULL
219+ AND i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}"
220+ ),
221+ &[workspace_id.into(), person.id.as_str().into()],
222+ 1,
223+ )
224+ .await?;
225+ if !pending.is_empty() {
226+ return Ok(Outcome::fail(
227+ FailureCode::Conflict,
228+ format!("@{} already has a pending invitation to {slug}. Revoke it to send a new one.", person.username),
229+ ));
230+ }
231+ let draft = Draft {
232+ email: None,
233+ kind: "workspace",
234+ workspace_id: Some(workspace_id),
235+ inviter: Some(actor),
236+ staff: None,
237+ // Costs nothing: the person is on g1t already.
238+ charged_to: "none",
239+ charged_workspace_id: None,
240+ limit: None,
241+ invitee_id: Some(&person.id),
242+ role: Some(if role == Role::Owner { "owner" } else { "member" }),
243+ };
244+ let Some(invite) = self.insert_invite(draft).await? else {
245+ return Ok(Outcome::fail(FailureCode::Conflict, "The invitation could not be made. Try again."));
246+ };
247+ if let (Some(email), true, Some(code)) = (&person.email, person.verified != 0, &invite.code) {
248+ let from = self.display_name(actor).await;
249+ let workspace = self.workspace_name(workspace_id, slug).await;
250+ self.send_invite_email(email, Some(&from), Some(&workspace), true, code, None).await;
251+ }
252+ if let Some(row) = self.invite_by_id(&invite.id).await? {
253+ self.invitation_sent(&row, &person.username).await;
254+ }
255+ self.audit_invites(
256+ actor,
257+ "invite.created",
258+ vec![slug.to_owned()],
259+ surface,
260+ format!("Invited @{} to {slug} as {}", person.username, as_role(role)),
261+ )
262+ .await;
263+ Ok(Outcome::Ok(invite))
264+ }
265+
266+ /// Tells `username` in their inbox that they are invited to the
267+ /// invite's workspace.
268+ pub(crate) async fn invitation_sent(&self, row: &InviteRow, username: &str) {
269+ let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) else {
270+ return;
271+ };
272+ let workspace = self.workspace_name(workspace_id, slug).await;
273+ let from = match &row.inviter {
274+ Some(inviter) => format!("@{inviter}"),
275+ None => "The g1t team".to_owned(),
276+ };
277+ self.invitation_notice(
278+ "workspace_invitation.created",
279+ row.inviter_id.as_deref(),
280+ row,
281+ vec![username.to_owned()],
282+ format!("{from} invited you to join {workspace}"),
283+ format!("Join as {}, or decline. The invitation works until {}.", as_role(row.joins_as()), &row.expires_at[..10.min(row.expires_at.len())]),
284+ "/invitations".to_owned(),
285+ )
286+ .await;
287+ }
288+
289+ #[allow(clippy::too_many_arguments)]
290+ async fn invitation_notice(
291+ &self,
292+ kind: &'static str,
293+ actor: Option<&str>,
294+ row: &InviteRow,
295+ notify: Vec<String>,
296+ title: String,
297+ body: String,
298+ link: String,
299+ ) {
300+ let slug = row.workspace.as_deref().unwrap_or_default();
301+ self.announce(
302+ kind,
303+ actor,
304+ InvitationNotice {
305+ workspace: slug,
306+ invitation_id: &row.id,
307+ thread: format!("invitation:{}", row.id),
308+ notify,
309+ title,
310+ body,
311+ link,
312+ },
313+ )
314+ .await;
315+ }
316+
317+ /// `list_invitations`: the workspace invitations waiting for `user`'s
318+ /// answer, newest first.
319+ pub async fn list_invitations(&self, a: UserArgs) -> Result<Vec<WorkspaceInvitation>> {
320+ if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
321+ return Ok(Vec::new());
322+ }
323+ let rows = self
324+ .db
325+ .prepare(format!(
326+ "SELECT i.id, w.slug, w.name, w.avatar, i.role, i.created_at, i.expires_at,
327+ iu.username AS inviter, iu.display_name AS inviter_name, iu.avatar AS inviter_avatar
328+ FROM invites i
329+ JOIN workspaces w ON w.id = i.workspace_id AND w.deleted_at IS NULL
330+ LEFT JOIN users iu ON iu.id = i.inviter_id
331+ WHERE i.invitee_id = ?1 AND i.accepted_at IS NULL AND i.declined_at IS NULL
332+ AND i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}
333+ AND ((i.kind = 'workspace' AND i.redeemed_at IS NULL)
334+ OR (i.kind = 'account' AND i.redeemed_at IS NOT NULL AND i.applied_at IS NOT NULL))
335+ AND NOT EXISTS (SELECT 1 FROM workspace_members m WHERE m.workspace_id = i.workspace_id AND m.user_id = ?1)
336+ ORDER BY i.created_at DESC, i.id DESC LIMIT {MAX_INVITATIONS}"
337+ ))
338+ .bind(&[a.user.id.as_str().into()])?
339+ .all()
340+ .await?
341+ .results::<Pending>()?;
342+ Ok(rows
343+ .into_iter()
344+ .map(|row| WorkspaceInvitation {
345+ id: row.id,
346+ workspace: ProfileWorkspace { slug: row.slug, name: row.name, avatar: row.avatar },
347+ role: if row.role.as_deref() == Some("owner") { Role::Owner } else { Role::Member },
348+ invited_by: row.inviter.map(|username| InviteFrom {
349+ username,
350+ name: row.inviter_name,
351+ avatar: row.inviter_avatar,
352+ }),
353+ created_at: row.created_at,
354+ expires_at: row.expires_at,
355+ })
356+ .collect())
357+ }
358+
359+ /// The invitation `id` if it is `user`'s and can be answered.
360+ async fn open_invitation(&self, user: &User, id: &str) -> Result<std::result::Result<InviteRow, &'static str>> {
361+ let row = self
362+ .invite_by_id(id)
363+ .await?
364+ .filter(|row| row.invitee_id.as_deref() == Some(user.id.as_str()));
365+ let Some(row) = row else {
366+ return Ok(Err(NOT_OPEN));
367+ };
368+ Ok(answerable(&row, &rfc3339(now_ms())).map(|()| row))
369+ }
370+
371+ /// `accept_invitation`: joins the invitation's workspace with its role.
372+ pub async fn accept_invitation(&self, a: InvitationArgs) -> Result<Outcome<String>> {
373+ if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
374+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can accept an invitation."));
375+ }
376+ if !a.user.verified {
377+ return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first, then accept the invitation."));
378+ }
379+ let row = match self.open_invitation(&a.user, &a.id).await? {
380+ Ok(row) => row,
381+ Err(why) => return Ok(Outcome::fail(FailureCode::NotFound, why)),
382+ };
383+ let (Some(workspace_id), Some(slug)) = (row.workspace_id.clone(), row.workspace.clone()) else {
384+ return Ok(Outcome::fail(FailureCode::NotFound, NOT_OPEN));
385+ };
386+ // What the workspace asks of its members (security.rs).
387+ if let Some(why) = self.policy_refusal(&a.user.id, &slug).await? {
388+ return Ok(Outcome::fail(FailureCode::Forbidden, why));
389+ }
390+ // A free workspace adds no one until it starts the plan (paid.rs);
391+ // the invitation stays open until then.
392+ if let Some(refused) = self.free_workspace_refusal(&slug).await? {
393+ return Ok(refused);
394+ }
395+ let role = row.joins_as();
396+ let now = rfc3339(now_ms());
397+ let user = JsValue::from(a.user.id.as_str());
398+ let id = JsValue::from(row.id.as_str());
399+ let at = JsValue::from(now.as_str());
400+ self.db
401+ .batch(vec![
402+ self.db
403+ .prepare(
404+ "UPDATE invites SET accepted_at = ?3, redeemed_by = COALESCE(redeemed_by, ?2),
405+ redeemed_at = COALESCE(redeemed_at, ?3), applied_at = COALESCE(applied_at, ?3), sealed_code = NULL
406+ WHERE id = ?1 AND invitee_id = ?2 AND accepted_at IS NULL AND declined_at IS NULL
407+ AND revoked_at IS NULL AND expires_at > ?3",
408+ )
409+ .bind(&[id.clone(), user.clone(), at.clone()])?,
410+ self.db
411+ .prepare(
412+ "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
413+ SELECT ?4, ?2, ?5, ?3
414+ WHERE EXISTS (SELECT 1 FROM invites WHERE id = ?1 AND invitee_id = ?2 AND accepted_at = ?3)
415+ AND EXISTS (SELECT 1 FROM workspaces WHERE id = ?4 AND deleted_at IS NULL)",
416+ )
417+ .bind(&[
418+ id.clone(),
419+ user,
420+ at,
421+ workspace_id.as_str().into(),
422+ if role == Role::Owner { "owner" } else { "member" }.into(),
423+ ])?,
424+ ])
425+ .await?;
426+ #[derive(Deserialize)]
427+ struct Accepted {
428+ accepted_at: Option<String>,
429+ }
430+ let accepted = self
431+ .db
432+ .prepare("SELECT accepted_at FROM invites WHERE id = ?")
433+ .bind(&[id])?
434+ .first::<Accepted>(None)
435+ .await?
436+ .and_then(|row| row.accepted_at);
437+ if accepted.as_deref() != Some(now.as_str()) {
438+ return Ok(Outcome::fail(FailureCode::Conflict, NOT_OPEN));
439+ }
440+ if row.kind == "workspace" {
441+ // An existing account's invitation: using it is this.
442+ self.settled(&row, &a.user, false, Some(slug.clone())).await;
443+ } else {
444+ let surface = a.surface.unwrap_or(Surface::Web);
445+ self.audit_invites(&a.user, "invite.accepted", vec![slug.clone()], surface, "Accepted the invitation".to_owned()).await;
446+ self.audit_invites(
447+ &a.user,
448+ "member.added",
449+ vec![slug.clone()],
450+ surface,
451+ format!("{} joined as {}", a.user.username, as_role(role)),
452+ )
453+ .await;
454+ }
455+ let workspace = self.workspace_name(&workspace_id, &slug).await;
456+ self.invitation_notice(
457+ "workspace_invitation.accepted",
458+ Some(&a.user.id),
459+ &row,
460+ row.inviter.iter().cloned().collect(),
461+ format!("@{} accepted your invitation to {workspace}", a.user.username),
462+ format!("They joined as {}.", as_role(role)),
463+ format!("/{slug}/-/people"),
464+ )
465+ .await;
466+ Ok(Outcome::Ok(slug))
467+ }
468+
469+ /// `decline_invitation`: says no, and tells whoever sent it.
470+ pub async fn decline_invitation(&self, a: InvitationArgs) -> Result<Outcome<bool>> {
471+ if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
472+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can decline an invitation."));
473+ }
474+ let row = match self.open_invitation(&a.user, &a.id).await? {
475+ Ok(row) => row,
476+ Err(why) => return Ok(Outcome::fail(FailureCode::NotFound, why)),
477+ };
478+ let declined = self
479+ .db
480+ .prepare(format!(
481+ "UPDATE invites SET declined_at = {SQL_NOW}, sealed_code = NULL
482+ WHERE id = ?1 AND invitee_id = ?2 AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL
483+ RETURNING id"
484+ ))
485+ .bind(&[row.id.as_str().into(), a.user.id.as_str().into()])?
486+ .first::<serde_json::Value>(None)
487+ .await?;
488+ if declined.is_none() {
489+ return Ok(Outcome::fail(FailureCode::Conflict, NOT_OPEN));
490+ }
491+ let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) else {
492+ return Ok(Outcome::Ok(true));
493+ };
494+ self.audit_invites(
495+ &a.user,
496+ "invite.declined",
497+ vec![slug.clone()],
498+ a.surface.unwrap_or(Surface::Web),
499+ format!("{} declined the invitation", a.user.username),
500+ )
501+ .await;
502+ let workspace = self.workspace_name(workspace_id, slug).await;
503+ self.invitation_notice(
504+ "workspace_invitation.declined",
505+ Some(&a.user.id),
506+ &row,
507+ row.inviter.iter().cloned().collect(),
508+ format!("@{} declined your invitation to {workspace}", a.user.username),
509+ "Nothing changed in the workspace.".to_owned(),
510+ format!("/{slug}/-/people"),
511+ )
512+ .await;
513+ Ok(Outcome::Ok(true))
514+ }
515+
516+ /// Closes the invitee's inbox item for an invitation revoked from People.
517+ pub(crate) async fn invitation_revoked(&self, actor: &User, row: &InviteRow) {
518+ if row.workspace_id.is_none() || row.invitee_id.is_none() {
519+ return;
520+ }
521+ self.invitation_notice(
522+ "workspace_invitation.revoked",
523+ Some(&actor.id),
524+ row,
525+ Vec::new(),
526+ String::new(),
527+ String::new(),
528+ String::new(),
529+ )
530+ .await;
531+ }
532+
533+ /// `find_people`: accounts to invite, by username or name.
534+ pub async fn find_people(&self, a: FindPeopleArgs) -> Result<Vec<InviteFrom>> {
535+ let Some((username, name)) = people_patterns(&a.query) else {
536+ return Ok(Vec::new());
537+ };
538+ let exact = a.query.trim().trim_start_matches('@').to_lowercase();
539+ let limit = a.limit.unwrap_or(8).clamp(1, MAX_PEOPLE);
540+ #[derive(Deserialize)]
541+ struct Row {
542+ username: String,
543+ name: Option<String>,
544+ avatar: Option<String>,
545+ }
546+ let rows = self
547+ .db
548+ .prepare(format!(
549+ "SELECT username, display_name AS name, avatar FROM users
550+ WHERE deleted_at IS NULL AND email_verified_at IS NOT NULL AND username <> ?4
551+ AND (username LIKE ?1 ESCAPE '\\' OR lower(display_name) LIKE ?2 ESCAPE '\\')
552+ ORDER BY username = ?3 DESC, username LIKE ?1 ESCAPE '\\' DESC, length(username), username
553+ LIMIT {limit}"
554+ ))
555+ .bind(&[
556+ username.into(),
557+ name.into(),
558+ exact.into(),
559+ g1t_contracts::identity::AGENT_NAME.into(),
560+ ])?
561+ .all()
562+ .await?
563+ .results::<Row>()?;
564+ Ok(rows
565+ .into_iter()
566+ .map(|row| InviteFrom { username: row.username, name: row.name, avatar: row.avatar })
567+ .collect())
568+ }
569+}
570+
571+#[cfg(test)]
572+mod tests {
573+ use super::*;
574+
575+ const NOW: &str = "2026-10-08T12:00:00.000Z";
576+ const EARLIER: &str = "2026-10-01T12:00:00.000Z";
577+ const LATER: &str = "2026-11-07T12:00:00.000Z";
578+
579+ fn invitation(kind: &str) -> InviteRow {
580+ InviteRow {
581+ id: "inv_1".into(),
582+ hint: "g1t-k7m2".into(),
583+ sealed_code: None,
584+ email: None,
585+ kind: kind.into(),
586+ workspace_id: Some("wsp_1".into()),
587+ workspace: Some("acme".into()),
588+ inviter_id: Some("usr_owner".into()),
589+ inviter: Some("syntaqx".into()),
590+ staff: None,
591+ charged_to: "none".into(),
592+ created_at: EARLIER.into(),
593+ expires_at: LATER.into(),
594+ revoked_at: None,
595+ redeemer: None,
596+ redeemed_at: None,
597+ applied_at: None,
598+ invitee_id: Some("usr_ada".into()),
599+ invitee: Some("ada".into()),
600+ role: None,
601+ accepted_at: None,
602+ declined_at: None,
603+ }
604+ }
605+
606+ #[test]
607+ fn an_existing_accounts_invitation_is_open_until_answered_revoked_or_expired() {
608+ let open = invitation("workspace");
609+ assert_eq!(answerable(&open, NOW), Ok(()));
610+ for closed in [
611+ InviteRow { accepted_at: Some(NOW.into()), ..invitation("workspace") },
612+ InviteRow { declined_at: Some(NOW.into()), ..invitation("workspace") },
613+ InviteRow { revoked_at: Some(NOW.into()), ..invitation("workspace") },
614+ InviteRow { expires_at: EARLIER.into(), ..invitation("workspace") },
615+ // Its workspace was deleted.
616+ InviteRow { workspace: None, ..invitation("workspace") },
617+ // Used through its link already.
618+ InviteRow { redeemed_at: Some(EARLIER.into()), ..invitation("workspace") },
619+ ] {
620+ assert_eq!(answerable(&closed, NOW), Err(NOT_OPEN));
621+ }
622+ }
623+
624+ #[test]
625+ fn a_new_accounts_invitation_opens_once_the_account_is_confirmed() {
626+ // The invite made the account, which has not confirmed its address.
627+ let waiting = InviteRow { redeemed_at: Some(EARLIER.into()), ..invitation("account") };
628+ assert_eq!(answerable(&waiting, NOW), Err(NOT_OPEN));
629+ // Confirmed: the invitation waits for its answer, and nothing was joined.
630+ let confirmed = InviteRow { applied_at: Some(NOW.into()), ..waiting };
631+ assert_eq!(answerable(&confirmed, NOW), Ok(()));
632+ assert_eq!(confirmed.status(NOW), InviteStatus::AwaitingAnswer);
633+ // Accepting joins with the role it names; member when none.
634+ assert_eq!(confirmed.joins_as(), Role::Member);
635+ assert_eq!(InviteRow { role: Some("owner".into()), ..invitation("workspace") }.joins_as(), Role::Owner);
636+ // An unused code is not an invitation yet.
637+ assert_eq!(answerable(&invitation("account"), NOW), Err(NOT_OPEN));
638+ }
639+
640+ #[test]
641+ fn an_answered_invitation_says_how_it_was_answered() {
642+ let accepted = InviteRow {
643+ redeemed_at: Some(NOW.into()),
644+ applied_at: Some(NOW.into()),
645+ accepted_at: Some(NOW.into()),
646+ ..invitation("account")
647+ };
648+ assert_eq!(accepted.status(NOW), InviteStatus::Redeemed);
649+ let declined = InviteRow { declined_at: Some(NOW.into()), ..invitation("workspace") };
650+ assert_eq!(declined.status(NOW), InviteStatus::Declined);
651+ // Not answered in time: expired, though the account it made stays.
652+ let late = InviteRow {
653+ redeemed_at: Some(EARLIER.into()),
654+ applied_at: Some(EARLIER.into()),
655+ expires_at: EARLIER.into(),
656+ ..invitation("account")
657+ };
658+ assert_eq!(late.status(NOW), InviteStatus::Expired);
659+ // An existing account's open invitation is pending.
660+ assert_eq!(invitation("workspace").status(NOW), InviteStatus::Pending);
661+ }
662+
663+ #[test]
664+ fn a_new_account_gets_its_own_workspace_unless_its_invite_brings_it_into_one() {
665+ // No invite, a shared link, or an invite that names no workspace.
666+ assert!(makes_own_workspace(None, false));
667+ // Brought into a workspace: none of its own, so never two.
668+ assert!(!makes_own_workspace(Some("acme"), false));
669+ // Into one on the free plan, which cannot take it: its own as well.
670+ assert!(makes_own_workspace(Some("acme"), true));
671+ }
672+
673+ #[test]
674+ fn people_are_found_by_username_prefix_or_name_with_wildcards_taken_literally() {
675+ assert_eq!(people_patterns(" @Ada "), Some(("ada%".to_owned(), "%ada%".to_owned())));
676+ assert_eq!(people_patterns("a_b%"), Some(("a\\_b\\%%".to_owned(), "%a\\_b\\%%".to_owned())));
677+ assert_eq!(people_patterns(" @ "), None);
678+ assert_eq!(people_patterns(""), None);
679+ }
680+}
+4−0
959959 "check_invite" => reply(&identity.check_invite(args(body)?).await?),
960960 "accept_invite" => reply(&identity.accept_invite(args(body)?).await?),
961961 "invite_member" => reply(&identity.invite_member(args(body)?).await?),
962+ "list_invitations" => reply(&identity.list_invitations(args(body)?).await?),
963+ "accept_invitation" => reply(&identity.accept_invitation(args(body)?).await?),
964+ "decline_invitation" => reply(&identity.decline_invitation(args(body)?).await?),
965+ "find_people" => reply(&identity.find_people(args(body)?).await?),
962966 "workspace_invites" => reply(&identity.workspace_invites(args(body)?).await?),
963967 "revoke_workspace_invite" => reply(&identity.revoke_workspace_invite(args(body)?).await?),
964968 "request_access" => reply(&identity.request_access(args(body)?).await?),
+62−7
7777 }
7878 }
7979
80+/// The name a new account's own workspace takes: its username, when that
81+/// can name a workspace at all (g1t's own names cannot).
82+pub(crate) fn own_workspace_slug(username: &str) -> Option<String> {
83+ claimable_namespace(username)
84+}
85+
8086 /// One of a person's memberships, as stored.
8187 #[derive(Deserialize)]
8288 struct MembershipRow {
201207 "That workspace name is taken.",
202208 ));
203209 }
210+ let name = match a.name.trim() {
211+ "" => slug.clone(),
212+ name => name.chars().take(MAX_NAME_LENGTH).collect(),
213+ };
214+ Ok(Outcome::Ok(self.insert_workspace(&a.user.id, slug, name).await?))
215+ }
216+
217+ /// A new account's own workspace, named for its username, on the free
218+ /// plan as every new workspace is: so nobody is left without one. Made
219+ /// only when the username is free to use as a workspace's name (it
220+ /// usually is: usernames and workspaces share one namespace). None
221+ /// when it is not, and the site asks the person to make one.
222+ pub(crate) async fn create_own_workspace(&self, user: &User) -> Result<Option<Workspace>> {
223+ let Some(slug) = own_workspace_slug(&user.username) else {
224+ return Ok(None);
225+ };
226+ if self.slug_in_use(&slug).await?
227+ || self.slug_held(&slug).await?
228+ || (self.slug_deleted(&slug).await? && !crate::deletion::may_reclaim(&slug, &user.username))
229+ {
230+ return Ok(None);
231+ }
232+ let made = self.insert_workspace(&user.id, slug.clone(), slug).await;
233+ match made {
234+ Ok(workspace) => Ok(Some(workspace)),
235+ // Taken a moment ago: the person makes one themselves.
236+ Err(error) if error.to_string().contains("UNIQUE") => Ok(None),
237+ Err(error) => Err(error),
238+ }
239+ }
240+
241+ /// Makes a workspace `user_id` owns, once every check has passed.
242+ async fn insert_workspace(&self, user_id: &str, slug: String, name: String) -> Result<Workspace> {
204243 let now = now_ms();
205244 let workspace = Workspace {
206245 id: new_id("wsp", now),
207− name: match a.name.trim() {
208− "" => slug.clone(),
209− name => name.chars().take(MAX_NAME_LENGTH).collect(),
210− },
246+ name,
211247 description: None,
212248 slug,
213249 created_at: rfc3339(now),
230266 workspace.id.as_str().into(),
231267 workspace.slug.as_str().into(),
232268 workspace.name.as_str().into(),
233− a.user.id.as_str().into(),
269+ user_id.into(),
234270 workspace.created_at.as_str().into(),
235271 workspace.base_permission.as_str().into(),
236272 ])?,
241277 )
242278 .bind(&[
243279 workspace.id.as_str().into(),
244− a.user.id.as_str().into(),
280+ user_id.into(),
245281 workspace.created_at.as_str().into(),
246282 ])?,
247283 ])
248284 .await?;
249285 self.forget_deleted(&workspace.slug).await?;
250− Ok(Outcome::Ok(workspace))
286+ Ok(workspace)
251287 }
252288
253289 pub async fn get_workspace(&self, a: SlugArgs) -> Result<Option<Workspace>> {
374410 Outcome::Ok(target) => target,
375411 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
376412 };
413+ // A person is never added without saying yes: they get a workspace
414+ // invitation to accept or decline (invites/invitations.rs). Only
415+ // g1t's own agent is added at once.
416+ if !crate::paid::is_g1t(&user.username) {
417+ let invited = self
418+ .invite_member(InviteMemberArgs {
419+ actor: a.actor,
420+ slug: a.slug,
421+ email: String::new(),
422+ username: Some(user.username),
423+ role: None,
424+ surface: a.surface,
425+ })
426+ .await?;
427+ return Ok(match invited {
428+ Outcome::Ok(_) => Outcome::Ok(true),
429+ Outcome::Fail(failure) => Outcome::Fail(failure),
430+ });
431+ }
377432 // What the workspace asks of its members (security.rs); nothing yet.
378433 if let Some(why) = self.policy_refusal(&user.id, &a.slug.to_lowercase()).await? {
379434 return Ok(Outcome::fail(FailureCode::Forbidden, why));