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