Skip to content
430 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1/**
2 * Invites, as the site shows them: what sign-up says while g1t is
3 * invite-only, links to an invite, and how each invite reads in a list.
4 * No Workers or React imports, so it can be tested under Node.
5 */
6
7/** Where people ask for more invites: support's mailbox (`CONTACT.support`). */
8export const INVITES_CONTACT = "hey@flagon.io";
9
10/** The subject that sorts a request for invites, as the support page lists them. */
11export const INVITES_SUBJECT = "[g1t Invites] ";
12
13/** A mail link asking for more invites, for a person or a workspace. */
14export function moreInvitesMailto(about?: string): string {
15 const subject = `${INVITES_SUBJECT}${about ? `More invites for ${about}` : "More invites"}`;
16 return `mailto:${INVITES_CONTACT}?subject=${encodeURIComponent(subject)}`;
17}
18
Sign-up buttons say Sign up everywhere; only the sign-up page says registration takes an invite19/**
20 * What the sign-up buttons say: Sign up, whether or not registration is
21 * invite-only. Only the sign-up page itself says how to get in (an invite,
22 * or a request for one), so nothing else reads as a waiting room.
23 */
24export function signUpCopy(): { primary: string; secondary: string | null } {
25 return { primary: "Sign up", secondary: null };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look26}
27
28/** The /register address that opens on the invite-code field. */
29export const HAVE_AN_INVITE = "/register#invite";
30
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)31/** The address a shared invite link has: sign-up, with its code filled in. */
32export function sharedInviteLink(code: string, origin = "https://g1t.sh"): string {
33 return `${origin.replace(/\/+$/, "")}/register?invite=${encodeURIComponent(code)}`;
34}
35
36/** What sign-up says above the form for a shared invite link's group. Null for a one-person invite. */
37export function sharedInviteLine(label: string | null | undefined): string | null {
38 const group = (label ?? "").trim();
39 return group ? `Invited as part of ${group}` : null;
40}
41
42/** The email field's hint for a shared invite link limited to some domains. */
43export function sharedDomainsHint(domains: string[] | null | undefined): string | undefined {
44 const list = (domains ?? []).filter(Boolean);
45 if (list.length === 0) return undefined;
46 const named = list.length === 1 ? list[0] : `${list.slice(0, -1).join(", ")} or ${list.at(-1)}`;
47 return `This invite is for addresses at ${named}. Use yours there.`;
48}
49
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look50/** The address an invite link has. */
51export function inviteLink(code: string, origin = "https://g1t.sh"): string {
52 return `${origin.replace(/\/+$/, "")}/invite/${code}`;
53}
54
55/**
56 * An invite code from however someone pasted it: the code, a whole
57 * invite link, or a /register?invite= address. Identity reads it again;
58 * this only tidies what is put back into a form.
59 */
60export function cleanCode(raw: string | null | undefined): string {
61 let text = (raw ?? "").trim();
62 const param = /[?&]invite=([^&#\s]+)/i.exec(text);
63 if (param) text = decodeURIComponent(param[1]!);
64 else if (/^https?:\/\//i.test(text)) text = text.replace(/[?#].*$/, "").split("/").filter(Boolean).pop() ?? "";
65 return text.replace(/\s+/g, "").slice(0, 80);
66}
67
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm68/**
69 * The `proof` an invite email's link carries, tidied: hex, or null for
70 * anything else. Identity decides whether it is the invite's own; this only
71 * keeps junk out of what is passed on and put back into a form.
72 */
73export function cleanProof(raw: string | null | undefined): string | null {
74 const text = (raw ?? "").trim().toLowerCase();
75 return /^[0-9a-f]{16,128}$/.test(text) ? text : null;
76}
77
78/** An invite's page, keeping the email's proof when there is one. */
79export function invitePath(code: string, proof?: string | null): string {
80 const path = `/invite/${encodeURIComponent(code)}`;
81 return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path;
82}
83
84type Proven = {
85 /** The bound address in full, or null for an invite to anyone with the code. */
86 address: string | null;
87 emailProven: boolean;
88 workspace: { name: string } | null;
89 repository: { name: string } | null;
90};
91
92/**
93 * What signing up on an invite's page says about the email address. Opened
94 * from the invite's own email (`emailProven`), the address is confirmed
95 * already, so there is no code to enter; otherwise the address is confirmed
96 * after sign-up, as it always is.
97 */
98export function inviteSignUpCopy(invite: Proven): {
99 /** Under "Create your account". */
100 intro: string;
101 /** Under the email field. */
102 hint: string;
103 /** Said plainly above the form when the address is confirmed already; null otherwise. */
104 confirmed: string | null;
105} {
106 const proven = invite.emailProven && invite.address !== null;
107 const when = proven ? "as soon as you create it" : "as soon as you confirm your email";
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)108 // A workspace is never joined without saying yes: the new account accepts its invitation.
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm109 const intro = invite.workspace
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)110 ? `You can join ${invite.workspace.name} ${when}: accept the invitation then.`
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm111 : invite.repository
112 ? `You get ${invite.repository.name} ${when}.`
113 : "It takes a minute.";
114 if (proven) {
115 return {
116 intro,
117 hint: "Your invite was sent here, and you opened it from that email, so this address is confirmed already.",
118 confirmed: `${invite.address} is confirmed: you came here from the invite we emailed to it, so there is no code to enter after you sign up.`,
119 };
120 }
121 return {
122 intro,
123 hint: invite.address
124 ? "Your invite was sent here. We email it a code to confirm it before you start."
125 : "We email it a code to confirm it before you start.",
126 confirmed: null,
127 };
128}
129
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look130type Listed = {
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)131 status: "pending" | "awaiting_confirmation" | "awaiting_answer" | "redeemed" | "declined" | "expired" | "revoked";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look132 redeemedBy: string | null;
133 email: string | null;
134 workspace: string | null;
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)135 /** The account a workspace invitation is for, by username. */
136 invitee?: string | null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look137};
138
139/** How an invite's state reads in a list. */
140export function inviteState(invite: Listed): { label: string; tone: "pending" | "done" | "dead" } {
141 switch (invite.status) {
142 case "pending":
143 return { label: "Pending", tone: "pending" };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)144 case "awaiting_confirmation":
145 // The account is made; it joins once it confirms its address.
146 return {
147 label: invite.redeemedBy ? `@${invite.redeemedBy} is confirming their email` : "Confirming their email",
148 tone: "pending",
149 };
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)150 case "awaiting_answer": {
151 // The account is made and confirmed; the workspace waits for its yes or no.
152 const who = invite.redeemedBy ?? invite.invitee;
153 return { label: who ? `Waiting for @${who} to accept` : "Waiting for an answer", tone: "pending" };
154 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look155 case "redeemed":
156 return { label: invite.redeemedBy ? `Joined as @${invite.redeemedBy}` : "Used", tone: "done" };
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)157 case "declined":
158 return { label: invite.invitee ? `@${invite.invitee} declined` : "Declined", tone: "dead" };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look159 case "expired":
160 return { label: "Expired", tone: "dead" };
161 case "revoked":
162 return { label: "Revoked", tone: "dead" };
163 }
164}
165
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)166type Previewed = {
167 kind: "account" | "workspace";
168 invitedBy: { username: string } | null;
169 workspace: { name: string } | null;
170 repository: { name: string; role: string } | null;
171 hasAccount: boolean;
172};
173
174/**
175 * What an invite's page (/invite/:code) says it is, so nobody mistakes one
176 * kind for the other: the headline, in three parts with the place between
177 * (shown in bold), and the line under it. "@syntaqx invited you to g1t" is
178 * an account and no workspace; "@syntaqx invited you to join Flagon, Inc.
179 * on g1t" is a workspace invitation, which also makes the account of
180 * someone who has none.
181 */
182export function invitePageCopy(
183 invite: Previewed,
184 signedIn: boolean,
185): { before: string; place: string | null; after: string; about: string } {
186 const from = invite.invitedBy ? `@${invite.invitedBy.username}` : "The g1t team";
187 const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account";
188 const g1t = "g1t is one workspace where a team and its agents talk, work and ship";
189 if (invite.workspace) {
190 const name = invite.workspace.name;
191 return {
192 before: `${from} invited you to join `,
193 place: name,
194 after: " on g1t",
195 about: `This is an invitation to join ${name}, which you accept or decline. ${
196 signingUp
197 ? `You do not have a g1t account yet, so it also lets you make one: make it below, then join ${name}.`
198 : `Accepting joins you to ${name}.`
199 }`,
200 };
201 }
202 if (invite.repository) {
203 return {
204 before: `${from} invited you to collaborate on `,
205 place: invite.repository.name,
206 after: "",
207 about: `${g1t}. ${signingUp ? "Make your account below and you get" : "Accepting gives you"} the ${invite.repository.role} role on ${invite.repository.name}.`,
208 };
209 }
210 return {
211 before: `${from} invited you to g1t`,
212 place: null,
213 after: "",
214 about: `${g1t}: chat with people and agents, give agents a job and a budget, and land code through checks that hold. This invite lets you make an account. It does not add you to anyone's workspace: your account starts with a workspace of its own.`,
215 };
216}
217
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look218/** Who an invite is for, in a list. */
219export function inviteFor(invite: Listed): string {
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)220 return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link");
221}
222
223/**
224 * Which of the two invites a listed one is, in words: an invite to g1t
225 * (an account, and no workspace), or an invitation to join a workspace.
226 */
227export function inviteKind(invite: { workspace: string | null }): { kind: "g1t" | "workspace"; label: string } {
228 return invite.workspace
229 ? { kind: "workspace", label: `Invite to join ${invite.workspace}` }
230 : { kind: "g1t", label: "Invite to g1t" };
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)231}
232
233type Membership = { slug: string; name?: string | null; role: "owner" | "member" };
234
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)235/** One workspace an own invite can also invite its person to. */
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)236export type BringInto = { slug: string; name: string };
237
238/**
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)239 * The workspaces Settings → Invites can also invite the person to, when
240 * "Also invite them to a workspace" is ticked: the ones the viewer owns
241 * that are not on the free plan, which adds no one. None is chosen for
242 * them: the box is off at first, and the list starts on "Choose a
243 * workspace". `note` says why the viewer's current workspace is missing.
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)244 */
245export function bringIntoChoices(
246 memberships: Membership[],
247 free: string[],
248 current: string | null | undefined,
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)249): { options: BringInto[]; note: string | null } {
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)250 const isFree = new Set(free.map((slug) => slug.toLowerCase()));
251 const options = memberships
252 .filter((m) => m.role === "owner" && !isFree.has(m.slug.toLowerCase()))
253 .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug }));
254 const here = current?.trim().toLowerCase() || null;
255 let note: string | null = null;
256 const membership = here ? memberships.find((m) => m.slug.toLowerCase() === here) : undefined;
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)257 if (membership && !options.some((option) => option.slug === here)) {
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)258 note =
259 membership.role !== "owner"
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)260 ? `Only the owners of ${membership.slug} can invite people to it.`
261 : `${membership.slug} is on the free plan, so it cannot add people. Start the plan to invite people to it.`;
262 }
263 return { options, note };
264}
265
266/**
267 * What the Settings → Invites form sends to identity. The workspace goes
268 * with it only when "Also invite them to a workspace" (`also_join`) is
269 * ticked: unticked, the invite is to g1t alone, whatever else the form held.
270 */
271export function inviteDraft(form: { get(name: string): unknown }): {
272 email: string | null;
273 workspace: string | null;
274 join?: string;
275 joinRole?: "owner" | "member";
276} {
277 const text = (name: string) => {
278 const value = form.get(name);
279 return typeof value === "string" ? value.trim() : "";
280 };
281 const charge = text("charge");
282 const draft: ReturnType<typeof inviteDraft> = {
283 email: text("email") || null,
284 workspace: charge && charge !== "mine" ? charge : null,
285 };
286 const join = text("join");
287 if (text("also_join") === "on" && join) {
288 draft.join = join;
289 draft.joinRole = text("join_role") === "owner" ? "owner" : "member";
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)290 }
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)291 return draft;
292}
293
294/**
295 * What Settings → Invites shows. Invites to g1t exist only while sign-up
296 * takes one: then the page has the form. Once anyone can sign up, it
297 * keeps only the list of invites already made, and the settings menu
298 * lists the page only when there are some.
299 */
300export function invitesPage(mode: "invite" | "open" | null | undefined, made: number): { form: boolean; listed: boolean } {
301 const inviteOnly = mode !== "open";
302 return { form: inviteOnly, listed: inviteOnly || made > 0 };
303}
304
305/** The words for Settings → Invites, the invite to g1t. */
306export const G1T_INVITES = {
307 /** The page's heading. */
308 heading: "Invite people to g1t",
309 /** Its name in the settings menu and the account menu. */
310 nav: "Invites to g1t",
311 about:
312 "An invite to g1t lets one person make an account. It does not add them to any workspace: their account starts with a workspace of its own.",
313 /** In place of the form once anyone can sign up. */
314 open: "Anyone can sign up for g1t now, so there are no invites to make here. Invitations to a workspace live on each workspace's People page.",
315 /** The off-by-default box that also invites the person to a workspace. */
316 alsoJoin: "Also invite them to a workspace",
317 alsoJoinHint:
318 "Once their account is made, they get an invitation to the workspace to accept or decline. Left off, the invite is to g1t only.",
319 /** Where the other kind of invite lives. */
320 elsewhere: "To bring someone into a workspace, invite them from that workspace's People page",
321} as const;
322
323/**
324 * The words for a workspace's People page, the invitation to join it.
325 * `inviteOnly` says whether sign-up takes an invite: then an invitation to
326 * an address with no account also lets it make one (and costs an invite),
327 * and the page points to Settings → Invites for an invite to g1t alone.
328 */
329export function workspaceInviteCopy(name: string, inviteOnly: boolean): { heading: string; hint: string; elsewhere: string | null } {
g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them.330 const base = `Search people on g1t by username or name, or enter an email address. They get an invitation to join ${name}, in their notifications and by email, and join only if they accept.`;
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)331 return {
332 heading: `Invite to ${name}`,
333 hint: inviteOnly
334 ? `${base} If they do not have a g1t account yet, the invitation also lets them sign up; that uses one of ${name}'s shared invites, or else one of yours.`
335 : `${base} If they do not have a g1t account yet, they sign up from the invitation first.`,
336 elsewhere: inviteOnly ? `To invite someone to g1t without adding them to ${name}, use Settings → Invites.` : null,
337 };
338}
339
340/**
341 * The People pages Settings → Invites points to for a workspace
342 * invitation: the workspaces the viewer owns (only owners invite), the
343 * current one first.
344 */
345export function peoplePages(memberships: Membership[], current: string | null | undefined): { slug: string; name: string; to: string }[] {
346 const here = current?.trim().toLowerCase() || null;
347 return memberships
348 .filter((m) => m.role === "owner")
349 .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug, to: `/${m.slug.toLowerCase()}/-/people` }))
350 .sort((a, b) => Number(b.slug === here) - Number(a.slug === here));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look351}
352
353/** How many invites are left, in words. */
354export function remainingLine(allowance: { limit: number | null; used: number; remaining: number | null }): string {
355 if (allowance.limit == null) return "No limit on your invites";
356 const left = allowance.remaining ?? 0;
357 if (left === 0) return `You have used all ${allowance.limit} of your invites`;
358 return `${left} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`;
359}
360
361/**
362 * Whether a sign-up or access form was filled in by a bot: the hidden
363 * `website` field people never see, or a form sent back faster than a
364 * person types.
365 */
366export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean {
367 const trap = form.get("website");
368 if (typeof trap === "string" && trap.trim() !== "") return true;
369 const started = Number(form.get("started"));
370 return Number.isFinite(started) && started > 0 && now - started < 1500;
371}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas372
373/**
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers374 * A username to offer someone signing up with `email`: its local part,
375 * lowercased, with anything a username cannot hold made single hyphens,
376 * up to 39. They can type it in any case they like. Empty when nothing
377 * usable is left. Identity checks it is free.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas378 */
379export function suggestUsername(email: string | null | undefined): string {
380 const local = (email ?? "").split("@")[0]?.split("+")[0] ?? "";
381 return local
382 .toLowerCase()
383 .replace(/[^a-z0-9]+/g, "-")
384 .replace(/^-+|-+$/g, "")
385 .slice(0, 39)
386 .replace(/-+$/g, "");
387}
388
389type Lands = { workspace: { slug: string } | null; repository: { name: string } | null };
390
391/**
392 * Where using an invite lands: the workspace it joins, the repository it
393 * gives access to, or nowhere in particular.
394 */
395export function landingFor(invite: Lands): string | null {
396 if (invite.workspace) return invite.workspace.slug.toLowerCase();
397 if (invite.repository) return invite.repository.name.toLowerCase();
398 return null;
399}
400
401/** What someone who just joined is welcomed into, for one page view. */
402export const WELCOME_COOKIE = "g1t_welcome";
403
404const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/;
405
406/** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */
407export function welcomeCookie(target: string, secure: boolean): string {
408 return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
409}
410
411/** The `Set-Cookie` value that ends the welcome, once it has been shown. */
412export function clearWelcome(secure: boolean): string {
413 return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
414}
415
416/** Whether the request's cookies welcome someone into `target`. */
417export function welcomes(cookieHeader: string | null, target: string): boolean {
418 for (const part of (cookieHeader ?? "").split(";")) {
419 const [key, ...rest] = part.trim().split("=");
420 if (key !== WELCOME_COOKIE) continue;
421 let value: string;
422 try {
423 value = decodeURIComponent(rest.join("="));
424 } catch {
425 return false;
426 }
427 return TARGET.test(value) && value === target.toLowerCase();
428 }
429 return false;
430}

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