fix(water-log): set wl_admin_session cookie outside pg transaction
Deploy to route.crispygoat.com / deploy (push) Successful in 4m49s

verifyWaterAdminPin previously called cookies().set(...) from inside
the withBrand(...) pg transaction. The pg transaction's
AsyncLocalStorage context masks Next.js's request-scoped cookie
store, so for users without a platform login the cookie set threw
and the route handler returned 500.

Move the cookie write to the outer function frame, after withBrand
resolves. The DB session row is still inserted inside the
transaction (so it's atomic with the PIN verification); only the
cookie write is hoisted out.

The prior fix removed getAdminUser() but left the cookie set inside
the transaction, so valid PINs still 500'd. This is the missing
half.

Adds a static regression test that asserts const cookieStore
is declared AFTER the matching close-paren of withBrand(...).
This commit is contained in:
Tyler
2026-07-01 18:05:09 -06:00
parent 98fc1d3bdd
commit ca79896d5f
2 changed files with 77 additions and 11 deletions
+32 -11
View File
@@ -205,7 +205,12 @@ export async function verifyWaterAdminPin(
const formatError = validatePin(pin); const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError }; if (formatError) return { success: false, error: formatError };
return withBrand(brandId, async (db) => { // Run the DB work inside withBrand, but resolve the cookie write
// OUTSIDE the transaction. Calling `cookies().set(...)` from inside
// a pg transaction corrupts Next.js's request-scoped AsyncLocalStorage
// and the cookie set throws for users without a platform login
// (manifesting as a 500 from /api/water-admin-auth).
const result = await withBrand(brandId, async (db) => {
const rows = await db const rows = await db
.select() .select()
.from(waterAdminSettings) .from(waterAdminSettings)
@@ -213,17 +218,21 @@ const formatError = validatePin(pin);
.limit(1); .limit(1);
const settings = rows[0]; const settings = rows[0];
if (!settings) { if (!settings) {
return { success: false, error: "Admin portal not configured" }; return { success: false, error: "Admin portal not configured" } as const;
} }
if (!settings.enabled) { if (!settings.enabled) {
return { success: false, error: "Admin portal is disabled" }; return { success: false, error: "Admin portal is disabled" } as const;
} }
if (!settings.pinHash) { if (!settings.pinHash) {
return { success: false, error: "No PIN configured — generate one in /admin/water-log/settings" }; return {
success: false,
error:
"No PIN configured — generate one in /admin/water-log/settings",
} as const;
} }
if (!verifyPin(pin, settings.pinHash)) { if (!verifyPin(pin, settings.pinHash)) {
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
return { success: false, error: "Invalid PIN" }; return { success: false, error: "Invalid PIN" } as const;
} }
// The `/water/admin/login` page is Tuxedo-brand-specific and PIN-only. // The `/water/admin/login` page is Tuxedo-brand-specific and PIN-only.
@@ -246,17 +255,29 @@ const formatError = validatePin(pin);
expiresAt, expiresAt,
}) })
.returning({ id: waterAdminSessions.id }); .returning({ id: waterAdminSessions.id });
if (!session) return { success: false, error: "Failed to create session" }; if (!session) {
return { success: false, error: "Failed to create session" } as const;
}
return {
success: true as const,
sessionId: session.id,
sessionDurationHours: settings.sessionDurationHours,
};
});
// Cookie write happens AFTER withBrand resolves, in the outer request
// frame where Next.js's cookie store is intact. See the comment above.
if (result.success) {
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set("wl_admin_session", session.id, { cookieStore.set("wl_admin_session", result.sessionId, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "lax", sameSite: "lax",
maxAge: settings.sessionDurationHours * 3600, maxAge: result.sessionDurationHours * 3600,
path: "/", path: "/",
}); });
return { success: true, session_id: result.sessionId };
return { success: true, session_id: session.id }; }
}); return result;
} }
+45
View File
@@ -144,6 +144,51 @@ describe("water-log/settings — verifyWaterAdminPin surface", () => {
/00000000-0000-0000-0000-000000000000/, /00000000-0000-0000-0000-000000000000/,
); );
}); });
it("writes the wl_admin_session cookie OUTSIDE the withBrand callback", async () => {
// Regression: calling `cookies().set(...)` from inside the
// withBrand(...) pg transaction corrupts Next.js's request-scoped
// AsyncLocalStorage and the cookie set throws for users without a
// platform login (the route handler returns 500). The cookie must
// be written in the OUTER function frame, after withBrand resolves.
//
// Distinguishing OLD vs NEW: in the old code, `const cookieStore
// = await cookies()` was declared INSIDE the withBrand callback
// (between `withBrand(` and its matching closing `)`). In the new
// code, it's declared AFTER that closing. We find the matching
// `)` that closes withBrand (depth-tracked, ignoring string
// contents) and assert that `const cookieStore` comes after it.
const stripped = stripComments(await getVerifyWaterAdminPinSource());
const wbStart = stripped.indexOf("withBrand(");
expect(wbStart).toBeGreaterThan(-1);
const wbOpenParen = wbStart + "withBrand".length; // position of the `(` after `withBrand`
let depth = 1; // we are inside withBrand(
let i = wbOpenParen + 1;
let inString: string | null = null;
let escaped = false;
let wbEnd = -1;
while (i < stripped.length) {
const c = stripped[i];
if (inString) {
if (escaped) { escaped = false; i++; continue; }
if (c === "\\") { escaped = true; i++; continue; }
if (c === inString) inString = null;
i++;
continue;
}
if (c === '"' || c === "'" || c === "`") { inString = c; i++; continue; }
if (c === "(" || c === "{") depth++;
else if (c === ")" || c === "}") {
depth--;
if (depth === 0) { wbEnd = i; break; }
}
i++;
}
expect(wbEnd).toBeGreaterThan(-1);
const cookieStoreIdx = stripped.indexOf("const cookieStore");
expect(cookieStoreIdx).toBeGreaterThan(-1);
expect(cookieStoreIdx).toBeGreaterThan(wbEnd);
});
}); });
// ── requireFieldSession ───────────────────────────────────────────────── // ── requireFieldSession ─────────────────────────────────────────────────