a457b9d96f
CI / Lint, typecheck, test, build (pull_request) Failing after 10s
- packages/db: postgres.js + ioredis wrapper, StateStore interface with InMemory + Postgres implementations, schema migration runner - apps/api: refactored to use @kerbal-rt/db; live WebSocket hub subscribes to store changes and broadcasts to all clients - apps/tools/mock-telemetry: Node script that generates realistic KSP state and POSTs to /api/v1/ingest at 1Hz (uses same keplerian math as the live-map renderer) - apps/hub: new /debug page that connects to /api/v1/live and shows the live state - ksp/README.md: documents Phase 1c (real kRPC bridge) and the two implementation options - Tests: 17 total (5 kepler math, 5 db store, 5 API health/state, 2 API WebSocket fan-out) End-to-end verified: mock publisher → API → hub /debug page, 11 snapshots/5s over WebSocket, vessels advancing in mean anomaly.
99 lines
3.5 KiB
TypeScript
99 lines
3.5 KiB
TypeScript
/**
|
|
* 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<typeof buildApp>;
|