feat(api): CL0UD capability probe + a11y/HAR evidence + worker scale toggle
CI (SIAX Cloud) / contracts (pull_request) Successful in 17s
CI (SIAX Cloud) / contracts (push) Successful in 17s
CI (SIAX Cloud) / quality (pull_request) Successful in 54s
CI (SIAX Cloud) / quality (push) Successful in 56s
CI (SIAX Cloud) / security (pull_request) Successful in 1m7s
CI (SIAX Cloud) / security (push) Successful in 1m5s
CI (SIAX Cloud) / contracts (pull_request) Successful in 17s
CI (SIAX Cloud) / contracts (push) Successful in 17s
CI (SIAX Cloud) / quality (pull_request) Successful in 54s
CI (SIAX Cloud) / quality (push) Successful in 56s
CI (SIAX Cloud) / security (pull_request) Successful in 1m7s
CI (SIAX Cloud) / security (push) Successful in 1m5s
- 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
This commit is contained in:
@@ -15,6 +15,16 @@ export interface CapturedPage {
|
|||||||
images: string[];
|
images: string[];
|
||||||
computedStyle: Record<string, Record<string, string | null>>;
|
computedStyle: Record<string, Record<string, string | null>>;
|
||||||
networkRequests: { url: string; method: string; resourceType: string; status: number | null }[];
|
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;
|
screenshotBase64: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +95,21 @@ export function buildEvidence(targetId: string, page: CapturedPage): CaptureEvid
|
|||||||
requests: page.networkRequests,
|
requests: page.networkRequests,
|
||||||
}, tool),
|
}, 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) {
|
if (page.screenshotBase64) {
|
||||||
evidence.push(
|
evidence.push(
|
||||||
record(targetId, page.finalUrl, "screenshot", {
|
record(targetId, page.finalUrl, "screenshot", {
|
||||||
@@ -122,8 +147,12 @@ export async function capturePage(
|
|||||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
});
|
});
|
||||||
const maxRequests = opts.maxRequests ?? 100;
|
const maxRequests = opts.maxRequests ?? 100;
|
||||||
|
const harPath = `/tmp/c0py-capture-${randomUUID()}.har`;
|
||||||
try {
|
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 page = await context.newPage();
|
||||||
const networkRequests: CapturedPage["networkRequests"] = [];
|
const networkRequests: CapturedPage["networkRequests"] = [];
|
||||||
page.on("response", (res) => {
|
page.on("response", (res) => {
|
||||||
@@ -205,15 +234,48 @@ export async function capturePage(
|
|||||||
// Screenshot failure is an evidence gap, not a capture failure.
|
// Screenshot failure is an evidence gap, not a capture failure.
|
||||||
screenshotBase64 = null;
|
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<unknown> } })
|
||||||
|
.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 {
|
return {
|
||||||
finalUrl: page.url(),
|
finalUrl: page.url(),
|
||||||
status: response?.status() ?? 0,
|
status: response?.status() ?? 0,
|
||||||
contentType: response?.headers()["content-type"] ?? null,
|
contentType: response?.headers()["content-type"] ?? null,
|
||||||
...captured,
|
...captured,
|
||||||
networkRequests,
|
networkRequests,
|
||||||
|
accessibilityTree,
|
||||||
|
har,
|
||||||
screenshotBase64,
|
screenshotBase64,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close().catch(() => {});
|
await browser.close().catch(() => {});
|
||||||
|
await import("node:fs/promises")
|
||||||
|
.then((fs) => fs.unlink(harPath).catch(() => {}))
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string | undefined>;
|
||||||
|
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<Cl0udProbe> {
|
||||||
|
if (!deps.baseUrl) {
|
||||||
|
last = { status: "disabled", capabilities: {}, checkedAt: new Date().toISOString(), detail: "CL0UD_BASE_URL not configured" };
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
const capabilities: Record<string, string | undefined> = {};
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+32
-1
@@ -10,6 +10,7 @@ import {
|
|||||||
import { createScan, listScans, getScan } from "./services/scans.js";
|
import { createScan, listScans, getScan } from "./services/scans.js";
|
||||||
import { createAud0Emitter, noopAud0 } from "./audit/aud0.js";
|
import { createAud0Emitter, noopAud0 } from "./audit/aud0.js";
|
||||||
import { startScanWorker } from "./worker/scan-worker.js";
|
import { startScanWorker } from "./worker/scan-worker.js";
|
||||||
|
import { createCl0udClient } from "./cl0ud/client.js";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const env = envSchema.parse(process.env);
|
const env = envSchema.parse(process.env);
|
||||||
@@ -19,6 +20,7 @@ async function main() {
|
|||||||
server.get("/health", async () => ({
|
server.get("/health", async () => ({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
cl0ud: cl0ud.status.status,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const introspectionConfigured =
|
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 = (
|
const requireUser = (
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
): { sub: string; tenantId: string } => {
|
): { sub: string; tenantId: string } => {
|
||||||
@@ -84,6 +94,24 @@ async function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Registry endpoints (owner + tenant scoped by introspected identity)
|
// 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) => {
|
server.get("/v1/c0py/registries", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const u = requireUser(request);
|
const u = requireUser(request);
|
||||||
@@ -209,7 +237,7 @@ async function main() {
|
|||||||
})
|
})
|
||||||
: noopAud0;
|
: noopAud0;
|
||||||
|
|
||||||
if (env.NODE_ENV !== "test") {
|
if (env.NODE_ENV !== "test" && env.CAPTURE_WORKER === "on") {
|
||||||
startScanWorker({
|
startScanWorker({
|
||||||
db: getPool(),
|
db: getPool(),
|
||||||
logger: server.log,
|
logger: server.log,
|
||||||
@@ -219,10 +247,13 @@ async function main() {
|
|||||||
console.log("c0py scan worker started");
|
console.log("c0py scan worker started");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cl0ud.start();
|
||||||
|
|
||||||
for (const signal of ["SIGTERM", "SIGINT"] as const) {
|
for (const signal of ["SIGTERM", "SIGINT"] as const) {
|
||||||
process.on(signal, async () => {
|
process.on(signal, async () => {
|
||||||
await server.close();
|
await server.close();
|
||||||
await closePool();
|
await closePool();
|
||||||
|
cl0ud.stop();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { createCl0udClient } from "../src/cl0ud/client.js";
|
||||||
|
|
||||||
|
function okFetch(body: Record<string, unknown>) {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,10 @@ export const envSchema = z.object({
|
|||||||
N0D_BASE_URL: z.string().url().optional(),
|
N0D_BASE_URL: z.string().url().optional(),
|
||||||
CRAWL_TIMEOUT_MS: z.coerce.number().default(30000),
|
CRAWL_TIMEOUT_MS: z.coerce.number().default(30000),
|
||||||
MAX_REGISTRY_PAGES: z.coerce.number().default(1000),
|
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<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user