fix(water-log): remove nested Promise.all in field PIN login
Deploy to route.crispygoat.com / deploy (push) Successful in 5m3s
Deploy to route.crispygoat.com / deploy (push) Successful in 5m3s
Replace nested Promise.all([insert, update]) + outer Promise.all([withBrand, cookies()]) with sequential awaits in verifyWaterPin, mirroring the admin-portal pattern from commitca79896and src/lib/water-admin-pin-auth.ts. Symptom: field PIN works once, fails after logout until reset. Root cause is the same family flagged inca79896: cookies() corrupts Next.js's request-scoped AsyncLocalStorage when called from inside a pg transaction, and nested Promise.all inside the transaction makes the failure non-deterministic. Also adds scripts/diag-water-pin.mjs for one-shot prod DB inspection if the symptom persists post-deploy.
This commit is contained in:
@@ -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=<irrigator-uuid>
|
||||
*
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user