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
@@ -0,0 +1,26 @@
-- C0PY 001 — registries + scans (canonical capture target registry)
-- Idempotent; owner_sub is the Zitadel introspection subject (never client-controlled).
CREATE TABLE IF NOT EXISTS registries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_sub TEXT NOT NULL,
name TEXT NOT NULL,
url TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS registries_owner_idx ON registries(owner_sub);
CREATE TABLE IF NOT EXISTS scans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
registry_id UUID NOT NULL REFERENCES registries(id) ON DELETE CASCADE,
owner_sub TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
result JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS scans_owner_idx ON scans(owner_sub);
CREATE INDEX IF NOT EXISTS scans_registry_idx ON scans(registry_id);
+3 -1
View File
@@ -11,14 +11,16 @@
"test": "vitest run"
},
"dependencies": {
"@siax/c0py-core": "workspace:*",
"@siax/c0py-config": "workspace:*",
"@siax/c0py-core": "workspace:*",
"@siax/c0py-types": "workspace:*",
"fastify": "^5.12.0",
"pg": "^8.23.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/pg": "^8.23.1",
"tsx": "^4.21.0",
"typescript": "^5.7.2",
"vitest": "^3.0.0"
+22
View File
@@ -0,0 +1,22 @@
import { Pool } from "pg";
let pool: Pool | null = null;
export function getPool(): Pool {
if (!pool) {
pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
}
return pool;
}
export async function closePool(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}
+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) => {
+56
View File
@@ -0,0 +1,56 @@
export interface RegistryRow {
id: string;
owner_sub: string;
name: string;
url: string;
config: unknown;
created_at: Date;
}
export interface PoolLike {
query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
}
function toRegistry(row: Record<string, unknown>): {
id: string;
name: string;
url: string;
config: unknown;
createdAt: string;
} {
return {
id: String(row.id),
name: String(row.name),
url: String(row.url),
config: row.config,
createdAt: new Date(row.created_at as string).toISOString(),
};
}
export async function listRegistries(db: PoolLike, ownerSub: string) {
const res = await db.query(
"SELECT id, owner_sub, name, url, config, created_at FROM registries WHERE owner_sub = $1 ORDER BY created_at DESC",
[ownerSub],
);
return res.rows.map(toRegistry);
}
export async function createRegistry(
db: PoolLike,
ownerSub: string,
input: { name: string; url: string; config?: unknown },
) {
const res = await db.query(
"INSERT INTO registries (owner_sub, name, url, config) VALUES ($1, $2, $3, $4::jsonb) RETURNING id, owner_sub, name, url, config, created_at",
[ownerSub, input.name, input.url, JSON.stringify(input.config ?? {})],
);
return toRegistry(res.rows[0]);
}
export async function getRegistry(db: PoolLike, ownerSub: string, id: string) {
const res = await db.query(
"SELECT id, owner_sub, name, url, config, created_at FROM registries WHERE owner_sub = $1 AND id = $2",
[ownerSub, id],
);
return res.rows[0] ? toRegistry(res.rows[0]) : null;
}
+70
View File
@@ -0,0 +1,70 @@
export interface ScanRow {
id: string;
registry_id: string;
owner_sub: string;
status: string;
result: unknown;
created_at: Date;
updated_at: Date;
}
export interface PoolLike {
query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
}
function toScan(row: Record<string, unknown>): {
id: string;
registryId: string;
status: string;
result: unknown;
createdAt: string;
updatedAt: string;
} {
return {
id: String(row.id),
registryId: String(row.registry_id),
status: String(row.status),
result: row.result,
createdAt: new Date(row.created_at as string).toISOString(),
updatedAt: new Date(row.updated_at as string).toISOString(),
};
}
export async function createScan(
db: PoolLike,
ownerSub: string,
registryId: string,
): Promise<ReturnType<typeof toScan> | null> {
// Ownership check happens in SQL: the scan may only reference a registry
// owned by the same subject. Inserting otherwise returns no rows (fail-closed).
const res = await db.query(
`INSERT INTO scans (registry_id, owner_sub, status)
SELECT id, $1, 'pending' FROM registries WHERE id = $2 AND owner_sub = $1
RETURNING id, registry_id, owner_sub, status, result, created_at, updated_at`,
[ownerSub, registryId],
);
return res.rows[0] ? toScan(res.rows[0]) : null;
}
export async function listScans(db: PoolLike, ownerSub: string, registryId?: string) {
if (registryId !== undefined) {
const res = await db.query(
"SELECT id, registry_id, owner_sub, status, result, created_at, updated_at FROM scans WHERE owner_sub = $1 AND registry_id = $2 ORDER BY created_at DESC",
[ownerSub, registryId],
);
return res.rows.map(toScan);
}
const res = await db.query(
"SELECT id, registry_id, owner_sub, status, result, created_at, updated_at FROM scans WHERE owner_sub = $1 ORDER BY created_at DESC",
[ownerSub],
);
return res.rows.map(toScan);
}
export async function getScan(db: PoolLike, ownerSub: string, id: string) {
const res = await db.query(
"SELECT id, registry_id, owner_sub, status, result, created_at, updated_at FROM scans WHERE owner_sub = $1 AND id = $2",
[ownerSub, id],
);
return res.rows[0] ? toScan(res.rows[0]) : null;
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { listRegistries, createRegistry, getRegistry } from "../src/services/registries.js";
import { createScan, listScans, getScan } from "../src/services/scans.js";
import type { PoolLike } from "../src/services/registries.js";
function fakePool(handlers: {
query: (text: string, values?: unknown[]) => { rows: Record<string, unknown>[] };
}): PoolLike & { queries: { text: string; values?: unknown[] }[] } {
const queries: { text: string; values?: unknown[] }[] = [];
return {
queries,
query(text, values) {
queries.push({ text, values });
return handlers.query(text, values);
},
};
}
const ROW = {
id: "11111111-1111-1111-1111-111111111111",
owner_sub: "svc-user",
name: "example",
url: "https://example.com",
config: {},
created_at: new Date("2026-09-16T10:00:00Z"),
};
describe("registries service", () => {
it("listRegistries filters by owner", async () => {
const db = fakePool({ query: () => ({ rows: [ROW] }) });
const out = await listRegistries(db, "svc-user");
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ id: ROW.id, name: "example", createdAt: "2026-09-16T10:00:00.000Z" });
expect(db.queries[0].values).toEqual(["svc-user"]);
expect(db.queries[0].text).toContain("owner_sub = $1");
});
it("createRegistry inserts with owner and config jsonb", async () => {
const db = fakePool({ query: () => ({ rows: [ROW] }) });
const out = await createRegistry(db, "svc-user", { name: "example", url: "https://example.com" });
expect(out.id).toBe(ROW.id);
expect(db.queries[0].values?.[0]).toBe("svc-user");
expect(db.queries[0].text).toContain("RETURNING");
});
it("getRegistry returns null when not owner (fail-closed)", async () => {
const db = fakePool({ query: () => ({ rows: [] }) });
const out = await getRegistry(db, "other-user", ROW.id);
expect(out).toBeNull();
});
});
describe("scans service", () => {
const SCAN_ROW = {
...ROW,
registry_id: ROW.id,
status: "pending",
result: {},
updated_at: ROW.created_at,
};
it("createScan enforces registry ownership in SQL and returns null otherwise", async () => {
const db = fakePool({ query: () => ({ rows: [] }) });
const out = await createScan(db, "svc-user", ROW.id);
expect(out).toBeNull();
expect(db.queries[0].text).toContain("owner_sub = $1");
});
it("createScan returns scan for owned registry", async () => {
const db = fakePool({ query: () => ({ rows: [SCAN_ROW] }) });
const out = await createScan(db, "svc-user", ROW.id);
expect(out).toMatchObject({ id: SCAN_ROW.id, registryId: SCAN_ROW.id, status: "pending" });
});
it("listScans supports optional registryId filter", async () => {
const db = fakePool({ query: () => ({ rows: [SCAN_ROW] }) });
await listScans(db, "svc-user");
expect(db.queries[0].text).not.toContain("registry_id = $2");
await listScans(db, "svc-user", SCAN_ROW.id);
expect(db.queries[1].values).toEqual(["svc-user", SCAN_ROW.id]);
});
it("getScan returns null for foreign scan", async () => {
const db = fakePool({ query: () => ({ rows: [] }) });
expect(await getScan(db, "other-user", SCAN_ROW.id)).toBeNull();
});
});