feat(api): Postgres persistence for registries + scans (owner-scoped, fail-closed)
CI (SIAX Cloud) / security (push) Successful in 13s
CI (SIAX Cloud) / contracts (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 13s
CI (SIAX Cloud) / contracts (push) Successful in 16s
CI (SIAX Cloud) / quality (push) Successful in 52s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m13s

- schema migration 001 (registries, scans; owner_sub = introspected subject)
- pg pool + services; scan ownership enforced in SQL (INSERT ... SELECT WHERE owner_sub)
- real handlers: 201/400/404/500, owner-scoped GET/POST, listScans registryId filter
- graceful shutdown closes pool; 7 new persistence tests (21/21)
This commit is contained in:
2026-09-16 22:32:48 +02:00
parent 72000b79ae
commit 9482a97795
8 changed files with 480 additions and 19 deletions
+100 -18
View File
@@ -1,6 +1,13 @@
import Fastify from "fastify";
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);
@@ -51,40 +58,115 @@ async function main() {
}
});
server.get("/", async () => ({
name: "c0py",
version: "0.1.0",
status: "running",
}));
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;
};
// Registry endpoints
server.get("/v1/c0py/registries", async () => {
return { registries: [] };
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) => {
server.post("/v1/c0py/registries", async (request, reply) => {
const body = (request.body as Record<string, unknown>) ?? {};
return { created: body };
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) => {
server.get("/v1/c0py/registries/:id", async (request, reply) => {
const { id } = request.params as Record<string, string>;
return { id };
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
server.post("/v1/c0py/scans", async (request) => {
// Scan endpoints (owner-scoped, registry ownership enforced in SQL)
server.post("/v1/c0py/scans", async (request, reply) => {
const body = (request.body as Record<string, unknown>) ?? {};
return { scanId: "scan-" + Date.now(), created: body };
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/:id", async (request) => {
server.get("/v1/c0py/scans", async (request, reply) => {
const query = request.query as Record<string, string | undefined>;
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<string, string>;
return { id };
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) => {