# Water Log — remove platform-login dependency — 2026-07-01

> Type: Bug fix + refactor (auth layer cleanup)
> Branch: `fix/water-log-no-platform-login`
> Status: 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_URL` is 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 `/water` users), (b) makes a network round-trip to
  `NEON_AUTH_BASE_URL` anyway, 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

- **`/water` PIN entry works with no platform login, no Neon Auth call,
  no `dev_session` cookie.** A ditch rider in the field can complete the
  full language → role → PIN → entry flow with no cookies set on the
  request other than the `wl_session` minted on success.
- **`/water/admin/login` works 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 `/water` and 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`, and `wl_session` is keyed by
  `irrigator_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` (in `settings.ts`) is now a
> fully PIN-only flow — it does NOT call `getAdminUser()` or
> `getSession()` at all. The `adminUserId` on the new
> `water_admin_sessions` row is always the zero-UUID placeholder. This
> is because `/water/admin/login` is the Tuxedo-brand-specific entry
> point and must work for users who are not signed into the platform.
> Audit attribution is intentionally deferred to a future ticket.

### 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.

```ts
// 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`,
  `getWaterSession` into `auth.ts`. Import from there.
- `setWaterLang()` keeps `cookies()` only.
- `verifyWaterPin`, `getWaterHeadgates`, `submitWaterEntry`, `logoutWater`,
  `logoutWaterAdmin` all remain — no behavior change other than
  dropping the spurious `getSession()` calls.

**`src/actions/water-log/admin.ts`**
- Drop `import { getSession } from "@/lib/auth"`.
- Drop every `await getSession();` line (18 occurrences).
- Move `requireWaterAdminSession` and `requireWaterAdminPermission` into
  `auth.ts`. Import from there.
- Action-by-action gate stays as today: mutations/reads in `admin.ts`
  use `requireWaterAdminPermission()` 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 in `verifyWaterAdminPin`).
- `getWaterAdminSettings`, `updateWaterAdminSettings`, `regenerateAdminPin`
  keep their existing `getAdminUser()` gate (site-admin path).
- `verifyWaterAdminPin` becomes a pure PIN-only flow: validate format,
  look up `water_admin_settings` for the brand, verify PIN, mint
  `wl_admin_session` cookie. **No `getSession()` call. No
  `getAdminUser()` call.** `adminUserId` is always the zero-UUID
  placeholder. The page is the Tuxedo-brand-specific entry point and
  must work without a platform login.

### 4.4 Why `verifyWaterAdminPin` is a special case

`verifyWaterAdminPin` is the most-affected function in this PR. Today:

1. It called `await getSession()` (Neon Auth) — dropped.
2. It called `getAdminUser()` inside the success path to attach the
   caller's platform-admin user to the new `wl_admin_session` row for
   audit. That call was wrapped in a try/catch that fell back to a
   zero-UUID `adminUserId`, but the underlying `getSession()` call
   inside `getAdminUser()` could still corrupt the Next.js cookie
   store and cause the subsequent `cookies().set("wl_admin_session",
   ...)` to throw — surfacing as a 500 to users without a platform
   login. The call is now **fully removed**; `adminUserId` is always
   the zero-UUID placeholder. Audit attribution is a follow-up.

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 with `expires_at` in the past.
  - Returns `{ ok: false, error: "Session not found" }` when cookie has
    no matching row.
  - Returns `{ ok: false, error: "User is inactive" }` when irrigator
    `active = false`.
  - Returns `{ ok: true, userId, brandId, role }` on happy path.
  - **Asserts no `getSession` import is reachable from this module**
    (literal `import.meta.glob` or a vitest module-mock trick).

- `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 `getSession` import reachable.

- `requireWaterAdminPermission()`:
  - Mock `getAdminUser` to return `null` → `{ ok: false, error: "Not signed in" }`.
  - Mock `getAdminUser` to return user without `can_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.

### 7.2 E2E (`tests/water-log.spec.ts`, new block)

New `test.describe("Water Log — no platform login required")`:

```ts
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)

1. Open `/water` in a private window. Confirm language → role → PIN.
2. Enter a wrong PIN — confirm error toast, no redirect to `/login`.
3. Enter a correct PIN — confirm the entry form loads with headgates.
4. Submit an entry — confirm the success banner.
5. Open `/water/admin/login` in another private window. Same flow.
6. 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_settings` already 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. `/water` and the water API routes are already
  in `PUBLIC_ROUTES`.
- No change to PIN hashing (`src/lib/water-log-pin.ts` is 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/export` or `/api/v1/water-logs` —
  both are correctly site-admin-gated via `getAdminUser()` 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.yml` runs
  the normal build + migration gate (no migration in this change).
- Existing `wl_session` / `wl_admin_session` cookies 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.
- `verifyWaterAdminPin` keeps the best-effort `getAdminUser()` call for
  audit, wrapped in try/catch.
- `getWaterAdminSettings` / `updateWaterAdminSettings` / `regenerateAdminPin`
  stay 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`.
