9482a97795
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)
176 lines
5.5 KiB
TypeScript
176 lines
5.5 KiB
TypeScript
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<string, unknown>) ?? {};
|
|
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<string, string>;
|
|
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<string, unknown>) ?? {};
|
|
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<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>;
|
|
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);
|
|
});
|