diff --git a/src/actions/water-log/settings.ts b/src/actions/water-log/settings.ts index 707f5ad..36a7631 100644 --- a/src/actions/water-log/settings.ts +++ b/src/actions/water-log/settings.ts @@ -205,7 +205,12 @@ export async function verifyWaterAdminPin( const formatError = validatePin(pin); 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 .select() .from(waterAdminSettings) @@ -213,17 +218,21 @@ const formatError = validatePin(pin); .limit(1); const settings = rows[0]; if (!settings) { - return { success: false, error: "Admin portal not configured" }; + return { success: false, error: "Admin portal not configured" } as const; } if (!settings.enabled) { - return { success: false, error: "Admin portal is disabled" }; + return { success: false, error: "Admin portal is disabled" } as const; } 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)) { 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. @@ -246,17 +255,29 @@ const formatError = validatePin(pin); expiresAt, }) .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(); - cookieStore.set("wl_admin_session", session.id, { + cookieStore.set("wl_admin_session", result.sessionId, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", - maxAge: settings.sessionDurationHours * 3600, + maxAge: result.sessionDurationHours * 3600, path: "/", }); - - return { success: true, session_id: session.id }; - }); + return { success: true, session_id: result.sessionId }; + } + return result; } diff --git a/tests/unit/water-log-auth.test.ts b/tests/unit/water-log-auth.test.ts index 6510820..697b75c 100644 --- a/tests/unit/water-log-auth.test.ts +++ b/tests/unit/water-log-auth.test.ts @@ -144,6 +144,51 @@ describe("water-log/settings — verifyWaterAdminPin surface", () => { /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 ─────────────────────────────────────────────────