refactor(water-log): centralize brand + session primitives
Deploy to route.crispygoat.com / deploy (push) Successful in 4m19s

Phase 1 of the water-log auth refactor. No behavior change.

NEW:
- src/lib/water-log/brand.ts       — single source of TUXEDO_BRAND_ID
- src/lib/water-log/session.ts     — cookie name constants + shared types
                                      (FieldSession, AdminSession, FieldSessionAuthed,
                                      FieldSessionResult). Safe to import from client
                                      components.
- src/lib/water-log/session-server.ts — server-only cookie I/O helpers
                                          (setSessionCookie, clearSessionCookie,
                                          readSessionCookie).

MODIFIED:
- src/lib/water-admin-pin-auth.ts   — re-export TUXEDO_BRAND_ID, use helpers
- src/actions/water-log/auth.ts     — use shared cookie constant + types
- src/actions/water-log/field.ts    — drop local TUXEDO_BRAND_ID copy
- src/app/api/water-photo-upload/route.ts — use shared cookie names
- src/components/water/mobile/MobileWaterApp.tsx — drop local
                                                    TUXEDO_BRAND_ID copy;
                                                    use string ops over
                                                    regex for cookie peek.

FieldSession retains its union shape (back-compat) — FieldSessionAuthed
is the narrowed success branch.
This commit is contained in:
Tyler
2026-07-02 10:54:12 -06:00
parent 69d8f829f1
commit c3da54af50
8 changed files with 230 additions and 43 deletions
+12 -5
View File
@@ -34,12 +34,19 @@ import {
waterIrrigators,
} from "@/db/schema/water-log";
import { getAdminUser } from "@/lib/admin-permissions";
import {
WL_SESSION_COOKIE,
type FieldSession,
type FieldSessionResult,
} from "@/lib/water-log/session-server";
// ── Result types ────────────────────────────────────────────────────────
export type FieldSession =
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
| { ok: false; error: string };
// `FieldSession` is now defined in `@/lib/water-log/session.ts` so the
// `FieldSession | { ok: false, error }` shape is shared with
// `lib/water-log/pin-login.ts` (Phase 2) and the rest of the module
// surface. Re-export for back-compat with existing call sites.
export type { FieldSession };
export type SiteAdminPermission =
| { ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> }
@@ -63,9 +70,9 @@ export type SiteAdminPermission =
* Never calls `getSession()`. Never calls `getAdminUser()`. The only
* network round-trip is the local DB query.
*/
export async function requireFieldSession(): Promise<FieldSession> {
export async function requireFieldSession(): Promise<FieldSessionResult> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
const sessionId = cookieStore.get(WL_SESSION_COOKIE)?.value;
if (!sessionId) return { ok: false, error: "Not logged in" };
return withPlatformAdmin(async (db) => {
+13 -11
View File
@@ -34,13 +34,21 @@ import {
requireFieldSession,
type FieldSession,
} from "@/actions/water-log/auth";
import {
WL_SESSION_COOKIE,
clearSessionCookie,
readSessionCookie,
setSessionCookie,
} from "@/lib/water-log/session-server";
import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand";
// Re-export so existing call sites that import from
// `@/actions/water-log/field` keep working.
export type { FieldSession };
// Field sessions last 8h — a working day in the field. Long enough
// that a single sign-in covers a morning shift + afternoon shift.
const FIELD_SESSION_HOURS = 8;
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -177,13 +185,8 @@ export async function verifyWaterPin(
// Cookie set is fully outside the DB transaction. Same rule as
// `verifyAdminPin` (see comment in `src/lib/water-admin-pin-auth.ts`).
const cookieStore = await cookies();
cookieStore.set("wl_session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: FIELD_SESSION_HOURS * 3600,
path: "/",
await setSessionCookie(WL_SESSION_COOKIE, sessionId, {
hours: FIELD_SESSION_HOURS,
});
return {
@@ -306,8 +309,7 @@ export async function submitWaterEntry(
// ── Cookie/language helpers ───────────────────────────────────────────────
export async function logoutWater(): Promise<void> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
const sessionId = await readSessionCookie(WL_SESSION_COOKIE);
if (sessionId) {
// Best-effort DB cleanup
try {
@@ -318,7 +320,7 @@ export async function logoutWater(): Promise<void> {
// ignore — the cookie is being deleted anyway
}
}
cookieStore.delete("wl_session");
await clearSessionCookie(WL_SESSION_COOKIE);
}
export async function logoutWaterAdmin(): Promise<void> {
+7 -1
View File
@@ -1,12 +1,18 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { uploadObject, BUCKETS } from "@/lib/storage";
import {
WL_ADMIN_SESSION_COOKIE,
WL_SESSION_COOKIE,
} from "@/lib/water-log/session";
export async function POST(request: Request) {
try {
// Validate session — irrigators use wl_session, admins use wl_admin_session
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value ?? cookieStore.get("wl_admin_session")?.value;
const sessionId =
cookieStore.get(WL_SESSION_COOKIE)?.value ??
cookieStore.get(WL_ADMIN_SESSION_COOKIE)?.value;
if (!sessionId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -39,8 +39,8 @@ import { MobileWaterPinScreen } from "./PinScreen";
import { MobileWaterHeadgateList } from "./HeadgateList";
import { MobileWaterLogForm } from "./LogForm";
import { MobileWaterSuccessScreen } from "./SuccessScreen";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
import { WL_SESSION_COOKIE } from "@/lib/water-log/session";
import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand";
export function MobileWaterApp() {
const [screen, setScreen] = useState<Screen>("pin");
@@ -79,7 +79,9 @@ export function MobileWaterApp() {
// so SSR markup matches (avoids hydration warnings). ────────
useEffect(() => {
if (typeof document === "undefined") return;
const hasSession = document.cookie.match(/wl_session=([^;]+)/);
const hasSession = document.cookie
.split(";")
.some((c) => c.trim().startsWith(`${WL_SESSION_COOKIE}=`));
if (!hasSession) return;
// Wrap setScreen in a microtask so the call doesn't fire
// synchronously inside the effect body.
+19 -23
View File
@@ -24,25 +24,28 @@
* - `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";
import {
WL_ADMIN_SESSION_COOKIE,
type AdminSession,
clearSessionCookie,
readSessionCookie,
setSessionCookie,
} from "@/lib/water-log/session-server";
/** Tuxedo brand UUID — hardcoded because this portal is Tuxedo-only. */
export const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
// Re-export for back-compat — `TUXEDO_BRAND_ID` is now defined in
// `@/lib/water-log/brand.ts`. The water-log folder is the single
// source of truth for the Tuxedo-only hardcoding.
import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand";
export { TUXEDO_BRAND_ID };
/** 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
@@ -88,16 +91,11 @@ export async function verifyAdminPin(
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: "/",
// Cookie set is OUTSIDE the DB transaction. (cookies().set() corrupts
// Next.js's request-scoped AsyncLocalStorage if called from inside a
// pg transaction — see commit ca79896 and `lib/water-log/session.ts`.)
await setSessionCookie(WL_ADMIN_SESSION_COOKIE, sessionId, {
hours: sessionDurationHours,
});
return { ok: true, session: { sessionId, brandId: TUXEDO_BRAND_ID } };
}
@@ -108,16 +106,14 @@ export async function verifyAdminPin(
* 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;
const sessionId = await readSessionCookie(WL_ADMIN_SESSION_COOKIE);
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");
await clearSessionCookie(WL_ADMIN_SESSION_COOKIE);
}
// Re-export `hashPin` so callers (e.g. a future "set PIN" UI) can
+14
View File
@@ -0,0 +1,14 @@
/**
* Water Log — brand constants.
*
* The water-log module is currently single-tenant (Tuxedo only — see
* CLAUDE.md "Public Storefront Architecture"). That hardcoding is
* intentional, but having the UUID duplicated across files invites
* drift and makes it easy to forget the constant when adding a new
* code path. This file is the single source of truth.
*
* Lifting this to multi-tenant will mean replacing every read of
* `TUXEDO_BRAND_ID` with `effectiveBrandId` from `getAdminUser()` —
* which is the standard pattern used throughout the rest of the app.
*/
export const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
+100
View File
@@ -0,0 +1,100 @@
/**
* Water Log — server-side cookie I/O.
*
* Companion to `./session.ts` (which holds the cookie names + types).
* **Server-only** — every helper calls `cookies()` from
* `next/headers`, which is not available in client components.
*
* The "cookie set must happen outside the pg transaction" invariant
* (documented in commit `ca79896` and `src/lib/water-admin-pin-auth.ts`)
* is preserved by the shape of `setSessionCookie` and
* `clearSessionCookie` below — they are pure cookie I/O and never
* touch a database.
*/
import "server-only";
import { cookies } from "next/headers";
import {
WL_ADMIN_SESSION_COOKIE,
WL_SESSION_COOKIE,
type SessionCookieName,
} from "./session";
export { WL_SESSION_COOKIE, WL_ADMIN_SESSION_COOKIE } from "./session";
export type {
SessionCookieName,
FieldSession,
FieldSessionError,
FieldSessionResult,
AdminSession,
} from "./session";
export type SessionCookieOptions = {
/** Lifetime in hours. Translated to `maxAge` in seconds for `cookies.set`. */
hours: number;
/** Optional override for `secure`. Defaults to NODE_ENV === "production". */
secure?: boolean;
};
const DEFAULT_COOKIE_PATH = "/";
/**
* Set a session cookie. **Must be called outside any pg transaction.**
*
* Calling `cookies().set()` from inside a pg transaction corrupts
* Next.js's request-scoped AsyncLocalStorage — that's the bug that
* commit `ca79896` fixed in the admin portal and that the field portal
* is being brought into line with.
*/
export async function setSessionCookie(
name: SessionCookieName,
value: string,
opts: SessionCookieOptions,
): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(name, value, {
httpOnly: true,
secure: opts.secure ?? process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: opts.hours * 3600,
path: DEFAULT_COOKIE_PATH,
});
}
/** Clear a session cookie. Idempotent. Safe to call inside or outside a tx. */
export async function clearSessionCookie(name: SessionCookieName): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(name);
}
/**
* Read the value of a session cookie (or null). Read-only.
*
* Note: `httpOnly` cookies set via `setSessionCookie` are NOT visible
* to `document.cookie` in the browser. This is intentional — it means
* XSS can't steal the session token. Client components that need to
* know "is the user signed in?" must round-trip a server action,
* not sniff `document.cookie`.
*/
export async function readSessionCookie(
name: SessionCookieName,
): Promise<string | null> {
const cookieStore = await cookies();
return cookieStore.get(name)?.value ?? null;
}
/**
* Convenience wrapper for the "is the user signed in?" check on the
* field portal — reads `wl_session`. Returns the session id (the
* `water_sessions.id` row UUID) or null.
*/
export async function readFieldSessionId(): Promise<string | null> {
return readSessionCookie(WL_SESSION_COOKIE);
}
/**
* Convenience wrapper for the admin portal — reads `wl_admin_session`.
*/
export async function readAdminSessionId(): Promise<string | null> {
return readSessionCookie(WL_ADMIN_SESSION_COOKIE);
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Water Log — session constants + shared types.
*
* This file is **safe to import from client components** — it has
* no server-only side effects (no `next/headers`, no `cookies()`).
* It just exports the cookie name strings and the public session
* result types so both client and server code can refer to them.
*
* The actual cookie I/O helpers (`setSessionCookie`,
* `clearSessionCookie`, `readSessionCookie`) live in
* `./session-server.ts` because they call `cookies()` from
* `next/headers`, which is server-only.
*
* Two session cookies live in this module's surface area:
*
* - `wl_session` — DB-backed row in `water_sessions`. Set by
* `verifyWaterPin` on a successful irrigator
* PIN. Server-side expiry is enforced by
* `requireFieldSession`.
*
* - `wl_admin_session` — cookie-only, no DB row. Set by
* `verifyAdminPin` on a successful admin PIN.
* Cookie expiry (`maxAge`) is the source of
* truth for session lifetime.
*/
export const WL_SESSION_COOKIE = "wl_session";
export const WL_ADMIN_SESSION_COOKIE = "wl_admin_session";
export type SessionCookieName =
| typeof WL_SESSION_COOKIE
| typeof WL_ADMIN_SESSION_COOKIE;
/**
* `FieldSession` is the discriminated union result of `requireFieldSession()`.
* Use `FieldSessionAuthed` (the `ok: true` branch) when you've already
* narrowed via the `ok` discriminator. The legacy shape — where
* `FieldSession` itself was the union — is preserved here for callers
* that imported it from `actions/water-log/auth.ts`.
*/
export type FieldSessionError = { ok: false; error: string };
export type FieldSession = FieldSessionAuthed | FieldSessionError;
/** Narrowed success-branch type, useful for parameter types after a guard. */
export type FieldSessionAuthed = {
ok: true;
userId: string;
brandId: string;
role: "irrigator" | "water_admin";
};
/** Back-compat alias for the union. Identical to `FieldSession`. */
export type FieldSessionResult = FieldSession;
/** Admin session (cookie-only bearer token). */
export type AdminSession = {
sessionId: string;
brandId: string;
};