Fix auth flows and add daemon heartbeat endpoint
This commit is contained in:
parent
c926613ee0
commit
d7d8fd5339
|
|
@ -7,6 +7,7 @@ import dbPlugin from './plugins/db.js';
|
||||||
import authPlugin from './plugins/auth.js';
|
import authPlugin from './plugins/auth.js';
|
||||||
import authRoutes from './routes/auth/index.js';
|
import authRoutes from './routes/auth/index.js';
|
||||||
import organizationRoutes from './routes/organizations/index.js';
|
import organizationRoutes from './routes/organizations/index.js';
|
||||||
|
import daemonNodeRoutes from './routes/nodes/daemon.js';
|
||||||
import nodeRoutes from './routes/nodes/index.js';
|
import nodeRoutes from './routes/nodes/index.js';
|
||||||
import serverRoutes from './routes/servers/index.js';
|
import serverRoutes from './routes/servers/index.js';
|
||||||
import adminRoutes from './routes/admin/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(authRoutes, { prefix: '/api/auth' });
|
||||||
await app.register(organizationRoutes, { prefix: '/api/organizations' });
|
await app.register(organizationRoutes, { prefix: '/api/organizations' });
|
||||||
await app.register(adminRoutes, { prefix: '/api/admin' });
|
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
|
// Nested org routes: nodes and servers are scoped to an org
|
||||||
await app.register(
|
await app.register(
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,25 @@ const ACCESS_TOKEN_EXPIRY = '15m';
|
||||||
const REFRESH_TOKEN_EXPIRY = '7d';
|
const REFRESH_TOKEN_EXPIRY = '7d';
|
||||||
|
|
||||||
export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayload): string {
|
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 {
|
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 {
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import type { AccessTokenPayload } from '../lib/jwt.js';
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||||
jwtRefresh: FastifyInstance['jwt'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,13 +27,13 @@ export default fp(async (app: FastifyInstance) => {
|
||||||
// Access token JWT
|
// Access token JWT
|
||||||
await app.register(jwt, {
|
await app.register(jwt, {
|
||||||
secret: jwtSecret,
|
secret: jwtSecret,
|
||||||
namespace: 'jwt',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh token JWT (separate namespace)
|
// Refresh token JWT (separate namespace)
|
||||||
await app.register(jwt, {
|
await app.register(jwt, {
|
||||||
secret: jwtRefreshSecret,
|
secret: jwtRefreshSecret,
|
||||||
namespace: 'jwtRefresh',
|
namespace: 'refresh',
|
||||||
|
decoratorName: 'jwtRefresh',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth decorator
|
// Auth decorator
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,16 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
||||||
.where(eq(nodes.organizationId, orgId))
|
.where(eq(nodes.organizationId, orgId))
|
||||||
.orderBy(nodes.createdAt);
|
.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
|
// POST /api/organizations/:orgId/nodes
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,13 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||||
|
|
||||||
const res = await fetch(url, { ...fetchOptions, headers });
|
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
|
// Try refresh
|
||||||
const refreshed = await refreshToken();
|
const refreshed = await refreshToken();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export function DashboardPage() {
|
||||||
|
|
||||||
const servers = serversData?.data ?? [];
|
const servers = serversData?.data ?? [];
|
||||||
const running = servers.filter((s) => s.status === 'running').length;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ async function seed() {
|
||||||
// Password: admin123 (argon2id hash)
|
// Password: admin123 (argon2id hash)
|
||||||
// In production, change this immediately after first login
|
// In production, change this immediately after first login
|
||||||
const ADMIN_PASSWORD_HASH =
|
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
|
await db
|
||||||
.insert(users)
|
.insert(users)
|
||||||
|
|
@ -26,7 +26,15 @@ async function seed() {
|
||||||
passwordHash: ADMIN_PASSWORD_HASH,
|
passwordHash: ADMIN_PASSWORD_HASH,
|
||||||
isSuperAdmin: true,
|
isSuperAdmin: true,
|
||||||
})
|
})
|
||||||
.onConflictDoNothing();
|
.onConflictDoUpdate({
|
||||||
|
target: users.email,
|
||||||
|
set: {
|
||||||
|
username: 'admin',
|
||||||
|
passwordHash: ADMIN_PASSWORD_HASH,
|
||||||
|
isSuperAdmin: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Seed games
|
// Seed games
|
||||||
console.log('Seeding games...');
|
console.log('Seeding games...');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue