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

77 lines2,472 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 { env } from "cloudflare:workers";
2import {
3 type MiddlewareFunction,
4 type RouterContextProvider,
5 createContext,
6 data,
7 redirect,
8} from "react-router";
9
10import { type Result, type User, type Viewer, httpStatus } from "@g1t/contracts";
11
12const SESSION_COOKIE = "g1t_session";
13const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
14
15const viewerContext = createContext<Viewer>(null);
16
17function sessionToken(request: Request): string | null {
18 const cookies = request.headers.get("cookie") ?? "";
19 const match = new RegExp(`(?:^|; )${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
20 return match ? match[1] : null;
21}
22
23function sessionCookie(value: string, maxAge: number): string {
24 return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
25}
26
27/** Root middleware: resolves the signed-in user once per request. */
28export const viewerMiddleware: MiddlewareFunction<Response> = async ({
29 request,
30 context,
31}) => {
32 const token = sessionToken(request);
33 if (token) {
34 context.set(viewerContext, await env.IDENTITY.userForSession(token));
35 }
36};
37
38type Context = Readonly<RouterContextProvider>;
39
40export function getViewer(context: Context): Viewer {
41 return context.get(viewerContext);
42}
43
44export function requireUser(context: Context, request: Request): User {
45 const viewer = getViewer(context);
46 if (!viewer) {
47 const next = new URL(request.url).pathname;
48 throw redirect(`/login?next=${encodeURIComponent(next)}`);
49 }
50 return viewer;
51}
52
53/** `Set-Cookie` value that starts a session. */
54export function startSession(token: string): string {
55 return sessionCookie(token, SESSION_TTL_SECONDS);
56}
57
58/** Ends the session and returns the `Set-Cookie` value that clears it. */
59export async function endSession(request: Request): Promise<string> {
60 const token = sessionToken(request);
61 if (token) await env.IDENTITY.signOut(token);
62 return sessionCookie("", 0);
63}
64
65/** Rejects cross-site form posts; call at the top of every action. */
66export function assertSameOrigin(request: Request): void {
67 const origin = request.headers.get("origin");
68 if (origin && origin !== new URL(request.url).origin) {
69 throw new Response("Cross-origin request rejected", { status: 403 });
70 }
71}
72
73/** The value of a service result, or the matching HTTP error. */
74export function unwrap<T>(result: Result<T>): T {
75 if (result.ok) return result.value;
76 throw data(result.error.message, { status: httpStatus(result.error) });
77}