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 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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
.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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
|
|
|
|||
Loading…
Reference in New Issue