Snapshot of uncommitted work that had accumulated in the tree alongside the Turnstile changes: - marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and structured data - admin billing actions and a per-user portfolio view, plus an admin error boundary - rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface - CSP and proxy adjustments, accounting/webhook lib updates - Playwright config and an e2e/unit test suite - next bumped to ^16.3.4 with the lockfile regenerated - generated AGENTS.md / CLAUDE.md Authored by other sessions working in this tree; committed here so the Turnstile work could be pushed without leaving the tree dirty. Typecheck passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
81 lines
3.2 KiB
TypeScript
81 lines
3.2 KiB
TypeScript
import { headers } from "next/headers"
|
|
import { redirect } from "next/navigation"
|
|
import { eq } from "drizzle-orm"
|
|
import { auth } from "@/lib/auth"
|
|
import { db } from "@/lib/db"
|
|
import { profiles } from "@/lib/db/schema"
|
|
|
|
/**
|
|
* Returns the authenticated Better Auth user for the current request, or null.
|
|
*
|
|
* Usage in a route / server component:
|
|
* const user = await getSessionUser()
|
|
* if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
|
*/
|
|
export async function getSessionUser() {
|
|
const session = await auth.api.getSession({ headers: await headers() })
|
|
return session?.user ?? null
|
|
}
|
|
|
|
export async function getSession() {
|
|
return auth.api.getSession({ headers: await headers() })
|
|
}
|
|
|
|
// ── Admin gating ─────────────────────────────────────────────────────────────
|
|
// Admins come from the Better Auth `user.role === "admin"` field (set via the
|
|
// admin plugin / bootstrap env). ADMIN_USER_IDS / ADMIN_EMAILS act as an
|
|
// env-level fallback so the first admin can be bootstrapped without DB access.
|
|
const ADMIN_USER_IDS = (process.env.ADMIN_USER_IDS ?? "")
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS ?? "")
|
|
.split(",")
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
|
|
export function isAdminUser(
|
|
u:
|
|
| { id?: string; email?: string; emailVerified?: boolean; role?: string | null }
|
|
| null
|
|
| undefined
|
|
): boolean {
|
|
if (!u) return false
|
|
if (u.role === "admin") return true
|
|
// ADMIN_USER_IDS is the unconditional bootstrap path: an id can only come from
|
|
// a row we created, so it is not attacker-selectable.
|
|
if (u.id && ADMIN_USER_IDS.includes(u.id)) return true
|
|
// ADMIN_EMAILS is matched on a user-supplied string, so it additionally
|
|
// requires a VERIFIED address. Otherwise, in any environment where
|
|
// REQUIRE_EMAIL_VERIFICATION is off, simply signing up with a listed address
|
|
// would grant admin without ever controlling the mailbox.
|
|
if (u.email && u.emailVerified && ADMIN_EMAILS.includes(u.email.toLowerCase())) return true
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* For API routes / server actions: returns `{ user, profile }` if the caller is
|
|
* an admin, otherwise null (caller returns 401/403). NEVER skip this — admin
|
|
* queries bypass user_id scoping, so this gate is the only data protection.
|
|
*/
|
|
export async function getAdminSession() {
|
|
const session = await auth.api.getSession({ headers: await headers() })
|
|
const user = session?.user ?? null
|
|
if (!isAdminUser(user)) return null
|
|
const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user!.id) })
|
|
return { user: user!, profile: profile ?? null }
|
|
}
|
|
|
|
/**
|
|
* For server components / the (admin) layout: redirects non-admins
|
|
* (anonymous → /login, logged-in non-admin → /dashboard).
|
|
*/
|
|
export async function requireAdmin() {
|
|
const session = await auth.api.getSession({ headers: await headers() })
|
|
const user = session?.user ?? null
|
|
if (!user) redirect("/login")
|
|
if (!isAdminUser(user)) redirect("/dashboard")
|
|
const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user.id) })
|
|
return { user, profile: profile ?? null }
|
|
}
|