Skip to content
285 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
166/** Who an invite is for, in a list. */
167export function inviteFor(invite: Listed): string {
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)168 const who = invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link");
169 return invite.workspace ? `${who} · invited to ${invite.workspace}` : who;
170}
171
172type Membership = { slug: string; name?: string | null; role: "owner" | "member" };
173
174/** One workspace an own invite can bring its person into. */
175export type BringInto = { slug: string; name: string };
176
177/** The value of "No workspace — they'll get their own" in the form. */
178export const OWN_WORKSPACE = "";
179
180/**
181 * The "Bring them into" choices on Settings → Invites: the workspaces the
182 * person may add members to (ones they own that are not on the free plan,
183 * which adds no one), and which is chosen at first: the workspace they are
184 * in (`current`) when it is one of those, else none (the new account gets
185 * a workspace of its own). `note` says why the current one is not offered.
186 */
187export function bringIntoChoices(
188 memberships: Membership[],
189 free: string[],
190 current: string | null | undefined,
191): { options: BringInto[]; chosen: string; note: string | null } {
192 const isFree = new Set(free.map((slug) => slug.toLowerCase()));
193 const options = memberships
194 .filter((m) => m.role === "owner" && !isFree.has(m.slug.toLowerCase()))
195 .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug }));
196 const here = current?.trim().toLowerCase() || null;
197 const chosen = here && options.some((option) => option.slug === here) ? here : OWN_WORKSPACE;
198 let note: string | null = null;
199 const membership = here ? memberships.find((m) => m.slug.toLowerCase() === here) : undefined;
200 if (membership && !chosen) {
201 note =
202 membership.role !== "owner"
203 ? `Only the owners of ${membership.slug} can bring people into it.`
204 : `${membership.slug} is on the free plan, so it cannot add people. Start the plan to bring people into it.`;
205 }
206 return { options, chosen, note };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look207}
208
209/** How many invites are left, in words. */
210export function remainingLine(allowance: { limit: number | null; used: number; remaining: number | null }): string {
211 if (allowance.limit == null) return "No limit on your invites";
212 const left = allowance.remaining ?? 0;
213 if (left === 0) return `You have used all ${allowance.limit} of your invites`;
214 return `${left} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`;
215}
216
217/**
218 * Whether a sign-up or access form was filled in by a bot: the hidden
219 * `website` field people never see, or a form sent back faster than a
220 * person types.
221 */
222export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean {
223 const trap = form.get("website");
224 if (typeof trap === "string" && trap.trim() !== "") return true;
225 const started = Number(form.get("started"));
226 return Number.isFinite(started) && started > 0 && now - started < 1500;
227}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas228
229/**
230 * A username to offer someone signing up with `email`: its local part, as
231 * usernames are written (lowercase letters, digits and single hyphens, up
232 * to 39). Empty when nothing usable is left. Identity checks it is free.
233 */
234export function suggestUsername(email: string | null | undefined): string {
235 const local = (email ?? "").split("@")[0]?.split("+")[0] ?? "";
236 return local
237 .toLowerCase()
238 .replace(/[^a-z0-9]+/g, "-")
239 .replace(/^-+|-+$/g, "")
240 .slice(0, 39)
241 .replace(/-+$/g, "");
242}
243
244type Lands = { workspace: { slug: string } | null; repository: { name: string } | null };
245
246/**
247 * Where using an invite lands: the workspace it joins, the repository it
248 * gives access to, or nowhere in particular.
249 */
250export function landingFor(invite: Lands): string | null {
251 if (invite.workspace) return invite.workspace.slug.toLowerCase();
252 if (invite.repository) return invite.repository.name.toLowerCase();
253 return null;
254}
255
256/** What someone who just joined is welcomed into, for one page view. */
257export const WELCOME_COOKIE = "g1t_welcome";
258
259const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/;
260
261/** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */
262export function welcomeCookie(target: string, secure: boolean): string {
263 return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
264}
265
266/** The `Set-Cookie` value that ends the welcome, once it has been shown. */
267export function clearWelcome(secure: boolean): string {
268 return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
269}
270
271/** Whether the request's cookies welcome someone into `target`. */
272export function welcomes(cookieHeader: string | null, target: string): boolean {
273 for (const part of (cookieHeader ?? "").split(";")) {
274 const [key, ...rest] = part.trim().split("=");
275 if (key !== WELCOME_COOKIE) continue;
276 let value: string;
277 try {
278 value = decodeURIComponent(rest.join("="));
279 } catch {
280 return false;
281 }
282 return TARGET.test(value) && value === target.toLowerCase();
283 }
284 return false;
285}

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