fix: session cookie over plain http, console onboarding gate, webkit qa coverage

- the session cookie was Secure whenever NODE_ENV=production, so a production build on http://localhost got no cookie back in Safari: every page after signup looked signed-out. It is now Secure only when X-Forwarded-Proto says https, or when COOKIE_SECURE=1.
- /app and /app/[id] now require an analyst and a Jev key, the same rule /api/analyze enforces with a 412. Closing the guide mid-way used to leave a console that could not run anything.
- Playwright runs the production server (pree2e builds first) and gains a webkit project for session survival; chromium-only QA could never see a Secure-cookie rejection.
- tests-e2e/session.spec.ts pins both; .env.example gains COOKIE_SECURE and stops shipping a blank APP_SECRET.
- e2e: 21 passed (setup, chromium, webkit, mobile).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ahmed mohammed
2026-09-22 10:45:12 +03:00
co-authored by Claude Opus 4.7
parent 5160a00db5
commit 2ebfcfb7a6
13 changed files with 124 additions and 12 deletions
+10 -2
View File
@@ -1,2 +1,10 @@
APP_SECRET= # At least 16 characters. Encrypts the model keys you paste in /settings (AES-256-GCM).
DATA_DIR=/data # Changing it makes already-stored keys unreadable — you will have to paste them again.
APP_SECRET=replace-with-32-random-characters
# Where qai.db lives. Default ./data; the Docker image sets /data.
DATA_DIR=./data
# Only for a direct https deploy with no proxy setting X-Forwarded-Proto.
# Leave unset on plain http: Safari will not store a Secure cookie there.
# COOKIE_SECURE=1
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9 -3
View File
@@ -40,6 +40,7 @@ bash scripts/smoke.sh # healthz, login page, signed-out redirect, analy
| --- | --- | | --- | --- |
| `APP_SECRET` | ≥16 chars. Encrypts API keys at rest (AES-256-GCM). Changing it makes stored keys unreadable. | | `APP_SECRET` | ≥16 chars. Encrypts API keys at rest (AES-256-GCM). Changing it makes stored keys unreadable. |
| `DATA_DIR` | where `qai.db` (SQLite, `node:sqlite`) lives. Default `./data`. Docker uses `/data`. | | `DATA_DIR` | where `qai.db` (SQLite, `node:sqlite`) lives. Default `./data`. Docker uses `/data`. |
| `COOKIE_SECURE` | set to `1` only when the app is served over https without a proxy that sets `X-Forwarded-Proto`. The session cookie is `Secure` when that header says `https`; forcing it on plain http makes Safari refuse to store it. |
Model keys are **not** config. Each user types them in the app (first-run guide or `/settings`), so a second user Model keys are **not** config. Each user types them in the app (first-run guide or `/settings`), so a second user
brings their own endpoint and their own spend. Blank key field = keep the stored key. brings their own endpoint and their own spend. Blank key field = keep the stored key.
@@ -56,12 +57,17 @@ brings their own endpoint and their own spend. Blank key field = keep the stored
```bash ```bash
npm test # node --test on tests/*.test.ts, no test framework installed npm test # node --test on tests/*.test.ts, no test framework installed
npm run build # production build + type check npm run build # production build + type check
npm run e2e # Playwright: signup, settings, real analyst + real Jev run, copy, print, history, 4 widths npm run e2e # builds first, then Playwright against the production server
``` ```
The unit suites run offline: the analyst and Jev are exercised through injected `fetch`, so no key and no network. The unit suites run offline: the analyst and Jev are exercised through injected `fetch`, so no key and no network.
`npm run e2e` is the opposite — it drives the real app against the real models, and reads `JEV_API_KEY` from `npm run e2e` is the opposite — `pree2e` builds, then Playwright drives `npm run start` (the production server,
`.env.local`. Expect it to be slow; that is the models, not the harness. not `next dev`: cookie flags and hydration differ, and that difference once hid a sign-out bug) against the real
models. It reads `JEV_API_KEY` from `.env.local`. Expect it to be slow; that is the models, not the harness.
Projects: `chromium` (everything except the session spec, reusing one signed-in session), `webkit` (Safari —
session survival only, because it is the one engine that rejects a `Secure` cookie over `http://localhost`),
and `mobile` (Pixel 7, layout only, so no model run is paid for twice).
`tests/digest.test.ts` skips unless a genuine plan exists at `fixtures/real.sqlplan`. Drop one there from SSMS and the `tests/digest.test.ts` skips unless a genuine plan exists at `fixtures/real.sqlplan`. Drop one there from SSMS and the
skip becomes the real check on ShowPlan attribute spellings. skip becomes the real check on ShowPlan attribute spellings.
+6
View File
@@ -1,8 +1,14 @@
import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { settingsView } from "@/lib/settings";
import { Rail } from "@/components/rail"; import { Rail } from "@/components/rail";
export default async function Console({ children }: { children: React.ReactNode }) { export default async function Console({ children }: { children: React.ReactNode }) {
const u = await requireUser(); const u = await requireUser();
const v = settingsView(u.id);
// Same requirement /api/analyze enforces (it answers 412). Without this, a user who closes the guide
// mid-way lands on a console that cannot run anything, and only finds out when Analyse turns red.
if (!v.has_analyst || !v.has_jev) redirect("/setup?step=2");
return <div className="shell"> return <div className="shell">
<aside className="rail no-print" aria-label="Previous analyses"><Rail userId={u.id} /></aside> <aside className="rail no-print" aria-label="Previous analyses"><Rail userId={u.id} /></aside>
<main className="main">{children}</main> <main className="main">{children}</main>
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

+9 -2
View File
@@ -33,6 +33,7 @@ export function signUpNewUser(email: string, password: string): { ok: true; id:
// next/headers is imported lazily: `node --test` loads this module to test the crypto above, and the // next/headers is imported lazily: `node --test` loads this module to test the crypto above, and the
// request-scoped binding must not be touched at import time. // request-scoped binding must not be touched at import time.
const jar = async () => (await import("next/headers")).cookies(); const jar = async () => (await import("next/headers")).cookies();
const fwdProto = async () => ((await (await import("next/headers")).headers())).get("x-forwarded-proto") ?? "";
export function changeEmail(userId: string, email: string): { ok: true } | { ok: false; error: string } { export function changeEmail(userId: string, email: string): { ok: true } | { ok: false; error: string } {
const e = email.trim().toLowerCase(); const e = email.trim().toLowerCase();
@@ -47,9 +48,15 @@ export function changeEmail(userId: string, email: string): { ok: true } | { ok:
export async function startSession(userId: string) { export async function startSession(userId: string) {
const token = randomBytes(32).toString("hex"); const token = randomBytes(32).toString("hex");
putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5); putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5);
const secure = process.env.COOKIE_SECURE === "1" || (await fwdProto()).includes("https");
(await jar()).set(SESSION_COOKIE, token, { (await jar()).set(SESSION_COOKIE, token, {
httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", httpOnly: true, sameSite: "lax", secure,
path: "/", maxAge: SESSION_DAYS * 86400 }); path: "/", maxAge: SESSION_DAYS * 86400,
// NODE_ENV is the wrong signal here. A production build served over plain http://localhost would set
// Secure, Safari then refuses to store the cookie at all, and every page after signup looks signed-out.
// (Chromium accepts Secure on localhost, which is why a chromium-only suite could never see it.)
// Only a TLS proxy reports https; COOKIE_SECURE=1 forces it on for a direct https deploy.
});
} }
export async function endSession() { export async function endSession() {
const store = await jar(); const store = await jar();
+2 -1
View File
@@ -8,7 +8,8 @@
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"test": "node --no-warnings --test tests/*.test.ts", "test": "node --no-warnings --test tests/*.test.ts",
"e2e": "playwright test" "e2e": "playwright test",
"pree2e": "npm run build"
}, },
"dependencies": { "dependencies": {
"@openuidev/lang-core": "^0.3.0", "@openuidev/lang-core": "^0.3.0",
+10 -2
View File
@@ -18,11 +18,19 @@ export default defineConfig({
use: { baseURL: "http://localhost:3000", trace: "retain-on-failure" }, use: { baseURL: "http://localhost:3000", trace: "retain-on-failure" },
projects: [ projects: [
{ name: "setup", testMatch: /auth\.setup\.ts/ }, { name: "setup", testMatch: /auth\.setup\.ts/ },
{ name: "chromium", use: { ...devices["Desktop Chrome"] }, dependencies: ["setup"] }, // The expensive specs are chromium-only (each real run costs ~30s of model time) and reuse the
// session the setup project wrote.
{ name: "chromium", use: { ...devices["Desktop Chrome"] }, dependencies: ["setup"],
testIgnore: /session\.spec\.ts/ },
// Safari/WebKit only checks session survival: it is the one engine that refuses a Secure cookie on
// http://localhost, which is exactly how the wizard "signed itself out" in a production build.
{ name: "webkit", use: { ...devices["Desktop Safari"] }, testMatch: /session\.spec\.ts/ },
// A real model run costs ~40s; the mobile project only needs the layout checks, not a second full run. // A real model run costs ~40s; the mobile project only needs the layout checks, not a second full run.
{ name: "mobile", use: { ...devices["Pixel 7"] }, dependencies: ["setup"], testMatch: /responsive\.spec\.ts/ }, { name: "mobile", use: { ...devices["Pixel 7"] }, dependencies: ["setup"], testMatch: /responsive\.spec\.ts/ },
], ],
webServer: { command: "npm run dev", url: "http://localhost:3000/api/healthz", reuseExistingServer: true, webServer: { // Run the production server: the cookie flags and hydration behaviour differ from dev, and that
// difference hid a sign-out bug from a dev-only suite. `npm run pree2e` builds before this boots.
command: "npm run start", url: "http://localhost:3000/api/healthz", reuseExistingServer: false,
timeout: 120_000, timeout: 120_000,
env: { DATA_DIR: ".playwright-data", APP_SECRET: process.env.APP_SECRET ?? "playwright-secret-value" } }, env: { DATA_DIR: ".playwright-data", APP_SECRET: process.env.APP_SECRET ?? "playwright-secret-value" } },
}); });
+30
View File
@@ -0,0 +1,30 @@
import { chromium } from "@playwright/test";
const BASE = "http://127.0.0.1:3100";
const b = await chromium.launch();
const ctx = await b.newContext({ baseURL: BASE });
const p = await ctx.newPage();
const where = () => new URL(p.url()).pathname + new URL(p.url()).search;
const h1 = async () => (await p.locator("h1").first().innerText().catch(() => "?"));
const email = `qa${Date.now()}@test.dev`;
await p.goto("/signup");
await p.fill("#email", email);
await p.fill("#password", "0123456789ab");
await p.getByRole("button", { name: "Create account" }).click();
await p.waitForURL(/setup|app|login/, { timeout: 20_000 });
console.log("1 signup done :", where(), "| h1:", await h1());
await p.getByRole("link", { name: "Next" }).click();
await p.waitForURL(/step=2/, { timeout: 20_000 });
console.log("2 clicked Next :", where(), "| h1:", await h1());
await p.getByRole("link", { name: "Next" }).click();
await p.waitForTimeout(2500);
console.log("3 Next again :", where(), "| h1:", await h1(), "| alert:", await p.getByRole("alert").count());
// Is the session still alive after that?
await p.goto("/app");
await p.waitForTimeout(2000);
console.log("4 open /app :", where(), "| h1:", await h1());
await b.close();
+3 -1
View File
@@ -13,7 +13,9 @@ function withDb<T>(fn: (d: DatabaseSync) => T): T {
test("a row left running renders what exists instead of redirecting forever", async ({ page }) => { test("a row left running renders what exists instead of redirecting forever", async ({ page }) => {
const id = withDb((d) => { const id = withDb((d) => {
const userId = d.prepare("SELECT id FROM users ORDER BY rowid DESC LIMIT 1").get() as { id: string }; // The setup project's account, not "the newest user": the webkit specs sign up their own accounts and
// would otherwise put this row under an account the stored session cannot open (404, not the note).
const userId = d.prepare("SELECT id FROM users WHERE email LIKE 'qa+%@qai.test' ORDER BY rowid DESC LIMIT 1").get() as { id: string };
const aid = randomUUID(); const aid = randomUUID();
// Backdated so it never becomes the rail's first row: history.spec.ts depends on that row being a real run. // Backdated so it never becomes the rail's first row: history.spec.ts depends on that row being a real run.
d.prepare("INSERT INTO analyses (id, user_id, title, xml, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)") d.prepare("INSERT INTO analyses (id, user_id, title, xml, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)")
+44
View File
@@ -0,0 +1,44 @@
import { test, expect } from "@playwright/test";
// WebKit-only on purpose. Chromium accepts a `Secure` cookie on http://localhost; Safari does not, and it
// silently drops it, so a production build on plain http looked like "the wizard signed me out".
// This file must stay self-contained: it signs up its own account and never uses .auth/user.json.
test("signup, wizard Next links, and /app stay logged in over plain http", async ({ page }) => {
const email = `safari${Date.now()}@qai.test`;
await page.goto("/signup");
await page.fill("#email", email);
await page.fill("#password", "0123456789ab");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page).toHaveURL(/\/setup/, { timeout: 20_000 });
await expect(page.getByText("Step 1 of 3")).toBeVisible();
await page.getByRole("link", { name: "Next" }).click();
await expect(page).toHaveURL(/step=2/);
await expect(page.getByText("Say which models to ask.")).toBeVisible();
await page.getByRole("link", { name: "Next" }).click();
await expect(page).toHaveURL(/step=3/);
// Session proof: this user has no keys yet, so the console gate sends them to the guide.
// Signed-in lands on /setup?step=2; a lost session would land on /login.
await page.goto("/app");
await expect(page).toHaveURL(/\/setup\?step=2/);
});
test("a reload deep in the app keeps the session", async ({ page }) => {
const email = `reload${Date.now()}@qai.test`;
await page.goto("/signup");
await page.fill("#email", email);
await page.fill("#password", "0123456789ab");
await page.getByRole("button", { name: "Create account" }).click();
// Wait for the signup POST to finish: the cookie is not in effect until its response lands, and an
// immediate goto races it (which reads as "the session did not survive a reload").
await expect(page).toHaveURL(/\/setup/, { timeout: 20_000 });
await page.goto("/settings");
await page.reload();
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
// The form is still here after the reload: this is the account's own settings page, not the login form.
await expect(page.getByRole("button", { name: "Save" })).toBeVisible();
});