flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

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

117 lines3,868 bytesCodeBlame
import {
  type MiddlewareFunction,
  type RouterContextProvider,
  createContext,
  data,
  redirect,
} from "react-router";

import { type Result, type Role, type User, type Viewer, httpStatus } from "@g1t/contracts";

import { safeNext } from "./next";
import { identity } from "./services.server";

const SESSION_COOKIE = "g1t_session";
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;

const viewerContext = createContext<Viewer>(null);

function sessionToken(request: Request): string | null {
  const cookies = request.headers.get("cookie") ?? "";
  const match = new RegExp(`(?:^|; )${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
  return match ? match[1] : null;
}

function sessionCookie(value: string, maxAge: number): string {
  return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
}

/** Pages a signed-in person can use before they have a workspace. */
const BEFORE_WORKSPACE = ["/workspaces/new", "/settings", "/verify", "/logout"];

/**
 * Root middleware: resolves the signed-in user once per request.
 *
 * Everything on g1t lives in a workspace, so a confirmed account with none
 * is sent to create one, from wherever it was going, and returned there
 * afterwards.
 */
export const viewerMiddleware: MiddlewareFunction<Response> = async ({
  request,
  context,
}) => {
  const token = sessionToken(request);
  if (!token) return;
  const viewer = await identity.userForSession(token);
  context.set(viewerContext, viewer);

  const { pathname, search } = new URL(request.url);
  if (
    request.method === "GET" &&
    viewer?.verified &&
    (viewer.workspaces ?? []).length === 0 &&
    !BEFORE_WORKSPACE.includes(pathname) &&
    !pathname.endsWith(".data")
  ) {
    const next = pathname === "/" ? "" : `?next=${encodeURIComponent(pathname + search)}`;
    throw redirect(`/workspaces/new${next}`);
  }
};

type Context = Readonly<RouterContextProvider>;

export function getViewer(context: Context): Viewer {
  return context.get(viewerContext);
}

/** The viewer's role in a workspace, or null if they are not a member. */
export function roleIn(viewer: Viewer, slug: string): Role | null {
  const wanted = slug.toLowerCase();
  return (
    viewer?.workspaces?.find((membership) => membership.slug === wanted)?.role ?? null
  );
}

export function requireUser(context: Context, request: Request): User {
  const viewer = getViewer(context);
  if (!viewer) {
    // Keep the query string: a device sign-in link carries its code there.
    const { pathname, search } = new URL(request.url);
    throw redirect(`/login?next=${encodeURIComponent(pathname + search)}`);
  }
  return viewer;
}

/**
 * Where to go after signing in. Only same-site paths are honoured, so
 * `next` cannot redirect off g1t.
 */
export function nextPath(request: Request): string {
  return safeNext(new URL(request.url).searchParams.get("next"));
}

/** `Set-Cookie` value that starts a session. */
export function startSession(token: string): string {
  return sessionCookie(token, SESSION_TTL_SECONDS);
}

/** Ends the session and returns the `Set-Cookie` value that clears it. */
export async function endSession(request: Request): Promise<string> {
  const token = sessionToken(request);
  if (token) await identity.signOut(token);
  return sessionCookie("", 0);
}

/** Rejects cross-site form posts; call at the top of every action. */
export function assertSameOrigin(request: Request): void {
  const origin = request.headers.get("origin");
  if (origin && origin !== new URL(request.url).origin) {
    throw new Response("Cross-origin request rejected", { status: 403 });
  }
}

/** The value of a service result, or the matching HTTP error. */
export function unwrap<T>(result: Result<T>): T {
  if (result.ok) return result.value;
  throw data(result.error.message, { status: httpStatus(result.error) });
}