Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 15 additions & 48 deletions src/lib/covenant/integrations.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,3 @@
import { createClient } from "redis";

// Reuse client if it exists globally to avoid reconnecting on every request
declare global {
var _redisClient: ReturnType<typeof createClient> | undefined;
}

async function getRedisClient() {
if (!global._redisClient) {
global._redisClient = createClient({ url: process.env.REDIS_URL || "redis://localhost:6379" });
global._redisClient.on("error", (err) => console.error("Redis error:", err));
await global._redisClient.connect().catch(() => {});
}
return global._redisClient;
}

export class IntegrationUnavailable extends Error {
readonly code = "INTEGRATION_UNAVAILABLE";
}
Expand All @@ -28,11 +12,13 @@ export function requireIntegration(name: string, value: string | undefined): str
return value.replace(/\/$/, "");
}

export async function postIntegration(url: string, body: unknown, headers: Record<string, string> = {}): Promise<Record<string, unknown>> {
export async function postIntegration(
url: string,
body: unknown,
headers: Record<string, string> = {},
): Promise<Record<string, unknown>> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second fail-fast

const cacheKey = `cAPI:integration:${Buffer.from(url).toString('base64')}:${Buffer.from(JSON.stringify(body)).toString('base64')}`;
const timeoutId = setTimeout(() => controller.abort(), 3000);

try {
const response = await fetch(url, {
Expand All @@ -41,48 +27,29 @@ export async function postIntegration(url: string, body: unknown, headers: Recor
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeoutId);

if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AuthorityDenied(`Authority denied: HTTP ${response.status}`);
}
throw new Error(`HTTP ${response.status}`);
throw new IntegrationUnavailable(`Integration failed: HTTP ${response.status}`);
}

const result: unknown = await response.json();
if (!result || typeof result !== "object" || Array.isArray(result)) {
throw new Error("Invalid response");
throw new IntegrationUnavailable("Integration failed: invalid response");
}

// Cache successful response asynchronously
getRedisClient().then(client => {
if (client.isOpen) client.setEx(cacheKey, 3600, JSON.stringify(result)).catch(console.error);
}).catch(console.error);

return result as Record<string, unknown>;
} catch (error) {
clearTimeout(timeoutId);

if (error instanceof AuthorityDenied) {
if (error instanceof AuthorityDenied || error instanceof IntegrationUnavailable) {
throw error;
}

// Attempt to retrieve stale data
try {
const client = await getRedisClient();
if (client.isOpen) {
const cached = await client.get(cacheKey);
if (cached) {
const parsed = JSON.parse(cached);
parsed._stale = true; // Mark as stale
return parsed;
}
}
} catch (redisError) {
console.error("Failed to retrieve stale cache:", redisError);
}

throw new IntegrationUnavailable(`Integration failed: ${error instanceof Error ? error.message : "Unknown error"}`);
throw new IntegrationUnavailable(
`Integration failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
clearTimeout(timeoutId);
}
}
121 changes: 121 additions & 0 deletions tests/integrations.fail-closed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import {
AuthorityDenied,
IntegrationUnavailable,
postIntegration,
} from "../src/lib/covenant/integrations";

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe("postIntegration fail-closed authority boundary", () => {
it("does not replay a prior successful response after the authority service fails", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ decision: "APPROVED" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
)
.mockResolvedValueOnce(new Response("upstream failure", { status: 503 }));

vi.stubGlobal("fetch", fetchMock);

await expect(
postIntegration("https://cappo.example.test/authorize", { run_id: "run-1" }),
).resolves.toEqual({ decision: "APPROVED" });

await expect(
postIntegration("https://cappo.example.test/authorize", { run_id: "run-1" }),
).rejects.toBeInstanceOf(IntegrationUnavailable);
});

it("does not replay a prior successful response after a network rejection", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ decision: "APPROVED" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
)
.mockRejectedValueOnce(new Error("network unavailable"));

vi.stubGlobal("fetch", fetchMock);

await expect(
postIntegration("https://cappo.example.test/authorize", { run_id: "run-network" }),
).resolves.toEqual({ decision: "APPROVED" });

await expect(
postIntegration("https://cappo.example.test/authorize", { run_id: "run-network" }),
).rejects.toBeInstanceOf(IntegrationUnavailable);
});

it("does not replay a prior successful response after the three-second timeout", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ decision: "APPROVED" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
)
.mockImplementationOnce((_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted", "AbortError"));
});
}),
);

vi.stubGlobal("fetch", fetchMock);

await expect(
postIntegration("https://cappo.example.test/authorize", { run_id: "run-timeout" }),
).resolves.toEqual({ decision: "APPROVED" });

vi.useFakeTimers();
const timedOutRequest = postIntegration("https://cappo.example.test/authorize", {
run_id: "run-timeout",
});
const timeoutExpectation = expect(timedOutRequest).rejects.toBeInstanceOf(
IntegrationUnavailable,
);

await vi.advanceTimersByTimeAsync(3000);
await timeoutExpectation;
});

it("preserves explicit authority denial", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response("denied", { status: 403 })),
);

await expect(
postIntegration("https://cappo.example.test/authorize", { run_id: "run-2" }),
).rejects.toBeInstanceOf(AuthorityDenied);
});

it("fails closed on invalid success payloads", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify(["not", "an", "authority", "object"]), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);

await expect(
postIntegration("https://cappo.example.test/authorize", { run_id: "run-3" }),
).rejects.toBeInstanceOf(IntegrationUnavailable);
});
});
Loading