Files
phaseflow/src/lib/auth-middleware.ts
Petru Paler eeeece17bf
Some checks failed
CI / quality (push) Failing after 28s
Deploy / deploy (push) Successful in 2m38s
Add spec compliance improvements: seed switch alert, calendar emojis, period indicator, IP logging
- NutritionPanel: Display seed switch alert on day 15 per dashboard spec
- MonthView: Add phase emojis to legend (🩸🌱🌸🌙🌑) per calendar spec
- DayCell: Show period indicator (🩸) for days 1-3 per calendar spec
- Auth middleware: Log client IP from x-forwarded-for/x-real-ip per observability spec
- Updated NutritionGuidance type to include seedSwitchAlert field
- /api/today now returns seedSwitchAlert in nutrition response

Test coverage: 1005 tests (15 new tests added)
- nutrition-panel.test.tsx: +4 tests
- month-view.test.tsx: +1 test
- day-cell.test.tsx: +5 tests
- auth-middleware.test.ts: +3 tests
- today/route.test.ts: +2 tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:33:14 +00:00

104 lines
3.2 KiB
TypeScript

// ABOUTME: Authentication middleware wrapper for protected API routes.
// ABOUTME: Provides withAuth HOF that validates session and injects user context.
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import type PocketBase from "pocketbase";
import type { User } from "@/types";
import { logger } from "./logger";
import {
createPocketBaseClient,
getCurrentUser,
isAuthenticated,
loadAuthFromCookies,
} from "./pocketbase";
/**
* Route handler function type that receives the authenticated user and PocketBase client.
*/
export type AuthenticatedHandler<T = unknown> = (
request: NextRequest,
user: User,
pb: PocketBase,
context?: { params?: T },
) => Promise<NextResponse>;
/**
* Higher-order function that wraps an API route handler with authentication.
* Loads auth from cookies, validates the session, and passes the user and
* authenticated PocketBase client to the handler.
*
* @param handler - The route handler that requires authentication
* @returns A wrapped handler that checks auth before calling the original handler
*
* @example
* ```ts
* export const GET = withAuth(async (request, user, pb) => {
* const data = await pb.collection("users").getOne(user.id);
* return NextResponse.json({ email: user.email });
* });
* ```
*/
/**
* Extracts client IP address from request headers.
* Checks x-forwarded-for and x-real-ip headers, returns "unknown" if neither present.
*/
function getClientIp(request: NextRequest): string {
const forwardedFor = request.headers.get("x-forwarded-for");
if (forwardedFor) {
// x-forwarded-for can contain multiple IPs; first one is the client
return forwardedFor.split(",")[0].trim();
}
const realIp = request.headers.get("x-real-ip");
if (realIp) {
return realIp;
}
return "unknown";
}
export function withAuth<T = unknown>(
handler: AuthenticatedHandler<T>,
): (request: NextRequest, context?: { params?: T }) => Promise<NextResponse> {
return async (
request: NextRequest,
context?: { params?: T },
): Promise<NextResponse> => {
try {
// Create a fresh PocketBase client for this request
const pb = createPocketBaseClient();
// Load auth state from cookies
const cookieStore = await cookies();
loadAuthFromCookies(pb, cookieStore);
// Get client IP for logging
const ip = getClientIp(request);
// Check if the user is authenticated
if (!isAuthenticated(pb)) {
logger.warn({ reason: "not_authenticated", ip }, "Auth failure");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get the current user
const user = getCurrentUser(pb);
if (!user) {
logger.warn({ reason: "user_not_found", ip }, "Auth failure");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Call the original handler with the user context and authenticated pb client
return await handler(request, user, pb, context);
} catch (error) {
logger.error({ err: error }, "Auth middleware error");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
};
}