Fix auth flows and add daemon heartbeat endpoint

This commit is contained in:
hibna 2026-02-22 09:41:17 +00:00
parent c926613ee0
commit d7d8fd5339
8 changed files with 115 additions and 11 deletions

View File

@ -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(

View File

@ -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;
}

View File

@ -6,7 +6,6 @@ import type { AccessTokenPayload } from '../lib/jwt.js';
declare module 'fastify' {
interface FastifyInstance {
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
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

View File

@ -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,
};
});
}

View File

@ -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

View File

@ -38,7 +38,13 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
const res = await fetch(url, { ...fetchOptions, headers });
if (res.status === 401) {
const shouldHandle401WithRefresh =
res.status === 401 &&
path !== '/auth/login' &&
path !== '/auth/register' &&
path !== '/auth/refresh';
if (shouldHandle401WithRefresh) {
// Try refresh
const refreshed = await refreshToken();
if (refreshed) {

View File

@ -37,7 +37,7 @@ export function DashboardPage() {
const servers = serversData?.data ?? [];
const running = servers.filter((s) => s.status === 'running').length;
const totalNodes = nodesData?.meta.total ?? 0;
const totalNodes = nodesData?.meta?.total ?? nodesData?.data?.length ?? 0;
return (
<div className="space-y-6">

View File

@ -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...');