g1t/apps/web/app/lib/session.server.ts

134 lines4,673 bytesCodeBlame

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.

Initial g1t: services, event bus, intents and attempts1import {
2 type MiddlewareFunction,
3 type RouterContextProvider,
4 createContext,
5 data,
6 redirect,
7} from "react-router";
8
Agents as a team: lifecycle, merge queue, billing and a new shell9import { type Result, type Role, type User, type Viewer, httpStatus } from "@g1t/contracts";
Initial g1t: services, event bus, intents and attempts10
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put11import { safeNext } from "./next";
API and MCP server, Rust identity service, registration, site redesign12import { identity } from "./services.server";
13
Initial g1t: services, event bus, intents and attempts14const SESSION_COOKIE = "g1t_session";
15const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
16
17const viewerContext = createContext<Viewer>(null);
18
19function sessionToken(request: Request): string | null {
20 const cookies = request.headers.get("cookie") ?? "";
21 const match = new RegExp(`(?:^|; )${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
22 return match ? match[1] : null;
23}
24
25function sessionCookie(value: string, maxAge: number): string {
26 return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
27}
28
Agents as a team: lifecycle, merge queue, billing and a new shell29/** Pages a signed-in person can use before they have a workspace. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look30const BEFORE_WORKSPACE = ["/workspaces/new", "/settings", "/verify", "/logout", "/auth/github", "/auth/github/callback"];
Agents as a team: lifecycle, merge queue, billing and a new shell31
32/**
33 * Root middleware: resolves the signed-in user once per request.
34 *
35 * Everything on g1t lives in a workspace, so a confirmed account with none
36 * is sent to create one, from wherever it was going, and returned there
37 * afterwards.
38 */
Initial g1t: services, event bus, intents and attempts39export const viewerMiddleware: MiddlewareFunction<Response> = async ({
40 request,
41 context,
42}) => {
43 const token = sessionToken(request);
Agents as a team: lifecycle, merge queue, billing and a new shell44 if (!token) return;
45 const viewer = await identity.userForSession(token);
46 context.set(viewerContext, viewer);
47
48 const { pathname, search } = new URL(request.url);
49 if (
50 request.method === "GET" &&
51 viewer?.verified &&
52 (viewer.workspaces ?? []).length === 0 &&
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look53 // Someone a repository is shared with can use it without a workspace.
54 (viewer.grants ?? []).length === 0 &&
Agents as a team: lifecycle, merge queue, billing and a new shell55 !BEFORE_WORKSPACE.includes(pathname) &&
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas56 !pathname.startsWith("/settings/") &&
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look57 // An invite to a workspace is how someone without one gets one, and an
58 // invitation to a repository is answered before anything else.
59 !pathname.startsWith("/invite/") &&
60 !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(pathname) &&
Agents as a team: lifecycle, merge queue, billing and a new shell61 !pathname.endsWith(".data")
62 ) {
63 const next = pathname === "/" ? "" : `?next=${encodeURIComponent(pathname + search)}`;
64 throw redirect(`/workspaces/new${next}`);
Initial g1t: services, event bus, intents and attempts65 }
66};
67
68type Context = Readonly<RouterContextProvider>;
69
70export function getViewer(context: Context): Viewer {
71 return context.get(viewerContext);
72}
73
Agents as a team: lifecycle, merge queue, billing and a new shell74/** The viewer's role in a workspace, or null if they are not a member. */
75export function roleIn(viewer: Viewer, slug: string): Role | null {
76 const wanted = slug.toLowerCase();
77 return (
78 viewer?.workspaces?.find((membership) => membership.slug === wanted)?.role ?? null
79 );
80}
81
Initial g1t: services, event bus, intents and attempts82export function requireUser(context: Context, request: Request): User {
83 const viewer = getViewer(context);
84 if (!viewer) {
Device sign-in replaces registering and minting tokens over the API85 // Keep the query string: a device sign-in link carries its code there.
86 const { pathname, search } = new URL(request.url);
87 throw redirect(`/login?next=${encodeURIComponent(pathname + search)}`);
Initial g1t: services, event bus, intents and attempts88 }
89 return viewer;
90}
91
API and MCP server, Rust identity service, registration, site redesign92/**
93 * Where to go after signing in. Only same-site paths are honoured, so
94 * `next` cannot redirect off g1t.
95 */
96export function nextPath(request: Request): string {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put97 return safeNext(new URL(request.url).searchParams.get("next"));
API and MCP server, Rust identity service, registration, site redesign98}
99
Initial g1t: services, event bus, intents and attempts100/** `Set-Cookie` value that starts a session. */
101export function startSession(token: string): string {
102 return sessionCookie(token, SESSION_TTL_SECONDS);
103}
104
105/** Ends the session and returns the `Set-Cookie` value that clears it. */
106export async function endSession(request: Request): Promise<string> {
107 const token = sessionToken(request);
API and MCP server, Rust identity service, registration, site redesign108 if (token) await identity.signOut(token);
Initial g1t: services, event bus, intents and attempts109 return sessionCookie("", 0);
110}
111
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look112/** The session token the request carries, for proof of a recent sign-in. */
113export function sessionTokenOf(request: Request): string | null {
114 return sessionToken(request);
115}
116
117/** The visitor's IP address, as Cloudflare saw it, for rate limits. */
118export function clientOf(request: Request): string | null {
119 return request.headers.get("cf-connecting-ip");
120}
121
Initial g1t: services, event bus, intents and attempts122/** Rejects cross-site form posts; call at the top of every action. */
123export function assertSameOrigin(request: Request): void {
124 const origin = request.headers.get("origin");
125 if (origin && origin !== new URL(request.url).origin) {
126 throw new Response("Cross-origin request rejected", { status: 403 });
127 }
128}
129
130/** The value of a service result, or the matching HTTP error. */
131export function unwrap<T>(result: Result<T>): T {
132 if (result.ok) return result.value;
133 throw data(result.error.message, { status: httpStatus(result.error) });
134}