#!/usr/bin/env node /** * diag-water-pin.mjs — diagnostic script for the "PIN only works * once" bug in the water log module. * * What it does: * 1. Snapshots every row in `water_irrigators` and `water_sessions`. * 2. For each irrigator, verifies the stored `pin_hash` format is * a parseable `scrypt$N$r$p$salt$hash` string. * 3. Cross-checks: any session whose `expires_at < now()` would * fail `requireFieldSession` but should NOT affect `verifyWaterPin` * (verifyWaterPin re-issues a session on success). * 4. Optionally tests a provided PIN against a provided pin_hash to * see if the cryptographic verify matches. * * Usage: * DATABASE_URL="postgresql://..." node scripts/diag-water-pin.mjs * DATABASE_URL="..." \ * node scripts/diag-water-pin.mjs --pin=4739 --id= * * Run this BEFORE and AFTER a failing second-login attempt to confirm * whether `pin_hash` is being modified mid-session. */ import "dotenv/config"; import { Pool } from "pg"; import { scryptSync, timingSafeEqual } from "node:crypto"; const args = Object.fromEntries( process.argv.slice(2).map((a) => { const m = a.match(/^--([^=]+)(?:=(.*))?$/); return m ? [m[1], m[2] ?? true] : [a, true]; }), ); const pin = typeof args.pin === "string" ? args.pin : null; const targetId = typeof args.id === "string" ? args.id : null; if (!process.env.DATABASE_URL) { console.error("DATABASE_URL is not set"); process.exit(1); } const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: process.env.DATABASE_URL.includes("sslmode") ? { rejectUnauthorized: false } : undefined, }); function verifyPin(rawPin, stored) { if (!stored || !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), r = parseInt(rStr, 10), p = parseInt(pStr, 10); if (![n, r, p].every(Number.isFinite)) return false; let salt, expected; try { salt = Buffer.from(saltB64, "base64"); expected = Buffer.from(hashB64, "base64"); } catch { return false; } if (expected.length !== 32) return false; let candidate; try { candidate = scryptSync(rawPin.normalize("NFKC"), salt, 32, { N: n, r, p, }); } catch { return false; } return ( candidate.length === expected.length && timingSafeEqual(candidate, expected) ); } function truncate(s, n = 60) { return s && s.length > n ? s.slice(0, n) + "..." : s; } async function main() { const client = await pool.connect(); try { console.log("=== SNAPSHOT @ " + new Date().toISOString() + " ===\n"); // Bypass RLS via withPlatformAdmin equivalent (raw app vars). await client.query("BEGIN"); await client.query( "SELECT set_config('app.current_brand_id', '', true)", ); await client.query( "SELECT set_config('app.platform_admin', 'true', true)", ); const irrigators = await client.query( "SELECT id, brand_id, name, role, active, " + "substr(pin_hash, 1, 40) || '...' AS pin_hash_preview, " + "length(pin_hash) AS pin_hash_len, last_used_at, created_at " + "FROM water_irrigators ORDER BY name", ); console.log("water_irrigators (" + irrigators.rowCount + " rows):"); for (const r of irrigators.rows) { console.log(` • [${r.id}] ${r.name}`); console.log(` brand=${r.brand_id}`); console.log(` role=${r.role} active=${r.active}`); console.log(` pin_hash_preview="${r.pin_hash_preview}" (len=${r.pin_hash_len})`); console.log(` last_used_at=${r.last_used_at}`); // Format check const fullRow = await client.query( "SELECT pin_hash FROM water_irrigators WHERE id = $1", [r.id], ); const ph = fullRow.rows[0]?.pin_hash; if (!ph || !ph.startsWith("scrypt$") || ph.split("$").length !== 6) { console.log(` ⚠️ pin_hash has UNEXPECTED format`); } else { console.log(` ✓ pin_hash is well-formed scrypt string`); } // Targeted PIN verify if (pin && targetId === r.id) { const ok = verifyPin(pin, ph); console.log(` ${ok ? "✓" : "✗"} pin '${pin}' matches pin_hash for ${r.name} → ${ok}`); } console.log(); } const sessions = await client.query( "SELECT id, irrigator_id, expires_at, created_at FROM water_sessions ORDER BY created_at DESC LIMIT 50", ); console.log("\nwater_sessions (" + sessions.rowCount + " recent rows):"); for (const s of sessions.rows) { const expired = new Date(s.expires_at).getTime() < Date.now(); const irrigator = irrigators.rows.find((i) => i.id === s.irrigator_id); console.log( ` • [${s.id}] irrigator=${s.irrigator_id} (${irrigator?.name ?? "?"}) expires_at=${s.expires_at.toISOString()} ${expired ? "(EXPIRED)" : "(active)"}`, ); } await client.query("COMMIT"); } finally { client.release(); await pool.end(); } } main().catch((err) => { console.error("error:", err.message); process.exit(1); });