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