Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user