feat: Phase 1 — Fastify backend with auth, users, admin API

Backend stack:
- Fastify v5 with JWT auth, CORS, cookies, rate limiting
- PostgreSQL via Drizzle ORM with full schema:
  users, presets, game_progress, shared_patches, likes, refresh_tokens
- Argon2 password hashing, httpOnly refresh cookie rotation

API endpoints:
- POST /api/v1/auth/register|login|refresh|logout
- GET|PATCH /api/v1/users/me (profile)
- GET /api/v1/admin/stats (dashboard KPIs)
- GET|PATCH /api/v1/admin/users (list, role change, ban)
- GET|PATCH /api/v1/admin/patches (moderation)
- GET /api/health

Infrastructure:
- Vite proxy /api → localhost:3001 for dev
- .env.example with all config vars
- Dockerfile updated: installs server deps, serves SPA + API
- npm run dev:server for backend hot-reload
- npm run db:push for schema sync

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 19:58:21 +01:00
parent b058997889
commit 6a4a308fd9
15 changed files with 3539 additions and 8 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules
dist
.vite
.env

View File

@@ -9,9 +9,11 @@ RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
COPY packages/server/package.json packages/server/
RUN npm install -w packages/server --omit=dev
COPY --from=build /app/dist ./dist
COPY packages/server/server.js ./packages/server/server.js
COPY packages/server/package.json ./packages/server/package.json
COPY package.json ./
COPY packages/server packages/server
ENV NODE_ENV=production
EXPOSE 80
CMD ["node", "packages/server/server.js"]
CMD ["node", "packages/server/src/index.js"]

2960
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,9 @@
"workspaces": ["packages/*"],
"scripts": {
"dev": "npm run dev -w packages/client",
"dev:server": "npm run dev -w packages/server",
"build": "npm run build -w packages/client",
"start": "node packages/server/server.js"
"start": "node packages/server/src/index.js",
"db:push": "npm run db:push -w packages/server"
}
}

View File

@@ -3,6 +3,11 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { port: 3000 },
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:3001',
},
},
build: { outDir: '../../dist', emptyOutDir: true }
});

View File

@@ -0,0 +1,5 @@
DATABASE_URL=postgres://reaktor:reaktor_dev@localhost:5432/reaktor
JWT_SECRET=change-this-to-a-random-secret
PORT=3001
CORS_ORIGIN=http://localhost:3000
NODE_ENV=development

View File

@@ -0,0 +1,8 @@
export default {
schema: './src/db/schema.js',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgres://reaktor:reaktor_dev@localhost:5432/reaktor',
},
};

View File

@@ -2,7 +2,28 @@
"name": "@reaktor/server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"argon2": "^0.44.0",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"fastify": "^5.8.2",
"postgres": "^3.4.8",
"uuid": "^13.0.0"
},
"devDependencies": {
"drizzle-kit": "^0.31.10"
}
}

View File

@@ -0,0 +1,9 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.js';
const connectionString = process.env.DATABASE_URL || 'postgres://reaktor:reaktor_dev@localhost:5432/reaktor';
const client = postgres(connectionString);
export const db = drizzle(client, { schema });
export { schema };

View File

