From f6ff1396d828f38d98e869da81e940d64238e22b Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 16 Sep 2026 23:21:54 +0200 Subject: [PATCH] feat(api): CL0UD capability probe + a11y/HAR evidence + worker scale toggle - cl0ud client: contract-first probe of documented GET /capabilities/:id, status connected/denied/unavailable/disabled surfaced in /health; CL0UD_REQUIRED=true = fail-closed 503; full policy-decisions gated on CL0UD B-4/ADR-0031 (deliberately not simulated) - capture depth: accessibility tree + condensed HAR as evidence kinds (null = explicit evidence gap) - CAPTURE_WORKER=on/off for separate-scale deployments - 34/34 tests --- apps/api/src/capture/capture.ts | 64 ++++++++++++++++++- apps/api/src/cl0ud/client.ts | 106 ++++++++++++++++++++++++++++++++ apps/api/src/index.ts | 33 +++++++++- apps/api/tests/cl0ud.test.ts | 50 +++++++++++++++ packages/config/src/index.ts | 4 ++ 5 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/cl0ud/client.ts create mode 100644 apps/api/tests/cl0ud.test.ts diff --git a/apps/api/src/capture/capture.ts b/apps/api/src/capture/capture.ts index 18655e0..627b8b5 100644 --- a/apps/api/src/capture/capture.ts +++ b/apps/api/src/capture/capture.ts @@ -15,6 +15,16 @@ export interface CapturedPage { images: string[]; computedStyle: Record>; networkRequests: { url: string; method: string; resourceType: string; status: number | null }[]; + accessibilityTree: { + role: string | null; + name: string | null; + value: string | null; + children?: unknown[]; + } | null; + har: { + entries: { url: string; method: string; status: number; mimeType: string | null; size: number | null }[]; + entryCount: number; + } | null; screenshotBase64: string | null; } @@ -85,6 +95,21 @@ export function buildEvidence(targetId: string, page: CapturedPage): CaptureEvid requests: page.networkRequests, }, tool), ]; + if (page.accessibilityTree) { + evidence.push( + record(targetId, page.finalUrl, "accessibility", { + tree: page.accessibilityTree, + }, tool), + ); + } + if (page.har) { + evidence.push( + record(targetId, page.finalUrl, "har", { + entries: page.har.entries, + entryCount: page.har.entryCount, + }, tool), + ); + } if (page.screenshotBase64) { evidence.push( record(targetId, page.finalUrl, "screenshot", { @@ -122,8 +147,12 @@ export async function capturePage( args: ["--no-sandbox", "--disable-dev-shm-usage"], }); const maxRequests = opts.maxRequests ?? 100; + const harPath = `/tmp/c0py-capture-${randomUUID()}.har`; try { - const context = await browser.newContext({ viewport: DEFAULT_VIEWPORT }); + const context = await browser.newContext({ + viewport: DEFAULT_VIEWPORT, + recordHar: { path: harPath, mode: "minimal" }, + }); const page = await context.newPage(); const networkRequests: CapturedPage["networkRequests"] = []; page.on("response", (res) => { @@ -205,15 +234,48 @@ export async function capturePage( // Screenshot failure is an evidence gap, not a capture failure. screenshotBase64 = null; } + // Accessibility tree (measured; null on failure = explicit evidence gap). + let accessibilityTree: CapturedPage["accessibilityTree"] = null; + try { + const acc = (page as unknown as { accessibility?: { snapshot(): Promise } }) + .accessibility; + accessibilityTree = (await acc?.snapshot()) as CapturedPage["accessibilityTree"] ?? null; + } catch { + accessibilityTree = null; + } + // HAR (measured; condensed to entry-level metadata). + let har: CapturedPage["har"] = null; + try { + await context.close(); + const raw = await import("node:fs/promises").then((fs) => fs.readFile(harPath, "utf8")); + const parsed = JSON.parse(raw) as { + log: { entries: { request: { url: string; method: string }; response: { status: number; content: { mimeType?: string; size?: number } } }[] }; + }; + const entries = parsed.log.entries.slice(0, maxRequests).map((e) => ({ + url: e.request.url, + method: e.request.method, + status: e.response.status, + mimeType: e.response.content?.mimeType ?? null, + size: e.response.content?.size ?? null, + })); + har = { entries, entryCount: parsed.log.entries.length }; + } catch { + har = null; + } return { finalUrl: page.url(), status: response?.status() ?? 0, contentType: response?.headers()["content-type"] ?? null, ...captured, networkRequests, + accessibilityTree, + har, screenshotBase64, }; } finally { await browser.close().catch(() => {}); + await import("node:fs/promises") + .then((fs) => fs.unlink(harPath).catch(() => {})) + .catch(() => {}); } } diff --git a/apps/api/src/cl0ud/client.ts b/apps/api/src/cl0ud/client.ts new file mode 100644 index 0000000..71668b1 --- /dev/null +++ b/apps/api/src/cl0ud/client.ts @@ -0,0 +1,106 @@ +import type { FastifyBaseLogger } from "fastify"; + +export interface CapabilityDescriptor { + id: string; + status?: string; + version?: string; + owner?: string; + surfaces?: string[]; +} + +export type Cl0udStatus = "disabled" | "connected" | "unavailable" | "denied"; + +export interface Cl0udProbe { + status: Cl0udStatus; + capabilities: Record; + checkedAt: string | null; + detail?: string; +} + +export interface Cl0udClientDeps { + baseUrl?: string; + apiToken?: string; + capabilityIds?: string[]; + logger?: FastifyBaseLogger; + fetchImpl?: typeof fetch; + probeIntervalMs?: number; +} + +const EXPECTED_IDS = ["c0py.capture.run.v1", "c0py.registry.manage.v1"]; + +// CL0UD wiring, contract-first: probes the documented capability discovery API +// (GET /capabilities/:id) and surfaces the result in /health as evidence. +// Full policy-decision calls remain gated on CL0UD B-4 / ADR-0031 (estate-level) +// and are deliberately NOT simulated here. With CL0UD_REQUIRED=true the API +// fails closed (503) while the probe cannot confirm ACTIVE capabilities. +export function createCl0udClient(deps: Cl0udClientDeps) { + const fetchImpl = deps.fetchImpl ?? fetch; + const ids = deps.capabilityIds ?? EXPECTED_IDS; + let last: Cl0udProbe = { + status: deps.baseUrl ? "unavailable" : "disabled", + capabilities: {}, + checkedAt: null, + detail: deps.baseUrl ? undefined : "CL0UD_BASE_URL not configured", + }; + + async function probe(): Promise { + if (!deps.baseUrl) { + last = { status: "disabled", capabilities: {}, checkedAt: new Date().toISOString(), detail: "CL0UD_BASE_URL not configured" }; + return last; + } + const capabilities: Record = {}; + let unreachable = 0; + let notActive = 0; + for (const id of ids) { + try { + const res = await fetchImpl(`${deps.baseUrl.replace(/\/$/, "")}/capabilities/${encodeURIComponent(id)}`, { + headers: deps.apiToken ? { Authorization: `Bearer ${deps.apiToken}` } : {}, + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + const body = (await res.json()) as CapabilityDescriptor & { capability?: CapabilityDescriptor }; + const cap = body.capability ?? body; + capabilities[id] = cap.status ?? "unknown"; + if (capabilities[id] !== "ACTIVE") notActive += 1; + } else { + capabilities[id] = `http_${res.status}`; + unreachable += 1; + } + } catch { + capabilities[id] = "unreachable"; + unreachable += 1; + } + } + const status: Cl0udStatus = + unreachable === ids.length + ? "unavailable" + : unreachable === 0 && notActive === 0 + ? "connected" + : "denied"; + last = { status, capabilities, checkedAt: new Date().toISOString() }; + deps.logger?.info({ cl0ud: last.status }, "CL0UD probe done"); + return last; + } + + let timer: NodeJS.Timeout | null = null; + function start() { + void probe().catch(() => {}); + if (deps.probeIntervalMs && deps.probeIntervalMs > 0) { + timer = setInterval(() => void probe().catch(() => {}), deps.probeIntervalMs); + timer.unref?.(); + } + } + + function stop() { + if (timer) clearInterval(timer); + } + + return { + probe, + start, + stop, + get status(): Cl0udProbe { + return last; + }, + }; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index fde4845..4d845da 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,6 +10,7 @@ import { import { createScan, listScans, getScan } from "./services/scans.js"; import { createAud0Emitter, noopAud0 } from "./audit/aud0.js"; import { startScanWorker } from "./worker/scan-worker.js"; +import { createCl0udClient } from "./cl0ud/client.js"; async function main() { const env = envSchema.parse(process.env); @@ -19,6 +20,7 @@ async function main() { server.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString(), + cl0ud: cl0ud.status.status, })); const introspectionConfigured = @@ -68,6 +70,14 @@ async function main() { } }); + const cl0ud = createCl0udClient({ + baseUrl: env.CL0UD_BASE_URL, + apiToken: env.CL0UD_API_TOKEN, + logger: server.log, + probeIntervalMs: env.CL0UD_PROBE_INTERVAL_MS, + }); + await cl0ud.probe(); + const requireUser = ( request: FastifyRequest, ): { sub: string; tenantId: string } => { @@ -84,6 +94,24 @@ async function main() { }; // Registry endpoints (owner + tenant scoped by introspected identity) + // CL0UD fail-closed mode (opt-in): deny when capability confirmation is not + // ACTIVE. Default is fail-visible (health shows cl0ud status as evidence). + server.addHook("onRequest", async (request, reply) => { + if (request.url === "/health") return; + if (env.CL0UD_REQUIRED === "true" && cl0ud.status.status === "unavailable") { + await reply.code(503).send({ error: "Service Unavailable (CL0UD unavailable)" }); + return; + } + if ( + env.CL0UD_REQUIRED === "true" && + cl0ud.status.status !== "disabled" && + Object.values(cl0ud.status.capabilities).some((s) => s !== undefined && s !== "ACTIVE") + ) { + await reply.code(503).send({ error: "Service Unavailable (CL0UD capability not confirmed)" }); + return; + } + }); + server.get("/v1/c0py/registries", async (request, reply) => { try { const u = requireUser(request); @@ -209,7 +237,7 @@ async function main() { }) : noopAud0; - if (env.NODE_ENV !== "test") { + if (env.NODE_ENV !== "test" && env.CAPTURE_WORKER === "on") { startScanWorker({ db: getPool(), logger: server.log, @@ -219,10 +247,13 @@ async function main() { console.log("c0py scan worker started"); } + cl0ud.start(); + for (const signal of ["SIGTERM", "SIGINT"] as const) { process.on(signal, async () => { await server.close(); await closePool(); + cl0ud.stop(); process.exit(0); }); } diff --git a/apps/api/tests/cl0ud.test.ts b/apps/api/tests/cl0ud.test.ts new file mode 100644 index 0000000..396d2cf --- /dev/null +++ b/apps/api/tests/cl0ud.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from "vitest"; +import { createCl0udClient } from "../src/cl0ud/client.js"; + +function okFetch(body: Record) { + return vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status: 200 })); +} + +describe("cl0ud client", () => { + it("disabled without baseUrl", async () => { + const client = createCl0udClient({}); + const p = await client.probe(); + expect(p.status).toBe("disabled"); + expect(client.status.checkedAt).toBeTruthy(); + }); + + it("connected when all capabilities ACTIVE", async () => { + const fetchImpl = okFetch({ id: "c0py.capture.run.v1", status: "ACTIVE" }); + const client = createCl0udClient({ + baseUrl: "https://cl0ud.example", + capabilityIds: ["c0py.capture.run.v1"], + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + const p = await client.probe(); + expect(p.status).toBe("connected"); + expect(p.capabilities["c0py.capture.run.v1"]).toBe("ACTIVE"); + expect(fetchImpl).toHaveBeenCalledWith( + "https://cl0ud.example/capabilities/c0py.capture.run.v1", + expect.anything(), + ); + }); + + it("unavailable when unreachable", async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error("down")); + const client = createCl0udClient({ + baseUrl: "https://cl0ud.example", + capabilityIds: ["c0py.capture.run.v1"], + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect((await client.probe()).status).toBe("unavailable"); + }); + + it("denied when capability present but not ACTIVE", async () => { + const client = createCl0udClient({ + baseUrl: "https://cl0ud.example", + capabilityIds: ["c0py.capture.run.v1"], + fetchImpl: okFetch({ id: "c0py.capture.run.v1", status: "DEPRECATED" }) as unknown as typeof fetch, + }); + expect((await client.probe()).status).toBe("denied"); + }); +}); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 71ea29f..c366db0 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -17,6 +17,10 @@ export const envSchema = z.object({ N0D_BASE_URL: z.string().url().optional(), CRAWL_TIMEOUT_MS: z.coerce.number().default(30000), MAX_REGISTRY_PAGES: z.coerce.number().default(1000), + CAPTURE_WORKER: z.enum(["on", "off"]).default("on"), + CL0UD_API_TOKEN: z.string().optional(), + CL0UD_REQUIRED: z.enum(["true", "false"]).default("false"), + CL0UD_PROBE_INTERVAL_MS: z.coerce.number().default(300000), }); export type Env = z.infer;