feat(03-01): add ServeMux groups, JWT verifier, and serve command

Named middleware resolves at boot, HS256 tokens are pinned with required
exp/sub, and both binaries expose a signal-aware serve command.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-17 20:04:13 +02:00
parent d0d845052b
commit 4ee4c4a2fc
18 changed files with 976 additions and 12 deletions

326
surf/router.go Normal file
View File

@@ -0,0 +1,326 @@
package surf
import (
"fmt"
"net/http"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
"git.golem15.com/golem15/summercms/towel"
)
// Use names a middleware list for Group, matching PHP ->middleware().
func Use(names ...string) []string {
return names
}
type namedMiddleware struct {
pluginID string
fn pact.Middleware
}
type route struct {
pluginID string
method string
path string
handler http.Handler
middleware []string
}
// Router compiles group declarations onto net/http ServeMux.
type Router struct {
pluginID string
prefix string
middleware []string
named map[string]namedMiddleware
routes []route
seen map[string]string
origins []string
compileErr error
}
var (
_ pact.Router = (*Router)(nil)
_ pact.Router = (*Group)(nil)
)
// Group is a prefixed route collection.
type Group struct {
router *Router
pluginID string
prefix string
middleware []string
}
// New returns an empty router.
func New(origins []string) *Router {
return &Router{
named: make(map[string]namedMiddleware),
seen: make(map[string]string),
origins: origins,
}
}
// RegisterMiddleware stores a named wrapper. Duplicate names fail.
func (r *Router) RegisterMiddleware(pluginID, name string, fn pact.Middleware) error {
if r == nil {
return fmt.Errorf("surf: router is nil")
}
if name == "" || fn == nil {
return fmt.Errorf("surf: plugin %q registered empty middleware", pluginID)
}
if existing, ok := r.named[name]; ok {
return fmt.Errorf("surf: middleware %q already registered by %s", name, existing.pluginID)
}
r.named[name] = namedMiddleware{pluginID: pluginID, fn: fn}
return nil
}
// BindPlugin records the plugin declaring subsequent routes.
func (r *Router) BindPlugin(id string) {
if r != nil {
r.pluginID = id
}
}
func (r *Router) Group(prefix string, middleware []string, fn func(pact.Router)) {
if r == nil || fn == nil {
return
}
g := &Group{
router: r,
pluginID: r.pluginID,
prefix: joinPath(r.prefix, prefix),
middleware: append([]string{}, r.middleware...),
}
g.middleware = append(g.middleware, middleware...)
fn(g)
}
func (r *Router) Get(path string, handler http.HandlerFunc, middleware ...string) {
if r == nil {
return
}
r.add(r.pluginID, r.prefix, r.middleware, path, handler, middleware)
}
func (g *Group) Group(prefix string, middleware []string, fn func(pact.Router)) {
if g == nil || g.router == nil || fn == nil {
return
}
next := &Group{
router: g.router,
pluginID: g.pluginID,
prefix: joinPath(g.prefix, prefix),
middleware: append([]string{}, g.middleware...),
}
next.middleware = append(next.middleware, middleware...)
fn(next)
}
func (g *Group) Get(path string, handler http.HandlerFunc, middleware ...string) {
if g == nil || g.router == nil {
return
}
g.router.add(g.pluginID, g.prefix, g.middleware, path, handler, middleware)
}
func (r *Router) add(pluginID, prefix string, groupMW []string, path string, handler http.HandlerFunc, extra []string) {
full := joinPath(prefix, path)
key := "GET " + full
if prev, ok := r.seen[key]; ok {
r.compileErr = fmt.Errorf("surf: duplicate route %s registered by %s and %s", key, prev, pluginID)
return
}
r.seen[key] = pluginID
mw := append([]string{}, groupMW...)
mw = append(mw, extra...)
r.routes = append(r.routes, route{
pluginID: pluginID,
method: "GET",
path: full,
handler: handler,
middleware: mw,
})
}
func (r *Router) compile() (http.Handler, error) {
if r.compileErr != nil {
return nil, r.compileErr
}
mux := http.NewServeMux()
for _, rt := range r.routes {
h, err := r.wrap(rt)
if err != nil {
return nil, err
}
mux.Handle("GET "+rt.path, h)
}
return recoverJSON(cors(r.origins, mux)), nil
}
func (r *Router) wrap(rt route) (http.Handler, error) {
h := rt.handler
h = noOpLimit(h)
h = orgSlot(h)
for i := len(rt.middleware) - 1; i >= 0; i-- {
name := rt.middleware[i]
named, ok := r.named[name]
if !ok {
return nil, fmt.Errorf("surf: plugin %q references unknown middleware %q", rt.pluginID, name)
}
h = named.fn(h)
}
h = locale(h)
return h, nil
}
// Assemble registers plugin middleware and routes, then compiles ServeMux.
func Assemble(app *backpack.App, plugins []party.Plugin) (http.Handler, error) {
r := New(corsOrigins(app))
for _, p := range plugins {
if hm, ok := p.(pact.HasMiddleware); ok {
for name, fn := range hm.Middlewares() {
if err := r.RegisterMiddleware(p.ID(), name, fn); err != nil {
return nil, err
}
}
}
}
for _, p := range plugins {
r.BindPlugin(p.ID())
if hr, ok := p.(pact.HasRoutes); ok {
if err := hr.Routes(r); err != nil {
return nil, fmt.Errorf("surf: routes %s: %w", p.ID(), err)
}
}
}
for _, rt := range r.routes {
if _, err := r.wrap(rt); err != nil {
return nil, err
}
}
return r.compile()
}
func corsOrigins(app *backpack.App) []string {
if app == nil || app.Config == nil {
return nil
}
raw, ok := app.Config.Lookup("http.cors.allowed_origins")
if !ok {
return nil
}
switch v := raw.(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
s, _ := item.(string)
if s != "" {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func recoverJSON(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":true,"message":"Internal server error"}`))
}
}()
next.ServeHTTP(w, r)
})
}
func cors(origins []string, next http.Handler) http.Handler {
allowed := make(map[string]struct{}, len(origins))
for _, o := range origins {
allowed[o] = struct{}{}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if _, ok := allowed[origin]; ok && origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func locale(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r.WithContext(towel.WithLocale(r.Context(), r.Header.Get("Accept-Language"))))
})
}
func orgSlot(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r.WithContext(towel.WithOrganization(r.Context(), "")))
})
}
// Limiter wraps handlers. Phase 6 replaces the no-op with named buckets.
type Limiter interface {
Wrap(http.Handler) http.Handler
}
type noopLimiter struct{}
func (noopLimiter) Wrap(next http.Handler) http.Handler { return next }
func noOpLimit(next http.Handler) http.Handler {
return noopLimiter{}.Wrap(next)
}
// IntParam returns a positive integer path value. Missing or malformed ids
// are false so callers can 404 both unknown and non-integer values.
func IntParam(r *http.Request, name string) (int64, bool) {
if r == nil {
return 0, false
}
raw := r.PathValue(name)
if raw == "" {
return 0, false
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 1 {
return 0, false
}
return n, true
}
func joinPath(prefix, path string) string {
prefix = strings.TrimSuffix(prefix, "/")
path = strings.TrimSpace(path)
if path == "" || path == "/" {
if prefix == "" {
return "/"
}
return prefix
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if prefix == "" {
return path
}
return prefix + path
}

143
surf/router_test.go Normal file
View File

@@ -0,0 +1,143 @@
package surf
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/pact"
)
type routePlugin struct {
id string
mw map[string]pact.Middleware
path string
use []string
}
func (p routePlugin) ID() string { return p.id }
func (p routePlugin) Requires() []string { return nil }
func (p routePlugin) Register(any) error { return nil }
func (p routePlugin) Boot(any) error { return nil }
func (p routePlugin) Middlewares() map[string]pact.Middleware { return p.mw }
func (p routePlugin) Routes(r pact.Router) error {
r.Group("/api", Use(p.use...), func(g pact.Router) {
g.Get(p.path, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Hit", "1")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
})
})
return nil
}
func TestMissingMiddlewareNamesPluginAndName(t *testing.T) {
r := New(nil)
p := routePlugin{id: "golem15.demo", path: "/items", use: []string{"jwt.auth"}}
r.BindPlugin(p.ID())
if err := p.Routes(r); err != nil {
t.Fatal(err)
}
_, err := r.compile()
if err == nil || !strings.Contains(err.Error(), "golem15.demo") || !strings.Contains(err.Error(), "jwt.auth") {
t.Fatalf("want plugin and middleware in error, got %v", err)
}
}
func TestRecoverReturnsOpaqueJSON500(t *testing.T) {
r := New(nil)
r.Get("/panic", func(http.ResponseWriter, *http.Request) {
panic("secret internals")
})
h, err := r.compile()
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/panic", nil))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d", rec.Code)
}
body := rec.Body.String()
if strings.Contains(body, "secret") {
t.Fatalf("leaked panic: %s", body)
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["error"] != true || payload["message"] != "Internal server error" {
t.Fatalf("payload = %v", payload)
}
}
func TestCORSPreflightBypassesNamedAuth(t *testing.T) {
called := false
r := New([]string{"http://localhost:3000"})
if err := r.RegisterMiddleware("golem15.user", "jwt.auth", func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
called = true
next.ServeHTTP(w, req)
})
}); err != nil {
t.Fatal(err)
}
r.BindPlugin("golem15.demo")
r.Group("/api", Use("jwt.auth"), func(g pact.Router) {
g.Get("/items", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
})
h, err := r.compile()
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodOptions, "/api/items", nil)
req.Header.Set("Origin", "http://localhost:3000")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d", rec.Code)
}
if called {
t.Fatal("jwt.auth ran on preflight")
}
if rec.Header().Get("Access-Control-Allow-Origin") != "http://localhost:3000" {
t.Fatalf("ACA origin = %q", rec.Header().Get("Access-Control-Allow-Origin"))
}
}
func TestIntParamMalformedIsFalse(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/items/abc", nil)
req.SetPathValue("id", "abc")
if _, ok := IntParam(req, "id"); ok {
t.Fatal("malformed id must be false")
}
req.SetPathValue("id", "12")
n, ok := IntParam(req, "id")
if !ok || n != 12 {
t.Fatalf("got %d %t", n, ok)
}
}
func TestTypedIDRouteReturns404(t *testing.T) {
r := New(nil)
r.Get("/items/{id}", func(w http.ResponseWriter, req *http.Request) {
if _, ok := IntParam(req, "id"); !ok {
http.NotFound(w, req)
return
}
w.WriteHeader(http.StatusOK)
})
h, err := r.compile()
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/items/nope", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d", rec.Code)
}
}

75
surf/serve.go Normal file
View File

@@ -0,0 +1,75 @@
package surf
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bonfire"
"git.golem15.com/golem15/summercms/lagoon"
"git.golem15.com/golem15/summercms/party"
)
// ServeCommand starts a signal-aware HTTP server on the assembled router.
func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
return bonfire.Command{
Name: "serve",
Description: "Serve the application HTTP API",
Flags: []bonfire.Flag{{
Name: "addr",
Description: "Listen address",
Default: ":8080",
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
addr, _ := in.Flag("addr")
if strings.TrimSpace(addr) == "" {
addr = ":8080"
}
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
if err != nil {
return err
}
defer sqlDB.Close()
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
return err
}
h, err := Assemble(app, plugins)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: addr, Handler: h, BaseContext: func(net.Listener) context.Context { return ctx }}
errCh := make(chan error, 1)
go func() {
out.Info(fmt.Sprintf("listening on %s", addr))
errCh <- srv.ListenAndServe()
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
err := <-errCh
if err == nil || err == http.ErrServerClosed {
return nil
}
return err
case err := <-errCh:
if err == nil || err == http.ErrServerClosed {
return nil
}
return err
}
},
}
}