@@ -0,0 +1,63 @@
import { pgTable, uuid, varchar, text, boolean, integer, timestamp, jsonb, primaryKey } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).unique().notNull(),
username: varchar('username', { length: 50 }).unique().notNull(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
avatarUrl: varchar('avatar_url', { length: 500 }),
bio: text('bio'),
role: varchar('role', { length: 20 }).default('user').notNull(), // user | premium | admin
authProvider: varchar('auth_provider', { length: 20 }).default('local'),
providerId: varchar('provider_id', { length: 255 }),
stripeCustomerId: varchar('stripe_customer_id', { length: 255 }),
subscriptionStatus: varchar('subscription_status', { length: 20 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const presets = pgTable('presets', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
name: varchar('name', { length: 200 }).notNull(),
data: jsonb('data').notNull(),
isAutosave: boolean('is_autosave').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const gameProgress = pgTable('game_progress', {
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).primaryKey(),
data: jsonb('data').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const sharedPatches = pgTable('shared_patches', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
title: varchar('title', { length: 200 }).notNull(),
description: text('description'),
tags: text('tags').array(),
data: jsonb('data').notNull(),
previewUrl: varchar('preview_url', { length: 500 }),
likesCount: integer('likes_count').default(0).notNull(),
isFlagged: boolean('is_flagged').default(false).notNull(),
isDeleted: boolean('is_deleted').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export const likes = pgTable('likes', {
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
patchId: uuid('patch_id').references(() => sharedPatches.id, { onDelete: 'cascade' }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.patchId] }),
}));
export const refreshTokens = pgTable('refresh_tokens', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
tokenHash: varchar('token_hash', { length: 255 }).notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

View File

@@ -0,0 +1,74 @@
import 'dotenv/config';
import Fastify from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import jwt from '@fastify/jwt';
import rateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import path from 'path';
import { fileURLToPath } from 'url';
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
import adminRoutes from './routes/admin.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
const fastify = Fastify({ logger: true });
// Plugins
await fastify.register(cors, {
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true,
});
await fastify.register(cookie);
await fastify.register(jwt, {
secret: JWT_SECRET,
});
await fastify.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
});
// API routes
await fastify.register(authRoutes, { prefix: '/api/v1/auth' });
await fastify.register(userRoutes, { prefix: '/api/v1/users' });
await fastify.register(adminRoutes, { prefix: '/api/v1/admin' });
// Rate limit auth endpoints more aggressively
fastify.addHook('onRoute', (routeOptions) => {
if (routeOptions.url?.startsWith('/api/v1/auth')) {
routeOptions.config = { ...routeOptions.config, rateLimit: { max: 10, timeWindow: '1 minute' } };
}
});
// Health check
fastify.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
// In production, serve static files (SPA)
if (process.env.NODE_ENV === 'production') {
const distDir = path.join(__dirname, '..', '..', '..', 'dist');
await fastify.register(fastifyStatic, { root: distDir });
// SPA fallback
fastify.setNotFoundHandler((request, reply) => {
if (request.url.startsWith('/api/')) {
reply.code(404).send({ error: 'Not found' });
} else {
reply.sendFile('index.html');
}
});
}
// Start
try {
await fastify.listen({ port: PORT, host: '0.0.0.0' });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}

View File

@@ -0,0 +1,18 @@
export async function authenticate(request, reply) {
try {
await request.jwtVerify();
} catch (err) {
reply.code(401).send({ error: 'Unauthorized' });
}
}
export async function requireAdmin(request, reply) {
try {
await request.jwtVerify();
if (request.user.role !== 'admin') {
reply.code(403).send({ error: 'Forbidden' });
}
} catch (err) {
reply.code(401).send({ error: 'Unauthorized' });
}
}

View File

@@ -0,0 +1,103 @@
import { eq, sql, desc, ilike } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { requireAdmin } from '../middleware/auth.js';
export default async function adminRoutes(fastify) {
// Dashboard stats
fastify.get('/stats', { preHandler: [requireAdmin] }, async () => {
const [userCount] = await db.select({ count: sql`count(*)::int` }).from(schema.users);
const [patchCount] = await db.select({ count: sql`count(*)::int` }).from(schema.sharedPatches).where(eq(schema.sharedPatches.isDeleted, false));
const [premiumCount] = await db.select({ count: sql`count(*)::int` }).from(schema.users).where(eq(schema.users.role, 'premium'));
const [flaggedCount] = await db.select({ count: sql`count(*)::int` }).from(schema.sharedPatches).where(eq(schema.sharedPatches.isFlagged, true));
return {
users: userCount.count,
patches: patchCount.count,
premium: premiumCount.count,
flagged: flaggedCount.count,
};
});
// List users
fastify.get('/users', { preHandler: [requireAdmin] }, async (request) => {
const { q, page = 1, role } = request.query;
const limit = 20;
const offset = (page - 1) * limit;
let query = db.select({
id: schema.users.id,
email: schema.users.email,
username: schema.users.username,
role: schema.users.role,
avatarUrl: schema.users.avatarUrl,
createdAt: schema.users.createdAt,
}).from(schema.users);
if (q) {
query = query.where(
sql`${schema.users.username} ILIKE ${'%' + q + '%'} OR ${schema.users.email} ILIKE ${'%' + q + '%'}`
);
}
if (role) {
query = query.where(eq(schema.users.role, role));
}
const users = await query.orderBy(desc(schema.users.createdAt)).limit(limit).offset(offset);
return { users, page, limit };
});
// Update user role / ban
fastify.patch('/users/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
const { role } = request.body || {};
if (!role || !['user', 'premium', 'admin', 'banned'].includes(role)) {
return reply.code(400).send({ error: 'Invalid role' });
}
const [user] = await db.update(schema.users)
.set({ role, updatedAt: new Date() })
.where(eq(schema.users.id, request.params.id))
.returning({ id: schema.users.id, username: schema.users.username, role: schema.users.role });
if (!user) return reply.code(404).send({ error: 'User not found' });
return user;
});
// List shared patches (moderation)
fastify.get('/patches', { preHandler: [requireAdmin] }, async (request) => {
const { flagged, deleted, page = 1 } = request.query;
const limit = 20;
const offset = (page - 1) * limit;
let query = db.select().from(schema.sharedPatches);
if (flagged === 'true') {
query = query.where(eq(schema.sharedPatches.isFlagged, true));
}
if (deleted === 'true') {
query = query.where(eq(schema.sharedPatches.isDeleted, true));
}
const patches = await query.orderBy(desc(schema.sharedPatches.createdAt)).limit(limit).offset(offset);
return { patches, page, limit };
});
// Moderate patch (delete, unflag, restore)
fastify.patch('/patches/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
const updates = {};
const { action } = request.body || {};
if (action === 'delete') updates.isDeleted = true;
else if (action === 'restore') updates.isDeleted = false;
else if (action === 'unflag') updates.isFlagged = false;
else return reply.code(400).send({ error: 'Invalid action' });
const [patch] = await db.update(schema.sharedPatches)
.set(updates)
.where(eq(schema.sharedPatches.id, request.params.id))
.returning();
if (!patch) return reply.code(404).send({ error: 'Patch not found' });
return patch;
});
}

