import type { FastifyInstance } from 'fastify' import Fastify from 'fastify' import cors from '@fastify/cors' import helmet from '@fastify/helmet' import rateLimit from '@fastify/rate-limit' import sensible from '@fastify/sensible' import formbody from '@fastify/formbody' import fjwt from '@fastify/jwt' import fastifyStatic from '@fastify/static' import { existsSync, mkdirSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { APP_IDS, allPermissionKeys, } from '@authportal/shared' import { createDb, getUserApps, healthCheck, migrateSchema, setUserAccess, users, type AppDb, type Sqlite, } from '@authportal/db' import { hash } from '@node-rs/argon2' import { randomUUID } from 'node:crypto' import type { AppConfig } from './config.js' import { authRoutes } from './routes/auth.js' import { adminRoutes } from './routes/admin.js' import { auditAdminRoutes } from './routes/audit.js' import { auditIngestRoutes } from './routes/ingest-audit.js' import { oidcRoutes } from './routes/oidc.js' import { startAuditRetentionJob } from './services/audit-retention.js' import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js' declare module 'fastify' { interface FastifyInstance { config: AppConfig db: AppDb sqlite: Sqlite } } async function ensureBootstrapAdmin(app: FastifyInstance): Promise { const existing = app.db.select().from(users).all() if (existing.length === 0) { const now = new Date().toISOString() const passwordHash = await hash(app.config.adminPassword) const id = randomUUID() app.db .insert(users) .values({ id, email: app.config.adminEmail.toLowerCase(), name: app.config.adminName, passwordHash, isAdmin: true, disabled: false, createdAt: now, updatedAt: now, }) .run() setUserAccess(app.db, id, [...APP_IDS], allPermissionKeys()) app.log.info(`bootstrap admin created: ${app.config.adminEmail}`) return } // Ensure existing admins have app tiles if access was never set for (const user of existing) { if (!user.isAdmin) continue const apps = getUserApps(app.db, user.id) if (apps.length === 0) { setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys()) app.log.info(`granted full catalog access to admin ${user.email}`) } } } export async function buildApp(opts: { config: AppConfig databaseUrl?: string }): Promise { const config = opts.config const dbUrl = opts.databaseUrl ?? config.databaseUrl const path = dbUrl.replace(/^sqlite:/, '') if (path !== ':memory:') { mkdirSync(dirname(resolve(path)), { recursive: true }) } const { db, sqlite } = createDb(dbUrl) migrateSchema(sqlite) const app = Fastify({ logger: { level: config.logLevel }, trustProxy: true, }) app.decorate('config', config) app.decorate('db', db) app.decorate('sqlite', sqlite) await app.register(sensible) await app.register(formbody) await app.register(cors, { origin: true, credentials: true }) await app.register(helmet, { contentSecurityPolicy: false }) await app.register(rateLimit, { max: 200, timeWindow: '1 minute' }) await app.register(fjwt, { secret: config.jwtSecret }) app.get('/health', async () => { healthCheck(sqlite) return { ok: true } }) app.get('/ready', async () => { healthCheck(sqlite) return { ok: true } }) app.get('/api/v1/health', async () => ({ ok: true, service: 'auth-portal' })) resetOidcKeyCache() await ensureOidcSigningKey(app) await ensureBootstrapAdmin(app) await app.register(authRoutes) await app.register(adminRoutes) await app.register(auditAdminRoutes) await app.register(auditIngestRoutes) await app.register(oidcRoutes) if (process.env.NODE_ENV !== 'test') { const stopRetention = startAuditRetentionJob(app) app.addHook('onClose', async () => { stopRetention() }) } if (config.staticDir && existsSync(config.staticDir)) { await app.register(fastifyStatic, { root: resolve(config.staticDir), wildcard: false, }) app.setNotFoundHandler((req, reply) => { if (req.method === 'GET' && !req.url.startsWith('/api')) { return reply.sendFile('index.html') } return reply .status(404) .send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) }) } return app }