import { redirect } from "@tanstack/react-router"; import UserRoles from "supertokens-node/recipe/userroles"; import UserMetadata from "supertokens-node/recipe/usermetadata"; import { getSessionForStart } from "@/lib/supertokens/recipes/start-session"; import { Logger } from "@/lib/logger"; import { pbAdmin } from "@/lib/pocketbase/client"; import { PUBLIC_ROUTES } from "./supertokens"; import type { Player } from "@/features/players/types"; const logger = new Logger("Middleware"); const ROLES_METADATA_TTL_MS = 60 * 1000; const rolesMetadataCache = new Map< string, { roles: string[]; metadata: any; expiresAt: number } >(); export const invalidateUserCache = (userAuthId: string) => { rolesMetadataCache.delete(userAuthId); }; export const fetchUserRoles = async (userAuthId: string) => { const response = await UserRoles.getRolesForUser("public", userAuthId); return response; }; const getRolesAndMetadata = async (userAuthId: string) => { const now = Date.now(); const cached = rolesMetadataCache.get(userAuthId); if (cached && cached.expiresAt > now) { return cached; } const [{ roles }, { metadata }] = await Promise.all([ fetchUserRoles(userAuthId), UserMetadata.getUserMetadata(userAuthId), ]); const entry = { roles, metadata, expiresAt: now + ROLES_METADATA_TTL_MS }; rolesMetadataCache.set(userAuthId, entry); return entry; }; export const updateUserMetadataFields = async ( userAuthId: string, fields: Record ) => { await UserMetadata.updateUserMetadata(userAuthId, fields); invalidateUserCache(userAuthId); }; const verifySuperTokensSession = async (request: Request) => { let session = await getSessionForStart(request, { sessionRequired: false }); if (session?.needsRefresh) { logger.info("Session needs refresh - redirecting to client"); return { context: { session: { tryRefresh: true } } }; } const userAuthId = session?.userId; if (!userAuthId || !session) { return { context: { userAuthId: null, roles: [] } }; } const { roles, metadata } = await getRolesAndMetadata(userAuthId); return { context: { userAuthId, roles, metadata, phone: session.phone, session: { accessTokenPayload: session.accessTokenPayload, sessionHandle: session.sessionHandle, }, }, }; }; const LAST_ACTIVITY_THROTTLE_MS = 5 * 60 * 1000; const lastActivityWriteAt = new Map(); export const getSessionContextCore = async ( request: Request, options?: { isServerFunction?: boolean } ) => { const session = await verifySuperTokensSession(request); if (session.context.session?.tryRefresh) { if (options?.isServerFunction) { throw new Error("SESSION_REFRESH_REQUIRED"); } const url = new URL(request.url); if (PUBLIC_ROUTES.some((route) => url.pathname.startsWith(route))) { throw new Error("Unauthenticated"); } const from = url.pathname + url.search; throw redirect({ to: "/refresh-session", search: from === "/" ? {} : { redirect: from }, }); } if (!session.context.userAuthId) { throw new Error("Unauthenticated"); } let player: Player | null = null; try { player = await pbAdmin.getPlayerByAuthId(session.context.userAuthId); if (player) { const now = Date.now(); const lastWrite = lastActivityWriteAt.get(session.context.userAuthId) ?? 0; if (now - lastWrite > LAST_ACTIVITY_THROTTLE_MS) { lastActivityWriteAt.set(session.context.userAuthId, now); await pbAdmin.updatePlayer(player.id, { last_activity: new Date().toISOString(), }); } } } catch (error) { logger.error("Failed to update player last_activity", error); } return { userAuthId: session.context.userAuthId, roles: session.context.roles, metadata: session.context.metadata, phone: session.context.phone, player, }; };