fix(water-log): remove nested Promise.all in field PIN login
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 commit
ca79896 and src/lib/water-admin-pin-auth.ts.

Symptom: field PIN works once, fails after logout until reset.
Root cause is the same family flagged in ca79896: 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:
Tyler
2026-07-02 10:32:20 -06:00
parent ce62b17898
commit 69d8f829f1
2 changed files with 192 additions and 26 deletions
+158
View File
@@ -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);
});
+34 -26
View File
@@ -144,32 +144,40 @@ export async function verifyWaterPin(
} }
// Create a session in the user's brand context. The session insert // Create a session in the user's brand context. The session insert
// and the last_used_at bump are independent writes — fire them in // and the last_used_at bump run *sequentially* on the same pg
// parallel and wait on both before returning. // connection inside the `withBrand` transaction — never concurrently.
const [sessionId, cookieStore] = await Promise.all([ //
withBrand(match.brandId, async (db) => { // The previous shape used a nested `Promise.all([insert, update])`
const expiresAt = new Date( // *and* an outer `Promise.all([withBrand, cookies()])`. That was
Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000, // known-fragile per the codebase's own commit `ca79896`: "cookies()
); // corrupts Next.js's request-scoped AsyncLocalStorage if called from
const [insertResult] = await Promise.all([ // inside a pg transaction." The admin portal has been on the
db // sequential shape (see `src/lib/water-admin-pin-auth.ts`) since
.insert(waterSessions) // that commit; this brings the field portal in line.
.values({ const sessionId = await withBrand(match.brandId, async (db) => {
irrigatorId: match.id, const expiresAt = new Date(
expiresAt, Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000,
}) );
.returning({ id: waterSessions.id }), const [inserted] = await db
db .insert(waterSessions)
.update(waterIrrigators) .values({
.set({ lastUsedAt: new Date() }) irrigatorId: match.id,
.where(eq(waterIrrigators.id, match.id)), expiresAt,
]); })
const row = insertResult[0]; .returning({ id: waterSessions.id });
if (!row) throw new Error("Failed to create session"); if (!inserted) throw new Error("Failed to create session");
return row.id;
}), await db
cookies(), .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, { cookieStore.set("wl_session", sessionId, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",