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.

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