Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions internal/broker/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,53 @@ type AppEntry struct {
BreakerThreshold int `json:"breaker_threshold"` // consecutive failures before opening (0 = disabled)
BreakerCooldownMs int `json:"breaker_cooldown_ms"` // how long the breaker stays open

master string // resolved from KeyEnv at load
injector AuthInjector // built from AuthHeader/Scheme
allowSet map[string]bool
breaker *Breaker
master string // resolved from KeyEnv at load
injector AuthInjector // built from AuthHeader/Scheme
allowSet map[string]bool
allowPatterns [][]string // templated allow entries split on "/" ("{x}" matches any one segment)
breaker *Breaker
}

// allowed reports whether a request path is permitted. Exact entries match
// literally (fast map hit); templated entries (containing a {name} segment, e.g.
// "/v1/calls/{call_id}") match any single non-empty segment in that position, so
// REST path params don't each need enumerating. An empty allow-list permits
// nothing (safe default; prod must declare).
func (a *AppEntry) allowed(path string) bool {
if len(a.allowSet) == 0 {
return false // empty allow-list = nothing allowed (safe default; prod must declare)
if len(a.allowSet) == 0 && len(a.allowPatterns) == 0 {
return false
}
return a.allowSet[path]
if a.allowSet[path] {
return true
}
segs := strings.Split(path, "/")
for _, pat := range a.allowPatterns {
if segmentsMatch(pat, segs) {
return true
}
}
return false
}

// segmentsMatch reports whether request segments satisfy a templated pattern. A
// "{name}" pattern segment matches any single non-empty segment; every other
// segment must match literally. Lengths must be equal (no implicit wildcards).
func segmentsMatch(pat, segs []string) bool {
if len(pat) != len(segs) {
return false
}
for i, p := range pat {
if len(p) >= 2 && p[0] == '{' && p[len(p)-1] == '}' {
if segs[i] == "" {
return false
}
continue
}
if p != segs[i] {
return false
}
}
return true
}

// Registry holds the managed apps by id.
Expand Down Expand Up @@ -87,8 +123,13 @@ func ParseRegistry(raw []byte, getenv func(string) string) (*Registry, error) {
}
a.injector = injectorFor(a.AuthStyle, a.AuthHeader, a.AuthScheme, a.AuthParam, a.AuthUser)
a.allowSet = map[string]bool{}
a.allowPatterns = nil
for _, p := range a.Allow {
a.allowSet[p] = true
if strings.Contains(p, "{") {
a.allowPatterns = append(a.allowPatterns, strings.Split(p, "/"))
} else {
a.allowSet[p] = true
}
}
if a.CostField == "" {
a.CostField = "cost_cents"
Expand Down
50 changes: 50 additions & 0 deletions internal/broker/zz_allow_pattern_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package broker

import "testing"

// Templated allow entries ("{name}" segments) let REST path params through
// without enumerating every id, while still refusing un-allowed paths.
func TestAllowPatternMatching(t *testing.T) {
reg, err := ParseRegistry([]byte(`[{
"id":"io.pilot.x","upstream":"https://api.example.com","key_env":"X_KEY",
"auth_header":"Authorization","auth_scheme":"Bearer",
"allow":["/v1/usage","/v1/calls/{call_id}","/v1/numbers/{number_id}/messages"]
}]`), func(string) string { return "secret" })
if err != nil {
t.Fatalf("ParseRegistry: %v", err)
}
app := reg.Get("io.pilot.x")
if app == nil {
t.Fatal("app not loaded")
}
cases := []struct {
path string
want bool
}{
{"/v1/usage", true}, // exact
{"/v1/calls/call_abc123", true}, // one path param
{"/v1/calls/", false}, // empty segment must not match {call_id}
{"/v1/calls/abc/extra", false}, // too many segments
{"/v1/numbers/num_1/messages", true}, // param in the middle
{"/v1/numbers/num_1/calls", false}, // literal tail mismatch
{"/v1/agents", false}, // not allowed at all
{"/v1/usage/daily", false}, // longer than the exact entry
}
for _, c := range cases {
if got := app.allowed(c.path); got != c.want {
t.Errorf("allowed(%q) = %v, want %v", c.path, got, c.want)
}
}
}

// An empty allow-list permits nothing (safe default), even with no patterns.
func TestAllowEmptyDeniesAll(t *testing.T) {
reg, err := ParseRegistry([]byte(`[{"id":"io.pilot.y","upstream":"https://api.example.com","key_env":"Y_KEY","allow":[]}]`),
func(string) string { return "secret" })
if err != nil {
t.Fatalf("ParseRegistry: %v", err)
}
if reg.Get("io.pilot.y").allowed("/anything") {
t.Error("empty allow-list must deny all")
}
}
8 changes: 8 additions & 0 deletions internal/publish/broker_register.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,16 @@ func (s Submission) MasterKeyEnv() string {
// which header to inject it as (AuthHeader), and which method paths are allowed.
func (s Submission) BrokerEntry() broker.AppEntry {
authHeader := "Authorization" // safe default if the submitter didn't name one
authScheme := "" // e.g. "Bearer" for Authorization: Bearer <key>
for _, h := range s.Backend.Headers {
if strings.TrimSpace(h.Name) != "" {
authHeader = h.Name
// A header value like "Bearer managed" carries a scheme prefix; the
// trailing "managed" sentinel just marks that the broker injects the
// key. A bare "managed" means no scheme (e.g. x-api-key: <key>).
if f := strings.Fields(h.Value); len(f) >= 2 {
authScheme = f[0]
}
break
}
}
Expand All @@ -48,6 +55,7 @@ func (s Submission) BrokerEntry() broker.AppEntry {
Upstream: strings.TrimRight(s.Backend.BaseURL, "/"),
KeyEnv: s.MasterKeyEnv(),
AuthHeader: authHeader,
AuthScheme: authScheme,
Allow: allow,
Quota: s.Backend.Quota, // per-caller cap set at publish time (0 = unlimited)
}
Expand Down
29 changes: 29 additions & 0 deletions internal/publish/broker_register_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,32 @@ func TestFileRegistrarUpsertsIdempotently(t *testing.T) {
t.Fatalf("expected 1 entry after idempotent upsert, got %d", len(raw))
}
}

// A Bearer API: the header value "Bearer managed" makes BrokerEntry derive
// auth_scheme "Bearer" (so the broker injects "Authorization: Bearer <key>"),
// and templated method paths flow through to the allow-list verbatim.
func TestBrokerEntryBearerScheme(t *testing.T) {
sub := Submission{
ID: "io.pilot.agentphone",
Version: "0.1.0",
Backend: SubBackend{
BaseURL: "https://api.agentphone.ai",
Auth: "managed",
Headers: []SubHeader{{Name: "Authorization", Value: "Bearer managed"}},
},
Methods: []SubMethod{
{Name: "agentphone.place_call", HTTP: SubRoute{Verb: "POST", Path: "/v1/calls"}},
{Name: "agentphone.get_call", HTTP: SubRoute{Verb: "GET", Path: "/v1/calls/{call_id}"}},
},
}
e := sub.BrokerEntry()
if e.KeyEnv != "AGENTPHONE_MASTER_KEY" {
t.Errorf("key_env = %q, want AGENTPHONE_MASTER_KEY", e.KeyEnv)
}
if e.AuthHeader != "Authorization" || e.AuthScheme != "Bearer" {
t.Errorf("auth = %q/%q, want Authorization/Bearer", e.AuthHeader, e.AuthScheme)
}
if len(e.Allow) != 2 || e.Allow[1] != "/v1/calls/{call_id}" {
t.Errorf("allow = %v, want the templated path preserved", e.Allow)
}
}
49 changes: 43 additions & 6 deletions internal/scaffold/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,38 @@
Roundtrip string `yaml:"roundtrip"` // measured warm roundtrip, for help
}

// HTTPRoute maps a method to one backend HTTP endpoint. GET forwards the flat
// JSON payload as a query string; POST forwards it as a JSON body.
// HTTPRoute maps a method to one backend HTTP endpoint. The path may contain
// {name} placeholders, filled at call time from the payload (REST-style path
// params, e.g. /v1/calls/{call_id}). Of the remaining payload fields, a
// body verb (POST/PATCH/PUT) forwards them as a JSON body; a non-body verb
// (GET/DELETE) forwards them as a query string.
type HTTPRoute struct {
Verb string `yaml:"verb"` // GET (default) | POST
Path string `yaml:"path"` // e.g. /current
Verb string `yaml:"verb"` // GET (default) | POST | PATCH | PUT | DELETE
Path string `yaml:"path"` // e.g. /current or /v1/calls/{call_id}

// PathParams is derived in Resolve from {name} placeholders in Path; the
// generated adapter substitutes each from the payload (URL-escaped) and
// drops it from the body/query so it isn't sent twice.
PathParams []string `yaml:"-"`
}

// BodyVerb reports whether this route sends remaining payload fields as a JSON
// body (POST/PATCH/PUT) rather than a query string (GET/DELETE).
func (h *HTTPRoute) BodyVerb() bool {
return h.Verb == "POST" || h.Verb == "PATCH" || h.Verb == "PUT"
}

// pathParamPattern matches {name} placeholders in an http path.
var pathParamPattern = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)\}`)

// pathParamNames returns the {name} placeholders in a path, in order.
func pathParamNames(path string) []string {
ms := pathParamPattern.FindAllStringSubmatch(path, -1)
out := make([]string, 0, len(ms))
for _, m := range ms {
out = append(out, m[1])
}
return out
}

// CLIRoute maps a method to a local subprocess invocation. Args may reference
Expand Down Expand Up @@ -327,6 +354,7 @@
}
if m.HTTP != nil {
m.HTTP.Verb = strings.ToUpper(m.HTTP.Verb)
m.HTTP.PathParams = pathParamNames(m.HTTP.Path)
}
}
}
Expand Down Expand Up @@ -412,10 +440,19 @@
if m.HTTP.Path == "" || !strings.HasPrefix(m.HTTP.Path, "/") {
errs = append(errs, fmt.Errorf("methods[%d].http.path must start with /", i))
}
if m.HTTP.Verb != "GET" && m.HTTP.Verb != "POST" {
errs = append(errs, fmt.Errorf("methods[%d].http.verb %q must be GET or POST", i, m.HTTP.Verb))
switch m.HTTP.Verb {
case "GET", "POST", "PATCH", "PUT", "DELETE":
default:
errs = append(errs, fmt.Errorf("methods[%d].http.verb %q must be GET|POST|PATCH|PUT|DELETE", i, m.HTTP.Verb))
}
// Every {name} placeholder in the path must be a declared param,
// so the adapter can fill it and <ns>.help documents it.
for _, p := range pathParamNames(m.HTTP.Path) {
if _, ok := m.Params[p]; !ok {
errs = append(errs, fmt.Errorf("methods[%d] (%s): path placeholder {%s} has no matching entry under params:", i, m.Name, p))
}
}
}

Check failure on line 455 in internal/scaffold/config.go

View workflow job for this annotation

GitHub Actions / lint

error strings should not end with punctuation or newlines (ST1005)
case "cli":
if m.CLI == nil {
errs = append(errs, fmt.Errorf("methods[%d] (%s): cli backend requires a cli: route", i, m.Name))
Expand Down
36 changes: 36 additions & 0 deletions internal/scaffold/templates/client_http.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,42 @@ func (c *Client) PostRaw(ctx context.Context, path string, payload json.RawMessa
{{- end}}
return c.do(req)
}

// Do issues an arbitrary method on <path> with an optional query string and JSON
// body, and returns the raw JSON body. Path placeholders are already filled by
// the caller; query is used for GET/DELETE, body for POST/PATCH/PUT. Empty-valued
// query keys are dropped; an empty body sends no body (no Content-Type).
func (c *Client) Do(ctx context.Context, method, path string, query map[string]string, body json.RawMessage) (json.RawMessage, error) {
u := c.baseURL + path
if len(query) > 0 {
q := url.Values{}
for k, v := range query {
if v != "" {
q.Set(k, v)
}
}
if enc := q.Encode(); enc != "" {
u += "?" + enc
}
}
var rdr io.Reader
var bodyBytes []byte
if len(bytes.TrimSpace(body)) > 0 {
bodyBytes = []byte(body)
rdr = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequestWithContext(ctx, method, u, rdr)
if err != nil {
return nil, err
}
if bodyBytes != nil {
req.Header.Set("Content-Type", "application/json")
}
{{- if .Managed}}
c.applySig(req, bodyBytes)
{{- end}}
return c.do(req)
}
{{- if .Managed}}

// applySig stamps the caller-identity headers the broker verifies. The signed
Expand Down
14 changes: 14 additions & 0 deletions internal/scaffold/templates/example.pilot.app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@ methods:
verb: POST # POST -> payload forwarded as the JSON body
path: /report

- name: weather.station
summary: "Get one station by id (REST path parameter)."
kind: utility
duration: fast
http:
verb: GET # GET | POST | PATCH | PUT | DELETE
# {placeholder} segments are filled from params at call time (URL-escaped)
# and dropped from the body/query. QUOTE any path with braces — an
# unquoted {x} is a YAML flow-map. Body verbs (POST/PATCH/PUT) send the
# remaining fields as JSON; GET/DELETE send them as the query string.
path: "/stations/{station_id}"
params:
station_id: "string (required) — path param"

- name: weather.health
summary: "Liveness probe."
kind: status
Expand Down
Loading
Loading