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
4 changes: 2 additions & 2 deletions authbridge/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,8 +425,8 @@ When `session.enabled` is true (default) and `listener.session_api_addr` is non-
| `GET /v1/sessions` | `application/json` | List active sessions: `{sessions: [{id, createdAt, updatedAt, eventCount, active}]}`. |
| `GET /v1/sessions/{id}` | `application/json` | Full snapshot of one session's events. 404 if unknown/expired. |
| `GET /v1/events` | `text/event-stream` | SSE stream of new events. Optional `?session=<id>` filters to one session. Heartbeat every 30s. |
| `GET /v1/pipeline` | `application/json` | Active pipeline composition: `{inbound: [...], outbound: [...]}`. Each plugin entry carries `name`, `direction`, `position`, `readsBody`, plus the static metadata (`requires`, `requiresAny`, `description`) and runtime `config` when present. abctl renders this as the Pipeline pane. |
| `GET /v1/plugins` | `application/json` | Catalog of every registered plugin (whether or not in the active pipeline): `{plugins: [{name, requires, requiresAny, description, ...}]}`. abctl renders this as the Catalog pane (`P` key). 404s when the binary's session API was constructed without `WithCatalog`. |
| `GET /v1/pipeline` | `application/json` | Active pipeline composition: `{inbound: [...], outbound: [...]}`. Each plugin entry carries `name`, `direction` (the chain this instance sits in), `position`, `readsBody`, plus the static metadata (`requires`, `requiresAny`, `directions`, `description`) and runtime `config` when present. abctl renders this as the Pipeline pane. |
| `GET /v1/plugins` | `application/json` | Catalog of every registered plugin (whether or not in the active pipeline): `{plugins: [{name, directions, requires, requiresAny, description, fields, ...}]}`. `directions` is the type-level list of chains the plugin supports (`["inbound"]`, `["outbound"]`, or both; absent = unconstrained) — distinct from `/v1/pipeline`'s positional `direction`, and what config generators read to place a plugin. `fields` carries per-field config schema for plugins implementing `pipeline.SchemaProvider`. abctl renders this as the Catalog pane (`P` key). 404s when the binary's session API was constructed without `WithCatalog`. |
| `GET /healthz` | text | Liveness probe. |

### Quick examples
Expand Down
96 changes: 96 additions & 0 deletions authbridge/authlib/pipeline/directions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package pipeline

import (
"reflect"
"testing"
)

// A plugin that declares nothing is unconstrained: Supports must answer
// "no objection" for every direction, which is what keeps the field
// advisory and backward-compatible with out-of-tree plugins.
func TestSupportsUnconstrained(t *testing.T) {
var caps PluginCapabilities
for _, d := range []Direction{Inbound, Outbound} {
if !caps.Supports(d) {
t.Errorf("nil Directions should support %s", d)
}
}
// An explicitly-empty slice behaves the same as nil.
caps.Directions = []Direction{}
for _, d := range []Direction{Inbound, Outbound} {
if !caps.Supports(d) {
t.Errorf("empty Directions should support %s", d)
}
}
}

func TestSupports(t *testing.T) {
cases := []struct {
name string
declared []Direction
wantIn bool
wantOutbnd bool
}{
{"inbound only", []Direction{Inbound}, true, false},
{"outbound only", []Direction{Outbound}, false, true},
{"both", []Direction{Inbound, Outbound}, true, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
caps := PluginCapabilities{Directions: c.declared}
if got := caps.Supports(Inbound); got != c.wantIn {
t.Errorf("Supports(Inbound) = %v, want %v", got, c.wantIn)
}
if got := caps.Supports(Outbound); got != c.wantOutbnd {
t.Errorf("Supports(Outbound) = %v, want %v", got, c.wantOutbnd)
}
})
}
}

// Normalize canonicalizes Directions so two literals describing the same
// plugin can't produce two different cached/wire representations.
func TestNormalizeDirections(t *testing.T) {
cases := []struct {
name string
in []Direction
want []Direction
}{
{"nil stays nil", nil, nil},
{"empty becomes nil", []Direction{}, nil},
{"sorts", []Direction{Outbound, Inbound}, []Direction{Inbound, Outbound}},
{"dedups", []Direction{Inbound, Inbound}, []Direction{Inbound}},
{"dedups and sorts", []Direction{Outbound, Inbound, Outbound}, []Direction{Inbound, Outbound}},
{"already canonical", []Direction{Inbound, Outbound}, []Direction{Inbound, Outbound}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := PluginCapabilities{Directions: c.in}.Normalize().Directions
if !reflect.DeepEqual(got, c.want) {
t.Errorf("Normalize().Directions = %v, want %v", got, c.want)
}
})
}
}

