Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
488 linesCodeBlameRaw
1/**
2 * Invites, as the site shows them: links to an invite, and how each
3 * invitation reads in a list.
4 * No Workers or React imports, so it can be tested under Node.
5 */
6
7/** What the sign-up buttons say: Sign up. Anyone may register. */
8export function signUpCopy(): { primary: string; secondary: string | null } {
9 return { primary: "Sign up", secondary: null };
10}
11
12/** The address an invite link has. */
13export function inviteLink(code: string, origin = "https://g1t.sh"): string {
14 return `${origin.replace(/\/+$/, "")}/invite/${code}`;
15}
16
17/**
18 * An invite code from however someone pasted it: the code, a whole
19 * invite link, or a /register?invite= address. Identity reads it again;
20 * this only tidies what is put back into a form.
21 */
22export function cleanCode(raw: string | null | undefined): string {
23 let text = (raw ?? "").trim();
24 const param = /[?&]invite=([^&#\s]+)/i.exec(text);
25 if (param) text = decodeURIComponent(param[1]!);
26 else if (/^https?:\/\//i.test(text)) text = text.replace(/[?#].*$/, "").split("/").filter(Boolean).pop() ?? "";
27 return text.replace(/\s+/g, "").slice(0, 80);
28}
29
30/**
31 * The `proof` an invite email's link carries, tidied: hex, or null for
32 * anything else. Identity decides whether it is the invite's own; this only
33 * keeps junk out of what is passed on and put back into a form.
34 */
35export function cleanProof(raw: string | null | undefined): string | null {
36 const text = (raw ?? "").trim().toLowerCase();
37 return /^[0-9a-f]{16,128}$/.test(text) ? text : null;
38}
39
40/** An invite's page, keeping the email's proof when there is one. */
41export function invitePath(code: string, proof?: string | null): string {
42 const path = `/invite/${encodeURIComponent(code)}`;
43 return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path;
44}
45
46type Proven = {
47 /** The bound address in full, or null for an invite to anyone with the code. */
48 address: string | null;
49 emailProven: boolean;
50 workspace: { name: string } | null;
51 repository: { name: string } | null;
52};
53
54/**
55 * What signing up on an invite's page says about the email address. Opened
56 * from the invite's own email (`emailProven`), the address is confirmed
57 * already, so there is no code to enter; otherwise the address is confirmed
58 * after sign-up, as it always is.
59 */
60export function inviteSignUpCopy(invite: Proven): {
61 /** Under "Create your account". */
62 intro: string;
63 /** Under the email field. */
64 hint: string;
65 /** Said plainly above the form when the address is confirmed already; null otherwise. */
66 confirmed: string | null;
67} {
68 const proven = invite.emailProven && invite.address !== null;
69 const when = proven ? "as soon as you create it" : "as soon as you confirm your email";
70 // A workspace is never joined without saying yes: the new account accepts its invitation.
71 const intro = invite.workspace
72 ? `You can join ${invite.workspace.name} ${when}: accept the invitation then.`
73 : invite.repository
74 ? `You get ${invite.repository.name} ${when}.`
75 : "It takes a minute.";
76 if (proven) {
77 return {
78 intro,
79 hint: "Your invite was sent here, and you opened it from that email, so this address is confirmed already.",
80 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.`,
81 };
82 }
83 return {
84 intro,
85 hint: invite.address
86 ? "Your invite was sent here. We email it a code to confirm it before you start."
87 : "We email it a code to confirm it before you start.",
88 confirmed: null,
89 };
90}
91
92type Listed = {
93 status: "pending" | "awaiting_confirmation" | "awaiting_answer" | "redeemed" | "declined" | "expired" | "revoked";
94 redeemedBy: string | null;
95 email: string | null;
96 workspace: string | null;
97 /** The account a workspace invitation is for, by username. */
98 invitee?: string | null;
99};
100
101/** How an invite's state reads in a list. */
102export function inviteState(invite: Listed): { label: string; tone: "pending" | "done" | "dead" } {
103 switch (invite.status) {
104 case "pending":
105 return { label: "Pending", tone: "pending" };
106 case "awaiting_confirmation":
107 // The account is made; it joins once it confirms its address.
108 return {
109 label: invite.redeemedBy ? `@${invite.redeemedBy} is confirming their email` : "Confirming their email",
110 tone: "pending",
111 };
112 case "awaiting_answer": {
113 // The account is made and confirmed; the workspace waits for its yes or no.
114 const who = invite.redeemedBy ?? invite.invitee;
115 return { label: who ? `Waiting for @${who} to accept` : "Waiting for an answer", tone: "pending" };
116 }
117 case "redeemed":
118 return { label: invite.redeemedBy ? `Joined as @${invite.redeemedBy}` : "Used", tone: "done" };
119 case "declined":
120 return { label: invite.invitee ? `@${invite.invitee} declined` : "Declined", tone: "dead" };
121 case "expired":
122 return { label: "Expired", tone: "dead" };
123 case "revoked":
124 return { label: "Revoked", tone: "dead" };
125 }
126}
127
128type Previewed = {
129 kind: "account" | "workspace";
130 invitedBy: { username: string } | null;
131 workspace: { name: string } | null;
132 repository: { name: string; role: string } | null;
133 hasAccount: boolean;
134};
135
136/**
137 * What an invite's page (/invite/:code) says it is, so nobody mistakes one
138 * kind for the other: the headline, in three parts with the place between
139 * (shown in bold), and the line under it. "@syntaqx invited you to g1t" is
140 * an account and no workspace; "@syntaqx invited you to join Flagon, Inc.
141 * on g1t" is a workspace invitation, which also makes the account of
142 * someone who has none.
143 */
144export function invitePageCopy(
145 invite: Previewed,
146 signedIn: boolean,
147): { before: string; place: string | null; after: string; about: string } {
148 const from = invite.invitedBy ? `@${invite.invitedBy.username}` : "The g1t team";
149 const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account";
150 const g1t = "g1t is where a team and the coding agents it already uses ship code";
151 if (invite.workspace) {
152 const name = invite.workspace.name;
153 return {
154 before: `${from} invited you to join `,
155 place: name,
156 after: " on g1t",
157 about: `This is an invitation to join ${name}, which you accept or decline. ${
158 signingUp
159 ? `You do not have a g1t account yet, so it also lets you make one: make it below, then join ${name}.`
160 : `Accepting joins you to ${name}.`
161 }`,
162 };
163 }
164 if (invite.repository) {
165 return {
166 before: `${from} invited you to collaborate on `,
167 place: invite.repository.name,
168 after: "",
169 about: `${g1t}. ${signingUp ? "Make your account below and you get" : "Accepting gives you"} the ${invite.repository.role} role on ${invite.repository.name}.`,
170 };
171 }
172 return {
173 before: `${from} invited you to g1t`,
174 place: null,
175 after: "",
176 about: `${g1t}: repositories, pull requests and checks that hold, a merge queue and deployments, with chat for the team. Sign up from this invite and your address is filled in for you. It does not add you to anyone's workspace: your account starts with a workspace of its own.`,
177 };
178}
179
180/** What inviting someone came to (lib/member-invitations.server.ts): who was invited, or why not. */
181export type InviteResult = { invited: string } | { error: string };
182
183/** What revoking or resending an invitation came to. */
184export type InvitationResult = { done: "revoked" | "resent"; id: string } | { error: string; id: string };
185
186/**
187 * The Invitations page's filters: every invitation, or the ones in one
188 * state. Pending gathers the three states an invitation waits in (to be
189 * used, for the new account to confirm its address, for the person's
190 * answer); Accepted is the ones that joined. g1t does not record an email
191 * that could not be delivered, so an invitation nobody used shows as
192 * Expired once its 30 days are up; there is no Failed filter until it does.
193 */
194export type InvitationFilter = "all" | "pending" | "accepted" | "declined" | "expired" | "revoked";
195
196export const INVITATION_FILTERS: readonly { key: InvitationFilter; label: string }[] = [
197 { key: "all", label: "All" },
198 { key: "pending", label: "Pending" },
199 { key: "accepted", label: "Accepted" },
200 { key: "declined", label: "Declined" },
201 { key: "expired", label: "Expired" },
202 { key: "revoked", label: "Revoked" },
203];
204
205/** The filter an invitation's state falls under. */
206export function invitationFilterOf(status: Listed["status"]): Exclude<InvitationFilter, "all"> {
207 switch (status) {
208 case "pending":
209 case "awaiting_confirmation":
210 case "awaiting_answer":
211 return "pending";
212 case "redeemed":
213 return "accepted";
214 case "declined":
215 return "declined";
216 case "expired":
217 return "expired";
218 case "revoked":
219 return "revoked";
220 }
221}
222
223/** The filter `?state=` names; All for anything else. */
224export function invitationFilter(raw: string | null | undefined): InvitationFilter {
225 return INVITATION_FILTERS.some((filter) => filter.key === raw) ? (raw as InvitationFilter) : "all";
226}
227
228/** The invitations under `filter`. */
229export function filterInvitations<T extends Pick<Listed, "status">>(invites: readonly T[], filter: InvitationFilter): T[] {
230 if (filter === "all") return [...invites];
231 return invites.filter((invite) => invitationFilterOf(invite.status) === filter);
232}
233
234/** How many invitations each filter holds, for the counts on the chips. */
235export function invitationCounts(invites: readonly Pick<Listed, "status">[]): Record<InvitationFilter, number> {
236 const counts: Record<InvitationFilter, number> = { all: invites.length, pending: 0, accepted: 0, declined: 0, expired: 0, revoked: 0 };
237 for (const invite of invites) counts[invitationFilterOf(invite.status)] += 1;
238 return counts;
239}
240
241/** The tone of an invitation's status badge, from how its state reads. */
242export function invitationTone(status: Listed["status"]): "neutral" | "success" | "warn" | "danger" {
243 switch (invitationFilterOf(status)) {
244 case "pending":
245 return "warn";
246 case "accepted":
247 return "success";
248 case "declined":
249 case "revoked":
250 return "danger";
251 default:
252 return "neutral";
253 }
254}
255
256/** Who an invite is for, in a list. */
257export function inviteFor(invite: Listed): string {
258 return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link");
259}
260
261/**
262 * Which of the two invites a listed one is, in words: an invite to g1t
263 * (an account, and no workspace), or an invitation to join a workspace.
264 */
265export function inviteKind(invite: { workspace: string | null }): { kind: "g1t" | "workspace"; label: string } {
266 return invite.workspace
267 ? { kind: "workspace", label: `Invite to join ${invite.workspace}` }
268 : { kind: "g1t", label: "Invite to g1t" };
269}
270
271// --- Invite a friend ----------------------------------------------------------
272//
273// Settings → Invites to g1t (`/settings/invites`): any person invites a friend
274// to g1t by email. Anyone can sign up without one; the invite is a note from
275// someone they know, with a link that opens sign-up with their address filled
276// in and confirmed. Twenty each, for good: a revoked or expired one that was
277// never used comes back.
278
279/** The words for Invite a friend. Never says sign-up is held or needs an invite. */
280export const G1T_INVITES = {
281 /** Its name in the settings menu. */
282 nav: "Invites to g1t",
283 /** The page's heading. */
284 heading: "Invite friends to g1t",
285 about:
286 "Anyone can sign up for g1t. An invite is a note from you: your friend gets an email with a link that opens sign-up with their address filled in and confirmed, and you see when they join.",
287 /** The account menu's entry and the page's button. */
288 action: "Invite a friend",
289 /** The dialog's description. */
290 dialog:
291 "They get an email from you with a link to sign up. Their account starts with a workspace of its own, and the invite works for 30 days.",
292 /** Under the count. */
293 comesBack: "A revoked invite that was never used comes back to you.",
294 /** The off-by-default box that also invites the person to a workspace. */
295 alsoJoin: "Also invite them to a workspace",
296 alsoJoinHint:
297 "Once their account is made, they get an invitation to the workspace to accept or decline. Left off, the invite is to g1t only.",
298 /** When none are left: on the disabled button. */
299 out: "You have used all your invites. Your friends can still sign up at g1t.sh without one.",
300 /** When none are left: under the count, which says so already. */
301 outNote: "Your friends can still sign up at g1t.sh without one.",
302} as const;
303
304type Membership = { slug: string; name?: string | null; role: "owner" | "member" };
305
306/** One workspace an invite to g1t can also invite its person to. */
307export type BringInto = { slug: string; name: string };
308
309/**
310 * The workspaces Invite a friend can also invite the person to, when "Also
311 * invite them to a workspace" is ticked: the ones the viewer owns that are
312 * on a paid plan. A free workspace invites no one, so it is not offered, and
313 * with none to offer the box is not shown at all.
314 */
315export function bringIntoChoices(memberships: readonly Membership[], free: readonly string[]): BringInto[] {
316 const isFree = new Set(free.map((slug) => slug.toLowerCase()));
317 return memberships
318 .filter((m) => m.role === "owner" && !isFree.has(m.slug.toLowerCase()))
319 .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug }));
320}
321
322/**
323 * What the Invite a friend form sends to identity. The workspace goes with
324 * it only when "Also invite them to a workspace" (`also_join`) is ticked and
325 * it is one of `offered`: unticked, the invite is to g1t alone, whatever
326 * else the form held.
327 */
328export function inviteDraft(
329 form: { get(name: string): unknown },
330 offered: readonly BringInto[] = [],
331): { email: string; message: string | null; join?: string; joinRole?: "owner" | "member" } {
332 const text = (name: string) => {
333 const value = form.get(name);
334 return typeof value === "string" ? value.trim() : "";
335 };
336 const draft: ReturnType<typeof inviteDraft> = { email: text("email"), message: text("message") || null };
337 const join = text("join").toLowerCase();
338 if (text("also_join") === "on" && join && offered.some((workspace) => workspace.slug === join)) {
339 draft.join = join;
340 draft.joinRole = text("join_role") === "owner" ? "owner" : "member";
341 }
342 return draft;
343}
344
345/** How many invites are left, in words: "18 of 20 invites left". */
346export function remainingLine(allowance: { limit: number; remaining: number }): string {
347 if (allowance.limit === 0) return "Invites to g1t are turned off on this installation";
348 if (allowance.remaining === 0) return `You have used all ${allowance.limit} of your invites`;
349 return `${allowance.remaining} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`;
350}
351
352/** The account menu's badge: "18 left", or nothing when the count is not known. */
353export function leftBadge(left: number | null | undefined): string | null {
354 return left == null ? null : `${left} left`;
355}
356
357/** Which of a person's invites to g1t can be revoked, sent again, or copied. */
358export function inviteActions(invite: { status: Listed["status"]; code: string | null }): {
359 revoke: boolean;
360 resend: boolean;
361 copy: boolean;
362} {
363 const pending = invite.status === "pending";
364 return {
365 revoke: pending || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer",
366 resend: pending,
367 copy: pending && invite.code != null,
368 };
369}
370
371/** The words for a workspace's People page, the invitation to join it. */
372export function workspaceInviteCopy(name: string): { heading: string; hint: string } {
373 return {
374 heading: `Invite to ${name}`,
375 hint: `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. If they do not have a g1t account yet, they sign up from the invitation first.`,
376 };
377}
378
379/**
380 * The Members page's filters, from its query: the role to show (`role=`:
381 * owners or members), whether two-factor authentication is on (`2fa=`: on
382 * or off; owners only see it), a team by slug (`team=`) and words to
383 * search by (`q=`, matched against the username and the name). Anything
384 * else is every member.
385 */
386export type MemberFilters = { role: "all" | "owner" | "member"; twoFactor: "all" | "on" | "off"; team: string | null; query: string };
387
388export function memberFilters(search: URLSearchParams | string): MemberFilters {
389 const params = typeof search === "string" ? new URLSearchParams(search) : search;
390 const role = params.get("role");
391 const twoFactor = params.get("2fa");
392 return {
393 role: role === "owner" || role === "member" ? role : "all",
394 twoFactor: twoFactor === "on" || twoFactor === "off" ? twoFactor : "all",
395 team: params.get("team")?.trim().toLowerCase() || null,
396 query: params.get("q")?.trim() ?? "",
397 };
398}
399
400type Listable = {
401 username: string;
402 name?: string | null;
403 role: "owner" | "member";
404 two_factor?: boolean | null;
405};
406
407/** The members `filters` keep, in the order given. `teams` is each member's teams, by slug. */
408export function filterMembers<T extends Listable>(members: readonly T[], filters: MemberFilters, teams: Record<string, { slug: string }[]> = {}): T[] {
409 const words = filters.query.toLowerCase().replace(/^@+/, "");
410 return members.filter((member) => {
411 if (filters.role !== "all" && member.role !== filters.role) return false;
412 if (filters.twoFactor !== "all" && member.two_factor !== (filters.twoFactor === "on")) return false;
413 if (filters.team && !(teams[member.username] ?? []).some((team) => team.slug.toLowerCase() === filters.team)) return false;
414 if (words && !member.username.toLowerCase().includes(words) && !(member.name ?? "").toLowerCase().includes(words)) return false;
415 return true;
416 });
417}
418
419/**
420 * Whether a sign-up or access form was filled in by a bot: the hidden
421 * `website` field people never see, or a form sent back faster than a
422 * person types.
423 */
424export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean {
425 const trap = form.get("website");
426 if (typeof trap === "string" && trap.trim() !== "") return true;
427 const started = Number(form.get("started"));
428 return Number.isFinite(started) && started > 0 && now - started < 1500;
429}
430
431/**
432 * A username to offer someone signing up with `email`: its local part,
433 * lowercased, with anything a username cannot hold made single hyphens,
434 * up to 39. They can type it in any case they like. Empty when nothing
435 * usable is left. Identity checks it is free.
436 */
437export function suggestUsername(email: string | null | undefined): string {
438 const local = (email ?? "").split("@")[0]?.split("+")[0] ?? "";
439 return local
440 .toLowerCase()
441 .replace(/[^a-z0-9]+/g, "-")
442 .replace(/^-+|-+$/g, "")
443 .slice(0, 39)
444 .replace(/-+$/g, "");
445}
446
447type Lands = { workspace: { slug: string } | null; repository: { name: string } | null };
448
449/**
450 * Where using an invite lands: the workspace it joins, the repository it
451 * gives access to, or nowhere in particular.
452 */
453export function landingFor(invite: Lands): string | null {
454 if (invite.workspace) return invite.workspace.slug.toLowerCase();
455 if (invite.repository) return invite.repository.name.toLowerCase();
456 return null;
457}
458
459/** What someone who just joined is welcomed into, for one page view. */
460export const WELCOME_COOKIE = "g1t_welcome";
461
462const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/;
463
464/** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */
465export function welcomeCookie(target: string, secure: boolean): string {
466 return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
467}
468
469/** The `Set-Cookie` value that ends the welcome, once it has been shown. */
470export function clearWelcome(secure: boolean): string {
471 return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
472}
473
474/** Whether the request's cookies welcome someone into `target`. */
475export function welcomes(cookieHeader: string | null, target: string): boolean {
476 for (const part of (cookieHeader ?? "").split(";")) {
477 const [key, ...rest] = part.trim().split("=");
478 if (key !== WELCOME_COOKIE) continue;
479 let value: string;
480 try {
481 value = decodeURIComponent(rest.join("="));
482 } catch {
483 return false;
484 }
485 return TARGET.test(value) && value === target.toLowerCase();
486 }
487 return false;
488}