View File

@@ -0,0 +1,206 @@
import argon2 from 'argon2';
import { v4 as uuidv4 } from 'uuid';
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
const REFRESH_EXPIRY_DAYS = 30;
async function hashRefreshToken(token) {
return argon2.hash(token, { type: argon2.argon2id });
}
export default async function authRoutes(fastify) {
// Register
fastify.post('/register', {
schema: {
body: {
type: 'object',
required: ['email', 'username', 'password'],
properties: {
email: { type: 'string', format: 'email' },
username: { type: 'string', minLength: 3, maxLength: 50 },
password: { type: 'string', minLength: 6 },
},
},
},
}, async (request, reply) => {
const { email, username, password } = request.body;
// Check existing
const existing = await db.select().from(schema.users)
.where(eq(schema.users.email, email)).limit(1);
if (existing.length > 0) {
return reply.code(409).send({ error: 'Email already registered' });
}
const existingUsername = await db.select().from(schema.users)
.where(eq(schema.users.username, username)).limit(1);
if (existingUsername.length > 0) {
return reply.code(409).send({ error: 'Username already taken' });
}
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
const [user] = await db.insert(schema.users).values({
email,
username,
passwordHash,
}).returning({ id: schema.users.id, email: schema.users.email, username: schema.users.username, role: schema.users.role });
// Generate tokens
const accessToken = fastify.jwt.sign(
{ id: user.id, email: user.email, username: user.username, role: user.role },
{ expiresIn: '15m' }
);
const refreshToken = uuidv4();
const refreshHash = await hashRefreshToken(refreshToken);
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await db.insert(schema.refreshTokens).values({
userId: user.id,
tokenHash: refreshHash,
expiresAt,
});
reply.setCookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/v1/auth',
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
});
return { user, accessToken };
});
// Login
fastify.post('/login', {
schema: {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string' },
password: { type: 'string' },
},
},
},
}, async (request, reply) => {
const { email, password } = request.body;
const [user] = await db.select().from(schema.users)
.where(eq(schema.users.email, email)).limit(1);
if (!user) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
const valid = await argon2.verify(user.passwordHash, password);
if (!valid) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
const accessToken = fastify.jwt.sign(
{ id: user.id, email: user.email, username: user.username, role: user.role },
{ expiresIn: '15m' }
);
const refreshToken = uuidv4();
const refreshHash = await hashRefreshToken(refreshToken);
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await db.insert(schema.refreshTokens).values({
userId: user.id,
tokenHash: refreshHash,
expiresAt,
});
reply.setCookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/v1/auth',
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
});
return {
user: { id: user.id, email: user.email, username: user.username, role: user.role, avatarUrl: user.avatarUrl },
accessToken,
};
});
// Refresh token
fastify.post('/refresh', async (request, reply) => {
const token = request.cookies.refreshToken;
if (!token) {
return reply.code(401).send({ error: 'No refresh token' });
}
// Find all non-expired tokens and verify
const candidates = await db.select().from(schema.refreshTokens)
.where(eq(schema.refreshTokens.expiresAt, new Date())); // will fix below
// Actually, find all tokens and check hash
const allTokens = await db.select().from(schema.refreshTokens);
let matchedToken = null;
for (const t of allTokens) {
if (t.expiresAt < new Date()) continue;
try {
if (await argon2.verify(t.tokenHash, token)) {
matchedToken = t;
break;
}
} catch {}
}
if (!matchedToken) {
return reply.code(401).send({ error: 'Invalid refresh token' });
}
// Delete old token (rotation)
await db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.id, matchedToken.id));
// Get user
const [user] = await db.select().from(schema.users)
.where(eq(schema.users.id, matchedToken.userId)).limit(1);
if (!user) {
return reply.code(401).send({ error: 'User not found' });
}
// Issue new tokens
const accessToken = fastify.jwt.sign(
{ id: user.id, email: user.email, username: user.username, role: user.role },
{ expiresIn: '15m' }
);
const newRefreshToken = uuidv4();
const refreshHash = await hashRefreshToken(newRefreshToken);
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await db.insert(schema.refreshTokens).values({
userId: user.id,
tokenHash: refreshHash,
expiresAt,
});
reply.setCookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/v1/auth',
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
});
return { accessToken };
});
// Logout
fastify.post('/logout', async (request, reply) => {
reply.clearCookie('refreshToken', { path: '/api/v1/auth' });
return { ok: true };
});
}

