diff --git a/.env.example b/.env.example
index 9347d32..1d87d37 100644
--- a/.env.example
+++ b/.env.example
@@ -1,2 +1,10 @@
-APP_SECRET=
-DATA_DIR=/data
+# At least 16 characters. Encrypts the model keys you paste in /settings (AES-256-GCM).
+# 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
diff --git a/.qa-data/qai.db b/.qa-data/qai.db
new file mode 100644
index 0000000..4a3492c
Binary files /dev/null and b/.qa-data/qai.db differ
diff --git a/.qa-data/qai.db-shm b/.qa-data/qai.db-shm
new file mode 100644
index 0000000..7d69b2e
Binary files /dev/null and b/.qa-data/qai.db-shm differ
diff --git a/.qa-data/qai.db-wal b/.qa-data/qai.db-wal
new file mode 100644
index 0000000..5f905cb
Binary files /dev/null and b/.qa-data/qai.db-wal differ
diff --git a/README.md b/README.md
index f70568b..495e7d5 100644
--- a/README.md
+++ b/README.md
@@ -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. |
| `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
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
npm test # node --test on tests/*.test.ts, no test framework installed
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.
-`npm run e2e` is the opposite — it drives the real app against the real models, and reads `JEV_API_KEY` from
-`.env.local`. Expect it to be slow; that is the models, not the harness.
+`npm run e2e` is the opposite — `pree2e` builds, then Playwright drives `npm run start` (the production server,
+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
skip becomes the real check on ShowPlan attribute spellings.
diff --git a/app/app/layout.tsx b/app/app/layout.tsx
index 9ca239c..359a1d9 100644
--- a/app/app/layout.tsx
+++ b/app/app/layout.tsx
@@ -1,8 +1,14 @@
+import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth";
+import { settingsView } from "@/lib/settings";
import { Rail } from "@/components/rail";
export default async function Console({ children }: { children: React.ReactNode }) {
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
{children}
diff --git a/e0c47bcd-00af-49ae-aac4-c32a92af6f01_upscayl_4x_upscayl-standard-4x.png b/e0c47bcd-00af-49ae-aac4-c32a92af6f01_upscayl_4x_upscayl-standard-4x.png
new file mode 100644
index 0000000..5593a34
Binary files /dev/null and b/e0c47bcd-00af-49ae-aac4-c32a92af6f01_upscayl_4x_upscayl-standard-4x.png differ
diff --git a/lib/auth.ts b/lib/auth.ts
index 10ffae8..4bbad87 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -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
// request-scoped binding must not be touched at import time.
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 } {
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) {
const token = randomBytes(32).toString("hex");
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, {
- httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production",
- path: "/", maxAge: SESSION_DAYS * 86400 });
+ httpOnly: true, sameSite: "lax", secure,
+ 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() {
const store = await jar();
diff --git a/package.json b/package.json
index 07b3a6d..a1e8c63 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,8 @@
"start": "next start",
"lint": "eslint",
"test": "node --no-warnings --test tests/*.test.ts",
- "e2e": "playwright test"
+ "e2e": "playwright test",
+ "pree2e": "npm run build"
},
"dependencies": {
"@openuidev/lang-core": "^0.3.0",
@@ -29,4 +30,4 @@
"eslint-config-next": "16.3.5",
"typescript": "^5"
}
-}
+}
\ No newline at end of file
diff --git a/playwright.config.ts b/playwright.config.ts
index 52bfc3c..0917fee 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -18,11 +18,19 @@ export default defineConfig({
use: { baseURL: "http://localhost:3000", trace: "retain-on-failure" },
projects: [
{ 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.
{ 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,
env: { DATA_DIR: ".playwright-data", APP_SECRET: process.env.APP_SECRET ?? "playwright-secret-value" } },
});
diff --git a/probe-tmp.mjs b/probe-tmp.mjs
new file mode 100644
index 0000000..0fd8405
--- /dev/null
+++ b/probe-tmp.mjs
@@ -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();
diff --git a/tests-e2e/edge.spec.ts b/tests-e2e/edge.spec.ts
index 39946ea..b79f463 100644
--- a/tests-e2e/edge.spec.ts
+++ b/tests-e2e/edge.spec.ts
@@ -13,7 +13,9 @@ function withDb
(fn: (d: DatabaseSync) => T): T {
test("a row left running renders what exists instead of redirecting forever", async ({ page }) => {
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();
// 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 (?,?,?,?,?,?,?)")
diff --git a/tests-e2e/session.spec.ts b/tests-e2e/session.spec.ts
new file mode 100644
index 0000000..ee611b5
--- /dev/null
+++ b/tests-e2e/session.spec.ts
@@ -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();
+});