diff --git a/admin-pages-flow.test.js b/admin-pages-flow.test.js index 55a31da..8faba99 100644 --- a/admin-pages-flow.test.js +++ b/admin-pages-flow.test.js @@ -41,7 +41,7 @@ function waitForServer(url, timeoutMs = 10000) { test.before(async () => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), "admin-pages-flow-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } diff --git a/auth.test.js b/auth.test.js new file mode 100644 index 0000000..f73482f --- /dev/null +++ b/auth.test.js @@ -0,0 +1,171 @@ +// Integration test for T19 (register/login/logout/me), dev-plan-3.md. +// Spawns server.js on a throwaway port against an isolated tmpdir copy +// (own data.sqlite too — db.js defaults to __dirname/data.sqlite), same +// isolation approach as admin-auth.test.js / uploads-route.test.js. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const { spawn } = require("node:child_process"); + +const PORT = 24934 + Math.floor(Math.random() * 1000); +const BASE = `http://localhost:${PORT}`; +const ADMIN_PASSWORD = "test-password-only-for-this-run"; + +function waitForServer(url, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + return (async function poll() { + while (Date.now() < deadline) { + try { + await fetch(url); + return; + } catch { + await new Promise((r) => setTimeout(r, 100)); + } + } + throw new Error("server did not come up in time"); + })(); +} + +function sessionCookie(res) { + const setCookie = res.headers.get("set-cookie") || ""; + return setCookie.split(";")[0]; +} + +let child; +let cwd; +test.before(async () => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "auth-test-")); + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); + } + child = spawn(process.execPath, ["server.js"], { + cwd, + env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD }, + stdio: "pipe", + }); + await waitForServer(BASE); +}); + +test.after(() => { + child.kill(); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +test("register: rejects a malformed email", async () => { + const res = await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "not-an-email", password: "longenoughpassword" }), + }); + assert.equal(res.status, 400); +}); + +test("register: rejects a too-short password", async () => { + const res = await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "short-pw@example.com", password: "short1" }), + }); + assert.equal(res.status, 400); +}); + +test("register -> auto-logged-in session -> GET /api/auth/me -> logout closes the loop", async () => { + const registerRes = await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "alice@example.com", password: "correct-horse-battery" }), + }); + assert.equal(registerRes.status, 200); + const registered = await registerRes.json(); + assert.equal(registered.email, "alice@example.com"); + assert.equal(registered.emailVerified, false); + const cookie = sessionCookie(registerRes); + assert.ok(cookie.startsWith("user_session="), "register should auto-issue a user_session cookie, distinct from admin_session"); + + const meRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }); + assert.equal(meRes.status, 200); + const me = await meRes.json(); + assert.equal(me.id, registered.id); + assert.equal(me.email, "alice@example.com"); + + const logoutRes = await fetch(`${BASE}/api/auth/logout`, { method: "POST", headers: { Cookie: cookie } }); + assert.equal(logoutRes.status, 200); + + const meAfterLogoutRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }); + assert.equal(meAfterLogoutRes.status, 401, "the old session token must be dead after logout"); +}); + +test("GET /api/auth/me with no cookie at all is unauthorized", async () => { + const res = await fetch(`${BASE}/api/auth/me`); + assert.equal(res.status, 401); +}); + +test("register: duplicate email is a 409, not a silently-created second account", async () => { + await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "dup@example.com", password: "correct-horse-battery" }), + }); + const secondRes = await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "dup@example.com", password: "a-totally-different-password" }), + }); + assert.equal(secondRes.status, 409); +}); + +test("login: correct credentials issue a fresh session", async () => { + await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "bob@example.com", password: "correct-horse-battery" }), + }); + const loginRes = await fetch(`${BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "bob@example.com", password: "correct-horse-battery" }), + }); + assert.equal(loginRes.status, 200); + const cookie = sessionCookie(loginRes); + const meRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }); + assert.equal(meRes.status, 200); + const me = await meRes.json(); + assert.equal(me.email, "bob@example.com"); +}); + +test("login: wrong password and unknown email return the exact same error (no email enumeration)", async () => { + await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "carol@example.com", password: "correct-horse-battery" }), + }); + + const wrongPasswordRes = await fetch(`${BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "carol@example.com", password: "totally-wrong-password" }), + }); + const unknownEmailRes = await fetch(`${BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "nobody-registered-this@example.com", password: "whatever" }), + }); + + assert.equal(wrongPasswordRes.status, 401); + assert.equal(unknownEmailRes.status, 401); + const [wrongBody, unknownBody] = await Promise.all([wrongPasswordRes.json(), unknownEmailRes.json()]); + assert.deepEqual(wrongBody, unknownBody, "the two failure modes must be indistinguishable to the caller"); +}); + +test("a user_session cookie doesn't unlock /api/admin/* (the two session systems stay separate)", async () => { + const registerRes = await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "dana@example.com", password: "correct-horse-battery" }), + }); + const cookie = sessionCookie(registerRes); + const res = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } }); + assert.equal(res.status, 401); +}); diff --git a/dance-channel.test.js b/dance-channel.test.js index 21b91e8..1ae4fbf 100644 --- a/dance-channel.test.js +++ b/dance-channel.test.js @@ -91,7 +91,7 @@ test.before(async () => { await startMockH3(); cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-channel-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true }); diff --git a/dressup-crud.test.js b/dressup-crud.test.js index d63afb6..16e374a 100644 --- a/dressup-crud.test.js +++ b/dressup-crud.test.js @@ -41,7 +41,7 @@ test.before(async () => { // empty and this test can't collide with real seed data or the live // pm2 instance. cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-crud-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } diff --git a/dressup-generate.test.js b/dressup-generate.test.js index e7b8c0c..29d03c1 100644 --- a/dressup-generate.test.js +++ b/dressup-generate.test.js @@ -104,7 +104,7 @@ test.before(async () => { await startMockH3(); cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-generate-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } // ensureH3AssetUrl reads these off disk (path.join(__dirname, record.imageUrl)) @@ -331,7 +331,7 @@ test("generate: poll loop honors GENERATION_TIMEOUT_MS wall-clock, not a fixed p const port2 = 22934 + Math.floor(Math.random() * 1000); const base2 = `http://localhost:${port2}`; const cwd2 = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-timeout-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd2, f)); } fs.mkdirSync(path.join(cwd2, "uploads"), { recursive: true }); @@ -451,7 +451,7 @@ test("generate: a transient non-JSON poll response doesn't fail the job, it just const port2 = 23934 + Math.floor(Math.random() * 1000); const base2 = `http://localhost:${port2}`; const cwd2 = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-flaky-poll-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd2, f)); } fs.mkdirSync(path.join(cwd2, "uploads"), { recursive: true }); diff --git a/server.js b/server.js index d435631..7bb7427 100644 --- a/server.js +++ b/server.js @@ -55,6 +55,7 @@ const { } = require("./uploads.js"); const { characters, garments, danceRefs, generationJobs } = require("./dressup-store.js"); const { buildStage2Prompt } = require("./prompt-template.js"); +const { users: userStore, sessions: userSessions } = require("./db.js"); const LIBRARY_PATH = path.join(__dirname, "library.json"); function getLibrary() { @@ -146,6 +147,42 @@ function isAdminAuthed(req) { return true; } +// User account login (dev-plan-3.md T19; dressup-dance-room-mvcp.md +// M.1/M.10) — a separate cookie/session table from admin_session above, on +// purpose: this is for viewers, not the single shared admin credential, and +// the two must never be confused for one another. +const USER_SESSION_COOKIE = "user_session"; +const USER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days, MVCP M.10 + +function hashPassword(password) { + const salt = crypto.randomBytes(16).toString("hex"); + const hash = crypto.scryptSync(password, salt, 64).toString("hex"); + return `${salt}:${hash}`; +} + +function verifyPassword(password, stored) { + const [salt, hash] = (stored || "").split(":"); + if (!salt || !hash) return false; + const candidate = crypto.scryptSync(password, salt, 64); + const expected = Buffer.from(hash, "hex"); + // scryptSync's output length is fixed by the third argument above, so + // both buffers are always the same length — timingSafeEqual would throw + // otherwise, but that can't happen here. + return crypto.timingSafeEqual(candidate, expected); +} + +// Returns the logged-in user (plain object, MVCP M.1 shape) or null. Free +// endpoints (/api/dance/*) don't call this at all — only routes that +// actually need an account (T20's email verify, T21's identity binding, +// anything paid in period 4) do. +function requireUserSession(req) { + const token = parseCookies(req)[USER_SESSION_COOKIE]; + if (!token) return null; + const session = userSessions.findByToken(token); + if (!session || session.kind !== "session") return null; + return userStore.get(session.userId); +} + // Generic CRUD router for the three cold-start asset libraries (T3-T5) — // GET list / POST create / PATCH update / DELETE, backed by store.js. The // three entities' shapes differ (see MVCP M.3/M.4/M.5) but the routing and @@ -963,6 +1000,104 @@ http return; } + // Viewer account auth (dev-plan-3.md T19) — entirely separate from the + // /api/admin/* gate right below. Registering/logging in is always + // optional: /api/dance/* keeps working on the plain viewer_uid cookie + // with no session at all (see requireUserSession's doc comment). + if (pathname === "/api/auth/register" && req.method === "POST") { + readJsonBody(req) + .then((body) => { + const email = (body.email || "").trim().toLowerCase(); + const password = body.password || ""; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid email" })); + return; + } + if (password.length < 8) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "password must be at least 8 characters" })); + return; + } + let user; + try { + user = userStore.create({ email, passwordHash: hashPassword(password) }); + } catch (err) { + if (err.code === "EMAIL_TAKEN") { + res.writeHead(409, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "email already registered" })); + return; + } + throw err; + } + const session = userSessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + USER_SESSION_TTL_MS }); + res.writeHead(200, { + "Content-Type": "application/json", + "Set-Cookie": `${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`, + }); + res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname })); + }) + .catch(() => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON body" })); + }); + return; + } + + if (pathname === "/api/auth/login" && req.method === "POST") { + readJsonBody(req) + .then((body) => { + const email = (body.email || "").trim().toLowerCase(); + const password = body.password || ""; + const user = userStore.findByEmail(email); + // Deliberately the same error for "no such email" and "wrong + // password" — distinguishing the two lets an attacker enumerate + // which emails are registered. + if (!user || !verifyPassword(password, user.passwordHash)) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid email or password" })); + return; + } + const session = userSessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + USER_SESSION_TTL_MS }); + res.writeHead(200, { + "Content-Type": "application/json", + "Set-Cookie": `${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`, + }); + res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname })); + }) + .catch(() => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON body" })); + }); + return; + } + + if (pathname === "/api/auth/logout" && req.method === "POST") { + const token = parseCookies(req)[USER_SESSION_COOKIE]; + if (token) userSessions.revoke(token); + res.writeHead(200, { + "Content-Type": "application/json", + "Set-Cookie": `${USER_SESSION_COOKIE}=; HttpOnly; Path=/; Max-Age=0`, + }); + res.end(JSON.stringify({ ok: true })); + return; + } + + // Debug-only endpoint, per T19's own acceptance criteria — fine to + // keep past this task too (a real account page will want it), but + // intentionally minimal for now. + if (pathname === "/api/auth/me" && req.method === "GET") { + const user = requireUserSession(req); + if (!user) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "unauthorized" })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname })); + return; + } + if (pathname.startsWith("/api/admin/") && !isAdminAuthed(req)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); diff --git a/uploads-route.test.js b/uploads-route.test.js index 7fbee51..68af903 100644 --- a/uploads-route.test.js +++ b/uploads-route.test.js @@ -48,7 +48,7 @@ test.before(async () => { // pattern as dressup-generate.test.js / dance-channel.test.js, so this // test can never touch the real repo's uploads/. cwd = fs.mkdtempSync(path.join(os.tmpdir(), "uploads-route-test-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); }