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
174 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.

The runner base keeps its old comment, so the pushed base image still matches its folder1import {
2 type MiddlewareFunction,
3 type RouterContextProvider,
4 createContext,
5 data,
6 redirect,
7} from "react-router";
8
Chat and workspace agents: channels, DMs and named agents you talk to9import { type Result, type Role, type User, type Viewer, hasCodeAccess, httpStatus } from "@g1t/contracts";
The runner base keeps its old comment, so the pushed base image still matches its folder10
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)11import { confirmGate, pageOf } from "./confirm-gate";
12import { workspaceGate } from "./workspace-gate";
Chat and workspace agents: channels, DMs and named agents you talk to13import { readCookie } from "./mission";
The runner base keeps its old comment, so the pushed base image still matches its folder14import { safeNext } from "./next";
Chat and workspace agents: channels, DMs and named agents you talk to15import { WORKSPACE_COOKIE, chosenWorkspace } from "./workspace-choice";
16import { codeGate } from "./workspace-nav";
The runner base keeps its old comment, so the pushed base image still matches its folder17import { identity } from "./services.server";
Merge main into Artifacts Phase 218import { TOKEN_CHALLENGE, bearerToken, tokenVerdict } from "./website-token";
19import { crossOrigin } from "./same-origin";
The runner base keeps its old comment, so the pushed base image still matches its folder20
21const SESSION_COOKIE = "g1t_session";
22const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
23
24const viewerContext = createContext<Viewer>(null);
25
26function sessionToken(request: Request): string | null {
Merge main into Artifacts Phase 227 // A request with a token is the token's alone (lib/website-token.ts).
28 if (bearerToken(request) !== null) return null;
The runner base keeps its old comment, so the pushed base image still matches its folder29 const cookies = request.headers.get("cookie") ?? "";
30 const match = new RegExp(`(?:^|; )${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
31 return match ? match[1] : null;
32}
33
34function sessionCookie(value: string, maxAge: number): string {
35 return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
36}
37
38/**
39 * Root middleware: resolves the signed-in user once per request.
40 *
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)41 * An account that has not confirmed its email address is sent to confirm
42 * it, from any page but the few that needs (lib/confirm-gate.ts).
43 *
The runner base keeps its old comment, so the pushed base image still matches its folder44 * Everything on g1t lives in a workspace, so a confirmed account with none
45 * is sent to create one, from wherever it was going, and returned there
46 * afterwards.
Merge main into Artifacts Phase 247 *
48 * Automation can send an access token as `Authorization: Bearer` in place
49 * of the cookie, when its owner let it use the website; the rules are in
50 * lib/website-token.ts.
The runner base keeps its old comment, so the pushed base image still matches its folder51 */
Merge main into Artifacts Phase 252export const viewerMiddleware: MiddlewareFunction<Response> = async ({ request, context }, next) => {
53 const verdict = await tokenVerdict(request, (token) => identity.userForAccessToken(token));
54 // Thrown, so pages and client navigations show it as any error; the
55 // Worker adds the 401's challenge header (workers/app.ts).
56 if (verdict.kind === "refused") throw data(verdict.body, { status: verdict.status });
57 if (verdict.kind === "signed-out") {
58 // The page as anyone signed out sees it, saying the token was not taken.
59 const response = await next();
60 response.headers.set("www-authenticate", TOKEN_CHALLENGE);
61 return response;
62 }
63 let viewer: Viewer;
64 if (verdict.kind === "signed-in") {
65 viewer = verdict.user;
66 } else {
67 const token = sessionToken(request);
68 if (!token) return;
69 viewer = await identity.userForSession(token);
70 }
The runner base keeps its old comment, so the pushed base image still matches its folder71 context.set(viewerContext, viewer);
72
73 const { pathname, search } = new URL(request.url);
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)74 // An account that has not confirmed its email address does that first,
75 // from wherever it was going (lib/confirm-gate.ts).
76 const gated = confirmGate(pathname, search, viewer);
77 if (gated) throw redirect(gated);
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.78 // A member without Code access in a workspace: their Home in place of
79 // Mission control, and the page that says to ask an owner in place of
80 // anything of Code's.
Chat and workspace agents: channels, DMs and named agents you talk to81 // The services enforce it too; this keeps the site from offering it.
82 const noCode = (viewer?.workspaces ?? []).filter((m) => !hasCodeAccess(m)).map((m) => m.slug.toLowerCase());
83 if (request.method === "GET" && noCode.length > 0) {
84 const chosen = chosenWorkspace(viewer?.workspaces ?? [], readCookie(request.headers.get("cookie"), WORKSPACE_COOKIE));
85 const around = codeGate(pathname, search, noCode, chosen?.slug ?? null);
86 if (around) throw redirect(around);
87 }
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)88 // Nobody uses g1t without a workspace: someone with none makes one, or
89 // answers an invitation to one, before anything else (lib/workspace-gate.ts).
90 // Data requests too, so a page is never loaded behind its back.
91 if (request.method === "GET") {
92 const around = workspaceGate(pageOf(pathname), search, viewer);
93 if (around) throw redirect(around);
The runner base keeps its old comment, so the pushed base image still matches its folder94 }
95};
96
97type Context = Readonly<RouterContextProvider>;
98
99export function getViewer(context: Context): Viewer {
100 return context.get(viewerContext);
101}
102
103/** The viewer's role in a workspace, or null if they are not a member. */
104export function roleIn(viewer: Viewer, slug: string): Role | null {
105 const wanted = slug.toLowerCase();
106 return (
107 viewer?.workspaces?.find((membership) => membership.slug === wanted)?.role ?? null
108 );
109}
110
Merge main (membership, two-factor, GitHub repo roles) into tokens111/** Whether the viewer may manage a workspace's billing: an owner or a billing manager. */
112export function managesBilling(viewer: Viewer, slug: string): boolean {
113 const membership = viewer?.workspaces?.find((m) => m.slug === slug.toLowerCase());
114 return membership?.role === "owner" || !!membership?.org_roles?.includes("billing_manager");
115}
116
117/** Whether the viewer may manage security across a workspace: an owner or a security manager. */
118export function managesSecurity(viewer: Viewer, slug: string): boolean {
119 const membership = viewer?.workspaces?.find((m) => m.slug === slug.toLowerCase());
120 return membership?.role === "owner" || !!membership?.org_roles?.includes("security_manager");
121}
122
The runner base keeps its old comment, so the pushed base image still matches its folder123export function requireUser(context: Context, request: Request): User {
124 const viewer = getViewer(context);
125 if (!viewer) {
126 // Keep the query string: a device sign-in link carries its code there.
127 const { pathname, search } = new URL(request.url);
128 throw redirect(`/login?next=${encodeURIComponent(pathname + search)}`);
129 }
130 return viewer;
131}
132
133/**
134 * Where to go after signing in. Only same-site paths are honoured, so
135 * `next` cannot redirect off g1t.
136 */
137export function nextPath(request: Request): string {
138 return safeNext(new URL(request.url).searchParams.get("next"));
139}
140
141/** `Set-Cookie` value that starts a session. */
142export function startSession(token: string): string {
143 return sessionCookie(token, SESSION_TTL_SECONDS);
144}
145
146/** Ends the session and returns the `Set-Cookie` value that clears it. */
147export async function endSession(request: Request): Promise<string> {
148 const token = sessionToken(request);
149 if (token) await identity.signOut(token);
150 return sessionCookie("", 0);
151}
152
153/** The session token the request carries, for proof of a recent sign-in. */
154export function sessionTokenOf(request: Request): string | null {
155 return sessionToken(request);
156}
157
158/** The visitor's IP address, as Cloudflare saw it, for rate limits. */
159export function clientOf(request: Request): string | null {
160 return request.headers.get("cf-connecting-ip");
161}
162
163/** Rejects cross-site form posts; call at the top of every action. */
164export function assertSameOrigin(request: Request): void {
Merge main into Artifacts Phase 2165 if (crossOrigin(request)) {
The runner base keeps its old comment, so the pushed base image still matches its folder166 throw new Response("Cross-origin request rejected", { status: 403 });
167 }
168}
169
170/** The value of a service result, or the matching HTTP error. */
171export function unwrap<T>(result: Result<T>): T {
172 if (result.ok) return result.value;
173 throw data(result.error.message, { status: httpStatus(result.error) });
174}

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