// 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/auth" "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 } // ── 4b. Gate permission check ────────────── if gatePerm, _ := pkg.Manifest["gate_permission"].(string); gatePerm != "" { userPerms, err := auth.ResolvePermissions(c.Request.Context(), h.stores, getUserID(c)) if err != nil || !userPerms[gatePerm] { c.JSON(http.StatusForbidden, gin.H{"error": "permission required: " + gatePerm}) return } } // ── 5. Build request dict ────────────────── reqDict, err := buildRequestDict(c, rawPath, h.stores) 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, stores ...store.Stores) (*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(7) _ = 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))) // Resolve user permissions for extension inline checks if len(stores) > 0 { userPerms, _ := auth.ResolvePermissions(c.Request.Context(), stores[0], getUserID(c)) permList := make([]starlark.Value, 0, len(userPerms)) for p := range userPerms { permList = append(permList, starlark.String(p)) } _ = d.SetKey(starlark.String("permissions"), starlark.NewList(permList)) } 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 }