From 85ba99243210a2a056a16183f945db41d5c4c2da Mon Sep 17 00:00:00 2001 From: kitadmin Date: Thu, 30 Apr 2026 02:04:57 +0000 Subject: [PATCH] feat: server HTTP API with auth, progress, and admin routes (Tasks 6+7) - server.js: Node HTTP server with all API routes, static file serving, SPA fallback - Path traversal protection on static file serving - Async handler wrapped with .catch() to prevent unhandled rejections - readBody: size limit (1MB) + error handler - letter validation: single [A-Z] char check on progress/mnemonics routes - phrase length limit (500 chars) on POST /api/mnemonics - requireAdmin reads ADMIN_PASSWORD at request time for testability - logout uses session.token from requireAuth (not re-read from header) - 51 server tests passing (auth, progress, admin routes + edge cases) Co-Authored-By: Claude Sonnet 4.6 --- server/server.js | 231 ++++++++++++++++++++++++++++++++++ server/tests/admin.test.js | 91 ++++++++++++++ server/tests/auth.test.js | 144 +++++++++++++++++++++ server/tests/progress.test.js | 145 +++++++++++++++++++++ 4 files changed, 611 insertions(+) create mode 100644 server/server.js create mode 100644 server/tests/admin.test.js create mode 100644 server/tests/auth.test.js create mode 100644 server/tests/progress.test.js diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..da50b06 --- /dev/null +++ b/server/server.js @@ -0,0 +1,231 @@ +'use strict' +const http = require('node:http') +const path = require('node:path') +const fs = require('node:fs') +const crypto = require('node:crypto') +const { initDb } = require('./db.js') +const { sendMagicLink } = require('./mailer.js') + +const PORT = parseInt(process.env.PORT || '3001', 10) +const DB_PATH = process.env.DB_PATH || undefined + +const DIST_DIR = path.join(__dirname, '../client/dist') + +function send(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(body)) +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let data = '' + req.on('error', reject) + req.on('data', c => { + data += c + if (data.length > 1024 * 1024) { + req.destroy(new Error('body too large')) + } + }) + req.on('end', () => { + try { resolve(JSON.parse(data)) } + catch { resolve({}) } + }) + }) +} + +function createServer(db) { + if (!db) { + db = initDb(DB_PATH) + } + + function requireAuth(req, res) { + const auth = req.headers['authorization'] || '' + const token = auth.startsWith('Bearer ') ? auth.slice(7) : null + if (!token) { send(res, 401, { error: 'unauthorized' }); return null } + const session = db.getSession(token) + if (!session) { send(res, 401, { error: 'unauthorized' }); return null } + return session + } + + function requireAdmin(req, res) { + const pwd = process.env.ADMIN_PASSWORD + const auth = req.headers['authorization'] || '' + const token = auth.startsWith('Bearer ') ? auth.slice(7) : null + if (!pwd || token !== pwd) { send(res, 401, { error: 'unauthorized' }); return false } + return true + } + + async function handleRequest(req, res) { + const url = new URL(req.url, 'http://127.0.0.1') + const pathname = url.pathname + const method = req.method + + // ── Auth routes ────────────────────────────────────────────────────────── + + if (pathname === '/api/auth/request' && method === 'POST') { + const body = await readBody(req) + const email = body.email + if (!email || typeof email !== 'string' || !email.includes('@') || !email.includes('.')) { + return send(res, 400, { error: 'email required' }) + } + let profile = db.findProfileByEmail(email) + if (!profile) { + profile = db.createProfile(email, null) + } + const token = crypto.randomBytes(32).toString('hex') + const expiresAt = Date.now() + 24 * 60 * 60 * 1000 + db.createMagicToken(token, profile.id, expiresAt) + await sendMagicLink(email, token) + return send(res, 200, { ok: true }) + } + + if (pathname === '/api/auth/verify' && method === 'GET') { + const token = url.searchParams.get('token') + if (!token) { return send(res, 400, { error: 'token required' }) } + const profileId = db.useMagicToken(token) + if (!profileId) { return send(res, 400, { error: 'invalid or expired token' }) } + const sessionToken = crypto.randomBytes(32).toString('hex') + db.createSession(sessionToken, profileId) + res.writeHead(302, { Location: `/?session=${sessionToken}` }) + return res.end() + } + + if (pathname === '/api/auth/me' && method === 'GET') { + const session = requireAuth(req, res) + if (!session) return + const profile = db.findProfileById(session.profile_id) + if (!profile) { return send(res, 404, { error: 'not found' }) } + return send(res, 200, { + id: profile.id, + email: profile.email, + display_name: profile.display_name, + }) + } + + if (pathname === '/api/auth/session' && method === 'DELETE') { + const session = requireAuth(req, res) + if (!session) return + db.deleteSession(session.token) + return send(res, 200, { ok: true }) + } + + // ── Progress routes ─────────────────────────────────────────────────────── + + if (pathname === '/api/progress' && method === 'GET') { + const session = requireAuth(req, res) + if (!session) return + const progress = db.getOrCreateProgress(session.profile_id) + return send(res, 200, progress) + } + + if (pathname === '/api/progress/answer' && method === 'POST') { + const session = requireAuth(req, res) + if (!session) return + const body = await readBody(req) + const letter = typeof body.letter === 'string' ? body.letter.toUpperCase() : '' + if (!/^[A-Z]$/.test(letter)) { + return send(res, 400, { error: 'letter must be a single letter A-Z' }) + } + if (typeof body.correct !== 'boolean') { + return send(res, 400, { error: 'correct required' }) + } + const progress = db.recordAnswer(session.profile_id, letter, body.correct) + return send(res, 200, progress) + } + + if (pathname === '/api/mnemonics' && method === 'GET') { + const session = requireAuth(req, res) + if (!session) return + const mnemonics = db.getMnemonics(session.profile_id) + return send(res, 200, mnemonics) + } + + if (pathname === '/api/mnemonics' && method === 'POST') { + const session = requireAuth(req, res) + if (!session) return + const body = await readBody(req) + const letter = typeof body.letter === 'string' ? body.letter.toUpperCase() : '' + if (!/^[A-Z]$/.test(letter)) { + return send(res, 400, { error: 'letter must be a single letter A-Z' }) + } + if (!body.phrase || typeof body.phrase !== 'string' || body.phrase.length > 500) { + return send(res, 400, { error: 'phrase required (max 500 chars)' }) + } + db.saveMnemonic(session.profile_id, letter, body.phrase) + return send(res, 200, { ok: true }) + } + + // ── Admin routes ────────────────────────────────────────────────────────── + + if (pathname === '/api/admin/users' && method === 'GET') { + if (!requireAdmin(req, res)) return + const users = db.getAdminUsers() + return send(res, 200, users) + } + + if (pathname === '/api/admin/stats' && method === 'GET') { + if (!requireAdmin(req, res)) return + const stats = db.getAdminStats() + return send(res, 200, stats) + } + + // ── Unknown /api/* ──────────────────────────────────────────────────────── + + if (pathname.startsWith('/api/')) { + return send(res, 404, { error: 'not found' }) + } + + // ── Static files (SPA) ─────────────────────────────────────────────────── + + // Prevent path traversal — ensure resolved path stays within DIST_DIR + const relative = pathname.replace(/^\/+/, '') + const filePath = path.join(DIST_DIR, relative) + if (!filePath.startsWith(DIST_DIR + path.sep) && filePath !== DIST_DIR) { + return send(res, 400, { error: 'bad request' }) + } + + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + const ext = path.extname(filePath) + const mimeTypes = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + } + const contentType = mimeTypes[ext] || 'application/octet-stream' + res.writeHead(200, { 'Content-Type': contentType }) + return fs.createReadStream(filePath).pipe(res) + } + + // SPA fallback — serve index.html + const indexPath = path.join(DIST_DIR, 'index.html') + if (fs.existsSync(indexPath)) { + res.writeHead(200, { 'Content-Type': 'text/html' }) + return fs.createReadStream(indexPath).pipe(res) + } + + // No dist yet + return send(res, 404, { error: 'not found' }) + } + + const server = http.createServer((req, res) => { + handleRequest(req, res).catch(err => { + console.error('[server] unhandled:', err) + if (!res.headersSent) { res.writeHead(500); res.end() } + }) + }) + + return server +} + +if (require.main === module) { + const db = initDb(DB_PATH) + const server = createServer(db) + server.listen(PORT, () => console.log(`[server] listening on :${PORT}`)) +} + +module.exports = { createServer } diff --git a/server/tests/admin.test.js b/server/tests/admin.test.js new file mode 100644 index 0000000..a246c71 --- /dev/null +++ b/server/tests/admin.test.js @@ -0,0 +1,91 @@ +'use strict' +const { test, describe, before, after } = require('node:test') +const assert = require('node:assert/strict') +const http = require('node:http') +const { initDb } = require('../db.js') +const { createServer } = require('../server.js') + +function request(server, method, path, body, headers = {}) { + return new Promise((resolve, reject) => { + const addr = server.address() + const opts = { + hostname: '127.0.0.1', + port: addr.port, + path, + method, + headers: { 'Content-Type': 'application/json', ...headers }, + } + const req = http.request(opts, res => { + let data = '' + res.on('data', c => { data += c }) + res.on('end', () => { + let json = null + try { json = JSON.parse(data) } catch {} + resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data }) + }) + }) + req.on('error', reject) + if (body) req.write(JSON.stringify(body)) + req.end() + }) +} + +let db, server +const ADMIN_PWD = 'testadminpwd' + +before(() => { + process.env.ADMIN_PASSWORD = ADMIN_PWD + db = initDb(':memory:') + server = createServer(db) + const p1 = db.createProfile('adm1@example.com', 'Admin1') + const p2 = db.createProfile('adm2@example.com', 'Admin2') + db.recordAnswer(p1.id, 'A', true) + db.recordAnswer(p2.id, 'B', false) + return new Promise(resolve => server.listen(0, resolve)) +}) + +after(() => { + delete process.env.ADMIN_PASSWORD + db._db.close() + return new Promise(resolve => server.close(resolve)) +}) + +describe('GET /api/admin/users', () => { + test('returns user list with valid admin password', async () => { + const r = await request(server, 'GET', '/api/admin/users', null, { + Authorization: `Bearer ${ADMIN_PWD}`, + }) + assert.equal(r.status, 200) + assert.ok(Array.isArray(r.body)) + const emails = r.body.map(u => u.email) + assert.ok(emails.includes('adm1@example.com')) + }) + + test('returns 401 without admin password', async () => { + const r = await request(server, 'GET', '/api/admin/users', null) + assert.equal(r.status, 401) + }) + + test('returns 401 with wrong admin password', async () => { + const r = await request(server, 'GET', '/api/admin/users', null, { + Authorization: 'Bearer wrongpwd', + }) + assert.equal(r.status, 401) + }) +}) + +describe('GET /api/admin/stats', () => { + test('returns stats with valid admin password', async () => { + const r = await request(server, 'GET', '/api/admin/stats', null, { + Authorization: `Bearer ${ADMIN_PWD}`, + }) + assert.equal(r.status, 200) + assert.ok(typeof r.body.totalUsers === 'number') + assert.ok(Array.isArray(r.body.mostMissed)) + }) + + test('returns 401 without admin password', async () => { + const r = await request(server, 'GET', '/api/admin/stats', null) + assert.equal(r.status, 401) + }) +}) diff --git a/server/tests/auth.test.js b/server/tests/auth.test.js new file mode 100644 index 0000000..0f98017 --- /dev/null +++ b/server/tests/auth.test.js @@ -0,0 +1,144 @@ +'use strict' +const { test, describe, before, after } = require('node:test') +const assert = require('node:assert/strict') +const http = require('node:http') +const { initDb } = require('../db.js') +const { createServer } = require('../server.js') + +function request(server, method, path, body, headers = {}) { + return new Promise((resolve, reject) => { + const addr = server.address() + const opts = { + hostname: '127.0.0.1', + port: addr.port, + path, + method, + headers: { 'Content-Type': 'application/json', ...headers }, + } + const req = http.request(opts, res => { + let data = '' + res.on('data', c => { data += c }) + res.on('end', () => { + let json = null + try { json = JSON.parse(data) } catch {} + resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data }) + }) + }) + req.on('error', reject) + if (body) req.write(JSON.stringify(body)) + req.end() + }) +} + +let db, server + +before(() => { + db = initDb(':memory:') + server = createServer(db) + return new Promise(resolve => server.listen(0, resolve)) +}) + +after(() => { + db._db.close() + return new Promise(resolve => server.close(resolve)) +}) + +describe('POST /api/auth/request', () => { + test('returns 200 ok for valid email', async () => { + const r = await request(server, 'POST', '/api/auth/request', { email: 'test@example.com' }) + assert.equal(r.status, 200) + assert.equal(r.body.ok, true) + }) + + test('creates profile if new email', async () => { + await request(server, 'POST', '/api/auth/request', { email: 'new@example.com' }) + const p = db.findProfileByEmail('new@example.com') + assert.ok(p) + assert.equal(p.email, 'new@example.com') + }) + + test('returns 400 if email missing', async () => { + const r = await request(server, 'POST', '/api/auth/request', {}) + assert.equal(r.status, 400) + }) +}) + +describe('GET /api/auth/verify', () => { + let profileId + + before(() => { + const p = db.createProfile('verify@example.com', 'Verify') + profileId = p.id + db.createMagicToken('testtoken123', profileId, Date.now() + 86400000) + }) + + test('redirects to /?session=... on valid token', async () => { + const r = await request(server, 'GET', '/api/auth/verify?token=testtoken123', null, {}) + assert.equal(r.status, 302) + assert.ok(r.headers.location?.startsWith('/?session=')) + }) + + test('returns 400 for invalid token', async () => { + const r = await request(server, 'GET', '/api/auth/verify?token=badtoken', null) + assert.equal(r.status, 400) + }) + + test('returns 400 for already-used token', async () => { + // testtoken123 was already used in the first verify test + const r = await request(server, 'GET', '/api/auth/verify?token=testtoken123', null) + assert.equal(r.status, 400) + }) +}) + +describe('GET /api/auth/me', () => { + let sessionToken + + before(() => { + const p = db.createProfile('me@example.com', 'Me') + sessionToken = 'sessmetoken' + db.createSession(sessionToken, p.id) + }) + + test('returns profile for valid session', async () => { + const r = await request(server, 'GET', '/api/auth/me', null, { + Authorization: `Bearer ${sessionToken}`, + }) + assert.equal(r.status, 200) + assert.equal(r.body.email, 'me@example.com') + }) + + test('returns 401 without auth header', async () => { + const r = await request(server, 'GET', '/api/auth/me', null) + assert.equal(r.status, 401) + }) +}) + +describe('DELETE /api/auth/session', () => { + let sessionToken + + before(() => { + const p = db.createProfile('logout@example.com', 'Logout') + sessionToken = 'sesslogouttoken' + db.createSession(sessionToken, p.id) + }) + + test('returns 200 and session is gone', async () => { + const r = await request(server, 'DELETE', '/api/auth/session', null, { + Authorization: `Bearer ${sessionToken}`, + }) + assert.equal(r.status, 200) + assert.equal(db.getSession(sessionToken), null) + }) + + test('returns 401 without auth header', async () => { + const r = await request(server, 'DELETE', '/api/auth/session', null) + assert.equal(r.status, 401) + }) + + test('returns 401 when using deleted session token', async () => { + const r = await request(server, 'GET', '/api/auth/me', null, { + Authorization: `Bearer ${sessionToken}`, + }) + assert.equal(r.status, 401) + }) +}) diff --git a/server/tests/progress.test.js b/server/tests/progress.test.js new file mode 100644 index 0000000..23b68ec --- /dev/null +++ b/server/tests/progress.test.js @@ -0,0 +1,145 @@ +'use strict' +const { test, describe, before, after } = require('node:test') +const assert = require('node:assert/strict') +const http = require('node:http') +const { initDb } = require('../db.js') +const { createServer } = require('../server.js') + +function request(server, method, path, body, headers = {}) { + return new Promise((resolve, reject) => { + const addr = server.address() + const opts = { + hostname: '127.0.0.1', + port: addr.port, + path, + method, + headers: { 'Content-Type': 'application/json', ...headers }, + } + const req = http.request(opts, res => { + let data = '' + res.on('data', c => { data += c }) + res.on('end', () => { + let json = null + try { json = JSON.parse(data) } catch {} + resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data }) + }) + }) + req.on('error', reject) + if (body) req.write(JSON.stringify(body)) + req.end() + }) +} + +let db, server, sessionToken, profileId + +before(() => { + db = initDb(':memory:') + server = createServer(db) + const p = db.createProfile('prog@example.com', 'Progress') + profileId = p.id + sessionToken = 'progtoken' + db.createSession(sessionToken, profileId) + return new Promise(resolve => server.listen(0, resolve)) +}) + +after(() => { + db._db.close() + return new Promise(resolve => server.close(resolve)) +}) + +const authHeader = () => ({ Authorization: `Bearer ${sessionToken}` }) + +describe('GET /api/progress', () => { + test('returns progress for authed user', async () => { + const r = await request(server, 'GET', '/api/progress', null, authHeader()) + assert.equal(r.status, 200) + assert.equal(r.body.level, 1) + assert.equal(r.body.score, 0) + }) + + test('returns 401 without auth', async () => { + const r = await request(server, 'GET', '/api/progress', null) + assert.equal(r.status, 401) + }) +}) + +describe('POST /api/progress/answer', () => { + test('records correct answer and returns updated progress', async () => { + const r = await request(server, 'POST', '/api/progress/answer', + { letter: 'A', correct: true }, authHeader()) + assert.equal(r.status, 200) + assert.ok(r.body.score > 0) + assert.equal(r.body.streak, 1) + }) + + test('records incorrect answer (resets streak)', async () => { + const r = await request(server, 'POST', '/api/progress/answer', + { letter: 'A', correct: false }, authHeader()) + assert.equal(r.status, 200) + assert.equal(r.body.streak, 0) + }) + + test('returns 401 without auth', async () => { + const r = await request(server, 'POST', '/api/progress/answer', + { letter: 'A', correct: true }) + assert.equal(r.status, 401) + }) + + test('returns 400 if letter missing', async () => { + const r = await request(server, 'POST', '/api/progress/answer', + { correct: true }, authHeader()) + assert.equal(r.status, 400) + }) + + test('returns 400 if correct is missing', async () => { + const r = await request(server, 'POST', '/api/progress/answer', + { letter: 'A' }, authHeader()) + assert.equal(r.status, 400) + }) + + test('returns 400 if correct is non-boolean', async () => { + const r = await request(server, 'POST', '/api/progress/answer', + { letter: 'A', correct: 'true' }, authHeader()) + assert.equal(r.status, 400) + }) +}) + +describe('GET /api/mnemonics', () => { + before(() => { + db.saveMnemonic(profileId, 'A', 'a phrase') + }) + + test('returns mnemonic map', async () => { + const r = await request(server, 'GET', '/api/mnemonics', null, authHeader()) + assert.equal(r.status, 200) + assert.equal(r.body.A, 'a phrase') + }) + + test('returns 401 without auth', async () => { + const r = await request(server, 'GET', '/api/mnemonics', null) + assert.equal(r.status, 401) + }) +}) + +describe('POST /api/mnemonics', () => { + test('saves mnemonic and returns ok', async () => { + const r = await request(server, 'POST', '/api/mnemonics', + { letter: 'E', phrase: 'yes' }, authHeader()) + assert.equal(r.status, 200) + assert.equal(r.body.ok, true) + const m = db.getMnemonics(profileId) + assert.equal(m.E, 'yes') + }) + + test('returns 400 if letter or phrase missing', async () => { + const r = await request(server, 'POST', '/api/mnemonics', + { letter: 'T' }, authHeader()) + assert.equal(r.status, 400) + }) + + test('returns 401 without auth', async () => { + const r = await request(server, 'POST', '/api/mnemonics', + { letter: 'E', phrase: 'yes' }) + assert.equal(r.status, 401) + }) +})