- Add auth.RequireScope() to all handler routes for proper authorization - Add SDLC OpenAPI endpoint documentation (state, features, tasks, branches, merge, archive, orchestrator) - Add SDLC documentation guides (getting-started, cli-reference, api-reference, command-catalog) - Add artifact_test.go for SDLC artifact coverage - Add CLAUDE.md rules: auth scopes requirement, error wrapping with %w - Fix error wrapping to use %w instead of %v throughout codebase - Improve CLI merge command with conflict detection and resolution - Fix handler tests to include auth middleware for RequireScope - Add cookbook tree runner scripts for automated testing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
470 lines
15 KiB
Go
470 lines
15 KiB
Go
package main
|
|
|
|
import "github.com/orchard9/rdev/pkg/api"
|
|
|
|
// buildOpenAPISpec creates the OpenAPI specification for the rdev API.
|
|
func buildOpenAPISpec() *api.OpenAPISpec {
|
|
spec := api.NewOpenAPISpec("rdev API", "0.5.0").
|
|
WithDescription(`Remote Developer API for controlling Claude Code instances in Kubernetes.
|
|
|
|
rdev runs Claude Code CLI in isolated pods, controlled via this REST API with SSE streaming.
|
|
External clients (Discord bots, Slack bots, CLI tools) connect here to interact with projects.
|
|
|
|
## Authentication
|
|
|
|
All endpoints except /health, /ready, and /docs require authentication via API key.
|
|
|
|
**Header**: `+"`X-API-Key: rdev_sk_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`"+`
|
|
|
|
Or: `+"`Authorization: Bearer rdev_sk_...`"+`
|
|
|
|
### Getting Started
|
|
|
|
1. Set RDEV_ADMIN_KEY environment variable with your super admin key
|
|
2. Use the admin key to create additional keys via POST /keys
|
|
3. Use created keys for normal operations
|
|
|
|
### Scopes
|
|
|
|
| Scope | Description |
|
|
|-------|-------------|
|
|
| projects:read | List and view projects |
|
|
| projects:execute | Run commands (claude, shell, git) |
|
|
| keys:read | List API keys (metadata only) |
|
|
| keys:write | Create and revoke keys |
|
|
| audit:read | View audit logs for command executions |
|
|
| admin | Full access (all scopes) |
|
|
|
|
## Architecture
|
|
|
|
- **rdev-api**: This Go service
|
|
- **claudebox pods**: Isolated pods running Claude Code CLI
|
|
- **postgres**: API key storage (auto-migrating)
|
|
|
|
## Streaming
|
|
|
|
Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events.
|
|
`).
|
|
WithServer("http://localhost:8080", "Local development").
|
|
WithServer("http://rdev-api.rdev.svc:8080", "Kubernetes internal")
|
|
|
|
// Tags
|
|
spec.WithTag("Authentication", "API key management")
|
|
spec.WithTag("Projects", "Project management and discovery")
|
|
spec.WithTag("Commands", "Command execution (claude, shell, git)")
|
|
spec.WithTag("Events", "Server-Sent Events for real-time output")
|
|
spec.WithTag("Claude Config", "Manage commands, skills, and agents in /workspace/.claude/")
|
|
spec.WithTag("Audit", "Command execution audit logs")
|
|
spec.WithTag("Code Agents", "Multi-provider code agent management")
|
|
spec.WithTag("Workers", "Worker pool management")
|
|
spec.WithTag("Builds", "Build orchestration and history")
|
|
spec.WithTag("SDLC", "Software Development Lifecycle orchestration")
|
|
spec.WithTag("System", "Health and readiness endpoints")
|
|
|
|
// Register all path operations
|
|
registerSystemPaths(spec)
|
|
registerKeyPaths(spec)
|
|
registerProjectPaths(spec)
|
|
registerCommandPaths(spec)
|
|
registerEventPaths(spec)
|
|
registerClaudeConfigPaths(spec)
|
|
registerAuditPaths(spec)
|
|
registerAgentPaths(spec)
|
|
registerWorkerPaths(spec)
|
|
registerBuildPaths(spec)
|
|
registerSDLCPaths(spec)
|
|
|
|
return spec
|
|
}
|
|
|
|
func registerSystemPaths(spec *api.OpenAPISpec) {
|
|
spec.AddPath("/health", "get", api.Op(
|
|
"Health check",
|
|
"Returns health status. No authentication required.",
|
|
"System",
|
|
))
|
|
spec.AddPath("/ready", "get", api.Op(
|
|
"Readiness check",
|
|
"Returns readiness status. No authentication required.",
|
|
"System",
|
|
))
|
|
}
|
|
|
|
func registerKeyPaths(spec *api.OpenAPISpec) {
|
|
spec.AddPath("/keys", "get", withAuth(
|
|
"List API keys",
|
|
"Returns all API keys with metadata (not secrets). Requires keys:read scope.",
|
|
"Authentication",
|
|
"keys:read",
|
|
`[
|
|
{
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"name": "discord-bot",
|
|
"key_prefix": "a1b2c3d4",
|
|
"scopes": ["projects:read", "projects:execute"],
|
|
"created_at": "2026-01-24T20:00:00Z",
|
|
"active": true
|
|
}
|
|
]`,
|
|
))
|
|
|
|
spec.AddPath("/keys", "post", withAuthAndBody(
|
|
"Create API key",
|
|
`Creates a new API key. The secret is returned only once - save it securely!
|
|
|
|
**Expiration options**: 30d, 60d, 90d, 1y, never (default: never)`,
|
|
"Authentication",
|
|
"keys:write",
|
|
`{
|
|
"name": "discord-bot",
|
|
"scopes": ["projects:read", "projects:execute"],
|
|
"project_ids": ["pantheon"],
|
|
"expires_in": "90d"
|
|
}`,
|
|
`{
|
|
"key": {
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"name": "discord-bot",
|
|
"key_prefix": "a1b2c3d4",
|
|
"scopes": ["projects:read", "projects:execute"],
|
|
"project_ids": ["pantheon"],
|
|
"created_at": "2026-01-24T20:00:00Z",
|
|
"expires_at": "2026-04-24T20:00:00Z",
|
|
"active": true
|
|
},
|
|
"secret": "rdev_sk_a1b2c3d4_e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
|
|
}`,
|
|
))
|
|
|
|
spec.AddPath("/keys/{id}", "get", withAuthAndParams(
|
|
"Get API key",
|
|
"Returns details for a specific API key. Requires keys:read scope.",
|
|
"Authentication",
|
|
"keys:read",
|
|
[]param{{Name: "id", In: "path", Description: "Key ID (UUID)", Required: true}},
|
|
))
|
|
|
|
spec.AddPath("/keys/{id}", "delete", withAuthAndParams(
|
|
"Revoke API key",
|
|
"Revokes an API key immediately. The key cannot be used after revocation. Requires keys:write scope.",
|
|
"Authentication",
|
|
"keys:write",
|
|
[]param{{Name: "id", In: "path", Description: "Key ID (UUID)", Required: true}},
|
|
))
|
|
}
|
|
|
|
func registerProjectPaths(spec *api.OpenAPISpec) {
|
|
spec.AddPath("/projects", "get", withAuth(
|
|
"List projects",
|
|
"Returns all available projects (claudebox pods). Requires projects:read scope.",
|
|
"Projects",
|
|
"projects:read",
|
|
`[
|
|
{
|
|
"id": "pantheon",
|
|
"name": "Pantheon",
|
|
"description": "Go API backend",
|
|
"pod": "claudebox-pantheon-0",
|
|
"status": "running"
|
|
}
|
|
]`,
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}", "get", withAuthAndParams(
|
|
"Get project",
|
|
"Returns details for a specific project. Requires projects:read scope.",
|
|
"Projects",
|
|
"projects:read",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID (e.g., 'pantheon')", Required: true}},
|
|
))
|
|
}
|
|
|
|
func registerCommandPaths(spec *api.OpenAPISpec) {
|
|
spec.AddPath("/projects/{id}/claude", "post", withAuthBodyAndParams(
|
|
"Run Claude command",
|
|
"Executes a Claude Code prompt in the project's claudebox pod. Supports session continuation, model selection (OpenCode), and tool restrictions. Requires projects:execute scope.",
|
|
"Commands",
|
|
"projects:execute",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
`{
|
|
"prompt": "fix the bug in auth handler",
|
|
"stream_id": "optional-correlation-id",
|
|
"session_id": "prev-session-123",
|
|
"model": "claude-sonnet-4-20250514",
|
|
"allowed_tools": ["Read", "Write", "Bash(git:*)"]
|
|
}`,
|
|
`{
|
|
"id": "cmd-pantheon-001",
|
|
"project": "pantheon",
|
|
"type": "claude",
|
|
"status": "queued",
|
|
"stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-001"
|
|
}`,
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/shell", "post", withAuthBodyAndParams(
|
|
"Run shell command",
|
|
"Executes a shell command in the project's claudebox pod. Requires projects:execute scope.",
|
|
"Commands",
|
|
"projects:execute",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
`{
|
|
"command": "go test ./...",
|
|
"stream_id": "optional-correlation-id"
|
|
}`,
|
|
`{
|
|
"id": "cmd-pantheon-002",
|
|
"project": "pantheon",
|
|
"type": "shell",
|
|
"status": "queued",
|
|
"stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-002"
|
|
}`,
|
|
))
|
|
|
|
spec.AddPath("/projects/{id}/git", "post", withAuthBodyAndParams(
|
|
"Run git command",
|
|
"Executes a git command in the project's claudebox pod. Requires projects:execute scope.",
|
|
"Commands",
|
|
"projects:execute",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
`{
|
|
"args": ["status"],
|
|
"stream_id": "optional-correlation-id"
|
|
}`,
|
|
`{
|
|
"id": "cmd-pantheon-003",
|
|
"project": "pantheon",
|
|
"type": "git",
|
|
"status": "queued",
|
|
"stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-003"
|
|
}`,
|
|
))
|
|
}
|
|
|
|
func registerEventPaths(spec *api.OpenAPISpec) {
|
|
spec.AddPath("/projects/{id}/events", "get", map[string]any{
|
|
"summary": "Stream events",
|
|
"description": `Server-Sent Events stream for real-time command output.
|
|
|
|
Requires projects:read scope.
|
|
|
|
## Event Types
|
|
|
|
- **connected**: Initial connection confirmation
|
|
- **output**: Command output (stdout or stderr)
|
|
- **error**: Error occurred during execution
|
|
- **complete**: Command finished (includes exit_code and duration_ms)
|
|
- **heartbeat**: Keep-alive (sent every 30s)
|
|
|
|
## Example
|
|
|
|
` + "```javascript" + `
|
|
const events = new EventSource('/projects/pantheon/events?stream_id=cmd-001', {
|
|
headers: { 'X-API-Key': 'rdev_sk_...' }
|
|
});
|
|
|
|
events.addEventListener('output', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
console.log(data.line);
|
|
});
|
|
|
|
events.addEventListener('complete', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
console.log('Done:', data.exit_code);
|
|
events.close();
|
|
});
|
|
` + "```",
|
|
"tags": []string{"Events"},
|
|
"security": []map[string]any{
|
|
{"ApiKeyAuth": []string{}},
|
|
},
|
|
"parameters": []map[string]any{
|
|
{
|
|
"name": "id",
|
|
"in": "path",
|
|
"description": "Project ID",
|
|
"required": true,
|
|
"schema": map[string]any{"type": "string"},
|
|
},
|
|
{
|
|
"name": "stream_id",
|
|
"in": "query",
|
|
"description": "Command ID to filter events (optional)",
|
|
"required": false,
|
|
"schema": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
"responses": map[string]any{
|
|
"200": map[string]any{
|
|
"description": "SSE stream",
|
|
"content": map[string]any{
|
|
"text/event-stream": map[string]any{
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
"example": "event: output\ndata: {\"line\": \"Building...\", \"stream\": \"stdout\"}\n\n",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func registerClaudeConfigPaths(spec *api.OpenAPISpec) {
|
|
// Overview
|
|
spec.AddPath("/projects/{id}/claude-config", "get", withAuthAndParams(
|
|
"Get config overview",
|
|
`Returns an overview of the project's Claude config (/workspace/.claude/).
|
|
|
|
Lists available commands, skills, and agents. Requires projects:read scope.`,
|
|
"Claude Config",
|
|
"projects:read",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
))
|
|
|
|
// Commands
|
|
registerConfigTypePaths(spec, "commands", "command", "Commands")
|
|
|
|
// Skills
|
|
registerConfigTypePaths(spec, "skills", "skill", "Skills")
|
|
|
|
// Agents
|
|
registerConfigTypePaths(spec, "agents", "agent", "Agents")
|
|
}
|
|
|
|
func registerConfigTypePaths(spec *api.OpenAPISpec, typePlural, typeSingular, typeTitle string) {
|
|
basePath := "/projects/{id}/claude-config/" + typePlural
|
|
|
|
spec.AddPath(basePath, "get", withAuthAndParams(
|
|
"List "+typePlural,
|
|
"Lists all "+typePlural+" in /workspace/.claude/"+typePlural+"/. Requires projects:read scope.",
|
|
"Claude Config",
|
|
"projects:read",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
))
|
|
|
|
spec.AddPath(basePath, "post", withAuthBodyAndParams(
|
|
"Create "+typeSingular,
|
|
"Creates a new "+typeSingular+". Requires projects:execute scope.",
|
|
"Claude Config",
|
|
"projects:execute",
|
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
`{"name": "example", "content": "# Example `+typeTitle+`\n\n..."}`,
|
|
`{"name": "example", "type": "`+typePlural+`", "content": "# Example `+typeTitle+`\n\n..."}`,
|
|
))
|
|
|
|
spec.AddPath(basePath+"/{name}", "get", withAuthAndParams(
|
|
"Get "+typeSingular,
|
|
"Returns a specific "+typeSingular+"'s content. Requires projects:read scope.",
|
|
"Claude Config",
|
|
"projects:read",
|
|
[]param{
|
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
{Name: "name", In: "path", Description: typeTitle + " name", Required: true},
|
|
},
|
|
))
|
|
|
|
spec.AddPath(basePath+"/{name}", "put", withAuthBodyAndParams(
|
|
"Update "+typeSingular,
|
|
"Updates a "+typeSingular+"'s content. Requires projects:execute scope.",
|
|
"Claude Config",
|
|
"projects:execute",
|
|
[]param{
|
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
{Name: "name", In: "path", Description: typeTitle + " name", Required: true},
|
|
},
|
|
`{"content": "# Updated `+typeTitle+`\n\n..."}`,
|
|
`{"name": "example", "type": "`+typePlural+`", "content": "# Updated `+typeTitle+`\n\n..."}`,
|
|
))
|
|
|
|
spec.AddPath(basePath+"/{name}", "delete", withAuthAndParams(
|
|
"Delete "+typeSingular,
|
|
"Deletes a "+typeSingular+". Requires projects:execute scope.",
|
|
"Claude Config",
|
|
"projects:execute",
|
|
[]param{
|
|
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
{Name: "name", In: "path", Description: typeTitle + " name", Required: true},
|
|
},
|
|
))
|
|
}
|
|
|
|
func registerAuditPaths(spec *api.OpenAPISpec) {
|
|
spec.AddPath("/audit-log", "get", map[string]any{
|
|
"summary": "List audit log entries",
|
|
"description": `Returns audit log entries with optional filtering.
|
|
|
|
**Required scope**: ` + "`audit:read`" + `
|
|
|
|
## Query Parameters
|
|
|
|
| Parameter | Type | Description |
|
|
|-----------|------|-------------|
|
|
| project | string | Filter by project ID |
|
|
| api_key | string | Filter by API key ID |
|
|
| command_type | string | Filter by type (claude, shell, git) |
|
|
| status | string | Filter by status (running, success, error, cancelled) |
|
|
| start | string | Filter by start time (RFC3339 format) |
|
|
| end | string | Filter by end time (RFC3339 format) |
|
|
| limit | int | Max entries to return (default: 100, max: 1000) |
|
|
| offset | int | Number of entries to skip for pagination |`,
|
|
"tags": []string{"Audit"},
|
|
"security": []map[string]any{
|
|
{"ApiKeyAuth": []string{}},
|
|
},
|
|
"parameters": []map[string]any{
|
|
{"name": "project", "in": "query", "description": "Filter by project ID", "schema": map[string]any{"type": "string"}},
|
|
{"name": "api_key", "in": "query", "description": "Filter by API key ID", "schema": map[string]any{"type": "string"}},
|
|
{"name": "command_type", "in": "query", "description": "Filter by command type", "schema": map[string]any{"type": "string", "enum": []string{"claude", "shell", "git"}}},
|
|
{"name": "status", "in": "query", "description": "Filter by status", "schema": map[string]any{"type": "string", "enum": []string{"running", "success", "error", "cancelled"}}},
|
|
{"name": "start", "in": "query", "description": "Filter by start time (RFC3339)", "schema": map[string]any{"type": "string", "format": "date-time"}},
|
|
{"name": "end", "in": "query", "description": "Filter by end time (RFC3339)", "schema": map[string]any{"type": "string", "format": "date-time"}},
|
|
{"name": "limit", "in": "query", "description": "Max entries (default: 100)", "schema": map[string]any{"type": "integer", "default": 100}},
|
|
{"name": "offset", "in": "query", "description": "Entries to skip", "schema": map[string]any{"type": "integer", "default": 0}},
|
|
},
|
|
"responses": map[string]any{
|
|
"200": map[string]any{
|
|
"description": "Success",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"example": `{
|
|
"entries": [
|
|
{
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"api_key_id": "key-123",
|
|
"command_id": "cmd-pantheon-001",
|
|
"project_id": "pantheon",
|
|
"command_type": "claude",
|
|
"args": "[\"fix the bug\"]",
|
|
"client_ip": "192.168.1.100",
|
|
"user_agent": "rdev-cli/1.0",
|
|
"started_at": "2026-01-25T12:00:00Z",
|
|
"completed_at": "2026-01-25T12:01:30Z",
|
|
"exit_code": 0,
|
|
"duration_ms": 90000,
|
|
"status": "success",
|
|
"output_size_bytes": 1024,
|
|
"created_at": "2026-01-25T12:00:00Z"
|
|
}
|
|
],
|
|
"total": 1,
|
|
"limit": 100,
|
|
"offset": 0
|
|
}`,
|
|
},
|
|
},
|
|
},
|
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
|
},
|
|
})
|
|
|
|
spec.AddPath("/audit-log/{command_id}", "get", withAuthAndParams(
|
|
"Get audit log entry",
|
|
"Returns a single audit log entry by command ID. Requires audit:read scope.",
|
|
"Audit",
|
|
"audit:read",
|
|
[]param{{Name: "command_id", In: "path", Description: "Command ID", Required: true}},
|
|
))
|
|
}
|