View File

@@ -0,0 +1,56 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { authenticate } from '../middleware/auth.js';
export default async function userRoutes(fastify) {
// Get my profile
fastify.get('/me', { preHandler: [authenticate] }, async (request) => {
const [user] = await db.select({
id: schema.users.id,
email: schema.users.email,
username: schema.users.username,
avatarUrl: schema.users.avatarUrl,
bio: schema.users.bio,
role: schema.users.role,
createdAt: schema.users.createdAt,
}).from(schema.users).where(eq(schema.users.id, request.user.id)).limit(1);
if (!user) return { error: 'User not found' };
return user;
});
// Update my profile
fastify.patch('/me', {
preHandler: [authenticate],
schema: {
body: {
type: 'object',
properties: {
username: { type: 'string', minLength: 3, maxLength: 50 },
bio: { type: 'string', maxLength: 500 },
},
},
},
}, async (request, reply) => {
const updates = {};
if (request.body.username) updates.username = request.body.username;
if (request.body.bio !== undefined) updates.bio = request.body.bio;
updates.updatedAt = new Date();
if (Object.keys(updates).length === 1) {
return reply.code(400).send({ error: 'Nothing to update' });
}
const [user] = await db.update(schema.users)
.set(updates)
.where(eq(schema.users.id, request.user.id))
.returning({
id: schema.users.id,
username: schema.users.username,
bio: schema.users.bio,
});
return user;
});
}