Skip to content
174 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 // 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 }
71 context.set(viewerContext, viewer);
72
73 const { pathname, search } = new URL(request.url);
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);
78 // 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 }
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);
94 }
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
111/** 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
123export 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 {
165 if (crossOrigin(request)) {
166 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}