diff --git a/src/actions/water-log/auth.ts b/src/actions/water-log/auth.ts index 9c2c1e1..4d2f2c7 100644 --- a/src/actions/water-log/auth.ts +++ b/src/actions/water-log/auth.ts @@ -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>> } @@ -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 { +export async function requireFieldSession(): Promise { 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) => { diff --git a/src/actions/water-log/field.ts b/src/actions/water-log/field.ts index 9819b3c..b6af910 100644 --- a/src/actions/water-log/field.ts +++ b/src/actions/water-log/field.ts @@ -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 { - 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 { // ignore — the cookie is being deleted anyway } } - cookieStore.delete("wl_session"); + await clearSessionCookie(WL_SESSION_COOKIE); } export async function logoutWaterAdmin(): Promise { diff --git a/src/app/api/water-photo-upload/route.ts b/src/app/api/water-photo-upload/route.ts index cb7590f..e823bbd 100644 --- a/src/app/api/water-photo-upload/route.ts +++ b/src/app/api/water-photo-upload/route.ts @@ -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 }); } diff --git a/src/components/water/mobile/MobileWaterApp.tsx b/src/components/water/mobile/MobileWaterApp.tsx index d2eafed..2efe425 100644 --- a/src/components/water/mobile/MobileWaterApp.tsx +++ b/src/components/water/mobile/MobileWaterApp.tsx @@ -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("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. diff --git a/src/lib/water-admin-pin-auth.ts b/src/lib/water-admin-pin-auth.ts index b621bf6..5d04107 100644 --- a/src/lib/water-admin-pin-auth.ts +++ b/src/lib/water-admin-pin-auth.ts @@ -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 { - 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 { - 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 diff --git a/src/lib/water-log/brand.ts b/src/lib/water-log/brand.ts new file mode 100644 index 0000000..23584ff --- /dev/null +++ b/src/lib/water-log/brand.ts @@ -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"; \ No newline at end of file diff --git a/src/lib/water-log/session-server.ts b/src/lib/water-log/session-server.ts new file mode 100644 index 0000000..0f0afc8 --- /dev/null +++ b/src/lib/water-log/session-server.ts @@ -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 { + 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 { + 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 { + 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 { + return readSessionCookie(WL_SESSION_COOKIE); +} + +/** + * Convenience wrapper for the admin portal — reads `wl_admin_session`. + */ +export async function readAdminSessionId(): Promise { + return readSessionCookie(WL_ADMIN_SESSION_COOKIE); +} \ No newline at end of file diff --git a/src/lib/water-log/session.ts b/src/lib/water-log/session.ts new file mode 100644 index 0000000..57921a3 --- /dev/null +++ b/src/lib/water-log/session.ts @@ -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; +}; \ No newline at end of file