/** * Base procedures with typed context for oRPC handlers * * Uses implement(contract).$context() to provide proper type safety. * All procedure handlers should import from this file. */ import type { APIContext, AuthenticatedContext, AuthInfo, LoginRequestContext, Session, SessionUser, } from "../context.js"; import { implement, ORPCError } from "@orpc/server"; import { contract } from "@reviq/api-contract"; import { COOKIE_NAMES, getCookie } from "../utils/cookies.js"; import { hashToken } from "../utils/crypto.js"; /** * Base implementer with typed APIContext * All procedures should be derived from this */ export const os = implement(contract).$context(); /** * Auth middleware - validates session/API token and adds user to context * Use with os.use(authMiddleware) to create authenticated procedures */ export const authMiddleware = os.middleware(async ({ context, next }) => { const { db, reqHeaders } = context; // Try session cookie first let tokenHash: string | undefined; const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN); if (sessionToken) { tokenHash = await hashToken(sessionToken); } // Fall back to API key header (for CLI) const apiKey = reqHeaders.get("x-api-key"); if (!tokenHash && apiKey) { tokenHash = await hashToken(apiKey); } if (!tokenHash) { throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" }); } // Look up session (check not expired and not revoked) const session = await db .selectFrom("sessions") .where("token_hash", "=", tokenHash) .where("expires_at", ">", new Date()) .where("revoked_at", "is", null) .selectAll() .executeTakeFirst(); // Fall back to API token if no session found const apiToken = !session ? await db .selectFrom("api_tokens") .where("token_hash", "=", tokenHash) .where("expires_at", ">", new Date()) .selectAll() .executeTakeFirst() : undefined; const userId = session?.user_id ?? apiToken?.user_id; if (!userId) { throw new ORPCError("UNAUTHORIZED", { message: "Invalid or expired token", }); } // Update last_used_at for API tokens if (apiToken) { await db .updateTable("api_tokens") .set({ last_used_at: new Date() }) .where("id", "=", apiToken.id) .execute(); } // Fetch user details const user = await db .selectFrom("users") .where("id", "=", userId) .select([ "id", "email", "display_name", "email_verified_at", "is_superuser", ]) .executeTakeFirst(); if (!user) { throw new ORPCError("UNAUTHORIZED", { message: "User not found", }); } const sessionUser: SessionUser = { id: user.id, email: user.email, displayName: user.display_name, emailVerifiedAt: user.email_verified_at, isSuperuser: user.is_superuser, }; // Build session and auth info based on authentication method let sessionInfo: Session; let authInfo: AuthInfo; if (session) { sessionInfo = { id: session.id, trustedMode: session.trusted_mode, createdAt: session.created_at, }; authInfo = { method: "session", sessionId: session.id, expiresAt: session.expires_at, createdAt: session.created_at, }; } else if (apiToken) { sessionInfo = { // For API token auth, create a synthetic session object id: "0", trustedMode: true, createdAt: apiToken.created_at, }; authInfo = { method: "api_token", tokenId: apiToken.id, tokenName: apiToken.name, expiresAt: apiToken.expires_at, lastUsedAt: apiToken.last_used_at, createdAt: apiToken.created_at, }; } else { // This should never happen since we checked userId above throw new ORPCError("UNAUTHORIZED", { message: "Invalid authentication state", }); } return next({ context: { user: sessionUser, session: sessionInfo, auth: authInfo, }, }); }); /** * Login request middleware - validates login request token from cookie */ export const loginRequestMiddleware = os.middleware( async ({ context, next }) => { const { db, reqHeaders } = context; // Read login request token from cookie const loginRequestToken = getCookie( reqHeaders, COOKIE_NAMES.LOGIN_REQUEST_TOKEN, ); if (!loginRequestToken) { throw new ORPCError("BAD_REQUEST", { message: "No login request found", }); } // Fetch login request with user data by token const result = await db .selectFrom("login_requests") .innerJoin("users", "users.id", "login_requests.user_id") .select([ "login_requests.id", "login_requests.user_id", "login_requests.expires_at", "users.email", "users.display_name", "users.email_verified_at", "users.is_superuser", ]) .where("login_requests.token", "=", loginRequestToken) .where("login_requests.expires_at", ">", new Date()) .executeTakeFirst(); if (!result) { throw new ORPCError("BAD_REQUEST", { message: "Login request expired or not found", }); } const sessionUser: SessionUser = { id: result.user_id, email: result.email, displayName: result.display_name, emailVerifiedAt: result.email_verified_at, isSuperuser: result.is_superuser, }; return next({ context: { loginRequestId: Number(result.id), user: sessionUser, }, }); }, ); /** * Superuser middleware - requires admin access (must be used after authMiddleware) */ export const superuserMiddleware = os.middleware(async ({ context, next }) => { // This middleware should be used after authMiddleware const ctx = context as AuthenticatedContext; if (!ctx.user.isSuperuser) { throw new ORPCError("FORBIDDEN", { message: "Superuser access required", }); } return next(); }); // Type exports for use in procedure files export type { APIContext, AuthenticatedContext, LoginRequestContext };