fix(vv): resolve all 6 V&V issues — field trial unblocked
All findings from the inaugural LeadValidator audit resolved and confirmed. Release gate: PASS. VV_ISSUE_002 (BLOCKER): 15 OpenAPI specs verified present covering all 20 route groups (46 endpoints documented in docs/openapi/) VV_ISSUE_003 (MAJOR): Remove any types from src/db/pool.ts — replaced pool.query shim with unknown[] + Object.defineProperty, zero any types, eslint-disable suppressions removed VV_ISSUE_004 (MAJOR): Remove raw Pool from ScaffoldController and HealthDetailedController — injected AgentRepository/CredentialRepository and DbProbe interface respectively; added CredentialRepository.findActiveClientId() VV_ISSUE_005 (MAJOR): Add unit tests for 5 untested services — ComplianceStatusStore, EventPublisher, MarketplaceService, OIDCTrustPolicyService, UsageService VV_ISSUE_006 (MAJOR): Add integration tests for 7 missing route groups — analytics, billing, tiers, webhooks, marketplace, oidc-trust-policies, oidc-token-exchange VV_ISSUE_001 (MINOR): Create missing design.md and tasks.md in 4 OpenSpec archives — all archives now complete Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -383,7 +383,7 @@ export async function createApp(): Promise<Application> {
|
||||
// Phase 5 WS5: Scaffold Generator
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const scaffoldService = new ScaffoldService();
|
||||
const scaffoldController = new ScaffoldController(scaffoldService, pool, auditService);
|
||||
const scaffoldController = new ScaffoldController(scaffoldService, agentRepo, credentialRepo, auditService);
|
||||
app.use(`${API_BASE}`, createScaffoldRouter(scaffoldController, authMiddleware));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
/** Timeout applied to each individual health-check probe (ms). */
|
||||
const PROBE_TIMEOUT_MS = 3000;
|
||||
@@ -39,12 +38,18 @@ export interface DetailedHealthResponse {
|
||||
services: Record<string, ServiceHealthResult>;
|
||||
}
|
||||
|
||||
/** Minimal interface for a PostgreSQL liveness probe. */
|
||||
export interface DbProbe {
|
||||
/** Checks DB liveness. Resolves when connected, rejects on failure. */
|
||||
checkLiveness(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies injected into the controller.
|
||||
* All fields are optional — services are only probed when their client is provided.
|
||||
*/
|
||||
export interface HealthDetailedDeps {
|
||||
pool: Pool;
|
||||
dbProbe: DbProbe;
|
||||
/** Optional Vault URL — when provided, the controller probes Vault's /v1/sys/health. */
|
||||
vaultAddr?: string;
|
||||
/** Optional OPA URL — when provided, the controller probes OPA's /health. */
|
||||
@@ -90,13 +95,13 @@ async function runProbe(
|
||||
* optional-service clients. The `handle` method is an Express route handler.
|
||||
*/
|
||||
export class HealthDetailedController {
|
||||
private readonly pool: Pool;
|
||||
private readonly dbProbe: DbProbe;
|
||||
private readonly vaultAddr: string | undefined;
|
||||
private readonly opaUrl: string | undefined;
|
||||
private readonly redisClient: { ping(): Promise<string> } | null;
|
||||
|
||||
constructor(deps: HealthDetailedDeps) {
|
||||
this.pool = deps.pool;
|
||||
this.dbProbe = deps.dbProbe;
|
||||
this.vaultAddr = deps.vaultAddr;
|
||||
this.opaUrl = deps.opaUrl;
|
||||
this.redisClient = deps.redisClient ?? null;
|
||||
@@ -118,12 +123,7 @@ export class HealthDetailedController {
|
||||
// ── PostgreSQL probe ────────────────────────────────────────────────────
|
||||
services['postgres'] = await runProbe(async () => {
|
||||
const start = Date.now();
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('SELECT 1');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
await this.dbProbe.checkLiveness();
|
||||
return Date.now() - start;
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Pool } from 'pg';
|
||||
import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
import { AuditService } from '../services/AuditService.js';
|
||||
import { ScaffoldService, SCAFFOLD_LANGUAGES } from '../services/ScaffoldService.js';
|
||||
import { ScaffoldLanguage } from '../types/scaffold.js';
|
||||
@@ -17,12 +18,14 @@ import { AgentNotFoundError, AuthorizationError, ValidationError } from '../util
|
||||
export class ScaffoldController {
|
||||
/**
|
||||
* @param scaffoldService - The scaffold generation service.
|
||||
* @param pool - PostgreSQL connection pool for agent credential lookup.
|
||||
* @param agentRepo - Agent repository for agent lookup.
|
||||
* @param credentialRepo - Credential repository for active credential lookup.
|
||||
* @param auditService - Audit log service.
|
||||
*/
|
||||
constructor(
|
||||
private readonly scaffoldService: ScaffoldService,
|
||||
private readonly pool: Pool,
|
||||
private readonly agentRepo: AgentRepository,
|
||||
private readonly credentialRepo: CredentialRepository,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
@@ -53,38 +56,25 @@ export class ScaffoldController {
|
||||
const tenantId = req.user.organization_id ?? 'org_system';
|
||||
|
||||
// Fetch agent and verify it belongs to the authenticated tenant
|
||||
const agentResult = await this.pool.query<{
|
||||
agent_id: string;
|
||||
email: string;
|
||||
organization_id: string;
|
||||
}>(
|
||||
`SELECT agent_id, email, organization_id FROM agents WHERE agent_id = $1`,
|
||||
[agentId],
|
||||
);
|
||||
const agent = await this.agentRepo.findById(agentId);
|
||||
|
||||
if (agentResult.rows.length === 0) {
|
||||
if (agent === null) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
const agentRow = agentResult.rows[0];
|
||||
if (agentRow.organization_id !== tenantId) {
|
||||
if (agent.organizationId !== tenantId) {
|
||||
throw new AuthorizationError('You do not own this agent.');
|
||||
}
|
||||
|
||||
// Fetch the agent's active credential client_id
|
||||
const credResult = await this.pool.query<{ client_id: string }>(
|
||||
`SELECT client_id FROM credentials WHERE agent_id = $1 AND status = 'active' ORDER BY created_at DESC LIMIT 1`,
|
||||
[agentId],
|
||||
);
|
||||
|
||||
const clientId =
|
||||
credResult.rows.length > 0 ? credResult.rows[0].client_id : agentRow.agent_id;
|
||||
const activeClientId = await this.credentialRepo.findActiveClientId(agentId);
|
||||
const clientId = activeClientId ?? agentId;
|
||||
|
||||
const apiUrl = process.env['API_URL'] ?? process.env['NEXT_PUBLIC_API_URL'] ?? 'https://api.sentryagent.ai';
|
||||
|
||||
const { stream, filename } = await this.scaffoldService.generateScaffold({
|
||||
agentId,
|
||||
agentName: agentRow.email.split('@')[0] ?? agentId,
|
||||
agentName: agent.email.split('@')[0] ?? agentId,
|
||||
clientId,
|
||||
language,
|
||||
apiUrl,
|
||||
|
||||
@@ -79,23 +79,23 @@ export function getPool(): Pool {
|
||||
}
|
||||
});
|
||||
|
||||
// Wrap pool.query to record duration in Prometheus.
|
||||
// The pg Pool.query method is heavily overloaded — the only safe approach
|
||||
// without TypeScript errors is a typed-any wrapper on the shim itself.
|
||||
// We capture originalQuery as `(...args: any[]) => Promise<any>` to satisfy
|
||||
// TypeScript's spread-into-rest constraint; this is the one sanctioned use of
|
||||
// `any` in this file.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const originalQuery = pool.query.bind(pool) as (...args: any[]) => Promise<any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(pool as any).query = async (...args: any[]): Promise<any> => {
|
||||
const end = dbQueryDurationSeconds.startTimer({ operation: 'query' });
|
||||
try {
|
||||
return await originalQuery(...args);
|
||||
} finally {
|
||||
end();
|
||||
}
|
||||
};
|
||||
// Wrap pool.query to record Prometheus query duration.
|
||||
// Uses unknown[] + Object.defineProperty to avoid `any` while preserving
|
||||
// the pool's typed interface for all callers (TypeScript still sees Pool['query']).
|
||||
const originalQuery = pool.query.bind(pool);
|
||||
Object.defineProperty(pool, 'query', {
|
||||
value: async (...args: unknown[]): Promise<unknown> => {
|
||||
const end = dbQueryDurationSeconds.startTimer({ operation: 'query' });
|
||||
try {
|
||||
return await (originalQuery as (...a: unknown[]) => Promise<unknown>)(...args);
|
||||
} finally {
|
||||
end();
|
||||
}
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
@@ -250,4 +250,18 @@ export class CredentialRepository {
|
||||
);
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the client_id of the most recent active credential for an agent.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @returns The client_id string, or null if no active credential exists.
|
||||
*/
|
||||
async findActiveClientId(agentId: string): Promise<string | null> {
|
||||
const result: QueryResult<{ client_id: string }> = await this.pool.query(
|
||||
`SELECT client_id FROM credentials WHERE agent_id = $1 AND status = 'active' ORDER BY created_at DESC LIMIT 1`,
|
||||
[agentId],
|
||||
);
|
||||
return result.rows.length > 0 ? (result.rows[0].client_id ?? null) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { Pool } from 'pg';
|
||||
import { RedisClientType } from 'redis';
|
||||
import { HealthDetailedController } from '../controllers/HealthDetailedController.js';
|
||||
import { DbProbe, HealthDetailedController } from '../controllers/HealthDetailedController.js';
|
||||
|
||||
/** Response shape for GET /health */
|
||||
interface HealthResponse {
|
||||
@@ -33,9 +33,21 @@ interface HealthResponse {
|
||||
export function createHealthRouter(pool: Pool, redis: RedisClientType): Router {
|
||||
const router = Router();
|
||||
|
||||
// Create a minimal DbProbe adapter — keeps raw Pool out of the controller.
|
||||
const dbProbe: DbProbe = {
|
||||
async checkLiveness(): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('SELECT 1');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Instantiate the detailed health controller with optional service clients.
|
||||
const detailedController = new HealthDetailedController({
|
||||
pool,
|
||||
dbProbe,
|
||||
redisClient: redis,
|
||||
vaultAddr: process.env['VAULT_ADDR'] ?? undefined,
|
||||
opaUrl: process.env['OPA_URL'] ?? undefined,
|
||||
|
||||
Reference in New Issue
Block a user