Skip to content
Open
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
2 changes: 2 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ STRIPE_KILOCLAW_2026_05_10_COMMIT_PRICE_ID=price_test_kiloclaw_2026_05_10_commit
# ============================================================================
# Internal API secret (generate: openssl rand -base64 32)
INTERNAL_API_SECRET=changeme
# Code review health monitor bearer secret (generate: openssl rand -base64 32)
CODE_REVIEW_HEALTH_SECRET=
# Authenticates only POST /api/internal/bot-token (internal bot token mint); unset disables it
# (generate: openssl rand -base64 32)
BOT_TOKEN_MINT_SECRET=changeme
Expand Down
17 changes: 17 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ This document lists all environment variables used in the Kilo Code cloud monore

Manage shared web env var additions and rotations with `pnpm web:env set <VARIABLE>`. The helper coordinates tracked root and `apps/web` dotenv defaults, the `kilocode-app` and `kilocode-ai-gateway` Vercel deployments, and 1Password storage for sensitive Production values. `pnpm web:env copy` fills a new project from an existing one. See `DEVELOPMENT.md` for the full workflow.

### Code review health monitor

`CODE_REVIEW_HEALTH_SECRET` is a dedicated server-only monitor credential. `[SECRET]`
The monitor must send `Authorization: Bearer <configured value>`. An unset or empty
value disables access. Each running instance shares its cached health response
across authorized callers for 60 seconds; simultaneous refreshes share one evaluation.

Apply the database migration before deploying the application.
For initial deployment or rotation, the owner runs
`pnpm web:env set CODE_REVIEW_HEALTH_SECRET`, supplies a newly generated secret, and
coordinates the application deployment with the external monitor's header update.
Verify the monitor after the deployment and header update. Only one configured
secret is accepted; deployment and monitor configuration must agree.
Keep credentials out of tracked defaults. For local monitor development, generate
a separate value with `openssl rand -base64 32` and set it in the local environment;
ordinary local startup does not require this credential.

### Configuration & Constant URLs

- `APP_URL_OVERRIDE` - Optional base application URL override in any environment; used in `packages/web-shared/src/lib/constants.ts` and `next.config.mjs`. When unset, Vercel's `staging` target uses `https://staging-app.kilo.ai`, production uses `https://app.kilo.ai`, and local development uses `PORT`. [SERVER]
Expand Down
119 changes: 88 additions & 31 deletions apps/web/src/app/api/code-reviews/up/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { NextRequest } from 'next/server';

const mockCaptureException = jest.fn();
let mockHealthSecret = 'test-monitor-secret';

jest.mock('@kilocode/web-shared/lib/config.server', () => ({
...jest.requireActual('@kilocode/web-shared/lib/config.server'),
get CODE_REVIEW_HEALTH_SECRET() {
return mockHealthSecret;
},
}));

