import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; import { envSchema } from "@siax/c0py-config"; import { createIntrospector } from "./auth/introspection.js"; import { getPool, closePool } from "./db/pool.js"; import { listRegistries, createRegistry, getRegistry, } from "./services/registries.js"; import { createScan, listScans, getScan } from "./services/scans.js"; async function main() { const env = envSchema.parse(process.env); const server = Fastify({ logger: true }); server.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString(), })); const introspectionConfigured = env.ZITADEL_INTROSPECTION_CLIENT_ID !== undefined && env.ZITADEL_INTROSPECTION_CLIENT_SECRET !== undefined; if (!introspectionConfigured && env.NODE_ENV === "production") { server.log.warn( "ZITADEL_INTROSPECTION_CLIENT_ID/SECRET not set — running Bearer-presence-only auth", ); } const introspect = introspectionConfigured ? createIntrospector({ issuer: env.ZITADEL_ISSUER, clientId: env.ZITADEL_INTROSPECTION_CLIENT_ID as string, clientSecret: env.ZITADEL_INTROSPECTION_CLIENT_SECRET as string, expectedAudience: env.ZITADEL_EXPECTED_AUDIENCE, logger: server.log, }) : null; server.addHook("preHandler", async (request, reply) => { if (request.url === "/health") return; const auth = request.headers.authorization; if (!auth || !auth.startsWith("Bearer ")) { await reply.code(401).send({ error: "Unauthorized" }); return; } if (introspect) { const token = auth.slice("Bearer ".length).trim(); const result = await introspect(token); if (!result.active) { await reply .code(401) .header("WWW-Authenticate", `Bearer error="invalid_token"`) .send({ error: "Unauthorized" }); return; } (request as never as { user?: { sub?: string } }).user = { sub: result.sub }; } }); const requireSub = (request: FastifyRequest): string => { const user = (request as unknown as { user?: { sub?: string } }).user; if (!user?.sub) { throw Object.assign(new Error("missing subject"), { statusCode: 401 }); } return user.sub; }; const dbError = (reply: FastifyReply, err: unknown) => { reply.log.error(err, "database error"); return reply.code(500).send({ error: "Internal Server Error" }); }; // Registry endpoints (owner-scoped by introspected subject) server.get("/v1/c0py/registries", async (request, reply) => { try { return { registries: await listRegistries(getPool(), requireSub(request)) }; } catch (err) { return dbError(reply, err); } }); server.post("/v1/c0py/registries", async (request, reply) => { const body = (request.body as Record) ?? {}; const name = typeof body.name === "string" ? body.name.trim() : ""; const url = typeof body.url === "string" ? body.url.trim() : ""; if (!name || !url) { await reply.code(400).send({ error: "name and url are required" }); return; } try { const registry = await createRegistry(getPool(), requireSub(request), { name, url, config: body.config, }); await reply.code(201).send({ registry }); } catch (err) { return dbError(reply, err); } }); server.get("/v1/c0py/registries/:id", async (request, reply) => { const { id } = request.params as Record; try { const registry = await getRegistry(getPool(), requireSub(request), id); if (!registry) { await reply.code(404).send({ error: "Not Found" }); return; } return { registry }; } catch (err) { return dbError(reply, err); } }); // Scan endpoints (owner-scoped, registry ownership enforced in SQL) server.post("/v1/c0py/scans", async (request, reply) => { const body = (request.body as Record) ?? {}; const registryId = typeof body.registryId === "string" ? body.registryId.trim() : ""; if (!registryId) { await reply.code(400).send({ error: "registryId is required" }); return; } try { const scan = await createScan(getPool(), requireSub(request), registryId); if (!scan) { await reply.code(404).send({ error: "Not Found" }); return; } await reply.code(201).send({ scan }); } catch (err) { return dbError(reply, err); } }); server.get("/v1/c0py/scans", async (request, reply) => { const query = request.query as Record; try { return { scans: await listScans(getPool(), requireSub(request), query.registryId) }; } catch (err) { return dbError(reply, err); } }); server.get("/v1/c0py/scans/:id", async (request, reply) => { const { id } = request.params as Record; try { const scan = await getScan(getPool(), requireSub(request), id); if (!scan) { await reply.code(404).send({ error: "Not Found" }); return; } return { scan }; } catch (err) { return dbError(reply, err); } }); await server.listen({ port: env.PORT, host: "0.0.0.0" }); console.log(`c0py API listening on port ${env.PORT}`); for (const signal of ["SIGTERM", "SIGINT"] as const) { process.on(signal, async () => { await server.close(); await closePool(); process.exit(0); }); } } main().catch((err) => { console.error(err); process.exit(1); });