diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx
new file mode 100644
index 0000000..f4ec431
--- /dev/null
+++ b/app/(auth)/layout.tsx
@@ -0,0 +1,6 @@
+export default function Auth({ children }: { children: React.ReactNode }) {
+ return
+
SQL Server qAI
{children}
+
ShowPlan XML in · Jev-ranked remediation out · self-hosted
+
;
+}
diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx
new file mode 100644
index 0000000..9a078a7
--- /dev/null
+++ b/app/(auth)/login/page.tsx
@@ -0,0 +1,11 @@
+import { signIn } from "@/lib/actions";
+import { AuthForm } from "@/components/auth-form";
+export default function Login() {
+ return <>
+ Sign in
+
+ Create an account
+ >;
+}
diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx
new file mode 100644
index 0000000..4163020
--- /dev/null
+++ b/app/(auth)/signup/page.tsx
@@ -0,0 +1,12 @@
+import { signUp } from "@/lib/actions";
+import { AuthForm } from "@/components/auth-form";
+export default function Signup() {
+ return <>
+ Create account
+
+ Sign in instead
+ >;
+}
diff --git a/app/app/layout.tsx b/app/app/layout.tsx
index 5d3376e..2562259 100644
--- a/app/app/layout.tsx
+++ b/app/app/layout.tsx
@@ -1,4 +1,7 @@
-export default function Console({ children }: { children: React.ReactNode }) {
+import { requireUser } from "@/lib/auth";
+
+export default async function Console({ children }: { children: React.ReactNode }) {
+ await requireUser();
return
{children}
diff --git a/app/page.tsx b/app/page.tsx
index ccb8628..5b6dc07 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,2 +1,3 @@
import { redirect } from "next/navigation";
-export default function Home() { redirect("/login"); }
+import { sessionUser } from "@/lib/auth";
+export default async function Home() { redirect((await sessionUser()) ? "/app" : "/login"); }
diff --git a/components/auth-form.tsx b/components/auth-form.tsx
new file mode 100644
index 0000000..4ff2127
--- /dev/null
+++ b/components/auth-form.tsx
@@ -0,0 +1,21 @@
+"use client";
+import { useActionState } from "react";
+
+export type FormResult = { ok?: boolean; error?: string } | null;
+
+export function AuthForm({ action, submit, fields }: {
+ action: (state: FormResult, fd: FormData) => Promise
;
+ submit: string;
+ fields: { name: string; label: string; type: string; autoComplete?: string; help?: string }[];
+}) {
+ const [state, formAction, pending] = useActionState(action, null);
+ return ;
+}
diff --git a/lib/actions.ts b/lib/actions.ts
new file mode 100644
index 0000000..5d36430
--- /dev/null
+++ b/lib/actions.ts
@@ -0,0 +1,34 @@
+"use server";
+import { redirect } from "next/navigation";
+import { revalidatePath } from "next/cache";
+import { userByEmail, getSettings, renameAnalysis, deleteAnalysis } from "@/lib/db";
+import { signUpNewUser, verifyPassword, startSession, endSession, requireUser, emailOk } from "@/lib/auth";
+
+export async function signUp(_: unknown, fd: FormData) {
+ const r = signUpNewUser(String(fd.get("email") ?? ""), String(fd.get("password") ?? ""));
+ if (!r.ok) return r;
+ await startSession(r.id);
+ redirect("/setup");
+}
+export async function signIn(_: unknown, fd: FormData) {
+ const e = String(fd.get("email") ?? "").trim().toLowerCase();
+ const u = emailOk(e) ? userByEmail(e) : null;
+ if (!u || !verifyPassword(String(fd.get("password") ?? ""), u.pass))
+ return { ok: false, error: "Email or password is wrong." };
+ await startSession(u.id);
+ redirect(getSettings(u.id)?.onboarded === 1 ? "/app" : "/setup");
+}
+export async function signOut() { await endSession(); redirect("/login"); }
+
+export async function rename(_: unknown, fd: FormData) {
+ const u = await requireUser();
+ const id = String(fd.get("id") ?? "");
+ const title = String(fd.get("title") ?? "").trim().slice(0, 120);
+ if (id && title) renameAnalysis(id, u.id, title);
+ revalidatePath("/app");
+}
+export async function remove(_: unknown, fd: FormData) {
+ const u = await requireUser();
+ deleteAnalysis(String(fd.get("id") ?? ""), u.id);
+ revalidatePath("/app");
+}
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..465c33a
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,64 @@
+import { randomBytes, scryptSync, createHash, timingSafeEqual } from "node:crypto";
+import { createUser, userByEmail, userEmail, putSession, sessionUserId, dropSession, getSettings } from "./db.ts";
+
+export const SESSION_COOKIE = "qai_s";
+export const SESSION_DAYS = 30;
+export const emailOk = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e);
+export const passwordOk = (p: string) => p.length >= 10; // trust boundary: signup + password change only
+
+export function hashPassword(pw: string): string {
+ const salt = randomBytes(16);
+ return `${salt.toString("hex")}:${scryptSync(pw, salt, 64).toString("hex")}`;
+}
+export function verifyPassword(pw: string, stored: string): boolean {
+ const [salt, hash] = stored.split(":");
+ if (!salt || !hash) return false;
+ try {
+ const a = scryptSync(pw, Buffer.from(salt, "hex"), 64);
+ const b = Buffer.from(hash, "hex");
+ return a.length === b.length && timingSafeEqual(a, b);
+ } catch { return false; }
+}
+export const hashToken = (t: string) => createHash("sha256").update(t).digest("hex");
+
+export function signUpNewUser(email: string, password: string): { ok: true; id: string } | { ok: false; error: string } {
+ const e = email.trim().toLowerCase();
+ if (!emailOk(e)) return { ok: false, error: "Enter a valid email address." };
+ if (!passwordOk(password)) return { ok: false, error: "Use at least 10 characters." };
+ if (userByEmail(e)) return { ok: false, error: "That email already has an account." };
+ // ponytail: no rate limiting — single-container tool. Add a token bucket before exposing this to a network.
+ return { ok: true, id: createUser(e, hashPassword(password)) };
+}
+
+// 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();
+
+export async function startSession(userId: string) {
+ const token = randomBytes(32).toString("hex");
+ putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5);
+ (await jar()).set(SESSION_COOKIE, token, {
+ httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production",
+ path: "/", maxAge: SESSION_DAYS * 86400 });
+}
+export async function endSession() {
+ const store = await jar();
+ const t = store.get(SESSION_COOKIE)?.value;
+ if (t) dropSession(hashToken(t));
+ store.delete(SESSION_COOKIE);
+}
+export async function sessionUser(): Promise<{ id: string; email: string } | null> {
+ const t = (await jar()).get(SESSION_COOKIE)?.value;
+ if (!t) return null;
+ const id = sessionUserId(hashToken(t));
+ if (!id) return null;
+ const row = userEmail(id);
+ return row ? { id, email: row.email } : null;
+}
+export async function requireUser(): Promise<{ id: string; email: string }> {
+ const { redirect } = await import("next/navigation"); // lazy for the same reason as jar()
+ const u = await sessionUser();
+ if (!u) { redirect("/login"); throw new Error("redirecting to /login"); } // redirect throws; this only satisfies the type
+ return u;
+}
+export const needsOnboarding = (userId: string) => getSettings(userId)?.onboarded !== 1;
diff --git a/lib/db.ts b/lib/db.ts
index d0024cf..9ddcdda 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -29,7 +29,8 @@ export const db = (g.__qaiDb ??= (() => {
})());
const now = () => Date.now();
-const one = (sql: string, ...p: unknown[]): T | null => (db.prepare(sql).get(...p) as T) ?? null;
+type Row = string | number | null;
+const one = (sql: string, ...p: Row[]): T | null => (db.prepare(sql).get(...p) as T | undefined) ?? null;
// node:sqlite binds null/number/string/bigint/Buffer only: pass 0/1, never booleans.
export type SettingsInput = { analyst_base_url: string; analyst_key: string; analyst_model: string;
diff --git a/package-lock.json b/package-lock.json
index ca5396b..8a06514 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,7 @@
"zod": "^4.6.5"
},
"devDependencies": {
- "@types/node": "^20",
+ "@types/node": "^24.13.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
@@ -1319,11 +1319,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "20.19.43",
+ "version": "24.13.6",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
+ "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~6.21.0"
+ "undici-types": "~7.18.0"
}
},
"node_modules/@types/react": {
@@ -5419,7 +5421,9 @@
}
},
"node_modules/undici-types": {
- "version": "6.21.0",
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
diff --git a/package.json b/package.json
index 28332ab..9fb0486 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
"zod": "^4.6.5"
},
"devDependencies": {
- "@types/node": "^20",
+ "@types/node": "^24.13.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
diff --git a/tests/auth.test.ts b/tests/auth.test.ts
new file mode 100644
index 0000000..df73f8d
--- /dev/null
+++ b/tests/auth.test.ts
@@ -0,0 +1,20 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+process.env.DATA_DIR = mkdtempSync(`${tmpdir()}/qai-auth-`);
+const { hashPassword, verifyPassword, hashToken } = await import("../lib/auth.ts");
+
+test("password hash round-trips and rejects", () => {
+ const h = hashPassword("correct horse battery staple");
+ assert.ok(h.includes(":"), "salt:hash shape");
+ assert.ok(verifyPassword("correct horse battery staple", h));
+ assert.ok(!verifyPassword("wrong", h));
+});
+test("malformed stored hash never verifies", () => {
+ for (const bad of ["", "nosep", "a:", ":b", "zz:zz"]) assert.ok(!verifyPassword("x", bad));
+});
+test("token hash is stable, 64 hex", () => {
+ assert.equal(hashToken("abc"), hashToken("abc"));
+ assert.match(hashToken("abc"), /^[0-9a-f]{64}$/);
+});
diff --git a/tsconfig.json b/tsconfig.json
index 3a13f90..742340f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -19,8 +23,11 @@
}
],
"paths": {
- "@/*": ["./*"]
- }
+ "@/*": [
+ "./*"
+ ]
+ },
+ "allowImportingTsExtensions": true
},
"include": [
"next-env.d.ts",
@@ -30,5 +37,7 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
- "exclude": ["node_modules"]
-}
+ "exclude": [
+ "node_modules"
+ ]
+}
\ No newline at end of file