Bug: every server action in src/actions/water-log/{field,admin,settings}.ts
calls await getSession() from @/lib/auth, which makes a network
round-trip to Neon Auth on every PIN-screen interaction. The water log
module is intentionally PIN-based and self-contained; the
getSession() calls are leftover copy-paste from site-admin patterns
and cause /water PIN submission to hang or fail for unauthenticated
users.
This spec removes the spurious getSession() calls, centralizes the
PIN-based auth helpers in a new src/actions/water-log/auth.ts, and
adds a regression-guard E2E test.
18 KiB
Water Log — remove platform-login dependency — 2026-07-01
Type: Bug fix + refactor (auth layer cleanup) Branch:
fix/water-log-no-platform-loginStatus: Draft — awaiting user review
1. Context
The Water Log module is PIN-based and self-contained: /water (irrigator)
and /water/admin/login (brand water admin) both authenticate via a
4-digit PIN that mints a wl_session or wl_admin_session cookie. Neither
surface is meant to require a platform admin (Neon Auth) login.
The middleware already agrees — src/proxy.ts lists /water and
/api/water-photo-upload among the public routes. The /water page
itself loads with no cookie and shows the language → role → PIN flow.
But every server action in src/actions/water-log/{field,admin,settings}.ts
opens with await getSession() from @/lib/auth. That call hits Neon
Auth on every PIN-screen interaction. In production:
- When
NEON_AUTH_BASE_URLis misconfigured,getSession()short-circuits to{ data: null, error: null }and the rest of the action runs. - When it is configured, the wrapper falls through to the real Neon Auth
getSession(), which (a) checks the request for a session cookie (none is present for/waterusers), (b) makes a network round-trip toNEON_AUTH_BASE_URLanyway, and (c) may hang or return an error if the Neon Auth service is slow / unhealthy / cross-region.
User-visible result: PIN submission hangs or fails on /water even
though the user is doing exactly what the design asks them to do — enter
a 4-digit PIN — and is correctly authenticated by the cookie they
receive in response.
The root cause is leftover copy-paste from the site-admin actions, which
do legitimately call getSession() because they sit behind
/admin/water-log/* and are gated by admin_users. The field and
admin-PIN actions have no such dependency; the getSession() lines are
spurious and should be removed.
2. Goals
/waterPIN entry works with no platform login, no Neon Auth call, nodev_sessioncookie. A ditch rider in the field can complete the full language → role → PIN → entry flow with no cookies set on the request other than thewl_sessionminted on success./water/admin/loginworks the same way for the brand water admin portal.- The site-admin
/admin/water-log/*surface is unchanged — it continues to require a platform admin (Neon Auth +admin_users). - No DB migration, no schema change, no env-var change. Deploy is a pure code change.
- Regression guard: an E2E test runs a fresh, cookie-free Playwright
context against
/waterand proves the PIN flow works.
3. Non-goals
- No change to admin-permissions, Neon Auth, or the PIN hashing library.
- No change to middleware, page components, or the existing API routes (all of which are already clean — see §6).
- No refactor of the irrigation-season / reporting / CSV layers.
- No change to the photo-upload or QR-code APIs.
- No attempt to enforce brand_id on the field PIN flow beyond the existing
data model (irrigators carry a
brand_id, andwl_sessionis keyed byirrigator_id, so brand isolation is preserved by the join).
4. Architecture
4.1 Three auth gates, three helpers
Every water-log server action is gated by exactly one helper, matched to its consumer:
| Surface | Cookie | Helper | Backed by |
|---|---|---|---|
/water (irrigator) |
wl_session |
requireFieldSession() |
cookies() + water_sessions row |
/water/admin/* (brand water admin) |
wl_admin_session |
requireWaterAdminSession() |
cookies() + water_admin_sessions row |
/admin/water-log/* (site admin) |
Neon Auth | requireWaterAdminPermission() |
getAdminUser() + admin_users.can_manage_water_log |
The three require* helpers in auth.ts never call getSession().
The two PIN-only helpers (requireFieldSession, requireWaterAdminSession)
read the cookie directly, join the cookie value to the appropriate
session row in Postgres, and return. No network round-trip, no Neon
Auth dependency, no platform-admin requirement.
The site-admin helper (requireWaterAdminPermission) keeps using
getAdminUser() because that is the correct auth check for
/admin/water-log/* (which sits behind Neon Auth in the middleware
and on the server).
Note: the action
verifyWaterAdminPin(insettings.ts) does make a best-effortgetAdminUser()call after a successful PIN check, but only to attach a platform-adminadminUserIdto the newwater_admin_sessionsrow for audit. It is wrapped in try/catch and falls back to a zero-UUID placeholder. Thewl_admin_sessionis valid either way — that helper call is not an auth gate.
4.2 New file: src/actions/water-log/auth.ts
Centralized auth layer for the water-log module. Holds the three helpers plus small typed return shapes.
// src/actions/water-log/auth.ts (sketch)
import "server-only";
import { cookies } from "next/headers";
import { and, eq } from "drizzle-orm";
import { withPlatformAdmin } from "@/db/client";
import {
waterSessions, waterIrrigators,
waterAdminSessions, waterAdminSettings,
} from "@/db/schema/water-log";
import { getAdminUser } from "@/lib/admin-permissions";
type FieldSession =
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
| { ok: false; error: string };
type AdminPinSession =
| { ok: true; sessionId: string; brandId: string; adminUserId: string }
| { ok: false; error: string };
type SiteAdminPermission =
| { ok: true; adminUser: AdminUser }
| { ok: false; error: string };
/** Read wl_session, look up the row + irrigator. No Neon Auth. */
export async function requireFieldSession(): Promise<FieldSession> { ... }
/** Read wl_admin_session, look up the row. No Neon Auth. */
export async function requireWaterAdminSession(): Promise<AdminPinSession> { ... }
/** Site-admin gate: getAdminUser() + can_manage_water_log. */
export async function requireWaterAdminPermission(): Promise<SiteAdminPermission> { ... }
/** Read the active water-admin session row (or null). Cheap, idempotent. */
export async function getWaterAdminSession(): Promise<AdminPinSession | null> { ... }
/** Read the active field session row (or null). Cheap, idempotent. */
export async function getFieldSessionUser(): Promise<{ userId; name; brandId; role } | null> { ... }
The helpers are exact ports of the existing local helpers in
field.ts and admin.ts — behavior is unchanged, only the import path
moves.
4.3 File-by-file changes
src/actions/water-log/field.ts
- Drop
import { getSession } from "@/lib/auth". - Drop every
await getSession();line (10 occurrences). - Move
requireFieldSession,getFieldSessionUser,getWaterAdminSession,getWaterSessionintoauth.ts. Import from there. setWaterLang()keepscookies()only.verifyWaterPin,getWaterHeadgates,submitWaterEntry,logoutWater,logoutWaterAdminall remain — no behavior change other than dropping the spuriousgetSession()calls.
src/actions/water-log/admin.ts
- Drop
import { getSession } from "@/lib/auth". - Drop every
await getSession();line (18 occurrences). - Move
requireWaterAdminSessionandrequireWaterAdminPermissionintoauth.ts. Import from there. - Action-by-action gate stays as today: mutations/reads in
admin.tsuserequireWaterAdminPermission()because every action that lives in this file is invoked from/admin/water-log/*(site-admin path).
src/actions/water-log/settings.ts
- Drop
import { getSession } from "@/lib/auth". - Drop every
await getSession();line (4 occurrences, including the one inverifyWaterAdminPin). getWaterAdminSettings,updateWaterAdminSettings,regenerateAdminPinkeep their existinggetAdminUser()gate (site-admin path).verifyWaterAdminPinbecomes a pure PIN-only flow: validate format, look upwater_admin_settingsfor the brand, verify PIN, mintwl_admin_sessioncookie. NogetSession()call.getAdminUser()is still called inside the success path to attach anadminUserIdto the new session row for audit — wrapped in try/catch, falls back to a zero-UUID placeholder if no admin user is signed in. The session itself is valid either way.
4.4 Why verifyWaterAdminPin is a special case
verifyWaterAdminPin is the most-affected function in this PR. Today:
- It calls
await getSession()(Neon Auth) — drop. - It calls
getAdminUser()inside the success path to attach the caller's platform-admin user to the newwl_admin_sessionrow for audit. This is correct and stays, but the call is now wrapped in a try/catch that swallows any error and falls back to a zero-UUIDadminUserId. Thewl_admin_sessionis still valid in either case — the cookie holds the brand + session id, not the admin user.
This is the right shape because the water-admin PIN flow is for ditch riders / brand water admins, who often aren't platform admins. Tying the brand-admin session to a platform-admin user is convenient for audit, not required for the flow to work.
5. Data flow
5.1 verifyWaterPin (irrigator)
client POST via server action: verifyWaterPin(brandId, pin)
└─ validatePin(pin) ── format check
└─ withPlatformAdmin(db => ...) ── look up irrigator
WHERE active = true ── across all brands
└─ match = irrigators.find(i => verifyPin(...)) ── scrypt compare
└─ if no match: 200ms delay, return error
└─ withBrand(brandId, db => ...)
INSERT water_sessions (irrigator_id, expires_at = now+8h)
UPDATE water_irrigators SET last_used_at
└─ cookies().set("wl_session", sessionId, ...)
└─ return { success: true, role, name, ... }
No getSession(). No getAdminUser(). No network call beyond the local
DB round-trips.
5.2 verifyWaterAdminPin (brand water admin)
client POST via server action: verifyWaterAdminPin(brandId, pin)
└─ validatePin(pin)
└─ withBrand(brandId, db => ...)
SELECT water_admin_settings WHERE brand_id
if !settings or !settings.enabled or !settings.pinHash → return error
if !verifyPin(pin, settings.pinHash) → 200ms delay, return error
try { adminUser = await getAdminUser() } catch { adminUser = null }
INSERT water_admin_sessions (brand_id, admin_user_id, expires_at)
└─ cookies().set("wl_admin_session", sessionId, ...)
└─ return { success: true, session_id }
5.3 submitWaterEntry (irrigator mutation)
client POST via server action: submitWaterEntry(...)
└─ requireFieldSession() → { ok: false, error } or { ok: true, userId, brandId }
└─ validate measurement / notes / lat / lng
└─ withBrand(brandId, db => ...)
SELECT headgate (verify it belongs to brand)
INSERT water_log_entries
UPDATE water_headgates SET last_used_at
(optional) logAlert() if threshold breach
└─ return { success: true, entry_id }
If requireFieldSession() returns ok: false, the action returns
{ success: false, error: "Session expired or invalid" }. The client
(WaterFieldClient) already handles this: it resets to the language
step and shows the "Session expired" message. No change to that
client behavior is needed.
6. Error handling
| Failure | Helper return | Caller return | Client behavior |
|---|---|---|---|
No wl_session cookie |
{ ok: false, error: "Not logged in" } |
{ success: false, error: "Session expired or invalid" } |
Reset to language step |
wl_session cookie but no matching row |
same as above | same | same |
Session row past expires_at |
same as above (best-effort delete) | same | same |
Irrigator row marked active = false |
{ ok: false, error: "User is inactive" } |
{ success: false, error: "User is inactive" } |
Reset to language step |
wl_admin_session missing / expired |
null (from getWaterAdminSession) |
{ success: false, error: "Not signed in" } |
Bounce to /water/admin/login |
| Site admin not signed in | { ok: false, error: "Not signed in" } |
{ success: false, error: ... } |
Toast + stay |
Site admin signed in but can_manage_water_log false |
{ ok: false, error: "Not permitted" } |
{ success: false, error: "Forbidden" } |
Toast + redirect |
7. Testing
7.1 Unit (tests/unit/water-log-auth.test.ts, new)
Each helper, no network:
-
requireFieldSession():- Returns
{ ok: false, error: "Not logged in" }when no cookie. - Returns
{ ok: false, error: "Session expired" }when cookie points at a row withexpires_atin the past. - Returns
{ ok: false, error: "Session not found" }when cookie has no matching row. - Returns
{ ok: false, error: "User is inactive" }when irrigatoractive = false. - Returns
{ ok: true, userId, brandId, role }on happy path. - Asserts no
getSessionimport is reachable from this module (literalimport.meta.globor a vitest module-mock trick).
- Returns
-
requireWaterAdminSession():- Returns
{ ok: false, error: "Not signed in" }when no cookie. - Returns
{ ok: false, error: "Session expired" }when past expiry. - Returns
{ ok: true, ... }on happy path. - No
getSessionimport reachable.
- Returns
-
requireWaterAdminPermission():- Mock
getAdminUserto returnnull→{ ok: false, error: "Not signed in" }. - Mock
getAdminUserto return user withoutcan_manage_water_log→{ ok: false, error: "Not permitted" }. - Mock to return platform_admin →
{ ok: true, ... }. - This helper is allowed to call
getAdminUser()— that is its defined behavior. No "no getSession" assertion here.
- Mock
7.2 E2E (tests/water-log.spec.ts, new block)
New test.describe("Water Log — no platform login required"):
test("GET /water renders the PIN flow with no cookies set", async ({ browser }) => {
// Fresh context: no storageState, no cookies, no dev_session.
const ctx = await browser.newContext();
const page = await ctx.newPage();
const res = await page.goto(`${BASE}/water`);
expect(res?.status()).toBe(200);
// The page goes through language → role → pin before showing the input.
// We assert it never 302s to /login.
const allResponses: string[] = [];
page.on("response", (r) => allResponses.push(r.url()));
// Click through the language step
await page.getByRole("button", { name: /english/i }).click();
// Click the "Irrigator" button (the lighter one)
await page.getByRole("button", { name: /irrigator/i }).click();
// PIN input is now visible
const pin = page.locator('input[type="password"][inputmode="numeric"]').first();
await expect(pin).toBeVisible({ timeout: 10_000 });
// Confirm we never hit /login
expect(allResponses.filter((u) => u.includes("/login"))).toHaveLength(0);
await ctx.close();
});
test("setWaterLang server action does not require auth", async ({ request }) => {
// Empty storage state, no cookies.
const ctx = await request.newContext({ baseURL: BASE, storageState: { cookies: [], origins: [] } });
// We don't have a direct POST endpoint for setWaterLang; instead hit
// the page and check the language cookie gets set without a redirect.
const res = await ctx.get("/water");
expect(res.status()).toBe(200);
await ctx.dispose();
});
The existing tests/water-log.spec.ts blocks for the /water/admin/login
and /admin/water-log redirects remain — they continue to pass because
we only relax the field and admin-PIN paths, not the site-admin path.
7.3 Manual smoke (post-deploy)
- Open
/waterin a private window. Confirm language → role → PIN. - Enter a wrong PIN — confirm error toast, no redirect to
/login. - Enter a correct PIN — confirm the entry form loads with headgates.
- Submit an entry — confirm the success banner.
- Open
/water/admin/loginin another private window. Same flow. - Sign in as a site admin in a third window, navigate to
/admin/water-log— confirm it still requires the platform login.
8. Out of scope (explicit)
- No DB migration. (
water_sessions,water_admin_sessions,water_admin_settingsalready have all columns the auth helpers read.) - No new RPC. The auth helpers run direct Drizzle queries against the pool.
- No env-var changes.
- No middleware change.
/waterand the water API routes are already inPUBLIC_ROUTES. - No change to PIN hashing (
src/lib/water-log-pin.tsis correct). - No change to the photo-upload route — it already uses the cookie.
- No change to QR-code routes — they're truly public.
- No change to
/api/water-logs/exportor/api/v1/water-logs— both are correctly site-admin-gated viagetAdminUser()and belong on the platform login.
9. Rollout
- Single branch
fix/water-log-no-platform-login, single PR. - Merge → push to
origin/main→.gitea/workflows/deploy.ymlruns the normal build + migration gate (no migration in this change). - Existing
wl_session/wl_admin_sessioncookies continue to work. - No data backfill.
- Rollback = revert the commit. The
getSession()calls being removed were inert in practice (just slow / occasionally failing), so any caller that worked before this change will continue to work after.
10. Open questions
None at design time. Decisions taken:
- Helpers move to
auth.ts, not split across files. verifyWaterAdminPinkeeps the best-effortgetAdminUser()call for audit, wrapped in try/catch.getWaterAdminSettings/updateWaterAdminSettings/regenerateAdminPinstay site-admin-gated (these mutate brand-level settings, which is a site-admin action — the brand water admin does not manage them).- Field session type union is preserved verbatim from current
field.ts.