feat: add mosaic gateway CLI commands and admin token auth
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

Gateway daemon lifecycle management via the Mosaic CLI:
- install, start, stop, restart, status, config, logs, uninstall
- Admin API token auth (Bearer tokens) alongside existing session auth
- Bootstrap endpoint for first-user setup (POST /api/bootstrap/setup)
- admin_tokens table in DB schema with SHA-256 hashed tokens
- Gateway .env fallback loading from ~/.config/mosaic/gateway/.env
- CLI daemon management with PID file, log tailing, health checks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jarvis
2026-04-04 12:59:11 -05:00
parent 202e375f41
commit 0e6f5eace0
16 changed files with 1325 additions and 188 deletions

View File

@@ -0,0 +1,101 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Inject,
InternalServerErrorException,
Post,
} from '@nestjs/common';
import { randomBytes, createHash } from 'node:crypto';
import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaic/db';
import type { Auth } from '@mosaic/auth';
import { v4 as uuid } from 'uuid';
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
import type { BootstrapSetupDto, BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
@Controller('api/bootstrap')
export class BootstrapController {
constructor(
@Inject(AUTH) private readonly auth: Auth,
@Inject(DB) private readonly db: Db,
) {}
@Get('status')
async status(): Promise<BootstrapStatusDto> {
const [result] = await this.db.select({ total: count() }).from(usersTable);
return { needsSetup: (result?.total ?? 0) === 0 };
}
@Post('setup')
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
// Only allow setup when zero users exist
const [result] = await this.db.select({ total: count() }).from(usersTable);
if ((result?.total ?? 0) > 0) {
throw new ForbiddenException('Setup already completed — users exist');
}
// Create admin user via BetterAuth API
const authApi = this.auth.api as unknown as {
createUser: (opts: {
body: { name: string; email: string; password: string; role?: string };
}) => Promise<{
user: { id: string; name: string; email: string };
}>;
};
const created = await authApi.createUser({
body: {
name: dto.name,
email: dto.email,
password: dto.password,
role: 'admin',
},
});
// Verify user was created
const [user] = await this.db
.select()
.from(usersTable)
.where(eq(usersTable.id, created.user.id))
.limit(1);
if (!user) throw new InternalServerErrorException('User created but not found');
// Ensure role is admin (createUser may not set it via BetterAuth)
if (user.role !== 'admin') {
await this.db.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
}
// Generate admin API token
const plaintext = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const tokenId = uuid();
const [token] = await this.db
.insert(adminTokens)
.values({
id: tokenId,
userId: user.id,
tokenHash,
label: 'Initial setup token',
scope: 'admin',
})
.returning();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: 'admin',
},
token: {
id: token!.id,
plaintext,
label: token!.label,
},
};
}
}