/** * Build the Hono app. Exported separately from index.ts so tests can * import it without binding a port. */ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { UniverseSnapshotSchema } from '@kerbal-rt/shared-types'; import { z } from 'zod'; import type { StateStore } from '@kerbal-rt/db'; export interface AppDeps { store: StateStore; /** The expected ingest API key, or null to disable auth (dev only). */ ingestApiKey: string | null; } export function buildApp(deps: AppDeps) { const app = new Hono(); const { store, ingestApiKey } = deps; app.use('*', logger()); app.use('*', cors({ origin: process.env.CORS_ORIGIN?.split(',') ?? '*' })); // ─── health ───────────────────────────────────────────────────────────── app.get('/health', (c) => c.json({ ok: true, service: 'kerbal-rt-api', version: '0.1.0', store: store.constructor.name, ts: new Date().toISOString(), }), ); // ─── ingest (called by the KSP telemetry bridge) ─────────────────────── const IngestAuth = z.object({ 'x-api-key': z.string() }); app.post('/api/v1/ingest', async (c) => { if (ingestApiKey) { const headerParse = IngestAuth.safeParse( Object.fromEntries(Array.from(c.req.raw.headers.entries())), ); if (!headerParse.success) { return c.json({ error: true, code: 'UNAUTHORIZED', message: 'Missing API key' }, 401); } if (headerParse.data['x-api-key'] !== ingestApiKey) { return c.json({ error: true, code: 'FORBIDDEN', message: 'Bad API key' }, 403); } } else if (process.env.NODE_ENV === 'production') { // Belt and suspenders: never allow unauthenticated ingest in prod // even if the env var is missing. return c.json({ error: true, code: 'UNAUTHORIZED', message: 'Ingest disabled' }, 401); } const body = await c.req.json(); const parsed = UniverseSnapshotSchema.safeParse(body); if (!parsed.success) { return c.json( { error: true, code: 'BAD_PAYLOAD', message: 'Invalid snapshot', details: parsed.error.format(), }, 400, ); } const result = await store.applySnapshot(parsed.data); return c.json({ error: false, data: { ok: true, rowsInserted: result.rowsInserted, ts: new Date().toISOString() }, }); }); // ─── read-only endpoints ─────────────────────────────────────────────── app.get('/api/v1/state', async (c) => { const snap = await store.latestSnapshot(); if (!snap) return c.json({ error: true, code: 'NO_DATA', message: 'No snapshot yet' }, 503); return c.json({ error: false, data: snap }); }); app.get('/api/v1/bodies', async (c) => c.json({ error: false, data: await store.listBodies() })); app.get('/api/v1/vessels', async (c) => c.json({ error: false, data: await store.listVessels() }), ); app.get('/api/v1/vessels/:id', async (c) => { const v = await store.getVessel(c.req.param('id')); if (!v) return c.json({ error: true, code: 'NOT_FOUND', message: 'Vessel not found' }, 404); return c.json({ error: false, data: v }); }); return app; } export type AppType = ReturnType;