Skip to content

Commit

Merge branch 'worktree-agent-a6da31375506fc440'

# Conflicts: # apps/web/app/components/shell.tsx

syntaqxcommitted Parents722617fddbbba1Browse files
44 files+371−2928 files not counted0/44 viewed
+9−1
8787 ),
8888 (
8989 "Invites",
90− "Inviting people into a workspace by username or email, and answering the invitations to workspaces sent to you.",
90+ "Inviting a friend to g1t by email, inviting people into a workspace by username or email, and answering the invitations to workspaces sent to you. Anyone can sign up for g1t without an invite.",
9191 &[
92+ Op::ListInvites,
93+ Op::CreateInvite,
94+ Op::RevokeInvite,
95+ Op::ResendInvite,
9296 Op::ListWorkspaceInvites,
9397 Op::InviteMember,
9498 Op::RevokeWorkspaceInvite,
633637 Op::ConfirmEmail => "Confirm an email address",
634638 Op::RemoveEmail => "Remove an email address",
635639 Op::UpdateEmailSettings => "Change your email settings",
640+ Op::ListInvites => "List your invites to g1t",
641+ Op::CreateInvite => "Invite a friend to g1t",
642+ Op::RevokeInvite => "Revoke an invite to g1t",
643+ Op::ResendInvite => "Send an invite to g1t again",
636644 Op::ListWorkspaceInvites => "List a workspace's invites",
637645 Op::InviteMember => "Invite someone to a workspace",
638646 Op::ListInvitations => "List your workspace invitations",
+83−1
127127 ConfirmEmail,
128128 RemoveEmail,
129129 UpdateEmailSettings,
130+ ListInvites,
131+ CreateInvite,
132+ RevokeInvite,
133+ ResendInvite,
130134 ListWorkspaceInvites,
131135 InviteMember,
132136 RevokeWorkspaceInvite,
700704 }
701705
702706 impl Op {
703− pub const ALL: [Op; 376] = [
707+ pub const ALL: [Op; 380] = [
704708 Op::Whoami,
705709 Op::GetWorkspace,
706710 Op::CreateWorkspace,
716720 Op::ConfirmEmail,
717721 Op::RemoveEmail,
718722 Op::UpdateEmailSettings,
723+ Op::ListInvites,
724+ Op::CreateInvite,
725+ Op::RevokeInvite,
726+ Op::ResendInvite,
719727 Op::ListWorkspaceInvites,
720728 Op::InviteMember,
721729 Op::RevokeWorkspaceInvite,
11011109 Op::ConfirmEmail => "confirm_email",
11021110 Op::RemoveEmail => "remove_email",
11031111 Op::UpdateEmailSettings => "update_email_settings",
1112+ Op::ListInvites => "list_invites",
1113+ Op::CreateInvite => "create_invite",
1114+ Op::RevokeInvite => "revoke_invite",
1115+ Op::ResendInvite => "resend_invite",
11041116 Op::ListWorkspaceInvites => "list_workspace_invites",
11051117 Op::InviteMember => "invite_member",
11061118 Op::RevokeWorkspaceInvite => "revoke_workspace_invite",
13101322 Op::UpdateEmailSettings => {
13111323 "Change what your addresses do; only the fields given change. `primary` is a confirmed address to make primary: account mail and password resets go there. `backup` is a confirmed address that also gets security notices, or an empty string for the primary only. Changing either needs your account `password`, and every confirmed address is told. `private_email` keeps your address off commits g1t makes for you (merges and changes made on the web, and agents' commits for you), which use your noreply address instead; `block_private_pushes` refuses pushes whose commits carry one of your addresses while it is private. People only."
13121324 }
1325+ Op::ListInvites => {
1326+ "Your invites to g1t, newest first, and how many you have left. Anyone can sign up for g1t without an invite; an invite is a note from you with a link that opens sign-up with your friend's address filled in and confirmed. You have 20 for good (`allowance.limit`, more if g1t granted them): pending and used ones count, and one revoked or expired before it was used comes back. A pending invite's `code` is shown to you; the link is https://g1t.sh/invite/<code>. `status` is `pending`; `awaiting_confirmation` (used to make an account that has not confirmed its address yet); `awaiting_answer` (used to make an account that has yet to accept or decline the workspace it was also invited to); `redeemed` (they joined: `redeemed_by` is their username); `declined`; `expired`; or `revoked`. People only."
1327+ }
1328+ Op::CreateInvite => {
1329+ "Invite a friend to g1t by `email`. They get an email from you, with your `message` if you write one, and a link that opens sign-up with their address filled in and confirmed. Their account starts with a workspace of its own. It works for 30 days and uses one of your 20 invites; revoking it before it is used gives it back. Refused with `409` when the address already has a g1t account or you already have a pending invite for it (neither uses one), and `limit` when you have none left. With `workspace`, a workspace you own on a paid plan, they are also invited to it as `role` (`member` unless you give `owner`): once their account is confirmed they accept or decline, and no workspace of their own is made; a free workspace is refused with `payment_required` (402). Returns the invite with its `code`. People only, once your own address is confirmed: an agent's or a workspace's token cannot invite anyone to g1t."
1330+ }
1331+ Op::RevokeInvite => {
1332+ "Revoke a pending invite to g1t you made. It stops working at once, and the invite comes back to you. One already used to make an account stays used."
1333+ }
1334+ Op::ResendInvite => {
1335+ "Send a pending invite to g1t again: the same email, with the same link, to the same address. Refused with `409` when it is no longer pending."
1336+ }
13131337 Op::ListWorkspaceInvites => {
13141338 "The invites made for a workspace, newest first, with each pending one's `code`. Owners only."
13151339 }
18811905 }),
18821906 &[],
18831907 ),
1908+ Op::ListInvites => object(json!({}), &[]),
1909+ Op::CreateInvite => object(
1910+ json!({
1911+ "email": { "type": "string", "description": "Your friend's email address. The invite is sent there, and only that address can use it." },
1912+ "message": {
1913+ "type": "string",
1914+ "description": "A note for the email, quoted under your name. Up to 500 characters.",
1915+ },
1916+ "workspace": {
1917+ "type": "string",
1918+ "description": "Also invite them to this workspace, by slug: one you own on a paid plan. Left out, the invite is to g1t only.",
1919+ },
1920+ "role": {
1921+ "type": "string",
1922+ "enum": ["member", "owner"],
1923+ "description": "The role they join workspace with when they accept. member when left out.",
1924+ },
1925+ }),
1926+ &["email"],
1927+ ),
1928+ Op::RevokeInvite | Op::ResendInvite => object(
1929+ json!({ "id": { "type": "string", "description": "The invite's id, such as inv_01k…" } }),
1930+ &["id"],
1931+ ),
18841932 Op::ListWorkspaceInvites => object(json!({ "workspace": workspace_schema() }), &["workspace"]),
18851933 Op::InviteMember => object(
18861934 json!({
33393387 | Op::ConfirmEmail
33403388 | Op::RemoveEmail
33413389 | Op::UpdateEmailSettings
3390+ | Op::ListInvites
3391+ | Op::CreateInvite
3392+ | Op::RevokeInvite
3393+ | Op::ResendInvite
33423394 | Op::ListWorkspaceInvites
33433395 | Op::InviteMember
33443396 | Op::RevokeWorkspaceInvite
36413693 )
36423694 .await
36433695 }
3696+ // Invites to g1t from Invite a friend: identity refuses anyone
3697+ // but a person with a confirmed address.
3698+ Op::ListInvites => {
3699+ let overview: g1t_contracts::identity::InvitesOverview =
3700+ g1t_kit::call(identity, "list_invites", &json!({ "user": actor() })).await?;
3701+ ok(&overview)
3702+ }
3703+ Op::CreateInvite => {
3704+ let role = optional_text(input, "role");
3705+ if role.as_deref().is_some_and(|role| role != "member" && role != "owner") {
3706+ return failed(FailureCode::Invalid, "role is member or owner.");
3707+ }
3708+ pass(
3709+ identity,
3710+ "create_invite",
3711+ &json!({
3712+ "user": actor(),
3713+ "email": text(input, "email"),
3714+ "message": optional_text(input, "message"),
3715+ "join": optional_text(input, "workspace"),
3716+ "join_role": role,
3717+ "surface": services.audit.surface,
3718+ }),
3719+ )
3720+ .await
3721+ }
3722+ Op::RevokeInvite | Op::ResendInvite => {
3723+ let method = if self == Op::RevokeInvite { "revoke_invite" } else { "resend_invite" };
3724+ pass(identity, method, &json!({ "user": actor(), "id": text(input, "id") })).await
3725+ }
36443726 Op::ListWorkspaceInvites => {
36453727 pass(identity, "workspace_invites", &json!({ "slug": workspace(), "viewer": viewer })).await
36463728 }
+119−0
383383 },
384384 "notes": "Only the fields given change. `primary` and `backup` need `password`; `private_email` and `block_private_pushes` do not."
385385 },
386+ "list_invites": {
387+ "response": {
388+ "allowance": {
389+ "limit": 20,
390+ "used": 2,
391+ "remaining": 18
392+ },
393+ "invites": [
394+ {
395+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
396+ "code": "g1t-k7m2-q9xd-4hpw-…",
397+ "hint": "g1t-k7m2",
398+ "email": "ada@example.com",
399+ "kind": "account",
400+ "workspace": null,
401+ "status": "pending",
402+ "charged_to": "user",
403+ "invited_by": "syntaqx",
404+ "redeemed_by": null,
405+ "created_at": "2026-10-05T17:00:00.000Z",
406+ "expires_at": "2026-11-04T17:00:00.000Z",
407+ "redeemed_at": null,
408+ "revoked_at": null,
409+ "invitee": null,
410+ "role": null
411+ },
412+ {
413+ "id": "inv_01kp1v3c4d5e6f7g8h9j0k1m2n",
414+ "code": null,
415+ "hint": "g1t-p3rt",
416+ "email": "grace@example.com",
417+ "kind": "account",
418+ "workspace": null,
419+ "status": "redeemed",
420+ "charged_to": "user",
421+ "invited_by": "syntaqx",
422+ "redeemed_by": "grace",
423+ "created_at": "2026-10-02T09:12:40.000Z",
424+ "expires_at": "2026-11-01T09:12:40.000Z",
425+ "redeemed_at": "2026-10-02T11:30:05.000Z",
426+ "revoked_at": null,
427+ "invitee": "grace",
428+ "role": null
429+ }
430+ ]
431+ },
432+ "notes": "Anyone can sign up for g1t without an invite. A revoked or expired invite that was never used is not counted, so it comes back; a used one stays spent. `status` is `pending`, `awaiting_confirmation`, `awaiting_answer` (the account it made has yet to accept or decline the workspace in `workspace`), `redeemed` (they joined, as `redeemed_by`), `declined`, `expired` or `revoked`. See [Invite a friend](/guides/authentication/#invite-a-friend)."
433+ },
434+ "create_invite": {
435+ "request": {
436+ "email": "ada@example.com",
437+ "message": "This is where we keep the compiler now. Come and look."
438+ },
439+ "response": {
440+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
441+ "code": "g1t-k7m2-q9xd-4hpw-…",
442+ "hint": "g1t-k7m2",
443+ "email": "ada@example.com",
444+ "kind": "account",
445+ "workspace": null,
446+ "status": "pending",
447+ "charged_to": "user",
448+ "invited_by": "syntaqx",
449+ "redeemed_by": null,
450+ "created_at": "2026-10-05T17:00:00.000Z",
451+ "expires_at": "2026-11-04T17:00:00.000Z",
452+ "redeemed_at": null,
453+ "revoked_at": null,
454+ "invitee": null,
455+ "role": null
456+ },
457+ "notes": "The code is returned in full; the email g1t sends carries a link to `https://g1t.sh/invite/<code>` that opens sign-up with the address filled in and confirmed. Add `workspace` (and `role`) to also invite them to a workspace you own on a paid plan: once their account is confirmed they accept or decline it, and no workspace of their own is made. A free workspace is refused with `402` (`payment_required`). Refused with `409` when the address already has a g1t account or you already have a pending invite for it, neither of which uses an invite; with `402` (`limit`) when you have none left; and with `403` for an agent's or a workspace's token, or before your own address is confirmed."
458+ },
459+ "revoke_invite": {
460+ "response": {
461+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
462+ "code": null,
463+ "hint": "g1t-k7m2",
464+ "email": "ada@example.com",
465+ "kind": "account",
466+ "workspace": null,
467+ "status": "revoked",
468+ "charged_to": "user",
469+ "invited_by": "syntaqx",
470+ "redeemed_by": null,
471+ "created_at": "2026-10-05T17:00:00.000Z",
472+ "expires_at": "2026-11-04T17:00:00.000Z",
473+ "redeemed_at": null,
474+ "revoked_at": "2026-10-06T08:00:00.000Z",
475+ "invitee": null,
476+ "role": null
477+ },
478+ "notes": "The invite comes back to you. One already used to make an account cannot be revoked and stays spent."
479+ },
480+ "resend_invite": {
481+ "response": {
482+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
483+ "code": "g1t-k7m2-q9xd-4hpw-…",
484+ "hint": "g1t-k7m2",
485+ "email": "ada@example.com",
486+ "kind": "account",
487+ "workspace": null,
488+ "status": "pending",
489+ "charged_to": "user",
490+ "invited_by": "syntaqx",
491+ "redeemed_by": null,
492+ "created_at": "2026-10-05T17:00:00.000Z",
493+ "expires_at": "2026-11-04T17:00:00.000Z",
494+ "redeemed_at": null,
495+ "revoked_at": null,
496+ "invitee": null,
497+ "role": null
498+ },
499+ "notes": "Sends the same email again, with the same link; when it stops working does not change. Refused with `409` unless it is pending."
500+ },
386501 "list_workspace_invites": {
387502 "response": [
388503 {
393508 "kind": "account",
394509 "workspace": "acme-labs",
395510 "status": "pending",
511+ "charged_to": "none",
396512 "invited_by": "syntaqx",
397513 "redeemed_by": null,
398514 "created_at": "2026-10-05T17:00:00.000Z",
410526 "kind": "workspace",
411527 "workspace": "acme-labs",
412528 "status": "pending",
529+ "charged_to": "none",
413530 "invited_by": "syntaqx",
414531 "redeemed_by": null,
415532 "created_at": "2026-10-05T17:00:00.000Z",
434551 "kind": "workspace",
435552 "workspace": "acme-labs",
436553 "status": "pending",
554+ "charged_to": "none",
437555 "invited_by": "syntaqx",
438556 "redeemed_by": null,
439557 "created_at": "2026-10-05T17:00:00.000Z",
454572 "kind": "account",
455573 "workspace": "acme-labs",
456574 "status": "revoked",
575+ "charged_to": "none",
457576 "invited_by": "syntaqx",
458577 "redeemed_by": null,
459578 "created_at": "2026-10-05T17:00:00.000Z",
+2−1
216216 through::<g1t_contracts::accounts::AccountEmails>(op, sent)
217217 }
218218 Op::ConfirmEmail => through::<g1t_contracts::accounts::EmailConfirmed>(op, sent),
219− Op::InviteMember | Op::RevokeWorkspaceInvite => {
219+ Op::ListInvites => through::<g1t_contracts::identity::InvitesOverview>(op, sent),
220+ Op::CreateInvite | Op::RevokeInvite | Op::ResendInvite | Op::InviteMember | Op::RevokeWorkspaceInvite => {
220221 through::<g1t_contracts::identity::Invite>(op, sent)
221222 }
222223 Op::ListWorkspaceInvites => through::<Vec<g1t_contracts::identity::Invite>>(op, sent),
+5−0
5252 route("POST", "/user/emails/confirm", Op::ConfirmEmail, &[]),
5353 route("DELETE", "/user/emails/:email", Op::RemoveEmail, &[]),
5454 route("PATCH", "/user/email-settings", Op::UpdateEmailSettings, &[]),
55+ // Your invites to g1t (Invite a friend).
56+ route("GET", "/user/invites", Op::ListInvites, &[]),
57+ route("POST", "/user/invites", Op::CreateInvite, &[]),
58+ route("DELETE", "/user/invites/:id", Op::RevokeInvite, &[]),
59+ route("POST", "/user/invites/:id/resend", Op::ResendInvite, &[]),
5560 // Invitations to workspaces waiting for your answer.
5661 route("GET", "/user/invitations", Op::ListInvitations, &[]),
5762 route("POST", "/user/invitations/:id/accept", Op::AcceptInvitation, &[]),
+5−1
533533 Tool {
534534 name: "account",
535535 title: "Your account",
536− description: "Who this token acts as and its workspaces (`whoami`), your email addresses, and invitations to workspaces and repositories waiting for you.",
536+ description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites to g1t, and invitations to workspaces and repositories waiting for you.",
537537 default_action: Some("whoami"),
538538 actions: &[
539539 a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"),
542542 a("confirm_email", Op::ConfirmEmail, "Confirm an address with the code from its email"),
543543 a("remove_email", Op::RemoveEmail, "Remove an address"),
544544 a("update_email_settings", Op::UpdateEmailSettings, "Primary, backup and privacy"),
545+ a("list_invites", Op::ListInvites, "Your invites to g1t, and how many are left"),
546+ a("create_invite", Op::CreateInvite, "Invite a friend to g1t by email"),
547+ a("revoke_invite", Op::RevokeInvite, "Revoke one; it comes back"),
548+ a("resend_invite", Op::ResendInvite, "Send a pending one again"),
545549 a("list_workspace_invitations", Op::ListInvitations, "Invitations to workspaces for you"),
546550 a("accept_workspace_invitation", Op::AcceptInvitation, "Accept one and join"),
547551 a("decline_workspace_invitation", Op::DeclineInvitation, "Decline one"),
+3−3
216216 | Someone else on g1t | They are sent an [invitation](#invitations) to accept. Typing the confirmed email address of someone on g1t does the same. |
217217 | An email address with no g1t account | g1t emails an invite code that only that address can use. Signing up with it makes their account and accepts the invitation in one step. |
218218
219−An invite code to someone without an account uses one of the workspace's
220−granted invites, or else one of yours (see
221−[invites](/guides/authentication/#invites)), and works for 30 days.
219+An invite code to someone without an account costs nothing (it never
220+uses one of your [invites to g1t](/guides/authentication/#invite-a-friend))
221+and works for 30 days.
222222
223223 On a free workspace, only the first row works: its members can be given a
224224 role, but nobody else can be invited until the workspace starts the g1t
+65−5
4545 | Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location, website and time zone. |
4646 | Emails | [`/settings/emails`](https://g1t.sh/settings/emails) | Your [email addresses](#email-addresses), the backup address, and [keeping your address private](#keeping-your-address-private). |
4747 | Notifications | [`/settings/notifications`](https://g1t.sh/settings/notifications) | [Sounds and do not disturb](/guides/chat/#sounds-and-do-not-disturb), [pop-ups and browser notifications](/guides/chat/#notifications), and [what you are emailed for and how you watch repositories](/guides/notifications/#settings). |
48+| Invites to g1t | [`/settings/invites`](https://g1t.sh/settings/invites) | [Invite a friend](#invite-a-friend): the invites you sent, who joined, and how many you have left. |
4849 | SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/#ssh), each with when it was added and last used. |
4950 | Access tokens | [`/settings/tokens`](https://g1t.sh/settings/tokens) | Your [personal access tokens](#access-tokens): their permissions, where they reach, and when they expire. |
5051 | Integrations | [`/settings/integrations`](https://g1t.sh/settings/integrations) | [Your own integrations](/guides/integrations/#workspace-and-personal): what you have connected for yourself, in every workspace, and what is coming. |
6364 unlink GitHub in [Settings → GitHub](https://g1t.sh/settings/github). See
6465 [GitHub](/guides/github/#sign-in-with-github).
6566
66−Making an account with GitHub needs an invite too: start from your invite
67−link, or enter the code when g1t asks for it after GitHub.
67+Making an account with GitHub needs no invite. If a friend invited you,
68+you can still start from the link in their email: when GitHub has
69+confirmed the same address, the account is made with it.
6870
6971 With [two-factor authentication](#two-factor-authentication) on, signing in
7072 with GitHub asks for a code from your app as well.
132134 Anyone can sign up without an invite. Invites are how people already on
133135 g1t bring someone in:
134136
137+- [Invite a friend](#invite-a-friend): anyone on g1t invites someone to
138+ g1t by email address, with no workspace attached;
135139 - an owner's [invitation into a workspace](#inviting-someone-into-a-workspace),
136140 by username or by email address;
137141 - an [invitation to collaborate on a repository](/guides/access-and-roles/),
197201 can ask them for a new one; or [sign up](https://g1t.sh/register) without
198202 it.
199203
204+### Invite a friend
205+
206+Anyone on g1t can invite a friend, by email, from **Invite a friend** in
207+the account menu at the bottom of the sidebar, or from
208+[Settings → Invites to g1t](https://g1t.sh/settings/invites). It is word
209+of mouth, not a key: your friend could sign up at
210+[g1t.sh/register](https://g1t.sh/register) without it. What the invite
211+adds:
212+
213+- an email from you, with your name and, if you write one, your message;
214+- a link that opens sign-up with their address filled in and, because they
215+ came from that email, already confirmed (see
216+ [invites from your inbox](#invites-from-your-inbox));
217+- on your Invites to g1t page, the invite's status: **Pending**,
218+ **@ada is confirming their email**, then **Joined as @ada**.
219+
220+Their account starts with a workspace of its own, on the free plan, the
221+same as any other. An invite to g1t adds them to no workspace of yours.
222+
223+Each person has **20 invites to g1t**, for good. A pending invite counts
224+until it is used, revoked or expires (after 30 days); revoking one that
225+was never used, or letting it expire, gives it back. One that was used
226+to make an account stays spent. The page shows how many you have left,
227+and the account menu shows the same number beside **Invite a friend**. If
228+you run out, your friends can still sign up without one; g1t staff can
229+grant more.
230+
231+Some invites are refused, and cost nothing:
232+
233+- to an address that already has a g1t account: to work with them,
234+ [invite them into a workspace](#inviting-someone-into-a-workspace) by
235+ username instead;
236+- to an address you already have a pending invite for: send that one
237+ again from its **⋯** menu, or revoke it first.
238+
239+From the **⋯** menu beside each pending invite you can copy its link,
240+send the email again, or revoke it. Only people can invite friends, once
241+their own address is confirmed: an agent's or a workspace's token cannot.
242+
243+**Also invite them to a workspace** appears in the dialog only when you
244+own a workspace on a paid plan. Ticked, your friend also gets a
245+[workspace invitation](#workspace-invitations) to that workspace, with
246+the role you choose, to accept or decline once their account is
247+confirmed; they then get no workspace of their own. A workspace on the
248+free plan cannot invite anyone, so it is never offered. An invitation
249+into a workspace never uses one of your 20 invites.
250+
251+On a self-hosted installation, identity's `INVITES_PER_USER` sets the
252+number (20 when unset; 0 turns Invite a friend off).
253+
200254 ### Invites from your inbox
201255
202−When g1t emails an invite to an address (an owner's invitation into a
203−workspace, or an invitation onto a repository), the link in that email carries a
256+When g1t emails an invite to an address (a friend's invite to g1t, an
257+owner's invitation into a workspace, or an invitation onto a repository),
258+the link in that email carries a
204259 `proof` that only the email has: `g1t.sh/invite/<code>?proof=…`. Opening
205260 the link shows that you can read that inbox, so:
206261
252307 - An account whose invite brings it into a workspace gets the invitation
253308 to it, and no workspace of its own.
254309 - Every other account (signed up with a password or with GitHub, or with
255− an invite that names no workspace, such as one onto a repository) gets a
310+ an invite that names no workspace, such as a friend's invite to g1t or
311+ one onto a repository) gets a
256312 workspace of its own, named for its username, on the free plan. Rename it or start the plan on it whenever you like.
257313
258314 Signed in without any workspace (you declined an invitation, or left the
265321
266322 | Route | MCP tool and action | What it does |
267323 | --- | --- | --- |
324+| [`GET /user/invites`](/reference/api/invites/list-invites/) | `account` `list_invites` | Your invites to g1t, newest first, and `allowance` (`limit`, `used`, `remaining`) |
325+| [`POST /user/invites`](/reference/api/invites/create-invite/) | `account` `create_invite` | Invite a friend to g1t by `email`, with an optional `message`; optionally also to a paid `workspace` you own, as `role` |
326+| [`DELETE /user/invites/{id}`](/reference/api/invites/revoke-invite/) | `account` `revoke_invite` | Revoke a pending invite; it comes back to you |
327+| [`POST /user/invites/{id}/resend`](/reference/api/invites/resend-invite/) | `account` `resend_invite` | Send a pending invite's email again |
268328 | [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/) | `workspace` `invite_member` | Invite a `username` or an `email` into a workspace, with a `role`. Owners only. |
269329 | [`GET /user/invitations`](/reference/api/invites/list-invitations/) | `account` `list_workspace_invitations` | The workspace invitations waiting for your answer |
270330 | [`POST /user/invitations/{id}/accept`](/reference/api/invites/accept-invitation/) | `account` `accept_workspace_invitation` | Join the invitation's workspace with its role |
+1−0
168168 | `AWS_CLI_IMAGE` | `amazon/aws-cli:2.37.10` | The image `storage-setup` makes the buckets and the packs' lifecycle rule with. |
169169 | `BACKUP_S3_BUCKET` | `g1t-backups` | The bucket on the same store that nightly repository backups (a `git bundle` of each repository whose branches or tags changed) are kept in. The bundles are cut by g1t's runner, which this installation does not run yet, so the bucket stays empty for now: copy the volumes, as below. |
170170 | `STATUS_PORT` | `8788` | The port the status page is published on |
171+| `INVITES_PER_USER` | `20` | How many friends each person may invite by email with [Invite a friend](/guides/authentication/#invite-a-friend). `0` turns it off. Anyone may register with or without an invite. |
171172 | `STATUS_PROBE_REPO` | (none) | A public repository, `workspace/repo`, whose branches the status page lists every minute as a clone would. Empty: git is not checked. |
172173 | `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET` | (none) | A [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) widget's site key and secret key, to check that a person fills in the sign-up form. With either unset, sign-up has no such check. |
173174 | `PHONE_PUSH` | `on` | `off`: send [the phone app](#the-phone-app) no notifications, so their words never pass through Expo's push service. Browser and desktop notifications are not affected. |
+3−1
368368
369369 Anyone can make a g1t account without an invite, so to bring someone in
370370 you invite them to the workspace; someone without an account signs up from
371−the invitation.
371+the invitation. To tell a friend about g1t without adding them to a
372+workspace, use [Invite a friend](/guides/authentication/#invite-a-friend)
373+in your account menu instead.
372374
373375 On the workspace's **Members** page, `g1t.sh/<workspace>/-/members`
374376 (**Workspace → Access → Members** in the sidebar), or on **Invitations**
+1−1
230230 | [Workspaces](/reference/api/workspaces/create-workspace/) | Creating a workspace. |
231231 | [Notifications](/reference/api/notifications/list-notifications/) | Your notifications: their threads, why you were told of each, marking them read, done, saved or snoozed, and what you subscribe to and watch. See [notifications](/guides/notifications/). |
232232 | [Billing](/reference/api/billing/get-usage/) | A workspace's usage by product, project and day, its budget and its invoices. See [usage and billing](/guides/usage-and-billing/). |
233−| [Invites](/reference/api/invites/list-workspace-invites/) | Inviting people into a workspace by username or email, and answering the invitations sent to you. |
233+| [Invites](/reference/api/invites/list-invites/) | Inviting a friend to g1t by email, inviting people into a workspace by username or email, and answering the invitations sent to you. Anyone can sign up without an invite. See [invite a friend](/guides/authentication/#invite-a-friend). |
234234 | [Repositories](/reference/api/repositories/list-repos/) | A repository, how it handles pull requests, and its timeline. |
235235 | [Access](/reference/api/access/list-collaborators/) | Who has which role on a repository, invitations, outside collaborators, and a workspace's base permission. |
236236 | [Secret scanning](/reference/api/secret-scanning/list-secret-scanning-alerts/) | Secrets found in pushes and history, where each one is, bypassing push protection and reviewing bypass requests, validity checks, and custom patterns. See [secret protection](/guides/security/secret-protection/). |
+7−2
773773
774774 ## `account`
775775
776−Who the token acts as and its workspaces, your email addresses, and
776+Who the token acts as and its workspaces, your email addresses, your
777+[invites to g1t](/guides/authentication/#invite-a-friend), and
777778 [invitations](/guides/authentication/#invites) to workspaces and
778779 repositories waiting for you. `whoami` is the default
779780 action, and needs no scope. A workspace's token cannot
780−use the email and invitation actions. An account that has not confirmed its
781+use the email, invite and invitation actions. An account that has not confirmed its
781782 email address gets `403` from every tool until it does; see
782783 [until you confirm](/guides/authentication/#until-you-confirm).
783784
789790 | [`confirm_email`](/reference/api/accounts/confirm-email/) | Confirm an address with the six-digit code from its email. See [confirming your email address](/guides/authentication/#confirming-your-email-address). | `code` | `account:write` |
790791 | [`remove_email`](/reference/api/accounts/remove-email/) | Remove an address; never the primary or the last confirmed one. | `email`, `password` | `account:write` |
791792 | [`update_email_settings`](/reference/api/accounts/update-email-settings/) | Change `primary` or `backup` (with `password`), `private_email` or `block_private_pushes`. See [email addresses](/guides/authentication/#email-addresses). | None | `account:write` |
793+| [`list_invites`](/reference/api/invites/list-invites/) | Your invites to g1t, with who joined, and how many of your 20 are left. | None | `account:read` |
794+| [`create_invite`](/reference/api/invites/create-invite/) | Invite a friend to g1t by email, with an optional `message`. With `workspace` (one you own on a paid plan) and `role`, also invite them to it. Uses one of your invites; refused with `409` when the address has an account or you already have a pending invite for it. | `email` | `account:write` |
795+| [`revoke_invite`](/reference/api/invites/revoke-invite/) | Revoke a pending invite to g1t; it comes back to you. | `id` | `account:write` |
796+| [`resend_invite`](/reference/api/invites/resend-invite/) | Send a pending invite to g1t again. | `id` | `account:write` |
792797 | [`list_workspace_invitations`](/reference/api/invites/list-invitations/) | The invitations to workspaces waiting for your answer, each with its `workspace`, the `role` it gives and who sent it. | None | `account:read` |
793798 | [`accept_workspace_invitation`](/reference/api/invites/accept-invitation/) | Accept one; you join the workspace at once with its role. | `id` | `account:write` |
794799 | [`decline_workspace_invitation`](/reference/api/invites/decline-invitation/) | Decline one; whoever sent it is told. | `id` | `account:write` |
+0−0

Binary or large file; its contents are not shown.

+9−6
149149 (`account_restored`, `account_purged`), naming the staff member. There
150150 is no API route for deleting an account; only the site and sudo can.
151151 - **Invites** (`/invites`): anyone may register on g1t.sh, so there is
152− no waitlist, allowance or shared link to manage. The page lists every
153− invite code sent with an invitation into a workspace or onto a
154− repository, searchable by a code's start, an email or a username, and
155− revokes a pending one (`admin_invites`, `admin_revoke_invite`). The
156− old waitlist, grants and shared links stay in identity's database,
157− untouched.
152+ no waitlist or shared link to manage, and an invite is never needed.
153+ The page lists every invite code: those sent with an invitation into a
154+ workspace or onto a repository, and invites to g1t that people send
155+ their friends with Invite a friend (20 each), on their own tab.
156+ Searchable by a code's start, an email or a username; a pending one can
157+ be revoked (`admin_invites`, `admin_revoke_invite`). The Invites to g1t
158+ tab also grants a person more of them, or takes some back, with why
159+ (`admin_grant_invites`, kept in `invite_grants`). The old waitlist and
160+ shared links stay in identity's database, untouched.
158161 - **Aliases** (`/aliases`, under Customers): names that lead to a
159162 workspace, set by staff only; there is no way for a customer to make
160163 one, and nothing user-facing mentions them. `g1t`, the product's name,
+22−3
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { INVITES_DONE, doneMessage, invitesHref, joinedThrough } from "./invites.ts";
4+import { INVITES_DONE, MAX_GRANT, doneMessage, invitesHref, invitesTab, joinedThrough, parseGrant } from "./invites.ts";
55
6−test("the page has one address, with its search", () => {
6+test("the page has one address, with its search and tab", () => {
77 assert.equal(invitesHref(), "/invites");
88 assert.equal(invitesHref({ q: "g1t-k7m2", empty: "" }), "/invites?q=g1t-k7m2");
9+ assert.equal(invitesHref({ tab: "g1t" }), "/invites?tab=g1t");
10+ assert.equal(invitesTab("g1t"), "g1t");
11+ assert.equal(invitesTab("waitlist"), "all");
12+ assert.equal(invitesTab(null), "all");
913 });
1014
11−test("revoking says so, and nothing else is flashed", () => {
15+test("revoking and granting say so, and nothing else is flashed", () => {
1216 assert.equal(doneMessage("revoked"), INVITES_DONE.revoked);
17+ assert.equal(doneMessage("granted"), INVITES_DONE.granted);
1318 assert.equal(doneMessage("approved"), null);
1419 assert.equal(doneMessage(null), null);
1520 });
1722 test("an account made with a shared link says where it came from", () => {
1823 assert.equal(joinedThrough("Cloudflare judges"), "Joined through Cloudflare judges");
1924 });
25+
26+test("a grant names a person, a whole number of invites and why", () => {
27+ const form = (fields: Record<string, string>) => new Map(Object.entries(fields));
28+ assert.deepEqual(parseGrant(form({ username: "@Ada", amount: "10", note: "Bringing her team" })), {
29+ ok: true,
30+ value: { username: "ada", amount: 10, note: "Bringing her team" },
31+ });
32+ assert.equal(parseGrant(form({ username: "ada", amount: "-5", note: "Too many" })).ok, true);
33+ assert.equal(parseGrant(form({ username: "not a name", amount: "10", note: "why" })).ok, false);
34+ assert.equal(parseGrant(form({ username: "ada", amount: "0", note: "why" })).ok, false);
35+ assert.equal(parseGrant(form({ username: "ada", amount: String(MAX_GRANT + 1), note: "why" })).ok, false);
36+ assert.equal(parseGrant(form({ username: "ada", amount: "2.5", note: "why" })).ok, false);
37+ assert.equal(parseGrant(form({ username: "ada", amount: "10", note: "" })).ok, false);
38+});
+37−4
11 /**
2− * The Invites page: every invitation sent to an address, to find and
3− * revoke one. Anyone may register, so there is no waitlist, no allowance
4− * and no shared link to manage. No Workers imports, so it can be tested
5− * under Node. Identity checks everything again.
2+ * The Invites page: every invite, to find and revoke one, with invites to
3+ * g1t from Invite a friend on their own tab, and more of those for a
4+ * person. Anyone may register, so there is no waitlist and no shared link
5+ * to manage; an invite is never needed to sign up. No Workers imports, so
6+ * it can be tested under Node. Identity checks everything again.
67 */
78
9+/** The page's tabs: every invite, or only invites to g1t (Invite a friend). */
10+export type InvitesTab = "all" | "g1t";
11+
12+export function invitesTab(raw: string | null | undefined): InvitesTab {
13+ return raw === "g1t" ? "g1t" : "all";
14+}
15+
816 /** The page's address, with its search. */
917 export function invitesHref(params: Record<string, string | null | undefined> = {}): string {
1018 const search = new URLSearchParams();
1624 /** The flash messages the page redirects back with (`?done=`). */
1725 export const INVITES_DONE: Record<string, string> = {
1826 revoked: "Invite revoked. It stops working at once.",
27+ granted: "Granted. It applies at once.",
1928 };
2029
2130 /** The flash for `?done=`. */
2736 export function joinedThrough(label: string): string {
2837 return `Joined through ${label}`;
2938 }
39+
40+/** The most invites one grant gives or takes back (`MAX_INVITE_GRANT` in the contracts). */
41+export const MAX_GRANT = 1000;
42+
43+const USERNAME = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/;
44+
45+export type Grant = { username: string; amount: number; note: string };
46+
47+/** A grant of invites to g1t, as typed: who, how many (negative takes back), and why. */
48+export function parseGrant(form: { get(name: string): unknown }): { ok: true; value: Grant } | { ok: false; error: string } {
49+ const read = (name: string) => {
50+ const value = form.get(name);
51+ return typeof value === "string" ? value.trim() : "";
52+ };
53+ const username = read("username").replace(/^@/, "").toLowerCase();
54+ if (!USERNAME.test(username)) return { ok: false, error: `“${read("username")}” is not a username.` };
55+ const raw = read("amount");
56+ if (!/^-?\d+$/.test(raw)) return { ok: false, error: "Give a whole number of invites, such as 10, or -5 to take some back." };
57+ const amount = Number(raw);
58+ if (amount === 0 || Math.abs(amount) > MAX_GRANT) return { ok: false, error: `Grant between 1 and ${MAX_GRANT} invites.` };
59+ const note = read("note");
60+ if (note.length < 3) return { ok: false, error: "Say why, for the record." };
61+ return { ok: true, value: { username, amount, note: note.slice(0, 500) } };
62+}
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…