Record Nuxt/MCP traffic as ordered flows via parity:proxy, pin loopback upstream, and refuse oversized or credential-shaped fixtures. Co-authored-by: Cursor <cursoragent@cursor.com>
148 lines
3.6 KiB
Go
148 lines
3.6 KiB
Go
package tide
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
)
|
|
|
|
const (
|
|
ClientNuxt = "nuxt"
|
|
ClientMCP = "mcp"
|
|
|
|
SessionHeader = "X-Parity-Session"
|
|
)
|
|
|
|
// Rules is the committed, non-secret capture policy for a proxy session.
|
|
type Rules struct {
|
|
Client string `yaml:"client"`
|
|
KeepRequestHeaders []string `yaml:"keep_request_headers"`
|
|
KeepResponseHeaders []string `yaml:"keep_response_headers"`
|
|
Routes []RouteRule `yaml:"routes"`
|
|
}
|
|
|
|
// RouteRule matches a method and path pattern and names capture sources.
|
|
type RouteRule struct {
|
|
Method string `yaml:"method"`
|
|
Path string `yaml:"path"`
|
|
KeepRequestHeaders []string `yaml:"keep_request_headers,omitempty"`
|
|
KeepResponseHeaders []string `yaml:"keep_response_headers,omitempty"`
|
|
Capture []CaptureRule `yaml:"capture,omitempty"`
|
|
}
|
|
|
|
// LoadRules reads a strict YAML rule file, rejecting unknown fields.
|
|
func LoadRules(path string) (Rules, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Rules{}, fmt.Errorf("tide: read rules %s: %w", path, err)
|
|
}
|
|
return ParseRules(raw)
|
|
}
|
|
|
|
// ParseRules decodes rules YAML with unknown-field rejection.
|
|
func ParseRules(raw []byte) (Rules, error) {
|
|
var rules Rules
|
|
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
|
if err := dec.Decode(&rules); err != nil {
|
|
return Rules{}, fmt.Errorf("tide: parse rules: %w", err)
|
|
}
|
|
if err := validateRules(rules); err != nil {
|
|
return Rules{}, err
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
func validateRules(rules Rules) error {
|
|
switch rules.Client {
|
|
case ClientNuxt, ClientMCP:
|
|
default:
|
|
return fmt.Errorf("tide: rules client must be %q or %q", ClientNuxt, ClientMCP)
|
|
}
|
|
for i, route := range rules.Routes {
|
|
if strings.TrimSpace(route.Method) == "" {
|
|
return fmt.Errorf("tide: rules routes[%d] is missing method", i)
|
|
}
|
|
if strings.TrimSpace(route.Path) == "" {
|
|
return fmt.Errorf("tide: rules routes[%d] is missing path", i)
|
|
}
|
|
for j, cap := range route.Capture {
|
|
if strings.TrimSpace(cap.As) == "" {
|
|
return fmt.Errorf("tide: rules routes[%d].capture[%d] is missing as", i, j)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Match returns the first route rule for method and path, or nil.
|
|
func (r Rules) Match(method, requestPath string) *RouteRule {
|
|
for i := range r.Routes {
|
|
route := &r.Routes[i]
|
|
if !matchMethod(route.Method, method) {
|
|
continue
|
|
}
|
|
if !matchPath(route.Path, requestPath) {
|
|
continue
|
|
}
|
|
return route
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r Rules) requestHeaders(route *RouteRule) []string {
|
|
if route != nil && len(route.KeepRequestHeaders) > 0 {
|
|
return route.KeepRequestHeaders
|
|
}
|
|
return r.KeepRequestHeaders
|
|
}
|
|
|
|
func (r Rules) responseHeaders(route *RouteRule) []string {
|
|
if route != nil && len(route.KeepResponseHeaders) > 0 {
|
|
return route.KeepResponseHeaders
|
|
}
|
|
return r.KeepResponseHeaders
|
|
}
|
|
|
|
func matchMethod(pattern, method string) bool {
|
|
pattern = strings.TrimSpace(pattern)
|
|
if pattern == "" || pattern == "*" {
|
|
return true
|
|
}
|
|
return strings.EqualFold(pattern, method)
|
|
}
|
|
|
|
func matchPath(pattern, requestPath string) bool {
|
|
if pattern == requestPath {
|
|
return true
|
|
}
|
|
ok, err := path.Match(pattern, requestPath)
|
|
return err == nil && ok
|
|
}
|
|
|
|
func filterHeaders(h http.Header, keep []string) map[string]string {
|
|
if len(keep) == 0 || h == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]string)
|
|
for _, name := range keep {
|
|
vals := h.Values(name)
|
|
if len(vals) == 0 {
|
|
continue
|
|
}
|
|
if len(vals) == 1 {
|
|
out[http.CanonicalHeaderKey(name)] = vals[0]
|
|
continue
|
|
}
|
|
out[http.CanonicalHeaderKey(name)] = strings.Join(vals, "\n")
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|