fix(water-log): set wl_admin_session cookie outside pg transaction
Deploy to route.crispygoat.com / deploy (push) Successful in 4m49s
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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user