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 };