diff --git a/scripts/diag-water-pin.mjs b/scripts/diag-water-pin.mjs new file mode 100644 index 0000000..835317d --- /dev/null +++ b/scripts/diag-water-pin.mjs @@ -0,0 +1,158 @@ +#!/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); +}); diff --git a/src/actions/water-log/field.ts b/src/actions/water-log/field.ts index c4fed79..9819b3c 100644 --- a/src/actions/water-log/field.ts +++ b/src/actions/water-log/field.ts @@ -144,32 +144,40 @@ export async function verifyWaterPin( } // Create a session in the user's brand context. The session insert - // and the last_used_at bump are independent writes — fire them in - // parallel and wait on both before returning. - const [sessionId, cookieStore] = await Promise.all([ - withBrand(match.brandId, async (db) => { - const expiresAt = new Date( - Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000, - ); - const [insertResult] = await Promise.all([ - db - .insert(waterSessions) - .values({ - irrigatorId: match.id, - expiresAt, - }) - .returning({ id: waterSessions.id }), - db - .update(waterIrrigators) - .set({ lastUsedAt: new Date() }) - .where(eq(waterIrrigators.id, match.id)), - ]); - const row = insertResult[0]; - if (!row) throw new Error("Failed to create session"); - return row.id; - }), - cookies(), - ]); + // and the last_used_at bump run *sequentially* on the same pg + // connection inside the `withBrand` transaction — never concurrently. + // + // The previous shape used a nested `Promise.all([insert, update])` + // *and* an outer `Promise.all([withBrand, cookies()])`. That was + // known-fragile per the codebase's own commit `ca79896`: "cookies() + // corrupts Next.js's request-scoped AsyncLocalStorage if called from + // inside a pg transaction." The admin portal has been on the + // sequential shape (see `src/lib/water-admin-pin-auth.ts`) since + // that commit; this brings the field portal in line. + const sessionId = await withBrand(match.brandId, async (db) => { + const expiresAt = new Date( + Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000, + ); + const [inserted] = await db + .insert(waterSessions) + .values({ + irrigatorId: match.id, + expiresAt, + }) + .returning({ id: waterSessions.id }); + if (!inserted) throw new Error("Failed to create session"); + + await db + .update(waterIrrigators) + .set({ lastUsedAt: new Date() }) + .where(eq(waterIrrigators.id, match.id)); + + return inserted.id; + }); + + // 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",