Commit 90777eda91cac5c41efecfd07f9ca12451e78c8e

Authored by yibo
1 parent 91d455b8

Add T19: register/login/logout backend + /api/auth/me

POST /api/auth/register, POST /api/auth/login, POST /api/auth/logout,
and a debug GET /api/auth/me (T19's own acceptance criteria calls for
it explicitly). Passwords hashed with node:crypto's scryptSync (salt
stored alongside the hash, verified with timingSafeEqual) — still zero
new dependencies.

Sessions live in a separate user_session cookie from admin_session
(T6) — the two are unrelated systems, confirmed by a test that a user
session can't unlock /api/admin/*. Login and "email not registered"
return the identical 401 body so the endpoint can't be used to
enumerate registered emails.

Every test file that spawns server.js in an isolated tmpdir now also
copies db.js alongside it (dance-channel/dressup-generate/dressup-crud/
admin-pages-flow/uploads-route .test.js) — server.js requires db.js
unconditionally now, so those child processes were crashing silently
without it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Deddn3sM3oQnmvqWSMb4y
admin-pages-flow.test.js
... ... @@ -41,7 +41,7 @@ function waitForServer(url, timeoutMs = 10000) {
41 41  
42 42 test.before(async () => {
43 43 cwd = fs.mkdtempSync(path.join(os.tmpdir(), "admin-pages-flow-"));
44   - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
  44 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) {
45 45 fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
46 46 }
47 47  
... ...
auth.test.js 0 → 100644
  1 +// Integration test for T19 (register/login/logout/me), dev-plan-3.md.
  2 +// Spawns server.js on a throwaway port against an isolated tmpdir copy
  3 +// (own data.sqlite too — db.js defaults to __dirname/data.sqlite), same
  4 +// isolation approach as admin-auth.test.js / uploads-route.test.js.
  5 +const test = require("node:test");
  6 +const assert = require("node:assert/strict");
  7 +const fs = require("node:fs");
  8 +const path = require("node:path");
  9 +const os = require("node:os");
  10 +const { spawn } = require("node:child_process");
  11 +
  12 +const PORT = 24934 + Math.floor(Math.random() * 1000);
  13 +const BASE = `http://localhost:${PORT}`;
  14 +const ADMIN_PASSWORD = "test-password-only-for-this-run";
  15 +
  16 +function waitForServer(url, timeoutMs = 10000) {
  17 + const deadline = Date.now() + timeoutMs;
  18 + return (async function poll() {
  19 + while (Date.now() < deadline) {
  20 + try {
  21 + await fetch(url);
  22 + return;
  23 + } catch {
  24 + await new Promise((r) => setTimeout(r, 100));
  25 + }
  26 + }
  27 + throw new Error("server did not come up in time");
  28 + })();
  29 +}
  30 +
  31 +function sessionCookie(res) {
  32 + const setCookie = res.headers.get("set-cookie") || "";
  33 + return setCookie.split(";")[0];
  34 +}
  35 +
  36 +let child;
  37 +let cwd;
  38 +test.before(async () => {
  39 + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "auth-test-"));
  40 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) {
  41 + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
  42 + }
  43 + child = spawn(process.execPath, ["server.js"], {
  44 + cwd,
  45 + env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD },
  46 + stdio: "pipe",
  47 + });
  48 + await waitForServer(BASE);
  49 +});
  50 +
  51 +test.after(() => {
  52 + child.kill();
  53 + fs.rmSync(cwd, { recursive: true, force: true });
  54 +});
  55 +
  56 +test("register: rejects a malformed email", async () => {
  57 + const res = await fetch(`${BASE}/api/auth/register`, {
  58 + method: "POST",
  59 + headers: { "Content-Type": "application/json" },
  60 + body: JSON.stringify({ email: "not-an-email", password: "longenoughpassword" }),
  61 + });
  62 + assert.equal(res.status, 400);
  63 +});
  64 +
  65 +test("register: rejects a too-short password", async () => {
  66 + const res = await fetch(`${BASE}/api/auth/register`, {
  67 + method: "POST",
  68 + headers: { "Content-Type": "application/json" },
  69 + body: JSON.stringify({ email: "short-pw@example.com", password: "short1" }),
  70 + });
  71 + assert.equal(res.status, 400);
  72 +});
  73 +
  74 +test("register -> auto-logged-in session -> GET /api/auth/me -> logout closes the loop", async () => {
  75 + const registerRes = await fetch(`${BASE}/api/auth/register`, {
  76 + method: "POST",
  77 + headers: { "Content-Type": "application/json" },
  78 + body: JSON.stringify({ email: "alice@example.com", password: "correct-horse-battery" }),
  79 + });
  80 + assert.equal(registerRes.status, 200);
  81 + const registered = await registerRes.json();
  82 + assert.equal(registered.email, "alice@example.com");
  83 + assert.equal(registered.emailVerified, false);
  84 + const cookie = sessionCookie(registerRes);
  85 + assert.ok(cookie.startsWith("user_session="), "register should auto-issue a user_session cookie, distinct from admin_session");
  86 +
  87 + const meRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } });
  88 + assert.equal(meRes.status, 200);
  89 + const me = await meRes.json();
  90 + assert.equal(me.id, registered.id);
  91 + assert.equal(me.email, "alice@example.com");
  92 +
  93 + const logoutRes = await fetch(`${BASE}/api/auth/logout`, { method: "POST", headers: { Cookie: cookie } });
  94 + assert.equal(logoutRes.status, 200);
  95 +
  96 + const meAfterLogoutRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } });
  97 + assert.equal(meAfterLogoutRes.status, 401, "the old session token must be dead after logout");
  98 +});
  99 +
  100 +test("GET /api/auth/me with no cookie at all is unauthorized", async () => {
  101 + const res = await fetch(`${BASE}/api/auth/me`);
  102 + assert.equal(res.status, 401);
  103 +});
  104 +
  105 +test("register: duplicate email is a 409, not a silently-created second account", async () => {
  106 + await fetch(`${BASE}/api/auth/register`, {
  107 + method: "POST",
  108 + headers: { "Content-Type": "application/json" },
  109 + body: JSON.stringify({ email: "dup@example.com", password: "correct-horse-battery" }),
  110 + });
  111 + const secondRes = await fetch(`${BASE}/api/auth/register`, {
  112 + method: "POST",
  113 + headers: { "Content-Type": "application/json" },
  114 + body: JSON.stringify({ email: "dup@example.com", password: "a-totally-different-password" }),
  115 + });
  116 + assert.equal(secondRes.status, 409);
  117 +});
  118 +
  119 +test("login: correct credentials issue a fresh session", async () => {
  120 + await fetch(`${BASE}/api/auth/register`, {
  121 + method: "POST",
  122 + headers: { "Content-Type": "application/json" },
  123 + body: JSON.stringify({ email: "bob@example.com", password: "correct-horse-battery" }),
  124 + });
  125 + const loginRes = await fetch(`${BASE}/api/auth/login`, {
  126 + method: "POST",
  127 + headers: { "Content-Type": "application/json" },
  128 + body: JSON.stringify({ email: "bob@example.com", password: "correct-horse-battery" }),
  129 + });
  130 + assert.equal(loginRes.status, 200);
  131 + const cookie = sessionCookie(loginRes);
  132 + const meRes = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } });
  133 + assert.equal(meRes.status, 200);
  134 + const me = await meRes.json();
  135 + assert.equal(me.email, "bob@example.com");
  136 +});
  137 +
  138 +test("login: wrong password and unknown email return the exact same error (no email enumeration)", async () => {
  139 + await fetch(`${BASE}/api/auth/register`, {
  140 + method: "POST",
  141 + headers: { "Content-Type": "application/json" },
  142 + body: JSON.stringify({ email: "carol@example.com", password: "correct-horse-battery" }),
  143 + });
  144 +
  145 + const wrongPasswordRes = await fetch(`${BASE}/api/auth/login`, {
  146 + method: "POST",
  147 + headers: { "Content-Type": "application/json" },
  148 + body: JSON.stringify({ email: "carol@example.com", password: "totally-wrong-password" }),
  149 + });
  150 + const unknownEmailRes = await fetch(`${BASE}/api/auth/login`, {
  151 + method: "POST",
  152 + headers: { "Content-Type": "application/json" },
  153 + body: JSON.stringify({ email: "nobody-registered-this@example.com", password: "whatever" }),
  154 + });
  155 +
  156 + assert.equal(wrongPasswordRes.status, 401);
  157 + assert.equal(unknownEmailRes.status, 401);
  158 + const [wrongBody, unknownBody] = await Promise.all([wrongPasswordRes.json(), unknownEmailRes.json()]);
  159 + assert.deepEqual(wrongBody, unknownBody, "the two failure modes must be indistinguishable to the caller");
  160 +});
  161 +
  162 +test("a user_session cookie doesn't unlock /api/admin/* (the two session systems stay separate)", async () => {
  163 + const registerRes = await fetch(`${BASE}/api/auth/register`, {
  164 + method: "POST",
  165 + headers: { "Content-Type": "application/json" },
  166 + body: JSON.stringify({ email: "dana@example.com", password: "correct-horse-battery" }),
  167 + });
  168 + const cookie = sessionCookie(registerRes);
  169 + const res = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } });
  170 + assert.equal(res.status, 401);
  171 +});
