Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s

Completes the Tuxedo Water Log feature end-to-end. Builds on the existing
water_* tables in 0001_init.sql; extends them with 0090 and rewrites the
server actions, settings page, export route, and admin panel to actually
function.

Schema (db/migrations/0090_water_log_completion.sql, db/schema/water-log.ts)
- headgate_token + status + max_flow_gpm + thresholds + notes on water_headgates
- role + phone + notes on water_irrigators
- method + total_gallons + photo_url + logged_date + lat/lon on water_log_entries
- new tables: water_admin_settings, water_admin_sessions, water_audit_log, water_alert_log

Auth (src/lib/water-log-pin.ts, src/lib/water-log-audit.ts)
- scrypt N=16384 per-PIN random salt, self-describing hash format
- weak-PIN detection (repeats, monotonic, palindromes)
- 200ms delay + timingSafeEqual on failed verify
- 4-8 digit PINs, generatePin() rejects weak patterns

Server actions (src/actions/water-log/{admin,field,settings}.ts)
- full Drizzle CRUD: headgates, irrigators, entries (with audit hooks)
- verifyPin + 8h field sessions, admin PIN + configurable 1-168h sessions
- regenerateAdminPin invalidates existing admin sessions
- threshold breach detection + alert log writes
- getWaterAdminSession() helper for admin pages

API routes
- /api/water-admin-auth: PIN-protected, generic error (no enum leak)
- /api/water-logs/export: admin-gated JSON/CSV, correct headers

UI (Field Almanac — cream + forest, Fraunces display, § section markers)
- src/components/admin/WaterLogAdminPanel.tsx: full rewrite
- src/components/water/WaterFieldClient.tsx: matches palette
- src/components/water/icons/{WaterGauge,SeasonMark}.tsx: custom SVGs
- src/app/admin/water-log/settings/page.tsx: full rewrite, Field Almanac

Tests
- tests/unit/water-log-pin.test.ts (21 cases): validate, hash, verify, generate
- tests/unit/water-log-reporting.test.ts (31 cases): season, filter, CSV, report
- tests/water-log.spec.ts (Playwright E2E): PIN form, auth gates, API gates

Docs
- docs/water-log.md: full admin guide, security model, data dictionary, checklist
- README: link to docs/water-log.md in admin modules list
- .env.example: Water Log section comment

