Private
Public Access
1
0
Files
Kanban/server.js
T
Catherine Renelle 0bba161c12 Phase 9: Admin & DevOps — health check, rate limiting, logging, site admin, Docker healthcheck
- Health check endpoint (GET /api/health) with DB probe and uptime
- In-memory sliding window rate limiting on login (10/60s) and register (5/3600s)
- Structured logging with pino (request timing in hooks, Socket.IO events in server.js)
- Site admin dashboard with tenant stats, CRUD, and requireSiteAdmin guard
- Tenant admin enhanced with tag and category management UI
- Docker HEALTHCHECK in Dockerfile and docker-compose.yml
- Backup documentation (docs/backup.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:24:24 -05:00

160 lines
4.7 KiB
JavaScript

import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import { PrismaClient } from '@prisma/client';
import pino from 'pino';
import { handler } from './build/handler.js';
const logger = pino({ name: 'pounce' });
const app = express();
const server = createServer(app);
const io = new Server(server, {
cors: {
origin: process.env.ORIGIN || 'http://localhost:3000',
credentials: true
}
});
// ─── Prisma client for session validation ───────────────────
const prisma = new PrismaClient();
// ─── Presence tracking ──────────────────────────────────────
// Map<boardId, Map<socketId, { id, name }>>
const presence = new Map();
function getPresenceList(boardId) {
const room = presence.get(boardId);
if (!room) return [];
return Array.from(room.values());
}
// ─── Cookie parser ──────────────────────────────────────────
function parseCookies(cookieHeader) {
const cookies = {};
if (!cookieHeader) return cookies;
cookieHeader.split(';').forEach((cookie) => {
const [name, ...rest] = cookie.trim().split('=');
cookies[name] = decodeURIComponent(rest.join('='));
});
return cookies;
}
// ─── Socket.IO auth middleware ───────────────────────────────
io.use(async (socket, next) => {
try {
const cookies = parseCookies(socket.handshake.headers.cookie);
const sessionId = cookies['session'];
if (!sessionId) {
socket.data.user = null;
return next();
}
const session = await prisma.session.findUnique({
where: { id: sessionId },
include: {
user: {
select: { id: true, name: true, avatarUrl: true, tenantId: true }
}
}
});
if (session && session.expiresAt > new Date()) {
socket.data.user = session.user;
} else {
socket.data.user = null;
}
next();
} catch (err) {
logger.error({ err }, 'Socket auth error');
socket.data.user = null;
next();
}
});
// ─── Socket.IO connection handler ────────────────────────────
io.on('connection', (socket) => {
const user = socket.data.user;
logger.info({ socketId: socket.id, user: user?.name ?? 'anon' }, 'Socket connected');
// Join user-specific room for notifications
if (user) {
socket.join('user:' + user.id);
}
// Track which boards this socket has joined (for cleanup on disconnect)
const joinedBoards = new Set();
socket.on('join-board', (boardId) => {
socket.join(`board:${boardId}`);
joinedBoards.add(boardId);
if (user) {
if (!presence.has(boardId)) presence.set(boardId, new Map());
presence.get(boardId).set(socket.id, { id: user.id, name: user.name, avatarUrl: user.avatarUrl });
}
// Notify others
socket.to(`board:${boardId}`).emit('user-joined', {
user: user ? { id: user.id, name: user.name, avatarUrl: user.avatarUrl } : null,
socketId: socket.id
});
// Send full presence list to everyone in the room (including the joiner)
io.to(`board:${boardId}`).emit('presence:update', getPresenceList(boardId));
});
socket.on('leave-board', (boardId) => {
socket.leave(`board:${boardId}`);
joinedBoards.delete(boardId);
if (presence.has(boardId)) {
presence.get(boardId).delete(socket.id);
if (presence.get(boardId).size === 0) presence.delete(boardId);
}
socket.to(`board:${boardId}`).emit('user-left', {
user: user ? { id: user.id, name: user.name } : null,
socketId: socket.id
});
// Update presence for remaining
const room = presence.get(boardId);
io.to(`board:${boardId}`).emit('presence:update', room ? Array.from(room.values()) : []);
});
socket.on('disconnect', () => {
logger.info({ socketId: socket.id }, 'Socket disconnected');
for (const boardId of joinedBoards) {
if (presence.has(boardId)) {
presence.get(boardId).delete(socket.id);
if (presence.get(boardId).size === 0) {
presence.delete(boardId);
}
}
socket.to(`board:${boardId}`).emit('user-left', {
user: user ? { id: user.id, name: user.name } : null,
socketId: socket.id
});
const room = presence.get(boardId);
io.to(`board:${boardId}`).emit('presence:update', room ? Array.from(room.values()) : []);
}
});
});
// Store io globally so SvelteKit API routes can broadcast via realtime.ts
globalThis.__socketIO = io;
// SvelteKit handler
app.use(handler);
const port = process.env.PORT || 3000;
const host = process.env.HOST || '0.0.0.0';
server.listen(port, host, () => {
logger.info({ host, port }, 'Server running');
});