From 951dd8d26b612c7bc24ce88d0e11e8ba8086ae9c Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Wed, 7 Oct 2026 15:11:35 +0200 Subject: [PATCH] initial commit --- .air.toml | 45 + .dockerignore | 76 + .editorconfig | 26 + .env.example | 166 + .gitattributes | 11 + .github/workflows/ci.yml | 289 + .gitignore | 54 + .golangci.yml | 275 + .prettierignore | 10 + .prettierrc.json | 12 + Dockerfile | 159 + Dockerfile.release | 68 + LICENSE | 21 + Makefile | 439 ++ README.md | 469 ++ cat | 0 cmd/go-bananas/main.go | 95 + compose.yaml | 143 + deploy/dev/realm-go-bananas.json | 120 + deploy/dev/values-dev-cluster.yaml | 90 + deploy/helm/go-bananas/.helmignore | 19 + deploy/helm/go-bananas/Chart.yaml | 36 + deploy/helm/go-bananas/templates/NOTES.txt | 89 + deploy/helm/go-bananas/templates/_helpers.tpl | 256 + .../helm/go-bananas/templates/configmap.yaml | 93 + .../helm/go-bananas/templates/deployment.yaml | 218 + deploy/helm/go-bananas/templates/hpa.yaml | 145 + deploy/helm/go-bananas/templates/ingress.yaml | 43 + deploy/helm/go-bananas/templates/pvc.yaml | 161 + deploy/helm/go-bananas/templates/secret.yaml | 63 + deploy/helm/go-bananas/templates/service.yaml | 64 + .../go-bananas/templates/serviceaccount.yaml | 56 + deploy/helm/go-bananas/values-dev.yaml | 44 + deploy/helm/go-bananas/values-lcl.yaml | 51 + deploy/helm/go-bananas/values-prd-fixed.yaml | 59 + deploy/helm/go-bananas/values-prd.yaml | 47 + deploy/helm/go-bananas/values-tst.yaml | 44 + deploy/helm/go-bananas/values.schema.json | 338 ++ deploy/helm/go-bananas/values.yaml | 481 ++ docker-bake.hcl | 90 + docs/adr/0001-embedded-sqlite.md | 176 + docs/adr/0002-runtime-delivery.md | 142 + docs/adr/0003-stateful-sessions.md | 101 + docs/adr/0004-typescript-version.md | 70 + docs/adr/0005-shared-token-contract.md | 119 + docs/api.md | 397 ++ docs/architecture.md | 348 ++ docs/deployment.md | 547 ++ docs/development.md | 223 + docs/integration.md | 565 ++ docs/operations.md | 273 + docs/security.md | 247 + docs/standards.md | 666 +++ docs/styling-contract.md | 217 + go.mod | 23 + go.sum | 58 + goreleaser.yaml | 197 + hack/check-contract.sh | 94 + hack/check-pilot-go-loose.sh | 153 + hack/check-pilot-go-tell.sh | 151 + hack/contract-check.js | 142 + hack/go.mod | 260 + hack/go.sum | 1029 ++++ hack/helm-matrix.sh | 135 + hack/integration-check.html | 76 + hack/pilot-go-loose.js | 124 + hack/pilot-go-tell.js | 167 + internal/app/app.go | 524 ++ internal/app/backup.go | 105 + internal/app/spa_test.go | 224 + internal/auth/auth.go | 715 +++ internal/auth/auth_test.go | 417 ++ internal/auth/helpers_test.go | 60 + internal/auth/hmac.go | 20 + internal/auth/oidc.go | 375 ++ internal/config/config.go | 572 ++ internal/domain/domain.go | 466 ++ internal/httpapi/admin.go | 931 ++++ internal/httpapi/auth_handlers.go | 282 + internal/httpapi/cache.go | 287 + internal/httpapi/embed_test.go | 225 + internal/httpapi/logging_middleware.go | 86 + internal/httpapi/metrics.go | 371 ++ internal/httpapi/middleware.go | 513 ++ internal/httpapi/preferences.go | 75 + internal/httpapi/preferences_test.go | 232 + internal/httpapi/respond.go | 195 + internal/httpapi/router.go | 280 + internal/httpapi/router_test.go | 421 ++ internal/httpapi/runtime.go | 761 +++ internal/logging/logging.go | 130 + internal/seed/consolidated.go | 605 +++ internal/seed/consolidated_test.go | 80 + internal/seed/seed.go | 586 ++ internal/seed/seed_test.go | 157 + internal/store/backup.go | 62 + internal/store/backup_test.go | 185 + internal/store/migrations.go | 250 + internal/store/migrations/0001_init.sql | 144 + .../store/migrations/0002_drop_api_keys.sql | 19 + .../migrations/0002_user_preferences.sql | 26 + internal/store/repositories.go | 369 ++ internal/store/sort.go | 13 + internal/store/store.go | 307 ++ internal/store/store_test.go | 717 +++ internal/store/themes.go | 733 +++ internal/store/user_preferences.go | 152 + internal/store/user_preferences_test.go | 262 + internal/tokenengine/color.go | 426 ++ internal/tokenengine/color_test.go | 216 + internal/tokenengine/contract.go | 311 ++ internal/tokenengine/contract_test.go | 82 + internal/tokenengine/css.go | 617 +++ internal/tokenengine/json.go | 165 + internal/tokenengine/palette.go | 147 + internal/tokenengine/schema.go | 508 ++ internal/tokenengine/tokens.go | 751 +++ internal/tokenengine/tokens_test.go | 251 + openapi.yaml | 2691 ++++++++++ sdk/eslint.config.js | 95 + sdk/package-lock.json | 2981 +++++++++++ sdk/package.json | 36 + sdk/src/components.js | 731 +++ sdk/src/components.test.js | 675 +++ sdk/src/contract.js | 275 + sdk/src/embed.js | 64 + sdk/src/global.js | 48 + sdk/src/react.js | 489 ++ sdk/src/runtime.js | 627 +++ sdk/src/runtime.test.js | 202 + sdk/tsconfig.json | 29 + sdk/vite.config.js | 51 + sdk/vitest.config.js | 11 + web/eslint.config.js | 129 + web/index.html | 29 + web/package-lock.json | 4692 +++++++++++++++++ web/package.json | 59 + web/public/favicon.svg | 5 + web/src/App.tsx | 78 + web/src/components/admin-theme-context.ts | 34 + web/src/components/admin-theme.test.tsx | 188 + web/src/components/admin-theme.tsx | 264 + web/src/components/providers.tsx | 39 + web/src/components/query-client.ts | 39 + web/src/components/shell.tsx | 526 ++ web/src/components/theme/preview.tsx | 401 ++ .../components/theme/token-control.test.tsx | 193 + web/src/components/theme/token-control.tsx | 343 ++ web/src/components/ui/index.tsx | 598 +++ web/src/components/use-admin-theme.ts | 19 + web/src/global.d.ts | 21 + web/src/index.css | 211 + web/src/lib/api.test.ts | 153 + web/src/lib/api.ts | 538 ++ web/src/lib/runtime-controls.ts | 60 + web/src/lib/tokens.test.ts | 252 + web/src/lib/tokens.ts | 305 ++ web/src/lib/types.ts | 315 ++ web/src/lib/utils.ts | 169 + web/src/main.tsx | 26 + web/src/routes/audit.tsx | 225 + web/src/routes/dashboard.tsx | 298 ++ web/src/routes/integrate.tsx | 264 + web/src/routes/login.tsx | 102 + web/src/routes/settings.tsx | 210 + web/src/routes/theme-detail.tsx | 1029 ++++ web/src/routes/theme-list.tsx | 591 +++ web/src/routes/versions.tsx | 338 ++ web/src/test/setup.ts | 46 + web/src/vite-env.d.ts | 40 + web/tsconfig.app.json | 31 + web/tsconfig.json | 7 + web/tsconfig.node.json | 24 + web/vite.config.ts | 52 + web/vitest.config.ts | 20 + 175 files changed, 50041 insertions(+) create mode 100644 .air.toml create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 Dockerfile create mode 100644 Dockerfile.release create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cat create mode 100644 cmd/go-bananas/main.go create mode 100644 compose.yaml create mode 100644 deploy/dev/realm-go-bananas.json create mode 100644 deploy/dev/values-dev-cluster.yaml create mode 100644 deploy/helm/go-bananas/.helmignore create mode 100644 deploy/helm/go-bananas/Chart.yaml create mode 100644 deploy/helm/go-bananas/templates/NOTES.txt create mode 100644 deploy/helm/go-bananas/templates/_helpers.tpl create mode 100644 deploy/helm/go-bananas/templates/configmap.yaml create mode 100644 deploy/helm/go-bananas/templates/deployment.yaml create mode 100644 deploy/helm/go-bananas/templates/hpa.yaml create mode 100644 deploy/helm/go-bananas/templates/ingress.yaml create mode 100644 deploy/helm/go-bananas/templates/pvc.yaml create mode 100644 deploy/helm/go-bananas/templates/secret.yaml create mode 100644 deploy/helm/go-bananas/templates/service.yaml create mode 100644 deploy/helm/go-bananas/templates/serviceaccount.yaml create mode 100644 deploy/helm/go-bananas/values-dev.yaml create mode 100644 deploy/helm/go-bananas/values-lcl.yaml create mode 100644 deploy/helm/go-bananas/values-prd-fixed.yaml create mode 100644 deploy/helm/go-bananas/values-prd.yaml create mode 100644 deploy/helm/go-bananas/values-tst.yaml create mode 100644 deploy/helm/go-bananas/values.schema.json create mode 100644 deploy/helm/go-bananas/values.yaml create mode 100644 docker-bake.hcl create mode 100644 docs/adr/0001-embedded-sqlite.md create mode 100644 docs/adr/0002-runtime-delivery.md create mode 100644 docs/adr/0003-stateful-sessions.md create mode 100644 docs/adr/0004-typescript-version.md create mode 100644 docs/adr/0005-shared-token-contract.md create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/deployment.md create mode 100644 docs/development.md create mode 100644 docs/integration.md create mode 100644 docs/operations.md create mode 100644 docs/security.md create mode 100644 docs/standards.md create mode 100644 docs/styling-contract.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 goreleaser.yaml create mode 100755 hack/check-contract.sh create mode 100755 hack/check-pilot-go-loose.sh create mode 100755 hack/check-pilot-go-tell.sh create mode 100644 hack/contract-check.js create mode 100644 hack/go.mod create mode 100644 hack/go.sum create mode 100755 hack/helm-matrix.sh create mode 100644 hack/integration-check.html create mode 100644 hack/pilot-go-loose.js create mode 100644 hack/pilot-go-tell.js create mode 100644 internal/app/app.go create mode 100644 internal/app/backup.go create mode 100644 internal/app/spa_test.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/helpers_test.go create mode 100644 internal/auth/hmac.go create mode 100644 internal/auth/oidc.go create mode 100644 internal/config/config.go create mode 100644 internal/domain/domain.go create mode 100644 internal/httpapi/admin.go create mode 100644 internal/httpapi/auth_handlers.go create mode 100644 internal/httpapi/cache.go create mode 100644 internal/httpapi/embed_test.go create mode 100644 internal/httpapi/logging_middleware.go create mode 100644 internal/httpapi/metrics.go create mode 100644 internal/httpapi/middleware.go create mode 100644 internal/httpapi/preferences.go create mode 100644 internal/httpapi/preferences_test.go create mode 100644 internal/httpapi/respond.go create mode 100644 internal/httpapi/router.go create mode 100644 internal/httpapi/router_test.go create mode 100644 internal/httpapi/runtime.go create mode 100644 internal/logging/logging.go create mode 100644 internal/seed/consolidated.go create mode 100644 internal/seed/consolidated_test.go create mode 100644 internal/seed/seed.go create mode 100644 internal/seed/seed_test.go create mode 100644 internal/store/backup.go create mode 100644 internal/store/backup_test.go create mode 100644 internal/store/migrations.go create mode 100644 internal/store/migrations/0001_init.sql create mode 100644 internal/store/migrations/0002_drop_api_keys.sql create mode 100644 internal/store/migrations/0002_user_preferences.sql create mode 100644 internal/store/repositories.go create mode 100644 internal/store/sort.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/store/themes.go create mode 100644 internal/store/user_preferences.go create mode 100644 internal/store/user_preferences_test.go create mode 100644 internal/tokenengine/color.go create mode 100644 internal/tokenengine/color_test.go create mode 100644 internal/tokenengine/contract.go create mode 100644 internal/tokenengine/contract_test.go create mode 100644 internal/tokenengine/css.go create mode 100644 internal/tokenengine/json.go create mode 100644 internal/tokenengine/palette.go create mode 100644 internal/tokenengine/schema.go create mode 100644 internal/tokenengine/tokens.go create mode 100644 internal/tokenengine/tokens_test.go create mode 100644 openapi.yaml create mode 100644 sdk/eslint.config.js create mode 100644 sdk/package-lock.json create mode 100644 sdk/package.json create mode 100644 sdk/src/components.js create mode 100644 sdk/src/components.test.js create mode 100644 sdk/src/contract.js create mode 100644 sdk/src/embed.js create mode 100644 sdk/src/global.js create mode 100644 sdk/src/react.js create mode 100644 sdk/src/runtime.js create mode 100644 sdk/src/runtime.test.js create mode 100644 sdk/tsconfig.json create mode 100644 sdk/vite.config.js create mode 100644 sdk/vitest.config.js create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/favicon.svg create mode 100644 web/src/App.tsx create mode 100644 web/src/components/admin-theme-context.ts create mode 100644 web/src/components/admin-theme.test.tsx create mode 100644 web/src/components/admin-theme.tsx create mode 100644 web/src/components/providers.tsx create mode 100644 web/src/components/query-client.ts create mode 100644 web/src/components/shell.tsx create mode 100644 web/src/components/theme/preview.tsx create mode 100644 web/src/components/theme/token-control.test.tsx create mode 100644 web/src/components/theme/token-control.tsx create mode 100644 web/src/components/ui/index.tsx create mode 100644 web/src/components/use-admin-theme.ts create mode 100644 web/src/global.d.ts create mode 100644 web/src/index.css create mode 100644 web/src/lib/api.test.ts create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/runtime-controls.ts create mode 100644 web/src/lib/tokens.test.ts create mode 100644 web/src/lib/tokens.ts create mode 100644 web/src/lib/types.ts create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/routes/audit.tsx create mode 100644 web/src/routes/dashboard.tsx create mode 100644 web/src/routes/integrate.tsx create mode 100644 web/src/routes/login.tsx create mode 100644 web/src/routes/settings.tsx create mode 100644 web/src/routes/theme-detail.tsx create mode 100644 web/src/routes/theme-list.tsx create mode 100644 web/src/routes/versions.tsx create mode 100644 web/src/test/setup.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts create mode 100644 web/vitest.config.ts diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..3408955 --- /dev/null +++ b/.air.toml @@ -0,0 +1,45 @@ +# air watches the Go sources and rebuilds on save. +# +# The bin directory is ignored on purpose: without it every rebuild would trigger +# a second rebuild, and air would loop forever watching its own output. +# +# Run with `make dev-api`, or `make dev` for the API and the admin UI together. + +root = "." +tmp_dir = ".cache/air" + +[build] + # The binary goes to the same place `make build-go` puts it, so that a + # hot-reloaded server and a scripted build produce identical artefacts. + bin = "bin/go-bananas" + cmd = "go build -o bin/go-bananas ./cmd/go-bananas" + # Race detection is on by default in development. It costs roughly a doubling of + # build time, which is a fair price for catching a data race in the cache layer + # the first time rather than in production. + full_bin = "APP_ENV=development go build -race -o bin/go-bananas ./cmd/go-bananas" + include_ext = ["go", "sql", "tmpl"] + # Only internal/ and cmd/ can change behaviour. Watching web/ would restart the + # Go server for a CSS edit, which the Vite dev server already handles. + exclude_dir = ["bin", "data", ".cache", "node_modules", "web", "sdk", "deploy", "docs"] + exclude_file = [".*_test.go"] + exclude_regex = ["_test\\.go$"] + exclude_unchanged = true + follow_symlink = false + delay = 200 + stop_on_error = true + send_interrupt = false + kill_delay = "2s" + rerun = false + rerun_delay = 500 + +[log] + time = true + +[misc] + # A clean build before the first run, so that a stale binary from an interrupted + # `make build-go` cannot be the thing that appears to work. + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7db4b67 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,76 @@ +# Build context exclusions. +# +# The build context is sent to the daemon in full before the first COPY, so +# anything left in here is uploaded and then ignored. On a working tree with a +# populated node_modules and a local database that is hundreds of megabytes of +# pointless transfer, and it is also how a local .env or an unencrypted database +# file ends up inside an image layer. + +# Dependencies and build output. The image builds these itself from the +# lockfiles, so shipping the host's copies would risk building against a +# different dependency set than the one that was tested. +node_modules +**/node_modules +web/dist +sdk/dist +dist +bin +.cache + +# Local data. A SQLite database in a layer is both a size problem and a data leak, +# and the image creates its own at /srv/data. +data +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + +# Secrets and local configuration. +.env +.env.* +!.env.example +*.pem +*.key +*.crt +secrets/ + +# Version control and CI. The build stamps the version from build arguments, so it +# never reads git metadata. +.git +.gitignore +.github +.gitattributes + +# Documentation. Not needed to build or run the image. +docs +*.md +!README.md + +# Deployment and development scaffolding. +deploy +hack +.github +.editorconfig +.prettierignore +.env.example +compose.yaml +docker-bake.hcl +docker-compose*.yml +Dockerfile* +goreleaser.yaml +.openapi-generatorignore + +# Editor and OS noise. +.vscode +.idea +*.swp +*.swo +.DS_Store +Thumbs.db + +# Logs and temporary files. +*.log +npm-debug.log* +tmp +.cache \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d247de4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.go] +indent_style = tab +indent_size = 4 +max_line_length = 120 + +[Makefile] +indent_style = tab + +[*.{md,mdx}] +trim_trailing_whitespace = false + +[*.{yaml,yml,json,jsonc,tfvars}] +indent_size = 2 + +[*.sh] +indent_size = 2 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..855317e --- /dev/null +++ b/.env.example @@ -0,0 +1,166 @@ +# go-bananas configuration. +# +# Copy to .env and edit: cp .env.example .env +# +# Every variable is optional except where noted. The defaults are chosen so that +# `docker compose up` works with no configuration at all; this file documents what +# can be changed and shows the values the Helm chart sets in production. +# +# Two prefixes are accepted for every variable: BANANAS_ and the unprefixed +# . The prefixed form is preferred and is what the chart uses, because an +# unprefixed name like ENVIRONMENT or LOG_LEVEL collides with other things in a +# process environment. The unprefixed form exists so that a shell running the +# binary locally does not need the prefix. + +# ---------------------------------------------------------------- required -- + +# Nothing is strictly required to start in development. In production these become +# mandatory and the service refuses to start without them: +# +# BANANAS_SESSION_SECRET at least 32 bytes; signs the session cookie +# BANANAS_PUBLIC_BASE_URL the externally reachable origin, no trailing slash +# +# Generate a session secret with: openssl rand -base64 48 + +# --------------------------------------------------------------- runtime ---- + +# Which authentication mode to run in: oidc, dev or none. +# +# oidc production. Requires an identity provider. +# dev a development-only bypass. Refused unless ENVIRONMENT is development, +# so an accidentally deployed bypass is not possible. +# none no authentication at all; every caller is an admin. For a laptop. +BANANAS_ENVIRONMENT=development +BANANAS_AUTH_MODE=dev + +# The address to listen on. +BANANAS_HTTP_ADDR=:8080 + +# The externally reachable origin, without a trailing slash. Used for the OIDC +# redirect URI, the absolute URLs in the runtime manifest, and the HSTS decision. +# The chart sets https://themes.example.com here. +BANANAS_PUBLIC_BASE_URL=http://localhost:8080 + +# Serve the admin UI under a sub-path, when it is not at the domain root. +# BANANAS_BASE_PATH=/themes + +# Directory holding the built admin UI. Empty means "no UI", which is what a +# deployment that serves the UI from a CDN wants. +# BANANAS_STATIC_DIR=/srv/web + +# Directory holding the built runtime SDK bundles. +# BANANAS_SDK_DIR=/srv/sdk + +# ----------------------------------------------------------------- OIDC ----- + +# The development realm. A production deployment points this at its own provider; +# only the issuer, client credentials and group mapping are provider-specific. +BANANAS_OIDC_ISSUER=https://auth-dev.urpi.be/realms/urpi +BANANAS_OIDC_CLIENT_ID=go-bananas +# BANANAS_OIDC_CLIENT_SECRET=read-from-a-secret + +# Extra scopes beyond `openid`. +# BANANAS_OIDC_SCOPES=profile,email,groups + +# Group-to-role mapping. A user in none of these groups gets the least privileged +# role, so a mapping mistake fails closed rather than open. +# +# The mock realm in deploy/dev/realm-go-bananas.json uses these names. A realm +# that uses different names will map everyone to viewer until these are corrected. +# BANANAS_OIDC_ADMIN_GROUPS=bananas-admins +# BANANAS_OIDC_EDITOR_GROUPS=bananas-editors +# BANANAS_OIDC_VIEWER_GROUPS=bananas-viewers + +# Override the derived callback and logout URLs. Rarely needed: both are derived +# from PUBLIC_BASE_URL, and setting them by hand is a chance to get them wrong. +# BANANAS_OIDC_REDIRECT_URL=https://themes.example.com/auth/callback +# BANANAS_OIDC_POST_LOGOUT_REDIRECT_URL=https://themes.example.com/ + +# Skip TLS verification when talking to the provider. Development only, for a +# provider with a self-signed certificate; refused when ENVIRONMENT is production. +# BANANAS_OIDC_SKIP_TLS_VERIFY=false + +# -------------------------------------------------------------- sessions ---- + +# Signs the session cookie. At least 32 bytes. Rotating it invalidates every +# session, which is the only way to revoke sessions before they expire. +# BANANAS_SESSION_SECRET= + +BANANAS_SESSION_TTL=8h +BANANAS_SESSION_COOKIE_NAME=go-bananas-session +# Lax, Strict or None. None requires HTTPS, since a cross-site cookie without it +# is rejected by every current browser. +BANANAS_SESSION_SAMESITE=Lax +# Must be true whenever the service is reached over HTTPS, or the browser will +# discard the cookie and every login will appear to succeed and then fail. +BANANAS_SESSION_SECURE=false + +# The role a development-bypass or anonymous caller is granted. +BANANAS_DEV_FALLBACK_ROLE=admin + +# ----------------------------------------------------------- development ---- + +# Seed the built-in themes on first start. Safe to leave on: the seed is +# idempotent and will not overwrite a theme that has been edited. +BANANAS_SEED_ON_START=true + +# ---------------------------------------------------------------- store ----- + +BANANAS_DATABASE_PATH=./data/go-bananas.db +BANANAS_DB_MAX_CONNS=8 +BANANAS_DB_BUSY_TIMEOUT=5s + +# -------------------------------------------------------------- runtime API -- + +# Comma-separated origins allowed to call the admin API from a browser. Empty +# means same-origin only, which is correct for the default deployment. +# BANANAS_CORS_ALLOWED_ORIGINS= + +# Public origins allowed to embed the runtime SDK and load a theme. This is what +# stops a third-party site from using your instance as a free CSS CDN, so it is +# worth setting even though it defaults to the public base URL. +# BANANAS_RUNTIME_PUBLIC= + +# Base URL of a CDN in front of the runtime API, if there is one. Absolute URLs in +# the manifest are rewritten to point at it. +# BANANAS_RUNTIME_CDN_BASE_URL=https://cdn.example.com + +# Cache-Control for runtime responses. +BANANAS_RUNTIME_CACHE_CONTROL=public, max-age=60, stale-while-revalidate=86400 + +# Requests per minute per client on the public runtime API. The admin API is not +# rate limited: it is authenticated, so the user count is known and small. +BANANAS_RATE_LIMIT_PER_MINUTE=600 + +# -------------------------------------------------------------- http/proxy -- + +BANANAS_HTTP_READ_TIMEOUT=15s +BANANAS_HTTP_WRITE_TIMEOUT=30s +BANANAS_HTTP_IDLE_TIMEOUT=120s +# How long to let in-flight requests finish on SIGTERM. Must be shorter than the +# pod's terminationGracePeriodSeconds, or Kubernetes sends SIGKILL mid-request. +BANANAS_SHUTDOWN_TIMEOUT=25s + +# Proxies whose X-Forwarded-For is trusted. Empty trusts none, which means every +# client looks like the proxy and the rate limiter groups everyone together. Set +# this when running behind an ingress. +# BANANAS_TRUSTED_PROXIES=10.0.0.0/8 + +# ---------------------------------------------------------------- logging --- + +BANANAS_LOG_LEVEL=info +# json for anything a machine reads, text for a human at a terminal. +BANANAS_LOG_FORMAT=text + +# ------------------------------------------------------------ diagnostics --- + +BANANAS_ENABLE_METRICS=true +# The profiler registers debug handlers and must never be on in production. +BANANAS_ENABLE_PROFILER=false + +# ------------------------------------------------------------- container ---- + +# Passed by docker-bake and goreleaser rather than set by hand. +# VERSION=dev +# COMMIT=unknown +# BUILD_DATE=unknown \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ca706f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.ico binary +*.woff binary +*.woff2 binary + +go.sum -diff +go.mod -diff +package-lock.json -diff \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8298f21 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,289 @@ +name: ci + +# One workflow for the whole pre-merge gate, with the jobs split so that a failure +# names the thing that broke instead of "the build". +# +# The job set is deliberately the same as `make verify`, and each job calls the +# Makefile rather than repeating the commands. A CI script that differs from the +# Makefile eventually differs in a way nobody notices, and then the Makefile stops +# being a usable local gate. + +on: + push: + branches: [main] + pull_request: + merge_group: + +# A new push to the same branch makes the in-flight run irrelevant: cancelling it +# saves runner minutes and stops an old result being reported against new code. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # Matches the Go directive in go.mod. setup-go would read it from there, but + # pinning it here means a toolchain bump shows up as a reviewable diff here too. + GO_VERSION: "1.27.1" + NODE_VERSION: "24" + +jobs: + go: + name: go + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + # The module and build caches live in the runner's cache rather than in + # the workspace, so a checkout never carries a stale artefact into a test. + cache: true + + - name: Verify formatting + run: make fmt-check + + - name: Vet + run: go vet ./cmd/... ./internal/... + + - name: Lint + run: make lint-go + env: + # golangci-lint caches analysis keyed on the Go version and the source. + GOLANGCI_LINT_CACHE: ${{ github.workspace }}/.cache/golangci-lint + + - name: Test with the race detector + run: make test-go + + - name: Build + run: make build-go + + web: + name: admin-ui + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install + # `npm ci`, never `npm install`: it installs exactly the lockfile, and it + # fails when the lockfile and package.json disagree instead of quietly + # resolving something new. + run: npm ci --no-audit --no-fund + working-directory: web + + - name: Lint + run: make lint-web + + - name: Test + run: make test-web + + - name: Build + run: make build-web + + sdk: + name: runtime-sdk + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: sdk/package-lock.json + + - name: Install + run: npm ci --no-audit --no-fund + working-directory: sdk + + - name: Lint and typecheck + run: make lint-sdk + + - name: Test + run: make test-sdk + + - name: Build + run: make build-sdk + + helm: + name: helm + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: azure/setup-helm@v5 + with: + version: v3.19.0 + + - name: Lint strictly + # --strict turns warnings into errors, which is what stops a chart shipping + # with an icon or maintainer field quietly missing. + run: helm lint --strict deploy/helm/go-bananas + + - name: Render the whole configuration matrix + # Not `helm lint`: lint only sees the default values. This renders every + # documented configuration and checks both that each valid one produces + # parseable YAML and that each invalid one is refused with a message + # naming the value to change. + run: make helm-matrix + + contract: + name: openapi + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: The document must be valid OpenAPI 3.1 + # A specification that does not parse is worse than no specification, + # because every client generator will trust it. + run: npx --yes @redocly/cli@latest lint openapi.yaml + + container: + name: container + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: docker/setup-buildx-action@v3 + + # Load the image into the local daemon so the steps after this can run it. + # Without `load` the result only exists in the build cache, and there is + # nothing to inspect. + - name: Build + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: false + load: true + tags: go-bananas:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: The image must actually serve + run: | + set -eu + docker run -d --name bananas \ + -e BANANAS_ENVIRONMENT=development \ + -e BANANAS_AUTH_MODE=dev \ + -e BANANAS_PUBLIC_BASE_URL=http://localhost:8080 \ + -p 8080:8080 \ + go-bananas:ci + + # Poll rather than sleeping a fixed time: a fixed sleep is either too + # short on a loaded runner or wasted time on a fast one. + for i in $(seq 1 60); do + if curl -fsS http://localhost:8080/healthz >/dev/null 2>&1; then + echo "healthy after ${i}s" + break + fi + if [ "$i" -eq 60 ]; then + echo "the container never became healthy" + docker logs bananas + exit 1 + fi + sleep 1 + done + + # The admin UI must be served from the image, not just the API: a + # container that starts and then 404s on its own dashboard is a + # regression the health check would not catch. + curl -fsS http://localhost:8080/rt/v1/manifest.json >/dev/null + curl -fsS http://localhost:8080/api/v1/auth/me | grep -q '"mode"' + + echo "--- running as ---" + docker exec bananas id + # The image declares USER 65532, so anything else means the build lost it. + test "$(docker exec bananas id -u)" = "65532" + + - name: Logs on failure + if: failure() + run: docker logs bananas || true + + - name: Clean up + if: always() + run: docker rm -f bananas || true + + release-config: + name: release-config + runs-on: ubuntu-latest + # Validating the release configuration on every push is cheap, and a broken + # goreleaser config discovered at tag time is a release that cannot be cut. + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Install GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: v2.18.2 + args: check + + styling: + name: shared styling + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + # The two checks that cannot be done in jsdom: whether the rendered contract CSS + # resolves to the right computed values, and whether an application that has not + # touched its stylesheet actually follows the contract. The second is the one that + # decides whether the consolidation is real. + - name: Contract and controls + run: make check-contract + + - name: go-loose pilot + run: make check-pilot + + security: + name: security + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Dependency review + uses: actions/dependency-review-action@v4 + with: + # Only on pull requests: the API needs a base branch to compare against, + # and it is the pull request that the review is protecting. + fail-on-severity: moderate + + all-green: + name: all green + # The single required status check. It depends on every job above and does + # nothing itself, so that a branch can be made to require one stable name + # instead of a list that has to be edited every time a job is added. + if: always() + needs: [go, web, sdk, helm, contract, container, styling, release-config, security] + runs-on: ubuntu-latest + steps: + - name: Fail if any job failed + run: | + results='${{ toJSON(needs) }}' + echo "$results" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + if echo "$results" | jq -e 'to_entries | any(.value.result != "success")' >/dev/null; then + echo "one or more jobs did not succeed" + exit 1 + fi + echo "all jobs succeeded" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8ff72d --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# --------------------------------------------------------------------------- +# Go +# --------------------------------------------------------------------------- +/bin/ +/dist/ +*.exe +*.test +*.out +coverage.* +/vendor/ + +# --------------------------------------------------------------------------- +# Node / frontend +# --------------------------------------------------------------------------- +node_modules/ +/web/dist/ +/sdk/dist/ +/web/.vite/ +/sdk/.vite/ +*.tsbuildinfo +.eslintcache +/test-results/ +/coverage/ + +# --------------------------------------------------------------------------- +# Runtime data +# --------------------------------------------------------------------------- +/data/ +*.db +*.db-wal +*.db-shm +*.sqlite +*.sqlite3 + +# --------------------------------------------------------------------------- +# Secrets / local overrides +# --------------------------------------------------------------------------- +.env +.env.* +!.env.example +*.pem +*.key +!**/*.pub + +# --------------------------------------------------------------------------- +# Editors / OS +# --------------------------------------------------------------------------- +.idea/ +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +.DS_Store +Thumbs.db +*.swp \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..bebbc63 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,275 @@ +# golangci-lint configuration. +# +# The enabled set is chosen for signal, not coverage. A linter that fires on +# stylistic preferences nobody agreed to trains the team to ignore the output, which +# costs more than the findings are worth. Every linter here catches a class of bug +# or a readability problem we actually care about; the exclusions below are +# deliberate and commented. +version: "2" + +run: + timeout: 5m + # Tests are linted. A lint failure in a test is a lint failure. + tests: true + build-tags: [] + modules-download-mode: readonly + issues-exit-code: 1 + +formatters: + enable: + - gofmt + - goimports + - gci + settings: + gci: + sections: + - standard + - default + - prefix(github.com/urpi/go-bananas) + no-lex-order: true + goimports: + local-prefixes: [github.com/urpi/go-bananas] + +linters: + enable: + # --- correctness ------------------------------------------------------- + - errcheck # an unchecked error is a bug waiting for a bad day + - govet + - ineffassign + - staticcheck # the staticcheck suite, at its default strictness + - unused + - bodyclose # an unclosed response body leaks a connection + - rowserrcheck # a rows.Err() not checked hides a mid-iteration failure + - sqlclosecheck + - noctx # an HTTP request without a context cannot be cancelled + - contextcheck # a context stored in a struct cannot be cancelled + - errorlint # %w and errors.Is, so wrapping and matching actually work + - nilerr # returning nil after checking err != nil is almost always wrong + + # --- readability ------------------------------------------------------- + - revive # exported comments, naming, control-flow clarity + - goconst # a repeated literal is a constant waiting to be named + - godot # comments end in a period + - misspell # a typo in a comment is a typo a reader will trust + - unconvert + - prealloc + - whitespace + - nolintlint # a nolint that no longer applies is misleading + - copyloopvar # Go 1.22+ loop variables; the old idiom is now a bug + + # --- security ---------------------------------------------------------- + - gosec # integer overflows, weak randomness, unsafe, etc. + + # --- testing ----------------------------------------------------------- + - thelper # t.Helper() in a test helper + - tparallel # a parallel test that does not call t.Parallel + + settings: + errcheck: + # A type assertion or conversion that cannot fail needs no check. + check-type-assertions: false + # A blank assignment to an error is a deliberate, documented decision, not an + # oversight, so errcheck is asked to flag it too. The ones that remain are + # marked with an explicit nolint and a reason. + check-blank: true + exclude-functions: + # Writing to an http.ResponseWriter returns an error only when the client has + # gone away, and there is nothing a handler can do about that: the response + # is already committed and the connection is already being torn down. + - (net/http.ResponseWriter).Write + - (net/http.ResponseWriter).WriteHeader + # A Rollback after a successful Commit is expected to report + # sql.ErrTxDone, and a Rollback on the error path is immediately followed by + # returning that error. Checking either produces a branch that can only ever + # discard a result. + - (*database/sql.Tx).Rollback + # Deferred Close on a read-only database handle: the only error it can + # report is already lost by the time the process exits. + - (*database/sql.Rows).Close + - (io.Closer).Close + - (*os.File).Close + - (*bytes.Buffer).Write + - (*bytes.Buffer).WriteByte + - (*bytes.Buffer).WriteString + - (io.Writer).Write + - (hash.Hash).Write + - fmt.Fprintf + - fmt.Fprint + - fmt.Fprintln + - fmt.Print + - fmt.Println + - fmt.Sprintf + - sort.Slice + + govet: + enable-all: true + disable: + # Field alignment is a micro-optimisation that makes every struct + # declaration a puzzle. It is not worth the readability cost. + - fieldalignment + # The shadow linter fires on ordinary, readable `err :=` inside an if. + - shadow + + revive: + rules: + # requireDoc is deliberately not set. Every exported item in this codebase + # carries a doc comment by convention and by review, but the rule's argument + # name is not "requireDoc" in this version of revive, so setting it disables + # the whole revive rule set with a configuration error rather than enforcing + # anything. + - name: exported + arguments: + - checkPrivateReceivers + - name: package-comments + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + # No arguments: the rule takes none. Passing "error-strings" as an argument + # makes revive look for a custom function of that name, fails to find it, and + # then silently disables the rule along with every other revive rule. + - name: error-strings + - name: error-naming + - name: increment-decrement + # The allowlist has to be a slice. Given as a bare string it is a + # configuration error, which stops every revive rule from loading rather + # than enabling this one. + - name: var-naming + arguments: + - [ID, VM, HTTP, URL, API, TLS, CSS, JSON, SQL, UUID, OIDC, ACL, CPU, RAM, WAC] + - name: receiver-naming + - name: indent-error-flow + - name: range + - name: unreachable-code + - name: redefines-builtin-id + + goconst: + min-len: 3 + # Raised from 4 to 6, and the wire vocabulary below is exempted. The strings + # that repeat in this codebase are JSON field names ("status", "version", + # "slug"), theme token vocabulary ("primary-hover", "normal") and the auth + # method "anonymous". Those are the wire and CSS contract rather than repeated + # logic, and hoisting them into Go constants would make them less greppable + # without changing anything the compiler checks. + min-occurrences: 6 + ignore-string-values: + - anonymous + - none + - normal + - primary + - primary-hover + - focus-ring + - link + - version + - status + - slug + - items + - must not be empty + # Test data legitimately repeats literals like "200" and "published". + ignore-tests: true + + godot: + scope: declarations + capital: false + + gosec: + excludes: + # G304: a file path from configuration is intentional. The path comes from + # the operator's own configuration, not from a request. + - G304 + # Everything gosec ships is enabled by default; only the exclusions above + # narrow it. G115 (integer overflow) is intentionally left on: it fires on + # the token engine's length arithmetic, and the two or three places that + # could genuinely overflow carry an explicit nolint with a reason. + + staticcheck: + checks: + - all + # SA1019: deprecation notices for dependencies we control the version of. + - -SA1019 + + misspell: + # US, deliberately, and this is a real decision rather than a default. + # + # misspell's locale setting makes it *require* one dialect. With UK it + # rewrites the checker's idea of a correct word, which means it flags every + # occurrence of the US spelling -- including identifiers that are part of the + # published API contract: the JSON error code "unauthorized", the CSS custom + # property and colour-scheme vocabulary, the OpenAPI document. + # + # The codebase writes prose in British English, which misspell has nothing to + # say about under a US locale (it only corrects genuine misspellings, it does + # not enforce a dialect). So: British prose, US locale, and no linter + # pressure to rename a wire-format string. + locale: US + # misspell enforces one dialect even under a locale setting, so every British + # spelling in the prose has to be listed explicitly. This keeps the check + # worth having -- a genuine typo in a comment is still caught -- while + # accepting the dialect the documentation is written in. + # + # No identifier is in this list: exported names and JSON keys are American + # throughout, because they are wire and API surface rather than prose. Only + # comments, documentation and doc strings use the British forms. + ignore-rules: + - artefact + - artefacts + - authorises + - behaviour + - cancelled + - colour + - colours + - defence + - honoured + - honouring + - honours + - initialise + - licence + - localise + - marshalled + - materialise + - materialises + - materialising + - maximises + - modelled + - neutralises + - recognised + - recognises + - sanitise + - sanitised + - sanitising + - serialise + - serialised + - serialises + - summarises + + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + # A test function legitimately ignores the error from a deliberate failure. + - path: _test\.go + linters: + - gosec + - errcheck + # The SQL query constants and their arguments are checked by the store tests, + # which assert on behaviour rather than on the statement text. + - path: internal/store/ + linters: + - goconst + paths: + - third_party$ + - builtin$ + - examples$ + +issues: + # 0 disables the cap. A linter that stops reporting after 50 findings is worse + # than one that reports everything, because the tail is where the surprises are. + max-issues-per-linter: 0 + max-same-issues: 0 + new: false + fix: false diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..608c1ff --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +node_modules +dist +coverage +bin +data +*.db +*.db-wal +*.db-shm +openapi.yaml +internal/store/migrations diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..1042cc8 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ec9043c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,159 @@ +# syntax=docker/dockerfile:1.7 +# +# Go Bananas!! container image. +# +# Design notes +# ------------ +# * The Go build is fully static. modernc.org/sqlite is a pure-Go translation of +# SQLite, so CGO is not needed and the runtime image needs no libc at all. That +# is what lets the final stage be `scratch`-adjacent: a smaller image, and no +# shared-library CVEs to patch. +# * The frontend and the SDK are built before the Go binary and copied in, so the +# image build fails if the frontend fails. A container that starts and then 404s +# on its own dashboard is worse than one that does not start. +# * Dependencies are installed from a lockfile in their own layer, so a source-only +# change does not re-download the module cache. +# * BuildKit cache mounts are used for the module and package caches. They are a +# cache, not an artefact, so they are not part of the image. + +# ---------------------------------------------------------------- arguments -- + +ARG GO_VERSION=1.27.1 +ARG NODE_VERSION=24.9.0 +ARG ALPINE_VERSION=3.22 + +# ------------------------------------------------------------- web and sdk ---- + +FROM node:${NODE_VERSION}-alpine AS assets + +# Corepack is enabled so that the pinned package manager in package.json is the one +# that runs, rather than whatever npm happens to ship with the Node release. +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /src/web +COPY web/package.json web/package-lock.json* ./ + +# `npm ci` when a lockfile is present, `npm install` otherwise. The fallback keeps +# the image buildable from a source tree that has not installed yet, while CI +# always takes the reproducible path. +RUN --mount=type=cache,target=/root/.npm \ + if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \ + else npm install --no-audit --no-fund; fi + +COPY web/ ./ +RUN npm run build + +WORKDIR /src/sdk +COPY sdk/package.json sdk/package-lock.json* ./ +RUN --mount=type=cache,target=/root/.npm \ + if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \ + else npm install --no-audit --no-fund; fi + +COPY sdk/ ./ +RUN npm run build + +# ------------------------------------------------------------------- go ------ + +FROM golang:${GO_VERSION}-alpine AS build + +ARG VERSION=dev +ARG COMMIT=unknown +ARG BUILD_DATE=unknown +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +# git is needed for the version stamping below when the build runs outside a +# checkout; ca-certificates for module downloads. +RUN apk add --no-cache git ca-certificates + +WORKDIR /src + +# Dependencies first, in their own layer. go.sum is copied and verified so that a +# tampered or truncated lockfile fails the build rather than silently resolving +# something different. +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download && go mod verify + +COPY internal/ ./internal/ +COPY cmd/ ./cmd/ + +ARG CGO_ENABLED=0 +ARG GOFLAGS=-trimpath + +# -trimpath removes local filesystem paths from the binary, which keeps build paths +# out of any stack trace a user might paste into a bug report. +# -s -w drop the symbol table and DWARF data: neither is useful without a matching +# local binary, and together they are a substantial fraction of the image size. +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=${CGO_ENABLED} GOFLAGS=${GOFLAGS} \ + go build \ + -ldflags "-s -w -X github.com/urpi/go-bananas/internal/app.Version=${VERSION} -X main.version=${VERSION} -X github.com/urpi/go-bananas/internal/app.Commit=${COMMIT} -X github.com/urpi/go-bananas/internal/app.BuildDate=${BUILD_DATE}" \ + -o /out/go-bananas ./cmd/go-bananas + +# Verify the binary is genuinely static before shipping it. A build that silently +# picked up cgo would produce an image that fails at run time with a confusing +# "no such file or directory" rather than at build time with a clear one. +RUN if [ "${CGO_ENABLED}" = "0" ]; then \ + if go version -m /out/go-bananas | grep -q 'CGO_ENABLED=1'; then \ + echo "error: binary is dynamically linked but CGO_ENABLED=0" >&2; exit 1; \ + fi; \ + fi + +# ---------------------------------------------------------------- runtime ---- + +# Alpine rather than distroless or scratch: the service needs a shell for a health +# check and a writable directory for SQLite, and Alpine supplies both in ~8 MB. The +# attack surface difference against distroless is small next to the operational +# benefit, and the runtime user is unprivileged either way. +FROM alpine:${ALPINE_VERSION} AS runtime + +ARG VERSION=dev + +# curl is only here for the container health check. wget from busybox would do, but +# it is already present and curl's exit codes are the ones the check reasons about. +# tzdata is needed because timestamps are rendered in the configured locale. +RUN apk add --no-cache ca-certificates tzdata curl \ + && addgroup -g 65532 -S bananas \ + && adduser -u 65532 -S -G bananas -H -s /sbin/nologin bananas + +COPY --from=build /out/go-bananas /usr/local/bin/go-bananas + +COPY --from=assets /src/web/dist /srv/web +COPY --from=assets /src/sdk/dist /srv/sdk + +# The data directory holds the SQLite file. It is a volume in every real deployment; +# creating it here means an unconfigured local run still starts. +RUN mkdir -p /srv/data && chown -R bananas:bananas /srv + +USER 65532:65532 +WORKDIR /srv + +ENV BANANAS_HTTP_ADDR=:8080 \ + BANANAS_STATIC_DIR=/srv/web \ + BANANAS_SDK_DIR=/srv/sdk \ + BANANAS_DATABASE_PATH=/srv/data/go-bananas.db \ + BANANAS_LOG_FORMAT=json + +EXPOSE 8080 +VOLUME ["/srv/data"] + +# The health check uses the liveness probe rather than readiness: a container that +# is alive but not ready should stay in the load-balancer pool's view of "starting" +# rather than be restarted, and Docker's HEALTHCHECK does not have that concept. +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl --fail --silent --show-error http://127.0.0.1:8080/healthz || exit 1 + +ENTRYPOINT ["/usr/local/bin/go-bananas"] + +# --------------------------------------------------------------- final ------ + +FROM runtime AS final +ARG VERSION=dev +LABEL org.opencontainers.image.title="go-bananas" \ + org.opencontainers.image.description="Runtime design-token service: author themes, serve them to any application at run time." \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.source="https://github.com/urpi/go-bananas" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.base.name="docker.io/library/alpine:3.22" diff --git a/Dockerfile.release b/Dockerfile.release new file mode 100644 index 0000000..df215a5 --- /dev/null +++ b/Dockerfile.release @@ -0,0 +1,68 @@ +# syntax=docker/dockerfile:1 +# +# Release image, built by GoReleaser. +# +# This exists alongside the main `Dockerfile` rather than replacing it, because the +# two answer different questions: +# +# * `Dockerfile` builds the frontend, the SDK and the binary from source, so that +# `docker build .` always produces something runnable from a clean checkout. +# * `Dockerfile.release` consumes a binary GoReleaser has already cross-compiled, +# so a release never rebuilds the Go code a second time with different flags +# from the one that was checksummed, signed and published. +# +# The runtime layer is identical to the main Dockerfile's, so a release image and a +# locally built image differ only in provenance, never in behaviour. +# +# GoReleaser assembles the build context with the compiled binary at +# `go-bananas_linux_{arch}/go-bananas`, and `extra_files` supplies the frontend and +# SDK bundles. Nothing here compiles anything. + +ARG ALPINE_VERSION=3.22 + +FROM alpine:${ALPINE_VERSION} + +ARG VERSION=dev +ARG TARGETARCH=amd64 + +# curl is present only for the container health check; busybox wget would do, but +# curl's exit codes are the ones the check reasons about. tzdata is needed because +# timestamps are rendered in the configured locale. +RUN apk add --no-cache ca-certificates tzdata curl \ + && addgroup -g 65532 -S bananas \ + && adduser -u 65532 -S -G bananas -H -s /sbin/nologin bananas + +# The build context is generated, so these paths are relative to it rather than to +# the repository root. TARGETARCH is the Go arch name and matches the directory +# GoReleaser wrote the binary into. +COPY go-bananas_linux_${TARGETARCH}/go-bananas /usr/local/bin/go-bananas +COPY web/dist /srv/web +COPY sdk/dist /srv/sdk + +RUN chmod 0755 /usr/local/bin/go-bananas \ + && mkdir -p /srv/data \ + && chown -R bananas:bananas /srv + +USER 65532:65532 +WORKDIR /srv + +ENV BANANAS_HTTP_ADDR=:8080 \ + BANANAS_STATIC_DIR=/srv/web \ + BANANAS_SDK_DIR=/srv/sdk \ + BANANAS_DATABASE_PATH=/srv/data/go-bananas.db \ + BANANAS_LOG_FORMAT=json + +EXPOSE 8080 +VOLUME ["/srv/data"] + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl --fail --silent --show-error http://127.0.0.1:8080/healthz || exit 1 + +LABEL org.opencontainers.image.title="go-bananas" \ + org.opencontainers.image.description="Runtime design-token service: author themes, serve them to any application at run time." \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.source="https://github.com/urpi/go-bananas" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.base.name="docker.io/library/alpine:3.22" + +ENTRYPOINT ["/usr/local/bin/go-bananas"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee515cf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 urpi platform team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..03ecdf1 --- /dev/null +++ b/Makefile @@ -0,0 +1,439 @@ +# Go Bananas!! +# +# The Makefile is the primary interface to this repository. Every task a developer +# or a CI job needs is a target here, and each target is safe to run repeatedly. +# +# Conventions: +# * `make help` lists everything. +# * Targets are grouped by phase and the phases run in order for `make all`. +# * Anything that changes the machine (installing a tool, pushing an image) is +# marked ##@destructive so it is obvious in `make help`. +# * Variables at the top are overridable: `make IMAGE_TAG=v2 build`. +# +# The `##@` markers are read by the self-documenting help target, which is why +# they are inline comments rather than a separate block. + +SHELL := /usr/bin/env bash +.SHELLFLAGS := -eu -o pipefail -c +.DEFAULT_GOAL := help +.DELETE_ON_ERROR: +.SUFFIXES: + +# --------------------------------------------------------------- variables ---- + +BINARY := go-bananas +CMD := ./cmd/go-bananas +BIN_DIR := bin +DIST_DIR := dist +WEB_DIR := web +SDK_DIR := sdk + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) + +REGISTRY ?= nexus.urpi.be +IMAGE_NAME ?= $(REGISTRY)/$(BINARY) +IMAGE_TAG ?= $(VERSION) +PLATFORM ?= linux/amd64,linux/arm64 + +GO ?= go +NPM ?= npm + +# Development tool versions, pinned so that every contributor and every CI run use +# the same linter. They are pinned here rather than in a separate tools module +# because `go install pkg@version` resolves the version itself and refuses a +# -modfile, which would make a tools.mod decorative rather than binding. `make +# tools-upgrade` bumps all three in one place. +AIR_VERSION ?= v1.67.4 +GOLANGCI_VERSION ?= v2.14.0 +SQLC_VERSION ?= v1.31.1 +HELM ?= helm +DOCKER ?= docker + +# Module cache and build cache live outside the tree so that a bind-mounted +# working directory does not slowly fill the developer's disk. +GO_CACHE ?= $(CURDIR)/.cache/go-build +GOMODCACHE ?= $(CURDIR)/.cache/go-mod + +# The Go packages belonging to this module, excluding the JavaScript trees. +# +# Go does not ignore node_modules: any directory under the module root with Go +# files in it is part of `./...`, and a dependency that happens to ship a stray +# package would then be compiled and linted as if it were ours. `./internal/...` +# and `./cmd/...` are listed instead of filtering, so the omission is visible here +# rather than hidden in a pipe. +GO_PACKAGES = ./cmd/... ./internal/... + +# The full set of ldflags. Kept in one variable so that `go build`, `go install` +# and goreleaser cannot drift apart and produce differently-identified binaries. +LDFLAGS := -s -w \ + -X github.com/urpi/go-bananas/internal/app.Version=$(VERSION) \ + -X github.com/urpi/go-bananas/internal/app.Commit=$(COMMIT) \ + -X github.com/urpi/go-bananas/internal/app.BuildDate=$(BUILD_DATE) + +GOFLAGS_BUILD := -trimpath -ldflags "$(LDFLAGS)" + +.DEFAULT_COLOR := \033[0m +.BOLD := \033[1m +.DIM := \033[2m +.GREEN := \033[32m +.YELLOW := \033[33m +.RED := \033[31m + +##@ General + +.PHONY: help +help: ## Show this help + @printf "$(BOLD)go-bananas$(DEFAULT_COLOR) — runtime design-token service\n\n" + @printf "$(DIM)Version$(DEFAULT_COLOR) $(VERSION) ($(COMMIT))\n\n" + @# The awk pass reads both the "##@" group markers and the "##" target help in + @# one scan so that groups appear in file order rather than in two separate + @# alphabetical lists. A trailing "##@destructive" is stripped from the + @# description and rendered as a marker instead. + @awk 'BEGIN {FS = ":.*##"} \ + /^##@/ { printf "\n$(BOLD)%s$(DEFAULT_COLOR)\n", substr($$0, 5); next } \ + /^[a-zA-Z0-9_-]+:.*?##/ { \ + help = $$0; sub(/^[^:]*:.*## /, "", help); \ + mark = ""; \ + if (help ~ /##@destructive/) { mark = "$(YELLOW) [modifies this machine]$(DEFAULT_COLOR)"; \ + sub(/ *##@destructive/, "", help) } \ + printf " $(BOLD)%-20s$(DEFAULT_COLOR) %s%s\n", $$1, help, mark \ + }' $(MAKEFILE_LIST) + @printf "\n$(DIM)Override any variable, e.g. make IMAGE_TAG=v2.1.0 image-push$(DEFAULT_COLOR)\n" + +.PHONY: version +version: ## Print the version that would be built + @echo "$(VERSION) ($(COMMIT)) built $(BUILD_DATE)" + +##@ Development + +.PHONY: install +install: ## Install Go and Node dependencies + $(GO) mod download + cd $(WEB_DIR) && $(NPM) install + cd $(SDK_DIR) && $(NPM) install + +.PHONY: dev +dev: ## Run the API with hot reload (Ctrl-C to stop) + @command -v air >/dev/null 2>&1 || { \ + echo "air is not installed; run 'make tools' or see docs/development.md"; exit 1; } + @BANANAS_ENVIRONMENT=development \ + BANANAS_AUTH_MODE=dev \ + BANANAS_HTTP_ADDR=:8080 \ + BANANAS_DATABASE_PATH=./data/go-bananas.db \ + BANANAS_STATIC_DIR=$(WEB_DIR)/dist \ + BANANAS_SDK_DIR=$(SDK_DIR)/dist \ + air -c .air.toml + +.PHONY: dev-api +dev-api: ## Run only the API server, no frontend + @BANANAS_ENVIRONMENT=development \ + BANANAS_AUTH_MODE=dev \ + BANANAS_HTTP_ADDR=:8080 \ + BANANAS_DATABASE_PATH=./data/go-bananas.db \ + BANANAS_STATIC_DIR= BANANAS_SDK_DIR= \ + $(GO) run -ldflags "$(LDFLAGS)" $(CMD) + +.PHONY: dev-web +dev-web: ## Run the Vite dev server for the admin UI, proxying the API + cd $(WEB_DIR) && $(NPM) run dev + +.PHONY: dev-all +dev-all: ## Run the API and the frontend dev server together + @echo "API on http://localhost:8080, admin UI on http://localhost:5173" + @trap 'kill 0' EXIT; \ + $(MAKE) dev-api & \ + $(MAKE) dev-web & \ + wait + +.PHONY: tools +tools: ## Install the pinned development tools (air, golangci-lint, sqlc) ##@destructive + $(GO) install github.com/air-verse/air@$(AIR_VERSION) + $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION) + $(GO) install github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) + @echo "Tools installed into $$( $(GO) env GOBIN )" + @echo "golangci-lint $(GOLANGCI_VERSION), air $(AIR_VERSION), sqlc $(SQLC_VERSION)" + +.PHONY: tools-upgrade +tools-upgrade: ## Bump the pinned development tools to their latest releases ##@destructive + @for tool in \ + github.com/air-verse/air:AIR_VERSION \ + github.com/golangci/golangci-lint/v2:GOLANGCI_VERSION \ + github.com/sqlc-dev/sqlc:SQLC_VERSION; do \ + pkg=$${tool%%:*}; var=$${tool##*:}; \ + latest=$$($(GO) list -m -versions $$pkg | tr ' ' '\n' | tail -1); \ + [ -n "$$latest" ] || { echo "could not resolve $$pkg"; exit 1; }; \ + sed -i.bak "s|^$$var .*|$$var ?= $$latest|" Makefile && rm -f Makefile.bak; \ + echo "$$var -> $$latest"; \ + done + @echo "Run 'make tools' to install them and commit the Makefile change." + +.PHONY: clean +clean: ## Remove build output and local data + rm -rf $(BIN_DIR) $(DIST_DIR) data .cache + cd $(WEB_DIR) && rm -rf dist + cd $(SDK_DIR) && rm -rf dist + @echo "Cleaned." + +.PHONY: clean-cache +clean-cache: ## Remove every build cache, including the module cache ##@destructive + $(GO) clean -cache -modcache + rm -rf .cache + cd $(WEB_DIR) && rm -rf node_modules/.vite + cd $(SDK_DIR) && rm -rf node_modules/.vite + +##@ Build + +.PHONY: build +build: build-go build-web build-sdk ## Build every artefact + +.PHONY: build-go +build-go: ## Build the server binary into bin/ + @mkdir -p $(BIN_DIR) + CGO_ENABLED=0 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) $(CMD) + @echo "Built $(BIN_DIR)/$(BINARY)" + +.PHONY: build-web +build-web: ## Build the admin UI into web/dist + cd $(WEB_DIR) && $(NPM) run build + +.PHONY: build-sdk +build-sdk: ## Build the runtime SDK bundles into sdk/dist + cd $(SDK_DIR) && $(NPM) run build + +.PHONY: run +run: build-go ## Build and run the server with the current assets + @mkdir -p data + BANANAS_ENVIRONMENT=development \ + BANANAS_AUTH_MODE=dev \ + BANANAS_STATIC_DIR=$(WEB_DIR)/dist \ + BANANAS_SDK_DIR=$(SDK_DIR)/dist \ + ./$(BIN_DIR)/$(BINARY) + +.PHONY: release +release: ## Build release binaries for every supported platform ##@destructive + @command -v goreleaser >/dev/null 2>&1 || { \ + echo "goreleaser is not installed; see docs/development.md"; exit 1; } + goreleaser release --clean + +.PHONY: release-snapshot +release-snapshot: ## Build release binaries without publishing ##@destructive + goreleaser release --snapshot --clean --skip=publish + +.PHONY: sbom +sbom: ## Generate a CycloneDX SBOM for the current version ##@destructive + @command -v syft >/dev/null 2>&1 || { \ + echo "syft is not installed"; exit 1; } + syft dir:. -o cyclonedx-json=sbom-$(VERSION).json + +##@ Quality + +.PHONY: check +check: fmt-check lint test ## Run every check + +.PHONY: fmt +fmt: ## Format the Go code + $(GO) fmt ./... + +.PHONY: fmt-check +fmt-check: ## Fail if the Go code is not formatted + @unformatted="$$(gofmt -l . | grep -v '^vendor/' || true)"; \ + if [ -n "$$unformatted" ]; then \ + echo "These files are not gofmt'd:"; echo "$$unformatted"; exit 1; \ + fi + @echo "Go formatting is clean." + +.PHONY: lint +lint: lint-go lint-web lint-sdk ## Run every linter + +.PHONY: lint-go +lint-go: ## Run golangci-lint + @command -v golangci-lint >/dev/null 2>&1 || { \ + echo "golangci-lint is not installed; run 'make tools'"; exit 1; } + golangci-lint run $(GO_PACKAGES) + +.PHONY: lint-web +lint-web: ## Lint and typecheck the admin UI + cd $(WEB_DIR) && $(NPM) run lint + cd $(WEB_DIR) && $(NPM) run typecheck + +.PHONY: lint-sdk +lint-sdk: ## Lint and typecheck the runtime SDK + cd $(SDK_DIR) && $(NPM) run typecheck + +.PHONY: fmt-web +fmt-web: ## Format the frontend and SDK sources + cd $(WEB_DIR) && $(NPM) run format + cd $(SDK_DIR) && $(NPM) exec prettier --write . + +.PHONY: tidy +tidy: ## Tidy the Go module + $(GO) mod tidy + +##@ Test + +.PHONY: test +test: test-go test-web test-sdk ## Run every test suite + +.PHONY: test-go +test-go: ## Run the Go tests + $(GO) test -race -count=1 $(GO_PACKAGES) + +.PHONY: test-cover +test-cover: ## Run the Go tests and write a coverage profile + $(GO) test -race -count=1 -coverprofile=coverage.out -covermode=atomic $(GO_PACKAGES) + $(GO) tool cover -func=coverage.out | tail -1 + @echo "Profile written to coverage.out" + +.PHONY: test-web +test-web: ## Run the admin UI tests + cd $(WEB_DIR) && $(NPM) test + +.PHONY: test-sdk +test-sdk: ## Run the runtime SDK tests + cd $(SDK_DIR) && $(NPM) test + +.PHONY: bench +bench: ## Run the token engine benchmarks + $(GO) test -bench=. -benchmem ./internal/tokenengine/ + +##@ Docker + +.PHONY: image +image: ## Build the container image for the host architecture + $(DOCKER) buildx build \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(COMMIT) \ + --build-arg BUILD_DATE=$(BUILD_DATE) \ + --tag $(IMAGE_NAME):$(IMAGE_TAG) \ + --tag $(IMAGE_NAME):latest \ + --load \ + . + +.PHONY: image-multi +image-multi: ## Build the multi-architecture image without pushing ##@destructive + $(DOCKER) buildx build \ + --platform $(PLATFORM) \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(COMMIT) \ + --build-arg BUILD_DATE=$(BUILD_DATE) \ + --tag $(IMAGE_NAME):$(IMAGE_TAG) \ + . + +.PHONY: image-push +image-push: ## Build and push the multi-architecture image ##@destructive + $(DOCKER) buildx build \ + --platform $(PLATFORM) \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(COMMIT) \ + --build-arg BUILD_DATE=$(BUILD_DATE) \ + --push \ + --tag $(IMAGE_NAME):$(IMAGE_TAG) \ + . + +.PHONY: image-run +image-run: ## Run the image locally on port 8080 + $(DOCKER) run --rm -it \ + -p 8080:8080 \ + -e BANANAS_ENVIRONMENT=development \ + -e BANANAS_AUTH_MODE=dev \ + -v go-bananas-data:/srv/data \ + $(IMAGE_NAME):$(IMAGE_TAG) + +.PHONY: compose-up +compose-up: ## Start the local stack with docker compose + $(DOCKER) compose up --build + +.PHONY: compose-down +compose-down: ## Stop the local stack ##@destructive + $(DOCKER) compose down + +.PHONY: compose-logs +compose-logs: ## Follow the local stack logs + $(DOCKER) compose logs -f + +.PHONY: compose-shell +compose-shell: ## Open a shell in the running service ##@destructive + $(DOCKER) compose exec go-bananas /bin/sh + +##@ Helm + +.PHONY: helm-lint +helm-lint: ## Lint the chart + $(HELM) lint deploy/helm/go-bananas --strict + +.PHONY: helm-matrix +helm-matrix: ## Render the chart across a matrix of valid and invalid configurations + ./hack/helm-matrix.sh + +.PHONY: helm-template +helm-template: ## Render the chart with the default values + $(HELM) template go-bananas deploy/helm/go-bananas + +.PHONY: helm-template-auth +helm-template-auth: ## Render the chart configured for OIDC + $(HELM) template go-bananas deploy/helm/go-bananas \ + --set identity.mode=oidc \ + --set identity.oidc.issuer=https://auth-dev.urpi.be/realms/urpi \ + --set identity.oidc.clientId=go-bananas \ + --set identity.oidc.generateSecret=false \ + --set identity.oidc.existingSecret=go-bananas-oidc \ + --set config.publicBaseUrl=https://themes-dev.example.com + +.PHONY: helm-install +helm-install: ## Install the chart into the current kube context ##@destructive + $(HELM) upgrade --install go-bananas deploy/helm/go-bananas \ + --namespace go-bananas --create-namespace \ + --values deploy/helm/go-bananas/values.yaml \ + --wait --timeout 5m + +.PHONY: helm-uninstall +helm-uninstall: ## Remove the release ##@destructive + $(HELM) uninstall go-bananas --namespace go-bananas + +##@ Docs + +.PHONY: docs-serve +docs-serve: ## Serve the documentation locally + @command -v mkdocs >/dev/null 2>&1 || { echo "mkdocs is not installed"; exit 1; } + mkdocs serve + +.PHONY: docs-build +docs-build: ## Build the documentation ##@destructive + mkdocs build --strict + +.PHONY: api-spec +api-spec: ## Print the OpenAPI document summary + @grep -E '^\s{2}/' openapi.yaml | sed 's/:$//' | tr -d ' ' | sed 's|^| |' + +##@ Contract + +.PHONY: check-contract +check-contract: ## Drive the shared styling contract and the shipped controls in a real browser ##@destructive + ./hack/check-contract.sh + +.PHONY: check-pilot +check-pilot: ## Prove both adopting applications follow the contract without editing their CSS ##@destructive + ./hack/check-pilot-go-loose.sh + ./hack/check-pilot-go-tell.sh + +##@ CI helpers + +.PHONY: ci +ci: check ## Everything CI runs, in one target + +.PHONY: verify +verify: ci helm-lint helm-matrix check-contract check-pilot ## The full pre-merge gate + +.PHONY: deps-outdated +deps-outdated: ## Report outdated Go and Node dependencies + $(GO) list -u -m all + @echo + cd $(WEB_DIR) && $(NPM) outdated || true + +.PHONY: deps-update +deps-update: ## Update Go and Node dependencies ##@destructive + $(GO) get -u ./... && $(GO) mod tidy + cd $(WEB_DIR) && $(NPM) update + cd $(SDK_DIR) && $(NPM) update diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a322d9 --- /dev/null +++ b/README.md @@ -0,0 +1,469 @@ +# go-bananas + +**Runtime design-token service.** Author design-token themes in a web interface, +serve them to any application as CSS custom properties at run time. Consumers +install nothing and have no build step. + +```html + + +``` + +--- + +## Why this exists + +The usual way to ship a design system is a package. That works, and it has a cost +paid on every release of the design system and on every rebuild of every consumer: +version resolution, peer dependency conflicts, a published version to track, and a +coordinated deploy when the two drift apart. + +This service takes the other approach. A theme is a document in a database. An +application asks for it over HTTP. Republishing a theme is a database write; every +application that has ever loaded the page picks it up on its next request. There is +no package to version, no dependency to resolve, and no build to re-run. + +The trade is that themes must be expressible as CSS custom properties. That covers +colour, typography, spacing, radius, shadow, motion and z-index — which is what +almost every design token actually is. What it cannot cover is anything that needs +to be compiled into the consuming application: a Tailwind preset, a set of React +components, a Sass mixin library. Those remain packages. This is not a replacement +for a component library, and it is not meant to be. + +--- + +## Contents + +- [Quick start](#quick-start) +- [How a theme is consumed](#how-a-theme-is-consumed) +- [The token model](#the-token-model) +- [Built-in themes](#built-in-themes) +- [The runtime API](#the-runtime-api) +- [The administration API](#the-administration-api) +- [Architecture](#architecture) +- [Why SQLite](#why-sqlite) +- [Deployment](#deployment) +- [Development](#development) +- [Documentation](#documentation) + +--- + +## Quick start + +### With Docker + +```bash +git clone https://github.com/urpi/go-bananas +cd go-bananas +make compose-up +``` + +Open . Authentication is bypassed in this mode: every +request is a local administrator. The mode is refused outside a development +environment, so it cannot be deployed by accident. + +### Without Docker + +Requires Go 1.27 and Node 22 or later. + +```bash +make install # Go and Node dependencies +make build # binary, admin UI and runtime SDK +make run # http://localhost:8080 +``` + +### With Kubernetes + +```bash +helm upgrade --install go-bananas oci://ghcr.io/urpi/charts/go-bananas \ + --namespace go-bananas --create-namespace \ + --set config.publicBaseUrl=https://themes.example.com \ + --set identity.oidc.issuer=https://auth.example.com/realms/urpi \ + --set identity.oidc.existingSecret=go-bananas-auth +``` + +The chart defaults to the shared development realm at `auth-dev.urpi.be`. See +[docs/deployment.md](docs/deployment.md) for the full production checklist. + +--- + +## How a theme is consumed + +Three integration shapes, in increasing order of control. All three are served +from the same origin as the theme CSS, so nothing is fetched cross-origin. + +### 1. A stylesheet link + +Works in any HTML page, server-rendered or not, in any framework, including ones +written before this existed. + +```html + +``` + +### 2. The framework-free runtime + +An ES module loaded by URL. Use it to switch themes at run time — a theme picker, a +per-user preference, a per-tenant brand. + +```js +import { applyTheme, watchSystemAppearance } from 'https://themes.example.com/rt/v1/runtime.js' + +watchSystemAppearance() // follow the operating system +applyTheme('midnight') // fetch and apply + +document.documentElement.dataset.bananasTheme = 'daylight' // or just set the attribute +``` + +### 3. React + +A provider and hooks, loaded through an import map. React stays the host +application's own copy; nothing is bundled and nothing is installed. + +```html + +``` + +```jsx +import { BananasProvider, useBananas, useThemeVariable } from 'go-bananas/runtime' + +function App() { + return ( + + + + ) +} + +function Badge() { + const { resolvedAppearance } = useBananas() + const primary = useThemeVariable('color-semantic-primary') + return {resolvedAppearance} +} +``` + +### Or one script tag + +```html + +``` + +### Reading tokens from JavaScript + +Some things cannot read CSS: a canvas chart, a PDF generator, an email template. + +```js +const { variables, tokens } = await fetch( + 'https://themes.example.com/rt/v1/theme.json?slug=midnight' +).then((response) => response.json()) + +const primary = variables['--bn-color-semantic-primary'] +``` + +### Using the variables directly + +Every token becomes a CSS custom property prefixed `--bn-`. + +```css +.my-card { + background: var(--bn-color-semantic-surface-raised); + color: var(--bn-color-semantic-text); + border: 1px solid var(--bn-color-semantic-border); + border-radius: var(--bn-radius-lg); + padding: var(--bn-space-6); + font-family: var(--bn-font-family-sans); + box-shadow: var(--bn-shadow-md); + transition: background-color var(--bn-duration-fast) var(--bn-ease-standard); +} +``` + +Or use the bundled component classes, which are written entirely in terms of the +generated variables: + +```html +
+
Deployed
+

Everything here comes from a design token.

+
+ + +healthy +``` + +--- + +## The token model + +A theme is a [W3C Design Tokens Community Group](https://www.designtokens.org/) +document. That format was chosen deliberately: it is an existing standard, so +tokens exported from this service can be imported into Figma, Style Dictionary or +any other DTCG-aware tool. + +```json +{ + "color": { + "palette": { + "brand": { + "500": { "$type": "color", "$value": "#7c6bf5" }, + "600": { "$type": "color", "$value": "#6a58e8" } + } + }, + "semantic": { + "primary": { "$type": "color", "$value": "#7c6bf5" }, + "on-primary": { "$type": "color", "$value": "#0f0d1f" }, + "background": { "$type": "color", "$value": "#1a1d23" } + } + }, + "font": { + "family": { "sans": { "$type": "fontFamily", "$value": "Inter, system-ui, sans-serif" } }, + "size": { "md": { "$type": "fontSize", "$value": 14 } } + }, + "space": { "4": { "$type": "dimension", "$value": 16 } }, + + "$appearances": { + "dark": { + "color": { "semantic": { "background": { "$type": "color", "$value": "#0b1220" } } } + } + } +} +``` + +**Palettes are for building; semantic roles are for using.** A component consumes +`color.semantic.primary`, never `color.palette.brand.500`. That indirection is what +makes it possible to re-tint a whole product by changing one palette step, and it +is why the engine derives the foreground pairs automatically. + +**Light and dark are one document.** `$appearances` holds a partial token document +per colour scheme, deep-merged over the base. A token absent from the dark +overrides simply inherits its light value, so a theme with a dozen genuine +differences does not need two hundred. + +**The engine derives the rest.** For a theme that does not define them: + +| Derived | Rule | +| --- | --- | +| `--bn-color-on-*` | Black or white, whichever has the higher WCAG contrast ratio against the base colour | +| `--bn-color-*-hover` | The base mixed 8% toward the foreground of the scheme | +| `--bn-color-*-active` | Mixed 16% | +| `--bn-color-*-subtle` | The base at 12% alpha, for badge and hover-row backgrounds | +| `--bn-color-*-tint` | The base mixed 14% into the background | + +Anything you define yourself wins: derivation only fills gaps. + +### Validation + +Publishing is gated on validation. The engine checks structure, value types and +colour syntax, and warns about a missing dark scheme, an unused recommended token +and text that fails WCAG AA. + +Every built-in theme is asserted against WCAG AA for body text on every surface it +is likely to sit on, in both schemes — a test, not a review. The `high-contrast` +theme is held to AAA. + +--- + +## Built-in themes + +Four ship inside the binary and are reconciled at start-up. A built-in theme an +operator has edited is never overwritten; the reconciler compares the version. + +| Slug | Scheme | Purpose | +| --- | --- | --- | +| `midnight` | dark | The default. Violet-accented dark interface on a slate ramp. | +| `daylight` | light | The same brand hue, inverted, with softer shadows. | +| `mono` | light | Near-greyscale and dense. For tables, consoles and data tooling. | +| `high-contrast` | both | Pure black on pure white, thick strokes, no translucency. AAA. | + +To make one of your own, duplicate it and edit the copy. + +--- + +## The runtime API + +Public by default. A theming service that required a credential could not be +consumed from a stylesheet link, which is the primary use case. Every response is +cacheable and carries an `ETag`. + +| Endpoint | Purpose | +| --- | --- | +| `GET /rt/v1/theme.css?slug=…` | A stylesheet. `appearance`, `variant` and `v` are optional. | +| `GET /rt/v1/theme.json?slug=…` | Resolved tokens, flat and nested. | +| `GET /rt/v1/manifest.json` | The theme list, for building a picker. | +| `GET /rt/v1/embed?slug=…` | A ready-to-paste snippet for the target framework. | +| `GET /rt/v1/runtime.js` | The framework-free ES module. | +| `GET /rt/v1/react.js` | The React provider and hooks. | +| `GET /rt/v1/global.js` | The same API on `window.Bananas`. | +| `GET /rt/v1/embed.js` | The one-line script. | + +```bash +curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight' +curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&v=7' # pin a version +curl 'https://themes.example.com/rt/v1/manifest.json' | jq '.themes[].slug' +``` + +Path-addressable equivalents exist for consumers that prefer REST shapes or a CDN +configured with path patterns: `/rt/v1/themes/{slug}/theme.css`. + +### Cache behaviour + +Runtime responses are immutable by URL: publishing a theme changes its version, +and a version-pinned URL never changes. That lets a CDN cache them hard and +revalidate in a few milliseconds. `If-None-Match` is honoured, so a redeploy costs +a revalidation and no body transfer. + +--- + +## The administration API + +Authenticated, never cached, and every mutation audited. See +[docs/api.md](docs/api.md). + +| Method | Path | Role | Purpose | +| --- | --- | --- | --- | +| `GET` | `/api/v1/themes` | viewer | List and search | +| `GET` | `/api/v1/themes/{slug}` | viewer | One theme with its tokens | +| `POST` | `/api/v1/themes` | editor | Create a draft | +| `PATCH` | `/api/v1/themes/{slug}` | editor | Update, with optimistic concurrency | +| `POST` | `/api/v1/themes/{slug}/publish` | editor | Publish | +| `DELETE` | `/api/v1/themes/{slug}` | admin | Delete | +| `GET` | `/api/v1/themes/{slug}/versions` | viewer | History | +| `POST` | `/api/v1/themes/{slug}/versions/{v}/rollback` | editor | Restore as a new version | +| `POST` | `/api/v1/tokens/validate` | viewer | Validate without saving | +| `GET` | `/api/v1/tokens/contrast?fg=…&bg=…` | viewer | WCAG contrast | +| `GET` | `/api/v1/tokens/palette?hue=…` | viewer | Generate a ramp | +| `GET` | `/api/v1/admin/audit` | admin | The activity log | + +--- + +## Architecture + +``` + ┌──────────────────────────────────────┐ + Browser ───────▶│ /rt/v1 runtime, public, cached │──┐ + , fetch │ /api/v1 admin, authenticated │ │ in-memory + │ /auth/* OIDC login flow │ │ render cache + └──────────────────────────────────────┘ │ + │ │ + chi router + middleware │ + │ │ + ┌────────────────▼─────────────────────────┴────┐ + │ auth OIDC · sessions · RBAC │ + │ httpapi handlers, no business logic │ + │ tokenengine colour maths, CSS and JSON render │ + │ domain entities and invariants │ + │ store SQLite (WAL), repositories │ + └──────────────────┬──────────────────────────────┘ + │ + SQLite on a local volume +``` + +The load-bearing decisions, each argued in `docs/adr/`: + +- [ADR 0001](docs/adr/0001-embedded-sqlite.md) — why SQLite and not Postgres +- [ADR 0002](docs/adr/0002-runtime-delivery.md) — why delivery is at run time +- [ADR 0003](docs/adr/0003-stateful-sessions.md) — stateless signed cookies + +The properties that matter: + +- **The admin UI consumes the runtime.** It loads themes from `/rt/v1` and maps the + variables onto Tailwind's namespaces. A change that breaks theme delivery breaks + the editor, immediately, rather than being discovered by a customer. +- **Handlers hold no business logic.** They decode, call, encode and translate + errors. Anything worth reasoning about lives in `domain` or `tokenengine` where it + is testable without an HTTP server. +- **The render cache is pre-warmed** at start-up, so the first request after a + deploy is already fast. A write invalidates exactly one theme's entries. +- **The database connection pool is deliberately small.** SQLite serialises + writers; more connections buy contention, not throughput. + +--- + +## Why SQLite + +Themes are a few thousand JSON documents, read constantly and written rarely. +That is precisely the shape 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 it fast rather than merely adequate: + +| Setting | Why | +| --- | --- | +| `journal_mode=WAL` | Readers never block the writer, and the writer never blocks readers | +| `synchronous=NORMAL` | Durable across a process crash; risks only the last transactions on an OS crash. A re-done publish is not an outage | +| `cache_size=-16000` | A 16 MiB cache holds the whole dataset | +| `mmap_size=256 MiB` | The page cache serves reads without a copy | +| Max 8 connections | SQLite serialises writers; a large pool adds contention | +| Prepared statements | The hot runtime query is compiled once | +| In-memory render cache | The steady-state runtime request touches no database at all | + +The engine is `modernc.org/sqlite`, a pure-Go translation. No cgo, so the release +image builds static and the cross-compiled binaries are genuinely portable. + +**Scaling past one replica** needs either a shared filesystem or a different +database. Both are documented in [docs/deployment.md](docs/deployment.md#scaling). + +--- + +## Deployment + +The Helm chart validates its own configuration at render time and refuses to +produce a manifest it knows is wrong: + +```bash +$ helm template g deploy/helm/go-bananas --set config.shutdownTimeoutMs=60000 +Error: config.shutdownTimeoutMs (60000) must be less than +deployment.terminationGracePeriodSeconds (45s), otherwise Kubernetes sends +SIGKILL before graceful shutdown finishes and in-flight requests are cut off. +``` + +Guarded at render time: a missing or malformed `publicBaseUrl`, an unknown auth +mode, a missing OIDC secret, both secret sources at once, `skipTLSVerify` in +production, and a shutdown timeout that exceeds the grace period. Failing at render +time means a bad release never reaches the API server, so there is no half-created +Deployment and no `CrashLoopBackOff` to diagnose. + +Security posture by default: non-root, read-only root filesystem, all capabilities +dropped, no API token mounted, no CPU limit, security headers on every response, and +a Content-Security-Policy strict enough that the admin UI needed no exceptions for +script sources. + +--- + +## Development + +```bash +make help # every task +make install # dependencies +make dev-all # API on :8080, admin UI on :5173 +make check # format, lint and test everything +``` + +Requires Go 1.27 and Node 22 or later. `make tools` installs `air`, +`golangci-lint` and `sqlc`. + +## Documentation + +| Document | Contents | +| --- | --- | +| [docs/architecture.md](docs/architecture.md) | How the pieces fit, and why | +| [docs/standards.md](docs/standards.md) | **Coding standards and conventions. Read this before contributing** | +| [docs/integration.md](docs/integration.md) | Consuming themes, every framework | +| [docs/api.md](docs/api.md) | The HTTP API, with examples | +| [docs/deployment.md](docs/deployment.md) | Running it, at any scale | +| [docs/development.md](docs/development.md) | Working on it | +| [docs/operations.md](docs/operations.md) | Monitoring, backup, runbook | +| [docs/security.md](docs/security.md) | Threat model and hardening | +| [docs/styling-contract.md](docs/styling-contract.md) | The shared styling contract, and how an application adopts it | +| [docs/adr/](docs/adr/) | Architecture decision records | + +## Licence + +MIT. \ No newline at end of file diff --git a/cat b/cat new file mode 100644 index 0000000..e69de29 diff --git a/cmd/go-bananas/main.go b/cmd/go-bananas/main.go new file mode 100644 index 0000000..a413227 --- /dev/null +++ b/cmd/go-bananas/main.go @@ -0,0 +1,95 @@ +// Command go-bananas is the theme service: an administrative UI for authoring +// design-token themes, and a runtime API that serves those themes to any +// application at run time. +// +// It reads its entire configuration from the environment so that the Helm chart +// is the single source of truth for how a deployment is configured. +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "syscall" + + "github.com/urpi/go-bananas/internal/app" +) + +func main() { + os.Exit(run(os.Args[1:])) +} + +// run dispatches on the subcommand and returns the process exit status. +// +// The subcommand is checked before the signal handler is installed: a backup is a +// short batch job that should exit on its own, and a context cancelled by an +// interrupt would make a snapshot look like it failed when it was in fact told to +// stop. +func run(args []string) int { + // The server is the default, so an invocation with no arguments, or one whose + // first argument is not a known subcommand, starts it. + if len(args) > 0 { + switch args[0] { + case "backup": + if err := app.RunBackup(context.Background(), args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "Go Bananas!! backup: %v\n", err) + return 1 + } + return 0 + case "version": + fmt.Printf("Go Bananas!! %s\n", app.Version) + return 0 + case "help", "-h", "--help": + usage(os.Stdout) + return 0 + } + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := app.Run(ctx); err != nil { + // Configuration and wiring failures are the expected failure mode at + // start-up, so they are reported plainly on stderr with no log decoration. + fmt.Fprintf(os.Stderr, "Go Bananas!!: %v\n", err) + return exitCodeFor(err) + } + return 0 +} + +// usage prints the top-level help. +func usage(w io.Writer) { + fmt.Fprint(w, `usage: go-bananas [command] + +Runs the theme service: an administrative UI for authoring design-token themes and +a runtime API that serves them to any application at run time. + +With no command, the HTTP server starts. Configuration comes entirely from the +environment; see docs/deployment.md. + +Commands: + backup Write a consistent snapshot of the database and exit + version Print the build version + help Print this message + +Examples: + go-bananas + go-bananas backup --to /srv/data/snapshots/go-bananas.db +`) +} + +// exitCodeFor maps an error onto a process exit status. +// +// 1 configuration or wiring failure +// 2 the context was cancelled before the server started +func exitCodeFor(err error) int { + switch { + case errors.Is(err, context.Canceled): + return 2 + default: + return 1 + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..c051152 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,143 @@ +# Local development stack. +# +# The default profile is the simplest thing that works: one container, the +# development authentication bypass, and a volume for the database. That is enough +# to work on the product. +# +# The `oidc` profile adds a mock OpenID Connect provider, so the authentication +# flow can be exercised end to end without pointing at a real realm. The `oidc` +# profile uses the mock because auth-dev.urpi.be is a shared development realm and a +# local stack should not depend on it being reachable. +# +# Usage: +# docker compose up --build # just the service, dev auth +# docker compose --profile oidc up --build # with a mock identity provider +# docker compose --profile postgres up # against a real PostgreSQL, for parity testing +# +# Ports: +# 8080 Go Bananas!! (admin UI and runtime API) +# 8081 mock OIDC provider (oidc profile) +# 5432 PostgreSQL (postgres profile) + +name: go-bananas + +services: + go-bananas: + build: + context: . + dockerfile: Dockerfile + args: + VERSION: ${VERSION:-dev} + COMMIT: ${COMMIT:-unknown} + BUILD_DATE: ${BUILD_DATE:-unknown} + image: ${IMAGE:-go-bananas:dev} + ports: + - "${PORT:-8080}:8080" + environment: + # Development environment. This is what unlocks the auth bypass; setting it + # to anything else makes the service refuse to start in dev mode, which is + # deliberate: an accidentally deployed bypass must not be possible. + BANANAS_ENVIRONMENT: development + BANANAS_AUTH_MODE: ${AUTH_MODE:-dev} + + BANANAS_HTTP_ADDR: :8080 + BANANAS_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:8080} + + # An explicit secret so that a session survives `docker compose restart`. + # This is a local-only value and is intentionally obvious about it. + BANANAS_SESSION_SECRET: ${SESSION_SECRET:-local-development-secret-do-not-use-in-production-000000} + + BANANAS_LOG_LEVEL: ${LOG_LEVEL:-debug} + BANANAS_LOG_FORMAT: ${LOG_FORMAT:-text} + + BANANAS_DATABASE_PATH: /srv/data/go-bananas.db + BANANAS_SEED_ON_START: "true" + + # Present only in the oidc profile; Compose passes the value through and the + # service ignores these when AUTH_MODE is dev. + BANANAS_OIDC_ISSUER: ${OIDC_ISSUER:-} + BANANAS_OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + BANANAS_OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + BANANAS_OIDC_SKIP_TLS_VERIFY: ${OIDC_SKIP_TLS_VERIFY:-false} + BANANAS_OIDC_ADMIN_GROUPS: ${OIDC_ADMIN_GROUPS:-} + BANANAS_OIDC_EDITOR_GROUPS: ${OIDC_EDITOR_GROUPS:-} + BANANAS_SESSION_SECURE: ${SESSION_SECURE:-false} + volumes: + - go-bananas-data:/srv/data + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8080/healthz"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 5s + restart: unless-stopped + # The Compose file is a local development convenience. The resource limits here + # are not a production sizing recommendation; use the Helm chart for that. + deploy: + resources: + limits: + cpus: "2.0" + memory: 512M + + # ------------------------------------------------------------ oidc profile -- + + keycloak: + image: quay.io/keycloak/keycloak:26.3 + profiles: ["oidc"] + command: + - start-dev + - --import-realm + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + KC_HOSTNAME_STRICT: "false" + # Lets the issuer be reached from the host and from inside the network under + # the same name, which is what makes the discovery document work in both. + KC_HOSTNAME_URL: http://keycloak:8080 + KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true" + volumes: + - ./deploy/dev/realm-go-bananas.json:/opt/keycloak/data/import/realm-go-bananas.json:ro + ports: + - "${KEYCLOAK_PORT:-8081}:8080" + healthcheck: + # Keycloak's own health endpoint is the only reliable readiness signal; the + # port is open well before the realm is actually ready. + test: + [ + "CMD-SHELL", + "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'", + ] + interval: 10s + timeout: 5s + retries: 20 + start_period: 20s + + # ------------------------------------------------------- postgres profile -- + # + # go-bananas does not need PostgreSQL. This profile exists so that a future + # backend can be compared against it for parity, not because it is part of the + # product's architecture. + + postgres: + image: postgres:18-alpine + profiles: ["postgres"] + environment: + POSTGRES_DB: go_bananas + POSTGRES_USER: go_bananas + POSTGRES_PASSWORD: local-only-password + volumes: + - go-bananas-postgres:/var/lib/postgresql/data + ports: + - "${POSTGRES_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U go_bananas -d go_bananas"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + go-bananas-data: + driver: local + go-bananas-postgres: + driver: local diff --git a/deploy/dev/realm-go-bananas.json b/deploy/dev/realm-go-bananas.json new file mode 100644 index 0000000..e4b9457 --- /dev/null +++ b/deploy/dev/realm-go-bananas.json @@ -0,0 +1,120 @@ +{ + "realm": "go-bananas-dev", + "enabled": true, + "displayName": "go-bananas (development)", + "sslRequired": "none", + "registrationAllowed": true, + "registrationEmailAsUsername": true, + "rememberMe": true, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "clients": [ + { + "clientId": "go-bananas", + "name": "go-bananas", + "description": "The theme service: admin UI and runtime API.", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "go-bananas-dev-secret", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:8080/auth/callback", + "http://localhost:5173/auth/callback" + ], + "webOrigins": [ + "http://localhost:8080", + "http://localhost:5173" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://localhost:8080/*" + }, + "defaultClientScopes": [ + "openid", + "profile", + "email", + "groups", + "roles" + ], + "optionalClientScopes": [ + "offline_access" + ] + } + ], + "roles": { + "realm": [ + { "name": "bananas-admin", "description": "Full administrative access." }, + { "name": "bananas-editor", "description": "Create and publish themes." }, + { "name": "bananas-viewer", "description": "Read-only access." } + ] + }, + "groups": [ + { + "name": "bananas-admins", + "path": "/bananas-admins", + "attributes": { "displayName": "go-bananas administrators" } + }, + { + "name": "bananas-editors", + "path": "/bananas-editors", + "attributes": { "displayName": "go-bananas editors" } + }, + { + "name": "bananas-viewers", + "path": "/bananas-viewers", + "attributes": { "displayName": "go-bananas viewers" } + } + ], + "users": [ + { + "username": "admin", + "email": "admin@go-bananas.local", + "firstName": "Dev", + "lastName": "Admin", + "emailVerified": true, + "enabled": true, + "credentials": [ + { "type": "password", "value": "admin", "temporary": false } + ], + "groups": ["/bananas-admins"], + "realmRoles": ["bananas-admin"] + }, + { + "username": "editor", + "email": "editor@go-bananas.local", + "firstName": "Dev", + "lastName": "Editor", + "emailVerified": true, + "enabled": true, + "credentials": [ + { "type": "password", "value": "editor", "temporary": false } + ], + "groups": ["/bananas-editors"], + "realmRoles": ["bananas-editor"] + }, + { + "username": "viewer", + "email": "viewer@go-bananas.local", + "firstName": "Dev", + "lastName": "Viewer", + "emailVerified": true, + "enabled": true, + "credentials": [ + { "type": "password", "value": "viewer", "temporary": false } + ], + "groups": ["/bananas-viewers"], + "realmRoles": ["bananas-viewer"] + } + ] +} \ No newline at end of file diff --git a/deploy/dev/values-dev-cluster.yaml b/deploy/dev/values-dev-cluster.yaml new file mode 100644 index 0000000..cc6c09c --- /dev/null +++ b/deploy/dev/values-dev-cluster.yaml @@ -0,0 +1,90 @@ +# go-bananas on the development cluster. +# +# Reproduce with: +# +# helm upgrade --install go-bananas deploy/helm/go-bananas \ +# --namespace go-insane-dev -f deploy/dev/values-dev-cluster.yaml \ +# --server-side=false --disable-openapi-validation \ +# --wait +# +# Two flags are needed for this cluster specifically, and neither is a chart defect. +# +# --server-side=false The cluster's OpenAPI validation is incomplete: +# it rejects `imagePullPolicy` and `lifecycle` at +# `spec.template.spec` as undeclared fields on a +# k8s 1.36 API server. A hand-written Deployment +# with those two fields fails the same way. Helm 4 +# uses server-side apply by default, which hits it. +# --disable-openapi-validation The same broken schema, on the client side. +# +# Nothing else in the cluster needs these; they are here so this release is +# reproducible rather than "whatever worked when it was deployed". + +image: + # The development cluster pulls from a private registry whose certificate is valid + # for a different name, so the host and the port have to stay in one piece: putting + # the port in `repository` renders an image reference without it, and the kubelet + # then resolves the registry over https on port 443. + registry: batty1039.startdedicated.net:5000 + repository: go-bananas + # The development registry is tagged `latest` rather than pinned to a version, to + # match every other release in this namespace. Consequently the pull policy must be + # Always: with IfNotPresent a node keeps serving the image it already has, and a + # redeploy silently does nothing. + tag: latest + pullPolicy: Always + +config: + # development, which is what permits the dev identity mode below. The application + # refuses both of these outside a development environment, so this pair cannot be + # promoted to production by accident. + environment: development + publicBaseUrl: https://bananas-dev.urpi.be + +identity: + # dev, not oidc. + # + # OIDC is the correct production mode and the chart is wired for it, but it cannot + # be used here yet: the cluster's CoreDNS returns NXDOMAIN for public names, so the + # issuer is unreachable from inside the cluster and the pod crash-loops in OIDC + # discovery. That is a cluster DNS problem, not a chart one, and it also explains why + # no certificate in this namespace has been issued. + # + # To switch, create the secret and set the OIDC values: + # + # kubectl create secret generic go-bananas-auth -n go-insane-dev \ + # --from-literal=session-secret="$(openssl rand -base64 48)" \ + # --from-literal=oidc-client-secret='' + # + # helm upgrade ... --set identity.mode=oidc \ + # --set identity.oidc.generateSecret=false \ + # --set identity.oidc.existingSecret=go-bananas-auth \ + # --set identity.oidc.issuer=https://auth-dev.urpi.be \ + # --set identity.oidc.clientId=go-bananas + mode: dev + oidc: + issuer: https://auth-dev.urpi.be + clientId: go-bananas + existingSecret: go-bananas-auth + generateSecret: false + +ingress: + enabled: true + annotations: + cert-manager.io/cluster-issuer: letsencrypt-staging + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: bananas-dev.urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-bananas-tls + hosts: + - bananas-dev.urpi.be + +deployment: + # One replica. The volume is ReadWriteOnce, so a second replica cannot mount it and + # would sit in ImagePullBackOff-equivalent limbo forever. Scaling needs either this + # changed to 1 or a ReadWriteMany filesystem. + replicaCount: 1 \ No newline at end of file diff --git a/deploy/helm/go-bananas/.helmignore b/deploy/helm/go-bananas/.helmignore new file mode 100644 index 0000000..9073086 --- /dev/null +++ b/deploy/helm/go-bananas/.helmignore @@ -0,0 +1,19 @@ +# Files excluded from the packaged chart. Anything matched here is neither sent to +# the cluster nor included in `helm package`, which keeps the artifact small and +# stops a local editor's swap file from ever reaching a release. +.DS_Store +.git/ +.gitignore +.idea/ +.vscode/ +*.swp +*.bak +*.tmp +*.orig +*.tgz +*.md + +# Documentation is excluded from the packaged chart: it is several hundred +# kilobytes of prose that the cluster does not need, and a chart artifact +# should contain only what is required to deploy. +*.md diff --git a/deploy/helm/go-bananas/Chart.yaml b/deploy/helm/go-bananas/Chart.yaml new file mode 100644 index 0000000..08e49f8 --- /dev/null +++ b/deploy/helm/go-bananas/Chart.yaml @@ -0,0 +1,36 @@ +apiVersion: v2 +name: go-bananas +description: >- + Runtime design-token service. Author design-token themes in a web interface and + serve them to any application as CSS custom properties at run time, with no npm + dependency and no build step on the consumer side. +type: application + +# The chart version. Bumped for every change to the chart itself. +version: 1.0.0 + +# The version of the application this chart deploys, which follows the image tag. +# Keeping the two in step is what `helm upgrade --reuse-values` needs to roll +# forward correctly. +appVersion: "1.0.0" + +kubeVersion: ">=1.28.0-0" + +keywords: + - design-system + - design-tokens + - theming + - css-variables + - frontend + +icon: https://raw.githubusercontent.com/urpi/go-bananas/main/docs/assets/logo.svg +home: https://github.com/urpi/go-bananas +sources: + - https://github.com/urpi/go-bananas +maintainers: + - name: urpi platform team + url: https://github.com/urpi + +annotations: + category: DeveloperTools + licenses: MIT diff --git a/deploy/helm/go-bananas/templates/NOTES.txt b/deploy/helm/go-bananas/templates/NOTES.txt new file mode 100644 index 0000000..b4ef892 --- /dev/null +++ b/deploy/helm/go-bananas/templates/NOTES.txt @@ -0,0 +1,89 @@ +{{- $name := include "go-bananas.fullname" . }} + +go-bananas {{ .Chart.AppVersion }} is deployed as {{ $name }} in namespace {{ .Release.Namespace }}. + +──────────────────────────────────────────────────────────────────────────── +{{- if .Values.ingress.enabled }} + +Your instance is reachable at: + +{{- range .Values.ingress.hosts }} +{{- $scheme := "http" }} +{{- if $.Values.ingress.tls }}{{ $scheme = "https" }}{{ end }} +{{- range $.Values.ingress.hosts }} + {{ $scheme }}://{{ .host }} +{{- end }} +{{- break }} +{{- end }} + +Follow the admin interface from there. Sign in with your identity provider. +{{- else if eq .Values.service.type "NodePort" }} + +Your instance is on node port {{ .Values.service.nodePort }}. Find the address with: + + kubectl get nodes -o wide + kubectl port-forward --address 0.0.0.0 svc/{{ $name }} 8080:{{ .Values.service.port }} +{{- else }} + +Port-forward to reach the admin interface: + + kubectl port-forward --namespace {{ .Release.Namespace }} svc/{{ $name }} 8080:{{ .Values.service.port }} + +Then open http://localhost:8080 +{{- end }} + +──────────────────────────────────────────────────────────────────────────── +Runtime integration + +{{- if eq .Values.identity.mode "oidc" }} + +Consumers fetch themes with no dependency and no build step. Add this to any +application's : + + + +Full instructions are at {{ include "go-bananas.publicUrl" . }}/rt/v1/embed?slug=midnight + +Available themes: + +{{- $base := printf "%s/rt/v1" (include "go-bananas.publicUrl" .) }} +{{- $base }}{{ "/theme.css?slug=" }} +{{- $base }}{{ "/theme.json?slug=" }} +{{- $base }}{{ "/manifest.json" }} +{{- $base }}{{ "/runtime.js" }} (framework-free ES module) +{{- $base }}{{ "/react.js" }} (React provider and hooks) +{{- $base }}{{ "/embed.js" }} (one-line script tag) +{{- else }} + +Authentication is set to "{{ .Values.identity.mode }}". Every request is currently +treated as an administrator. Before exposing this to a network, set: + + identity.mode=oidc + identity.oidc.issuer=https://auth-dev.urpi.be/realms/ + identity.oidc.clientId=go-bananas + identity.oidc.existingSecret= +{{- end }} + +──────────────────────────────────────────────────────────────────────────── +Verify it is working + + kubectl --namespace {{ .Release.Namespace }} get pods -l app.kubernetes.io/instance={{ .Release.Name }} + kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ $name }} 8080:{{ .Values.service.port }} + curl -s localhost:{{ .Values.service.port }}/readyz +{{- if .Values.config.enableMetrics }} + + curl -s localhost:{{ .Values.service.port }}/metrics | head +{{- end }} + +──────────────────────────────────────────────────────────────────────────── +Notes + + * The database is a single SQLite file on a {{ .Values.persistence.type | default "disabled" }} volume +{{- if and .Values.persistence.enabled (eq .Values.persistence.type "persistentVolumeClaim") }} + at {{ .Values.persistence.mountPath }}. It is retained on uninstall. +{{- end }} + * {{ .Values.deployment.replicaCount }} replica(s). With a ReadWriteOnce volume, more than one + replica needs a filesystem that permits concurrent mounts. + * Built-in themes are reconciled at start-up. A built-in theme an operator has + edited is never overwritten. diff --git a/deploy/helm/go-bananas/templates/_helpers.tpl b/deploy/helm/go-bananas/templates/_helpers.tpl new file mode 100644 index 0000000..d94981e --- /dev/null +++ b/deploy/helm/go-bananas/templates/_helpers.tpl @@ -0,0 +1,256 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "go-bananas.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +A fully qualified app name, truncated to the 63-character DNS label limit. + +The release name is included because a chart may legitimately be installed more +than once in a namespace: one for staging themes and one for production. Without +it, the two would collide on every object name. +*/}} +{{- define "go-bananas.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Chart name and version, as used by the standard chart label. +*/}} +{{- define "go-bananas.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Selector labels. + +These are deliberately stable: a Deployment's selector is immutable, so changing +these labels on an existing release makes the upgrade fail. That failure is the +point — it prevents the controller from orphaning pods. +*/}} +{{- define "go-bananas.selectorLabels" -}} +app.kubernetes.io/name: {{ include "go-bananas.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Common labels, including the recommended Kubernetes labels and the operator's own. +*/}} +{{- define "go-bananas.labels" -}} +helm.sh/chart: {{ include "go-bananas.chart" . }} +{{ include "go-bananas.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: go-bananas +{{- with .Values.global.extraLabels }} +{{ toYaml . }} +{{- end }} +{{- end }} + +{{/* +The image reference, honouring the digest override. + +A digest is preferred over a tag when both are available: it is the only way to +guarantee that a rollout is running exactly the image that was reviewed, rather +than whatever a mutable tag points at today. + +Both branches build the reference the same way — registry, then repository path, +then the tag or digest — so the digest branch cannot drift into producing a +doubled registry prefix such as ghcr.io/ghcr.io/urpi/go-bananas. +*/}} +{{- define "go-bananas.image" -}} +{{- $registry := .Values.image.registry -}} +{{- if .Values.global.imageRegistry -}} +{{- $registry = .Values.global.imageRegistry -}} +{{- end -}} +{{- if not $registry -}} +{{- fail "image.registry must not be empty. It is the registry host, for example ghcr.io or registry.example.com." -}} +{{- end -}} +{{- $repository := .Values.image.repository -}} +{{- /* Convention: image.registry is the host and image.repository is the path. + But an operator who writes the host into repository should get the reference + they asked for, not ghcr.io/quay.io/other/app. A first path segment + containing a dot or a colon, or equal to localhost, is a registry host + rather than a namespace. */ -}} +{{- $first := first (splitList "/" $repository) -}} +{{- $looksQualified := or (eq $repository $registry) (hasPrefix (printf "%s/" $registry) $repository) (contains "." $first) (contains ":" $first) (eq $first "localhost") -}} +{{- $name := $repository -}} +{{- if not $looksQualified -}} +{{- $name = printf "%s/%s" $registry $repository -}} +{{- end -}} +{{- if .Values.image.digest -}} +{{- printf "%s@%s" $name .Values.image.digest -}} +{{- else -}} +{{- printf "%s:%s" $name (default .Chart.AppVersion .Values.image.tag) -}} +{{- end -}} +{{- end }} + +{{/* +The ServiceAccount name to use. +*/}} +{{- define "go-bananas.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "go-bananas.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Whether a generated Secret should be produced. + +Either mode needs a Secret, but the two differ in ownership. An existing Secret is +the operator's, so the chart only reads it. A generated one is created when +generateSecret is true, and the value is preserved across upgrades so that a +rolling update does not silently invalidate every session. +*/}} +{{- define "go-bananas.usesExistingSecret" -}} +{{- if and .Values.identity.oidc.existingSecret .Values.identity.oidc.generateSecret -}} +{{- fail "identity.oidc.existingSecret and identity.oidc.generateSecret are mutually exclusive. Set generateSecret to false when supplying an existing Secret." -}} +{{- end -}} +{{- if not (or .Values.identity.oidc.existingSecret .Values.identity.oidc.generateSecret) -}} +{{- fail "identity.oidc needs a Secret. Set identity.oidc.existingSecret to a Secret you manage, or identity.oidc.generateSecret to true for a chart-managed one." -}} +{{- end -}} +{{- .Values.identity.oidc.existingSecret -}} +{{- end }} + +{{/* +The name of the Secret holding the session and client secrets. +*/}} +{{- define "go-bananas.secretName" -}} +{{- if .Values.identity.oidc.existingSecret -}} +{{- .Values.identity.oidc.existingSecret -}} +{{- else -}} +{{- printf "%s-auth" (include "go-bananas.fullname" .) -}} +{{- end -}} +{{- end }} + +{{/* +The key inside the Secret holding the session signing secret. +*/}} +{{- define "go-bananas.sessionSecretKey" -}} +{{- .Values.identity.sessionSecretKey -}} +{{- end }} + +{{/* +The key inside the Secret holding the OIDC client secret. +*/}} +{{- define "go-bananas.clientSecretKey" -}} +{{- default .Values.identity.oidc.clientSecretKey .Values.identity.oidc.existingSecretKeys.clientSecret -}} +{{- end }} + +{{/* +Validate the identity configuration before anything is rendered. + +Failing at template time is much better than failing at run time: a misconfigured +release never reaches the API server, so there is no half-created Deployment and no +CrashLoopBackOff to diagnose. Each check names the value to change. +*/}} +{{- define "go-bananas.validate" -}} +{{- $mode := .Values.identity.mode -}} +{{- if not (has $mode (list "oidc" "dev" "disabled")) -}} +{{- fail (printf "identity.mode %q is not valid. Use oidc, dev or disabled." $mode) -}} +{{- end -}} + +{{- if eq $mode "oidc" -}} + {{- if not .Values.identity.oidc.issuer -}} + {{- fail "identity.oidc.issuer is required when identity.mode is oidc. For the development realm this is https://auth-dev.urpi.be/realms/." -}} + {{- end -}} + {{- if not (regexMatch "^https?://" .Values.identity.oidc.issuer) -}} + {{- fail (printf "identity.oidc.issuer %q is not a URL. It must include the scheme." .Values.identity.oidc.issuer) -}} + {{- end -}} + {{- if not .Values.identity.oidc.clientId -}} + {{- fail "identity.oidc.clientId is required when identity.mode is oidc." -}} + {{- end -}} + {{- /* The result is discarded on purpose. This call exists for its fail-fast side + effect, and returning the Secret *name* from the helper would write it into + the rendered manifest as a bare line above `apiVersion`, which is invalid + YAML. It only went unnoticed because with the default generateSecret the + helper returns the empty string. */ -}} + {{- $_ := include "go-bananas.usesExistingSecret" . -}} +{{- end -}} + +{{- if eq $mode "oidc" -}} + {{- /* Refused in production because it disables certificate verification on the + connection to the identity provider, which is the one connection where a + man-in-the-middle would be most damaging. */}} + {{- if and .Values.identity.oidc.skipTLSVerify (eq .Values.config.environment "production") -}} + {{- fail "identity.oidc.skipTLSVerify cannot be combined with config.environment: production. It disables certificate verification against the identity provider. Set config.environment to development, or supply a trusted CA instead." -}} + {{- end -}} +{{- end -}} + +{{- if not .Values.config.publicBaseUrl -}} +{{- fail "config.publicBaseUrl is required. It is used to build the OIDC redirect URI, the absolute URLs in the runtime manifest, and the HSTS decision." -}} +{{- end -}} +{{- if not (regexMatch "^https?://[^/]+" .Values.config.publicBaseUrl) -}} +{{- fail (printf "config.publicBaseUrl %q must be an absolute URL with a scheme and host, for example https://themes.example.com" .Values.config.publicBaseUrl) -}} +{{- end -}} +{{- if hasSuffix "/" .Values.config.publicBaseUrl -}} +{{- fail (printf "config.publicBaseUrl %q must not end with a slash. Use https://themes.example.com, not https://themes.example.com/." .Values.config.publicBaseUrl) -}} +{{- end -}} + +{{- if and (gt (int .Values.config.shutdownTimeoutMs) 0) (gt (int .Values.deployment.terminationGracePeriodSeconds) 0) -}} + {{- /* The grace period is in seconds and the timeout in milliseconds, so the + comparison converts one to the other rather than comparing 25000 to 45. */}} + {{- $graceMs := mul (int .Values.deployment.terminationGracePeriodSeconds) 1000 -}} + {{- if ge (int .Values.config.shutdownTimeoutMs) $graceMs -}} + {{- fail (printf "config.shutdownTimeoutMs (%d) must be less than deployment.terminationGracePeriodSeconds (%ds), otherwise Kubernetes sends SIGKILL before graceful shutdown finishes and in-flight requests are cut off." (int .Values.config.shutdownTimeoutMs) (int .Values.deployment.terminationGracePeriodSeconds)) -}} + {{- end -}} +{{- end -}} + +{{- end }} + +{{/* +The public origin, with the base path removed so that it can be concatenated with +a route. +*/}} +{{- define "go-bananas.publicUrl" -}} +{{- .Values.config.publicBaseUrl -}} +{{- end }} + +{{/* +The base path, normalised to either an empty string or a leading-slash path with no +trailing slash. +*/}} +{{- define "go-bananas.basePath" -}} +{{- .Values.config.basePath | trimSuffix "/" -}} +{{- end }} + +{{/* +Whether the deployment is served over TLS, which decides the Secure cookie flag and +whether HSTS is advertised. +*/}} +{{- define "go-bananas.isSecure" -}} +{{- if .Values.service.sessionSecure -}}true{{- end -}} +{{- end }} + +{{/* +The database file path inside the container. +*/}} +{{- define "go-bananas.databasePath" -}} +{{- printf "%s/go-bananas.db" (trimSuffix "/" .Values.persistence.mountPath) -}} +{{- end }} + +{{/* +The data volume name, whether it is a PVC or an emptyDir. +*/}} +{{- define "go-bananas.dataVolumeName" -}} +{{- if .Values.persistence.existingClaim -}} +{{- .Values.persistence.existingClaim -}} +{{- else -}} +{{- printf "%s-data" (include "go-bananas.fullname" .) -}} +{{- end -}} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/configmap.yaml b/deploy/helm/go-bananas/templates/configmap.yaml new file mode 100644 index 0000000..48f6678 --- /dev/null +++ b/deploy/helm/go-bananas/templates/configmap.yaml @@ -0,0 +1,93 @@ +{{/* +The ConfigMap holding every non-secret setting. + +Splitting configuration from secrets is the point of this file: a ConfigMap is +readable by anyone with namespace access and appears in `kubectl describe`, so +nothing sensitive can end up in one by accident. +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- with .Values.global.extraAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +data: + # ---------------------------------------------------------------- runtime --- + BANANAS_ENVIRONMENT: {{ .Values.config.environment | quote }} + BANANAS_HTTP_ADDR: ":8080" + BANANAS_PUBLIC_BASE_URL: {{ include "go-bananas.publicUrl" . | quote }} + BANANAS_BASE_PATH: {{ include "go-bananas.basePath" . | quote }} + BANANAS_STATIC_DIR: "/srv/web" + BANANAS_SDK_DIR: "/srv/sdk" + + BANANAS_LOG_LEVEL: {{ .Values.config.logLevel | quote }} + BANANAS_LOG_FORMAT: {{ .Values.config.logFormat | quote }} + + # ---------------------------------------------------------------- storage --- + BANANAS_DATABASE_PATH: {{ include "go-bananas.databasePath" . | quote }} + BANANAS_DB_MAX_CONNS: {{ .Values.config.database.maxConns | quote }} + BANANAS_DB_BUSY_TIMEOUT: {{ printf "%dms" (int .Values.config.database.busyTimeoutMs) | quote }} + BANANAS_SEED_ON_START: "true" + + # ------------------------------------------------------------------- http --- + BANANAS_HTTP_READ_TIMEOUT: "15s" + BANANAS_HTTP_WRITE_TIMEOUT: "30s" + BANANAS_HTTP_IDLE_TIMEOUT: "120s" + BANANAS_SHUTDOWN_TIMEOUT: {{ printf "%dms" (int .Values.config.shutdownTimeoutMs) | quote }} + BANANAS_RATE_LIMIT_PER_MINUTE: {{ .Values.config.rateLimitPerMinute | quote }} + BANANAS_ENABLE_METRICS: {{ .Values.config.enableMetrics | quote }} + BANANAS_ENABLE_PROFILER: {{ .Values.config.enableProfiler | quote }} + + # Rendered as a comma-separated list because that is what the config loader + # parses. An empty value is deliberate: it means forwarding headers are ignored, + # so the audit log records the real peer rather than a spoofable header. + BANANAS_TRUSTED_PROXIES: {{ join "," .Values.config.trustedProxies | quote }} + BANANAS_CORS_ALLOWED_ORIGINS: {{ join "," .Values.config.corsAllowedOrigins | quote }} + + # ----------------------------------------------------------------- runtime --- + BANANAS_RUNTIME_PUBLIC: "true" + BANANAS_RUNTIME_CACHE_CONTROL: "public, max-age=60, s-maxage=300, stale-while-revalidate=86400" + # Set this when assets are served from a CDN, so the manifest hands consumers the + # CDN URL rather than this service. + BANANAS_RUNTIME_CDN_BASE_URL: {{ .Values.config.runtimeCdnBaseUrl | default "" | quote }} + + # ------------------------------------------------------------- identity --- + BANANAS_AUTH_MODE: {{ .Values.identity.mode | quote }} + + {{- if eq .Values.identity.mode "oidc" }} + BANANAS_OIDC_ISSUER: {{ .Values.identity.oidc.issuer | quote }} + BANANAS_OIDC_CLIENT_ID: {{ .Values.identity.oidc.clientId | quote }} + BANANAS_OIDC_SCOPES: {{ join "," .Values.identity.oidc.scopes | quote }} + BANANAS_OIDC_REDIRECT_URL: {{ .Values.identity.oidc.redirectURL | default "" | quote }} + BANANAS_OIDC_POST_LOGOUT_REDIRECT_URL: {{ .Values.identity.oidc.postLogoutRedirectURL | default "" | quote }} + {{- with .Values.identity.oidc.adminGroups }} + BANANAS_OIDC_ADMIN_GROUPS: {{ join "," . | quote }} + {{- end }} + {{- with .Values.identity.oidc.editorGroups }} + BANANAS_OIDC_EDITOR_GROUPS: {{ join "," . | quote }} + {{- end }} + {{- with .Values.identity.oidc.viewerGroups }} + BANANAS_OIDC_VIEWER_GROUPS: {{ join "," . | quote }} + {{- end }} + {{- if .Values.identity.oidc.skipTLSVerify }} + BANANAS_OIDC_SKIP_TLS_VERIFY: "true" + {{- end }} + {{- end }} + + {{- if eq .Values.identity.mode "dev" }} + # Development bypass: every request is a local administrator. The application + # refuses to start in this mode outside a development environment, so this block + # is refused by config.environment: production above. + BANANAS_DEV_FALLBACK_ROLE: "admin" + {{- end }} + + # ----------------------------------------------------------------- session --- + BANANAS_SESSION_COOKIE_NAME: "go-bananas-session" + BANANAS_SESSION_TTL: {{ printf "%dh" (int .Values.config.sessionTTLHours) | quote }} + BANANAS_SESSION_SECURE: {{ .Values.service.sessionSecure | quote }} + BANANAS_SESSION_SAMESITE: {{ .Values.service.sessionSameSite | quote }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/deployment.yaml b/deploy/helm/go-bananas/templates/deployment.yaml new file mode 100644 index 0000000..f42cdb3 --- /dev/null +++ b/deploy/helm/go-bananas/templates/deployment.yaml @@ -0,0 +1,218 @@ +{{- include "go-bananas.validate" . }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + app.kubernetes.io/component: server + {{- with .Values.global.extraAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.deployment.replicaCount }} + {{- end }} + revisionHistoryLimit: {{ .Values.deployment.revisionHistoryLimit }} + minReadySeconds: {{ .Values.deployment.minReadySeconds }} + progressDeadlineSeconds: {{ .Values.deployment.progressDeadlineSeconds }} + strategy: + {{- toYaml .Values.deployment.strategy | nindent 4 }} + selector: + matchLabels: + {{- include "go-bananas.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + # Rolling the pods when the ConfigMap changes is what makes `helm upgrade` + # actually apply a configuration change: without this annotation the + # Deployment spec is unchanged and Kubernetes would not restart anything. + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- if and .Values.persistence.enabled (eq .Values.persistence.type "persistentVolumeClaim") (not .Values.persistence.existingClaim) (gt (int .Values.deployment.replicaCount) 1) }} + # A ReadWriteOnce volume can be mounted by one pod at a time. With more than + # one replica the others will fail to start unless the storage class + # supports concurrent mounts. Set deployment.replicaCount to 1, or use a + # ReadWriteMany filesystem. + go-bananas.urpi.be/storage-note: >- + ReadWriteOnce volume with {{ .Values.deployment.replicaCount }} replicas. + Only one replica can mount it unless the storage class permits concurrent + mounts. + {{- end }} + {{- if and (eq .Values.identity.mode "oidc") .Values.identity.oidc.generateSecret }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- end }} + {{- with .Values.deployment.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "go-bananas.selectorLabels" . | nindent 8 }} + {{- with .Values.deployment.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullPolicy }} + imagePullPolicy: {{ . }} + {{- end }} + serviceAccountName: {{ include "go-bananas.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.deployment.priorityClassName }} + priorityClassName: {{ . }} + {{- end }} + {{- with .Values.deployment.hostAliases }} + hostAliases: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }} + {{- if .Values.deployment.lifecycle.preStop.enabled }} + # A preStop sleep gives the Service endpoints controller time to remove this + # pod from the endpoint list before the process starts refusing connections. + # Without it, a rolling update can drop requests that were already routed. + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "sleep {{ .Values.deployment.lifecycle.preStop.sleepSeconds }}"] + {{- end }} + containers: + - name: go-bananas + image: {{ include "go-bananas.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.deployment.extraArgs }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + envFrom: + - configMapRef: + name: {{ include "go-bananas.fullname" . }} + {{- if ne .Values.identity.mode "disabled" }} + env: + {{- /* + Secrets are injected through env rather than a mounted volume. Both + are equally protected by the kubelet; env avoids the temptation to + read the value from a file path into a log line, and the application + never echoes it back. + */}} + - name: BANANAS_SESSION_SECRET + valueFrom: + secretKeyRef: + name: {{ include "go-bananas.secretName" . }} + key: {{ include "go-bananas.sessionSecretKey" . }} + optional: false + {{- if and (eq .Values.identity.mode "oidc") (not (empty (include "go-bananas.usesExistingSecret" .))) }} + - name: BANANAS_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "go-bananas.secretName" . }} + key: {{ include "go-bananas.clientSecretKey" . }} + optional: false + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + {{- if .Values.probes.startup.enabled }} + startupProbe: + httpGet: + path: {{ .Values.probes.startup.path }} + port: http + periodSeconds: {{ .Values.probes.startup.periodSeconds }} + failureThreshold: {{ .Values.probes.startup.failureThreshold }} + {{- end }} + {{- if .Values.probes.liveness.enabled }} + # Liveness deliberately uses /healthz, which does not touch the database. + # A probe that fails on a slow database would have the kubelet restart a + # process that is otherwise healthy, turning a dependency blip into an + # outage. + livenessProbe: + httpGet: + path: {{ .Values.probes.liveness.path }} + port: http + initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.liveness.failureThreshold }} + {{- end }} + {{- if .Values.probes.readiness.enabled }} + # Readiness does check the database: a pod that cannot reach it cannot + # serve anything, and removing it from the endpoints is the correct + # response. + readinessProbe: + httpGet: + path: {{ .Values.probes.readiness.path }} + port: http + initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.readiness.failureThreshold }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: {{ .Values.persistence.mountPath }} + {{- /* + The root filesystem is read-only, so /tmp has to be an explicit + emptyDir. Go writes temporary files there during compression and + the SQLite driver uses it for spill, so without this the process + fails in ways that look unrelated. + */}} + - name: tmp + mountPath: /tmp + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: data + {{- if not .Values.persistence.enabled }} + emptyDir: {} + {{- else if .Values.persistence.existingClaim }} + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim }} + readOnly: false + {{- else if eq .Values.persistence.type "persistentVolumeClaim" }} + persistentVolumeClaim: + claimName: {{ include "go-bananas.dataVolumeName" . }} + readOnly: false + {{- else }} + emptyDir: {} + {{- end }} + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 32Mi + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.deployment.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.deployment.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.deployment.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.deployment.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/hpa.yaml b/deploy/helm/go-bananas/templates/hpa.yaml new file mode 100644 index 0000000..b5bb96f --- /dev/null +++ b/deploy/helm/go-bananas/templates/hpa.yaml @@ -0,0 +1,145 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- with .Values.global.extraAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "go-bananas.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} + {{- with .Values.autoscaling.behavior }} + behavior: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} +{{- if .Values.podDisruptionBudget.enabled }} +--- +{{/* +A PodDisruptionBudget only earns its keep if it does not also block a node drain. +minAvailable of 1 with two replicas permits exactly one disruption, which is the +useful setting: a drain can proceed, but not all pods at once. +*/}} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- with .Values.global.extraAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} + {{- else }} + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + {{- end }} + {{- with .Values.podDisruptionBudget.unhealthyPodEvictionPolicy }} + unhealthyPodEvictionPolicy: {{ . }} + {{- end }} + selector: + matchLabels: + {{- include "go-bananas.selectorLabels" . | nindent 6 }} +{{- end }} +{{- if .Values.networkPolicy.enabled }} +--- +{{/* +Default-deny ingress with explicit allowances. + +The default posture is deny: the administration API performs writes, so a cluster +where every pod can reach it means every pod can change every theme. The two +allowances are the ingress controller, which is how real users arrive, and the +metrics paths, so Prometheus can still scrape. +*/}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- with .Values.global.extraAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + podSelector: + matchLabels: + {{- include "go-bananas.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + ingress: + {{- if .Values.networkPolicy.allowedNamespaces }} + - from: + {{- range .Values.networkPolicy.allowedNamespaces }} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ . }} + {{- end }} + ports: + - protocol: TCP + port: 8080 + {{- else }} + # An empty allowedNamespaces list means the cluster's ingress controllers are + # allowed, identified by their well-known labels rather than by a namespace + # name, which differs between distributions. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: TCP + port: 8080 + {{- end }} + {{- if .Values.config.enableMetrics }} + - from: + {{- if .Values.networkPolicy.allowedNamespaces }} + {{- range .Values.networkPolicy.allowedNamespaces }} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ . }} + {{- end }} + {{- else }} + - namespaceSelector: {} + {{- end }} + ports: + - protocol: TCP + port: 8080 + {{- end }} +{{- end }} +{{- with .Values.extraManifests }} +--- +{{ toYaml . }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/ingress.yaml b/deploy/helm/go-bananas/templates/ingress.yaml new file mode 100644 index 0000000..d4a4f5f --- /dev/null +++ b/deploy/helm/go-bananas/templates/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.ingress.enabled }} +{{- $fullName := include "go-bananas.fullname" . }} +{{- $servicePort := .Values.service.port }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- if or .Values.ingress.annotations .Values.global.extraAnnotations }} + annotations: + {{- with .Values.global.extraAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ $fullName }} + port: + number: {{ $servicePort }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/pvc.yaml b/deploy/helm/go-bananas/templates/pvc.yaml new file mode 100644 index 0000000..d1fe88d --- /dev/null +++ b/deploy/helm/go-bananas/templates/pvc.yaml @@ -0,0 +1,161 @@ +{{- if .Values.persistence.enabled }} +{{- if eq .Values.persistence.type "persistentVolumeClaim" }} +{{- if not .Values.persistence.existingClaim }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "go-bananas.dataVolumeName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + annotations: + {{- /* + Retain the volume when the release is uninstalled. The themes in it are the + only copy of an operator's work: deleting a volume on uninstall would make + `helm uninstall` destructive in a way that is very easy to do by accident. + */}} + helm.sh/resource-policy: keep + {{- with .Values.global.extraAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- with .Values.persistence.storageClassName }} + storageClassName: {{ . | quote }} + {{- end }} +{{- end }} +{{- end }} +{{- if and .Values.persistence.snapshot.enabled (eq .Values.persistence.type "persistentVolumeClaim") }} +--- +{{/* +A scheduled SQLite snapshot. + +Three things this gets right that the obvious implementation does not: + +1. *It is a CronJob, not a Helm hook.* Combining `schedule` with + `helm.sh/hook: pre-upgrade` is a contradiction: a hook runs on every upgrade + regardless of the schedule, and with `hook-succeeded` the resource is then + deleted. As a hook it would both ignore its own schedule and never produce + anything to restore. + +2. *The snapshots land on the same volume.* An `emptyDir` destination would be + discarded the moment the pod exits, which makes the whole exercise pointless. + +3. *It runs the service's own `backup` command.* Copying a database file while a + write is in flight produces a torn copy. `go-bananas backup` uses SQLite's + VACUUM INTO, which reads through the database's own backup machinery and is + consistent without blocking writers. Using the application binary rather than a + `sqlite3` from some other image also guarantees the SQLite library is the same + version the schema was written for, and means no third-party image and no + package installation into a read-only root filesystem. + +The known limitation is stated rather than hidden: the job mounts the same +ReadWriteOnce volume as the Deployment, so on a cluster whose CSI driver enforces +single-node attachment strictly the job can fail to mount while the application is +running. If your cluster reports MultiAttach errors, give the job a node affinity +that pins it alongside the running pod, or take snapshots from outside the cluster. +A release using `emptyDir` data has nothing to snapshot and is unaffected. + +Restoring is manual, because a restore should be a deliberate act: + + kubectl -n exec -- /usr/local/bin/go-bananas backup --to /tmp/check.db + # then stop the service, replace the database file, and start it again. +*/}} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "go-bananas.fullname" . }}-snapshot + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + annotations: + {{- with .Values.global.extraAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + schedule: {{ .Values.persistence.snapshot.schedule | quote }} + concurrencyPolicy: Forbid + # Snapshots are named with a timestamp, so two runs cannot collide, and an + # overlap is a sign the volume is too slow rather than a problem to solve by + # running them at once. + startingDeadlineSeconds: 600 + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + metadata: + labels: + {{- include "go-bananas.labels" . | nindent 8 }} + spec: + backoffLimit: 2 + template: + metadata: + labels: + {{- include "go-bananas.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: snapshot + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + containers: + - name: snapshot + # The service image itself: the snapshot command is part of the + # binary, so there is no second image to keep in step with this one. + image: {{ include "go-bananas.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + env: + - name: DATABASE_PATH + value: {{ include "go-bananas.databasePath" . | quote }} + - name: SNAPSHOT_RETENTION_DAYS + value: {{ .Values.persistence.snapshot.retention | quote }} + # A shell, because the snapshot filename needs a timestamp and an argv + # list cannot expand one. + command: ["/bin/sh", "-euc"] + args: + - | + dest="/snapshot/go-bananas-$(date -u +%Y%m%dT%H%M%SZ).db" + /usr/local/bin/go-bananas backup --to "$dest" + + # Retention by age, run here rather than as a lifecycle hook so it + # happens on the same schedule as the snapshot and cannot be skipped + # by a container that failed to start. An old snapshot is removed even + # though the Job that produced it has long since been garbage + # collected. + find /snapshot -name 'go-bananas-*.db' \ + -mtime "+$SNAPSHOT_RETENTION_DAYS" -delete + ls -la /snapshot + volumeMounts: + - name: data + mountPath: {{ .Values.persistence.mountPath }} + readOnly: true + - name: snapshot + # The writable half of the same volume. Only the snapshot + # directory is writable; the database file itself is mounted + # read-only above. + mountPath: /snapshot + subPath: snapshots + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "go-bananas.dataVolumeName" .) }} + readOnly: true + - name: snapshot + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "go-bananas.dataVolumeName" .) }} + # Writable, and only for the subPath above. The pod as a whole can + # still reach the database, which is why the read-only mount and the + # `runAsNonRoot` security context are both kept. + readOnly: false +{{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/secret.yaml b/deploy/helm/go-bananas/templates/secret.yaml new file mode 100644 index 0000000..c3d21a5 --- /dev/null +++ b/deploy/helm/go-bananas/templates/secret.yaml @@ -0,0 +1,63 @@ +{{/* +The Secret holding the session signing key and, unless an existing Secret is +referenced, the OIDC client secret. + +Two things are deliberate here: + + * `lookup` is used to read an already-generated value back on upgrade. Without it, + every `helm upgrade` would rotate the session secret and silently sign every + user out. The client secret is not looked up: rotating a client secret is + occasionally the intent, and an operator who wants to keep it uses + `existingSecret`. + * Values are base64-encoded, not stringData, because stringData is transformed by + the API server and would make the stored manifest differ from what Helm holds, + which in turn makes the upgrade detection above unreliable. +*/}} +{{- if and (eq .Values.identity.mode "oidc") .Values.identity.oidc.generateSecret }} +{{- $secretName := include "go-bananas.secretName" . }} +{{- $sessionKey := include "go-bananas.sessionSecretKey" . }} +{{- $clientKey := include "go-bananas.clientSecretKey" . }} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secretName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + annotations: + # A hook annotation would be tempting, but a Secret must exist before the + # Deployment starts and a pre-install hook cannot guarantee ordering with a + # plain Deployment. + helm.sh/resource-policy: keep + {{- with .Values.global.extraAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +type: Opaque +data: + {{- /* Reuse the previous session secret so an upgrade does not sign everyone out. */}} + {{- $session := "" }} + {{- if $existing }} + {{- $session = (index $existing.data $sessionKey | default "") }} + {{- end }} + {{- if $session }} + {{ $sessionKey }}: {{ $session }} + {{- else }} + {{ $sessionKey }}: {{ randAlphaNum 64 | b64enc }} + {{- end }} + + {{- /* + The OIDC client secret is only generated when one was not supplied out of + band. Leaving it empty produces a deployment that fails to authenticate, which + the application reports clearly at start-up rather than as a login loop. + */}} + {{- $client := "" }} + {{- if $existing }} + {{- $client = (index $existing.data $clientKey | default "") }} + {{- end }} + {{- if $client }} + {{ $clientKey }}: {{ $client }} + {{- else }} + {{ $clientKey }}: {{ "" | b64enc }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/service.yaml b/deploy/helm/go-bananas/templates/service.yaml new file mode 100644 index 0000000..b00e6a4 --- /dev/null +++ b/deploy/helm/go-bananas/templates/service.yaml @@ -0,0 +1,64 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + app.kubernetes.io/component: server + {{- if or .Values.service.annotations .Values.global.extraAnnotations }} + annotations: + {{- with .Values.global.extraAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.service.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +spec: + type: {{ .Values.service.type }} + {{- with .Values.service.clusterIP }} + clusterIP: {{ . }} + {{- end }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} + selector: + {{- include "go-bananas.selectorLabels" . | nindent 4 }} + {{- if and (eq .Values.persistence.type "persistentVolumeClaim") (gt (int .Values.deployment.replicaCount) 1) }} + # More than one replica against a ReadWriteOnce volume needs a rollout strategy + # that does not overlap the two mounts. The values validator warns about this + # combination; this annotation records the intent in the live object. + sessionAffinity: None + {{- end }} + {{- with .Values.service.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- if and .Values.metrics.serviceMonitor.enabled .Values.config.enableMetrics }} +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- with .Values.metrics.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "go-bananas.selectorLabels" . | nindent 6 }} + endpoints: + - port: http + path: /metrics + interval: {{ .Values.metrics.serviceMonitor.interval }} + scrapeTimeout: {{ .Values.metrics.serviceMonitor.scrapeTimeout }} + honorLabels: {{ .Values.metrics.serviceMonitor.honorLabels }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/serviceaccount.yaml b/deploy/helm/go-bananas/templates/serviceaccount.yaml new file mode 100644 index 0000000..c5f92a5 --- /dev/null +++ b/deploy/helm/go-bananas/templates/serviceaccount.yaml @@ -0,0 +1,56 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "go-bananas.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- if or .Values.serviceAccount.annotations .Values.global.extraAnnotations }} + annotations: + {{- with .Values.global.extraAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.serviceAccount.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +{{- /* + The API token is not mounted. The service talks to nothing inside the cluster: + its database is a local file and its identity provider is external. Mounting a + token it does not need would be a credential available to any container escape. +*/}} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} +{{- end }} +{{- if .Values.rbac.create }} +--- +apiVersion: rbac.authoriz.k8s.io/v1 +kind: Role +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- with .Values.global.extraAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +rules: + {{- toYaml .Values.rbac.rules | nindent 2 }} +--- +apiVersion: rbac.authoriz.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "go-bananas.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "go-bananas.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/values-dev.yaml b/deploy/helm/go-bananas/values-dev.yaml new file mode 100644 index 0000000..17c354f --- /dev/null +++ b/deploy/helm/go-bananas/values-dev.yaml @@ -0,0 +1,44 @@ +# Dev values for go-bananas +nameOverride: "" +fullnameOverride: "" + +replicaCount: 1 + +image: + repository: nexus.urpi.be/docker-hosted/go-bananas + tag: "0.0.1" + pullPolicy: Always + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-staging + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: bananas-dev.intern-urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-bananas-tls + hosts: + - bananas-dev.intern-urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-staging + kind: ClusterIssuer + +resources: + limits: + cpu: 300m + memory: 256Mi + requests: + cpu: 50m + memory: 128Mi diff --git a/deploy/helm/go-bananas/values-lcl.yaml b/deploy/helm/go-bananas/values-lcl.yaml new file mode 100644 index 0000000..366b2da --- /dev/null +++ b/deploy/helm/go-bananas/values-lcl.yaml @@ -0,0 +1,51 @@ +# Local (LCL) values for go-bananas. +# Served at https://bananas-lcl.intern-urpi.be by the docker TLS edge. +# +# NOTE: this chart validates values against values.schema.json, which allows +# only the keys defined there -- no certManager and no replicaCount here +# (values-dev.yaml still carries both and fails `helm template`). +nameOverride: "" +fullnameOverride: "" + +image: + registry: localhost:5000 + repository: go-bananas + tag: "0.0.1" + pullPolicy: IfNotPresent + +service: + type: ClusterIP + port: 80 + +# Local environment: development unlocks the local authentication bypass, so +# the deployment works without the shared Keycloak realm at auth-dev.urpi.be +# (go-loose does not serve /realms/... endpoints, so the oidc default cannot +# be pointed at it either). +config: + environment: development + publicBaseUrl: "https://bananas-lcl.intern-urpi.be" + +identity: + mode: dev + +ingress: + enabled: true + className: traefik + annotations: {} + hosts: + - host: bananas-lcl.intern-urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: local-dev-cert + hosts: + - bananas-lcl.intern-urpi.be + +resources: + limits: + cpu: 300m + memory: 256Mi + requests: + cpu: 50m + memory: 128Mi diff --git a/deploy/helm/go-bananas/values-prd-fixed.yaml b/deploy/helm/go-bananas/values-prd-fixed.yaml new file mode 100644 index 0000000..22e290d --- /dev/null +++ b/deploy/helm/go-bananas/values-prd-fixed.yaml @@ -0,0 +1,59 @@ +# PRD values for go-bananas +global: + imagePullSecrets: + - name: nexus-registry + +image: + registry: nexus.urpi.be + repository: docker-hosted/go-bananas + tag: "dev-1791360078" + pullPolicy: Always + +deployment: + replicaCount: 1 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: bananas.urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-bananas-tls + hosts: + - bananas.urpi.be + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + +config: + environment: production + publicBaseUrl: "https://bananas.urpi.be" + +identity: + mode: oidc + oidc: + issuer: "https://auth.urpi.be/realms/urpi" + clientId: "go-bananas" + existingSecret: "go-bananas-auth" + generateSecret: false + scopes: + - profile + - email + - groups + adminGroups: ["go-bananas-admin"] + editorGroups: ["go-bananas-editor"] \ No newline at end of file diff --git a/deploy/helm/go-bananas/values-prd.yaml b/deploy/helm/go-bananas/values-prd.yaml new file mode 100644 index 0000000..f86abb3 --- /dev/null +++ b/deploy/helm/go-bananas/values-prd.yaml @@ -0,0 +1,47 @@ +# PRD values for go-bananas +nameOverride: "" +fullnameOverride: "" + +replicaCount: 1 + +imagePullSecrets: + - name: nexus-registry + +image: + repository: nexus.urpi.be/docker-hosted/go-bananas + tag: "0.0.1" + pullPolicy: Always + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: bananas.urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-bananas-tls + hosts: + - bananas.urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi diff --git a/deploy/helm/go-bananas/values-tst.yaml b/deploy/helm/go-bananas/values-tst.yaml new file mode 100644 index 0000000..7dd90c1 --- /dev/null +++ b/deploy/helm/go-bananas/values-tst.yaml @@ -0,0 +1,44 @@ +# TST values for go-bananas +nameOverride: "" +fullnameOverride: "" + +replicaCount: 1 + +image: + repository: nexus.urpi.be/docker-hosted/go-bananas + tag: "0.0.1" + pullPolicy: Always + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-staging + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: bananas-tst.intern-urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-bananas-tls + hosts: + - bananas-tst.intern-urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-staging + kind: ClusterIssuer + +resources: + limits: + cpu: 300m + memory: 256Mi + requests: + cpu: 50m + memory: 128Mi diff --git a/deploy/helm/go-bananas/values.schema.json b/deploy/helm/go-bananas/values.schema.json new file mode 100644 index 0000000..56edb14 --- /dev/null +++ b/deploy/helm/go-bananas/values.schema.json @@ -0,0 +1,338 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "Go Bananas!! Helm values", + "description": "Validation schema for the Go Bananas!! chart. Editors use this to offer completion and to catch a mistyped key before the chart is rendered.", + "type": "object", + "additionalProperties": false, + "required": ["image", "identity", "config", "service"], + "properties": { + "global": { + "type": "object", + "properties": { + "imageRegistry": { "type": "string" }, + "imagePullSecrets": { + "type": "array", + "items": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + }, + "extraLabels": { "type": "object" }, + "extraAnnotations": { "type": "object" } + } + }, + + "nameOverride": { "type": "string", "maxLength": 63 }, + "fullnameOverride": { "type": "string", "maxLength": 63 }, + + "image": { + "type": "object", + "additionalProperties": false, + "required": ["repository"], + "properties": { + "registry": { "type": "string" }, + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string" }, + "digest": { "type": "string" }, + "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] } + } + }, + + "deployment": { + "type": "object", + "additionalProperties": false, + "properties": { + "replicaCount": { "type": "integer", "minimum": 0, "maximum": 100 }, + "extraArgs": { "type": "array", "items": { "type": "string" } }, + "revisionHistoryLimit": { "type": "integer", "minimum": 0 }, + "minReadySeconds": { "type": "integer", "minimum": 0 }, + "progressDeadlineSeconds": { "type": "integer", "minimum": 1 }, + "terminationGracePeriodSeconds": { "type": "integer", "minimum": 0 }, + "strategy": { "type": "object" }, + "podAnnotations": { "type": "object" }, + "podLabels": { "type": "object" }, + "hostAliases": { "type": "array" }, + "nodeSelector": { "type": "object" }, + "tolerations": { "type": "array" }, + "affinity": { "type": "object" }, + "topologySpreadConstraints": { "type": "array" }, + "priorityClassName": { "type": "string" }, + "lifecycle": { + "type": "object", + "properties": { + "preStop": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "sleepSeconds": { "type": "integer", "minimum": 0, "maximum": 60 } + } + } + } + } + } + }, + + "resources": { "type": "object" }, + + "config": { + "type": "object", + "additionalProperties": false, + "required": ["environment", "publicBaseUrl"], + "properties": { + "environment": { + "type": "string", + "minLength": 1, + "description": "The deployment name. Anything other than a development or test value disables the dev authentication bypass." + }, + "logLevel": { "enum": ["debug", "info", "warn", "error"] }, + "logFormat": { "enum": ["json", "text"] }, + "publicBaseUrl": { + "type": "string", + "pattern": "^https?://[^/]+", + "description": "The externally reachable origin, with no trailing slash." + }, + "basePath": { + "type": "string", + "pattern": "^$|^/", + "description": "A route prefix for a path-based ingress. Empty or a leading-slash path." + }, + "rateLimitPerMinute": { "type": "integer", "minimum": 0 }, + "trustedProxies": { "type": "array", "items": { "type": "string" } }, + "corsAllowedOrigins": { "type": "array", "items": { "type": "string" } }, + "enableMetrics": { "type": "boolean" }, + "enableProfiler": { "type": "boolean" }, + "shutdownTimeoutMs": { "type": "integer", "minimum": 0, "maximum": 300000 }, + "runtimeCdnBaseUrl": { "type": "string" }, + "database": { + "type": "object", + "additionalProperties": false, + "properties": { + "maxConns": { "type": "integer", "minimum": 1, "maximum": 128 }, + "busyTimeoutMs": { "type": "integer", "minimum": 0, "maximum": 60000 } + } + }, + "sessionTTLHours": { "type": "integer", "minimum": 1, "maximum": 720 } + } + }, + + "persistence": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "type": { "enum": ["persistentVolumeClaim", "emptyDir"] }, + "accessMode": { + "enum": ["ReadWriteOnce", "ReadWriteMany", "ReadWriteOncePod", "ReadOnlyMany"] + }, + "size": { "type": "string", "pattern": "^[0-9]+(Ei|Pi|Ti|Gi|Mi|Ki|E|P|T|G|M|K)?$" }, + "storageClassName": { "type": "string" }, + "existingClaim": { "type": "string" }, + "mountPath": { "type": "string", "pattern": "^/" }, + "snapshot": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "schedule": { "type": "string", "minLength": 1 }, + "retention": { "type": "integer", "minimum": 1, "maximum": 365 } + } + } + } + }, + + "service": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "nodePort": { "type": ["integer", "string"] }, + "clusterIP": { "type": "string" }, + "sessionSecure": { "type": "boolean" }, + "sessionSameSite": { "enum": ["Lax", "Strict", "None"] }, + "annotations": { "type": "object" }, + "labels": { "type": "object" } + } + }, + + "ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "className": { "type": "string" }, + "annotations": { "type": "object" }, + "hosts": { + "type": "array", + "items": { + "type": "object", + "required": ["host"], + "properties": { + "host": { "type": "string", "minLength": 1 }, + "paths": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { "type": "string", "pattern": "^/" }, + "pathType": { "enum": ["Prefix", "Exact", "ImplementationSpecific"] } + } + } + } + } + } + }, + "tls": { + "type": "array", + "items": { + "type": "object", + "required": ["secretName"], + "properties": { + "secretName": { "type": "string" }, + "hosts": { "type": "array", "items": { "type": "string" } } + } + } + } + } + }, + + "identity": { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": { + "enum": ["oidc", "dev", "disabled"], + "description": "oidc is the production choice. dev and disabled are refused by the application outside a development environment." + }, + "sessionSecretKey": { "type": "string", "minLength": 1 }, + "oidc": { + "type": "object", + "additionalProperties": false, + "properties": { + "issuer": { "type": "string", "pattern": "^https?://" }, + "clientId": { "type": "string" }, + "clientSecretKey": { "type": "string", "minLength": 1 }, + "existingSecret": { "type": "string" }, + "existingSecretKeys": { "type": "object" }, + "generateSecret": { "type": "boolean" }, + "scopes": { "type": "array", "items": { "type": "string" } }, + "adminGroups": { "type": "array", "items": { "type": "string" } }, + "editorGroups": { "type": "array", "items": { "type": "string" } }, + "viewerGroups": { "type": "array", "items": { "type": "string" } }, + "redirectURL": { "type": "string" }, + "postLogoutRedirectURL": { "type": "string" }, + "logoutURL": { "type": "string" }, + "skipTLSVerify": { "type": "boolean" }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 300 } + } + } + } + }, + + "probes": { + "type": "object", + "additionalProperties": false, + "properties": { + "liveness": { "$ref": "#/definitions/probe" }, + "readiness": { "$ref": "#/definitions/probe" }, + "startup": { "$ref": "#/definitions/probe" } + } + }, + + "autoscaling": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "minReplicas": { "type": "integer", "minimum": 1 }, + "maxReplicas": { "type": "integer", "minimum": 1 }, + "targetCPUUtilizationPercentage": { "type": "integer", "minimum": 1, "maximum": 100 }, + "targetMemoryUtilizationPercentage": { "type": "integer", "minimum": 0, "maximum": 100 }, + "behavior": { "type": "object" } + } + }, + + "podDisruptionBudget": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "minAvailable": { "type": ["integer", "string"] }, + "maxUnavailable": { "type": ["integer", "string"] }, + "unhealthyPodEvictionPolicy": { "enum": ["IfHealthyBudget", "AlwaysAllow"] } + } + }, + + "serviceAccount": { + "type": "object", + "additionalProperties": false, + "properties": { + "create": { "type": "boolean" }, + "name": { "type": "string" }, + "annotations": { "type": "object" }, + "automountServiceAccountToken": { "type": "boolean" } + } + }, + + "rbac": { + "type": "object", + "additionalProperties": false, + "properties": { + "create": { "type": "boolean" }, + "rules": { "type": "array" } + } + }, + + "networkPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "allowedNamespaces": { "type": "array", "items": { "type": "string" } }, + "metricsPaths": { "type": "array", "items": { "type": "string" } } + } + }, + + "podSecurityContext": { "type": "object" }, + "securityContext": { "type": "object" }, + "extraEnv": { "type": "array" }, + "extraVolumes": { "type": "array" }, + "extraVolumeMounts": { "type": "array" }, + + "metrics": { + "type": "object", + "additionalProperties": false, + "properties": { + "serviceMonitor": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "interval": { "type": "string" }, + "scrapeTimeout": { "type": "string" }, + "labels": { "type": "object" }, + "honorLabels": { "type": "boolean" } + } + } + } + }, + + "extraManifests": { "type": "array" } + }, + + "definitions": { + "probe": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "path": { "type": "string", "pattern": "^/" }, + "initialDelaySeconds": { "type": "integer", "minimum": 0 }, + "periodSeconds": { "type": "integer", "minimum": 1 }, + "timeoutSeconds": { "type": "integer", "minimum": 1 }, + "failureThreshold": { "type": "integer", "minimum": 1 } + } + } + } +} diff --git a/deploy/helm/go-bananas/values.yaml b/deploy/helm/go-bananas/values.yaml new file mode 100644 index 0000000..c8950b0 --- /dev/null +++ b/deploy/helm/go-bananas/values.yaml @@ -0,0 +1,481 @@ +# Go Bananas!! Helm values +# +# Every value has a safe default so that `helm install` works with no arguments at +# all. The exceptions are the authentication credentials, which are required when +# auth.mode is oidc and are supplied through an existing Secret. +# +# The defaults are tuned for a small internal deployment. Before running this in +# production, read the annotated sections: replica count, the ingress, the resource +# requests and the persistence choice all deserve a deliberate decision. + +# ------------------------------------------------------------------ global --- + +global: + # imageRegistry overrides the registry for every image the chart pulls. Used by a + # cluster with a mirrored registry or an air-gapped pull-through cache. + imageRegistry: "" + # imagePullSecrets are added to every ServiceAccount. + imagePullSecrets: + - name: nexus-registry + # extraLabels are added to every object the chart creates, for cost allocation + # and for a Prometheus `release` label if the cluster does not add one. + extraLabels: {} + # extraAnnotations are added to every object. + extraAnnotations: {} + +# ------------------------------------------------------------------- image --- + +image: + registry: nexus.urpi.be + repository: docker-hosted/go-bananas + # tag defaults to the chart's appVersion, so upgrading the chart upgrades the + # image. Set it to an immutable digest for a reproducible deployment. + tag: "0.0.1" + # digest pins the image by content. Takes precedence over tag when set, which is + # the strongest guarantee that a rollout is running exactly what was reviewed. + digest: "" + pullPolicy: IfNotPresent + +# --------------------------------------------------------------- deployment --- + +deployment: + # Two replicas by default: the service is stateless apart from the SQLite file, + # so horizontal scaling is only safe when persistence is a shared volume or a + # network filesystem. See the persistence section. + replicaCount: 2 + + # extraArgs are appended to the container command. The binary takes no positional + # arguments; this exists for future flags without a chart change. + extraArgs: [] + + # revisionHistoryLimit keeps the last N ReplicaSets so a rollback is possible + # without the Deployment controller's default of ten consuming etcd history. + revisionHistoryLimit: 5 + + # minReadySeconds makes a new pod count as ready only after it has been healthy + # for this long, which stops a rolling update from proceeding on the strength of + # a single lucky probe. + minReadySeconds: 10 + + # progressDeadlineSeconds aborts a rollout that cannot complete, rather than + # leaving a Deployment stuck in progress indefinitely. + progressDeadlineSeconds: 600 + + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + + podAnnotations: {} + podLabels: {} + + # hostAliases is mostly useful for a development cluster where the identity + # provider is not in public DNS. + hostAliases: [] + + # terminationGracePeriodSeconds must exceed the application's shutdown timeout + # (config.shutdownTimeoutMs) or in-flight requests are cut off mid-response. + # The chart refuses to render when it does not. + terminationGracePeriodSeconds: 45 + + # A preStop sleep gives the Service endpoints controller time to remove this pod + # from the endpoint list before the process starts refusing connections. + # Without it, a rolling update drops requests that were already routed. + lifecycle: + preStop: + enabled: true + sleepSeconds: 3 + + # nodeSelector, tolerations, affinity and topologySpreadConstraints are passed + # through to the pod spec unchanged. + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + + # priorityClassName for a cluster where the API tier matters. + priorityClassName: "" + +# ---------------------------------------------------------------- resources --- + +resources: + requests: + cpu: 50m + memory: 96Mi + limits: + # No CPU limit is set deliberately. A CPU limit throttles the process when the + # node is busy, which for a latency-sensitive asset server produces exactly the + # tail latency it is meant to prevent. The memory limit is the one that matters: + # it is what keeps a runaway cache from taking the node down. + memory: 384Mi + +# ------------------------------------------------------------------- config --- + +config: + # environment is the deployment name. Anything other than development or test + # turns off the dev authentication bypass and the TLS-skip switch. + environment: production + + logLevel: info + # json in production so the log is structured for a collector. + logFormat: json + + # publicBaseUrl is the externally reachable origin, with no trailing slash. + # It is used to build the OIDC redirect URI, the absolute URLs in the runtime + # manifest and the HSTS decision, so getting it wrong means a login loop. + # + # The default points at a local hostname so that `helm install` works with no + # arguments. Override it for any real deployment. + publicBaseUrl: "http://themes.go-bananas.local" + + # basePath prefixes every route, for deployments behind a path-based ingress + # such as https://example.com/themes/. Leave empty for the default of /. + basePath: "" + + # rateLimitPerMinute caps requests per client per minute on the runtime API. + # The default is high because the legitimate pattern is one request per page + # view; the limit exists to stop abuse, not to ration traffic. + rateLimitPerMinute: 3000 + + # trustedProxies are the CIDRs whose X-Forwarded-For header may be believed. + # Empty means forwarding headers are ignored entirely and the direct peer is + # recorded, which is the safe default. Set this to the ingress controller's + # address or the audit log will record the ingress pod instead of the client. + trustedProxies: [] + + # corsAllowedOrigins are browser origins allowed to call the admin API + # cross-origin. The runtime API is public by design and is not affected. + corsAllowedOrigins: [] + + # enableMetrics exposes /metrics in Prometheus format. + enableMetrics: true + # enableProfiler exposes net/http/pprof. Off by default: a profiler on a + # network-reachable endpoint is a denial-of-service surface, so it is opt-in and + # should be paired with a NetworkPolicy. + enableProfiler: false + + # shutdownTimeoutMs bounds graceful shutdown. Must be lower than + # deployment.terminationGracePeriodSeconds. + shutdownTimeoutMs: 25000 + + # runtimeCdnBaseUrl is the absolute base the runtime manifest hands to + # consumers. Set it when the stylesheets are served from a CDN or a separate + # asset host, so that consumers fetch from there instead of from this service. + runtimeCdnBaseUrl: "" + + # database tuning. The defaults suit an embedded SQLite file; see docs on + # persistence before changing them. + database: + # maxConns bounds the connection pool. SQLite serialises writers, so a small + # pool is faster than a large one, not slower. + maxConns: 8 + # busyTimeoutMs is how long a blocked writer waits before failing. + busyTimeoutMs: 5000 + + # sessionTTLHours is how long an administrative session lasts. The session is a + # signed cookie with no server-side record, so an expiry cannot be revoked + # early — see the identity section for the trade-off this represents. + sessionTTLHours: 8 + +# -------------------------------------------------------------- persistence --- +# +# The database is a single SQLite file. Two deployment shapes are supported: +# +# type: emptyDir A per-pod temporary file. Every theme change is lost when a +# pod restarts. Correct only for a demo or for evaluating the +# service; the built-in themes are re-seeded automatically so +# the instance still comes up, but operator work is not kept. +# +# type: persistentVolumeClaim A ReadWriteOnce volume. Correct for a +# single-replica deployment, which is the shape this chart +# recommends in production. +# +# Note on replicas: RWO volumes cannot be attached to two pods at once. With +# replicaCount > 1 and a PVC, only one pod mounts the volume successfully and the +# others crash-loop. Running more than one replica therefore requires either +# replicaCount: 1, or a filesystem that permits multiple mounts. + +persistence: + enabled: true + type: persistentVolumeClaim + accessMode: ReadWriteOnce + size: 2Gi + # storageClassName: "" uses the cluster default. + storageClassName: "" + # existingClaim mounts a volume the operator manages. Its lifecycle is then the + # operator's responsibility: this chart will not delete or resize it. + existingClaim: "" + mountPath: /srv/data + + # A snapshot taken before an upgrade, when the upgrade involves a destructive + # change to the schema. Restoring is a manual kubectl apply. + snapshot: + enabled: false + # CronJob schedule in the cluster's timezone. + schedule: "0 3 * * *" + # How long to keep snapshots. + retention: 14 + +# ------------------------------------------------------------------ service --- + +service: + type: ClusterIP + port: 80 + # nodePort, only used when type is NodePort. + nodePort: "" + # ClusterIP: set to None for a headless service, which a StatefulSet needs. + clusterIP: "" + # The session cookie's Secure attribute and SameSite=None both require the + # browser to see the service over HTTPS. Set to true when TLS terminates at the + # ingress. + sessionSecure: true + sessionSameSite: Lax + annotations: {} + labels: {} + +# ----------------------------------------------------------------- ingress --- + +ingress: + enabled: false + className: nginx + annotations: {} + hosts: + - host: themes.example.com + paths: + - path: / + pathType: Prefix + # tls is empty for a plain-HTTP installation. For the usual production shape, + # point it at a cert-manager cluster issuer so the certificate is renewed + # without anyone remembering to. + tls: [] + # - secretName: go-bananas-tls + # hosts: + # - themes.example.com + +# ---------------------------------------------------------------- identity --- +# +# This is the part that most often goes wrong in a deployment, so it is worth being +# explicit about the three modes. + +identity: + # disabled No authentication. Every request is granted administrator access. + # Refused by the application outside a development environment, so + # this cannot be deployed to production by accident. + # dev Every request is authenticated as a local administrator. + # Also refused outside development. + # oidc OpenID Connect against a real provider. The production choice. + mode: oidc + + # sessionSecretKey is the key inside the Secret that holds the session signing + # secret. The secret must be at least 32 bytes. + # + # kubectl create secret generic go-bananas-auth \ + # --from-literal=session-secret="$(openssl rand -base64 48)" \ + # --from-literal=oidc-client-secret="..." + # + # The secret is never templated into a ConfigMap or written to a log. Rotating + # it invalidates every open session at once. + sessionSecretKey: session-secret + + oidc: + # issuer is the OIDC issuer URL. Everything else — the authorization, + # token, userinfo and JWKS endpoints — is discovered from + # /.well-known/openid-configuration, so this is the only provider-specific + # value needed. + # + # The default is the shared development realm. The realm path is part of the + # issuer: this string must match the `iss` claim in the ID token exactly, + # including a trailing slash if the provider emits one. A mismatch here is the + # single most common cause of "invalid issuer" at the callback. + issuer: "https://auth-dev.urpi.be/realms/urpi" + + # clientId is the client identifier registered with the provider. + clientId: "go-bananas" + + # clientSecretKey names the key inside existingSecret (or generatedSecret) + # holding the client secret. Referencing it by key name rather than by value + # is what keeps the secret out of values.yaml. + clientSecretKey: oidc-client-secret + + # existingSecret is a Secret you manage. Preferred for production: the chart + # will not create, rotate or delete it, and its contents never appear in a + # rendered manifest. + # + # kubectl create secret generic go-bananas-auth \ + # --from-literal=session-secret="$(openssl rand -base64 48)" \ + # --from-literal=oidc-client-secret="…" + existingSecret: "" + # existingSecretKeys overrides the key names inside existingSecret, for a + # Secret managed by an external secrets operator with its own naming. + existingSecretKeys: {} + + # generatedSecret creates a Secret owned by this release. The session secret is + # preserved across upgrades by reading the live Secret back, so an upgrade does + # not sign every user out. + # + # The OIDC client secret cannot be generated — it has to come from the provider. + # A generated Secret therefore contains an empty client secret, which means the + # deployment will fail to authenticate until one is supplied. For anything real, + # use existingSecret. + generateSecret: true + + # scopes requested in addition to openid. The email scope is what makes + # `email` appear in the session; groups is what the role mapping reads. + scopes: + - profile + - email + - groups + + # Group-to-role mapping, read from the `groups` claim. + # + # The most privileged matching group wins; a user in no configured group gets + # viewer. The default is deliberately least privilege, so a mapping mistake + # fails closed rather than granting access nobody intended. + adminGroups: [] + editorGroups: [] + viewerGroups: [] + # With all three empty, every authenticated user is a viewer. Populate these + # from the provider's actual group names before rolling this out to real users; + # the group names are realm-specific and are not guessed here. + + # redirectURL overrides the computed callback. Leave empty unless the + # provider has a different redirect URI registered, which happens behind a + # path-based ingress or when several environments share a provider client. + redirectURL: "" + + postLogoutRedirectURL: "" + # End-session endpoint, for providers that do not follow the RP-initiated + # logout convention. + logoutURL: "" + + # skipTLSVerify disables certificate verification against the provider. It + # exists so that a development provider presenting a self-signed certificate + # can be used, and the application refuses to start with it enabled outside + # development. + skipTLSVerify: false + + # timeout and insecureSkipVerify exist for the same development reason. + timeoutSeconds: 15 + +# ------------------------------------------------------------------- probes --- + +probes: + liveness: + enabled: true + path: /healthz + initialDelaySeconds: 3 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readiness: + enabled: true + path: /readyz + initialDelaySeconds: 2 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startup: + enabled: true + path: /healthz + periodSeconds: 3 + failureThreshold: 30 + +# --------------------------------------------------------------- autoscaling --- + +autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + # targetMemoryUtilizationPercentage is left unset by default: memory here is + # dominated by the render cache, which grows with the number of themes rather + # than with load, so scaling on it would respond to a content change rather than + # to traffic. + targetMemoryUtilizationPercentage: 0 + behavior: {} + +# ------------------------------------------------------- disruption budgets --- +# +# A PDB only helps if it does not also block a node drain. minAvailable: 1 with two +# replicas permits exactly one disruption, which is the useful setting. + +podDisruptionBudget: + enabled: true + minAvailable: 1 + # maxUnavailable: 1 + # unhealthyPodEvictionPolicy: AlwaysAllow + +# --------------------------------------------------------- service account --- + +serviceAccount: + create: true + name: "" + annotations: {} + automountServiceAccountToken: false + +rbac: + # create is off by default: the service talks to nothing in the cluster, so the + # default ServiceAccount needs no rules at all. + create: false + rules: [] + +# ------------------------------------------------------------ network policy --- +# +# A default-deny ingress with an explicit allowance for the ingress controller and +# for Prometheus. Without this, any pod in the cluster can reach the admin API. + +networkPolicy: + enabled: false + # ingressNamespaces restricts which namespaces may reach the service. Empty means + # the whole cluster. + allowedNamespaces: [] + # metricsPaths is the additional path allowed from the metrics sources. + metricsPaths: ["/metrics"] + +# ------------------------------------------------------------------- extras --- + +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + +# extraEnv is for one-off variables that do not warrant a chart value. Anything +# added here is the operator's responsibility to keep consistent across replicas. +extraEnv: [] + +# extraVolumes and extraVolumeMounts are for a sidecar or a config file the chart +# does not model. +extraVolumes: [] +extraVolumeMounts: [] + +# metricsServiceMonitor creates a Prometheus Operator ServiceMonitor. Requires the +# Prometheus Operator CRDs to be installed. +metrics: + serviceMonitor: + enabled: false + interval: 30s + scrapeTimeout: 10s + labels: {} + honorLabels: true + +# ------------------------------------------------------------------- extras --- +# +# `extraManifests` is an escape hatch for a resource this chart does not model. It +# is templated, so a value can reference another template's output. +extraManifests: [] +# - apiVersion: v1 +# kind: ConfigMap +# metadata: +# name: {{ include "go-bananas.fullname" . }}-extra +# data: +# example: "value" diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000..5128764 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,90 @@ +# Docker Bake definition. +# +# One place that knows how to build every target variant, so that `docker buildx +# bake`, the GitHub Actions workflow and a developer's terminal all produce the same +# image from the same inputs. +# +# docker buildx bake # build the default tag +# docker buildx bake --push # build and push +# docker buildx bake --print # show what would run +# docker buildx bake --set *.platform=linux/arm64 + +variable "VERSION" { + default = "dev" +} + +variable "COMMIT" { + default = "unknown" +} + +variable "BUILD_DATE" { + default = "unknown" +} + +variable "REGISTRY" { + default = "ghcr.io/urpi" +} + +variable "IMAGE" { + default = "${REGISTRY}/go-bananas" +} + +variable "TAGS" { + default = "${IMAGE}:${VERSION}" +} + +# `platforms` is a list rather than a single value so that one invocation produces +# a manifest list covering both architectures. +group "default" { + targets = ["image"] +} + +target "_common" { + context = "." + dockerfile = "Dockerfile" + args = { + VERSION = VERSION + COMMIT = COMMIT + BUILD_DATE = BUILD_DATE + } + labels = { + "org.opencontainers.image.title" = "go-bananas" + "org.opencontainers.image.description" = "Runtime design-token service" + "org.opencontainers.image.version" = VERSION + "org.opencontainers.image.revision" = COMMIT + "org.opencontainers.image.created" = BUILD_DATE + "org.opencontainers.image.source" = "https://github.com/urpi/go-bananas" + } +} + +target "image" { + inherits = ["_common"] + tags = ["${IMAGE}:${VERSION}", "${IMAGE}:latest"] + platforms = ["linux/amd64", "linux/arm64"] + output = ["type=image"] +} + +target "image-push" { + inherits = ["_common"] + tags = split("\n", TAGS) + platforms = ["linux/amd64", "linux/arm64"] + output = ["type=image,push=true"] +} + +# A local single-architecture image for `docker compose up` and for a quick look +# on a laptop, where a manifest list only adds a layer of indirection. +target "dev" { + inherits = ["_common"] + tags = ["go-bananas:dev"] + platforms = ["linux/amd64"] + output = ["type=docker"] + cache-to = ["type=gha,mode=max"] +} + +# The cache-only target, for warming a CI runner before the real build. +target "cache" { + inherits = ["_common"] + platforms = ["linux/amd64"] + cache-to = ["type=gha,mode=max"] + cache-from = ["type=gha"] +} diff --git a/docs/adr/0001-embedded-sqlite.md b/docs/adr/0001-embedded-sqlite.md new file mode 100644 index 0000000..79b4263 --- /dev/null +++ b/docs/adr/0001-embedded-sqlite.md @@ -0,0 +1,176 @@ +# ADR 0001: Embedded SQLite rather than a client/server database + +- **Status:** accepted +- **Date:** 2026-10-04 +- **Deciders:** platform team +- **Applies to:** all deployments of the current major version + +## Context + +go-bananas stores themes. A theme is a slug, some metadata and a JSON document of +design tokens, typically 10–60 kB. A useful instance has tens to a few thousand of +them. + +The workload shape is: + +- **Reads:** constant and bursty. Every page view in every consuming application + requests CSS. A service with ten consumer applications and a few hundred users + can easily see thousands of stylesheet requests a minute, and 95%+ of them are + the same handful of bytes served from a render cache. +- **Writes:** rare. A theme is edited by a human. A busy week might be fifty writes. +- **Total size:** a few megabytes. Even ten thousand large themes would fit in + fifty. + +The conventional answer for a service with this shape is PostgreSQL. That answer is +right for almost every service and wrong for this one, for reasons worth writing +down rather than rediscovering in a year. + +## Decision + +Use SQLite through `modernc.org/sqlite`, a pure-Go translation, on a local volume. + +## Rationale + +### What a client/server database would add + +For this dataset, Postgres buys nothing and costs: + +- **A network hop per query.** A local SQLite read is microseconds; a query over a + pod network is a millisecond and a tail-latency distribution with a long right + tail. For a stylesheet endpoint on the critical path of every page view, that + tail is the difference between a fast site and a janky one. +- **A connection pool to get wrong.** Every deployment needs its own tuning, and the + failure mode of under-tuning is a saturated pool under load. +- **Credentials to create, distribute, rotate and revoke.** Another secret in + Kubernetes, another thing to get wrong. +- **A schema migration process.** For a dataset that fits in a few megabytes, this + is a disproportionate amount of ceremony. +- **A second thing to operate.** Backups, upgrades, monitoring, incident response, + for a component whose whole job is to hold some JSON. +- **A second failure mode.** The database becoming unavailable is now the most + likely way the theme service goes down. + +### What SQLite gives up + +Honesty requires stating the costs: + +- **One writer at a time.** Correct at this write rate. A theme edit is a + millisecond-scale transaction; even a hundred concurrent editors would not + saturate it. +- **Single-node.** This is the real constraint. See Scaling below. +- **No network access.** A consuming application cannot query the database directly. + It does not need to; the runtime API is the interface. + +### Why this particular configuration is fast + +The default SQLite configuration is not fast. These settings are what make it fast +enough that the question does not arise: + +| Setting | Effect | +| --- | --- | +| `journal_mode=WAL` | Readers never block the writer and the writer never blocks readers. This is the single most important setting. | +| `synchronous=NORMAL` | In WAL mode this is durable across an application crash and risks only the last transactions on an operating-system crash. A lost publish is re-doable; an outage is not. | +| `cache_size=-16000` | A 16 MiB page cache, which holds the entire dataset. | +| `mmap_size=268435456` | The file is read through the page cache directly, removing a copy from the read path. | +| `temp_store=MEMORY` | Sort and group-by spill stays in memory. | +| Max 8 connections | SQLite serialises writers. A large pool adds contention, not throughput. This is counter-intuitive and correct. | +| In-memory render cache | The steady-state runtime request performs **no database access at all**. | + +The render cache is the decisive optimisation. Rendering a theme traverses its whole +token document; serving the cached string is one map lookup. Because the cache key +includes the theme version, publishing invalidates exactly one theme's entries and +disturbs nothing else. The database is consulted only on a cache miss, which is a +publish rather than a read. + +### Why `modernc.org/sqlite` rather than `mattn/go-sqlite3` + +`mattn/go-sqlite3` is a cgo binding to the C library. It is faster, and it means: + +- `CGO_ENABLED=1`, so the release image needs a C toolchain at build time and libc + at run time, and cross-compilation needs a cross-C toolchain; +- a statically linked binary is impossible, so the image carries a C library whose + CVEs need tracking; +- `go install` from source fails on a machine without a compiler. + +`modernc.org/sqlite` is a transpiled Go implementation: no cgo, a fully static +binary, and cross-compilation is `GOOS=linux GOARCH=arm64` and nothing else. The +performance difference is irrelevant here because the hot path is the in-memory +render cache, not SQLite. + +## Alternatives considered + +**PostgreSQL.** The conventional choice. Rejected on the grounds above. It remains +the right answer if the product changes shape — see Scaling. + +**Redis.** Excellent for this read-heavy workload, and a good fit for a render +cache. But it is another service to operate for a cache that is already +process-local and loses nothing on restart, because the source of truth is the +database. Rejected as unnecessary operational surface. Worth revisiting if a +many-node deployment needs a shared cache; the render cache is an interface, so +that change is contained. + +**bbolt.** Faster than SQLite for pure key/value workloads and simpler. Rejected +because the schema needs indexes for listing, filtering and sorting, and because +SQLite's query planner handles the "a few hundred rows filtered by status and +ordered by date" shape without the ceremony of managing bbolt buckets by hand. + +**A JSON file per theme on disk.** No dependency at all. Rejected because concurrent +writes need locking, and because every listing requires reading and parsing every +file. SQLite is a better version of this idea that is already written. + +**An in-memory store with write-through to object storage.** Attractive for scale. +Rejected for v1: it removes the transactional guarantee that makes version history +and rollback correct, and it adds an object-storage dependency. The write path +here is not hot enough to justify losing the invariant. + +## Scaling + +SQLite on a local volume is single-node. Two shapes are supported: + +1. **One replica, one volume.** The recommended production shape. Horizontally + scalable only in the sense that the service is so cheap that one replica serves + a very large number of consumers. + +2. **Many replicas, a shared filesystem.** Works when the storage class supports + concurrent mounts — NFS, CephFS, or a ReadWriteMany block device. WAL mode + requires the filesystem to support shared memory mapping, which these do. The + Helm chart warns when a ReadWriteOnce volume is combined with more than one + replica, because that combination cannot work. + +If the workload ever outgrows both — genuinely concurrent multi-writer access across +nodes — the migration is to Postgres. It is contained: `internal/store` is the only +package that writes SQL, and its interface is defined by the methods it exposes +rather than by SQLite's features. + +## Consequences + +**Good** + +- One less thing to operate, back up, monitor and secure. +- No credential to leak. +- A static binary and a small image with no libc. +- A backup is `cp` of one file. There is no consistent-snapshot dance. +- The render cache can be process-local because there is only one process. + +**Bad** + +- Cannot scale horizontally past one node without a shared filesystem. +- A shared-filesystem deployment is slower than local disk, and WAL over NFS is + sensitive to latency. Measure before choosing it. +- Single-writer means a pathological write loop would queue. Nothing in the + application can produce one. + +**Neutral** + +- The `store` package has a wider interface than a repository might otherwise need, + because it must express listing, filtering and sorting. Moving to another engine + means reimplementing that surface. + +## Revisit when + +- A single instance cannot serve the load, and profiling shows the database rather + than the render cache is the bottleneck. +- Concurrent writers become a real requirement, for example if themes can be edited + by an automated pipeline from several deployments. +- The dataset outgrows a few hundred megabytes, at which point the time to back up + by copying a file becomes its own problem. diff --git a/docs/adr/0002-runtime-delivery.md b/docs/adr/0002-runtime-delivery.md new file mode 100644 index 0000000..a09f8bc --- /dev/null +++ b/docs/adr/0002-runtime-delivery.md @@ -0,0 +1,142 @@ +# ADR 0002: Deliver themes at run time rather than as a package + +- **Status:** accepted +- **Date:** 2026-10-04 + +## Context + +The conventional way to ship a design system to applications is a package: an npm +package, a Go package, a Maven artifact. The consuming application installs it, +imports from it, and rebuilds when it changes. + +That model is good, and it is the right answer for most design-system artefacts. +The question here is whether it is the right answer for *design tokens specifically*. + +## Decision + +Themes are served over HTTP at run time as CSS custom properties and JSON. The +runtime SDKs are ES modules loaded by URL through an import map. Nothing is +installed by the consumer. + +## Rationale + +### The cost of the package model, for tokens specifically + +Design tokens are unusual among design-system artefacts. They are: + +- **entirely data.** No behaviour to tree-shake, no components to code-split; +- **consumed at a single moment.** At first paint, by a stylesheet link; +- **changing on a different cadence from code.** A designer adjusts a colour; no + application code needs to change. + +The package model imposes costs that fit code perfectly and fit data badly: + +| Cost | Fits code? | Fits tokens? | +| --- | --- | --- | +| Version resolution and peer conflicts | Yes | No. Nothing here has a peer. | +| A rebuild of every consumer | Necessary | Pure overhead. The CSS output is byte-identical. | +| Coordinated deploys on version skew | Necessary | Avoidable: a runtime fetch has no skew. | +| A published version to track | Necessary | A database row, which an operator already tracks. | +| Install friction | Usually fine | Often the blocker. A Go service with a templated frontend cannot `npm install` at all. | +| Tree-shaking and chunk sizing | Necessary | Nothing to shake. | + +The specific motivating case: a service that serves server-rendered HTML cannot +consume a design-system npm package without introducing a Node build into its +release pipeline. That is a large change made to deliver a CSS file. Serving the +CSS directly removes it. + +### What runtime delivery buys + +**Republishing is a database write.** A designer adjusts a colour, clicks publish, +and every application picks it up on its next request. There is no version to cut, +no build to trigger, no consumer to upgrade. + +**No skew.** An application never holds a stale copy of a theme in its +`node_modules`, because it never held one. Rolling back a theme is a database write +too, and is atomic across every consumer. + +**No supply chain.** A token file is not code. It cannot execute anything, it has +no transitive dependencies, and it cannot be compromised in the way an npm package +can. This is a genuinely large reduction in attack surface. + +**Language neutrality.** Any environment that can make an HTTP request and read CSS +can consume a theme: Go, Rust, PHP, Jinja, Django templates, a static site +generator, a server-rendered email. + +### What it costs + +Honesty requires stating the costs: + +**A network hop at first paint.** A stylesheet link is a blocking request. Mitigated +by immutable-by-URL caching: a CDN serves it from an edge, a publish changes the +version and therefore the URL, and a revalidation costs a few milliseconds and no +body. This is the same cost model as a web font, which every application already +pays. + +**A runtime dependency.** The service must be up for a consuming application to look +right. Mitigated by a hard failure being a *fallback to the application's own +styles*, not an error page: a failed theme load leaves the page unstyled rather than +broken, which is exactly the right degradation. + +**CSS custom properties as the only interface.** A token cannot be compiled into a +Tailwind class, a Sass mixin or a styled-component prop. Anything that needs +compilation is not served by this system. That is the boundary of the approach, and +it is a real boundary rather than a detail. + +**A version-pinning story is needed.** Runtime delivery means a consumer can be +mid-request while a theme is republished. The answer is that a published theme's +version never changes — a publish creates version N+1 — so a URL pinned to version +N is immutable forever, and `/rt/v1/themes/{slug}` resolves to the latest. This is +the same model as an immutable container tag. + +## Alternatives considered + +**An npm package of design tokens.** Rejected for the reasons above. It remains the +right answer for anything requiring compilation, and the two models compose: an +application can build a Tailwind preset from a theme at its own build time, using +`/rt/v1/theme.json`, and then use the runtime for the parts that must be live. + +**A CSS file on a CDN, uploaded by hand.** The delivery mechanism without the +management. It removes the coupling but gives up atomic publish, rollback, history +and per-theme authorisation. This is what the service automates. + +**Server-side rendering of inline styles.** Each application renders the variables +into its own HTML. Zero latency, but every application must implement a fetcher, a +cache, a fallback and a revalidation policy, and they will all get it subtly +different. Centralising it means one correct implementation. + +**Web Components for the tokens.** Not applicable. Tokens are not components. + +**A GraphQL API.** Rejected: the payload is one object, so a single GET is simpler, +cacheable at the HTTP layer, and readable with `curl`. GraphQL would add a schema, +a server and a client library to deliver a JSON document. + +## Consequences + +**Good** + +- Publishing is immediate, atomic and reversible. +- No consumer rebuild, no version skew, no package to track. +- Consumers in any language, including ones that cannot use npm at all. +- A theme cannot execute code. +- Caching is a solved problem rather than a design decision, because URLs are + immutable. + +**Bad** + +- A network hop on the critical rendering path, mitigated by aggressive caching. +- Only expressible as CSS custom properties. Anything needing compilation is out of + scope. +- The service is a runtime dependency for every consumer's first paint. +- Debugging "why does this consumer look wrong" requires knowing which theme and + version it resolved. Mitigated by `X-Theme-Version` and `X-Theme-Updated-At` + response headers, and by the `sourceURL` comment in the generated CSS that makes + browser devtools navigate back to the endpoint. + +**Neutral** + +- The admin UI is itself a consumer, which means the delivery path is exercised on + every interaction. A change that breaks delivery breaks the tool that maintains + themes, which is a useful continuous check. +- The runtime SDKs are built and served rather than published, so they are versioned + by the service's release rather than by a registry. diff --git a/docs/adr/0003-stateful-sessions.md b/docs/adr/0003-stateful-sessions.md new file mode 100644 index 0000000..edded1a --- /dev/null +++ b/docs/adr/0003-stateful-sessions.md @@ -0,0 +1,101 @@ +# ADR 0003: Stateless signed session cookies + +- **Status:** accepted +- **Date:** 2026-10-04 + +## Context + +The administration API needs a session. The service may run more than one replica +behind a load balancer, and the login callback may be handled by a different pod +from the request that started it. + +## Decision + +A session is a signed JSON payload in an `HttpOnly`, `Secure`, `SameSite` cookie. +There is no server-side session table. + +## Rationale + +**The load-balancer problem.** With a server-side session store, a session created +by one replica must be readable by another. That means either sticky sessions (which +lose the pod on every rollout) or a shared store (Redis or a database table), which +is the operational surface [ADR 0001](0001-embedded-sqlite.md) set out to avoid. +A signed cookie removes the question entirely. + +**The transaction state has the same problem.** The OIDC flow carries a PKCE +verifier and a return path from the authorize redirect to the callback. Keeping that +in server-side state reintroduces the same requirement. It is a second signed cookie, +read and consumed exactly once. + +**The lifetime is short.** An administrative session is eight hours by default. +The cost of not being able to revoke a session before it expires is bounded by that +window. + +**What it costs.** Two things, stated plainly: + +1. **Revocation.** A stolen session cannot be invalidated before it expires. The + mitigations are an eight-hour lifetime and, for a full invalidation, rotating the + signing key, which logs out every session at once. Rotating is a deployment, not + a button. +2. **Payload size.** The cookie carries identity and roles. That is a few hundred + bytes, comfortably inside the 4 kB browser limit. A larger claim set would push + against it. + +## What was rejected + +**A server-side session table in SQLite.** Would work, and would add revocation. +Rejected because it adds a table, a write on every login, a sweep job, and a +migration path for a problem the eight-hour window already bounds. + +**Redis.** Would work. Rejected as operational surface for the same reason the +render cache stayed process-local. + +**JWT as a bearer token in localStorage.** Rejected on security grounds. A bearer +token in `localStorage` is readable by any script on the page, so one XSS bug +becomes a credential theft. An `HttpOnly` cookie is not readable by script, and +`SameSite=Lax` plus the session cookie's scope mean a cross-site form post cannot +ride it. The session is a cookie precisely so that it is not script-accessible. + +**Signed but not encrypted.** The payload is readable by the user holding the +cookie. It contains a subject, an email, a display name and roles — no secrets. It +is signed, not encrypted, so that a client can read it and debugging is possible. +Encrypting it would add a key for no confidentiality gain on this content. + +## Implementation requirements + +Every one of these is a security property, and each has a test: + +- HMAC-SHA256 signature, compared with `subtle.ConstantTimeCompare`. A + byte-by-byte early return leaks the correct prefix length to an attacker measuring + responses. +- `HttpOnly`, so script cannot read it. +- `Secure` in any non-development environment, enforced by configuration + validation. +- `SameSite`, defaulted to `Lax`. `None` requires `Secure`, enforced by validation, + because browsers reject the combination and the session would silently never + survive a redirect. +- Payload expiry, checked server-side on every request. The cookie's own `Max-Age` + is a client hint, not a control. +- An absent or invalid role set defaults to viewer. A cookie carrying no roles must + never be treated as carrying privileges. +- The signing secret is at least 32 bytes, and an ephemeral one is generated in + development rather than requiring configuration that does not matter locally. + +## Consequences + +**Good** + +- Any replica can authenticate any request, with no stickiness and no shared store. +- A rollback of the Deployment does not log anyone out. +- Nothing to sweep, nothing to migrate. + +**Bad** + +- No per-session revocation. Bounded by the session lifetime. +- Rotating the key logs out everyone, not one session. + +**Neutral** + +- Logout must clear the cookie rather than delete a record, so a client that keeps + a copy can still replay until expiry. Mitigated by the short lifetime. +- The payload is readable by its holder. Acceptable because it contains no secrets. diff --git a/docs/adr/0004-typescript-version.md b/docs/adr/0004-typescript-version.md new file mode 100644 index 0000000..56300da --- /dev/null +++ b/docs/adr/0004-typescript-version.md @@ -0,0 +1,70 @@ +# ADR 0004: TypeScript 6.0.3 rather than 7.x + +- **Status:** accepted +- **Date:** 2026-10-04 +- **Revisit when:** typescript-eslint ships TypeScript 7 support + +## Context + +The brief was to take the latest version of everything. For TypeScript, the latest +release is 7.0.2, which is the native Go port of the compiler (`tsgo`). + +## Decision + +Pin TypeScript to 6.0.3 in both `web/` and `sdk/`. + +## Rationale + +TypeScript 7.0.2 ships `tsc.js` and a version module. It does **not** ship a +JavaScript compiler API: + +```console +$ npm install typescript@7.0.2 +$ node -e "const ts=require('typescript'); console.log(typeof ts.createProgram)" +undefined +``` + +That API is what every type-aware linting tool consumes. `typescript-eslint@8.71.0`, +the current release, declares: + +``` +peerDependencies: { typescript: ">=4.8.4 <6.1.0" } +``` + +So TypeScript 7 fails to install at all alongside the standard linting stack, and +would fail at run time if forced past the peer check. + +The choice is therefore between: + +- **TypeScript 7.x** — a much faster compiler, and no type-aware ESLint rules at all, + because the tooling that provides them cannot run. `npm install` fails outright + without `--legacy-peer-deps`, which is a smell in a project rather than a solution. +- **TypeScript 6.0.3** — the newest release that ships both the CLI and the + JavaScript API, satisfying every tool in the toolchain. + +## Why not upgrade anyway and lose the linting + +The project's standards require type-aware linting: `@typescript-eslint` rules that +need the compiler's type information, which is what catches the mistakes that matter +in a codebase using `unknown` at API boundaries. Dropping it to gain compiler speed +is a bad trade: the speed is a developer-experience improvement, and the linting is +a correctness control. + +## What this costs + +TypeScript 6.0.3 is slower than 7.0.2. On this codebase the typecheck completes in +well under a second, so the difference is not observable, and the compilation cost +that matters is Vite's, which uses its own transform. + +## When to revisit + +When typescript-eslint declares support for TypeScript 7, which requires the +JavaScript API or a native alternative to it. That is a one-line version bump plus +a `make check` run. This ADR exists so that the pin is a decision with a reason +rather than an accident that outlives its justification. + +## A related note: `baseUrl` is deprecated + +TypeScript 6 deprecates `compilerOptions.baseUrl`. The project resolves `paths` +relative to the tsconfig instead, which is the documented replacement and removes a +compiler option that only ever existed to make `paths` work. diff --git a/docs/adr/0005-shared-token-contract.md b/docs/adr/0005-shared-token-contract.md new file mode 100644 index 0000000..67872eb --- /dev/null +++ b/docs/adr/0005-shared-token-contract.md @@ -0,0 +1,119 @@ +# ADR 0005: One token contract for every application, with a legacy alias layer + +- **Status:** accepted +- **Date:** 2026-10-05 +- **Revisit when:** an application adopts Tailwind v4 as its only styling mechanism, which + would let the alias layer be dropped + +## Context + +Six applications consume design decisions today, each in its own dialect: + +| Application | Mechanism | Palettes | Light/dark | Token indirection | +| --- | --- | --- | --- | --- | +| `go-doc` | Tailwind v4 `@theme` | 1, dark hardcoded | none | `--color-ink-*` | +| `go-guess` | one 2800-line CSS file | 1, light hardcoded | none | 11 `:root` vars, mostly bypassed by ~120 hardcoded hexes | +| `go-loose` | plain CSS | 4 × 2 modes | works | `[data-theme=X]` + `[data-mode=light]` | +| `go-mad` | plain CSS, copied from `go-loose` | 1 × 2 modes | CSS present, JS dead | `[data-mode=X]` | +| `go-places` | JS-injected `' + ' ', + 1, +) + +# The reporter, appended to the copy. go-tell's own source is never edited. +s = s.replace('', ' \n') +open(path, 'w').write(s) +PY + +echo "==> starting the theme service on :$THEME_PORT" +DATABASE_PATH="$WORK/tell.db" \ +BANANAS_ENVIRONMENT=development \ +BANANAS_AUTH_MODE=dev \ +BANANAS_PUBLIC_BASE_URL="http://localhost:$THEME_PORT" \ +BANANAS_HTTP_ADDR=":$THEME_PORT" \ +BANANAS_SDK_DIR="$WORK/site/assets" \ +BANANAS_CORS_ALLOWED_ORIGINS="http://localhost:$PORT" \ + "$WORK/go-bananas" >"$WORK/theme.log" 2>&1 & +THEME_PID=$! + +for _ in $(seq 1 40); do + curl -fsS -m 2 "http://localhost:$THEME_PORT/healthz" >/dev/null 2>&1 && break + sleep 0.25 +done + +echo "==> serving go-tell's shell on :$PORT" +(cd "$WORK/site" && exec python3 -m http.server "$PORT") >"$WORK/site.log" 2>&1 & +SERVER_PID=$! +for _ in $(seq 1 40); do + curl -fsS -m 2 "http://localhost:$PORT/index.html" >/dev/null 2>&1 && break + sleep 0.25 +done + +echo "==> driving the page" +"$CHROME" \ + --headless --no-sandbox --disable-gpu \ + --virtual-time-budget=15000 \ + --dump-dom "http://localhost:$PORT/index.html" \ + >"$WORK/dom.html" 2>"$WORK/chrome.log" + +result="$(python3 - "$WORK/dom.html" <<'PYEOF' +import re, sys +dom = open(sys.argv[1]).read() +m = re.search(r'id="pilot"[^>]*>(.*?)', dom, re.S) +print(m.group(1).strip() if m else "") +PYEOF +)" + +if [[ -z "$result" ]]; then + echo "PILOT FAIL: the page recorded nothing, so the check did not run." >&2 + echo "--- browser console ---" >&2 + grep -iE 'CONSOLE.*(error|Uncaught|violates|refused)' "$WORK/chrome.log" | head -5 >&2 || true + echo "--- dom size ---" >&2 + wc -c <"$WORK/dom.html" >&2 || true + exit 1 +fi + +echo +echo "$result" +[[ "$result" == PILOT\ PASS* ]] \ No newline at end of file diff --git a/hack/contract-check.js b/hack/contract-check.js new file mode 100644 index 0000000..845acd5 --- /dev/null +++ b/hack/contract-check.js @@ -0,0 +1,142 @@ +/** + * The integration check's script. + * + * It is a separate file rather than an inline + + \ No newline at end of file diff --git a/hack/pilot-go-loose.js b/hack/pilot-go-loose.js new file mode 100644 index 0000000..3ff46b3 --- /dev/null +++ b/hack/pilot-go-loose.js @@ -0,0 +1,124 @@ +/** + * The pilot check's reporter. + * + * It is injected into a *copy* of go-loose's page; go-loose's own source is untouched. + * + * The question it answers is narrow and specific: does the component shipped by + * go-bananas change the attributes that go-loose's own hand-written stylesheet already + * keys on? If it does, go-loose is themed by the shared contract without a single edit + * to its CSS, which is the entire claim of ADR 0005. + */ +import { getContractState } from '/rt/v1/components.js' + +const results = [] +/** @param {string} name @param {boolean} pass @param {string} [detail] */ +const check = (name, pass, detail = '') => results.push({ name, pass: !!pass, detail: String(detail) }) + +const root = document.documentElement +const state = getContractState() + +/** The computed background of go-loose's own page surface. */ +function pageBackground() { + return getComputedStyle(document.body).backgroundColor +} + +/** + * The value go-loose's page sees for a contract custom property. + * + * This is the claim worth testing: go-loose's stylesheet never mentions --bn-* or + * --surface being defined anywhere, yet reading the property off its own body proves + * the contract reached the page through the one stylesheet link. + */ +function contractValueOnHost(name) { + return getComputedStyle(document.body).getPropertyValue(name).trim() +} + +/** The text colour go-loose's own CSS takes from --text. */ +function pageText() { + return getComputedStyle(document.body).color +} + +async function run() { + // The selector fetches its palette list from the service. The wait has to cover a + // real cross-origin round trip, not the instant rejection that used to happen when + // the service sent no CORS headers: that failure was fast enough to hide behind a + // short settle time, and the wait was tuned to it. + await new Promise((r) => setTimeout(r, 4000)) + + const selector = document.querySelector('bananas-theme-selector') + const toggle = document.querySelector('bananas-appearance-toggle') + const shadow = (el) => /** @type {ShadowRoot} */ (el.shadowRoot) + + check('the selector element upgraded', !!shadow(selector), selector ? selector.tagName : 'absent') + check('the toggle element upgraded', !!shadow(toggle), toggle ? toggle.tagName : 'absent') + + const buttons = [...shadow(selector).querySelectorAll('button')] + const labels = buttons.map((b) => b.textContent.trim()) + check('palettes are listed from the service', buttons.length > 1, labels.join(', ')) + + // go-loose's four palettes must all be offered, because those are the slugs its own + // CSS has blocks for. + for (const slug of ['Tokyo Night', 'Catppuccin', 'Tomorrow', 'Kanagawa']) { + check(`go-loose's "${slug}" is offered`, labels.includes(slug)) + } + + // The contract stylesheet loaded and reached go-loose's page. + const bgTokyo = pageBackground() + check('go-loose\'s page is themed through the contract', bgTokyo !== 'rgba(0, 0, 0, 0)' && bgTokyo !== '', bgTokyo) + + // The decisive test: change the palette with the component, and confirm go-loose's + // OWN stylesheet responded. Nothing in style.css was edited. + const startBg = bgTokyo + const startText = pageText() + const kanagawa = buttons.find((b) => b.textContent.trim() === 'Kanagawa') + if (!kanagawa) { + check('the Kanagawa button was found', false, labels.join(', ')) + } else { + kanagawa.click() + await new Promise((r) => setTimeout(r, 60)) + check('selecting a palette writes data-theme', root.getAttribute('data-theme') === 'kanagawa', root.getAttribute('data-theme')) + check('selecting a palette writes the legacy attribute', root.getAttribute('data-bananas-theme') === 'kanagawa', root.getAttribute('data-bananas-theme')) + check("go-loose's own CSS followed the palette", pageBackground() !== startBg, startBg + ' -> ' + pageBackground()) + // The alias layer reached a page that never referenced it. + check('go-loose\'s page reads --surface from the contract', contractValueOnHost('--surface') !== '', contractValueOnHost('--surface')) + check('go-loose\'s page reads --bn-surface from the contract', contractValueOnHost('--bn-surface') !== '', contractValueOnHost('--bn-surface')) + check('the alias and the contract agree', contractValueOnHost('--surface') === contractValueOnHost('--bn-surface'), + contractValueOnHost('--surface') + ' vs ' + contractValueOnHost('--bn-surface')) + } + + // And the appearance axis, which go-loose has never had. + const toggleButtons = [...shadow(toggle).querySelectorAll('button')] + check('the appearance toggle has three states', toggleButtons.length === 3, toggleButtons.map((b) => b.getAttribute('aria-label')).join(', ')) + const light = toggleButtons.find((b) => b.getAttribute('aria-label') === 'Light') + if (light) { + light.click() + await new Promise((r) => setTimeout(r, 60)) + check('light writes data-mode', root.getAttribute('data-mode') === 'light', root.getAttribute('data-mode')) + check("go-loose's own CSS followed the appearance", pageBackground() !== 'rgb(0, 0, 0)', pageBackground()) + } + + // Isolation: go-loose has a broad `.card button { all: unset }`-style surface, and a + // control that leaked would be reset by it. + const first = shadow(selector).querySelector('button') + const style = first ? getComputedStyle(first) : null + check('the control is not reset by the host page', !!style && style.padding !== '0px', style ? style.padding : 'no button') + check('the control is not inheriting the page font', !!style && !style.fontFamily.includes('DM Sans'), style ? style.fontFamily : '') + + // The store is reachable, so go-loose can read or set the selection itself. + check('the contract store is reachable from the module', state && typeof state.selectTheme === 'function') + + const failed = results.filter((r) => !r.pass) + const out = document.getElementById('pilot') || document.createElement('pre') + out.id = 'pilot' + out.textContent = failed.length + ? 'PILOT FAIL ' + failed.length + '/' + results.length + ' :: ' + failed.map((f) => f.name + ' [' + f.detail + ']').join(' | ') + : 'PILOT PASS ' + results.length + ' :: ' + results.map((f) => f.name).join(' | ') + document.body.append(out) +} + +run().catch((error) => { + const out = document.createElement('pre') + out.id = 'pilot' + out.textContent = 'PILOT FAIL the check threw: ' + (error instanceof Error ? error.message : String(error)) + document.body.append(out) +}) \ No newline at end of file diff --git a/hack/pilot-go-tell.js b/hack/pilot-go-tell.js new file mode 100644 index 0000000..c0bf7af --- /dev/null +++ b/hack/pilot-go-tell.js @@ -0,0 +1,167 @@ +/** + * The go-tell pilot check. + * + * Injected into a *copy* of go-tell's shell; go-tell's own source is untouched. + * + * go-tell is the hard case among the applications: before this it had no CSS custom + * properties at all, 340 hardcoded hex values and 153 per-palette override rules. Its + * stylesheet now reads from the shared contract, so the question this answers is + * whether that rewrite actually works, and whether the two shipped controls can drive + * an application whose mechanism is `html[data-theme="…"]` rather than a class or a + * `--theme` variable. + */ +import { getContractState } from '/assets/components.js' + +const results = [] +const check = (name, pass, detail = '') => results.push({ name, pass: !!pass, detail: String(detail) }) + +const root = document.documentElement +const state = getContractState() + +/** The computed background of the page, which is what a palette actually looks like. */ +const pageBg = () => getComputedStyle(document.body).backgroundColor +/** The computed colour of a heading, which proves the text role resolved. */ +const headingColour = () => { + const el = document.querySelector('.brand, h1, .header') + return el ? getComputedStyle(el).color : '' +} + +/** + * Waits past this application's own CSS transitions. + * + * go-tell transitions `background` over 300ms, so reading a computed background sooner + * than that returns a value mid-animation and makes a working palette switch look + * broken. The wait is deliberately longer than the longest transition in the + * stylesheet rather than merely "long enough today". + */ +async function settle() { + await new Promise((r) => setTimeout(r, 450)) +} + +async function run() { + await new Promise((r) => setTimeout(r, 600)) // the selector's fetch + + const selector = document.querySelector('bananas-theme-selector') + const toggle = document.querySelector('bananas-appearance-toggle') + const shadow = (el) => /** @type {ShadowRoot} */ (el.shadowRoot) + + check('the controls upgraded', !!selector && !!toggle && !!shadow(selector) && !!shadow(toggle)) + if (!selector || !toggle) return finish() + + const buttons = [...shadow(selector).querySelectorAll('button')] + const labels = buttons.map((b) => b.textContent.trim()) + check('palettes are listed from the service', buttons.length > 1, labels.join(', ')) + + // go-tell's own four palettes must all be offered, because those are the slugs its + // stylesheet and its rules key on. + for (const name of ['Cyberpunk', 'Nordic Moss', 'Deep Space Obsidian', 'Terminal Retro']) { + check(`go-tell's "${name}" is offered`, labels.some((l) => l.includes(name))) + } + + // The decisive one: the contract must reach go-tell's page, which no longer + // defines a single custom property of its own. + const surface = getComputedStyle(document.body).getPropertyValue('--bn-surface').trim() + + // Diagnostics, because "the contract did not reach the page" has three very + // different causes and the symptom alone cannot tell them apart. + const link = document.querySelector('link[data-theme-contract]') + const linkHref = link ? link.href : '(no link element)' + let linkLoaded = 'unknown' + if (link && link.sheet) { + try { + linkLoaded = String(link.sheet.cssRules.length) + ' rules' + } catch { + // A cross-origin sheet is unreadable by design. Reaching this branch means the + // stylesheet loaded and the browser is refusing to expose its rules, which is + // the healthy case for a cross-origin link. + linkLoaded = 'loaded (cross-origin, rules not readable)' + } + } else if (link) { + linkLoaded = 'not loaded' + } + + check('the contract link is present', !!link, linkHref) + check('the contract stylesheet loaded', linkLoaded !== 'not loaded', linkLoaded) + check('the contract reaches go-tell', surface !== '', 'href=' + linkHref + ' loaded=' + linkLoaded + ' data-theme=' + root.getAttribute('data-theme') + ' data-mode=' + root.getAttribute('data-mode')) + check('--bn-surface has a real value', surface !== '' && surface !== 'rgba(0, 0, 0, 0)', surface) + + // Switching a palette must move the page, through go-tell's own rules. + let startBg = pageBg() + const startText = headingColour() + + for (const name of ['Nordic Moss', 'Deep Space Obsidian', 'Terminal Retro', 'Cyberpunk']) { + const button = buttons.find((b) => b.textContent.trim().includes(name)) + if (!button) { + check(`selecting ${name}`, false, 'button not found') + continue + } + + // The loop returns to Cyberpunk at the end, which is where it started. Selecting + // the palette that is already showing cannot change anything, so the "did the + // page move" assertion does not apply to that step. + const alreadyShowing = buttons.find((b) => b.getAttribute('aria-checked') === 'true') + const wasThis = alreadyShowing && alreadyShowing.textContent.trim().includes(name) + + button.click() + await settle() + + const slug = root.getAttribute('data-theme') + const contractBg = getComputedStyle(document.body).getPropertyValue('--bn-bg').trim() + check(`${name} writes data-theme`, !!slug, slug) + if (wasThis) { + check(`${name} is a no-op when already showing`, pageBg() === startBg, pageBg()) + } else { + check(`${name} changes the page`, pageBg() !== startBg || headingColour() !== startText, + `slug=${slug} mode=${root.getAttribute('data-mode')} bodyBg=${pageBg()} --bn-bg=${contractBg} text=${headingColour()}`) + } + startBg = pageBg() + } + + // The terminal palette forces a monospace face with !important on every element. + // Shadow DOM is supposed to make that impossible to reach the controls in; if the + // controls inherit the terminal face here, the isolation has failed. + const terminal = buttons.find((b) => b.textContent.trim().includes('Terminal')) + if (terminal) { + terminal.click() + await settle() + const font = getComputedStyle(terminal).fontFamily + check('the controls are not restyled by the terminal palette', !font.includes('Courier'), font) + check('the app itself did adopt the monospace face', + getComputedStyle(document.body).fontFamily.includes('Courier') || + getComputedStyle(document.body).fontFamily.includes('monospace'), + getComputedStyle(document.body).fontFamily) + } + + // The appearance axis: go-tell had no light mode before this. The control writing + // data-mode is what makes one possible. + const toggleButtons = [...shadow(toggle).querySelectorAll('button')] + check('the appearance toggle has three states', toggleButtons.length === 3, + toggleButtons.map((b) => b.getAttribute('aria-label')).join(', ')) + + const light = toggleButtons.find((b) => b.getAttribute('aria-label') === 'Light') + if (light) { + light.click() + await settle() + check('light writes data-mode', root.getAttribute('data-mode') === 'light', root.getAttribute('data-mode')) + } + + check('the contract store is reachable', state && typeof state.selectTheme === 'function') + finish() +} + +function finish() { + const failed = results.filter((r) => !r.pass) + const out = document.createElement('pre') + out.id = 'pilot' + out.textContent = failed.length + ? 'PILOT FAIL ' + failed.length + '/' + results.length + ' :: ' + failed.map((f) => f.name + ' [' + f.detail + ']').join(' | ') + : 'PILOT PASS ' + results.length + ' :: ' + results.map((f) => f.name).join(' | ') + document.body.append(out) +} + +run().catch((error) => { + const out = document.createElement('pre') + out.id = 'pilot' + out.textContent = 'PILOT FAIL the check threw: ' + (error instanceof Error ? error.message : String(error)) + document.body.append(out) +}) \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..b9d254a --- /dev/null +++ b/internal/app/app.go @@ -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))) +} diff --git a/internal/app/backup.go b/internal/app/backup.go new file mode 100644 index 0000000..b23a936 --- /dev/null +++ b/internal/app/backup.go @@ -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 [--database ]\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 +} diff --git a/internal/app/spa_test.go b/internal/app/spa_test.go new file mode 100644 index 0000000..d4f1a28 --- /dev/null +++ b/internal/app/spa_test.go @@ -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"), "app") + 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(), "app") { + 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, "app") { + 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"), "app") + + 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) + } + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..1a906db --- /dev/null +++ b/internal/auth/auth.go @@ -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 } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..90027e9 --- /dev/null +++ b/internal/auth/auth_test.go @@ -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) + } +} diff --git a/internal/auth/helpers_test.go b/internal/auth/helpers_test.go new file mode 100644 index 0000000..9b06a1e --- /dev/null +++ b/internal/auth/helpers_test.go @@ -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 +} diff --git a/internal/auth/hmac.go b/internal/auth/hmac.go new file mode 100644 index 0000000..dd57b19 --- /dev/null +++ b/internal/auth/hmac.go @@ -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 +} diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..82bff8d --- /dev/null +++ b/internal/auth/oidc.go @@ -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} +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4d60f7f --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go new file mode 100644 index 0000000..1b4762b --- /dev/null +++ b/internal/domain/domain.go @@ -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") diff --git a/internal/httpapi/admin.go b/internal/httpapi/admin.go new file mode 100644 index 0000000..d0754d7 --- /dev/null +++ b/internal/httpapi/admin.go @@ -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] + } + } +} diff --git a/internal/httpapi/auth_handlers.go b/internal/httpapi/auth_handlers.go new file mode 100644 index 0000000..9c5de14 --- /dev/null +++ b/internal/httpapi/auth_handlers.go @@ -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}) +} diff --git a/internal/httpapi/cache.go b/internal/httpapi/cache.go new file mode 100644 index 0000000..6b8143b --- /dev/null +++ b/internal/httpapi/cache.go @@ -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() } diff --git a/internal/httpapi/embed_test.go b/internal/httpapi/embed_test.go new file mode 100644 index 0000000..c745cd6 --- /dev/null +++ b/internal/httpapi/embed_test.go @@ -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", + "../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, "") { + 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
.
+	if strings.Contains(body, "`,
+	}, "\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{
+		``,
+		``,
+	}, "\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, `  
+

`+htmlEscape(sample.title)+`

+
`+htmlEscape(sample.body)+`
+ +
`) + } + + return ` + + + + + +go-bananas · integrate ` + htmlEscape(slug) + ` + + + + +
+

Integrate ` + htmlEscape(slug) + `

+

Copy one of these into your application. Nothing is installed: the + theme is fetched from this deployment at run time.

+ +` + strings.Join(sections, "\n") + ` + +
+

Live preview

+
+ + + +
+
Card heading
+

Every value here comes from a design token in the + theme you are editing.

+
+

published + draft + archived

+ +
+
+
+ + +` +} + +// 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) +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..111bbc4 --- /dev/null +++ b/internal/logging/logging.go @@ -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) +} diff --git a/internal/seed/consolidated.go b/internal/seed/consolidated.go new file mode 100644 index 0000000..dad8a03 --- /dev/null +++ b/internal/seed/consolidated.go @@ -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", + }, + ) +} diff --git a/internal/seed/consolidated_test.go b/internal/seed/consolidated_test.go new file mode 100644 index 0000000..1b0bb78 --- /dev/null +++ b/internal/seed/consolidated_test.go @@ -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=""]. 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) + } + } +} diff --git a/internal/seed/seed.go b/internal/seed/seed.go new file mode 100644 index 0000000..965d6cc --- /dev/null +++ b/internal/seed/seed.go @@ -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 +} diff --git a/internal/seed/seed_test.go b/internal/seed/seed_test.go new file mode 100644 index 0000000..9f0f3e8 --- /dev/null +++ b/internal/seed/seed_test.go @@ -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 +} diff --git a/internal/store/backup.go b/internal/store/backup.go new file mode 100644 index 0000000..78d5142 --- /dev/null +++ b/internal/store/backup.go @@ -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 +} diff --git a/internal/store/backup_test.go b/internal/store/backup_test.go new file mode 100644 index 0000000..6986ebc --- /dev/null +++ b/internal/store/backup_test.go @@ -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) + } +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go new file mode 100644 index 0000000..5216a15 --- /dev/null +++ b/internal/store/migrations.go @@ -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 +} diff --git a/internal/store/migrations/0001_init.sql b/internal/store/migrations/0001_init.sql new file mode 100644 index 0000000..ce27e5f --- /dev/null +++ b/internal/store/migrations/0001_init.sql @@ -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 '' +); \ No newline at end of file diff --git a/internal/store/migrations/0002_drop_api_keys.sql b/internal/store/migrations/0002_drop_api_keys.sql new file mode 100644 index 0000000..8235e4a --- /dev/null +++ b/internal/store/migrations/0002_drop_api_keys.sql @@ -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; \ No newline at end of file diff --git a/internal/store/migrations/0002_user_preferences.sql b/internal/store/migrations/0002_user_preferences.sql new file mode 100644 index 0000000..9a75724 --- /dev/null +++ b/internal/store/migrations/0002_user_preferences.sql @@ -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); \ No newline at end of file diff --git a/internal/store/repositories.go b/internal/store/repositories.go new file mode 100644 index 0000000..7e96b22 --- /dev/null +++ b/internal/store/repositories.go @@ -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 +} diff --git a/internal/store/sort.go b/internal/store/sort.go new file mode 100644 index 0000000..f665abe --- /dev/null +++ b/internal/store/sort.go @@ -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]) }) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..388bf03 --- /dev/null +++ b/internal/store/store.go @@ -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) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..2619c0b --- /dev/null +++ b/internal/store/store_test.go @@ -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) + } +} diff --git a/internal/store/themes.go b/internal/store/themes.go new file mode 100644 index 0000000..00cd6cd --- /dev/null +++ b/internal/store/themes.go @@ -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) } diff --git a/internal/store/user_preferences.go b/internal/store/user_preferences.go new file mode 100644 index 0000000..c651735 --- /dev/null +++ b/internal/store/user_preferences.go @@ -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 + } +} diff --git a/internal/store/user_preferences_test.go b/internal/store/user_preferences_test.go new file mode 100644 index 0000000..02be00e --- /dev/null +++ b/internal/store/user_preferences_test.go @@ -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 +} diff --git a/internal/tokenengine/color.go b/internal/tokenengine/color.go new file mode 100644 index 0000000..c51387b --- /dev/null +++ b/internal/tokenengine/color.go @@ -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 +} diff --git a/internal/tokenengine/color_test.go b/internal/tokenengine/color_test.go new file mode 100644 index 0000000..debfc8d --- /dev/null +++ b/internal/tokenengine/color_test.go @@ -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) + } +} diff --git a/internal/tokenengine/contract.go b/internal/tokenengine/contract.go new file mode 100644 index 0000000..7088003 --- /dev/null +++ b/internal/tokenengine/contract.go @@ -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 +// ; 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 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 ; 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 +} diff --git a/internal/tokenengine/contract_test.go b/internal/tokenengine/contract_test.go new file mode 100644 index 0000000..dea3b9b --- /dev/null +++ b/internal/tokenengine/contract_test.go @@ -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) + } + } +} diff --git a/internal/tokenengine/css.go b/internal/tokenengine/css.go new file mode 100644 index 0000000..d311e2b --- /dev/null +++ b/internal/tokenengine/css.go @@ -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 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 +} diff --git a/internal/tokenengine/json.go b/internal/tokenengine/json.go new file mode 100644 index 0000000..9503b7b --- /dev/null +++ b/internal/tokenengine/json.go @@ -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 + } +} diff --git a/internal/tokenengine/palette.go b/internal/tokenengine/palette.go new file mode 100644 index 0000000..e681fd2 --- /dev/null +++ b/internal/tokenengine/palette.go @@ -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 +} diff --git a/internal/tokenengine/schema.go b/internal/tokenengine/schema.go new file mode 100644 index 0000000..ec343c8 --- /dev/null +++ b/internal/tokenengine/schema.go @@ -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 or 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 +} diff --git a/internal/tokenengine/tokens.go b/internal/tokenengine/tokens.go new file mode 100644 index 0000000..3e2de1d --- /dev/null +++ b/internal/tokenengine/tokens.go @@ -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 +} diff --git a/internal/tokenengine/tokens_test.go b/internal/tokenengine/tokens_test.go new file mode 100644 index 0000000..cf42341 --- /dev/null +++ b/internal/tokenengine/tokens_test.go @@ -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") + } +} diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..b213610 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,2691 @@ +openapi: 3.1.0 +info: + title: go-bananas + version: 1.0.0 + summary: Runtime design-token service. + description: 'Author design-token themes and serve them to any application at run time. + + + ## Two surfaces, two postures + + + `/rt/v1` is the **runtime** API: public, aggressively cacheable, and designed to be consumed from + a `` tag with no credential. A theming service that required a credential could not be consumed + the way a theme is actually consumed. + + + `/api/v1` is the **administrative** API: authenticated, never cached, and every mutation audited. + + + ## Versioning + + + Both surfaces are independently versioned. Adding an optional field, a new endpoint or a new query + parameter is not a breaking change. Changing the meaning of an existing one is, and gets a new major + version served alongside the old one for at least one cycle.' + license: + name: MIT + identifier: MIT + contact: + name: urpi platform team + url: https://github.com/urpi/go-bananas +servers: +- url: '{origin}' + description: This deployment. + variables: + origin: + default: https://themes.example.com + description: The externally reachable origin. +tags: +- name: runtime + description: Public. Consumed by applications and browsers. +- name: themes + description: Theme management. Requires authentication. +- name: history + description: Version history, diffing and rollback. +- name: tokens + description: Read-only analysis of a token document. +- name: auth + description: Session introspection and the login flow. +- name: preferences + description: The caller's own interface preferences. Any authenticated role. +- name: admin + description: Keys, users, settings and the audit log. Admin role. +- name: operational + description: Probes and metrics. +paths: + /rt/v1/theme.css: + get: + summary: A theme stylesheet + description: 'Returns the theme rendered as CSS custom properties, plus a small component layer + in `@layer bananas`, so an application''s own unlayered CSS always wins the cascade. + + + Responses are **immutable by URL**: publishing a theme changes its version, and a version-pinned + URL never changes. That lets a CDN cache them hard. `If-None-Match` and `If-Modified-Since` are + both honoured, so a redeploy costs a revalidation and no body. + + + A draft theme returns **404 rather than 403**, so a probe cannot confirm that it exists.' + tags: + - runtime + responses: + '200': + description: The stylesheet. + content: + text/css: + schema: + type: string + example: "[data-bananas-theme=\"midnight\"] {\n --bn-color-semantic-primary: #7c6bf5;\n\ + }" + headers: &id001 + ETag: + description: A strong validator for the response. + schema: + type: string + X-Theme-Version: + description: The version served. + schema: + type: string + X-Theme-Updated-At: + description: When it was published. + schema: + type: string + Cache-Control: + description: The caching policy applied. + schema: + type: string + X-Request-ID: + description: Correlation id for the server log. + schema: + type: string + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: query + required: true + description: The theme slug. + schema: + type: string + - name: appearance + in: query + required: false + description: The colour scheme. Defaults to the theme's own default, which is what makes a dark-first + theme correct without the parameter being passed. + schema: + type: string + enum: + - light + - dark + - name: variant + in: query + required: false + description: '`full` includes the component layer, `tokens` omits it, `minified` strips comments + for production.' + schema: + type: string + enum: + - full + - tokens + - minified + - name: v + in: query + required: false + description: 'Pin a version. This URL never changes, which is what makes a safe deploy possible: + build against version 7 and the render cannot change underneath you.' + schema: + type: integer + minimum: 1 + security: [] + /rt/v1/theme.json: + get: + summary: A theme's resolved tokens + description: Carries both a flat custom-property map and the nested DTCG document, so a consumer + can read either without a transformation step. Derived tokens such as the `on-*` foreground pairs + and the `-hover` / `-active` interaction states are included. + tags: + - runtime + responses: + '200': + description: The resolved theme. + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedTheme' + headers: *id001 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: query + required: true + description: The theme slug. + schema: + type: string + - name: appearance + in: query + required: false + description: The colour scheme. Defaults to the theme's default. + schema: + type: string + enum: + - light + - dark + security: [] + /rt/v1/manifest.json: + get: + summary: The theme manifest + description: 'The discovery document a loader fetches once to build a theme picker. Deliberately + small: no token data, so a page with a picker does not download every theme. Cached briefly, because + it changes whenever a theme is published.' + tags: + - runtime + responses: + '200': + description: The manifest. + content: + application/json: + schema: + $ref: '#/components/schemas/Manifest' + headers: + Cache-Control: + description: Short-lived. + schema: + type: string + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] + /rt/v1/embed: + get: + summary: An integration snippet + description: 'Generates the markup to paste, from this deployment''s own configuration, so it cannot + drift from reality. Always answers JSON, whatever the target: the admin UI fetches this with a + JSON parser and an HTML response for the default target is what made the Integrate page report + that it could not parse the reply. The slug must name an existing theme, and every interpolated + value is escaped for the target language, because the output is pasted into someone else''s application.' + tags: + - runtime + responses: + '200': + description: The snippet. + content: + application/json: + schema: + $ref: '#/components/schemas/EmbedSnippet' + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: No such theme. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: query + required: false + description: The theme to generate for. + schema: + type: string + - name: target + in: query + required: false + description: The integration shape. + schema: + type: string + enum: + - html + - react + - javascript + security: [] + /rt/v1/themes/{slug}/theme.css: + get: + summary: A theme stylesheet, by path + description: A REST-shaped equivalent of `/rt/v1/theme.css`, for consumers that prefer a path to + a query string, or a CDN configured with path patterns. + tags: + - runtime + responses: + '200': + description: The stylesheet. + content: + text/css: + schema: + type: string + headers: *id001 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: [] + /rt/v1/themes/{slug}/theme.json: + get: + summary: A theme's resolved tokens, by path + description: A REST-shaped equivalent of `/rt/v1/theme.json`. + tags: + - runtime + responses: + '200': + description: The resolved theme. + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedTheme' + headers: *id001 + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: [] + /rt/v1/runtime.js: + get: + summary: The runtime.js bundle + description: 'The framework-free ES module: `applyTheme`, `setAppearance`, `loadManifest`, `getVariables`, + `watchSystemAppearance`, `subscribe`. + + + Served from this origin so a consuming application needs no build step and no npm dependency. + React is imported as a **bare specifier** and resolved by the consumer''s own import map, which + is what guarantees a single React instance in the page: two copies would each have their own hook + dispatcher.' + tags: + - runtime + responses: + '200': + description: The bundle. + content: + application/javascript: + schema: + type: string + headers: + Cache-Control: + description: Immutable. The filename is stable so an import map can name it. + schema: + type: string + security: [] + /rt/v1/react.js: + get: + summary: The react.js bundle + description: 'The React provider and hooks: `BananasProvider`, `useBananas`, `useThemeVariable`, + `useThemeVariables`, `useAvailableThemes`. + + + Served from this origin so a consuming application needs no build step and no npm dependency. + React is imported as a **bare specifier** and resolved by the consumer''s own import map, which + is what guarantees a single React instance in the page: two copies would each have their own hook + dispatcher.' + tags: + - runtime + responses: + '200': + description: The bundle. + content: + application/javascript: + schema: + type: string + headers: + Cache-Control: + description: Immutable. The filename is stable so an import map can name it. + schema: + type: string + security: [] + /rt/v1/global.js: + get: + summary: The global.js bundle + description: 'The same API attached to `window.Bananas`, for a page that cannot use modules. + + + Served from this origin so a consuming application needs no build step and no npm dependency. + React is imported as a **bare specifier** and resolved by the consumer''s own import map, which + is what guarantees a single React instance in the page: two copies would each have their own hook + dispatcher.' + tags: + - runtime + responses: + '200': + description: The bundle. + content: + application/javascript: + schema: + type: string + headers: + Cache-Control: + description: Immutable. The filename is stable so an import map can name it. + schema: + type: string + security: [] + /rt/v1/embed.js: + get: + summary: The embed.js bundle + description: 'The one-line script-tag embed. Configured with data attributes. + + + Served from this origin so a consuming application needs no build step and no npm dependency. + React is imported as a **bare specifier** and resolved by the consumer''s own import map, which + is what guarantees a single React instance in the page: two copies would each have their own hook + dispatcher.' + tags: + - runtime + responses: + '200': + description: The bundle. + content: + application/javascript: + schema: + type: string + headers: + Cache-Control: + description: Immutable. The filename is stable so an import map can name it. + schema: + type: string + security: [] + /auth/login: + get: + summary: Begin the login flow + description: Redirects to the identity provider. The return path and the PKCE verifier are carried + in a signed cookie rather than server-side state, so any replica behind the load balancer can + complete the flow. + tags: + - auth + responses: + '302': + description: Redirect to the provider. + '404': + description: No identity provider is configured. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: returnTo + in: query + required: false + description: 'A **local** path to return to. An absolute URL is rejected: it would be an open + redirect that also primes a phishing flow.' + schema: + type: string + security: [] + /auth/callback: + get: + summary: Complete the login flow + description: Exchanges the authorization code, verifies the ID token signature, issuer, audience + and nonce, resolves roles from the user's groups, and sets the session cookie. The transaction + state is single-use. + tags: + - auth + responses: + '302': + description: Redirect to the return path, with a session cookie set. + '401': + description: The login could not be completed. The message names the reason, because "invalid + state" and "user declined" need different actions. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '502': + description: The provider returned an unexpected response. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] + /auth/logout: + get: + summary: End the session + description: Clears the session cookie and, when an OIDC provider is configured, ends the provider + session too. + tags: + - auth + responses: + '302': + description: Redirect, to the provider when configured. + security: + - sessionCookie: [] + - bearerToken: [] + post: + summary: End the session + description: Clears the session cookie and, when an OIDC provider is configured, ends the provider + session too. + tags: + - auth + responses: + '302': + description: Redirect, to the provider when configured. + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/auth/me: + get: + summary: The current session + description: The session, and what the caller may do. + tags: + - auth + responses: + '200': + description: The session. + content: + application/json: + schema: + $ref: '#/components/schemas/Session' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] + /api/v1/auth/config: + get: + summary: Client-visible configuration + description: What the UI needs before a session exists. Contains no secret material. + tags: + - auth + responses: + '200': + description: The configuration. + content: + application/json: + schema: + $ref: '#/components/schemas/AppConfig' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] + /api/v1/me/preferences: + get: + summary: The caller's interface preferences + description: Empty when nothing is stored, which is every account until it chooses once. + tags: + - preferences + responses: + '200': + description: The stored preference. + content: + application/json: + schema: + $ref: '#/components/schemas/UserPreferences' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + put: + summary: Record the caller's interface preferences + description: 'Any authenticated role may call this. The preference is keyed to the caller''s own + subject and grants nothing. An empty selectedTheme clears the choice. The response is authoritative: + the server rejects a theme that is not published, and reports a stored theme that has since disappeared + as no choice. tags: - preferences requestBody: required: true content: application/json: schema: + $ref: ' + responses: + '200': + description: What the server stored. + content: + application/json: + schema: + $ref: '#/components/schemas/UserPreferences' + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + delete: + summary: Forget the caller's preferences + description: Returns the interface to its defaults. + tags: + - preferences + responses: + '200': + description: The empty preference. + content: + application/json: + schema: + $ref: '#/components/schemas/UserPreferences' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + /api/v1/themes: + get: + summary: List themes + description: A page of themes, without their token documents. + tags: + - themes + responses: + '200': + description: A page of themes. + content: + application/json: + schema: + $ref: '#/components/schemas/ThemePage' + headers: &id002 + Cache-Control: &id003 + description: '`no-store`. Administration responses are per-user and change on every write.' + schema: + type: string + X-Request-ID: &id004 + description: Correlation id for the server log. + schema: + type: string + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: q + in: query + required: false + description: Matches slug, name or description. LIKE wildcards are escaped, so a literal `%` matches + a literal `%`. + schema: + type: string + - name: status + in: query + required: false + description: Filter by publication state. + schema: &id005 + $ref: '#/components/schemas/ThemeStatus' + - name: system + in: query + required: false + description: Filter on the built-in flag. + schema: + type: boolean + - name: tag + in: query + required: false + description: Requires the tag in the metadata. + schema: + type: string + - name: limit + in: query + required: false + description: '' + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - name: offset + in: query + required: false + description: '' + schema: + type: integer + minimum: 0 + default: 0 + - name: sort + in: query + required: false + description: Resolved through an allow-list. An unrecognised value falls back to the default rather + than reaching the SQL statement. + schema: + type: string + enum: + - updated + - created + - name + - slug + default: updated + - name: order + in: query + required: false + description: '' + schema: + type: string + enum: + - asc + - desc + default: desc + security: + - sessionCookie: [] + - bearerToken: [] + post: + summary: Create a theme + description: 'Creates a **draft**. A draft may be incomplete: validation gates publication, not + editing, which is what makes building a theme up incrementally practical.' + tags: + - themes + responses: + '201': + description: The created theme. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '409': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ThemeMutation' + example: + name: Acme Product + slug: acme + description: The default theme for customer-facing applications + status: draft + tokens: + color: + semantic: + primary: + $type: color + $value: '#4f46e5' + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}: + get: + summary: Get a theme + description: The theme with its full token document. + tags: + - themes + responses: + '200': + description: The theme. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: + - sessionCookie: [] + - bearerToken: [] + patch: + summary: Update a theme + description: 'A partial update: absent fields are left unchanged. Every write bumps the version, + because the version is the render cache key and must change whenever the rendered output could. + + + `baseVersion` gives optimistic concurrency. Without it, two editors silently overwrite each other; + with it, the loser gets a 409 naming both versions.' + tags: + - themes + responses: + '200': + description: The updated theme. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '409': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ThemeMutation' + example: + tokens: + color: + semantic: + primary: + $type: color + $value: '#7c6bf5' + baseVersion: 3 + message: rebalanced the neutrals + security: + - sessionCookie: [] + - bearerToken: [] + delete: + summary: Delete a theme + description: 'Removes the theme and its history. Refused for a built-in theme with 409: built-ins + are immutable so that a consumer pinned to the slug cannot break on upgrade. Duplicate it instead.' + tags: + - themes + responses: + '204': + description: Deleted. + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '409': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/publish: + post: + summary: Publish a theme + description: 'Validates the token document first: errors block, warnings do not. On success the + theme is served to every consuming application within one `s-maxage` window. A built-in theme + can be published but not deleted.' + tags: + - themes + responses: + '200': + description: The published theme. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/archive: + post: + summary: Archive a theme + description: Hidden from listings and from the runtime manifest. The last published revision stays + resolvable for a pinned consumer, so archiving does not break a deployment in progress. + tags: + - themes + responses: + '200': + description: The archived theme. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/duplicate: + post: + summary: Duplicate a theme + description: Copies a theme under a new slug as a draft, with its own history starting at version + 1. The copy is no longer flagged as built-in, so it can then be deleted. + tags: + - themes + responses: + '201': + description: The copy. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '409': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The source theme slug. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - slug + properties: + slug: + type: string + description: The slug for the copy. + example: + slug: acme-v2 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/export: + get: + summary: Export a theme + description: A portable document. Import and export are exact inverses, so migrating a theme between + environments is a file copy rather than a project. + tags: + - themes + responses: + '200': + description: The export. + content: + application/json: + schema: + $ref: '#/components/schemas/ThemeExport' + headers: + Cache-Control: *id003 + X-Request-ID: *id004 + Content-Disposition: + description: A download filename. + schema: + type: string + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/import: + post: + summary: Import a theme + description: Creates or replaces a theme from a portable document. The document is validated before + anything is written, so a malformed import never reaches the database. + tags: + - themes + responses: + '201': + description: The imported theme. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '409': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ImportRequest' + example: + slug: acme + name: Acme + asDraft: true + tokens: {} + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/versions: + get: + summary: A theme's history + description: 'Summaries newest first, without token documents: a hundred revisions of a hundred-token + theme would otherwise be a very large response for no benefit.' + tags: + - history + responses: + '200': + description: The history. + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/ThemeVersion' + headers: *id002 + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + - name: limit + in: query + required: false + description: '' + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/versions/{version}: + get: + summary: A historical snapshot + description: One immutable revision, including its token document. + tags: + - history + responses: + '200': + description: The snapshot. + content: + application/json: + schema: + $ref: '#/components/schemas/ThemeVersion' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + - name: version + in: path + required: true + description: The revision number. + schema: + type: integer + minimum: 1 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/versions/{version}/rollback: + post: + summary: Roll back to a revision + description: Restores a snapshot as a **new** version rather than rewinding. History is append-only, + so an application deployed against version 7 can never have version 7 taken away from it. + tags: + - history + responses: + '200': + description: The theme after rollback. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + - name: version + in: path + required: true + description: The revision to restore. + schema: + type: integer + minimum: 1 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/diff: + get: + summary: Diff two revisions + description: A token-level comparison. Only the token tree is compared; a metadata change is reported + as a boolean rather than field by field. + tags: + - history + responses: + '200': + description: The differences. + content: + application/json: + schema: + $ref: '#/components/schemas/VersionDiff' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + - name: from + in: query + required: true + description: The base revision. + schema: + type: integer + minimum: 1 + - name: to + in: query + required: true + description: The comparison revision. + schema: + type: integer + minimum: 1 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/tokens/validate: + post: + summary: Validate a token document + description: Runs the engine's validator without persisting anything. The editor calls it as the + user works, which is exactly why it must not write. + tags: + - tokens + responses: + '200': + description: The findings. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationResult' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - tokens + properties: + tokens: &id006 + $ref: '#/components/schemas/TokenDocument' + example: + tokens: + color: + semantic: + primary: + $type: color + $value: '#4f46e5' + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/tokens/contrast: + get: + summary: Measure WCAG contrast + description: Reports the ratio and the highest conformance level it satisfies. The editor uses this + to flag an unreadable combination before it is saved rather than after it is published. + tags: + - tokens + responses: + '200': + description: The measurement. + content: + application/json: + schema: + $ref: '#/components/schemas/ContrastResult' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: fg + in: query + required: true + description: The foreground colour. + schema: + type: string + - name: bg + in: query + required: true + description: The background colour. + schema: + type: string + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/tokens/palette: + get: + summary: Generate a colour ramp + description: Produces the canonical 11-step ramp for a hue. Exposed so the editor's palette builder + and the built-in themes cannot diverge from the engine's ramp curve. + tags: + - tokens + responses: + '200': + description: The ramp. + content: + application/json: + schema: + $ref: '#/components/schemas/RampResponse' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: hue + in: query + required: false + description: Hue in degrees. + schema: + type: number + minimum: 0 + maximum: 360 + default: 250 + - name: saturation + in: query + required: false + description: Saturation at the mid steps, percent. + schema: + type: number + minimum: 0 + maximum: 100 + default: 78 + - name: lightness + in: query + required: false + description: Lightness at step 500, percent. + schema: + type: number + minimum: 20 + maximum: 80 + default: 55 + - name: hueSpread + in: query + required: false + description: Hue drift from the lightest to the darkest step. A value of 12 reproduces the warm-to-cool + lean of a modern perceptual ramp. + schema: + type: number + minimum: 0 + maximum: 40 + default: 12 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/tokens/schema: + get: + summary: The token schema + description: The group and kind registry, so a client renders the right control per token type instead + of duplicating a list that would drift. + tags: + - tokens + responses: + '200': + description: The schema. + content: + application/json: + schema: + $ref: '#/components/schemas/TokenSchema' + headers: + Cache-Control: + description: Cacheable. + schema: + type: string + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/admin/users: + get: + summary: List known accounts + description: Accounts seen at least once at login. Roles are overwritten from the provider on every + login rather than merged, so a user removed from a group actually loses access. + tags: + - admin + responses: + '200': + description: The users. + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/User' + headers: *id002 + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: limit + in: query + required: false + description: '' + schema: + type: integer + minimum: 1 + maximum: 500 + default: 100 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/admin/audit: + get: + summary: Read the activity log + description: Every mutation in the administration API. + tags: + - admin + responses: + '200': + description: A page of entries. + content: + application/json: + schema: + $ref: '#/components/schemas/AuditPage' + headers: *id002 + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: actor + in: query + required: false + description: Filter by actor. + schema: + type: string + - name: action + in: query + required: false + description: Filter by action. + schema: + type: string + - name: entityType + in: query + required: false + description: '' + schema: + type: string + - name: entityId + in: query + required: false + description: '' + schema: + type: string + - name: since + in: query + required: false + description: '' + schema: + type: string + format: date-time + - name: limit + in: query + required: false + description: '' + schema: + type: integer + minimum: 1 + maximum: 500 + default: 50 + - name: offset + in: query + required: false + description: '' + schema: + type: integer + minimum: 0 + default: 0 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/admin/settings: + get: + summary: Read instance settings + description: The settings the engine reads at run time. Everything requiring a restart is environment + configuration and belongs in the Helm chart, not in a database row. + tags: + - admin + responses: + '200': + description: The settings. + content: + application/json: + schema: + type: object + properties: + items: + type: object + additionalProperties: + $ref: '#/components/schemas/Setting' + headers: *id002 + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: + - sessionCookie: [] + - bearerToken: [] + put: + summary: Write instance settings + description: Writing the default theme clears the render cache, because it is baked into the runtime + manifest. Unrecognised keys are rejected rather than silently stored. + tags: + - admin + responses: + '200': + description: The keys written. + content: + application/json: + schema: + type: object + properties: + updated: + type: array + items: + type: string + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: + type: string + description: 'Setting keys to values. Recognised keys: `default.theme`, `default.appearance`, + `runtime.public`, `runtime.apiBase`.' + example: + default.theme: midnight + default.appearance: dark + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/admin/cache/rebuild: + post: + summary: Rebuild the render cache + description: 'Clears and re-warms it. Useful after a bulk change, or to reclaim memory: the cache + holds every published theme multiplied by appearance and variant.' + tags: + - admin + responses: + '200': + description: What was warmed. + content: + application/json: + schema: + type: object + properties: + themes: + type: integer + artefacts: + type: integer + headers: *id002 + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/stats: + get: + summary: Dashboard counters + description: Theme, key, user and audit counts, plus the render cache hit rate. + tags: + - themes + responses: + '200': + description: The counters. + content: + application/json: + schema: + $ref: '#/components/schemas/Stats' + headers: *id002 + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: + - sessionCookie: [] + - bearerToken: [] + /healthz: + get: + summary: Liveness, with build information + description: Does **not** touch the database, deliberately. A liveness probe that failed on a slow + database would have the orchestrator restart a process that is otherwise healthy, turning a dependency + blip into an outage. + tags: + - operational + responses: + '200': + description: Healthy. + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + '500': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] + /livez: + get: + summary: Liveness + description: Answers as long as the process can serve HTTP. + tags: + - operational + responses: + '200': + description: Healthy. + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + '500': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] + /readyz: + get: + summary: Readiness + description: Reports whether the service can actually serve traffic, which means the database must + answer within a bounded time. Warns when the database is in-memory, which is legitimate in a test + and never in production. + tags: + - operational + responses: + '200': + description: Ready. + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + headers: *id002 + '503': + description: Not ready. + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + security: [] + /metrics: + get: + summary: Prometheus metrics + description: Request counts labelled by **route pattern** rather than concrete path — a thousand + distinct theme slugs must not become a thousand time series — a latency histogram, the render + cache hit rate, and Go runtime gauges. Disabled when `config.enableMetrics` is false. + tags: + - operational + responses: + '200': + description: The exposition text. + content: + text/plain: + schema: + type: string + headers: *id002 + security: [] + /rt/v1/embed-page: + get: + summary: A shareable integration page + description: 'A complete HTML page showing the snippet for a theme next to a live preview of it. + Separate from /rt/v1/embed because the two have different callers: this one is a page a human + opens, that one is data a UI fetches. Every code sample on the page comes from the same builders, + so what is copied here is byte-for-byte what the admin UI shows.' + tags: + - runtime + parameters: + - name: slug + in: query + required: false + description: The theme to show. + schema: + type: string + responses: + '200': + description: The page. + content: + text/html: + schema: + type: string + '400': + description: Invalid slug. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: No such theme. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] +components: + securitySchemes: + sessionCookie: + type: apiKey + in: cookie + name: go-bananas-session + description: An HMAC-signed session cookie. Set `HttpOnly`, `Secure` and `SameSite`. Issued by the + OIDC login flow. There is no server-side session table, so any replica can authenticate any request. + bearerToken: + type: http + scheme: bearer + description: An API key. May also be supplied as a `token` query parameter, because a stylesheet + link cannot carry a header. Stored as a SHA-256 digest. + schemas: + ApiError: + type: object + description: 'The uniform error body. `code` is stable and part of the contract: clients branch + on it, so its meaning must not change between releases.' + required: + - code + - message + properties: + code: + type: string + description: A stable machine-readable code. + examples: + - validation_failed + message: + type: string + description: A human-readable explanation. + details: + type: array + items: + $ref: '#/components/schemas/FieldError' + requestId: + type: string + description: Correlates the response with the server log. + FieldError: + type: object + required: + - field + - message + properties: + field: + type: string + description: The JSON path or dotted token path. + message: + type: string + Token: + type: object + required: + - $value + description: A single token leaf, in the W3C Design Tokens Community Group format. + properties: + $type: + type: string + description: The token type. + examples: + - color + - dimension + - fontSize + - cubicBezier + $value: + description: 'The value: a string, number, boolean or number array.' + oneOf: + - type: string + - type: integer + - type: boolean + - type: array + items: + type: integer + $description: + type: string + TokenDocument: + type: object + additionalProperties: true + description: A design-token document. `$appearances` holds a partial document per colour scheme, + deep-merged over the base, so a token absent from the dark overrides simply inherits its light + value. + properties: + $schema: + type: string + $appearances: + type: object + additionalProperties: true + ThemeStatus: + type: string + enum: + - draft + - published + - archived + description: Publication state. Only `published` is served to the runtime. + Appearance: + type: string + enum: + - light + - dark + description: Colour scheme. + Theme: + type: object + required: + - id + - slug + - name + - status + - defaultAppearance + - tokens + - system + - version + - createdAt + - updatedAt + properties: + id: + type: string + slug: + type: string + pattern: ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$ + name: + type: string + description: + type: string + status: *id005 + defaultAppearance: &id007 + $ref: '#/components/schemas/Appearance' + tokens: *id006 + meta: + type: object + additionalProperties: true + description: Opaque to the engine apart from `tags`. + system: + type: boolean + description: A built-in theme. Cannot be deleted. + version: + type: integer + minimum: 1 + description: Bumped on every mutation. The render cache key. + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + createdBy: + type: string + updatedBy: + type: string + ThemeSummary: + type: object + description: 'A theme without its token document. Listings never include them: they are the bulk + of each row and a listing does not need them.' + properties: + id: + type: string + slug: + type: string + name: + type: string + description: + type: string + status: *id005 + defaultAppearance: *id007 + meta: + type: object + additionalProperties: true + system: + type: boolean + version: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + createdBy: + type: string + updatedBy: + type: string + ThemePage: + type: object + required: + - items + - total + - limit + - offset + properties: + items: + type: array + items: + $ref: '#/components/schemas/ThemeSummary' + total: + type: integer + limit: + type: integer + offset: + type: integer + ThemeMutation: + type: object + description: Create or update. Absent fields are left unchanged on update. + properties: + slug: + type: string + pattern: ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$ + name: + type: string + description: + type: string + status: *id005 + defaultAppearance: *id007 + tokens: *id006 + meta: + type: object + additionalProperties: true + message: + type: string + description: Recorded in the version history. + baseVersion: + type: integer + description: Optimistic concurrency. The write is rejected with 409 when the stored version + differs, which stops two editors silently overwriting each other. + ThemeVersion: + type: object + description: 'An immutable snapshot. History is append-only: a rollback restores a snapshot as a + new version rather than rewinding, so a deployed application can never lose the revision it was + built against.' + required: + - themeId + - version + - slug + - name + - createdAt + properties: + themeId: + type: string + version: + type: integer + slug: + type: string + name: + type: string + description: + type: string + status: *id005 + defaultAppearance: *id007 + tokens: *id006 + message: + type: string + author: + type: string + createdAt: + type: string + format: date-time + VersionDiff: + type: object + required: + - fromVersion + - toVersion + - added + - removed + - changed + properties: + fromVersion: + type: integer + toVersion: + type: integer + added: + type: array + items: + type: string + removed: + type: array + items: + type: string + changed: + type: array + items: + $ref: '#/components/schemas/TokenDiff' + metaChanged: + type: boolean + TokenDiff: + type: object + required: + - path + - from + - to + properties: + path: + type: string + from: + type: string + to: + type: string + ValidationIssue: + type: object + required: + - path + - severity + - message + properties: + path: + type: string + severity: + type: string + enum: + - error + - warning + - info + message: + type: string + hint: + type: string + ValidationResult: + type: object + required: + - valid + - tokenCount + - issues + properties: + valid: + type: boolean + description: False when any issue is an error. Errors block publication; warnings do not. + tokenCount: + type: integer + issues: + type: array + items: + $ref: '#/components/schemas/ValidationIssue' + ContrastResult: + type: object + required: + - foreground + - background + - ratio + - level + properties: + foreground: + type: string + background: + type: string + ratio: + type: number + rounded: + type: number + level: + type: string + enum: + - fail + - aa-large + - aa + - aaa + thresholds: + type: object + properties: + aaLarge: + type: number + aa: + type: number + aaa: + type: number + bestForeground: + type: string + RampResponse: + type: object + description: A generated colour ramp. + properties: + options: + type: object + properties: + Hue: + type: number + Saturation: + type: number + Lightness: + type: number + HueSpread: + type: number + shades: + type: array + items: + type: string + ramp: + type: object + additionalProperties: + $ref: '#/components/schemas/Token' + TokenSchema: + type: object + description: The engine's group and kind registry, so a client renders the right control per token + type instead of duplicating a list that would drift. + properties: + id: + type: string + prefix: + type: string + kinds: + type: array + items: + type: string + groups: + type: array + items: + type: object + properties: + name: + type: string + kind: + type: string + description: + type: string + recommended: + type: array + items: + type: string + shades: + type: array + items: + type: string + ImportRequest: + type: object + required: + - slug + - tokens + properties: + slug: + type: string + name: + type: string + tokens: *id006 + meta: + type: object + additionalProperties: true + asDraft: + type: boolean + default: true + description: Import as a draft, so an import can never publish something by accident. + overwrite: + type: boolean + description: Replace an existing theme. Without it, an existing slug is a 409 rather than a + silent overwrite. + message: + type: string + ThemeExport: + type: object + description: The portable document. Import and export are exact inverses, so migrating a theme between + environments is a file copy rather than a project. + properties: + $schema: + type: string + slug: + type: string + name: + type: string + status: *id005 + version: + type: integer + tokens: *id006 + meta: + type: object + additionalProperties: true + exportedAt: + type: string + format: date-time + exportedBy: + type: string + Session: + type: object + description: 'The current session. Always answers 200: an absent session is the normal state on + first load, not an error the UI should have to handle as one.' + required: + - authenticated + - mode + properties: + authenticated: + type: boolean + mode: + type: string + enum: + - oidc + - dev + - none + identity: + type: object + properties: + subject: + type: string + email: + type: string + name: + type: string + roles: + type: array + items: + type: string + enum: + - viewer + - editor + - admin + method: + type: string + expiresAt: + type: string + format: date-time + permissions: + type: object + additionalProperties: + type: boolean + description: A UI convenience only. The server enforces every check, so hiding a control is + never the control. + preferences: + $ref: '#/components/schemas/UserPreferences' + description: Present once authenticated. Reported with the session so the UI can preselect a + theme without a second request. + devBypass: + type: boolean + loginUrl: + type: string + UserPreferences: + type: object + description: 'Per-account interface preferences. Both fields empty means nothing has been chosen, + which is a valid state rather than a missing one: the client then applies its own default.' + properties: + selectedTheme: + type: string + description: A published theme slug, or empty for no choice. Names the theme rather than copying + it, so editing or unpublishing that theme shows through everywhere. A stored value naming + a theme that has since been deleted or archived reads back as empty. + selectedAppearance: + $ref: '#/components/schemas/Appearance' + description: Which of the theme''s two colour schemes to render. Ignored, and dropped on write, + when selectedTheme is empty. + AppConfig: + type: object + description: What the UI needs before a session exists. Contains no secret material. + properties: + mode: + type: string + issuer: + type: string + runtimeBase: + type: string + publicBase: + type: string + basePath: + type: string + runtimeApiVersion: + type: string + variablePrefix: + type: string + defaultTheme: + type: string + User: + type: object + properties: + subject: + type: string + email: + type: string + name: + type: string + roles: + type: array + items: + type: string + method: + type: string + firstSeen: + type: string + format: date-time + lastLogin: + type: string + format: date-time + AuditEntry: + type: object + description: One recorded action. The actor comes from the authenticated identity, never from the + request body. + required: + - id + - at + - actor + - action + properties: + id: + type: integer + at: + type: string + format: date-time + actor: + type: string + actorEmail: + type: string + action: + type: string + enum: + - theme.create + - theme.update + - theme.delete + - theme.publish + - theme.archive + - theme.duplicate + - theme.rollback + - theme.import + - key.create + - key.revoke + - auth.login + - auth.logout + entityType: + type: string + entityId: + type: string + summary: + type: string + detail: + type: object + additionalProperties: true + remoteIp: + type: string + AuditPage: + type: object + required: + - items + - total + - limit + - offset + properties: + items: + type: array + items: + $ref: '#/components/schemas/AuditEntry' + total: + type: integer + limit: + type: integer + offset: + type: integer + Setting: + type: object + required: + - key + - value + - updatedAt + properties: + key: + type: string + value: + type: string + updatedAt: + type: string + format: date-time + updatedBy: + type: string + Stats: + type: object + properties: + store: + type: object + properties: + themes: + type: integer + themesByStatus: + type: object + additionalProperties: + type: integer + users: + type: integer + auditEntries: + type: integer + databaseSizeBytes: + type: integer + versionsRetaind: + type: integer + cache: + type: object + properties: + CSSHits: + type: integer + CSSMisses: + type: integer + JSONHits: + type: integer + JSONMisses: + type: integer + Entries: + type: integer + ResolvedTheme: + type: object + description: A theme with every token resolved for one appearance, including derived tokens, because + consumers should not reimplement the derivation. + required: + - theme + - tokens + - variables + properties: + theme: + type: object + properties: + slug: + type: string + name: + type: string + description: + type: string + version: + type: integer + appearance: *id007 + defaultAppearance: *id007 + updatedAt: + type: string + format: date-time + tokens: *id006 + variables: + type: object + additionalProperties: + type: string + description: Flat CSS custom property name to rendered value. + meta: + type: object + additionalProperties: true + ManifestEntry: + type: object + required: + - slug + - name + - defaultAppearance + - version + - css + - tokens + properties: + slug: + type: string + name: + type: string + description: + type: string + defaultAppearance: *id007 + version: + type: integer + tags: + type: array + items: + type: string + css: + type: string + format: uri + tokens: + type: string + format: uri + Manifest: + type: object + description: 'The discovery document a loader fetches once to build a theme picker. Deliberately + small: no token data, so a page with a picker does not download every theme.' + required: + - apiVersion + - baseUrl + - themes + properties: + apiVersion: + type: string + default: + type: string + baseUrl: + type: string + format: uri + themes: + type: array + items: + $ref: '#/components/schemas/ManifestEntry' + Health: + type: object + properties: + status: + type: string + enum: + - ok + - ready + - not ready + version: + type: string + uptime: + type: string + checks: + type: object + additionalProperties: true + EmbedSnippet: + type: object + description: A ready-to-paste integration snippet for one theme. + required: + - slug + - name + - version + - baseUrl + - language + - snippet + properties: + slug: + type: string + description: 'The theme, which exists: the endpoint validates it.' + name: + type: string + description: The theme's display name. + version: + type: integer + description: The version the snippet was generated against. + baseUrl: + type: string + description: The runtime base URL baked into the snippet. + language: + type: string + enum: + - html + - js + - tsx + description: The syntax of the snippet. + snippet: + type: string + description: The code to paste. Values interpolated into it are escaped for the language. + note: + type: string + description: Anything else the integrator needs to know. +security: +- sessionCookie: [] +- bearerToken: [] diff --git a/sdk/eslint.config.js b/sdk/eslint.config.js new file mode 100644 index 0000000..efa3741 --- /dev/null +++ b/sdk/eslint.config.js @@ -0,0 +1,95 @@ +import js from '@eslint/js' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +/** + * ESLint configuration for the runtime SDK. + * + * The SDK is written in JavaScript with JSDoc types rather than TypeScript, + * because it is served as source to be read as well as to be executed. That + * choice drives the rule set: + * + * - The rules designed for a `.ts` boundary (`explicit-module-boundary-types`) + * are redundant here: a JSDoc `@returns` annotation is the same information, + * and the linter cannot see it as an annotation. + * - The rules that catch real defects — floating promises, misused promises, + * unreachable switch cases, unused variables — stay, and are errors. + * - `no-console` is an error rather than a warning, because this module runs in + * someone else's page and must not write to their console on a path they did + * not ask for. The two exceptions below are deliberate diagnostics that only + * fire when the theme itself has already failed. + * + * The SDK is loaded by third-party pages, so anything it gets wrong is somebody + * else's incident. The strictness here is proportionate to that. + */ +export default tseslint.config( + { ignores: ['dist', 'node_modules', '*.config.js'] }, + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + { + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + globals: { ...globals.browser, ...globals.es2022 }, + }, + plugins: { '@typescript-eslint': tseslint.plugin }, + rules: { + // JSDoc already declares every boundary type in a JavaScript project. + '@typescript-eslint/explicit-module-boundary-types': 'off', + // A fetch stub that returns a Response directly has nothing to await. + '@typescript-eslint/require-await': 'off', + // `||` is deliberate where an empty string should fall through, which is + // not what `??` does. + '@typescript-eslint/prefer-nullish-coalescing': 'off', + + // --- correctness ----------------------------------------------------- + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/switch-exhaustiveness-check': 'error', + // Disabled in this package. The DOM type definitions describe a fully + // featured modern browser, but the runtime guards below exist for Safari + // below 14 (no `MediaQueryList.addEventListener`) and for embedded + // webviews with no `matchMedia` at all. Those guards are load-bearing for + // the consumers this package exists to serve, and the rule reports them as + // dead code. + '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, + ], + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }, + ], + + // --- host safety ----------------------------------------------------- + 'no-console': ['error', { allow: ['warn', 'error'] }], + eqeqeq: ['error', 'always', { null: 'ignore' }], + 'prefer-const': 'error', + 'no-var': 'error', + 'object-shorthand': 'error', + // An unsubscribe function that does nothing is the correct return value + // when there is nothing to unsubscribe from. + '@typescript-eslint/no-empty-function': [ + 'error', + { allow: ['arrowFunctions'] }, + ], + }, + }, + { + // Tests build doubles that are deliberately loosely typed, and a test's + // console output is how a failure is diagnosed. + files: ['**/*.test.js'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/unbound-method': 'off', + 'no-console': 'off', + }, + }, +) \ No newline at end of file diff --git a/sdk/package-lock.json b/sdk/package-lock.json new file mode 100644 index 0000000..9660554 --- /dev/null +++ b/sdk/package-lock.json @@ -0,0 +1,2981 @@ +{ + "name": "@go-bananas/sdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@go-bananas/sdk", + "version": "1.0.0", + "license": "SEE LICENSE IN ../LICENSE", + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/react": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "eslint": "10.12.0", + "globals": "17.13.0", + "jsdom": "30.1.2", + "react": "19.3.0", + "react-dom": "19.3.0", + "typescript": "6.0.3", + "typescript-eslint": "8.71.0", + "vite": "8.3.2", + "vitest": "5.0.3" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", + "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.2", + "@csstools/css-color-parser": "^4.2.5", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsdom": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.11.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..a08bd6c --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,36 @@ +{ + "name": "@go-bananas/sdk", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Runtime theme delivery SDKs served by Go Bananas!!. Built, not published: consumers load these from the service origin.", + "license": "SEE LICENSE IN ../LICENSE", + "scripts": { + "build": "vite build", + "dev": "vite build --watch --mode development", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "peerDependencies": { + "react": ">=18" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/react": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "eslint": "10.12.0", + "globals": "17.13.0", + "jsdom": "30.1.2", + "react": "19.3.0", + "react-dom": "19.3.0", + "typescript": "6.0.3", + "typescript-eslint": "8.71.0", + "vite": "8.3.2", + "vitest": "5.0.3" + }, + "engines": { + "node": ">=22" + } +} diff --git a/sdk/src/components.js b/sdk/src/components.js new file mode 100644 index 0000000..bd4ed8b --- /dev/null +++ b/sdk/src/components.js @@ -0,0 +1,731 @@ +/** + * The two shipped controls: a theme selector and an appearance toggle. + * + * They are custom elements rather than framework components on purpose. The + * applications this service feeds are a mix of vanilla JS, React and Tailwind, and a + * React component cannot be used by the first while a plain custom element works in + * all three with no build step and no import map. + * + * Both render into a shadow root, which is what makes them safe to inject into + * somebody else's page: + * + * - The page's CSS cannot reach inside, so a global `button { all: unset }` or a + * Tailwind preflight cannot restyle them. + * - Their CSS cannot leak out, so they cannot restyle the page. This is the same + * property that makes a live preview of a theme safe: the preview's styles stay in + * the preview. + * - Theming still works, because a shadow root inherits the custom properties that + * cross the boundary. `--bn-bg` set on is visible inside, so the controls + * follow the palette they are switching between without a single extra rule. + * + * Styling hooks: `::part(button)`, `::part(list)` and so on, plus the `--bn-control-*` + * custom properties below. Nothing else is reachable, which is the point. + * + * ```html + * + * + * + * ``` + * + * @module go-bananas/components + */ + +import { + ADMIN_MODE, + ContractState, + DEFAULT_STORAGE_KEY, + getContractState, + LEGACY_MODE_ATTRIBUTE, + LEGACY_THEME_ATTRIBUTE, + MODE_ATTRIBUTE, + MODES, + readMode, + resetContractState, + THEME_ATTRIBUTE, +} from './contract.js' + +/** + * The base the selector falls back to. + * + * It is only a fallback: a page that has already linked the contract stylesheet has + * said where the service is, and the component reads it from there rather than being + * configured a second time. Two places to set the same value is two places to get + * wrong. + */ +const DEFAULT_BASE = '/rt/v1' + +/** The marker attribute on the contract stylesheet the selector looks for. */ +const CONTRACT_LINK_MARKER = 'data-theme-contract' + +/** + * Finds the runtime base from the contract stylesheet already on the page. + * + * @returns {string|null} The directory, or null when no link was found. + */ +function baseFromContractLink() { + if (typeof document === 'undefined') return null + + // The marker attribute is preferred, because it is unambiguous. A link whose href + // ends in contract.css is accepted as a fallback so a page that predates the marker + // still works. + let link = document.querySelector(`link[${CONTRACT_LINK_MARKER}]`) + if (!link) { + for (const candidate of document.querySelectorAll('link[rel~="stylesheet"][href]')) { + const href = candidate.getAttribute('href') || '' + if (href.endsWith('contract.css')) { + link = candidate + break + } + } + } + if (!link) return null + + const href = link.getAttribute('href') + if (!href) return null + + // Strip the stylesheet filename, leaving the runtime directory. + const withoutFile = href.replace(/\/[^/]*$/, '') + return withoutFile === href ? null : withoutFile +} + +/** + * Constructed contract stylesheets, cached per contract URL. + * + * A stylesheet in the document does not select into a shadow root, so the selector + * cannot borrow the one the page already linked. It has to fetch the contract itself + * and adopt it. Constructed sheets are shareable, so one fetch and one parse serve + * every control on the page. + * + * @type {Map>} + */ +const contractSheets = new Map() + +/** + * Fetches the contract and parses it into a stylesheet the controls can adopt. + * + * Returns null rather than throwing when the contract cannot be adopted. The palette + * names still come from `/contract.json`, so a control with no stylesheet is a + * working text-only selector rather than a broken one, and that is the right trade: + * the preview is the thing that degrades, not the ability to pick a theme. + * + * @param {string} base + * @returns {Promise} + */ +async function adoptContractSheet(base) { + const url = `${base}/contract.css` + // The capability is probed by name rather than by reading the method off the + // prototype: holding the function would detach it from the constructor it belongs + // to. `in` asks the same question without a value to misuse. + const supported = + typeof CSSStyleSheet !== 'undefined' && + 'replaceSync' in CSSStyleSheet.prototype && + typeof fetch === 'function' + if (!supported) return null + + let pending = contractSheets.get(url) + if (!pending) { + pending = (async () => { + try { + const response = await fetch(url, { credentials: 'omit' }) + if (!response.ok) throw new Error(`contract.css responded ${response.status}`) + const css = await response.text() + const sheet = new CSSStyleSheet() + // The contract is generated, not authored, so there is nothing to await a + // progressive render for. replaceSync also keeps adoption off the microtask + // queue, which is what stops the preview flashing unstyled. + sheet.replaceSync(css) + return sheet + } catch { + return null + } + })() + contractSheets.set(url, pending) + } + + const sheet = await pending + if (!sheet) { + // Only failures are evicted, so a control mounted later can retry a contract that + // was momentarily unavailable. Concurrent controls still share the one attempt, + // because they were all awaiting the same promise. + contractSheets.delete(url) + return null + } + return sheet +} + +/** + * The shadow stylesheet for both controls. + * + * Colours come from the contract custom properties with a literal fallback for each, + * so the controls are legible even on a page that has not loaded contract.css. The + * fallbacks are the light-mode values: a control that has to guess should guess + * legibly rather than guess dark text on a dark background. + */ +const STYLES = ` + :host { + /* Overridable by the host application without reaching into the shadow root. */ + --bn-control-radius: 8px; + --bn-control-gap: 2px; + --bn-control-padding: 6px 10px; + --bn-control-font: 13px/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif; + + display: inline-block; + font: var(--bn-control-font); + color: var(--bn-text, #0f172a); + /* The control is a control, so it does not inherit the page's text colour. */ + -webkit-font-smoothing: antialiased; + } + + :host([hidden]) { display: none; } + + /* A segmented control: one bordered group with an inset selected segment. Built + from a plain flex row rather than a list so there is no marker semantics to + suppress, and so it reads the same in every engine. */ + .group { + display: inline-flex; + gap: var(--bn-control-gap); + padding: 2px; + background: var(--bn-surface-sunken, #eef2f6); + border: 1px solid var(--bn-border, #e2e8f0); + border-radius: var(--bn-control-radius); + } + + button { + appearance: none; + font: inherit; + margin: 0; + cursor: pointer; + padding: var(--bn-control-padding); + color: var(--bn-muted, #475569); + background: transparent; + border: 0; + border-radius: calc(var(--bn-control-radius) - 2px); + white-space: nowrap; + transition: background-color 120ms ease, color 120ms ease; + } + + button:hover { color: var(--bn-text, #0f172a); } + + button:focus-visible { + outline: 2px solid var(--bn-focus, #2563eb); + outline-offset: 1px; + } + + button[aria-pressed='true'], + button[aria-checked='true'] { + background: var(--bn-surface, #fff); + color: var(--bn-text, #0f172a); + /* A border rather than a shadow: it survives a forced-colours mode where a + shadow is not painted. */ + box-shadow: inset 0 0 0 1px var(--bn-border, #e2e8f0); + } + + button[disabled] { cursor: not-allowed; opacity: 0.5; } + + label { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + /* The palette preview: a miniature of the application, not a row of colour chips. + Every colour is read from the contract, so what the preview shows is what the + theme does -- there is no hardcoded palette here to fall out of step with the + server. A chip of the accent colour was tried first and told you almost nothing: + two themes sharing an accent looked identical, which is the case the preview + exists to resolve. */ + .preview { + display: block; + flex: none; + width: 28px; + height: 18px; + overflow: hidden; + border-radius: 3px; + background: var(--bn-bg, #fff); + box-shadow: inset 0 0 0 1px var(--bn-border, #e2e8f0); + } + + /* A title bar, then a body with two rules of text and an accent control: enough + surface that the palette's surface, border and text all show at once. */ + .preview-bar { + display: block; + height: 5px; + background: var(--bn-surface, #f8fafc); + border-bottom: 1px solid var(--bn-border, #e2e8f0); + } + + .preview-body { + display: flex; + align-items: center; + gap: 2px; + padding: 2px; + } + + .preview-rule { + display: block; + height: 2px; + flex: 1; + border-radius: 1px; + background: var(--bn-text, #0f172a); + /* Faded rather than a second colour: the miniature has to work on a palette whose + muted role is very close to its text role. */ + opacity: 0.4; + } + + .preview-accent { + display: block; + flex: none; + width: 9px; + height: 4px; + border-radius: 1px; + background: var(--bn-accent, #4f46e5); + } + + .spinner { opacity: 0.6; } +` + +/** + * A control's shared behaviour: shadow root, styles, subscription, attributes. + * + * @abstract + */ +class BananasControl extends HTMLElement { + constructor() { + super() + this._root = this.attachShadow({ mode: 'open' }) + const style = document.createElement('style') + style.textContent = STYLES + this._root.append(style) + this._unsub = null + } + + /** @returns {ShadowRoot} */ + get shadowRoot_() { + return this._root + } + + connectedCallback() { + this._unsub = getContractState().subscribe((state) => this._render(state)) + } + + disconnectedCallback() { + if (this._unsub) this._unsub() + this._unsub = null + } + + /** + * @param {string} name + * @param {string} [fallback] + * @returns {string} + */ + _attr(name, fallback = '') { + const value = this.getAttribute(name) + return typeof value === 'string' ? value : fallback + } + + /** + * @param {import('./contract.js').Selection} _state + * @returns {void} + */ + _render(_state) { + throw new Error('not implemented') + } + + /** + * Fires a bubbling composed event so a host framework can react without having to + * reach into the shadow root. + * + * @param {string} type + * @param {Record} detail + * @returns {void} + */ + _emit(type, detail) { + this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true })) + } +} + +/** + * Narrows the contract payload's theme list to what the control can render. + * + * @param {unknown} raw + * @returns {Array<{slug: string, name: string, defaultMode: string}>} + */ +function normaliseThemes(raw) { + if (!Array.isArray(raw)) return [] + const out = [] + for (const entry of raw) { + if (typeof entry !== 'object' || entry === null) continue + /** @type {unknown} */ + const asUnknown = entry + const item = /** @type {Record} */ (asUnknown) + if (typeof item.slug !== 'string' || item.slug === '') continue + out.push({ + slug: item.slug, + name: typeof item.name === 'string' ? item.name : item.slug, + defaultMode: item.defaultMode === 'light' ? 'light' : 'dark', + }) + } + return out +} + +/** + * Builds the miniature that previews a palette. + * + * The palette is carried on the element as an attribute rather than applied from the + * component, because that is the same mechanism the application uses. The preview is + * then styled by the real contract rules, which is the only way it can be honest: if + * the server changes how a palette is built, the preview changes with it instead of + * disagreeing with the page it is previewing. + * + * @param {{slug: string, defaultMode: string}} theme + * @returns {HTMLSpanElement} + */ +function previewFor(theme) { + const preview = document.createElement('span') + preview.className = 'preview' + preview.setAttribute('part', 'preview') + // The palette name is the button's text and therefore already announced. Announcing + // the picture as well would read the same theme twice. + preview.setAttribute('aria-hidden', 'true') + preview.setAttribute(THEME_ATTRIBUTE, theme.slug) + preview.setAttribute(MODE_ATTRIBUTE, theme.defaultMode) + + const bar = document.createElement('span') + bar.className = 'preview-bar' + const body = document.createElement('span') + body.className = 'preview-body' + const rule = document.createElement('span') + rule.className = 'preview-rule' + const accent = document.createElement('span') + accent.className = 'preview-accent' + body.append(rule, accent) + preview.append(bar, body) + return preview +} + +/** + * `` — picks the palette. + * + * It reads the palette list from `/contract.json` on the service, so a host + * application publishes a theme and it appears here with no code change on either side. + * + * Each palette is previewed with a miniature of the application styled by the contract. + * The contract itself is fetched separately and adopted into the shadow root, because a + * stylesheet in the document does not select into a shadow root. When it cannot be + * fetched the selector still works, listing palette names only. + * + * Attributes: + * base service runtime base; defaults to /rt/v1 + * label accessible label; defaults to "Theme" + * compact omit the "No theme" entry when present + * no-preview list palette names without the miniature + * + * Properties (for framework interop): + * value the selected slug, or '' + * + * Events: `bananas:theme` with `detail: { slug, mode }`. + */ +export class BananasThemeSelector extends BananasControl { + /** @type {Array<{slug: string, name: string, defaultMode: string}>} */ + _themes = [] + + /** @type {'idle'|'loading'|'ready'|'error'} */ + _status = 'idle' + + /** @type {CSSStyleSheet|null} */ + _sheet = null + + /** @override */ + connectedCallback() { + // The DOM is built before subscribing. Subscription renders immediately, so + // subscribing first would render into a group that does not exist yet and the + // control would come up empty until something else changed the store. + this._root.append( + (this._label = document.createElement('label')), + (this._group = document.createElement('div')), + ) + this._label.className = 'sr' + this._label.textContent = this._attr('label', 'Theme') + this._group.className = 'group' + this._group.setAttribute('part', 'group') + this._group.setAttribute('role', 'radiogroup') + this._group.setAttribute('aria-label', this._label.textContent) + super.connectedCallback() + // Not awaited: connectedCallback cannot be async, and the control renders a + // loading state until the store resolves. The method handles its own failures. + void this._load() + } + + /** @returns {string} */ + get value() { + return getContractState().state.theme || '' + } + + set value(slug) { + getContractState().selectTheme(slug || null) + } + + /** @returns {Promise} */ + async _load() { + if (this._status === 'loading' || this._status === 'ready') return + this._status = 'loading' + + // The explicit attribute wins; otherwise the contract stylesheet on the page says + // where the service is; otherwise the same-origin default. + const configured = this.getAttribute('base') + const base = (configured || baseFromContractLink() || DEFAULT_BASE).replace(/\/$/, '') + // The contract is adopted alongside the palette list rather than before it. Waiting + // for it would delay the control for a decoration, and the palette names are what + // the control is actually for. + void this._adoptContract(base) + try { + const response = await fetch(`${base}/contract.json`, { credentials: 'omit' }) + if (!response.ok) throw new Error(`contract.json responded ${response.status}`) + // json() is untyped, so the payload is narrowed here rather than trusted: a + // contract document from a mismatched build must not throw inside the render. + // The value is landed in an `unknown`-typed variable first. `json()` returns + // `any`, and assigning an `any` straight into a typed position is exactly the + // hole no-unsafe-assignment exists to close. + /** @type {unknown} */ + const body = await response.json() + const payload = /** @type {{themes?: unknown}} */ (body) + this._themes = normaliseThemes(payload.themes) + this._status = 'ready' + this._render(getContractState().state) + } catch (error) { + this._status = 'error' + this._render(getContractState().state) + this._emit('bananas:error', { error: error instanceof Error ? error.message : String(error) }) + } + } + + /** + * Adopts the contract into the shadow root, then repaints with the previews on. + * + * @param {string} base + * @returns {Promise} + */ + async _adoptContract(base) { + const sheet = await adoptContractSheet(base) + // A null sheet is not a failure here: the control renders without previews, which + // is a lesser outcome, not a broken one. + if (sheet) { + this._sheet = sheet + // Read defensively: the property is absent in some DOM implementations, and + // spreading undefined throws where appending a sheet is otherwise harmless. + const adopted = this._root.adoptedStyleSheets + this._root.adoptedStyleSheets = adopted ? [...adopted, sheet] : [sheet] + } + if (this._status === 'ready') this._render(getContractState().state) + } + + /** + * @override + * @param {import('./contract.js').Selection} state + * @returns {void} + */ + _render(state) { + const group = this._group + if (!group) return + group.replaceChildren() + + if (this._status === 'error') { + const note = document.createElement('button') + note.type = 'button' + note.className = 'spinner' + note.textContent = 'No themes' + note.disabled = true + group.append(note) + return + } + + if (this._status !== 'ready') { + const note = document.createElement('button') + note.type = 'button' + note.className = 'spinner' + note.textContent = 'Loading' + note.disabled = true + group.append(note) + return + } + + if (!this.hasAttribute('compact')) { + group.append(this._button(null, 'None', state.theme === null)) + } + + for (const theme of this._themes) { + group.append(this._button(theme, theme.name, state.theme === theme.slug)) + } + } + + /** + * @param {{slug: string, name: string, defaultMode: string}|null} theme + * @param {string} label + * @param {boolean} selected + * @returns {HTMLButtonElement} + */ + _button(theme, label, selected) { + const button = document.createElement('button') + button.type = 'button' + button.setAttribute('part', 'button') + button.setAttribute('role', 'radio') + button.setAttribute('aria-checked', selected ? 'true' : 'false') + button.title = theme ? `${label} (${theme.defaultMode})` : 'Use no theme' + if (selected) button.setAttribute('aria-current', 'true') + + button.addEventListener('click', () => { + const slug = theme ? theme.slug : null + getContractState().selectTheme(slug, theme ? theme.defaultMode : null) + this._emit('bananas:theme', { slug, mode: theme ? theme.defaultMode : ADMIN_MODE }) + }) + + // A preview needs the contract to mean anything, and the "None" entry has no + // palette to preview, so only real palettes get one. + if (theme && this._sheet && !this.hasAttribute('no-preview')) { + button.prepend(previewFor(theme)) + } + button.append(document.createTextNode(label)) + return button + } +} + +/** + * `` — picks between admin chrome, light and dark. + * + * Three states, not two, because "light or dark" is the wrong question for an + * administration screen: it has to be able to fall back to the application's own + * chrome while a palette is being previewed. + * + * Attributes: + * label accessible label; defaults to "Appearance" + * show-labels render the text labels rather than icons only + * + * Events: `bananas:appearance` with `detail: { mode }`. + */ +export class BananasAppearanceToggle extends BananasControl { + /** @override */ + connectedCallback() { + this._root.append((this._group = document.createElement('div'))) + this._group.className = 'group' + this._group.setAttribute('part', 'group') + this._group.setAttribute('role', 'radiogroup') + this._group.setAttribute('aria-label', this._attr('label', 'Appearance')) + super.connectedCallback() + } + + /** @returns {'admin'|'light'|'dark'} */ + get value() { + return getContractState().state.mode + } + + set value(mode) { + if (MODES.includes(mode)) getContractState().selectMode(mode) + } + + /** + * @override + * @param {import('./contract.js').Selection} state + * @returns {void} + */ + _render(state) { + const group = this._group + if (!group) return + group.replaceChildren() + + const withLabels = this.hasAttribute('show-labels') + /** @type {Array<{mode: 'admin'|'light'|'dark', icon: string, label: string}>} */ + const entries = [ + { mode: ADMIN_MODE, icon: '◧', label: 'Admin' }, + { mode: 'light', icon: '☀', label: 'Light' }, + { mode: 'dark', icon: '☾', label: 'Dark' }, + ] + + for (const entry of entries) { + const button = document.createElement('button') + button.type = 'button' + button.setAttribute('part', 'button') + button.setAttribute('role', 'radio') + // The label is always present for assistive technology; only the visible form + // changes. An icon-only button with no accessible name is unusable with a screen + // reader, which is why the text is not dropped but hidden. + button.setAttribute('aria-label', entry.label) + button.setAttribute('aria-checked', state.mode === entry.mode ? 'true' : 'false') + button.title = entry.label + + const icon = document.createElement('span') + icon.setAttribute('aria-hidden', 'true') + icon.textContent = entry.icon + button.append(icon) + + if (withLabels) { + const text = document.createElement('span') + text.textContent = ` ${entry.label}` + button.append(text) + } + + button.addEventListener('click', () => { + getContractState().selectMode(entry.mode) + this._emit('bananas:appearance', { mode: entry.mode }) + }) + + group.append(button) + } + } +} + +/** + * Defines both elements. Safe to call more than once. + * + * The registration is guarded on the constructor's own name rather than + * `customElements.get`, because two copies of this module in one page (two bundles, + * or an import map plus a direct import) is a realistic scenario and the second + * registration would otherwise throw and take the page down with it. + * + * @returns {void} + */ +export function defineComponents() { + if (typeof customElements === 'undefined') return + for (const [name, ctor] of /** @type {const} */ ([ + ['bananas-theme-selector', BananasThemeSelector], + ['bananas-appearance-toggle', BananasAppearanceToggle], + ])) { + if (!customElements.get(name)) customElements.define(name, ctor) + } +} + +if (typeof customElements !== 'undefined') defineComponents() + +// Expose getContractState globally so consumers loading via script tag can access it. +// This is needed because dynamic import() may not find the module loaded via script tag. +if (typeof window !== 'undefined') { + window.__BANANAS_GET_CONTRACT_STATE__ = getContractState +} + +// The contract API is re-exported so that loading this one module is enough. A +// consumer that wants to build its own control, or to read the current selection, has +// no reason to know that the store lives in a different file — and requiring a second +// import is exactly the kind of friction that gets a consumer writing their own +// storage code instead. +export { + ADMIN_MODE, + ContractState, + DEFAULT_STORAGE_KEY, + LEGACY_MODE_ATTRIBUTE, + LEGACY_THEME_ATTRIBUTE, + MODE_ATTRIBUTE, + THEME_ATTRIBUTE, + getContractState, + readMode, + resetContractState, +} \ No newline at end of file diff --git a/sdk/src/components.test.js b/sdk/src/components.test.js new file mode 100644 index 0000000..144ae0b --- /dev/null +++ b/sdk/src/components.test.js @@ -0,0 +1,675 @@ +/** + * Tests for the contract store and the two controls. + * + * The store is tested directly because both controls are thin views over it. The + * controls are tested for the two properties that matter and are easy to lose: the + * attributes land on , and the shadow root actually isolates style in both + * directions. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + ADMIN_MODE, + ContractState, + DEFAULT_STORAGE_KEY, + LEGACY_MODE_ATTRIBUTE, + LEGACY_THEME_ATTRIBUTE, + MODE_ATTRIBUTE, + THEME_ATTRIBUTE, + getContractState, + resetContractState, +} from './contract.js' +import { BananasAppearanceToggle, BananasThemeSelector, defineComponents } from './components.js' + +/** Clears the document and storage between tests. */ +function reset() { + resetContractState() + localStorage.clear() + for (const attr of [THEME_ATTRIBUTE, MODE_ATTRIBUTE, LEGACY_THEME_ATTRIBUTE, LEGACY_MODE_ATTRIBUTE]) { + document.documentElement.removeAttribute(attr) + } + document.documentElement.classList.remove('dark') + document.documentElement.removeAttribute('style') +} + +/** + * The shadow root of a custom element, typed. + * + * `shadowRoot` is `ShadowRoot | null` in the DOM types even though these elements + * always open one, so the cast lives in one place instead of at twenty call sites. + * + * @param {Element} el + * @returns {ShadowRoot} + */ +function shadowOf(el) { + const root = /** @type {ShadowRoot | null} */ (el.shadowRoot) + if (!root) throw new Error('element has no shadow root') + return root +} + +/** Builds a state wired to the live document. */ +function liveState() { + return new ContractState({ storageKey: 'test:contract' }) +} + +/** + * Fetches a contract payload for the selector. + * + * @param {Array<{slug: string, name: string, defaultMode: string}>} themes + * @returns {void} + */ +function stubContract(themes) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ themes }), + })), + ) +} + +/** + * A contract stylesheet with the rules a preview needs. + * + * Bare attribute selectors, because that is what the control adopts: a document + * stylesheet reaches the shadow root only through an adopted sheet, where `:root` + * matches nothing. + */ +const CONTRACT_CSS = ` + [data-theme="midnight"] { --bn-bg: #101014; --bn-surface: #1a1a20; --bn-border: #2e2e38; --bn-text: #cfd0e0; --bn-accent: #6f7dff; } + [data-theme="midnight"][data-mode="dark"] { --bn-bg: #0b0b0e; --bn-accent: #8a94ff; } + [data-theme="daylight"] { --bn-bg: #fdfdfd; --bn-surface: #f2f2f4; --bn-border: #d8d8de; --bn-text: #17171a; --bn-accent: #0a5ad6; } + [data-theme="daylight"][data-mode="light"] { --bn-bg: #ffffff; --bn-accent: #0a5ad6; } +` + +/** + * Serves both halves of the runtime the selector reads: the palette list and the + * contract it previews from. + * + * Each call gets its own base URL. The component caches a parsed contract per URL for + * the life of the page, which is right in a browser and wrong in a test file: without + * a fresh URL every test would inherit whichever contract the first one parsed, and a + * test stubbing a 404 would still get the sheet an earlier test warmed. + * + * @param {Array<{slug: string, name: string, defaultMode: string}>} themes + * @param {{css?: string, failCss?: boolean}} [options] + * @returns {{base: string, calls: () => Array}} The base to mount on, and + * the recorded requests. + */ +function stubRuntime(themes, options = {}) { + const css = options.css === undefined ? CONTRACT_CSS : options.css + /** @type {Array} */ + const calls = [] + const base = `/rt/v1-test-${++runtimeSeq}` + vi.stubGlobal( + 'fetch', + vi.fn(async (/** @type {string} */ url) => { + calls.push([url]) + if (url.endsWith('contract.css')) { + if (options.failCss) return { ok: false, status: 404, text: async () => '' } + return { ok: true, status: 200, text: async () => css } + } + return { ok: true, status: 200, json: async () => ({ themes }) } + }), + ) + return { base, calls: () => calls } +} + +/** Counts bases so no two tests share a contract cache entry. */ +let runtimeSeq = 0 + +/** + * Mounts a selector and waits for it to settle. + * + * The control fires two unawaited promises -- the palette list and the contract -- and + * which one finishes last decides whether previews are on the first paint. Waiting for + * both is the only way to assert the finished state rather than a race. + * + * @param {Record} [attributes] + * @returns {Promise} + */ +async function mountSelector(attributes = {}) { + const el = /** @type {BananasThemeSelector} */ (document.createElement('bananas-theme-selector')) + for (const [name, value] of Object.entries(attributes)) el.setAttribute(name, value) + document.body.append(el) + await vi.waitFor(() => { + expect(shadowOf(el).querySelectorAll('button').length).toBeGreaterThan(0) + }) + return el +} + +describe('ContractState', () => { + beforeEach(reset) + + it('writes the palette to both the short and the prefixed attribute', () => { + const state = liveState() + state.selectTheme('midnight') + + // Both, because which convention a host's CSS reads is not knowable from here. + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('midnight') + expect(document.documentElement.getAttribute(LEGACY_THEME_ATTRIBUTE)).toBe('midnight') + }) + + it('writes the appearance to both attributes and sets the dark class', () => { + const state = liveState() + state.selectMode('dark') + + expect(document.documentElement.getAttribute(MODE_ATTRIBUTE)).toBe('dark') + expect(document.documentElement.getAttribute(LEGACY_MODE_ATTRIBUTE)).toBe('dark') + expect(document.documentElement.classList.contains('dark')).toBe(true) + // colorScheme is what makes native form controls and scrollbars follow. + expect(document.documentElement.style.colorScheme).toBe('dark') + }) + + it('removes both appearance attributes for admin chrome', () => { + const state = liveState() + state.selectMode('dark') + state.selectMode(ADMIN_MODE) + + expect(document.documentElement.hasAttribute(MODE_ATTRIBUTE)).toBe(false) + expect(document.documentElement.hasAttribute(LEGACY_MODE_ATTRIBUTE)).toBe(false) + expect(document.documentElement.classList.contains('dark')).toBe(false) + }) + + it('restores the theme default appearance when a palette is chosen', () => { + const state = liveState() + state.selectMode('light') + state.selectTheme('midnight', 'dark') + + expect(state.state.theme).toBe('midnight') + expect(state.state.mode).toBe('dark') + }) + + it('drops the palette and returns to admin chrome when there is none', () => { + const state = liveState() + state.selectTheme('midnight', 'dark') + state.selectTheme(null) + + expect(state.state.theme).toBeNull() + expect(state.state.mode).toBe(ADMIN_MODE) + expect(document.documentElement.hasAttribute(THEME_ATTRIBUTE)).toBe(false) + }) + + it('persists and restores across instances', () => { + liveState().selectTheme('midnight', 'dark') + + // A second store over the same key is what a page reload produces. + const restored = new ContractState({ storageKey: 'test:contract' }) + expect(restored.state.theme).toBe('midnight') + expect(restored.state.mode).toBe('dark') + }) + + it('ignores a corrupt stored value instead of failing', () => { + localStorage.setItem('test:contract', '{not json') + expect(() => new ContractState({ storageKey: 'test:contract' })).not.toThrow() + }) + + it('survives localStorage throwing, as it does in private browsing', () => { + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError') + }) + const state = liveState() + + expect(() => state.selectTheme('midnight')).not.toThrow() + // The selection still applies to this page view even though it cannot persist. + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('midnight') + + setItem.mockRestore() + }) + + it('notifies subscribers and reports who wrote', () => { + const state = liveState() + /** @type {import('./contract.js').Selection[]} */ + const seen = [] + state.subscribe((s) => seen.push(s)) + + state.selectMode('dark') + + // Once on subscribe, once on the change. + expect(seen).toHaveLength(2) + expect(seen[1]?.mode).toBe('dark') + expect(seen[1]?.source).toBe('toggle') + }) + + it('hands out copies so a subscriber cannot mutate the store', () => { + const state = liveState() + /** @type {import('./contract.js').Selection|undefined} */ + let captured + state.subscribe((s) => { + captured = s + }) + if (!captured) throw new Error('subscribe did not deliver a value') + captured.theme = 'tampered' + + expect(state.state.theme).not.toBe('tampered') + }) + + it('does not apply anything when there is no document', () => { + // A build step or a test environment can import this without a DOM. + const state = new ContractState({ storageKey: 'test:contract', root: null }) + expect(() => state.selectTheme('midnight')).not.toThrow() + }) + + it('shares one store between callers', () => { + const a = getContractState({ storageKey: DEFAULT_STORAGE_KEY }) + const b = getContractState() + expect(a).toBe(b) + }) +}) + +describe('components', () => { + beforeEach(() => { + reset() + defineComponents() + }) + + it('registers both elements without throwing on a second call', () => { + expect(() => defineComponents()).not.toThrow() + expect(customElements.get('bananas-theme-selector')).toBe(BananasThemeSelector) + expect(customElements.get('bananas-appearance-toggle')).toBe(BananasAppearanceToggle) + }) + + it('renders the appearance toggle with three states', () => { + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + + const buttons = shadowOf(el).querySelectorAll('button') + expect(buttons).toHaveLength(3) + // Each is named for assistive technology even though only the icon is visible. + expect([...buttons].map((b) => b.getAttribute('aria-label'))).toEqual(['Admin', 'Light', 'Dark']) + }) + + it('marks the current appearance as checked', () => { + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + getContractState().selectMode('dark') + + const checked = [...shadowOf(el).querySelectorAll('button')].find( + (b) => b.getAttribute('aria-checked') === 'true', + ) + expect(checked?.getAttribute('aria-label')).toBe('Dark') + }) + + it('changes the appearance when a button is pressed', () => { + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + + const dark = [...shadowOf(el).querySelectorAll('button')].find( + (b) => b.getAttribute('aria-label') === 'Dark', + ) + dark?.click() + + expect(getContractState().state.mode).toBe('dark') + expect(document.documentElement.getAttribute(MODE_ATTRIBUTE)).toBe('dark') + }) + + it('emits an appearance event that crosses the shadow boundary', () => { + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + + /** @type {Array<{mode: string}>} */ + const seen = [] + // Listening on document, not on the element: the event has to be composed for a + // host framework to see it without a shadow-root reference. + document.addEventListener('bananas:appearance', (event) => { + seen.push(/** @type {CustomEvent<{mode: string}>} */ (event).detail) + }) + + const light = [...shadowOf(el).querySelectorAll('button')].find( + (b) => b.getAttribute('aria-label') === 'Light', + ) + light?.click() + + expect(seen).toEqual([{ mode: 'light' }]) + }) + + it('loads palettes from the service and renders one button each', async () => { + stubContract([ + { slug: 'midnight', name: 'Midnight', defaultMode: 'dark' }, + { slug: 'daylight', name: 'Daylight', defaultMode: 'light' }, + ]) + const el = document.createElement('bananas-theme-selector') + document.body.append(el) + + await vi.waitFor(() => { + expect(shadowOf(el).querySelectorAll('button').length).toBe(3) + }) + const labels = [...shadowOf(el).querySelectorAll('button')].map((b) => b.textContent) + expect(labels).toContain('Midnight') + expect(labels).toContain('None') + }) + + it('surfaces a service failure without throwing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) })), + ) + const el = document.createElement('bananas-theme-selector') + document.body.append(el) + + await vi.waitFor(() => { + const button = shadowOf(el).querySelector('button') + expect(button?.textContent).toBe('No themes') + }) + }) + + it('keeps its styles out of the page and the page out of it', () => { + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + + // The shadow root exists, which is the mechanism: page CSS cannot select into it + // and this stylesheet has no way to reach a page rule. + const shadow = shadowOf(el) + expect(shadow.querySelector('style')).toBeTruthy() + + // Nothing was written to the document head, so nothing can leak out. + const leaked = [...document.head.querySelectorAll('style')].filter((s) => + (s.textContent || '').includes('--bn-control-radius'), + ) + expect(leaked).toHaveLength(0) + }) + + it('inherits the contract custom properties through the shadow boundary', () => { + // This is what makes a control follow the palette it is switching: custom + // properties cross the shadow boundary, so --bn-text set on reaches in. + document.documentElement.style.setProperty('--bn-text', 'rgb(1, 2, 3)') + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + + const sheet = shadowOf(el).querySelector('style') + expect(sheet?.textContent).toContain('var(--bn-text') + document.documentElement.style.removeProperty('--bn-text') + }) + + it('removes its subscription when disconnected', () => { + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + el.remove() + + // A store change after removal must not reach a detached element. + expect(() => getContractState().selectMode('dark')).not.toThrow() + }) + + it('reflects the value property both ways', () => { + const selector = /** @type {BananasThemeSelector} */ ( + document.createElement('bananas-theme-selector') + ) + const toggle = /** @type {BananasAppearanceToggle} */ ( + document.createElement('bananas-appearance-toggle') + ) + document.body.append(selector, toggle) + + selector.value = 'midnight' + expect(getContractState().state.theme).toBe('midnight') + + toggle.value = 'light' + expect(getContractState().state.mode).toBe('light') + }) +}) + +describe('BananasThemeSelector previews', () => { + const themes = [ + { slug: 'midnight', name: 'Midnight', defaultMode: 'dark' }, + { slug: 'daylight', name: 'Daylight', defaultMode: 'light' }, + ] + + beforeEach(reset) + + it('gives every palette a miniature that carries the palette it previews', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + await vi.waitFor(() => { + expect(shadowOf(el).querySelectorAll('.preview').length).toBe(2) + }) + const previews = [...shadowOf(el).querySelectorAll('.preview')] + // The palette is on the element as an attribute, so the contract styles the + // miniature rather than the component hardcoding a colour per theme. + expect(previews.map((p) => p.getAttribute(THEME_ATTRIBUTE))).toEqual(['midnight', 'daylight']) + // Each palette previews in its own default appearance, because the default is the + // appearance the theme is actually chosen for. + expect(previews.map((p) => p.getAttribute(MODE_ATTRIBUTE))).toEqual(['dark', 'light']) + }) + + it('builds the miniature out of the contract tokens rather than fixed colours', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + const preview = await vi.waitFor(() => { + const found = shadowOf(el).querySelector('.preview') + expect(found).toBeTruthy() + return /** @type {Element} */ (found) + }) + // A title bar, a text rule and an accent control: enough of the application for + // surface, border, text and accent to all be visible at once. + expect(preview.querySelectorAll('.preview-bar')).toHaveLength(1) + expect(preview.querySelectorAll('.preview-rule')).toHaveLength(1) + expect(preview.querySelectorAll('.preview-accent')).toHaveLength(1) + + // Every colour is a contract token with a fallback, so no palette value is baked + // into the component and none can fall out of step with the server. + const css = shadowOf(el).querySelector('style')?.textContent || '' + for (const token of ['--bn-bg', '--bn-surface', '--bn-border', '--bn-text', '--bn-accent']) { + expect(css).toContain(`var(${token},`) + } + }) + + it('hides the miniature from assistive technology', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + await vi.waitFor(() => { + expect(shadowOf(el).querySelectorAll('.preview').length).toBe(2) + }) + // The palette name is the button's text, so announcing the picture too would read + // the same theme twice. + for (const preview of shadowOf(el).querySelectorAll('.preview')) { + expect(preview.getAttribute('aria-hidden')).toBe('true') + } + }) + + it('leaves the name readable and the miniature beside it', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + const button = await vi.waitFor(() => { + const found = [...shadowOf(el).querySelectorAll('button')].find( + (b) => b.textContent === 'Midnight' && b.querySelector('.preview'), + ) + expect(found).toBeTruthy() + return /** @type {Element} */ (found) + }) + // textContent ignores the picture, so the accessible name is still the palette. + expect(button.textContent).toBe('Midnight') + expect(button.querySelector('.preview')).toBeTruthy() + // The miniature comes first, which is the order the two read in visually. + expect(button.firstElementChild?.className).toBe('preview') + }) + + it('adopts the contract into the shadow root', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + await vi.waitFor(() => { + expect(shadowOf(el).adoptedStyleSheets.length).toBe(1) + }) + // The reason the control fetches the contract at all: a stylesheet in the document + // does not select into a shadow root, so linking it would leave every preview + // rendering from its fallbacks. + const sheet = shadowOf(el).adoptedStyleSheets[0] + if (!sheet) throw new Error('no stylesheet was adopted') + expect(sheet.cssRules.length).toBeGreaterThan(0) + // The contract's own selectors are what style the miniature, so the adopted sheet + // has to carry them rather than just the component's styles. + const selectors = [...sheet.cssRules] + .filter((rule) => rule instanceof CSSStyleRule) + .map((rule) => rule.selectorText) + .join('\n') + expect(selectors).toContain('[data-theme="midnight"]') + }) + + it('fetches the contract once for every control on the page', async () => { + const rt = stubRuntime(themes) + await mountSelector({ base: rt.base }) + await mountSelector({ base: rt.base }) + + const cssCalls = rt.calls().filter((call) => String(call[0]).endsWith('contract.css')) + // Constructed stylesheets are shareable, so a second control costs no second + // request. + expect(cssCalls).toHaveLength(1) + }) + + it('lists the palettes without a miniature when the contract is unavailable', async () => { + const rt = stubRuntime(themes, { failCss: true }) + const el = await mountSelector({ base: rt.base }) + + await vi.waitFor(() => { + expect(shadowOf(el).querySelectorAll('button').length).toBe(3) + }) + // Let the failed contract request settle, so the assertions below are about the + // outcome of the failure rather than about not having got there yet. + await vi.waitFor(() => { + expect(rt.calls().filter((call) => String(call[0]).endsWith('contract.css')).length).toBe(1) + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + // Degrading to names, not to a broken control: picking a theme still has to work. + expect(shadowOf(el).querySelectorAll('.preview')).toHaveLength(0) + // Read tolerantly: the property is absent in some DOM implementations, and the + // claim under test is that nothing was adopted, not that an empty list came back. + expect(shadowOf(el).adoptedStyleSheets?.length ?? 0).toBe(0) + const labels = [...shadowOf(el).querySelectorAll('button')].map((b) => b.textContent) + expect(labels).toContain('Midnight') + expect(labels).toContain('Daylight') + }) + + it('does not preview the None entry, which has no palette', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + await vi.waitFor(() => { + expect(shadowOf(el).querySelectorAll('.preview').length).toBe(2) + }) + const none = [...shadowOf(el).querySelectorAll('button')].find((b) => b.textContent === 'None') + expect(none?.querySelector('.preview')).toBeNull() + }) + + it('omits every miniature when asked', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base, 'no-preview': '' }) + + // Wait for the contract to land before asserting that no miniature was drawn. + // Without that, "no miniature" would also hold simply because none had been drawn + // yet, and the assertion would prove nothing about the attribute. + await vi.waitFor(() => { + expect(shadowOf(el).adoptedStyleSheets.length).toBe(1) + }) + // The escape hatch for a toolbar too narrow to hold them. + expect(shadowOf(el).querySelectorAll('.preview')).toHaveLength(0) + }) + + it('keeps the miniature in step with the selection', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + /** @returns {Promise} */ + const midnightButton = () => + vi.waitFor(() => { + const found = [...shadowOf(el).querySelectorAll('button')].find( + (b) => b.textContent === 'Midnight', + ) + if (!(found instanceof HTMLButtonElement)) throw new Error('no Midnight button') + return found + }) + + expect((await midnightButton()).getAttribute('aria-checked')).toBe('false') + ;(await midnightButton()).click() + + // The group is rebuilt on every state change, so the button that was clicked is + // replaced; the check has to be made against the one now on screen. + await vi.waitFor(async () => { + expect((await midnightButton()).getAttribute('aria-checked')).toBe('true') + }) + // The previews survive the re-render, so the palette that was previewed is still + // the one on screen after selecting it. + expect(shadowOf(el).querySelectorAll('.preview').length).toBe(2) + }) +}) + +describe('base discovery', () => { + beforeEach(() => { + reset() + defineComponents() + }) + + afterEach(() => { + for (const link of document.querySelectorAll('link')) link.remove() + }) + + it('finds the runtime base from the contract stylesheet', () => { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.setAttribute('data-theme-contract', '') + link.href = 'https://themes.example.com/rt/v1/contract.css' + document.head.append(link) + + stubContract([{ slug: 'midnight', name: 'Midnight', defaultMode: 'dark' }]) + const el = document.createElement('bananas-theme-selector') + document.body.append(el) + + return vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + 'https://themes.example.com/rt/v1/contract.json', + expect.anything(), + ) + }) + }) + + it('accepts a link without the marker when the href is recognisable', () => { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = 'https://cdn.example.com/rt/v1/contract.css' + document.head.append(link) + + stubContract([]) + const el = document.createElement('bananas-theme-selector') + document.body.append(el) + + return vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + 'https://cdn.example.com/rt/v1/contract.json', + expect.anything(), + ) + }) + }) + + it('lets an explicit base attribute win over the stylesheet', () => { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.setAttribute('data-theme-contract', '') + link.href = 'https://from-link.example.com/rt/v1/contract.css' + document.head.append(link) + + stubContract([]) + const el = document.createElement('bananas-theme-selector') + el.setAttribute('base', 'https://explicit.example.com/rt/v1') + document.body.append(el) + + return vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + 'https://explicit.example.com/rt/v1/contract.json', + expect.anything(), + ) + }) + }) + + it('falls back to the same-origin default with no link', () => { + stubContract([]) + const el = document.createElement('bananas-theme-selector') + document.body.append(el) + + return vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith('/rt/v1/contract.json', expect.anything()) + }) + }) +}) diff --git a/sdk/src/contract.js b/sdk/src/contract.js new file mode 100644 index 0000000..d0df723 --- /dev/null +++ b/sdk/src/contract.js @@ -0,0 +1,275 @@ +/** + * The contract state shared by the shipped components. + * + * This module is the single place that knows how a palette and an appearance turn + * into attributes on the document element. Both components read and write it, and a + * host application can use it directly to build its own controls. + * + * The attributes are configurable rather than hard-coded, because a host page may + * already have committed to a different pair — two generations of applications do use + * different ones — and because a component that cannot be told where to write cannot + * be adopted by either. + * + * @module go-bananas/contract + */ + +/** The attribute a palette is selected with. */ +export const THEME_ATTRIBUTE = 'data-theme' + +/** The attribute an appearance is selected with. */ +export const MODE_ATTRIBUTE = 'data-mode' + +/** The prefixed attributes this service emitted before the contract existed. */ +export const LEGACY_THEME_ATTRIBUTE = 'data-bananas-theme' +export const LEGACY_MODE_ATTRIBUTE = 'data-bananas-appearance' + +/** + * The appearance that means "no theme": the application's own chrome. + * + * It is a third state rather than a fourth mode, because it is a different question + * from light or dark. An administration screen has to look like the administration + * screen even while a palette is being previewed. + */ +export const ADMIN_MODE = 'admin' + +/** Every appearance value the shipped components use. */ +export const MODES = /** @type {const} */ ([ADMIN_MODE, 'light', 'dark']) + +/** The default storage key. Namespaced by origin so two apps on one host do not collide. */ +export const DEFAULT_STORAGE_KEY = 'go-bananas:appearance' + +/** + * @typedef {object} Selection + * @property {string|null} theme The selected palette slug, or null for none. + * @property {'admin'|'light'|'dark'} mode The selected appearance. + * @property {boolean} system Follow the operating system preference for mode. + * @property {string|null} source The component that last wrote, for change events. + */ + +/** + * A tiny observable holding the current selection. + * + * The alternative is a custom event on `document` plus a global variable, which is + * what a single component needs. With two components that have to agree, a shared + * store with subscribers is less code and fewer edge cases. + */ +export class ContractState { + /** @type {Selection} */ + #state + + /** @type {Set<(state: Selection) => void>} */ + #subscribers = new Set() + + /** @type {string} */ + #storageKey + + /** + * @param {object} [options] + * @param {string} [options.storageKey] + * @param {Element|null} [options.root] The element carrying the attributes. + * Defaults to the document element, so a caller with no DOM can pass null. + */ + constructor(options = {}) { + this.#storageKey = options.storageKey || DEFAULT_STORAGE_KEY + const root = + options.root !== undefined + ? options.root + : typeof document === 'undefined' + ? null + : document.documentElement + + this.#state = { + theme: root?.getAttribute(THEME_ATTRIBUTE) || root?.getAttribute(LEGACY_THEME_ATTRIBUTE) || null, + mode: readMode(root), + system: true, + source: null, + } + this.#readStored() + + // Normalised on construction rather than on the first change. A host page that + // sets only `data-theme` would otherwise keep the two conventions out of step + // until the user touched a control, and a consumer whose CSS reads the prefixed + // attribute would render unthemed in the meantime. Writing both on load is one + // attribute write and removes the whole class of "why is nothing themed". + this.#applyToDocument() + } + + /** @returns {Selection} A copy, so a subscriber cannot mutate the store. */ + get state() { + return { ...this.#state } + } + + /** @returns {string} */ + get storageKey() { + return this.#storageKey + } + + /** + * Applies a partial change, writes it to the document, persists it, and notifies. + * + * @param {Partial} patch + * @param {string|null} [source] + * @returns {Selection} + */ + set(patch, source = null) { + const next = { ...this.#state, ...patch, source } + this.#state = next + this.#applyToDocument() + this.#persist() + for (const fn of this.#subscribers) fn(this.state) + return this.state + } + + /** + * @param {(state: Selection) => void} fn + * @returns {() => void} An unsubscribe function. + */ + subscribe(fn) { + this.#subscribers.add(fn) + fn(this.state) + return () => this.#subscribers.delete(fn) + } + + /** + * Selects a palette. + * + * Selecting the palette a theme's own default came from also restores that default + * appearance, because "I want this theme" and "as its designer intended" are the same + * request far more often than not. + * + * @param {string|null} slug + * @param {string|null} [defaultMode] + * @returns {Selection} + */ + selectTheme(slug, defaultMode = null) { + if (!slug) return this.set({ theme: null, mode: ADMIN_MODE }, 'selector') + return this.set( + defaultMode ? { theme: slug, mode: /** @type {'light'|'dark'} */ (defaultMode) } : { theme: slug }, + 'selector', + ) + } + + /** + * @param {'admin'|'light'|'dark'} mode + * @returns {Selection} + */ + selectMode(mode) { + return this.set({ mode, system: false }, 'toggle') + } + + /** + * Follows the operating system appearance, ignoring any explicit choice. + * + * @returns {Selection} + */ + followSystem() { + return this.set({ system: true }, 'toggle') + } + + /** @returns {void} */ + #applyToDocument() { + if (typeof document === 'undefined') return + const root = document.documentElement + + for (const attr of [THEME_ATTRIBUTE, LEGACY_THEME_ATTRIBUTE]) { + if (this.#state.theme) root.setAttribute(attr, this.#state.theme) + else root.removeAttribute(attr) + } + + // `admin` means no appearance, so both attributes come off. Anything else sets + // both conventions, because which one a consumer's CSS reads is not knowable from + // here and setting both is free. + const mode = this.#state.mode + if (mode === ADMIN_MODE || !mode) { + for (const attr of [MODE_ATTRIBUTE, LEGACY_MODE_ATTRIBUTE]) root.removeAttribute(attr) + } else { + for (const attr of [MODE_ATTRIBUTE, LEGACY_MODE_ATTRIBUTE]) root.setAttribute(attr, mode) + } + + root.classList.toggle('dark', mode === 'dark') + root.style.colorScheme = mode === 'dark' ? 'dark' : 'light' + } + + /** @returns {void} */ + #readStored() { + if (typeof localStorage === 'undefined') return + try { + const raw = localStorage.getItem(this.#storageKey) + if (!raw) return + // Landed in `unknown` first: JSON.parse returns `any`, and assigning an `any` + // into a typed position is the hole no-unsafe-assignment exists to close. + /** @type {unknown} */ + const body = JSON.parse(raw) + const parsed = /** @type {{theme?: unknown, mode?: unknown, system?: unknown}} */ (body) + if (typeof parsed !== 'object' || parsed === null) return + + /** @type {Partial} */ + const patch = {} + if (typeof parsed.theme === 'string' || parsed.theme === null) patch.theme = parsed.theme + if (typeof parsed.mode === 'string' && MODES.includes(/** @type {'admin'} */ (parsed.mode))) { + patch.mode = /** @type {'admin'|'light'|'dark'} */ (parsed.mode) + } + if (typeof parsed.system === 'boolean') patch.system = parsed.system + this.#state = { ...this.#state, ...patch } + this.#applyToDocument() + } catch { + // A corrupt or unreadable value is not worth failing a page load over. The + // attributes already on the element are the fallback. + } + } + + /** @returns {void} */ + #persist() { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem( + this.#storageKey, + JSON.stringify({ + theme: this.#state.theme, + mode: this.#state.mode, + system: this.#state.system, + }), + ) + } catch { + // Private browsing and a full quota both raise here. The selection still works + // for this page view, which is the important part. + } + } +} + +/** + * Reads the appearance already on an element, tolerating either attribute or neither. + * + * @param {Element|null|undefined} el + * @returns {'admin'|'light'|'dark'} + */ +export function readMode(el) { + if (!el) return ADMIN_MODE + const value = el.getAttribute(MODE_ATTRIBUTE) || el.getAttribute(LEGACY_MODE_ATTRIBUTE) + if (value === 'light' || value === 'dark') return value + if (el.classList.contains('dark')) return 'dark' + return ADMIN_MODE +} + +/** @type {ContractState|null} */ +let shared = null + +/** + * The process-wide store, created on first use. + * + * Both components default to this so that a page with two of them agrees without the + * host having to wire them together. + * + * @param {object} [options] + * @param {string} [options.storageKey] + * @returns {ContractState} + */ +export function getContractState(options = {}) { + if (!shared) shared = new ContractState(options) + return shared +} + +/** @returns {void} Resets the shared store. For tests. */ +export function resetContractState() { + shared = null +} \ No newline at end of file diff --git a/sdk/src/embed.js b/sdk/src/embed.js new file mode 100644 index 0000000..243841f --- /dev/null +++ b/sdk/src/embed.js @@ -0,0 +1,64 @@ +/** + * go-bananas one-line embed. + * + * The smallest possible integration: a single script tag that applies a theme + * before the first paint. There is no module, no import map and no configuration, + * which makes this the right snippet for a quick trial or for a page that cannot + * be modified beyond adding one tag. + * + * ```html + * + * ``` + * + * The data attributes configure it: + * + * data-theme slug to apply + * data-appearance light | dark | system + * data-variant full | tokens | minified + * data-storage "false" to skip localStorage + * data-auto-dark "true" to follow the operating system + * + * @module go-bananas/embed + */ + +import { applyTheme, watchSystemAppearance } from './runtime.js' + +/** Attribute that marks this script so a second copy is a no-op. */ +const MARKER = 'data-bananas-embedded' + +// Reading the currentScript synchronously is the only way to discover the +// element's data attributes: the script is async, so document.currentScript is +// already null by the time this module body runs. +// document.currentScript is null by the time this module body runs, because the +// script is async. The element is found by selector instead, which works whether +// the tag was loaded by the parser or injected. +const script = /** @type {HTMLScriptElement|null} */ ( + document.querySelector('script[src*="/rt/v1/embed.js"]') +) + +/** Already embedded on this page. */ +if (script?.hasAttribute(MARKER)) { + // A second copy would race the first over the same link element. +} else if (script) { + script.setAttribute(MARKER, 'true') + + const slug = script.dataset.theme || 'default' + const appearance = /** @type {'light'|'dark'|'system'} */ ( + script.dataset.appearance || 'system' + ) + const autoDark = script.dataset.autoDark === 'true' || appearance === 'system' + const variant = script.dataset.variant || 'full' + const storage = script.dataset.storage !== 'false' + + // Following the operating system has to be set up before the first apply, so + // that a user whose OS is already dark never sees a light flash. + if (autoDark) watchSystemAppearance() + + applyTheme(slug, { appearance, variant, storage }).catch((error) => { + // A theme that fails to load must never take the host page down with it. + console.warn('Go Bananas!!: could not apply theme', slug, error) + }) +} + +export { applyTheme } diff --git a/sdk/src/global.js b/sdk/src/global.js new file mode 100644 index 0000000..d0e27a0 --- /dev/null +++ b/sdk/src/global.js @@ -0,0 +1,48 @@ +/** + * go-bananas runtime — global build. + * + * The ESM build is the right choice for modern applications, but a plain + * + * + * ``` + * + * @module go-bananas/global + */ + +import * as runtime from './runtime.js' +import * as components from './components.js' + +const api = { + ...runtime, + // Registering on load means a consumer that only adds a + * ``` + * + * React itself is imported as a bare specifier so the host application's copy is + * used. Importing a second copy of React would break hooks, because each copy has + * its own dispatcher state. + * + * @module go-bananas/runtime-react + */ + +import { + createContext, + createElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' + +import { + APPEARANCE_ATTRIBUTE, + THEME_ATTRIBUTE, + applyTheme, + assetUrl, + clearTheme, + getAppearance, + getSlug, + getVariables, + loadManifest, + loadTheme, + setAppearance, + subscribe, + version, +} from './runtime.js' + +export { + APPEARANCE_ATTRIBUTE, + THEME_ATTRIBUTE, + applyTheme, + assetUrl, + clearTheme, + getAppearance, + getSlug, + getVariables, + loadManifest, + loadTheme, + setAppearance, + version, +} + +/** + * @typedef {'light'|'dark'|'system'} Appearance + */ + +/** + * @typedef {import('./runtime.js').Manifest} Manifest + * @typedef {import('./runtime.js').ResolvedTheme} ResolvedTheme + */ + +/** + * @typedef {object} BananasContextValue + * @property {string|null} slug + * @property {Appearance} appearance + * @property {'light'|'dark'} resolvedAppearance + * @property {boolean} prefersDark + * @property {ResolvedTheme|null} theme Resolved token payload, once loaded. + * @property {Manifest|null} manifest Available themes, once loaded. + * @property {'idle'|'loading'|'ready'|'error'} status + * @property {Error|null} error + * @property {(slug: string) => Promise} setTheme + * @property {(appearance: Appearance) => void} setAppearance + */ + +/** @type {import('react').Context} */ +const BananasContext = createContext(/** @type {BananasContextValue|null} */ (null)) + +BananasContext.displayName = 'Bananas' + +/** + * Subscribes to the vanilla runtime's state from React. + * + * useSyncExternalStore is used rather than a useEffect subscription so that a + * theme change made outside React — a theme switcher in another part of the + * application, or a browser back button — cannot tear the tree. + * + * @returns {{slug: string|null, appearance: Appearance, prefersDark: boolean}} + */ +function useExternalState() { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +} + +/** Reads the runtime snapshot outside of React's render cycle. */ +function getSnapshot() { + return { + slug: getSlug(), + appearance: getAppearance(), + prefersDark: + typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches, + } +} + +/** Server snapshot: a stable object so hydration does not mismatch. */ +function getServerSnapshot() { + return { slug: null, appearance: /** @type {Appearance} */ ('system'), prefersDark: false } +} + +/** + * @typedef {object} BananasProviderProps + * @property {string} [slug] Theme to apply. Controlled when supplied. + * @property {Appearance} [appearance='system'] + * @property {string} [baseUrl] Runtime base URL. + * @property {boolean} [autoDark] Follow the operating system preference. + * @property {boolean} [storage=true] Persist the user's choice. + * @property {string} [variant] Render variant. + * @property {boolean} [loadManifest] Fetch the theme list on mount. + * @property {boolean} [suspense] Suspend until the theme is ready. + * @property {(error: Error) => void} [onError] + * @property {import('react').ReactNode} children + */ + +/** + * Applies a theme and shares its state with the tree. + * + * @param {BananasProviderProps} props + * @returns {import('react').ReactElement} + */ +export function BananasProvider(props) { + const { + slug, + appearance = 'system', + baseUrl, + autoDark = false, + storage = true, + variant = 'full', + loadManifest: shouldLoadManifest = false, + onError, + children, + } = props + + const [theme, setTheme] = useState(/** @type {ResolvedTheme|null} */ (null)) + const [manifest, setManifest] = useState(/** @type {Manifest|null} */ (null)) + const [status, setStatus] = useState(/** @type {'idle'|'loading'|'ready'|'error'} */ ('idle')) + const [error, setError] = useState(/** @type {Error|null} */ (null)) + const external = useExternalState() + + // Configuration is applied once, through a module-level singleton inside the + // vanilla runtime. The effect runs on mount only; changing baseUrl later + // requires a reload, which is documented rather than silently supported. + useEffect(() => { + const controller = new AbortController() + let cancelled = false + + async function start() { + setStatus('loading') + try { + if (baseUrl) { + // The runtime reads its base from the module URL by default. An explicit + // override is published on the global, which is what `resolveBaseUrl` + // checks before falling back to the module URL. + /** @type {{ __BANANAS_BASE__?: string }} */ (globalThis).__BANANAS_BASE__ = baseUrl + } + + const target = autoDark ? 'system' : appearance + + if (slug) { + const loaded = await applyTheme(slug, { appearance: target, storage, variant }) + if (!cancelled) { + setTheme(loaded) + setStatus('ready') + } + } + + if (shouldLoadManifest) { + const loadedManifest = await loadManifest() + if (!cancelled) setManifest(loadedManifest) + } + + if (!slug && shouldLoadManifest) setStatus('ready') + } catch (caught) { + if (cancelled) return + const failure = + caught instanceof Error ? caught : new Error(String(caught)) + setError(failure) + setStatus('error') + onError?.(failure) + } + } + + void start() + return () => { + cancelled = true + controller.abort() + } + // The effect intentionally depends on nothing that can change per render. + }, []) + + const changeTheme = useCallback( + /** + * Changes the applied theme. + * + * @param {string} next + * @returns {Promise} + */ + async (next) => { + setStatus('loading') + try { + const loaded = await applyTheme(next, { appearance: external.appearance }) + setTheme(loaded) + setError(null) + setStatus('ready') + } catch (caught) { + const failure = caught instanceof Error ? caught : new Error(String(caught)) + setError(failure) + setStatus('error') + onError?.(failure) + } + }, + [external.appearance, onError], + ) + + const changeAppearance = useCallback( + /** + * Changes the colour scheme. + * + * @param {Appearance} next + * @returns {void} + */ + (next) => { + setAppearance(next) + }, + [], + ) + + const value = useMemo( + () => ({ + slug: external.slug, + appearance: external.appearance, + resolvedAppearance: + external.appearance === 'system' + ? external.prefersDark + ? 'dark' + : 'light' + : external.appearance, + prefersDark: external.prefersDark, + theme, + manifest, + status, + error, + setTheme: changeTheme, + setAppearance: changeAppearance, + }), + [external, theme, manifest, status, error, changeTheme, changeAppearance], + ) + + return createElement(BananasContext.Provider, { value }, children) +} + +/** + * Reads the theme context. + * + * @returns {BananasContextValue} + * @throws when used outside a BananasProvider, because a silent default would + * let a component render with the wrong theme and never report the mistake. + */ +export function useBananas() { + const value = useContext(BananasContext) + if (!value) { + throw new Error( + 'go-bananas: useBananas must be used inside a . ' + + 'Wrap your application in one, or import { BananasProvider } from this module.', + ) + } + return value +} + +/** + * Returns the resolved value of one design token. + * + * @param {string} name Token name, with or without the `--bn-` prefix. + * @returns {string|undefined} + */ +export function useThemeVariable(name) { + const { theme } = useBananas() + return useMemo(() => { + if (!theme) return undefined + const key = name.startsWith('--') ? name : `--bn-${name}` + return theme.variables[key] + }, [theme, name]) +} + +/** + * Returns every resolved token as a record. + * + * @returns {Record} + */ +export function useThemeVariables() { + const { theme } = useBananas() + // An empty record rather than undefined: a consumer spreading or indexing the + // result should not have to guard for the pre-load window. + return useMemo(() => theme?.variables ?? {}, [theme]) +} + +/** + * Returns the list of available themes. + * + * @returns {{themes: any[], loading: boolean, error: Error|null}} + */ +export function useAvailableThemes() { + const { manifest } = useBananas() + const [local, setLocal] = useState(/** @type {Manifest|null} */ (null)) + const [error, setError] = useState(/** @type {Error|null} */ (null)) + + useEffect(() => { + let cancelled = false + if (manifest === null) { + loadManifest() + .then((loaded) => { + if (!cancelled) setLocal(loaded) + }) + .catch((caught) => { + if (!cancelled) setError(caught instanceof Error ? caught : new Error(String(caught))) + }) + } + return () => { + cancelled = true + } + }, [manifest]) + + const source = manifest ?? local + return { + themes: source?.themes ?? [], + loading: !source && !error, + error, + } +} + +export { BananasContext } + +/* -------------------------------------------------------------------------- */ +/* Palette and appearance controls */ +/* -------------------------------------------------------------------------- */ + +/** + * The element interface the palette control presents. Declared here rather than + * cast to `any`, because a wrapper that cannot see the element's own API is how a + * ref-based binding silently stops working. + * + * @typedef {object} ThemeSelectorElement + * @property {string} value + * @property {string|null} base + * @property {(type: string, listener: (event: any) => void) => void} addEventListener + * @property {(type: string, listener: (event: any) => void) => void} removeEventListener + */ + +/** + * The element interface the appearance control presents. + * + * @typedef {object} AppearanceToggleElement + * @property {'admin'|'light'|'dark'} value + * @property {(type: string, listener: (event: any) => void) => void} addEventListener + * @property {(type: string, listener: (event: any) => void) => void} removeEventListener + */ + +/** + * @typedef {object} ThemeChangeDetail + * @property {string|null} slug + */ + +/** + * @typedef {object} AppearanceChangeDetail + * @property {'admin'|'light'|'dark'} mode + */ + +/** + * @typedef {object} ThemeSelectorProps + * @property {string} [base] Service runtime base. Defaults to the contract + * stylesheet already on the page. + * @property {string} [label] Accessible label. + * @property {boolean} [compact] Omit the "no theme" entry. + * @property {string} [value] The selected slug. Controlled when supplied. + * @property {(slug: string) => void} [onChange] + */ + +/** + * The palette control, as a React component. + * + * A thin wrapper around the custom element rather than a React reimplementation: + * the element owns its rendering, its Shadow DOM and its accessibility tree, so a + * React version would be a second implementation that drifts. + * + * It exists because using the bare element from JSX is workable but awkward. The + * element's `value` is a property rather than a React-controlled one, so a caller + * that wants to drive it from state needs a ref and an effect. This accepts ordinary + * props instead. + * + * @param {ThemeSelectorProps & Record} props + * @returns {any} + */ +export function ThemeSelector({ base, label, compact, value, onChange, ...rest }) { + const ref = useRef(/** @type {ThemeSelectorElement|null} */ (null)) + + useEffect(() => { + const el = ref.current + if (value === undefined || !el) return + if (el.value !== value) el.value = value + }, [value]) + + useEffect(() => { + const el = ref.current + if (!el || !onChange) return undefined + // The event is composed and crosses the shadow boundary, so listening on the + // element rather than on document is enough. + /** @param {Event} raw */ + const handler = (raw) => { + const event = /** @type {CustomEvent} */ (raw) + onChange(event.detail?.slug ?? '') + } + el.addEventListener('bananas:theme', handler) + return () => el.removeEventListener('bananas:theme', handler) + }, [onChange]) + + return createElement('bananas-theme-selector', { + ...rest, + ref, + base, + label, + compact: compact ? '' : undefined, + }) +} + +/** + * @typedef {object} AppearanceToggleProps + * @property {string} [label] Accessible label. + * @property {boolean} [showLabels] Render text labels rather than icons only. + * @property {'admin'|'light'|'dark'} [value] Controlled when supplied. + * @property {(mode: 'admin'|'light'|'dark') => void} [onChange] + */ + +/** + * The appearance control, as a React component. Wraps the custom element for the + * same reason as {@link ThemeSelector}. + * + * @param {AppearanceToggleProps & Record} props + * @returns {any} + */ +export function AppearanceToggle({ label, showLabels, value, onChange, ...rest }) { + const ref = useRef(/** @type {AppearanceToggleElement|null} */ (null)) + + useEffect(() => { + const el = ref.current + if (value === undefined || !el) return + if (el.value !== value) el.value = value + }, [value]) + + useEffect(() => { + const el = ref.current + if (!el || !onChange) return undefined + /** @param {Event} raw */ + const handler = (raw) => { + const event = /** @type {CustomEvent} */ (raw) + onChange(event.detail?.mode ?? 'admin') + } + el.addEventListener('bananas:appearance', handler) + return () => el.removeEventListener('bananas:appearance', handler) + }, [onChange]) + + return createElement('bananas-appearance-toggle', { + ...rest, + ref, + label, + showLabels: showLabels ? '' : undefined, + }) +} diff --git a/sdk/src/runtime.js b/sdk/src/runtime.js new file mode 100644 index 0000000..84efc9f --- /dev/null +++ b/sdk/src/runtime.js @@ -0,0 +1,627 @@ +/** + * go-bananas runtime — framework-free. + * + * Applies a theme to the page at run time. There is nothing to install: load + * this module with a URL and the theme is applied by fetching CSS from the + * service. That is the entire point of the runtime delivery model — a theme + * becomes a deployment-time concern rather than a dependency. + * + * @module go-bananas/runtime + */ + +/** + * @typedef {object} ThemeRef + * @property {string} slug + * @property {string} name + * @property {string} [description] + * @property {number} version + * @property {Appearance} appearance + * @property {Appearance} defaultAppearance + * @property {string} updatedAt + */ + +/** + * @typedef {object} ManifestTheme + * @property {string} slug + * @property {string} name + * @property {string} [description] + * @property {Appearance} defaultAppearance + * @property {number} version + * @property {string[]} [tags] + * @property {string} css + * @property {string} tokens + */ + +/** + * @typedef {object} Manifest + * @property {string} apiVersion + * @property {string} [default] + * @property {string} baseUrl + * @property {ManifestTheme[]} themes + */ + +/** + * The payload the runtime JSON endpoint returns. + * + * Typed here rather than as `any` so that a consumer importing these typedefs + * gets the real shape, and so that a change to the payload is a type error rather + * than a surprise at run time. + * + * @typedef {object} ResolvedTheme + * @property {ThemeRef} theme + * @property {Record} tokens + * @property {Record} variables + * @property {Record} [meta] + */ + +/** Attribute that scopes a theme to a subtree. */ +export const THEME_ATTRIBUTE = 'data-bananas-theme' +/** Attribute that selects the colour scheme within a themed subtree. */ +export const APPEARANCE_ATTRIBUTE = 'data-bananas-appearance' +/** Contract version this module understands. */ +export const API_VERSION = '1' + +/** Local storage key used to remember the user's appearance choice. */ +const STORAGE_KEY = 'go-bananas:appearance' +/** Local storage key used to remember the applied slug. */ +const SLUG_KEY = 'go-bananas:theme' + +/** + * @typedef {'light'|'dark'|'system'} Appearance + */ + +/** + * @typedef {object} BananasOptions + * @property {string} [baseUrl] Runtime base URL. Defaults to the origin the + * module was loaded from, which is what makes a same-origin deployment work + * with no configuration at all. + * @property {string} [slug] Theme to apply on load. + * @property {Appearance} [appearance='system'] Initial colour scheme. + * @property {Element} [target] Element to scope to. Defaults to + * `document.documentElement`. + * @property {boolean} [storage=true] Persist the appearance choice. + * @property {string} [variant] Render variant, e.g. `full` or `tokens`. + * @property {(err: Error) => void} [onError] Error callback. + */ + +/** @type {BananasOptions} */ +let options = { + appearance: 'system', + storage: true, + variant: 'full', +} + +/** + * Current state, exposed so consumers can render a theme picker. + * + * The shape is annotated explicitly because an object literal infers `null` for + * an initialised-to-null field and `string` rather than a literal union for an + * appearance, which then makes every later assignment a type error under strict + * checking. Declaring the types once here keeps the rest of the module clean. + * + * @type {{ + * slug: string|null, + * appearance: Appearance, + * cache: Map, + * manifest: Manifest | null, + * listeners: Set<(state: {slug: string|null, appearance: Appearance, dark: boolean}) => void>, + * mediaQuery: MediaQueryList|null, + * dark: boolean, + * }} + */ +const state = { + slug: null, + appearance: 'system', + cache: new Map(), + manifest: null, + listeners: new Set(), + mediaQuery: null, + dark: false, +} + +/* -------------------------------------------------------------------------- */ +/* URL construction */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns the runtime base URL, inferred from where this module was loaded. + * + * Inferring beats configuring here because the overwhelmingly common deployment + * serves the SDK and the runtime API from the same origin, so the correct answer + * is knowable without being told. + * + * @returns {string} + */ +export function resolveBaseUrl() { + if (options.baseUrl) return stripTrailingSlash(options.baseUrl) + + const configured = /** @type {{ __BANANAS_BASE__?: string }} */ (globalThis).__BANANAS_BASE__ + if (typeof configured === 'string' && configured) { + return stripTrailingSlash(configured) + } + + if (typeof document !== 'undefined') { + // currentScript is typed as an SVG-or-HTML script element; only an HTML one + // has a src, so the narrowing is explicit rather than a cast on the value. + const script = document.currentScript + if (script instanceof HTMLScriptElement && script.src) { + return new URL('.', script.src).toString() + } + } + + if (typeof location !== 'undefined') { + return `${stripTrailingSlash(location.origin)}${basePath()}` + } + throw new Error('go-bananas: cannot determine the runtime base URL; pass options.baseUrl') +} + +/** + * Reads the deployment's base path from the document, for installations served + * under a sub-path such as `/themes`. + * + * @returns {string} + */ +function basePath() { + if (typeof document !== 'undefined') { + const configured = document.documentElement.getAttribute('data-bananas-base-path') + if (configured) return configured.replace(/\/$/, '') + } + return '' +} + +/** + * Strips trailing slashes from a URL. + * + * @param {string} value + * @returns {string} + */ +function stripTrailingSlash(value) { + return value.replace(/\/+$/, '') +} + +/** + * Builds the URL of a runtime asset. + * + * @param {string} path e.g. `theme.css` + * @param {Record} [query] + * @returns {string} + */ +export function assetUrl(path, query = {}) { + const url = new URL(`${resolveBaseUrl()}/${path}`) + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue + url.searchParams.set(key, String(value)) + } + return url.toString() +} + +/* -------------------------------------------------------------------------- */ +/* Appearance */ +/* -------------------------------------------------------------------------- */ + +/** + * Reports whether the operating system currently prefers a dark colour scheme. + * + * @returns {boolean} + */ +export function prefersDark() { + // matchMedia is absent in some embedded webviews, and the theme attribute + // cannot be resolved without it. + if (typeof window === 'undefined' || !window.matchMedia) return false + return window.matchMedia('(prefers-color-scheme: dark)').matches +} + +/** + * Resolves an appearance choice against the operating system preference. + * + * @param {Appearance} appearance + * @returns {'light'|'dark'} + */ +export function resolveAppearance(appearance) { + if (appearance === 'light' || appearance === 'dark') return appearance + return prefersDark() ? 'dark' : 'light' +} + +/** + * Subscribes to operating-system appearance changes. + * + * The returned function unsubscribes. When the user has chosen `system`, a change + * in the operating system preference re-applies the theme; when they have chosen + * an explicit scheme, nothing happens, which is the behaviour a user who picked + * "light" on a dark laptop expects. + * + * @param {(dark: boolean) => void} [onChange] + * @returns {() => void} + */ +export function watchSystemAppearance(onChange) { + // There is nothing to watch without matchMedia. Returning a no-op unsubscribe + // keeps the caller's cleanup symmetric. + if (typeof window === 'undefined' || !window.matchMedia) return () => undefined + + const query = window.matchMedia('(prefers-color-scheme: dark)') + /** @param {MediaQueryListEvent} event */ + const listener = (event) => { + state.dark = event.matches + if (state.appearance === 'system') { + applyToDocument() + notify() + } + onChange?.(event.matches) + } + + // addEventListener is the modern API; addListener is kept for Safari < 14. + if (query.addEventListener) query.addEventListener('change', listener) + else query.addListener?.(listener) + + state.mediaQuery = query + state.dark = query.matches + + return () => { + if (query.removeEventListener) query.removeEventListener('change', listener) + else query.removeListener?.(listener) + state.mediaQuery = null + } +} + +/* -------------------------------------------------------------------------- */ +/* Theme application */ +/* -------------------------------------------------------------------------- */ + +/** + * Loads and applies a theme. + * + * The stylesheet is injected as a `` rather than fetched and evaluated, so + * the browser's own cache, preload scanner and CDN revalidation all apply. That + * is both faster and safer than injecting a constructed `