From d7d8fd53390ac6bceb792168b358277c8a3aefcf Mon Sep 17 00:00:00 2001 From: hibna Date: Sun, 22 Feb 2026 09:41:17 +0000 Subject: [PATCH] Fix auth flows and add daemon heartbeat endpoint --- apps/api/src/index.ts | 2 + apps/api/src/lib/jwt.ts | 18 +++++-- apps/api/src/plugins/auth.ts | 5 +- apps/api/src/routes/nodes/daemon.ts | 68 ++++++++++++++++++++++++++ apps/api/src/routes/nodes/index.ts | 11 ++++- apps/web/src/lib/api.ts | 8 ++- apps/web/src/pages/dashboard/index.tsx | 2 +- packages/database/src/seed.ts | 12 ++++- 8 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/routes/nodes/daemon.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 36c8c4e..5ad3fb0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -7,6 +7,7 @@ import dbPlugin from './plugins/db.js'; import authPlugin from './plugins/auth.js'; import authRoutes from './routes/auth/index.js'; import organizationRoutes from './routes/organizations/index.js'; +import daemonNodeRoutes from './routes/nodes/daemon.js'; import nodeRoutes from './routes/nodes/index.js'; import serverRoutes from './routes/servers/index.js'; import adminRoutes from './routes/admin/index.js'; @@ -83,6 +84,7 @@ app.get('/api/health', async () => { await app.register(authRoutes, { prefix: '/api/auth' }); await app.register(organizationRoutes, { prefix: '/api/organizations' }); await app.register(adminRoutes, { prefix: '/api/admin' }); +await app.register(daemonNodeRoutes, { prefix: '/api/nodes' }); // Nested org routes: nodes and servers are scoped to an org await app.register( diff --git a/apps/api/src/lib/jwt.ts b/apps/api/src/lib/jwt.ts index d25c906..7d5b2c6 100644 --- a/apps/api/src/lib/jwt.ts +++ b/apps/api/src/lib/jwt.ts @@ -15,13 +15,25 @@ const ACCESS_TOKEN_EXPIRY = '15m'; const REFRESH_TOKEN_EXPIRY = '7d'; export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayload): string { - return app.jwt.sign(payload, { expiresIn: ACCESS_TOKEN_EXPIRY }); + const signer = (app as any).jwt?.sign; + if (typeof signer !== 'function') { + throw new Error('JWT signer is not configured'); + } + return signer(payload, { expiresIn: ACCESS_TOKEN_EXPIRY }); } export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayload): string { - return (app as any).jwtRefresh.sign(payload, { expiresIn: REFRESH_TOKEN_EXPIRY }); + const signer = (app as any).jwt?.refresh?.sign ?? (app as any).jwt?.jwtRefresh?.sign; + if (typeof signer !== 'function') { + throw new Error('Refresh JWT signer is not configured'); + } + return signer(payload, { expiresIn: REFRESH_TOKEN_EXPIRY }); } export function verifyRefreshToken(app: FastifyInstance, token: string): RefreshTokenPayload { - return (app as any).jwtRefresh.verify(token) as RefreshTokenPayload; + const verifier = (app as any).jwt?.refresh?.verify ?? (app as any).jwt?.jwtRefresh?.verify; + if (typeof verifier !== 'function') { + throw new Error('Refresh JWT verifier is not configured'); + } + return verifier(token) as RefreshTokenPayload; } diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index ebabc50..b351126 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -6,7 +6,6 @@ import type { AccessTokenPayload } from '../lib/jwt.js'; declare module 'fastify' { interface FastifyInstance { authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; - jwtRefresh: FastifyInstance['jwt']; } } @@ -28,13 +27,13 @@ export default fp(async (app: FastifyInstance) => { // Access token JWT await app.register(jwt, { secret: jwtSecret, - namespace: 'jwt', }); // Refresh token JWT (separate namespace) await app.register(jwt, { secret: jwtRefreshSecret, - namespace: 'jwtRefresh', + namespace: 'refresh', + decoratorName: 'jwtRefresh', }); // Auth decorator diff --git a/apps/api/src/routes/nodes/daemon.ts b/apps/api/src/routes/nodes/daemon.ts new file mode 100644 index 0000000..cf51fad --- /dev/null +++ b/apps/api/src/routes/nodes/daemon.ts @@ -0,0 +1,68 @@ +import { Type } from '@sinclair/typebox'; +import type { FastifyInstance } from 'fastify'; +import { eq } from 'drizzle-orm'; +import { nodes } from '@source/database'; +import { AppError } from '../../lib/errors.js'; + +const HeartbeatSchema = { + body: Type.Object({ + active_servers: Type.Number({ minimum: 0 }), + total_servers: Type.Number({ minimum: 0 }), + version: Type.String(), + }), +}; + +function extractBearerToken(authHeader?: string): string | null { + if (!authHeader) return null; + const [scheme, token] = authHeader.split(' '); + if (!scheme || !token || scheme.toLowerCase() !== 'bearer') return null; + return token; +} + +export default async function daemonNodeRoutes(app: FastifyInstance) { + // POST /api/nodes/heartbeat + app.post('/heartbeat', { schema: HeartbeatSchema }, async (request) => { + const token = extractBearerToken( + typeof request.headers.authorization === 'string' + ? request.headers.authorization + : undefined, + ); + + if (!token) { + throw AppError.unauthorized('Missing daemon bearer token', 'DAEMON_AUTH_MISSING'); + } + + const node = await app.db.query.nodes.findFirst({ + where: eq(nodes.daemonToken, token), + columns: { id: true }, + }); + + if (!node) { + throw AppError.unauthorized('Invalid daemon token', 'DAEMON_AUTH_INVALID'); + } + + const now = new Date(); + await app.db + .update(nodes) + .set({ + isOnline: true, + lastHeartbeat: now, + updatedAt: now, + }) + .where(eq(nodes.id, node.id)); + + const body = request.body as { + active_servers: number; + total_servers: number; + version: string; + }; + + return { + success: true, + nodeId: node.id, + activeServers: body.active_servers, + totalServers: body.total_servers, + version: body.version, + }; + }); +} diff --git a/apps/api/src/routes/nodes/index.ts b/apps/api/src/routes/nodes/index.ts index 677766a..2c0bf93 100644 --- a/apps/api/src/routes/nodes/index.ts +++ b/apps/api/src/routes/nodes/index.ts @@ -26,7 +26,16 @@ export default async function nodeRoutes(app: FastifyInstance) { .where(eq(nodes.organizationId, orgId)) .orderBy(nodes.createdAt); - return { data: nodeList }; + const total = nodeList.length; + return { + data: nodeList, + meta: { + total, + page: 1, + perPage: total, + totalPages: total === 0 ? 0 : 1, + }, + }; }); // POST /api/organizations/:orgId/nodes diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 7b31bad..03aafa1 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -38,7 +38,13 @@ async function request(path: string, options: RequestOptions = {}): Promise s.status === 'running').length; - const totalNodes = nodesData?.meta.total ?? 0; + const totalNodes = nodesData?.meta?.total ?? nodesData?.data?.length ?? 0; return (
diff --git a/packages/database/src/seed.ts b/packages/database/src/seed.ts index 0b9183d..ead1d30 100644 --- a/packages/database/src/seed.ts +++ b/packages/database/src/seed.ts @@ -16,7 +16,7 @@ async function seed() { // Password: admin123 (argon2id hash) // In production, change this immediately after first login const ADMIN_PASSWORD_HASH = - '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+daw'; + '$argon2id$v=19$m=65536,t=3,p=4$3968YbMY1wOYMK5NTLa2dQ$j8BkXfK7znAAiuYiC9zWgOaBK11VeimROd28QOMMgd0'; await db .insert(users) @@ -26,7 +26,15 @@ async function seed() { passwordHash: ADMIN_PASSWORD_HASH, isSuperAdmin: true, }) - .onConflictDoNothing(); + .onConflictDoUpdate({ + target: users.email, + set: { + username: 'admin', + passwordHash: ADMIN_PASSWORD_HASH, + isSuperAdmin: true, + updatedAt: new Date(), + }, + }); // Seed games console.log('Seeding games...');