Type-check: clean. Tests: 70 pass, 3 fail (3 pre-existing getAdminUser
failures on main, unrelated to this work).
This commit is contained in:
Tyler
2026-06-17 11:36:00 -06:00
parent 52c8a71cd0
commit 11cd2fd01a
22 changed files with 4944 additions and 1137 deletions
+82
View File
@@ -0,0 +1,82 @@
/**
* Water Log — small audit + alert helpers.
*
* Every mutating action in `actions/water-log/*` calls `logAuditEvent()`
* so a regulator or curious admin can answer "who deleted that headgate
* on Tuesday" without trawling server logs.
*/
import "server-only";
import { query } from "@/lib/db";
import { withBrand } from "@/db/client";
export type WaterAuditEvent = {
brandId: string;
actorLabel: string;
actorId?: string | null;
action: string;
entityType: "headgate" | "user" | "entry" | "settings" | "session";
entityId?: string | null;
details?: Record<string, unknown> | null;
};
/**
* Fire-and-forget audit insert. The withBrand wrapper sets the brand
* context so RLS lets the write through. We swallow errors so a broken
* audit log never blocks the user-facing action — but we always log to
* the console as a backstop.
*/
export async function logAuditEvent(ev: WaterAuditEvent): Promise<void> {
try {
await withBrand(ev.brandId, async () => {
await query(
`INSERT INTO water_audit_log
(brand_id, actor_id, actor_label, action, entity_type, entity_id, details)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
ev.brandId,
ev.actorId ?? null,
ev.actorLabel,
ev.action,
ev.entityType,
ev.entityId ?? null,
ev.details ? JSON.stringify(ev.details) : null,
],
);
});
} catch (err) {
// Audit must not break the user flow. Surface to console.
console.error("[water-log] audit insert failed", err, ev);
}
}
/**
* Log a high/low water alert. Currently this is a passive record — the
* scheduled job that actually sends SMS/WhatsApp can pick rows where
* `sent_at IS NULL`. Phase 2 wires up the sender.
*/
export async function logAlert(args: {
brandId: string;
headgateId: string | null;
alertType: "high" | "low";
reading: number;
threshold: number;
headgateName: string;
}): Promise<void> {
try {
await withBrand(args.brandId, async () => {
await query(
`INSERT INTO water_alert_log
(brand_id, alert_type, headgate_id, message)
VALUES ($1, $2, $3, $4)`,
[
args.brandId,
args.alertType,
args.headgateId,
`${args.alertType.toUpperCase()} alert on ${args.headgateName}: reading ${args.reading} crossed threshold ${args.threshold}`,
],
);
});
} catch (err) {
console.error("[water-log] alert insert failed", err, args);
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Water Log — PIN hashing.
*
* PINs in TYLER OS are 4-digit numerics that field workers type on a wet
* phone screen. That makes them both *very* low-entropy and *very*
* frequently entered. A slow KDF (scrypt) gives us a defense-in-depth
* layer: even if the database leaks, an attacker still has to do real
* work per guess.
*
* Format: `scrypt$N$r$p$salt_b64$hash_b64` — self-describing so we can
* upgrade the parameters later without forking the data. No third-party
* deps; Node's built-in `crypto.scryptSync` is plenty.
*/
import "server-only";
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
const N = 16384; // CPU/memory cost
const r = 8;
const p = 1;
const KEY_LEN = 32;
const SALT_LEN = 16;
export const PIN_MIN = 4;
export const PIN_MAX = 8;
/**
* Validate raw PIN format. Returns null on success, an error string on failure.
* Used by API entry points and unit tests alike.
*/
export function validatePin(rawPin: unknown): string | null {
if (typeof rawPin !== "string") return "PIN must be a string";
if (rawPin.length < PIN_MIN || rawPin.length > PIN_MAX) {
return `PIN must be ${PIN_MIN}${PIN_MAX} digits`;
}
if (!/^\d+$/.test(rawPin)) return "PIN must be numeric";
// Catch obviously weak PINs (1111, 1234, 0000, …). These are still
// possible to use, but we warn. We don't reject — the customer
// explicitly asked for simple PINs.
return null;
}
/** True if the PIN is "weak" (all-same, monotonic, or palindromic). */
export function isWeakPin(rawPin: string): boolean {
if (/^(.)\1+$/.test(rawPin)) return true; // 1111, 2222
if ("0123456789".includes(rawPin)) return true; // 1234, 5678
if ("9876543210".includes(rawPin)) return true; // 4321, 8765
if (rawPin === rawPin.split("").reverse().join("")) return true; // 1221
return false;
}
export function hashPin(rawPin: string): string {
const salt = randomBytes(SALT_LEN);
const hash = scryptSync(rawPin.normalize("NFKC"), salt, KEY_LEN, {
N,
r,
p,
});
return `scrypt$${N}$${r}$${p}$${salt.toString("base64")}$${hash.toString(
"base64",
)}`;
}
export function verifyPin(rawPin: string, stored: string): boolean {
if (typeof stored !== "string" || !stored.startsWith("scrypt$")) {
return false;
}
const parts = stored.split("$");
if (parts.length !== 6) return false;
const [, nStr, rStr, pStr, saltB64, hashB64] = parts;
const n = parseInt(nStr, 10);
const r = parseInt(rStr, 10);
const p = parseInt(pStr, 10);
if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) {
return false;
}
let salt: Buffer;
let expected: Buffer;
try {
salt = Buffer.from(saltB64, "base64");
expected = Buffer.from(hashB64, "base64");
} catch {
return false;
}
if (expected.length !== KEY_LEN) return false;
let candidate: Buffer;
try {
candidate = scryptSync(rawPin.normalize("NFKC"), salt, KEY_LEN, {
N: n,
r,
p,
});
} catch {
return false;
}
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
}
/** Generate a random 4-digit PIN. Used by the create-user action. */
export function generatePin(): string {
// Reject obviously weak PINs at generation time so the admin never has
// to hand a brand-new worker a PIN of "1234".
for (let attempt = 0; attempt < 16; attempt++) {
const candidate = (
Math.floor(Math.random() * 10000) + 0
)
.toString()
.padStart(4, "0");
if (!isWeakPin(candidate)) return candidate;
}
// Fallback: still return something — this branch is essentially impossible
return Math.floor(Math.random() * 10000)
.toString()
.padStart(4, "0");
}