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:
SentryAgent.ai Developer
2026-04-03 02:50:32 +00:00
parent 16497706d3
commit 662879f0ee
42 changed files with 6176 additions and 1741 deletions

View File

@@ -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.

View 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);
}
};
}

View File

@@ -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
View 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;
}

View 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);
}
}

View File

@@ -0,0 +1,3 @@
.env
{{AGENT_NAME}}
*.exe

View 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
```

View File

@@ -0,0 +1,7 @@
module {{AGENT_NAME}}
go 1.21
require (
github.com/joho/godotenv v1.5.1
)

View 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
}

View File

@@ -0,0 +1,3 @@
.env
target/
*.class

View 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"
```

View 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>

View 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())) + "...");
}
}
}

View File

@@ -0,0 +1,6 @@
.env
__pycache__/
*.pyc
*.pyo
.venv/
venv/

View 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
```

View 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]}...")

View File

@@ -0,0 +1,2 @@
python-dotenv>=1.0.0
requests>=2.31.0

View File

@@ -0,0 +1,3 @@
.env
target/
Cargo.lock

View 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"

View 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
```

View 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(&params)
.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(())
}

View File

@@ -0,0 +1,4 @@
.env
node_modules/
dist/
*.js.map

View 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)

View 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"
}
}

View 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);
});

View 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"]
}

View File

@@ -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
View 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;
}