import Fastify from 'fastify'; import { readFileSync } from 'node:fs'; import helmet from '@fastify/helmet'; import sensible from '@fastify/sensible'; import swagger from '@fastify/swagger'; import type { AppConfig } from './config.js'; import { Database } from './infrastructure/database.js'; import { newUlid } from './core/ids.js'; import { SERVICE_NAME, SERVICE_VERSION } from './version.js'; import { registerPortalRoutes } from './portal.js'; import { registerSyncRoutes } from './sync.js'; import { portalCssEnhanced as portalCss, portalHtml, portalIcon192, portalIcon512, portalJsEnhanced as portalJs, portalManifest, portalServiceWorkerEnhanced as portalServiceWorker, } from './portal-ui.js'; export interface BuildAppOptions { config: AppConfig; database?: Database; } const apiPrefix = '/api/v1/staff'; const ulidPattern = /^[0-9A-HJKMNP-TV-Z]{26}$/; export async function buildApp({ config, database = new Database(config.DATABASE_URL), }: BuildAppOptions) { const https = config.HTTPS_PFX_PATH ? { pfx: readFileSync(config.HTTPS_PFX_PATH), passphrase: config.HTTPS_PFX_PASSPHRASE, minVersion: 'TLSv1.2' as const, } : undefined; const app = Fastify({ ...(https ? { https } : {}), logger: { level: config.LOG_LEVEL }, genReqId: (request) => { const supplied = request.headers['x-correlation-id']?.toString(); return supplied && ulidPattern.test(supplied) ? supplied : newUlid(); }, }); await app.register(helmet); await app.register(sensible); app.addHook('onRequest', async (request, reply) => { reply.header('x-correlation-id', request.id); }); await app.register(swagger, { openapi: { info: { title: 'Krona Staff API', version: SERVICE_VERSION }, servers: [{ url: apiPrefix }], }, }); await app.register( async (api) => { api.get( '/live', { schema: { tags: ['Operations'], response: { 200: { type: 'object', required: ['status'], properties: { status: { type: 'string', const: 'ok' } }, }, }, }, }, async () => ({ status: 'ok' }), ); api.get( '/ready', { schema: { tags: ['Operations'], response: { 200: { type: 'object', required: ['status'], properties: { status: { type: 'string', const: 'ready' } }, }, 503: { type: 'object', required: ['status'], properties: { status: { type: 'string', const: 'unavailable' } }, }, }, }, }, async (_request, reply) => { try { await database.ping(); return { status: 'ready' }; } catch (error) { api.log.error({ err: error }, 'readiness dependency check failed'); return reply.code(503).send({ status: 'unavailable' }); } }, ); api.get( '/version', { schema: { tags: ['Operations'], response: { 200: { type: 'object', required: ['name', 'version', 'mode'], properties: { name: { type: 'string' }, version: { type: 'string' }, mode: { type: 'string' }, }, }, }, }, }, async () => ({ name: SERVICE_NAME, version: SERVICE_VERSION, mode: config.STAFF_RUNTIME_MODE, }), ); api.get('/openapi.json', async () => app.swagger()); await registerPortalRoutes(api, config, database); await registerSyncRoutes(api, config, database); }, { prefix: apiPrefix }, ); app.get('/', async (_request, reply) => reply.redirect('/staff')); app.get('/staff', async (_request, reply) => reply.type('text/html; charset=utf-8').send(portalHtml), ); app.get('/staff/styles.css', async (_request, reply) => reply.type('text/css; charset=utf-8').send(portalCss), ); app.get('/staff/app.js', async (_request, reply) => reply.type('application/javascript; charset=utf-8').send(portalJs), ); app.get('/staff/sw.js', async (_request, reply) => reply .header('service-worker-allowed', '/staff') .type('application/javascript; charset=utf-8') .send(portalServiceWorker), ); app.get('/staff/manifest.webmanifest', async (_request, reply) => reply.type('application/manifest+json').send(portalManifest), ); app.get('/staff/icon-192.svg', async (_request, reply) => reply.type('image/svg+xml').send(portalIcon192), ); app.get('/staff/icon-512.svg', async (_request, reply) => reply.type('image/svg+xml').send(portalIcon512), ); app.get('/staff/*', async (_request, reply) => reply.type('text/html; charset=utf-8').send(portalHtml), ); app.addHook('onClose', async () => database.close()); return app; }