69d8f829f1
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.
159 lines
5.1 KiB
JavaScript
159 lines
5.1 KiB
JavaScript
#!/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);
|
|
});
|