mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
The last three first-party gin files were transports wrapped around decisions, and the decisions could not be reached without the framework. That braiding had already cost this repo a silent hole once (the fiber adaptor restoring headers the strip had deleted); it was still costing two more, quietly. widget_security.go and the CORS half of routes.go had NO zip transport at all. Not a weaker one — none. So on the HIP-0106 mount, the boundary the unified cloud binary runs, an hz_ widget key — a public credential anyone can read out of a page's source — arrived with no origin allowlist and no rate limit against the model budget, and a browser preflight got whatever the router does with an unrouted OPTIONS. Both gates existed, both were tested, and neither ran, because each was written inside a gin closure the zip edge could not call. That is the same defect class as the deleted gin bridge, one level up: the decision was in a place instead of being a value. Now each gate is a value and each edge is a few lines over it — widgetGate.admit in widget_security.go and corsPolicy.admit in cors.go, alongside the authGate.admit that already was one. Mount installs all three natively, in the order legacy_engine.go runs them. Every gin transport moved to legacy_transports.go behind the tag: one file, one reason to exist, deleted whole with legacy_engine.go at Phase C. What that buys, measured on the binary rather than on a test: go list -deps ./cmd/gateway | grep gin-gonic 6 packages -> 0 package count 2161 -> 2112 And what it does NOT buy, stated because the opposite is the easy thing to claim: gin CANNOT leave go.mod, and gin is still in the SHIPPED binary. The Makefile sets BUILD_TAGS ?= legacy, the Dockerfile runs `make build`, and all five live workloads run `run -c gateway.json` — so ghcr.io/hanzoai/gateway is the legacy build, and its gin comes from the vendored KrakenD/Lura tree (internal/lura, internal/plugin), which is not ours to rewrite. `go list -deps -tags legacy` still reports gin, unchanged at 2680 packages. First-party gin is gone; the vendored legacy tree keeps it until Phase C. The shipped edge is proven unmoved, not asserted to be: both binaries booted against configs/hanzo/gateway.json and probed on fourteen requests covering every gate. Route table 19 endpoints before and 19 after, and every response — status line, CORS headers, security posture, BODY — diffs clean against origin/main. Tests: transport_parity_test.go drives one request through BOTH edges over 25 cases (auth classes, widget origin+rate limit, CORS preflight) and fails on disagreement rather than on either edge alone; it was mutation-checked, and a broken zip transport reports TRANSPORTS DISAGREE on 6 of them. TestMount_GatesAreOnTheChainMountInstalls drives the app gateway.Mount BUILT, because a gate that passes its own test while the mount never installs it is exactly what was wrong here. The gin-driven suites are `legacy`-tagged — that build is the only one where a gin transport exists, and it is the one that ships, so nothing stopped running: 215 tests pass in the legacy pass and the untagged pass is green too, both with -race. Shared JWKS fixtures moved to an untagged fixtures_test.go so the two halves cannot grow two signing identities. The routing table is the one gate with no zip twin, and routes.go says why rather than deferring: its compiled proxies are net/http, so reaching them from fasthttp would drop WebSocket upgrade and streaming, and the api-host passthrough rests on cloud being another process — false inside the unified binary, where the same rule would proxy it to itself. Co-authored-by: Hanzo Dev <[email protected]>
129 lines
4.7 KiB
Go
129 lines
4.7 KiB
Go
// Copyright © 2026 Hanzo AI. MIT License.
|
|
|
|
//go:build legacy
|
|
// +build legacy
|
|
|
|
// gin-driven: this suite drives the LEGACY Lura edge's transports
|
|
// (legacy_transports.go), which exist only under this tag. The policies it
|
|
// exercises are framework-free values shared with the zip edge, and
|
|
// transport_parity_test.go asserts the two edges answer alike.
|
|
|
|
package gateway
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// TestCORSPreflight_BrandAllowlist verifies that corsPreflightMiddleware
|
|
// answers OPTIONS preflight for any Hanzo/Lux/Zoo/Pars property (and its
|
|
// subdomains) using the SAME baked-in allowlist as the widget security
|
|
// middleware (defaultAllowedOrigins), without any per-origin env config.
|
|
//
|
|
// This is the fix for browser SPAs (e.g. https://cowork.hanzo.ai) whose
|
|
// AI calls to /v1/chat/completions previously failed "Failed to fetch"
|
|
// because the preflight returned no Access-Control-Allow-Origin.
|
|
func TestCORSPreflight_BrandAllowlist(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cases := []struct {
|
|
name string
|
|
origin string
|
|
allowed bool
|
|
}{
|
|
// Subdomain of a baked-in brand domain — the cowork case.
|
|
{"cowork.hanzo.ai subdomain", "https://cowork.hanzo.ai", true},
|
|
// Apex brand domain.
|
|
{"hanzo.app apex", "https://hanzo.app", true},
|
|
// Other brand properties via suffix match.
|
|
{"hanzo.chat", "https://hanzo.chat", true},
|
|
{"lux.network subdomain", "https://app.lux.network", true},
|
|
{"zoo.ngo apex", "https://zoo.ngo", true},
|
|
// Karma (external customer) — apex + any subdomain both resolve from the
|
|
// single "karma.style" allowlist entry (suffix match, like the cowork case).
|
|
{"karma.style apex", "https://karma.style", true},
|
|
{"tryon.karma.style subdomain", "https://tryon.karma.style", true},
|
|
// Localhost default (dev).
|
|
{"localhost:3000 default", "http://localhost:3000", true},
|
|
// Not on any allowlist.
|
|
{"evil.com denied", "https://evil.com", false},
|
|
{"look-alike suffix denied", "https://nothanzo.ai", false},
|
|
{"empty origin ignored", "", false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
r.Use(corsPreflightMiddleware())
|
|
// Downstream handler returns 200 if the preflight was NOT
|
|
// short-circuited by the middleware (i.e. origin not allowed).
|
|
r.OPTIONS("/v1/chat/completions", func(c *gin.Context) {
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil)
|
|
req.Host = "api.hanzo.ai"
|
|
if tc.origin != "" {
|
|
req.Header.Set("Origin", tc.origin)
|
|
}
|
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
acao := w.Header().Get("Access-Control-Allow-Origin")
|
|
if tc.allowed {
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("allowed origin %q: expected 204, got %d", tc.origin, w.Code)
|
|
}
|
|
if acao != tc.origin {
|
|
t.Fatalf("allowed origin %q: expected ACAO=%q, got %q", tc.origin, tc.origin, acao)
|
|
}
|
|
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
|
t.Fatalf("allowed origin %q: expected ACA-Credentials=true, got %q", tc.origin, got)
|
|
}
|
|
if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
|
t.Fatalf("allowed origin %q: expected ACA-Methods set", tc.origin)
|
|
}
|
|
} else {
|
|
// Denied: preflight falls through to the handler (200) and
|
|
// MUST NOT carry an Access-Control-Allow-Origin header.
|
|
if acao != "" {
|
|
t.Fatalf("denied origin %q: expected no ACAO, got %q", tc.origin, acao)
|
|
}
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("denied origin %q: expected fall-through 200, got %d", tc.origin, w.Code)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCORSPreflight_EnvOriginStillHonored verifies that an explicit
|
|
// fully-qualified origin supplied via GATEWAY_CORS_ORIGINS is still allowed
|
|
// alongside the brand allowlist (additive, no regression).
|
|
func TestCORSPreflight_EnvOriginStillHonored(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("GATEWAY_CORS_ORIGINS", "https://partner.example.com")
|
|
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
r.Use(corsPreflightMiddleware())
|
|
r.OPTIONS("/v1/chat/completions", func(c *gin.Context) { c.Status(http.StatusOK) })
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil)
|
|
req.Header.Set("Origin", "https://partner.example.com")
|
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("env origin: expected 204, got %d", w.Code)
|
|
}
|
|
if acao := w.Header().Get("Access-Control-Allow-Origin"); acao != "https://partner.example.com" {
|
|
t.Fatalf("env origin: expected ACAO echo, got %q", acao)
|
|
}
|
|
}
|