... ...
dance-channel.test.js
... ... @@ -91,7 +91,7 @@ test.before(async () =&gt; {
91 91 await startMockH3();
92 92  
93 93 cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-channel-"));
94   - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
  94 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) {
95 95 fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
96 96 }
97 97 fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true });
... ...
dressup-crud.test.js
... ... @@ -41,7 +41,7 @@ test.before(async () =&gt; {
41 41 // empty and this test can't collide with real seed data or the live
42 42 // pm2 instance.
43 43 cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-crud-"));
44   - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
  44 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) {
45 45 fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
46 46 }
47 47  
... ...
dressup-generate.test.js
... ... @@ -104,7 +104,7 @@ test.before(async () =&gt; {
104 104 await startMockH3();
105 105  
106 106 cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-generate-"));
107   - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
  107 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) {
108 108 fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
109 109 }
110 110 // ensureH3AssetUrl reads these off disk (path.join(__dirname, record.imageUrl))
... ... @@ -331,7 +331,7 @@ test(&quot;generate: poll loop honors GENERATION_TIMEOUT_MS wall-clock, not a fixed p
331 331 const port2 = 22934 + Math.floor(Math.random() * 1000);
332 332 const base2 = `http://localhost:${port2}`;
333 333 const cwd2 = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-timeout-"));
334   - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
  334 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) {
335 335 fs.copyFileSync(path.join(__dirname, f), path.join(cwd2, f));
336 336 }
337 337 fs.mkdirSync(path.join(cwd2, "uploads"), { recursive: true });
... ... @@ -451,7 +451,7 @@ test(&quot;generate: a transient non-JSON poll response doesn&#39;t fail the job, it just
451 451 const port2 = 23934 + Math.floor(Math.random() * 1000);
452 452 const base2 = `http://localhost:${port2}`;
453 453 const cwd2 = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-flaky-poll-"));
454   - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
  454 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) {
455 455 fs.copyFileSync(path.join(__dirname, f), path.join(cwd2, f));
456 456 }
457 457 fs.mkdirSync(path.join(cwd2, "uploads"), { recursive: true });
... ...
server.js
... ... @@ -55,6 +55,7 @@ const {
55 55 } = require("./uploads.js");
56 56 const { characters, garments, danceRefs, generationJobs } = require("./dressup-store.js");
57 57 const { buildStage2Prompt } = require("./prompt-template.js");
  58 +const { users: userStore, sessions: userSessions } = require("./db.js");
58 59  
59 60 const LIBRARY_PATH = path.join(__dirname, "library.json");
60 61 function getLibrary() {
... ... @@ -146,6 +147,42 @@ function isAdminAuthed(req) {
146 147 return true;
147 148 }
148 149  
  150 +// User account login (dev-plan-3.md T19; dressup-dance-room-mvcp.md
  151 +// M.1/M.10) — a separate cookie/session table from admin_session above, on
  152 +// purpose: this is for viewers, not the single shared admin credential, and
  153 +// the two must never be confused for one another.
  154 +const USER_SESSION_COOKIE = "user_session";
  155 +const USER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days, MVCP M.10
  156 +
  157 +function hashPassword(password) {
  158 + const salt = crypto.randomBytes(16).toString("hex");
  159 + const hash = crypto.scryptSync(password, salt, 64).toString("hex");
  160 + return `${salt}:${hash}`;
  161 +}
  162 +
  163 +function verifyPassword(password, stored) {
  164 + const [salt, hash] = (stored || "").split(":");
  165 + if (!salt || !hash) return false;
  166 + const candidate = crypto.scryptSync(password, salt, 64);
  167 + const expected = Buffer.from(hash, "hex");
  168 + // scryptSync's output length is fixed by the third argument above, so
  169 + // both buffers are always the same length — timingSafeEqual would throw
  170 + // otherwise, but that can't happen here.
  171 + return crypto.timingSafeEqual(candidate, expected);
  172 +}
  173 +
  174 +// Returns the logged-in user (plain object, MVCP M.1 shape) or null. Free
  175 +// endpoints (/api/dance/*) don't call this at all — only routes that
  176 +// actually need an account (T20's email verify, T21's identity binding,
  177 +// anything paid in period 4) do.
  178 +function requireUserSession(req) {
  179 + const token = parseCookies(req)[USER_SESSION_COOKIE];
  180 + if (!token) return null;
  181 + const session = userSessions.findByToken(token);
  182 + if (!session || session.kind !== "session") return null;
  183 + return userStore.get(session.userId);
  184 +}
  185 +
149 186 // Generic CRUD router for the three cold-start asset libraries (T3-T5) —
150 187 // GET list / POST create / PATCH update / DELETE, backed by store.js. The
151 188 // three entities' shapes differ (see MVCP M.3/M.4/M.5) but the routing and
... ... @@ -963,6 +1000,104 @@ http
963 1000 return;
964 1001 }
965 1002  
  1003 + // Viewer account auth (dev-plan-3.md T19) — entirely separate from the
  1004 + // /api/admin/* gate right below. Registering/logging in is always
  1005 + // optional: /api/dance/* keeps working on the plain viewer_uid cookie
  1006 + // with no session at all (see requireUserSession's doc comment).
  1007 + if (pathname === "/api/auth/register" && req.method === "POST") {
  1008 + readJsonBody(req)
  1009 + .then((body) => {
  1010 + const email = (body.email || "").trim().toLowerCase();
  1011 + const password = body.password || "";
  1012 + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
  1013 + res.writeHead(400, { "Content-Type": "application/json" });
  1014 + res.end(JSON.stringify({ error: "invalid email" }));
  1015 + return;
  1016 + }
  1017 + if (password.length < 8) {
  1018 + res.writeHead(400, { "Content-Type": "application/json" });
  1019 + res.end(JSON.stringify({ error: "password must be at least 8 characters" }));
  1020 + return;
  1021 + }
  1022 + let user;
  1023 + try {
  1024 + user = userStore.create({ email, passwordHash: hashPassword(password) });
  1025 + } catch (err) {
  1026 + if (err.code === "EMAIL_TAKEN") {
  1027 + res.writeHead(409, { "Content-Type": "application/json" });
  1028 + res.end(JSON.stringify({ error: "email already registered" }));
  1029 + return;
  1030 + }
  1031 + throw err;
  1032 + }
  1033 + const session = userSessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + USER_SESSION_TTL_MS });
  1034 + res.writeHead(200, {
  1035 + "Content-Type": "application/json",
  1036 + "Set-Cookie": `${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`,
  1037 + });
  1038 + res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname }));
  1039 + })
  1040 + .catch(() => {
  1041 + res.writeHead(400, { "Content-Type": "application/json" });
  1042 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  1043 + });
  1044 + return;
  1045 + }
  1046 +
  1047 + if (pathname === "/api/auth/login" && req.method === "POST") {
  1048 + readJsonBody(req)
  1049 + .then((body) => {
  1050 + const email = (body.email || "").trim().toLowerCase();
  1051 + const password = body.password || "";
  1052 + const user = userStore.findByEmail(email);
  1053 + // Deliberately the same error for "no such email" and "wrong
  1054 + // password" — distinguishing the two lets an attacker enumerate
  1055 + // which emails are registered.
  1056 + if (!user || !verifyPassword(password, user.passwordHash)) {
  1057 + res.writeHead(401, { "Content-Type": "application/json" });
  1058 + res.end(JSON.stringify({ error: "invalid email or password" }));
  1059 + return;
  1060 + }
  1061 + const session = userSessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + USER_SESSION_TTL_MS });
  1062 + res.writeHead(200, {
  1063 + "Content-Type": "application/json",
  1064 + "Set-Cookie": `${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`,
  1065 + });
  1066 + res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname }));
  1067 + })
  1068 + .catch(() => {
  1069 + res.writeHead(400, { "Content-Type": "application/json" });
  1070 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  1071 + });
  1072 + return;
  1073 + }
  1074 +
  1075 + if (pathname === "/api/auth/logout" && req.method === "POST") {
  1076 + const token = parseCookies(req)[USER_SESSION_COOKIE];
  1077 + if (token) userSessions.revoke(token);
  1078 + res.writeHead(200, {
  1079 + "Content-Type": "application/json",
  1080 + "Set-Cookie": `${USER_SESSION_COOKIE}=; HttpOnly; Path=/; Max-Age=0`,
  1081 + });
  1082 + res.end(JSON.stringify({ ok: true }));
  1083 + return;
  1084 + }
  1085 +
  1086 + // Debug-only endpoint, per T19's own acceptance criteria — fine to
  1087 + // keep past this task too (a real account page will want it), but
  1088 + // intentionally minimal for now.
  1089 + if (pathname === "/api/auth/me" && req.method === "GET") {
  1090 + const user = requireUserSession(req);
  1091 + if (!user) {
  1092 + res.writeHead(401, { "Content-Type": "application/json" });
  1093 + res.end(JSON.stringify({ error: "unauthorized" }));
  1094 + return;
  1095 + }
  1096 + res.writeHead(200, { "Content-Type": "application/json" });
  1097 + res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname }));
  1098 + return;
  1099 + }
  1100 +
966 1101 if (pathname.startsWith("/api/admin/") && !isAdminAuthed(req)) {
967 1102 res.writeHead(401, { "Content-Type": "application/json" });
968 1103 res.end(JSON.stringify({ error: "unauthorized" }));
... ...
uploads-route.test.js
... ... @@ -48,7 +48,7 @@ test.before(async () =&gt; {
48 48 // pattern as dressup-generate.test.js / dance-channel.test.js, so this
49 49 // test can never touch the real repo's uploads/.
50 50 cwd = fs.mkdtempSync(path.join(os.tmpdir(), "uploads-route-test-"));
51   - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
  51 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) {
52 52 fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
53 53 }
54 54  
... ...