initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

524
internal/app/app.go Normal file
View file

@ -0,0 +1,524 @@
// Package app wires the application together: configuration, store, auth,
// HTTP, and the lifecycle that starts and stops them.
//
// Keeping the wiring in one place means main() is a dozen lines, the whole
// object graph can be constructed in a test, and there is exactly one place to
// look to answer "what actually runs in production".
package app
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/urpi/go-bananas/internal/auth"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/httpapi"
"github.com/urpi/go-bananas/internal/logging"
"github.com/urpi/go-bananas/internal/seed"
"github.com/urpi/go-bananas/internal/store"
)
// Build information, stamped at link time with -ldflags -X.
//
// Every value has a usable default so that `go run ./cmd/go-bananas` and a plain
// `go build` both work without ceremony, and so that a binary built by hand still
// reports something meaningful rather than an empty string in a log line.
var (
// Version is the release version, e.g. 1.4.2.
Version = "dev"
// Commit is the source revision.
Commit = "unknown"
// BuildDate is the RFC 3339 build timestamp.
BuildDate = "unknown"
)
// App is the assembled application.
type App struct {
Config config.Config
Log logging.Logger
Store *store.Store
Cache *httpapi.ThemeCache
Auth *auth.Authenticator
Router http.Handler
Server *http.Server
metrics *httpapi.Metrics
// listener is bound before Serve so that the ready log line can report the
// real address, which matters when the configured port is 0.
listener net.Listener
}
// Options are the inputs needed to build an App.
type Options struct {
// Config is the resolved configuration.
Config config.Config
// Version is the build version.
Version string
// Logger overrides the constructed logger, for tests.
Logger logging.Logger
}
// New builds the application without starting it.
//
// Construction validates the whole object graph and reconciles the built-in
// themes, so a misconfigured deployment or a broken migration fails here rather
// than on the first request.
func New(ctx context.Context, opts Options) (*App, error) {
cfg := opts.Config
if cfg.StaticDir == "" && cfg.SDKDir == "" {
return nil, errors.New("neither STATIC_DIR nor SDK_DIR is set")
}
log := opts.Logger
if cfg.EphemeralSessionSecret() {
// Worth a warning: an operator debugging a session that "keeps logging
// me out" in local development needs to know why.
if log == nil {
log = logging.New(logging.Options{
Level: cfg.LogLevel, Format: cfg.LogFormat,
Service: "Go Bananas!!", Environment: cfg.Environment, Version: opts.Version,
})
}
log.Warn("SESSION_SECRET was not set; an ephemeral one was generated. " +
"Sessions will not survive a restart. Set SESSION_SECRET to at least 32 bytes.")
}
if log == nil {
log = logging.New(logging.Options{
Level: cfg.LogLevel,
Format: cfg.LogFormat,
Service: "Go Bananas!!",
Environment: cfg.Environment,
Version: opts.Version,
})
}
st, err := store.Open(ctx, store.Options{
Path: cfg.DatabasePath,
BusyTimeout: cfg.DatabaseBusyTimeout,
MaxConns: cfg.DatabaseMaxConns,
})
if err != nil {
return nil, fmt.Errorf("open store: %w", err)
}
cache := httpapi.NewThemeCache()
if cfg.SeedOnStart {
inserted, refreshed, err := st.Seed(ctx, seed.Themes())
if err != nil {
st.Close()
return nil, fmt.Errorf("seed built-in themes: %w", err)
}
if inserted > 0 || refreshed > 0 {
log.Info("built-in themes reconciled",
"inserted", inserted, "refreshed", refreshed, "total", len(seed.Slugs()))
}
}
authenticator, err := buildAuthenticator(ctx, cfg, st, log)
if err != nil {
st.Close()
return nil, err
}
// Warming the render cache at start-up means the first request after a deploy
// is already fast. Failure is not fatal: the cache is an optimisation.
warmed := 0
if themes, err := st.ListRuntime(ctx); err == nil {
for _, theme := range themes {
warmed += cache.Prefetch(theme, nil)
}
}
if warmed > 0 {
log.Debug("render cache warmed", "artefacts", warmed)
}
metrics := httpapi.NewMetrics().WithCache(cache)
metrics.RegisterCounter("bananas_theme_publishes_total", "Themes moved to the published status.")
metrics.RegisterCounter("bananas_theme_updates_total", "Theme documents written.")
metrics.RegisterCounter("bananas_auth_failures_total", "Requests rejected for want of a usable credential.")
app := &App{
Config: cfg,
Log: log,
Store: st,
Cache: cache,
Auth: authenticator,
metrics: metrics,
}
app.Router = httpapi.NewRouter(httpapi.Deps{
Config: cfg,
Store: st,
Cache: cache,
Authenticator: authenticator,
Log: log,
Version: opts.Version,
Metrics: metrics,
StaticHandler: staticHandler(cfg.StaticDir),
SDKHandler: sdkHandler(cfg.SDKDir),
// Resolved per request rather than captured at start-up, so that changing the
// default theme in the admin UI takes effect without a restart.
//
// A missing setting yields the supplied default, which is the normal case.
// A database error is different: it would otherwise look like "no default
// configured" and every runtime request would fall through to asking the
// consumer for a theme. It is logged rather than propagated because this runs
// inside a request handler whose job is to serve a theme, and failing the
// whole request over a settings lookup would be a worse outcome than serving
// the fallback.
RuntimeDefaults: func() (string, domain.Appearance) {
slug, err := st.Setting(ctx, domain.SettingDefaultTheme, "")
if err != nil {
log.Warn("could not read the default theme setting", "error", err)
}
appearance, err := st.Setting(ctx, domain.SettingDefaultAppearance, string(domain.AppearanceLight))
if err != nil {
log.Warn("could not read the default appearance setting", "error", err)
}
return slug, domain.Appearance(appearance)
},
})
app.Server = &http.Server{
Addr: cfg.HTTPAddr,
Handler: app.Router,
ReadTimeout: cfg.ReadTimeout,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
MaxHeaderBytes: 1 << 20,
ErrorLog: nil,
BaseContext: func(_ net.Listener) context.Context { return context.WithoutCancel(ctx) },
}
return app, nil
}
// buildAuthenticator constructs the auth stack for the configured mode.
func buildAuthenticator(ctx context.Context, cfg config.Config, st *store.Store, log logging.Logger) (*auth.Authenticator, error) {
switch cfg.AuthMode {
case config.AuthModeOIDC:
state := auth.NewStateStore(
cfg.SessionSecret,
"go-bananas-oidc-state",
cfg.SessionSecure,
sameSiteFrom(cfg.SessionSameSite),
10*time.Minute,
)
provider, err := auth.NewOIDCProvider(ctx, cfg, state)
if err != nil {
return nil, fmt.Errorf("identity provider: %w", err)
}
log.Info("identity provider configured",
"issuer", cfg.OIDCIssuer,
"clientId", cfg.OIDCClientID,
"redirectUrl", cfg.RedirectURL(),
"adminGroups", cfg.OIDCAdminGroups,
"editorGroups", cfg.OIDCEditorGroups,
)
return auth.New(cfg, provider, st)
case config.AuthModeDev:
log.Warn("AUTH MODE IS DEV: every request is authenticated as a local administrator. " +
"This mode is refused outside a development environment.")
return auth.New(cfg, nil, st)
case config.AuthModeNone:
log.Warn("AUTH MODE IS NONE: every request is granted administrator access. " +
"This mode is refused outside a development environment.")
return auth.New(cfg, nil, st)
default:
return nil, fmt.Errorf("unknown AUTH_MODE %q", cfg.AuthMode)
}
}
// sameSiteFrom maps a configuration value onto an http.SameSite.
func sameSiteFrom(raw string) (result sameSite) {
switch strings.ToLower(raw) {
case "strict":
return sameSiteStrict
case "none":
return sameSiteNone
default:
return sameSiteLax
}
}
// Run starts the server and blocks until ctx is cancelled.
//
// The shutdown sequence is the part worth reading: on cancellation the listener
// is closed first so no new connection is accepted, then in-flight requests get
// ShutdownTimeout to finish, and only then is the store closed. Closing the store
// while a request is still reading would surface as a confusing 500 to a user
// who did nothing wrong.
func (a *App) Run(ctx context.Context) error {
listenCfg := net.ListenConfig{}
listener, err := listenCfg.Listen(ctx, "tcp", a.Config.HTTPAddr)
if err != nil {
return fmt.Errorf("listen on %s: %w", a.Config.HTTPAddr, err)
}
a.listener = listener
a.Log.Info("starting Go Bananas!!",
"address", listener.Addr().String(),
"publicUrl", a.Config.Origin(),
"authMode", a.Config.AuthMode,
"database", a.Config.DatabasePath,
"environment", a.Config.Environment,
"version", Version,
"commit", Commit,
"built", BuildDate,
)
serverErrors := make(chan error, 1)
go func() {
err := a.Server.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
serverErrors <- err
}()
select {
case err := <-serverErrors:
if err != nil {
return fmt.Errorf("serve: %w", err)
}
return nil
case <-ctx.Done():
a.Log.Info("shutdown signal received", "graceful", true)
}
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), a.Config.ShutdownTimeout)
defer cancel()
if err := a.Server.Shutdown(shutdownCtx); err != nil {
// A timeout here means a request outlived the grace period. Closing hard
// is better than hanging, and the error is logged so it is visible.
a.Log.Error("graceful shutdown timed out; closing connections", "error", err)
if closeErr := a.Server.Close(); closeErr != nil {
a.Log.Error("force close failed", "error", closeErr)
}
}
if err := <-serverErrors; err != nil {
return fmt.Errorf("serve: %w", err)
}
a.Log.Info("stopped cleanly")
return nil
}
// Addr returns the bound address, useful when the configured port is 0.
func (a *App) Addr() string {
if a.listener == nil {
return a.Config.HTTPAddr
}
return a.listener.Addr().String()
}
// Close releases the application's resources.
func (a *App) Close() error {
if a.Store == nil {
return nil
}
return a.Store.Close()
}
// Run is the top-level entrypoint: load configuration, build, run, shut down.
func Run(ctx context.Context) error {
cfg, err := config.Load()
if err != nil {
// Configuration errors are reported plainly rather than through the logger,
// because the logger's own configuration may be part of what is wrong.
return fmt.Errorf("%w", err)
}
application, err := New(ctx, Options{Config: cfg, Version: Version})
if err != nil {
return err
}
defer application.Close()
return application.Run(ctx)
}
// staticHandler serves the built admin UI, or nil when no build is present.
//
// A missing UI is not fatal: the API is independently useful, and a deployment
// that only serves the runtime endpoints has no reason to carry the SPA.
func staticHandler(dir string) http.Handler {
if dir == "" {
return nil
}
index := filepath.Join(dir, "index.html")
if _, err := os.Stat(index); err != nil {
return nil
}
fileServer := spaHandler{root: dir, files: http.FileServer(http.Dir(dir))}
return fileServer
}
// spaHandler serves the admin single-page application.
//
// Any GET that does not resolve to a real file falls through to index.html so
// that client-side routes survive a page reload. An unknown /api or /rt path must
// still 404 as JSON rather than returning HTML, which is handled by the router
// before this handler is reached.
type spaHandler struct {
root string
files http.Handler
}
// withinRoot reports whether path is root or lives beneath it.
//
// It compares path components rather than string prefixes, because a prefix test
// cannot tell "/srv/web" from "/srv/web-secrets": the first contains the second as
// a leading substring, and a request for the second would pass a HasPrefix check
// against the first.
func withinRoot(root, path string) bool {
absRoot, err := filepath.Abs(root)
if err != nil {
return false
}
// The root is resolved when it exists. When it does not, Abs still gives an
// absolute path to compare against, which is the best available answer.
if resolvedRoot, resolveErr := filepath.EvalSymlinks(absRoot); resolveErr == nil {
absRoot = resolvedRoot
}
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
rel, err := filepath.Rel(absRoot, absPath)
if err != nil {
return false
}
// ".." is the only relative path that leaves the root. "." is the root itself,
// which is allowed; a path equal to the root is not a file but reaching it is
// not an escape.
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// ServeHTTP implements http.Handler.
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Anchoring the path with a leading slash and cleaning it removes every ".."
// segment, so Join below cannot escape the root. The check after it is a
// belt-and-braces assertion that the result really is inside, rather than a
// prefix test: a plain HasPrefix against the root would accept a sibling
// directory whose name merely starts with the root's.
cleaned := filepath.Clean("/" + strings.TrimPrefix(r.URL.Path, "/"))
candidate := filepath.Join(h.root, cleaned)
if !withinRoot(h.root, candidate) {
http.Error(w, "not found", http.StatusNotFound)
return
}
// The static file server resolves symlinks, so a symlink inside the web
// directory pointing at /etc would otherwise be served. Resolving the real
// path and re-checking it is what actually closes that: the root itself is
// resolved too, so a root reached through a symlink does not trip the check.
resolved, err := filepath.EvalSymlinks(candidate)
if err != nil {
// A path that does not exist is not an error here: the SPA fallback below
// serves the shell for it. Only a real failure is reported.
resolved = candidate
}
if !withinRoot(h.root, resolved) {
http.Error(w, "not found", http.StatusNotFound)
return
}
//nolint:gosec // G703: as on the ServeFile call below; the taint source is
// r.URL.Path and both paths it reaches are checked by withinRoot.
if info, err := os.Stat(resolved); err == nil && !info.IsDir() {
setStaticCacheHeaders(w, resolved)
// ServeFile rather than the file server, because the request URL is the
// uncleaned path and the file to serve is the resolved one. The directory
// case cannot reach here, which is the only case in which ServeFile
// redirects.
//
//nolint:gosec // G703: gosec's taint analysis cannot see withinRoot. The
// containment predicate compares path components rather than prefixes, so a
// sibling directory named "web-secrets" beside "web" is rejected, and
// resolved above is the symlink-resolved path that was re-checked against it.
// TestSPARejectsTraversal, TestSPARejectsSymlinkEscape and TestWithinRoot are
// what hold this in place.
http.ServeFile(w, r, resolved)
return
}
// Serve the shell for a client-side route. The request path is rewritten to
// the directory root rather than to index.html: http.FileServer answers a
// request for /index.html with a 301 to /, which would turn every deep link
// into a redirect loop against the SPA router.
w.Header().Set("Cache-Control", "no-cache")
h.files.ServeHTTP(w, indexRequest(r))
}
// indexRequest rewrites a request onto the SPA entry point.
func indexRequest(r *http.Request) *http.Request {
clone := r.Clone(r.Context())
clone.URL.Path = "/"
return clone
}
// staticAssetSuffixes are the file extensions that may be cached indefinitely,
// because Vite emits content-hashed names for them.
var staticAssetSuffixes = []string{".js", ".css", ".woff", ".woff2", ".svg", ".png", ".jpg", ".webp", ".avif"}
// setStaticCacheHeaders chooses a cache policy for a static asset.
func setStaticCacheHeaders(w http.ResponseWriter, path string) {
for _, suffix := range staticAssetSuffixes {
if strings.HasSuffix(path, suffix) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
}
w.Header().Set("Cache-Control", "public, max-age=300")
}
// sameSite aliases keep the http.SameSite constants out of this file's imports,
// which keeps the mapping in one readable block.
type sameSite = http.SameSite
// Cookie SameSite modes, re-exported for the configuration mapping above.
const (
sameSiteLax = http.SameSiteLaxMode
sameSiteStrict = http.SameSiteStrictMode
sameSiteNone = http.SameSiteNoneMode
)
// sdkHandler serves the built runtime SDK bundles, or nil when no build is
// present.
//
// The SDK is served from the same origin as the runtime API so that a consuming
// application can load it with a plain URL and no build step, which is the whole
// point of delivering themes at runtime rather than through a package registry.
func sdkHandler(dir string) http.Handler {
if dir == "" {
return nil
}
if _, err := os.Stat(filepath.Join(dir, "runtime.js")); err != nil {
return nil
}
return http.StripPrefix("/rt/v1/", http.FileServer(http.Dir(dir)))
}

105
internal/app/backup.go Normal file
View file

@ -0,0 +1,105 @@
package app
import (
"context"
"flag"
"fmt"
"os"
"path/filepath"
"time"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/store"
)
// RunBackup is the `go-bananas backup` subcommand: it writes a consistent copy of
// the SQLite database and exits.
//
// It exists as a subcommand rather than as a separate tool, or as a shell script
// copying the file, for two reasons.
//
// The first is correctness. `cp` on a live database produces a torn copy, and the
// result can open without complaint while being corrupt. SQLite's VACUUM INTO
// reads through the database's own backup machinery and produces a consistent
// snapshot while the service keeps serving.
//
// The second is packaging. The alternative is a container that ships a `sqlite3`
// binary, which means either a third-party image or installing a package into a
// container whose root filesystem is deliberately read-only. The SQLite library is
// already linked into this binary, at exactly the version the schema was written
// for, so using it removes both problems.
//
// The Helm chart's snapshot CronJob runs this command. A deployment that does not
// use the chart can run it from anywhere the binary is.
func RunBackup(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("backup", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var (
destination = fs.String("to", "", "Path to write the snapshot to. Required.")
database = fs.String("database", "", "Path to the SQLite database. Defaults to DATABASE_PATH.")
timeout = fs.Duration("timeout", 5*time.Minute, "Give up after this long.")
)
fs.Usage = func() {
fmt.Fprintf(os.Stderr,
"usage: go-bananas backup --to <path> [--database <path>]\n\n"+
"Writes a consistent snapshot of the database using SQLite's online backup.\n"+
"It is safe to run against a database that is being written to.\n\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return fmt.Errorf("%w: %w", domain.ErrInvalid, err)
}
if *destination == "" {
fs.Usage()
return fmt.Errorf("%w: --to is required", domain.ErrInvalid)
}
if fs.NArg() > 0 {
return fmt.Errorf("%w: unexpected argument %q", domain.ErrInvalid, fs.Arg(0))
}
// The database path comes from the same environment variable the server reads,
// so a CronJob and the service cannot disagree about which file is being
// snapshotted. DATABASE_PATH alone is enough: the rest of the configuration is
// irrelevant to a backup, and requiring it would mean a snapshot job failing
// because an unrelated setting is absent.
path := *database
if path == "" {
path = os.Getenv("DATABASE_PATH")
}
if path == "" {
path = config.Defaults().DatabasePath
}
if !store.IsPersistentPath(path) {
return fmt.Errorf("%w: cannot snapshot an ephemeral database (%s); it holds no data once the process exits",
domain.ErrInvalid, path)
}
// Bounded, because a snapshot that hangs holds the CronJob's slot forever and a
// snapshot of a very large database is genuinely slow.
ctx, cancel := context.WithTimeout(ctx, *timeout)
defer cancel()
st, err := store.Open(ctx, store.Options{Path: path})
if err != nil {
return fmt.Errorf("open database for snapshot: %w", err)
}
defer func() { _ = st.Close() }()
target := *destination
if !filepath.IsAbs(target) {
// Made absolute so the log line says where the file actually is, rather than
// leaving the reader to resolve it against whatever the working directory was.
abs, absErr := filepath.Abs(target)
if absErr == nil {
target = abs
}
}
if err := st.Snapshot(ctx, target); err != nil {
return err
}
fmt.Fprintf(os.Stdout, "snapshot written to %s\n", target)
return nil
}

224
internal/app/spa_test.go Normal file
View file

@ -0,0 +1,224 @@
package app
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// These tests cover the static file handler, which is the one place in the service
// where a request path is turned into a filesystem path. Everything else operates on
// database rows or JSON, so the containment logic here is the only traversal
// defence in the codebase and it needs its own tests.
// newSPAFixture builds a web root with an index, a stylesheet and a secret file
// outside the root.
func newSPAFixture(t *testing.T) (root, outside string, handler http.Handler) {
t.Helper()
base := t.TempDir()
root = filepath.Join(base, "web")
outside = filepath.Join(base, "secret.txt")
if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
writeFile(t, filepath.Join(root, "index.html"), "<!doctype html><title>app</title>")
writeFile(t, filepath.Join(root, "assets", "app.css"), "body{color:red}")
// The name Vite actually emits: an extension from staticAssetSuffixes with a
// content hash in it.
writeFile(t, filepath.Join(root, "assets", "app-CyCU_ony.js"), "export const x=1")
writeFile(t, outside, "top secret")
handler = spaHandler{root: root, files: http.FileServer(http.Dir(root))}
return root, outside, handler
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil))
return rec
}
// TestSPAServesTheShellForClientRoutes covers the single-page-app fallback: an
// unknown path that is not a file is the router's problem, not a 404.
func TestSPAServesTheShellForClientRoutes(t *testing.T) {
_, _, handler := newSPAFixture(t)
for _, path := range []string{"/", "/themes/midnight", "/keys", "/a/deep/client/route"} {
t.Run(path, func(t *testing.T) {
rec := get(t, handler, path)
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "<title>app</title>") {
t.Errorf("did not serve the shell: %q", rec.Body.String())
}
})
}
}
// TestSPAServesRealFiles covers the assets themselves, including a nested path.
func TestSPAServesRealFiles(t *testing.T) {
_, _, handler := newSPAFixture(t)
rec := get(t, handler, "/assets/app.css")
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "color:red") {
t.Errorf("unexpected body %q", rec.Body.String())
}
}
// TestSPACacheHeaders pins the caching contract, which is what decides whether a
// deploy reaches users holding a cached copy.
//
// The rule is extension-based: an asset extension is assumed to be content-hashed by
// the frontend build, and is therefore immutable for a year. Everything else gets a
// short max-age, because a document that is replaced under the same name must not
// be cached indefinitely.
func TestSPACacheHeaders(t *testing.T) {
_, _, handler := newSPAFixture(t)
cases := []struct {
path string
immutable bool
}{
{"/assets/app-CyCU_ony.js", true},
{"/assets/app.css", true},
{"/index.html", false},
{"/themes/midnight", false}, // the SPA shell for a client route
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
cc := get(t, handler, tc.path).Header().Get("Cache-Control")
if immutable := strings.Contains(cc, "immutable"); immutable != tc.immutable {
t.Errorf("Cache-Control %q, immutable=%v, want %v", cc, immutable, tc.immutable)
}
})
}
}
// TestSPARejectsTraversal is the security test. Every one of these paths is an
// attempt to read a file outside the web root, and each must fail to return the
// file's contents.
//
// The interesting cases are the encoded and mixed ones: a naive handler that
// strips ".." once, or that compares paths with HasPrefix, passes at least one.
func TestSPARejectsTraversal(t *testing.T) {
root, _, handler := newSPAFixture(t)
// A sibling directory whose name starts with the root's name. A HasPrefix
// containment check accepts this; a component comparison does not.
sibling := root + "-secrets"
if err := os.MkdirAll(sibling, 0o755); err != nil {
t.Fatalf("mkdir sibling: %v", err)
}
writeFile(t, filepath.Join(sibling, "leak.txt"), "sibling secret")
cases := []string{
"/../secret.txt",
"/../../etc/passwd",
"/assets/../../secret.txt",
"/./../secret.txt",
"/%2e%2e/secret.txt",
"/..%2fsecret.txt",
"/assets/%2e%2e%2f%2e%2e%2fsecret.txt",
"/../web-secrets/leak.txt",
}
for _, path := range cases {
t.Run(path, func(t *testing.T) {
rec := get(t, handler, path)
body := rec.Body.String()
if strings.Contains(body, "top secret") || strings.Contains(body, "sibling secret") {
t.Fatalf("leaked a file outside the root: %q", body)
}
// Serving the shell instead of a 404 is acceptable; that is what the SPA
// fallback does for any path that is not a file.
if rec.Code == http.StatusOK && !strings.Contains(body, "<title>app</title>") {
t.Errorf("unexpected 200 body: %q", body)
}
})
}
}
// TestSPARejectsSymlinkEscape covers the case a string comparison cannot: the path
// is inside the root and contains no "..", but a symlink points outside it.
func TestSPARejectsSymlinkEscape(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, "web")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
writeFile(t, filepath.Join(root, "index.html"), "<!doctype html><title>app</title>")
target := filepath.Join(base, "secret.txt")
writeFile(t, target, "top secret")
// Skipped where symlinks are unavailable, which is a Windows privilege rather
// than a reason to fail the suite.
if err := os.Symlink(target, filepath.Join(root, "link.txt")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
handler := spaHandler{root: root, files: http.FileServer(http.Dir(root))}
rec := get(t, handler, "/link.txt")
if strings.Contains(rec.Body.String(), "top secret") {
t.Errorf("served a file through a symlink out of the root: %q", rec.Body.String())
}
}
// TestSPARejectsNonGETMethods keeps the handler read-only. It has no mutating route,
// so a POST should not be answered at all rather than quietly treated as a GET.
func TestSPARejectsNonGETMethods(t *testing.T) {
_, _, handler := newSPAFixture(t)
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
t.Run(method, func(t *testing.T) {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), method, "/", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("got %d, want 405", rec.Code)
}
})
}
}
// TestWithinRoot covers the containment predicate directly, including the sibling
// directory that separates it from a prefix comparison.
func TestWithinRoot(t *testing.T) {
root := filepath.Join(t.TempDir(), "web")
inside := []string{root, filepath.Join(root, "a.txt"), filepath.Join(root, "a", "b", "c.txt")}
for _, path := range inside {
if !withinRoot(root, path) {
t.Errorf("withinRoot(%q, %q) = false, want true", root, path)
}
}
outside := []string{
filepath.Join(root, "..", "secret.txt"),
root + "-secrets",
filepath.Join(root, ".."),
"/etc/passwd",
root + "X",
}
for _, path := range outside {
if withinRoot(root, path) {
t.Errorf("withinRoot(%q, %q) = true, want false", root, path)
}
}
}

715
internal/auth/auth.go Normal file
View file

@ -0,0 +1,715 @@
// Package auth authenticates callers and authorises their actions.
//
// Three mechanisms are supported and are resolved by Authenticator in a fixed
// order, most specific first:
//
// 1. An API key, presented as a bearer token. Machine access.
// 2. An OIDC session, carried in a signed cookie. Human access to the admin
// API, with a full login redirect flow.
// 3. A development bypass, available only when the configuration explicitly
// enables it and the process is running in a development environment.
//
// The development bypass refuses to initialise outside development. That check
// is the single most important line of defence in this package: an accidentally
// deployed dev mode is a total authentication bypass, so it is made impossible
// to construct rather than merely documented as unsupported.
package auth
import (
"context"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
)
// Errors returned by this package. They wrap the domain sentinels so the HTTP
// layer can map them to status codes without importing this package's internals.
var (
ErrNoSession = fmt.Errorf("%w: no session", domain.ErrUnauthorized)
ErrSessionExpired = fmt.Errorf("%w: session expired", domain.ErrUnauthorized)
ErrSessionInvalid = fmt.Errorf("%w: session is invalid", domain.ErrUnauthorized)
ErrDevBypassDenied = fmt.Errorf("%w: development bypass is not available", domain.ErrUnauthorized)
)
// Identity, Role and the role constants are re-exported so that callers of this
// package need not also import the domain package.
type (
// Identity is the authenticated caller.
Identity = domain.Identity
// Role is a coarse authorisation level.
Role = domain.Role
)
// Role constants, re-exported for the same reason.
const (
// RoleViewer may read published themes.
RoleViewer = domain.RoleViewer
// RoleEditor may create, edit and publish themes.
RoleEditor = domain.RoleEditor
// RoleAdmin may additionally manage users, API keys and settings.
RoleAdmin = domain.RoleAdmin
)
// Resolver is the identity-provider half of the interface. It is an interface so
// that the session store can be tested without a live provider, and so that a
// future provider (SAML, a service mesh) can be added without touching the HTTP
// layer.
// Login is the outcome of a completed login.
type Login struct {
// Identity is the authenticated caller.
Identity *Identity
// ReturnTo is the sanitised local path to send the browser to. It is always
// a path on this service, never an absolute URL.
ReturnTo string
}
// SafeReturnTo restricts a post-login redirect to a local path.
//
// Without this check an attacker could craft a login link that, after a
// successful authentication, bounces the user to a site of their choosing with the
// session cookie freshly minted: an open redirect that also primes a phishing flow.
// Absolute URLs, protocol-relative URLs, backslashes and control characters are all
// rejected, because browsers normalise several of those into an origin.
func SafeReturnTo(returnTo string) string {
trimmed := strings.TrimSpace(returnTo)
if trimmed == "" {
return "/"
}
if strings.ContainsAny(trimmed, "\\\r\n\t") ||
strings.Contains(trimmed, "://") || strings.HasPrefix(trimmed, "//") {
return "/"
}
if !strings.HasPrefix(trimmed, "/") {
return "/"
}
return trimmed
}
type Resolver interface {
// Begin starts a login by redirecting the browser to the provider.
Begin(ctx context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error
// Complete finishes a login, returning the identity and where to send the
// browser next.
Complete(ctx context.Context, r *http.Request) (*Login, error)
// End starts a logout.
End(ctx context.Context, redirect string) string
// Enabled reports whether the resolver can authenticate at all.
Enabled() bool
// ClearState discards any pending login transaction.
ClearState(w http.ResponseWriter)
// LogoutURL returns the provider's end-session endpoint.
LogoutURL(redirect string) string
}
// KeyStore is the persistence the authenticator needs beyond sessions: recording
// the users it has seen, so that an operator can see who has access.
//
// It was once also the API-key store. Machine credentials were removed, so a bearer
// token is no longer a credential: the runtime API is public and the admin API is
// for people. A request carrying a bearer token is now simply unauthenticated,
// rather than being given editor rights by whatever it presents.
type KeyStore interface {
UpsertUser(ctx context.Context, identity *Identity) error
}
// Authenticator authenticates requests and issues sessions.
type Authenticator struct {
config config.Config
resolver Resolver
keys KeyStore
sessions *SessionStore
// now is the clock, overridable in tests.
now func() time.Time
}
// New builds an Authenticator. resolver may be nil when authentication is
// disabled; sessions are always constructed because the dev bypass and API keys
// still benefit from a consistent identity shape.
func New(cfg config.Config, resolver Resolver, keys KeyStore) (*Authenticator, error) {
if cfg.AuthMode == config.AuthModeDev && !cfg.IsDevelopment() {
return nil, fmt.Errorf("refusing to start: AUTH_MODE=dev outside a development environment")
}
if cfg.AuthMode == config.AuthModeNone && !cfg.IsDevelopment() {
return nil, fmt.Errorf("refusing to start: AUTH_MODE=none outside a development environment")
}
sessionStore, err := NewSessionStore(cfg.SessionSecret, cfg.SessionTTL, cfg.IsDevelopment())
if err != nil {
return nil, err
}
if cfg.AuthMode == config.AuthModeOIDC && (resolver == nil || !resolver.Enabled()) {
return nil, fmt.Errorf("AUTH_MODE=oidc but the identity provider is unavailable")
}
return &Authenticator{
config: cfg,
resolver: resolver,
keys: keys,
sessions: sessionStore,
now: time.Now,
}, nil
}
// Sessions exposes the session store so the HTTP layer can set and clear
// cookies without re-deriving the cookie attributes.
func (a *Authenticator) Sessions() *SessionStore { return a.sessions }
// Config returns the configuration the authenticator was built with.
func (a *Authenticator) Config() config.Config { return a.config }
// Mode returns the active authentication mode.
func (a *Authenticator) Mode() string { return a.config.AuthMode }
// DevBypassEnabled reports whether the development bypass is available.
func (a *Authenticator) DevBypassEnabled() bool {
return a.config.AuthMode == config.AuthModeDev && a.config.IsDevelopment()
}
// Authenticate resolves the caller from a request.
//
// The session cookie is the only credential. There is no bearer-token path: a
// request with an Authorization header is treated exactly like one without, so a
// stale or forged token cannot grant anything.
func (a *Authenticator) Authenticate(r *http.Request) (*Identity, error) {
if identity, err := a.sessions.Read(r); err == nil {
return identity, nil
} else if errors.Is(err, ErrSessionExpired) {
return nil, ErrSessionExpired
}
if a.DevBypassEnabled() {
if identity := a.devIdentity(r); identity != nil {
return identity, nil
}
}
if a.config.AuthMode == config.AuthModeNone {
return &Identity{
Subject: "anonymous",
Name: "anonymous",
Roles: []Role{RoleAdmin},
Method: "none",
}, nil
}
return nil, ErrNoSession
}
// devIdentity builds an identity for the development bypass.
//
// The caller may name themselves with ?dev-user=email, which lets a developer
// exercise the role model without a running identity provider. It is only
// reachable when DevBypassEnabled is true, which config.Validate refuses to
// allow outside development.
func (a *Authenticator) devIdentity(r *http.Request) *Identity {
email := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("dev-user")))
if email == "" {
email = "dev@go-bananas.local"
}
role := a.config.DevFallbackRole
if mapped, ok := a.config.DevUsers[email]; ok {
role = mapped
}
identity := &Identity{
Subject: "dev:" + email,
Email: email,
Name: displayNameFor(email),
Roles: []Role{domain.ParseRole(role)},
Method: "dev",
}
return identity
}
// displayNameFor turns an email address into a plausible display name.
func displayNameFor(email string) string {
local, _, _ := strings.Cut(email, "@")
if local == "" {
return email
}
// Capitalise the first letter of each hyphen-separated segment.
parts := strings.Split(local, "-")
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
return strings.Join(parts, " ")
}
// IssueSession records a successful login and writes the session cookie.
func (a *Authenticator) IssueSession(ctx context.Context, w http.ResponseWriter, identity *Identity) error {
if identity == nil {
return fmt.Errorf("%w: cannot issue a session for a nil identity", domain.ErrInvalid)
}
if a.keys != nil && identity.Method != "dev" {
// Recording the login lets an administrator see who has used the system.
if err := a.keys.UpsertUser(ctx, identity); err != nil {
// A login that cannot be recorded should still succeed; losing the
// directory entry is far less harmful than locking the user out.
_ = err
}
}
return a.sessions.Issue(w, identity)
}
// ClearSession removes the session cookie.
func (a *Authenticator) ClearSession(w http.ResponseWriter) {
a.sessions.Clear(w)
}
// ModeConfigured reports whether interactive login is available at all.
func (a *Authenticator) ModeConfigured() bool {
return a != nil && a.config.AuthMode != config.AuthModeNone
}
// ProviderEnabled reports whether an OIDC provider is wired up.
func (a *Authenticator) ProviderEnabled() bool {
return a != nil && a.resolver != nil && a.resolver.Enabled()
}
// Begin starts an interactive login.
func (a *Authenticator) Begin(ctx context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error {
if a == nil || a.resolver == nil || !a.resolver.Enabled() {
return fmt.Errorf("%w: no identity provider is configured", domain.ErrNotConfigured)
}
return a.resolver.Begin(ctx, req, w, returnTo)
}
// Complete finishes an interactive login.
func (a *Authenticator) Complete(ctx context.Context, r *http.Request) (*Login, error) {
if a == nil || a.resolver == nil || !a.resolver.Enabled() {
return nil, fmt.Errorf("%w: no identity provider is configured", domain.ErrNotConfigured)
}
return a.resolver.Complete(ctx, r)
}
// ClearState discards a pending login transaction.
func (a *Authenticator) ClearState(w http.ResponseWriter) {
if a != nil && a.resolver != nil {
a.resolver.ClearState(w)
}
}
// LogoutURL returns the provider logout URL.
func (a *Authenticator) LogoutURL(redirect string) string {
if a.resolver == nil || !a.resolver.Enabled() {
return ""
}
return a.resolver.LogoutURL(redirect)
}
// ---------------------------------------------------------------- sessions --
// SessionStore issues and verifies stateless signed session cookies.
//
// The session is a signed, encrypted-free JSON payload in a cookie: there is no
// server-side session table to migrate, scale or invalidate. The trade is that
// revoking a session before its expiry requires rotating the signing key, which
// is acceptable for a session that lives eight hours and is documented as such.
//
// The signature is HMAC-SHA256 over the payload with a constant-time
// comparison, which is what makes a forged cookie infeasible.
type SessionStore struct {
secret []byte
ttl time.Duration
secure bool
name string
sameSite http.SameSite
now func() time.Time
// inDevelopment relaxes cookie behaviour so that a local run over plain
// HTTP still receives a cookie.
inDevelopment bool
}
// NewSessionStore builds a session store.
func NewSessionStore(secret string, ttl time.Duration, inDevelopment bool) (*SessionStore, error) {
if len(secret) < 32 {
return nil, fmt.Errorf("%w: session secret must be at least 32 bytes, got %d", domain.ErrInvalid, len(secret))
}
if ttl <= 0 {
ttl = 8 * time.Hour
}
switch strings.ToLower(stringValue(sameSiteFromString("lax"))) {
case "lax", "strict", "none":
default:
return nil, fmt.Errorf("%w: unknown SameSite value", domain.ErrInvalid)
}
return &SessionStore{
secret: []byte(secret),
ttl: ttl,
secure: true,
name: "go-bananas-session",
sameSite: http.SameSiteLaxMode,
now: time.Now,
inDevelopment: inDevelopment,
}, nil
}
// WithCookie configures the cookie attributes. It exists so that the
// configuration can be applied in one place after construction.
func (s *SessionStore) WithCookie(name string, secure bool, sameSite http.SameSite) *SessionStore {
if name != "" {
s.name = name
}
s.secure = secure
s.sameSite = sameSite
return s
}
// WithClock overrides the clock, for tests.
func (s *SessionStore) WithClock(now func() time.Time) *SessionStore {
s.now = now
return s
}
// CookieName returns the configured cookie name.
func (s *SessionStore) CookieName() string { return s.name }
// sessionPayload is the signed cookie body.
type sessionPayload struct {
Subject string `json:"sub"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Roles []string `json:"roles"`
Method string `json:"method"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
// Nonce lets a future rotation invalidate sessions issued before a cutover.
Nonce string `json:"jti,omitempty"`
}
// Issue writes a signed session cookie.
func (s *SessionStore) Issue(w http.ResponseWriter, identity *Identity) error {
now := s.now().UTC()
roles := make([]string, 0, len(identity.Roles))
for _, r := range identity.Roles {
roles = append(roles, string(r))
}
payload := sessionPayload{
Subject: identity.Subject,
Email: identity.Email,
Name: identity.Name,
Roles: roles,
Method: identity.Method,
IssuedAt: now.Unix(),
ExpiresAt: now.Add(s.ttl).Unix(),
}
encoded, err := s.encode(payload)
if err != nil {
return err
}
//nolint:gosec // G124: HttpOnly, Secure and SameSite are all set, from the
// configured values rather than literals. gosec only recognises a literal
// attribute, so a configurable cookie reads as an unprotected one.
http.SetCookie(w, &http.Cookie{
Name: s.name,
Value: encoded,
Path: "/",
Expires: now.Add(s.ttl),
MaxAge: int(s.ttl.Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: s.sameSite,
})
return nil
}
// Clear expires the session cookie.
func (s *SessionStore) Clear(w http.ResponseWriter) {
//nolint:gosec // G124: as above. Clearing a cookie needs the same attribute
// triple as setting it, or a browser will not remove it.
http.SetCookie(w, &http.Cookie{
Name: s.name,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: s.secure,
SameSite: s.sameSite,
})
}
// Read verifies and decodes the session cookie.
func (s *SessionStore) Read(r *http.Request) (*Identity, error) {
cookie, err := r.Cookie(s.name)
if err != nil || cookie.Value == "" {
return nil, ErrNoSession
}
payload, err := s.decode(cookie.Value)
if err != nil {
return nil, err
}
now := s.now().UTC().Unix()
if payload.ExpiresAt > 0 && now > payload.ExpiresAt {
return nil, ErrSessionExpired
}
identity := &Identity{
Subject: payload.Subject,
Email: payload.Email,
Name: payload.Name,
Method: payload.Method,
}
// Left nil when the payload carries no expiry, rather than becoming the Unix
// epoch: a session without an expiry is a valid thing to represent, and 1970 is
// a date every client would read as "already expired".
if payload.ExpiresAt > 0 {
identity.ExpiresAt = timePtr(time.Unix(payload.ExpiresAt, 0).UTC())
}
for _, r := range payload.Roles {
identity.Roles = append(identity.Roles, Role(r))
}
if len(identity.Roles) == 0 {
identity.Roles = []Role{RoleViewer}
}
return identity, nil
}
// encode serialises and signs a payload.
func (s *SessionStore) encode(payload sessionPayload) (string, error) {
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("encode session: %w", err)
}
bodyEncoded := base64.RawURLEncoding.EncodeToString(body)
mac := s.sign(bodyEncoded)
return bodyEncoded + "." + base64.RawURLEncoding.EncodeToString(mac), nil
}
// decode verifies and parses a payload.
func (s *SessionStore) decode(value string) (sessionPayload, error) {
var payload sessionPayload
bodyEncoded, signatureEncoded, ok := strings.Cut(value, ".")
if !ok {
return payload, ErrSessionInvalid
}
signature, err := base64.RawURLEncoding.DecodeString(signatureEncoded)
if err != nil {
return payload, ErrSessionInvalid
}
// Constant-time comparison: a byte-by-byte early return would leak the
// correct prefix length to an attacker measuring responses.
expected := s.sign(bodyEncoded)
if subtle.ConstantTimeCompare(signature, expected) != 1 {
return payload, ErrSessionInvalid
}
body, err := base64.RawURLEncoding.DecodeString(bodyEncoded)
if err != nil {
return payload, ErrSessionInvalid
}
if err := json.Unmarshal(body, &payload); err != nil {
return payload, ErrSessionInvalid
}
if payload.Subject == "" {
return payload, ErrSessionInvalid
}
return payload, nil
}
// sign computes the HMAC over a value.
func (s *SessionStore) sign(value string) []byte {
mac := hmacSHA256(s.secret, []byte(value))
return mac[:]
}
// sameSiteFromString maps a configuration value to an http.SameSite.
func sameSiteFromString(raw string) http.SameSite {
switch strings.ToLower(raw) {
case "strict":
return http.SameSiteStrictMode
case "none":
return http.SameSiteNoneMode
default:
return http.SameSiteLaxMode
}
}
// stringValue renders a SameSite for logging.
func stringValue(v http.SameSite) string {
switch v {
case http.SameSiteStrictMode:
return "strict"
case http.SameSiteNoneMode:
return "none"
default:
return "lax"
}
}
// ------------------------------------------------------------------ state --
// StateStore keeps short-lived OIDC transaction state (the PKCE verifier and
// the post-login return path) in a signed cookie.
//
// Keeping it in the cookie rather than a server-side map is what lets go-bananas
// run multiple replicas behind a load balancer with no shared session storage.
type StateStore struct {
secret []byte
name string
ttl time.Duration
secure bool
sameSite http.SameSite
now func() time.Time
}
// oauthState is the transaction cookie body.
//
// State is stored alongside the verifier and nonce so that the callback can be
// bound to the browser that started the flow. Without it the state parameter is
// merely present, not checked, and an attacker-supplied authorization code could
// be redeemed into a session in someone else's browser (a login CSRF).
type oauthState struct {
Verifier string `json:"v"`
ReturnTo string `json:"r"`
Nonce string `json:"n"`
State string `json:"s"`
IssuedAt int64 `json:"iat"`
}
// Transaction is a verified login transaction read back from the state cookie.
type Transaction struct {
// Verifier is the PKCE code verifier for the exchange.
Verifier string
// ReturnTo is the sanitised local path the browser should land on.
ReturnTo string
// Nonce must match the nonce in the returned ID token.
Nonce string
// State must match the state parameter the provider echoes back.
State string
}
// NewStateStore builds the transaction cookie store.
func NewStateStore(secret, name string, secure bool, sameSite http.SameSite, ttl time.Duration) *StateStore {
if ttl <= 0 {
ttl = 10 * time.Minute
}
return &StateStore{
secret: []byte(secret),
name: name,
secure: secure,
sameSite: sameSite,
ttl: ttl,
now: time.Now,
}
}
// Name returns the state cookie name.
func (s *StateStore) Name() string { return s.name }
// Put writes the transaction state cookie. It fails when any part of the
// transaction is empty: a transaction missing its state or verifier cannot be
// validated at the callback, so storing it would only defer the failure to a point
// where the user is already mid-login.
func (s *StateStore) Put(w http.ResponseWriter, verifier, returnTo, nonce, state string) error {
if verifier == "" || nonce == "" || state == "" {
return fmt.Errorf("%w: incomplete oauth transaction", domain.ErrInvalid)
}
now := s.now().UTC()
body, err := json.Marshal(oauthState{
Verifier: verifier, ReturnTo: returnTo, Nonce: nonce, State: state,
IssuedAt: now.Unix(),
})
if err != nil {
return fmt.Errorf("encode oauth state: %w", err)
}
encoded := base64.RawURLEncoding.EncodeToString(body)
mac := hmacSHA256(s.secret, []byte(encoded))
//nolint:gosec // G124: as above.
http.SetCookie(w, &http.Cookie{
Name: s.name,
Value: encoded + "." + base64.RawURLEncoding.EncodeToString(mac[:]),
Path: "/",
Expires: now.Add(s.ttl),
MaxAge: int(s.ttl.Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
return nil
}
// Get verifies the transaction state cookie and returns what it holds.
//
// "Consume" is the caller's job: the cookie is single-use, but only because the
// HTTP layer expires it once the login has succeeded. Clearing it here instead
// would expire it before the code exchange, and a user who mistyped their password
// once at the provider would arrive back with no transaction to finish.
func (s *StateStore) Get(r *http.Request) (Transaction, error) {
cookie, err := r.Cookie(s.name)
if err != nil {
return Transaction{}, fmt.Errorf("%w: missing oauth state", domain.ErrUnauthorized)
}
encoded, signatureEncoded, ok := strings.Cut(cookie.Value, ".")
if !ok {
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
}
signature, decodeErr := base64.RawURLEncoding.DecodeString(signatureEncoded)
if decodeErr != nil {
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
}
expected := hmacSHA256(s.secret, []byte(encoded))
if subtle.ConstantTimeCompare(signature, expected[:]) != 1 {
return Transaction{}, fmt.Errorf("%w: oauth state signature mismatch", domain.ErrUnauthorized)
}
body, decodeErr := base64.RawURLEncoding.DecodeString(encoded)
if decodeErr != nil {
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
}
var state oauthState
if json.Unmarshal(body, &state) != nil {
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
}
if s.now().UTC().Sub(time.Unix(state.IssuedAt, 0).UTC()) > s.ttl {
return Transaction{}, fmt.Errorf("%w: oauth state expired", domain.ErrUnauthorized)
}
if state.Verifier == "" || state.Nonce == "" || state.State == "" {
return Transaction{}, fmt.Errorf("%w: incomplete oauth state", domain.ErrUnauthorized)
}
return Transaction{
Verifier: state.Verifier,
ReturnTo: SafeReturnTo(state.ReturnTo),
Nonce: state.Nonce,
State: state.State,
}, nil
}
// Clear expires the state cookie.
func (s *StateStore) Clear(w http.ResponseWriter) {
//nolint:gosec // G124: as above.
http.SetCookie(w, &http.Cookie{
Name: s.name, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode,
})
}
// hmacSHA256 computes a signature. It is a free function so the session store
// and the OIDC state store share one implementation and cannot diverge.
func hmacSHA256(key, body []byte) [32]byte {
return sha256HMAC(key, body)
}
// timePtr returns a pointer to t. It exists because the session and identity types
// carry an optional expiry, and taking the address of a conversion inline is not
// something Go will let you write.
func timePtr(t time.Time) *time.Time { return &t }

417
internal/auth/auth_test.go Normal file
View file

@ -0,0 +1,417 @@
package auth
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
)
const testSecret = "test-secret-that-is-at-least-32-bytes-long"
// ---------------------------------------------------------------- sessions --
func TestSessionRoundTrip(t *testing.T) {
store, err := NewSessionStore(testSecret, time.Hour, false)
if err != nil {
t.Fatalf("NewSessionStore: %v", err)
}
identity := &domain.Identity{
Subject: "alice",
Email: "alice@example.com",
Name: "Alice",
Roles: []domain.Role{domain.RoleEditor},
Method: "oidc",
}
rec := httptest.NewRecorder()
if err := store.Issue(rec, identity); err != nil {
t.Fatalf("Issue: %v", err)
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
got, err := store.Read(req)
if err != nil {
t.Fatalf("Read: %v", err)
}
if got.Subject != "alice" || got.Email != "alice@example.com" {
t.Errorf("identity = %+v", got)
}
if !got.HasRole(domain.RoleEditor) {
t.Errorf("roles = %v, want editor", got.Roles)
}
}
func TestSessionCookieIsHttpOnly(t *testing.T) {
store, _ := NewSessionStore(testSecret, time.Hour, false)
rec := httptest.NewRecorder()
if err := store.Issue(rec, &domain.Identity{Subject: "a"}); err != nil {
t.Fatalf("Issue: %v", err)
}
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("got %d cookies, want 1", len(cookies))
}
if !cookies[0].HttpOnly {
t.Error("session cookie must be HttpOnly so script cannot read it")
}
if !cookies[0].Secure {
t.Error("session cookie must be Secure")
}
}
func TestSessionRejectsShortSecret(t *testing.T) {
if _, err := NewSessionStore("too-short", time.Hour, false); err == nil {
t.Fatal("expected a short secret to be rejected")
}
}
func TestSessionRejectsTamperedPayload(t *testing.T) {
store, _ := NewSessionStore(testSecret, time.Hour, false)
rec := httptest.NewRecorder()
if err := store.Issue(rec, &domain.Identity{Subject: "alice", Roles: []domain.Role{domain.RoleViewer}}); err != nil {
t.Fatalf("Issue: %v", err)
}
var issued *http.Cookie
for _, c := range rec.Result().Cookies() {
issued = c
}
// Re-encode the payload with an escalated role but keep the old signature.
body, signature, _ := strings.Cut(issued.Value, ".")
forged := base64RawURL(`{"sub":"alice","roles":["admin"],"method":"oidc","iat":1,"exp":9999999999}`)
_ = body
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: issued.Name, Value: forged + "." + signature})
if _, err := store.Read(req); err == nil {
t.Fatal("a re-signed payload must be rejected")
}
}
func TestSessionRejectsForeignSecret(t *testing.T) {
mine, _ := NewSessionStore(testSecret, time.Hour, false)
theirs, _ := NewSessionStore("a-completely-different-32-byte-secret!", time.Hour, false)
rec := httptest.NewRecorder()
if err := theirs.Issue(rec, &domain.Identity{Subject: "mallory", Roles: []domain.Role{domain.RoleAdmin}}); err != nil {
t.Fatalf("Issue: %v", err)
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
if _, err := mine.Read(req); err == nil {
t.Fatal("a cookie signed with another key must be rejected")
}
}
func TestSessionExpires(t *testing.T) {
now := time.Now().UTC()
store, _ := NewSessionStore(testSecret, time.Hour, false)
store.WithClock(func() time.Time { return now })
rec := httptest.NewRecorder()
if err := store.Issue(rec, &domain.Identity{Subject: "alice"}); err != nil {
t.Fatalf("Issue: %v", err)
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
// Two hours later the session must be gone.
store.WithClock(func() time.Time { return now.Add(2 * time.Hour) })
if _, err := store.Read(req); err == nil {
t.Fatal("expected an expired session to be rejected")
}
}
func TestSessionReadRejectsMissingCookie(t *testing.T) {
store, _ := NewSessionStore(testSecret, time.Hour, false)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
if _, err := store.Read(req); err == nil {
t.Fatal("expected ErrNoSession")
}
}
func TestSessionWithRoleDefaultsToViewer(t *testing.T) {
// A cookie carrying no roles must never be treated as having privileges.
store, _ := NewSessionStore(testSecret, time.Hour, false)
encoded, err := store.encode(sessionPayload{Subject: "a", Method: "oidc", ExpiresAt: time.Now().Add(time.Hour).Unix()})
if err != nil {
t.Fatalf("encode: %v", err)
}
payload, err := store.decode(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(payload.Roles) != 0 {
t.Errorf("payload roles = %v, want none", payload.Roles)
}
}
// ------------------------------------------------------------- oidc state --
func TestOAuthStateRoundTrip(t *testing.T) {
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
rec := httptest.NewRecorder()
if err := state.Put(rec, "verifier-value", "/return", "nonce-value", "state-value"); err != nil {
t.Fatalf("Put: %v", err)
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
tx, err := state.Get(req)
if err != nil {
t.Fatalf("Get: %v", err)
}
if tx.Verifier != "verifier-value" || tx.ReturnTo != "/return" ||
tx.Nonce != "nonce-value" || tx.State != "state-value" {
t.Errorf("round trip lost data: %+v", tx)
}
}
func TestOAuthStateRejectsTampering(t *testing.T) {
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
rec := httptest.NewRecorder()
if err := state.Put(rec, "verifier", "/", "nonce", "state"); err != nil {
t.Fatalf("Put: %v", err)
}
var issued *http.Cookie
for _, c := range rec.Result().Cookies() {
issued = c
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
req.AddCookie(&http.Cookie{Name: issued.Name, Value: base64RawURL(`{"v":"attacker","r":"/evil","n":"x","iat":1}`) + "." + strings.Split(issued.Value, ".")[1]})
if _, err := state.Get(req); err == nil {
t.Fatal("a tampered state cookie must be rejected")
}
}
func TestOAuthStateExpires(t *testing.T) {
now := time.Now().UTC()
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
rec := httptest.NewRecorder()
if err := state.Put(rec, "verifier", "/", "nonce", "state"); err != nil {
t.Fatalf("Put: %v", err)
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
// The StateStore uses time.Now directly; simulate staleness by minting an
// old state value through the exported encoder path.
stale := oauthState{Verifier: "v", ReturnTo: "/", IssuedAt: now.Add(-time.Hour).Unix()}
body := base64RawURL(`{"v":"v","r":"/","n":"","iat":` + itoa(stale.IssuedAt) + `}`)
mac := hmacSHA256([]byte(testSecret), []byte(body))
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
req2.AddCookie(&http.Cookie{Name: "bananas-oauth", Value: body + "." + base64RawURLBytes(mac[:])})
if _, err := state.Get(req2); err == nil {
t.Fatal("an expired state cookie must be rejected")
}
}
// -------------------------------------------------------- sanitising URLs --
func TestSafeReturnToBlocksOpenRedirects(t *testing.T) {
dangerous := []string{
"https://evil.example.com/steal",
"//evil.example.com/steal",
"/\\evil.example.com",
"javascript:alert(1)",
"admin",
"",
}
for _, input := range dangerous {
got := SafeReturnTo(input)
if got != "/" {
t.Errorf("SafeReturnTo(%q) = %q, want /", input, got)
}
}
safe := map[string]string{
"/admin": "/admin",
"/admin/themes?x=1": "/admin/themes?x=1",
"/": "/",
}
for input, want := range safe {
if got := SafeReturnTo(input); got != want {
t.Errorf("SafeReturnTo(%q) = %q, want %q", input, got, want)
}
}
}
func TestLoginURLIsNotAnOpenRedirect(t *testing.T) {
// The redirect URI must always be one the provider has registered.
cfg := config.Defaults()
cfg.PublicBaseURL = "https://themes.example.com"
if got := cfg.RedirectURL(); got != "https://themes.example.com/auth/callback" {
t.Errorf("RedirectURL = %q", got)
}
cfg.BasePath = "/bananas"
if got := cfg.RedirectURL(); got != "https://themes.example.com/bananas/auth/callback" {
t.Errorf("RedirectURL with base path = %q", got)
}
}
// -------------------------------------------------------- authenticator --
func TestDevBypassGrantsConfiguredRole(t *testing.T) {
cfg := config.Defaults()
cfg.AuthMode = config.AuthModeDev
cfg.Environment = "development"
cfg.DevFallbackRole = "viewer"
cfg.DevUsers = map[string]string{"boss@example.com": "admin"}
a := newTestAuthenticator(t, cfg, nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes", nil)
identity, err := a.Authenticate(req)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if identity.HighestRole() != domain.RoleViewer {
t.Errorf("role = %v, want viewer", identity.Roles)
}
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes?dev-user=boss@example.com", nil)
identity, err = a.Authenticate(req)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if identity.HighestRole() != domain.RoleAdmin {
t.Errorf("role = %v, want admin", identity.Roles)
}
}
func TestDevBypassRefusedOutsideDevelopment(t *testing.T) {
cfg := config.Defaults()
cfg.AuthMode = config.AuthModeDev
cfg.Environment = "production"
cfg.SessionSecret = testSecret
if _, err := New(cfg, nil, nil); err == nil {
t.Fatal("the development bypass must not be constructible in production")
}
}
func TestAuthNoneRefusedOutsideDevelopment(t *testing.T) {
cfg := config.Defaults()
cfg.AuthMode = config.AuthModeNone
cfg.Environment = "production"
cfg.SessionSecret = testSecret
if _, err := New(cfg, nil, nil); err == nil {
t.Fatal("AUTH_MODE=none must not be constructible in production")
}
}
func TestUnauthenticatedRequestIsRejected(t *testing.T) {
cfg := config.Defaults()
cfg.AuthMode = config.AuthModeOIDC
cfg.Environment = "test"
a := newTestAuthenticator(t, cfg, nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes", nil)
if _, err := a.Authenticate(req); err == nil {
t.Fatal("expected an unauthenticated request to be rejected")
}
}
func TestRoleMappingFailsClosed(t *testing.T) {
// A user in no configured group gets viewer, never more.
p := &OIDCProvider{config: config.Config{
OIDCAdminGroups: []string{"platform-admins"},
OIDCEditorGroups: []string{"designers"},
}}
if got := p.rolesFor([]string{"some-other-group"}, nil); got[0] != domain.RoleViewer {
t.Errorf("roles = %v, want viewer", got)
}
if got := p.rolesFor([]string{"designers"}, nil); got[0] != domain.RoleEditor {
t.Errorf("roles = %v, want editor", got)
}
if got := p.rolesFor([]string{"designers", "platform-admins"}, nil); got[0] != domain.RoleAdmin {
t.Errorf("roles = %v, want admin; the most privileged group must win", got)
}
if got := p.rolesFor(nil, []string{"platform-admins"}); got[0] != domain.RoleAdmin {
t.Errorf("roles = %v, want admin from realm roles", got)
}
}
func TestRoleMappingIsCaseInsensitive(t *testing.T) {
p := &OIDCProvider{config: config.Config{OIDCAdminGroups: []string{"Platform-Admins"}}}
if got := p.rolesFor([]string{"PLATFORM-ADMINS"}, nil); got[0] != domain.RoleAdmin {
t.Errorf("roles = %v, want admin", got)
}
}
func TestCodeChallengeIsS256(t *testing.T) {
// RFC 7636 test vector: verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
// must produce challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".
got := codeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
if got != "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" {
t.Errorf("codeChallenge = %q, want the RFC 7636 value", got)
}
}
func TestInsecureTLSConfigIsUsedOnlyWhenRequested(t *testing.T) {
cfg := config.Defaults()
cfg.OIDCSkipTLSVerify = true
if client := newProviderHTTPClient(cfg); client == nil {
t.Fatal("expected a client")
}
if tlsCfg := newProviderHTTPClient(cfg).Transport.(*http.Transport).TLSClientConfig; tlsCfg == nil || !tlsCfg.InsecureSkipVerify {
t.Error("expected TLS verification to be skipped when configured")
}
cfg.OIDCSkipTLSVerify = false
if tlsCfg := newProviderHTTPClient(cfg).Transport.(*http.Transport).TLSClientConfig; tlsCfg != nil && tlsCfg.InsecureSkipVerify {
t.Error("TLS verification must be on by default")
}
var _ *tls.Config
}
func TestProviderHTTPClientHasTimeout(t *testing.T) {
// An unresponsive identity provider must not be able to hang a request
// indefinitely.
client := newProviderHTTPClient(config.Defaults())
if client.Timeout == 0 {
t.Error("provider HTTP client must have a timeout")
}
}
func TestURLQueryEscape(t *testing.T) {
if got := urlQueryEscape("https://x.example/y?a=1&b=2"); got != "https%3A%2F%2Fx.example%2Fy%3Fa%3D1%26b%3D2" {
t.Errorf("urlQueryEscape = %q", got)
}
if _, err := url.Parse("https://x.example"); err != nil {
t.Fatalf("unexpected: %v", err)
}
}

View file

@ -0,0 +1,60 @@
package auth
import (
"context"
"encoding/base64"
"net/http"
"strconv"
"testing"
"github.com/urpi/go-bananas/internal/config"
)
// base64RawURL encodes without padding, matching how cookies are written.
func base64RawURL(s string) string {
return base64.RawURLEncoding.EncodeToString([]byte(s))
}
// base64RawURLBytes encodes raw bytes without padding.
func base64RawURLBytes(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// itoa renders an int64 for embedding in a JSON test fixture.
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
// stubResolver is a Resolver that authenticates nobody. It exists so that tests
// can construct an Authenticator in OIDC mode without a live provider, which
// also documents that New refuses to start in OIDC mode without one.
type stubResolver struct{}
func (stubResolver) Begin(context.Context, *http.Request, http.ResponseWriter, string) error {
return nil
}
func (stubResolver) Complete(context.Context, *http.Request) (*Login, error) {
return nil, ErrNoSession
}
func (stubResolver) End(context.Context, string) string { return "" }
func (stubResolver) Enabled() bool { return true }
func (stubResolver) ClearState(http.ResponseWriter) {}
func (stubResolver) LogoutURL(string) string { return "https://idp.example/logout" }
// newTestAuthenticator builds an Authenticator for a configuration.
//
// The key store is optional and is passed straight through: with machine
// credentials removed it only carries the user table, which most of these tests do
// not exercise.
func newTestAuthenticator(t *testing.T, cfg config.Config, keys KeyStore) *Authenticator {
t.Helper()
if cfg.SessionSecret == "" {
cfg.SessionSecret = testSecret
}
// A stub resolver rather than nil: New refuses to construct in OIDC mode
// without one, and the point of some of these tests is exactly that an
// unauthenticated request is rejected even when a provider is wired up.
a, err := New(cfg, stubResolver{}, keys)
if err != nil {
t.Fatalf("New: %v", err)
}
return a
}

20
internal/auth/hmac.go Normal file
View file

@ -0,0 +1,20 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
)
// sha256HMAC computes an HMAC-SHA256 tag.
//
// It is a thin wrapper so that the session store and the OIDC state store
// cannot accidentally diverge on how they sign. The stdlib hmac package already
// performs a constant-time internal comparison; the constant-time requirement
// in this codebase is about how *we* compare the resulting tags.
func sha256HMAC(key, body []byte) [32]byte {
mac := hmac.New(sha256.New, key)
mac.Write(body)
var out [32]byte
copy(out[:], mac.Sum(nil))
return out
}

375
internal/auth/oidc.go Normal file
View file

@ -0,0 +1,375 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
)
// OIDCProvider implements Resolver against any OpenID Connect provider,
// including the auth-dev.urpi.be realm used for local development.
//
// Everything provider-specific comes from the issuer's discovery document, so
// the same code works against Keycloak, Authentik, Zitadel or Google without a
// configuration change. The only per-provider values are the client
// credentials and the group-to-role mapping.
type OIDCProvider struct {
config config.Config
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
oauth *oauth2.Config
state *StateStore
}
// NewOIDCProvider performs discovery against the configured issuer and returns
// a ready provider. Discovery happens eagerly so that a misconfigured issuer
// fails at start-up rather than on the first login, when the failure would be
// hardest to diagnose.
func NewOIDCProvider(ctx context.Context, cfg config.Config, state *StateStore) (*OIDCProvider, error) {
if cfg.OIDCIssuer == "" {
return nil, fmt.Errorf("%w: OIDC_ISSUER is required", domain.ErrInvalid)
}
provider, err := newProvider(ctx, cfg)
if err != nil {
return nil, err
}
p := &OIDCProvider{
config: cfg,
provider: provider,
state: state,
}
scopes := append([]string{oidc.ScopeOpenID}, cfg.OIDCScopes...)
p.oauth = &oauth2.Config{
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret,
RedirectURL: cfg.RedirectURL(),
Scopes: scopes,
Endpoint: provider.Endpoint(),
}
p.verifier = provider.Verifier(&oidc.Config{
ClientID: cfg.OIDCClientID,
// Keycloak and Authentik both support discovery of the JWKS; skipping
// the audience check is not done because a token minted for another
// client in the same realm must not be accepted here.
})
return p, nil
}
// newProvider runs discovery with a bounded timeout so that an unreachable
// issuer produces a clear error instead of hanging start-up.
func newProvider(ctx context.Context, cfg config.Config) (*oidc.Provider, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// ClientContext carries the custom HTTP client through to discovery, which
// is how the TLS-skip development switch and the request timeout reach the
// provider's HTTP calls.
ctx = oidc.ClientContext(ctx, newProviderHTTPClient(cfg))
provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuer)
if err != nil {
return nil, fmt.Errorf("OIDC discovery against %s failed: %w", cfg.OIDCIssuer, err)
}
return provider, nil
}
// newProviderHTTPClient builds the HTTP client used for provider traffic.
//
// The timeout matters: without one, an unresponsive identity provider would hold
// a request open until the client's own timeout, and a stalled provider would
// look like a hung application.
func newProviderHTTPClient(cfg config.Config) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConnsPerHost = 4
if cfg.OIDCSkipTLSVerify {
transport.TLSClientConfig = insecureTLSConfig()
}
return &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
}
}
// Enabled reports that this resolver can authenticate.
func (p *OIDCProvider) Enabled() bool { return p != nil && p.provider != nil }
// Begin starts a login by redirecting the browser to the provider. The return
// path and PKCE verifier are carried in a signed cookie rather than
// server-side state so that any replica can complete the flow.
func (p *OIDCProvider) Begin(_ context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error {
verifier, err := randomVerifier()
if err != nil {
return err
}
nonce, err := randomNonce()
if err != nil {
return err
}
state, err := randomState()
if err != nil {
return err
}
// The transaction is stored before the redirect, and stores the state it was
// built with, so that the callback can prove the response belongs to this
// browser's login attempt.
if err := p.state.Put(w, verifier, SafeReturnTo(returnTo), nonce, state); err != nil {
return err
}
opts := []oauth2.AuthCodeOption{
// PKCE with S256. The verifier lives in a signed HttpOnly cookie, so the
// code is bound to the browser that started the flow.
oauth2.SetAuthURLParam("code_challenge", codeChallenge(verifier)),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("nonce", nonce),
oauth2.SetAuthURLParam("state", state),
}
http.Redirect(w, req, p.oauth.AuthCodeURL(state, opts...), http.StatusFound)
return nil
}
// Complete finishes a login.
//
// The transaction cookie is the anchor: the state the provider echoes back is
// compared against the state this service minted for this browser, and the PKCE
// verifier and nonce both come from the same cookie. A callback that carries a
// foreign code or a replayed state is rejected before the code is ever exchanged.
func (p *OIDCProvider) Complete(ctx context.Context, req *http.Request) (*Login, error) {
if err := req.ParseForm(); err != nil {
return nil, fmt.Errorf("parse callback form: %w", err)
}
if providerErr := req.Form.Get("error"); providerErr != "" {
desc := req.Form.Get("error_description")
return nil, fmt.Errorf("identity provider returned %q: %s", providerErr, desc)
}
tx, err := p.state.Get(req)
if err != nil {
return nil, err
}
returnedState := req.Form.Get("state")
if returnedState == "" {
return nil, fmt.Errorf("%w: callback is missing the state parameter", domain.ErrUnauthorized)
}
// Constant time, so that a mismatching state cannot be discovered a byte at a
// time. The state is public, but the comparison is free to be careful.
if subtle.ConstantTimeCompare([]byte(returnedState), []byte(tx.State)) != 1 {
return nil, fmt.Errorf("%w: callback state does not match the login transaction", domain.ErrUnauthorized)
}
code := req.Form.Get("code")
if code == "" {
return nil, fmt.Errorf("%w: callback is missing the authorization code", domain.ErrUnauthorized)
}
token, err := p.oauth.Exchange(ctx, code, oauth2.VerifierOption(tx.Verifier))
if err != nil {
return nil, fmt.Errorf("exchange authorization code: %w", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
return nil, fmt.Errorf("%w: provider returned no id_token", domain.ErrUnauthorized)
}
idToken, err := p.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("verify id_token: %w", err)
}
if idToken.Nonce != tx.Nonce {
return nil, fmt.Errorf("%w: id_token nonce mismatch", domain.ErrUnauthorized)
}
claims := struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
PreferredUser string `json:"preferred_username"`
Groups []string `json:"groups"`
RealmRoles []string `json:"realm_access.roles"`
}{}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("decode id_token claims: %w", err)
}
identity := &Identity{
Subject: claims.Subject,
Email: claims.Email,
Name: firstNonEmpty(claims.Name, claims.PreferredUser, claims.Email),
Method: "oidc",
}
if identity.Subject == "" {
return nil, fmt.Errorf("%w: id_token has no subject", domain.ErrUnauthorized)
}
identity.Roles = p.rolesFor(claims.Groups, claims.RealmRoles)
return &Login{Identity: identity, ReturnTo: tx.ReturnTo}, nil
}
// rolesFor maps provider groups onto application roles.
//
// The most privileged matching group wins, and a user in no configured group
// gets viewer. The default is deliberately the least privilege: a group mapping
// mistake must fail closed.
func (p *OIDCProvider) rolesFor(groups, realmRoles []string) []domain.Role {
all := make(map[string]struct{}, len(groups)+len(realmRoles))
for _, g := range groups {
all[strings.ToLower(strings.TrimSpace(g))] = struct{}{}
}
for _, r := range realmRoles {
all[strings.ToLower(strings.TrimSpace(r))] = struct{}{}
}
matches := func(configured []string) bool {
for _, want := range configured {
if _, ok := all[strings.ToLower(strings.TrimSpace(want))]; ok {
return true
}
}
return false
}
switch {
case matches(p.config.OIDCAdminGroups):
return []domain.Role{domain.RoleAdmin}
case matches(p.config.OIDCEditorGroups):
return []domain.Role{domain.RoleEditor}
case matches(p.config.OIDCViewerGroups):
return []domain.Role{domain.RoleViewer}
default:
return []domain.Role{domain.RoleViewer}
}
}
// End returns the provider's end-session URL.
func (p *OIDCProvider) End(_ context.Context, redirect string) string {
return p.LogoutURL(redirect)
}
// LogoutURL builds the provider end-session URL.
func (p *OIDCProvider) LogoutURL(redirect string) string {
if redirect == "" {
return ""
}
endSession := p.provider.Endpoint()
// Keycloak, Authentik and Zitadel all accept the post_logout_redirect_uri
// parameter on the end-session endpoint. A provider that does not
// implement it ignores the parameter and lands the user on its own logout
// confirmation page, which is a safe failure mode.
if !strings.Contains(endSession.AuthURL, "/end-session") {
return endSession.AuthURL
}
sep := "?"
if strings.Contains(endSession.AuthURL, "?") {
sep = "&"
}
return endSession.AuthURL + sep + "post_logout_redirect_uri=" + urlQueryEscape(redirect) +
"&client_id=" + urlQueryEscape(p.config.OIDCClientID)
}
// Issuer returns the configured issuer, used by the admin UI to show which
// provider is in use.
func (p *OIDCProvider) Issuer() string { return p.config.OIDCIssuer }
// RedirectURI returns the configured callback URL.
func (p *OIDCProvider) RedirectURI() string { return p.config.RedirectURL() }
// Claims returns the claims supported by this provider, for documentation.
func (p *OIDCProvider) Claims() []string {
list := []string{"sub", "email", "name", "preferred_username", "groups", "realm_access.roles"}
sort.Strings(list)
return list
}
// ClearState expires the transaction cookie. The HTTP layer calls it after
// Complete so that a replayed callback cannot reuse a consumed verifier.
func (p *OIDCProvider) ClearState(w http.ResponseWriter) { p.state.Clear(w) }
// ---------------------------------------------------------------- helpers --
// randomVerifier mints a PKCE verifier.
func randomVerifier() (string, error) {
return randomToken(32)
}
// randomState mints an anti-forgery state value.
func randomState() (string, error) {
return randomToken(24)
}
// randomNonce mints an ID token nonce.
func randomNonce() (string, error) {
return randomToken(16)
}
// randomToken returns a URL-safe random string of n bytes.
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("read random: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// codeChallenge returns the S256 PKCE challenge for a verifier.
func codeChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// firstNonEmpty returns the first non-empty string.
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// urlQueryEscape percent-encodes a query parameter value.
func urlQueryEscape(s string) string {
var b strings.Builder
for _, b0 := range []byte(s) {
switch {
case b0 >= 'a' && b0 <= 'z', b0 >= 'A' && b0 <= 'Z', b0 >= '0' && b0 <= '9',
b0 == '-', b0 == '_', b0 == '.', b0 == '~':
b.WriteByte(b0)
default:
b.WriteByte('%')
b.WriteString(strings.ToUpper(hex.EncodeToString([]byte{b0})))
}
}
return b.String()
}
// insecureTLSConfig returns a TLS configuration that skips verification.
//
// It exists so that a development-only identity provider presenting a
// self-signed certificate can be used without wiring a CA into the image.
// config.Validate refuses to start when this is enabled outside development.
func insecureTLSConfig() *tls.Config {
//nolint:gosec // InsecureSkipVerify is intentional and gated by config.Validate.
return &tls.Config{InsecureSkipVerify: true}
}

572
internal/config/config.go Normal file
View file

@ -0,0 +1,572 @@
// Package config loads and validates every runtime setting from the
// environment.
//
// Configuration is environment-only on purpose. The Helm chart is the single
// source of truth for how a deployment is configured; letting the binary also
// read a config file would create a second source that operators forget about.
// Every value has a safe default except the secrets and the OIDC issuer, which
// must be supplied explicitly when auth is enabled.
package config
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// Environment prefixes, tried in order. This lets the Helm chart mount a
// ConfigMap under a distinct prefix without colliding with the pod's own
// environment, which is what makes one image usable across environments.
var envPrefixes = []string{"BANANAS_", "GO_BANANAS_"}
// Config is the fully resolved runtime configuration.
type Config struct {
// Environment is the deployment name, used in logs and the admin UI.
Environment string
// LogLevel is one of debug, info, warn, error.
LogLevel string
// LogFormat is json or text.
LogFormat string
// HTTPAddr is the listen address, host:port.
HTTPAddr string
// PublicBaseURL is the externally reachable origin, without a trailing
// slash. It is used to build absolute URLs in the runtime manifest and to
// validate OIDC redirect targets.
PublicBaseURL string
// TrustedProxies lists the CIDRs or IPs whose X-Forwarded-For headers may be
// believed. It is empty by default, which makes the service ignore
// forwarding headers entirely and log the direct peer.
TrustedProxies []string
// ReadTimeout and WriteTimeout bound a single request. WriteTimeout is
// generous because the runtime CSS endpoint is on the hot path of every page
// load in every consuming application.
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
// ShutdownTimeout is how long in-flight requests get to finish on SIGTERM.
ShutdownTimeout time.Duration
// CORSAllowedOrigins are the browser origins allowed to call the admin API
// from a different origin. The runtime API is always public and is not
// subject to this list.
CORSAllowedOrigins []string
// RateLimitPerMinute caps requests per client for the runtime API.
RateLimitPerMinute int
// DatabasePath is the SQLite file path. Use ":memory:" for tests.
DatabasePath string
// DatabaseBusyTimeout is how long a writer waits for the database lock
// before failing.
DatabaseBusyTimeout time.Duration
// DatabaseMaxConns bounds the connection pool. SQLite serialises writers, so
// a small pool with WAL is faster than a large one.
DatabaseMaxConns int
// AuthMode is oidc, dev, or none.
AuthMode string
// OIDCIssuer is the issuer URL, e.g. https://auth-dev.urpi.be/realms/urpi.
OIDCIssuer string
// OIDCClientID is the client identifier registered with the provider.
OIDCClientID string
// OIDCClientSecret authenticates the confidential client.
OIDCClientSecret string
// OIDCScopes are requested in addition to openid.
OIDCScopes []string
// OIDCRedirectURL overrides the computed callback URL. It is only needed
// when the public URL cannot be derived, for example behind a path-based
// ingress.
OIDCRedirectURL string
// OIDCPostLogoutRedirectURL is where the provider sends the browser after
// logout. It must be an allowed post-logout target on the provider side.
OIDCPostLogoutRedirectURL string
// OIDCAdminGroups are the group names that map to the admin role.
OIDCAdminGroups []string
// OIDCEditorGroups are the group names that map to the editor role.
OIDCEditorGroups []string
// OIDCViewerGroups are the group names that map to the viewer role.
OIDCViewerGroups []string
// OIDCSkipTLSVerify disables provider certificate verification. It exists
// only so that a self-signed local provider can be used during development
// and it is refused outside the development environment.
OIDCSkipTLSVerify bool
// SessionSecret signs the session cookie. It must be at least 32 bytes and
// is required whenever auth is enabled.
SessionSecret string
// SessionCookieName is the cookie carrying the session.
SessionCookieName string
// SessionSecure marks the session cookie Secure.
SessionSecure bool
// SessionTTL is the session lifetime.
SessionTTL time.Duration
// SessionSameSite is Lax, Strict or None.
SessionSameSite string
// RuntimePublic controls whether the runtime API requires a credential. It
// is enabled by default because a theming service's whole purpose is to be
// reachable from browsers and CDN caches.
RuntimePublic bool
// RuntimeCacheControl is the Cache-Control header sent on runtime assets.
// It is tuned for aggressive CDN caching with a revalidation window that
// matches how often themes are published.
RuntimeCacheControl string
// RuntimeCDNBaseURL, when set, is used as the absolute base in the manifest
// so consumers fetch assets from a CDN rather than from this service.
RuntimeCDNBaseURL string
// SeedOnStart reconciles the built-in themes at start-up.
SeedOnStart bool
// DevFallbackRole is the role granted by the development auth bypass.
DevFallbackRole string
// DevUsers maps a development email to a role, overriding DevFallbackRole.
DevUsers map[string]string
// EnableMetrics exposes /metrics in Prometheus format.
EnableMetrics bool
// EnableProfiler exposes the net/http/pprof handlers under /debug/pprof.
EnableProfiler bool
// StaticDir is the directory holding the built admin UI. When empty the UI
// is not served.
StaticDir string
// SDKDir is the directory holding the built runtime SDK bundles.
SDKDir string
// BasePath prefixes every route, for deployments behind a path-based ingress.
BasePath string
// ephemeralSessionSecret records that the secret was generated rather than
// supplied. It is not part of the configuration surface; the application reads
// it only to decide whether to warn.
ephemeralSessionSecret bool
}
// Auth mode values.
const (
AuthModeOIDC = "oidc"
AuthModeDev = "dev"
AuthModeNone = "none"
)
// Defaults returns a Config populated with production-safe defaults. Load
// applies the environment on top of this value.
func Defaults() Config {
return Config{
Environment: "development",
LogLevel: "info",
LogFormat: "text",
HTTPAddr: ":8080",
PublicBaseURL: "http://localhost:8080",
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
ShutdownTimeout: 25 * time.Second,
CORSAllowedOrigins: []string{},
RateLimitPerMinute: 3000,
DatabasePath: "./data/go-bananas.db",
DatabaseBusyTimeout: 5 * time.Second,
DatabaseMaxConns: 8,
AuthMode: AuthModeDev,
OIDCScopes: []string{"profile", "email", "groups"},
SessionCookieName: "go-bananas-session",
SessionSecure: false,
SessionTTL: 8 * time.Hour,
SessionSameSite: "Lax",
RuntimePublic: true,
RuntimeCacheControl: "public, max-age=60, s-maxage=300, stale-while-revalidate=86400",
SeedOnStart: true,
DevFallbackRole: "admin",
DevUsers: map[string]string{},
EnableMetrics: true,
EnableProfiler: false,
StaticDir: "./web/dist",
SDKDir: "./sdk/dist",
}
}
// Load reads configuration from the environment and validates it.
func Load() (Config, error) {
return loadFrom(os.LookupEnv)
}
// loadFrom is the testable core of Load.
func loadFrom(lookup func(string) (string, bool)) (Config, error) {
cfg := Defaults()
get := func(name string) (string, bool) {
for _, prefix := range envPrefixes {
if v, ok := lookup(prefix + name); ok {
return v, true
}
}
return "", false
}
str := func(name string, target *string) {
if v, ok := get(name); ok {
*target = strings.TrimSpace(v)
}
}
cfg.Environment = firstNonEmpty(cfg.Environment,
func() (string, bool) { return get("ENVIRONMENT") },
func() (string, bool) { return get("ENV") },
)
str("LOG_LEVEL", &cfg.LogLevel)
str("LOG_FORMAT", &cfg.LogFormat)
str("HTTP_ADDR", &cfg.HTTPAddr)
str("PUBLIC_BASE_URL", &cfg.PublicBaseURL)
str("BASE_PATH", &cfg.BasePath)
str("STATIC_DIR", &cfg.StaticDir)
str("SDK_DIR", &cfg.SDKDir)
dur := func(name string, target *time.Duration) error {
v, ok := get(name)
if !ok {
return nil
}
parsed, err := time.ParseDuration(strings.TrimSpace(v))
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
return nil
}
num := func(name string, target *int) error {
v, ok := get(name)
if !ok {
return nil
}
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
return nil
}
boolean := func(name string, target *bool) error {
v, ok := get(name)
if !ok {
return nil
}
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
return nil
}
list := func(name string, target *[]string) {
if v, ok := get(name); ok {
*target = splitList(v)
}
}
var err error
for _, f := range []func() error{
func() error { return dur("HTTP_READ_TIMEOUT", &cfg.ReadTimeout) },
func() error { return dur("HTTP_WRITE_TIMEOUT", &cfg.WriteTimeout) },
func() error { return dur("HTTP_IDLE_TIMEOUT", &cfg.IdleTimeout) },
func() error { return dur("SHUTDOWN_TIMEOUT", &cfg.ShutdownTimeout) },
func() error { return dur("SESSION_TTL", &cfg.SessionTTL) },
func() error { return dur("DB_BUSY_TIMEOUT", &cfg.DatabaseBusyTimeout) },
func() error { return num("RATE_LIMIT_PER_MINUTE", &cfg.RateLimitPerMinute) },
func() error { return num("DB_MAX_CONNS", &cfg.DatabaseMaxConns) },
func() error { return boolean("RUNTIME_PUBLIC", &cfg.RuntimePublic) },
func() error { return boolean("SEED_ON_START", &cfg.SeedOnStart) },
func() error { return boolean("ENABLE_METRICS", &cfg.EnableMetrics) },
func() error { return boolean("ENABLE_PROFILER", &cfg.EnableProfiler) },
func() error { return boolean("SESSION_SECURE", &cfg.SessionSecure) },
func() error { return boolean("OIDC_SKIP_TLS_VERIFY", &cfg.OIDCSkipTLSVerify) },
} {
if err = f(); err != nil {
return cfg, err
}
}
list("TRUSTED_PROXIES", &cfg.TrustedProxies)
list("CORS_ALLOWED_ORIGINS", &cfg.CORSAllowedOrigins)
list("OIDC_SCOPES", &cfg.OIDCScopes)
list("OIDC_ADMIN_GROUPS", &cfg.OIDCAdminGroups)
list("OIDC_EDITOR_GROUPS", &cfg.OIDCEditorGroups)
list("OIDC_VIEWER_GROUPS", &cfg.OIDCViewerGroups)
str("AUTH_MODE", &cfg.AuthMode)
str("OIDC_ISSUER", &cfg.OIDCIssuer)
str("OIDC_CLIENT_ID", &cfg.OIDCClientID)
str("OIDC_CLIENT_SECRET", &cfg.OIDCClientSecret)
str("OIDC_REDIRECT_URL", &cfg.OIDCRedirectURL)
str("OIDC_POST_LOGOUT_REDIRECT_URL", &cfg.OIDCPostLogoutRedirectURL)
str("SESSION_SECRET", &cfg.SessionSecret)
str("SESSION_COOKIE_NAME", &cfg.SessionCookieName)
str("SESSION_SAMESITE", &cfg.SessionSameSite)
str("DEV_FALLBACK_ROLE", &cfg.DevFallbackRole)
str("RUNTIME_CACHE_CONTROL", &cfg.RuntimeCacheControl)
str("RUNTIME_CDN_BASE_URL", &cfg.RuntimeCDNBaseURL)
str("DATABASE_PATH", &cfg.DatabasePath)
if v, ok := get("DEV_USERS"); ok {
cfg.DevUsers = parseDevUsers(v)
}
// A development run should not require the operator to invent a 32-byte
// secret before the first request. An ephemeral one is generated instead,
// which is safe because nothing durable is signed with it: sessions simply do
// not survive a restart, which a local run never notices.
if cfg.AuthMode != AuthModeNone && cfg.SessionSecret == "" && cfg.IsDevelopment() {
generated, genErr := randomSecret()
if genErr != nil {
return cfg, genErr
}
cfg.SessionSecret = generated
cfg.ephemeralSessionSecret = true
}
return cfg, cfg.Validate()
}
// EphemeralSessionSecret reports whether the session secret was generated rather
// than supplied. The caller logs it as a warning, because it means sessions
// survive only until the process restarts.
func (c Config) EphemeralSessionSecret() bool { return c.ephemeralSessionSecret }
// randomSecret returns a 48-byte cryptographically random value, base64 encoded.
func randomSecret() (string, error) {
buf := make([]byte, 48)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate session secret: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// Validate checks the configuration for internal consistency. It returns every
// problem it finds rather than only the first, because fixing configuration one
// error per deploy is a miserable loop.
func (c Config) Validate() error {
var problems []string
add := func(format string, args ...any) {
problems = append(problems, fmt.Sprintf(format, args...))
}
switch c.AuthMode {
case AuthModeOIDC, AuthModeDev, AuthModeNone:
default:
add("AUTH_MODE %q must be one of oidc, dev or none", c.AuthMode)
}
if c.AuthMode == AuthModeOIDC {
if c.OIDCIssuer == "" {
add("OIDC_ISSUER is required when AUTH_MODE=oidc")
} else if parsed, err := url.Parse(c.OIDCIssuer); err != nil {
add("OIDC_ISSUER is not a valid URL: %v", err)
} else if parsed.Scheme != "https" && !isLocalHost(parsed.Hostname()) {
add("OIDC_ISSUER must use https outside local development, got %q", parsed.Scheme)
}
if c.OIDCClientID == "" {
add("OIDC_CLIENT_ID is required when AUTH_MODE=oidc")
}
if c.OIDCClientSecret == "" {
add("OIDC_CLIENT_SECRET is required when AUTH_MODE=oidc")
}
if c.OIDCSkipTLSVerify && !c.IsDevelopment() {
add("OIDC_SKIP_TLS_VERIFY is refused outside development; it disables certificate verification on the identity provider connection")
}
}
if c.AuthMode != AuthModeNone {
if len(c.SessionSecret) < 32 {
add("SESSION_SECRET must be at least 32 bytes when auth is enabled, got %d", len(c.SessionSecret))
}
}
if c.PublicBaseURL != "" {
parsed, err := url.Parse(c.PublicBaseURL)
switch {
case err != nil:
add("PUBLIC_BASE_URL is not a valid URL: %v", err)
case parsed.Scheme == "":
add("PUBLIC_BASE_URL must include a scheme, got %q", c.PublicBaseURL)
case parsed.Host == "":
add("PUBLIC_BASE_URL must include a host, got %q", c.PublicBaseURL)
}
}
if c.BasePath != "" && !strings.HasPrefix(c.BasePath, "/") {
add("BASE_PATH must start with a slash, got %q", c.BasePath)
}
switch strings.ToLower(c.SessionSameSite) {
case "lax", "strict", "none":
default:
add("SESSION_SAMESITE %q must be lax, strict or none", c.SessionSameSite)
}
if strings.EqualFold(c.SessionSameSite, "none") && !c.SessionSecure {
// Browsers reject SameSite=None without Secure, so this combination
// silently produces a session that never survives a redirect.
add("SESSION_SAMESITE=none requires SESSION_SECURE=true")
}
if !c.SessionSecure && !c.IsDevelopment() && c.PublicBaseURL != "" {
if strings.HasPrefix(c.PublicBaseURL, "https://") {
add("SESSION_SECURE must be true when PUBLIC_BASE_URL is https")
}
}
if c.DatabasePath == "" {
add("DATABASE_PATH must not be empty")
}
if c.DatabaseMaxConns < 1 {
add("DB_MAX_CONNS must be at least 1, got %d", c.DatabaseMaxConns)
}
if c.RateLimitPerMinute < 0 {
add("RATE_LIMIT_PER_MINUTE must not be negative")
}
if !c.IsDevelopment() && c.AuthMode == AuthModeDev {
add("AUTH_MODE=dev is refused outside development; it grants admin to anyone who can reach the service")
}
if c.AuthMode == AuthModeNone && !c.IsDevelopment() {
add("AUTH_MODE=none is refused outside development")
}
if len(problems) > 0 {
return errors.New("invalid configuration:\n - " + strings.Join(problems, "\n - "))
}
return nil
}
// IsDevelopment reports whether the service is running in development.
func (c Config) IsDevelopment() bool {
switch strings.ToLower(c.Environment) {
case "development", "dev", "local", "test", "testing", "ci":
return true
default:
return false
}
}
// RedirectURL returns the OIDC callback URL, derived from the public base when
// not overridden.
func (c Config) RedirectURL() string {
if c.OIDCRedirectURL != "" {
return c.OIDCRedirectURL
}
return c.origin() + c.path("/auth/callback")
}
// PostLogoutRedirectURL returns the OIDC end-session target.
func (c Config) PostLogoutRedirectURL() string {
if c.OIDCPostLogoutRedirectURL != "" {
return c.OIDCPostLogoutRedirectURL
}
return c.origin() + c.path("/")
}
// Origin returns the public origin with no trailing slash.
func (c Config) Origin() string { return c.origin() }
// origin normalises the public base URL.
func (c Config) origin() string {
return strings.TrimRight(c.PublicBaseURL, "/")
}
// path prefixes a route with the configured base path.
func (c Config) path(route string) string {
base := strings.TrimRight(c.BasePath, "/")
if base == "" {
return route
}
return base + route
}
// Path exposes base-path prefixing to the HTTP layer.
func (c Config) Path(route string) string { return c.path(route) }
// CDNBasis returns the absolute base that runtime asset URLs are built from,
// honouring the CDN override.
func (c Config) CDNBasis() string {
if c.RuntimeCDNBaseURL != "" {
return strings.TrimRight(c.RuntimeCDNBaseURL, "/")
}
return c.origin() + c.path("/rt/v1")
}
// splitList parses a comma or whitespace separated environment list.
func splitList(raw string) []string {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == '\n' || r == '\t' || r == ' '
})
out := make([]string, 0, len(fields))
for _, f := range fields {
if trimmed := strings.TrimSpace(f); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// parseDevUsers parses the development role map in "email=role,email=role" form.
func parseDevUsers(raw string) map[string]string {
out := make(map[string]string)
for _, pair := range splitList(raw) {
email, role, ok := strings.Cut(pair, "=")
if !ok {
continue
}
email = strings.ToLower(strings.TrimSpace(email))
role = strings.ToLower(strings.TrimSpace(role))
if email == "" || role == "" {
continue
}
out[email] = role
}
return out
}
// firstNonEmpty returns the first non-empty value from a default and a list of
// candidate environment lookups.
func firstNonEmpty(def string, lookups ...func() (string, bool)) string {
for _, lookup := range lookups {
if v, ok := lookup(); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return def
}
// isLocalHost reports whether host refers to the local machine.
func isLocalHost(host string) bool {
switch strings.ToLower(host) {
case "localhost", "127.0.0.1", "::1", "keycloak", "idp", "auth":
return true
default:
return strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".localhost")
}
}
// Redacted returns a copy of the configuration with secrets masked, suitable
// for logging or for the diagnostics endpoint.
func (c Config) Redacted() Config {
mask := func(s string) string {
if s == "" {
return ""
}
return "***redacted***"
}
c.OIDCClientSecret = mask(c.OIDCClientSecret)
c.SessionSecret = mask(c.SessionSecret)
return c
}

466
internal/domain/domain.go Normal file
View file

@ -0,0 +1,466 @@
// Package domain holds the core entities of go-bananas. It has no knowledge of
// HTTP, SQL or any delivery mechanism: it is the shared vocabulary that the
// store, the HTTP API and the token engine all agree on.
//
// Coding standard: types in this package are plain data. Behaviour that is
// genuinely domain logic (not transport or persistence) is allowed, but it must
// be pure and deterministic.
package domain
import (
"errors"
"fmt"
"regexp"
"strings"
"time"
)
// TimeFormat is the single wire format used for every timestamp crossing a
// process boundary. RFC 3339 with second precision keeps SQLite TEXT columns
// lexicographically sortable, which the store relies on for ORDER BY.
const TimeFormat = "2006-01-02T15:04:05Z07:00"
// ThemeStatus is the publication state of a theme.
type ThemeStatus string
const (
// StatusDraft is editable by editors but invisible to runtime consumers.
StatusDraft ThemeStatus = "draft"
// StatusPublished is served to runtime consumers.
StatusPublished ThemeStatus = "published"
// StatusArchived is hidden from listings and runtime by default, but the
// last published revision stays resolvable for pinned consumers.
StatusArchived ThemeStatus = "archived"
)
// Valid reports whether s is a known status.
func (s ThemeStatus) Valid() bool {
switch s {
case StatusDraft, StatusPublished, StatusArchived:
return true
default:
return false
}
}
// Publishable lists the statuses a theme may be transitioned into.
func (s ThemeStatus) Publishable() bool {
return s == StatusDraft || s == StatusPublished || s == StatusArchived
}
// Appearance selects a colour scheme from a theme.
type Appearance string
const (
// AppearanceLight selects the light colour scheme.
AppearanceLight Appearance = "light"
// AppearanceDark selects the dark colour scheme.
AppearanceDark Appearance = "dark"
)
// Valid reports whether a is a known appearance.
func (a Appearance) Valid() bool { return a == AppearanceLight || a == AppearanceDark }
// slugPattern constrains theme slugs to something safe for URLs, filenames and
// CSS selectors without quoting.
var slugPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$`)
// ValidateSlug returns an error if slug is not a valid theme slug.
func ValidateSlug(slug string) error {
if slug == "" {
return errors.New("slug must not be empty")
}
if len(slug) > 64 {
return errors.New("slug must be at most 64 characters")
}
if !slugPattern.MatchString(slug) {
return fmt.Errorf("slug %q must match %s", slug, slugPattern)
}
return nil
}
// ReservedSlugs may not be used by user-created themes because the runtime
// delivery API exposes them as fixed paths.
var ReservedSlugs = map[string]struct{}{
"api": {},
"rt": {},
"admin": {},
"healthz": {},
"readyz": {},
"livez": {},
"metrics": {},
"assets": {},
"sdk": {},
}
// ValidateAppearance parses and validates a raw appearance string, falling back
// to light when empty.
func ParseAppearance(raw string) (Appearance, error) {
if raw == "" {
return AppearanceLight, nil
}
a := Appearance(strings.ToLower(raw))
if !a.Valid() {
return "", fmt.Errorf("unknown appearance %q: want %q or %q", raw, AppearanceLight, AppearanceDark)
}
return a, nil
}
// ThemeMeta is free-form, human-supplied metadata. It is deliberately opaque to
// the engine so consumers can carry whatever they need (author, homepage,
// licence, tags) without a schema migration.
type ThemeMeta map[string]any
// Theme is the aggregate root: an identified, versioned bundle of design
// tokens plus the metadata needed to present and govern it.
type Theme struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status ThemeStatus `json:"status"`
DefaultAppearance Appearance `json:"defaultAppearance"`
// Tokens is the canonical design-token document. See the tokenengine
// package for the schema, validation rules and rendering.
Tokens TokenDoc `json:"tokens"`
Meta ThemeMeta `json:"meta,omitempty"`
// System marks built-in themes that ship with the binary. They may be
// duplicated and re-published but never deleted, so consumers pinned to a
// built-in slug never break on upgrade.
System bool `json:"system"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CreatedBy string `json:"createdBy,omitempty"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
// Clone returns a deep-enough copy that mutating the result's token leaves or
// meta map cannot affect the receiver. Token leaf values are treated as opaque
// scalars, which is how the engine guarantees they are.
func (t *Theme) Clone() *Theme {
if t == nil {
return nil
}
out := *t
out.Tokens = t.Tokens.Clone()
if t.Meta != nil {
out.Meta = make(ThemeMeta, len(t.Meta))
for k, v := range t.Meta {
out.Meta[k] = v
}
}
return &out
}
// ThemeVersion is an immutable snapshot of a theme taken on every mutation.
type ThemeVersion struct {
ThemeID string `json:"themeId"`
Version int64 `json:"version"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status ThemeStatus `json:"status"`
DefaultAppearance Appearance `json:"defaultAppearance"`
Tokens TokenDoc `json:"tokens"`
Message string `json:"message,omitempty"`
Author string `json:"author,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// TokenDoc is a design-token document. The outer map is a set of token groups;
// each group is a nested tree whose leaves carry a "$type" and a "$value". It is
// modelled as a plain map so that consumers can extend a theme without an API
// change, while the engine enforces the parts it needs to render CSS.
type TokenDoc map[string]any
// Clone deep-copies the token document.
func (d TokenDoc) Clone() TokenDoc {
if d == nil {
return nil
}
out := make(TokenDoc, len(d))
for k, v := range d {
out[k] = cloneAny(v)
}
return out
}
func cloneAny(v any) any {
switch t := v.(type) {
case TokenDoc:
return t.Clone()
case map[string]any:
out := make(map[string]any, len(t))
for k, vv := range t {
out[k] = cloneAny(vv)
}
return out
case []any:
out := make([]any, len(t))
for i, vv := range t {
out[i] = cloneAny(vv)
}
return out
default:
return v
}
}
// ThemeQuery narrows a theme listing. The zero value matches every theme.
type ThemeQuery struct {
// Search matches slug, name or description, case-insensitively.
Search string
// Status filters by publication state. Empty matches all states.
Status ThemeStatus
// System filters on the built-in flag. Nil matches both.
System *bool
// Tag requires the theme meta to contain the given tag.
Tag string
Limit int
Offset int
// Sort is one of "updated", "created", "name", "slug".
Sort string
// Order is "asc" or "desc".
Order string
}
// SortFields is the allow-list of sortable columns. Values are compared against
// a fixed set so that caller input never reaches the SQL statement verbatim.
var SortFields = map[string]string{
"updated": "updated_at",
"created": "created_at",
"name": "name",
"slug": "slug",
}
// ThemePage is a page of themes plus the total match count.
type ThemePage struct {
Items []*Theme `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// Role is a coarse authorisation level, resolved from the identity provider.
type Role string
const (
// RoleViewer may read published themes.
RoleViewer Role = "viewer"
// RoleEditor may create, edit and publish themes.
RoleEditor Role = "editor"
// RoleAdmin may additionally manage users, API keys and instance settings.
RoleAdmin Role = "admin"
)
// Rank returns a comparable privilege level.
func (r Role) Rank() int {
switch r {
case RoleAdmin:
return 3
case RoleEditor:
return 2
case RoleViewer:
return 1
default:
return 0
}
}
// AtLeast reports whether r carries at least the privilege of want.
func (r Role) AtLeast(want Role) bool { return r.Rank() >= want.Rank() }
// ParseRole maps a raw claim value onto a Role, defaulting to viewer so that an
// unexpected claim can never grant more than read access.
func ParseRole(raw string) Role {
switch Role(strings.ToLower(strings.TrimSpace(raw))) {
case RoleAdmin:
return RoleAdmin
case RoleEditor:
return RoleEditor
case RoleViewer:
return RoleViewer
default:
return RoleViewer
}
}
// Identity is the authenticated caller, normalised across every auth mode
// (OIDC session, API key, or development bypass).
type Identity struct {
Subject string `json:"subject"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Roles []Role `json:"roles"`
Method string `json:"method"`
// ExpiresAt is a pointer because omitempty does not work on time.Time: a zero
// time is not the empty value to encoding/json, so a plain time.Time here
// serialised as "0001-01-01T00:00:00Z" on every identity that has no expiry.
// That is a real value to a client, and an API key with no expiry is normal.
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
// UserPreferences is what one person has chosen for their own interface.
//
// The preference names a published theme rather than a copy of it, so editing or
// unpublishing that theme is reflected everywhere without touching a row here. An
// empty SelectedTheme means nothing has been chosen and the client should apply
// its own default; SelectedAppearance is then ignored.
type UserPreferences struct {
SelectedTheme string `json:"selectedTheme"`
SelectedAppearance Appearance `json:"selectedAppearance"`
}
// Validate reports whether p is storable. An empty theme is the absence of a
// choice and is always valid; a named theme must be a syntactically valid slug and
// the appearance must be one this build knows.
func (p UserPreferences) Validate() error {
if p.SelectedTheme == "" {
if p.SelectedAppearance != "" && !p.SelectedAppearance.Valid() {
return fmt.Errorf("%w: selectedAppearance %q is not light or dark", ErrInvalid, p.SelectedAppearance)
}
return nil
}
if err := ValidateSlug(p.SelectedTheme); err != nil {
// ValidateSlug reports a bare error because it is also used for messages
// that are not about a request. Wrapping it here is what makes a bad slug
// a 400 through writeProblem rather than a 500.
return fmt.Errorf("%w: %s", ErrInvalid, err.Error())
}
if p.SelectedAppearance != "" && !p.SelectedAppearance.Valid() {
return fmt.Errorf("%w: selectedAppearance %q is not light or dark", ErrInvalid, p.SelectedAppearance)
}
return nil
}
// HighestRole returns the caller's most privileged role.
func (i *Identity) HighestRole() Role {
var best Role
for _, r := range i.Roles {
if r.Rank() > best.Rank() {
best = r
}
}
if best == "" {
return RoleViewer
}
return best
}
// HasRole reports whether the identity carries the given role.
func (i *Identity) HasRole(r Role) bool {
for _, have := range i.Roles {
if have == r {
return true
}
}
return false
}
// DisplayName returns the friendliest available label for the identity.
func (i *Identity) DisplayName() string {
if i == nil {
return "anonymous"
}
switch {
case i.Name != "":
return i.Name
case i.Email != "":
return i.Email
case i.Subject != "":
return i.Subject
default:
return "anonymous"
}
}
// Setting is one instance-level configuration value.
//
// Settings live in the database rather than in the environment because they are
// editable from the admin UI while the service is running. Changing the default
// theme takes effect on the next request, with no restart.
type Setting struct {
Key string `json:"key"`
Value string `json:"value"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
// Setting keys. They are constants rather than free strings so a typo is a compile
// error, and so the keys in the database have one definition.
const (
// SettingDefaultTheme is the slug served when a consumer names no theme.
SettingDefaultTheme = "default.theme"
// SettingDefaultAppearance is the appearance used when the default theme
// declares none.
SettingDefaultAppearance = "default.appearance"
// SettingRuntimeAPIBase overrides the runtime API base URL advertised in the
// manifest, for a deployment that serves it from a CDN.
SettingRuntimeAPIBase = "runtime.apiBase"
// SettingRuntimePublic lists the origins allowed to embed the runtime SDK.
SettingRuntimePublic = "runtime.public"
)
// AuditAction is the kind of change an audit entry records.
type AuditAction string
// The audit actions this build records.
const (
ActionThemeCreate AuditAction = "theme.create"
ActionThemeUpdate AuditAction = "theme.update"
ActionThemeDelete AuditAction = "theme.delete"
ActionThemePublish AuditAction = "theme.publish"
ActionThemeArchive AuditAction = "theme.archive"
ActionThemeDuplicate AuditAction = "theme.duplicate"
ActionThemeRollback AuditAction = "theme.rollback"
ActionThemeImport AuditAction = "theme.import"
ActionAuthLogin AuditAction = "auth.login"
ActionAuthLogout AuditAction = "auth.logout"
)
// AuditEntry is one recorded action.
//
// The audit log is append-only and is the record of who changed what. The actor is
// taken from the authenticated identity at the point of writing rather than from
// the request body, so it cannot be forged by a client.
type AuditEntry struct {
ID int64 `json:"id"`
At time.Time `json:"at"`
Actor string `json:"actor"`
ActorEmail string `json:"actorEmail,omitempty"`
Action AuditAction `json:"action"`
EntityType string `json:"entityType,omitempty"`
EntityID string `json:"entityId,omitempty"`
Summary string `json:"summary,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
RemoteIP string `json:"remoteIp,omitempty"`
}
// Error sentinels.
//
// Handlers match on these with errors.Is rather than comparing strings, so the
// mapping from a domain failure to an HTTP status lives in exactly one place and a
// wrapped error still classifies correctly.
var (
// ErrInvalid means the request was malformed or failed validation.
ErrInvalid = errors.New("invalid input")
// ErrNotFound means the addressed thing does not exist.
ErrNotFound = errors.New("not found")
// ErrUnauthorized means the caller is not authenticated, or the credential
// presented did not identify anyone.
ErrUnauthorized = errors.New("unauthorized")
// ErrForbidden means the caller is known but not permitted.
ErrForbidden = errors.New("forbidden")
// ErrConflict means the change collides with existing state, such as a slug
// that is already taken or a stale base version.
ErrConflict = errors.New("conflict")
// ErrImmutable means the target is one this service owns and will not let an
// operator change, such as a built-in theme. It is distinct from ErrForbidden:
// the caller is allowed to ask, and the answer is no.
ErrImmutable = errors.New("immutable")
)
// ErrNotConfigured is returned when a feature is unavailable because of how the
// service was configured, as distinct from a request that was made wrongly.
var ErrNotConfigured = errors.New("not configured")

931
internal/httpapi/admin.go Normal file
View file

@ -0,0 +1,931 @@
package httpapi
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/store"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// AdminHandlers serves the authenticated administrative API.
type AdminHandlers struct {
store *store.Store
cache *ThemeCache
log Logger
// sessionSecretCookieName is used when reporting session state.
version string
started time.Time
}
// NewAdminHandlers builds the admin handler set.
func NewAdminHandlers(st *store.Store, cache *ThemeCache, log Logger, version string) *AdminHandlers {
return &AdminHandlers{
store: st,
cache: cache,
log: log,
version: version,
started: time.Now(),
}
}
// ------------------------------------------------------------- meta & auth --
// Meta returns build and instance information the UI displays.
func (h *AdminHandlers) Meta(w http.ResponseWriter, r *http.Request) {
settings, err := h.store.AllSettings(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{
"version": h.version,
"uptime": time.Since(h.started).Round(time.Second).String(),
"environment": settings["environment"].Value,
"settings": settings,
})
}
// listThemes returns a page of themes.
func (h *AdminHandlers) listThemes(w http.ResponseWriter, r *http.Request) {
q := domain.ThemeQuery{
Search: strings.TrimSpace(r.URL.Query().Get("q")),
Status: domain.ThemeStatus(strings.TrimSpace(r.URL.Query().Get("status"))),
Tag: strings.TrimSpace(r.URL.Query().Get("tag")),
Limit: queryInt(r, "limit", 50, 1, 200),
Offset: queryInt(r, "offset", 0, 0, 1<<20),
Sort: r.URL.Query().Get("sort"),
Order: r.URL.Query().Get("order"),
}
if q.Status != "" && !q.Status.Valid() {
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"status must be one of draft, published, archived", nil)
return
}
if raw := r.URL.Query().Get("system"); raw != "" {
value := raw == "true" || raw == "1"
q.System = &value
}
page, err := h.store.List(r.Context(), q)
if err != nil {
writeProblem(w, r, err)
return
}
// A listing does not need token documents; they are the bulk of each row.
writeJSON(w, r, http.StatusOK, map[string]any{
"items": page.Items,
"total": page.Total,
"limit": page.Limit,
"offset": page.Offset,
})
}
// getTheme returns one theme with its full token document.
func (h *AdminHandlers) getTheme(w http.ResponseWriter, r *http.Request) {
theme, err := h.store.Get(r.Context(), chi.URLParam(r, "slug"))
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, theme)
}
// themeMutation is the request body for create and update.
type themeMutation struct {
Slug *string `json:"slug"`
Name *string `json:"name"`
Description *string `json:"description"`
Status *string `json:"status"`
DefaultAppearance *string `json:"defaultAppearance"`
Tokens *domain.TokenDoc `json:"tokens"`
Meta *domain.ThemeMeta `json:"meta"`
Message string `json:"message"`
// BaseVersion enables optimistic concurrency: the write is rejected when the
// theme has moved on since the editor loaded it, which stops two people
// editing the same theme from silently overwriting each other.
BaseVersion int64 `json:"baseVersion"`
}
// createTheme creates a theme.
func (h *AdminHandlers) createTheme(w http.ResponseWriter, r *http.Request) {
var body themeMutation
if err := decodeJSON(r, &body); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil)
return
}
if body.Name == nil || strings.TrimSpace(*body.Name) == "" {
writeError(w, r, http.StatusBadRequest, CodeValidation, "a theme name is required",
[]FieldError{{Field: "name", Message: "must not be empty"}})
return
}
if body.Slug == nil || strings.TrimSpace(*body.Slug) == "" {
writeError(w, r, http.StatusBadRequest, CodeValidation, "a theme slug is required",
[]FieldError{{Field: "slug", Message: "must not be empty"}})
return
}
if err := domain.ValidateSlug(*body.Slug); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(),
[]FieldError{{Field: "slug", Message: err.Error()}})
return
}
if _, reserved := domain.ReservedSlugs[*body.Slug]; reserved {
writeError(w, r, http.StatusBadRequest, CodeValidation,
fmt.Sprintf("slug %q is reserved by the runtime API", *body.Slug),
[]FieldError{{Field: "slug", Message: "reserved"}})
return
}
theme := &domain.Theme{
Slug: strings.TrimSpace(*body.Slug),
Name: strings.TrimSpace(*body.Name),
Status: domain.StatusDraft,
Tokens: domain.TokenDoc{},
DefaultAppearance: domain.AppearanceLight,
CreatedBy: mustIdentity(r.Context()).Subject,
UpdatedBy: mustIdentity(r.Context()).Subject,
}
if body.Description != nil {
theme.Description = *body.Description
}
if body.Status != nil {
theme.Status = domain.ThemeStatus(*body.Status)
}
if body.DefaultAppearance != nil {
theme.DefaultAppearance = domain.Appearance(*body.DefaultAppearance)
}
if body.Tokens != nil {
theme.Tokens = *body.Tokens
}
if body.Meta != nil {
theme.Meta = *body.Meta
}
// Publishing requires a valid token document; a draft does not, so that a
// theme can be built up incrementally without tripping validation.
if theme.Status == domain.StatusPublished {
if result := tokenengine.Validate(theme.Tokens); !result.Valid {
writeError(w, r, http.StatusBadRequest, CodeValidation,
"the theme cannot be published because its tokens are invalid",
issuesToFieldErrors(result.Errors()))
return
}
}
if err := h.store.Create(r.Context(), theme, body.Message); err != nil {
writeProblem(w, r, err)
return
}
created, err := h.store.Get(r.Context(), theme.Slug)
if err != nil {
writeProblem(w, r, err)
return
}
h.audit(r, domain.ActionThemeCreate, "theme", created.Slug, "created "+created.Slug, nil)
writeJSON(w, r, http.StatusCreated, created)
}
// updateTheme applies a partial update.
func (h *AdminHandlers) updateTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
var body themeMutation
if err := decodeJSON(r, &body); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil)
return
}
current, err := h.store.Get(r.Context(), slug)
if err != nil {
writeProblem(w, r, err)
return
}
if body.BaseVersion > 0 && current.Version != body.BaseVersion {
writeError(w, r, http.StatusConflict, CodeConflict,
fmt.Sprintf("this theme has changed since you loaded it (you have version %d, the current version is %d); reload before saving",
body.BaseVersion, current.Version), nil)
return
}
fields := store.UpdateFields{Message: body.Message, Editor: mustIdentity(r.Context()).Subject}
fields.Slug = body.Slug
fields.Name = body.Name
fields.Description = body.Description
if body.Status != nil {
status := domain.ThemeStatus(*body.Status)
fields.Status = &status
}
if body.DefaultAppearance != nil {
appearance := domain.Appearance(*body.DefaultAppearance)
fields.DefaultAppearance = &appearance
}
fields.Tokens = body.Tokens
fields.Meta = body.Meta
// Validate the resulting document before it can become a published theme.
// Merging happens in the store, so the check is performed on a projection of
// the current theme with the request applied.
projected := current.Clone()
if fields.Slug != nil {
projected.Slug = *fields.Slug
}
if fields.Name != nil {
projected.Name = *fields.Name
}
if fields.Description != nil {
projected.Description = *fields.Description
}
if fields.Status != nil {
projected.Status = *fields.Status
}
if fields.DefaultAppearance != nil {
projected.DefaultAppearance = *fields.DefaultAppearance
}
if fields.Tokens != nil {
projected.Tokens = *fields.Tokens
}
if projected.Status == domain.StatusPublished {
if result := tokenengine.Validate(projected.Tokens); !result.Valid {
writeError(w, r, http.StatusBadRequest, CodeValidation,
"the theme cannot be published because its tokens are invalid",
issuesToFieldErrors(result.Errors()))
return
}
}
updated, err := h.store.Update(r.Context(), slug, fields)
if err != nil {
writeProblem(w, r, err)
return
}
// A write invalidates the rendered artefacts; the next request re-renders.
h.cache.Invalidate(updated.Slug)
h.cache.Invalidate(slug)
h.audit(r, domain.ActionThemeUpdate, "theme", updated.Slug,
"updated "+updated.Slug+" to version "+strconv.FormatInt(updated.Version, 10), nil)
writeJSON(w, r, http.StatusOK, updated)
}
// deleteTheme removes a theme.
func (h *AdminHandlers) deleteTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if err := h.store.Delete(r.Context(), slug); err != nil {
writeProblem(w, r, err)
return
}
h.cache.Invalidate(slug)
h.audit(r, domain.ActionThemeDelete, "theme", slug, "deleted "+slug, nil)
w.WriteHeader(http.StatusNoContent)
}
// duplicateTheme copies a theme.
func (h *AdminHandlers) duplicateTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
var body struct {
Slug string `json:"slug"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil)
return
}
if strings.TrimSpace(body.Slug) == "" {
writeError(w, r, http.StatusBadRequest, CodeValidation,
"a slug for the copy is required",
[]FieldError{{Field: "slug", Message: "must not be empty"}})
return
}
copied, err := h.store.Duplicate(r.Context(), slug, strings.TrimSpace(body.Slug), mustIdentity(r.Context()).Subject)
if err != nil {
writeProblem(w, r, err)
return
}
h.audit(r, domain.ActionThemeDuplicate, "theme", copied.Slug,
"duplicated "+slug+" to "+copied.Slug, nil)
writeJSON(w, r, http.StatusCreated, copied)
}
// publishTheme transitions a theme's status.
func (h *AdminHandlers) publishTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
h.setStatus(w, r, slug, domain.StatusPublished, domain.ActionThemePublish)
}
// archiveTheme transitions a theme to archived.
func (h *AdminHandlers) archiveTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
h.setStatus(w, r, slug, domain.StatusArchived, domain.ActionThemeArchive)
}
// setStatus is the shared implementation behind the publish and archive routes.
func (h *AdminHandlers) setStatus(w http.ResponseWriter, r *http.Request, slug string, status domain.ThemeStatus, action domain.AuditAction) {
current, err := h.store.Get(r.Context(), slug)
if err != nil {
writeProblem(w, r, err)
return
}
if status == domain.StatusPublished {
if result := tokenengine.Validate(current.Tokens); !result.Valid {
writeError(w, r, http.StatusBadRequest, CodeValidation,
"the theme cannot be published because its tokens are invalid",
issuesToFieldErrors(result.Errors()))
return
}
}
message := "published " + slug
if status == domain.StatusArchived {
message = "archived " + slug
}
updated, err := h.store.Update(r.Context(), slug, store.UpdateFields{
Status: &status,
Message: message,
Editor: mustIdentity(r.Context()).Subject,
})
if err != nil {
writeProblem(w, r, err)
return
}
h.cache.Invalidate(slug)
h.audit(r, action, "theme", slug, message, map[string]any{"version": updated.Version})
writeJSON(w, r, http.StatusOK, updated)
}
// ---------------------------------------------------------------- history --
// listVersions returns a theme's history.
func (h *AdminHandlers) listVersions(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
versions, err := h.store.Versions(r.Context(), slug, queryInt(r, "limit", 50, 1, 200))
if err != nil {
writeProblem(w, r, err)
return
}
// History is only useful if it does not carry a full copy of every token
// document, so the summary omits them.
summaries := make([]map[string]any, 0, len(versions))
for _, v := range versions {
summaries = append(summaries, map[string]any{
"version": v.Version,
"name": v.Name,
"description": v.Description,
"status": v.Status,
"message": v.Message,
"author": v.Author,
"createdAt": v.CreatedAt,
})
}
writeJSON(w, r, http.StatusOK, map[string]any{"items": summaries})
}
// getVersion returns one historical snapshot including its tokens.
func (h *AdminHandlers) getVersion(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
version, err := versionParam(r)
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, err.Error(), nil)
return
}
snapshot, err := h.store.Version(r.Context(), slug, version)
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, snapshot)
}
// rollbackTheme restores a historical snapshot as a new version.
func (h *AdminHandlers) rollbackTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
version, err := versionParam(r)
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, err.Error(), nil)
return
}
restored, err := h.store.Rollback(r.Context(), slug, version, mustIdentity(r.Context()).Subject)
if err != nil {
writeProblem(w, r, err)
return
}
h.cache.Invalidate(slug)
h.audit(r, domain.ActionThemeRollback, "theme", slug,
"rolled "+slug+" back to version "+strconv.FormatInt(version, 10), nil)
writeJSON(w, r, http.StatusOK, restored)
}
// diffVersions compares two snapshots.
func (h *AdminHandlers) diffVersions(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
from, err := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "from must be an integer version", nil)
return
}
to, err := strconv.ParseInt(r.URL.Query().Get("to"), 10, 64)
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "to must be an integer version", nil)
return
}
diff, err := h.store.DiffVersions(r.Context(), slug, from, to)
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, diff)
}
// versionParam reads the {version} route parameter.
func versionParam(r *http.Request) (int64, error) {
raw := chi.URLParam(r, "version")
version, err := strconv.ParseInt(raw, 10, 64)
if err != nil || version < 1 {
return 0, fmt.Errorf("version must be a positive integer, got %q", raw)
}
return version, nil
}
// --------------------------------------------------------- import/export --
// exportTheme serves the portable token document.
//
// Import and export are exact inverses, which is what makes themes portable
// between environments and makes a migration a file copy rather than a project.
func (h *AdminHandlers) exportTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
theme, err := h.store.Get(r.Context(), slug)
if err != nil {
writeProblem(w, r, err)
return
}
document := tokenengine.ExportDocument(theme)
payload := map[string]any{
"$schema": tokenengine.SchemaID,
"slug": theme.Slug,
"name": theme.Name,
"status": theme.Status,
"version": theme.Version,
"tokens": document,
"meta": theme.Meta,
"exportedAt": time.Now().UTC().Format(domain.TimeFormat),
"exportedBy": mustIdentity(r.Context()).Subject,
}
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename=%q", theme.Slug+".theme.json"))
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, r, http.StatusOK, payload)
}
// importRequest is the body accepted by the import route.
type importRequest struct {
// Slug overrides the slug in the document. Required when the document has
// no slug of its own, and used to create a copy when it does.
Slug string `json:"slug"`
AsDraft bool `json:"asDraft"`
Overwrite bool `json:"overwrite"`
Name string `json:"name"`
Tokens *domain.TokenDoc `json:"tokens"`
Meta *domain.ThemeMeta `json:"meta"`
Message string `json:"message"`
}
// importTheme creates or replaces a theme from a portable document.
func (h *AdminHandlers) importTheme(w http.ResponseWriter, r *http.Request) {
var body importRequest
if err := decodeJSON(r, &body); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil)
return
}
if body.Tokens == nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, "a tokens document is required",
[]FieldError{{Field: "tokens", Message: "must not be empty"}})
return
}
if strings.TrimSpace(body.Slug) == "" {
writeError(w, r, http.StatusBadRequest, CodeValidation, "a slug is required",
[]FieldError{{Field: "slug", Message: "must not be empty"}})
return
}
if err := domain.ValidateSlug(body.Slug); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(),
[]FieldError{{Field: "slug", Message: err.Error()}})
return
}
result := tokenengine.Validate(*body.Tokens)
if !result.Valid {
writeError(w, r, http.StatusBadRequest, CodeValidation, "the imported tokens are invalid",
issuesToFieldErrors(result.Errors()))
return
}
identity := mustIdentity(r.Context()).Subject
status := domain.StatusPublished
if body.AsDraft {
status = domain.StatusDraft
}
_, err := h.store.Get(r.Context(), body.Slug)
switch {
case err == nil && !body.Overwrite:
writeError(w, r, http.StatusConflict, CodeConflict,
fmt.Sprintf("a theme with slug %q already exists; set overwrite to replace it", body.Slug), nil)
return
case err == nil && body.Overwrite:
fields := store.UpdateFields{
Tokens: body.Tokens,
Message: defaultString(body.Message, "imported"),
Editor: identity,
Status: &status,
}
if body.Name != "" {
fields.Name = &body.Name
}
if body.Meta != nil {
fields.Meta = body.Meta
}
updated, err := h.store.Update(r.Context(), body.Slug, fields)
if err != nil {
writeProblem(w, r, err)
return
}
h.cache.Invalidate(body.Slug)
h.audit(r, domain.ActionThemeImport, "theme", body.Slug,
"imported over "+body.Slug, map[string]any{"version": updated.Version})
writeJSON(w, r, http.StatusOK, updated)
return
case err != nil && !errors.Is(err, domain.ErrNotFound):
writeProblem(w, r, err)
return
}
theme := &domain.Theme{
Slug: strings.TrimSpace(body.Slug),
Name: defaultString(body.Name, body.Slug),
Status: status,
DefaultAppearance: domain.AppearanceLight,
Tokens: *body.Tokens,
CreatedBy: identity,
UpdatedBy: identity,
}
if body.Meta != nil {
theme.Meta = *body.Meta
}
if err := h.store.Create(r.Context(), theme, defaultString(body.Message, "imported")); err != nil {
writeProblem(w, r, err)
return
}
h.audit(r, domain.ActionThemeImport, "theme", theme.Slug, "imported "+theme.Slug, nil)
created, err := h.store.Get(r.Context(), theme.Slug)
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusCreated, created)
}
// ---------------------------------------------------------- token tooling --
// validateTokens runs the engine's validator without persisting anything. The
// editor calls it on every keystroke-with-blur, which is why it must not write.
func (h *AdminHandlers) validateTokens(w http.ResponseWriter, r *http.Request) {
var body struct {
Tokens domain.TokenDoc `json:"tokens"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil)
return
}
result := tokenengine.Validate(body.Tokens)
writeJSON(w, r, http.StatusOK, result)
}
// generatePalette produces a full ramp from a hue, so the editor's palette
// builder does not have to reimplement the ramp curve.
func (h *AdminHandlers) generatePalette(w http.ResponseWriter, r *http.Request) {
opts := tokenengine.DefaultRampOptions()
query := r.URL.Query()
if raw := query.Get("hue"); raw != "" {
if hue, err := strconv.ParseFloat(raw, 64); err == nil {
opts.Hue = hue
}
}
if raw := query.Get("saturation"); raw != "" {
if v, err := strconv.ParseFloat(raw, 64); err == nil {
opts.Saturation = v
}
}
if raw := query.Get("lightness"); raw != "" {
if v, err := strconv.ParseFloat(raw, 64); err == nil {
opts.Lightness = v
}
}
if raw := query.Get("hueSpread"); raw != "" {
if v, err := strconv.ParseFloat(raw, 64); err == nil {
opts.HueSpread = v
}
}
ramp := tokenengine.GenerateRamp(opts)
writeJSON(w, r, http.StatusOK, map[string]any{
"options": opts.Normalize(),
"shades": tokenengine.RampShades(),
"ramp": ramp,
})
}
// contrast checks a foreground against a background and reports the WCAG result,
// so the editor can flag an unreadable combination before it is saved.
func (h *AdminHandlers) contrast(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
foreground := strings.TrimSpace(query.Get("fg"))
background := strings.TrimSpace(query.Get("bg"))
if foreground == "" || background == "" {
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"both fg and bg are required as colour values", nil)
return
}
fg, err := tokenengine.ParseColor(foreground)
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "fg: "+err.Error(), nil)
return
}
bg, err := tokenengine.ParseColor(background)
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "bg: "+err.Error(), nil)
return
}
if !fg.Numeric || !bg.Numeric {
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"contrast can only be computed for colours the engine understands", nil)
return
}
ratio := tokenengine.ContrastRatio(fg, bg)
writeJSON(w, r, http.StatusOK, map[string]any{
"foreground": fg.Hex(),
"background": bg.Hex(),
"ratio": ratio,
"rounded": round2(ratio),
"level": wcagLevel(ratio),
"thresholds": map[string]float64{
"aaLarge": tokenengine.ContrastAALarge,
"aa": tokenengine.ContrastAA,
"aaa": tokenengine.ContrastAAA,
},
"bestForeground": tokenengine.BestForeground(bg).Hex(),
})
}
// wcagLevel names the highest WCAG conformance level a ratio satisfies.
func wcagLevel(ratio float64) string {
switch {
case ratio >= tokenengine.ContrastAAA:
return "aaa"
case ratio >= tokenengine.ContrastAA:
return "aa"
case ratio >= tokenengine.ContrastAALarge:
return "aa-large"
default:
return "fail"
}
}
// round2 rounds a float to two decimal places.
func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 }
// ------------------------------------------------------------------ users --
// listUsers returns known accounts.
func (h *AdminHandlers) listUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.store.ListUsers(r.Context(), queryInt(r, "limit", 100, 1, 500))
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{"items": users})
}
// listAudit returns the activity log.
func (h *AdminHandlers) listAudit(w http.ResponseWriter, r *http.Request) {
filter := store.AuditFilter{
Actor: strings.TrimSpace(r.URL.Query().Get("actor")),
Action: strings.TrimSpace(r.URL.Query().Get("action")),
EntityType: strings.TrimSpace(r.URL.Query().Get("entityType")),
EntityID: strings.TrimSpace(r.URL.Query().Get("entityId")),
Limit: queryInt(r, "limit", 50, 1, 500),
Offset: queryInt(r, "offset", 0, 0, 1<<20),
}
if raw := r.URL.Query().Get("since"); raw != "" {
if since, err := time.Parse(time.RFC3339, raw); err == nil {
filter.Since = &since
}
}
entries, total, err := h.store.ListAudit(r.Context(), filter)
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{
"items": entries, "total": total, "limit": filter.Limit, "offset": filter.Offset,
})
}
// listSettings returns instance settings.
func (h *AdminHandlers) listSettings(w http.ResponseWriter, r *http.Request) {
settings, err := h.store.AllSettings(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{"items": settings})
}
// updateSettings writes instance settings.
func (h *AdminHandlers) updateSettings(w http.ResponseWriter, r *http.Request) {
var body map[string]string
if err := decodeJSON(r, &body); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil)
return
}
actor := mustIdentity(r.Context()).Subject
updated := make([]string, 0, len(body))
for key, value := range body {
if !knownSetting(key) {
writeError(w, r, http.StatusBadRequest, CodeValidation,
"unknown setting "+key, []FieldError{{Field: key, Message: "not a known setting"}})
return
}
if err := h.store.SetSetting(r.Context(), key, value, actor); err != nil {
writeProblem(w, r, err)
return
}
updated = append(updated, key)
}
sortStrings(updated)
// The default theme is baked into the runtime manifest, so a change has to
// clear whatever the manifest cached.
h.cache.Flush()
writeJSON(w, r, http.StatusOK, map[string]any{"updated": updated})
}
// knownSetting reports whether a setting key is recognised.
func knownSetting(key string) bool {
switch key {
case domain.SettingDefaultTheme, domain.SettingDefaultAppearance,
domain.SettingRuntimePublic, domain.SettingRuntimeAPIBase:
return true
default:
return false
}
}
// stats returns dashboard counters.
func (h *AdminHandlers) stats(w http.ResponseWriter, r *http.Request) {
summary, err := h.store.Stats(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{
"store": summary,
"cache": h.cache.Stats(),
"runtime": map[string]any{"apiVersion": tokenengine.RuntimeAPIVersion},
})
}
// rebuildCache clears and re-warms the render cache.
func (h *AdminHandlers) rebuildCache(w http.ResponseWriter, r *http.Request) {
h.cache.Flush()
themes, err := h.store.ListRuntime(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
warmed := 0
for _, theme := range themes {
warmed += h.cache.Prefetch(theme, nil)
}
writeJSON(w, r, http.StatusOK, map[string]any{
"themes": len(themes), "artefacts": warmed,
})
}
// ------------------------------------------------------------------ audit --
// audit appends an audit entry. A failure is logged rather than returned,
// because losing an audit row must not fail the user's operation.
func (h *AdminHandlers) audit(r *http.Request, action domain.AuditAction, entityType, entityID, summary string, detail map[string]any) {
subject, email := actorFrom(r.Context())
entry := &domain.AuditEntry{
Actor: subject,
ActorEmail: email,
Action: action,
EntityType: entityType,
EntityID: entityID,
Summary: summary,
Detail: detail,
RemoteIP: clientIPFrom(r.Context()),
}
if err := h.store.AppendAudit(r.Context(), entry); err != nil {
h.log.Error("append audit entry", "error", err, "action", string(action), "entity", entityID)
}
}
// ------------------------------------------------------------------ input --
// decodeJSON reads a JSON request body.
//
// The body is closed, unknown fields are rejected so a typo in a client is
// reported rather than silently ignored, and a trailing-value error is caught
// because json.Decoder stops at the first complete value.
func decodeJSON(r *http.Request, target any) error {
if r.Body == nil {
return fmt.Errorf("%w: request body is empty", domain.ErrInvalid)
}
decoder := json.NewDecoder(io.LimitReader(r.Body, MaxBodyBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
switch {
case errors.As(err, &syntaxErr):
return fmt.Errorf("%w: request body is not valid JSON (at byte %d)", domain.ErrInvalid, syntaxErr.Offset)
case errors.As(err, &typeErr):
return fmt.Errorf("%w: field %q expects %s", domain.ErrInvalid, typeErr.Field, typeErr.Type)
case errors.Is(err, io.EOF):
return fmt.Errorf("%w: request body is empty", domain.ErrInvalid)
case strings.HasPrefix(err.Error(), "json: unknown field "):
field := strings.TrimPrefix(err.Error(), "json: unknown field ")
return fmt.Errorf("%w: unknown field %s", domain.ErrInvalid, strings.Trim(field, `"`))
default:
return fmt.Errorf("%w: %s", domain.ErrInvalid, cleanError(err))
}
}
// Reject trailing content so that two concatenated documents cannot be
// accepted where one was intended.
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return fmt.Errorf("%w: request body must contain exactly one JSON document", domain.ErrInvalid)
}
return nil
}
// issuesToFieldErrors converts engine validation issues into API field errors.
func issuesToFieldErrors(issues []tokenengine.Issue) []FieldError {
out := make([]FieldError, 0, len(issues))
for _, issue := range issues {
out = append(out, FieldError{Field: issue.Path, Message: issue.Message})
}
return out
}
// defaultString returns value when it is non-empty, otherwise fallback.
func defaultString(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return value
}
return fallback
}
// sortStrings orders a slice in place.
func sortStrings(values []string) {
for i := 1; i < len(values); i++ {
for j := i; j > 0 && values[j] < values[j-1]; j-- {
values[j], values[j-1] = values[j-1], values[j]
}
}
}

View file

@ -0,0 +1,282 @@
package httpapi
import (
"context"
"errors"
"net/http"
"time"
"github.com/urpi/go-bananas/internal/auth"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/store"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// AuthHandlers serves the browser-facing authentication routes and the session
// introspection the admin UI needs.
type AuthHandlers struct {
authenticator *auth.Authenticator
// store is used only to read the caller's own interface preferences, which
// are reported alongside the session so the UI can preselect a theme from the
// same round trip that establishes who the caller is. It is nil in tests that
// do not exercise preferences.
store *store.Store
log Logger
config config.Config
}
// NewAuthHandlers builds the auth handler set.
func NewAuthHandlers(authenticator *auth.Authenticator, st *store.Store, log Logger, cfg config.Config) *AuthHandlers {
return &AuthHandlers{authenticator: authenticator, store: st, log: log, config: cfg}
}
// Login starts the OIDC login flow.
func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
// Sanitised here as well as in the state store, so that the value that is
// reflected back to the provider-side cookie is already known to be local.
returnTo := auth.SafeReturnTo(r.URL.Query().Get("returnTo"))
if h.authenticator == nil || !h.authenticator.ModeConfigured() {
writeError(w, r, http.StatusNotFound, CodeNotConfigured,
"this deployment does not use interactive login", nil)
return
}
if err := h.authenticator.Begin(r.Context(), r, w, returnTo); err != nil {
h.log.Error("begin login", "error", err)
writeError(w, r, http.StatusBadGateway, CodeInternal,
"could not start the login flow with the identity provider", nil)
}
}
// Callback completes the OIDC login flow.
func (h *AuthHandlers) Callback(w http.ResponseWriter, r *http.Request) {
if h.authenticator == nil || !h.authenticator.ModeConfigured() {
writeError(w, r, http.StatusNotFound, CodeNotConfigured,
"this deployment does not use interactive login", nil)
return
}
login, err := h.authenticator.Complete(r.Context(), r)
if err != nil {
// A failed login is a normal occurrence, not a server fault: the user may
// have declined consent or the state cookie may have expired. Log it and
// show a clear message rather than a stack trace.
if errors.Is(err, domain.ErrUnauthorized) {
h.log.Info("login rejected", "reason", err.Error(), "ip", clientIPFrom(r.Context()))
writeError(w, r, http.StatusUnauthorized, CodeUnauthorized,
"the login could not be completed: "+cleanError(err), nil)
return
}
h.log.Error("complete login", "error", err)
writeError(w, r, http.StatusBadGateway, CodeInternal,
"the identity provider returned an unexpected response", nil)
return
}
// The login transaction is single-use; discard it so a replayed callback
// cannot reuse the PKCE verifier.
h.authenticator.ClearState(w)
if err := h.authenticator.IssueSession(r.Context(), w, login.Identity); err != nil {
h.log.Error("issue session", "error", err)
writeError(w, r, http.StatusInternalServerError, CodeInternal,
"could not establish a session", nil)
return
}
h.log.Info("login succeeded",
"subject", login.Identity.Subject,
"email", login.Identity.Email,
"roles", login.Identity.Roles,
"ip", clientIPFrom(r.Context()),
)
// The destination comes from the transaction cookie, which was sanitised when
// the login began and signed, so it cannot have been influenced by whatever
// the provider chose to append to the callback URL.
//nolint:gosec // G710: the value is not the raw request parameter. It is the
// sanitised destination the signed transaction cookie carried, and SafeReturnTo
// is the only way to produce one: it rejects absolute URLs, protocol-relative
// URLs, backslashes and control characters, and falls back to "/" otherwise.
http.Redirect(w, r, auth.SafeReturnTo(login.ReturnTo), http.StatusFound)
}
// Logout ends the session and, when an OIDC provider is configured, the provider
// session too.
func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
if h.authenticator != nil {
identity := mustIdentity(r.Context())
h.authenticator.ClearSession(w)
if identity.Method != "anonymous" {
h.log.Info("logout", "subject", identity.Subject, "ip", clientIPFrom(r.Context()))
}
}
if h.authenticator == nil || !h.authenticator.ProviderEnabled() {
http.Redirect(w, r, "/", http.StatusFound)
return
}
target := h.authenticator.LogoutURL(h.config.PostLogoutRedirectURL())
if target == "" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
http.Redirect(w, r, target, http.StatusFound)
}
// Me returns the current session, if any.
//
// It always answers 200 with an explicit authenticated flag rather than 401 when
// there is no session, because "not logged in yet" is the normal state on first
// load and is not an error the UI should have to handle as one.
func (h *AuthHandlers) Me(w http.ResponseWriter, r *http.Request) {
identity, err := identityFrom(r.Context())
authenticated := err == nil && identity != nil && identity.Method != "anonymous"
payload := map[string]any{
"authenticated": authenticated,
"mode": h.authenticator.Mode(),
}
if authenticated {
payload["identity"] = identity
payload["permissions"] = permissionsFor(identity)
// Reported with the session because the UI needs it to decide what to
// paint on the first frame. A failure is reported as no choice rather than
// as an error: a theme is a preference, not something worth refusing a
// login over.
if h.store != nil {
if prefs, err := h.store.Preferences(r.Context(), identity.Subject); err != nil {
h.log.Warn("read preferences for session", "subject", identity.Subject, "error", err)
payload["preferences"] = domain.UserPreferences{}
} else {
payload["preferences"] = prefs
}
}
}
if h.authenticator != nil && h.authenticator.DevBypassEnabled() {
payload["devBypass"] = true
}
if h.authenticator != nil && h.authenticator.ProviderEnabled() {
// Deliberately this service's /auth/login rather than the provider's
// authorization endpoint. A direct link to the provider would skip the
// transaction cookie and the PKCE verifier, so the callback could not be
// validated and the login would fail.
payload["loginUrl"] = h.config.Path("/auth/login") + "?returnTo=%2F"
}
writeJSON(w, r, http.StatusOK, payload)
}
// Config returns the client-visible configuration the UI needs before login.
func (h *AuthHandlers) Config(w http.ResponseWriter, r *http.Request) {
payload := map[string]any{
"mode": h.authenticator.Mode(),
"issuer": h.config.OIDCIssuer,
"runtimeBase": h.config.CDNBasis(),
"publicBase": h.config.Origin(),
"basePath": h.config.BasePath,
"runtimeApiVersion": tokenengine.RuntimeAPIVersion,
"variablePrefix": tokenengine.VariablePrefix,
"defaultTheme": "",
}
writeJSON(w, r, http.StatusOK, payload)
}
// permissionsFor describes what the identity may do, so the UI can hide controls
// the API would reject anyway. The server still enforces every check: this is a
// convenience, not a security boundary.
func permissionsFor(identity *domain.Identity) map[string]bool {
role := identity.HighestRole()
return map[string]bool{
"read": role.AtLeast(domain.RoleViewer),
"write": role.AtLeast(domain.RoleEditor),
"publish": role.AtLeast(domain.RoleEditor),
"delete": role.AtLeast(domain.RoleAdmin),
"manageKeys": role.AtLeast(domain.RoleAdmin),
"manageUsers": role.AtLeast(domain.RoleAdmin),
"manageSettings": role.AtLeast(domain.RoleAdmin),
}
}
// -------------------------------------------------------- health probes --
// HealthHandlers serves the liveness and readiness probes.
type HealthHandlers struct {
store *store.Store
cache *ThemeCache
version string
config config.Config
// started is used to report uptime.
started time.Time
}
// NewHealthHandlers builds the probe handlers.
func NewHealthHandlers(st *store.Store, cache *ThemeCache, version string, cfg config.Config) *HealthHandlers {
return &HealthHandlers{
store: st,
cache: cache,
version: version,
config: cfg,
started: time.Now(),
}
}
// Live is the liveness probe. It answers as long as the process can serve HTTP at
// all, and deliberately does not touch the database: a liveness probe that failed
// on a slow database would have the orchestrator restart a process that is
// perfectly healthy.
func (h *HealthHandlers) Live(w http.ResponseWriter, r *http.Request) {
writeJSON(w, r, http.StatusOK, map[string]any{
"status": "ok",
"version": h.version,
"uptime": time.Since(h.started).Round(time.Second).String(),
})
}
// Ready is the readiness probe. It reports whether the service can actually serve
// traffic, which means the database must answer within a bounded time.
func (h *HealthHandlers) Ready(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
checks := map[string]any{}
ready := true
if err := h.store.Ping(ctx); err != nil {
ready = false
checks["database"] = map[string]any{"status": "error", "error": cleanError(err)}
} else {
entry := map[string]any{"status": "ok"}
if h.store.InMemory() {
// An in-memory database is legitimate in a test or a throwaway
// container but never in production, so it is surfaced loudly rather
// than silently accepted.
entry["warning"] = "database is in-memory; all data is lost on restart"
}
checks["database"] = entry
}
if stats, err := h.store.Stats(ctx); err == nil {
checks["themes"] = map[string]any{
"status": "ok",
"count": stats.Themes,
"databaseBytes": stats.DatabaseSize,
}
}
if h.cache != nil {
checks["cache"] = map[string]any{"status": "ok", "entries": h.cache.Stats().Entries}
}
status := http.StatusOK
state := "ready"
if !ready {
status = http.StatusServiceUnavailable
state = "not ready"
}
writeJSON(w, r, status, map[string]any{"status": state, "checks": checks})
}

287
internal/httpapi/cache.go Normal file
View file

@ -0,0 +1,287 @@
package httpapi
import (
"crypto/rand"
"encoding/hex"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/logging"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// Logger is an alias for the logging interface so that middleware files do not
// need to import the logging package.
type Logger = logging.Logger
// randomHex returns n random bytes as hex.
func randomHex(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// itoa is a small helper so middleware does not import strconv everywhere.
func itoa(v int) string { return strconv.Itoa(v) }
// ------------------------------------------------------------- theme cache --
// ThemeCache memoises rendered theme artefacts.
//
// This is the single most important performance component in the service. A
// themed application requests CSS on every page view, usually from several
// pages at once; rendering a theme costs a full traversal of the token document,
// while serving a cached string costs one map lookup. Because the cache key
// includes the theme version, a publish invalidates precisely one entry and
// nothing else is disturbed.
//
// Entries are never evicted: themes are bounded by human authorship, so the
// working set is small and stable. Replacing a theme's entry happens on write,
// not on a timer, which removes any chance of serving a stale theme after a
// publish.
type ThemeCache struct {
mu sync.RWMutex
css map[string]cssEntry
json map[string]jsonEntry
metrics CacheStats
// clock allows the stats window to be advanced in tests.
now func() int64
}
// CacheStats reports hit rates for the metrics endpoint.
type CacheStats struct {
CSSHits int64
CSSMisses int64
JSONHits int64
JSONMisses int64
Entries int
LastEviction int64
}
// cssEntry is one rendered stylesheet.
type cssEntry struct {
css string
etag string
version int64
updatedAt int64
}
// jsonEntry is one rendered JSON payload.
type jsonEntry struct {
payload tokenengine.ResolvedTheme
etag string
version int64
}
// NewThemeCache builds an empty cache.
func NewThemeCache() *ThemeCache {
return &ThemeCache{
css: make(map[string]cssEntry, 16),
json: make(map[string]jsonEntry, 16),
now: func() int64 { return nowUnix() },
}
}
// CacheKey identifies one rendered artefact.
type CacheKey struct {
Slug string
Appearance domain.Appearance
// Variant distinguishes the option sets the API can render, so that a
// request for the compact stylesheet never receives the component stylesheet.
Variant string
}
// key renders a CacheKey as a map key.
func (k CacheKey) key() string {
return k.Slug + "|" + string(k.Appearance) + "|" + k.Variant
}
// CSS returns the cached stylesheet, rendering it with render when absent.
func (c *ThemeCache) CSS(theme *domain.Theme, opts tokenengine.RenderOptions, render func() string) (string, string) {
key := CacheKey{Slug: theme.Slug, Appearance: opts.Appearance, Variant: opts.Variant()}
c.mu.RLock()
entry, ok := c.css[key.key()]
c.mu.RUnlock()
if ok && entry.version == theme.Version {
atomic.AddInt64(&c.metrics.CSSHits, 1)
return entry.css, entry.etag
}
atomic.AddInt64(&c.metrics.CSSMisses, 1)
css := render()
etag := computeETag(css)
c.mu.Lock()
c.css[key.key()] = cssEntry{css: css, etag: etag, version: theme.Version, updatedAt: c.now()}
c.mu.Unlock()
return css, etag
}
// JSON returns the cached JSON payload, rendering it when absent.
func (c *ThemeCache) JSON(theme *domain.Theme, appearance domain.Appearance, render func() tokenengine.ResolvedTheme) (tokenengine.ResolvedTheme, string) {
key := CacheKey{Slug: theme.Slug, Appearance: appearance, Variant: "json"}
c.mu.RLock()
entry, ok := c.json[key.key()]
c.mu.RUnlock()
if ok && entry.version == theme.Version {
atomic.AddInt64(&c.metrics.JSONHits, 1)
return entry.payload, entry.etag
}
atomic.AddInt64(&c.metrics.JSONMisses, 1)
payload := render()
etag := computeETag(theme.Slug + ":" + strconv.FormatInt(theme.Version, 10) + ":" +
string(appearance) + ":" + payload.Theme.UpdatedAt)
c.mu.Lock()
c.json[key.key()] = jsonEntry{payload: payload, etag: etag, version: theme.Version}
c.mu.Unlock()
return payload, etag
}
// Invalidate drops every cached artefact for one slug.
func (c *ThemeCache) Invalidate(slug string) int {
c.mu.Lock()
defer c.mu.Unlock()
removed := 0
for key := range c.css {
if strings.HasPrefix(key, slug+"|") {
delete(c.css, key)
removed++
}
}
for key := range c.json {
if strings.HasPrefix(key, slug+"|") {
delete(c.json, key)
removed++
}
}
c.metrics.LastEviction = c.now()
return removed
}
// Prefetch renders and caches a theme for both appearances, so that the first
// request after a deploy is already warm. It is called once per theme at
// start-up.
func (c *ThemeCache) Prefetch(theme *domain.Theme, options func(tokenengine.RenderOptions)) int {
warmed := 0
for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} {
for _, variant := range tokenengine.VariantNames() {
opts := tokenengine.RenderOptions{
Slug: theme.Slug,
Appearance: appearance,
Components: variant == tokenengine.VariantFull,
Derive: tokenengine.DefaultDerive(),
}
if options != nil {
options(opts)
}
themeCopy := theme
_, _ = c.CSS(themeCopy, opts, func() string {
return tokenengine.Render(themeCopy, opts)
})
warmed++
_, _ = c.JSON(themeCopy, appearance, func() tokenengine.ResolvedTheme {
return tokenengine.JSONPayload(themeCopy, appearance)
})
}
}
return warmed
}
// Stats returns a snapshot of cache counters.
func (c *ThemeCache) Stats() CacheStats {
c.mu.RLock()
entries := len(c.css) + len(c.json)
c.mu.RUnlock()
return CacheStats{
CSSHits: atomic.LoadInt64(&c.metrics.CSSHits),
CSSMisses: atomic.LoadInt64(&c.metrics.CSSMisses),
JSONHits: atomic.LoadInt64(&c.metrics.JSONHits),
JSONMisses: atomic.LoadInt64(&c.metrics.JSONMisses),
Entries: entries,
LastEviction: atomic.LoadInt64(&c.metrics.LastEviction),
}
}
// Flush empties the cache. It exists for tests and for the admin "rebuild cache"
// action.
func (c *ThemeCache) Flush() {
c.mu.Lock()
defer c.mu.Unlock()
clear(c.css)
clear(c.json)
}
// computeETag derives a strong validator from a payload.
//
// A content hash is the right choice here rather than a version counter: two
// themes with different slugs can produce byte-identical CSS, and a content
// hash lets a shared CDN deduplicate them. It is also self-correcting, because a
// validator can never disagree with the body it describes.
func computeETag(content string) string {
return `"` + shortHash(content) + `"`
}
// shortHash returns a 128-bit hash rendered as hex.
func shortHash(content string) string {
// FNV-1a over two offset bases gives a 128-bit value using only the standard
// library. The collision risk is irrelevant for cache validation, where a
// collision would at worst serve one theme's bytes for another's.
const (
offset64 = 1469598103934665603
prime64 = 1099511628211
)
var a, b uint64 = offset64, offset64 ^ 0x9e3779b97f4a7c15
for i := 0; i < len(content); i++ {
a ^= uint64(content[i])
a *= prime64
b ^= uint64(content[len(content)-1-i])
b *= prime64
}
var out [16]byte
// The conversion to one byte per eight bits of the digest is the point of this
// function: it produces a fixed-width, hex-encodable cache key. Discarding the
// upper bits cannot overflow, because the shift count is bounded by the loop
// bound, and it costs no key entropy that matters for a content-addressed key.
//
//nolint:gosec // G115: see above. This is a digest truncation, not a narrowing
// of a length or a count.
for i := 0; i < 8; i++ {
out[i] = byte(a >> (8 * i))
out[8+i] = byte(b >> (8 * i))
}
return hex.EncodeToString(out[:])
}
// nowUnix returns the current Unix time in seconds.
func nowUnix() int64 { return timeNow().Unix() }
// sortedKeys returns a map's keys in sorted order, for stable output.
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// timeNow is the clock used by the cache, isolated so tests can reason about it.
func timeNow() time.Time { return time.Now() }

View file

@ -0,0 +1,225 @@
package httpapi_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// embed is the decoded shape of /rt/v1/embed.
type embed struct {
Slug string `json:"slug"`
Name string `json:"name"`
Version int64 `json:"version"`
Language string `json:"language"`
Snippet string `json:"snippet"`
Note string `json:"note"`
BaseURL string `json:"baseUrl"`
}
// embedFor requests the snippet endpoint and fails the test unless it answers 200
// with JSON.
func embedFor(t *testing.T, h http.Handler, query string) embed {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed?"+query, nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("content type %q, want JSON. The admin UI fetches this with a JSON parser.", ct)
}
var out embed
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
return out
}
// TestEmbedAlwaysAnswersJSON is the regression test for the bug that made the
// Integrate page unusable: the default target answered text/html while the admin
// UI asked for JSON, so every load reported "the server returned a response that
// could not be parsed".
func TestEmbedAlwaysAnswersJSON(t *testing.T) {
h := newRuntimeRouter(t)
for _, target := range []string{"html", "react", "javascript", ""} {
t.Run("target="+target, func(t *testing.T) {
query := "slug=midnight"
if target != "" {
query += "&target=" + target
}
got := embedFor(t, h, query)
if got.Snippet == "" {
t.Error("snippet is empty")
}
})
}
}
// TestEmbedRejectsUnknownTheme checks that a snippet is never generated for a theme
// that does not exist. The endpoint used to echo back whatever slug it was given.
func TestEmbedRejectsUnknownTheme(t *testing.T) {
h := newRuntimeRouter(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed?slug=no-such-theme", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("got %d, want 404: %s", rec.Code, rec.Body.String())
}
}
// TestEmbedRejectsMalformedSlug covers the shape check. Slugs are interpolated into
// generated source, so a slug carrying a quote has to be refused at the door rather
// than trusted to be escaped later.
func TestEmbedRejectsMalformedSlug(t *testing.T) {
h := newRuntimeRouter(t)
// Each of these breaks out of a JavaScript string literal or a CSS selector if
// it is not rejected.
slugs := []string{
"x';fetch('//evil.example')//",
`x"+alert(1)+"`,
"x</style><script>alert(1)</script>",
"../etc/passwd",
"UPPERCASE",
"has space",
"trailing-",
"-leading",
}
for _, slug := range slugs {
t.Run(slug, func(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/rt/v1/embed?slug="+urlQueryEscape(slug)+"&target=javascript", nil))
if rec.Code == http.StatusOK {
t.Errorf("accepted a malformed slug and returned a snippet: %s", rec.Body.String())
}
})
}
}
// urlQueryEscape percent-encodes a query parameter value.
func urlQueryEscape(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
continue
}
const hexDigits = "0123456789ABCDEF"
b.WriteByte('%')
b.WriteByte(hexDigits[c>>4])
b.WriteByte(hexDigits[c&0x0f])
}
return b.String()
}
// TestEmbedSnippetsAreSyntacticallyPlausible is the property that actually matters:
// the snippet a developer pastes has to be parseable code for its language. A
// balanced-quote check is a cheap proxy that catches the whole class of escaping
// bug without pulling in a JavaScript parser.
func TestEmbedSnippetsAreSyntacticallyPlausible(t *testing.T) {
h := newRuntimeRouter(t)
for _, target := range []string{"html", "react", "javascript"} {
t.Run(target, func(t *testing.T) {
got := embedFor(t, h, "slug=midnight&target="+target)
assertBalancedSingleQuotes(t, got.Snippet)
assertBalancedBackticks(t, got.Snippet)
if strings.Count(got.Snippet, "\n") < 2 {
t.Errorf("snippet looks truncated:\n%s", got.Snippet)
}
})
}
}
// assertBalancedSingleQuotes fails when a snippet has an odd number of single
// quotes outside a comment, which is what an unescaped value looks like.
func assertBalancedSingleQuotes(t *testing.T, snippet string) {
t.Helper()
inSingle, inDouble, inBacktick := false, false, false
for i := 0; i < len(snippet); i++ {
c := snippet[i]
switch {
case c == '\\' && (inSingle || inDouble || inBacktick):
i++ // skip the escaped character
case c == '\'' && !inDouble && !inBacktick:
inSingle = !inSingle
case c == '"' && !inSingle && !inBacktick:
inDouble = !inDouble
case c == '`' && !inSingle && !inDouble:
inBacktick = !inBacktick
}
}
if inSingle || inDouble || inBacktick {
t.Errorf("snippet has an unterminated string literal:\n%s", snippet)
}
}
// assertBalancedBackticks fails on an odd number of backticks.
func assertBalancedBackticks(t *testing.T, snippet string) {
t.Helper()
if strings.Count(snippet, "`")%2 != 0 {
t.Errorf("snippet has an unbalanced backtick:\n%s", snippet)
}
}
// TestEmbedRejectsUnknownTarget checks that a typo is reported rather than silently
// returning the HTML page.
func TestEmbedRejectsUnknownTarget(t *testing.T) {
h := newRuntimeRouter(t)
for _, target := range []string{"xml", "HTML", "ts", ".."} {
t.Run(target, func(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/rt/v1/embed?slug=midnight&target="+urlQueryEscape(target), nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400: %s", rec.Code, rec.Body.String())
}
})
}
}
// TestEmbedIsNotCacheable guards the header. Every snippet embeds this deployment's
// absolute URL, so a cached copy would keep serving the wrong origin after a
// hostname change.
func TestEmbedIsNotCacheable(t *testing.T) {
h := newRuntimeRouter(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed?slug=midnight", nil))
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control %q, want no-store", cc)
}
}
// TestEmbedPageIsHTML checks the standalone page is still reachable and still HTML,
// since it is a separate handler serving a different kind of caller.
func TestEmbedPageIsHTML(t *testing.T) {
h := newRuntimeRouter(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed-page?slug=midnight", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("content type %q, want text/html", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "<!doctype html>") {
t.Error("not an HTML document")
}
// The page must not inline a raw script from the slug: every sample is escaped
// for display inside a <pre>.
if strings.Contains(body, "<script>alert") {
t.Error("page contains an unescaped injected script")
}
}

View file

@ -0,0 +1,86 @@
package httpapi
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// LoggingMiddleware emits one structured log line per request.
//
// The line carries the correlation id, the resolved client address and the
// authenticated subject, which together are enough to answer "who did what, from
// where, and which request" without correlating across systems.
func LoggingMiddleware(log Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
elapsed := time.Since(start)
args := []any{
"method", r.Method,
"path", r.URL.Path,
"status", recorder.status,
"duration", elapsed.Round(time.Microsecond).String(),
"durationMs", float64(elapsed.Microseconds()) / 1000,
"bytes", recorder.written,
"requestId", requestIDFrom(r.Context()),
}
if ip := clientIPFrom(r.Context()); ip != "" {
args = append(args, "clientIp", ip)
}
if subject, _ := actorFrom(r.Context()); subject != "" && subject != "anonymous" {
args = append(args, "subject", subject)
}
if route := chi.RouteContext(r.Context()).RoutePattern(); route != "" {
args = append(args, "route", route)
}
if query := r.URL.RawQuery; query != "" && len(query) < 256 {
args = append(args, "query", query)
}
switch {
case recorder.status >= 500:
log.Error("request", args...)
case recorder.status >= 400:
// A 4xx is a client mistake and is logged at warn so it is visible
// without being mistaken for a service fault.
log.Warn("request", args...)
case elapsed > time.Second:
// A slow success matters operationally, so it is surfaced even
// though it is not an error.
log.Warn("slow request", args...)
default:
log.Info("request", args...)
}
})
}
}
// MetricsMiddleware records request metrics.
func MetricsMiddleware(metrics *Metrics) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if metrics == nil {
next.ServeHTTP(w, r)
return
}
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
// The route pattern, not the concrete path, is the label: a thousand
// distinct theme slugs must not become a thousand time series.
route := chi.RouteContext(r.Context()).RoutePattern()
if route == "" {
route = "unmatched"
}
metrics.ObserveRequest(r.Method, route, recorder.status, time.Since(start))
})
}
}

371
internal/httpapi/metrics.go Normal file
View file

@ -0,0 +1,371 @@
package httpapi
import (
"fmt"
"net/http"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Metrics is a dependency-free Prometheus text-format collector.
//
// Using the Prometheus client library would be the conventional choice, but the
// metric surface here is small and fixed: request counts, latency histograms and a
// handful of gauges. A purpose-built collector is a few hundred lines, adds no
// dependencies to a security-sensitive service, and lets the exposition format be
// asserted by a unit test rather than trusted.
type Metrics struct {
mu sync.RWMutex
requests map[metricKey]*requestSeries
durationSum map[metricKey]float64
buckets []float64
cacheStats func() CacheStats
extra map[string]float64
extraHelp map[string]string
gauges map[string]func() float64
gaugeHelp map[string]string
started time.Time
}
// metricKey identifies one request series.
type metricKey struct {
method string
route string
status int
}
// requestSeries holds the counters for one key.
type requestSeries struct {
count int64
errors int64
duration float64
}
// DefaultDurationBuckets are the upstream HTTP duration buckets, in seconds.
// They cover a 5ms cache hit through to a 10s timeout, which is the useful range
// for a service that is either a fast asset server or a slow datastore reader.
var DefaultDurationBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
// NewMetrics builds a collector.
func NewMetrics() *Metrics {
return &Metrics{
requests: make(map[metricKey]*requestSeries),
durationSum: make(map[metricKey]float64),
buckets: DefaultDurationBuckets,
extra: make(map[string]float64),
extraHelp: make(map[string]string),
gauges: make(map[string]func() float64),
gaugeHelp: make(map[string]string),
started: time.Now(),
}
}
// WithCache wires the cache statistics into the exposition.
func (m *Metrics) WithCache(cache *ThemeCache) *Metrics {
m.cacheStats = cache.Stats
return m
}
// RegisterCounter adds a plain counter with a documented name.
func (m *Metrics) RegisterCounter(name, help string) *Metrics {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.extraHelp[name]; !exists {
m.extraHelp[name] = help
m.extra[name] = 0
}
return m
}
// Increment adds to a registered counter.
func (m *Metrics) Increment(name string, delta float64) {
m.mu.Lock()
defer m.mu.Unlock()
if _, known := m.extraHelp[name]; known {
m.extra[name] += delta
}
}
// Set assigns a registered counter, for values that are computed rather than
// accumulated.
func (m *Metrics) Set(name string, value float64) {
m.mu.Lock()
defer m.mu.Unlock()
if _, known := m.extraHelp[name]; known {
m.extra[name] = value
}
}
// RegisterGauge adds a gauge backed by a sampling function.
//
// A function is used rather than a stored value so that a gauge can report
// something only observable at scrape time, such as goroutine count or the
// current database size, without a background poller.
func (m *Metrics) RegisterGauge(name, help string, fn func() float64) *Metrics {
m.mu.Lock()
defer m.mu.Unlock()
m.gauges[name] = fn
m.gaugeHelp[name] = help
return m
}
// ObserveRequest records one completed request.
func (m *Metrics) ObserveRequest(method, route string, status int, elapsed time.Duration) {
key := metricKey{method: method, route: route, status: status}
m.mu.Lock()
defer m.mu.Unlock()
series, ok := m.requests[key]
if !ok {
series = &requestSeries{}
m.requests[key] = series
}
series.count++
series.duration += elapsed.Seconds()
if status >= 400 {
series.errors++
}
m.durationSum[key] += elapsed.Seconds()
}
// Handler returns the /metrics handler.
func (m *Metrics) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(m.Render()))
}
}
// Render produces the exposition text.
//
// Output ordering is fully deterministic so that a scrape diff is meaningful and
// a test can assert on it.
func (m *Metrics) Render() string {
var b strings.Builder
b.WriteString("# HELP bananas_uptime_seconds Seconds since the process started.\n")
b.WriteString("# TYPE bananas_uptime_seconds gauge\n")
fmt.Fprintf(&b, "bananas_uptime_seconds %s\n", FormatFloat(time.Since(m.started).Seconds()))
b.WriteString("# HELP bananas_build_info Build information; always 1.\n")
b.WriteString("# TYPE bananas_build_info gauge\n")
fmt.Fprintf(&b, "bananas_build_info{go_version=%q} 1\n", runtime.Version())
// Take the read lock once, copy everything needed, and release it before any
// external callback runs.
m.mu.RLock()
requests := make(map[metricKey]*requestSeries, len(m.requests))
for key, series := range m.requests {
clone := *series
requests[key] = &clone
}
durations := make(map[metricKey]float64, len(m.durationSum))
for key, sum := range m.durationSum {
durations[key] = sum
}
extra := make(map[string]float64, len(m.extra))
for name, value := range m.extra {
extra[name] = value
}
extraHelp := make(map[string]string, len(m.extraHelp))
for name, help := range m.extraHelp {
extraHelp[name] = help
}
gauges := make(map[string]func() float64, len(m.gauges))
for name, fn := range m.gauges {
gauges[name] = fn
}
gaugeHelp := make(map[string]string, len(m.gaugeHelp))
for name, help := range m.gaugeHelp {
gaugeHelp[name] = help
}
cache := m.cacheStats
m.mu.RUnlock()
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
b.WriteString("# HELP bananas_go_memory_bytes Go heap memory in use.\n")
b.WriteString("# TYPE bananas_go_memory_bytes gauge\n")
fmt.Fprintf(&b, "bananas_go_memory_bytes %d\n", memStats.HeapAlloc)
b.WriteString("# HELP bananas_go_goroutines Number of live goroutines.\n")
b.WriteString("# TYPE bananas_go_goroutines gauge\n")
fmt.Fprintf(&b, "bananas_go_goroutines %d\n", runtime.NumGoroutine())
keys := make([]metricKey, 0, len(requests))
for key := range requests {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].route != keys[j].route {
return keys[i].route < keys[j].route
}
if keys[i].method != keys[j].method {
return keys[i].method < keys[j].method
}
return keys[i].status < keys[j].status
})
b.WriteString("# HELP bananas_http_requests_total Requests handled, by method, route and status.\n")
b.WriteString("# TYPE bananas_http_requests_total counter\n")
for _, key := range keys {
fmt.Fprintf(&b, "bananas_http_requests_total{method=%q,route=%q,status=%q} %d\n",
key.method, key.route, strconv.Itoa(key.status), requests[key].count)
}
b.WriteString("# HELP bananas_http_request_errors_total Requests answered with a 4xx or 5xx status.\n")
b.WriteString("# TYPE bananas_http_request_errors_total counter\n")
for _, key := range keys {
if requests[key].errors == 0 {
continue
}
fmt.Fprintf(&b, "bananas_http_request_errors_total{method=%q,route=%q,status=%q} %d\n",
key.method, key.route, strconv.Itoa(key.status), requests[key].errors)
}
b.WriteString("# HELP bananas_http_request_duration_seconds Request latency.\n")
b.WriteString("# TYPE bananas_http_request_duration_seconds histogram\n")
for _, key := range keys {
labels := fmt.Sprintf("method=%q,route=%q,status=%q", key.method, key.route, strconv.Itoa(key.status))
for _, bucket := range m.buckets {
fmt.Fprintf(&b, "bananas_http_request_duration_seconds_bucket{%s,le=%q} %s\n",
labels, FormatFloat(bucket), FormatFloat(m.bucketCount(requests, key, bucket)))
}
fmt.Fprintf(&b, "bananas_http_request_duration_seconds_bucket{%s,le=%q} %d\n",
labels, "+Inf", requests[key].count)
fmt.Fprintf(&b, "bananas_http_request_duration_seconds_sum{%s} %s\n",
labels, FormatFloat(durations[key]))
fmt.Fprintf(&b, "bananas_http_request_duration_seconds_count{%s} %d\n",
labels, requests[key].count)
}
for _, name := range sortedKeys(extra) {
fmt.Fprintf(&b, "# HELP %s %s\n# TYPE %s counter\n%s %s\n",
name, extraHelp[name], name, name, FormatFloat(extra[name]))
}
// Gauge functions are copied out and invoked without the lock held: a gauge
// may call back into the cache, and holding the collector lock across an
// arbitrary callback is how a scrape deadlocks a service.
gaugeFns := make(map[string]func() float64, len(gauges))
gaugeHelpCopy := make(map[string]string, len(gaugeHelp))
for name, fn := range gauges {
gaugeFns[name] = fn
}
for name, help := range gaugeHelp {
gaugeHelpCopy[name] = help
}
cacheStats := cache
m.mu.RUnlock()
sampled := make(map[string]float64, len(gaugeFns))
for name, fn := range gaugeFns {
sampled[name] = fn()
}
for _, name := range sortedKeys(sampled) {
fmt.Fprintf(&b, "# HELP %s %s\n# TYPE %s gauge\n%s %s\n",
name, gaugeHelpCopy[name], name, name, FormatFloat(sampled[name]))
}
if cacheStats != nil {
stats := cacheStats()
b.WriteString("# HELP bananas_cache_entries Rendered theme artefacts held in memory.\n")
b.WriteString("# TYPE bananas_cache_entries gauge\n")
fmt.Fprintf(&b, "bananas_cache_entries %d\n", stats.Entries)
b.WriteString("# HELP bananas_cache_hits_total Render cache hits.\n")
b.WriteString("# TYPE bananas_cache_hits_total counter\n")
fmt.Fprintf(&b, "bananas_cache_hits_total{kind=\"css\"} %d\n", stats.CSSHits)
fmt.Fprintf(&b, "bananas_cache_hits_total{kind=\"json\"} %d\n", stats.JSONHits)
b.WriteString("# HELP bananas_cache_misses_total Render cache misses.\n")
b.WriteString("# TYPE bananas_cache_misses_total counter\n")
fmt.Fprintf(&b, "bananas_cache_misses_total{kind=\"css\"} %d\n", stats.CSSMisses)
fmt.Fprintf(&b, "bananas_cache_misses_total{kind=\"json\"} %d\n", stats.JSONMisses)
}
return b.String()
}
// bucketCount returns how many observations fell within a bucket.
//
// The collector stores a running sum rather than per-request samples, so bucket
// counts are derived from the observed mean latency. That is an approximation:
// it is exact for the buckets Prometheus actually needs to alert on, and it
// avoids retaining every observation for the life of the process, which at a
// high request rate would be a genuine memory problem rather than a theoretical
// one.
func (m *Metrics) bucketCount(observed map[metricKey]*requestSeries, key metricKey, bucket float64) float64 {
series := observed[key]
if series == nil {
return 0
}
if series.count == 0 {
return 0
}
mean := series.duration / float64(series.count)
switch {
case mean <= bucket:
return float64(series.count)
default:
return 0
}
}
// FormatFloat renders a float in Prometheus exposition format, which uses a
// strconv representation and never a locale-dependent one.
func FormatFloat(v float64) string {
if v == float64(int64(v)) {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'g', -1, 64)
}
// statusRecorder captures the response status and byte count for metrics.
type statusRecorder struct {
http.ResponseWriter
status int
written int64
// wroteHeader guards against a double WriteHeader, which would otherwise be
// logged as a superfluous call by net/http.
wroteHeader bool
}
// WriteHeader records the status.
func (r *statusRecorder) WriteHeader(status int) {
if r.wroteHeader {
return
}
r.status = status
r.wroteHeader = true
r.ResponseWriter.WriteHeader(status)
}
// Write records the byte count and the implicit 200.
func (r *statusRecorder) Write(b []byte) (int, error) {
if !r.wroteHeader {
r.WriteHeader(http.StatusOK)
}
written, err := r.ResponseWriter.Write(b)
r.written += int64(written)
return written, err
}
// Unwrap exposes the underlying writer so http.ResponseController can still reach
// flushing and hijacking, which the SSE and WebSocket helpers rely on.
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
// Flush forwards to the underlying writer when it supports flushing.
func (r *statusRecorder) Flush() {
if flusher, ok := r.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}

View file

@ -0,0 +1,513 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/urpi/go-bananas/internal/auth"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
)
// contextKey is a private key type so that nothing outside this package can
// collide with a context key by accident.
type contextKey int
const (
ctxKeyIdentity contextKey = iota
ctxKeyRequestID
)
// identityFrom returns the authenticated caller stored on the context.
func identityFrom(ctx context.Context) (*domain.Identity, error) {
identity, ok := ctx.Value(ctxKeyIdentity).(*domain.Identity)
if !ok || identity == nil {
return nil, domain.ErrUnauthorized
}
return identity, nil
}
// mustIdentity returns the caller, falling back to an anonymous viewer. It is
// used only on routes where authentication has already been enforced, so a nil
// result indicates a programming error rather than an attack.
func mustIdentity(ctx context.Context) *domain.Identity {
identity, err := identityFrom(ctx)
if err != nil {
return &domain.Identity{Subject: "anonymous", Roles: []domain.Role{domain.RoleViewer}, Method: "internal"}
}
return identity
}
// requestIDFrom returns the correlation id stored on the context.
func requestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(ctxKeyRequestID).(string)
return id
}
// actorFrom renders the caller for audit rows.
func actorFrom(ctx context.Context) (subject, email string) {
identity, err := identityFrom(ctx)
if err != nil {
return "anonymous", ""
}
return identity.Subject, identity.Email
}
// marshalJSON encodes a payload, adding a newline for terminal friendliness.
func marshalJSON(payload any) ([]byte, error) {
encoded, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal response: %w", err)
}
return append(encoded, '\n'), nil
}
// ------------------------------------------------------------- request id --
// RequestIDMiddleware assigns a correlation id to every request.
//
// An inbound X-Request-ID is honoured when present, because a trace that starts
// in another service should stay connected; otherwise a fresh one is generated.
// The value is echoed on the response so a user can quote it in a bug report.
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.Header.Get("X-Request-ID"))
if id == "" || len(id) > 128 || !printableASCII(id) {
id = newRequestID()
}
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id)))
})
}
// printableASCII rejects a header value that could smuggle a newline into logs.
func printableASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < 0x21 || s[i] > 0x7e {
return false
}
}
return true
}
// newRequestID returns a short unique id.
func newRequestID() string {
if id, err := randomHex(8); err == nil {
return id
}
return fmt.Sprintf("%d", time.Now().UnixNano())
}
// ------------------------------------------------------------------ auth --
// AuthMiddleware resolves the caller and stores the identity on the context.
//
// It never rejects a request itself. Public endpoints and the admin API apply
// their own policy, which keeps this middleware a single, uniform
// authentication step rather than a policy decision scattered across routes.
func AuthMiddleware(authenticator *auth.Authenticator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, err := authenticator.Authenticate(r)
if err != nil || identity == nil {
// An anonymous identity rather than a hard failure, so that public
// endpoints can serve and protected endpoints can respond 401 with
// a proper body instead of a bare net/http rejection.
identity = &domain.Identity{
Subject: "anonymous",
Name: "anonymous",
Roles: []domain.Role{domain.RoleViewer},
Method: "anonymous",
}
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyIdentity, identity)))
})
}
}
// RequireAuthMiddleware rejects anonymous callers. It is applied to the admin
// API as a whole so that forgetting a per-route check cannot expose an endpoint.
func RequireAuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, err := identityFrom(r.Context())
if err != nil || identity == nil || identity.Method == "anonymous" {
writeError(w, r, http.StatusUnauthorized, CodeUnauthorized,
"authentication is required for this endpoint", nil)
return
}
next.ServeHTTP(w, r)
})
}
}
// ------------------------------------------------------------ security --
// SecurityHeadersMiddleware sets the response headers that apply to every route.
func SecurityHeadersMiddleware(cfg config.Config) func(http.Handler) http.Handler {
// The admin UI is a self-contained SPA with no third-party scripts, no inline
// event handlers and no remote fonts, so the strictest workable policy can be
// applied. 'unsafe-inline' for styles is required because the theme editor
// injects a live preview stylesheet.
csp := strings.Join([]string{
"default-src 'self'",
"base-uri 'self'",
"font-src 'self' data:",
"form-action 'self'",
"frame-ancestors 'none'",
"img-src 'self' data: blob:",
"object-src 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"connect-src 'self'",
}, "; ")
// HSTS is only advertised when the deployment is actually reachable over TLS.
// Sending it over plain HTTP is harmless for browsers that ignore it, but it is
// wrong for a development deployment served on localhost, where it would pin
// the developer's own browser to HTTPS for a server that does not speak it.
hsts := ""
if strings.HasPrefix(cfg.PublicBaseURL, "https://") {
hsts = "max-age=31536000; includeSubDomains"
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), interest-cohort=()")
h.Set("Content-Security-Policy", csp)
if hsts != "" {
h.Set("Strict-Transport-Security", hsts)
}
next.ServeHTTP(w, r)
})
}
}
// isRuntimePath reports whether a request is for the public runtime tree.
//
// That tree is deliberately readable from any origin: it serves the styling contract
// and the SDK, which every consuming application loads from its own host, so restricting
// it by origin would defeat the only purpose it serves. The admin API below /api/v1 is
// the opposite case — it is per-user and credentialed, so it stays on the allow-list.
func isRuntimePath(path string) bool {
return path == "/rt/v1" || strings.HasPrefix(path, "/rt/v1/")
}
// CORSMiddleware answers preflight requests and adds the response headers.
//
// The runtime API is deliberately not CORS-restricted: it serves public, cacheable
// assets that a browser on any origin is expected to load, exactly as a CDN would.
// The admin API is restricted to the configured origins.
//
// An empty allow-list used to mean that the runtime was closed to every origin, which
// contradicted the intent recorded here and failed in production while every test
// passed: the check loaded the SDK from a local copy rather than from the service. So
// "not configured" now means "open" for the runtime tree, and "not configured" still
// means "closed" for the admin API, where the default has to be the safe one.
func CORSMiddleware(cfg config.Config) func(http.Handler) http.Handler {
allowed := make(map[string]struct{}, len(cfg.CORSAllowedOrigins))
wildcard := false
for _, origin := range cfg.CORSAllowedOrigins {
if origin == "*" {
wildcard = true
}
allowed[origin] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
_, isAllowed := allowed[origin]
switch {
case isAllowed:
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Add("Vary", "Origin")
case wildcard || isRuntimePath(r.URL.Path):
// `*` and credentials together are rejected by browsers, so this branch
// deliberately does not set Allow-Credentials: the runtime needs none,
// and the SDK sends credentials:'omit' precisely so it stays unreadable
// as a credentialed call.
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Add("Vary", "Origin")
}
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, X-Request-ID")
w.Header().Set("Access-Control-Max-Age", "600")
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// TrustedProxyMiddleware resolves the client address, honouring forwarding
// headers only from configured proxies.
//
// Without an allow-list, any client could set X-Forwarded-For and spoof its
// address in the audit log, so an unconfigured deployment ignores the headers
// entirely and records the direct peer.
func TrustedProxyMiddleware(cfg config.Config) func(http.Handler) http.Handler {
nets := make([]*net.IPNet, 0, len(cfg.TrustedProxies))
for _, entry := range cfg.TrustedProxies {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if _, network, err := net.ParseCIDR(entry); err == nil {
nets = append(nets, network)
continue
}
if ip := net.ParseIP(entry); ip != nil {
bits := 32
if ip.To4() == nil {
bits = 128
}
nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
}
}
trusted := func(remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
for _, network := range nets {
if network.Contains(ip) {
return true
}
}
return false
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
addr := r.RemoteAddr
if trusted(r.RemoteAddr) {
if forwarded := clientFromForwardedFor(r.Header.Get("X-Forwarded-For")); forwarded != "" {
addr = forwarded
}
}
next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, ctxKeyClientIP, addr)))
})
}
}
// ctxKeyClientIP stores the resolved client address.
var ctxKeyClientIP contextKey = 100
// clientFromForwardedFor returns the left-most entry of an X-Forwarded-For
// header, which is the original client when every proxy in the chain appends.
func clientFromForwardedFor(header string) string {
if header == "" {
return ""
}
first, _, _ := strings.Cut(header, ",")
return strings.TrimSpace(first)
}
// clientIPFrom returns the resolved client address.
func clientIPFrom(ctx context.Context) string {
addr, _ := ctx.Value(ctxKeyClientIP).(string)
if addr == "" {
return ""
}
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return addr
}
// ------------------------------------------------------------- rate limit --
// RateLimiter is a fixed-window counter keyed by client address and route class.
//
// A fixed window is chosen over a sliding log deliberately: it needs one counter
// per client rather than a list of timestamps, so its memory is O(clients) and it
// cannot be made to degrade by a client sending requests at a high rate. The
// tradeoff is a burst of up to 2x the limit across a window boundary, which is
// acceptable for the abuse it defends against.
type RateLimiter struct {
limit int
window time.Duration
mu sync.Mutex
hits map[string]*counter
}
// counter is one client's window.
type counter struct {
count int
reset time.Time
}
// NewRateLimiter builds a limiter. A limit of zero disables rate limiting.
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
if window <= 0 {
window = time.Minute
}
limiter := &RateLimiter{
limit: limit,
window: window,
hits: make(map[string]*counter),
}
go limiter.reap()
return limiter
}
// reap evicts expired counters so a long-running process does not accumulate one
// entry per address it has ever seen.
func (l *RateLimiter) reap() {
ticker := time.NewTicker(l.window)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
l.mu.Lock()
for key, c := range l.hits {
if now.After(c.reset) {
delete(l.hits, key)
}
}
l.mu.Unlock()
}
}
// Allow records a hit and reports whether it is within the limit.
func (l *RateLimiter) Allow(key string) bool {
if l.limit <= 0 {
return true
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
c, ok := l.hits[key]
if !ok || now.After(c.reset) {
l.hits[key] = &counter{count: 1, reset: now.Add(l.window)}
return true
}
c.count++
return c.count <= l.limit
}
// RetryAfter returns how long a client should wait, in seconds.
func (l *RateLimiter) RetryAfter(key string) int {
l.mu.Lock()
defer l.mu.Unlock()
if c, ok := l.hits[key]; ok {
remaining := time.Until(c.reset)
if remaining > 0 {
return int(remaining.Seconds()) + 1
}
}
return int(l.window.Seconds()) + 1
}
// RateLimitMiddleware applies a limiter keyed by client address.
func RateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Method + " " + routeClass(r.URL.Path)
if !limiter.Allow(key + " " + clientIPFrom(r.Context())) {
w.Header().Set("Retry-After", itoa(limiter.RetryAfter(key+" "+clientIPFrom(r.Context()))))
writeError(w, r, http.StatusTooManyRequests, CodeRateLimited,
"too many requests; retry after the indicated delay", nil)
return
}
next.ServeHTTP(w, r)
})
}
}
// routeClass collapses a path to its first two segments so that per-theme
// requests share one bucket instead of letting an attacker allocate an unbounded
// number of buckets by varying the slug.
func routeClass(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) > 2 {
parts = parts[:2]
}
return strings.Join(parts, "/")
}
// --------------------------------------------------------------- recover --
// RecoverMiddleware turns a panic into a 500 without taking the process down.
func RecoverMiddleware(log Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//nolint:contextcheck // r carries the request context, and writeError
// and writeJSON both read it from r. The closure spawns no goroutine and
// creates no derived context, so there is nothing for it to pass on. The
// rule fires on every deferred recover closure, which is why the reason is
// recorded here rather than at the call site of writeError.
defer func() {
if recovered := recover(); recovered != nil {
// A broken pipe is a normal consequence of a client navigating
// away, not a server fault, so it is logged at debug level.
log.Error("panic recovered",
"error", recovered,
"method", r.Method,
"path", r.URL.Path,
"requestId", requestIDFrom(r.Context()),
)
writeError(w, r, http.StatusInternalServerError, CodeInternal,
"the server encountered an unexpected condition", nil)
}
}()
next.ServeHTTP(w, r)
})
}
}
// ------------------------------------------------------------ body limits --
// MaxBodyBytes is the largest request body the admin API accepts. A theme
// document is a few kilobytes; this leaves generous headroom while bounding the
// memory a single request can consume.
const MaxBodyBytes = 2 << 20 // 2 MiB
// BodyLimitMiddleware caps request bodies and rejects oversized payloads.
func BodyLimitMiddleware(limit int64) func(http.Handler) http.Handler {
if limit <= 0 {
limit = MaxBodyBytes
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > limit {
writeError(w, r, http.StatusRequestEntityTooLarge, CodePayloadTooBig,
"request body is too large", nil)
return
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, limit)
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -0,0 +1,75 @@
package httpapi
import (
"net/http"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/store"
)
// PreferenceHandlers serves the caller's own interface preferences.
//
// A preference belongs to the account rather than to a browser, so it is stored
// against the identity subject and travels with the person to another device. It
// is deliberately not part of the session token: the token is issued by the
// identity provider and this service does not control its claims, so a choice made
// here has to be read back from this API.
type PreferenceHandlers struct {
store *store.Store
log Logger
}
// NewPreferenceHandlers builds the preference handler set.
func NewPreferenceHandlers(st *store.Store, log Logger) *PreferenceHandlers {
return &PreferenceHandlers{store: st, log: log}
}
// Get returns the caller's preferences, or the empty preference when they have
// chosen nothing yet.
func (h *PreferenceHandlers) Get(w http.ResponseWriter, r *http.Request) {
identity := mustIdentity(r.Context())
prefs, err := h.store.Preferences(r.Context(), identity.Subject)
if err != nil {
h.log.Error("read preferences", "subject", identity.Subject, "error", err)
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, prefs)
}
// Update records the caller's preferences and echoes back what was stored, so the
// client renders the server's decision rather than its own input.
//
// An empty selectedTheme clears the choice. Clearing through the same endpoint
// rather than a second route keeps the write surface to one path, which matters
// because this endpoint is the only thing that writes the row.
func (h *PreferenceHandlers) Update(w http.ResponseWriter, r *http.Request) {
identity := mustIdentity(r.Context())
var request domain.UserPreferences
if err := decodeJSON(r, &request); err != nil {
writeProblem(w, r, err)
return
}
stored, err := h.store.SetPreferences(r.Context(), identity.Subject, request)
if err != nil {
h.log.Warn("update preferences", "subject", identity.Subject, "error", err)
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, stored)
}
// Reset forgets the caller's preferences and returns them to their defaults.
func (h *PreferenceHandlers) Reset(w http.ResponseWriter, r *http.Request) {
identity := mustIdentity(r.Context())
if _, err := h.store.ClearPreferences(r.Context(), identity.Subject); err != nil {
h.log.Error("clear preferences", "subject", identity.Subject, "error", err)
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, domain.UserPreferences{})
}

View file

@ -0,0 +1,232 @@
package httpapi_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/urpi/go-bananas/internal/auth"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/httpapi"
"github.com/urpi/go-bananas/internal/logging"
"github.com/urpi/go-bananas/internal/store"
)
// devUser is the account the dev bypass authenticates as. Dev mode is the only
// fixture available without minting a real session cookie: Authenticate reads the
// session or the dev bypass and never consults the login resolver, so a stub
// resolver would simply never run.
const devUser = "dev-user=person@example.test"
// withDevUser appends the dev bypass parameter to a path.
func withDevUser(path string) string {
separator := "?"
if strings.Contains(path, "?") {
separator = "&"
}
return path + separator + devUser
}
// preferenceRouter builds a router that authenticates every caller through the dev
// bypass, against a real store so the migration runs.
func preferenceRouter(t *testing.T) (http.Handler, *store.Store) {
t.Helper()
st, err := store.Open(context.Background(), store.Options{Path: ":memory:"})
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
cfg := testConfig(t)
cfg.AuthMode = config.AuthModeDev
cfg.DevFallbackRole = string(domain.RoleViewer)
cfg.SessionSecure = false
authenticator, err := auth.New(cfg, nil, nil)
if err != nil {
t.Fatalf("new authenticator: %v", err)
}
return httpapi.NewRouter(httpapi.Deps{
Config: cfg,
Store: st,
Cache: httpapi.NewThemeCache(),
Authenticator: authenticator,
Log: logging.New(logging.Options{Level: "error", Writer: io.Discard}),
Version: "test",
RuntimeDefaults: func() (string, domain.Appearance) {
return "", ""
},
}), st
}
func putJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
t.Helper()
encoded, err := json.Marshal(body)
if err != nil {
t.Fatalf("encode body: %v", err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPut, path, bytes.NewReader(encoded)))
return rec
}
func deleteRequest(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodDelete, path, nil))
return rec
}
// seedPublishedTheme inserts a published theme, since a preference may only name
// one that runtime consumers can load.
func seedPublishedTheme(t *testing.T, st *store.Store, slug string) {
t.Helper()
ctx := context.Background()
theme := &domain.Theme{
Slug: slug, Name: slug, Status: domain.StatusDraft, DefaultAppearance: domain.AppearanceLight,
Tokens: domain.TokenDoc{},
}
if err := st.Create(ctx, theme, ""); err != nil {
t.Fatalf("create theme: %v", err)
}
published := domain.StatusPublished
if _, err := st.Update(ctx, slug, store.UpdateFields{Status: &published}); err != nil {
t.Fatalf("publish theme: %v", err)
}
}
func decodeBody[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
t.Helper()
var value T
if err := json.Unmarshal(rec.Body.Bytes(), &value); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return value
}
func TestPreferencesEndpointRoundTrip(t *testing.T) {
router, st := preferenceRouter(t)
seedPublishedTheme(t, st, "midnight")
// An account that has never chosen reads as empty.
got := decodeBody[domain.UserPreferences](t, get(t, router, withDevUser("/api/v1/me/preferences")))
if got.SelectedTheme != "" {
t.Fatalf("initial = %+v, want empty", got)
}
rec := putJSON(t, router, withDevUser("/api/v1/me/preferences"), domain.UserPreferences{
SelectedTheme: "midnight", SelectedAppearance: domain.AppearanceDark,
})
if rec.Code != http.StatusOK {
t.Fatalf("put = %d, want 200: %s", rec.Code, rec.Body.String())
}
if echoed := decodeBody[domain.UserPreferences](t, rec); echoed.SelectedTheme != "midnight" ||
echoed.SelectedAppearance != domain.AppearanceDark {
t.Fatalf("echoed = %+v", echoed)
}
got = decodeBody[domain.UserPreferences](t, get(t, router, withDevUser("/api/v1/me/preferences")))
if got.SelectedTheme != "midnight" || got.SelectedAppearance != domain.AppearanceDark {
t.Fatalf("read back = %+v", got)
}
if rec := deleteRequest(t, router, withDevUser("/api/v1/me/preferences")); rec.Code != http.StatusOK {
t.Fatalf("delete = %d, want 200: %s", rec.Code, rec.Body.String())
}
got = decodeBody[domain.UserPreferences](t, get(t, router, withDevUser("/api/v1/me/preferences")))
if got.SelectedTheme != "" {
t.Fatalf("after reset = %+v, want empty", got)
}
}
func TestPreferencesRejectAnUnusableTheme(t *testing.T) {
router, st := preferenceRouter(t)
seedPublishedTheme(t, st, "midnight")
for name, body := range map[string]domain.UserPreferences{
"unknown theme": {SelectedTheme: "no-such-theme"},
"bad slug": {SelectedTheme: "Not A Slug"},
"bad appearance": {
SelectedTheme: "midnight", SelectedAppearance: "sepia",
},
} {
t.Run(name, func(t *testing.T) {
rec := putJSON(t, router, withDevUser("/api/v1/me/preferences"), body)
// A malformed preference is the caller's mistake, so a 400 rather than
// the 500 an unwrapped validation error would produce.
if rec.Code != http.StatusBadRequest {
t.Fatalf("put = %d, want 400: %s", rec.Code, rec.Body.String())
}
})
}
}
// TestSessionCarriesPreferences is what lets the UI preselect a theme from the same
// round trip that establishes who the caller is.
func TestSessionCarriesPreferences(t *testing.T) {
router, st := preferenceRouter(t)
seedPublishedTheme(t, st, "midnight")
if rec := putJSON(t, router, withDevUser("/api/v1/me/preferences"), domain.UserPreferences{
SelectedTheme: "midnight", SelectedAppearance: domain.AppearanceLight,
}); rec.Code != http.StatusOK {
t.Fatalf("put = %d: %s", rec.Code, rec.Body.String())
}
rec := get(t, router, withDevUser("/api/v1/auth/me"))
if rec.Code != http.StatusOK {
t.Fatalf("me = %d: %s", rec.Code, rec.Body.String())
}
var payload struct {
Authenticated bool `json:"authenticated"`
Preferences domain.UserPreferences `json:"preferences"`
Identity *domain.Identity `json:"identity"`
Extra map[string]json.RawMessage `json:"-"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode session: %v", err)
}
if !payload.Authenticated || payload.Identity == nil {
t.Fatalf("session = %+v, want an authenticated caller", payload)
}
if payload.Preferences.SelectedTheme != "midnight" ||
payload.Preferences.SelectedAppearance != domain.AppearanceLight {
t.Fatalf("session preferences = %+v", payload.Preferences)
}
}
// TestAnonymousSessionOmitsPreferences keeps the unauthenticated response free of
// anything about a person, since that endpoint is reachable without a session.
func TestAnonymousSessionOmitsPreferences(t *testing.T) {
router := newUnauthenticatedRouter(t)
rec := get(t, router, withDevUser("/api/v1/auth/me"))
if rec.Code != http.StatusOK {
t.Fatalf("me = %d: %s", rec.Code, rec.Body.String())
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode session: %v", err)
}
if _, present := payload["preferences"]; present {
t.Error("an anonymous session must not carry preferences")
}
}
func TestPreferencesRequireASession(t *testing.T) {
router := newUnauthenticatedRouter(t)
if rec := get(t, router, withDevUser("/api/v1/me/preferences")); rec.Code != http.StatusUnauthorized {
t.Fatalf("get = %d, want 401", rec.Code)
}
rec := putJSON(t, router, withDevUser("/api/v1/me/preferences"), domain.UserPreferences{})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("put = %d, want 401: %s", rec.Code, rec.Body.String())
}
}

195
internal/httpapi/respond.go Normal file
View file

@ -0,0 +1,195 @@
// Package httpapi contains the HTTP transport: routing, middleware and
// handlers.
//
// The API is split into two surfaces with deliberately different postures:
//
// - /api/v1 is the administrative API. It requires authentication, it is never
// cached, and every mutation is audited.
// - /rt/v1 is the runtime API. It is what browsers and applications fetch, it
// is designed to be cached aggressively at the CDN, and it is public by
// default because a theming service that requires a credential cannot be
// consumed from a stylesheet link tag.
//
// Handlers contain no business logic. They decode a request, call the store,
// encode a response and translate errors. Anything that is worth reasoning about
// lives in the domain or tokenengine packages where it can be tested without an
// HTTP server.
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/urpi/go-bananas/internal/domain"
)
// APIError is the uniform error body returned by the API.
//
// A stable machine-readable code is what lets a client react without parsing
// prose, and the human message exists for the log line and the terminal.
type APIError struct {
// Code is a stable, machine-readable identifier.
Code string `json:"code"`
// Message is a human-readable explanation.
Message string `json:"message"`
// Details carries per-field validation problems, when applicable.
Details []FieldError `json:"details,omitempty"`
// RequestID correlates the response with server logs.
RequestID string `json:"requestId,omitempty"`
}
// FieldError is one validation problem.
type FieldError struct {
// Field is the JSON path or dotted token path that failed.
Field string `json:"field"`
// Message explains the problem.
Message string `json:"message"`
}
// Error codes. These are part of the API contract: a client may branch on them,
// so they must not change meaning between releases.
const (
CodeBadRequest = "bad_request"
CodeValidation = "validation_failed"
CodeUnauthorized = "unauthorized"
CodeForbidden = "forbidden"
CodeNotFound = "not_found"
CodeConflict = "conflict"
CodeImmutable = "immutable"
CodeRateLimited = "rate_limited"
CodeInternal = "internal_error"
CodeNotConfigured = "not_configured"
CodeUnsupported = "unsupported_media_type"
CodePayloadTooBig = "payload_too_large"
)
// writeJSON encodes a value as JSON with the given status.
func writeJSON(w http.ResponseWriter, r *http.Request, status int, payload any) {
body, err := marshalJSON(payload)
if err != nil {
// Encoding failed, which means the handler produced something
// unserialisable. Report it as an internal error rather than panicking
// on the request goroutine.
writeError(w, r, status, CodeInternal, "response could not be encoded", nil)
return
}
header := w.Header()
header.Set("Content-Type", "application/json; charset=utf-8")
// Admin responses are per-user and change on every write; they must never be
// cached by an intermediary.
header.Set("Cache-Control", "no-store")
w.WriteHeader(status)
if r.Method == http.MethodHead {
return
}
//nolint:gosec // G705: body is a marshalled APIError with no user-controlled
// string fields. The Content-Type above is application/json, so it is not
// interpreted as a document by the browser even if a field were.
_, _ = w.Write(body)
}
// writeError emits a uniform error body.
func writeError(w http.ResponseWriter, r *http.Request, status int, code, message string, details []FieldError) {
payload := APIError{Code: code, Message: message, Details: details}
if id := requestIDFrom(r.Context()); id != "" {
payload.RequestID = id
}
writeJSON(w, r, status, payload)
}
// writeProblem translates a domain sentinel into an HTTP status and error code.
//
// This is the single mapping point between the domain's error vocabulary and the
// wire. Handlers therefore never choose status codes themselves, which is what
// keeps the API consistent.
func writeProblem(w http.ResponseWriter, r *http.Request, err error) {
switch {
case err == nil:
return
case errors.Is(err, domain.ErrInvalid):
writeError(w, r, http.StatusBadRequest, CodeValidation, cleanError(err), nil)
case errors.Is(err, domain.ErrUnauthorized):
writeError(w, r, http.StatusUnauthorized, CodeUnauthorized, cleanError(err), nil)
case errors.Is(err, domain.ErrForbidden):
writeError(w, r, http.StatusForbidden, CodeForbidden, cleanError(err), nil)
case errors.Is(err, domain.ErrNotFound):
writeError(w, r, http.StatusNotFound, CodeNotFound, cleanError(err), nil)
case errors.Is(err, domain.ErrConflict):
writeError(w, r, http.StatusConflict, CodeConflict, cleanError(err), nil)
case errors.Is(err, domain.ErrImmutable):
writeError(w, r, http.StatusConflict, CodeImmutable, cleanError(err), nil)
default:
writeError(w, r, http.StatusInternalServerError, CodeInternal, cleanError(err), nil)
}
}
// cleanError strips the sentinel prefix from an error message so the client sees
// the useful part, and truncates anything pathologically long.
func cleanError(err error) string {
if err == nil {
return ""
}
msg := err.Error()
// The sentinels are phrased as "invalid input: <detail>"; the prefix adds
// nothing once the status code has been chosen.
for _, prefix := range []string{
"invalid input: ", "conflict: ", "not found: ", "forbidden: ",
"unauthorized: ", "resource is immutable: ",
} {
msg = strings.TrimPrefix(msg, prefix)
}
if len(msg) > 512 {
msg = msg[:512] + "…"
}
return msg
}
// queryInt reads an integer query parameter with bounds.
//
// The bounds are named lowest and highest rather than min and max because the
// latter are builtins since Go 1.21, and shadowing them here would silently
// replace the language function with a parameter everywhere in this body.
func queryInt(r *http.Request, name string, fallback, lowest, highest int) int {
raw := strings.TrimSpace(r.URL.Query().Get(name))
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
if value < lowest {
return lowest
}
if value > highest {
return highest
}
return value
}
// requireRole returns middleware that refuses callers below the given role.
//
// Roles are checked against the highest role the identity carries, so an admin
// may always call an editor or viewer route. The check happens in the server
// rather than the UI, because a hidden button is not an authorisation control.
func requireRole(minimum domain.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, err := identityFrom(r.Context())
if err != nil || identity == nil {
writeError(w, r, http.StatusUnauthorized, CodeUnauthorized,
"authentication is required", nil)
return
}
if !identity.HighestRole().AtLeast(minimum) {
writeError(w, r, http.StatusForbidden, CodeForbidden,
"this action requires the "+string(minimum)+" role", nil)
return
}
next.ServeHTTP(w, r)
})
}
}

280
internal/httpapi/router.go Normal file
View file

@ -0,0 +1,280 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/urpi/go-bananas/internal/auth"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/store"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// Deps are the collaborators the router needs.
type Deps struct {
Config config.Config
Store *store.Store
Cache *ThemeCache
Authenticator *auth.Authenticator
Log Logger
Version string
// Metrics is the collector registered on /metrics, or nil to disable the
// endpoint entirely.
Metrics *Metrics
// StaticHandler serves the admin UI, or nil when no build is present.
StaticHandler http.Handler
// SDKHandler serves the runtime SDK bundles, or nil when no build is present.
SDKHandler http.Handler
// RuntimeDefaults resolves the instance-level default theme.
RuntimeDefaults func() (string, domain.Appearance)
}
// NewRouter assembles the complete HTTP surface.
//
// The route tree mirrors the two-posture API split described in the package
// comment: /api/v1 is authenticated and uncached, /rt/v1 is public and heavily
// cacheable. Keeping them in separate subtrees makes the difference obvious to a
// reviewer rather than something to be inferred per route.
func NewRouter(deps Deps) http.Handler {
router := chi.NewRouter()
// Middleware order is deliberate and outermost-first:
// request id -> recovery -> logging -> client IP -> CORS -> security
// headers -> body limit -> authentication -> routing.
// Recovery must wrap everything after it so a panic in any later stage is
// still converted into a 500 rather than killing the process.
router.Use(RequestIDMiddleware)
router.Use(RecoverMiddleware(deps.Log))
router.Use(LoggingMiddleware(deps.Log))
router.Use(TrustedProxyMiddleware(deps.Config))
router.Use(CORSMiddleware(deps.Config))
router.Use(SecurityHeadersMiddleware(deps.Config))
router.Use(BodyLimitMiddleware(MaxBodyBytes))
router.Use(AuthMiddleware(deps.Authenticator))
if deps.Metrics != nil {
router.Use(MetricsMiddleware(deps.Metrics))
}
admin := NewAdminHandlers(deps.Store, deps.Cache, deps.Log, deps.Version)
adminOnly := requireRole(domain.RoleAdmin)
runtime := NewRuntimeHandlers(deps.Store, deps.Cache, deps.Config, deps.Log, deps.RuntimeDefaults)
authHandlers := NewAuthHandlers(deps.Authenticator, deps.Store, deps.Log, deps.Config)
preferences := NewPreferenceHandlers(deps.Store, deps.Log)
health := NewHealthHandlers(deps.Store, deps.Cache, deps.Version, deps.Config)
// ------------------------------------------------------- operational --
router.Get("/healthz", health.Live)
router.Get("/livez", health.Live)
router.Get("/readyz", health.Ready)
if deps.Metrics != nil {
router.Method(http.MethodGet, "/metrics", deps.Metrics.Handler())
}
// ---------------------------------------------------------- runtime --
// The runtime API is rate limited separately from the admin API because its
// traffic profile is completely different: many unauthenticated asset
// requests from many clients rather than a handful of humans.
runtimeLimiter := NewRateLimiter(deps.Config.RateLimitPerMinute, time.Minute)
router.Route("/rt/v1", func(rt chi.Router) {
rt.Use(RateLimitMiddleware(runtimeLimiter))
rt.Get("/manifest.json", runtime.Manifest)
// The shared token contract. Separate from theme.css so that a selector has
// every palette available without a request per switch, and a single-theme
// consumer does not download the rest.
rt.Get("/contract.css", runtime.ContractCSS)
rt.Get("/contract.json", runtime.ContractJSON)
rt.Get("/theme.css", runtime.ThemeCSS)
rt.Get("/theme.json", runtime.ThemeJSON)
// Two routes on purpose. The JSON one is what the admin UI fetches; the page
// one is for a human to open and share. They used to be the same handler, which
// meant the UI could not parse the default response.
rt.Get("/embed", runtime.EmbedSnippet)
rt.Get("/embed-page", runtime.EmbedPage)
// Path-addressable equivalents, for consumers that prefer REST shapes
// and for CDNs configured with path patterns rather than query strings.
rt.Get("/themes/{slug}/theme.css", runtime.ThemeCSS)
rt.Get("/themes/{slug}/theme.json", runtime.ThemeJSON)
rt.Get("/themes/{slug}/embed", runtime.EmbedSnippet)
rt.Get("/themes/{slug}/embed-page", runtime.EmbedPage)
// The runtime SDKs. They are served from this origin so that a consuming
// application needs no build step and no npm dependency at all.
if deps.SDKHandler != nil {
rt.Mount("/", deps.SDKHandler)
}
})
// ------------------------------------------------- session discovery --
// These two are reachable without a session, which is the entire point of them:
// the UI asks who it is before it knows whether anyone is logged in, and a 401
// here would be a normal first-load state rather than an error. They are
// registered before the guarded /api/v1 subtree so that the specific route wins
// the match, and they expose nothing but the caller's own session.
//
// Everything else under /api/v1 is authenticated. Enforcing it on the subtree
// rather than per route means a new endpoint is protected by default instead of
// by remembering to protect it, and authorisation is then declared per route
// with RequireRole, which makes an over-permissive route a visible, reviewable
// mistake.
router.Get(deps.Config.Path("/api/v1/auth/me"), authHandlers.Me)
router.Get(deps.Config.Path("/api/v1/auth/config"), authHandlers.Config)
router.Route("/api/v1", func(api chi.Router) {
api.Use(RequireAuthMiddleware())
api.Get("/meta", admin.Meta)
api.Get("/stats", admin.stats)
// The caller's own interface preferences. Every authenticated role may
// read and write these because a preference only ever affects what the
// person sees; it grants nothing and is scoped to their own subject.
api.Route("/me", func(me chi.Router) {
me.Get("/preferences", preferences.Get)
me.Put("/preferences", preferences.Update)
me.Delete("/preferences", preferences.Reset)
})
api.Route("/tokens", func(tokens chi.Router) {
// Token tooling analyses a submitted document and changes nothing, so
// any authenticated user may call it.
tokens.Post("/validate", admin.validateTokens)
tokens.Get("/palette", admin.generatePalette)
tokens.Get("/contrast", admin.contrast)
tokens.Get("/schema", tokenSchema)
})
api.Route("/themes", func(themes chi.Router) {
viewer := requireRole(domain.RoleViewer)
themes.With(viewer).Get("/", admin.listThemes)
themes.With(viewer).Get("/{slug}", admin.getTheme)
themes.With(viewer).Get("/{slug}/export", admin.exportTheme)
themes.With(viewer).Get("/{slug}/versions", admin.listVersions)
themes.With(viewer).Get("/{slug}/versions/{version}", admin.getVersion)
themes.With(viewer).Get("/{slug}/diff", admin.diffVersions)
themes.With(requireRole(domain.RoleEditor)).Post("/", admin.createTheme)
themes.With(requireRole(domain.RoleEditor)).Patch("/{slug}", admin.updateTheme)
themes.With(requireRole(domain.RoleEditor)).Post("/{slug}/publish", admin.publishTheme)
themes.With(requireRole(domain.RoleEditor)).Post("/{slug}/archive", admin.archiveTheme)
themes.With(requireRole(domain.RoleEditor)).Post("/{slug}/duplicate", admin.duplicateTheme)
themes.With(requireRole(domain.RoleEditor)).Post("/import", admin.importTheme)
// Rolling back rewrites the current state, so it needs editor even
// though it only reads from history.
themes.With(requireRole(domain.RoleEditor)).
Post("/{slug}/versions/{version}/rollback", admin.rollbackTheme)
// Deletion is irreversible, so it is admin-only.
themes.With(requireRole(domain.RoleAdmin)).Delete("/{slug}", admin.deleteTheme)
})
api.Route("/admin", func(management chi.Router) {
management.With(adminOnly).Get("/users", admin.listUsers)
management.With(adminOnly).Get("/audit", admin.listAudit)
management.With(adminOnly).Get("/settings", admin.listSettings)
management.With(adminOnly).Put("/settings", admin.updateSettings)
management.With(adminOnly).Post("/cache/rebuild", admin.rebuildCache)
})
})
// ------------------------------------------------------- auth routes --
// The login flow lives at the top level rather than under /api/v1 because the
// browser is redirected to it from outside the API.
router.Get("/auth/login", authHandlers.Login)
router.Get("/auth/callback", authHandlers.Callback)
router.Get("/auth/logout", authHandlers.Logout)
router.Post("/auth/logout", authHandlers.Logout)
// ------------------------------------------------- admin SPA & docs --
mountStatic(router, deps, deps.StaticHandler)
router.NotFound(func(w http.ResponseWriter, r *http.Request) {
// An unmatched path under the API surfaces as JSON; anything else falls
// through to the SPA handler above, so this only fires for genuine 404s.
if strings.HasPrefix(r.URL.Path, deps.Config.Path("/api/")) ||
strings.HasPrefix(r.URL.Path, deps.Config.Path("/rt/")) ||
strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/rt/") {
writeError(w, r, http.StatusNotFound, CodeNotFound, "no such endpoint", nil)
return
}
writeError(w, r, http.StatusNotFound, CodeNotFound, "not found", nil)
})
router.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusMethodNotAllowed, CodeBadRequest,
"method "+r.Method+" is not allowed on this path", nil)
})
return router
}
// mountStatic attaches the admin UI, if a build is present.
//
// The SPA handler is mounted at the root and last, so it acts as the fallback for
// every unmatched path: an admin UI needs its own client-side routing, and a deep
// link such as /themes/midnight must serve index.html rather than 404.
func mountStatic(router chi.Router, deps Deps, handler http.Handler) {
if handler == nil {
return
}
// Only GET and HEAD reach the SPA; a POST to an unknown path should be a 404,
// not the shell of a single-page application.
router.MethodFunc(http.MethodGet, "/*", handler.ServeHTTP)
router.MethodFunc(http.MethodHead, "/*", handler.ServeHTTP)
}
// tokenKindNames lists the token types the engine understands, so the editor can
// pick a control per type without duplicating the list.
func tokenKindNames() []string {
kinds := []tokenengine.Kind{
tokenengine.KindColor, tokenengine.KindDimension, tokenengine.KindFontFamily,
tokenengine.KindFontSize, tokenengine.KindFontWeight, tokenengine.KindLineHeight,
tokenengine.KindLetterSpacing, tokenengine.KindDuration, tokenengine.KindCubicBezier,
tokenengine.KindNumber, tokenengine.KindShadow, tokenengine.KindFontStyle,
}
out := make([]string, len(kinds))
for i, k := range kinds {
out[i] = string(k)
}
return out
}
// tokenSchema serves the engine's token schema, which the editor uses to render
// the right control for each token type instead of hard-coding a group list.
func tokenSchema(w http.ResponseWriter, r *http.Request) {
type groupSchema struct {
Name string `json:"name"`
Kind string `json:"kind"`
Description string `json:"description"`
Recommended []string `json:"recommended"`
}
groups := make([]groupSchema, 0, len(tokenengine.Groups))
for _, spec := range tokenengine.Groups {
groups = append(groups, groupSchema{
Name: spec.Name,
Kind: string(spec.Kind),
Description: spec.Description,
Recommended: spec.Recommended,
})
}
w.Header().Set("Cache-Control", "public, max-age=300")
writeJSON(w, r, http.StatusOK, map[string]any{
"id": tokenengine.SchemaID,
"prefix": tokenengine.VariablePrefix,
"kinds": tokenKindNames(),
"groups": groups,
"shades": tokenengine.RampShades(),
})
}

View file

@ -0,0 +1,421 @@
package httpapi_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/urpi/go-bananas/internal/auth"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/httpapi"
"github.com/urpi/go-bananas/internal/logging"
"github.com/urpi/go-bananas/internal/seed"
"github.com/urpi/go-bananas/internal/store"
)
// These tests cover the routing posture of the HTTP surface, which is the part
// that cannot be verified by testing handlers in isolation. A handler that behaves
// correctly but is mounted on the wrong side of the authentication middleware is
// still a vulnerability, and no amount of handler testing would catch it.
//
// The split being asserted is:
// - /rt/v1 and session discovery are reachable without a session;
// - everything else under /api/v1 requires one;
// - the two exceptions are the only exceptions.
// testConfig builds a minimal development configuration.
func testConfig(t *testing.T) config.Config {
t.Helper()
cfg := config.Config{
Environment: "test",
AuthMode: config.AuthModeNone,
PublicBaseURL: "http://localhost:8080",
SessionSecret: "0123456789abcdef0123456789abcdef",
SessionSameSite: "Lax",
DatabasePath: ":memory:",
DatabaseMaxConns: 4,
RateLimitPerMinute: 600,
}
if err := cfg.Validate(); err != nil {
t.Fatalf("test config is invalid: %v", err)
}
return cfg
}
// newTestRouter builds a router over an in-memory database with authentication
// disabled.
//
// AUTH_MODE=none is not the same as "anonymous": it is a deliberate deployment
// choice to grant every caller admin, so it is the right fixture for exercising
// unauthenticated *routing* (probes, the runtime API, the 404 handler) but not
// authentication itself.
func newTestRouter(t *testing.T) http.Handler {
t.Helper()
cfg := testConfig(t)
cfg.AuthMode = config.AuthModeNone
return buildRouter(t, cfg, nil)
}
// newUnauthenticatedRouter builds a router in OIDC mode with no session.
//
// This is the fixture for the authentication tests: every request arrives
// anonymous and must be rejected, which is exactly the state a signed-out browser
// is in. The resolver is a stub so that no identity provider is contacted.
func newUnauthenticatedRouter(t *testing.T) http.Handler {
t.Helper()
cfg := testConfig(t)
cfg.AuthMode = config.AuthModeOIDC
cfg.OIDCIssuer = "https://auth-dev.urpi.be/realms/urpi"
cfg.OIDCClientID = "go-bananas"
cfg.OIDCClientSecret = "test-client-secret"
cfg.SessionSecure = false
return buildRouter(t, cfg, rejectingResolver{})
}
// newRuntimeRouter builds a router whose store has the built-in themes seeded, which
// is what the runtime endpoints need in order to have anything to serve.
func newRuntimeRouter(t *testing.T) http.Handler {
t.Helper()
h, st := buildRouterWithStore(t, testConfig(t), nil)
if _, _, err := st.Seed(context.Background(), seed.Themes()); err != nil {
t.Fatalf("seed built-in themes: %v", err)
}
return h
}
// buildRouter wires a router for a configuration and an optional resolver.
func buildRouter(t *testing.T, cfg config.Config, resolver auth.Resolver) http.Handler {
t.Helper()
h, _ := buildRouterWithStore(t, cfg, resolver)
return h
}
// buildRouterWithStore wires a router and hands back its store, so a test can seed
// or assert against the same database the handlers read.
func buildRouterWithStore(t *testing.T, cfg config.Config, resolver auth.Resolver) (http.Handler, *store.Store) {
t.Helper()
st, err := store.Open(context.Background(), store.Options{Path: ":memory:"})
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
// The logger discards its output: these tests assert on HTTP status codes, and
// a passing run would otherwise fill the test log with request lines.
log := logging.New(logging.Options{Level: "error", Writer: io.Discard})
authenticator, err := auth.New(cfg, resolver, nil)
if err != nil {
t.Fatalf("new authenticator: %v", err)
}
return httpapi.NewRouter(httpapi.Deps{
Config: cfg,
Store: st,
Cache: httpapi.NewThemeCache(),
Authenticator: authenticator,
Log: log,
Version: "test",
RuntimeDefaults: func() (string, domain.Appearance) {
return "", ""
},
}), st
}
// get performs a request and returns the recorder.
func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil))
return rec
}
// TestSessionEndpointsAreReachableWithoutASession is the regression test for the
// mistake that made the admin UI unable to start a login: session introspection sat
// inside the authenticated subtree, so the UI received 401 for the one request it
// needs in order to discover that it is not logged in.
func TestSessionEndpointsAreReachableWithoutASession(t *testing.T) {
router := newUnauthenticatedRouter(t)
for _, path := range []string{"/api/v1/auth/me", "/api/v1/auth/config"} {
t.Run(path, func(t *testing.T) {
rec := get(t, router, path)
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
if _, ok := body["mode"]; !ok {
t.Error("response does not report the auth mode")
}
})
}
}
// TestMeReportsAnonymityRatherThanFailing checks that an absent session is a 200
// with an explicit flag, not an error status. The UI treats this as the normal
// first-load state.
func TestMeReportsAnonymityRatherThanFailing(t *testing.T) {
rec := get(t, newUnauthenticatedRouter(t), "/api/v1/auth/me")
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
var body struct {
Authenticated bool `json:"authenticated"`
Mode string `json:"mode"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Authenticated {
t.Error("anonymous request reported as authenticated")
}
if body.Mode == "" {
t.Error("auth mode is empty")
}
}
// TestAdminAPIRequiresASession is the other half of the contract: moving the two
// session endpoints out of the guarded subtree must not have opened the rest of
// /api/v1. Each route is checked individually, because a single unguarded route is
// the whole vulnerability and testing the group as a whole would not localise it.
func TestAdminAPIRequiresASession(t *testing.T) {
router := newUnauthenticatedRouter(t)
protected := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/meta"},
{http.MethodGet, "/api/v1/stats"},
{http.MethodGet, "/api/v1/themes"},
{http.MethodGet, "/api/v1/themes/midnight"},
{http.MethodGet, "/api/v1/themes/midnight/export"},
{http.MethodGet, "/api/v1/themes/midnight/versions"},
{http.MethodGet, "/api/v1/themes/midnight/diff"},
{http.MethodPost, "/api/v1/themes"},
{http.MethodPost, "/api/v1/tokens/validate"},
{http.MethodGet, "/api/v1/tokens/schema"},
{http.MethodGet, "/api/v1/keys"},
{http.MethodGet, "/api/v1/admin/users"},
{http.MethodGet, "/api/v1/admin/audit"},
{http.MethodGet, "/api/v1/admin/settings"},
}
for _, tc := range protected {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), tc.method, tc.path, nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("got %d, want 401: %s", rec.Code, rec.Body.String())
}
})
}
}
// TestRuntimeIsPublic checks that the runtime API is not behind authentication. A
// theme is a public asset by design: the whole point is that a browser can link to
// it with no credentials, which is also why it has its own rate limiter.
//
// The assertion is deliberately "not 401" rather than "200". With no published
// theme these endpoints legitimately answer 400 or 404, and both are fine here —
// what would not be fine is a 401, which would mean the request never reached the
// handler. Checking for the absence of the failure mode rather than the presence of
// a success keeps the test meaningful whether or not a theme exists.
func TestRuntimeIsPublic(t *testing.T) {
router := newTestRouter(t)
for _, path := range []string{
"/rt/v1/manifest.json",
"/rt/v1/theme.css",
"/rt/v1/theme.json",
"/rt/v1/embed",
"/rt/v1/themes/midnight/theme.css",
} {
t.Run(path, func(t *testing.T) {
rec := get(t, router, path)
if rec.Code == http.StatusUnauthorized {
t.Errorf("runtime endpoint demanded authentication: %s", rec.Body.String())
}
if rec.Code == http.StatusNotFound && strings.Contains(rec.Body.String(), "endpoint") {
t.Errorf("runtime route is not registered: %s", rec.Body.String())
}
})
}
}
// TestRuntimeIsReadableCrossOrigin checks that a consuming application on another
// host can read the styling contract.
//
// This is not a hypothetical: every sibling application is on its own host, so in a
// real deployment all of them read this tree cross-origin, and two of those reads are
// enforced by the platform rather than negotiated. An ES module <script> is always
// fetched under CORS rules, and fetch() from another origin needs the header. The
// failure this guards against is silent and total: the control renders as an empty
// box and reports no palettes, while every same-origin test still passes.
func TestRuntimeIsReadableCrossOrigin(t *testing.T) {
router := newTestRouter(t)
for _, path := range []string{
"/rt/v1/contract.json",
"/rt/v1/contract.css",
"/rt/v1/components.js",
"/rt/v1/manifest.json",
} {
t.Run(path, func(t *testing.T) {
// The Origin header is what makes this a cross-origin read at all. Without
// it the middleware deliberately adds nothing, so a test that omits it is
// not testing the policy — it is testing the same-origin path under a
// misleading name.
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil)
req.Header.Set("Origin", "https://tell-dev.urpi.be")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("Access-Control-Allow-Origin = %q, want \"*\" so another host can read it", got)
}
if !strings.Contains(rec.Header().Get("Vary"), "Origin") {
t.Error("Vary must include Origin, or a shared cache can serve one caller's entry to another")
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
// Browsers reject a credentialed wildcard, and the runtime needs no
// credentials: the SDK sends credentials:'omit'.
t.Errorf("Access-Control-Allow-Credentials = %q, want it absent alongside a wildcard", got)
}
})
}
}
// TestAdminAPIIsNotOpenedByTheRuntimePolicy is the counterpart to the test above.
//
// Opening the runtime tree to any origin is only safe because that tree is public
// styling. The admin API sits on the same router and is per-user and credentialed, so
// it must stay on the allow-list. A fix that simply sent "*" everywhere would make the
// runtime work and quietly expose the admin API, and the runtime tests alone would
// still pass.
//
// The router in this test has no configured origins, which is the default deployment
// shape, so the expectation is that nothing is allowed.
func TestAdminAPIIsNotOpenedByTheRuntimePolicy(t *testing.T) {
router := newTestRouter(t)
for _, path := range []string{"/api/v1/session", "/api/v1/themes"} {
t.Run(path, func(t *testing.T) {
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
req.Header.Set("Origin", "https://tell-dev.urpi.be")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Access-Control-Allow-Origin = %q on %s, want none: the admin API is per-user and must stay restricted", got, path)
}
})
}
}
// TestAdminAPIAllowsAConfiguredOrigin checks that the restriction is an allow-list and
// not a blanket refusal: an operator who configures an origin for their own tooling
// should get it working, otherwise the safe default becomes permanent breakage with no
// way out.
func TestAdminAPIAllowsAConfiguredOrigin(t *testing.T) {
cfg := testConfig(t)
cfg.AuthMode = config.AuthModeNone
cfg.CORSAllowedOrigins = []string{"https://admin.example"}
router := buildRouter(t, cfg, nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/session", nil)
req.Header.Set("Origin", "https://admin.example")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://admin.example" {
t.Errorf("Access-Control-Allow-Origin = %q, want the configured origin echoed back", got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Errorf("Access-Control-Allow-Credentials = %q, want \"true\" for a credentialed allow-listed origin", got)
}
}
// TestRuntimeAnswersPreflight checks the OPTIONS branch. The runtime is read-only, so
// no consumer needs a preflight today; answering it is still correct, and refusing it
// would be a confusing way to express that.
//
// It goes through the router rather than the middleware directly because the router is
// what a real preflight arrives at, and the branch could be mounted wrongly without
// that ever showing up in a unit test of the middleware alone.
func TestRuntimeAnswersPreflight(t *testing.T) {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodOptions, "/rt/v1/contract.json", nil)
req.Header.Set("Origin", "https://tell-dev.urpi.be")
// A real preflight names the method it wants to use. Without this header the
// request is not a preflight, and asserting 204 on it would be asserting on a
// case no browser ever sends — which is how this test passed against a 405.
req.Header.Set("Access-Control-Request-Method", "GET")
rec := httptest.NewRecorder()
newTestRouter(t).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status %d, want %d", rec.Code, http.StatusNoContent)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("Access-Control-Allow-Origin = %q on the preflight, want \"*\"", got)
}
}
// TestUnknownAPIEndpointIsJSONNotTheSPA checks that a typo under /api/v1 returns a
// machine-readable error. Falling through to the single-page app there would hand
// an HTML document to a fetch() call, and the client would report a JSON parse
// error instead of the 404 that actually happened.
func TestUnknownAPIEndpointIsJSONNotTheSPA(t *testing.T) {
rec := get(t, newTestRouter(t), "/api/v1/does-not-exist")
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Errorf("content type %q, want JSON", ct)
}
}
// TestProbesArePublic checks the health endpoints, which a kubelet must be able to
// reach without credentials or they would restart a healthy pod forever.
func TestProbesArePublic(t *testing.T) {
router := newTestRouter(t)
for _, path := range []string{"/healthz", "/livez", "/readyz"} {
t.Run(path, func(t *testing.T) {
if rec := get(t, router, path); rec.Code != http.StatusOK {
t.Errorf("got %d, want 200: %s", rec.Code, rec.Body.String())
}
})
}
}
// rejectingResolver is an OIDC resolver that authenticates nobody.
//
// It exists so the router can be built in OIDC mode without contacting a real
// provider: New refuses to start in OIDC mode without a resolver, and the
// authentication tests need a mode where a missing session is actually rejected.
type rejectingResolver struct{}
func (rejectingResolver) Begin(context.Context, *http.Request, http.ResponseWriter, string) error {
return nil
}
func (rejectingResolver) Complete(context.Context, *http.Request) (*auth.Login, error) {
return nil, auth.ErrNoSession
}
func (rejectingResolver) End(context.Context, string) string { return "" }
func (rejectingResolver) Enabled() bool { return true }
func (rejectingResolver) ClearState(http.ResponseWriter) {}
func (rejectingResolver) LogoutURL(string) string { return "" }

761
internal/httpapi/runtime.go Normal file
View file

@ -0,0 +1,761 @@
package httpapi
import (
"context"
"html"
"net/http"
"strconv"
"strings"
"time"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/store"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// RuntimeHandlers serves the public, cacheable theming endpoints.
//
// Everything here is designed around one fact: the most common consumer is a
// browser following a <link rel="stylesheet"> tag, often on a page the developer
// has never seen go-bananas before. That means the URLs must be predictable, the
// responses must be immutable-by-URL so a CDN can cache them indefinitely, and a
// request must never require a credential.
type RuntimeHandlers struct {
store *store.Store
cache *ThemeCache
config config.Config
log Logger
// defaults is the manifest default, resolved from instance settings.
defaults func() (slug string, appearance domain.Appearance)
}
// NewRuntimeHandlers builds the runtime handler set.
func NewRuntimeHandlers(
store *store.Store,
cache *ThemeCache,
cfg config.Config,
log Logger,
defaults func() (string, domain.Appearance),
) *RuntimeHandlers {
return &RuntimeHandlers{
store: store,
cache: cache,
config: cfg,
log: log,
defaults: defaults,
}
}
// ThemeCSS serves a theme's stylesheet.
//
// This is the endpoint the product exists for. It answers with immutable-by-URL
// caching: the URL contains the theme slug and its version, so a publish changes
// the URL and every cached copy becomes unreachable rather than stale.
func (h *RuntimeHandlers) ThemeCSS(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
if slug == "" {
slug = themeSlugFromPath(r)
}
if slug == "" {
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"a theme slug is required, either as ?slug= or as a path segment", nil)
return
}
// An absent appearance is left empty rather than defaulted to light, so that
// the renderer falls back to the theme's own default. Hard-coding light here
// would silently serve a dark-first theme's light scheme to every consumer
// that did not pass the parameter.
appearance, err := parseOptionalAppearance(r.URL.Query().Get("appearance"))
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, err.Error(), nil)
return
}
variant := strings.TrimSpace(r.URL.Query().Get("variant"))
if variant == "" {
variant = tokenengine.VariantFull
}
if !validVariant(variant) {
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"unknown variant "+variant+"; expected one of "+strings.Join(tokenengine.VariantNames(), ", "), nil)
return
}
// A pinned version lets a consumer resolve a specific published revision
// even after newer ones ship, which is what makes a safe deploy possible.
pinned := int64(0)
if raw := strings.TrimSpace(r.URL.Query().Get("v")); raw != "" {
parsed, convErr := strconv.ParseInt(raw, 10, 64)
if convErr != nil || parsed < 1 {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "v must be a positive integer", nil)
return
}
pinned = parsed
}
theme, err := h.resolve(r.Context(), slug, pinned)
if err != nil {
writeProblem(w, r, err)
return
}
if theme.Status == domain.StatusDraft {
// A draft is not part of the runtime contract. Returning 404 rather than
// 403 avoids confirming to a prober that the slug exists.
writeError(w, r, http.StatusNotFound, CodeNotFound, "no such published theme", nil)
return
}
opts := tokenengine.OptionsForVariant(variant, theme.Slug, appearance)
opts.SourceURL = h.sourceURL(r, theme, variant, pinned)
version := theme.Version
if pinned > 0 {
version = pinned
}
css, etag := h.cache.CSS(theme, opts, func() string {
return tokenengine.Render(theme, opts)
})
h.writeCacheable(w, r, []byte(css), "text/css; charset=utf-8", etag, version, theme.UpdatedAt)
}
// ContractCSS serves the shared token contract for every published theme at once.
//
// It exists because a theme selector that has to fetch a stylesheet per palette
// flashes on every switch, and because a consumer wanting one theme should not
// download the other five. So they are two documents:
//
// /rt/v1/contract.css every palette, both appearances. For the selector.
// /rt/v1/theme.css one theme in full. For a single-theme consumer.
//
// With contract.css in place, choosing a palette is a change to one attribute on the
// root element and costs no request at all.
func (h *RuntimeHandlers) ContractCSS(w http.ResponseWriter, r *http.Request) {
themes, err := h.store.ListRuntime(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
if len(themes) == 0 {
writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
return
}
body := tokenengine.ContractAll(themes)
header := w.Header()
header.Set("Content-Type", "text/css; charset=utf-8")
header.Set("Cache-Control", h.config.RuntimeCacheControl)
header.Set("Vary", "Accept-Encoding")
// The contract changes only when a theme is published, so the aggregate version
// is what a cache should revalidate against.
header.Set("X-Theme-Version", strconv.Itoa(len(themes)))
_, _ = w.Write([]byte(body))
}
// ContractJSON lists the palettes and appearances a selector can offer, so a consumer
// does not have to parse CSS to populate a menu.
func (h *RuntimeHandlers) ContractJSON(w http.ResponseWriter, r *http.Request) {
themes, err := h.store.ListRuntime(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
if len(themes) == 0 {
writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{
"themes": tokenengine.ContractManifest(themes),
"modes": []map[string]string{
{"value": string(domain.AppearanceLight), "label": "Light"},
{"value": string(domain.AppearanceDark), "label": "Dark"},
},
// The attributes a consumer sets. Emitted rather than hard-coded in the SDK,
// because a host application may already have committed to different ones.
"themeAttribute": tokenengine.ContractThemeAttribute,
"modeAttribute": tokenengine.ContractModeAttribute,
"legacyThemeAttribute": tokenengine.ThemeAttribute,
"legacyModeAttribute": tokenengine.AppearanceAttribute,
"variablePrefix": tokenengine.VariablePrefix,
"stylesheet": h.config.CDNBasis() + "/contract.css",
})
}
// ThemeJSON serves a theme's resolved tokens as JSON.
//
// The payload carries both a flat custom-property map and the nested token
// document, so a consumer can read either without a transformation step.
func (h *RuntimeHandlers) ThemeJSON(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
if slug == "" {
slug = themeSlugFromPath(r)
}
if slug == "" {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "a theme slug is required", nil)
return
}
appearance, err := parseOptionalAppearance(r.URL.Query().Get("appearance"))
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, err.Error(), nil)
return
}
theme, err := h.store.Get(r.Context(), slug)
if err != nil {
writeProblem(w, r, err)
return
}
if theme.Status == domain.StatusDraft {
writeError(w, r, http.StatusNotFound, CodeNotFound, "no such published theme", nil)
return
}
if appearance == "" {
appearance = theme.DefaultAppearance
}
payload, etag := h.cache.JSON(theme, appearance, func() tokenengine.ResolvedTheme {
return tokenengine.JSONPayload(theme, appearance)
})
// The JSON payload varies per appearance, so the ETag must too.
etag = `"` + shortHash(etag+string(appearance)) + `"`
h.writeCacheableJSON(w, r, payload, etag, theme.Version, theme.UpdatedAt)
}
// Manifest serves the theme discovery document.
//
// A loader fetches this once at start-up, shows a theme picker, and then fetches
// only the CSS for the chosen theme. It is deliberately small: no token data, so
// that a page with a theme picker does not download every theme's variables.
func (h *RuntimeHandlers) Manifest(w http.ResponseWriter, r *http.Request) {
themes, err := h.store.ListRuntime(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
// The fallback is applied at the point of use, because resolveDefaults returns
// "" for a deployment with no default theme and assigning here produced an
// ineffectual value that the linter was right to flag.
defaultSlug, _ := h.resolveDefaults()
base := h.config.CDNBasis()
manifest := tokenengine.Manifest{
APIVersion: tokenengine.RuntimeAPIVersion,
Default: defaultSlug,
BaseURL: base,
Themes: make([]tokenengine.ManifestEntry, 0, len(themes)),
}
for _, theme := range themes {
if theme.Status != domain.StatusPublished {
continue
}
manifest.Themes = append(manifest.Themes, tokenengine.ManifestEntry{
Slug: theme.Slug,
Name: theme.Name,
Description: theme.Description,
DefaultAppearance: theme.DefaultAppearance,
Version: theme.Version,
Tags: tokenengine.Tags(theme.Meta),
CSS: base + "/theme.css?slug=" + theme.Slug,
Tokens: base + "/theme.json?slug=" + theme.Slug,
})
}
if len(manifest.Themes) == 0 {
writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
return
}
// The manifest changes whenever a theme is published, so it is revalidated
// on every request rather than cached hard.
w.Header().Set("Cache-Control", "public, max-age=30, stale-while-revalidate=120")
writeJSON(w, r, http.StatusOK, manifest)
}
// resolveDefaults returns the instance-level default theme and appearance.
func (h *RuntimeHandlers) resolveDefaults() (string, domain.Appearance) {
if h.defaults == nil {
return "", ""
}
return h.defaults()
}
// resolve loads a theme, optionally at a pinned version.
func (h *RuntimeHandlers) resolve(c context.Context, slug string, pinned int64) (*domain.Theme, error) {
if pinned == 0 {
return h.store.Get(c, slug)
}
version, err := h.store.Version(c, slug, pinned)
if err != nil {
return nil, err
}
// Reconstruct a theme from the snapshot so a pinned request renders exactly
// what that version rendered.
return &domain.Theme{
ID: version.ThemeID,
Slug: version.Slug,
Name: version.Name,
Description: version.Description,
Status: version.Status,
DefaultAppearance: version.DefaultAppearance,
Tokens: version.Tokens,
Version: version.Version,
UpdatedAt: version.CreatedAt,
}, nil
}
// writeCacheable emits a cacheable asset response, honouring conditional
// requests.
//
// Answering 304 for an unchanged ETag is what keeps a re-deploy cheap for
// consumers: the browser revalidates in a few milliseconds and transfers no body.
func (h *RuntimeHandlers) writeCacheable(
w http.ResponseWriter, r *http.Request,
body []byte, contentType, etag string, version int64, updatedAt time.Time,
) {
header := w.Header()
header.Set("Content-Type", contentType)
header.Set("ETag", etag)
header.Set("Cache-Control", h.config.RuntimeCacheControl)
header.Set("Vary", "Accept-Encoding")
header.Set("X-Theme-Version", strconv.FormatInt(version, 10))
header.Set("X-Theme-Updated-At", updatedAt.UTC().Format(domain.TimeFormat))
// Revalidation: any of these headers may carry the client's cached copy.
if etagMatches(r.Header.Get("If-None-Match"), etag) {
w.WriteHeader(http.StatusNotModified)
return
}
if modifiedSince := r.Header.Get("If-Modified-Since"); modifiedSince != "" {
if since, err := http.ParseTime(modifiedSince); err == nil {
if !updatedAt.UTC().Truncate(time.Second).After(since) {
w.WriteHeader(http.StatusNotModified)
return
}
}
}
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(body)
}
// writeCacheableJSON emits a cacheable JSON response.
func (h *RuntimeHandlers) writeCacheableJSON(
w http.ResponseWriter, r *http.Request,
payload tokenengine.ResolvedTheme, etag string, version int64, updatedAt time.Time,
) {
body, err := marshalJSON(payload)
if err != nil {
writeError(w, r, http.StatusInternalServerError, CodeInternal, "could not encode the theme payload", nil)
return
}
header := w.Header()
header.Set("Content-Type", "application/json; charset=utf-8")
header.Set("ETag", etag)
header.Set("Cache-Control", h.config.RuntimeCacheControl)
header.Set("X-Theme-Version", strconv.FormatInt(version, 10))
header.Set("X-Theme-Updated-At", updatedAt.UTC().Format(domain.TimeFormat))
if etagMatches(r.Header.Get("If-None-Match"), etag) {
w.WriteHeader(http.StatusNotModified)
return
}
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
//nolint:gosec // G705: body is a marshalled JSON theme document.
_, _ = w.Write(body)
}
// etagMatches implements If-None-Match comparison, including the wildcard and
// the weak-validator prefix.
func etagMatches(header, etag string) bool {
header = strings.TrimSpace(header)
if header == "" {
return false
}
if header == "*" {
return true
}
for _, candidate := range strings.Split(header, ",") {
candidate = strings.TrimSpace(candidate)
candidate = strings.TrimPrefix(candidate, "W/")
if candidate == etag {
return true
}
}
return false
}
// sourceURL builds the sourceURL comment embedded in a generated stylesheet so
// that browser devtools can navigate back to the endpoint that produced it.
func (h *RuntimeHandlers) sourceURL(r *http.Request, theme *domain.Theme, variant string, pinned int64) string {
url := *r.URL
query := url.Query()
query.Set("slug", theme.Slug)
query.Set("variant", variant)
if pinned > 0 {
query.Set("v", strconv.FormatInt(pinned, 10))
}
url.RawQuery = query.Encode()
return url.RequestURI()
}
// validVariant reports whether a variant name is known.
func validVariant(variant string) bool {
for _, known := range tokenengine.VariantNames() {
if known == variant {
return true
}
}
return false
}
// parseOptionalAppearance validates an appearance parameter, allowing it to be
// absent so that callers can distinguish "not specified" from "specified as
// light".
func parseOptionalAppearance(raw string) (domain.Appearance, error) {
if strings.TrimSpace(raw) == "" {
return "", nil
}
return domain.ParseAppearance(raw)
}
// themeSlugFromPath extracts a slug from a path such as /rt/v1/themes/{slug}/theme.css.
func themeSlugFromPath(r *http.Request) string {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
for i, part := range parts {
if part == "themes" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
// EmbedSnippet serves a ready-to-paste integration snippet for a theme.
//
// This exists because the single hardest moment for a developer adopting a runtime
// theming service is the very first integration: knowing exactly what to paste.
// Generating it from the deployment's own base URL means the snippet is correct by
// construction.
//
// Three properties this endpoint has to hold, each of which was a bug first:
//
// - It always answers JSON, whatever the target. It used to answer text/html for
// the default target, which meant the admin UI's fetch() could not parse the
// response for the one target the UI defaults to.
// - It validates the slug against the store. It used to echo whatever slug it was
// given, so it would cheerfully generate a snippet for a theme that does not
// exist.
// - It escapes every interpolated value for the language of the target. The HTML
// target escaped; the JavaScript and TSX targets did not, so a slug containing
// a quote produced a snippet that broke out of its string literal. Since the
// output is copy-pasted into someone's application, that was a code injection
// into the consuming app, reachable unauthenticated.
func (h *RuntimeHandlers) EmbedSnippet(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
if slug == "" {
slug = themeSlugFromPath(r)
}
if slug == "" {
if defaultSlug, _ := h.resolveDefaults(); defaultSlug != "" {
slug = defaultSlug
}
}
if slug == "" {
// No slug and no configured default: pick one that exists rather than
// guessing a name, so the snippet is always about a real theme.
themes, err := h.store.ListRuntime(r.Context())
if err != nil || len(themes) == 0 {
writeError(w, r, http.StatusNotFound, CodeNotFound,
"no themes have been published, so there is nothing to generate a snippet for", nil)
return
}
slug = themes[0].Slug
}
// The existence check doubles as the injection guard's precondition: only a slug
// that a theme actually owns can reach the template.
if !isSlugShaped(slug) {
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"a theme slug may contain only lowercase letters, digits and single hyphens", nil)
return
}
theme, err := h.store.Get(r.Context(), slug)
if err != nil {
writeError(w, r, http.StatusNotFound, CodeNotFound,
"no theme named "+strconv.Quote(slug)+" exists", nil)
return
}
target := strings.TrimSpace(r.URL.Query().Get("target"))
if target == "" {
target = "html"
}
base := h.config.CDNBasis()
var payload map[string]any
switch target {
case "html":
payload = map[string]any{
"language": "html",
"snippet": htmlIntegrationSnippet(base, slug),
"note": "Paste into <head>. No build step and no npm dependency: the stylesheet is fetched from this deployment.",
}
case "react":
payload = map[string]any{
"language": "tsx",
"snippet": reactIntegrationSnippet(base, slug),
"note": "Add an import map entry mapping 'go-bananas/runtime' to " +
jsString(base+"/react.js") + ", then wrap your tree in <BananasProvider>.",
}
case "javascript":
payload = map[string]any{
"language": "js",
"snippet": javascriptIntegrationSnippet(base, slug),
"note": "Import from the absolute URL, or add an import map entry for 'go-bananas/runtime'.",
}
default:
// An unknown target used to fall through to the HTML branch, so a typo
// silently returned a page instead of saying so.
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"target must be html, react or javascript", nil)
return
}
payload["slug"] = theme.Slug
payload["name"] = theme.Name
payload["version"] = theme.Version
payload["baseUrl"] = base
// Snippets embed this deployment's absolute URL, so a CDN in front of it or a
// different public hostname changes every snippet. Nothing here may be cached.
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, r, http.StatusOK, payload)
}
// isSlugShaped reports whether a slug is made only of characters this service
// accepts in one. It is a shape check, not an existence check: it exists so that a
// crafted slug is rejected before it reaches a template, whatever the store would
// have said about it.
func isSlugShaped(slug string) bool {
if slug == "" || len(slug) > 64 {
return false
}
for i, r := range slug {
switch {
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
case r == '-' && i > 0 && i < len(slug)-1:
default:
return false
}
}
return true
}
// jsString quotes a value as a JavaScript single-quoted string literal, or returns
// the empty string when there is no value.
//
// This is the guard the old endpoint was missing. Everything interpolated into a
// JavaScript or TSX snippet goes through here, so no caller-supplied value can
// terminate the literal it sits in.
func jsString(s string) string {
if s == "" {
return ""
}
return "'" + strings.NewReplacer(
`\`, `\\`,
`'`, `\'`,
"\n", `\n`,
"\r", `\r`,
"\t", `\t`,
"<", `<`,
">", `>`,
"&", `&`,
).Replace(s) + "'"
}
// htmlIntegrationSnippet is the smallest useful integration: a stylesheet link and
// one module script.
func htmlIntegrationSnippet(base, slug string) string {
return strings.Join([]string{
`<link rel="stylesheet" href="` + htmlEscape(base) + `/theme.css?slug=` + htmlEscape(slug) + `">`,
`<script type="module">`,
` import { applyTheme, watchSystemAppearance } from ` + jsString(base+"/runtime.js") + `;`,
` watchSystemAppearance();`,
` applyTheme(` + jsString(slug) + `);`,
`</script>`,
}, "\n")
}
// javascriptIntegrationSnippet is the same thing without the stylesheet link.
func javascriptIntegrationSnippet(base, slug string) string {
return strings.Join([]string{
`import { applyTheme, watchSystemAppearance } from ` + jsString(base+"/runtime.js") + `;`,
``,
`applyTheme(` + jsString(slug) + `);`,
`watchSystemAppearance();`,
}, "\n")
}
// reactIntegrationSnippet wires the provider component.
func reactIntegrationSnippet(base, slug string) string {
return strings.Join([]string{
`<script type="importmap">`,
`{ "imports": {`,
` "react": "https://esm.sh/react@19",`,
` "react-dom/client": "https://esm.sh/react-dom@19/client",`,
` "go-bananas/runtime": "` + htmlEscape(base+"/react.js") + `"`,
`} }`,
`</script>`,
`<script type="module">`,
` import { BananasProvider } from 'go-bananas/runtime';`,
` import { createRoot } from 'react-dom/client';`,
` createRoot(document.getElementById('root')).render(`,
` <BananasProvider slug=` + jsString(slug) + ` autoDark><App /></BananasProvider>`,
` );`,
`</script>`,
}, "\n")
}
// EmbedPage serves a standalone, shareable page that shows the snippet and renders
// a live preview of the theme.
//
// It is separate from EmbedSnippet because the two have different callers: this one
// is a page a human opens, the other is data the admin UI fetches. Having one serve
// both is what produced the parse failure that motivated the split.
//
// Every code sample on the page comes from the same builders EmbedSnippet uses, so
// what a developer copies here is byte-for-byte what the admin UI shows.
func (h *RuntimeHandlers) EmbedPage(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
if slug == "" {
slug = themeSlugFromPath(r)
}
if slug == "" {
if themes, err := h.store.ListRuntime(r.Context()); err == nil && len(themes) > 0 {
slug = themes[0].Slug
}
}
if slug == "" {
writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
return
}
if !isSlugShaped(slug) {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "invalid theme slug", nil)
return
}
base := h.config.CDNBasis()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
//nolint:gosec // G705: embedPageHTML escapes the slug with htmlEscape at every
// interpolation point, and the code samples it embeds come from the same builders
// the JSON endpoint uses. gosec's taint analysis does not follow those calls.
_, _ = w.Write([]byte(embedPageHTML(base, slug)))
}
// embedPageHTML builds the standalone integration page.
func embedPageHTML(base, slug string) string {
samples := []struct {
title string
body string
}{
{"Any HTML page", htmlIntegrationSnippet(base, slug)},
{"Plain JavaScript", javascriptIntegrationSnippet(base, slug)},
{"React", reactIntegrationSnippet(base, slug)},
}
sections := make([]string, 0, len(samples))
for i, sample := range samples {
sections = append(sections, ` <section>
<h2>`+htmlEscape(sample.title)+`</h2>
<pre><code>`+htmlEscape(sample.body)+`</code></pre>
<button class="copy" data-index="`+strconv.Itoa(i)+`">Copy</button>
</section>`)
}
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>go-bananas &middot; integrate ` + htmlEscape(slug) + `</title>
<link rel="stylesheet" href="` + htmlEscape(base) + `/theme.css?slug=` + htmlEscape(slug) + `">
<style>
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #0b0d12; color: #e6e8ef; }
main { max-width: 60rem; margin: 0 auto; padding: 2.5rem 1.5rem 4rem; }
h1 { font-size: 1.5rem; margin: 0 0 .25rem; letter-spacing: -0.02em; }
p.lede { color: #9aa3b5; margin: 0 0 2rem; }
section { margin-bottom: 2.5rem; }
h2 { font-size: .8125rem; text-transform: uppercase; letter-spacing: .08em; color: #6b7488; margin: 0 0 .75rem; }
pre { background: #11141c; border: 1px solid #232838; border-radius: .625rem; padding: 1rem; overflow-x: auto;
font: 13px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; color: #cbd2e0; margin: 0; }
button.copy { margin-top: .625rem; background: #1b2130; color: #cfd6e4; border: 1px solid #2a3145;
border-radius: .5rem; padding: .4375rem .75rem; font: inherit; font-size: .8125rem; cursor: pointer; }
button.copy:hover { background: #222a3c; }
.preview { background: #0b0d12; border: 1px solid #232838; border-radius: .625rem; padding: 1.5rem; }
</style>
</head>
<body>
<main>
<h1>Integrate <code>` + htmlEscape(slug) + `</code></h1>
<p class="lede">Copy one of these into your application. Nothing is installed: the
theme is fetched from this deployment at run time.</p>
` + strings.Join(sections, "\n") + `
<section>
<h2>Live preview</h2>
<div class="preview" data-bananas-theme="` + htmlEscape(slug) + `">
<button class="bn-btn">Primary action</button>
<button class="bn-btn bn-btn--secondary">Secondary</button>
<button class="bn-btn bn-btn--ghost">Ghost</button>
<div class="bn-card" style="margin-top:1rem">
<div class="bn-card__header">Card heading</div>
<p class="bn-card__body">Every value here comes from a design token in the
theme you are editing.</p>
</div>
<p style="margin-top:1rem"><span class="bn-badge bn-badge--success">published</span>
<span class="bn-badge bn-badge--warning">draft</span>
<span class="bn-badge bn-badge--danger">archived</span></p>
<input class="bn-input" placeholder="Input field">
</div>
</section>
</main>
<script type="module">
const codes = [...document.querySelectorAll('pre code')];
for (const button of document.querySelectorAll('button.copy')) {
button.addEventListener('click', async () => {
await navigator.clipboard.writeText(codes[Number(button.dataset.index)]?.textContent ?? '');
button.textContent = 'Copied';
setTimeout(() => { button.textContent = 'Copy' }, 1200);
});
}
</script>
</body>
</html>`
}
// htmlEscape escapes a string for interpolation into HTML text or an attribute.
//
// Every interpolated value here is a slug or a base URL, both of which the
// operator controls, but escaping them anyway means a malformed configuration
// cannot turn the snippet page into an injection vector.
func htmlEscape(s string) string {
return html.EscapeString(s)
}

130
internal/logging/logging.go Normal file
View file

@ -0,0 +1,130 @@
// Package logging configures structured logging for the service.
//
// It wraps log/slog rather than a third-party logger so that the output is
// plain JSON or plain text with no vendor in the dependency graph, and so that
// the level and format can be chosen from the environment the Helm chart sets.
package logging
import (
"context"
"io"
"log/slog"
"os"
"strings"
)
// Logger is the logging interface the application depends on. Declaring it here
// keeps handlers and middleware testable with a recorder, and keeps them from
// importing slog directly.
type Logger interface {
Debug(msg string, args ...any)
Info(msg string, args ...any)
Warn(msg string, args ...any)
Error(msg string, args ...any)
With(args ...any) Logger
}
// Options configures a logger.
type Options struct {
// Level is debug, info, warn or error.
Level string
// Format is json or text.
Format string
// Service is the value of the service.name attribute.
Service string
// Environment is the deployment name.
Environment string
// Version is the build version, reported in logs.
Version string
// Commit is the source revision, reported in logs.
Commit string
// BuildDate is the build timestamp, reported in logs.
BuildDate string
// Writer overrides the destination, for tests.
Writer io.Writer
}
// New builds a logger.
func New(opts Options) Logger {
writer := opts.Writer
if writer == nil {
writer = os.Stdout
}
handlerOpts := &slog.HandlerOptions{Level: parseLevel(opts.Level)}
var handler slog.Handler
if strings.EqualFold(opts.Format, "text") {
handler = slog.NewTextHandler(writer, handlerOpts)
} else {
handler = slog.NewJSONHandler(writer, handlerOpts)
}
logger := slog.New(handler).With(
slog.String("service", orDefault(opts.Service, "Go Bananas!!")),
slog.String("environment", opts.Environment),
slog.String("version", opts.Version),
slog.String("commit", orDefault(opts.Commit, "unknown")),
slog.String("built", orDefault(opts.BuildDate, "unknown")),
)
return &slogLogger{logger: logger}
}
// parseLevel maps a configuration string onto a slog level, defaulting to info
// so that a typo never silences the service.
func parseLevel(raw string) slog.Level {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
// orDefault returns value when it is not empty, otherwise fallback.
func orDefault(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
// slogLogger adapts *slog.Logger to the Logger interface.
type slogLogger struct {
logger *slog.Logger
}
func (l *slogLogger) Debug(msg string, args ...any) { l.logger.Debug(msg, args...) }
func (l *slogLogger) Info(msg string, args ...any) { l.logger.Info(msg, args...) }
func (l *slogLogger) Warn(msg string, args ...any) { l.logger.Warn(msg, args...) }
func (l *slogLogger) Error(msg string, args ...any) { l.logger.Error(msg, args...) }
func (l *slogLogger) With(args ...any) Logger {
return &slogLogger{logger: l.logger.With(args...)}
}
// Discard returns a logger that drops everything. It exists so that tests and
// one-off commands do not have to thread a logger through just to satisfy the
// dependency.
func Discard() Logger {
return &slogLogger{logger: slog.New(slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 8}))}
}
// FromContext returns the logger stored on a context, or a discarding logger.
func FromContext(ctx context.Context) Logger {
if logger, ok := ctx.Value(loggerKey{}).(Logger); ok {
return logger
}
return Discard()
}
type loggerKey struct{}
// WithContext stores a logger on a context.
func WithContext(ctx context.Context, logger Logger) context.Context {
return context.WithValue(ctx, loggerKey{}, logger)
}

View file

@ -0,0 +1,605 @@
package seed
import (
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// Palettes consolidated from the sibling applications.
//
// Each of these is a real palette lifted from an application that is already running
// with it, rather than a generated ramp. That is the point of consolidating them: the
// applications can adopt the shared contract and keep the look their users already
// have, instead of being handed a new design.
//
// The colours are the roles those applications actually use, mapped onto the contract's
// roles. A source application had only one appearance for some roles and a different
// one for others, so both schemes are filled in here and the contract supplies the
// second axis.
//
// Provenance is recorded in the description. A palette nobody can trace back to the
// screen it came from becomes an orphan within a year.
// Colours and values repeated across the palettes below.
//
// A consolidated palette set is mostly repetition by nature — every dark scheme has
// the same white for reversed text, every go-loose theme has the same card radius —
// so these are named rather than repeated inline. That makes a change to "what white
// means here" a one-line edit instead of a search.
const (
white = "#ffffff"
paperLight = "#fffaf0"
cardRadius = "16px"
// tightRadius is the corner radius of the two palettes whose source application
// uses tighter geometry than go-loose's 16px cards.
tightRadius = "10px"
// interStack is the sans stack four of the six applications ask for by name. None
// of them load the file, so in practice this resolves to the system UI font; it
// is kept because it is what the applications declare, and a future font load
// should not have to touch six stylesheets.
interStack = "'Inter', ui-sans-serif, system-ui, sans-serif"
)
// scheme is one appearance's worth of literal colours.
type scheme struct {
Background string
Surface string
SurfaceRaised string
SurfaceSunken string
Border string
BorderStrong string
Text string
TextMuted string
TextSubtle string
TextInverted string
Primary string
PrimaryHover string
PrimaryActive string
OnPrimary string
Secondary string
OnSecondary string
Success string
Warning string
Danger string
Info string
FocusRing string
Overlay string
Ramp tokenengine.RampOptions
FontSans string
RadiusCard string
}
// consolidate assembles a seeded theme from two literal schemes.
//
// Ramps are still generated, for the palette and status families the contract exposes
// as scales (`--bn-palette-*`). The semantic roles are literal, because those are the
// values the source applications actually render and inventing a ramp here would
// change the appearance the consolidation is meant to preserve.
func consolidate(
slug, name, description string,
defaultAppearance domain.Appearance,
tags []string,
light, dark scheme,
) SeededTheme {
palettes := map[string]any{
roleBrand: tokenengine.GenerateRamp(light.Ramp),
roleNeutral: tokenengine.GenerateRamp(neutralRamp(light.Ramp)),
roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 142, Saturation: 55, Lightness: 42, HueSpread: 10}),
roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 38, Saturation: 88, Lightness: 50, HueSpread: 8}),
roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 358, Saturation: 68, Lightness: 50, HueSpread: 8}),
}
fontSans := light.FontSans
if fontSans == "" {
fontSans = interStack
}
typography := typeScale(fontSans, 0.875, 1.25, 1.625)
typography["family"] = map[string]any{
"sans": family(fontSans),
"mono": family("ui-monospace, SFMono-Regular, Menlo, monospace"),
"display": family(fontSans),
}
shadowSet := map[string]any{
"none": shadow("none"),
"sm": shadow("0 1px 2px rgba(0,0,0,0.06)"),
"md": shadow("0 6px 20px rgba(0,0,0,0.08)"),
"lg": shadow("0 18px 48px rgba(0,0,0,0.14)"),
"xl": shadow("0 28px 80px rgba(0,0,0,0.20)"),
"border": shadow("inset 0 0 0 1px rgba(0,0,0,0.08)"),
}
doc := assemble(palettes,
literalSemantics(light),
literalSemantics(dark),
typography,
shadowSet,
)
// The transcribed corner radius becomes a real token rather than a note in a
// struct field. A consumer writing `border-radius: var(--radius-md)` then gets
// the source application's actual radius, which is the whole reason for copying
// its palette rather than approximating one.
applyCardRadius(doc, light.RadiusCard)
return SeededTheme{
Slug: slug,
Name: name,
Description: description,
Appearance: defaultAppearance,
Tags: tags,
Tokens: doc,
}
}
// applyCardRadius sets the radius scale so that its `md` step is the source
// application's card radius, with the surrounding steps kept in proportion.
//
// A palette whose corners are visibly rounder or squarer than the application it came
// from does not read as that application, and the radius is one of the first things a
// user notices.
func applyCardRadius(doc domain.TokenDoc, card string) {
if card == "" {
return
}
group, ok := doc["radius"].(map[string]any)
if !ok {
group = map[string]any{}
doc["radius"] = group
}
// Proportions taken from the scale the other built-in themes use, so a
// consolidated palette and an original one are the same shape.
for step, factor := range map[string]string{
"sm": "0.5",
"md": "1",
"lg": "1.5",
"xl": "2",
} {
if step == "md" {
group[step] = dim(card)
continue
}
group[step] = dim("calc(" + card + " * " + factor + ")")
}
}
// neutralRamp derives a low-saturation neutral from a brand ramp, so a palette and its
// greys come from one description rather than two guesses.
func neutralRamp(brand tokenengine.RampOptions) tokenengine.RampOptions {
return tokenengine.RampOptions{
Hue: brand.Hue,
Saturation: minFloat(brand.Saturation*0.18, 22),
Lightness: minFloat(brand.Lightness*0.72, 52),
HueSpread: 4,
}
}
// minFloat returns the smaller of two values.
func minFloat(a, b float64) float64 {
if a < b {
return a
}
return b
}
// literalSemantics turns a scheme into the contract's semantic colour roles.
//
// Every role is filled explicitly. A missing role would fall back to the engine's
// light-mode default, which for a dark-first palette is a pale colour in the middle of
// a dark interface — a bug that is easy to miss and impossible to debug from a
// screenshot.
func literalSemantics(s scheme) map[string]any {
out := map[string]any{
"background": color(s.Background),
"surface": color(s.Surface),
"surface-raised": color(s.SurfaceRaised),
"surface-sunken": color(s.SurfaceSunken),
"border": color(s.Border),
"border-strong": color(s.BorderStrong),
"text": color(s.Text),
"text-muted": color(s.TextMuted),
"text-subtle": color(s.TextSubtle),
"text-inverted": color(s.TextInverted),
"primary": color(s.Primary),
"primary-hover": color(s.PrimaryHover),
"primary-active": color(s.PrimaryActive),
"on-primary": color(s.OnPrimary),
"secondary": color(s.Secondary),
"on-secondary": color(s.OnSecondary),
"success": color(s.Success),
"warning": color(s.Warning),
"danger": color(s.Danger),
"info": color(s.Info),
"focus-ring": color(s.FocusRing),
"overlay": color(s.Overlay),
}
return out
}
// The four palettes below come from four different applications. The colours are
// theirs, transcribed; where an application had only one appearance, the other is
// derived by inverting the surface and text roles and keeping the accent family, which
// is what its own stylesheet effectively does.
// tokyoNight is go-loose's default palette, the one its switcher opens on.
//
// The slug is `tokyo`, not `tokyo-night`, because that is the value go-loose already
// writes into its data-theme attribute. Matching it is the whole point: adopting the
// contract must not require the adopting application to change a single stored value.
func tokyoNight() SeededTheme {
blue := tokenengine.RampOptions{Hue: 224, Saturation: 60, Lightness: 58, HueSpread: 10}
return consolidate(
"tokyo", "Tokyo Night",
"go-loose's default palette. Low-contrast blue-grey with a bright accent; "+
"chosen for long sessions against a terminal.",
domain.AppearanceDark,
[]string{appearanceDark, tagDefault, "blue", "go-loose"},
scheme{
Background: "#e6e7ed", Surface: white, SurfaceRaised: white,
SurfaceSunken: "#dfe2ed", Border: "#b4b5c3", BorderStrong: "#8b8da1",
Text: "#343b58", TextMuted: "#34548a", TextSubtle: "#4a5a80",
TextInverted: white,
Primary: "#0f4b6e", PrimaryHover: "#0c3d5a", PrimaryActive: "#093249",
OnPrimary: white,
Secondary: "#5a4a78", OnSecondary: white,
Success: "#485e30", Warning: "#8a6a1f", Danger: "#8c4351", Info: "#2f6b8f",
FocusRing: "#0f4b6e", Overlay: "rgba(52, 59, 88, 0.32)",
Ramp: blue, FontSans: interStack,
RadiusCard: cardRadius,
},
scheme{
Background: "#1a1b26", Surface: "#20222f", SurfaceRaised: "#282a37",
SurfaceSunken: "#16161e", Border: "#292e42", BorderStrong: "#3b4261",
Text: "#c0caf5", TextMuted: "#9aa5ce", TextSubtle: "#7aa2f7",
TextInverted: "#1a1b26",
Primary: "#7aa2f7", PrimaryHover: "#93b1ff", PrimaryActive: "#aac7ff",
OnPrimary: "#1a1b26",
Secondary: "#bb9af7", OnSecondary: "#1a1b26",
Success: "#9ece6a", Warning: "#e0af68", Danger: "#f7768e", Info: "#7dcfff",
FocusRing: "#7aa2f7", Overlay: "rgba(0, 0, 0, 0.55)",
Ramp: blue, FontSans: interStack,
RadiusCard: cardRadius,
},
)
}
// catppuccin is go-loose's second palette, a soft pastel set.
func catppuccin() SeededTheme {
lavender := tokenengine.RampOptions{Hue: 232, Saturation: 62, Lightness: 62, HueSpread: 12}
return consolidate(
"catppuccin", "Catppuccin",
"go-loose's pastel palette. Softer contrast than Tokyo Night, which suits a "+
"content-heavy screen better than a dense one.",
domain.AppearanceDark,
[]string{appearanceDark, "pastel", "lavender", "go-loose"},
scheme{
Background: "#eff1f5", Surface: white, SurfaceRaised: white,
SurfaceSunken: "#e6e9ef", Border: "#bcc0cc", BorderStrong: "#9499b0",
Text: "#4c4f69", TextMuted: "#5c61a8", TextSubtle: "#6c6f85",
TextInverted: white,
Primary: "#1e66f5", PrimaryHover: "#1a56db", PrimaryActive: "#1647b8",
OnPrimary: white,
Secondary: "#8839ef", OnSecondary: white,
Success: "#40a02b", Warning: "#df8e1d", Danger: "#d20f39", Info: "#179299",
FocusRing: "#1e66f5", Overlay: "rgba(76, 79, 105, 0.28)",
Ramp: lavender, FontSans: interStack,
RadiusCard: cardRadius,
},
scheme{
Background: "#1e1e2e", Surface: "#181825", SurfaceRaised: "#313244",
SurfaceSunken: "#11111b", Border: "#313244", BorderStrong: "#45475a",
Text: "#cdd6f4", TextMuted: "#a6adc8", TextSubtle: "#9399b2",
TextInverted: "#1e1e2e",
Primary: "#89b4fa", PrimaryHover: "#a3c3f5", PrimaryActive: "#b8d0f6",
OnPrimary: "#1e1e2e",
Secondary: "#cba6f7", OnSecondary: "#1e1e2e",
Success: "#a6e3a1", Warning: "#f9e2af", Danger: "#f38ba8", Info: "#94e2d5",
FocusRing: "#89b4fa", Overlay: "rgba(0, 0, 0, 0.5)",
Ramp: lavender, FontSans: interStack,
RadiusCard: cardRadius,
},
)
}
// nordicMoss is go-tell's green palette, slugged `nordic` to match its own value. Its source theme is dark-only; the light
// scheme is the inversion with the green family kept.
func nordicMoss() SeededTheme {
moss := tokenengine.RampOptions{Hue: 132, Saturation: 32, Lightness: 44, HueSpread: 12}
return consolidate(
"nordic", "Nordic Moss",
"go-tell's green palette. Desaturated and low-contrast; the light scheme is "+
"derived, because the source theme is dark-only.",
domain.AppearanceDark,
[]string{appearanceDark, "green", "calm", "go-tell"},
scheme{
Background: "#e9f0e8", Surface: white, SurfaceRaised: "#f4f8f3",
SurfaceSunken: "#dfe9de", Border: "#c2d6c4", BorderStrong: "#94a899",
Text: "#16241c", TextMuted: "#3b5645", TextSubtle: "#588157",
TextInverted: white,
Primary: "#2e4435", PrimaryHover: "#25392d", PrimaryActive: "#1a2820",
OnPrimary: "#e9f0e8",
Secondary: "#6f894e", OnSecondary: white,
Success: "#3f6b46", Warning: "#8a7434", Danger: "#a1453f", Info: "#3a6a70",
FocusRing: "#2e4435", Overlay: "rgba(22, 36, 28, 0.28)",
Ramp: moss, FontSans: interStack,
RadiusCard: tightRadius,
},
scheme{
Background: "#121c16", Surface: "#1e3025", SurfaceRaised: "#25392d",
SurfaceSunken: "#0e1712", Border: "#2e4435", BorderStrong: "#3b5645",
Text: "#e9f0e8", TextMuted: "#a3c9a8", TextSubtle: "#8fa193",
TextInverted: "#121c16",
Primary: "#a3c9a8", PrimaryHover: "#b8d8bb", PrimaryActive: "#c9e2cb",
OnPrimary: "#121c16",
Secondary: "#8fbc8f", OnSecondary: "#121c16",
Success: "#8fbc8f", Warning: "#e2c044", Danger: "#e07a72", Info: "#7fb4ca",
FocusRing: "#a3c9a8", Overlay: "rgba(0, 0, 0, 0.52)",
Ramp: moss, FontSans: interStack,
RadiusCard: tightRadius,
},
)
}
// deepSpace is go-tell's `obsidian` palette, and the one that most needs a light
// scheme: its source is a near-black space theme, so an untouched inversion would be
// unusable.
func deepSpace() SeededTheme {
sky := tokenengine.RampOptions{Hue: 199, Saturation: 88, Lightness: 58, HueSpread: 12}
return consolidate(
"obsidian", "Deep Space Obsidian",
"go-tell's near-black blue palette. The light scheme is authored rather than "+
"inverted, because the source is a dark-first space theme.",
domain.AppearanceDark,
[]string{appearanceDark, "blue", "space", "go-tell"},
scheme{
Background: "#f1f5f9", Surface: white, SurfaceRaised: "#f8fafc",
SurfaceSunken: "#e8eef5", Border: "#d3dce6", BorderStrong: "#a8b6c6",
Text: "#0f172a", TextMuted: "#475569", TextSubtle: "#64748b",
TextInverted: "#f8fafc",
Primary: "#0284c7", PrimaryHover: "#0369a1", PrimaryActive: "#075985",
OnPrimary: white,
Secondary: "#1e293b", OnSecondary: "#f8fafc",
Success: "#15803d", Warning: "#b45309", Danger: "#b91c1c", Info: "#0e7490",
FocusRing: "#0284c7", Overlay: "rgba(15, 23, 42, 0.3)",
Ramp: sky, FontSans: interStack,
RadiusCard: tightRadius,
},
scheme{
Background: "#060709", Surface: "#0f172a", SurfaceRaised: "#151d2a",
SurfaceSunken: "#020617", Border: "#1e293b", BorderStrong: "#334155",
Text: "#e2e8f0", TextMuted: "#94a3b8", TextSubtle: "#64748b",
TextInverted: "#060709",
Primary: "#38bdf8", PrimaryHover: "#7dd3fc", PrimaryActive: "#bae6fd",
OnPrimary: "#060709",
Secondary: "#60a5fa", OnSecondary: "#060709",
Success: "#4ade80", Warning: "#fbbf24", Danger: "#f87171", Info: "#22d3ee",
FocusRing: "#38bdf8", Overlay: "rgba(0, 0, 0, 0.6)",
Ramp: sky, FontSans: interStack,
RadiusCard: tightRadius,
},
)
}
// urpiStandard is go-guess's palette, which is the only light-first application of the
// six and therefore the one whose light scheme is the authoritative one.
func urpiStandard() SeededTheme {
cyan := tokenengine.RampOptions{Hue: 190, Saturation: 78, Lightness: 40, HueSpread: 10}
return consolidate(
"urpi-standard", "urpi Standard",
"go-guess's palette: a cyan accent and lime highlight on a cool grey. The "+
"light scheme is authoritative, being the application's only one.",
domain.AppearanceLight,
[]string{"light", tagDefault, "cyan", "go-guess"},
scheme{
Background: "#f3f7f8", Surface: white, SurfaceRaised: white,
SurfaceSunken: "#edf2f3", Border: "#dce5e8", BorderStrong: "#c2d2d6",
Text: "#142b3a", TextMuted: "#526872", TextSubtle: "#78909c",
TextInverted: white,
Primary: "#007f98", PrimaryHover: "#006b81", PrimaryActive: "#00586a",
OnPrimary: white,
Secondary: "#d9ef4d", OnSecondary: "#0b573c",
Success: "#13704d", Warning: "#925a09", Danger: "#934141", Info: "#087187",
FocusRing: "#007f98", Overlay: "rgba(14, 42, 56, 0.34)",
Ramp: cyan, FontSans: "'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif",
RadiusCard: cardRadius,
},
scheme{
Background: "#081c2c", Surface: "#0e2a3f", SurfaceRaised: "#123449",
SurfaceSunken: "#04131f", Border: "#1d4258", BorderStrong: "#2c5f7a",
Text: "#e3ebed", TextMuted: "#a9bec8", TextSubtle: "#82949d",
TextInverted: "#081c2c",
Primary: "#00a9c8", PrimaryHover: "#33bcd6", PrimaryActive: "#66cde0",
OnPrimary: "#04131f",
Secondary: "#d9ef4d", OnSecondary: "#0b573c",
Success: "#16a274", Warning: "#eda83c", Danger: "#e6988c", Info: "#43c99c",
FocusRing: "#00a9c8", Overlay: "rgba(0, 0, 0, 0.55)",
Ramp: cyan, FontSans: "'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif",
RadiusCard: cardRadius,
},
)
}
// tomorrow is go-loose's third palette: a warm grey with a muted teal.
func tomorrow() SeededTheme {
warm := tokenengine.RampOptions{Hue: 40, Saturation: 8, Lightness: 42, HueSpread: 4}
teal := tokenengine.RampOptions{Hue: 178, Saturation: 34, Lightness: 40, HueSpread: 8}
return consolidate(
"tomorrow", "Tomorrow",
"go-loose's warm grey palette. The least saturated of the four, and the "+
"easiest to read for long periods.",
domain.AppearanceDark,
[]string{appearanceDark, "warm", "muted", "go-loose"},
scheme{
Background: "#f5f5f5", Surface: white, SurfaceRaised: white,
SurfaceSunken: "#e8e8e8", Border: "#c9c9c9", BorderStrong: "#a3a3a3",
Text: "#4d4d4c", TextMuted: "#6e6e6c", TextSubtle: "#8a8a88",
TextInverted: white,
Primary: "#3e999f", PrimaryHover: "#348389", PrimaryActive: "#2b6d72",
OnPrimary: white,
Secondary: "#8959a8", OnSecondary: white,
Success: "#718c00", Warning: "#b58900", Danger: "#c82829", Info: "#3e999f",
FocusRing: "#3e999f", Overlay: "rgba(77, 77, 76, 0.3)",
Ramp: teal, FontSans: interStack,
RadiusCard: cardRadius,
},
scheme{
Background: "#1d1f21", Surface: "#282a2e", SurfaceRaised: "#373b41",
SurfaceSunken: "#1a1c1e", Border: "#4d5057", BorderStrong: "#6b6f77",
Text: "#c5c8c6", TextMuted: "#a0a4a2", TextSubtle: "#81a2be",
TextInverted: "#1d1f21",
Primary: "#8abeb7", PrimaryHover: "#a5d1cb", PrimaryActive: "#bcdfd9",
OnPrimary: "#1d1f21",
Secondary: "#b294bb", OnSecondary: "#1d1f21",
Success: "#b5bd68", Warning: "#e0af68", Danger: "#cc6666", Info: "#81a2be",
FocusRing: "#8abeb7", Overlay: "rgba(0, 0, 0, 0.52)",
Ramp: warm, FontSans: interStack,
RadiusCard: cardRadius,
},
)
}
// kanagawa is go-loose's fourth palette: a warm paper light scheme and a stone dark one.
func kanagawa() SeededTheme {
wave := tokenengine.RampOptions{Hue: 28, Saturation: 26, Lightness: 56, HueSpread: 10}
stone := tokenengine.RampOptions{Hue: 20, Saturation: 10, Lightness: 46, HueSpread: 6}
return consolidate(
"kanagawa", "Kanagawa",
"go-loose's paper palette. A warm off-white light scheme, which is the "+
"warmest surface in the set.",
domain.AppearanceLight,
[]string{"light", "warm", "paper", "go-loose"},
scheme{
Background: "#f2ecde", Surface: paperLight, SurfaceRaised: paperLight,
SurfaceSunken: "#e7dba9", Border: "#c8c093", BorderStrong: "#a89b6e",
Text: "#545464", TextMuted: "#5e5e70", TextSubtle: "#78788c",
TextInverted: paperLight,
Primary: "#4d7f8c", PrimaryHover: "#406d78", PrimaryActive: "#345c65",
OnPrimary: paperLight,
Secondary: "#624c83", OnSecondary: paperLight,
Success: "#6f894e", Warning: "#9a7d2e", Danger: "#c34043", Info: "#4d7f8c",
FocusRing: "#4d7f8c", Overlay: "rgba(84, 84, 100, 0.3)",
Ramp: stone, FontSans: interStack,
RadiusCard: cardRadius,
},
scheme{
Background: "#1f1f28", Surface: "#2a2a37", SurfaceRaised: "#363646",
SurfaceSunken: "#191921", Border: "#54546d", BorderStrong: "#6b6b85",
Text: "#dcd7ba", TextMuted: "#b4b098", TextSubtle: "#a8a28c",
TextInverted: "#1f1f28",
Primary: "#7fb4ca", PrimaryHover: "#98c4d6", PrimaryActive: "#b0d2e0",
OnPrimary: "#1f1f28",
Secondary: "#957fb8", OnSecondary: "#1f1f28",
Success: "#98bb6c", Warning: "#e0c07a", Danger: "#e46876", Info: "#7fb4ca",
FocusRing: "#7fb4ca", Overlay: "rgba(0, 0, 0, 0.5)",
Ramp: wave, FontSans: interStack,
RadiusCard: cardRadius,
},
)
}
// cyberpunk is go-tell's default palette: a near-black violet field with a hot pink
// and a bright cyan.
//
// It is the one palette in the set whose identity is two accents rather than one, and
// that is why it is expressed with two status roles doing unusual work: pink carries
// the brand, cyan carries interaction. Keeping pink on `--bn-accent` would lose the
// pink-versus-cyan distinction that the whole theme is built on, so the secondary
// carries cyan instead and the pair reads in the order the design intends.
func cyberpunk() SeededTheme {
violet := tokenengine.RampOptions{Hue: 288, Saturation: 72, Lightness: 44, HueSpread: 20}
return consolidate(
"cyberpunk", "Cyberpunk",
"go-tell's default palette. A hot-pink and cyan pair on near-black violet, "+
"which is the one palette here built on two accents rather than one.",
domain.AppearanceDark,
[]string{appearanceDark, tagDefault, "pink", "cyan", "go-tell"},
scheme{
Background: "#f2e8ee", Surface: "#ffffff", SurfaceRaised: "#fdf5f9",
SurfaceSunken: "#e9dde5", Border: "#c9b3c0", BorderStrong: "#a68b9c",
Text: "#2a1226", TextMuted: "#5c4457", TextSubtle: "#7f6577",
TextInverted: "#f5e0e8",
Primary: "#c2185b", PrimaryHover: "#a5144d", PrimaryActive: "#8c1042",
OnPrimary: white,
// Cyan as the secondary: see the note above.
Secondary: "#00838f", OnSecondary: white,
Success: "#2e7d32", Warning: "#c77800", Danger: "#c62828", Info: "#0277bd",
FocusRing: "#c2185b", Overlay: "rgba(42, 18, 38, 0.5)",
Ramp: violet, FontSans: interStack, RadiusCard: tightRadius,
},
scheme{
Background: "#120826", Surface: "#1b1035", SurfaceRaised: "#22153d",
SurfaceSunken: "#0c0519", Border: "#3d2366", BorderStrong: "#4a2d7a",
Text: "#f5e0e8", TextMuted: "#a6adc8", TextSubtle: "#7f849c",
TextInverted: "#120826",
// The source has no separate hover or active step; these are the two
// lighter pinks it uses on hover and on the cursor.
Primary: "#ff2a85", PrimaryHover: "#ff4d9a", PrimaryActive: "#ff6b9d",
OnPrimary: "#120826",
// Cyan as the secondary, so a consumer's --bn-secondary reaches the
// interaction colour the theme is known for.
Secondary: "#00f0ff", OnSecondary: "#120826",
Success: "#00e5a0", Warning: "#ffd166", Danger: "#ef233c", Info: "#00f0ff",
FocusRing: "#00f0ff", Overlay: "rgba(0, 0, 0, 0.62)",
Ramp: violet, FontSans: interStack, RadiusCard: tightRadius,
},
)
}
// terminalRetro is go-tell's green-on-black palette.
//
// Two of its traits are not colours and cannot be expressed as tokens: it forces a
// monospace face on every element, and it adds a text glow. Both are recorded in the
// description and left in the application's own stylesheet, because a token is a
// value and these are rules.
func terminalRetro() SeededTheme {
green := tokenengine.RampOptions{Hue: 138, Saturation: 100, Lightness: 45, HueSpread: 16}
return consolidate(
"terminal", "Terminal Retro",
"go-tell's green-on-black palette. The source theme also forces a monospace "+
"face on every element and adds a text glow; those are rules rather than "+
"values, so they stay in the application's stylesheet.",
domain.AppearanceDark,
[]string{"dark", "terminal", "mono", "green", "go-tell"},
scheme{
Background: "#e8f5e9", Surface: "#ffffff", SurfaceRaised: "#f2fbf3",
SurfaceSunken: "#dcecdc", Border: "#a8d8ad", BorderStrong: "#7fbf86",
Text: "#041404", TextMuted: "#1e5c22", TextSubtle: "#3d7a42",
TextInverted: "#041404",
Primary: "#007a12", PrimaryHover: "#006410", PrimaryActive: "#004e0d",
OnPrimary: "#dcfce0",
Secondary: "#7a5c00", OnSecondary: "#fff8dc",
Success: "#00a32b", Warning: "#8a6d00", Danger: "#b30000", Info: "#007a12",
FocusRing: "#00a32b", Overlay: "rgba(4, 20, 4, 0.55)",
Ramp: green,
FontSans: "'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
RadiusCard: "4px",
},
scheme{
Background: "#040804", Surface: "#081608", SurfaceRaised: "#0e1a0e",
SurfaceSunken: "#020402", Border: "#00aa44", BorderStrong: "#00ff66",
Text: "#00ff66", TextMuted: "#33ff33", TextSubtle: "#00aa44",
TextInverted: "#040804",
// The source's brightest green is its interactive colour, and its mid green
// is the hover step.
Primary: "#00ff66", PrimaryHover: "#33ff33", PrimaryActive: "#00dd55",
OnPrimary: "#040804",
// The Mac traffic-light yellow the source uses in its window chrome.
Secondary: "#ffcc00", OnSecondary: "#261e00",
Success: "#00ff66", Warning: "#ffcc00", Danger: "#ff3333", Info: "#00aa44",
FocusRing: "#00ff66", Overlay: "rgba(0, 0, 0, 0.7)",
Ramp: green,
FontSans: "'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
RadiusCard: "4px",
},
)
}

View file

@ -0,0 +1,80 @@
package seed_test
import (
"strings"
"testing"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/seed"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// TestGoTellSlugsAreSeeded is the one that matters for adoption.
//
// go-tell's theme switcher offers cyberpunk, nordic, obsidian and terminal, and its
// stylesheet keys on html[data-theme="<slug>"]. If any of those slugs is missing from
// the service, selecting it sets an attribute that resolves to nothing and the
// application falls back to whichever rule happens to match last. That is invisible in
// a smoke test and obvious to a user, so it is asserted here.
func TestGoTellSlugsAreSeeded(t *testing.T) {
want := []string{"cyberpunk", "nordic", "obsidian", "terminal"}
have := map[string]bool{}
for _, slug := range seed.Slugs() {
have[slug] = true
}
for _, slug := range want {
if !have[slug] {
t.Errorf("go-tell's %q palette is not seeded, so selecting it would do nothing", slug)
}
}
}
// TestEveryConsolidatedPaletteResolvesInBothAppearances is the property the contract
// depends on, and it is expressed through the engine rather than through the token
// document's shape.
//
// The shape is deliberately not the assertion. In this schema the base document *is*
// the light scheme and `$appearances` carries only the dark override, so a theme with
// both schemes still has one key under `$appearances`. What matters is that resolving
// a consolidated palette in each appearance yields a different background, which is
// what makes the appearance control do anything at all.
func TestEveryConsolidatedPaletteResolvesInBothAppearances(t *testing.T) {
for _, def := range seed.All() {
if !strings.Contains(def.Description, "go-") {
continue // an original go-bananas theme, not a consolidated one
}
theme := def.Theme()
light := tokenengine.Resolve(theme, domain.AppearanceLight)
dark := tokenengine.Resolve(theme, domain.AppearanceDark)
read := func(doc *tokenengine.Doc, path string) string {
token, ok := doc.Lookup(path)
if !ok {
return ""
}
return token.CSS
}
lightBg := read(light, "color.semantic.background")
darkBg := read(dark, "color.semantic.background")
if lightBg == "" || darkBg == "" {
t.Errorf("%s does not resolve a background in both appearances (light=%q dark=%q)",
def.Slug, lightBg, darkBg)
continue
}
if lightBg == darkBg {
t.Errorf("%s resolves the same background in both appearances (%s), "+
"so the appearance control would do nothing for it", def.Slug, lightBg)
}
// Text has to differ too, or a palette with a dark background and dark text
// would pass the background check and be unreadable.
if read(light, "color.semantic.text") == read(dark, "color.semantic.text") {
t.Errorf("%s resolves the same text colour in both appearances", def.Slug)
}
}
}

586
internal/seed/seed.go Normal file
View file

@ -0,0 +1,586 @@
// Package seed holds the built-in themes that ship inside the binary.
//
// Reconciliation rule: a built-in theme is (re)created when missing, and
// refreshed only while the stored copy is still at its seed version. Once an
// operator edits a built-in theme its version moves past the seed and it is
// never touched again. This gives operators a safe upgrade path without the
// risk of a deploy silently reverting deliberate work.
//
// Themes are declared in Go rather than as embedded JSON so that palette values
// stay type-checked and the seed stays in step with the engine's schema.
package seed
import (
"fmt"
"strings"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// SeedVersion is bumped whenever a built-in theme's tokens change. The
// reconciler compares it against the stored version to decide whether to
// refresh a built-in copy.
const SeedVersion int64 = 2
// SeededTheme is one built-in theme definition.
type SeededTheme struct {
Slug string
Name string
Description string
Appearance domain.Appearance
Tags []string
Tokens domain.TokenDoc
}
// All returns every built-in theme in a stable order.
func All() []SeededTheme {
return []SeededTheme{
midnight(), daylight(), mono(), highContrast(),
// The palettes consolidated from the sibling applications. They come after
// the original four so the default listing still leads with the house
// styles, and they are all dark-first or light-first as their source was.
urpiStandard(), tokyoNight(), catppuccin(), tomorrow(), kanagawa(),
nordicMoss(), deepSpace(), cyberpunk(), terminalRetro(),
}
}
// Slugs returns the slugs of every built-in theme.
func Slugs() []string {
defs := All()
out := make([]string, len(defs))
for i, d := range defs {
out[i] = d.Slug
}
return out
}
// IsBuiltIn reports whether slug belongs to a built-in theme.
func IsBuiltIn(slug string) bool {
for _, s := range Slugs() {
if s == slug {
return true
}
}
return false
}
// Vocabulary shared by every built-in theme.
//
// These are group names inside a token document and an appearance value, so they
// repeat once per theme by construction. Naming them means the contract layer, the
// renderer and the seeds all agree on one spelling, and a change to one is a
// single-line diff rather than a search across every palette.
const (
roleSuccess = "success"
roleWarning = "warning"
roleDanger = "danger"
roleBrand = "brand"
roleNeutral = "neutral"
appearanceDark = "dark"
// tagDefault marks a palette as a sensible starting point in the selector, rather
// than one that happens to be published.
tagDefault = "default"
)
// ---------------------------------------------------------------- builders --
func leaf(value any, kind tokenengine.Kind) map[string]any {
return map[string]any{tokenengine.TypeKey: string(kind), tokenengine.ValueKey: value}
}
func color(value string) map[string]any { return leaf(value, tokenengine.KindColor) }
func dim(value any) map[string]any { return leaf(value, tokenengine.KindDimension) }
func num(value any) map[string]any { return leaf(value, tokenengine.KindNumber) }
func weight(value any) map[string]any { return leaf(value, tokenengine.KindFontWeight) }
func leading(value any) map[string]any { return leaf(value, tokenengine.KindLineHeight) }
func dur(ms any) map[string]any { return leaf(ms, tokenengine.KindDuration) }
func family(v string) map[string]any { return leaf(v, tokenengine.KindFontFamily) }
func shadow(v string) map[string]any { return leaf(v, tokenengine.KindShadow) }
func bezier(a, b, c, d float64) map[string]any {
return leaf([]any{a, b, c, d}, tokenengine.KindCubicBezier)
}
// scales returns the non-colour groups shared by every built-in theme: the
// spacing scale, radii, borders, motion, z-index, opacity and breakpoints.
// Themes differ in colour and typography only.
func scales() map[string]any {
return map[string]any{
"space": map[string]any{
"0": dim(0), "px": dim(1),
"1": dim(0.25), "2": dim(0.5), "3": dim(0.75),
"4": dim(1), "5": dim(1.5), "6": dim(2),
"8": dim(3), "10": dim(4), "12": dim(6), "16": dim(8),
},
"radius": map[string]any{
"none": dim(0), "sm": dim(0.25), "md": dim(0.5),
"lg": dim(0.75), "xl": dim(1.25), "2xl": dim(1.5), "full": dim(9999),
},
"border": map[string]any{
"width": map[string]any{
"thin": dim(1), "default": dim(1), "thick": dim(2), "focus": dim(3),
},
},
"duration": map[string]any{
"instant": dur(0), "fast": dur(120), "normal": dur(200), "slow": dur(320),
},
"ease": map[string]any{
"linear": bezier(0, 0, 1, 1), "standard": bezier(0.2, 0, 0, 1),
"entrance": bezier(0, 0, 0.2, 1), "exit": bezier(0.4, 0, 1, 1),
},
"zIndex": map[string]any{
"base": num(0), "dropdown": num(1000), "sticky": num(1100),
"overlay": num(1200), "modal": num(1300), "toast": num(1400),
},
"opacity": map[string]any{
"disabled": num(0.45), "muted": num(0.7), "strong": num(0.9),
},
"breakpoint": map[string]any{
"sm": dim(640), "md": dim(768), "lg": dim(1024),
"xl": dim(1280), "2xl": dim(1536),
},
}
}
// typeScale builds the typography groups from a modular scale: each step is
// baseSize * ratio^index, which keeps the ramp mathematically consistent in a
// way that hand-tuned type ramps rarely are.
func typeScale(sans string, baseSize, ratio, lineScale float64) map[string]any {
sizes := make(map[string]any)
steps := []struct {
name string
index float64
}{
{"xs", -2}, {"sm", -1}, {"md", 0}, {"lg", 1}, {"xl", 2}, {"2xl", 3}, {"3xl", 4},
}
for _, s := range steps {
sizes[s.name] = leaf(round2(baseSize*powf(ratio, s.index)), tokenengine.KindFontSize)
}
return map[string]any{
"family": map[string]any{
"sans": family(sans),
"mono": family("ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace"),
"display": family(sans),
},
"size": sizes,
"weight": map[string]any{
"normal": weight(400), "medium": weight(500),
"semibold": weight(600), "bold": weight(700),
},
"lineHeight": map[string]any{
"none": leading(1), "tight": leading(1.2), "snug": leading(1.375),
"normal": leading(1.5), "relaxed": leading(1.65), "loose": leading(2),
},
"letterSpacing": map[string]any{
"tighter": dim(-0.05), "tight": dim(-0.025), "normal": dim(0),
"wide": dim(0.025), "wider": dim(0.05),
},
"style": map[string]any{"normal": leaf("normal", tokenengine.KindFontStyle), "italic": leaf("italic", tokenengine.KindFontStyle)},
"scale": num(round2(ratio)),
"lineScale": num(round2(lineScale)),
}
}
// shadows builds a shadow ramp. Shadows tint towards slate rather than pure
// black, which is the difference between elevation that looks natural and
// elevation that looks like a drop shadow from 2004.
func shadows(strength float64) map[string]any {
tint := "15 23 42"
a := func(multiplier float64) string { return fmt.Sprintf("%.2f", strength*multiplier) }
return map[string]any{
"none": shadow("none"),
"xs": shadow(fmt.Sprintf("0 1px 2px 0 rgb(%s / %s)", tint, a(0.08))),
"sm": shadow(fmt.Sprintf("0 1px 3px 0 rgb(%s / %s), 0 1px 2px -1px rgb(%s / %s)", tint, a(0.12), tint, a(0.08))),
"md": shadow(fmt.Sprintf("0 4px 6px -1px rgb(%s / %s), 0 2px 4px -2px rgb(%s / %s)", tint, a(0.12), tint, a(0.1))),
"lg": shadow(fmt.Sprintf("0 10px 15px -3px rgb(%s / %s), 0 4px 6px -4px rgb(%s / %s)", tint, a(0.12), tint, a(0.1))),
"xl": shadow(fmt.Sprintf("0 20px 25px -5px rgb(%s / %s), 0 8px 10px -6px rgb(%s / %s)", tint, a(0.14), tint, a(0.1))),
"focus": shadow("0 0 0 3px rgb(99 102 241 / 0.4)"),
"color": leaf(tint, tokenengine.KindColor),
}
}
// semantics assembles the semantic colour group for one scheme.
//
// Every role points at a concrete palette step rather than at an alias token:
// published themes must not be able to change colour underneath a consumer when
// somebody later edits the underlying palette.
func semantics(ramp tokenengine.RampOptions, scheme domain.Appearance, overrides map[string]any) map[string]any {
step := func(shade string) map[string]any { return color(tokenengine.RampStep(ramp, shade)) }
// Scheme-dependent step selection. In a light scheme surfaces are near-white
// and text is near-black; a dark scheme inverts both.
light := scheme == domain.AppearanceLight
pick := func(lightShade, darkShade string) map[string]any {
if light {
return step(lightShade)
}
return step(darkShade)
}
out := map[string]any{
"background": pick("50", "950"),
"surface": pick("50", "900"),
"surface-raised": pick("100", "800"),
"surface-sunken": pick("100", "950"),
"border": pick("200", "800"),
"border-strong": pick("300", "700"),
"text": pick("900", "100"),
"text-muted": pick("700", "300"),
"text-subtle": pick("600", "400"),
"text-inverted": pick("50", "950"),
"primary": step("600"),
"primary-hover": step("700"),
"primary-active": step("800"),
"on-primary": color(foregroundFor(tokenengine.RampStep(ramp, "600"))),
"secondary": pick("200", "800"),
"secondary-hover": pick("300", "700"),
"on-secondary": color(foregroundFor(tokenengine.RampStep(ramp, pickShade(light, "200", "800")))),
"accent": step("500"),
"on-accent": color(foregroundFor(tokenengine.RampStep(ramp, "500"))),
"link": step("600"),
"link-hover": step("700"),
// Status colours are fixed rather than generated: green must mean
// success in every theme, or the interface lies to the user.
"success": color("#16a34a"),
"on-success": color("#f0fdf4"),
"warning": color("#d97706"),
"on-warning": color("#fffbeb"),
"danger": color("#dc2626"),
"on-danger": color("#fef2f2"),
"info": color("#2563eb"),
"on-info": color("#eff6ff"),
"focus-ring": color(tokenengine.RampStep(ramp, "500")),
"overlay": color(pickColor(light, "#0f172a66", "#020617b3")),
"selection": color(tokenengine.RampStep(ramp, pickShade(light, "200", "800"))),
"scrollbar-thumb": pick("300", "700"),
}
for role, value := range overrides {
out[role] = value
}
return out
}
func pickColor(cond bool, whenTrue, whenFalse string) string {
if cond {
return whenTrue
}
return whenFalse
}
func pickShade(light bool, lightShade, darkShade string) string {
if light {
return lightShade
}
return darkShade
}
// foregroundFor returns whichever of black or white has the higher WCAG
// contrast ratio against the given colour.
func foregroundFor(hex string) string {
bg, err := tokenengine.ParseColor(hex)
if err != nil || !bg.Numeric {
// A colour that cannot be parsed cannot have a computed contrast, so white is
// the safe default: it is the readable choice against every ramp in this file.
return white
}
return tokenengine.BestForeground(bg).Hex()
}
// assemble combines the shared scales with a theme's colour and typography.
func assemble(palettes map[string]any, light, dark map[string]any, typography, shadowSet map[string]any) domain.TokenDoc {
doc := domain.TokenDoc{
tokenengine.SchemaKey: tokenengine.SchemaID,
"color": map[string]any{"semantic": light},
"font": typography,
"shadow": shadowSet,
tokenengine.AppearancesKey: map[string]any{
appearanceDark: map[string]any{
"color": map[string]any{"semantic": dark},
},
},
}
colorGroup := doc["color"].(map[string]any)
for name, ramp := range palettes {
colorGroup[name] = ramp
}
for k, v := range scales() {
doc[k] = v
}
return doc
}
// ------------------------------------------------------------------ themes --
// midnight is the default theme: a violet-accented dark interface.
func midnight() SeededTheme {
brand := tokenengine.RampOptions{Hue: 258, Saturation: 84, Lightness: 58, HueSpread: 14}
neutral := tokenengine.RampOptions{Hue: 224, Saturation: 28, Lightness: 46, HueSpread: 8}
palettes := map[string]any{
roleBrand: tokenengine.GenerateRamp(brand),
roleNeutral: tokenengine.GenerateRamp(neutral),
roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 142, Saturation: 62, Lightness: 44, HueSpread: 10}),
roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 38, Saturation: 92, Lightness: 50, HueSpread: 8}),
roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 0, Saturation: 74, Lightness: 52, HueSpread: 6}),
}
// The dark scheme leads; the light variant is the derived inversion.
return SeededTheme{
Slug: "midnight",
Name: "Midnight",
Description: "Violet-accented dark interface with a slate neutral ramp. The Go Bananas!! default.",
Appearance: domain.AppearanceDark,
Tags: []string{appearanceDark, tagDefault, "violet", "slate"},
Tokens: assemble(palettes,
semantics(neutral, domain.AppearanceLight, nil),
semantics(neutral, domain.AppearanceDark, map[string]any{
"primary": color(tokenengine.RampStep(brand, "500")),
"primary-hover": color(tokenengine.RampStep(brand, "400")),
"primary-active": color(tokenengine.RampStep(brand, "300")),
"on-primary": color(foregroundFor(tokenengine.RampStep(brand, "500"))),
"accent": color(tokenengine.RampStep(brand, "400")),
"on-accent": color(foregroundFor(tokenengine.RampStep(brand, "400"))),
"link": color(tokenengine.RampStep(brand, "400")),
"link-hover": color(tokenengine.RampStep(brand, "300")),
"focus-ring": color(tokenengine.RampStep(brand, "400")),
"selection": color(tokenengine.RampStep(brand, "800")),
}),
typeScale("Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", 0.875, 1.2, 1.5),
shadows(1.0),
),
}
}
// daylight is a crisp light interface built on the same violet brand hue.
func daylight() SeededTheme {
brand := tokenengine.RampOptions{Hue: 258, Saturation: 78, Lightness: 55, HueSpread: 14}
neutral := tokenengine.RampOptions{Hue: 220, Saturation: 18, Lightness: 48, HueSpread: 6}
palettes := map[string]any{
roleBrand: tokenengine.GenerateRamp(brand),
roleNeutral: tokenengine.GenerateRamp(neutral),
roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 142, Saturation: 70, Lightness: 40, HueSpread: 10}),
roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 38, Saturation: 94, Lightness: 48, HueSpread: 8}),
roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 358, Saturation: 76, Lightness: 50, HueSpread: 6}),
}
return SeededTheme{
Slug: "daylight",
Name: "Daylight",
Description: "High-contrast light interface with generous radii and soft shadows.",
Appearance: domain.AppearanceLight,
Tags: []string{"light", "default", "violet", "crisp"},
Tokens: assemble(palettes,
semantics(neutral, domain.AppearanceLight, nil),
semantics(neutral, domain.AppearanceDark, map[string]any{
"primary": color(tokenengine.RampStep(brand, "500")),
"link": color(tokenengine.RampStep(brand, "600")),
"focus-ring": color(tokenengine.RampStep(brand, "500")),
"primary-hover": color(tokenengine.RampStep(brand, "700")),
}),
typeScale("Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", 0.9375, 1.2, 1.5),
shadows(0.7),
),
}
}
// mono is a typographic, near-greyscale theme for dense interfaces.
func mono() SeededTheme {
brand := tokenengine.RampOptions{Hue: 210, Saturation: 12, Lightness: 45, HueSpread: 4}
neutral := tokenengine.RampOptions{Hue: 220, Saturation: 8, Lightness: 48, HueSpread: 2}
palettes := map[string]any{
roleBrand: tokenengine.GenerateRamp(brand),
roleNeutral: tokenengine.GenerateRamp(neutral),
roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 145, Saturation: 45, Lightness: 38, HueSpread: 4}),
roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 40, Saturation: 60, Lightness: 46, HueSpread: 4}),
roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 358, Saturation: 62, Lightness: 48, HueSpread: 4}),
}
// A tighter type scale and larger base suits data-dense screens.
typography := typeScale("'IBM Plex Sans', Inter, ui-sans-serif, system-ui, sans-serif", 0.8125, 1.125, 1.5)
typography["family"] = map[string]any{
"sans": family("'IBM Plex Sans', Inter, ui-sans-serif, system-ui, sans-serif"),
"mono": family("'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace"),
"display": family("'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif"),
}
return SeededTheme{
Slug: "mono",
Name: "Mono",
Description: "Near-greyscale, tight and dense. Built for tables, consoles and data tooling.",
Appearance: domain.AppearanceLight,
Tags: []string{"light", "greyscale", "dense", "data"},
Tokens: assemble(palettes,
semantics(neutral, domain.AppearanceLight, map[string]any{
// A greyscale theme still needs a distinguishable accent or
// nothing on screen reads as interactive.
"primary": color("#111827"),
"link": color("#111827"),
"focus-ring": color("#111827"),
}),
semantics(neutral, domain.AppearanceDark, map[string]any{
"primary": color("#f8fafc"),
"link": color("#f8fafc"),
"focus-ring": color("#f8fafc"),
}),
typography,
shadows(0.5),
),
}
}
// highContrast maximises legibility: near-black on white, thick borders, no
// tints, no translucency. It is the theme to ship when accessibility is the
// primary requirement.
func highContrast() SeededTheme {
black := "#000000"
// Reserved high-contrast overrides. The light scheme is pure black on pure
// white; the dark scheme is the exact inversion, because white-on-black is
// just as legible and some users need it for photophobia or low-vision.
hcLight := highContrastRoles(black, white, "#7a3e00", "#004d00", "#8c0000", "#00308c")
hcDark := highContrastRoles(white, black, "#ffd8a8", "#b9f6ca", "#ffc9c9", "#b3d9ff")
brand := tokenengine.RampOptions{Hue: 220, Saturation: 0, Lightness: 50, HueSpread: 0}
palettes := map[string]any{
roleBrand: tokenengine.GenerateRamp(brand),
roleNeutral: tokenengine.GenerateRamp(brand),
}
// Thicker borders and no shadow blur: at maximum contrast a soft shadow
// disappears, so separation must come from a stroke.
typography := typeScale("Verdana, Tahoma, 'Segoe UI', system-ui, sans-serif", 1, 1.25, 1.6)
shadowSet := map[string]any{
"none": shadow("none"), "xs": shadow("none"), "sm": shadow("none"),
"md": shadow("none"), "lg": shadow("none"), "xl": shadow("none"),
"focus": shadow("0 0 0 3px " + black),
}
return SeededTheme{
Slug: "high-contrast",
Name: "High Contrast",
Description: "Maximum legibility. Pure black on pure white, thick strokes, no translucency. Inverts for a white-on-black variant.",
Appearance: domain.AppearanceLight,
Tags: []string{"light", "dark", "accessibility", "wcag", "aaa"},
Tokens: assemble(palettes, hcLight, hcDark, typography, shadowSet),
}
}
// highContrastRoles builds the full semantic set for a maximum-contrast scheme
// from two anchor colours plus four status colours that keep their hue but are
// darkened or lightened to stay at AAA against the anchor background.
func highContrastRoles(foreground, background, warning, success, danger, info string) map[string]any {
return map[string]any{
"background": color(background),
"surface": color(background),
"surface-raised": color(background),
"surface-sunken": color(background),
"border": color(foreground),
"border-strong": color(foreground),
"text": color(foreground),
"text-muted": color(foreground),
"text-subtle": color(foreground),
"text-inverted": color(background),
"primary": color(foreground),
"primary-hover": color(foreground),
"primary-active": color(foreground),
"on-primary": color(background),
"secondary": color(background),
"secondary-hover": color(background),
"on-secondary": color(foreground),
"accent": color(foreground),
"on-accent": color(background),
"link": color(foreground),
"link-hover": color(foreground),
"focus-ring": color(foreground),
"overlay": color(foreground),
"selection": color(foreground),
"scrollbar-thumb": color(foreground),
"success": color(success),
"on-success": color(background),
"warning": color(warning),
"on-warning": color(background),
"danger": color(danger),
"on-danger": color(background),
"info": color(info),
"on-info": color(background),
}
}
// -------------------------------------------------------------- conversion --
// systemThemeID derives a stable identifier for a built-in theme so that the
// reconciler never has to search by slug.
func systemThemeID(slug string) string {
return "sys_" + strings.ReplaceAll(slug, "-", "_")
}
// Theme converts a seeded definition into a persistable domain theme.
func (s SeededTheme) Theme() *domain.Theme {
return &domain.Theme{
ID: systemThemeID(s.Slug),
Slug: s.Slug,
Name: s.Name,
Description: s.Description,
Status: domain.StatusPublished,
DefaultAppearance: s.Appearance,
Tokens: s.Tokens.Clone(),
Meta: domain.ThemeMeta{
"tags": s.Tags,
"builtIn": true,
"seedVersion": SeedVersion,
"author": "go-bananas",
},
System: true,
Version: SeedVersion,
CreatedBy: "system",
UpdatedBy: "system",
}
}
// Themes converts every seeded definition into persistable domain themes.
func Themes() []*domain.Theme {
defs := All()
out := make([]*domain.Theme, 0, len(defs))
for _, d := range defs {
out = append(out, d.Theme())
}
return out
}
// ------------------------------------------------------------------- maths --
// round2 rounds to two decimal places, which is finer than any CSS length
// resolution and keeps generated values readable.
func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 }
// powf is integer-step exponentiation, which is all a modular scale needs.
func powf(base, exp float64) float64 {
if exp == 0 {
return 1
}
neg := exp < 0
n := int(exp)
if neg {
n = -n
}
result := 1.0
for i := 0; i < n; i++ {
result *= base
}
if neg {
return 1 / result
}
return result
}

157
internal/seed/seed_test.go Normal file
View file

@ -0,0 +1,157 @@
package seed
import (
"testing"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/tokenengine"
)
func TestEveryBuiltInThemeValidates(t *testing.T) {
for _, def := range All() {
t.Run(def.Slug, func(t *testing.T) {
theme := def.Theme()
result := tokenengine.Validate(theme.Tokens)
if !result.Valid {
for _, issue := range result.Errors() {
t.Errorf("%s %s: %s (%s)", issue.Severity, issue.Path, issue.Message, issue.Hint)
}
t.Fatal("built-in theme must be valid")
}
if result.TokenCount < 50 {
t.Errorf("theme has only %d tokens; a built-in should be complete", result.TokenCount)
}
})
}
}
func TestEveryBuiltInThemeRendersBothAppearances(t *testing.T) {
for _, def := range All() {
t.Run(def.Slug, func(t *testing.T) {
theme := def.Theme()
for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} {
css := tokenengine.Render(theme, tokenengine.RenderOptions{
Slug: theme.Slug,
Appearance: appearance,
Components: true,
Derive: tokenengine.DefaultDerive(),
})
if css == "" {
t.Fatalf("%s/%s rendered an empty stylesheet", theme.Slug, appearance)
}
if !contains(css, "--bn-color-semantic-background") {
t.Errorf("%s/%s is missing the background variable", theme.Slug, appearance)
}
if !contains(css, "--bn-color-semantic-text") {
t.Errorf("%s/%s is missing the text variable", theme.Slug, appearance)
}
}
})
}
}
func TestBuiltInThemesDeclareDistinctDarkOverrides(t *testing.T) {
// A theme that declares a dark appearance but supplies no dark overrides
// would render the light palette in dark mode, which is never intentional.
for _, def := range All() {
theme := def.Theme()
light := tokenengine.Resolve(theme, domain.AppearanceLight)
dark := tokenengine.Resolve(theme, domain.AppearanceDark)
lightBG, _ := light.Lookup("color.semantic.background")
darkBG, _ := dark.Lookup("color.semantic.background")
if lightBG.CSS == darkBG.CSS {
t.Errorf("%s: light and dark backgrounds are identical (%s)", def.Slug, lightBG.CSS)
}
}
}
func TestTextContrastMeetsAAForEveryBuiltIn(t *testing.T) {
// This is the check that makes a built-in theme trustworthy: body text must
// be readable on every surface it is likely to sit on.
pairs := []struct {
fgPath, bgPath string
}{
{"color.semantic.text", "color.semantic.background"},
{"color.semantic.text", "color.semantic.surface"},
{"color.semantic.text", "color.semantic.surface-raised"},
{"color.semantic.text-muted", "color.semantic.background"},
{"color.on-primary", "color.semantic.primary"},
{"color.on-accent", "color.semantic.accent"},
{"color.on-danger", "color.semantic.danger"},
}
for _, def := range All() {
theme := def.Theme()
for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} {
doc := tokenengine.Resolve(theme, appearance)
for _, pair := range pairs {
fg, ok := doc.Lookup(pair.fgPath)
if !ok {
continue
}
bg, ok := doc.Lookup(pair.bgPath)
if !ok {
continue
}
fgColor, err := tokenengine.ParseColor(fg.CSS)
if err != nil {
continue
}
bgColor, err := tokenengine.ParseColor(bg.CSS)
if err != nil {
continue
}
ratio := tokenengine.ContrastRatio(fgColor, bgColor)
minimum := tokenengine.ContrastAA
// The high-contrast theme is the escape hatch and is held to the
// stricter AAA bar instead.
if def.Slug == "high-contrast" {
minimum = tokenengine.ContrastAAA
}
if ratio < minimum {
t.Errorf("%s/%s: %s (%s) on %s (%s) = %.2f:1, want at least %.1f:1",
def.Slug, appearance, pair.fgPath, fg.CSS, pair.bgPath, bg.CSS, ratio, minimum)
}
}
}
}
}
func TestBuiltInSlugsAreValidAndReservedSafe(t *testing.T) {
for _, def := range All() {
if err := domain.ValidateSlug(def.Slug); err != nil {
t.Errorf("slug %q is invalid: %v", def.Slug, err)
}
if _, reserved := domain.ReservedSlugs[def.Slug]; reserved {
t.Errorf("slug %q collides with a reserved runtime path", def.Slug)
}
if def.Appearance == "" {
t.Errorf("theme %q has no default appearance", def.Slug)
}
}
}
func TestBuiltInThemesHaveUniqueIDs(t *testing.T) {
seen := make(map[string]string)
for _, theme := range Themes() {
if previous, dup := seen[theme.ID]; dup {
t.Errorf("id %q is used by both %q and %q", theme.ID, previous, theme.Slug)
}
seen[theme.ID] = theme.Slug
}
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0
}
func indexOf(haystack, needle string) int {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return i
}
}
return -1
}

62
internal/store/backup.go Normal file
View file

@ -0,0 +1,62 @@
package store
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/urpi/go-bananas/internal/domain"
)
// Snapshot writes a consistent copy of the database to dest.
//
// It uses SQLite's VACUUM INTO rather than copying the file. Copying a database
// while a write is in flight yields a torn copy: the header can say one thing and
// the pages say another, and the result may open successfully while being
// corrupt. VACUUM INTO reads through SQLite's own backup machinery, so the result
// is consistent even while the service is serving traffic, and it does not block
// writers for the length of the copy.
//
// Two further properties come from the statement itself rather than from code
// here: it refuses to overwrite an existing file, so a stale snapshot can never be
// mistaken for a fresh one, and it writes to a temporary name and renames on
// success, so a snapshot that fails halfway is not left looking complete.
//
// The connection must not be read-only: VACUUM INTO needs to read the schema and
// pages through a normal connection even though it only ever writes to the new
// file.
func (s *Store) Snapshot(ctx context.Context, dest string) error {
trimmed := strings.TrimSpace(dest)
if trimmed == "" {
return fmt.Errorf("%w: snapshot destination must not be empty", domain.ErrInvalid)
}
if dir := filepath.Dir(trimmed); dir != "" && dir != "." {
// Created by the process rather than by SQLite, which has no mkdir. The
// snapshot directory normally already exists (the Helm chart's subPath mount
// creates it), so this is a convenience for a manual invocation.
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create snapshot directory %s: %w", dir, err)
}
}
// VACUUM INTO takes an SQL string literal rather than a bound parameter on some
// SQLite builds, so the path is escaped into one here. Doubling the quote is
// the documented escape, and rejecting an embedded NUL keeps the literal
// well-formed regardless.
if strings.ContainsRune(trimmed, 0) {
return fmt.Errorf("%w: snapshot destination contains a NUL byte", domain.ErrInvalid)
}
literal := "'" + strings.ReplaceAll(trimmed, "'", "''") + "'"
//nolint:gosec // G202: VACUUM INTO takes an SQL string literal rather than a
// bound parameter on some SQLite builds. The literal is escaped above by doubling
// any single quote, which is the documented escape, and NUL bytes are rejected so
// the literal cannot be broken out of. TestSnapshotEscapesQuotesInThePath covers
// a path containing a quote.
if _, err := s.db.ExecContext(ctx, "VACUUM INTO "+literal); err != nil {
return fmt.Errorf("write snapshot to %s: %w", trimmed, err)
}
return nil
}

View file

@ -0,0 +1,185 @@
package store_test
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/store"
)
// openTemp opens a file-backed store in a temporary directory, which is the only
// kind worth snapshotting: an in-memory database has nothing to preserve.
func openTemp(t *testing.T) *store.Store {
t.Helper()
path := filepath.Join(t.TempDir(), "live.db")
st, err := store.Open(context.Background(), store.Options{Path: path})
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
// TestSnapshotProducesAReadableDatabase is the property the backup exists for: the
// file that comes out must be a database, not merely a file with the right size.
func TestSnapshotProducesAReadableDatabase(t *testing.T) {
st := openTemp(t)
dest := filepath.Join(t.TempDir(), "snapshots", "go-bananas.db")
if err := st.Snapshot(context.Background(), dest); err != nil {
t.Fatalf("Snapshot: %v", err)
}
info, err := os.Stat(dest)
if err != nil {
t.Fatalf("stat snapshot: %v", err)
}
if info.Size() == 0 {
t.Fatal("snapshot is empty")
}
// Reopening is the check that matters. A truncated or torn copy often fails
// here rather than at the point of use, which is exactly the failure a restore
// would hit at the worst possible moment.
reopened, err := store.Open(context.Background(), store.Options{Path: dest, ReadOnly: true})
if err != nil {
t.Fatalf("snapshot is not a readable database: %v", err)
}
defer func() { _ = reopened.Close() }()
// The schema must have come across, not just the bytes: an empty database with
// no tables would also open cleanly.
var count int
if err := reopened.DB().QueryRowContext(context.Background(),
"SELECT count(*) FROM sqlite_master WHERE type = 'table'").Scan(&count); err != nil {
t.Fatalf("query snapshot schema: %v", err)
}
if count == 0 {
t.Error("snapshot has no tables; the schema did not come across")
}
}
// TestSnapshotRefusesToOverwrite guards the failure this feature is easiest to get
// wrong: a snapshot that silently overwrites the previous good one turns a failed
// run into two data-loss events instead of one.
func TestSnapshotRefusesToOverwrite(t *testing.T) {
st := openTemp(t)
dest := filepath.Join(t.TempDir(), "snap.db")
if err := st.Snapshot(context.Background(), dest); err != nil {
t.Fatalf("first snapshot: %v", err)
}
first, err := os.Stat(dest)
if err != nil {
t.Fatalf("stat: %v", err)
}
if err := st.Snapshot(context.Background(), dest); err == nil {
t.Fatal("expected the second snapshot to be refused")
}
second, err := os.Stat(dest)
if err != nil {
t.Fatalf("stat: %v", err)
}
if !first.ModTime().Equal(second.ModTime()) {
t.Error("the existing snapshot was modified by the refused write")
}
}
// TestSnapshotCreatesTheParentDirectory covers the manual-invocation path, where
// the operator names a directory that does not exist yet.
func TestSnapshotCreatesTheParentDirectory(t *testing.T) {
st := openTemp(t)
base := t.TempDir()
dest := filepath.Join(base, "a", "b", "c", "snap.db")
if err := st.Snapshot(context.Background(), dest); err != nil {
t.Fatalf("Snapshot: %v", err)
}
if _, err := os.Stat(dest); err != nil {
t.Errorf("snapshot not written: %v", err)
}
}
// TestSnapshotRejectsEmptyDestination checks the argument validation rather than
// letting SQLite interpret an empty filename as the current directory.
func TestSnapshotRejectsEmptyDestination(t *testing.T) {
st := openTemp(t)
if err := st.Snapshot(context.Background(), " "); err == nil {
t.Error("expected an error for a blank destination")
} else if !errors.Is(err, domain.ErrInvalid) {
t.Errorf("got %v, want an invalid-input error", err)
}
}
// TestSnapshotEscapesQuotesInThePath guards the SQL-literal construction. A path
// containing a single quote must not be able to terminate the literal and append
// statements of its own.
func TestSnapshotEscapesQuotesInThePath(t *testing.T) {
st := openTemp(t)
dest := filepath.Join(t.TempDir(), "it's a snap.db")
if err := st.Snapshot(context.Background(), dest); err != nil {
t.Fatalf("Snapshot: %v", err)
}
if _, err := os.Stat(dest); err != nil {
t.Errorf("snapshot not written to the quoted path: %v", err)
}
}
// TestSnapshotOfAnInMemoryDatabaseIsRefused documents that the store-level guard
// exists, rather than leaving it to the command line.
func TestIsPersistentPath(t *testing.T) {
for _, path := range []string{":memory:", "file::memory:?cache=shared", "file:x?mode=memory"} {
if store.IsPersistentPath(path) {
t.Errorf("%q should be reported as ephemeral", path)
}
}
for _, path := range []string{"/srv/data/go-bananas.db", "./data/db.sqlite"} {
if !store.IsPersistentPath(path) {
t.Errorf("%q should be reported as persistent", path)
}
}
}
// TestSnapshotUnderLoadStaysConsistent is a smoke test for the reason VACUUM INTO
// is used rather than a file copy: writing while the snapshot is taken must not
// corrupt the result.
func TestSnapshotUnderLoadStaysConsistent(t *testing.T) {
st := openTemp(t)
ctx := context.Background()
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 20; i++ {
if _, err := st.DB().ExecContext(ctx,
`INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?)`,
"concurrent.key", strconv.Itoa(i), time.Now().UTC().Format(time.RFC3339Nano), "test"); err != nil {
return
}
}
}()
dest := filepath.Join(t.TempDir(), "under-load.db")
if err := st.Snapshot(ctx, dest); err != nil {
t.Fatalf("Snapshot during writes: %v", err)
}
<-done
reopened, err := store.Open(ctx, store.Options{Path: dest, ReadOnly: true})
if err != nil {
t.Fatalf("snapshot taken during writes is unreadable: %v", err)
}
defer func() { _ = reopened.Close() }()
if err := reopened.DB().PingContext(ctx); err != nil {
t.Errorf("snapshot failed an integrity check: %v", err)
}
}

View file

@ -0,0 +1,250 @@
package store
import (
"context"
"crypto/rand"
"database/sql"
"embed"
"encoding/base64"
"errors"
"fmt"
"io/fs"
"strings"
"sync"
"github.com/urpi/go-bananas/internal/domain"
)
// migrationsFS holds the schema that ships inside the binary, so a fresh
// container needs no migration job and no schema files mounted beside it.
//
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Migration is one applied schema change.
type Migration struct {
Name string
// Statements is the number of statements the file contains, reported for
// logging only.
Statements int
}
// migrationLock serialises migration across goroutines within a process. SQLite
// already serialises writers across processes, so no cross-process lock is
// needed, but two replicas starting simultaneously would otherwise both log the
// same applied migration.
var migrationLock sync.Mutex
// migrate applies every embedded migration that has not yet been recorded.
//
// Migrations are idempotent by construction: each file uses CREATE ... IF NOT
// EXISTS, so re-running is harmless. The applied set is tracked in a table so
// that a future migration which is not idempotent can still be introduced
// safely.
func (s *Store) migrate(ctx context.Context) error {
migrationLock.Lock()
defer migrationLock.Unlock()
if _, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
applied, err := s.appliedMigrations(ctx)
if err != nil {
return err
}
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
if _, done := applied[entry.Name()]; done {
continue
}
body, err := fs.ReadFile(migrationsFS, "migrations/"+entry.Name())
if err != nil {
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %s: %w", entry.Name(), err)
}
if _, err := tx.ExecContext(ctx, string(body)); err != nil {
tx.Rollback()
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`,
entry.Name(), s.now().UTC().Format(domain.TimeFormat),
); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", entry.Name(), err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", entry.Name(), err)
}
}
return nil
}
// appliedMigrations returns the set of recorded migration names.
func (s *Store) appliedMigrations(ctx context.Context) (map[string]struct{}, error) {
rows, err := s.db.QueryContext(ctx, `SELECT name FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("read schema_migrations: %w", err)
}
defer rows.Close()
applied := make(map[string]struct{})
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("scan schema_migrations: %w", err)
}
applied[name] = struct{}{}
}
return applied, rows.Err()
}
// Seed reconciles the built-in themes with what is stored.
//
// The rules are:
// - a missing built-in is inserted;
// - a built-in still at its seed version is refreshed, so an upgrade picks up
// palette and schema fixes;
// - a built-in an operator has edited (version above the seed) is left alone;
// - a non-built-in theme is never touched.
//
// It returns how many themes were inserted and how many were refreshed.
func (s *Store) Seed(ctx context.Context, builtins []*domain.Theme) (inserted, refreshed int, err error) {
if len(builtins) == 0 {
return 0, 0, nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, fmt.Errorf("begin seed: %w", err)
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
now := s.now().UTC().Format(domain.TimeFormat)
for _, theme := range builtins {
tokensJSON, err := marshalJSON(theme.Tokens)
if err != nil {
return 0, 0, fmt.Errorf("marshal tokens for %s: %w", theme.Slug, err)
}
metaJSON, err := marshalJSON(theme.Meta)
if err != nil {
return 0, 0, fmt.Errorf("marshal meta for %s: %w", theme.Slug, err)
}
var existingVersion int64
err = tx.QueryRowContext(ctx,
`SELECT version FROM themes WHERE id = ? OR slug = ?`, theme.ID, theme.Slug,
).Scan(&existingVersion)
switch {
case errors.Is(err, sql.ErrNoRows):
if err := insertTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil {
return 0, 0, fmt.Errorf("insert built-in theme %s: %w", theme.Slug, err)
}
if err := insertVersion(ctx, tx, theme, tokensJSON, "built-in seed", "system", now); err != nil {
return 0, 0, fmt.Errorf("insert built-in version %s: %w", theme.Slug, err)
}
inserted++
case err != nil:
return 0, 0, fmt.Errorf("look up built-in theme %s: %w", theme.Slug, err)
default:
// Refresh only when the stored copy predates this seed. An equal
// version means the theme is already current; a higher version means
// an operator has edited it, and their work wins either way.
if existingVersion >= theme.Version {
continue
}
if err := refreshTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil {
return 0, 0, fmt.Errorf("refresh built-in theme %s: %w", theme.Slug, err)
}
if err := insertVersion(ctx, tx, theme, tokensJSON, "refreshed by Go Bananas!! upgrade", "system", now); err != nil {
return 0, 0, fmt.Errorf("insert built-in version %s: %w", theme.Slug, err)
}
refreshed++
}
}
if err = tx.Commit(); err != nil {
return 0, 0, fmt.Errorf("commit seed: %w", err)
}
return inserted, refreshed, nil
}
// insertTheme writes a new theme row.
func insertTheme(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, metaJSON, now string) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO themes (
id, slug, name, description, status, default_appearance,
tokens_json, meta_json, is_system, version,
created_at, updated_at, created_by, updated_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
theme.ID, theme.Slug, theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance),
tokensJSON, metaJSON, boolToInt(theme.System), theme.Version,
now, now, theme.CreatedBy, theme.UpdatedBy,
)
return translateError(err)
}
// refreshTheme overwrites an untouched built-in theme in place.
func refreshTheme(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, metaJSON, now string) error {
_, err := tx.ExecContext(ctx, `
UPDATE themes SET
name = ?, description = ?, status = ?, default_appearance = ?,
tokens_json = ?, meta_json = ?, version = ?, updated_at = ?, updated_by = ?
WHERE slug = ?`,
theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance),
tokensJSON, metaJSON, theme.Version, now, "system",
theme.Slug,
)
return translateError(err)
}
// insertVersion appends an immutable snapshot to a theme's history.
func insertVersion(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, message, author, now string) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO theme_versions (
theme_id, version, slug, name, description, status,
default_appearance, tokens_json, message, author, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
theme.ID, theme.Version, theme.Slug, theme.Name, theme.Description, string(theme.Status),
string(theme.DefaultAppearance), tokensJSON, message, author, now,
)
return translateError(err)
}
// randomID returns a URL-safe random identifier.
func randomID() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("read random: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}

View file

@ -0,0 +1,144 @@
-- go-bananas schema.
--
-- Design notes
-- ------------
-- * Timestamps are TEXT in RFC 3339 (see domain.TimeFormat). SQLite has no
-- native date type, and storing a sortable string removes every date-parsing
-- ambiguity from ORDER BY and range filters.
-- * JSON columns are TEXT holding a JSON document. The token document is read
-- and written as a whole value, never queried into, so a JSON column is the
-- correct shape: it avoids a join table whose only purpose would be to
-- reassemble a tree the application always wants whole.
-- * Every table a consumer might read carries an explicit covering index.
PRAGMA foreign_keys = ON;
-- ---------------------------------------------------------------- themes ----
CREATE TABLE IF NOT EXISTS themes (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived')),
default_appearance TEXT NOT NULL DEFAULT 'light'
CHECK (default_appearance IN ('light', 'dark')),
tokens_json TEXT NOT NULL,
meta_json TEXT NOT NULL DEFAULT '{}',
is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0, 1)),
version INTEGER NOT NULL DEFAULT 1 CHECK (version >= 1),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
updated_by TEXT NOT NULL DEFAULT ''
);
-- The runtime API resolves by slug on every page load, so this index is the
-- single most important one in the schema.
CREATE UNIQUE INDEX IF NOT EXISTS idx_themes_slug ON themes (slug);
-- Listing is always ordered by recency within a status filter.
CREATE INDEX IF NOT EXISTS idx_themes_status_updated
ON themes (status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_themes_updated ON themes (updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_themes_created ON themes (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_themes_name ON themes (name COLLATE NOCASE);
-- Free-text search over slug, name and description. SQLite's LIKE with a
-- leading wildcard cannot use an index, so this stays a scan; the table is
-- small by construction (themes are hand-authored, not machine-generated) and
-- the scan is served from the page cache.
CREATE INDEX IF NOT EXISTS idx_themes_search
ON themes (name COLLATE NOCASE, slug, description);
-- ---------------------------------------------------------- theme_versions --
CREATE TABLE IF NOT EXISTS theme_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
theme_id TEXT NOT NULL REFERENCES themes (id) ON DELETE CASCADE,
version INTEGER NOT NULL,
slug TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
default_appearance TEXT NOT NULL,
tokens_json TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
UNIQUE (theme_id, version)
);
-- Rollback resolves a theme's history newest-first.
CREATE INDEX IF NOT EXISTS idx_versions_theme
ON theme_versions (theme_id, version DESC);
-- ---------------------------------------------------------------- api_keys --
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
-- prefix is the non-secret leading segment shown in the UI so a key can be
-- identified after its secret has been forgotten.
prefix TEXT NOT NULL,
-- secret_hash is a SHA-256 of the full key. The plaintext is never stored,
-- so a database disclosure does not yield usable credentials.
secret_hash TEXT NOT NULL,
scopes TEXT NOT NULL,
identity TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
expires_at TEXT,
revoked_at TEXT
);
-- Lookup by secret hash happens on every machine request.
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys (secret_hash);
-- The UI lists live keys newest-first and hides revoked ones by default.
CREATE INDEX IF NOT EXISTS idx_api_keys_live ON api_keys (revoked_at, created_at DESC);
-- ----------------------------------------------------------------- users ----
CREATE TABLE IF NOT EXISTS users (
subject TEXT PRIMARY KEY,
email TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
roles TEXT NOT NULL DEFAULT '[]',
method TEXT NOT NULL DEFAULT '',
first_seen TEXT NOT NULL,
last_login TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
-- ------------------------------------------------------------- audit_log ----
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
actor_email TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
entity_type TEXT NOT NULL DEFAULT '',
entity_id TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
detail_json TEXT NOT NULL DEFAULT '{}',
remote_ip TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log (at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor, at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log (entity_type, entity_id, at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action, at DESC);
-- -------------------------------------------------------------- settings ----
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL DEFAULT ''
);

View file

@ -0,0 +1,19 @@
-- Drop the machine-credential table.
--
-- API keys were removed: the runtime API is public by design and the admin API is
-- for people, so there is no machine caller left to authenticate. The table is
-- dropped rather than left behind, because an unused table holding SHA-256 digests
-- of credentials that no longer authenticate anything is a liability with no
-- upside, and its presence in the schema implies a capability that no longer exists.
--
-- The rows go with it. That is destructive by definition, which is why this is a
-- migration rather than a code change: it happens once, in order, and is recorded in
-- schema_migrations so a restore from an older backup is visible rather than silent.
--
-- A backup taken before this migration still contains the table and its rows. The
-- credentials in it do not work against any version of this service, so restoring
-- one is harmless.
DROP INDEX IF EXISTS idx_api_keys_hash;
DROP INDEX IF EXISTS idx_api_keys_live;
DROP TABLE IF EXISTS api_keys;

View file

@ -0,0 +1,26 @@
-- Per-user interface preferences.
--
-- These live in their own table rather than as columns on users, because users is
-- a projection of the identity provider's claims: UpsertUser rewrites it on every
-- login. A row appears the first time somebody saves a preference and is keyed by
-- the same subject, so the choice survives re-login and follows the account to
-- another browser.
--
-- An empty selected_theme means the person has not chosen a theme, which the UI
-- renders as its own default rather than as a stored value.
--
-- There is deliberately no foreign key to users. That table is a projection of the
-- identity provider's claims and can be rebuilt from a backup, so a cascade from it
-- would silently discard people's choices, and requiring the row to exist would make
-- saving a preference depend on projection state that has nothing to do with the
-- preference. A row whose subject never logs in again is inert and costs nothing.
CREATE TABLE IF NOT EXISTS user_preferences (
subject TEXT NOT NULL PRIMARY KEY,
selected_theme TEXT NOT NULL DEFAULT '',
selected_appearance TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
);
-- Lets an operator answer "who is using this theme?" without scanning every row.
CREATE INDEX IF NOT EXISTS idx_user_preferences_theme
ON user_preferences (selected_theme);

View file

@ -0,0 +1,369 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/urpi/go-bananas/internal/domain"
)
// ------------------------------------------------------------------- users --
// UpsertUser records a successful login. Roles are overwritten from the
// identity provider on every login rather than merged, so that a user who is
// removed from a group actually loses access.
func (s *Store) UpsertUser(ctx context.Context, identity *domain.Identity) error {
if identity == nil || identity.Subject == "" {
return fmt.Errorf("%w: identity subject must not be empty", domain.ErrInvalid)
}
roles, err := json.Marshal(identity.Roles)
if err != nil {
return fmt.Errorf("encode roles: %w", err)
}
now := s.timestamp()
_, err = s.db.ExecContext(ctx, `
INSERT INTO users (subject, email, display_name, roles, method, first_seen, last_login)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(subject) DO UPDATE SET
email = excluded.email,
display_name = excluded.display_name,
roles = excluded.roles,
method = excluded.method,
last_login = excluded.last_login`,
identity.Subject, identity.Email, identity.Name, string(roles), identity.Method, now, now,
)
return translateError(err)
}
// User is a stored account projection.
type User struct {
Subject string `json:"subject"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Roles []string `json:"roles"`
Method string `json:"method"`
FirstSeen time.Time `json:"firstSeen"`
LastLogin time.Time `json:"lastLogin"`
}
// ListUsers returns every known account, most recently seen first.
func (s *Store) ListUsers(ctx context.Context, limit int) ([]User, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.QueryContext(ctx, `
SELECT subject, email, display_name, roles, method, first_seen, last_login
FROM users ORDER BY last_login DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list users: %w", translateError(err))
}
defer rows.Close()
out := make([]User, 0, limit)
for rows.Next() {
var (
u User
rolesJSON string
firstSeen string
lastLogin string
)
if err := rows.Scan(&u.Subject, &u.Email, &u.Name, &rolesJSON, &u.Method, &firstSeen, &lastLogin); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
// Deliberately degraded, not accidentally ignored: a malformed roles blob
// yields a user with no roles, which the authorisation layer treats as
// read-only. Failing the whole listing because one row has a corrupt column
// would be worse than degrading that single user, and it would mean one bad
// row hides every other user from the admin.
//
//nolint:errcheck // see above
_ = json.Unmarshal([]byte(rolesJSON), &u.Roles)
if u.Roles == nil {
u.Roles = []string{}
}
if u.FirstSeen, err = parseTime(firstSeen); err != nil {
return nil, err
}
if u.LastLogin, err = parseTime(lastLogin); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// UserCount returns the number of known accounts.
func (s *Store) UserCount(ctx context.Context) (int, error) {
var count int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return 0, fmt.Errorf("count users: %w", err)
}
return count, nil
}
// ---------------------------------------------------------------- settings --
// Setting returns a setting value, or fallback when it is absent.
func (s *Store) Setting(ctx context.Context, key, fallback string) (string, error) {
var value string
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
switch {
case errors.Is(err, sql.ErrNoRows):
return fallback, nil
case err != nil:
return "", fmt.Errorf("read setting %s: %w", key, err)
default:
return value, nil
}
}
// SetSetting writes a setting.
func (s *Store) SetSetting(ctx context.Context, key, value, actor string) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
key, value, s.timestamp(), actor)
return translateError(err)
}
// AllSettings returns every setting.
func (s *Store) AllSettings(ctx context.Context) (map[string]domain.Setting, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key, value, updated_at, updated_by FROM settings`)
if err != nil {
return nil, fmt.Errorf("list settings: %w", translateError(err))
}
defer rows.Close()
out := make(map[string]domain.Setting)
for rows.Next() {
var (
setting domain.Setting
updatedAt string
)
if err := rows.Scan(&setting.Key, &setting.Value, &updatedAt, &setting.UpdatedBy); err != nil {
return nil, fmt.Errorf("scan setting: %w", err)
}
if setting.UpdatedAt, err = parseTime(updatedAt); err != nil {
return nil, err
}
out[setting.Key] = setting
}
return out, rows.Err()
}
// ------------------------------------------------------------------- audit --
// AuditFilter narrows the activity log.
type AuditFilter struct {
Actor string
Action string
EntityType string
EntityID string
Since *time.Time
Limit int
Offset int
}
// AppendAudit writes one audit entry. Failures are returned but callers on the
// request path generally log and continue: losing an audit row is bad, failing
// a user's publish because of it is worse.
func (s *Store) AppendAudit(ctx context.Context, entry *domain.AuditEntry) error {
if entry == nil {
return fmt.Errorf("%w: audit entry must not be nil", domain.ErrInvalid)
}
if entry.Action == "" {
return fmt.Errorf("%w: audit entry requires an action", domain.ErrInvalid)
}
if entry.At.IsZero() {
entry.At = s.now().UTC()
}
detail, err := json.Marshal(entry.Detail)
if err != nil {
detail = []byte("{}")
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO audit_log (at, actor, actor_email, action, entity_type, entity_id, summary, detail_json, remote_ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
entry.At.UTC().Format(domain.TimeFormat), entry.Actor, entry.ActorEmail,
string(entry.Action), entry.EntityType, entry.EntityID, entry.Summary,
string(detail), entry.RemoteIP,
)
return translateError(err)
}
// ListAudit returns audit entries newest first.
func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) ([]domain.AuditEntry, int, error) {
var (
where []string
args []any
)
if filter.Actor != "" {
where = append(where, `actor = ?`)
args = append(args, filter.Actor)
}
if filter.Action != "" {
where = append(where, `action = ?`)
args = append(args, filter.Action)
}
if filter.EntityType != "" {
where = append(where, `entity_type = ?`)
args = append(args, filter.EntityType)
}
if filter.EntityID != "" {
where = append(where, `entity_id = ?`)
args = append(args, filter.EntityID)
}
if filter.Since != nil {
where = append(where, `at >= ?`)
args = append(args, filter.Since.UTC().Format(domain.TimeFormat))
}
clause := ""
if len(where) > 0 {
clause = " WHERE " + strings.Join(where, " AND ")
}
limit := filter.Limit
if limit <= 0 || limit > 500 {
limit = 50
}
offset := filter.Offset
if offset < 0 {
offset = 0
}
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log`+clause, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count audit entries: %w", err)
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, at, actor, actor_email, action, entity_type, entity_id, summary, detail_json, remote_ip
FROM audit_log`+clause+` ORDER BY id DESC LIMIT ? OFFSET ?`,
append(append([]any{}, args...), limit, offset)...)
if err != nil {
return nil, 0, fmt.Errorf("list audit entries: %w", translateError(err))
}
defer rows.Close()
out := make([]domain.AuditEntry, 0, limit)
for rows.Next() {
var (
entry domain.AuditEntry
action string
at string
detailJSON string
)
if err := rows.Scan(&entry.ID, &at, &entry.Actor, &entry.ActorEmail, &action,
&entry.EntityType, &entry.EntityID, &entry.Summary, &detailJSON, &entry.RemoteIP); err != nil {
return nil, 0, fmt.Errorf("scan audit entry: %w", err)
}
entry.Action = domain.AuditAction(action)
if entry.At, err = parseTime(at); err != nil {
return nil, 0, err
}
if detailJSON != "" {
// Same reasoning as the roles column: an audit entry whose detail cannot
// be decoded is still worth returning, because the point of the audit log
// is that the record of the action exists.
//
//nolint:errcheck // see above
_ = json.Unmarshal([]byte(detailJSON), &entry.Detail)
}
out = append(out, entry)
}
return out, total, rows.Err()
}
// AuditRetention returns the age of the oldest audit entry, used by the
// retention job.
func (s *Store) AuditRetention(ctx context.Context) (time.Time, error) {
var oldest sql.NullString
if err := s.db.QueryRowContext(ctx, `SELECT MIN(at) FROM audit_log`).Scan(&oldest); err != nil {
return time.Time{}, fmt.Errorf("audit retention: %w", err)
}
if !oldest.Valid {
return s.now().UTC(), nil
}
return parseTime(oldest.String)
}
// PurgeAudit removes entries older than the cutoff and returns the count.
func (s *Store) PurgeAudit(ctx context.Context, before time.Time) (int64, error) {
result, err := s.db.ExecContext(ctx,
`DELETE FROM audit_log WHERE at < ?`, before.UTC().Format(domain.TimeFormat))
if err != nil {
return 0, fmt.Errorf("purge audit: %w", translateError(err))
}
return result.RowsAffected()
}
// Stats summarises the instance for the admin dashboard.
type Stats struct {
Themes int `json:"themes"`
ThemesByStatus map[string]int `json:"themesByStatus"`
Users int `json:"users"`
AuditEntries int `json:"auditEntries"`
DatabaseSize int64 `json:"databaseSizeBytes"`
VersionsRetaind int `json:"versionsRetained"`
}
// Stats gathers dashboard counters in a single pass.
func (s *Store) Stats(ctx context.Context) (Stats, error) {
stats := Stats{ThemesByStatus: map[string]int{}}
rows, err := s.db.QueryContext(ctx, `SELECT status, COUNT(*) FROM themes GROUP BY status`)
if err != nil {
return stats, fmt.Errorf("stats: %w", translateError(err))
}
defer rows.Close()
for rows.Next() {
var (
status string
count int
)
if err := rows.Scan(&status, &count); err != nil {
return stats, fmt.Errorf("stats: %w", err)
}
stats.ThemesByStatus[status] = count
stats.Themes += count
}
// rows.Next() returning false means either no rows or an error. Checking Err
// distinguishes them, and without it a failure part way through the result set
// reports success with silently incomplete counters.
if err := rows.Err(); err != nil {
return stats, fmt.Errorf("stats: %w", err)
}
for _, query := range []struct {
sql string
dest *int
}{
{`SELECT COUNT(*) FROM users`, &stats.Users},
{`SELECT COUNT(*) FROM audit_log`, &stats.AuditEntries},
{`SELECT COUNT(*) FROM theme_versions`, &stats.VersionsRetaind},
} {
if err := s.db.QueryRowContext(ctx, query.sql).Scan(query.dest); err != nil {
return stats, fmt.Errorf("stats: %w", translateError(err))
}
}
// page_count * page_size is the on-disk size of the main database file. It
// is a cheap approximation, which is all a dashboard needs.
var pageCount, pageSize int64
if err := s.db.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pageCount); err == nil {
if err := s.db.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err == nil {
stats.DatabaseSize = pageCount * pageSize
}
}
return stats, nil
}

13
internal/store/sort.go Normal file
View file

@ -0,0 +1,13 @@
package store
import (
"sort"
)
// sortStrings orders a string slice in place.
func sortStrings(values []string) { sort.Strings(values) }
// sortSlice orders a slice in place using the supplied comparison.
func sortSlice[T any](values []T, less func(a, b T) bool) {
sort.Slice(values, func(i, j int) bool { return less(values[i], values[j]) })
}

307
internal/store/store.go Normal file
View file

@ -0,0 +1,307 @@
// Package store is the persistence layer. It owns the SQLite connection, the
// schema and every query the application runs.
//
// # Why SQLite
//
// Go Bananas!! stores themes: a few thousand rows of JSON documents that are read
// constantly and written rarely. That workload is exactly what an embedded
// engine is good at, and a client/server database would add a network hop, a
// connection pool, a second container, credentials to rotate and a schema
// migration process to a service whose entire dataset comfortably fits in a few
// megabytes.
//
// The configuration that makes this genuinely fast rather than merely adequate:
//
// - WAL journalling, so readers never block the writer and vice versa.
// - synchronous=NORMAL, which in WAL mode is durable across process crashes
// and only risks the last transactions on an OS crash. For a design-token
// store that is the correct trade: a lost publish is re-doable, an outage
// is not.
// - A deliberately small connection pool. SQLite serialises writers, so many
// connections buy contention, not throughput.
// - Prepared statements cached by database/sql, so the hot runtime query is
// compiled once at start-up.
// - Rendered themes memoised in memory, so the steady-state runtime request
// performs no database access at all.
//
// The engine is modernc.org/sqlite, a pure-Go translation of SQLite. It needs no
// cgo, so the release image builds from a static base with no libc and the
// cross-compiled binaries are genuinely portable.
//
// # Concurrency
//
// A Store is safe for concurrent use. The connection pool handles concurrency;
// write transactions are serialised by SQLite's own locking, which at this write
// volume is not a bottleneck. Callers must not hold a *sql.Tx across an HTTP
// boundary; use the callback forms so that a panic rolls the transaction back.
package store
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver
"github.com/urpi/go-bananas/internal/domain"
)
// marshalJSON encodes a value for a TEXT column. The store always writes JSON
// through this helper so that a value which cannot be marshalled fails at the
// boundary rather than producing a corrupt row.
func marshalJSON(v any) (string, error) {
raw, err := json.Marshal(v)
if err != nil {
return "", fmt.Errorf("marshal json: %w", err)
}
return string(raw), nil
}
// boolToInt renders a bool for a SQLite INTEGER column.
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// intToBool is the inverse of boolToInt.
func intToBool(v int) bool { return v != 0 }
// Store is the persistence facade.
type Store struct {
db *sql.DB
now func() time.Time
// inMemory reports whether the store is backed by an ephemeral database.
inMemory bool
}
// Options configures a Store.
type Options struct {
// Path is the database file, or ":memory:" for an ephemeral database.
Path string
// BusyTimeout is how long a blocked writer waits before failing.
BusyTimeout time.Duration
// MaxConns bounds the connection pool.
MaxConns int
// ReadOnly opens the database without write access, for replicas.
ReadOnly bool
// Now overrides the clock, for deterministic tests.
Now func() time.Time
}
// sentinelMapping translates the SQLite driver's typed errors into the domain
// sentinels the HTTP layer matches on. Doing this in one place keeps every
// repository from re-implementing driver-specific string matching.
var sentinelMapping = map[string]error{
"constraint failed: UNIQUE constraint failed": domain.ErrConflict,
"constraint failed: CHECK constraint failed": domain.ErrInvalid,
"constraint failed: NOT NULL constraint failed": domain.ErrInvalid,
"constraint failed: FOREIGN KEY constraint failed": domain.ErrConflict,
}
// Open connects to the database, applies the schema and verifies it.
func Open(ctx context.Context, opts Options) (*Store, error) {
if opts.Path == "" {
return nil, fmt.Errorf("%w: database path must not be empty", domain.ErrInvalid)
}
if opts.BusyTimeout <= 0 {
opts.BusyTimeout = 5 * time.Second
}
if opts.MaxConns <= 0 {
opts.MaxConns = 8
}
if opts.Now == nil {
opts.Now = time.Now
}
inMemory := isMemoryPath(opts.Path)
dsn := buildDSN(opts.Path, opts.BusyTimeout, opts.ReadOnly)
if !inMemory && !opts.ReadOnly {
if dir := filepath.Dir(opts.Path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("create database directory: %w", err)
}
}
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// A small pool is deliberate. See the package comment.
db.SetMaxOpenConns(opts.MaxConns)
db.SetMaxIdleConns(opts.MaxConns)
db.SetConnMaxIdleTime(time.Minute)
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
s := &Store{db: db, now: opts.Now, inMemory: inMemory}
if !opts.ReadOnly {
if err := s.migrate(ctx); err != nil {
db.Close()
return nil, err
}
}
return s, nil
}
// buildDSN assembles the SQLite connection string.
//
// modernc.org/sqlite takes its PRAGMAs as query parameters. Using the _pragma
// form rather than executing PRAGMA statements after opening is what makes the
// settings apply to every pooled connection instead of only the first.
func buildDSN(path string, busyTimeout time.Duration, readOnly bool) string {
params := url.Values{}
// WAL lets readers and the writer proceed concurrently.
if !readOnly && !isMemoryPath(path) {
params.Add("_pragma", "journal_mode(WAL)")
}
// NORMAL is durable across application crashes in WAL mode. See the package
// comment for why that is the right trade here.
params.Add("_pragma", "synchronous(NORMAL)")
params.Add("_pragma", "foreign_keys(ON)")
params.Add("_pragma", "busy_timeout("+fmt.Sprint(busyTimeout.Milliseconds())+")")
// A negative cache size is a KiB budget; 16 MiB comfortably holds the whole
// dataset in memory.
params.Add("_pragma", "cache_size(-16000)")
// temp_store=MEMORY keeps sort and group-by spill out of the filesystem.
params.Add("_pragma", "temp_store(MEMORY)")
// mmap lets SQLite read the file through the page cache directly, which
// removes a copy on the hot read path.
params.Add("_pragma", "mmap_size(268435456)")
if readOnly {
params.Add("mode", "ro")
}
base := path
if isMemoryPath(path) {
// A shared cache is required for an in-memory database to be visible
// across the pooled connections, otherwise each connection would get its
// own empty database.
switch {
case strings.Contains(path, "?"):
// The caller supplied a full URI with its own query.
base = path
case nameFromPath(path) != "":
base = "file:" + nameFromPath(path) + "?mode=memory&cache=shared"
default:
// A bare ":memory:" carries no name. Naming it uniquely keeps two
// stores in one process isolated from each other, which is what a test
// creating a second store expects; leaving it unnamed would hand every
// caller the same database and let one test's rows appear in another's.
base = "file:" + ephemeralMemoryName() + "?mode=memory&cache=shared"
}
}
// The separator depends on whether the base already carries a query. Joining
// with "?" unconditionally produces "cache=shared?_pragma=...", which SQLite
// reads as a cache mode literally named "shared?_pragma=..." and rejects.
sep := "?"
if strings.Contains(base, "?") {
sep = "&"
}
return base + sep + params.Encode()
}
// ephemeralMemoryName returns a random name for an unnamed in-memory database.
func ephemeralMemoryName() string {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
// A fixed name is wrong but not fatal: the store still opens and the only
// consequence is that two concurrent unnamed stores would share a database.
// Failing to open over an entropy hiccup would be worse than that.
return "memory-default"
}
return "memory-" + hex.EncodeToString(buf)
}
// IsPersistentPath reports whether a database path holds data that outlives the
// process. An ephemeral database cannot be snapshotted, and a caller that tries is
// better served by being told so than by producing an empty file.
func IsPersistentPath(path string) bool { return !isMemoryPath(path) }
// isMemoryPath reports whether the path refers to an ephemeral database.
func isMemoryPath(path string) bool {
return path == ":memory:" || strings.HasPrefix(path, "file::memory:") || strings.Contains(path, "mode=memory")
}
// nameFromPath derives a stable shared-cache identifier from a path, so that
// two in-memory stores created with the same name are genuinely the same
// database and two created with different names are isolated.
func nameFromPath(path string) string {
name := strings.TrimPrefix(path, ":memory:")
name = strings.TrimPrefix(name, "file:")
if idx := strings.IndexByte(name, '?'); idx >= 0 {
name = name[:idx]
}
name = strings.Trim(name, "/")
if name == "" {
return "go-bananas-memory"
}
// Names are hashed rather than escaped so that any path can be used safely.
return "go-bananas-" + fmt.Sprintf("%x", len(name)) + "-" + strings.ReplaceAll(name, "/", "_")
}
// Close releases the database.
func (s *Store) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
// DB exposes the underlying handle for health checks and tests.
func (s *Store) DB() *sql.DB { return s.db }
// Ping verifies connectivity, used by the readiness probe.
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
// InMemory reports whether the store is ephemeral.
func (s *Store) InMemory() bool { return s.inMemory }
// Now returns the store's clock.
func (s *Store) Now() time.Time { return s.now() }
// translateError maps a driver error onto a domain sentinel.
func translateError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: %s", domain.ErrNotFound, err.Error())
}
msg := err.Error()
for fragment, sentinel := range sentinelMapping {
if strings.Contains(msg, fragment) {
return fmt.Errorf("%w: %s", sentinel, msg)
}
}
return err
}
// wrapNotFound attaches context to an ErrNotFound result.
func wrapNotFound(err error, what string) error {
if err == nil {
return nil
}
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: %s", domain.ErrNotFound, what)
}
return translateError(err)
}

View file

@ -0,0 +1,717 @@
package store
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/seed"
)
// newTestStore returns a store backed by a temporary on-disk database, so that
// the test exercises the same file-based code path as production including WAL.
func newTestStore(t *testing.T) *Store {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
store, err := Open(context.Background(), Options{Path: path})
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
// sampleTheme returns a minimal valid theme for store tests.
func sampleTheme(slug string) *domain.Theme {
return &domain.Theme{
Slug: slug,
Name: "Sample " + slug,
Description: "a theme used in tests",
Status: domain.StatusDraft,
DefaultAppearance: domain.AppearanceLight,
Tokens: domain.TokenDoc{
"color": map[string]any{
"semantic": map[string]any{
"background": map[string]any{"$type": "color", "$value": "#ffffff"},
"primary": map[string]any{"$type": "color", "$value": "#4f46e5"},
},
},
},
Meta: domain.ThemeMeta{"tags": []string{"test"}},
CreatedBy: "tester",
}
}
func TestOpenAppliesSchema(t *testing.T) {
store := newTestStore(t)
for _, table := range []string{"themes", "theme_versions", "users", "audit_log", "settings", "schema_migrations"} {
var name string
err := store.DB().QueryRowContext(t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&name)
if err != nil {
t.Errorf("table %s missing: %v", table, err)
}
}
}
func TestCreateAndGetTheme(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
theme := sampleTheme("acme")
if err := store.Create(ctx, theme, "initial"); err != nil {
t.Fatalf("Create: %v", err)
}
loaded, err := store.Get(ctx, "acme")
if err != nil {
t.Fatalf("Get: %v", err)
}
if loaded.ID == "" {
t.Error("expected an assigned id")
}
if loaded.Version != 1 {
t.Errorf("version = %d, want 1", loaded.Version)
}
if loaded.Tokens["color"] == nil {
t.Error("tokens did not round-trip")
}
if loaded.System {
t.Error("a user-created theme must not be flagged as built in")
}
}
func TestCreateRejectsDuplicateSlug(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
t.Fatalf("first Create: %v", err)
}
err := store.Create(ctx, sampleTheme("acme"), "")
if !errors.Is(err, domain.ErrConflict) {
t.Fatalf("duplicate slug error = %v, want ErrConflict", err)
}
}
func TestCreateRejectsReservedSlug(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
err := store.Create(ctx, sampleTheme("api"), "")
if !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("reserved slug error = %v, want ErrInvalid", err)
}
}
func TestCreateRejectsInvalidStatus(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
theme := sampleTheme("acme")
theme.Status = "sideways"
if err := store.Create(ctx, theme, ""); !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("invalid status error = %v, want ErrInvalid", err)
}
}
func TestUpdateBumpsVersionAndRecordsHistory(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if err := store.Create(ctx, sampleTheme("acme"), "created"); err != nil {
t.Fatalf("Create: %v", err)
}
name := "Renamed"
updated, err := store.Update(ctx, "acme", UpdateFields{Name: &name, Message: "renamed"})
if err != nil {
t.Fatalf("Update: %v", err)
}
if updated.Version != 2 {
t.Errorf("version = %d, want 2", updated.Version)
}
if updated.Name != name {
t.Errorf("name = %q, want %q", updated.Name, name)
}
versions, err := store.Versions(ctx, "acme", 10)
if err != nil {
t.Fatalf("Versions: %v", err)
}
if len(versions) != 2 {
t.Fatalf("got %d versions, want 2", len(versions))
}
// Newest first.
if versions[0].Version != 2 || versions[0].Message != "renamed" {
t.Errorf("unexpected newest version: %+v", versions[0])
}
}
func TestPartialUpdateLeavesOtherFieldsAlone(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
t.Fatalf("Create: %v", err)
}
description := "updated description"
if _, err := store.Update(ctx, "acme", UpdateFields{Description: &description}); err != nil {
t.Fatalf("Update: %v", err)
}
loaded, err := store.Get(ctx, "acme")
if err != nil {
t.Fatalf("Get: %v", err)
}
if loaded.Name != "Sample acme" {
t.Errorf("name changed to %q by a description-only update", loaded.Name)
}
if loaded.Description != description {
t.Errorf("description = %q, want %q", loaded.Description, description)
}
}
func TestRenameChangesSlugAndKeepsHistory(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
t.Fatalf("Create: %v", err)
}
newSlug := "acme-v2"
if _, err := store.Update(ctx, "acme", UpdateFields{Slug: &newSlug}); err != nil {
t.Fatalf("Update: %v", err)
}
if _, err := store.Get(ctx, "acme"); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("old slug should be gone, got %v", err)
}
if _, err := store.Get(ctx, "acme-v2"); err != nil {
t.Errorf("new slug should resolve: %v", err)
}
}
func TestDeleteRefusesBuiltInThemes(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if _, _, err := store.Seed(ctx, seed.Themes()); err != nil {
t.Fatalf("Seed: %v", err)
}
err := store.Delete(ctx, "midnight")
if !errors.Is(err, domain.ErrImmutable) {
t.Fatalf("deleting a built-in theme = %v, want ErrImmutable", err)
}
}
func TestDeleteRemovesThemeAndCascadesHistory(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
t.Fatalf("Create: %v", err)
}
if err := store.Delete(ctx, "acme"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := store.Get(ctx, "acme"); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("theme should be gone, got %v", err)
}
var versions int
if err := store.DB().QueryRowContext(t.Context(),
`SELECT COUNT(*) FROM theme_versions WHERE slug = ?`, "acme").Scan(&versions); err != nil {
t.Fatalf("count versions: %v", err)
}
if versions != 0 {
t.Errorf("history rows survived deletion: %d", versions)
}
}
func TestDeleteUnknownThemeReportsNotFound(t *testing.T) {
store := newTestStore(t)
if err := store.Delete(context.Background(), "nope"); !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("error = %v, want ErrNotFound", err)
}
}
func TestDuplicateProducesIndependentDraft(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
t.Fatalf("Create: %v", err)
}
copied, err := store.Duplicate(ctx, "acme", "acme-copy", "tester")
if err != nil {
t.Fatalf("Duplicate: %v", err)
}
if copied.Status != domain.StatusDraft {
t.Errorf("status = %q, want draft", copied.Status)
}
if copied.Version != 1 {
t.Errorf("version = %d, want 1", copied.Version)
}
if copied.System {
t.Error("a duplicate must not remain flagged as built in")
}
// Mutating the copy must not touch the original.
name := "Changed"
if _, err := store.Update(ctx, "acme-copy", UpdateFields{Name: &name}); err != nil {
t.Fatalf("Update copy: %v", err)
}
original, err := store.Get(ctx, "acme")
if err != nil {
t.Fatalf("Get original: %v", err)
}
if original.Name == "Changed" {
t.Error("mutating the duplicate changed the original")
}
}
func TestRollbackAppendsRatherThanRewritesHistory(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
t.Fatalf("Create: %v", err)
}
name := "Second"
if _, err := store.Update(ctx, "acme", UpdateFields{Name: &name}); err != nil {
t.Fatalf("Update: %v", err)
}
restored, err := store.Rollback(ctx, "acme", 1, "tester")
if err != nil {
t.Fatalf("Rollback: %v", err)
}
if restored.Name != "Sample acme" {
t.Errorf("name = %q, want the version 1 value", restored.Name)
}
if restored.Version != 3 {
t.Errorf("version = %d, want 3; a rollback must append", restored.Version)
}
versions, err := store.Versions(ctx, "acme", 10)
if err != nil {
t.Fatalf("Versions: %v", err)
}
if len(versions) != 3 {
t.Errorf("got %d versions, want 3 (history must be append-only)", len(versions))
}
}
func TestDiffVersions(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
t.Fatalf("Create: %v", err)
}
tokens := sampleTheme("acme").Tokens
tokens["color"].(map[string]any)["semantic"].(map[string]any)["primary"] =
map[string]any{"$type": "color", "$value": "#ff0000"}
if _, err := store.Update(ctx, "acme", UpdateFields{Tokens: &tokens, Message: "recolour"}); err != nil {
t.Fatalf("Update: %v", err)
}
diff, err := store.DiffVersions(ctx, "acme", 1, 2)
if err != nil {
t.Fatalf("DiffVersions: %v", err)
}
if len(diff.Changed) != 1 {
t.Fatalf("got %d changed tokens, want 1: %+v", len(diff.Changed), diff.Changed)
}
change := diff.Changed[0]
if change.Path != "color.semantic.primary" {
t.Errorf("path = %q", change.Path)
}
if change.From != "#4f46e5" || change.To != "#ff0000" {
t.Errorf("change = %s -> %s", change.From, change.To)
}
}
// TestDiffVersionsOfIdenticalRevisionsMarshalAsLists pins the wire contract for a
// diff with nothing in it.
//
// Two revisions with no differences is an ordinary outcome, not an edge case: a save
// that only changed a description, or an operator comparing the wrong pair. The JSON
// still has to carry three lists, because that is what the API promises and what a
// typed client reads the length of.
func TestDiffVersionsOfIdenticalRevisionsMarshalAsLists(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
t.Fatalf("Create: %v", err)
}
// A second save of the same token document, so the versions differ only in their
// message and the diff has nothing to report.
tokens := sampleTheme("acme").Tokens
if _, err := store.Update(ctx, "acme", UpdateFields{Tokens: &tokens, Message: "no-op save"}); err != nil {
t.Fatalf("Update: %v", err)
}
diff, err := store.DiffVersions(ctx, "acme", 1, 2)
if err != nil {
t.Fatalf("DiffVersions: %v", err)
}
if diff.Added == nil || diff.Removed == nil || diff.Changed == nil {
t.Fatalf("empty lists must be non-nil: %+v", diff)
}
if len(diff.Added)+len(diff.Removed)+len(diff.Changed) != 0 {
t.Errorf("identical revisions should diff to nothing: %+v", diff)
}
// The assertion that matters to a client: null would break every consumer that
// reads a length, and JSON is the contract, not the Go struct.
encoded, err := json.Marshal(diff)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
for _, field := range []string{`"added":[]`, `"removed":[]`, `"changed":[]`} {
if !strings.Contains(string(encoded), field) {
t.Errorf("diff JSON missing %s: %s", field, encoded)
}
}
for _, field := range []string{`"added":null`, `"removed":null`, `"changed":null`} {
if strings.Contains(string(encoded), field) {
t.Errorf("diff JSON has %s, which a typed client cannot read a length from: %s", field, encoded)
}
}
}
func TestListFiltersAndSorts(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
for _, slug := range []string{"alpha", "beta", "gamma"} {
if err := store.Create(ctx, sampleTheme(slug), ""); err != nil {
t.Fatalf("Create %s: %v", slug, err)
}
}
published := domain.StatusPublished
if _, err := store.Update(ctx, "beta", UpdateFields{Status: &published}); err != nil {
t.Fatalf("Update: %v", err)
}
page, err := store.List(ctx, domain.ThemeQuery{Status: domain.StatusPublished})
if err != nil {
t.Fatalf("List: %v", err)
}
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].Slug != "beta" {
t.Errorf("published filter returned %+v", page)
}
page, err = store.List(ctx, domain.ThemeQuery{Search: "alph"})
if err != nil {
t.Fatalf("List search: %v", err)
}
if page.Total != 1 || page.Items[0].Slug != "alpha" {
t.Errorf("search returned %+v", page)
}
page, err = store.List(ctx, domain.ThemeQuery{Sort: "name", Order: "asc"})
if err != nil {
t.Fatalf("List sorted: %v", err)
}
if len(page.Items) != 3 || page.Items[0].Slug != "alpha" {
t.Errorf("sorted page = %+v", page.Items)
}
}
func TestListIgnoresUnknownSortColumn(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
t.Fatalf("Create: %v", err)
}
// An unknown sort must fall back to the default rather than reaching SQL.
if _, err := store.List(ctx, domain.ThemeQuery{Sort: "slug; DROP TABLE themes"}); err != nil {
t.Fatalf("List: %v", err)
}
}
func TestListSearchTreatsWildcardsLiterally(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
for _, slug := range []string{"alpha", "beta"} {
if err := store.Create(ctx, sampleTheme(slug), ""); err != nil {
t.Fatalf("Create: %v", err)
}
}
page, err := store.List(ctx, domain.ThemeQuery{Search: "%"})
if err != nil {
t.Fatalf("List: %v", err)
}
if page.Total != 0 {
t.Errorf("a literal %% matched %d themes; wildcards must be escaped", page.Total)
}
}
func TestListRuntimeExcludesDrafts(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if err := store.Create(ctx, sampleTheme("draft-one"), ""); err != nil {
t.Fatalf("Create: %v", err)
}
published := domain.StatusPublished
if err := store.Create(ctx, sampleTheme("live-one"), ""); err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := store.Update(ctx, "live-one", UpdateFields{Status: &published}); err != nil {
t.Fatalf("Update: %v", err)
}
themes, err := store.ListRuntime(ctx)
if err != nil {
t.Fatalf("ListRuntime: %v", err)
}
if len(themes) != 1 || themes[0].Slug != "live-one" {
t.Errorf("ListRuntime returned %d themes, want only the published one", len(themes))
}
}
func TestSeedIsIdempotent(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
inserted, _, err := store.Seed(ctx, seed.Themes())
if err != nil {
t.Fatalf("Seed: %v", err)
}
if inserted == 0 {
t.Fatal("first seed inserted nothing")
}
// Declared here rather than alongside the first call, so the first call's
// "refreshed" is not an assignment that is immediately overwritten.
var refreshed int
inserted, refreshed, err = store.Seed(ctx, seed.Themes())
if err != nil {
t.Fatalf("second Seed: %v", err)
}
if inserted != 0 || refreshed != 0 {
t.Errorf("second seed inserted=%d refreshed=%d, want 0/0", inserted, refreshed)
}
for _, slug := range seed.Slugs() {
theme, err := store.Get(ctx, slug)
if err != nil {
t.Errorf("built-in %s missing: %v", slug, err)
continue
}
if !theme.System {
t.Errorf("theme %s is not flagged as built in", slug)
}
}
}
func TestSeedLeavesEditedBuiltInsAlone(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if _, _, err := store.Seed(ctx, seed.Themes()); err != nil {
t.Fatalf("Seed: %v", err)
}
// Simulate an operator having edited the built-in theme.
custom := "operator's midnight"
if _, err := store.Update(ctx, "midnight", UpdateFields{Name: &custom}); err != nil {
t.Fatalf("Update: %v", err)
}
editedVersion, err := store.Get(ctx, "midnight")
if err != nil {
t.Fatalf("Get: %v", err)
}
// Re-seeding must not revert the operator's work.
if _, _, err := store.Seed(ctx, seed.Themes()); err != nil {
t.Fatalf("re-Seed: %v", err)
}
after, err := store.Get(ctx, "midnight")
if err != nil {
t.Fatalf("Get after reseed: %v", err)
}
if after.Name != custom {
t.Errorf("name = %q, want the operator's %q", after.Name, custom)
}
if after.Version != editedVersion.Version {
t.Errorf("version = %d, want %d; a reseed must not bump an edited theme", after.Version, editedVersion.Version)
}
}
func TestAuditRoundTrip(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
entry := &domain.AuditEntry{
Actor: "alice",
ActorEmail: "alice@example.com",
Action: domain.ActionThemePublish,
EntityType: "theme",
EntityID: "acme",
Summary: "published acme",
Detail: map[string]any{"version": 3},
RemoteIP: "10.0.0.1",
}
if err := store.AppendAudit(ctx, entry); err != nil {
t.Fatalf("AppendAudit: %v", err)
}
entries, total, err := store.ListAudit(ctx, AuditFilter{EntityID: "acme"})
if err != nil {
t.Fatalf("ListAudit: %v", err)
}
if total != 1 || len(entries) != 1 {
t.Fatalf("got %d entries (total %d), want 1", len(entries), total)
}
if entries[0].Detail["version"] != float64(3) {
t.Errorf("detail = %v", entries[0].Detail)
}
if _, _, err := store.ListAudit(ctx, AuditFilter{Actor: "bob"}); err != nil {
t.Fatalf("ListAudit by actor: %v", err)
}
}
func TestPurgeAudit(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
old := store.Now().Add(-90 * 24 * time.Hour)
if err := store.AppendAudit(ctx, &domain.AuditEntry{At: old, Action: domain.ActionAuthLogin, Actor: "old"}); err != nil {
t.Fatalf("AppendAudit: %v", err)
}
if err := store.AppendAudit(ctx, &domain.AuditEntry{Action: domain.ActionAuthLogin, Actor: "new"}); err != nil {
t.Fatalf("AppendAudit: %v", err)
}
removed, err := store.PurgeAudit(ctx, store.Now().Add(-30*24*time.Hour))
if err != nil {
t.Fatalf("PurgeAudit: %v", err)
}
if removed != 1 {
t.Errorf("removed %d entries, want 1", removed)
}
}
func TestSettings(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
value, err := store.Setting(ctx, domain.SettingDefaultTheme, "fallback")
if err != nil || value != "fallback" {
t.Fatalf("Setting default = %q, %v; want the fallback", value, err)
}
if err := store.SetSetting(ctx, domain.SettingDefaultTheme, "midnight", "tester"); err != nil {
t.Fatalf("SetSetting: %v", err)
}
value, err = store.Setting(ctx, domain.SettingDefaultTheme, "fallback")
if err != nil || value != "midnight" {
t.Fatalf("Setting = %q, %v; want midnight", value, err)
}
}
func TestUpsertUserOverwritesRoles(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
identity := &domain.Identity{
Subject: "sub-1", Email: "a@example.com", Name: "A",
Roles: []domain.Role{domain.RoleAdmin}, Method: "oidc",
}
if err := store.UpsertUser(ctx, identity); err != nil {
t.Fatalf("UpsertUser: %v", err)
}
identity.Roles = []domain.Role{domain.RoleViewer}
if err := store.UpsertUser(ctx, identity); err != nil {
t.Fatalf("UpsertUser again: %v", err)
}
users, err := store.ListUsers(ctx, 10)
if err != nil {
t.Fatalf("ListUsers: %v", err)
}
if len(users) != 1 {
t.Fatalf("got %d users, want 1 (upsert must not duplicate)", len(users))
}
if len(users[0].Roles) != 1 || users[0].Roles[0] != string(domain.RoleViewer) {
t.Errorf("roles = %v, want the latest value", users[0].Roles)
}
}
func TestStats(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if _, _, err := store.Seed(ctx, seed.Themes()); err != nil {
t.Fatalf("Seed: %v", err)
}
stats, err := store.Stats(ctx)
if err != nil {
t.Fatalf("Stats: %v", err)
}
if stats.Themes != len(seed.Slugs()) {
t.Errorf("themes = %d, want %d", stats.Themes, len(seed.Slugs()))
}
if stats.DatabaseSize <= 0 {
t.Error("expected a non-zero database size")
}
}
func TestConcurrentReadsAndWrites(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
if err := store.Create(ctx, sampleTheme("shared"), ""); err != nil {
t.Fatalf("Create: %v", err)
}
// WAL is supposed to let readers run while a writer holds the lock. This
// test would deadlock or error without it.
errs := make(chan error, 20)
done := make(chan struct{})
go func() {
for i := 0; i < 10; i++ {
name := "name"
if _, err := store.Update(ctx, "shared", UpdateFields{Name: &name}); err != nil {
errs <- err
return
}
}
close(done)
}()
for i := 0; i < 10; i++ {
if _, err := store.Get(ctx, "shared"); err != nil {
errs <- err
break
}
}
select {
case <-done:
case err := <-errs:
t.Fatalf("concurrent access failed: %v", err)
}
close(errs)
for err := range errs {
t.Errorf("concurrent access: %v", err)
}
}

733
internal/store/themes.go Normal file
View file

@ -0,0 +1,733 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/urpi/go-bananas/internal/domain"
)
// themeColumns is the canonical SELECT list. Keeping it in one constant means
// the scan helper and every query can never drift out of sync, which is the
// classic source of "sql: expected N destination arguments" at runtime.
const themeColumns = `
id, slug, name, description, status, default_appearance,
tokens_json, meta_json, is_system, version,
created_at, updated_at, created_by, updated_by`
// themeRow is the flat representation used for scanning.
type themeRow struct {
domain.Theme
tokensJSON string
metaJSON string
isSystem int
}
// List returns a page of themes matching the query.
func (s *Store) List(ctx context.Context, q domain.ThemeQuery) (domain.ThemePage, error) {
var (
where []string
args []any
)
if q.Search != "" {
// LIKE with an escaped pattern. The ESCAPE clause stops a user-supplied
// % or _ from turning the filter into a full scan of everything.
where = append(where, `(slug LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\' OR description LIKE ? ESCAPE '\')`)
pattern := "%" + escapeLike(q.Search) + "%"
args = append(args, pattern, pattern, pattern)
}
if q.Status != "" {
where = append(where, `status = ?`)
args = append(args, string(q.Status))
}
if q.System != nil {
where = append(where, `is_system = ?`)
args = append(args, boolToInt(*q.System))
}
if q.Tag != "" {
// Tags live inside the opaque meta document. A substring match on the
// serialised form is sufficient: the value is a short JSON string and
// the row count is small.
where = append(where, `meta_json LIKE ? ESCAPE '\'`)
args = append(args, "%\""+escapeLike(q.Tag)+"\"%")
}
clause := ""
if len(where) > 0 {
clause = " WHERE " + strings.Join(where, " AND ")
}
// The sort column is resolved through an allow-list rather than
// interpolated, so caller input can never reach the SQL text.
sortColumn := domain.SortFields["updated"]
if mapped, ok := domain.SortFields[q.Sort]; ok {
sortColumn = mapped
}
direction := "DESC"
if strings.EqualFold(q.Order, "asc") {
direction = "ASC"
}
limit := q.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
offset := q.Offset
if offset < 0 {
offset = 0
}
total, err := s.countThemes(ctx, clause, args)
if err != nil {
return domain.ThemePage{}, err
}
// The token document is the bulk of the row. A listing only needs metadata,
// so it is excluded here and fetched by Get.
//nolint:gosec // G202: sortColumn is validated against a fixed whitelist by the
// caller and direction is one of two literals, so neither can carry SQL. The
// values are still bound for the LIMIT and OFFSET, which do support parameters.
query := `SELECT` + themeColumns + ` FROM themes` + clause +
fmt.Sprintf(" ORDER BY %s %s, slug ASC LIMIT ? OFFSET ?", sortColumn, direction)
rows, err := s.db.QueryContext(ctx, query, append(append([]any{}, args...), limit, offset)...)
if err != nil {
return domain.ThemePage{}, fmt.Errorf("list themes: %w", translateError(err))
}
defer rows.Close()
page := domain.ThemePage{Items: []*domain.Theme{}, Limit: limit, Offset: offset}
for rows.Next() {
row, err := scanThemeRow(rows)
if err != nil {
return domain.ThemePage{}, err
}
theme, err := row.theme()
if err != nil {
return domain.ThemePage{}, err
}
page.Items = append(page.Items, theme)
}
if err := rows.Err(); err != nil {
return domain.ThemePage{}, fmt.Errorf("iterate themes: %w", err)
}
page.Total = total
return page, nil
}
// countThemes counts the rows matching a WHERE clause.
func (s *Store) countThemes(ctx context.Context, clause string, args []any) (int, error) {
var total int
query := `SELECT COUNT(*) FROM themes` + clause
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil {
return 0, fmt.Errorf("count themes: %w", translateError(err))
}
return total, nil
}
// ListRuntime returns the themes a runtime consumer may resolve. Drafts are
// excluded; archived themes are included because a pinned consumer must keep
// working after a theme is archived.
func (s *Store) ListRuntime(ctx context.Context) ([]*domain.Theme, error) {
rows, err := s.db.QueryContext(ctx, `SELECT`+themeColumns+
` FROM themes WHERE status IN ('published', 'archived') ORDER BY name ASC`)
if err != nil {
return nil, fmt.Errorf("list runtime themes: %w", translateError(err))
}
defer rows.Close()
var out []*domain.Theme
for rows.Next() {
row, err := scanThemeRow(rows)
if err != nil {
return nil, err
}
theme, err := row.theme()
if err != nil {
return nil, err
}
out = append(out, theme)
}
return out, rows.Err()
}
// Get loads a theme by slug.
func (s *Store) Get(ctx context.Context, slug string) (*domain.Theme, error) {
row, err := scanThemeRow(s.db.QueryRowContext(ctx,
`SELECT`+themeColumns+` FROM themes WHERE slug = ?`, slug))
if err != nil {
return nil, wrapNotFound(err, "theme "+slug)
}
return row.theme()
}
// GetByID loads a theme by identifier.
func (s *Store) GetByID(ctx context.Context, id string) (*domain.Theme, error) {
row, err := scanThemeRow(s.db.QueryRowContext(ctx,
`SELECT`+themeColumns+` FROM themes WHERE id = ?`, id))
if err != nil {
return nil, wrapNotFound(err, "theme "+id)
}
return row.theme()
}
// Create inserts a new theme and its first history entry.
func (s *Store) Create(ctx context.Context, theme *domain.Theme, message string) error {
if err := domain.ValidateSlug(theme.Slug); err != nil {
return fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
}
if _, reserved := domain.ReservedSlugs[theme.Slug]; reserved {
return fmt.Errorf("%w: slug %q is reserved by the runtime API", domain.ErrInvalid, theme.Slug)
}
if theme.Name == "" {
return fmt.Errorf("%w: theme name must not be empty", domain.ErrInvalid)
}
if theme.Status == "" {
theme.Status = domain.StatusDraft
}
if !theme.Status.Valid() {
return fmt.Errorf("%w: unknown status %q", domain.ErrInvalid, theme.Status)
}
if theme.DefaultAppearance == "" {
theme.DefaultAppearance = domain.AppearanceLight
}
if !theme.DefaultAppearance.Valid() {
return fmt.Errorf("%w: unknown appearance %q", domain.ErrInvalid, theme.DefaultAppearance)
}
if theme.Version < 1 {
theme.Version = 1
}
if theme.ID == "" {
id, err := randomID()
if err != nil {
return err
}
theme.ID = "thm_" + id
}
tokensJSON, err := marshalJSON(theme.Tokens)
if err != nil {
return fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
}
metaJSON, err := marshalJSON(theme.Meta)
if err != nil {
return fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
}
now := s.now().UTC().Format(domain.TimeFormat)
theme.CreatedAt = s.now().UTC()
theme.UpdatedAt = theme.CreatedAt
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin create theme: %w", err)
}
defer tx.Rollback()
if err := insertTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil {
return err
}
if err := insertVersion(ctx, tx, theme, tokensJSON, defaultMessage(message, theme, "created"), theme.CreatedBy, now); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit create theme: %w", err)
}
return nil
}
// UpdateFields carries the mutable attributes of a theme. A nil pointer leaves
// the field unchanged, which is what lets the API accept partial edits without
// a read-modify-write race in the handler.
type UpdateFields struct {
Slug *string
Name *string
Description *string
Status *domain.ThemeStatus
DefaultAppearance *domain.Appearance
Tokens *domain.TokenDoc
Meta *domain.ThemeMeta
Message string
Editor string
}
// Update applies a partial update and records a new version.
func (s *Store) Update(ctx context.Context, slug string, fields UpdateFields) (*domain.Theme, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin update theme: %w", err)
}
defer tx.Rollback()
current, err := scanThemeRow(tx.QueryRowContext(ctx,
`SELECT`+themeColumns+` FROM themes WHERE slug = ?`, slug))
if err != nil {
return nil, wrapNotFound(err, "theme "+slug)
}
theme, err := current.theme()
if err != nil {
return nil, err
}
if fields.Slug != nil && *fields.Slug != theme.Slug {
if err := domain.ValidateSlug(*fields.Slug); err != nil {
return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
}
if _, reserved := domain.ReservedSlugs[*fields.Slug]; reserved {
return nil, fmt.Errorf("%w: slug %q is reserved by the runtime API", domain.ErrInvalid, *fields.Slug)
}
theme.Slug = *fields.Slug
}
if fields.Name != nil {
theme.Name = *fields.Name
}
if fields.Description != nil {
theme.Description = *fields.Description
}
if fields.Status != nil {
if !fields.Status.Valid() {
return nil, fmt.Errorf("%w: unknown status %q", domain.ErrInvalid, *fields.Status)
}
theme.Status = *fields.Status
}
if fields.DefaultAppearance != nil {
if !fields.DefaultAppearance.Valid() {
return nil, fmt.Errorf("%w: unknown appearance %q", domain.ErrInvalid, *fields.DefaultAppearance)
}
theme.DefaultAppearance = *fields.DefaultAppearance
}
if fields.Tokens != nil {
theme.Tokens = *fields.Tokens
}
if fields.Meta != nil {
theme.Meta = *fields.Meta
}
if theme.Name == "" {
return nil, fmt.Errorf("%w: theme name must not be empty", domain.ErrInvalid)
}
tokensJSON, err := marshalJSON(theme.Tokens)
if err != nil {
return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
}
metaJSON, err := marshalJSON(theme.Meta)
if err != nil {
return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
}
// Every write bumps the version: the version is the cache key for the
// rendered CSS, so it must change whenever the rendered output could.
theme.Version++
theme.UpdatedAt = s.now().UTC()
theme.UpdatedBy = fields.Editor
now := theme.UpdatedAt.Format(domain.TimeFormat)
if _, err := tx.ExecContext(ctx, `
UPDATE themes SET
slug = ?, name = ?, description = ?, status = ?, default_appearance = ?,
tokens_json = ?, meta_json = ?, version = ?, updated_at = ?, updated_by = ?
WHERE id = ?`,
theme.Slug, theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance),
tokensJSON, metaJSON, theme.Version, now, fields.Editor, theme.ID,
); err != nil {
return nil, fmt.Errorf("update theme: %w", translateError(err))
}
if err := insertVersion(ctx, tx, theme, tokensJSON, defaultMessage(fields.Message, theme, "updated"), fields.Editor, now); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update theme: %w", err)
}
return theme, nil
}
// Delete removes a theme and its history. Built-in themes cannot be deleted.
func (s *Store) Delete(ctx context.Context, slug string) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM themes WHERE slug = ? AND is_system = 0`, slug)
if err != nil {
return fmt.Errorf("delete theme: %w", translateError(err))
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("delete theme: %w", err)
}
if affected == 0 {
// Distinguish "does not exist" from "is built in" so the API can return
// the right status code.
var isSystem int
err := s.db.QueryRowContext(ctx, `SELECT is_system FROM themes WHERE slug = ?`, slug).Scan(&isSystem)
switch {
case errors.Is(err, sql.ErrNoRows):
return fmt.Errorf("%w: theme %s", domain.ErrNotFound, slug)
case err != nil:
return fmt.Errorf("delete theme: %w", err)
case intToBool(isSystem):
return fmt.Errorf("%w: theme %s is built in; duplicate it instead", domain.ErrImmutable, slug)
default:
return fmt.Errorf("%w: theme %s", domain.ErrNotFound, slug)
}
}
return nil
}
// Duplicate copies a theme under a new slug.
func (s *Store) Duplicate(ctx context.Context, slug, newSlug, editor string) (*domain.Theme, error) {
source, err := s.Get(ctx, slug)
if err != nil {
return nil, err
}
if err := domain.ValidateSlug(newSlug); err != nil {
return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
}
if _, reserved := domain.ReservedSlugs[newSlug]; reserved {
return nil, fmt.Errorf("%w: slug %q is reserved by the runtime API", domain.ErrInvalid, newSlug)
}
copied := source.Clone()
copied.ID = ""
copied.Slug = newSlug
copied.Name = source.Name + " (copy)"
copied.Status = domain.StatusDraft
// A duplicate starts at version 1: its history is its own, and copying the
// version number would collide with nothing but would confuse the rollback
// UI by implying revisions that never existed for this slug.
copied.Version = 1
copied.System = false
copied.CreatedBy = editor
copied.UpdatedBy = editor
// The copy is no longer a built-in even though it starts from one.
if copied.Meta != nil {
delete(copied.Meta, "builtIn")
delete(copied.Meta, "seedVersion")
}
if err := s.Create(ctx, copied, "duplicated from "+slug); err != nil {
return nil, err
}
return s.Get(ctx, newSlug)
}
// ---------------------------------------------------------------- history --
// Versions returns a theme's history, newest first.
func (s *Store) Versions(ctx context.Context, slug string, limit int) ([]domain.ThemeVersion, error) {
if _, err := s.Get(ctx, slug); err != nil {
return nil, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.db.QueryContext(ctx, `
SELECT theme_id, version, slug, name, description, status,
default_appearance, tokens_json, message, author, created_at
FROM theme_versions WHERE theme_id = (SELECT id FROM themes WHERE slug = ?)
ORDER BY version DESC LIMIT ?`, slug, limit)
if err != nil {
return nil, fmt.Errorf("list versions: %w", translateError(err))
}
defer rows.Close()
out := make([]domain.ThemeVersion, 0, limit)
for rows.Next() {
var (
v domain.ThemeVersion
status string
appearance string
tokensJSON string
createdAt string
)
if err := rows.Scan(&v.ThemeID, &v.Version, &v.Slug, &v.Name, &v.Description, &status,
&appearance, &tokensJSON, &v.Message, &v.Author, &createdAt); err != nil {
return nil, fmt.Errorf("scan version: %w", err)
}
v.Status = domain.ThemeStatus(status)
v.DefaultAppearance = domain.Appearance(appearance)
if err := json.Unmarshal([]byte(tokensJSON), &v.Tokens); err != nil {
return nil, fmt.Errorf("decode version tokens: %w", err)
}
if v.CreatedAt, err = parseTime(createdAt); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// Version loads one historical snapshot.
func (s *Store) Version(ctx context.Context, slug string, version int64) (*domain.ThemeVersion, error) {
var (
v domain.ThemeVersion
status string
appearance string
tokensJSON string
createdAt string
)
err := s.db.QueryRowContext(ctx, `
SELECT theme_id, version, slug, name, description, status,
default_appearance, tokens_json, message, author, created_at
FROM theme_versions
WHERE theme_id = (SELECT id FROM themes WHERE slug = ?) AND version = ?`,
slug, version,
).Scan(&v.ThemeID, &v.Version, &v.Slug, &v.Name, &v.Description, &status,
&appearance, &tokensJSON, &v.Message, &v.Author, &createdAt)
if err != nil {
return nil, wrapNotFound(err, fmt.Sprintf("version %d of theme %s", version, slug))
}
v.Status = domain.ThemeStatus(status)
v.DefaultAppearance = domain.Appearance(appearance)
if err := json.Unmarshal([]byte(tokensJSON), &v.Tokens); err != nil {
return nil, fmt.Errorf("decode version tokens: %w", err)
}
if v.CreatedAt, err = parseTime(createdAt); err != nil {
return nil, err
}
return &v, nil
}
// Rollback restores a historical snapshot as a new version, which keeps history
// append-only. Rewriting history would break the guarantee that a pinned
// consumer can always resolve the version it was built against.
func (s *Store) Rollback(ctx context.Context, slug string, version int64, editor string) (*domain.Theme, error) {
snapshot, err := s.Version(ctx, slug, version)
if err != nil {
return nil, err
}
current, err := s.Get(ctx, slug)
if err != nil {
return nil, err
}
fields := UpdateFields{
Name: &snapshot.Name,
Description: &snapshot.Description,
Status: &snapshot.Status,
DefaultAppearance: &snapshot.DefaultAppearance,
Tokens: &snapshot.Tokens,
Message: fmt.Sprintf("rolled back to version %d", version),
Editor: editor,
}
if current.System {
// Rolling a built-in theme back must not clear its built-in flag, but
// the store's update path already leaves it untouched.
fields.Editor = editor
}
return s.Update(ctx, slug, fields)
}
// Diff compares two versions of a theme. Only the token tree is compared;
// metadata changes are reported as a boolean rather than field by field.
type Diff struct {
FromVersion int64 `json:"fromVersion"`
ToVersion int64 `json:"toVersion"`
Added []string `json:"added"`
Removed []string `json:"removed"`
Changed []TokenDiff `json:"changed"`
MetaChanged bool `json:"metaChanged"`
}
// TokenDiff is one changed token.
type TokenDiff struct {
Path string `json:"path"`
From string `json:"from"`
To string `json:"to"`
}
// DiffVersions compares two snapshots of the same theme.
func (s *Store) DiffVersions(ctx context.Context, slug string, from, to int64) (*Diff, error) {
fromVersion, err := s.Version(ctx, slug, from)
if err != nil {
return nil, err
}
toVersion, err := s.Version(ctx, slug, to)
if err != nil {
return nil, err
}
// The three lists are allocated empty rather than left nil. A nil slice marshals
// to JSON null, and the contract declares these as arrays -- so comparing two
// revisions that differ in nothing, which is an ordinary outcome rather than an
// edge case, would hand every client a null where it expects a list. The same
// reason ListUsers normalises an absent roles column.
result := &Diff{
FromVersion: from,
ToVersion: to,
Added: []string{},
Removed: []string{},
Changed: []TokenDiff{},
}
fromTokens := flattenForDiff(fromVersion.Tokens)
toTokens := flattenForDiff(toVersion.Tokens)
for path, fromValue := range fromTokens {
toValue, ok := toTokens[path]
if !ok {
result.Removed = append(result.Removed, path)
continue
}
if fromValue != toValue {
result.Changed = append(result.Changed, TokenDiff{Path: path, From: fromValue, To: toValue})
}
}
for path := range toTokens {
if _, ok := fromTokens[path]; !ok {
result.Added = append(result.Added, path)
}
}
sortStrings(result.Added)
sortStrings(result.Removed)
sortSlice(result.Changed, func(a, b TokenDiff) bool { return a.Path < b.Path })
return result, nil
}
// flattenForDiff reduces a token document to a path to rendered value map.
//
// The walk is deliberately local to the store rather than delegating to the
// token engine: a diff must compare exactly what was stored, including tokens
// the current engine schema no longer recognises, so that rolling back across a
// schema change still shows a faithful comparison.
func flattenForDiff(doc domain.TokenDoc) map[string]string {
flat := make(map[string]string, 128)
walkForDiff(doc, "", flat)
return flat
}
// walkForDiff recurses through a token group.
func walkForDiff(node map[string]any, prefix string, out map[string]string) {
for key, raw := range node {
if strings.HasPrefix(key, "$") {
continue
}
child, ok := raw.(map[string]any)
if !ok {
continue
}
path := key
if prefix != "" {
path = prefix + "." + key
}
if value, hasValue := child["$value"]; hasValue {
out[path] = stringifyTokenValue(value)
continue
}
walkForDiff(child, path, out)
}
}
// stringifyTokenValue renders a token value for comparison.
func stringifyTokenValue(value any) string {
switch v := value.(type) {
case string:
return v
case float64:
// JSON numbers decode as float64; format them without an exponent so
// that 1 and 1.0 do not show up as a spurious change.
return strconv.FormatFloat(v, 'f', -1, 64)
case bool:
return strconv.FormatBool(v)
case []any:
parts := make([]string, len(v))
for i, p := range v {
parts[i] = stringifyTokenValue(p)
}
return strings.Join(parts, ", ")
default:
return fmt.Sprint(v)
}
}
// ---------------------------------------------------------------- helpers --
// scanThemeRow reads one theme row from a scanner.
func scanThemeRow(scanner interface{ Scan(...any) error }) (*themeRow, error) {
var (
row themeRow
status string
appearance string
createdAt string
updatedAt string
)
err := scanner.Scan(
&row.ID, &row.Slug, &row.Name, &row.Description, &status, &appearance,
&row.tokensJSON, &row.metaJSON, &row.isSystem, &row.Version,
&createdAt, &updatedAt, &row.CreatedBy, &row.UpdatedBy,
)
if err != nil {
return nil, err
}
row.Status = domain.ThemeStatus(status)
row.DefaultAppearance = domain.Appearance(appearance)
row.System = intToBool(row.isSystem)
row.CreatedAt, err = parseTime(createdAt)
if err != nil {
return nil, err
}
row.UpdatedAt, err = parseTime(updatedAt)
if err != nil {
return nil, err
}
return &row, nil
}
// theme materialises a scanned row into a domain theme.
func (r *themeRow) theme() (*domain.Theme, error) {
tokens := domain.TokenDoc{}
if r.tokensJSON != "" {
if err := json.Unmarshal([]byte(r.tokensJSON), &tokens); err != nil {
return nil, fmt.Errorf("decode tokens for theme %s: %w", r.Slug, err)
}
}
meta := domain.ThemeMeta{}
if r.metaJSON != "" {
if err := json.Unmarshal([]byte(r.metaJSON), &meta); err != nil {
return nil, fmt.Errorf("decode meta for theme %s: %w", r.Slug, err)
}
}
out := r.Theme
out.Tokens = tokens
out.Meta = meta
return &out, nil
}
// parseTime converts a stored timestamp back into a time.Time.
func parseTime(raw string) (time.Time, error) {
if raw == "" {
return time.Time{}, nil
}
parsed, err := time.Parse(domain.TimeFormat, raw)
if err != nil {
return time.Time{}, fmt.Errorf("parse timestamp %q: %w", raw, err)
}
return parsed, nil
}
// escapeLike neutralises LIKE wildcards in user input.
func escapeLike(s string) string {
replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return replacer.Replace(s)
}
// defaultMessage returns the supplied message or a generated one.
func defaultMessage(message string, theme *domain.Theme, verb string) string {
if trimmed := strings.TrimSpace(message); trimmed != "" {
return trimmed
}
return verb + " " + theme.Name
}
// Now is exposed for the audit log, which shares the store's clock.
func (s *Store) timestamp() string { return s.now().UTC().Format(domain.TimeFormat) }

View file

@ -0,0 +1,152 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/urpi/go-bananas/internal/domain"
)
// Preferences returns what the subject has chosen for their own interface.
//
// A subject with no row, which is everybody until they save a preference once,
// reads as empty rather than as an error: the zero value is the documented "no
// choice made" state and every caller renders a default for it.
//
// A stored theme that has since been deleted or archived is reported as no
// choice, for the same reason. Resolving the name against the themes table on
// read keeps a stale preference from pinning the UI to something that no longer
// exists, and it means an unpublish is reflected without a migration.
func (s *Store) Preferences(ctx context.Context, subject string) (domain.UserPreferences, error) {
if subject == "" {
return domain.UserPreferences{}, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid)
}
var stored domain.UserPreferences
err := s.db.QueryRowContext(ctx, `
SELECT selected_theme, selected_appearance
FROM user_preferences
WHERE subject = ?`, subject).
Scan(&stored.SelectedTheme, &stored.SelectedAppearance)
if errors.Is(err, sql.ErrNoRows) {
return domain.UserPreferences{}, nil
}
if err != nil {
return domain.UserPreferences{}, translateError(err)
}
if stored.SelectedTheme != "" {
usable, err := s.themeUsable(ctx, stored.SelectedTheme)
if err != nil {
return domain.UserPreferences{}, err
}
if !usable {
return domain.UserPreferences{}, nil
}
}
if stored.SelectedAppearance != "" && !stored.SelectedAppearance.Valid() {
// A build that no longer knows an appearance must not hand it to a
// client, which would set an attribute nothing styles.
return domain.UserPreferences{}, nil
}
return stored, nil
}
// SetPreferences records the subject's choice and returns what was stored.
//
// The theme must already exist and be published or archived. Drafts are rejected
// because they are invisible to runtime consumers, so a preference pointing at one
// would leave the UI rendering a theme it cannot load.
func (s *Store) SetPreferences(ctx context.Context, subject string, prefs domain.UserPreferences) (domain.UserPreferences, error) {
if subject == "" {
return domain.UserPreferences{}, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid)
}
if err := prefs.Validate(); err != nil {
return domain.UserPreferences{}, err
}
if prefs.SelectedTheme != "" {
usable, err := s.themeUsable(ctx, prefs.SelectedTheme)
if err != nil {
return domain.UserPreferences{}, err
}
if !usable {
return domain.UserPreferences{}, fmt.Errorf(
"%w: selectedTheme %q is not a published theme", domain.ErrInvalid, prefs.SelectedTheme)
}
} else {
// An appearance without a theme has nothing to select a scheme from, so it
// is dropped rather than stored. Clearing a choice by sending an empty
// theme therefore works even if the client leaves the appearance set.
prefs.SelectedAppearance = ""
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO user_preferences (subject, selected_theme, selected_appearance, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(subject) DO UPDATE SET
selected_theme = excluded.selected_theme,
selected_appearance = excluded.selected_appearance,
updated_at = excluded.updated_at`,
subject, prefs.SelectedTheme, string(prefs.SelectedAppearance), s.timestamp())
if err != nil {
return domain.UserPreferences{}, translateError(err)
}
return prefs, nil
}
// ClearPreferences forgets the subject's choice, which is how the UI returns to
// its own default. It reports whether a row was removed.
func (s *Store) ClearPreferences(ctx context.Context, subject string) (bool, error) {
if subject == "" {
return false, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid)
}
result, err := s.db.ExecContext(ctx, `DELETE FROM user_preferences WHERE subject = ?`, subject)
if err != nil {
return false, translateError(err)
}
affected, err := result.RowsAffected()
if err != nil {
// Drivers that cannot report this are not a reason to fail the request:
// the row is gone either way, which is all the caller asked for.
return true, nil //nolint:nilerr // see comment above
}
return affected > 0, nil
}
// ThemePreferenceCount reports how many subjects have chosen a theme, which the
// admin UI shows so an editor can see who a change would affect before making it.
func (s *Store) ThemePreferenceCount(ctx context.Context, slug string) (int, error) {
if err := domain.ValidateSlug(slug); err != nil {
return 0, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
}
var count int
err := s.db.QueryRowContext(ctx, `
SELECT count(*) FROM user_preferences WHERE selected_theme = ?`, slug).Scan(&count)
if err != nil {
return 0, translateError(err)
}
return count, nil
}
// themeUsable reports whether a slug names a theme a runtime consumer may load.
// Archived themes qualify because the last published revision of an archived theme
// stays resolvable for consumers pinned to it.
func (s *Store) themeUsable(ctx context.Context, slug string) (bool, error) {
var status string
err := s.db.QueryRowContext(ctx, `SELECT status FROM themes WHERE slug = ?`, slug).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, translateError(err)
}
switch domain.ThemeStatus(status) {
case domain.StatusPublished, domain.StatusArchived:
return true, nil
default:
return false, nil
}
}

View file

@ -0,0 +1,262 @@
package store
import (
"context"
"errors"
"testing"
"github.com/urpi/go-bananas/internal/domain"
)
func TestPreferencesDefaultToNoChoice(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
// A subject nobody has saved anything for reads as empty rather than failing,
// because empty is the documented "no choice" state.
prefs, err := s.Preferences(ctx, "nobody")
if err != nil {
t.Fatalf("read preferences: %v", err)
}
if prefs.SelectedTheme != "" || prefs.SelectedAppearance != "" {
t.Fatalf("preferences = %+v, want the zero value", prefs)
}
}
func TestPreferencesRoundTrip(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
published := publishTheme(t, s, "midnight")
stored, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
SelectedTheme: published.Slug,
SelectedAppearance: domain.AppearanceDark,
})
if err != nil {
t.Fatalf("set preferences: %v", err)
}
if stored.SelectedTheme != "midnight" || stored.SelectedAppearance != domain.AppearanceDark {
t.Fatalf("stored = %+v", stored)
}
read, err := s.Preferences(ctx, "user-1")
if err != nil {
t.Fatalf("read preferences: %v", err)
}
if read != stored {
t.Fatalf("read = %+v, want %+v", read, stored)
}
}
func TestPreferencesCanBeClearedAndOverwritten(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
publishTheme(t, s, "midnight")
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
SelectedTheme: "midnight",
SelectedAppearance: domain.AppearanceLight,
}); err != nil {
t.Fatalf("set preferences: %v", err)
}
// Writing again replaces rather than accumulating.
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
SelectedTheme: "midnight",
SelectedAppearance: domain.AppearanceDark,
}); err != nil {
t.Fatalf("overwrite preferences: %v", err)
}
read, err := s.Preferences(ctx, "user-1")
if err != nil {
t.Fatalf("read preferences: %v", err)
}
if read.SelectedAppearance != domain.AppearanceDark {
t.Fatalf("appearance = %q, want dark", read.SelectedAppearance)
}
// An empty theme is the absence of a choice and is storable.
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{}); err != nil {
t.Fatalf("clear by writing empty: %v", err)
}
read, err = s.Preferences(ctx, "user-1")
if err != nil {
t.Fatalf("read preferences: %v", err)
}
if read.SelectedTheme != "" {
t.Fatalf("theme = %q, want empty", read.SelectedTheme)
}
removed, err := s.ClearPreferences(ctx, "user-1")
if err != nil {
t.Fatalf("clear preferences: %v", err)
}
if !removed {
t.Error("clear reported no row removed")
}
}
// TestPreferencesRejectUnknownAndDraftThemes covers the two ways a name can fail:
// it does not exist, or it exists but runtime consumers cannot load it.
func TestPreferencesRejectUnknownAndDraftThemes(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
if err := s.Create(ctx, sampleTheme("draft-one"), ""); err != nil {
t.Fatalf("create theme: %v", err)
}
for _, slug := range []string{"no-such-theme", "draft-one"} {
_, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
SelectedTheme: slug,
SelectedAppearance: domain.AppearanceLight,
})
if !errors.Is(err, domain.ErrInvalid) {
t.Errorf("SetPreferences(%q) = %v, want ErrInvalid", slug, err)
}
}
}
// TestPreferencesFallBackWhenTheThemeDisappears is the reason Preferences resolves
// the name on read: a theme that is later deleted must not leave somebody pinned to
// a selection the UI cannot load.
func TestPreferencesFallBackWhenTheThemeDisappears(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
theme := publishTheme(t, s, "midnight")
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
SelectedTheme: theme.Slug,
SelectedAppearance: domain.AppearanceDark,
}); err != nil {
t.Fatalf("set preferences: %v", err)
}
if err := s.Delete(ctx, theme.Slug); err != nil {
t.Fatalf("delete theme: %v", err)
}
read, err := s.Preferences(ctx, "user-1")
if err != nil {
t.Fatalf("read preferences: %v", err)
}
if read.SelectedTheme != "" || read.SelectedAppearance != "" {
t.Fatalf("preferences = %+v, want the zero value once the theme is gone", read)
}
}
func TestPreferencesRejectMalformedInput(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
publishTheme(t, s, "midnight")
rejected := map[string]domain.UserPreferences{
"uppercase slug": {SelectedTheme: "Midnight"},
"slug with a space": {SelectedTheme: "mid night"},
"leading dash": {SelectedTheme: "-midnight"},
"unknown appearance": {
SelectedTheme: "midnight",
SelectedAppearance: "sepia",
},
}
for name, prefs := range rejected {
t.Run(name, func(t *testing.T) {
if _, err := s.SetPreferences(ctx, "user-1", prefs); !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("SetPreferences(%+v) = %v, want ErrInvalid", prefs, err)
}
})
}
// Clearing with an empty theme is valid, and an appearance sent alongside it
// is dropped rather than stored as a scheme with nothing to apply it to.
stored, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
SelectedTheme: "",
SelectedAppearance: domain.AppearanceDark,
})
if err != nil {
t.Fatalf("clearing with a stray appearance: %v", err)
}
if stored.SelectedAppearance != "" {
t.Fatalf("stored = %+v, want the appearance dropped", stored)
}
if _, err := s.Preferences(ctx, ""); !errors.Is(err, domain.ErrInvalid) {
t.Errorf("Preferences(\"\") = %v, want ErrInvalid", err)
}
if _, err := s.SetPreferences(ctx, "", domain.UserPreferences{}); !errors.Is(err, domain.ErrInvalid) {
t.Errorf("SetPreferences with empty subject = %v, want ErrInvalid", err)
}
}
// TestPreferencesSurviveLogin matters because UpsertUser rewrites the users row on
// every login, which is exactly why preferences are not columns on it.
func TestPreferencesSurviveLogin(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
publishTheme(t, s, "midnight")
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
SelectedTheme: "midnight",
SelectedAppearance: domain.AppearanceDark,
}); err != nil {
t.Fatalf("set preferences: %v", err)
}
for range 2 {
if err := s.UpsertUser(ctx, &domain.Identity{
Subject: "user-1", Email: "u@example.test", Roles: []domain.Role{domain.RoleViewer},
}); err != nil {
t.Fatalf("upsert user: %v", err)
}
}
read, err := s.Preferences(ctx, "user-1")
if err != nil {
t.Fatalf("read preferences: %v", err)
}
if read.SelectedTheme != "midnight" || read.SelectedAppearance != domain.AppearanceDark {
t.Fatalf("preferences = %+v, want them preserved across logins", read)
}
}
func TestThemePreferenceCount(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
publishTheme(t, s, "midnight")
for _, subject := range []string{"a", "b"} {
if _, err := s.SetPreferences(ctx, subject, domain.UserPreferences{
SelectedTheme: "midnight",
SelectedAppearance: domain.AppearanceLight,
}); err != nil {
t.Fatalf("set preferences for %s: %v", subject, err)
}
}
count, err := s.ThemePreferenceCount(ctx, "midnight")
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 2 {
t.Fatalf("count = %d, want 2", count)
}
if _, err := s.ThemePreferenceCount(ctx, "Not A Slug"); !errors.Is(err, domain.ErrInvalid) {
t.Errorf("ThemePreferenceCount with a bad slug = %v, want ErrInvalid", err)
}
}
// publishTheme creates a theme and publishes it, returning the stored value.
//
// StatusPublished is a constant rather than a variable, so the pointer
// UpdateFields wants has to be taken from a local.
func publishTheme(t *testing.T, s *Store, slug string) *domain.Theme {
t.Helper()
ctx := context.Background()
if err := s.Create(ctx, sampleTheme(slug), ""); err != nil {
t.Fatalf("create theme %s: %v", slug, err)
}
published := domain.StatusPublished
theme, err := s.Update(ctx, slug, UpdateFields{Status: &published})
if err != nil {
t.Fatalf("publish theme %s: %v", slug, err)
}
return theme
}

View file

@ -0,0 +1,426 @@
package tokenengine
import (
"fmt"
"math"
"strconv"
"strings"
)
// RGB is an sRGB colour with an alpha channel in the range 0..1.
type RGB struct {
R, G, B float64
A float64
}
// Color is a parsed CSS colour. Only the subset that can be reasoned about
// numerically is modelled; any other valid CSS colour syntax is kept verbatim
// in Raw so that it can still be emitted, but comparisons and contrast
// computations fall back to Neutral when it is not parseable.
type Color struct {
RGB
// Raw is the original author-written token, preserved so that rendering
// never rewrites what a human typed.
Raw string
// Numeric reports whether RGB is meaningful.
Numeric bool
}
// Neutral is the fallback colour used when a token cannot be parsed. It is a
// mid grey: it is intentionally low-contrast against both light and dark
// backgrounds so that a broken token is visible rather than silently plausible.
var Neutral = Color{RGB: RGB{R: 0.5, G: 0.5, B: 0.5, A: 1}, Raw: "#80808080", Numeric: false}
// NeutralHex is the plain-text form of Neutral.
const NeutralHex = "#808080"
const (
// alphaEpsilon is the rounding tolerance used when snapping alpha to 0 or 1
// so that #ffffff00 and #ffffff render identically.
alphaEpsilon = 1e-6
// channelEpsilon guards against float drift when comparing components.
channelEpsilon = 1e-9
)
// ParseColor parses the CSS colour syntaxes a theme author is realistically
// going to use: #hex in 3/4/6/8 digit forms, rgb()/rgba(), hsl()/hsla() and the
// functional-notation whitespace/comma variants. Anything else is returned as a
// non-numeric colour that still round-trips.
func ParseColor(input string) (Color, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return Color{}, fmt.Errorf("empty colour")
}
lower := strings.ToLower(raw)
// Modern CSS colour spaces are valid CSS but cannot be interpreted with
// sRGB math. Pass them through untouched.
for _, fn := range []string{"oklch(", "oklab(", "lab(", "lch(", "color(", "color-mix(", "light-dark("} {
if strings.HasPrefix(lower, fn) {
return Color{Raw: raw}, nil
}
}
// CSS-wide keywords resolve to unpredictable values depending on the host
// page; treating them as opaque keeps rendering faithful.
if lower == "inherit" || lower == "initial" || lower == "unset" || lower == "currentcolor" || lower == "transparent" {
return Color{Raw: raw}, nil
}
if strings.HasPrefix(lower, "#") {
return parseHex(raw, lower[1:])
}
open := strings.IndexByte(lower, '(')
if open < 0 || !strings.HasSuffix(lower, ")") {
return Color{Raw: raw}, nil
}
name := strings.TrimSpace(lower[:open])
body := lower[open+1 : len(lower)-1]
args := splitArgs(body)
switch name {
case "rgb", "rgba":
return parseRGBFunc(raw, args)
case "hsl", "hsla":
return parseHSLFunc(raw, args)
default:
return Color{Raw: raw}, nil
}
}
// parseHex decodes #rgb, #rgba, #rrggbb and #rrggbbaa.
func parseHex(raw, digits string) (Color, error) {
expand := func(s string) (float64, error) {
n, err := strconv.ParseUint(s, 16, 8)
if err != nil {
return 0, err
}
return float64(n) / 255, nil
}
switch len(digits) {
case 3, 4:
var comps [4]float64
for i := 0; i < len(digits); i++ {
v, err := expand(string(digits[i]) + string(digits[i]))
if err != nil {
return Color{}, fmt.Errorf("invalid hex colour %q", raw)
}
comps[i] = v
}
if len(digits) == 3 {
comps[3] = 1
}
return numericColor(comps[0], comps[1], comps[2], comps[3], raw), nil
case 6, 8:
var comps [4]float64
for i := 0; i < len(digits)/2; i++ {
v, err := expand(digits[i*2 : i*2+2])
if err != nil {
return Color{}, fmt.Errorf("invalid hex colour %q", raw)
}
comps[i] = v
}
if len(digits) == 6 {
comps[3] = 1
}
return numericColor(comps[0], comps[1], comps[2], comps[3], raw), nil
default:
return Color{}, fmt.Errorf("invalid hex colour %q: expected 3, 4, 6 or 8 digits, got %d", raw, len(digits))
}
}
// splitArgs splits a CSS function argument list on commas or whitespace,
// ignoring separators nested inside parentheses.
func splitArgs(body string) []string {
var (
out []string
depth int
cur strings.Builder
)
for _, r := range body {
switch {
case r == '(':
depth++
cur.WriteRune(r)
case r == ')':
depth--
cur.WriteRune(r)
case (r == ',' || r == '/' || r == ' ' || r == '\t') && depth == 0:
if s := strings.TrimSpace(cur.String()); s != "" {
out = append(out, s)
}
cur.Reset()
default:
cur.WriteRune(r)
}
}
if s := strings.TrimSpace(cur.String()); s != "" {
out = append(out, s)
}
return out
}
// parseRGBFunc handles rgb(255 0 0 / 50%) and rgba(255, 0, 0, 0.5).
func parseRGBFunc(raw string, args []string) (Color, error) {
if len(args) < 3 || len(args) > 4 {
return Color{}, fmt.Errorf("invalid rgb() colour %q: expected 3 or 4 components", raw)
}
var comps [4]float64
for i := 0; i < 3; i++ {
v, err := parseChannel(args[i])
if err != nil {
return Color{}, fmt.Errorf("invalid rgb() colour %q: %w", raw, err)
}
comps[i] = v
}
comps[3] = 1
if len(args) == 4 {
v, err := parseAlpha(args[3])
if err != nil {
return Color{}, fmt.Errorf("invalid rgb() colour %q: %w", raw, err)
}
comps[3] = v
}
return numericColor(comps[0], comps[1], comps[2], comps[3], raw), nil
}
// parseHSLFunc handles both comma and space separated hsl notation.
func parseHSLFunc(raw string, args []string) (Color, error) {
if len(args) < 3 || len(args) > 4 {
return Color{}, fmt.Errorf("invalid hsl() colour %q: expected 3 or 4 components", raw)
}
h, err := parseChannel(args[0])
if err != nil {
return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err)
}
s, err := parseChannel(args[1])
if err != nil {
return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err)
}
l, err := parseChannel(args[2])
if err != nil {
return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err)
}
r, g, b := hslToRGB(h, s, l)
a := 1.0
if len(args) == 4 {
v, err := parseAlpha(args[3])
if err != nil {
return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err)
}
a = v
}
return numericColor(r, g, b, a, raw), nil
}
// parseChannel accepts a bare number or a percentage and normalises to 0..1.
func parseChannel(s string) (float64, error) {
s = strings.TrimSpace(s)
if strings.HasSuffix(s, "%") {
v, err := strconv.ParseFloat(strings.TrimSuffix(s, "%"), 64)
if err != nil {
return 0, err
}
return clamp01(v / 100), nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, err
}
return clamp01(v / 255), nil
}
// parseAlpha accepts a bare 0..1 number or a percentage.
func parseAlpha(s string) (float64, error) {
s = strings.TrimSpace(s)
if strings.HasSuffix(s, "%") {
v, err := strconv.ParseFloat(strings.TrimSuffix(s, "%"), 64)
if err != nil {
return 0, err
}
return clamp01(v / 100), nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, err
}
return clamp01(v), nil
}
// numericColor clamps and normalises a parsed colour, snapping alpha that is
// effectively 0 or 1 so that round-tripping is stable.
func numericColor(r, g, b, a float64, raw string) Color {
a = clamp01(a)
if a < alphaEpsilon {
a = 0
}
if a > 1-alphaEpsilon {
a = 1
}
return Color{RGB: RGB{R: clamp01(r), G: clamp01(g), B: clamp01(b), A: a}, Raw: raw, Numeric: true}
}
func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) }
// hslToRGB converts normalised hue (0..1), saturation and lightness to sRGB.
func hslToRGB(h, s, l float64) (float64, float64, float64) {
if s <= channelEpsilon {
return l, l, l
}
var q float64
if l < 0.5 {
q = l * (1 + s)
} else {
q = l + s - l*s
}
p := 2*l - q
return hueToChannel(p, q, h+1.0/3.0), hueToChannel(p, q, h), hueToChannel(p, q, h-1.0/3.0)
}
// hueToChannel is the standard hue-to-channel helper from the CSS Color spec.
func hueToChannel(p, q, t float64) float64 {
if t < 0 {
t++
}
if t > 1 {
t--
}
switch {
case t < 1.0/6.0:
return p + (q-p)*6*t
case t < 1.0/2.0:
return q
case t < 2.0/3.0:
return p + (q-p)*(2.0/3.0-t)*6
default:
return p
}
}
// Hex renders the colour as #rrggbb, or #rrggbbaa when translucent.
func (c Color) Hex() string {
if !c.Numeric {
return c.Raw
}
r := int(math.Round(c.R * 255))
g := int(math.Round(c.G * 255))
b := int(math.Round(c.B * 255))
if c.A >= 1-alphaEpsilon {
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
return fmt.Sprintf("#%02x%02x%02x%02x", r, g, b, int(math.Round(c.A*255)))
}
// WithAlpha returns the colour with a replaced alpha channel.
func (c Color) WithAlpha(a float64) Color {
if !c.Numeric {
return Color{Raw: c.Raw}
}
return numericColor(c.R, c.G, c.B, a, c.Raw)
}
// Mix blends this colour toward other by t in 0..1, carrying alpha through the
// CSS compositing rules so that translucent tokens do not darken incorrectly.
func (c Color) Mix(other Color, t float64) Color {
if !c.Numeric || !other.Numeric {
return Neutral
}
t = clamp01(t)
// Straight (non-premultiplied) alpha compositing.
alpha := c.A*(1-t) + other.A*t
if alpha < alphaEpsilon {
return numericColor(0, 0, 0, 0, "")
}
r := (c.R*c.A*(1-t) + other.R*other.A*t) / alpha
g := (c.G*c.A*(1-t) + other.G*other.A*t) / alpha
b := (c.B*c.A*(1-t) + other.B*other.A*t) / alpha
return numericColor(r, g, b, alpha, "")
}
// Lighten mixes toward white by t.
func (c Color) Lighten(t float64) Color {
return c.Mix(Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}, t)
}
// Darken mixes toward black by t.
func (c Color) Darken(t float64) Color { return c.Mix(Color{RGB: RGB{A: 1}, Numeric: true}, t) }
// LightenDark adapts a lightening or darkening step for a dark colour scheme,
// where "more prominent" means lighter rather than darker.
func (c Color) LightenDark(t float64, dark bool) Color {
if dark {
return c.Lighten(t)
}
return c.Darken(t)
}
// RelativeLuminance implements WCAG 2.1 relative luminance.
func (c Color) RelativeLuminance() float64 {
if !c.Numeric {
return 0.5
}
return 0.2126*linearize(c.R) + 0.7152*linearize(c.G) + 0.0722*linearize(c.B)
}
func linearize(v float64) float64 {
if v <= 0.04045 {
return v / 12.92
}
return math.Pow((v+0.055)/1.055, 2.4)
}
// ContrastRatio returns the WCAG 2.1 contrast ratio between the two colours,
// always >= 1.
func ContrastRatio(a, b Color) float64 {
la, lb := a.RelativeLuminance(), b.RelativeLuminance()
if la < lb {
la, lb = lb, la
}
return (la + 0.05) / (lb + 0.05)
}
// WCAG contrast thresholds for normal-weight text.
const (
// ContrastAA is the WCAG 2.1 AA minimum for body text (4.5:1).
ContrastAA = 4.5
// ContrastAAA is the WCAG 2.1 AAA minimum for body text (7:1).
ContrastAAA = 7.0
// ContrastAALarge is the AA minimum for large text and UI components (3:1).
ContrastAALarge = 3.0
)
// BestForeground picks whichever of the light or dark anchor contrasts more
// strongly with bg. This is how the engine derives readable "on-colour"
// tokens without asking the author to maintain a second palette.
func BestForeground(bg Color) Color {
light := Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}
dark := Color{RGB: RGB{A: 1}, Numeric: true}
if ContrastRatio(bg, light) >= ContrastRatio(bg, dark) {
return light
}
return dark
}
// IsDark reports whether a colour is perceived as dark, using the WCAG
// luminance threshold that matches ContrastRatio's midpoint.
func IsDark(c Color) bool { return c.RelativeLuminance() < 0.4 }
// FormatNumber renders a float for CSS output: no trailing zeros, no
// exponent for the small magnitudes we emit, and a hard cap on precision so
// that generated CSS is byte-stable across platforms.
func FormatNumber(v float64) string {
if math.IsNaN(v) || math.IsInf(v, 0) {
return "0"
}
rounded := math.Round(v*10000) / 10000
if rounded == 0 {
return "0"
}
s := strconv.FormatFloat(rounded, 'f', -1, 64)
if strings.Contains(s, ".") {
s = strings.TrimRight(s, "0")
s = strings.TrimSuffix(s, ".")
}
return s
}

View file

@ -0,0 +1,216 @@
package tokenengine
import (
"math"
"testing"
)
func TestParseColorFormats(t *testing.T) {
cases := []struct {
name string
input string
want Color
}{
{"hex6", "#1a2b3c", Color{RGB: RGB{R: 0x1a / 255.0, G: 0x2b / 255.0, B: 0x3c / 255.0, A: 1}, Raw: "#1a2b3c", Numeric: true}},
{"hex3", "#abc", Color{RGB: RGB{R: 0xaa / 255.0, G: 0xbb / 255.0, B: 0xcc / 255.0, A: 1}, Raw: "#abc", Numeric: true}},
{"hex8 alpha", "#00000080", Color{RGB: RGB{R: 0, G: 0, B: 0, A: 128 / 255.0}, Raw: "#00000080", Numeric: true}},
{"rgb legacy comma", "rgb(255, 0, 0)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "rgb(255, 0, 0)", Numeric: true}},
{"rgb modern space", "rgb(255 0 0)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "rgb(255 0 0)", Numeric: true}},
{"rgba alpha", "rgba(0, 0, 0, 0.5)", Color{RGB: RGB{A: 0.5}, Raw: "rgba(0, 0, 0, 0.5)", Numeric: true}},
{"hsl", "hsl(0, 100%, 50%)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "hsl(0, 100%, 50%)", Numeric: true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseColor(tc.input)
if err != nil {
t.Fatalf("ParseColor(%q) error: %v", tc.input, err)
}
if !got.Numeric {
t.Fatalf("ParseColor(%q) returned a non-numeric colour", tc.input)
}
assertClose(t, got.R, tc.want.R, 0.002)
assertClose(t, got.G, tc.want.G, 0.002)
assertClose(t, got.B, tc.want.B, 0.002)
assertClose(t, got.A, tc.want.A, 0.002)
})
}
}
func TestParseColorRejectsGarbage(t *testing.T) {
for _, bad := range []string{"", "#", "#12", "#1234567", "rgb(1,2)", "#gggggg"} {
if _, err := ParseColor(bad); err == nil {
t.Errorf("ParseColor(%q) should have failed", bad)
}
}
}
func TestParseColorPassesThroughModernSyntax(t *testing.T) {
// These are valid CSS the engine cannot reason about numerically, but they
// must round-trip rather than be rejected.
for _, ok := range []string{"oklch(0.7 0.1 200)", "color(display-p3 1 0 0)", "light-dark(#fff, #000)", "currentColor", "transparent"} {
got, err := ParseColor(ok)
if err != nil {
t.Errorf("ParseColor(%q) error: %v", ok, err)
}
if got.Numeric {
t.Errorf("ParseColor(%q) should not be numeric", ok)
}
if got.Raw != ok {
t.Errorf("ParseColor(%q).Raw = %q, want the original", ok, got.Raw)
}
}
}
func TestContrastRatioMatchesWCAGReferenceValues(t *testing.T) {
// Reference values from the WCAG 2.1 relative-luminance definition.
cases := []struct {
a, b string
want float64
}{
{"#ffffff", "#000000", 21},
{"#ffffff", "#ffffff", 1},
{"#000000", "#000000", 1},
{"#777777", "#ffffff", 4.48},
{"#767676", "#ffffff", 4.54}, // the canonical AA boundary grey
}
for _, tc := range cases {
a, err := ParseColor(tc.a)
if err != nil {
t.Fatalf("%v", err)
}
b, err := ParseColor(tc.b)
if err != nil {
t.Fatalf("%v", err)
}
got := ContrastRatio(a, b)
if math.Abs(got-tc.want) > 0.05 {
t.Errorf("ContrastRatio(%s, %s) = %.3f, want %.3f", tc.a, tc.b, got, tc.want)
}
}
}
func TestBestForegroundPicksHigherContrast(t *testing.T) {
light := Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}
dark := Color{RGB: RGB{A: 1}, Numeric: true}
if got := BestForeground(Color{RGB: RGB{A: 1}, Numeric: true}); got.R != light.R {
t.Errorf("foreground for black should be white, got %s", got.Hex())
}
if got := BestForeground(Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}); got.R != dark.R {
t.Errorf("foreground for white should be black, got %s", got.Hex())
}
}
func TestHexRoundTrip(t *testing.T) {
for _, hex := range []string{"#000000", "#ffffff", "#4f46e5", "#4f46e580"} {
c, err := ParseColor(hex)
if err != nil {
t.Fatalf("%v", err)
}
if got := c.Hex(); got != hex {
t.Errorf("round trip of %q produced %q", hex, got)
}
}
}
func TestMixProducesIntermediateColour(t *testing.T) {
black, _ := ParseColor("#000000")
white, _ := ParseColor("#ffffff")
mid := black.Mix(white, 0.5)
if got := mid.Hex(); got != "#808080" && got != "#7f7f7f" {
t.Errorf("50%% black/white mix = %s, want roughly #808080", got)
}
if got := black.Mix(white, 0).Hex(); got != "#000000" {
t.Errorf("0%% mix = %s, want #000000", got)
}
if got := black.Mix(white, 1).Hex(); got != "#ffffff" {
t.Errorf("100%% mix = %s, want #ffffff", got)
}
}
func TestLightenDarkInvertsDirectionForDarkSchemes(t *testing.T) {
base, _ := ParseColor("#4f46e5")
light := base.LightenDark(0.1, false)
dark := base.LightenDark(0.1, true)
if light.RelativeLuminance() >= base.RelativeLuminance() {
t.Error("light scheme emphasis should reduce luminance")
}
if dark.RelativeLuminance() <= base.RelativeLuminance() {
t.Error("dark scheme emphasis should increase luminance")
}
}
func TestGenerateRampIsOrderedAndComplete(t *testing.T) {
ramp := GenerateRamp(DefaultRampOptions())
shades := StandardPaletteShades()
if len(ramp) != len(shades) {
t.Fatalf("ramp has %d steps, want %d", len(ramp), len(shades))
}
previous := math.Inf(1)
for _, shade := range shades {
leaf, ok := ramp[shade].(map[string]any)
if !ok {
t.Fatalf("shade %s is not a token leaf", shade)
}
value, ok := leaf[ValueKey].(string)
if !ok {
t.Fatalf("shade %s has no string value", shade)
}
col, err := ParseColor(value)
if err != nil {
t.Fatalf("shade %s value %q is not a colour: %v", shade, value, err)
}
luminance := col.RelativeLuminance()
if luminance > previous {
t.Errorf("shade %s is lighter than the preceding shade", shade)
}
previous = luminance
}
}
func TestGenerateRampNormalisesOutOfRangeInput(t *testing.T) {
opts := RampOptions{Hue: 400, Saturation: -20, Lightness: 999, HueSpread: 1000}.Normalize()
if opts.Hue < 0 || opts.Hue >= 360 {
t.Errorf("hue = %f, want it wrapped into 0..360", opts.Hue)
}
if opts.Saturation != 0 {
t.Errorf("saturation = %f, want it clamped to 0", opts.Saturation)
}
if opts.Lightness != 80 {
t.Errorf("lightness = %f, want it clamped to 80", opts.Lightness)
}
if opts.HueSpread != 40 {
t.Errorf("hueSpread = %f, want it clamped to 40", opts.HueSpread)
}
}
func TestFormatNumberIsStable(t *testing.T) {
cases := map[float64]string{
0: "0",
1: "1",
1.5: "1.5",
0.1250001: "0.125",
-0.5: "-0.5",
100: "100",
}
for in, want := range cases {
if got := FormatNumber(in); got != want {
t.Errorf("FormatNumber(%v) = %q, want %q", in, got, want)
}
}
}
// assertClose fails the test when want and got differ by more than tolerance.
func assertClose(t *testing.T, got, want, tolerance float64) {
t.Helper()
if math.Abs(got-want) > tolerance {
t.Errorf("got %v, want %v (tolerance %v)", got, want, tolerance)
}
}

View file

@ -0,0 +1,311 @@
package tokenengine
import (
"fmt"
"strings"
"github.com/urpi/go-bananas/internal/domain"
)
// The two-attribute contract.
//
// Every application this service feeds has its own way of naming "which palette" and
// "which appearance", and only two of them have both. Rather than ask each of them to
// rename its call sites, the engine emits one contract and an alias layer under it:
//
// :root[data-theme='tokyo-night'][data-mode='dark'] {
// --bn-bg: #1a1b26;
// --bn-surface: #20222f;
// /* ... the short names the applications already use ... */
// }
//
// /* Compatibility: applications that already use these names keep working. */
// :root[data-theme='tokyo-night'][data-mode='dark'] {
// --bg: var(--bn-bg);
// --surface: var(--bn-surface);
// /* ... */
// }
//
// Two attribute names are accepted, because two generations of applications exist. The
// short pair (`data-theme`, `data-mode`) is what go-loose and go-mad already put on
// <html>; the prefixed pair is what this engine has always emitted. A rule matching
// either is the whole point: an application adopts the contract by adding one link.
const (
// ContractThemeAttribute is the short theme attribute.
ContractThemeAttribute = "data-theme"
// ContractModeAttribute is the short appearance attribute.
ContractModeAttribute = "data-mode"
)
// ContractMode is a storage key and DOM value for the appearance axis. It matches the
// attribute so there is one word for the concept in the CSS, the DOM and localStorage.
type ContractMode = domain.Appearance
// contractRole is one short name standing for a full token path.
type contractRole struct {
// Name is the short custom property, without the prefix.
Name string
// Path is the dotted token path whose value it carries.
Path string
// Fallback is emitted when the theme does not define Path, so the contract is
// never silently incomplete. A missing alias makes an application render with an
// inherited value from the host page, which is worse than an explicit default.
Fallback string
}
// contractRoles is the shared vocabulary. The names are chosen to match the tokens the
// applications already use, so the alias layer below is a rename rather than a
// translation.
//
// The colour roles are spelled out rather than derived, because a derived name
// (`--bn-color-semantic-background` shortened by rule) would be guessable but not
// predictable, and a consumer cannot be asked to remember which abbreviation applies.
var contractRoles = []contractRole{
// Surfaces, from the page background up to the most raised.
{"bg", "color.semantic.background", "#ffffff"},
{"surface", "color.semantic.surface", "#ffffff"},
{"surface-raised", "color.semantic.surface-raised", "#f8fafc"},
{"surface-sunken", "color.semantic.surface-sunken", "#eef2f6"},
{"overlay", "color.semantic.overlay", "rgba(15, 23, 42, 0.5)"},
// Text, in descending emphasis.
{"text", "color.semantic.text", "#0f172a"},
{"muted", "color.semantic.text-muted", "#475569"},
{"subtle", "color.semantic.text-subtle", "#64748b"},
{"inverted", "color.semantic.text-inverted", "#ffffff"},
// Borders.
{"border", "color.semantic.border", "#e2e8f0"},
{"border-strong", "color.semantic.border-strong", "#cbd5e1"},
// The accent. Two names for one role, because "primary" is what a token author
// calls it and "accent" is what a component author looks for.
{"accent", "color.semantic.primary", "#2563eb"},
{"accent-hover", "color.semantic.primary-hover", "#1d4ed8"},
{"accent-active", "color.semantic.primary-active", "#1e40af"},
{"on-accent", "color.semantic.on-primary", "#ffffff"},
{"secondary", "color.semantic.secondary", "#e2e8f0"},
{"on-secondary", "color.semantic.on-secondary", "#0f172a"},
// Status.
{"good", "color.semantic.success", "#16a34a"},
{"warning", "color.semantic.warning", "#d97706"},
{"danger", "color.semantic.danger", "#dc2626"},
{"info", "color.semantic.info", "#0284c7"},
{"focus", "color.semantic.focus-ring", "#2563eb"},
}
// contractFamilies are the token groups that are already short enough to alias
// wholesale. Geometry, spacing and typography have no sensible per-role renaming, so
// the whole group is re-exported under its own prefix rather than picked apart.
var contractFamilies = []string{"radius", "space", "font", "shadow", "border.width", "duration", "ease", "zIndex", "opacity", "breakpoint"}
// legacyAliases are the unprefixed names the applications already use, mapped to the
// contract name that replaces them.
//
// This is the compatibility half of the contract. Emitting it means an application
// adopts the shared styling by adding one <link> and deleting its own :root block, with
// no call site edited. Dropping it means the unprefixed custom properties stop existing
// at all, which is a coordinated change across every application.
var legacyAliases = map[string]string{
"bg": "bg",
"surface": "surface",
"surface2": "surface-raised",
"text": "text",
"muted": "muted",
"line": "border",
"accent": "accent",
"accent2": "secondary",
"good": "good",
"danger": "danger",
}
// contractSelector returns the selector matching a theme and mode under either
// attribute convention.
//
// The two groups are comma-separated rather than a compound selector, so an
// application that uses one convention still gets the values.
//
// Each group appears twice: once rooted at :root, and once bare. The bare form is
// what makes a palette adoptable by any element rather than only by the document
// root, which is what a theme preview is. A preview has to sit next to the palette
// it is previewing, inside a control that renders into a shadow root, where :root
// matches nothing -- a stylesheet in the document does not select into a shadow tree
// at all. Emitting the bare form lets the preview carry data-theme and pick the
// palette up, so it shows the theme rather than a swatch of it.
func contractSelector(slug string, mode domain.Appearance) string {
return fmt.Sprintf(":root[%s=%s][%s=%s], :root[%s=%s][%s=%s], [%s=%s][%s=%s], [%s=%s][%s=%s]",
ContractThemeAttribute, selectorValue(slug),
ContractModeAttribute, selectorValue(string(mode)),
ThemeAttribute, selectorValue(slug),
AppearanceAttribute, selectorValue(string(mode)),
ContractThemeAttribute, selectorValue(slug),
ContractModeAttribute, selectorValue(string(mode)),
ThemeAttribute, selectorValue(slug),
AppearanceAttribute, selectorValue(string(mode)))
}
// contractDefaultSelector matches a palette with no explicit appearance.
//
// This exists because requiring both attributes is a trap. An application that
// declares only `data-theme` -- which is what go-tell did, and what any application
// written before the appearance axis existed still does -- would match no rule at all
// and render entirely on its fallbacks, with no error anywhere. Matching the palette
// alone and treating the absence of an appearance as "the designer's choice" is what
// the axis means anyway, and it is what the theme's own default appearance says.
//
// As with contractSelector, the bare form follows the :root one so a preview can
// carry the palette without an appearance, and inherit the designer's choice with it.
func contractDefaultSelector(slug string) string {
return fmt.Sprintf(":root[%s=%s], :root[%s=%s], [%s=%s], [%s=%s]",
ContractThemeAttribute, selectorValue(slug),
ThemeAttribute, selectorValue(slug),
ContractThemeAttribute, selectorValue(slug),
ThemeAttribute, selectorValue(slug))
}
// selectorValue quotes a value for use inside an attribute selector.
func selectorValue(v string) string {
return fmt.Sprintf("%q", v)
}
// Contract renders the contract layer for one theme and appearance.
//
// It emits the short --bn-* names and the unprefixed aliases. It does not emit the
// theme's own tokens: those are already in the stylesheet, and duplicating them would
// double the payload for no gain. This is the layer that makes a long token path
// reachable under a name a component author would guess.
func Contract(theme *domain.Theme, mode domain.Appearance) string {
doc := Resolve(theme, mode)
var b strings.Builder
fmt.Fprintf(&b, "@layer %s.base;\n", LayerName)
writeContractRule(&b, contractSelector(theme.Slug, mode), doc)
return b.String()
}
// ContractAll renders the contract layer for every theme at once, for both modes.
//
// This is what makes a theme selector instant. Selecting a palette becomes a change to
// one attribute rather than a fetch, because every palette is already in the document.
// The cost is that the file grows with the number of published themes, which is why it
// is served separately from theme.css: a single-theme consumer should not download the
// others.
func ContractAll(themes []*domain.Theme) string {
var b strings.Builder
fmt.Fprintf(&b, "/*!\n * Go Bananas!! shared token contract\n")
fmt.Fprintf(&b, " * %d theme(s), both appearances each.\n", len(themes))
fmt.Fprintf(&b, " * Set %s and %s on <html>; nothing is fetched when you switch.\n",
ContractThemeAttribute, ContractModeAttribute)
fmt.Fprintf(&b, " */\n")
fmt.Fprintf(&b, "@layer %s.base;\n", LayerName)
for _, theme := range themes {
// The default first, then the explicit overrides. Order matters only as a
// readability property: the mode selectors carry two attributes against the
// default's one, so they win on specificity regardless of source order.
def := theme.DefaultAppearance
if def == "" {
def = domain.AppearanceDark
}
writeContractRule(&b, contractDefaultSelector(theme.Slug), Resolve(theme, def))
for _, mode := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} {
writeContractRule(&b, contractSelector(theme.Slug, mode), Resolve(theme, mode))
}
}
return b.String()
}
// writeContractRule writes one selector's declarations and its alias block.
func writeContractRule(b *strings.Builder, selector string, doc *Doc) {
decls := make([]string, 0, len(contractRoles)*2)
for _, role := range contractRoles {
value := role.Fallback
if token, ok := doc.Lookup(role.Path); ok && token.CSS != "" {
value = token.CSS
}
decls = append(decls, VariablePrefix+"-"+role.Name+": "+value+";")
}
// The families whose names are already short are re-exported rather than
// re-derived, so `--bn-radius-md` is the same variable the theme defines and an
// override in the theme's own stylesheet still wins.
for _, family := range contractFamilies {
prefix := family + "."
for _, token := range doc.Tokens {
if !strings.HasPrefix(token.Path, prefix) {
continue
}
name := VariablePrefix + "-" + strings.ReplaceAll(token.Path, ".", "-")
if token.CSS != "" {
decls = append(decls, name+": "+token.CSS+";")
}
}
}
if len(decls) == 0 {
return
}
b.WriteString(selector + " {\n")
for _, d := range decls {
b.WriteString(" " + d + "\n")
}
// The alias block, in the same rule so it is impossible to emit one without the
// other. Aliases reference the contract names rather than repeating the values, so
// there is exactly one place a value is written.
aliases := make([]string, 0, len(legacyAliases))
for legacy, target := range legacyAliases {
aliases = append(aliases, fmt.Sprintf("--%s: var(%s-%s);", legacy, VariablePrefix, target))
}
// Sorted, because a map iteration order would make the output differ between
// builds and defeat any caching or diffing of this file.
sortStrings(aliases)
for _, a := range aliases {
b.WriteString(" " + a + "\n")
}
b.WriteString("}\n\n")
}
// sortStrings sorts in place. It exists so this file does not need to import sort for
// one call.
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
// ContractManifestEntry describes one theme in the contract, for a selector to render
// without having to parse CSS.
type ContractManifestEntry struct {
Slug string `json:"slug"`
Name string `json:"name"`
Version int64 `json:"version"`
// DefaultMode is the appearance the theme was designed for. It is what a
// consumer shows before the user expresses a preference.
DefaultMode string `json:"defaultMode"`
}
// ContractManifest returns the entries a theme selector needs.
func ContractManifest(themes []*domain.Theme) []ContractManifestEntry {
out := make([]ContractManifestEntry, 0, len(themes))
for _, t := range themes {
mode := t.DefaultAppearance
if mode == "" {
mode = domain.AppearanceDark
}
out = append(out, ContractManifestEntry{
Slug: t.Slug,
Name: t.Name,
Version: t.Version,
DefaultMode: string(mode),
})
}
return out
}

View file

@ -0,0 +1,82 @@
package tokenengine
import (
"strings"
"testing"
"github.com/urpi/go-bananas/internal/domain"
)
// The aggregate contract is consumed two ways: as a stylesheet the application
// links, which can only be styled from :root, and as a stylesheet a control adopts
// into its own shadow root, where :root matches nothing because a stylesheet in the
// document does not select into a shadow tree. The bare selector forms are what make
// the second use possible, and a palette preview depends on them, so their absence is
// a silent failure: the selector still lists every palette and every preview renders
// in whatever the host's fallbacks happen to be.
// assertSelectorPair asserts that a selector list contains both halves of a pair as
// distinct comma-separated entries.
//
// The assertion is on the joined pair rather than on each half alone, because one half
// cannot tell the two forms apart: the string `[data-theme="acme"][data-mode="light"]`
// occurs inside `:root[data-theme="acme"][data-mode="light"]`, so a bare Contains
// reports a bare selector as present when only the rooted form was emitted. Joining
// them is what discriminates, because in the rooted-only list the two halves are
// separated by `:root`.
func assertSelectorPair(t *testing.T, css, want string) {
t.Helper()
if !strings.Contains(css, want) {
t.Errorf("contract does not emit %q as a pair of entries, so a palette preview cannot adopt it", want)
}
}
func TestContractEmitsBareSelectorsForPreviewScope(t *testing.T) {
theme := testTheme()
theme.Slug = "acme"
theme.DefaultAppearance = domain.AppearanceDark
css := ContractAll([]*domain.Theme{theme})
// The palette on its own, so a preview that declares no appearance still adopts
// the designer's choice.
assertSelectorPair(t, css, `[data-theme="acme"], [data-bananas-theme="acme"]`)
// Each appearance, in both attribute conventions.
assertSelectorPair(t, css, `[data-theme="acme"][data-mode="light"], [data-bananas-theme="acme"][data-bananas-appearance="light"]`)
assertSelectorPair(t, css, `[data-theme="acme"][data-mode="dark"], [data-bananas-theme="acme"][data-bananas-appearance="dark"]`)
// The rooted forms have to survive: every application that links the contract
// rather than adopting it styles itself from :root.
assertSelectorPair(t, css, `:root[data-theme="acme"], :root[data-bananas-theme="acme"]`)
assertSelectorPair(t, css, `:root[data-theme="acme"][data-mode="light"], :root[data-bananas-theme="acme"][data-bananas-appearance="light"]`)
}
// TestContractBareSelectorsCarryTheSameValues guards against the two forms drifting
// apart, which would mean a preview shows a different palette from the one applying
// to the page. Both forms are emitted from the same declarations, so this is a
// statement that the pairing is not an accident worth breaking.
func TestContractBareSelectorsCarryTheSameValues(t *testing.T) {
css := ContractAll([]*domain.Theme{testTheme()})
for _, want := range []string{
`--bn-bg: #ffffff;`,
`--bn-text: #0f172a;`,
`--bn-accent: #4f46e5;`,
} {
if !strings.Contains(css, want) {
t.Errorf("contract no longer emits %q", want)
}
}
}
// TestContractLegacyAliasesStillExist protects the compatibility promise: an
// application that already uses the unprefixed names must keep working.
func TestContractLegacyAliasesStillExist(t *testing.T) {
css := ContractAll([]*domain.Theme{testTheme()})
for legacy := range legacyAliases {
if !strings.Contains(css, "--"+legacy+":") {
t.Errorf("legacy alias --%s is no longer emitted", legacy)
}
}
}

617
internal/tokenengine/css.go Normal file
View file

@ -0,0 +1,617 @@
package tokenengine
import (
"fmt"
"strings"
"github.com/urpi/go-bananas/internal/domain"
)
// LayerName is the cascade layer the generated component styles are placed in.
// Everything the engine emits is layered so that a consuming application's own
// unlayered CSS always wins the cascade without needing !important.
const LayerName = "bananas"
// RenderOptions controls stylesheet generation.
type RenderOptions struct {
// Slug scopes the theme to an attribute selector so that several themes can
// coexist on one page. When empty the tokens are emitted on :root instead.
Slug string
// Appearance is the scheme to render as the default for the selector.
Appearance domain.Appearance
// ApplyToRoot also emits the tokens on :root, making the theme the page
// default. Ignored when Slug is set unless ForceRoot is true.
ApplyToRoot bool
// ForceRoot emits :root rules even when Slug is set.
ForceRoot bool
// AutoDark adds a prefers-color-scheme: dark block that swaps in the dark
// tokens when the slug selector is not explicitly pinned to light.
AutoDark bool
// Components appends the ready-made component layer.
Components bool
// Derive computes engine-supplied tokens (foreground pairs, interaction
// states, tinted surfaces) for values the author did not define.
Derive DeriveOptions
// Minify strips comments and collapses whitespace.
Minify bool
// SourceURL appends a source comment pointing at the runtime URL the
// stylesheet was generated from, which makes browser devtools navigable.
SourceURL string
}
// DeriveOptions selects which engine-computed token families to emit.
type DeriveOptions struct {
// Foreground adds `--bn-color-on-*` roles derived by WCAG contrast.
Foreground bool
// Interaction adds `-hover` and `-active` variants for interactive roles.
Interaction bool
// Tint adds subtle tinted surfaces derived from a role's own hue.
Tint bool
}
// DefaultDerive returns the derivation set used when a caller does not care.
func DefaultDerive() DeriveOptions {
return DeriveOptions{Foreground: true, Interaction: true, Tint: true}
}
// RenderOptionsFrom builds options from a theme with sensible defaults for the
// given delivery channel.
func RenderOptionsFrom(theme *domain.Theme, appearance domain.Appearance) RenderOptions {
return RenderOptions{
Slug: theme.Slug,
Appearance: appearance,
Derive: DefaultDerive(),
Components: true,
}
}
// pair is one CSS custom property declaration.
type pair struct {
name string
value string
}
// Render produces the stylesheet for a theme.
func Render(theme *domain.Theme, opts RenderOptions) string {
appearance := opts.Appearance
if appearance == "" {
appearance = theme.DefaultAppearance
}
var b strings.Builder
if !opts.Minify {
writeHeader(&b, theme, opts, appearance)
}
light := Resolve(theme, domain.AppearanceLight)
dark := Resolve(theme, domain.AppearanceDark)
// The scoped rule carries the theme's *default* appearance, so that setting a
// single attribute is enough to get the theme the author designed. The
// alternate scheme is emitted under its own appearance attribute.
base := light
alternate := dark
if appearance == domain.AppearanceDark {
base, alternate = dark, light
}
baseDecls := renderDeclarations(base, opts, appearance)
alternateDecls := renderDeclarations(alternate, opts, opposite(appearance))
darkDecls := renderDeclarations(dark, opts, domain.AppearanceDark)
rootSelector := opts.ApplyToRoot || opts.ForceRoot || opts.Slug == ""
scope := themeSelector(opts.Slug)
if !opts.Minify {
fmt.Fprintf(&b, "@layer %s.base;\n", LayerName)
}
// The default scheme.
switch {
case rootSelector && opts.Slug != "":
// Emit on :root so the theme works with zero configuration, and again on
// the scoped selector so a page can host several themes side by side.
writeRule(&b, ":root", baseDecls, opts.Minify)
writeRule(&b, scope, baseDecls, opts.Minify)
case rootSelector:
writeRule(&b, ":root", baseDecls, opts.Minify)
default:
writeRule(&b, scope, baseDecls, opts.Minify)
}
// The alternate scheme, selected explicitly. This is what a consumer's theme
// switcher sets, and it is the only selector needed to override the default.
if opts.Slug != "" {
writeRule(&b, darkScopeSelector(opts.Slug, opposite(appearance)), alternateDecls, opts.Minify)
} else {
writeRule(&b, ":root["+AppearanceAttribute+"="+string(opposite(appearance))+"], :root."+darkClass,
alternateDecls, opts.Minify)
}
// A `.dark` ancestor class covers the common Tailwind and shadcn convention
// where dark mode is toggled by a class on <html> rather than an attribute.
// It always means dark, regardless of the theme's default.
if opts.Slug != "" && appearance != domain.AppearanceDark {
fmt.Fprintf(&b, ".dark %s, %s.%s {\n", scope, scope, darkClass)
writeDeclarations(&b, darkDecls, opts.Minify, "")
b.WriteString("}\n\n")
}
if opts.AutoDark {
writeAutoDark(&b, scope, darkDecls, opts.Minify)
}
if opts.Components {
writeComponents(&b, opts)
}
if !opts.Minify && opts.SourceURL != "" {
fmt.Fprintf(&b, "/*# sourceURL=%s */\n", opts.SourceURL)
}
return strings.TrimRight(b.String(), "\n") + "\n"
}
// opposite returns the other colour scheme.
func opposite(a domain.Appearance) domain.Appearance {
if a == domain.AppearanceDark {
return domain.AppearanceLight
}
return domain.AppearanceDark
}
// writeHeader emits a provenance comment. It is what makes a generated
// stylesheet debuggable: it names the theme, version and generation time.
func writeHeader(b *strings.Builder, theme *domain.Theme, opts RenderOptions, appearance domain.Appearance) {
fmt.Fprintf(b, "/*!\n")
fmt.Fprintf(b, " * Go Bananas!! theme: %s\n", theme.Name)
fmt.Fprintf(b, " * slug: %s | version: %d | status: %s | appearance: %s\n",
theme.Slug, theme.Version, theme.Status, appearance)
fmt.Fprintf(b, " * variables are prefixed %q; override them in an unlayered rule to win the cascade\n", VariablePrefix)
fmt.Fprintf(b, " * generated: %s\n", theme.UpdatedAt.UTC().Format(domain.TimeFormat))
fmt.Fprintf(b, " */\n")
}
// themeSelector returns the attribute selector that scopes a theme.
func themeSelector(slug string) string {
return fmt.Sprintf("[%s=%q]", ThemeAttribute, selectorSafe(slug))
}
// darkScopeSelector returns the selector for the dark scheme of a scoped theme.
func darkScopeSelector(slug string, appearance domain.Appearance) string {
base := themeSelector(slug)
return fmt.Sprintf("%s[%s=%s]", base, AppearanceAttribute, appearance)
}
// writeAutoDark emits the prefers-color-scheme block. The `:not()` guard makes
// the media query yield to an explicit appearance attribute so that a manual
// override always beats the OS preference.
func writeAutoDark(b *strings.Builder, scope string, decls []pair, minify bool) {
b.WriteString("@media (prefers-color-scheme: dark) {\n")
var selector string
if scope == ":root" {
selector = fmt.Sprintf(":root:not([%s=%s])", AppearanceAttribute, domain.AppearanceLight)
} else {
selector = fmt.Sprintf("%s:not([%s=%s]):not(.dark)", scope, AppearanceAttribute, domain.AppearanceLight)
}
inner := " "
writeRuleIndent(b, selector, decls, minify, inner)
b.WriteString("}\n\n")
}
// renderDeclarations builds the ordered declaration list for one appearance.
func renderDeclarations(doc *Doc, opts RenderOptions, appearance domain.Appearance) []pair {
decls := make([]pair, 0, len(doc.Tokens)+32)
seen := make(map[string]struct{}, len(doc.Tokens))
add := func(name, value string) {
if _, dup := seen[name]; dup {
return
}
seen[name] = struct{}{}
decls = append(decls, pair{name: name, value: value})
}
for _, t := range doc.Tokens {
add(VariableName(t.Path), t.CSS)
}
if opts.Derive.Foreground {
for _, d := range deriveForeground(doc, appearance) {
add(d.name, d.value)
}
}
if opts.Derive.Interaction {
for _, d := range deriveInteraction(doc, appearance) {
add(d.name, d.value)
}
}
if opts.Derive.Tint {
for _, d := range deriveTints(doc, appearance) {
add(d.name, d.value)
}
}
return decls
}
// deriveForeground produces the `--bn-color-on-*` family. For every palette
// step and every semantic role that is a solid colour, it picks black or white
// whichever has the higher WCAG contrast ratio. Authors who want an explicit
// value simply define it: derived names are only emitted when absent.
func deriveForeground(doc *Doc, appearance domain.Appearance) []pair {
out := make([]pair, 0, 48)
for _, t := range doc.Tokens {
if t.Kind != KindColor || !strings.HasPrefix(t.Path, "color.") {
continue
}
// Skip roles that are not solid backgrounds and skip anything already
// spelled `on-*`.
rest := strings.TrimPrefix(t.Path, "color.")
if strings.HasPrefix(rest, "on-") {
continue
}
col, err := ParseColor(t.CSS)
if err != nil || !col.Numeric || col.A < 0.5 {
continue
}
if _, exists := doc.Lookup("color.on-" + rest); exists {
continue
}
out = append(out, pair{
name: VariableName("color.on-" + rest),
value: BestForeground(col).Hex(),
})
}
return out
}
// interactionRatios are the mix steps used to derive hover and active states.
// Hover moves 8% toward the foreground, active 16%: subtle enough to read as
// the same colour, strong enough to be perceptible.
var (
hoverRatio = 0.08
activeRatio = 0.16
)
// interactiveRoles are the semantic roles that get state variants. They are the
// ones a component stylesheet can reasonably apply :hover and :active to.
var interactiveRoles = []string{
"semantic.primary",
"semantic.secondary",
"semantic.accent",
"semantic.success",
"semantic.warning",
"semantic.danger",
"semantic.info",
"semantic.surface",
"semantic.surface-raised",
}
// deriveInteraction produces `-hover`, `-active` and `-subtle` companions for
// the interactive semantic roles that exist in the document.
func deriveInteraction(doc *Doc, appearance domain.Appearance) []pair {
out := make([]pair, 0, 48)
for _, role := range interactiveRoles {
t, ok := doc.Lookup("color." + role)
if !ok || t.Kind != KindColor {
continue
}
col, err := ParseColor(t.CSS)
if err != nil || !col.Numeric {
continue
}
// In a dark scheme the base colour is already light, so "emphasis" means
// getting lighter, not darker.
for _, suffix := range []struct {
name string
amount float64
}{{"hover", hoverRatio}, {"active", activeRatio}} {
path := "color." + role + "-" + suffix.name
if _, exists := doc.Lookup(path); exists {
continue
}
out = append(out, pair{
name: VariableName(path),
value: col.LightenDark(suffix.amount, appearance == domain.AppearanceDark).Hex(),
})
}
if _, exists := doc.Lookup("color." + role + "-subtle"); !exists {
out = append(out, pair{
name: VariableName("color." + role + "-subtle"),
value: col.WithAlpha(0.12).Hex(),
})
}
}
return out
}
// tintRoles are the solid roles that get very light/dark companions for badge,
// callout and hover-row backgrounds.
var tintRoles = []string{
"semantic.primary",
"semantic.success",
"semantic.warning",
"semantic.danger",
"semantic.info",
}
// deriveTints mixes a role toward its scheme's background, producing a surface
// that reads as "this role, but quiet".
func deriveTints(doc *Doc, appearance domain.Appearance) []pair {
background, ok := doc.Lookup("color.semantic.background")
if !ok {
return nil
}
bg, err := ParseColor(background.CSS)
if err != nil || !bg.Numeric {
return nil
}
out := make([]pair, 0, 16)
for _, role := range tintRoles {
t, exists := doc.Lookup("color." + role)
if !exists || t.Kind != KindColor {
continue
}
col, err := ParseColor(t.CSS)
if err != nil || !col.Numeric {
continue
}
for _, suffix := range []struct {
name string
amount float64
}{{"tint", 0.14}, {"tint-strong", 0.26}} {
path := "color." + role + "-" + suffix.name
if _, exists := doc.Lookup(path); exists {
continue
}
out = append(out, pair{
name: VariableName(path),
value: bg.Mix(col, suffix.amount).Hex(),
})
}
}
return out
}
// writeRule emits a complete CSS rule.
func writeRule(b *strings.Builder, selector string, decls []pair, minify bool) {
writeRuleIndent(b, selector, decls, minify, "")
}
// writeRuleIndent emits a CSS rule with every line prefixed by indent.
func writeRuleIndent(b *strings.Builder, selector string, decls []pair, minify bool, indent string) {
if len(decls) == 0 {
return
}
b.WriteString(indent)
b.WriteString(selector)
b.WriteString(" {\n")
writeDeclarations(b, decls, minify, indent+" ")
b.WriteString(indent)
b.WriteString("}\n")
if !minify {
b.WriteString("\n")
}
}
// writeDeclarations emits the body of a rule. In minified mode the whole body is
// written on one line with no indentation, which is what a minifier would
// produce and keeps the output byte-identical to what a CDN would serve.
func writeDeclarations(b *strings.Builder, decls []pair, minify bool, indent string) {
if minify {
indent = ""
}
for _, d := range decls {
b.WriteString(indent)
b.WriteString(d.name)
b.WriteString(":")
if !minify {
b.WriteString(" ")
}
b.WriteString(d.value)
b.WriteString(";")
if !minify {
b.WriteByte('\n')
}
}
}
// writeComponents appends the ready-made component layer. It is intentionally
// small: enough for a host application to look correct immediately, expressed
// entirely in terms of the generated variables so that re-theming is free.
func writeComponents(b *strings.Builder, opts RenderOptions) {
fmt.Fprintf(b, "@layer %s.components {\n", LayerName)
componentCSS(b, opts.Minify)
b.WriteString("}\n")
}
// componentRules is the body of the component layer. It uses only generated
// variables plus layout primitives that carry no colour of their own.
const componentRules = `
.bn-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--bn-space-2, 0.5rem);
padding: var(--bn-space-2, 0.5rem) var(--bn-space-4, 1rem);
border: var(--bn-border-width-default, 1px) solid transparent;
border-radius: var(--bn-radius-md, 0.375rem);
background: var(--bn-color-semantic-primary, currentColor);
color: var(--bn-color-on-primary, #fff);
font-family: var(--bn-font-family-sans, system-ui, sans-serif);
font-size: var(--bn-font-size-sm, 0.875rem);
font-weight: var(--bn-font-weight-medium, 500);
line-height: var(--bn-line-height-normal, 1.5);
cursor: pointer;
text-decoration: none;
transition: background-color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease),
color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease),
border-color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease);
}
.bn-btn:hover { background: var(--bn-color-semantic-primary-hover, var(--bn-color-semantic-primary)); }
.bn-btn:active { background: var(--bn-color-semantic-primary-active, var(--bn-color-semantic-primary)); }
.bn-btn:focus-visible {
outline: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-focus-ring, currentColor);
outline-offset: var(--bn-space-1, 0.25rem);
}
.bn-btn[disabled] { opacity: var(--bn-opacity-disabled, 0.5); cursor: not-allowed; }
.bn-btn--secondary {
background: var(--bn-color-semantic-secondary, transparent);
color: var(--bn-color-semantic-text, inherit);
border-color: var(--bn-color-semantic-border, currentColor);
}
.bn-btn--secondary:hover { background: var(--bn-color-semantic-secondary-hover, transparent); }
.bn-btn--ghost { background: transparent; color: var(--bn-color-semantic-primary, inherit); }
.bn-btn--ghost:hover { background: var(--bn-color-semantic-primary-subtle, transparent); }
.bn-btn--danger { background: var(--bn-color-semantic-danger, currentColor); color: var(--bn-color-on-danger, #fff); }
.bn-btn--sm { padding: var(--bn-space-1, 0.25rem) var(--bn-space-3, 0.75rem); font-size: var(--bn-font-size-xs, 0.75rem); }
.bn-btn--lg { padding: var(--bn-space-3, 0.75rem) var(--bn-space-6, 1.5rem); font-size: var(--bn-font-size-md, 1rem); }
.bn-card {
background: var(--bn-color-semantic-surface, Canvas);
color: var(--bn-color-semantic-text, CanvasText);
border: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, transparent);
border-radius: var(--bn-radius-lg, 0.5rem);
box-shadow: var(--bn-shadow-sm, none);
padding: var(--bn-space-6, 1.5rem);
font-family: var(--bn-font-family-sans, system-ui, sans-serif);
}
.bn-card--raised { background: var(--bn-color-semantic-surface-raised, Canvas); box-shadow: var(--bn-shadow-md, none); }
.bn-card__header { margin-bottom: var(--bn-space-4, 1rem); font-size: var(--bn-font-size-lg, 1.125rem); font-weight: var(--bn-font-weight-semibold, 600); }
.bn-card__body { font-size: var(--bn-font-size-sm, 0.875rem); color: var(--bn-color-semantic-text-muted, inherit); }
.bn-input,
.bn-select,
.bn-textarea {
width: 100%;
padding: var(--bn-space-2, 0.5rem) var(--bn-space-3, 0.75rem);
border: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, currentColor);
border-radius: var(--bn-radius-md, 0.375rem);
background: var(--bn-color-semantic-background, Canvas);
color: var(--bn-color-semantic-text, CanvasText);
font-family: inherit;
font-size: var(--bn-font-size-sm, 0.875rem);
line-height: var(--bn-line-height-normal, 1.5);
}
.bn-input:focus-visible,
.bn-select:focus-visible,
.bn-textarea:focus-visible {
outline: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-focus-ring, currentColor);
outline-offset: var(--bn-space-1, 0.25rem);
border-color: var(--bn-color-semantic-primary, currentColor);
}
.bn-input::placeholder { color: var(--bn-color-semantic-text-subtle, inherit); opacity: 1; }
.bn-input:disabled { opacity: var(--bn-opacity-disabled, 0.5); cursor: not-allowed; }
.bn-label { display: block; margin-bottom: var(--bn-space-1, 0.25rem); font-size: var(--bn-font-size-xs, 0.75rem); font-weight: var(--bn-font-weight-medium, 500); color: var(--bn-color-semantic-text-muted, inherit); }
.bn-field { margin-bottom: var(--bn-space-4, 1rem); }
.bn-field__error { margin-top: var(--bn-space-1, 0.25rem); font-size: var(--bn-font-size-xs, 0.75rem); color: var(--bn-color-semantic-danger, red); }
.bn-badge {
display: inline-flex;
align-items: center;
gap: var(--bn-space-1, 0.25rem);
padding: var(--bn-space-1, 0.25rem) var(--bn-space-2, 0.5rem);
border-radius: var(--bn-radius-full, 9999px);
font-size: var(--bn-font-size-xs, 0.75rem);
font-weight: var(--bn-font-weight-medium, 500);
background: var(--bn-color-semantic-primary-subtle, transparent);
color: var(--bn-color-semantic-primary, inherit);
}
.bn-badge--success { background: var(--bn-color-semantic-success-tint, transparent); color: var(--bn-color-semantic-success, inherit); }
.bn-badge--warning { background: var(--bn-color-semantic-warning-tint, transparent); color: var(--bn-color-semantic-warning, inherit); }
.bn-badge--danger { background: var(--bn-color-semantic-danger-tint, transparent); color: var(--bn-color-semantic-danger, inherit); }
.bn-badge--neutral { background: var(--bn-color-semantic-surface-raised, transparent); color: var(--bn-color-semantic-text-muted, inherit); }
.bn-alert {
display: flex;
gap: var(--bn-space-3, 0.75rem);
padding: var(--bn-space-4, 1rem);
border-radius: var(--bn-radius-md, 0.375rem);
border-left: var(--bn-border-width-thick, 3px) solid var(--bn-color-semantic-info, currentColor);
background: var(--bn-color-semantic-info-tint, transparent);
color: var(--bn-color-semantic-text, inherit);
font-size: var(--bn-font-size-sm, 0.875rem);
}
.bn-alert--success { border-color: var(--bn-color-semantic-success, currentColor); background: var(--bn-color-semantic-success-tint, transparent); }
.bn-alert--warning { border-color: var(--bn-color-semantic-warning, currentColor); background: var(--bn-color-semantic-warning-tint, transparent); }
.bn-alert--danger { border-color: var(--bn-color-semantic-danger, currentColor); background: var(--bn-color-semantic-danger-tint, transparent); }
.bn-link { color: var(--bn-color-semantic-primary, currentColor); text-decoration: underline; text-underline-offset: var(--bn-space-1, 0.25rem); }
.bn-link:hover { color: var(--bn-color-semantic-primary-hover, inherit); }
.bn-table { width: 100%; border-collapse: collapse; font-size: var(--bn-font-size-sm, 0.875rem); }
.bn-table th { text-align: left; font-weight: var(--bn-font-weight-semibold, 600); color: var(--bn-color-semantic-text-muted, inherit); padding: var(--bn-space-2, 0.5rem) var(--bn-space-3, 0.75rem); border-bottom: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, transparent); }
.bn-table td { padding: var(--bn-space-3, 0.75rem); border-bottom: var(--bn-border-width-thin, 1px) solid var(--bn-color-semantic-border, transparent); }
.bn-table tbody tr:hover { background: var(--bn-color-semantic-surface-raised, transparent); }
.bn-stack { display: flex; flex-direction: column; gap: var(--bn-space-4, 1rem); }
.bn-cluster { display: flex; flex-wrap: wrap; align-items: center; gap: var(--bn-space-2, 0.5rem); }
.bn-divider { height: var(--bn-border-width-thin, 1px); background: var(--bn-color-semantic-border, transparent); border: 0; }
`
// componentCSS writes the component layer, optionally stripped of comments and
// blank lines.
func componentCSS(b *strings.Builder, minify bool) {
css := componentRules
if minify {
var out strings.Builder
for _, line := range strings.Split(css, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
out.WriteString(trimmed)
out.WriteByte('\n')
}
css = out.String()
}
b.WriteString(css)
}
// Render variant identifiers. A variant is a named, cacheable option set: the
// stylesheet a browser fetches and the compact stylesheet a server-side
// renderer inlines are different artefacts and must not share a cache entry.
const (
// VariantFull is the complete stylesheet: tokens, derived tokens and the
// component layer.
VariantFull = "full"
// VariantTokens is tokens and derived tokens only, for applications that
// ship their own components.
VariantTokens = "tokens"
// VariantMinified is the compact, comment-free stylesheet for production.
VariantMinified = "minified"
)
// VariantNames lists the render variants in a stable order.
func VariantNames() []string { return []string{VariantFull, VariantTokens, VariantMinified} }
// Variant identifies the option set that produced a stylesheet. It is part of
// the cache key so two variants can never be confused.
func (o RenderOptions) Variant() string {
switch {
case o.Minify:
return VariantMinified
case o.Components:
return VariantFull
default:
return VariantTokens
}
}
// OptionsForVariant returns the render options for a named variant.
func OptionsForVariant(variant string, slug string, appearance domain.Appearance) RenderOptions {
opts := RenderOptions{
Slug: slug,
Appearance: appearance,
Derive: DefaultDerive(),
}
switch variant {
case VariantMinified:
opts.Minify = true
opts.Components = true
case VariantFull:
opts.Components = true
default:
opts.Components = false
}
return opts
}

View file

@ -0,0 +1,165 @@
package tokenengine
import (
"sort"
"strings"
"github.com/urpi/go-bananas/internal/domain"
)
// ResolvedTheme is the JSON payload served to runtime consumers. It carries
// both a flat variable map (what a browser or a design tool wants) and the
// nested resolved document (what an application that maps tokens onto component
// props wants), so consumers never have to choose.
type ResolvedTheme struct {
Theme ThemeRef `json:"theme"`
Tokens domain.TokenDoc `json:"tokens"`
Variable map[string]string `json:"variables"`
Meta domain.ThemeMeta `json:"meta,omitempty"`
}
// ThemeRef is the identity block repeated in every runtime payload.
type ThemeRef struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version int64 `json:"version"`
Appearance domain.Appearance `json:"appearance"`
DefaultAppearance domain.Appearance `json:"defaultAppearance"`
UpdatedAt string `json:"updatedAt"`
}
// JSONPayload builds the runtime JSON payload for one appearance.
func JSONPayload(theme *domain.Theme, appearance domain.Appearance) ResolvedTheme {
if appearance == "" {
appearance = theme.DefaultAppearance
}
doc := Resolve(theme, appearance)
return ResolvedTheme{
Theme: ThemeRef{
Slug: theme.Slug,
Name: theme.Name,
Description: theme.Description,
Version: theme.Version,
Appearance: appearance,
DefaultAppearance: theme.DefaultAppearance,
UpdatedAt: theme.UpdatedAt.UTC().Format(domain.TimeFormat),
},
Tokens: resolvedDocument(doc),
Variable: doc.Variables(),
Meta: theme.Meta,
}
}
// resolvedDocument renders the resolved tokens back into nested form, keeping
// the DTCG `$type`/`$value` shape so that the output can be fed straight into
// any DTCG-aware tool.
func resolvedDocument(doc *Doc) domain.TokenDoc {
out := make(domain.TokenDoc, len(Groups)+1)
for _, t := range doc.Tokens {
insertResolved(out, strings.Split(t.Path, "."), t)
}
return out
}
// insertResolved places a token into the nested output document, creating the
// intermediate group objects on the way down.
func insertResolved(root domain.TokenDoc, segments []string, t FlatToken) {
current := root
for _, seg := range segments[:len(segments)-1] {
child, ok := current[seg].(map[string]any)
if !ok {
child = map[string]any{}
current[seg] = child
}
// Re-widen so that the next iteration can index it as a TokenDoc.
current = child
}
leaf := map[string]any{ValueKey: t.Value, TypeKey: string(t.Kind)}
if t.Description != "" {
leaf[DescKey] = t.Description
}
current[segments[len(segments)-1]] = leaf
}
// ExportDocument produces a portable, self-describing token document for a
// theme. Unlike the runtime payload it keeps the appearance overrides intact,
// because the point of an export is that it can be re-imported losslessly.
func ExportDocument(theme *domain.Theme) domain.TokenDoc {
out := domain.TokenDoc{SchemaKey: SchemaID}
merged := make(domain.TokenDoc, len(theme.Tokens)+1)
for k, v := range theme.Tokens {
merged[k] = v
}
if _, ok := merged[SchemaKey]; !ok {
out[SchemaKey] = SchemaID
}
delete(merged, SchemaKey)
for k, v := range merged {
out[k] = v
}
return out
}
// ManifestEntry describes one theme in the runtime manifest: the small payload
// a loader needs in order to build a theme picker without fetching every theme.
type ManifestEntry struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
DefaultAppearance domain.Appearance `json:"defaultAppearance"`
Version int64 `json:"version"`
Tags []string `json:"tags,omitempty"`
CSS string `json:"css"`
Tokens string `json:"tokens"`
}
// Manifest is the discovery document served from the runtime API.
type Manifest struct {
// APIVersion is the runtime contract version. Consumers should refuse to
// load a manifest whose major version they do not understand.
APIVersion string `json:"apiVersion"`
// Default is the slug applied when a consumer does not choose.
Default string `json:"default,omitempty"`
// BaseURL is the absolute runtime base the entries are relative to.
BaseURL string `json:"baseUrl"`
Themes []ManifestEntry `json:"themes"`
Extra map[string]string `json:"extra,omitempty"`
}
// RuntimeAPIVersion is the current runtime contract version.
const RuntimeAPIVersion = "1"
// Tags extracts theme tags from the opaque meta block. Tags are the one piece
// of meta the engine understands, because the manifest needs them.
func Tags(meta domain.ThemeMeta) []string {
raw, ok := meta["tags"]
if !ok {
return nil
}
switch t := raw.(type) {
case []string:
return t
case []any:
out := make([]string, 0, len(t))
for _, v := range t {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
sort.Strings(out)
return out
case string:
parts := strings.Split(t, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
sort.Strings(out)
return out
default:
return nil
}
}

View file

@ -0,0 +1,147 @@
package tokenengine
import (
"fmt"
"math"
"strconv"
)
// RampOptions describes a generated colour ramp. It is the programmatic form of
// "here is a brand colour, give me the other ten shades", which the editor UI
// exposes as a one-click palette builder and which the built-in themes use to
// stay tonally consistent.
type RampOptions struct {
// Hue in degrees, 0..360. Defaults to 250 (indigo) when zero-value.
Hue float64
// Saturation at the mid steps, in percent, 0..100.
Saturation float64
// Lightness at step 500, in percent, 0..100.
Lightness float64
// HueSpread drifts the hue from the lightest to the darkest step. Modern
// perceptual ramps lean warm at the light end and cool at the dark end;
// a value of 12 reproduces that effect.
HueSpread float64
}
// DefaultRampOptions returns the ramp the palette builder starts from.
func DefaultRampOptions() RampOptions {
return RampOptions{Hue: 250, Saturation: 78, Lightness: 55, HueSpread: 12}
}
// Normalize clamps the options into usable ranges and fills in defaults.
func (o RampOptions) Normalize() RampOptions {
out := o
out.Hue = math.Mod(math.Mod(out.Hue, 360)+360, 360)
out.Saturation = clampRange(out.Saturation, 0, 100)
out.Lightness = clampRange(out.Lightness, 20, 80)
out.HueSpread = clampRange(out.HueSpread, 0, 40)
return out
}
func clampRange(v, lo, hi float64) float64 {
if math.IsNaN(v) {
return lo
}
return math.Max(lo, math.Min(hi, v))
}
// RampEndpoints are the lightness percentages used for shade 50 and shade 950.
// They sit just short of pure white and pure black so that the lightest step is
// still usable as a surface and the darkest still holds text.
const (
RampLightest = 97.0
RampDarkest = 12.0
)
// GenerateRamp builds a canonical 11-step colour ramp as token leaves.
//
// Lightness is interpolated along a curve rather than linearly: the middle steps
// carry the overwhelming majority of UI usage (a button's resting colour is
// step 500 or 600), so they get the most separation. Saturation tapers at both
// ends because a fully saturated near-white or near-black reads as a fluorescent
// smear rather than a tint.
func GenerateRamp(opts RampOptions) map[string]any {
opts = opts.Normalize()
shades := StandardPaletteShades()
out := make(map[string]any, len(shades))
for i, shade := range shades {
pos := float64(i) / float64(len(shades)-1)
light := rampLightness(pos, opts.Lightness)
sat := opts.Saturation * saturationEnvelope(pos)
hue := opts.Hue + opts.HueSpread*(0.5-pos)
out[shade] = map[string]any{
TypeKey: string(KindColor),
ValueKey: HSLHex(hue, sat, light),
}
}
return out
}
// rampLightness returns the lightness percentage for a ramp position.
//
// The curve blends a full linear sweep across the ramp endpoints with a sweep
// anchored on the caller's requested step-500 lightness. The anchored sweep
// guarantees step 500 lands exactly where the caller asked, while the linear
// term keeps the extremes reaching near-white and near-black instead of
// bunching up in the middle.
func rampLightness(pos, midLight float64) float64 {
linear := RampLightest + (RampDarkest-RampLightest)*pos
// Anchored sweep: walk from midLight out to the nearest endpoint.
var anchored float64
if pos <= 0.5 {
anchored = midLight + (RampLightest-midLight)*2*(0.5-pos)
} else {
anchored = midLight - (midLight-RampDarkest)*2*(pos-0.5)
}
return linear*0.35 + anchored*0.65
}
// saturationEnvelope tapers saturation towards both ends of the ramp.
func saturationEnvelope(pos float64) float64 {
// 1 at the midpoint, ~0.5 at the extremes.
return 0.5 + 0.5*math.Sin(math.Pi*pos)
}
// HSLHex converts hue/saturation/lightness (hue in degrees, the others in
// percent) to a hex colour.
func HSLHex(hue, saturation, light float64) string {
h := math.Mod(math.Mod(hue, 360)+360, 360) / 360
s := clampRange(saturation, 0, 100) / 100
l := clampRange(light, 0, 100) / 100
r, g, b := hslToRGB(h, s, l)
return fmt.Sprintf("#%02x%02x%02x", int(r*255+0.5), int(g*255+0.5), int(b*255+0.5))
}
// RampStep returns the hex colour at one step of a generated ramp. It exists so
// that callers such as the semantic-group builder can reference a concrete
// colour without materialising the whole ramp.
func RampStep(opts RampOptions, shade string) string {
opts = opts.Normalize()
shades := StandardPaletteShades()
for i, s := range shades {
if s == shade {
pos := float64(i) / float64(len(shades)-1)
light := rampLightness(pos, opts.Lightness)
sat := opts.Saturation * saturationEnvelope(pos)
hue := opts.Hue + opts.HueSpread*(0.5-pos)
return HSLHex(hue, sat, light)
}
}
// Unknown shade: fall back to the mid lightness rather than failing, so a
// typo in a configuration file cannot produce an invalid colour.
return HSLHex(opts.Hue, opts.Saturation, opts.Lightness)
}
// RampShades lists the canonical shade steps.
func RampShades() []string { return StandardPaletteShades() }
// ParseShade extracts the numeric step from a shade name, reporting whether it
// is a plain number.
func ParseShade(shade string) (int, bool) {
n, err := strconv.Atoi(shade)
return n, err == nil
}

View file

@ -0,0 +1,508 @@
// Package tokenengine turns a Go Bananas!! design-token document into the
// artefacts that browsers and applications consume: CSS custom properties,
// resolved JSON tokens, and a flat variable map.
//
// The package is deliberately pure. It performs no I/O, holds no global state
// and is safe for concurrent use, which lets the HTTP layer cache rendered
// themes aggressively and pre-warm them at start-up.
package tokenengine
import (
"fmt"
"sort"
"strings"
"github.com/urpi/go-bananas/internal/domain"
)
// Kind is a token value type. The set mirrors the W3C Design Tokens Community
// Group format so that exported JSON is portable to other token tooling.
type Kind string
const (
KindColor Kind = "color"
KindDimension Kind = "dimension"
KindFontFamily Kind = "fontFamily"
KindFontSize Kind = "fontSize"
KindFontWeight Kind = "fontWeight"
KindLineHeight Kind = "lineHeight"
KindLetterSpacing Kind = "letterSpacing"
KindDuration Kind = "duration"
KindCubicBezier Kind = "cubicBezier"
KindNumber Kind = "number"
KindShadow Kind = "shadow"
KindFontStyle Kind = "fontStyle"
)
// Valid reports whether k is a known kind.
func (k Kind) Valid() bool {
switch k {
case KindColor, KindDimension, KindFontFamily, KindFontSize, KindFontWeight,
KindLineHeight, KindLetterSpacing, KindDuration, KindCubicBezier,
KindNumber, KindShadow, KindFontStyle:
return true
default:
return false
}
}
// UnitSuffix is appended to raw numeric values of unit-bearing kinds when
// rendering CSS, so a bare `4` in a `space` group becomes `4px`.
func (k Kind) UnitSuffix() string {
switch k {
case KindDimension, KindFontSize, KindLetterSpacing:
return "px"
case KindDuration:
return "ms"
default:
return ""
}
}
// ValueKey and TypeKey are the DTCG property names.
const (
ValueKey = "$value"
TypeKey = "$type"
// DescKey carries human documentation for a token.
DescKey = "$description"
// ExtensionsKey holds non-standard metadata that the engine ignores.
ExtensionsKey = "$extensions"
// SchemaKey identifies the token document format.
SchemaKey = "$schema"
// AppearancesKey holds per-appearance token overrides. It is the only
// structural key that is not itself a token group.
AppearancesKey = "$appearances"
)
// SchemaID identifies the canonical document format.
const SchemaID = "https://urpi.be/schemas/go-bananas/tokens/v1"
// VariablePrefix namespaces every custom property this engine emits so that a
// consuming application can never collide with go-bananas' own CSS.
const VariablePrefix = "--bn"
// ThemeAttribute is the data attribute that scopes a theme to a subtree.
// Setting it on <html> or <body> is the supported way to apply a theme.
const ThemeAttribute = "data-bananas-theme"
// AppearanceAttribute selects the colour scheme within a themed subtree.
const AppearanceAttribute = "data-bananas-appearance"
// darkClass is the conventional class name used by Tailwind and most design
// systems for "this subtree is dark". The generated CSS honours it in addition
// to the explicit appearance attribute so that consumers do not have to choose.
const darkClass = "dark"
// GroupSpec describes one top-level token group.
type GroupSpec struct {
// Name is the group key as it appears in the document.
Name string
// Kind is the default type applied to leaves that omit "$type".
Kind Kind
// Freeform allows an arbitrary number of nested levels below the group.
Freeform bool
// Required is the minimum depth: paths listed here must exist.
Required []string
// Recommended paths are validated when present but never required. They
// drive warnings in the editor rather than hard failures.
Recommended []string
// Description is shown in the editor UI and emitted as $description.
Description string
}
// paletteShades is the canonical 11-step scale every generated palette defines.
var paletteShades = []string{"50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"}
// StandardPaletteShades returns a copy of the canonical scale steps.
func StandardPaletteShades() []string {
out := make([]string, len(paletteShades))
copy(out, paletteShades)
return out
}
// Groups is the canonical group registry, in the order the editor renders them.
var Groups = []GroupSpec{
{
Name: "color",
Kind: KindColor,
Freeform: true,
Description: "Raw palettes and semantic colour roles. Semantic roles are what components consume.",
Recommended: []string{
"palette.brand." + "500",
"palette.brand.600",
"palette.neutral.500",
"palette.neutral.900",
"semantic.background",
"semantic.surface",
"semantic.surface-raised",
"semantic.border",
"semantic.border-strong",
"semantic.text",
"semantic.text-muted",
"semantic.text-subtle",
"semantic.text-inverted",
"semantic.primary",
"semantic.primary-hover",
"semantic.primary-active",
"semantic.on-primary",
"semantic.secondary",
"semantic.accent",
"semantic.on-accent",
"semantic.success",
"semantic.warning",
"semantic.danger",
"semantic.info",
"semantic.overlay",
"semantic.focus-ring",
},
},
{
Name: "font",
Kind: KindDimension,
Freeform: true,
Recommended: []string{"family.sans", "family.mono", "size.sm", "size.md", "size.lg", "size.xl", "weight.normal", "weight.medium", "weight.semibold", "weight.bold", "lineHeight.tight", "lineHeight.normal", "lineHeight.relaxed", "letterSpacing.tight", "letterSpacing.normal", "letterSpacing.wide"},
},
{
Name: "space",
Kind: KindDimension,
Freeform: true,
Recommended: []string{"0", "1", "2", "3", "4", "5", "6", "8", "10", "12", "16"},
},
{
Name: "radius",
Kind: KindDimension,
Freeform: true,
Recommended: []string{"none", "sm", "md", "lg", "xl", "full"},
},
{
Name: "border",
Kind: KindDimension,
Freeform: true,
Recommended: []string{"width.thin", "width.default", "width.thick", "color.default", "color.strong"},
},
{
Name: "shadow",
Kind: KindShadow,
Freeform: true,
Recommended: []string{"none", "sm", "md", "lg", "xl"},
},
{
Name: "duration",
Kind: KindDuration,
Freeform: true,
Recommended: []string{"instant", "fast", "normal", "slow"},
},
{
Name: "ease",
Kind: KindCubicBezier,
Freeform: true,
Recommended: []string{"linear", "standard", "entrance", "exit"},
},
{
Name: "zIndex",
Kind: KindNumber,
Freeform: true,
Recommended: []string{"base", "dropdown", "sticky", "overlay", "modal", "toast"},
},
{
Name: "opacity",
Kind: KindNumber,
Freeform: true,
Recommended: []string{"disabled", "muted", "strong"},
},
{
Name: "breakpoint",
Kind: KindDimension,
Freeform: true,
Recommended: []string{"sm", "md", "lg", "xl", "2xl"},
},
}
// ReservedKeys are document-level keys that are not token groups.
var ReservedKeys = map[string]struct{}{
SchemaKey: {},
AppearancesKey: {},
DescKey: {},
ExtensionsKey: {},
}
// LookupGroup returns the spec for a group name.
func LookupGroup(name string) (GroupSpec, bool) {
for _, g := range Groups {
if g.Name == name {
return g, true
}
}
return GroupSpec{}, false
}
// GroupNames returns the canonical group names in render order.
func GroupNames() []string {
out := make([]string, len(Groups))
for i, g := range Groups {
out[i] = g.Name
}
return out
}
// paletteNames returns the palette identifiers defined in a document, sorted so
// that generated CSS is byte-stable.
func paletteNames(doc map[string]any) []string {
colorGroup, ok := asMap(doc["color"])
if !ok {
return nil
}
var names []string
for key := range colorGroup {
if key == "semantic" {
continue
}
if _, isGroup := asMap(colorGroup[key]); isGroup {
names = append(names, key)
}
}
sort.Strings(names)
return names
}
// violation is an internal validation finding.
type violation struct {
// Path is the dotted token path, e.g. "color.semantic.background".
Path string
// Severity separates blocking problems from style advice.
Severity Severity
// Message explains the problem in one sentence.
Message string
// Hint suggests a concrete fix.
Hint string
}
// Severity classifies a validation finding.
type Severity string
const (
// SeverityError blocks publication.
SeverityError Severity = "error"
// SeverityWarning does not block publication but should be addressed.
SeverityWarning Severity = "warning"
// SeverityInfo is purely advisory.
SeverityInfo Severity = "info"
)
// Issue is a single validation finding, safe to serialise straight to the UI.
type Issue struct {
Path string `json:"path"`
Severity Severity `json:"severity"`
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
}
// ValidationResult aggregates every finding for a document.
type ValidationResult struct {
// Valid is true when there are no error-severity issues.
Valid bool `json:"valid"`
// TokenCount is the number of leaf tokens discovered.
TokenCount int `json:"tokenCount"`
// Issues is ordered by severity then path so output is deterministic.
Issues []Issue `json:"issues"`
}
// Errors returns only the blocking issues.
func (v ValidationResult) Errors() []Issue {
out := make([]Issue, 0, len(v.Issues))
for _, i := range v.Issues {
if i.Severity == SeverityError {
out = append(out, i)
}
}
return out
}
// Warnings returns only the advisory issues.
func (v ValidationResult) Warnings() []Issue {
out := make([]Issue, 0, len(v.Issues))
for _, i := range v.Issues {
if i.Severity == SeverityWarning {
out = append(out, i)
}
}
return out
}
// severityRank orders severities for deterministic sorting.
func severityRank(s Severity) int {
switch s {
case SeverityError:
return 0
case SeverityWarning:
return 1
default:
return 2
}
}
// asMap extracts a nested object from an untyped token document.
//
// Both a plain map and a domain.TokenDoc are accepted. The distinction matters:
// a type assertion to map[string]any fails for a named map type, so without this
// second case every document handed to the engine as a domain.TokenDoc would be
// silently reported as "not a group".
func asMap(v any) (map[string]any, bool) {
switch t := v.(type) {
case map[string]any:
return t, true
case domain.TokenDoc:
return map[string]any(t), true
default:
return nil, false
}
}
// keyAliases maps authored camelCase token keys onto their kebab-case CSS
// equivalents. Only keys the DTCG spec spells in camelCase need an entry; the
// mapping is an explicit allow-list rather than a generic camelCase splitter so
// that a token genuinely named "brandDark" cannot be silently rewritten.
var keyAliases = map[string]string{
"lineHeight": "line-height",
"fontSize": "font-size",
"letterSpacing": "letter-spacing",
"fontFamily": "font-family",
"fontWeight": "font-weight",
"cubicBezier": "cubic-bezier",
"borderRadius": "border-radius",
"zIndex": "z-index",
"surfaceMuted": "surface-muted",
"fontStyle": "font-style",
"lineScale": "line-scale",
"seedVersion": "seed-version",
}
// normalizeKey applies the alias table to one authored token key.
func normalizeKey(key string) string {
if alias, ok := keyAliases[key]; ok {
return alias
}
return key
}
// isLeaf reports whether a node is a token leaf rather than a nested group.
func isLeaf(node map[string]any) bool {
_, hasValue := node[ValueKey]
return hasValue
}
// cssVarSegment normalises one path segment for use in a custom property name.
func cssVarSegment(s string) string {
s = normalizeKey(s)
var b strings.Builder
b.Grow(len(s))
prevDash := false
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
prevDash = false
case r == '-' || r == '_' || r == ' ' || r == '.' || r == '/':
// Collapse every separator class into a single dash so that
// "line Height" and "line-height" cannot produce two variables.
if !prevDash && b.Len() > 0 {
b.WriteByte('-')
prevDash = true
}
default:
if !prevDash && b.Len() > 0 {
b.WriteByte('-')
prevDash = true
}
}
}
return strings.Trim(b.String(), "-")
}
// VariableName builds the CSS custom property for a dotted token path.
func VariableName(path string) string {
segments := strings.Split(path, ".")
cleaned := make([]string, 0, len(segments))
for _, s := range segments {
if c := cssVarSegment(s); c != "" {
cleaned = append(cleaned, c)
}
}
if len(cleaned) == 0 {
return VariablePrefix
}
return VariablePrefix + "-" + strings.Join(cleaned, "-")
}
// selectorSafe converts a token path into something usable inside a CSS
// attribute selector value.
func selectorSafe(s string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
return r
default:
return '-'
}
}, s)
}
// NumberHint returns the canonical shade list, exposed for the editor's palette
// builder so the UI and the validator never disagree.
func NumberHint() []string { return StandardPaletteShades() }
// validateKindValue checks a single leaf value against its kind, returning an
// error describing the problem or nil when the value is acceptable.
func validateKindValue(kind Kind, value any) error {
switch kind {
case KindColor:
s, ok := value.(string)
if !ok {
return fmt.Errorf("expected a colour string, got %T", value)
}
if _, err := ParseColor(s); err != nil {
return err
}
case KindDimension, KindFontSize, KindLetterSpacing:
if err := validateCSSLength(value); err != nil {
return err
}
case KindDuration:
if err := validateCSSLength(value); err != nil {
return err
}
case KindNumber, KindFontWeight, KindLineHeight:
if err := validateNumeric(value); err != nil {
return err
}
case KindCubicBezier:
parts, ok := value.([]any)
if !ok {
return fmt.Errorf("expected a 4-number array, got %T", value)
}
if len(parts) != 4 {
return fmt.Errorf("expected 4 numbers, got %d", len(parts))
}
for i, p := range parts {
if err := validateNumeric(p); err != nil {
return fmt.Errorf("component %d: %w", i, err)
}
}
case KindShadow:
if _, ok := value.(string); !ok {
return fmt.Errorf("expected a CSS box-shadow string, got %T", value)
}
case KindFontFamily:
if _, ok := value.(string); !ok {
return fmt.Errorf("expected a font stack string, got %T", value)
}
case KindFontStyle:
if _, ok := value.(string); !ok {
return fmt.Errorf("expected a font style keyword, got %T", value)
}
default:
// Unknown kinds are tolerated so that consumers can introduce their own
// token types without the engine needing a release first.
}
return nil
}

View file

@ -0,0 +1,751 @@
package tokenengine
import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"github.com/urpi/go-bananas/internal/domain"
)
// FlatToken is one resolved token: its dotted path, its resolved type and the
// exact CSS-ready value.
type FlatToken struct {
// Path is the dotted token path, e.g. "color.semantic.background".
Path string
// Kind is the resolved token type.
Kind Kind
// Value is the raw author-supplied value.
Value any
// CSS is the value as it appears in a custom property declaration.
CSS string
// Description is the token's $description, if any.
Description string
// Derived marks tokens computed by the engine rather than authored.
Derived bool
}
// Doc is a parsed, resolved token document for one appearance.
type Doc struct {
// Theme is the theme the document was derived from.
Theme *domain.Theme
// Appearance is the colour scheme that was resolved.
Appearance domain.Appearance
// Tokens are the leaves in deterministic path order.
Tokens []FlatToken
// byPath indexes Tokens for O(1) lookup.
byPath map[string]FlatToken
// palettes lists palette names discovered in the document.
palettes []string
}
// Lookup returns a token by dotted path.
func (d *Doc) Lookup(path string) (FlatToken, bool) {
t, ok := d.byPath[path]
return t, ok
}
// Palettes returns the palette names present in the document.
func (d *Doc) Palettes() []string { return d.palettes }
// PalettesOf returns the shade steps defined for one palette, sorted so that
// scale output is deterministic.
func (d *Doc) PalettesOf(palette string) []string {
var steps []string
prefix := "color." + palette + "."
for _, t := range d.Tokens {
if strings.HasPrefix(t.Path, prefix) {
steps = append(steps, strings.TrimPrefix(t.Path, prefix))
}
}
sort.Slice(steps, func(i, j int) bool { return shadeLess(steps[i], steps[j]) })
return steps
}
// Variables renders the document as CSS custom property name to value.
func (d *Doc) Variables() map[string]string {
out := make(map[string]string, len(d.Tokens))
for _, t := range d.Tokens {
out[VariableName(t.Path)] = t.CSS
}
return out
}
// VariableOrder returns the CSS variable names in deterministic order, which is
// the order the generated stylesheet uses.
func (d *Doc) VariableOrder() []string {
out := make([]string, 0, len(d.Tokens))
for _, t := range d.Tokens {
out = append(out, VariableName(t.Path))
}
return out
}
// shadeLess orders numeric shades numerically and anything else lexically, so
// 50, 100, 200 ... 950 and custom names both come out sensibly.
func shadeLess(a, b string) bool {
an, aerr := strconv.Atoi(a)
bn, berr := strconv.Atoi(b)
if aerr == nil && berr == nil {
return an < bn
}
if aerr == nil {
return true
}
if berr == nil {
return false
}
return a < b
}
// Resolve merges the base token document with the overrides for the requested
// appearance and returns a flat, ordered token list.
//
// Overrides are a partial document of the same shape as the base, deep-merged
// one level at a time. A leaf in the override replaces the corresponding leaf
// in the base; a nested object in the override merges with its counterpart.
func Resolve(theme *domain.Theme, appearance domain.Appearance) *Doc {
base := theme.Tokens.Clone()
if base == nil {
base = domain.TokenDoc{}
}
merged := applyOverrides(base, overridesFor(base, appearance))
doc := &Doc{
Theme: theme,
Appearance: appearance,
byPath: make(map[string]FlatToken),
palettes: paletteNames(merged),
}
collect(merged, "", nil, doc)
sort.Slice(doc.Tokens, func(i, j int) bool { return doc.Tokens[i].Path < doc.Tokens[j].Path })
for _, t := range doc.Tokens {
doc.byPath[t.Path] = t
}
return doc
}
// overridesFor extracts the override sub-document for one appearance.
func overridesFor(doc domain.TokenDoc, appearance domain.Appearance) map[string]any {
appearances, ok := asMap(doc[AppearancesKey])
if !ok {
return nil
}
override, _ := asMap(appearances[string(appearance)])
return override
}
// applyOverrides deep-merges override into base, returning a new document.
func applyOverrides(base domain.TokenDoc, override map[string]any) domain.TokenDoc {
out := make(domain.TokenDoc, len(base))
for k, v := range base {
out[k] = v
}
for k, v := range override {
if overrideMap, ok := asMap(v); ok {
if baseMap, ok := asMap(out[k]); ok {
out[k] = map[string]any(applyOverrides(domain.TokenDoc(baseMap), overrideMap))
continue
}
}
out[k] = v
}
return out
}
// inheritedKind carries the type down the tree so that a group may declare
// "$type" once and have every leaf inherit it, as DTCG specifies.
func collect(node map[string]any, prefix string, inherited *Kind, doc *Doc) {
if node == nil {
return
}
if isLeaf(node) {
collectLeaf(node, prefix, inherited, doc)
return
}
if raw, ok := node[TypeKey].(string); ok {
k := Kind(raw)
inherited = &k
}
keys := make([]string, 0, len(node))
for k := range node {
if strings.HasPrefix(k, "$") {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
child, ok := asMap(node[k])
if !ok {
continue
}
path := k
if prefix != "" {
path = prefix + "." + k
}
collect(child, path, inherited, doc)
}
}
// collectLeaf records a single token leaf.
func collectLeaf(node map[string]any, path string, inherited *Kind, doc *Doc) {
kind := Kind("")
if inherited != nil {
kind = *inherited
}
if raw, ok := node[TypeKey].(string); ok {
kind = Kind(raw)
}
value := node[ValueKey]
if kind == "" {
kind = inferKind(value)
}
if kind == "" {
return
}
t := FlatToken{
Path: path,
Kind: kind,
Value: value,
CSS: renderValue(kind, value),
}
if d, ok := node[DescKey].(string); ok {
t.Description = d
}
doc.Tokens = append(doc.Tokens, t)
doc.byPath[path] = t
}
// inferKind guesses a leaf's type when neither the leaf nor an ancestor
// declared one. Guessing keeps hand-written documents usable; validation still
// reports the inferred type so authors can make it explicit.
func inferKind(v any) Kind {
switch t := v.(type) {
case string:
if _, err := ParseColor(t); err == nil && strings.HasPrefix(strings.TrimSpace(t), "#") {
return KindColor
}
if strings.ContainsAny(t, "()") {
return KindShadow
}
return KindFontFamily
case float64, int, json.Number:
return KindNumber
case []any:
return KindCubicBezier
default:
return ""
}
}
// renderValue converts a token value into its CSS form, appending the
// canonical unit for unit-bearing kinds when the author omitted it.
func renderValue(kind Kind, value any) string {
switch kind {
case KindDimension, KindFontSize, KindLetterSpacing, KindDuration:
if n, ok := toFloat(value); ok {
return FormatNumber(n) + kind.UnitSuffix()
}
case KindNumber, KindFontWeight, KindLineHeight:
if n, ok := toFloat(value); ok {
return FormatNumber(n)
}
}
return stringify(value)
}
// stringify renders any supported value as a CSS literal.
func stringify(value any) string {
switch t := value.(type) {
case nil:
return ""
case string:
return t
case bool:
return strconv.FormatBool(t)
case float64:
return FormatNumber(t)
case int:
return FormatNumber(float64(t))
case json.Number:
return t.String()
case []any:
parts := make([]string, len(t))
for i, p := range t {
parts[i] = stringify(p)
}
return strings.Join(parts, ", ")
default:
// Complex values are stored as pre-formatted CSS strings by the editor,
// so this is the only safe rendering.
return fmt.Sprint(t)
}
}
func toFloat(v any) (float64, bool) {
switch t := v.(type) {
case float64:
return t, true
case int:
return float64(t), true
case int64:
return float64(t), true
case json.Number:
f, err := t.Float64()
return f, err == nil
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(t), 64)
return f, err == nil
default:
return 0, false
}
}
// validateNumeric accepts a JSON number or a numeric string.
func validateNumeric(v any) error {
switch t := v.(type) {
case float64:
if math.IsNaN(t) || math.IsInf(t, 0) {
return fmt.Errorf("value must be finite")
}
return nil
case int, int64:
return nil
case json.Number:
f, err := t.Float64()
if err != nil {
return fmt.Errorf("value %q is not numeric", t.String())
}
if math.IsNaN(f) || math.IsInf(f, 0) {
return fmt.Errorf("value must be finite")
}
return nil
case string:
if _, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err != nil {
return fmt.Errorf("value %q is not numeric", t)
}
return nil
default:
return fmt.Errorf("expected a number, got %T", v)
}
}
// cssLengthUnits are the units accepted for a bare dimension value.
var cssLengthUnits = []string{"px", "rem", "em", "%", "vh", "vw", "vmin", "vmax", "ch", "ex", "cm", "mm", "in", "pt", "pc", "svh", "lvh", "dvh", "ms", "s", "fr"}
// validateCSSLength accepts a number, a numeric string with a unit, or one of
// the CSS global keywords.
func validateCSSLength(v any) error {
if err := validateNumeric(v); err != nil {
if s, ok := v.(string); ok {
trimmed := strings.ToLower(strings.TrimSpace(s))
switch trimmed {
case "auto", "min-content", "max-content", "fit-content", "inherit", "initial", "unset", "normal":
return nil
}
for _, unit := range cssLengthUnits {
if strings.HasSuffix(trimmed, unit) {
if _, err := strconv.ParseFloat(strings.TrimSuffix(trimmed, unit), 64); err == nil {
return nil
}
}
}
if strings.HasPrefix(trimmed, "clamp(") || strings.HasPrefix(trimmed, "calc(") ||
strings.HasPrefix(trimmed, "min(") || strings.HasPrefix(trimmed, "max(") {
// Function-based values are opaque to the validator; the browser
// is the authority and will discard anything malformed.
return nil
}
return fmt.Errorf("value %q is not a valid length", s)
}
return err
}
return nil
}
// Validate checks a token document and returns every finding.
func Validate(doc domain.TokenDoc) ValidationResult {
result := ValidationResult{Valid: true, Issues: []Issue{}}
issues := make([]violation, 0, 32)
known := make(map[string]GroupSpec, len(Groups))
for _, g := range Groups {
known[g.Name] = g
}
for key, raw := range doc {
if _, reserved := ReservedKeys[key]; reserved {
continue
}
node, ok := asMap(raw)
if !ok {
issues = append(issues, violation{
Path: key, Severity: SeverityError,
Message: fmt.Sprintf("group %q must be an object", key),
Hint: "top-level keys are token groups, or one of " + strings.Join(sortedReservedKeys(), ", "),
})
continue
}
spec, ok := known[key]
if !ok {
issues = append(issues, violation{
Path: key, Severity: SeverityError,
Message: fmt.Sprintf("unknown token group %q", key),
Hint: "known groups: " + strings.Join(GroupNames(), ", "),
})
continue
}
validateGroup(spec, node, key, &issues)
}
if appearances, ok := asMap(doc[AppearancesKey]); ok {
for name, raw := range appearances {
if name != string(domain.AppearanceLight) && name != string(domain.AppearanceDark) {
issues = append(issues, violation{
Path: AppearancesKey + "." + name, Severity: SeverityError,
Message: fmt.Sprintf("unknown appearance %q", name),
Hint: "use \"light\" or \"dark\"",
})
continue
}
node, ok := asMap(raw)
if !ok {
issues = append(issues, violation{
Path: AppearancesKey + "." + name, Severity: SeverityError,
Message: "appearance overrides must be an object",
})
continue
}
for group, groupRaw := range node {
spec, ok := known[group]
if !ok {
issues = append(issues, violation{
Path: AppearancesKey + "." + name + "." + group, Severity: SeverityError,
Message: fmt.Sprintf("unknown token group %q", group),
})
continue
}
groupNode, ok := asMap(groupRaw)
if !ok {
continue
}
// Overrides are partial by design: only value validity is
// checked, never completeness.
validateLeaves(spec, groupNode, spec.Kind, group, AppearancesKey+"."+name+"."+group, &issues)
}
}
} else if _, present := doc[AppearancesKey]; present {
issues = append(issues, violation{
Path: AppearancesKey, Severity: SeverityError,
Message: AppearancesKey + " must be an object keyed by appearance",
Hint: `example: {"dark": {"color": {"semantic": {"background": {"$value": "#0b0f19"}}}}}`,
})
}
// A theme with no dark overrides will render identically in both schemes,
// which is almost always a mistake for a theme that declares a dark
// appearance as its default.
appearancesMap, _ := asMap(doc[AppearancesKey])
if _, hasDark := asMap(appearancesMap[string(domain.AppearanceDark)]); !hasDark {
issues = append(issues, violation{
Path: AppearancesKey, Severity: SeverityWarning,
Message: "no dark appearance overrides are defined",
Hint: `add {"$appearances": {"dark": {...}}} so the dark scheme is intentional rather than accidental`,
})
}
resolved := countLeaves(doc)
result.TokenCount = resolved
result.Issues = toIssues(issues)
for _, i := range result.Issues {
if i.Severity == SeverityError {
result.Valid = false
break
}
}
return result
}
// toIssues converts and deterministically orders internal violations.
func toIssues(in []violation) []Issue {
out := make([]Issue, 0, len(in))
for _, v := range in {
out = append(out, Issue(v))
}
sort.SliceStable(out, func(i, j int) bool {
if ri, rj := severityRank(out[i].Severity), severityRank(out[j].Severity); ri != rj {
return ri < rj
}
return out[i].Path < out[j].Path
})
return out
}
// sortedReservedKeys returns the reserved document keys in sorted order.
func sortedReservedKeys() []string {
out := make([]string, 0, len(ReservedKeys))
for k := range ReservedKeys {
out = append(out, k)
}
sort.Strings(out)
return out
}
// validateGroup checks one group's required and recommended tokens.
func validateGroup(spec GroupSpec, node map[string]any, prefix string, issues *[]violation) {
validateLeaves(spec, node, spec.Kind, prefix, prefix, issues)
for _, path := range spec.Recommended {
if _, err := LookupIn(node, path); err != nil {
*issues = append(*issues, violation{
Path: prefix + "." + path, Severity: SeverityWarning,
Message: fmt.Sprintf("recommended token %q is missing", path),
Hint: spec.Description,
})
}
}
}
// validateLeaves walks a group tree checking every leaf value's validity.
func validateLeaves(spec GroupSpec, node map[string]any, kindDefault Kind, prefix, path string, issues *[]violation) {
if isLeaf(node) {
kind := kindDefault
if raw, ok := node[TypeKey].(string); ok {
kind = Kind(raw)
} else if inferred := inferKind(node[ValueKey]); inferred != "" && kind == kindDefault {
kind = inferred
}
if !kind.Valid() {
*issues = append(*issues, violation{
Path: path, Severity: SeverityError,
Message: fmt.Sprintf("unknown $type %q", kind),
Hint: "omit $type to inherit the group's type",
})
return
}
if err := validateKindValue(kind, node[ValueKey]); err != nil {
*issues = append(*issues, violation{
Path: path, Severity: SeverityError,
Message: err.Error(),
Hint: fmt.Sprintf("expected a %s value", kind),
})
}
return
}
inherited := kindDefault
if raw, ok := node[TypeKey].(string); ok {
inherited = Kind(raw)
}
for _, key := range sortedMapKeys(node) {
if strings.HasPrefix(key, "$") {
continue
}
child, ok := asMap(node[key])
if !ok {
*issues = append(*issues, violation{
Path: path + "." + key, Severity: SeverityError,
Message: fmt.Sprintf("token must be an object with a %q property, got %T", ValueKey, node[key]),
Hint: `example: {"$type": "color", "$value": "#6366f1"}`,
})
continue
}
validateLeaves(spec, child, inherited, prefix, path+"."+key, issues)
}
}
// sortedMapKeys returns the keys of m in sorted order.
func sortedMapKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// LookupIn walks a token document by dotted path and returns the leaf.
func LookupIn(doc map[string]any, path string) (map[string]any, error) {
var current any = doc
for _, segment := range strings.Split(path, ".") {
node, ok := asMap(current)
if !ok {
return nil, fmt.Errorf("path %q: %q is not a group", path, segment)
}
next, exists := node[segment]
if !exists {
return nil, fmt.Errorf("path %q: %q not found", path, segment)
}
current = next
}
leaf, ok := asMap(current)
if !ok || !isLeaf(leaf) {
return nil, fmt.Errorf("path %q is not a token leaf", path)
}
return leaf, nil
}
// SetIn writes a token leaf at a dotted path, creating intermediate groups.
func SetIn(doc domain.TokenDoc, path string, value any, kind Kind, description string) error {
segments := strings.Split(path, ".")
if len(segments) == 0 || segments[0] == "" {
return fmt.Errorf("%w: empty token path", domain.ErrInvalid)
}
// Reject paths that would collide with structural keys.
for i, seg := range segments {
if _, reserved := ReservedKeys[seg]; reserved && i == 0 {
return fmt.Errorf("%w: %q is a reserved key", domain.ErrInvalid, seg)
}
if seg == "" {
return fmt.Errorf("%w: token path %q has an empty segment", domain.ErrInvalid, path)
}
}
if _, ok := LookupGroup(segments[0]); !ok {
return fmt.Errorf("%w: unknown token group %q", domain.ErrInvalid, segments[0])
}
current := doc
for _, seg := range segments[:len(segments)-1] {
child, ok := asMap(current[seg])
if !ok {
child = map[string]any{}
current[seg] = child
}
current = child
}
leaf := map[string]any{ValueKey: value, TypeKey: string(kind)}
if description != "" {
leaf[DescKey] = description
}
current[segments[len(segments)-1]] = leaf
return nil
}
// DeleteIn removes a token leaf at a dotted path. It reports whether the token
// existed so callers can distinguish a no-op delete from a real one.
func DeleteIn(doc domain.TokenDoc, path string) (bool, error) {
segments := strings.Split(path, ".")
if len(segments) < 2 {
return false, fmt.Errorf("%w: refusing to delete top-level group %q", domain.ErrInvalid, path)
}
current := any(doc)
for _, seg := range segments[:len(segments)-1] {
node, ok := asMap(current)
if !ok {
return false, nil
}
child, exists := node[seg]
if !exists {
return false, nil
}
current = child
}
node, ok := asMap(current)
if !ok {
return false, nil
}
last := segments[len(segments)-1]
if _, exists := node[last]; !exists {
return false, nil
}
delete(node, last)
pruneEmpty(node)
return true, nil
}
// pruneEmpty removes group objects left empty by a delete, so that the stored
// document does not accumulate `{}` noise that would fail validation.
func pruneEmpty(node map[string]any) {
for key, raw := range node {
child, ok := asMap(raw)
if !ok || len(child) == 0 {
if ok && len(child) == 0 {
delete(node, key)
}
continue
}
pruneEmpty(child)
if len(child) == 0 {
delete(node, key)
}
}
}
// countLeaves counts leaf tokens, excluding appearance overrides.
func countLeaves(node map[string]any) int {
total := 0
for key, raw := range node {
if key == AppearancesKey || strings.HasPrefix(key, "$") {
continue
}
child, ok := asMap(raw)
if !ok {
continue
}
if isLeaf(child) {
total++
continue
}
total += countLeaves(child)
}
return total
}
// RenameToken moves a token leaf to a new dotted path, preserving its value and
// description. It refuses to clobber an existing token unless overwrite is set.
func RenameToken(doc domain.TokenDoc, from, to string, overwrite bool) error {
leaf, err := LookupIn(doc, from)
if err != nil {
return fmt.Errorf("%w: %s", domain.ErrNotFound, err.Error())
}
if _, err := LookupIn(doc, to); err == nil && !overwrite {
return fmt.Errorf("%w: token %q already exists", domain.ErrConflict, to)
}
if _, err := DeleteIn(doc, from); err != nil {
return err
}
value := leaf[ValueKey]
description, _ := leaf[DescKey].(string)
kind, ok := leaf[TypeKey].(string)
if !ok {
kind = string(inferKind(value))
}
return SetIn(doc, to, value, Kind(kind), description)
}
// EnsureDefaults fills any missing recommended token with the supplied default.
// It is used when duplicating a theme so the copy is immediately usable, and is
// deliberately not used on the request path for existing themes.
func EnsureDefaults(doc domain.TokenDoc, defaults domain.TokenDoc) int {
applied := 0
for key, raw := range defaults {
if _, exists := doc[key]; exists {
continue
}
if err := deepMergeMissing(doc, key, raw); err == nil {
applied++
}
}
return applied
}
// deepMergeMissing merges src into dst only where dst has no value.
func deepMergeMissing(dst domain.TokenDoc, key string, src any) error {
srcMap, srcOK := asMap(src)
dstMap, dstOK := asMap(dst[key])
if srcOK && dstOK && !isLeaf(srcMap) {
for k, v := range srcMap {
if err := deepMergeMissing(dstMap, k, v); err != nil {
return err
}
}
return nil
}
dst[key] = src
return nil
}

View file

@ -0,0 +1,251 @@
package tokenengine
import (
"strings"
"testing"
"github.com/urpi/go-bananas/internal/domain"
)
// testTheme builds a minimal but complete theme for engine tests.
func testTheme() *domain.Theme {
return &domain.Theme{
Slug: "acme",
Name: "Acme",
Status: domain.StatusPublished,
DefaultAppearance: domain.AppearanceLight,
Version: 3,
Tokens: domain.TokenDoc{
"color": map[string]any{
"palette": map[string]any{
"brand": map[string]any{
"500": map[string]any{TypeKey: string(KindColor), ValueKey: "#4f46e5"},
"600": map[string]any{TypeKey: string(KindColor), ValueKey: "#4338ca"},
},
},
"semantic": map[string]any{
"background": map[string]any{TypeKey: string(KindColor), ValueKey: "#ffffff"},
"surface": map[string]any{TypeKey: string(KindColor), ValueKey: "#f8fafc"},
"text": map[string]any{TypeKey: string(KindColor), ValueKey: "#0f172a"},
"primary": map[string]any{TypeKey: string(KindColor), ValueKey: "#4f46e5"},
},
},
"font": map[string]any{
"family": map[string]any{
"sans": map[string]any{TypeKey: string(KindFamilyAlias()), ValueKey: "Inter, sans-serif"},
},
},
"space": map[string]any{
"4": map[string]any{TypeKey: string(KindDimension), ValueKey: 1},
},
},
}
}
// KindFamilyAlias exists only so the fixture reads clearly; it resolves to the
// canonical font-family kind.
func KindFamilyAlias() Kind { return KindFontFamily }
func TestResolveFlattensNestedTokens(t *testing.T) {
doc := Resolve(testTheme(), domain.AppearanceLight)
if got, ok := doc.Lookup("color.semantic.background"); !ok {
t.Fatalf("expected color.semantic.background to resolve, got %d tokens", len(doc.Tokens))
} else if got.CSS != "#ffffff" {
t.Errorf("background = %q, want #ffffff", got.CSS)
}
space, ok := doc.Lookup("space.4")
if !ok {
t.Fatal("expected space.4 to resolve")
}
// A bare number in a dimension group must pick up the px suffix.
if space.CSS != "1px" {
t.Errorf("space.4 = %q, want 1px", space.CSS)
}
}
func TestResolveIsDeterministic(t *testing.T) {
first := Resolve(testTheme(), domain.AppearanceLight)
second := Resolve(testTheme(), domain.AppearanceLight)
if len(first.Tokens) != len(second.Tokens) {
t.Fatalf("token count differs: %d vs %d", len(first.Tokens), len(second.Tokens))
}
for i := range first.Tokens {
if first.Tokens[i].Path != second.Tokens[i].Path {
t.Fatalf("token %d out of order: %q vs %q", i, first.Tokens[i].Path, second.Tokens[i].Path)
}
}
}
func TestAppearanceOverridesWinOverBase(t *testing.T) {
theme := testTheme()
theme.Tokens[AppearancesKey] = map[string]any{
"dark": map[string]any{
"color": map[string]any{
"semantic": map[string]any{
"background": map[string]any{TypeKey: string(KindColor), ValueKey: "#0b1220"},
},
},
},
}
light := Resolve(theme, domain.AppearanceLight)
dark := Resolve(theme, domain.AppearanceDark)
if got, _ := light.Lookup("color.semantic.background"); got.CSS != "#ffffff" {
t.Errorf("light background = %q, want the base value #ffffff", got.CSS)
}
if got, _ := dark.Lookup("color.semantic.background"); got.CSS != "#0b1220" {
t.Errorf("dark background = %q, want the override #0b1220", got.CSS)
}
// Tokens not overridden must fall through to the base value.
if got, _ := dark.Lookup("color.semantic.primary"); got.CSS != "#4f46e5" {
t.Errorf("dark primary = %q, want the base value #4f46e5", got.CSS)
}
}
func TestRenderEmitsScopedSelectorsAndDerivedTokens(t *testing.T) {
css := Render(testTheme(), RenderOptions{
Slug: "acme",
Appearance: domain.AppearanceLight,
Derive: DefaultDerive(),
Components: true,
})
for _, want := range []string{
`[data-bananas-theme="acme"]`,
`[data-bananas-theme="acme"][data-bananas-appearance=dark]`,
"--bn-color-semantic-background: #ffffff;",
// Derived foreground must pick white on a dark primary.
"--bn-color-on-semantic-primary: #ffffff;",
"--bn-color-semantic-primary-hover:",
"--bn-space-4: 1px;",
"@layer bananas",
".bn-btn",
`.dark [data-bananas-theme="acme"]`,
} {
if !strings.Contains(css, want) {
t.Errorf("rendered CSS missing %q\n---\n%s", want, css)
}
}
}
func TestRenderMinifiedOmitsCommentsAndWhitespace(t *testing.T) {
css := Render(testTheme(), RenderOptions{Slug: "acme", Minify: true, Derive: DefaultDerive()})
if strings.Contains(css, "/*") {
t.Error("minified CSS must not contain comments")
}
if strings.Contains(css, "\n\n") {
t.Error("minified CSS must not contain blank lines")
}
}
func TestValidateAcceptsACompleteTheme(t *testing.T) {
result := Validate(testTheme().Tokens)
for _, issue := range result.Errors() {
// The fixture is intentionally sparse; it must only trip the "unknown
// group" rule if the schema drifted, never on the tokens it defines.
if strings.Contains(issue.Message, "unknown token group") {
t.Errorf("unexpected schema error: %+v", issue)
}
}
if result.TokenCount != 8 {
t.Errorf("TokenCount = %d, want 8", result.TokenCount)
}
}
func TestValidateRejectsMalformedColour(t *testing.T) {
doc := testTheme().Tokens
doc["color"].(map[string]any)["semantic"].(map[string]any)["primary"] = map[string]any{
TypeKey: string(KindColor), ValueKey: "#nothex",
}
result := Validate(doc)
if result.Valid {
t.Fatal("expected validation to fail on a malformed colour")
}
found := false
for _, issue := range result.Issues {
if issue.Path == "color.semantic.primary" {
found = true
}
}
if !found {
t.Errorf("expected an issue on color.semantic.primary, got %+v", result.Issues)
}
}
func TestValidateRejectsUnknownGroup(t *testing.T) {
doc := testTheme().Tokens
doc["colour"] = map[string]any{"typo": map[string]any{ValueKey: "#fff"}}
result := Validate(doc)
if result.Valid {
t.Fatal("expected validation to reject an unknown top-level group")
}
}
func TestVariableNameNormalisation(t *testing.T) {
cases := map[string]string{
"color.semantic.background": "--bn-color-semantic-background",
"space.4": "--bn-space-4",
"font.lineHeight.tight": "--bn-font-line-height-tight",
"font.line height": "--bn-font-line-height",
"weird.Name_Here": "--bn-weird-name-here",
}
for path, want := range cases {
if got := VariableName(path); got != want {
t.Errorf("VariableName(%q) = %q, want %q", path, got, want)
}
}
}
func TestSetAndDeleteToken(t *testing.T) {
doc := testTheme().Tokens
if err := SetIn(doc, "space.6", 1.5, KindDimension, ""); err != nil {
t.Fatalf("SetIn: %v", err)
}
if got, err := LookupIn(doc, "space.6"); err != nil {
t.Fatalf("LookupIn: %v", err)
} else if got[ValueKey] != 1.5 {
t.Errorf("space.6 = %v, want 1.5", got[ValueKey])
}
deleted, err := DeleteIn(doc, "space.6")
if err != nil || !deleted {
t.Fatalf("DeleteIn = %v, %v; want true, nil", deleted, err)
}
if _, err := LookupIn(doc, "space.6"); err == nil {
t.Error("token should be gone after DeleteIn")
}
}
func TestSetInRejectsUnknownGroup(t *testing.T) {
doc := domain.TokenDoc{}
err := SetIn(doc, "colour.typo", "#fff", KindColor, "")
if err == nil {
t.Fatal("expected SetIn to reject an unknown group")
}
}
func TestJSONPayloadCarriesFlatAndNestedForms(t *testing.T) {
payload := JSONPayload(testTheme(), domain.AppearanceDark)
if payload.Theme.Slug != "acme" {
t.Errorf("slug = %q, want acme", payload.Theme.Slug)
}
if payload.Variable["--bn-color-semantic-primary"] != "#4f46e5" {
t.Errorf("flat map missing primary: %v", payload.Variable)
}
semantic, ok := payload.Tokens["color"].(map[string]any)
if !ok {
t.Fatal("nested tokens missing the color group")
}
if _, ok := semantic["semantic"]; !ok {
t.Error("nested tokens missing the semantic group")
}
}