- 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>
104 lines
3.2 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
};
|
|
}
|