refactor(water-log): replace PIN auth with self-contained cookie module
Deploy to route.crispygoat.com / deploy (push) Successful in 4m19s
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:
@@ -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 };
|
||||
Reference in New Issue
Block a user