feat: mosaic gateway CLI daemon management + admin token auth (#369)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed

This commit was merged in pull request #369.
This commit is contained in:
2026-04-04 18:03:12 +00:00
parent 202e375f41
commit 39ccba95d0
16 changed files with 1325 additions and 188 deletions

View File

@@ -6,10 +6,11 @@ import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaic/auth';
import type { Db } from '@mosaic/db';
import { eq, users as usersTable } from '@mosaic/db';
import { eq, adminTokens, users as usersTable } from '@mosaic/db';
import type { FastifyRequest } from 'fastify';
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
@@ -19,6 +20,8 @@ interface UserWithRole {
role?: string;
}
type AuthenticatedRequest = FastifyRequest & { user: unknown; session: unknown };
@Injectable()
export class AdminGuard implements CanActivate {
constructor(
@@ -28,8 +31,64 @@ export class AdminGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<FastifyRequest>();
const headers = fromNodeHeaders(request.raw.headers);
// Try bearer token auth first
const authHeader = request.raw.headers['authorization'];
if (authHeader?.startsWith('Bearer ')) {
return this.validateBearerToken(request, authHeader.slice(7));
}
// Fall back to BetterAuth session
return this.validateSession(request);
}
private async validateBearerToken(request: FastifyRequest, plaintext: string): Promise<boolean> {
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const [row] = await this.db
.select({
tokenId: adminTokens.id,
userId: adminTokens.userId,
scope: adminTokens.scope,
expiresAt: adminTokens.expiresAt,
userName: usersTable.name,
userEmail: usersTable.email,
userRole: usersTable.role,
})
.from(adminTokens)
.innerJoin(usersTable, eq(adminTokens.userId, usersTable.id))
.where(eq(adminTokens.tokenHash, tokenHash))
.limit(1);
if (!row) {
throw new UnauthorizedException('Invalid API token');
}
if (row.expiresAt && row.expiresAt < new Date()) {
throw new UnauthorizedException('API token expired');
}
if (row.userRole !== 'admin') {
throw new ForbiddenException('Admin access required');
}
// Update last-used timestamp (fire-and-forget)
this.db
.update(adminTokens)
.set({ lastUsedAt: new Date() })
.where(eq(adminTokens.id, row.tokenId))
.then(() => {})
.catch(() => {});
const req = request as AuthenticatedRequest;
req.user = { id: row.userId, name: row.userName, email: row.userEmail, role: row.userRole };
req.session = { id: `token:${row.tokenId}`, userId: row.userId };
return true;
}
private async validateSession(request: FastifyRequest): Promise<boolean> {
const headers = fromNodeHeaders(request.raw.headers);
const result = await this.auth.api.getSession({ headers });
if (!result) {
@@ -38,8 +97,6 @@ export class AdminGuard implements CanActivate {
const user = result.user as UserWithRole;
// Ensure the role field is populated. better-auth should include additionalFields
// in the session, but as a fallback, fetch the role from the database if needed.
let userRole = user.role;
if (!userRole) {
const [dbUser] = await this.db
@@ -48,7 +105,6 @@ export class AdminGuard implements CanActivate {
.where(eq(usersTable.id, user.id))
.limit(1);
userRole = dbUser?.role ?? 'member';
// Update the session user object with the fetched role
(user as UserWithRole).role = userRole;
}
@@ -56,8 +112,9 @@ export class AdminGuard implements CanActivate {
throw new ForbiddenException('Admin access required');
}
(request as FastifyRequest & { user: unknown; session: unknown }).user = result.user;
(request as FastifyRequest & { user: unknown; session: unknown }).session = result.session;
const req = request as AuthenticatedRequest;
req.user = result.user;
req.session = result.session;
return true;
}