jest.mock('@sentry/nextjs', () => ({
captureException: (...args: unknown[]) => mockCaptureException(...args),
Expand All @@ -17,20 +25,20 @@ const REPO = `test-org/code-review-up-${Date.now()}`;
type CodeReviewInsert = typeof cloud_agent_code_reviews.$inferInsert;

function minutesAgo(minutes: number): string {
return new Date(Date.now() - minutes * 60 * 1000).toISOString();
return new Date(new Date().getTime() - minutes * 60 * 1000).toISOString();
}

function makeRequest(key: string | null) {
const url =
key === null
? 'http://localhost:3000/api/code-reviews/up'
: `http://localhost:3000/api/code-reviews/up?key=${key}`;
return new NextRequest(url, { method: 'GET' });
function makeRequest(token: string | null = 'test-monitor-secret') {
return new NextRequest('http://localhost:3000/api/code-reviews/up', {
method: 'GET',
headers: token === null ? {} : { Authorization: `Bearer ${token}` },
});
}

describe('GET /api/code-reviews/up', () => {
let testUser: User;
let reviewSequence = 0;
let clock = Date.now();

beforeAll(async () => {
testUser = await insertTestUser();
Expand All @@ -39,10 +47,14 @@ describe('GET /api/code-reviews/up', () => {
beforeEach(async () => {
await db.delete(cloud_agent_code_reviews).where(sql`true`);
mockCaptureException.mockReset();
mockHealthSecret = 'test-monitor-secret';
clock += 61_000;
jest.spyOn(Date, 'now').mockImplementation(() => clock);
});

afterEach(async () => {
await db.delete(cloud_agent_code_reviews).where(sql`true`);
jest.restoreAllMocks();
});

afterAll(async () => {
Expand Down Expand Up @@ -75,22 +87,22 @@ describe('GET /api/code-reviews/up', () => {
} satisfies CodeReviewInsert;
}

it('rejects requests with the wrong key', async () => {
it('requires monitor authorization', async () => {
const response = await GET(makeRequest('wrong-key'));

expect(response.status).toBe(401);
await expect(response.json()).resolves.toEqual({ healthy: false });
});

it('rejects requests with no key', async () => {
it('requires monitor credentials', async () => {
const response = await GET(makeRequest(null));

expect(response.status).toBe(401);
await expect(response.json()).resolves.toEqual({ healthy: false });
});

it('returns healthy when no detectors trip', async () => {
const response = await GET(makeRequest('kilo-code-reviews-health-check'));
const response = await GET(makeRequest());

expect(response.status).toBe(200);
const body = await response.json();
Expand All @@ -104,26 +116,71 @@ describe('GET /api/code-reviews/up', () => {
});
});

it('runs each detector inside its own timed transaction', async () => {
const txExecuteCalls: jest.Mock[] = [];
const transactionSpy = jest.spyOn(db, 'transaction').mockImplementation(async callback => {
const execute = jest.fn().mockResolvedValue({ rows: [] });
txExecuteCalls.push(execute);
return callback({ execute } as never);
it('requires monitor configuration', async () => {
mockHealthSecret = '';
const transactionSpy = jest.spyOn(db, 'transaction');
const response = await GET(makeRequest());

expect(response.status).toBe(401);
expect(transactionSpy).not.toHaveBeenCalled();
});

it('retains the evaluated health result until expiry', async () => {
const first = await GET(makeRequest());
const body = await first.json();
expect(first.status).toBe(200);
expect(first.headers.get('cache-control')).toBe('no-store');
await db
.insert(cloud_agent_code_reviews)
.values(reviewValues({ status: 'failed', terminal_reason: 'timeout' }));

clock += 59_999;
const second = await GET(makeRequest());
expect(second.status).toBe(200);
await expect(second.json()).resolves.toEqual(body);

clock += 1;
const refreshed = await GET(makeRequest());
expect(refreshed.status).toBe(503);
const refreshedBody = await refreshed.json();
expect(refreshedBody).toMatchObject({
healthy: false,
alerts: [expect.objectContaining({ kind: 'error_spike', errorCount: 1 })],
});

try {
const response = await GET(makeRequest('kilo-code-reviews-health-check'));
await db.delete(cloud_agent_code_reviews).where(sql`true`);
const retained = await GET(makeRequest());
expect(retained.status).toBe(503);
await expect(retained.json()).resolves.toEqual(refreshedBody);
});

expect(response.status).toBe(200);
it('authorizes access to evaluated results', async () => {
await GET(makeRequest());
const transactionSpy = jest.spyOn(db, 'transaction');
const response = await GET(makeRequest('different-secret'));
expect(response.status).toBe(401);
await expect(response.json()).resolves.toEqual({ healthy: false });
expect(transactionSpy).not.toHaveBeenCalled();
});

it('shares an active evaluation', async () => {
const transaction = db.transaction.bind(db);
const { promise: gate, resolve: release } = Promise.withResolvers<void>();
const transactionSpy = jest.spyOn(db, 'transaction').mockImplementation(async callback => {
await gate;
return transaction(callback);
});
const first = GET(makeRequest());
const second = GET(makeRequest());
try {
expect(transactionSpy).toHaveBeenCalledTimes(2);
expect(txExecuteCalls).toHaveLength(2);
for (const execute of txExecuteCalls) {
expect(execute).toHaveBeenCalledTimes(2);
}
} finally {
transactionSpy.mockRestore();
release();
}
const responses = await Promise.all([first, second]);
expect(responses.map(response => response.status)).toEqual([200, 200]);
const bodies = await Promise.all(responses.map(response => response.json()));
expect(bodies[0]).toEqual(bodies[1]);
});

it('returns 503 with slow-review alert when slow-review rate trips', async () => {
Expand All @@ -136,7 +193,7 @@ describe('GET /api/code-reviews/up', () => {
...Array.from({ length: 9 }, () => reviewValues()),
]);

const response = await GET(makeRequest('kilo-code-reviews-health-check'));
const response = await GET(makeRequest());

expect(response.status).toBe(503);
const body = await response.json();
Expand Down Expand Up @@ -170,7 +227,7 @@ describe('GET /api/code-reviews/up', () => {
...Array.from({ length: 16 }, () => reviewValues()),
]);

const response = await GET(makeRequest('kilo-code-reviews-health-check'));
const response = await GET(makeRequest());

expect(response.status).toBe(503);
const body = await response.json();
Expand Down Expand Up @@ -211,7 +268,7 @@ describe('GET /api/code-reviews/up', () => {
...Array.from({ length: 16 }, () => reviewValues()),
]);

const response = await GET(makeRequest('kilo-code-reviews-health-check'));
const response = await GET(makeRequest());

expect(response.status).toBe(200);
const body = await response.json();
Expand All @@ -229,7 +286,7 @@ describe('GET /api/code-reviews/up', () => {
...Array.from({ length: 16 }, () => reviewValues()),
]);

const response = await GET(makeRequest('kilo-code-reviews-health-check'));
const response = await GET(makeRequest());

expect(response.status).toBe(503);
const body = await response.json();
Expand All @@ -252,7 +309,7 @@ describe('GET /api/code-reviews/up', () => {
)
);

const response = await GET(makeRequest('kilo-code-reviews-health-check'));
const response = await GET(makeRequest());

expect(response.status).toBe(200);
const body = await response.json();
Expand All @@ -276,7 +333,7 @@ describe('GET /api/code-reviews/up', () => {
...Array.from({ length: 17 }, () => reviewValues()),
]);

const response = await GET(makeRequest('kilo-code-reviews-health-check'));
const response = await GET(makeRequest());

expect(response.status).toBe(503);
const body = await response.json();
Expand All @@ -291,7 +348,7 @@ describe('GET /api/code-reviews/up', () => {
.mockRejectedValue(new Error('DB unavailable'));

try {
const response = await GET(makeRequest('kilo-code-reviews-health-check'));
const response = await GET(makeRequest());

expect(response.status).toBe(200);
const body = await response.json();
Expand Down
57 changes: 41 additions & 16 deletions apps/web/src/app/api/code-reviews/up/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { timingSafeEqual } from '@kilocode/encryption';
import { extractBearerToken } from '@kilocode/worker-utils/extract-bearer-token';
import { CODE_REVIEW_HEALTH_SECRET } from '@kilocode/web-shared/lib/config.server';
import { captureException } from '@sentry/nextjs';
import { NextResponse } from 'next/server';
import { APP_URL } from '@kilocode/web-shared/lib/constants';
Expand All @@ -13,11 +16,16 @@ import {
type CodeReviewHealthResponse,
} from '@/lib/code-reviews/alerting/health-response';

// Hardcoded query-string filter that the BetterStack monitor passes. Not a
// secret — just a low-friction way to keep scanners and accidental hits from
// running detector queries. Mirrors `apps/web/src/app/api/models/up/route.ts`.
const HEALTH_CHECK_KEY = 'kilo-code-reviews-health-check';
const DETECTOR_STATEMENT_TIMEOUT_MS = 10_000;
const CACHE_TTL_MS = 60_000;

type HealthResult = {
body: CodeReviewHealthResponse;
status: 200 | 503;
};

let cached: { result: HealthResult; expiresAt: number } | undefined;
let inFlight: Promise<HealthResult> | undefined;

type AlertingDb = Pick<typeof db, 'execute'>;

Expand All @@ -33,16 +41,7 @@ const DETECTORS: Detector[] = [

type UnauthorizedResponse = { healthy: false };

export async function GET(
request: Request
): Promise<NextResponse<CodeReviewHealthResponse | UnauthorizedResponse>> {
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');

if (key !== HEALTH_CHECK_KEY) {
return NextResponse.json({ healthy: false }, { status: 401 });
}

async function evaluateHealth(): Promise<HealthResult> {
// Run detectors independently so a single broken detector does not mask the
// alerts the others would have produced. Failed detectors are captured to
// Sentry; we still return whatever alerts the surviving detectors produced.
Expand Down Expand Up @@ -78,12 +77,38 @@ export async function GET(
console.warn('[code-reviews/up] returning 503: code review pipeline detectors tripped', {
kinds: alerts.map(alert => alert.kind),
});
return NextResponse.json(response, { status: 503 });
return { body: response, status: 503 };
}

// Fail open even when every detector errored: a query timeout or DB error is
// not evidence of a code review pipeline outage, and treating it as one
// would create false BetterStack incidents. The errors are already captured
// to Sentry above.
return NextResponse.json(response, { status: 200 });
return { body: response, status: 200 };
}

export async function GET(
request: Request
): Promise<NextResponse<CodeReviewHealthResponse | UnauthorizedResponse>> {
const token = extractBearerToken(request.headers.get('authorization'));
const headers = { 'Cache-Control': 'no-store' };
if (!CODE_REVIEW_HEALTH_SECRET || !token || !timingSafeEqual(token, CODE_REVIEW_HEALTH_SECRET)) {
return NextResponse.json({ healthy: false }, { status: 401, headers });
}

let result: HealthResult;
if (cached && Date.now() < cached.expiresAt) {
result = cached.result;
} else {
inFlight ??= evaluateHealth()
.then(result => {
cached = { result, expiresAt: Date.now() + CACHE_TTL_MS };
return result;
})
.finally(() => {
inFlight = undefined;
});
result = await inFlight;
}
return NextResponse.json(result.body, { status: result.status, headers });
}
Loading
Loading