refactor(water-log): centralize brand + session primitives
Deploy to route.crispygoat.com / deploy (push) Successful in 4m19s
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:
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user