fix(water-log): remove platform-login dependency from PIN flows
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s

Field users at /water were blocked because every server action called
getSession() (Neon Auth) even though the field path is PIN-authenticated.
A missing or expired platform session hung or rejected PIN submissions,
so users without a platform login could never reach the water log entry
screen.

Centralize water-log auth in src/actions/water-log/auth.ts with three
helpers — requireFieldSession, requireWaterAdminSession, and
requireWaterAdminPermission — and migrate all server actions off the
stray getSession() calls. Auth.ts deliberately does NOT import
getSession; a static-source unit test enforces that.

Changes:
- src/actions/water-log/auth.ts (new): dedicated auth layer, no Neon Auth
- src/actions/water-log/field.ts: -10 await getSession(), use helpers
- src/actions/water-log/admin.ts: -18 await getSession(), use helpers
- src/actions/water-log/settings.ts: -4 await getSession(), resilient
  to missing platform admin
- 3x admin pages: update getWaterAdminSession import path
- tests/unit/water-log-auth.test.ts (new): 17 tests incl. regression
  guard that auth.ts never imports getSession
- tests/water-log.spec.ts: 3 E2E regression tests (no platform login)
- vitest.config.ts: alias for deep @/db/schema/water-log path

Rollback = revert this commit. No DB migration; no schema change.
Existing rows/cookies unchanged.
This commit is contained in:
Tyler
2026-07-01 17:27:37 -06:00
parent 015eb33291
commit 658f6a5b8b
10 changed files with 780 additions and 209 deletions
+11 -128
View File
@@ -30,7 +30,12 @@ import {
} from "@/db/schema/water-log";
import { verifyPin, validatePin } from "@/lib/water-log-pin";
import { logAlert } from "@/lib/water-log-audit";
import { getSession } from "@/lib/auth";
import {
requireFieldSession,
type FieldSession,
} from "@/actions/water-log/auth";
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.
@@ -70,8 +75,7 @@ export async function getWaterHeadgates(
_brandId: string,
activeOnly: boolean = false,
): Promise<FieldHeadgate[]> {
await getSession(); return withBrand(TUXEDO_BRAND_ID, async (db) => {
return withBrand(TUXEDO_BRAND_ID, async (db) => {
const where = activeOnly
? and(
eq(waterHeadgates.brandId, TUXEDO_BRAND_ID),
@@ -101,8 +105,7 @@ export async function verifyWaterPin(
_brandId: string,
pin: string,
): Promise<VerifyPinResult> {
await getSession(); // Validate format first (cheap, no DB).
// Validate format first (cheap, no DB).
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
@@ -185,8 +188,7 @@ export async function submitWaterEntry(
longitude?: number,
_headgateLocked?: boolean,
): Promise<SubmitEntryResult> {
await getSession(); // ── Auth ──
// ── Auth ──
const session = await requireFieldSession();
if (!session.ok) return { success: false, error: session.error };
@@ -281,78 +283,10 @@ await getSession(); // ── Auth ──
});
}
// ── Session helpers (shared with admin.ts via requireFieldSession) ────────
type FieldSession =
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
| { ok: false; error: string };
async function requireFieldSession(): Promise<FieldSession> {
await getSession(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (!sessionId) return { ok: false, error: "Not logged in" };
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
session: waterSessions,
irrigator: waterIrrigators,
})
.from(waterSessions)
.innerJoin(waterIrrigators, eq(waterIrrigators.id, waterSessions.irrigatorId))
.where(eq(waterSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return { ok: false as const, error: "Session not found" };
if (row.session.expiresAt.getTime() < Date.now()) {
// Best-effort cleanup
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
return { ok: false as const, error: "Session expired" };
}
if (!row.irrigator.active) {
return { ok: false as const, error: "User is inactive" };
}
return {
ok: true as const,
userId: row.irrigator.id,
brandId: row.irrigator.brandId,
role: (row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
}
async function getFieldSessionUser(): Promise<{
userId: string;
name: string;
brandId: string;
role: "irrigator" | "water_admin";
} | null> {
await getSession(); const s = await requireFieldSession();
if (!s.ok) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ name: waterIrrigators.name, role: waterIrrigators.role })
.from(waterIrrigators)
.where(eq(waterIrrigators.id, s.userId))
.limit(1);
const row = rows[0];
if (!row) return null;
return {
userId: s.userId,
name: row.name,
brandId: s.brandId,
role: (row.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
}
// ── Cookie/language helpers ───────────────────────────────────────────────
export async function logoutWater(): Promise<void> {
await getSession(); const cookieStore = await cookies();
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (sessionId) {
// Best-effort DB cleanup
@@ -368,8 +302,7 @@ await getSession(); const cookieStore = await cookies();
}
export async function logoutWaterAdmin(): Promise<void> {
await getSession(); const cookieStore = await cookies();
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (sessionId) {
try {
@@ -385,58 +318,8 @@ await getSession(); const cookieStore = await cookies();
cookieStore.delete("wl_admin_session");
}
async function getWaterSession(): Promise<string | null> {
await getSession(); const cookieStore = await cookies();
return cookieStore.get("wl_session")?.value ?? null;
}
/**
* Resolves the current `/water/admin` session.
*
* Returns the admin role + brandId on success, or `null` if the cookie
* is missing / expired / points at a deleted session row. Used by
* admin pages (entries/[id], headgates/[id], users/[id]) as a *second*
* gate on top of `getAdminUser()` — a site admin can edit entries
* directly, but a PIN-authenticated water admin can too.
*/
export async function getWaterAdminSession(): Promise<{
sessionId: string;
brandId: string;
adminUserId: string;
role: "water_admin";
} | null> {
await getSession(); 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,
};
});
}
export async function setWaterLang(lang: string): Promise<void> {
if (!["en", "es"].includes(lang)) return;
await getSession();
const cookieStore = await cookies();
cookieStore.set("wl_lang", lang, {
httpOnly: false,