Initial commit: courtlab tactical board with team management and MP4 export

This commit is contained in:
2026-09-08 12:22:20 +09:00
commit d2e39a452e
43 changed files with 7812 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import { isValidPlay } from '../src/playRepository.js';
import { createMongoStore, DEFAULT_MONGODB_DB, DEFAULT_MONGODB_URI } from './db.js';
import { isDevelopmentOrigin, loadConfig } from './config.js';
import { hashPassword, hashToken, newSessionToken, sessionExpiry, SESSION_COOKIE, verifyPassword, validEmail } from './security.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(here, '..');
const defaultStaticRoot = path.join(projectRoot, 'dist');
function nowDate() { return new Date(); }
function iso(value) { return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); }
function safeText(value, fallback = '') { return String(value ?? '').trim() || fallback; }
function publicUser(row) { return { id: row.id, email: row.email, displayName: row.displayName, status: row.status, isOperator: Boolean(row.isOperator) }; }
function publicTeam(row) { return { id: row.id, name: row.name, ownerUserId: row.ownerUserId, role: row.role, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt) }; }
function errorPayload(code, message) { return { error: { code, message } }; }
function fail(reply, status, code, message) { return reply.code(status).send(errorPayload(code, message)); }
function parsePositiveId(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value); }
function isProtectedConfigPath(url) { return /(?:^|\/)\.config\.json(?:\.[^/]*)?$/.test(String(url).split('?')[0]); }
function requestOriginAllowed(request, allowedOrigins, mode) {
const origin = request.headers.origin;
if (!origin) return true;
if (origin === 'null') return false;
if (mode === 'production' && isDevelopmentOrigin(origin)) return false;
if (allowedOrigins.includes(origin)) return true;
if (mode !== 'production' && isDevelopmentOrigin(origin)) return true;
return false;
}
function bodySchema(properties, required = []) { return { type: 'object', additionalProperties: false, required, properties }; }
const emailSchema = { type: 'string', minLength: 3, maxLength: 320 };
const passwordSchema = { type: 'string', minLength: 8, maxLength: 200 };
export async function buildServer(options = {}) {
const config = options.config || await loadConfig();
const mode = options.mode || config.server.mode;
const store = options.db || await createMongoStore({ config, uri: options.mongoUri || config.mongodb.uri, dbName: options.mongoDb || config.mongodb.db, client: options.mongoClient, serverSelectionTimeoutMS: options.serverSelectionTimeoutMs });
const staticRoot = options.staticRoot || defaultStaticRoot;
const allowedOrigins = options.allowedOrigins || (options.allowedOrigin ? String(options.allowedOrigin).split(',').map((origin) => origin.trim()).filter(Boolean) : config.server.allowedOrigins);
const secureCookie = mode === 'production' ? true : options.secureCookie ?? config.server.secureCookie;
const app = Fastify({ logger: options.logger ?? false, bodyLimit: 2_500_000 });
const loginAttempts = new Map(); const registerAttempts = new Map();
app.decorate('courtStore', store);
app.decorateRequest('user', null);
app.register(cookie);
app.addHook('onRequest', async (request, reply) => {
if (isProtectedConfigPath(request.url)) return fail(reply, 404, 'not_found', '요청한 경로를 찾을 수 없습니다');
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method) && !requestOriginAllowed(request, allowedOrigins, mode)) return fail(reply, 403, 'csrf_origin', '허용되지 않은 요청 출처입니다');
const token = request.cookies[SESSION_COOKIE];
if (!token) return;
const session = await store.sessions.findOne({ tokenHash: hashToken(token), expiresAt: { $gt: nowDate() } });
if (!session) return;
const row = await store.users.findOne({ id: session.userId });
if (!row || row.status === 'suspended') return;
request.user = row;
});
app.addHook('onClose', async () => { await store.close(); });
const requireUser = async (request, reply) => {
if (!request.user) return fail(reply, 401, 'unauthenticated', '로그인이 필요합니다');
if (request.user.status !== 'approved') return fail(reply, 403, 'approval_required', '운영자 승인을 기다리는 계정입니다');
};
const requireOperator = async (request, reply) => {
const auth = await requireUser(request, reply); if (auth) return auth;
if (!request.user.isOperator) return fail(reply, 403, 'operator_required', '운영자 권한이 필요합니다');
};
async function teamMember(teamId, userId) {
const member = await store.teamMembers.findOne({ teamId, userId });
if (!member) return null;
const team = await store.teams.findOne({ id: teamId });
return team ? { ...team, role: member.role } : null;
}
const requireTeam = (minimum = 'viewer') => async (request, reply) => {
const auth = await requireUser(request, reply); if (auth) return auth;
const teamId = request.params.teamId;
if (!parsePositiveId(teamId)) return fail(reply, 400, 'invalid_team', '팀 식별자가 올바르지 않습니다');
const member = await teamMember(teamId, request.user.id);
if (!member) return fail(reply, 404, 'team_not_found', '팀을 찾을 수 없습니다');
const rank = { viewer: 1, editor: 2, owner: 3 };
if ((rank[member.role] || 0) < (rank[minimum] || 1)) return fail(reply, 403, 'team_forbidden', '팀 권한이 부족합니다');
request.team = member;
};
app.get('/api/health', async () => ({ ok: true }));
app.post('/api/auth/register', {
schema: { body: bodySchema({ email: emailSchema, password: passwordSchema, displayName: { type: 'string', minLength: 1, maxLength: 80 } }, ['email', 'password']) },
}, async (request, reply) => {
const email = safeText(request.body.email).toLowerCase(); const password = String(request.body.password || ''); const displayName = safeText(request.body.displayName, email.split('@')[0]).slice(0, 80);
const key = `${request.ip}:${email}`; const currentTime = Date.now(); const attempt = registerAttempts.get(key);
for (const [entry, value] of registerAttempts) if (value.resetAt <= currentTime) registerAttempts.delete(entry);
if (attempt && attempt.until > currentTime) return fail(reply, 429, 'register_throttled', '가입 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요');
const nextAttempt = attempt && attempt.resetAt > currentTime ? { count: attempt.count + 1, resetAt: attempt.resetAt } : { count: 1, resetAt: currentTime + 15 * 60 * 1000 };
if (nextAttempt.count >= 5) nextAttempt.until = currentTime + 60 * 1000;
if (registerAttempts.size >= 10_000) registerAttempts.delete(registerAttempts.keys().next().value); registerAttempts.set(key, nextAttempt);
if (!validEmail(email)) return fail(reply, 400, 'invalid_email', '이메일 형식이 올바르지 않습니다');
if (password.length < 8) return fail(reply, 400, 'invalid_password', '비밀번호는 8자 이상이어야 합니다');
if (await store.users.findOne({ email }, { projection: { id: 1 } })) return fail(reply, 409, 'email_exists', '이미 가입된 이메일입니다');
const createdAt = nowDate(); const id = randomUUID(); const passwordHash = await hashPassword(password);
try { await store.users.insertOne({ id, email, displayName, passwordHash, status: 'pending', isOperator: false, createdAt, updatedAt: createdAt }); }
catch (error) { if (error?.code === 11000) return fail(reply, 409, 'email_exists', '이미 가입된 이메일입니다'); throw error; }
return reply.code(201).send({ user: { id, email, displayName, status: 'pending', isOperator: false }, message: '가입 신청이 접수되었습니다. 운영자 승인을 기다려 주세요.' });
});
app.post('/api/auth/login', {
schema: { body: bodySchema({ email: emailSchema, password: { type: 'string', minLength: 1, maxLength: 200 } }, ['email', 'password']) },
}, async (request, reply) => {
const email = safeText(request.body.email).toLowerCase(); const password = String(request.body.password || ''); const key = `${request.ip}:${email}`; const currentTime = Date.now();
for (const [entry, value] of loginAttempts) if (value.resetAt <= currentTime) loginAttempts.delete(entry);
const attempt = loginAttempts.get(key);
if (attempt && attempt.until > currentTime) return fail(reply, 429, 'login_throttled', '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요');
const row = await store.users.findOne({ email }); const valid = row ? await verifyPassword(password, row.passwordHash) : false;
if (!valid) {
const next = attempt && attempt.resetAt > currentTime ? { count: attempt.count + 1, resetAt: attempt.resetAt } : { count: 1, resetAt: currentTime + 15 * 60 * 1000 };
if (next.count >= 10) next.until = currentTime + 30 * 1000;
if (loginAttempts.size >= 10_000) loginAttempts.delete(loginAttempts.keys().next().value);
loginAttempts.set(key, next); return fail(reply, 401, 'invalid_credentials', '이메일 또는 비밀번호가 올바르지 않습니다');
}
loginAttempts.delete(key);
if (row.status === 'pending') return fail(reply, 403, 'approval_required', '운영자 승인을 기다리는 계정입니다');
if (row.status === 'suspended') return fail(reply, 403, 'account_suspended', '사용이 중지된 계정입니다');
const token = newSessionToken(); const createdAt = nowDate();
await store.sessions.deleteMany({ expiresAt: { $lte: createdAt } });
await store.sessions.insertOne({ tokenHash: hashToken(token), userId: row.id, expiresAt: new Date(sessionExpiry()), createdAt });
reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: secureCookie, maxAge: 7 * 24 * 60 * 60 });
return { user: publicUser(row) };
});
app.post('/api/auth/logout', async (request, reply) => {
const token = request.cookies[SESSION_COOKIE]; if (token) await store.sessions.deleteOne({ tokenHash: hashToken(token) });
reply.clearCookie(SESSION_COOKIE, { path: '/' }); return { ok: true };
});
app.get('/api/auth/me', { preHandler: requireUser }, async (request) => ({ user: publicUser(request.user) }));
app.get('/api/teams', { preHandler: requireUser }, async (request) => {
const memberships = await store.teamMembers.find({ userId: request.user.id }).toArray();
const teams = await store.teams.find({ id: { $in: memberships.map((membership) => membership.teamId) } }).sort({ updatedAt: -1, name: 1 }).toArray();
const roles = new Map(memberships.map((membership) => [membership.teamId, membership.role]));
return { teams: teams.map((team) => publicTeam({ ...team, role: roles.get(team.id) })) };
});
app.post('/api/teams', {
preHandler: requireUser,
schema: { body: bodySchema({ name: { type: 'string', minLength: 1, maxLength: 100 } }, ['name']) },
}, async (request, reply) => {
const name = safeText(request.body.name); if (!name) return fail(reply, 400, 'invalid_team_name', '팀 이름을 입력해 주세요');
const id = randomUUID(); const createdAt = nowDate();
await store.teams.insertOne({ id, name, ownerUserId: request.user.id, createdAt, updatedAt: createdAt });
try { await store.teamMembers.insertOne({ teamId: id, userId: request.user.id, role: 'owner', createdAt }); }
catch (error) { await store.teams.deleteOne({ id, ownerUserId: request.user.id }); throw error; }
return reply.code(201).send({ team: publicTeam({ id, name, ownerUserId: request.user.id, role: 'owner', createdAt, updatedAt: createdAt }) });
});
app.get('/api/teams/:teamId', { preHandler: requireTeam('viewer') }, async (request) => ({ team: publicTeam(request.team) }));
app.get('/api/teams/:teamId/plays', { preHandler: requireTeam('viewer') }, async (request) => {
const rows = await store.plays.find({ teamId: request.params.teamId }, { projection: { _id: 0, id: 1, name: 1, updatedAt: 1 } }).sort({ updatedAt: -1, name: 1 }).toArray();
return { plays: rows.map((row) => ({ id: row.id, name: row.name, updatedAt: iso(row.updatedAt) })) };
});
app.get('/api/teams/:teamId/plays/:playId', { preHandler: requireTeam('viewer') }, async (request, reply) => {
if (!parsePositiveId(request.params.playId)) return fail(reply, 400, 'invalid_play', '전술 식별자가 올바르지 않습니다');
const row = await store.plays.findOne({ teamId: request.params.teamId, id: request.params.playId }, { projection: { _id: 0, data: 1 } });
if (!row) return fail(reply, 404, 'play_not_found', '전술을 찾을 수 없습니다');
return { play: row.data };
});
app.put('/api/teams/:teamId/plays/:playId', {
preHandler: requireTeam('editor'),
schema: { body: bodySchema({ play: { type: 'object' } }, ['play']) },
}, async (request, reply) => {
const { play } = request.body; let valid = false; try { valid = Boolean(play && isValidPlay(play)); } catch { valid = false; }
if (!parsePositiveId(request.params.playId) || !play || play.id !== request.params.playId || !valid) return fail(reply, 400, 'invalid_play', '저장할 전술 데이터가 올바르지 않습니다');
const name = safeText(play.name, '새 전술').slice(0, 160); const copy = structuredClone(play); copy.name = name; const updatedAt = nowDate();
const result = await store.plays.findOneAndUpdate({ teamId: request.params.teamId, id: request.params.playId }, { $set: { name, data: copy, updatedAt, updatedBy: request.user.id }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true, returnDocument: 'after', includeResultMetadata: true });
return reply.code(result?.lastErrorObject?.upserted ? 201 : 200).send({ id: copy.id, name, updatedAt: updatedAt.toISOString(), play: copy });
});
app.get('/api/admin/users', { preHandler: requireOperator }, async (request) => {
const status = ['pending', 'approved', 'suspended'].includes(request.query?.status) ? request.query.status : 'pending';
const rows = await store.users.find({ status }).sort({ createdAt: 1 }).toArray(); return { users: rows.map(publicUser) };
});
app.post('/api/admin/users/:userId/approve', { preHandler: requireOperator }, async (request, reply) => {
const result = await store.users.findOneAndUpdate({ id: request.params.userId, status: 'pending' }, { $set: { status: 'approved', updatedAt: nowDate() } }, { returnDocument: 'after' });
const user = result?.value || result; if (!user) return fail(reply, 404, 'user_not_found', '승인 대기 사용자를 찾을 수 없습니다'); return { user: publicUser(user) };
});
app.post('/api/admin/users/:userId/suspend', { preHandler: requireOperator, schema: { body: bodySchema({}, []) } }, async (request, reply) => {
const result = await store.users.updateOne({ id: request.params.userId }, { $set: { status: 'suspended', updatedAt: nowDate() } });
if (!result.modifiedCount) return fail(reply, 404, 'user_not_found', '사용자를 찾을 수 없습니다');
await store.sessions.deleteMany({ userId: request.params.userId }); return { ok: true };
});
if (options.serveStatic !== false) {
app.register(fastifyStatic, { root: staticRoot, prefix: '/', decorateReply: true });
app.setNotFoundHandler((request, reply) => {
if (request.method === 'GET' && !request.url.startsWith('/api/')) return reply.sendFile('index.html');
return fail(reply, 404, 'not_found', '요청한 경로를 찾을 수 없습니다');
});
}
return app;
}
export { DEFAULT_MONGODB_DB, DEFAULT_MONGODB_URI };
+98
View File
@@ -0,0 +1,98 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { createInitialPlay } from '../src/domain.js';
import { buildServer } from './app.js';
import { hashPassword } from './security.js';
function clone(value) { return structuredClone(value); }
function matches(document, filter) {
return Object.entries(filter).every(([key, expected]) => {
const actual = document[key];
if (expected && typeof expected === 'object' && !Array.isArray(expected)) {
if ('$gt' in expected && !(actual > expected.$gt)) return false;
if ('$lte' in expected && !(actual <= expected.$lte)) return false;
if ('$in' in expected && !expected.$in.includes(actual)) return false;
return true;
}
return actual === expected;
});
}
function project(document, projection = {}) {
if (!projection || !Object.keys(projection).length) return clone(document);
const include = Object.entries(projection).filter(([, value]) => value === 1).map(([key]) => key);
const result = include.length ? Object.fromEntries(include.filter((key) => key in document).map((key) => [key, clone(document[key])])) : clone(document);
if (projection._id === 0) delete result._id;
return result;
}
function collection() {
const documents = [];
return {
documents,
async createIndex() { return 'index'; },
async findOne(filter, options) { const document = documents.find((item) => matches(item, filter)); return document ? project(document, options?.projection) : null; },
find(filter, options) {
let result = documents.filter((item) => matches(item, filter)).map((item) => project(item, options?.projection));
return { sort(spec) { result.sort((left, right) => { for (const [key, direction] of Object.entries(spec)) { const a = left[key]; const b = right[key]; if (a === b) continue; return (a > b ? 1 : -1) * direction; } return 0; }); return this; }, toArray: async () => result };
},
async insertOne(document) { if (documents.some((item) => item.email && item.email === document.email)) { const error = new Error('duplicate'); error.code = 11000; throw error; } documents.push(clone(document)); return { acknowledged: true }; },
async updateOne(filter, update) { const document = documents.find((item) => matches(item, filter)); if (!document) return { modifiedCount: 0 }; Object.assign(document, clone(update.$set || {})); return { modifiedCount: 1 }; },
async findOneAndUpdate(filter, update, options) { let document = documents.find((item) => matches(item, filter)); let upserted = false; if (!document && options?.upsert) { document = { ...clone(filter), ...clone(update.$setOnInsert || {}) }; documents.push(document); upserted = true; } if (!document) return null; Object.assign(document, clone(update.$set || {})); const value = clone(document); return options?.includeResultMetadata ? { value, lastErrorObject: upserted ? { upserted: document.id } : {} } : value; },
async deleteOne(filter) { const index = documents.findIndex((item) => matches(item, filter)); if (index < 0) return { deletedCount: 0 }; documents.splice(index, 1); return { deletedCount: 1 }; },
async deleteMany(filter) { let deletedCount = 0; for (let index = documents.length - 1; index >= 0; index -= 1) if (matches(documents[index], filter)) { documents.splice(index, 1); deletedCount += 1; } return { deletedCount }; },
};
}
function memoryStore() { return { users: collection(), sessions: collection(), teams: collection(), teamMembers: collection(), plays: collection(), async close() {} }; }
function testConfig() { return { mongodb: { uri: 'mongodb://172.16.0.7:27017', db: 'basket_utils' }, server: { host: '0.0.0.0', port: 3000, mode: 'development', allowedOrigins: ['http://localhost:5173', 'http://127.0.0.1:5173', 'http://192.168.5.10:5173'], secureCookie: false }, bootstrap: { password: '' }, qa: { db: 'basket_utils_qa', password: '' } }; }
async function seedUser(store, { id, email, displayName, isOperator = false, status = 'approved', password = 'correct horse battery staple' }) {
const at = new Date(); await store.users.insertOne({ id, email, displayName, passwordHash: await hashPassword(password), status, isOperator, createdAt: at, updatedAt: at }); return { id, email, password };
}
async function login(app, email, password) { const response = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { email, password } }); const setCookie = response.headers['set-cookie']; const cookieHeader = Array.isArray(setCookie) ? setCookie[0] : setCookie; return { response, cookie: cookieHeader?.split(';')[0] }; }
describe('Fastify Mongo contract', () => {
let app;
const temporaryRoots = [];
afterEach(async () => { await app?.close(); await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); });
it('rejects unauthenticated and pending access', async () => {
const store = memoryStore(); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
expect((await app.inject({ method: 'GET', url: '/api/teams' })).statusCode).toBe(401);
await app.inject({ method: 'POST', url: '/api/auth/register', payload: { email: 'pending@example.com', password: 'password123', displayName: 'Pending' } });
const pending = await login(app, 'pending@example.com', 'password123'); expect(pending.response.statusCode).toBe(403); expect(pending.response.json().error.code).toBe('approval_required');
});
it('enforces team ownership and isolates multi-team play save/load', async () => {
const store = memoryStore(); await seedUser(store, { id: 'u1', email: 'one@example.com', displayName: 'One' }); await seedUser(store, { id: 'u2', email: 'two@example.com', displayName: 'Two' });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false }); const one = await login(app, 'one@example.com', 'correct horse battery staple'); expect(one.response.statusCode).toBe(200);
const teamAResponse = await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: one.cookie }, payload: { name: 'Team A' } }); expect(teamAResponse.statusCode, teamAResponse.body).toBe(201); const teamA = teamAResponse.json().team; const teamBResponse = await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: one.cookie }, payload: { name: 'Team B' } }); expect(teamBResponse.statusCode, teamBResponse.body).toBe(201); const teamB = teamBResponse.json().team;
const play = createInitialPlay('A play'); const saved = await app.inject({ method: 'PUT', url: `/api/teams/${teamA.id}/plays/${play.id}`, headers: { cookie: one.cookie }, payload: { play } }); expect(saved.statusCode).toBe(201);
const loaded = await app.inject({ method: 'GET', url: `/api/teams/${teamA.id}/plays/${play.id}`, headers: { cookie: one.cookie } }); expect(loaded.statusCode).toBe(200); expect(loaded.json().play).toEqual(play);
const other = await login(app, 'two@example.com', 'correct horse battery staple'); const forbidden = await app.inject({ method: 'PUT', url: `/api/teams/${teamA.id}/plays/${play.id}`, headers: { cookie: other.cookie }, payload: { play } }); expect(forbidden.statusCode).toBe(404);
const teamBList = await app.inject({ method: 'GET', url: `/api/teams/${teamB.id}/plays`, headers: { cookie: one.cookie } }); expect(teamBList.json().plays).toEqual([]);
});
it('invalidates the session on logout and returns malformed plays as 400', async () => {
const store = memoryStore(); await seedUser(store, { id: 'u1', email: 'one@example.com', displayName: 'One' }); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false }); const one = await login(app, 'one@example.com', 'correct horse battery staple');
const teamResponse = await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: one.cookie }, payload: { name: 'Team' } }); expect(teamResponse.statusCode, teamResponse.body).toBe(201); const team = teamResponse.json().team; const bad = await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/play-1`, headers: { cookie: one.cookie }, payload: { play: { id: 'play-1', players: [] } } }); expect(bad.statusCode).toBe(400);
expect((await app.inject({ method: 'POST', url: '/api/auth/logout', headers: { cookie: one.cookie }, payload: {} })).statusCode).toBe(200); expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: one.cookie } })).statusCode).toBe(401);
});
it('supports operator approval, viewer read access, editor denial, and secure cookies', async () => {
const store = memoryStore(); await seedUser(store, { id: 'operator', email: 'operator@example.com', displayName: 'Operator', isOperator: true }); await seedUser(store, { id: 'viewer', email: 'viewer@example.com', displayName: 'Viewer', status: 'pending' });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: true }); const operator = await login(app, 'operator@example.com', 'correct horse battery staple'); const operatorCookie = operator.response.headers['set-cookie']; expect((Array.isArray(operatorCookie) ? operatorCookie : [operatorCookie]).some((value) => value.includes('Secure'))).toBe(true);
const pending = await app.inject({ method: 'GET', url: '/api/admin/users?status=pending', headers: { cookie: operator.cookie } }); expect(pending.json().users.map((user) => user.id)).toContain('viewer'); expect((await app.inject({ method: 'POST', url: '/api/admin/users/viewer/approve', headers: { cookie: operator.cookie }, payload: {} })).statusCode).toBe(200);
const team = (await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: operator.cookie, origin: 'http://192.168.5.10:5173' }, payload: { name: 'Shared' } })).json().team; const play = createInitialPlay('Shared play'); expect((await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/${play.id}`, headers: { cookie: operator.cookie }, payload: { play } })).statusCode).toBe(201);
await store.teamMembers.insertOne({ teamId: team.id, userId: 'viewer', role: 'viewer', createdAt: new Date() }); const viewer = await login(app, 'viewer@example.com', 'correct horse battery staple'); expect((await app.inject({ method: 'GET', url: `/api/teams/${team.id}/plays/${play.id}`, headers: { cookie: viewer.cookie } })).statusCode).toBe(200); expect((await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/${play.id}`, headers: { cookie: viewer.cookie }, payload: { play } })).statusCode).toBe(403);
expect((await app.inject({ method: 'POST', url: '/api/admin/users/viewer/suspend', headers: { cookie: operator.cookie }, payload: {} })).statusCode).toBe(200); expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: viewer.cookie } })).statusCode).toBe(401);
});
it('does not expose config files through the static handler', async () => {
const staticRoot = await mkdtemp(path.join(os.tmpdir(), 'basket-utils-static-')); temporaryRoots.push(staticRoot);
await writeFile(path.join(staticRoot, 'index.html'), 'safe'); await writeFile(path.join(staticRoot, '.config.json'), '{"bootstrap":{"password":"static-secret"}}');
app = await buildServer({ config: testConfig(), db: memoryStore(), staticRoot });
for (const url of ['/.config.json', '/.config.json.sample', '/nested/.config.json.bak']) {
const response = await app.inject({ method: 'GET', url }); expect(response.statusCode).toBe(404); expect(response.body).not.toContain('static-secret');
}
});
});
+22
View File
@@ -0,0 +1,22 @@
import { randomUUID } from 'node:crypto';
import { createMongoStore } from './db.js';
import { loadConfig } from './config.js';
import { hashPassword, validEmail } from './security.js';
function argument(name) { const prefix = `--${name}=`; const item = process.argv.find((value) => value.startsWith(prefix)); return item ? item.slice(prefix.length) : ''; }
const config = await loadConfig();
const email = argument('email').trim().toLowerCase(); const password = argument('password') || config.bootstrap.password || ''; const displayName = (argument('display-name') || email.split('@')[0]).trim(); const promoteExisting = process.argv.includes('--promote-existing');
if (!validEmail(email) || !displayName) throw new Error('사용법: npm run bootstrap-admin -- --email=admin@example.com [--password=긴비밀번호] [--display-name=이름]');
const store = await createMongoStore({ config, uri: config.mongodb.uri, dbName: config.mongodb.db });
try {
const existing = await store.users.findOne({ email }); const at = new Date();
if (existing) {
if (!promoteExisting) throw new Error('사용자가 이미 존재합니다. 비밀번호를 바꾸지 않고 운영자로 승격하려면 --promote-existing를 사용하세요');
await store.users.updateOne({ id: existing.id }, { $set: { status: 'approved', isOperator: true, updatedAt: at } });
} else {
if (password.length < 8) throw new Error('새 운영자 계정은 --password 또는 .config.json의 bootstrap.password에 8자 이상 비밀번호를 지정해야 합니다');
const at = new Date();
await store.users.insertOne({ id: randomUUID(), email, displayName, passwordHash: await hashPassword(password), status: 'approved', isOperator: true, createdAt: at, updatedAt: at });
}
} finally { await store.close(); }
console.log(`Bootstrap operator ready for ${email}`);
+96
View File
@@ -0,0 +1,96 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFile } from 'node:fs/promises';
const here = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(here, '..');
export const CONFIG_FILENAME = '.config.json';
function invalid(filePath, detail) {
throw new Error(`Invalid configuration in ${filePath}: ${detail}`);
}
function requireObject(value, field, filePath) {
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(filePath, `${field} must be an object`);
return value;
}
function requireString(value, field, filePath) {
if (typeof value !== 'string' || !value.trim()) invalid(filePath, `${field} must be a non-empty string`);
return value.trim();
}
function requirePassword(value, field, filePath) {
if (typeof value !== 'string') invalid(filePath, `${field} must be a string`);
return value;
}
function isDevelopmentOrigin(origin) {
let url;
try { url = new URL(origin); } catch { return false; }
if (url.protocol !== 'http:') return false;
const hostname = url.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1') return true;
const octets = hostname.split('.').map((part) => Number(part));
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
return octets[0] === 10
|| (octets[0] === 192 && octets[1] === 168)
|| (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31);
}
function validateConfig(value, filePath) {
const root = requireObject(value, 'root', filePath);
const mongodb = requireObject(root.mongodb, 'mongodb', filePath);
const server = requireObject(root.server, 'server', filePath);
const bootstrap = requireObject(root.bootstrap, 'bootstrap', filePath);
const qa = requireObject(root.qa, 'qa', filePath);
const uri = requireString(mongodb.uri, 'mongodb.uri', filePath);
const db = requireString(mongodb.db, 'mongodb.db', filePath);
const host = requireString(server.host, 'server.host', filePath);
if (!Number.isInteger(server.port) || server.port < 1 || server.port > 65_535) invalid(filePath, 'server.port must be an integer between 1 and 65535');
if (server.mode !== 'development' && server.mode !== 'production') invalid(filePath, 'server.mode must be development or production');
if (!Array.isArray(server.allowedOrigins) || server.allowedOrigins.some((origin) => typeof origin !== 'string' || !origin.trim())) invalid(filePath, 'server.allowedOrigins must be an array of non-empty strings');
const allowedOrigins = server.allowedOrigins.map((origin) => origin.trim());
if (typeof server.secureCookie !== 'boolean') invalid(filePath, 'server.secureCookie must be a boolean');
const bootstrapPassword = requirePassword(bootstrap.password, 'bootstrap.password', filePath);
const qaDb = requireString(qa.db, 'qa.db', filePath);
const qaPassword = requirePassword(qa.password, 'qa.password', filePath);
const production = server.mode === 'production';
return {
mongodb: { uri, db },
server: {
host,
port: server.port,
mode: server.mode,
allowedOrigins: production ? allowedOrigins.filter((origin) => !isDevelopmentOrigin(origin)) : allowedOrigins,
secureCookie: production || server.secureCookie,
},
bootstrap: { password: bootstrapPassword },
qa: { db: qaDb, password: qaPassword },
};
}
export function resolveConfigPath(configPath = CONFIG_FILENAME, root = projectRoot) {
return path.isAbsolute(configPath) ? configPath : path.resolve(root, configPath);
}
export async function loadConfig(options = {}) {
const requestedPath = typeof options === 'string' ? options : options.path || options.filePath || CONFIG_FILENAME;
const filePath = resolveConfigPath(requestedPath, typeof options === 'object' ? options.projectRoot || projectRoot : projectRoot);
let source;
try {
source = await readFile(filePath, 'utf8');
} catch (error) {
if (error?.code === 'ENOENT') throw new Error(`Configuration file not found at ${filePath}. Copy .config.json.sample to .config.json and set the required values.`);
throw new Error(`Unable to read configuration file ${filePath}: ${error?.code || 'unknown error'}`);
}
let parsed;
try {
parsed = JSON.parse(source.charCodeAt(0) === 0xFEFF ? source.slice(1) : source);
} catch {
throw new Error(`Invalid JSON in configuration file ${filePath}; check its JSON syntax.`);
}
return validateConfig(parsed, filePath);
}
export { isDevelopmentOrigin, projectRoot };
+69
View File
@@ -0,0 +1,69 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { loadConfig } from './config.js';
const temporaryRoots = [];
function validConfig(overrides = {}) {
return {
mongodb: { uri: 'mongodb://172.16.0.7:27017', db: 'basket_utils' },
server: { host: '0.0.0.0', port: 3000, mode: 'development', allowedOrigins: ['http://localhost:5173'], secureCookie: false },
bootstrap: { password: '' },
qa: { db: 'basket_utils_qa', password: '' },
...overrides,
};
}
async function writeConfig(source) {
const root = await mkdtemp(path.join(os.tmpdir(), 'basket-utils-config-')); temporaryRoots.push(root);
await writeFile(path.join(root, '.config.json'), source); return root;
}
afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe('JSON configuration', () => {
it('loads relative to the project root and accepts a UTF-8 BOM', async () => {
const root = await writeConfig(`\uFEFF${JSON.stringify(validConfig())}`);
const config = await loadConfig({ projectRoot: root });
expect(config.mongodb.db).toBe('basket_utils');
expect(config.server.port).toBe(3000);
});
it('reports missing and malformed files without exposing source values', async () => {
const missingRoot = await mkdtemp(path.join(os.tmpdir(), 'basket-utils-config-')); temporaryRoots.push(missingRoot);
await expect(loadConfig({ projectRoot: missingRoot })).rejects.toThrow(/not found.*\.config\.json\.sample/i);
const secret = 'config-only-secret'; const malformedRoot = await writeConfig(`{"bootstrap":{"password":"${secret}"},`);
await expect(loadConfig({ projectRoot: malformedRoot })).rejects.toThrow(/Invalid JSON.*check its JSON syntax/i);
await expect(loadConfig({ projectRoot: malformedRoot })).rejects.not.toThrow(secret);
});
it('rejects invalid port types with a field-specific message', async () => {
const root = await writeConfig(JSON.stringify(validConfig({ server: { ...validConfig().server, port: '3000' } })));
await expect(loadConfig({ projectRoot: root })).rejects.toThrow(/server\.port.*integer/i);
});
it('forces secure cookies and removes private HTTP origins in production', async () => {
const root = await writeConfig(JSON.stringify(validConfig({ server: {
...validConfig().server,
mode: 'production',
secureCookie: false,
allowedOrigins: ['http://localhost:5173', 'http://192.168.5.10:5173', 'https://coach.example.com'],
} })));
const config = await loadConfig({ projectRoot: root });
expect(config.server.secureCookie).toBe(true);
expect(config.server.allowedOrigins).toEqual(['https://coach.example.com']);
});
it('keeps the sample schema and empty sample passwords', async () => {
const sample = JSON.parse(await readFile(path.resolve(import.meta.dirname, '..', '.config.json.sample'), 'utf8'));
expect(sample.mongodb).toEqual({ uri: 'mongodb://172.16.0.7:27017', db: 'basket_utils' });
expect(sample.server).toMatchObject({ host: '0.0.0.0', port: 3000, mode: 'development', secureCookie: false });
expect(sample.server.allowedOrigins).toEqual(['http://localhost:5173', 'http://127.0.0.1:5173', 'http://192.168.5.10:5173']);
expect(sample.bootstrap.password).toBe('');
expect(sample.qa).toEqual({ db: 'basket_utils_qa', password: '' });
});
});
+37
View File
@@ -0,0 +1,37 @@
import { MongoClient } from 'mongodb';
import { loadConfig } from './config.js';
export const DEFAULT_MONGODB_URI = 'mongodb://172.16.0.7:27017';
export const DEFAULT_MONGODB_DB = 'basket_utils';
export async function createMongoStore(options = {}) {
const config = options.config || (options.uri && options.dbName ? null : await loadConfig());
const uri = options.uri || config?.mongodb?.uri || DEFAULT_MONGODB_URI;
const dbName = options.dbName || config?.mongodb?.db || DEFAULT_MONGODB_DB;
const client = options.client || new MongoClient(uri, { maxPoolSize: 10, serverSelectionTimeoutMS: options.serverSelectionTimeoutMS || 5000 });
try { await client.connect(); } catch (error) { if (!options.client) await client.close().catch(() => {}); throw error; }
const database = client.db(dbName);
const users = database.collection('users');
const sessions = database.collection('sessions');
const teams = database.collection('teams');
const teamMembers = database.collection('team_members');
const plays = database.collection('plays');
try {
await Promise.all([
users.createIndex({ email: 1 }, { unique: true }),
sessions.createIndex({ tokenHash: 1 }, { unique: true }),
sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }),
teamMembers.createIndex({ teamId: 1, userId: 1 }, { unique: true }),
teamMembers.createIndex({ userId: 1 }),
plays.createIndex({ teamId: 1, id: 1 }, { unique: true }),
plays.createIndex({ teamId: 1, updatedAt: -1 }),
]);
} catch (error) {
if (!options.client) await client.close().catch(() => {});
throw error;
}
return {
client, database, users, sessions, teams, teamMembers, plays,
async close() { if (!options.client) await client.close(); },
};
}
+15
View File
@@ -0,0 +1,15 @@
import { buildServer } from './app.js';
import { loadConfig } from './config.js';
const config = await loadConfig();
const { port, host } = config.server;
const app = await buildServer({ config });
try {
await app.listen({ port, host });
console.log(`court.lab server listening on http://${host}:${port}`);
const shutdown = async () => { try { await app.close(); } finally { process.exit(0); } };
process.once('SIGTERM', shutdown); process.once('SIGINT', shutdown);
} catch (error) {
app.log.error(error); await app.close().catch(() => {});
process.exitCode = 1;
}
+48
View File
@@ -0,0 +1,48 @@
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);
const N = 16_384;
const R = 8;
const P = 1;
export const SESSION_COOKIE = 'court_session';
export const SESSION_DAYS = 7;
export function hashToken(token) {
return createHash('sha256').update(token).digest('hex');
}
export async function hashPassword(password) {
const salt = randomBytes(16);
const derived = await scryptAsync(password, salt, 64, { N, r: R, p: P, maxmem: 32 * 1024 * 1024 });
return `scrypt$${N},${R},${P}$${salt.toString('base64url')}$${Buffer.from(derived).toString('base64url')}`;
}
export async function verifyPassword(password, encoded) {
try {
const [algorithm, params, saltText, hashText] = String(encoded).split('$');
if (algorithm !== 'scrypt' || !params || !saltText || !hashText) return false;
const [nText, rText, pText] = params.split(',');
const n = Number(nText); const r = Number(rText); const p = Number(pText);
if (n !== N || r !== R || p !== P) return false;
const expected = Buffer.from(hashText, 'base64url');
const derived = Buffer.from(await scryptAsync(password, Buffer.from(saltText, 'base64url'), expected.length, { N: n, r, p, maxmem: 32 * 1024 * 1024 }));
if (derived.length !== expected.length) return false;
return timingSafeEqual(derived, expected);
} catch {
return false;
}
}
export function newSessionToken() {
return randomBytes(32).toString('base64url');
}
export function sessionExpiry(now = Date.now()) {
return new Date(now + SESSION_DAYS * 24 * 60 * 60 * 1000).toISOString();
}
export function validEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
+19
View File
@@ -0,0 +1,19 @@
import { randomUUID } from 'node:crypto';
import { createMongoStore } from './db.js';
import { loadConfig } from './config.js';
import { hashPassword, validEmail } from './security.js';
function argument(name) { const prefix = `--${name}=`; const item = process.argv.find((value) => value.startsWith(prefix)); return item ? item.slice(prefix.length) : ''; }
const config = await loadConfig();
const email = argument('email').trim().toLowerCase(); const password = argument('password') || config.qa.password || ''; const displayName = (argument('display-name') || 'QA Operator').trim(); const teamName = (argument('team-name') || 'QA Team').trim();
const dbName = argument('db') || config.qa.db; const uri = config.mongodb.uri;
if (!/^basket_utils_qa(?:_[A-Za-z0-9-]+)?$/.test(dbName)) throw new Error('QA seed는 basket_utils_qa 또는 basket_utils_qa_* 데이터베이스만 사용할 수 있습니다');
if (!validEmail(email) || password.length < 8 || !displayName || !teamName) throw new Error('사용법: npm run seed-qa -- --email=qa@example.com --password=긴비밀번호 [--db=basket_utils_qa]');
const store = await createMongoStore({ config, uri, dbName });
try {
const at = new Date(); let user = await store.users.findOne({ email });
if (user) { await store.users.updateOne({ id: user.id }, { $set: { displayName, passwordHash: await hashPassword(password), status: 'approved', isOperator: true, updatedAt: at } }); }
else { user = { id: randomUUID(), email, displayName }; await store.users.insertOne({ ...user, passwordHash: await hashPassword(password), status: 'approved', isOperator: true, createdAt: at, updatedAt: at }); }
const team = { id: randomUUID(), name: teamName, ownerUserId: user.id, createdAt: at, updatedAt: at }; await store.teams.insertOne(team); await store.teamMembers.insertOne({ teamId: team.id, userId: user.id, role: 'owner', createdAt: at });
console.log(JSON.stringify({ dbName, email, teamId: team.id, teamName }, null, 2));
} finally { await store.close(); }