feat(phase-5): WS5 — Developer Experience
Implements scaffold ZIP generator, Stoplight Elements API explorer, and CLI scaffold command: Scaffold API: - 25 template files for TypeScript/Python/Go/Java/Rust in src/templates/scaffold/ - ScaffoldService: in-memory ZIP via archiver, variable injection (AGENT_ID/NAME/CLIENT_ID/API_URL) - ScaffoldController: tenant ownership check (403), language validation (400), ZIP stream response - Route GET /sdk/scaffold/:agentId with rate limiter (10 req/min per tenant) - Prometheus: scaffold_generated_total + scaffold_generation_duration_ms histogram Portal: - Replaced swagger-ui-react with @stoplight/elements API component - Dynamic import (ssr: false) for browser-only DOM dependency - Type declarations for @stoplight/elements and CSS module CLI: - sentryagent scaffold --agent-id <id> [--language typescript] [--out .] - Raw fetch for binary ZIP stream → unzipper.Extract() → prints next steps - Human-readable 400/403/404 error messages Tests: 19 tests (unit + integration), ScaffoldService 80%+ branch coverage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
114
src/controllers/ScaffoldController.ts
Normal file
114
src/controllers/ScaffoldController.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Scaffold Controller for SentryAgent.ai AgentIdP.
|
||||
* HTTP handler for the GET /sdk/scaffold/:agentId endpoint.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Pool } from 'pg';
|
||||
import { AuditService } from '../services/AuditService.js';
|
||||
import { ScaffoldService, SCAFFOLD_LANGUAGES } from '../services/ScaffoldService.js';
|
||||
import { ScaffoldLanguage } from '../types/scaffold.js';
|
||||
import { AgentNotFoundError, AuthorizationError, ValidationError } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* Controller for the scaffold generator endpoint.
|
||||
* Validates request, fetches agent credentials, delegates ZIP generation to ScaffoldService.
|
||||
*/
|
||||
export class ScaffoldController {
|
||||
/**
|
||||
* @param scaffoldService - The scaffold generation service.
|
||||
* @param pool - PostgreSQL connection pool for agent credential lookup.
|
||||
* @param auditService - Audit log service.
|
||||
*/
|
||||
constructor(
|
||||
private readonly scaffoldService: ScaffoldService,
|
||||
private readonly pool: Pool,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handles GET /sdk/scaffold/:agentId — generates and streams a scaffold ZIP.
|
||||
*
|
||||
* @param req - Express request with agentId path param and language query param.
|
||||
* @param res - Express response streaming the ZIP file.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getScaffold = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
|
||||
const { agentId } = req.params;
|
||||
const rawLanguage = req.query['language'] as string | undefined;
|
||||
const language: ScaffoldLanguage = (rawLanguage as ScaffoldLanguage) ?? 'typescript';
|
||||
|
||||
if (!SCAFFOLD_LANGUAGES.includes(language)) {
|
||||
throw new ValidationError(
|
||||
`Unsupported language '${language}'. Choose: ${SCAFFOLD_LANGUAGES.join(', ')}`,
|
||||
{ code: 'INVALID_LANGUAGE' },
|
||||
);
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
if (agentResult.rows.length === 0) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
const agentRow = agentResult.rows[0];
|
||||
if (agentRow.organization_id !== 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 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,
|
||||
clientId,
|
||||
language,
|
||||
apiUrl,
|
||||
});
|
||||
|
||||
const ipAddress = req.ip ?? '0.0.0.0';
|
||||
const userAgent = req.headers['user-agent'] ?? 'unknown';
|
||||
|
||||
await this.auditService.logEvent(
|
||||
req.user.sub,
|
||||
'scaffold.generated',
|
||||
'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
{ agentId, language },
|
||||
tenantId,
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
stream.pipe(res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user