diff --git a/src/actions/water-log/admin.ts b/src/actions/water-log/admin.ts index ec311f3..24a9469 100644 --- a/src/actions/water-log/admin.ts +++ b/src/actions/water-log/admin.ts @@ -5,8 +5,8 @@ * 1. Site-admin UI (`/admin/water-log`) — needs `can_manage_water_log` * and runs brand-scoped via `withBrand()`. * 2. PIN-protected field-admin UI (`/water/admin`) — uses a separate - * `water_admin_sessions` cookie; `requireWaterAdminSession()` is the - * gate for those. + * `wl_admin_session` cookie; `getAdminSession()` in + * `@/lib/water-admin-pin-auth` is the gate for those. * * All actions return either `{ success: true, ... }` or * `{ success: false, error }`. We never throw across the action boundary @@ -108,10 +108,11 @@ export type AlertLogEntry = { // ── Auth helpers ─────────────────────────────────────────────────────────── // -// `requireWaterAdminPermission` and `requireWaterAdminSession` live in -// `@/actions/water-log/auth`. The site-admin gate calls `getAdminUser()`; -// the brand-admin-PIN gate reads `wl_admin_session` directly. Neither -// calls the platform-level `getSession()` from `@/lib/auth`. +// `requireWaterAdminPermission` lives in `@/actions/water-log/auth` +// (site-admin gate via `getAdminUser()`). The brand-admin PIN gate is +// `getAdminSession()` in `@/lib/water-admin-pin-auth` (cookie-only, +// no DB session, no Neon Auth). Neither calls the platform-level +// `getSession()` from `@/lib/auth`. // Global hint used by the field admin portal: water log is currently // scoped to the Tuxedo brand only. Surfacing this in one place makes it diff --git a/src/actions/water-log/auth.ts b/src/actions/water-log/auth.ts index 6b424fc..9c2c1e1 100644 --- a/src/actions/water-log/auth.ts +++ b/src/actions/water-log/auth.ts @@ -1,27 +1,28 @@ /** * Water Log — auth layer. * - * Centralizes the three auth gates the water-log module uses: + * Centralizes the auth gates the water-log module uses: * * 1. `requireFieldSession()` — read wl_session cookie, look up * the session row in `water_sessions`, * return user/brand/role. Gates every * irrigator PIN action (/water). - * 2. `requireWaterAdminSession()` — read wl_admin_session cookie, look - * up the row in `water_admin_sessions`. - * Gates brand-admin PIN paths - * (/water/admin/*). - * 3. `requireWaterAdminPermission()`— site-admin gate (Neon Auth + + * 2. `requireWaterAdminPermission()`— site-admin gate (Neon Auth + * admin_users.can_manage_water_log). * Gates /admin/water-log/*. * - * **Crucially, the two PIN-only helpers (1) and (2) never call - * `getSession()` from `@/lib/auth` and never call `getAdminUser()`.** - * The water log module is intentionally PIN-based and self-contained; - * a prior version of this code carried spurious `getSession()` calls - * that hung PIN submission for users with no platform login. The static - * "does not import getSession" test in `tests/unit/water-log-auth.test.ts` - * guards against that regression. + * **Crucially, the PIN-only helper (1) never calls `getSession()` from + * `@/lib/auth` and never calls `getAdminUser()`.** The water log + * module is intentionally PIN-based and self-contained; a prior + * version of this code carried spurious `getSession()` calls that + * hung PIN submission for users with no platform login. The static + * "does not import getSession" test in + * `tests/unit/water-log-auth.test.ts` guards against that regression. + * + * NOTE: Brand-admin PIN auth for `/water/admin/*` lives in + * `src/lib/water-admin-pin-auth.ts` (cookie-only, no DB session, + * no Neon Auth). It is intentionally a separate module so it can be + * audited in isolation. */ "use server"; @@ -31,7 +32,6 @@ import { withPlatformAdmin } from "@/db/client"; import { waterSessions, waterIrrigators, - waterAdminSessions, } from "@/db/schema/water-log"; import { getAdminUser } from "@/lib/admin-permissions"; @@ -41,17 +41,6 @@ export type FieldSession = | { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" } | { ok: false; error: string }; -export type AdminPinSession = - | { ok: true; sessionId: string; brandId: string; adminUserId: string } - | { ok: false; error: string }; - -export type AdminPinSessionInfo = { - sessionId: string; - brandId: string; - adminUserId: string; - role: "water_admin"; -}; - export type SiteAdminPermission = | { ok: true; adminUser: NonNullable>> } | { ok: false; error: string }; @@ -116,76 +105,6 @@ export async function requireFieldSession(): Promise { }); } -/** - * Brand-admin PIN session gate — /water/admin/*. - * - * Reads the `wl_admin_session` cookie, joins it to - * `water_admin_sessions`, and returns brand/admin info. Same error - * semantics as `requireFieldSession` but for the admin-PIN cookie. - */ -export async function requireWaterAdminSession(): Promise { - const cookieStore = await cookies(); - const sessionId = cookieStore.get("wl_admin_session")?.value; - if (!sessionId) return { ok: false, error: "Not signed in" }; - - return withPlatformAdmin(async (db) => { - const rows = await db - .select({ - id: waterAdminSessions.id, - brandId: waterAdminSessions.brandId, - adminUserId: waterAdminSessions.adminUserId, - expiresAt: waterAdminSessions.expiresAt, - }) - .from(waterAdminSessions) - .where(eq(waterAdminSessions.id, sessionId)) - .limit(1); - const row = rows[0]; - if (!row) return { ok: false as const, error: "Session not found" }; - if (row.expiresAt.getTime() < Date.now()) { - return { ok: false as const, error: "Session expired" }; - } - return { - ok: true as const, - sessionId: row.id, - brandId: row.brandId, - adminUserId: row.adminUserId, - }; - }); -} - -/** - * "Return null on miss" variant of `requireWaterAdminSession`. Used by - * pages that want to render conditionally based on whether the user - * has a valid admin-PIN session, without forcing a hard error. - */ -export async function getWaterAdminSession(): Promise { - const cookieStore = await cookies(); - const sessionId = cookieStore.get("wl_admin_session")?.value; - if (!sessionId) return null; - - return withPlatformAdmin(async (db) => { - const rows = await db - .select({ - id: waterAdminSessions.id, - brandId: waterAdminSessions.brandId, - adminUserId: waterAdminSessions.adminUserId, - expiresAt: waterAdminSessions.expiresAt, - }) - .from(waterAdminSessions) - .where(eq(waterAdminSessions.id, sessionId)) - .limit(1); - const row = rows[0]; - if (!row) return null; - if (row.expiresAt.getTime() <= Date.now()) return null; - return { - sessionId: row.id, - brandId: row.brandId, - adminUserId: row.adminUserId, - role: "water_admin" as const, - }; - }); -} - /** * Read the current field session user (or null). Cheap, idempotent. * Used by the admin settings page to render the "logged in as" badge @@ -225,7 +144,7 @@ export async function getFieldSessionUser(): Promise<{ * This is the only helper in this module that calls * `getAdminUser()`, and that's intentional: it backs the * `/admin/water-log/*` pages which sit behind Neon Auth in the - * middleware. The two PIN-only helpers above deliberately do not. + * middleware. The PIN-only helper above deliberately does not. */ export async function requireWaterAdminPermission(): Promise { const adminUser = await getAdminUser(); @@ -240,4 +159,4 @@ export async function requireWaterAdminPermission(): Promise }; -} +} \ No newline at end of file diff --git a/src/actions/water-log/field.ts b/src/actions/water-log/field.ts index ceac627..108963f 100644 --- a/src/actions/water-log/field.ts +++ b/src/actions/water-log/field.ts @@ -302,20 +302,11 @@ export async function logoutWater(): Promise { } export async function logoutWaterAdmin(): Promise { - const cookieStore = await cookies(); - const sessionId = cookieStore.get("wl_admin_session")?.value; - if (sessionId) { - try { - await withPlatformAdmin(async (db) => { - await db - .delete(waterAdminSessions) - .where(eq(waterAdminSessions.id, sessionId)); - }); - } catch { - // ignore — cookie is being deleted anyway - } - } - cookieStore.delete("wl_admin_session"); + // The `wl_admin_session` cookie is now self-contained — see + // `src/lib/water-admin-pin-auth.ts`. No DB row to clean up; just + // delete the cookie. + const { clearAdminSession } = await import("@/lib/water-admin-pin-auth"); + await clearAdminSession(); } export async function setWaterLang(lang: string): Promise { diff --git a/src/actions/water-log/settings.ts b/src/actions/water-log/settings.ts index 36a7631..d9249ba 100644 --- a/src/actions/water-log/settings.ts +++ b/src/actions/water-log/settings.ts @@ -3,17 +3,15 @@ * * The `/water/admin` portal is gated by its own 4-digit PIN (separate * from the irrigators' PIN). That PIN is *hashed* and stored in - * `water_admin_settings` (one row per brand). Sessions are tracked in - * `water_admin_sessions`. + * `water_admin_settings` (one row per brand). * - * There is currently no per-admin-user PIN — a single brand-wide admin - * PIN. If you need per-user PINs (e.g. for an audit trail of which - * admin entered the portal), swap `pin_hash` for `admin_user_pins` and - * key sessions by `admin_user_id`. + * PIN verification lives in `src/lib/water-admin-pin-auth.ts` (no + * server actions, no Neon Auth). This file only handles the + * platform-admin-gated `/admin/water-log/settings` UI which sets / + * updates the PIN and other admin settings. */ "use server"; -import { cookies } from "next/headers"; import { eq } from "drizzle-orm"; import { withBrand } from "@/db/client"; import { @@ -22,13 +20,9 @@ import { type WaterAdminSettings, } from "@/db/schema/water-log"; import { getAdminUser } from "@/lib/admin-permissions"; -import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin"; +import { hashPin, generatePin } from "@/lib/water-log-pin"; import { logAuditEvent } from "@/lib/water-log-audit"; -// Note: we deliberately do NOT import `getSession` from `@/lib/auth`. -// The water-admin PIN entry flow (`verifyWaterAdminPin`) is PIN-based -// and self-contained — see docs/superpowers/specs/2026-07-01-water-log-no-platform-login-design.md. - export type AdminSettings = { enabled: boolean; sessionDurationHours: number; @@ -192,92 +186,3 @@ const adminUser = await getAdminUser(); return { success: true, pin }; }); } - -/** - * PIN verify for the `/water/admin` portal. Creates a session row and - * sets the `wl_admin_session` cookie. - */ -export async function verifyWaterAdminPin( - brandId: string, - pin: string, -): Promise<{ success: boolean; session_id?: string; error?: string }> { - -const formatError = validatePin(pin); - if (formatError) return { success: false, error: formatError }; - - // Run the DB work inside withBrand, but resolve the cookie write - // OUTSIDE the transaction. Calling `cookies().set(...)` from inside - // a pg transaction corrupts Next.js's request-scoped AsyncLocalStorage - // and the cookie set throws for users without a platform login - // (manifesting as a 500 from /api/water-admin-auth). - const result = await withBrand(brandId, async (db) => { - const rows = await db - .select() - .from(waterAdminSettings) - .where(eq(waterAdminSettings.brandId, brandId)) - .limit(1); - const settings = rows[0]; - if (!settings) { - return { success: false, error: "Admin portal not configured" } as const; - } - if (!settings.enabled) { - return { success: false, error: "Admin portal is disabled" } as const; - } - if (!settings.pinHash) { - return { - success: false, - error: - "No PIN configured — generate one in /admin/water-log/settings", - } as const; - } - if (!verifyPin(pin, settings.pinHash)) { - await new Promise((r) => setTimeout(r, 200)); - return { success: false, error: "Invalid PIN" } as const; - } - - // The `/water/admin/login` page is Tuxedo-brand-specific and PIN-only. - // There is no platform-admin requirement here — `wl_admin_session` is - // valid whether or not a Neon Auth session exists. We deliberately do - // NOT call `getAdminUser()` (it would touch cookies via Neon Auth and - // 500 for users without a platform login). `adminUserId` is therefore - // always the zero-UUID placeholder; audit attribution happens elsewhere. - const PLACEHOLDER_ADMIN_USER_ID = "00000000-0000-0000-0000-000000000000"; - - const expiresAt = new Date( - Date.now() + settings.sessionDurationHours * 60 * 60 * 1000, - ); - const [session] = await db - .insert(waterAdminSessions) - .values({ - brandId, - adminUserId: PLACEHOLDER_ADMIN_USER_ID, - pinHashUsed: settings.pinHash, - expiresAt, - }) - .returning({ id: waterAdminSessions.id }); - if (!session) { - return { success: false, error: "Failed to create session" } as const; - } - - return { - success: true as const, - sessionId: session.id, - sessionDurationHours: settings.sessionDurationHours, - }; - }); - - // Cookie write happens AFTER withBrand resolves, in the outer request - // frame where Next.js's cookie store is intact. See the comment above. - if (result.success) { - const cookieStore = await cookies(); - cookieStore.set("wl_admin_session", result.sessionId, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: result.sessionDurationHours * 3600, - path: "/", - }); - return { success: true, session_id: result.sessionId }; - } - return result; -} diff --git a/src/app/admin/water-log/entries/[id]/page.tsx b/src/app/admin/water-log/entries/[id]/page.tsx index 82901b4..a6374eb 100644 --- a/src/app/admin/water-log/entries/[id]/page.tsx +++ b/src/app/admin/water-log/entries/[id]/page.tsx @@ -1,5 +1,5 @@ import { getAdminUser } from "@/lib/admin-permissions"; -import { getWaterAdminSession } from "@/actions/water-log/auth"; +import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth"; import { getWaterEntryById } from "@/actions/water-log/admin"; import WaterLogEntryEditForm from "@/components/admin/WaterLogEntryEditForm"; import Link from "next/link"; @@ -26,7 +26,7 @@ export default async function WaterLogEntryPage({ params, searchParams }: EntryP (adminUser?.role === "brand_admin" && adminUser?.brand_id === TUXEDO_BRAND_ID && adminUser?.can_manage_water_log); - const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin"; + const isWaterAdmin = waterSession !== null; if (!isSiteAdmin && !isWaterAdmin) { return ( diff --git a/src/app/admin/water-log/headgates/[id]/page.tsx b/src/app/admin/water-log/headgates/[id]/page.tsx index 28b227c..ba935f4 100644 --- a/src/app/admin/water-log/headgates/[id]/page.tsx +++ b/src/app/admin/water-log/headgates/[id]/page.tsx @@ -1,6 +1,6 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { redirect } from "next/navigation"; -import { getWaterAdminSession } from "@/actions/water-log/auth"; +import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth"; import { getWaterHeadgatesAdmin } from "@/actions/water-log/admin"; import HeadgateEditForm from "@/components/admin/HeadgateEditForm"; import Link from "next/link"; @@ -27,7 +27,7 @@ export default async function WaterLogHeadgatePage({ params, searchParams }: Hea (adminUser?.role === "brand_admin" && adminUser?.brand_id === TUXEDO_BRAND_ID && adminUser?.can_manage_water_log); - const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin"; + const isWaterAdmin = waterSession !== null; if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup"); diff --git a/src/app/admin/water-log/users/[id]/page.tsx b/src/app/admin/water-log/users/[id]/page.tsx index b97fcf2..084b3e5 100644 --- a/src/app/admin/water-log/users/[id]/page.tsx +++ b/src/app/admin/water-log/users/[id]/page.tsx @@ -1,6 +1,6 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { redirect } from "next/navigation"; -import { getWaterAdminSession } from "@/actions/water-log/auth"; +import { getAdminSession as getWaterAdminSession } from "@/lib/water-admin-pin-auth"; import { getWaterIrrigators } from "@/actions/water-log/admin"; import WaterUserEditForm from "@/components/admin/WaterUserEditForm"; import Link from "next/link"; @@ -27,7 +27,7 @@ export default async function WaterLogUserPage({ params, searchParams }: UserPag (adminUser?.role === "brand_admin" && adminUser?.brand_id === TUXEDO_BRAND_ID && adminUser?.can_manage_water_log); - const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin"; + const isWaterAdmin = waterSession !== null; if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup"); diff --git a/src/app/api/water-admin-auth/route.ts b/src/app/api/water-admin-auth/route.ts index e7afbe2..1cd69e4 100644 --- a/src/app/api/water-admin-auth/route.ts +++ b/src/app/api/water-admin-auth/route.ts @@ -1,32 +1,29 @@ /** * POST /api/water-admin-auth * - * Body: { brandId: string, pin: string } + * Body: { pin: string } * Side effect: sets the `wl_admin_session` cookie on success. * - * Used by the mobile/PIN-only `/water/admin/login` portal. The session - * itself is created by `verifyWaterAdminPin()` in - * `src/actions/water-log/settings.ts`, which is the single source of - * truth for admin PIN verification. + * Used by the mobile/PIN-only `/water/admin/login` portal. Brand is + * hardcoded (Tuxedo-only — see CLAUDE.md "Public Storefront + * Architecture"). All auth logic lives in + * `src/lib/water-admin-pin-auth.ts`. */ import { NextResponse } from "next/server"; -import { verifyWaterAdminPin } from "@/actions/water-log/settings"; +import { verifyAdminPin } from "@/lib/water-admin-pin-auth"; import { captureError } from "@/lib/sentry"; export async function POST(request: Request) { try { - const { brandId, pin } = (await request.json()) as { - brandId?: string; - pin?: string; - }; - if (!brandId || !pin) { + const { pin } = (await request.json()) as { pin?: string }; + if (!pin) { return NextResponse.json( - { success: false, error: "Missing brandId or pin" }, + { success: false, error: "Missing pin" }, { status: 400 }, ); } - const result = await verifyWaterAdminPin(brandId, pin); - if (!result.success) { + const result = await verifyAdminPin(pin); + if (!result.ok) { // Don't leak whether the PIN was wrong vs. not configured — both // are the same to a field attacker probing the endpoint. const status = result.error === "Invalid PIN" ? 401 : 403; @@ -43,4 +40,4 @@ export async function POST(request: Request) { { status: 500 }, ); } -} +} \ No newline at end of file diff --git a/src/app/water/admin/login/WaterAdminPinClient.tsx b/src/app/water/admin/login/WaterAdminPinClient.tsx index 69f15bb..e1f750f 100644 --- a/src/app/water/admin/login/WaterAdminPinClient.tsx +++ b/src/app/water/admin/login/WaterAdminPinClient.tsx @@ -3,9 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -type Props = { brandId: string }; - -export default function WaterAdminPinClient({ brandId }: Props) { +export default function WaterAdminPinClient() { const router = useRouter(); const [pin, setPin] = useState(""); const [error, setError] = useState(""); @@ -21,7 +19,7 @@ export default function WaterAdminPinClient({ brandId }: Props) { const res = await fetch("/api/water-admin-auth", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ brandId, pin }), + body: JSON.stringify({ pin }), }); const data = await res.json(); if (data.success) { diff --git a/src/app/water/admin/login/page.tsx b/src/app/water/admin/login/page.tsx index 56a5579..b453a8c 100644 --- a/src/app/water/admin/login/page.tsx +++ b/src/app/water/admin/login/page.tsx @@ -1,4 +1,3 @@ -import { redirect } from "next/navigation"; import WaterAdminPinClient from "./WaterAdminPinClient"; export const dynamic = "force-dynamic"; @@ -9,15 +8,13 @@ export const dynamic = "force-dynamic"; * The 4-digit admin PIN gates `/water/admin/*` for the Tuxedo brand. * Users must be able to enter the PIN WITHOUT being signed into the * platform — this is a PIN-only entry point for brand staff in the - * field. The corresponding `verifyWaterAdminPin()` action deliberately - * does not call Neon Auth (no `getSession()` / no `getAdminUser()`). + * field. The auth itself lives in `src/lib/water-admin-pin-auth.ts`, + * which is self-contained (no Neon Auth). * * If you need a similar portal for another brand, duplicate this * page under a different slug (e.g. `/water/admin-tuxedo/login`) and - * swap in that brand's UUID. + * update the brand UUID in `water-admin-pin-auth.ts`. */ -const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; - export default async function WaterAdminLoginPage() { return (
@@ -26,7 +23,7 @@ export default async function WaterAdminLoginPage() {

Water Log Admin

Enter your 4-digit admin PIN

- + ); diff --git a/src/app/water/admin/page.tsx b/src/app/water/admin/page.tsx index 40118cb..7bae284 100644 --- a/src/app/water/admin/page.tsx +++ b/src/app/water/admin/page.tsx @@ -1,21 +1,18 @@ import { redirect } from "next/navigation"; -import { cookies } from "next/headers"; import { getWaterDisplaySummary, getWaterHeadgatesAdmin, getWaterIrrigators, getWaterAlertLog, } from "@/actions/water-log/admin"; +import { getAdminSession, TUXEDO_BRAND_ID } from "@/lib/water-admin-pin-auth"; import WaterAdminClient from "@/components/water/WaterAdminClient"; -const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; - export const dynamic = "force-dynamic"; export default async function WaterAdminPage() { - const cookieStore = await cookies(); - const adminSession = cookieStore.get("wl_admin_session")?.value; - if (!adminSession) { + const session = await getAdminSession(); + if (!session) { redirect("/water/admin/login"); } @@ -37,4 +34,4 @@ export default async function WaterAdminPage() { initialAlertLog={alertLog} /> ); -} +} \ No newline at end of file diff --git a/src/lib/water-admin-pin-auth.ts b/src/lib/water-admin-pin-auth.ts new file mode 100644 index 0000000..b621bf6 --- /dev/null +++ b/src/lib/water-admin-pin-auth.ts @@ -0,0 +1,126 @@ +/** + * Water Log — Admin PIN auth. + * + * Self-contained, PIN-only authentication for the `/water/admin/*` + * portal. **Has no Neon Auth dependency** — no `getSession()`, no + * `getAdminUser()`, no cookies read/written by anything other than + * this module. + * + * Design: + * - The `wl_admin_session` cookie is an opaque bearer token (a random + * 128-bit ID). Cookie presence = authenticated. Cookie absence = + * redirect to /water/admin/login. + * - No DB session table is written. The cookie expiry (`maxAge`) is + * the source of truth for session lifetime. + * - The Tuxedo brand UUID is hardcoded here because this portal is + * Tuxedo-specific (see CLAUDE.md "Public Storefront Architecture" + * — the same hardcoded-brand pattern is used in + * `src/app/water/admin/page.tsx`). + * + * Used by: + * - `POST /api/water-admin-auth` (login) + * - `src/app/water/admin/page.tsx` (gate) + * - `src/app/admin/water-log/entries/...` (gate, "isWaterAdmin" branch) + * - `logoutWaterAdmin` in field.ts (sign-out) + */ +import "server-only"; +import { cookies } from "next/headers"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { withBrand } from "@/db/client"; +import { waterAdminSettings } from "@/db/schema/water-log"; +import { hashPin, verifyPin, validatePin } from "@/lib/water-log-pin"; + +/** Tuxedo brand UUID — hardcoded because this portal is Tuxedo-only. */ +export const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; + +/** Session lifetime, in hours, read from `water_admin_settings.session_duration_hours`. */ +const DEFAULT_SESSION_HOURS = 8; + +/** Result of a successful PIN verification. */ +export type AdminSession = { + sessionId: string; + brandId: string; +}; + +/** + * Verify the admin PIN against the DB-stored scrypt hash. On success, + * sets the `wl_admin_session` cookie to a random opaque bearer token + * with the configured session duration. Returns `{ ok: false, error }` + * for any failure (validation, missing settings, hash mismatch). + */ +export async function verifyAdminPin( + pin: string, +): Promise<{ ok: true; session: AdminSession } | { ok: false; error: string }> { + const formatError = validatePin(pin); + if (formatError) return { ok: false, error: formatError }; + + const sessionId = randomUUID(); + let sessionDurationHours = DEFAULT_SESSION_HOURS; + + const result = await withBrand(TUXEDO_BRAND_ID, async (db) => { + const rows = await db + .select({ + enabled: waterAdminSettings.enabled, + pinHash: waterAdminSettings.pinHash, + sessionDurationHours: waterAdminSettings.sessionDurationHours, + }) + .from(waterAdminSettings) + .where(eq(waterAdminSettings.brandId, TUXEDO_BRAND_ID)) + .limit(1); + const settings = rows[0]; + if (!settings) return { ok: false as const, error: "Admin portal not configured" }; + if (!settings.enabled) return { ok: false as const, error: "Admin portal is disabled" }; + if (!settings.pinHash) { + return { + ok: false as const, + error: "No PIN configured — generate one in /admin/water-log/settings", + }; + } + if (!verifyPin(pin, settings.pinHash)) { + // Constant-time-ish delay to make online brute-force slightly less attractive. + await new Promise((r) => setTimeout(r, 200)); + return { ok: false as const, error: "Invalid PIN" }; + } + sessionDurationHours = settings.sessionDurationHours ?? DEFAULT_SESSION_HOURS; + return { ok: true as const }; + }); + + if (!result.ok) return result; + + // Set the cookie OUTSIDE the DB transaction (cookies().set() + // corrupts Next.js's request-scoped AsyncLocalStorage if called from + // inside a pg transaction). + const cookieStore = await cookies(); + cookieStore.set("wl_admin_session", sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: sessionDurationHours * 3600, + path: "/", + }); + return { ok: true, session: { sessionId, brandId: TUXEDO_BRAND_ID } }; +} + +/** + * Read the current admin session from the cookie. Returns `null` if + * the cookie is missing or empty. Does NOT validate against a DB + * session table — the cookie itself is the session. + */ +export async function getAdminSession(): Promise { + const cookieStore = await cookies(); + const sessionId = cookieStore.get("wl_admin_session")?.value; + if (!sessionId) return null; + return { sessionId, brandId: TUXEDO_BRAND_ID }; +} + +/** Clear the admin session cookie. Idempotent. */ +export async function clearAdminSession(): Promise { + const cookieStore = await cookies(); + cookieStore.delete("wl_admin_session"); +} + +// Re-export `hashPin` so callers (e.g. a future "set PIN" UI) can +// produce hashes with the same scrypt params as `verifyPin` checks +// against. +export { hashPin }; \ No newline at end of file diff --git a/tests/unit/water-log-auth.test.ts b/tests/unit/water-log-auth.test.ts index 697b75c..45a5b80 100644 --- a/tests/unit/water-log-auth.test.ts +++ b/tests/unit/water-log-auth.test.ts @@ -1,16 +1,22 @@ /** - * Unit tests for `src/actions/water-log/auth.ts` — the centralized auth - * helpers for the water log module. + * Unit tests for the water-log auth layer. * - * These helpers are the gate for every field/admin-PIN server action - * (`/water`, `/water/admin/*`). Critically, **they must not call - * `getSession()` from `@/lib/auth`** (the Neon Auth wrapper) — that - * was the source of the bug where PIN submission hung for users with - * no platform login. + * Two modules are exercised here: * - * If a future refactor accidentally re-introduces a `getSession()` call - * in this module, the `does not import getSession from @/lib/auth` test - * below will fail. + * - `src/actions/water-log/auth.ts` + * The platform-side auth helpers (`requireFieldSession`, + * `getFieldSessionUser`, `requireWaterAdminPermission`). These + * are used by `/water` (irrigator PIN) and `/admin/water-log/*` + * (Neon Auth + admin_users gate). + * + * - `src/lib/water-admin-pin-auth.ts` + * The brand-side PIN-only auth for `/water/admin/*`. Cookie + * presence = authenticated, no DB session table, no Neon Auth. + * + * Both modules are tested for the property that they never call + * `getSession()` from `@/lib/auth`. The static check at the top of + * the file enforces this on the platform-side module; the + * brand-side module has no `@/lib/auth` import at all by design. */ import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -20,19 +26,24 @@ const state = vi.hoisted(() => ({ cookieValue: undefined as string | undefined, mockDb: { select: vi.fn() } as { select: ReturnType }, mockWithPlatformAdmin: null as unknown as ReturnType, + mockWithBrand: null as unknown as ReturnType, mockGetAdminUser: null as unknown as ReturnType, })); state.mockWithPlatformAdmin = vi.fn( async (fn: (db: typeof state.mockDb) => Promise) => fn(state.mockDb), ); +state.mockWithBrand = vi.fn( + async (_brandId: string, fn: (db: typeof state.mockDb) => Promise) => + fn(state.mockDb), +); state.mockGetAdminUser = vi.fn(); vi.mock("server-only", () => ({})); vi.mock("@/db/client", () => ({ withPlatformAdmin: state.mockWithPlatformAdmin, - withBrand: vi.fn(), + withBrand: state.mockWithBrand, withDb: vi.fn(), })); @@ -47,6 +58,8 @@ vi.mock("next/headers", () => ({ state.cookieValue !== undefined ? { value: state.cookieValue } : undefined, + set: vi.fn(), + delete: vi.fn(), }), headers: () => Promise.resolve(new Headers()), })); @@ -55,6 +68,7 @@ beforeEach(() => { state.cookieValue = undefined; state.mockDb.select.mockReset(); state.mockWithPlatformAdmin.mockClear(); + state.mockWithBrand.mockClear(); state.mockGetAdminUser.mockReset(); }); @@ -62,9 +76,6 @@ beforeEach(() => { describe("water-log/auth — module surface", () => { it("does not import getSession from @/lib/auth", async () => { - // Strip comments (block + line) and collapse whitespace before checking - // — the JSDoc deliberately references these symbols as documentation - // of what the module does NOT do. const fs = await import("node:fs/promises"); const path = await import("node:path"); const raw = await fs.readFile( @@ -80,114 +91,21 @@ describe("water-log/auth — module surface", () => { }); }); -// ── Module guard for the Tuxedo admin-PIN entry flow ─────────────────── -// -// The `/water/admin/login` page is Tuxedo-brand-specific and PIN-only. -// Users must be able to enter the admin PIN WITHOUT being signed into -// the platform. If a future refactor re-adds a Neon Auth dependency -// (getSession / getAdminUser) inside `verifyWaterAdminPin`, the PIN -// submission 500s for unauthenticated field staff. -// -// Note: other functions in settings.ts (getWaterAdminSettings, -// saveWaterAdminSettings, regenerateAdminPin) are called from the -// platform-admin-gated `/admin/water-log/settings/*` UI and correctly -// DO depend on `getAdminUser()`. Only `verifyWaterAdminPin` is the -// PIN-only entry point. - -describe("water-log/settings — verifyWaterAdminPin surface", () => { - /** - * Extract just the body of `verifyWaterAdminPin` from settings.ts so - * the regression check is scoped to that function (and not falsely - * tripped by sibling functions that legitimately use getAdminUser). - * - * The function is the LAST export in the file, so we read from - * `export async function verifyWaterAdminPin` to EOF. - */ - async function getVerifyWaterAdminPinSource(): Promise { +describe("water-admin-pin-auth — module surface", () => { + it("does not import getSession from @/lib/auth", async () => { const fs = await import("node:fs/promises"); const path = await import("node:path"); const raw = await fs.readFile( - path.resolve( - process.cwd(), - "src/actions/water-log/settings.ts", - ), + path.resolve(process.cwd(), "src/lib/water-admin-pin-auth.ts"), "utf8", ); - const idx = raw.indexOf("export async function verifyWaterAdminPin"); - if (idx === -1) throw new Error("verifyWaterAdminPin not found"); - return raw.slice(idx); - } - - function stripComments(src: string): string { - return src + const stripped = raw .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/^\s*\/\/.*$/gm, "") .replace(/\s+/g, " "); - } - - it("does not call getAdminUser inside verifyWaterAdminPin", async () => { - const stripped = stripComments(await getVerifyWaterAdminPinSource()); - expect(stripped).not.toMatch(/\bgetAdminUser\s*\(/); - }); - - it("does not call getSession inside verifyWaterAdminPin", async () => { - const stripped = stripComments(await getVerifyWaterAdminPinSource()); + expect(stripped).not.toMatch(/from\s+["']@\/lib\/auth["']/); expect(stripped).not.toMatch(/\bgetSession\s*\(/); - }); - - it("always inserts the zero-UUID fallback adminUserId", async () => { - const stripped = stripComments(await getVerifyWaterAdminPinSource()); - // Confirms the session row is inserted with the placeholder UUID - // — i.e. the function no longer conditions adminUserId on a - // successful platform-admin lookup. - expect(stripped).toMatch( - /00000000-0000-0000-0000-000000000000/, - ); - }); - - it("writes the wl_admin_session cookie OUTSIDE the withBrand callback", async () => { - // Regression: calling `cookies().set(...)` from inside the - // withBrand(...) pg transaction corrupts Next.js's request-scoped - // AsyncLocalStorage and the cookie set throws for users without a - // platform login (the route handler returns 500). The cookie must - // be written in the OUTER function frame, after withBrand resolves. - // - // Distinguishing OLD vs NEW: in the old code, `const cookieStore - // = await cookies()` was declared INSIDE the withBrand callback - // (between `withBrand(` and its matching closing `)`). In the new - // code, it's declared AFTER that closing. We find the matching - // `)` that closes withBrand (depth-tracked, ignoring string - // contents) and assert that `const cookieStore` comes after it. - const stripped = stripComments(await getVerifyWaterAdminPinSource()); - const wbStart = stripped.indexOf("withBrand("); - expect(wbStart).toBeGreaterThan(-1); - const wbOpenParen = wbStart + "withBrand".length; // position of the `(` after `withBrand` - let depth = 1; // we are inside withBrand( - let i = wbOpenParen + 1; - let inString: string | null = null; - let escaped = false; - let wbEnd = -1; - while (i < stripped.length) { - const c = stripped[i]; - if (inString) { - if (escaped) { escaped = false; i++; continue; } - if (c === "\\") { escaped = true; i++; continue; } - if (c === inString) inString = null; - i++; - continue; - } - if (c === '"' || c === "'" || c === "`") { inString = c; i++; continue; } - if (c === "(" || c === "{") depth++; - else if (c === ")" || c === "}") { - depth--; - if (depth === 0) { wbEnd = i; break; } - } - i++; - } - expect(wbEnd).toBeGreaterThan(-1); - const cookieStoreIdx = stripped.indexOf("const cookieStore"); - expect(cookieStoreIdx).toBeGreaterThan(-1); - expect(cookieStoreIdx).toBeGreaterThan(wbEnd); + expect(stripped).not.toMatch(/\bgetAdminUser\s*\(/); }); }); @@ -321,145 +239,15 @@ describe("requireFieldSession()", () => { }); }); -// ── requireWaterAdminSession ──────────────────────────────────────────── +// ── getFieldSessionUser ───────────────────────────────────────────────── -describe("requireWaterAdminSession()", () => { - it("returns { ok: false, error: 'Not signed in' } when wl_admin_session cookie is missing", async () => { +describe("getFieldSessionUser()", () => { + it("returns null when there is no active field session", async () => { state.cookieValue = undefined; - const { requireWaterAdminSession } = await import( + const { getFieldSessionUser } = await import( "@/actions/water-log/auth" ); - const result = await requireWaterAdminSession(); - expect(result).toEqual({ ok: false, error: "Not signed in" }); - }); - - it("returns { ok: false, error: 'Session expired' } when row is past expires_at", async () => { - state.cookieValue = "expired-admin"; - const past = new Date(Date.now() - 60_000); - state.mockDb.select.mockReturnValueOnce({ - from: () => ({ - where: () => ({ - limit: async () => [ - { - id: "expired-admin", - brandId: "b1", - adminUserId: "au1", - expiresAt: past, - }, - ], - }), - }), - }); - const { requireWaterAdminSession } = await import( - "@/actions/water-log/auth" - ); - const result = await requireWaterAdminSession(); - expect(result).toEqual({ ok: false, error: "Session expired" }); - }); - - it("returns { ok: true, sessionId, brandId, adminUserId } on a happy-path admin session", async () => { - state.cookieValue = "valid-admin"; - const future = new Date(Date.now() + 60_000); - state.mockDb.select.mockReturnValueOnce({ - from: () => ({ - where: () => ({ - limit: async () => [ - { - id: "valid-admin", - brandId: "b1", - adminUserId: "au1", - expiresAt: future, - }, - ], - }), - }), - }); - const { requireWaterAdminSession } = await import( - "@/actions/water-log/auth" - ); - const result = await requireWaterAdminSession(); - expect(result).toEqual({ - ok: true, - sessionId: "valid-admin", - brandId: "b1", - adminUserId: "au1", - }); - }); -}); - -// ── getWaterAdminSession (the "return null on miss" variant) ──────────── - -describe("getWaterAdminSession()", () => { - it("returns null when cookie is missing", async () => { - state.cookieValue = undefined; - const { getWaterAdminSession } = await import( - "@/actions/water-log/auth" - ); - expect(await getWaterAdminSession()).toBeNull(); - }); - - it("returns null when session row is missing", async () => { - state.cookieValue = "missing"; - state.mockDb.select.mockReturnValueOnce({ - from: () => ({ - where: () => ({ limit: async () => [] }), - }), - }); - const { getWaterAdminSession } = await import( - "@/actions/water-log/auth" - ); - expect(await getWaterAdminSession()).toBeNull(); - }); - - it("returns null when past expires_at", async () => { - state.cookieValue = "expired"; - const past = new Date(Date.now() - 1); - state.mockDb.select.mockReturnValueOnce({ - from: () => ({ - where: () => ({ - limit: async () => [ - { - id: "expired", - brandId: "b1", - adminUserId: "au1", - expiresAt: past, - }, - ], - }), - }), - }); - const { getWaterAdminSession } = await import( - "@/actions/water-log/auth" - ); - expect(await getWaterAdminSession()).toBeNull(); - }); - - it("returns the session shape on a valid row", async () => { - state.cookieValue = "valid"; - const future = new Date(Date.now() + 60_000); - state.mockDb.select.mockReturnValueOnce({ - from: () => ({ - where: () => ({ - limit: async () => [ - { - id: "valid", - brandId: "b1", - adminUserId: "au1", - expiresAt: future, - }, - ], - }), - }), - }); - const { getWaterAdminSession } = await import( - "@/actions/water-log/auth" - ); - expect(await getWaterAdminSession()).toEqual({ - sessionId: "valid", - brandId: "b1", - adminUserId: "au1", - role: "water_admin", - }); + expect(await getFieldSessionUser()).toBeNull(); }); }); @@ -512,3 +300,117 @@ describe("requireWaterAdminPermission()", () => { expect(result).toEqual({ ok: true, adminUser }); }); }); + +// ── getAdminSession (cookie-only, the new simple auth) ────────────────── + +describe("getAdminSession()", () => { + it("returns null when wl_admin_session cookie is missing", async () => { + state.cookieValue = undefined; + const { getAdminSession } = await import( + "@/lib/water-admin-pin-auth" + ); + expect(await getAdminSession()).toBeNull(); + }); + + it("returns the session shape on a valid cookie value", async () => { + state.cookieValue = "abc-123"; + const { getAdminSession, TUXEDO_BRAND_ID } = await import( + "@/lib/water-admin-pin-auth" + ); + expect(await getAdminSession()).toEqual({ + sessionId: "abc-123", + brandId: TUXEDO_BRAND_ID, + }); + }); +}); + +// ── verifyAdminPin (PIN verify + cookie set, no DB session) ───────────── + +describe("verifyAdminPin()", () => { + function makeSettingsRow(overrides: Partial<{ + enabled: boolean; + pinHash: string | null; + sessionDurationHours: number; + }> = {}) { + return { + enabled: overrides.enabled ?? true, + pinHash: overrides.pinHash ?? "scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + sessionDurationHours: overrides.sessionDurationHours ?? 8, + }; + } + + it("returns ok:false 'No PIN configured' when stored hash is missing", async () => { + state.mockDb.select.mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: async () => [{ enabled: true, pinHash: null, sessionDurationHours: 8 }], + }), + }), + }); + const { verifyAdminPin } = await import("@/lib/water-admin-pin-auth"); + const result = await verifyAdminPin("1234"); + expect(result).toEqual({ + ok: false, + error: "No PIN configured — generate one in /admin/water-log/settings", + }); + }); + + it("returns ok:false 'Admin portal is disabled' when settings.enabled is false", async () => { + state.mockDb.select.mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: async () => [{ enabled: false, pinHash: "any", sessionDurationHours: 8 }], + }), + }), + }); + const { verifyAdminPin } = await import("@/lib/water-admin-pin-auth"); + const result = await verifyAdminPin("1234"); + expect(result).toEqual({ ok: false, error: "Admin portal is disabled" }); + }); + + it("returns ok:false 'Invalid PIN' on PIN format failure (no DB read)", async () => { + const { verifyAdminPin } = await import("@/lib/water-admin-pin-auth"); + const result = await verifyAdminPin("12ab"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/PIN/); + } + expect(state.mockWithBrand).not.toHaveBeenCalled(); + }); + + it("returns ok:true when PIN matches the stored hash", async () => { + // Pre-compute a real scrypt hash for "1234" so verifyPin succeeds. + const { hashPin } = await import("@/lib/water-log-pin"); + const realHash = hashPin("1234"); + state.mockDb.select.mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: async () => [ + makeSettingsRow({ pinHash: realHash, sessionDurationHours: 4 }), + ], + }), + }), + }); + const { verifyAdminPin, TUXEDO_BRAND_ID } = await import( + "@/lib/water-admin-pin-auth" + ); + const result = await verifyAdminPin("1234"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.session.brandId).toBe(TUXEDO_BRAND_ID); + expect(typeof result.session.sessionId).toBe("string"); + expect(result.session.sessionId.length).toBeGreaterThan(8); + } + }); +}); + +// ── clearAdminSession ─────────────────────────────────────────────────── + +describe("clearAdminSession()", () => { + it("returns without throwing", async () => { + const { clearAdminSession } = await import( + "@/lib/water-admin-pin-auth" + ); + await expect(clearAdminSession()).resolves.toBeUndefined(); + }); +}); \ No newline at end of file