refactor(water-log): replace PIN auth with self-contained cookie module
Deploy to route.crispygoat.com / deploy (push) Successful in 4m19s

The /water/admin PIN portal had a server-action implementation that
went through a pg transaction + cookies() interaction that 500'd for
users without a platform login. The fix attempts (move cookie set
outside the transaction, remove getAdminUser, add placeholder UUID)
made it brittle and hard to reason about.

This replaces the whole stack with a self-contained module:

  src/lib/water-admin-pin-auth.ts (new, 126 lines, no Neon Auth)

  - verifyAdminPin(pin)         — scrypt verify, sets wl_admin_session
                                  cookie to a random UUID
  - getAdminSession()           — read cookie, return session or null
  - clearAdminSession()         — delete cookie
  - hashPin (re-exported)       — same scrypt params as verifyPin

The /water/admin portal is Tuxedo-brand-specific (CLAUDE.md "Public
Storefront Architecture"), so the brand UUID is hardcoded. The cookie
is the session — no DB session table writes. Cookie presence =
authenticated. This means the /admin/water-log/* pages (entries,
headgates, users) can use the same `getAdminSession()` helper for
their "isWaterAdmin" branch — no more DB session lookup.

Deleted:
  - verifyWaterAdminPin server action in settings.ts (no callers)
  - requireWaterAdminSession / getWaterAdminSession from auth.ts
    (replaced by cookie-only check)
  - the cookie-outside-transaction dance (no longer needed)

Updated callers:
  - /api/water-admin-auth       — POST { pin } instead of { brandId, pin }
  - /water/admin                — uses new getAdminSession() helper
  - /water/admin/login          — no longer needs brandId prop
  - /admin/water-log/entries|headgates|users/[id] — same helper

Tests: 19 passing in water-log-auth.test.ts (3 new for the new module).
This commit is contained in:
Tyler
2026-07-01 18:31:57 -06:00
parent ca79896d5f
commit b966787daa
13 changed files with 338 additions and 505 deletions
+7 -6
View File
@@ -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
+16 -97
View File
@@ -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<Awaited<ReturnType<typeof getAdminUser>>> }
| { ok: false; error: string };
@@ -116,76 +105,6 @@ export async function requireFieldSession(): Promise<FieldSession> {
});
}
/**
* 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<AdminPinSession> {
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<AdminPinSessionInfo | null> {
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<SiteAdminPermission> {
const adminUser = await getAdminUser();
@@ -240,4 +159,4 @@ export async function requireWaterAdminPermission(): Promise<SiteAdminPermission
// the success-branch type narrow (non-nullable) so callers can read
// `auth.adminUser.foo` without an extra null check.
return { ok: true, adminUser: adminUser as NonNullable<typeof adminUser> };
}
}
+5 -14
View File
@@ -302,20 +302,11 @@ export async function logoutWater(): Promise<void> {
}
export async function logoutWaterAdmin(): Promise<void> {
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<void> {
+6 -101
View File
@@ -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;
}
@@ -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 (
@@ -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");
+2 -2
View File
@@ -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");
+12 -15
View File
@@ -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 },
);
}
}
}
@@ -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) {
+4 -7
View File
@@ -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 (
<div className="min-h-screen bg-slate-100 flex flex-col items-center justify-center p-6">
@@ -26,7 +23,7 @@ export default async function WaterAdminLoginPage() {
<h1 className="text-2xl font-bold text-slate-900">Water Log Admin</h1>
<p className="text-sm text-slate-500 mt-1">Enter your 4-digit admin PIN</p>
</div>
<WaterAdminPinClient brandId={TUXEDO_BRAND_ID} />
<WaterAdminPinClient />
</div>
</div>
);
+4 -7
View File
@@ -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}
/>
);
}
}
+126
View File
@@ -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<AdminSession | null> {
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<void> {
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 };
+150 -248
View File
@@ -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<typeof vi.fn> },
mockWithPlatformAdmin: null as unknown as ReturnType<typeof vi.fn>,
mockWithBrand: null as unknown as ReturnType<typeof vi.fn>,
mockGetAdminUser: null as unknown as ReturnType<typeof vi.fn>,
}));
state.mockWithPlatformAdmin = vi.fn(
async (fn: (db: typeof state.mockDb) => Promise<unknown>) => fn(state.mockDb),
);
state.mockWithBrand = vi.fn(
async (_brandId: string, fn: (db: typeof state.mockDb) => Promise<unknown>) =>
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<string> {
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();
});
});