Skip to content
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.

Docs: integrations, and your own model provider1import {
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";
Docs: integrations, and your own model provider10
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";
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put14import { 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";
Docs: integrations, and your own model provider17import { identity } from "./services.server";
Merge main into Artifacts Phase 218import { TOKEN_CHALLENGE, bearerToken, tokenVerdict } from "./website-token";
19import { crossOrigin } from "./same-origin";
Docs: integrations, and your own model provider20
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;
Docs: integrations, and your own model provider29 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 *
Docs: integrations, and your own model provider44 * 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.
Docs: integrations, and your own model provider51 */
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 }
Docs: integrations, and your own model provider71 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);
Chat and workspace agents: channels, DMs and named agents you talk to78 // A member without Code access in a workspace (docs/WORKSPACE.md,
79 // "Members without Code"): their Home in place of Mission control, and
80 // the page that says to ask an owner in place of anything of Code's.
81 // 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);
Docs: integrations, and your own model provider94 }
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
Docs: integrations, and your own model provider123export 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 {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put138 return safeNext(new URL(request.url).searchParams.get("next"));
Docs: integrations, and your own model provider139}
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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look153/** 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
Docs: integrations, and your own model provider163/** 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)) {
Docs: integrations, and your own model provider166 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.