This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/ext_api.go
Jeffrey Smith f0dd43144e rebrand: Switchboard Core → Armature
- Rename Go module switchboard-core → armature (155+ files)
- Rename Docker image → gobha/armature
- Rename K8s resources, secrets, deployments
- Rename Prometheus metrics switchboard_* → armature_*
- Rename env vars SWITCHBOARD_ADMIN_* → ARMATURE_ADMIN_*
- Rename DB names switchboard_core* → armature*
- Update all frontend branding, notification templates, docs
- Update CI scripts, e2e tests, Keycloak realm, nginx conf
- Rename scripts/switchboard-ca.sh → scripts/armature-ca.sh
- Rename k8s/switchboard.yaml → k8s/armature.yaml
- Rename chart alerting/dashboard files
- Fix: DockerHub push uses env: binding for secret injection
- Helm chart updated (name, labels, template functions, dashboard, alerting)
- Replace favicon/icon assets with Armature brand

No functional changes. Pure mechanical rename + CI fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:39:58 +00:00

450 lines
12 KiB
Go

// Package handlers — ext_api.go
//
// JSON endpoints via the existing Gin router. Mounted at
// /s/:slug/api/*path with JWT auth.
//
// Manifest format:
//
// {
// "api_routes": [
// {"method": "GET", "path": "/status"},
// {"method": "POST", "path": "/webhook"},
// {"method": "*", "path": "/proxy/*"}
// ]
// }
//
// Route matching:
// - method: exact match (case-insensitive), or "*" for any method
// - path: exact match, or trailing "*" for prefix match
// - If api_routes is boolean true, all routes are forwarded
//
// Starlark contract:
//
// def on_request(req):
// # req = {"method": "GET", "path": "/status", "headers": {...},
// # "query": {...}, "body": "", "user_id": "..."}
// return {"status": 200, "body": '{"ok": true}'}
//
// Response dict fields:
// - status: int (default 200)
// - headers: dict (optional, string→string)
// - body: string (default "")
package handlers
import (
"fmt"
"io"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"go.starlark.net/starlark"
"armature/models"
"armature/sandbox"
"armature/store"
)
// ExtAPIHandler serves extension API routes via Starlark.
type ExtAPIHandler struct {
stores store.Stores
runner *sandbox.Runner
}
// NewExtAPIHandler creates the extension API handler.
func NewExtAPIHandler(stores store.Stores, runner *sandbox.Runner) *ExtAPIHandler {
return &ExtAPIHandler{
stores: stores,
runner: runner,
}
}
// Handle processes an incoming request to /s/:slug/api/*path.
// It loads the target package, validates it, matches the route against
// the manifest's api_routes, and dispatches to the Starlark on_request
// entry point.
func (h *ExtAPIHandler) Handle(c *gin.Context) {
slug := c.Param("slug")
rawPath := c.Param("path") // includes leading "/"
if slug == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "extension not found"})
return
}
// Gin *path captures include leading slash; normalize
if rawPath == "" {
rawPath = "/"
}
// ── 1. Load package ────────────────────────
pkg, err := h.stores.Packages.Get(c.Request.Context(), slug)
if err != nil || pkg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "extension not found"})
return
}
// ── 2. Validate package state ──────────────
if !pkg.Enabled {
c.JSON(http.StatusNotFound, gin.H{"error": "extension is disabled"})
return
}
if pkg.Status != models.PackageStatusActive {
c.JSON(http.StatusForbidden, gin.H{"error": "extension is " + pkg.Status})
return
}
if pkg.Tier != models.ExtTierStarlark {
c.JSON(http.StatusBadRequest, gin.H{"error": "extension is not a starlark package"})
return
}
// ── 3. Check permissions ──────────────────
// The package needs at least one granted permission to be active.
// api.http is NOT required here — a consumer package may delegate
// HTTP to a library via lib.require(). The sandbox wires the http
// module only when api.http is granted; without it the script simply
// cannot call http.get() directly, which is correct for consumers.
if h.stores.ExtPermissions == nil {
c.JSON(http.StatusForbidden, gin.H{"error": "permission system unavailable"})
return
}
granted, err := h.stores.ExtPermissions.GrantedForPackage(c.Request.Context(), pkg.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "permission check failed"})
return
}
if len(granted) == 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "extension has no granted permissions"})
return
}
// ── 4. Match route against manifest ────────
if !matchAPIRoute(pkg.Manifest, c.Request.Method, rawPath) {
c.JSON(http.StatusNotFound, gin.H{"error": "route not declared in extension manifest"})
return
}
// ── 5. Build request dict ──────────────────
reqDict, err := buildRequestDict(c, rawPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request: " + err.Error()})
return
}
// ── 6. Call on_request entry point ─────────
rc := &sandbox.RunContext{UserID: getUserID(c)}
val, output, err := h.runner.CallEntryPoint(
c.Request.Context(), pkg, "on_request",
starlark.Tuple{reqDict}, nil, rc,
)
if err != nil {
log.Printf("⚠️ ext_api %s: on_request error: %v", pkg.ID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "extension error: " + err.Error()})
return
}
if output != "" {
log.Printf(" 🔧 ext_api %s print: %s", pkg.ID, output)
}
// ── 7. Parse and write response ────────────
writeStarlarkResponse(c, val)
}
// ─── Route Matching ─────────────────────────
// matchAPIRoute checks whether the incoming method+path is declared in
// the package manifest's api_routes field.
//
// Formats accepted:
// - boolean true: all routes match
// - []any of route dicts: {"method": "GET", "path": "/status"}
// method "*" matches any method; path trailing "*" matches prefix
func matchAPIRoute(manifest map[string]any, method, path string) bool {
raw, ok := manifest["api_routes"]
if !ok {
return false
}
// Boolean shorthand: true = all routes
if b, ok := raw.(bool); ok {
return b
}
// Array of route declarations
routes, ok := raw.([]any)
if !ok {
return false
}
method = strings.ToUpper(method)
for _, entry := range routes {
route, ok := entry.(map[string]any)
if !ok {
continue
}
// Match method
rm, _ := route["method"].(string)
rm = strings.ToUpper(rm)
if rm != "*" && rm != method {
continue
}
// Match path
rp, _ := route["path"].(string)
if rp == "" {
continue
}
if strings.HasSuffix(rp, "*") {
// Prefix match: "/proxy/*" matches "/proxy/anything"
prefix := strings.TrimSuffix(rp, "*")
if strings.HasPrefix(path, prefix) {
return true
}
} else {
// Exact match
if path == rp {
return true
}
}
}
return false
}
// ─── Request Building ───────────────────────
// buildRequestDict creates a Starlark dict from the Gin context:
//
// {
// "method": "GET",
// "path": "/status",
// "headers": {"content-type": "application/json", ...},
// "query": {"page": "1", ...},
// "body": "...",
// "user_id": "uuid",
// }
func buildRequestDict(c *gin.Context, path string) (*starlark.Dict, error) {
// Read body (capped at 1 MB for safety)
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("reading body: %w", err)
}
// Headers dict (lowercase keys, first value)
hdrs := starlark.NewDict(len(c.Request.Header))
for k, vals := range c.Request.Header {
if len(vals) > 0 {
_ = hdrs.SetKey(starlark.String(strings.ToLower(k)), starlark.String(vals[0]))
}
}
// Query dict (first value per key)
query := starlark.NewDict(len(c.Request.URL.Query()))
for k, vals := range c.Request.URL.Query() {
if len(vals) > 0 {
_ = query.SetKey(starlark.String(k), starlark.String(vals[0]))
}
}
d := starlark.NewDict(6)
_ = d.SetKey(starlark.String("method"), starlark.String(c.Request.Method))
_ = d.SetKey(starlark.String("path"), starlark.String(path))
_ = d.SetKey(starlark.String("headers"), hdrs)
_ = d.SetKey(starlark.String("query"), query)
_ = d.SetKey(starlark.String("body"), starlark.String(string(body)))
_ = d.SetKey(starlark.String("user_id"), starlark.String(getUserID(c)))
return d, nil
}
// ─── Response Writing ───────────────────────
// writeStarlarkResponse parses the Starlark return value and writes
// the HTTP response. Expected: dict with "status", "headers", "body".
// Missing fields use defaults (200, no extra headers, empty body).
func writeStarlarkResponse(c *gin.Context, val starlark.Value) {
if val == nil || val == starlark.None {
c.Status(http.StatusNoContent)
c.Writer.WriteHeaderNow()
return
}
dict, ok := val.(*starlark.Dict)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("on_request must return a dict, got %s", val.Type()),
})
return
}
// Status code
status := http.StatusOK
if sv, found, _ := dict.Get(starlark.String("status")); found {
if i, err := starlark.AsInt32(sv); err == nil {
status = i
}
}
// Response headers
if hv, found, _ := dict.Get(starlark.String("headers")); found {
if hd, ok := hv.(*starlark.Dict); ok {
for _, item := range hd.Items() {
k, ok1 := starlark.AsString(item[0])
v, ok2 := starlark.AsString(item[1])
if ok1 && ok2 {
c.Header(k, v)
}
}
}
}
// Body
body := ""
if bv, found, _ := dict.Get(starlark.String("body")); found {
body, _ = starlark.AsString(bv)
}
// Auto-detect content type if not set explicitly
if c.Writer.Header().Get("Content-Type") == "" {
if len(body) > 0 && (body[0] == '{' || body[0] == '[') {
c.Header("Content-Type", "application/json")
} else {
c.Header("Content-Type", "text/plain; charset=utf-8")
}
}
c.String(status, body)
}
// ─── Helpers ────────────────────────────────
func hasPermission(granted []string, perm string) bool {
for _, p := range granted {
if p == perm {
return true
}
}
return false
}
// ── api_schema support ─────────────────────────────────────
// APISchemaEntry represents one route's OpenAPI-level documentation
// declared via the optional manifest "api_schema" array.
type APISchemaEntry struct {
Path string `json:"path"`
Method string `json:"method"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
Params map[string]any `json:"params,omitempty"`
Body map[string]any `json:"body,omitempty"`
Response map[string]any `json:"response,omitempty"`
}
// ParseAPISchema extracts the optional api_schema array from a package manifest.
// Malformed entries are logged and skipped — never blocks extension loading.
func ParseAPISchema(manifest map[string]any) []APISchemaEntry {
raw, ok := manifest["api_schema"]
if !ok {
return nil
}
arr, ok := raw.([]any)
if !ok {
log.Printf("[ext_api] api_schema is not an array, skipping")
return nil
}
var entries []APISchemaEntry
for i, item := range arr {
m, ok := item.(map[string]any)
if !ok {
log.Printf("[ext_api] api_schema[%d]: not an object, skipping", i)
continue
}
path, _ := m["path"].(string)
method, _ := m["method"].(string)
if path == "" || method == "" {
log.Printf("[ext_api] api_schema[%d]: missing path or method, skipping", i)
continue
}
entry := APISchemaEntry{
Path: path,
Method: strings.ToUpper(method),
}
if s, ok := m["summary"].(string); ok {
entry.Summary = s
}
if s, ok := m["description"].(string); ok {
entry.Description = s
}
if p, ok := m["params"].(map[string]any); ok {
entry.Params = p
}
if b, ok := m["body"].(map[string]any); ok {
entry.Body = b
}
if r, ok := m["response"].(map[string]any); ok {
entry.Response = r
}
entries = append(entries, entry)
}
return entries
}
// ExtractAPIRoutes returns all declared path+method pairs from the manifest.
// Used by the OpenAPI builder to enumerate routes for stub generation.
func ExtractAPIRoutes(manifest map[string]any) []struct{ Method, Path string } {
raw, ok := manifest["api_routes"]
if !ok {
return nil
}
// Boolean shorthand — no specific routes to enumerate
if _, ok := raw.(bool); ok {
return nil
}
routes, ok := raw.([]any)
if !ok {
return nil
}
var result []struct{ Method, Path string }
for _, entry := range routes {
route, ok := entry.(map[string]any)
if !ok {
continue
}
method, _ := route["method"].(string)
path, _ := route["path"].(string)
if method == "" || path == "" {
continue
}
result = append(result, struct{ Method, Path string }{
Method: strings.ToUpper(method),
Path: path,
})
}
return result
}
// HasAPIRoutes checks whether a package manifest declares API routes.
// Used by startup discovery and admin UI to identify API-capable packages.
func HasAPIRoutes(manifest map[string]any) bool {
raw, ok := manifest["api_routes"]
if !ok {
return false
}
if b, ok := raw.(bool); ok {
return b
}
if routes, ok := raw.([]any); ok {
return len(routes) > 0
}
return false
}