Commit 90777eda91cac5c41efecfd07f9ca12451e78c8e
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
Showing
7 changed files
with
313 additions
and
7 deletions
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 () => { |
| 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 () => { |
| 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 () => { |
| 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("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("generate: a transient non-JSON poll response doesn'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 () => { |
| 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 | ... | ... |