// Normalize must not reorder the caller's slice: a plugin returning a
// package-level slice from Capabilities() would otherwise have it
// permuted underneath it by whoever normalized first.
func TestNormalizeDoesNotMutateInput(t *testing.T) {
orig := []Direction{Outbound, Inbound}
caps := PluginCapabilities{Directions: orig}
_ = caps.Normalize()
if orig[0] != Outbound || orig[1] != Inbound {
t.Fatalf("Normalize mutated the input slice: %v", orig)
}
}

// Normalize is idempotent — the catalog normalizes on read, so applying
// it twice must not change the answer.
func TestNormalizeDirectionsIdempotent(t *testing.T) {
once := PluginCapabilities{Directions: []Direction{Outbound, Inbound, Inbound}}.Normalize()
twice := once.Normalize()
if !reflect.DeepEqual(once.Directions, twice.Directions) {
t.Errorf("not idempotent: %v then %v", once.Directions, twice.Directions)
}
}
69 changes: 67 additions & 2 deletions authbridge/authlib/pipeline/plugin.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package pipeline

import "context"
import (
"context"
"slices"
)

// Plugin is the interface that all pipeline extensions implement.
type Plugin interface {
Expand Down Expand Up @@ -60,6 +63,25 @@ type PluginCapabilities struct {
// of running the guardrail as silent dead code.
RequiresAny []string

// Directions declares which pipeline chains this plugin is designed
// to run in. Advisory metadata only: nothing rejects a plugin that
// is configured into another chain, because no plugin enforces
// direction at runtime (opa, the one plugin that cares, merely
// branches on pctx.Direction). A mismatch produces a startup WARN
// from plugins.WarnPluginDirections and an advisory in abctl's
// pre-apply validator.
//
// Nil or empty means unconstrained — the plugin makes no claim and
// no warning is ever emitted for it. That is the zero value, so
// out-of-tree plugins and test stubs need no change.
//
// This is the machine-readable form of the Direction column in
// authbridge/docs/plugin-catalog.md, and it is what lets config
// generators place a plugin in the right chain without a
// hand-maintained table. Use Supports to test membership rather
// than scanning the slice directly.
Directions []Direction

// Description is operator-facing prose, one line, ≤80 chars,
// describing what this plugin does. Surfaces in `abctl`'s
// plugin-detail and catalog panes, and in /v1/plugins.
Expand All @@ -71,17 +93,60 @@ type PluginCapabilities struct {
Description string
}

// Normalize applies WritesBody-implies-ReadsBody promotion.
// Normalize applies WritesBody-implies-ReadsBody promotion and puts
// Directions into a canonical form (de-duplicated, ascending).
// Called by Pipeline.New for every plugin's declared capabilities so the
// rest of the framework reads a normalized form. Plugins never need to
// call this themselves.
//
// Canonicalizing Directions matters because the catalog is cached and
// compared: a hand-written literal of {Outbound, Inbound} and one of
// {Inbound, Outbound} describe the same plugin and must not produce two
// different wire representations. Normalize copies the slice rather than
// sorting in place, so a plugin returning a shared backing array from
// Capabilities() can't have it reordered underneath it.
func (c PluginCapabilities) Normalize() PluginCapabilities {
if c.WritesBody {
c.ReadsBody = true
}
c.Directions = canonicalDirections(c.Directions)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must-fix — this function will not compile against main. WritesBody no longer exists there: 6ca3fbb ("Rename WritesBody to WritesRequestBody") and 6440927 ("Split body-write capability by direction") both landed 2026-09-02, the day this PR was opened, and grep -c WritesBody on main's plugin.go is now 0.

main's version reads:

func (c PluginCapabilities) Normalize() PluginCapabilities {
	if c.WritesRequestBody || c.WritesResponseBody {
		c.ReadsBody = true
	}
	return c
}

So the resolution is to keep main's condition and add your one line to it:

	if c.WritesRequestBody || c.WritesResponseBody {
		c.ReadsBody = true
	}
	c.Directions = canonicalDirections(c.Directions)
	return c

The Directions field itself, canonicalDirections, and Supports all apply cleanly — the collision is only with the WritesBody line and the doc comment above it (which also names WritesBody, at :96). Flagging it explicitly because a rebase that resolves this hunk by taking your side compiles nowhere, and one that takes main's side silently drops the canonicalDirections call — losing the de-duplication and sort that the rest of the change depends on.

return c
}

// canonicalDirections returns a de-duplicated, ascending copy of in.
// Returns nil for empty input so the "unconstrained" case stays a nil
// slice all the way to the wire (where it elides via omitempty).
func canonicalDirections(in []Direction) []Direction {
if len(in) == 0 {
return nil
}
out := make([]Direction, 0, len(in))
for _, d := range in {
if !slices.Contains(out, d) {
out = append(out, d)
}
}
slices.Sort(out)
return out
}

// Supports reports whether the plugin declares itself usable in the
// given direction. A plugin with no declared Directions is
// unconstrained and supports every direction, so this returns true —
// callers get "no objection" rather than "no support" for the zero
// value, which is what keeps the field advisory and backward
// compatible.
//
// Every consumer (the startup warning, abctl's validator, the template
// renderer) goes through this method so the membership rule lives in
// one place.
func (c PluginCapabilities) Supports(d Direction) bool {
if len(c.Directions) == 0 {
return true
}
return slices.Contains(c.Directions, d)
}

// Initializer is an optional interface a plugin may implement when it
// needs to run work once before the pipeline starts serving traffic.
// Typical uses: load a model, warm a cache, open a database connection,
Expand Down
7 changes: 6 additions & 1 deletion authbridge/authlib/pipeline/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ type FieldSchema struct {

// Type is a coarse-grained category sufficient to render templates
// and pick value placeholders. One of:
// "string", "int", "bool", "[]string", "object", "unknown".
// "string", "int", "number", "bool", "[]string", "object", "unknown".
// "number" is a float (per-token costs, budgets); "int" stays

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — the stated rationale is contradicted by the only consumer, inside this same PR. "int" is said to stay separate "so a template can emit \"0\" vs \"0.0\" appropriately", but templates.go's placeholderFor returns "0" for both, and its new case "number" explains why that is the right call: "0 is valid YAML for a float, and avoids implying a fixed precision."

I agree with the code, not the comment — the two case bodies are now byte-identical, and no template emits 0.0. Keeping "number" distinct is still worth doing, just for a different reason: it is honest type metadata for consumers that are not this template renderer (a JSON-Schema emitter, a form generator, a validator that needs to know a value may be fractional). Restating it that way would leave the comment true, and would stop the next person collapsing the two cases on the grounds that the documented distinction is unused.

// reserved for integral kinds so a template can emit "0" vs "0.0"
// appropriately.
// "object" indicates a nested struct whose fields populate Fields.
// "unknown" covers shapes the helper hasn't been taught (maps,
// slice-of-struct, etc.); the field still renders but without a
Expand Down Expand Up @@ -174,6 +177,8 @@ func kindOf(t reflect.Type) string {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "int"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Slice:
// Only []string gets a typed tag; other slices are "unknown"
// (slice-of-struct, slice-of-map, etc. are rare in plugin
Expand Down
25 changes: 25 additions & 0 deletions authbridge/authlib/pipeline/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,28 @@ func TestSchemaOf_SelfReferentialIsBounded(t *testing.T) {
cur = cur.Fields[1]
}
}

// floats is a separate fixture rather than an extension of `primitives`
// so TestSchemaOf_Primitives' exact-match assertion stays untouched.
type floats struct {
Budget float64 `json:"budget" required:"true" description:"Daily budget in USD."`
Rate32 float32 `json:"rate32" description:"Narrower float still maps to number."`
Whole int `json:"whole" description:"Integral kinds must NOT become number."`
AlsoWhole int64 `json:"also_whole"`
}

// Float fields report "number", distinct from "int", so a template can
// tell a per-token cost from a port number. litellm-budget-track is the
// first plugin to expose float config, which is what motivated the type.
func TestSchemaOf_Floats(t *testing.T) {
got := SchemaOf(floats{})
want := []FieldSchema{
{Name: "budget", Type: "number", Required: true, Description: "Daily budget in USD."},
{Name: "rate32", Type: "number", Description: "Narrower float still maps to number."},
{Name: "whole", Type: "int", Description: "Integral kinds must NOT become number."},
{Name: "also_whole", Type: "int"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("SchemaOf floats:\n got: %+v\nwant: %+v", got, want)
}
}
1 change: 1 addition & 0 deletions authbridge/authlib/plugins/a2aparser/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func (p *A2AParser) Name() string { return "a2a-parser" }

func (p *A2AParser) Capabilities() pipeline.PluginCapabilities {
return pipeline.PluginCapabilities{
Directions: []pipeline.Direction{pipeline.Inbound},
ReadsBody: true,
Description: "Parses A2A messages into pctx.Extensions.A2A for downstream plugins.",
}
Expand Down
Loading
Loading