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:
22
src/app.ts
22
src/app.ts
@@ -68,6 +68,12 @@ import { createOIDCRouter } from './routes/oidc.js';
|
||||
import { createFederationRouter } from './routes/federation.js';
|
||||
import { createWebhooksRouter } from './routes/webhooks.js';
|
||||
import { createComplianceRouter } from './routes/compliance.js';
|
||||
import { createDelegationRouter } from './routes/delegation.js';
|
||||
import { DelegationService } from './services/DelegationService.js';
|
||||
import { DelegationController } from './controllers/DelegationController.js';
|
||||
import { createScaffoldRouter } from './routes/scaffold.js';
|
||||
import { ScaffoldService } from './services/ScaffoldService.js';
|
||||
import { ScaffoldController } from './controllers/ScaffoldController.js';
|
||||
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { createOpaMiddleware } from './middleware/opa.js';
|
||||
@@ -326,6 +332,22 @@ export async function createApp(): Promise<Application> {
|
||||
app.use(`${API_BASE}/oidc`, createOIDCTrustPoliciesRouter(oidcTrustPolicyController, authMiddleware));
|
||||
app.use(`${API_BASE}/oidc`, createOIDCTokenExchangeRouter(oidcTokenExchangeController));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Phase 5 WS2: A2A Delegation (guarded by A2A_ENABLED flag)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
if (process.env['A2A_ENABLED'] !== 'false') {
|
||||
const delegationService = new DelegationService(pool, auditService);
|
||||
const delegationController = new DelegationController(delegationService);
|
||||
app.use(`${API_BASE}`, createDelegationRouter(delegationController, authMiddleware));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Phase 5 WS5: Scaffold Generator
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const scaffoldService = new ScaffoldService();
|
||||
const scaffoldController = new ScaffoldController(scaffoldService, pool, auditService);
|
||||
app.use(`${API_BASE}`, createScaffoldRouter(scaffoldController, authMiddleware));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Dashboard static assets (served from dashboard/dist/)
|
||||
// Placed after API routes so API routes take precedence.
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -172,3 +172,66 @@ export const billingLimitRejectionsTotal = new Counter({
|
||||
labelNames: ['tenant_id', 'limit_type'] as const,
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Phase 5 — WS2: A2A Delegation Metrics
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Total number of A2A delegation chains created.
|
||||
* Labels: tenant_id
|
||||
*/
|
||||
export const delegationsCreatedTotal = new Counter({
|
||||
name: 'agentidp_delegations_created_total',
|
||||
help: 'Total number of A2A delegation chains created.',
|
||||
labelNames: ['tenant_id'] as const,
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
/**
|
||||
* Total number of A2A delegation verifications performed.
|
||||
* Labels: tenant_id, result (valid | invalid | expired | revoked)
|
||||
*/
|
||||
export const delegationsVerifiedTotal = new Counter({
|
||||
name: 'agentidp_delegations_verified_total',
|
||||
help: 'Total number of A2A delegation verifications, labelled by outcome.',
|
||||
labelNames: ['tenant_id', 'result'] as const,
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
/**
|
||||
* Total number of A2A delegation chains revoked.
|
||||
* Labels: tenant_id
|
||||
*/
|
||||
export const delegationsRevokedTotal = new Counter({
|
||||
name: 'agentidp_delegations_revoked_total',
|
||||
help: 'Total number of A2A delegation chains revoked.',
|
||||
labelNames: ['tenant_id'] as const,
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Phase 5 — WS5: Scaffold Metrics
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Total number of scaffold ZIPs generated, labelled by language.
|
||||
*/
|
||||
export const scaffoldGeneratedTotal = new Counter({
|
||||
name: 'agentidp_scaffold_generated_total',
|
||||
help: 'Total number of scaffold ZIPs generated by target language.',
|
||||
labelNames: ['language'] as const,
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
/**
|
||||
* Duration of scaffold ZIP generation in milliseconds.
|
||||
* Labels: language
|
||||
*/
|
||||
export const scaffoldGenerationDurationMs = new Histogram({
|
||||
name: 'agentidp_scaffold_generation_duration_ms',
|
||||
help: 'Time taken to generate a scaffold ZIP archive in milliseconds.',
|
||||
labelNames: ['language'] as const,
|
||||
buckets: [10, 50, 100, 250, 500, 1000, 2500],
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
58
src/routes/scaffold.ts
Normal file
58
src/routes/scaffold.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Scaffold routes for SentryAgent.ai AgentIdP.
|
||||
* Provides the GET /sdk/scaffold/:agentId endpoint.
|
||||
*/
|
||||
|
||||
import { Router, RequestHandler, Request, Response, NextFunction } from 'express';
|
||||
import { RateLimiterMemory } from 'rate-limiter-flexible';
|
||||
import { ScaffoldController } from '../controllers/ScaffoldController.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
import { RateLimitError } from '../utils/errors.js';
|
||||
|
||||
/** Scaffold-specific rate limiter: 10 requests per minute per tenant. */
|
||||
const scaffoldRateLimiter = new RateLimiterMemory({ points: 10, duration: 60 });
|
||||
|
||||
/**
|
||||
* Express middleware enforcing the scaffold-specific rate limit by tenant ID.
|
||||
*/
|
||||
async function scaffoldRateLimitMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
const tenantId = req.user?.organization_id ?? req.ip ?? 'unknown';
|
||||
try {
|
||||
await scaffoldRateLimiter.consume(tenantId);
|
||||
next();
|
||||
} catch {
|
||||
const retryAfter = Math.ceil(60);
|
||||
res.setHeader('Retry-After', String(retryAfter));
|
||||
next(new RateLimitError());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns the Express router for scaffold endpoints.
|
||||
*
|
||||
* Routes:
|
||||
* GET /sdk/scaffold/:agentId — authenticated; generate and stream a scaffold ZIP
|
||||
*
|
||||
* @param controller - The scaffold controller instance.
|
||||
* @param authMiddleware - The JWT authentication middleware.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createScaffoldRouter(
|
||||
controller: ScaffoldController,
|
||||
authMiddleware: RequestHandler,
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
'/sdk/scaffold/:agentId',
|
||||
authMiddleware,
|
||||
asyncHandler(scaffoldRateLimitMiddleware),
|
||||
asyncHandler(controller.getScaffold.bind(controller)),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
153
src/services/ScaffoldService.ts
Normal file
153
src/services/ScaffoldService.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Scaffold Service for SentryAgent.ai AgentIdP.
|
||||
* Generates in-memory ZIP archives containing language-specific starter projects
|
||||
* pre-wired with the requesting agent's credentials.
|
||||
*/
|
||||
|
||||
import archiver from 'archiver';
|
||||
import { PassThrough } from 'stream';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { ScaffoldLanguage, ScaffoldOptions } from '../types/scaffold.js';
|
||||
import { ValidationError } from '../utils/errors.js';
|
||||
import {
|
||||
scaffoldGeneratedTotal,
|
||||
scaffoldGenerationDurationMs,
|
||||
} from '../metrics/registry.js';
|
||||
|
||||
/** Map of language → list of template file paths (relative to the language directory). */
|
||||
const TEMPLATE_FILES: Record<ScaffoldLanguage, string[]> = {
|
||||
typescript: [
|
||||
'package.json.tmpl',
|
||||
'tsconfig.json.tmpl',
|
||||
'src/index.ts.tmpl',
|
||||
'.env.example.tmpl',
|
||||
'.gitignore.tmpl',
|
||||
'README.md.tmpl',
|
||||
],
|
||||
python: [
|
||||
'requirements.txt.tmpl',
|
||||
'main.py.tmpl',
|
||||
'.env.example.tmpl',
|
||||
'.gitignore.tmpl',
|
||||
'README.md.tmpl',
|
||||
],
|
||||
go: [
|
||||
'go.mod.tmpl',
|
||||
'main.go.tmpl',
|
||||
'.env.example.tmpl',
|
||||
'.gitignore.tmpl',
|
||||
'README.md.tmpl',
|
||||
],
|
||||
java: [
|
||||
'pom.xml.tmpl',
|
||||
'src/main/java/Main.java.tmpl',
|
||||
'.env.example.tmpl',
|
||||
'.gitignore.tmpl',
|
||||
'README.md.tmpl',
|
||||
],
|
||||
rust: [
|
||||
'Cargo.toml.tmpl',
|
||||
'src/main.rs.tmpl',
|
||||
'.env.example.tmpl',
|
||||
'.gitignore.tmpl',
|
||||
'README.md.tmpl',
|
||||
],
|
||||
};
|
||||
|
||||
/** Valid scaffold language values for input validation. */
|
||||
export const SCAFFOLD_LANGUAGES: ScaffoldLanguage[] = [
|
||||
'typescript',
|
||||
'python',
|
||||
'go',
|
||||
'java',
|
||||
'rust',
|
||||
];
|
||||
|
||||
/** Interface contract for the scaffold service. */
|
||||
export interface IScaffoldService {
|
||||
/**
|
||||
* Generate an in-memory ZIP archive for the given agent and language.
|
||||
* Template variables injected: {{AGENT_ID}}, {{AGENT_NAME}}, {{CLIENT_ID}}, {{API_URL}}
|
||||
*
|
||||
* @param options - Scaffold generation options.
|
||||
* @returns Readable stream of the ZIP binary and the filename for Content-Disposition.
|
||||
*/
|
||||
generateScaffold(options: ScaffoldOptions): Promise<{ stream: NodeJS.ReadableStream; filename: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of IScaffoldService.
|
||||
* Generates scaffold ZIPs in memory using the `archiver` library — no disk writes.
|
||||
*/
|
||||
export class ScaffoldService implements IScaffoldService {
|
||||
private readonly templatesDir: string;
|
||||
|
||||
constructor() {
|
||||
// __dirname is available because the project compiles to CommonJS
|
||||
this.templatesDir = join(__dirname, '..', 'templates', 'scaffold');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an in-memory ZIP scaffold for the given agent.
|
||||
*
|
||||
* @param options - Agent metadata and target language.
|
||||
* @returns Object with a readable stream of the ZIP and the ZIP filename.
|
||||
* @throws ValidationError if the language is not supported.
|
||||
*/
|
||||
async generateScaffold(
|
||||
options: ScaffoldOptions,
|
||||
): Promise<{ stream: NodeJS.ReadableStream; filename: string }> {
|
||||
const { agentId, agentName, clientId, language, apiUrl } = options;
|
||||
|
||||
if (!SCAFFOLD_LANGUAGES.includes(language)) {
|
||||
throw new ValidationError(
|
||||
`Unsupported language '${language}'. Choose: ${SCAFFOLD_LANGUAGES.join(', ')}`,
|
||||
{ code: 'INVALID_LANGUAGE' },
|
||||
);
|
||||
}
|
||||
|
||||
const startMs = Date.now();
|
||||
const safeAgentName = agentName.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase();
|
||||
const projectDir = `sentryagent-scaffold-${safeAgentName}-${language}`;
|
||||
const filename = `${projectDir}.zip`;
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||
const passThrough = new PassThrough();
|
||||
archive.pipe(passThrough);
|
||||
|
||||
const templateFiles = TEMPLATE_FILES[language];
|
||||
for (const templateFile of templateFiles) {
|
||||
const templatePath = join(this.templatesDir, language, templateFile);
|
||||
const rawContent = readFileSync(templatePath, 'utf-8');
|
||||
const content = this.injectVariables(rawContent, { agentId, agentName, clientId, apiUrl });
|
||||
// Strip .tmpl extension for the archive entry
|
||||
const archiveEntry = templateFile.endsWith('.tmpl')
|
||||
? templateFile.slice(0, -5)
|
||||
: templateFile;
|
||||
archive.append(content, { name: `${projectDir}/${archiveEntry}` });
|
||||
}
|
||||
|
||||
archive.finalize().catch((err: unknown) => {
|
||||
passThrough.destroy(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startMs;
|
||||
scaffoldGeneratedTotal.labels(language).inc();
|
||||
scaffoldGenerationDurationMs.labels(language).observe(durationMs);
|
||||
|
||||
return { stream: passThrough, filename };
|
||||
}
|
||||
|
||||
/** Replaces all template variable placeholders in a template string. */
|
||||
private injectVariables(
|
||||
template: string,
|
||||
vars: { agentId: string; agentName: string; clientId: string; apiUrl: string },
|
||||
): string {
|
||||
return template
|
||||
.replace(/\{\{AGENT_ID\}\}/g, vars.agentId)
|
||||
.replace(/\{\{AGENT_NAME\}\}/g, vars.agentName)
|
||||
.replace(/\{\{CLIENT_ID\}\}/g, vars.clientId)
|
||||
.replace(/\{\{API_URL\}\}/g, vars.apiUrl);
|
||||
}
|
||||
}
|
||||
3
src/templates/scaffold/go/.gitignore.tmpl
Normal file
3
src/templates/scaffold/go/.gitignore.tmpl
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
{{AGENT_NAME}}
|
||||
*.exe
|
||||
26
src/templates/scaffold/go/README.md.tmpl
Normal file
26
src/templates/scaffold/go/README.md.tmpl
Normal file
@@ -0,0 +1,26 @@
|
||||
# {{AGENT_NAME}}
|
||||
|
||||
A SentryAgent.ai agent starter project (Go).
|
||||
|
||||
**Agent ID:** `{{AGENT_ID}}`
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Copy the environment file and fill in your client secret:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env and set AGENTIDP_CLIENT_SECRET
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
3. Run the agent:
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
7
src/templates/scaffold/go/go.mod.tmpl
Normal file
7
src/templates/scaffold/go/go.mod.tmpl
Normal file
@@ -0,0 +1,7 @@
|
||||
module {{AGENT_NAME}}
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
)
|
||||
73
src/templates/scaffold/go/main.go.tmpl
Normal file
73
src/templates/scaffold/go/main.go.tmpl
Normal file
@@ -0,0 +1,73 @@
|
||||
// {{AGENT_NAME}} — SentryAgent.ai agent starter
|
||||
// Agent ID: {{AGENT_ID}}
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
apiURL := getEnv("AGENTIDP_API_URL", "{{API_URL}}")
|
||||
clientID := os.Getenv("AGENTIDP_CLIENT_ID")
|
||||
clientSecret := os.Getenv("AGENTIDP_CLIENT_SECRET")
|
||||
|
||||
if clientID == "" || clientSecret == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: AGENTIDP_CLIENT_ID and AGENTIDP_CLIENT_SECRET must be set")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Issuing token for agent {{AGENT_ID}} at %s ...\n", apiURL)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "client_credentials")
|
||||
form.Set("client_id", clientID)
|
||||
form.Set("client_secret", clientSecret)
|
||||
form.Set("scope", "agents:read")
|
||||
|
||||
resp, err := http.Post(apiURL+"/api/v1/token", "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Fprintf(os.Stderr, "Token issuance failed: HTTP %d\n", resp.StatusCode)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var token TokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to decode response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Token issued successfully!")
|
||||
fmt.Printf(" Expires in: %ds\n", token.ExpiresIn)
|
||||
truncated := token.AccessToken
|
||||
if len(truncated) > 20 {
|
||||
truncated = truncated[:20]
|
||||
}
|
||||
fmt.Printf(" Token (first 20 chars): %s...\n", truncated)
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
3
src/templates/scaffold/java/.gitignore.tmpl
Normal file
3
src/templates/scaffold/java/.gitignore.tmpl
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
target/
|
||||
*.class
|
||||
20
src/templates/scaffold/java/README.md.tmpl
Normal file
20
src/templates/scaffold/java/README.md.tmpl
Normal file
@@ -0,0 +1,20 @@
|
||||
# {{AGENT_NAME}}
|
||||
|
||||
A SentryAgent.ai agent starter project (Java).
|
||||
|
||||
**Agent ID:** `{{AGENT_ID}}`
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Copy the environment file and fill in your client secret:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env and set AGENTIDP_CLIENT_SECRET
|
||||
```
|
||||
|
||||
2. Build and run:
|
||||
|
||||
```bash
|
||||
mvn compile exec:java -Dexec.mainClass="Main"
|
||||
```
|
||||
35
src/templates/scaffold/java/pom.xml.tmpl
Normal file
35
src/templates/scaffold/java/pom.xml.tmpl
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>ai.sentryagent</groupId>
|
||||
<artifactId>{{AGENT_NAME}}</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
<version>4.12.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.10.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.cdimascio</groupId>
|
||||
<artifactId>dotenv-java</artifactId>
|
||||
<version>3.0.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
50
src/templates/scaffold/java/src/main/java/Main.java.tmpl
Normal file
50
src/templates/scaffold/java/src/main/java/Main.java.tmpl
Normal file
@@ -0,0 +1,50 @@
|
||||
import io.github.cdimascio.dotenv.Dotenv;
|
||||
import okhttp3.*;
|
||||
import com.google.gson.*;
|
||||
|
||||
/**
|
||||
* {{AGENT_NAME}} — SentryAgent.ai agent starter (Java)
|
||||
* Agent ID: {{AGENT_ID}}
|
||||
*/
|
||||
public class Main {
|
||||
public static void main(String[] args) throws Exception {
|
||||
Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load();
|
||||
|
||||
String apiUrl = dotenv.get("AGENTIDP_API_URL", "{{API_URL}}");
|
||||
String clientId = dotenv.get("AGENTIDP_CLIENT_ID", "");
|
||||
String clientSecret = dotenv.get("AGENTIDP_CLIENT_SECRET", "");
|
||||
|
||||
if (clientId.isEmpty() || clientSecret.isEmpty()) {
|
||||
System.err.println("Error: AGENTIDP_CLIENT_ID and AGENTIDP_CLIENT_SECRET must be set");
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
System.out.println("Issuing token for agent {{AGENT_ID}} at " + apiUrl + " ...");
|
||||
|
||||
OkHttpClient client = new OkHttpClient();
|
||||
RequestBody body = new FormBody.Builder()
|
||||
.add("grant_type", "client_credentials")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("scope", "agents:read")
|
||||
.build();
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(apiUrl + "/api/v1/token")
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
System.err.println("Token issuance failed: HTTP " + response.code());
|
||||
System.exit(1);
|
||||
}
|
||||
JsonObject token = JsonParser.parseString(response.body().string()).getAsJsonObject();
|
||||
String accessToken = token.get("access_token").getAsString();
|
||||
int expiresIn = token.get("expires_in").getAsInt();
|
||||
System.out.println("✓ Token issued successfully!");
|
||||
System.out.println(" Expires in: " + expiresIn + "s");
|
||||
System.out.println(" Token (first 20 chars): " + accessToken.substring(0, Math.min(20, accessToken.length())) + "...");
|
||||
}
|
||||
}
|
||||
}
|
||||
6
src/templates/scaffold/python/.gitignore.tmpl
Normal file
6
src/templates/scaffold/python/.gitignore.tmpl
Normal file
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
26
src/templates/scaffold/python/README.md.tmpl
Normal file
26
src/templates/scaffold/python/README.md.tmpl
Normal file
@@ -0,0 +1,26 @@
|
||||
# {{AGENT_NAME}}
|
||||
|
||||
A SentryAgent.ai agent starter project (Python).
|
||||
|
||||
**Agent ID:** `{{AGENT_ID}}`
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Copy the environment file and fill in your client secret:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env and set AGENTIDP_CLIENT_SECRET
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Run the agent:
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
43
src/templates/scaffold/python/main.py.tmpl
Normal file
43
src/templates/scaffold/python/main.py.tmpl
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
{{AGENT_NAME}} — SentryAgent.ai agent starter
|
||||
Agent ID: {{AGENT_ID}}
|
||||
|
||||
Demonstrates how to authenticate with SentryAgent.ai and issue an OAuth2 access token.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
API_URL = os.environ.get("AGENTIDP_API_URL", "{{API_URL}}")
|
||||
CLIENT_ID = os.environ.get("AGENTIDP_CLIENT_ID", "")
|
||||
CLIENT_SECRET = os.environ.get("AGENTIDP_CLIENT_SECRET", "")
|
||||
|
||||
if not CLIENT_ID or not CLIENT_SECRET:
|
||||
print("Error: AGENTIDP_CLIENT_ID and AGENTIDP_CLIENT_SECRET must be set in .env")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Issuing token for agent {{AGENT_ID}} at {API_URL} ...")
|
||||
|
||||
response = requests.post(
|
||||
f"{API_URL}/api/v1/token",
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET,
|
||||
"scope": "agents:read",
|
||||
},
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
print(f"Token issuance failed: {response.text}")
|
||||
sys.exit(1)
|
||||
|
||||
token = response.json()
|
||||
print("✓ Token issued successfully!")
|
||||
print(f" Token type: Bearer")
|
||||
print(f" Expires in: {token['expires_in']}s")
|
||||
print(f" Token (first 20 chars): {token['access_token'][:20]}...")
|
||||
2
src/templates/scaffold/python/requirements.txt.tmpl
Normal file
2
src/templates/scaffold/python/requirements.txt.tmpl
Normal file
@@ -0,0 +1,2 @@
|
||||
python-dotenv>=1.0.0
|
||||
requests>=2.31.0
|
||||
3
src/templates/scaffold/rust/.gitignore.tmpl
Normal file
3
src/templates/scaffold/rust/.gitignore.tmpl
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
target/
|
||||
Cargo.lock
|
||||
11
src/templates/scaffold/rust/Cargo.toml.tmpl
Normal file
11
src/templates/scaffold/rust/Cargo.toml.tmpl
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "{{AGENT_NAME}}"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
dotenvy = "0.15"
|
||||
20
src/templates/scaffold/rust/README.md.tmpl
Normal file
20
src/templates/scaffold/rust/README.md.tmpl
Normal file
@@ -0,0 +1,20 @@
|
||||
# {{AGENT_NAME}}
|
||||
|
||||
A SentryAgent.ai agent starter project (Rust).
|
||||
|
||||
**Agent ID:** `{{AGENT_ID}}`
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Copy the environment file and fill in your client secret:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env and set AGENTIDP_CLIENT_SECRET
|
||||
```
|
||||
|
||||
2. Run the agent:
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
47
src/templates/scaffold/rust/src/main.rs.tmpl
Normal file
47
src/templates/scaffold/rust/src/main.rs.tmpl
Normal file
@@ -0,0 +1,47 @@
|
||||
//! {{AGENT_NAME}} — SentryAgent.ai agent starter (Rust)
|
||||
//! Agent ID: {{AGENT_ID}}
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
expires_in: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let api_url = std::env::var("AGENTIDP_API_URL").unwrap_or_else(|_| "{{API_URL}}".to_string());
|
||||
let client_id = std::env::var("AGENTIDP_CLIENT_ID")?;
|
||||
let client_secret = std::env::var("AGENTIDP_CLIENT_SECRET")?;
|
||||
|
||||
println!("Issuing token for agent {{AGENT_ID}} at {} ...", api_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "client_credentials");
|
||||
params.insert("client_id", &client_id);
|
||||
params.insert("client_secret", &client_secret);
|
||||
params.insert("scope", "agents:read");
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/api/v1/token", api_url))
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
eprintln!("Token issuance failed: HTTP {}", response.status());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let token: TokenResponse = response.json().await?;
|
||||
println!("✓ Token issued successfully!");
|
||||
println!(" Expires in: {}s", token.expires_in);
|
||||
let truncated = &token.access_token[..token.access_token.len().min(20)];
|
||||
println!(" Token (first 20 chars): {}...", truncated);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
4
src/templates/scaffold/typescript/.gitignore.tmpl
Normal file
4
src/templates/scaffold/typescript/.gitignore.tmpl
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
node_modules/
|
||||
dist/
|
||||
*.js.map
|
||||
40
src/templates/scaffold/typescript/README.md.tmpl
Normal file
40
src/templates/scaffold/typescript/README.md.tmpl
Normal file
@@ -0,0 +1,40 @@
|
||||
# {{AGENT_NAME}}
|
||||
|
||||
A SentryAgent.ai agent starter project.
|
||||
|
||||
**Agent ID:** `{{AGENT_ID}}`
|
||||
**API:** {{API_URL}}
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Copy the example environment file and fill in your client secret:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env and set AGENTIDP_CLIENT_SECRET to your agent's secret
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Run the agent:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `AGENTIDP_API_URL` | SentryAgent.ai API base URL |
|
||||
| `AGENTIDP_CLIENT_ID` | Your agent's OAuth2 client ID (pre-filled) |
|
||||
| `AGENTIDP_CLIENT_SECRET` | Your agent's client secret (copy from dashboard) |
|
||||
|
||||
## Resources
|
||||
|
||||
- [SentryAgent.ai Documentation]({{API_URL}}/docs)
|
||||
- [API Reference]({{API_URL}}/api-explorer)
|
||||
20
src/templates/scaffold/typescript/package.json.tmpl
Normal file
20
src/templates/scaffold/typescript/package.json.tmpl
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "{{AGENT_NAME}}",
|
||||
"version": "0.1.0",
|
||||
"description": "SentryAgent.ai agent — {{AGENT_NAME}}",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"sentryagent-idp-sdk": "^1.0.0",
|
||||
"dotenv": "^16.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"ts-node": "^10.9.0"
|
||||
}
|
||||
}
|
||||
50
src/templates/scaffold/typescript/src/index.ts.tmpl
Normal file
50
src/templates/scaffold/typescript/src/index.ts.tmpl
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
/**
|
||||
* {{AGENT_NAME}} — SentryAgent.ai agent starter
|
||||
* Agent ID: {{AGENT_ID}}
|
||||
*
|
||||
* This file demonstrates how to authenticate with SentryAgent.ai and issue
|
||||
* an OAuth2 access token using your agent credentials.
|
||||
*/
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const apiUrl = process.env['AGENTIDP_API_URL'] ?? '{{API_URL}}';
|
||||
const clientId = process.env['AGENTIDP_CLIENT_ID'] ?? '';
|
||||
const clientSecret = process.env['AGENTIDP_CLIENT_SECRET'] ?? '';
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
console.error('Error: AGENTIDP_CLIENT_ID and AGENTIDP_CLIENT_SECRET must be set in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Issuing token for agent {{AGENT_ID}} at ${apiUrl} ...`);
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/v1/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
scope: 'agents:read',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('Token issuance failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const token = await response.json() as { access_token: string; expires_in: number };
|
||||
console.log('✓ Token issued successfully!');
|
||||
console.log(` Token type: Bearer`);
|
||||
console.log(` Expires in: ${token.expires_in}s`);
|
||||
console.log(` Token (first 20 chars): ${token.access_token.substring(0, 20)}...`);
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error('Unexpected error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
src/templates/scaffold/typescript/tsconfig.json.tmpl
Normal file
16
src/templates/scaffold/typescript/tsconfig.json.tmpl
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -57,7 +57,11 @@ export type AuditAction =
|
||||
| 'org.member_added'
|
||||
| 'webhook.created'
|
||||
| 'webhook.updated'
|
||||
| 'webhook.deleted';
|
||||
| 'webhook.deleted'
|
||||
| 'delegation.created'
|
||||
| 'delegation.verified'
|
||||
| 'delegation.revoked'
|
||||
| 'scaffold.generated';
|
||||
|
||||
/** Outcome of an audited action. */
|
||||
export type AuditOutcome = 'success' | 'failure';
|
||||
|
||||
28
src/types/scaffold.ts
Normal file
28
src/types/scaffold.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* TypeScript types for the scaffold generator (WS5 Developer Experience).
|
||||
*/
|
||||
|
||||
/** Supported target languages for scaffold generation. */
|
||||
export type ScaffoldLanguage = 'typescript' | 'python' | 'go' | 'java' | 'rust';
|
||||
|
||||
/** Options for generating a scaffold project. */
|
||||
export interface ScaffoldOptions {
|
||||
/** Agent UUID. */
|
||||
agentId: string;
|
||||
/** Human-readable agent name (used in filenames and README). */
|
||||
agentName: string;
|
||||
/** OAuth2 client ID pre-filled in .env.example. */
|
||||
clientId: string;
|
||||
/** Target programming language. */
|
||||
language: ScaffoldLanguage;
|
||||
/** API base URL injected into template files. */
|
||||
apiUrl: string;
|
||||
}
|
||||
|
||||
/** A single file within a scaffold template. */
|
||||
export interface ScaffoldTemplate {
|
||||
/** Path within the ZIP archive (relative to the project root directory). */
|
||||
archivePath: string;
|
||||
/** File content with template variables replaced. */
|
||||
content: string;
|
||||
}
|
||||
Reference in New Issue
Block a user