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

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

45
.air.toml Normal file
View file

@ -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

76
.dockerignore Normal file
View file

@ -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

26
.editorconfig Normal file
View file

@ -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

166
.env.example Normal file
View file

@ -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_<NAME> and the unprefixed
# <NAME>. 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

11
.gitattributes vendored Normal file
View file

@ -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

289
.github/workflows/ci.yml vendored Normal file
View file

@ -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"

54
.gitignore vendored Normal file
View file

@ -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

275
.golangci.yml Normal file
View file

@ -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

10
.prettierignore Normal file
View file

@ -0,0 +1,10 @@
node_modules
dist
coverage
bin
data
*.db
*.db-wal
*.db-shm
openapi.yaml
internal/store/migrations

12
.prettierrc.json Normal file
View file

@ -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"]
}

159
Dockerfile Normal file
View file

@ -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"

68
Dockerfile.release Normal file
View file

@ -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"]

21
LICENSE Normal file
View file

@ -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.

439
Makefile Normal file
View file

@ -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

469
README.md Normal file
View file

@ -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
<!-- That is the whole integration. -->
<link rel="stylesheet" href="https://themes.example.com/rt/v1/theme.css?slug=midnight">
```
---
## 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 <http://localhost:8080>. 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
<link rel="stylesheet" href="https://themes.example.com/rt/v1/theme.css?slug=midnight">
```
### 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
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19",
"react-dom/client": "https://esm.sh/react-dom@19/client",
"go-bananas/runtime": "https://themes.example.com/rt/v1/react.js"
} }
</script>
```
```jsx
import { BananasProvider, useBananas, useThemeVariable } from 'go-bananas/runtime'
function App() {
return (
<BananasProvider slug="midnight" autoDark>
<Shell />
</BananasProvider>
)
}
function Badge() {
const { resolvedAppearance } = useBananas()
const primary = useThemeVariable('color-semantic-primary')
return <span style={{ color: primary }}>{resolvedAppearance}</span>
}
```
### Or one script tag
```html
<script async src="https://themes.example.com/rt/v1/embed.js" data-theme="midnight"></script>
```
### 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
<div class="bn-card bn-card--raised">
<div class="bn-card__header">Deployed</div>
<p class="bn-card__body">Everything here comes from a design token.</p>
</div>
<button class="bn-btn">Primary</button>
<button class="bn-btn bn-btn--secondary">Secondary</button>
<span class="bn-badge bn-badge--good">healthy</span>
```
---
## 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 │──┐
<link>, 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.

0
cat Normal file
View file

95
cmd/go-bananas/main.go Normal file
View file

@ -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
}
}

143
compose.yaml Normal file
View file

@ -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

View file

@ -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"]
}
]
}

View file

@ -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='<from the provider>'
#
# 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

View file

@ -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

View file

@ -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

View file

@ -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 <head>:
<link rel="stylesheet"
href="{{ include "go-bananas.publicUrl" . }}/rt/v1/theme.css?slug=midnight">
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=<slug>" }}
{{- $base }}{{ "/theme.json?slug=<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/<realm>
identity.oidc.clientId=go-bananas
identity.oidc.existingSecret=<secret-name>
{{- 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.

View file

@ -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/<realm>." -}}
{{- 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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 <namespace> exec <pod> -- /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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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

View file

@ -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 }
}
}
}
}

View file

@ -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"

90
docker-bake.hcl Normal file
View file

@ -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"]
}

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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 `<style>` | 2 objects | broken | inline custom properties |
| `go-tell` | plain CSS, **no custom properties at all** | 4, all dark | none | 340 hardcoded hexes |
Four problems follow from that, and only one of them is aesthetic:
1. **No shared vocabulary.** `--navy` in `go-guess` and `--surface` in `go-loose` mean
unrelated things, so nothing can be shared and no token can be changed in one place.
2. **No light mode in three of six.** A theme is the wrong level of abstraction to fix
this: the missing axis is *appearance*, and a theme that is dark-only cannot express
it.
3. **Two incompatible theme mechanisms.** `go-loose` sets `data-theme` and `data-mode`
as separate attributes; `go-tell` sets `data-theme` only, with the mode baked into
each palette. An application can adopt one or the other, not both.
4. **Tailwind is a different language.** A Tailwind application cannot consume a
`:root` block; it needs `@theme`.
The brief was to consolidate, find the middle ground, and make that styling available to
every application.
## Decision
Adopt `go-loose`'s two-attribute model as the contract, because it is the only
mechanism that is both working and already copied by another application:
```html
<html data-theme="tokyo-night" data-mode="dark">
```
Two orthogonal axes. `data-theme` is the palette, `data-mode` is the appearance. Every
theme therefore has both a light and a dark rendering, which fixes problem 2 without
inventing a second concept.
Token names are namespaced with `--bn-`, and the rendered stylesheet emits a **legacy
alias block** underneath:
```css
:root[data-theme='tokyo-night'][data-mode='dark'] {
--bn-bg: #1a1b26;
--bn-surface: #20222f;
/* ... */
}
/* Compatibility with applications that already use these names. */
:root[data-theme='tokyo-night'][data-mode='dark'] {
--bg: var(--bn-bg);
--surface: var(--bn-surface);
--text: var(--bn-text);
--muted: var(--bn-muted);
--line: var(--bn-border);
--accent: var(--bn-accent);
--good: var(--bn-good);
--danger: var(--bn-danger);
--radius: var(--bn-radius);
}
```
The alias block is the middle ground made literal. `go-loose` and `go-mad` keep working
unchanged, and the other four applications can migrate by deleting their own `:root`
block and renaming nothing.
## Rationale
**Namespaced names, because the stylesheet is injected into someone else's page.** An
unprefixed `--surface` on a host page is a collision waiting to happen, and the host's
value wins or loses depending on order. `--bn-bg` cannot collide with anything.
**Aliases, because a rename is a coordinated six-application change.** Emitting both
means an application adopts the contract by adding one `<link>`, with no call sites
edited. That is the difference between a migration that happens and one that is
proposed.
**Two axes rather than N themes.** Six applications currently have 13 palettes between
them, several of which are the same palette twice. Splitting palette from appearance
turns 13 one-dimensional choices into 7 palettes × 2 modes, and makes "give me a light
version" a solvable problem instead of a request for a new theme.
**Attributes, not classes.** `[data-theme=X]` beats `.theme-x` because an attribute is
one axis for two features, and because a consumer can set it in the `<head>` before
first paint. A class list forces the two axes into an ordering the cascade then has to
be told about.
**`color-mix`, not extra tokens.** `go-loose` and `go-mad` already derive hover and
subtle states with `color-mix(in srgb, var(--bn-accent) 12%, var(--bn-surface))`. That
is kept: it means one accent serves every interaction state, instead of a hand-tuned
ladder that drifts between themes.
**`--bn-font-*` and `--bn-radius` join the set.** Both applications that have a scale
disagree (`go-loose` uses 16px cards, `go-mad` 8px; `go-doc` uses Tailwind defaults).
Consolidation without them would leave typography and geometry app-local.
## Consequences
- The rendered stylesheet grows by the alias block. It is small, it is compressible, and
it is removed entirely once an application has renamed its own references.
- `go-doc` needs a three-line Tailwind bridge, because `@theme` cannot read a runtime
custom property. It becomes `@theme inline { --color-bn-bg: var(--bn-bg) }`.
- `go-tell`'s four themes are palettes with the appearance baked in. They become four
`data-theme` values with their dark values as the `dark` mode, and their light
counterparts have to be authored. That work is the honest cost of the migration.
- Appearance preference is per-application. Two applications on the same browser do not
share a `data-mode` unless they agree on a storage key; the shipped components use one
key per origin and document it.

397
docs/api.md Normal file
View file

@ -0,0 +1,397 @@
# HTTP API
Two surfaces with different postures:
| | `/api/v1` | `/rt/v1` |
| --- | --- | --- |
| Audience | Administrators | Applications and browsers |
| Authentication | Required | None, by design |
| Caching | `no-store` | Aggressive, immutable by URL |
| Audit | Every mutation | No |
| Rate limit | Generous | Applied |
A theming service that required a credential could not be consumed from a
`<link>` tag, which is the primary use case.
---
## Conventions
- All bodies are JSON. One shape, everywhere.
- Errors: `{ code, message, details?, requestId? }`. `code` is a stable
machine-readable identifier and is part of the contract.
- `requestId` is on every error and in `X-Request-ID` on every response.
- Timestamps are RFC 3339 in UTC.
- Every mutable request accepts `baseVersion` for optimistic concurrency.
| Code | Status | Meaning |
| --- | --- | --- |
| `bad_request` | 400 | Malformed |
| `validation_failed` | 400 | Invalid, with per-field `details` |
| `unauthorized` | 401 | No usable credential |
| `forbidden` | 403 | Insufficient role |
| `not_found` | 404 | Missing, or deliberately not disclosed |
| `conflict` | 409 | Uniqueness, or an optimistic-concurrency conflict |
| `immutable` | 409 | A built-in theme cannot be deleted |
| `rate_limited` | 429 | With `Retry-After` |
| `internal_error` | 500 | Unexpected |
---
## Runtime API
### `GET /rt/v1/theme.css`
A theme's stylesheet.
| Parameter | Required | Default | Purpose |
| --- | --- | --- | --- |
| `slug` | Yes | — | The theme |
| `appearance` | No | The theme's default | `light` or `dark` |
| `variant` | No | `full` | `full`, `tokens`, `minified` |
| `v` | No | Latest | Pin a version |
```bash
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight'
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&appearance=dark'
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&variant=tokens'
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&v=7'
```
```
HTTP/1.1 200 OK
Content-Type: text/css; charset=utf-8
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=86400
ETag: "a1f4b5833f60d17d42328a90213b20f6"
X-Theme-Version: 7
X-Theme-Updated-At: 2026-10-04T09:14:27Z
Vary: Accept-Encoding
```
A draft theme returns **404**, not 403, so a probe cannot confirm it exists.
### `GET /rt/v1/theme.json`
Resolved tokens.
```json
{
"theme": {
"slug": "midnight",
"name": "Midnight",
"version": 7,
"appearance": "dark",
"defaultAppearance": "dark",
"updatedAt": "2026-10-04T09:14:27Z"
},
"variables": {
"--bn-color-semantic-primary": "#7c6bf5",
"--bn-color-on-primary": "#0f0d1f",
"--bn-radius-lg": "0.75rem",
"--bn-space-4": "1rem"
},
"tokens": {
"color": { "semantic": { "primary": { "$type": "color", "$value": "#7c6bf5" } } }
},
"meta": { "tags": ["dark", "violet"] }
}
```
`variables` is a flat map for anything reading values. `tokens` is the nested DTCG
document for mapping onto component props.
### `GET /rt/v1/manifest.json`
The discovery document for building a picker. Deliberately small: no token data.
```json
{
"apiVersion": "1",
"default": "midnight",
"baseUrl": "https://themes.example.com/rt/v1",
"themes": [
{
"slug": "midnight",
"name": "Midnight",
"description": "Violet-accented dark interface.",
"defaultAppearance": "dark",
"version": 7,
"tags": ["dark", "violet"],
"css": "https://themes.example.com/rt/v1/theme.css?slug=midnight",
"tokens": "https://themes.example.com/rt/v1/theme.json?slug=midnight"
}
]
}
```
### `GET /rt/v1/embed`
A ready-to-paste snippet. `?target=html` (the default, a full preview page), `react`,
or `javascript`.
### SDK bundles
`GET /rt/v1/runtime.js`, `/react.js`, `/global.js`, `/embed.js`. Immutable, served
with a one-year `Cache-Control`, and CORS-open, since they are public assets loaded
cross-origin.
### Path-addressable forms
`/rt/v1/themes/{slug}/theme.css`, `/rt/v1/themes/{slug}/theme.json`,
`/rt/v1/themes/{slug}/embed`. For consumers that prefer REST shapes or a CDN
configured with path patterns rather than query strings.
---
## Administration API
Requires a session cookie. Every mutation is audited.
There is no machine credential: API keys were removed. The runtime API is public by
design, and the administration API is for people, so a bearer token is not a
credential here and a request carrying one is treated as unauthenticated.
### Auth
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/v1/auth/me` | The current session. Always 200 |
| `GET` | `/api/v1/auth/config` | Client-visible configuration |
| `GET` | `/auth/login` | Start the OIDC flow |
| `GET` | `/auth/callback` | Complete it |
| `GET`/`POST` | `/auth/logout` | End the session and the provider session |
`/auth/me` always answers 200 with `authenticated: false` when there is no session,
because "not logged in yet" is the normal state on first load and not an error the UI
should handle as one.
```json
{
"authenticated": true,
"mode": "oidc",
"identity": {
"subject": "a1b2-c3d4",
"email": "designer@example.com",
"name": "A Designer",
"roles": ["editor"],
"method": "oidc"
},
"permissions": {
"read": true, "write": true, "publish": true,
"delete": false, "manageKeys": false, "manageUsers": false, "manageSettings": false
},
"preferences": {
"selectedTheme": "midnight",
"selectedAppearance": "dark"
}
}
```
`preferences` is present only when `authenticated` is true. It rides along with the
session so the UI can preselect a theme from the same round trip that establishes who
the caller is, rather than making a second request before it can paint.
### Preferences
Per-account interface preferences: which published theme the person has chosen, and
which of its two colour schemes to render. Any authenticated role may read and write
these, because a preference only affects what that one person sees and is keyed to
their own subject.
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/v1/me/preferences` | The caller's preference, or empty when none is stored |
| `PUT` | `/api/v1/me/preferences` | Record a preference and echo back what was stored |
| `DELETE` | `/api/v1/me/preferences` | Forget it, returning the interface defaults |
```http
PUT /api/v1/me/preferences
{"selectedTheme": "midnight", "selectedAppearance": "dark"}
```
```json
{"selectedTheme": "midnight", "selectedAppearance": "dark"}
```
Both fields empty means nothing has been chosen, and that is a valid state rather than
a missing one: the client applies its own default. An empty `selectedTheme` clears the
choice, and a `selectedAppearance` sent with it is dropped rather than stored, because
a scheme has nothing to select from.
`selectedTheme` names a **published** theme rather than copying it, so editing or
unpublishing that theme shows through everywhere without touching a stored value. Two
consequences are worth knowing:
- Saving a draft theme slug is a 400. Drafts are invisible to runtime consumers, so a
preference pointing at one would leave the UI rendering a theme it cannot load.
- A preference naming a theme that has since been deleted or archived **reads back as
no choice**, so a stale value cannot pin the interface to something that no longer
exists. The row is left alone; only the read is filtered.
The response is authoritative and should be applied in place of the request. It is the
only way a client learns that its value was filtered or cleared.
Preferences are deliberately **not** claims in the session token. The token is issued by
the identity provider and this service does not control it, so a choice made here has
to be read back from this API. They are also stored per account rather than per
browser, so they survive a re-login (`UpsertUser` rewrites the `users` projection on
every login) and follow the person to another device.
### Themes
| Method | Path | Role |
| --- | --- | --- |
| `GET` | `/api/v1/themes` | viewer |
| `GET` | `/api/v1/themes/{slug}` | viewer |
| `GET` | `/api/v1/themes/{slug}/export` | viewer |
| `POST` | `/api/v1/themes` | editor |
| `PATCH` | `/api/v1/themes/{slug}` | editor |
| `POST` | `/api/v1/themes/{slug}/publish` | editor |
| `POST` | `/api/v1/themes/{slug}/archive` | editor |
| `POST` | `/api/v1/themes/{slug}/duplicate` | editor |
| `POST` | `/api/v1/themes/import` | editor |
| `DELETE` | `/api/v1/themes/{slug}` | admin |
`GET /api/v1/themes` takes `q`, `status`, `system`, `tag`, `limit`, `offset`, `sort`
(`updated`, `created`, `name`, `slug`) and `order`. An unrecognised `sort` falls back
to the default rather than reaching SQL.
Listings omit token documents. They are the bulk of each row and a listing never needs
them.
```bash
curl -X POST https://themes.example.com/api/v1/themes \
-H 'Content-Type: application/json' \
-b cookies.txt \
-d '{
"name": "Acme Product",
"slug": "acme",
"description": "The default theme for customer-facing applications",
"status": "draft",
"tokens": { "color": { "semantic": { "primary": { "$type": "color", "$value": "#4f46e5" } } } }
}'
```
```bash
curl -X PATCH https://themes.example.com/api/v1/themes/acme \
-H 'Content-Type: application/json' -b cookies.txt \
-d '{ "tokens": { … }, "baseVersion": 3, "message": "rebalanced the neutrals" }'
```
`baseVersion` gives optimistic concurrency. If the theme has moved on, the write is
rejected with 409 and a message naming both versions, rather than silently overwriting
someone else's work.
Publishing validates first:
```json
{
"code": "validation_failed",
"message": "the theme cannot be published because its tokens are invalid",
"details": [
{ "field": "color.semantic.primary", "message": "not a valid colour: \"#nothex\"" }
]
}
```
### History
| Method | Path | Role |
| --- | --- | --- |
| `GET` | `/api/v1/themes/{slug}/versions` | viewer |
| `GET` | `/api/v1/themes/{slug}/versions/{version}` | viewer |
| `GET` | `/api/v1/themes/{slug}/diff?from=1&to=2` | viewer |
| `POST` | `/api/v1/themes/{slug}/versions/{version}/rollback` | editor |
Rollback **appends**: the snapshot becomes the current state as a new version.
History is never rewritten, so an application deployed against version 7 can never
have version 7 taken away.
```json
{
"fromVersion": 6,
"toVersion": 7,
"added": ["color.semantic.tertiary"],
"removed": [],
"changed": [
{ "path": "color.semantic.primary", "from": "#4f46e5", "to": "#7c6bf5" }
],
"metaChanged": false
}
```
### Token tooling
Read-only analysis of a submitted document. Any authenticated user may call these.
| Method | Path | Purpose |
| --- | --- | --- |
| `POST` | `/api/v1/tokens/validate` | Validate without saving |
| `GET` | `/api/v1/tokens/schema` | The group and kind registry |
| `GET` | `/api/v1/tokens/palette?hue=&saturation=&lightness=&hueSpread=` | Generate a ramp |
| `GET` | `/api/v1/tokens/contrast?fg=…&bg=…` | WCAG contrast |
```json
{
"valid": false,
"tokenCount": 214,
"issues": [
{ "path": "color.semantic.primary", "severity": "error", "message": "not a valid colour: \"#nothex\"" },
{ "path": "$appearances", "severity": "warning", "message": "no dark appearance overrides are defined", "hint": "add {\"$appearances\":{\"dark\":{…}}}" }
]
}
```
```json
{
"foreground": "#7c6bf5",
"background": "#1a1d23",
"ratio": 6.42,
"rounded": 6.42,
"level": "aa",
"thresholds": { "aaLarge": 3, "aa": 4.5, "aaa": 7 },
"bestForeground": "#ffffff"
}
```
### Administration
| Method | Path | Role |
| --- | --- | --- |
| `GET` | `/api/v1/stats` | viewer |
| `GET` | `/api/v1/admin/users` | admin |
| `GET` | `/api/v1/admin/audit` | admin |
| `GET` | `/api/v1/admin/settings` | admin |
| `PUT` | `/api/v1/admin/settings` | admin |
| `POST` | `/api/v1/admin/cache/rebuild` | admin |
Creating a key returns the plaintext **once**:
```json
{
"key": { "id": "key_x7f2", "name": "CI publish", "prefix": "gb_a1b2c3d4", "scopes": ["themes:write"] },
"secret": "gb_a1b2c3d4_…",
"warning": "This secret is shown once and cannot be recovered. Store it now."
}
```
---
## Operational endpoints
| Path | Purpose |
| --- | --- |
| `/livez`, `/healthz` | Liveness, with build information on `/healthz` |
| `/readyz` | Readiness. Checks the database |
| `/metrics` | Prometheus, when `config.enableMetrics` |
```json
{
"status": "ready",
"checks": {
"database": { "status": "ok" },
"themes": { "status": "ok", "count": 42, "databaseBytes": 212992 },
"cache": { "status": "ok", "entries": 24 }
}
}
```

348
docs/architecture.md Normal file
View file

@ -0,0 +1,348 @@
# Architecture
How go-bananas is put together, and why each decision was made. The reasoning
behind the load-bearing choices lives in [the ADRs](adr/); this document is the map.
---
## Contents
- [The shape of the system](#the-shape-of-the-system)
- [Packages](#packages)
- [The dependency rule](#the-dependency-rule)
- [Request lifecycle](#request-lifecycle)
- [The token engine](#the-token-engine)
- [The render cache](#the-render-cache)
- [Publishing](#publishing)
- [The database](#the-database)
- [The stack](#the-stack)
- [Cross-cutting concerns](#cross-cutting-concerns)
- [Performance](#performance)
- [What is deliberately absent](#what-is-deliberately-absent)
---
## The shape of the system
```
┌────────────────────────────────────────────┐
browser ──────────────▶│ /rt/v1 runtime public, cached │
<link>, fetch, import │ /api/v1 admin authenticated │
│ /auth/* OIDC login flow │
│ /metrics Prometheus │
│ /healthz /readyz /livez │
│ /* admin SPA (static) │
└───────────────────┬────────────────────────┘
│
chi router (v5)
│
┌─────────────────────┼─────────────────────┐
│ │ │
┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ middleware │ │ router │ │ handlers │
│ 13 layers │ │ route tree │ │ no logic │
└─────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
┌─────▼──────┐ │ ┌──────▼──────┐
│ auth │ │ │ tokenengine │
│ OIDC·keys │ │ │ colour·CSS │
│ ·RBAC │ │ │ ·validate │
└─────┬──────┘ │ └──────┬──────┘
│ │ │
└──────────┬──────────┴─────────────────────┘
│
┌─────────▼──────────┐
│ domain │ entities, invariants
└─────────┬──────────┘ errors, validation rules
│
┌─────────▼──────────┐
│ store │ the only package with SQL
│ SQLite (WAL) │
└─────────<E29480><E29480>──────────┘
│
SQLite file on disk
```
---
## Packages
| Package | Responsibility | May import |
| --- | --- | --- |
| `cmd/go-bananas` | `main()`. Signal handling and an exit code. Nothing else. | `app` |
| `app` | Dependency injection, the process lifecycle, static file serving. | everything except `cmd` |
| `config` | Environment parsing, validation, defaults. | stdlib |
| `domain` | Entities, invariants, sentinel errors, slug rules. | stdlib |
| `tokenengine` | Colour maths, validation, CSS and JSON rendering, palettes. | `domain` |
| `store` | The only package that writes SQL. Migrations, repositories. | `domain` |
| `auth` | OIDC, session cookies, role resolution. | `config`, `domain` |
| `httpapi` | Routing, middleware, request and response encoding. | `domain`, `store`, `tokenengine`, `auth`, `config` |
| `logging` | slog configuration and the `Logger` interface. | stdlib |
| `seed` | The four built-in themes, and their reconciliation rules. | `domain`, `tokenengine` |
| `observability` | Reserved for OpenTelemetry wiring. | — |
### The dependency rule
```
cmd → app → httpapi → auth → config
↓ ↓
tokenengine store
↓ ↓
domain ←──┘
```
Enforced by review, not by tooling, because a linter for this is more trouble than
it is worth. The rule that matters most: **`store` is the only package that contains
SQL**. A handler never writes a query, which is what makes replacing the database a
change to one package.
---
## Request lifecycle
A runtime stylesheet request, which is the overwhelming majority of traffic:
```
1. RequestIDMiddleware assign or propagate a correlation id
2. RecoverMiddleware a panic becomes a 500, not a dead process
3. LoggingMiddleware one structured line, on completion
4. TrustedProxyMiddleware resolve the client address, believing X-Forwarded-For
only from a configured proxy
5. CORSMiddleware preflight, for the admin API
6. SecurityHeaders CSP, nosniff, frame-deny, HSTS when TLS
7. BodyLimitMiddleware 2 MiB cap
8. AuthMiddleware resolve the caller; never rejects
9. MetricsMiddleware record route pattern, status and latency
10. RateLimitMiddleware fixed window, keyed by route class and client
11. Handler
store.Get(slug) ← the only database access, on a cache miss
cache.CSS(...) ← one map lookup on a cache hit
renderDeclarations(...) ← only on a miss
writeCacheable(...) ETag, Cache-Control, 304 when unchanged
```
Layers 1–3 are outermost so that everything after them is covered by the panic
recovery. Layer 8 authenticates without rejecting, so a public endpoint can serve
while a protected one responds 401 with a proper JSON body rather than a bare
`net/http` rejection.
The steady-state cost is one map lookup, one string write, and no database access.
---
## The token engine
Pure functions over a token document. No I/O, no global state, safe for concurrent
use — which is what lets the HTTP layer cache rendered themes aggressively and
pre-warm them at start-up.
### Validation
`Validate(doc) → ValidationResult` returns every issue, ordered by severity then
path so the output is deterministic. Three tiers:
- **Errors** block publication: an unknown group, a value of the wrong type, a
malformed colour, a reserved key used as a group.
- **Warnings** do not block: a missing recommended token, no dark scheme declared.
- **Info** is advisory.
### Rendering
`Render(theme, options) → string` produces the stylesheet:
1. Resolve the base document.
2. Deep-merge the requested appearance's overrides on top.
3. Flatten to leaves, in sorted path order.
4. Append derived tokens for values the author did not define.
5. Emit scoped selectors: the theme's default appearance, the alternate, a `.dark`
ancestor, and optionally `prefers-color-scheme`.
6. Emit the component layer inside `@layer bananas`.
**Sorted path order** is not cosmetic. It makes the output byte-stable, so the ETag
is stable, so a re-render of an unchanged theme produces a cache hit at the CDN rather
than a 200 with a fresh body.
### Colour
Everything the engine needs is computed from WCAG 2.1 relative luminance:
`ContrastRatio`, `BestForeground` (black or white, whichever wins), `Mix` with
straight-alpha compositing, `HSLHex`, and `GenerateRamp` — the palette builder behind
the editor's one-click ramp and the reason the built-in themes are tonally consistent.
Non-`#rrggbb` syntaxes (`oklch()`, `color()`, CSS keywords) parse, render faithfully,
and are excluded from contrast computation rather than guessed at.
---
## The render cache
The single most important performance component.
| | |
| --- | --- |
| Key | `slug \| appearance \| variant` |
| Value | the rendered string, plus a content-hash ETag |
| Invalidated | on write, for exactly one theme's entries |
| Eviction | none: themes are bounded by human authorship |
| Pre-warmed | both appearances × all three variants, at start-up |
Rendering a theme traverses its whole token document. Serving the cached string is one
map lookup. Because the key includes the version, publishing invalidates precisely
that theme and nothing else, and a re-render after a restart produces byte-identical
output, so a CDN's existing entry stays valid.
The ETag is a content hash 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: a validator can never disagree with the
body it describes.
---
## Publishing
```
POST /api/v1/themes/{slug}/publish
│
├─ 1. load the theme
├─ 2. Validate(tokens) ← errors block; warnings do not
├─ 3. transaction:
│ UPDATE themes SET status='published', version=version+1, …
│ INSERT INTO theme_versions … ← immutable snapshot
├─ 4. cache.Invalidate(slug) ← exactly this theme
└─ 5. audit append ← actor, action, target, IP
```
Every mutation bumps `version`, because the version is the cache key for the rendered
CSS and must change whenever the rendered output could.
History is **append-only**. A rollback restores an old snapshot as a *new* version
rather than rewinding, so an application already deployed against version 7 can never
have version 7 silently taken away.
---
## The database
See [ADR 0001](adr/0001-embedded-sqlite.md) for the full argument. In brief: a single
file, WAL journalling, `synchronous=NORMAL`, a 16 MiB page cache, mmap reads, and a
deliberately small connection pool.
Migrations are embedded with `go:embed`, applied at start-up, and tracked in a
`schema_migrations` table. They are idempotent by construction.
Timestamps are TEXT in RFC 3339, which sorts lexicographically, so `ORDER BY` and range
filters need no date parsing and no timezone ambiguity.
Token documents are JSON columns. They are always read and written whole, never
queried into — which is exactly the case where a JSON column is the right shape and a
join table would be ceremony with no benefit.
---
## The stack
| Layer | Choice | Why |
| --- | --- | --- |
| Language | Go 1.27 | Static binaries, trivial cross-compilation, a good standard library |
| Router | chi v5 | Small, `net/http`-compatible, middleware composition, no magic |
| Database | `modernc.org/sqlite` | Pure Go: no cgo, static binary, no libc |
| OIDC | `coreos/go-oidc` v3 | Discovery-driven: provider-agnostic from one issuer URL |
| Session | HMAC-signed cookie | Replica-agnostic, no shared state |
| Logging | `log/slog` | Standard library; no vendor in the graph |
| Metrics | hand-written collector | ~200 lines, no dependency in a security-sensitive service |
| Frontend | React 19 | The most common consumer, and the admin UI's own choice |
| Build | Vite 8 | Fast, and the SDK is served rather than published |
| Styling | Tailwind 4 | The admin UI consumes runtime variables through Tailwind's namespaces |
| Data | TanStack Query 5 | Server-state management without inventing a cache |
| Validation | Zod 4 | Runtime parsing at the network boundary |
| Routing | React Router 7 | Data router; `lazy` route splitting |
| Language | TypeScript 6.0.3 | Newest release with the compiler API the linters need — [ADR 0004](adr/0004-typescript-version.md) |
Deliberately absent: no ORM (the queries are simple and an ORM would hide them), no
DI framework (constructor injection is enough), no GraphQL (a stylesheet is a GET), no
Redis (the cache is process-local), no component library in the admin UI (it would
fight the runtime variables).
---
## Cross-cutting concerns
### Authentication
Two mechanisms: an OIDC session cookie, and a development bypass that refuses to
initialise outside development.
The session cookie is the only credential. There is no bearer-token path, so a
request carrying an `Authorization` header is treated exactly like one without: a
stale or forged token cannot grant anything. Machine credentials were removed
because there is no machine caller left — the runtime API is public and the
administration API is for people.
Roles come from provider groups. The mapping fails **closed**: a user in no
configured group is a viewer. A mapping mistake must grant nothing, not everything.
### Authorisation
Enforced in the server on every admin route. The UI hides what the caller cannot do,
as a convenience — never as the control.
### Audit
Every mutation records actor, action, target, timestamp and IP. The actor comes from
the authenticated identity, never from the request body. A write failure is logged,
not propagated: losing an audit row is bad, failing a publish because of it is worse.
### Observability
Structured JSON logs with a correlation id on every line. Prometheus metrics with
request counts by route pattern (never by concrete path — a thousand theme slugs must
not become a thousand time series), a latency histogram, cache hit rate, and Go
runtime gauges. Three probes: `/livez` for liveness, `/healthz` for liveness with
build information, `/readyz` for readiness, which is the only one that touches the
database.
### Errors
One vocabulary. The domain returns sentinel errors; the store translates driver
errors once; `writeProblem` maps sentinels to status codes once; the frontend client
turns a non-2xx into an `ApiError` carrying the code and the per-field messages. A
handler never chooses a status code itself, and no repository matches an error string.
---
## Performance
What the runtime path actually costs, with a warm cache:
| Operation | Cost |
| --- | --- |
| Stylesheet hit | one map lookup + one string write |
| Stylesheet miss | token document traversal, ~100 µs for 200 tokens |
| Manifest | one query, small result, cached by the CDN for 30 s |
| Admin list | one indexed query |
| Save | one transaction, two writes, one cache invalidation |
Measured on the built-in themes, a cold stylesheet render is sub-millisecond, which is
why the render cache is an optimisation rather than a requirement. It exists to remove
the work entirely under load, and to make the first request after a deploy fast.
The design consequence: **the hot path is not the database**. That is what makes an
embedded database viable here, and it is why the connection pool is small on purpose.
---
## What is deliberately absent
| Not used | Why |
| --- | --- |
| An ORM | The queries are ten lines each; an ORM would hide the SQL that matters for the hot path |
| Redis | The render cache is process-local and loses nothing on restart. A second service to operate for a cache is a bad trade — until multi-node, where it becomes a good one |
| DI framework | Constructor injection in `app.New` is clearer than a container |
| GraphQL | A stylesheet is a GET. A schema, a server and a client library to deliver a JSON document is a poor trade |
| A component library in the admin UI | It would fight the runtime variables, which are the point |
| Server-side rendering of tokens per request | Every consumer would implement its own fetcher, cache, fallback and revalidation, and they would all get it subtly wrong |
| A message queue | The write path is a human clicking publish |
| Feature flags | The chart covers configuration; a flag system for four booleans is a second way to be wrong |
Each of these would be the right answer under different constraints, and the ADR
process is how one of them becomes the answer here.

547
docs/deployment.md Normal file
View file

@ -0,0 +1,547 @@
# Deployment
Running go-bananas, from a laptop to a production cluster.
---
## Contents
- [The deployment checklist](#the-deployment-checklist)
- [Configuration](#configuration)
- [Authentication](#authentication)
- [Helm](#helm)
- [The production checklist](#the-production-checklist)
- [Scaling](#scaling)
- [Backup and restore](#backup-and-restore)
- [Behind an ingress](#behind-an-ingress)
- [Monitoring](#monitoring)
- [Upgrading](#upgrading)
- [Troubleshooting](#troubleshooting)
---
## The deployment checklist
Four things must be right. Everything else has a working default.
1. **`config.publicBaseUrl`** — the externally reachable origin, no trailing slash.
It builds the OIDC redirect URI, the absolute URLs in the runtime manifest and the
HSTS decision. Wrong means a login loop.
2. **`identity.mode`** — `oidc` in production. `dev` and `disabled` are refused by the
application outside a development environment.
3. **The OIDC client is registered** with that exact redirect URI:
`{publicBaseUrl}/auth/callback`.
4. **`identity.oidc.existingSecret`** — a Secret you manage, holding a session secret
of at least 32 bytes and the provider's client secret.
The Helm chart refuses to render a manifest it knows is wrong, so items 1–2 fail at
`helm template` time rather than at run time.
---
## Configuration
Everything is an environment variable. Both `BANANAS_` and `GO_BANANAS_` prefixes are
accepted, so a ConfigMap can be mounted under a distinct prefix without colliding
with the pod's own environment. That is what makes one image usable across
environments.
### Server
| Variable | Default | Notes |
| --- | --- | --- |
| `BANANAS_ENVIRONMENT` | `development` | `production` disables every development affordance |
| `BANANAS_HTTP_ADDR` | `:8080` | |
| `BANANAS_PUBLIC_BASE_URL` | `http://localhost:8080` | No trailing slash. Required in practice |
| `BANANAS_BASE_PATH` | — | For a path-based ingress |
| `BANANAS_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
| `BANANAS_LOG_FORMAT` | `text` | `json` in production |
| `BANANAS_HTTP_READ_TIMEOUT` | `15s` | |
| `BANANAS_HTTP_WRITE_TIMEOUT` | `30s` | Generous: this is the hot asset path |
| `BANANAS_SHUTDOWN_TIMEOUT` | `25s` | Must be below the pod's grace period |
### Storage
| Variable | Default | Notes |
| --- | --- | --- |
| `BANANAS_DATABASE_PATH` | `./data/go-bananas.db` | |
| `BANANAS_DB_MAX_CONNS` | `8` | Small on purpose. SQLite serialises writers |
| `BANANAS_DB_BUSY_TIMEOUT` | `5s` | How long a blocked writer waits |
| `BANANAS_SEED_ON_START` | `true` | Reconciles the built-in themes |
### Runtime
| Variable | Default | Notes |
| --- | --- | --- |
| `BANANAS_RUNTIME_PUBLIC` | `true` | The runtime API requires no credential by design |
| `BANANAS_RUNTIME_CACHE_CONTROL` | `public, max-age=60, s-maxage=300, stale-while-revalidate=86400` | |
| `BANANAS_RUNTIME_CDN_BASE_URL` | — | Absolute base for the manifest, when assets come from a CDN |
| `BANANAS_RATE_LIMIT_PER_MINUTE` | `3000` | High: the pattern is one request per page view |
### Network
| Variable | Default | Notes |
| --- | --- | --- |
| `BANANAS_TRUSTED_PROXIES` | — | CIDRs whose `X-Forwarded-For` is believed. Empty means forwarding headers are ignored entirely |
| `BANANAS_CORS_ALLOWED_ORIGINS` | — | For the admin API only. The runtime API is public by design |
| `BANANAS_ENABLE_METRICS` | `true` | |
| `BANANAS_ENABLE_PROFILER` | `false` | A network-reachable profiler is a DoS surface. Pair with a NetworkPolicy |
### Authentication
| Variable | Default | Notes |
| --- | --- | --- |
| `BANANAS_AUTH_MODE` | `dev` | `oidc`, `dev`, `none`. The latter two are refused outside development |
| `BANANAS_SESSION_SECRET` | ephemeral in dev | At least 32 bytes. Rotating it logs out everyone |
| `BANANAS_SESSION_TTL` | `8h` | |
| `BANANAS_SESSION_SECURE` | `true` outside dev | |
| `BANANAS_SESSION_SAMESITE` | `Lax` | `None` requires `Secure` |
| `BANANAS_OIDC_ISSUER` | — | Must match the `iss` claim exactly |
| `BANANAS_OIDC_CLIENT_ID` | — | |
| `BANANAS_OIDC_CLIENT_SECRET` | — | From a Secret, never a ConfigMap |
| `BANANAS_OIDC_SCOPES` | `profile,email,groups` | `groups` is what the role mapping reads |
| `BANANAS_OIDC_ADMIN_GROUPS` | — | Comma separated |
| `BANANAS_OIDC_EDITOR_GROUPS` | — | |
| `BANANAS_OIDC_VIEWER_GROUPS` | — | |
| `BANANAS_OIDC_REDIRECT_URL` | derived | Override only if the provider has a different URI registered |
| `BANANAS_OIDC_SKIP_TLS_VERIFY` | `false` | Development only. Refused in production |
Full reference in `internal/config/config.go`, which is the single source of truth and
carries the reasoning for each default.
---
## Authentication
### The three modes
| Mode | Behaviour | Allowed in production |
| --- | --- | --- |
| `oidc` | OpenID Connect against a real provider | **Yes. This is the mode.** |
| `dev` | Every request is a local administrator | No. Refused by the application |
| `none` | Every request is granted administrator access | No. Refused by the application |
The refusal is in the code, not in documentation, and it is a hard `fail` in the
constructor — an accidentally deployed bypass is a total authentication bypass, so it
is made impossible to construct.
### The shared development realm
The chart defaults to `https://auth-dev.urpi.be/realms/urpi`, so a development cluster
works with no identity configuration.
The realm path is part of the issuer. The value 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, and it is
worth checking first when a login loop appears.
### Registering the client
Redirect URIs, exact:
```
https://themes.example.com/auth/callback
https://bananas-dev.example.com/auth/callback
```
Post-logout redirect, if the provider requires one:
```
https://themes.example.com/
```
Web origins, for a cross-origin admin UI (same-origin needs none):
```
https://app.example.com
```
### Group-to-role mapping
```yaml
identity:
oidc:
adminGroups: [platform-admins]
editorGroups: [design-team]
viewerGroups: [everyone]
```
The most privileged matching group wins. **A user in no configured group gets
viewer.** That is deliberate: a mapping mistake must fail closed.
Group names are realm-specific and are deliberately not guessed by the chart. Read
them from the provider:
```bash
curl -s "$ISSUER/protocol/openid-connect/certs" | jq -r '.keys[].kid'
# or, with a token:
curl -s -H "Authorization: Bearer $TOKEN" "$ISSUER/admin/realms/urpi/groups" | jq '.[].name'
```
### A note on sessions
The session is a signed cookie with no server-side record
([ADR 0003](adr/0003-stateful-sessions.md)). Concretely:
- any replica authenticates any request, so a rollout logs nobody out;
- a stolen session cannot be revoked before it expires — bounded by
`BANANAS_SESSION_TTL`, eight hours by default;
- full invalidation means rotating the session secret, which logs out everyone.
For a design-token service this is the right trade. If you need per-session
revocation, or your organisation's policy requires it, this is the one component to
replace.
---
## Helm
```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.clientId=go-bananas \
--set identity.oidc.existingSecret=go-bananas-auth \
--set identity.oidc.adminGroups[0]=platform-admins \
--set ingress.enabled=true \
--set ingress.hosts[0].host=themes.example.com \
--set ingress.tls[0].secretName=go-bananas-tls \
--set ingress.tls[0].hosts[0]=themes.example.com \
--wait --timeout 5m
```
### The Secret
```bash
kubectl create secret generic go-bananas-auth \
--namespace go-bananas \
--from-literal=session-secret="$(openssl rand -base64 48)" \
--from-literal=oidc-client-secret="$CLIENT_SECRET_FROM_YOUR_SECRET_MANAGER"
```
Key names are configurable through `identity.sessionSecretKey`,
`identity.oidc.clientSecretKey` and `identity.oidc.existingSecretKeys`, for a
Secret managed by an external secrets operator.
A chart-managed Secret (`identity.oidc.generateSecret=true`) generates the session key
and preserves it across upgrades by reading the live Secret back — so an upgrade does
not sign everyone out. It **cannot** generate the OIDC client secret, because that has
to come from the provider. A generated Secret therefore has an empty client secret,
which means the deployment fails to authenticate until one is supplied. For anything
real, use `existingSecret`.
### Validation at render time
The chart fails rather than producing a manifest it knows is wrong:
| Misconfiguration | Message |
| --- | --- |
| `publicBaseUrl` empty | Required; names what it is used for |
| `publicBaseUrl` has a trailing slash | The exact acceptable form |
| `shutdownTimeoutMs` ≥ grace period | Kubernetes would SIGKILL mid-shutdown |
| `identity.mode` unknown | The three valid modes |
| `identity.mode=oidc`, no issuer | Required, with the development-realm form |
| `identity.mode=oidc`, no secret | Names both ways to supply one |
| Both `existingSecret` and `generateSecret` | Mutually exclusive |
| `skipTLSVerify` with `environment: production` | Refused, with the reason |
| Any value outside the schema | The JSON-schema path |
Failing at render time means a bad release never reaches the API server: there is no
half-created Deployment and no `CrashLoopBackOff` to diagnose at 3am.
### Render it before you apply it
```bash
make helm-template # defaults
make helm-template-auth # configured for OIDC
helm lint deploy/helm/go-bananas --strict
```
---
## The production checklist
- [ ] `config.environment: production`
- [ ] `config.publicBaseUrl` is the real HTTPS origin, no trailing slash
- [ ] `identity.mode: oidc`
- [ ] The OIDC client registered with `{publicBaseUrl}/auth/callback`
- [ ] `identity.oidc.issuer` verified against the provider's discovery document
- [ ] Group-to-role mapping populated and **tested with a real account** in each role
- [ ] `identity.oidc.existingSecret` set; the session secret is at least 32 random bytes
- [ ] Ingress with TLS; `service.sessionSecure: true`
- [ ] `config.trustedProxies` set to the ingress controller, so the audit log records real client addresses
- [ ] Persistence enabled; a snapshot scheduled
- [ ] `networkPolicy.enabled: true`
- [ ] Resources set, with a memory limit
- [ ] Prometheus scraping `/metrics`
- [ ] A backup job that copies the SQLite file
- [ ] `make helm-template` reviewed
### Verify a deployment
```bash
kubectl --namespace go-bananas get pods -l app.kubernetes.io/instance=go-bananas
kubectl --namespace go-bananas port-forward svc/go-bananas 8080:80
curl -s localhost:8080/readyz | jq # "status": "ready"
curl -s localhost:8080/rt/v1/manifest.json | jq '.themes[].slug'
curl -sI 'localhost:8080/rt/v1/theme.css?slug=midnight' | grep -i x-theme-version
```
Then open the admin interface and confirm a real account can sign in and see a theme.
---
## Scaling
### Read scaling: put a CDN in front
The runtime is the hot path and it is entirely cacheable. Immutable-by-URL responses
mean a CDN is a complete answer for read scaling.
```yaml
config:
runtimeCdnBaseUrl: https://cdn.example.com/themes
```
The manifest then hands consumers the CDN URL, so they never reach the service at all.
### The one real constraint: one writer
SQLite on a local volume is single-node. Three shapes:
**One replica, one volume.** The recommended production shape. One replica serves a
very large number of consumers, because after the render cache a request is a map
lookup.
**Many replicas, a shared filesystem.** Works with NFS, CephFS or a ReadWriteMany
volume — anything that permits concurrent mounts and shared mmap, which WAL mode
requires. Expect lower throughput than local disk; measure before choosing it.
```yaml
persistence:
type: persistentVolumeClaim
accessMode: ReadWriteMany
size: 2Gi
storageClassName: nfs-client
deployment:
replicaCount: 3
```
The chart annotates the Deployment when a ReadWriteOnce volume is combined with more
than one replica, because that combination cannot work: only one pod mounts the
volume and the others crash-loop.
### If you outgrow SQLite
Move to PostgreSQL. The change is contained to `internal/store` — the only package
with SQL — and its interface is the set of methods it exposes rather than SQLite's
features. [ADR 0001](adr/0001-embedded-sqlite.md) covers when that point arrives.
---
## Backup and restore
A backup is a copy of one file, and the supported way to take it is the `backup`
subcommand:
```bash
kubectl exec -n go-bananas deploy/go-bananas -- \
/usr/local/bin/go-bananas backup --to /srv/data/snapshots/$(date -u +%Y%m%dT%H%M%SZ).db
```
It uses SQLite's `VACUUM INTO`, which reads through the database's own backup
machinery. Two consequences matter:
- **It is safe against a running service.** The snapshot is consistent even while
the application is serving writes, and it does not block them for the length of
the copy. There is no need to scale to zero.
- **Do not substitute `cp` for it.** Copying a SQLite file while a write is in
flight produces a torn copy: the header can describe one set of pages while the
file holds another. The result frequently opens without complaint and is corrupt.
Copying the `-wal` file alongside it is worse, because the two files are then
captured at different instants and the pair may not correspond to any point in
time.
The command refuses to overwrite an existing file, so a stale snapshot can never be
mistaken for a fresh one, and it will not snapshot an in-memory database, which has
nothing to preserve.
Run it from cron, a Kubernetes CronJob, or by hand. When you do it by hand, the
output path is printed in full so you can confirm where the file went.
### Restoring
```bash
kubectl scale deploy/go-bananas -n go-bananas --replicas=0
kubectl cp ./backup.db go-bananas-0:/srv/data/go-bananas.db
kubectl delete pod -n go-bananas -l app.kubernetes.io/instance=go-bananas
kubectl scale deploy/go-bananas -n go-bananas --replicas=1
```
A restored database serves immediately. The built-in themes are reconciled at
start-up, and a built-in theme an operator had edited is recognised by its version and
left alone.
Scale to zero before replacing the file. Copying over a database that another
process has open produces exactly the torn copy described above, and the running
service will keep writing to the file you just replaced.
### Retention
The audit log is the only unbounded table. Everything else is bounded by the number of
themes and their versions. Prune it on a schedule:
```sql
DELETE FROM audit_log WHERE at < datetime('now', '-180 days');
```
The chart's snapshot CronJob (`persistence.snapshot.enabled`) writes to
`<mountPath>/snapshots` on the same volume, so a snapshot survives the pod that took
it. Restore from there with the same `backup` machinery in reverse.
---
## Behind an ingress
### Path-based
```yaml
config:
publicBaseUrl: https://example.com/themes
basePath: /themes
```
Every route, including the OIDC redirect, is prefixed. The provider must be registered
with `https://example.com/themes/auth/callback`.
### Correct client addresses
`X-Forwarded-For` is believed only from a configured proxy. Without this, any client
can set it and spoof its address in the audit log.
```yaml
config:
trustedProxies:
- 10.42.0.0/16 # the ingress controller's node or pod CIDR
```
### TLS
Terminate at the ingress. `service.sessionSecure: true` marks the session cookie
`Secure`, and HSTS is advertised only when `publicBaseUrl` is `https://`.
Behind a proxy that terminates TLS and forwards `X-Forwarded-Proto`, the service
derives scheme from `publicBaseUrl`, not from the header, which is the correct trust
boundary: the operator states the truth in configuration rather than a client
asserting it.
---
## Monitoring
### Metrics
| Metric | Type | Alert when |
| --- | --- | --- |
| `bananas_http_requests_total` | counter | Rate collapses, or 5xx ratio climbs |
| `bananas_http_request_duration_seconds` | histogram | p99 above ~250 ms |
| `bananas_cache_hits_total` / `_misses_total` | counter | Hit rate below ~80% after warm-up |
| `bananas_go_memory_bytes` | gauge | Approaches the limit |
| `bananas_uptime_seconds` | gauge | Resets: the process restarted |
A sustained cache miss rate means a publish loop, a version-keyed cache storm, or
more themes than expected. It is the first thing to look at when latency rises.
### Logs
One JSON line per request, carrying `requestId`, `route`, `status`, `duration`,
`clientIp`, `subject` and `query`. The `subject` field is the authenticated identity,
so "who did this" needs no second query.
```bash
kubectl logs -n go-bananas -l app.kubernetes.io/instance=go-bananas \
| jq 'select(.status >= 500)'
```
### Probes
| Path | Use | Touches the database |
| --- | --- | --- |
| `/livez` | Liveness | No |
| `/healthz` | Liveness, with build information | No |
| `/readyz` | Readiness | **Yes** |
The split is deliberate. A liveness probe that touched the database would have the
kubelet restart a process that is otherwise healthy, turning a dependency blip into
an outage. Readiness does check the database, because a pod that cannot reach it
cannot serve.
---
## Upgrading
```bash
helm upgrade go-bananas oci://ghcr.io/urpi/charts/go-bananas \
--namespace go-bananas \
--set image.tag=1.4.2 \
--wait --timeout 5m
```
Migrations apply at start-up and are idempotent. The built-in themes are reconciled:
missing ones are created, and ones an operator has edited are recognised by version
and left alone.
Enable `persistence.snapshot.enabled` for a scheduled snapshot. It is a CronJob
that runs before each upgrade, and it costs a few megabytes.
### Rolling back
```bash
helm rollback go-bananas 3 --namespace go-bananas --wait
```
Safe, because the schema is additive and migrations are idempotent. If a future change
is destructive, it gets a snapshot step in the chart and an entry in
[the runbook](operations.md).
---
## Troubleshooting
**A login loop.** The redirect URI does not match what the provider has registered.
Check in this order:
1. `{publicBaseUrl}/auth/callback` in the provider's client configuration.
2. `publicBaseUrl` has no trailing slash and matches the scheme exactly.
3. `basePath` is reflected in the registered URI.
4. `identity.oidc.issuer` matches the discovery document's `issuer` field **exactly**.
```bash
kubectl exec -n go-bananas deploy/go-bananas -- \
wget -qO- "$ISSUER/.well-known/openid-configuration" | jq -r .issuer
```
**`invalid issuer`.** The `iss` claim in the ID token differs from the configured
issuer. Usually a trailing slash, or a realm path that is not the one configured.
**`no such user in the roles mapping`.** Not an error this service produces: a user in
no configured group gets viewer, silently. Check the `groups` claim is present —
`groups` must be in `OIDC_SCOPES`.
**A pod stuck in `CrashLoopBackOff`.** Almost always configuration. The first log line
is the reason; every configuration problem is reported in full at start-up.
**`SQLITE_BUSY`.** A writer is waiting for the lock. Raise
`config.database.busyTimeoutMs`, or check for a publish loop.
**Replicas crash-looping with a ReadWriteOnce volume.** Exactly one pod can mount it.
Set `deployment.replicaCount: 1`, or use a filesystem that permits concurrent mounts.
**A theme looks wrong in one application.** Compare `X-Theme-Version` across
applications. A CDN or browser holding an old version explains it; the runtime
manifest's `baseUrl` will show whether a CDN is in the path.
**The stylesheet is stale after a publish.** Expected within one `s-maxage` window
(300 s by default). To force it, lower `config.runtimeCacheControl` or purge the CDN.

223
docs/development.md Normal file
View file

@ -0,0 +1,223 @@
# Development
Working on go-bananas.
## Requirements
| Tool | Version | Why |
| --- | --- | --- |
| Go | 1.27 or later | Static binaries, `net/http` routing primitives |
| Node.js | 22 or later | Vite 8 requires it |
| Docker | 24 or later | BuildKit for the multi-stage image |
| Helm | 3.14 or later | Chart validation and `helm lint --strict` |
Optional, installed by `make tools`: `air` for hot reload, `golangci-lint` for
linting, `sqlc` if you add a query.
## Setup
```bash
git clone https://github.com/urpi/go-bananas
cd go-bananas
make install # Go modules, admin UI dependencies, SDK dependencies
make tools # air, golangci-lint, sqlc
```
## Running
```bash
make dev-all # API on :8080, admin UI on :5173 with a proxy
```
Open <http://localhost:5173>. The Vite dev server proxies `/api`, `/rt` and `/auth`
to the Go process, so the session cookie works exactly as it will in production.
Same-origin matters: a cross-origin setup would not authenticate at all, and the
problem would look like a bug rather than a topology difference.
Other entry points:
```bash
make dev-api # the API alone, with hot reload
make dev-web # the admin UI alone
make run # build everything, run the real binary
```
### Trying the theme preference locally
The theme switcher in the header writes to the account, not just to the browser, so the
selection follows you to another browser and survives a re-login. To see that end to
end without a second browser:
```bash
# 1. Start the stack and sign in. `make dev-all` defaults to AUTH_MODE=dev, so the
# dev-user query parameter is enough to be somebody:
# http://localhost:5173/?dev-user=designer@example.test
# 2. Pick a published theme and a scheme in the header switcher. That is a PUT to
# /api/v1/me/preferences.
# 3. Read back what the server decided, which is the authoritative value:
curl -s localhost:8080/api/v1/me/preferences \
-H 'Cookie: go-bananas-session=...' | jq
# 4. Confirm it arrives with the session, which is how the UI gets it on first paint:
curl -s localhost:8080/api/v1/auth/me \
-H 'Cookie: go-bananas-session=...' | jq .preferences
# 5. Prove it is account state rather than cookie state by clearing local storage,
# or by asking in a second browser profile: the theme you chose is still selected.
```
Two things are deliberately not local state:
- **The theme is a preference, not a session claim.** The token comes from the identity
provider, so this service cannot put anything in it; `/api/v1/me/preferences` is the
contract instead.
- **`localStorage` is still read on the first render.** A server round trip cannot
happen before paint, and waiting for one flashes the wrong theme on every load. The
stored preference is adopted as soon as the session arrives, and a change writes both
places, so the next paint is correct even offline.
Pointing it at a draft theme returns 400, and pointing it at a theme that has since been
archived reads back as no choice. Both are covered in `docs/api.md`.
## Layout
```
cmd/go-bananas/ main(): signals, exit codes, nothing else
internal/
app/ dependency injection and the process lifecycle
auth/ OIDC, sessions, roles
config/ environment parsing and validation
domain/ entities, invariants, sentinel errors
httpapi/ routing, middleware, handlers
logging/ slog configuration
seed/ the built-in themes
store/ SQLite: migrations and repositories
tokenengine/ pure logic: colour, validation, rendering
store/migrations/ embedded SQL, applied at start-up
web/ the administration UI (React, Vite, Tailwind)
sdk/ the runtime SDKs, served from /rt/v1
deploy/helm/go-bananas/ the chart
docs/ this, plus the ADRs
```
See [architecture.md](architecture.md) for the dependency rule and
[standards.md](standards.md) for the conventions. Read both before a substantial
change.
## Tests
```bash
make test # everything
make test-go # Go, with the race detector
make test-web # the admin UI
make test-sdk # the runtime SDK
make test-cover # Go, with a coverage profile
make bench # the token engine benchmarks
```
Every test run in CI is `go test -race`. A data race in a cache or a store is a
production incident waiting for load.
## The pre-merge gate
```bash
make verify # check + helm lint
```
This is exactly what CI runs. A pull request that does not pass it will not merge.
## Things that will surprise you
**The admin UI consumes the runtime.** It loads themes from `/rt/v1` and maps the
variables onto Tailwind's namespaces. Switching the UI to the "Light" or "Dark" mode
in the top bar loads a published theme's real stylesheet. A change to the token engine
that breaks delivery breaks the editor, immediately.
**Publishing bumps the version, and the version is the cache key.** Every mutation
does, not only publication. If you change `store.Update`, check whether the version
needs to move.
**History is append-only.** A rollback restores a snapshot as a *new* version. There
is no code path that removes a version, by design: an application deployed against
version 7 must never have version 7 taken away.
**The built-in themes are reconciled, not overwritten.** `internal/seed` compares the
stored version with the seed version. An edited built-in theme is recognised by its
higher version and left alone.
**Validation gates publication, not editing.** A draft can be incomplete. That is
what makes building a theme up incrementally practical.
## Common changes
### Adding a token group
1. Add a `GroupSpec` to `tokenengine.Groups`.
2. Add the tokens to the built-in themes in `internal/seed`.
3. Run `make test-go`. The seed tests validate every built-in theme and check WCAG
contrast for body text on every surface, so an inaccessible colour fails the build.
### Adding an API endpoint
1. The handler in `internal/httpapi`. No business logic; call the store, encode the
result.
2. The route in `internal/httpapi/router.go`, with the role it requires.
3. The type in `web/src/lib/types.ts` and the client function in `web/src/lib/api.ts`.
4. The documentation in `docs/api.md`.
5. Tests for each branch and each authorisation decision.
### Adding a configuration variable
1. The field and default in `internal/config`.
2. The parsing in `loadFrom`.
3. A validation rule in `Validate` if it can be wrong.
4. The environment variable in the chart's `configmap.yaml` and `values.yaml`, and in
`values.schema.json`.
5. The table in `docs/deployment.md`.
A variable with no validation is a variable that will be set to something wrong.
### Changing the runtime contract
Add, do not change. `/rt/v1` is a contract with applications that have no way to
upgrade in step with you. Adding an optional query parameter or a response field is
safe. Changing the meaning of an existing one is not.
## Conventions
Enforced, not suggested. `make check` is the same command CI runs.
| | |
| --- | --- |
| Go formatting | `gofmt`, `make fmt-check` |
| Go lint | `golangci-lint`, `make lint-go` |
| Go imports | `gci`, configured in `.golangci.yml` |
| TypeScript | `tsc --build`, strict |
| Frontend lint | `eslint --max-warnings 0` |
| Frontend format | `prettier`, including the Tailwind plugin |
| Chart | `helm lint --strict` plus `values.schema.json` |
Read [standards.md](standards.md) before contributing. It is the difference between a
change that fits and one that has to be rewritten.
## Troubleshooting
**Port 8080 in use.** `make dev-api` with `BANANAS_HTTP_ADDR=:8081`, or stop whatever
holds the port.
**The admin UI cannot reach the API.** The Vite proxy targets
`http://localhost:8080`. If the API is elsewhere, edit `server.proxy` in
`vite.config.ts`.
**`npm ci` fails.** A stale `node_modules`. `make clean-cache`.
**`go build` fails after a dependency change.** `make tidy`, then `make install`.
**A test is flaky.** Do not re-run it and hope. Find the ordering dependency. The most
common cause here is module-level state in a frontend test: use `vi.resetModules()`
and a fresh module instance.
**The chart will not render.** Read the error. Every chart failure names the value to
change — that is deliberate, and the alternative is a failure at run time instead.

565
docs/integration.md Normal file
View file

@ -0,0 +1,565 @@
# Integrating a go-bananas theme
Everything here works without installing anything. A theme is data delivered over
HTTP, so the integration is a URL, not a dependency.
---
## Contents
1. [The thirty-second version](#the-thirty-second-version)
2. [How theming works](#how-theming-works)
3. [Choosing an integration](#choosing-an-integration)
4. [Any HTML page](#1-any-html-page)
5. [Plain JavaScript](#2-plain-javascript)
6. [React](#3-react)
7. [Server-rendered frameworks](#4-server-rendered-frameworks)
8. [Tailwind](#5-tailwind)
9. [Reading tokens in JavaScript](#reading-tokens-in-javascript)
10. [Email and documents](#email-and-documents)
11. [Runtime switching](#runtime-switching)
12. [Versioning and caching](#versioning-and-caching)
13. [Offline and failure behaviour](#offline-and-failure-behaviour)
14. [Multiple themes on one page](#multiple-themes-on-one-page)
---
## The thirty-second version
```html
<link rel="stylesheet" href="https://themes.example.com/rt/v1/theme.css?slug=midnight">
```
Done. Every `--bn-*` variable is now defined, and the bundled `bn-*` component
classes are available.
---
## How theming works
A theme renders to a stylesheet of CSS custom properties, all prefixed `--bn-`, plus
a small component layer.
```css
[data-bananas-theme="midnight"] {
--bn-color-semantic-background: #1a1d23;
--bn-color-semantic-text: #d9dbe5;
--bn-color-semantic-primary: #7c6bf5;
--bn-space-4: 1rem;
--bn-radius-lg: 0.75rem;
}
[data-bananas-theme="midnight"][data-bananas-appearance="dark"] { /* … */ }
.dark [data-bananas-theme="midnight"],
[data-bananas-theme="midnight"].dark { /* … */ }
```
Two attributes do all the work:
| Attribute | Values | Effect |
| --- | --- | --- |
| `data-bananas-theme` | a theme slug | Activates that theme on the element and its subtree |
| `data-bananas-appearance` | `light`, `dark` | Selects the colour scheme |
Put `data-bananas-theme` on `<html>` for the whole page, or on any element for a
subtree. Several themes can coexist.
The stylesheet also honours a `.dark` ancestor class and `prefers-color-scheme`, so it
fits an application that already toggles dark mode the Tailwind or shadcn way.
### The component layer
The generated stylesheet includes a small set of classes written entirely in terms of
the variables: `.bn-btn`, `.bn-card`, `.bn-input`, `.bn-badge`, `.bn-alert`,
`.bn-table`, `.bn-link`.
Use them to look correct immediately, then replace them with your own components as
soon as you want them. They exist so that the first integration needs no CSS work at
all.
They are placed in `@layer bananas`, so your own unlayered CSS always wins the
cascade. You will never need `!important` to override them.
Request them with `?variant=tokens` if you want the variables without the component
layer.
---
## Choosing an integration
| Your stack | Use |
| --- | --- |
| Server-rendered HTML, any framework | [Any HTML page](#1-any-html-page) |
| A SPA you control | [Plain JavaScript](#2-plain-javascript) |
| React | [React](#3-react) |
| Django, Rails, Laravel, Go templates, Jinja | [Server-rendered](#4-server-rendered-frameworks) |
| Tailwind | [Tailwind](#5-tailwind) |
| Canvas, charts, PDFs, email | [Reading tokens](#reading-tokens-in-javascript) |
---
## 1. Any HTML page
The stylesheet link. Two tags if you want the theme to follow the operating system.
```html
<link rel="stylesheet" href="https://themes.example.com/rt/v1/theme.css?slug=midnight">
<script async src="https://themes.example.com/rt/v1/embed.js"
data-theme="midnight"
data-appearance="system"></script>
```
`embed.js` is a single file with no dependencies. It sets the attributes and fetches
the stylesheet before first paint.
Configure it with data attributes:
| Attribute | Values | Default |
| --- | --- | --- |
| `data-theme` | a theme slug | `default` |
| `data-appearance` | `light`, `dark`, `system` | `system` |
| `data-variant` | `full`, `tokens`, `minified` | `full` |
| `data-storage` | `false` to skip localStorage | persists |
| `data-auto-dark` | `true` to follow the system | follows `data-appearance` |
A generated snippet for any theme is at `/rt/v1/embed?slug=<slug>`, including a live
preview of what it looks like.
---
## 2. Plain JavaScript
An ES module loaded by URL.
```html
<script type="module">
import { applyTheme, watchSystemAppearance } from 'https://themes.example.com/rt/v1/runtime.js'
watchSystemAppearance() // follow the operating system preference
await applyTheme('midnight') // fetch and apply
</script>
```
Or with a `package.json`-free bundler, by aliasing the URL:
```js
// vite.config.js, webpack, esbuild — the tool does not care which.
resolve: { alias: { 'go-bananas/runtime': 'https://themes.example.com/rt/v1/runtime.js' } }
```
The module API:
| Export | Purpose |
| --- | --- |
| `applyTheme(slug, options?)` | Fetch and apply. Returns the resolved payload. |
| `setAppearance(appearance)` | Change the scheme without re-fetching. |
| `getAppearance()`, `getSlug()` | Read the current state. |
| `clearTheme()` | Remove the theme, restoring the page's own styles. |
| `loadManifest()` | Fetch the available themes. |
| `loadTheme(slug, appearance?)` | Fetch one theme's resolved tokens. |
| `getVariables(name?)` | Read a token by name, or all of them. |
| `watchSystemAppearance(callback?)` | Follow the operating system. Returns an unsubscribe. |
| `subscribe(listener)` | Observe theme changes. Returns an unsubscribe. |
| `assetUrl(path, query?)` | Build a runtime asset URL, for your own fetches. |
There is also a global build that attaches the same API to `window.Bananas`, for a
page that cannot use modules:
```html
<script src="https://themes.example.com/rt/v1/global.js"></script>
<script>Bananas.applyTheme('midnight')</script>
```
---
## 3. React
A provider and hooks, loaded through an import map. **React stays your own copy.**
The module imports the bare specifier `react`, which the import map resolves to the
application's own React — two copies of React in one page break hooks, because each
copy has its own dispatcher state.
```html
<script type="module">
import { BananasProvider } from 'https://themes.example.com/rt/v1/react.js'
import { createRoot } from 'https://esm.sh/react-dom@19/client'
createRoot(document.getElementById('root')).render(
<BananasProvider slug="midnight" autoDark>
<App />
</BananasProvider>,
)
</script>
```
Or in a bundled application, alias the URL to a bare specifier:
```js
// vite.config.js
resolve: { alias: { 'go-bananas/runtime': 'https://themes.example.com/rt/v1/react.js' } }
```
```jsx
import { BananasProvider, useBananas, useThemeVariable } from 'go-bananas/runtime'
```
### Props
| Prop | Default | Purpose |
| --- | --- | --- |
| `slug` | — | The theme to apply |
| `appearance` | `system` | `light`, `dark` or `system` |
| `autoDark` | `false` | Follow the operating system |
| `baseUrl` | inferred | Runtime base, for a cross-origin deployment |
| `storage` | `true` | Persist the user's choice |
| `variant` | `full` | Render variant |
| `loadManifest` | `false` | Fetch the theme list on mount |
| `onError` | — | Called when loading fails |
### Hooks
```jsx
import { useBananas, useThemeVariable, useThemeVariables, useAvailableThemes } from 'go-bananas/runtime'
function ThemeSwitcher() {
const { slug, appearance, setTheme, setAppearance, status } = useBananas()
const { themes } = useAvailableThemes()
if (status === 'loading') return <Spinner />
return (
<>
<select value={slug ?? ''} onChange={(event) => setTheme(event.target.value)}>
{themes.map((theme) => (
<option key={theme.slug} value={theme.slug}>{theme.name}</option>
))}
</select>
<button onClick={() => setAppearance(appearance === 'dark' ? 'light' : 'dark')}>
{appearance === 'dark' ? 'Light' : 'Dark'}
</button>
</>
)
}
function ThemedButton() {
// Returns the resolved CSS value, so the component works in any scheme.
const background = useThemeVariable('color-semantic-primary')
const foreground = useThemeVariable('on-primary')
return <button style={{ background, color: foreground }}>Save</button>
}
```
`useBananas()` throws when used outside a provider, rather than returning a default.
A silent default would let a component render with the wrong theme and never report
the mistake.
### Cross-application consistency
`useSyncExternalStore` is used under the provider, so a theme change made outside
React — a switcher in another part of the page, a browser navigation, a
`postMessage` from an iframe — repaints the tree rather than tearing it.
---
## 4. Server-rendered frameworks
The stylesheet link is a `<link>` tag. It works identically in Django, Rails,
Laravel, Go's `html/template`, Jinja, ERB and anywhere else, because the mechanism is
the browser's, not a framework's.
```jinja
{# Django #}
<link rel="stylesheet" href="{{ theme_service_url }}/rt/v1/theme.css?slug={{ theme_slug }}">
```
```erb
<%# Rails ERB %>
<link rel="stylesheet" href="<%= themes_url %>/rt/v1/theme.css?slug=<%= @theme_slug %>">
```
```go
{{/* Go html/template */}}
<link rel="stylesheet" href="{{ .ThemeBase }}/rt/v1/theme.css?slug={{ .ThemeSlug }}">
```
For a per-request theme, resolve the slug in your handler and template it in. The
stylesheet is immutable by URL and safe to cache at any layer, so rendering the link
per request costs nothing.
### Go
```go
func themeStylesheet(base, slug string) template.HTML {
return template.HTML(fmt.Sprintf(
`<link rel="stylesheet" href="%s/rt/v1/theme.css?slug=%s">`, base, url.PathEscape(slug)))
}
```
---
## 5. Tailwind
Two options, depending on whether the theme must be live.
### Live: consume the variables
Works with any Tailwind version, needs no rebuild, and a theme publish applies
immediately.
```css
/* app.css */
@import 'tailwindcss';
@theme inline {
--color-primary: var(--bn-color-semantic-primary);
--color-background: var(--bn-color-semantic-background);
--color-foreground: var(--bn-color-semantic-text);
--color-muted: var(--bn-color-semantic-text-muted);
--color-border: var(--bn-color-semantic-border);
--color-danger: var(--bn-color-semantic-danger);
--radius-card: var(--bn-radius-lg);
}
```
Then use the semantic names normally: `bg-background text-foreground bg-primary
text-primary rounded-card`.
### Build-time: generate a preset
For a theme that changes rarely and should be baked into the CSS.
```bash
curl -s "$THEMES/rt/v1/theme.json?slug=acme" > tokens.json
npx @tokens-studio/sd-transform-cli tokens.json tokens.json
npx tailwindcss --theme ./tokens.json -i app.css -o out.css
```
The JSON is a standard [DTCG](https://www.designtokens.org/) document, so it feeds
any token pipeline rather than only this one.
---
## Reading tokens in JavaScript
Some things cannot read CSS: a canvas chart, a PDF generator, an email template, a
colour picker that needs to show a palette.
```js
const { variables, tokens, theme } = await fetch(
`${THEMES}/rt/v1/theme.json?slug=midnight`,
).then((response) => response.json())
theme.slug // 'midnight'
theme.version // 3
variables['--bn-color-semantic-primary'] // '#7c6bf5'
variables['--bn-radius-lg'] // '0.75rem'
```
The payload has three parts:
| Field | Shape | Use |
| --- | --- | --- |
| `theme` | identity | Which theme and version this is |
| `variables` | flat map | CSS custom property name to value |
| `tokens` | nested DTCG | The tree, for mapping onto component props |
The same is available from the runtime module as `getVariables()` and `loadTheme()`,
with caching.
### Chart example
```js
const { variables } = await loadTheme('midnight')
new Chart(canvas, {
data,
options: {
plugins: {
legend: { labels: { color: variables['--bn-color-semantic-text-muted'] } },
scales: {
x: { grid: { color: variables['--bn-color-semantic-border'] } },
y: { grid: { color: variables['--bn-color-semantic-border'] } },
},
},
},
})
```
---
## Email and documents
Email clients have no CSS custom property support. Resolve the variables server-side
and inline the values.
```go
func themeForEmail(base, slug string) map[string]string {
resp, err := http.Get(base + "/rt/v1/theme.json?slug=" + url.QueryEscape(slug))
if err != nil {
return nil
}
defer resp.Body.Close()
var payload struct {
Variables map[string]string `json:"variables"`
}
if json.NewDecoder(resp.Body).Decode(&payload) != nil {
return nil
}
return payload.Variables
}
// Inline the values; a <style> block is stripped by most clients.
html := fmt.Sprintf(
`<td style="background:%s;color:%s;font-family:%s">…</td>`,
v["--bn-color-semantic-surface-raised"],
v["--bn-color-semantic-text"],
v["--bn-font-family-sans"],
)
```
Resolve once per message and cache it. A theme changes rarely, so a five-minute cache
is plenty.
---
## Runtime switching
```js
// A theme picker.
import { applyTheme, loadManifest } from '.../rt/v1/runtime.js'
const { themes } = await loadManifest()
for (const theme of themes) {
button.onclick = () => applyTheme(theme.slug)
}
```
Or with no JavaScript at all:
```html
<select onchange="document.documentElement.dataset.bananasTheme = this.value">
<option value="midnight">Midnight</option>
<option value="daylight">Daylight</option>
</select>
```
The theme picker must be outside the themed subtree, or it restyles itself on every
change. Put it in a fixed header above the themed container.
---
## Versioning and caching
Runtime responses are immutable by URL.
```bash
# The latest version. This URL changes when the theme is republished.
/rt/v1/theme.css?slug=midnight
# A specific version. This URL never changes, ever.
/rt/v1/theme.css?slug=midnight&v=7
```
Pinning `v` is what makes a safe deploy possible: build against version 7, and the
render cannot change underneath you. The `/rt/v1/themes/{slug}` path form resolves
to the latest version.
| Header | Use |
| --- | --- |
| `ETag` | Revalidation. Answer a `304` and transfer no body. |
| `Cache-Control` | `max-age=60, s-maxage=300, stale-while-revalidate=86400` |
| `X-Theme-Version` | Which version was served. Log it. |
| `X-Theme-Updated-At` | When it was published. |
| `sourceURL` comment in the CSS | Navigate from browser devtools to the endpoint |
Log `X-Theme-Version` alongside your request id. "The theme looks wrong" becomes
answerable: you know which version that request resolved.
### Recommended policy
**Consumers:** CDN with `s-maxage` in the hundreds and a long
`stale-while-revalidate`. Revalidation is a few milliseconds and no body.
**Browsers:** leave the `Cache-Control` as served. The version-scoped URL means the
cache entry is never stale.
**Rollback:** publishing is a new version, and the latest-version URL follows it. A
rollback propagates within one `s-maxage` window.
---
## Offline and failure behaviour
A theme that fails to load leaves the page as it was, with the application's own
styles. It does not break the page.
That is deliberate. A theming service is a presentation concern; it should never be
able to take an application down. Every entry point catches its own failure:
```js
applyTheme('midnight').catch((error) => {
reportError(error) // the page is still usable
})
```
If you need a theme to be non-optional, add a `<style>` block in your own CSS with
your brand values as the fallback. The custom properties will be overridden when the
theme arrives, and used when it does not.
---
## Multiple themes on one page
Theme attributes scope to a subtree, so a documentation site with per-product themes
is one attribute per section.
```html
<body>
<header data-bananas-theme="midnight">
<!-- always the dark theme -->
</header>
<main>
<section data-bananas-theme="daylight">
<!-- light theme, even though the page default is dark -->
</section>
<section data-bananas-theme="high-contrast">
<!-- maximum contrast -->
</section>
</main>
</body>
```
Two rules worth knowing:
- **Only one theme stylesheet is loaded at a time.** Scoping means several themes on
one page share the variables in the most recently applied stylesheet. Themes whose
token *sets* differ will collide.
- **Appearance is per subtree.** `data-bananas-appearance` on a section overrides the
document's scheme for that section only.
```html
<section data-bananas-theme="daylight" data-bananas-appearance="dark">
<!-- the daylight theme, in its dark scheme -->
</section>
```
If you need genuinely different variable sets on one page, request each theme's
stylesheet as a separate `<link>` and scope each to its own subtree. That works
because the selectors are attribute-scoped; the later link does not overwrite the
earlier one.
---
## Checklist
- [ ] A `<link>` to the theme stylesheet, or `applyTheme` called before first paint
- [ ] The theme attribute set on `<html>`, for a page-wide theme
- [ ] Components consuming semantic tokens (`color.semantic.primary`), not palette steps
- [ ] Dark mode handled: `autoDark`, or `data-bananas-appearance` on `<html>`
- [ ] A fallback, so a theme that fails to load leaves the page usable
- [ ] `X-Theme-Version` logged with your request id
- [ ] For a multi-theme page, one stylesheet per distinct token set, correctly scoped

273
docs/operations.md Normal file
View file

@ -0,0 +1,273 @@
# Operations
Running go-bananas in production: what to watch, what to do when something breaks,
and how to change things safely.
## Contents
- [Health](#health)
- [The runbook](#the-runbook)
- [Routine operations](#routine-operations)
- [Alerts](#alerts)
- [Diagnostics](#diagnostics)
- [Capacity](#capacity)
## Health
Three endpoints, deliberately different in what they check.
| Endpoint | Checks | Touches the database | Used by |
| --- | --- | --- | --- |
| `/livez` | Nothing. The process can serve HTTP | No | Liveness probe |
| `/healthz` | The process can serve HTTP, plus build information | No | Human, uptime checks |
| `/readyz` | The database answers | Yes | Readiness probe |
The liveness probe does not touch the database on purpose. A probe that failed on a
slow database would have the kubelet restart a process that is otherwise healthy,
turning a dependency blip into an outage. Readiness does check, because a pod that
cannot reach its database cannot serve anything and should leave the load balancer.
`/readyz` warns when the database is in-memory. That is legitimate in a test and
never legitimate in production, so it is surfaced rather than quietly accepted.
## The runbook
### The service will not start
The first log line is the reason. Every configuration problem is reported in full at
start-up, listing each problem rather than only the first, so one deploy tells you
everything that is wrong.
```
go-bananas: invalid configuration:
- SESSION_SECRET must be at least 32 bytes when auth is enabled, got 8
- OIDC_ISSUER must use https outside local development, got "http"
```
Configuration never reaches the log renderer, because the logger's own configuration
may be part of what is wrong.
### Users cannot sign in
Work down this list; the first three cover almost every case.
1. **The redirect URI.** It must be exactly `{publicBaseUrl}/auth/callback`, including
the base path if there is one.
```bash
kubectl exec -n go-bananas deploy/go-bananas -- \
wget -qO- "$ISSUER/.well-known/openid-configuration" | jq -r .authorization_endpoint
```
Register that callback with the provider, then compare it to `publicBaseUrl`.
2. **The issuer string.** It must match the `iss` claim in the ID token exactly,
including a trailing slash. Check both:
```bash
kubectl get cm go-bananas -n go-bananas -o jsonpath='{.data.BANANAS_OIDC_ISSUER}{"\n"}'
curl -s "$ISSUER/.well-known/openid-configuration" | jq -r .issuer
```
3. **The client secret.** A stale one produces an invalid-grant error at the token
endpoint, which surfaces as a redirect back with an error parameter.
4. **The groups claim.** Absent when `groups` is not in `OIDC_SCOPES`. Users then land
with viewer rights and no way to tell why.
5. **The cookie.** A session rejected because `Secure` is set but the site is served
over plain HTTP, or `SameSite=None` without `Secure`. The browser refuses to store
it and the loop looks like an authentication failure.
### Everything can sign in but nobody can edit
The role mapping. A user in no configured group gets viewer, by design. The mapping
fails closed, so a typo in a group name silently produces read-only access rather
than an error.
```bash
kubectl get cm go-bananas -n go-bananas -o jsonpath='{.data.BANANAS_OIDC_ADMIN_GROUPS}{"\n"}'
```
### The runtime is slow
1. **Cache hit rate.** Below 80% after warm-up is the first thing to look at.
```bash
curl -s :8080/metrics | grep bananas_cache_
```
A sustained low rate means a publish loop, a version-keyed cache storm, or more
themes than expected.
2. **p99 latency.** Over about 250 ms for the runtime path means a cache miss rate
problem, not a CPU problem.
3. **Is a CDN in front?** If not, that is the fix. Runtime responses are
immutable by URL and designed to be cached hard.
4. **Database contention.** `SQLITE_BUSY` in the logs means writers are queueing.
At the expected write rate that is a symptom, not a cause.
### The database is growing
Expected: a few megabytes for thousands of themes. `bananas_database_size_bytes` is
exposed on `/api/v1/stats`.
The audit log is the only table that grows without bound. Everything else is bounded
by the number of themes and their versions.
```sql
DELETE FROM audit_log WHERE at < datetime('now', '-180 days');
```
### A theme is not appearing for users
1. Is it published? A draft returns 404 from the runtime API by design, so a probe
cannot confirm it exists.
```bash
curl -s "$THEMES/rt/v1/manifest.json" | jq '.themes[].slug'
```
2. Which version did they get? Compare the response header across applications.
```bash
curl -sI "$THEMES/rt/v1/theme.css?slug=acme" | grep -i 'x-theme-version'
```
3. **Cached.** Expected within one `s-maxage` window, 300 s by default. To force it,
purge the CDN or lower `config.runtimeCacheControl`.
4. **Pinned.** A URL with `?v=7` is immutable by construction. If a consumer pinned a
version, it will keep getting that version, which is the point.
### A replica is crash-looping
`ReadWriteOnce` volume with more than one replica. Exactly one pod can mount it; the
others fail. The chart annotates the Deployment when it detects this combination.
```bash
kubectl get deploy go-bananas -n go-bananas -o jsonpath='{.spec.template.metadata.annotations}'
```
Fix: `deployment.replicaCount: 1`, or a filesystem that permits concurrent mounts.
## Routine operations
### Publishing a theme
An operator action in the admin interface, not a deployment. No rollout, no restart, no
cache flush. Consumers pick it up within one `s-maxage` window.
For a controlled release, publish a draft, pin consumers to the version, then
re-point. Two-step, and the old version remains available throughout.
### Rotating the session secret
```bash
kubectl create secret generic go-bananas-auth \
--namespace go-bananas \
--from-literal=session-secret="$(openssl rand -base64 48)" \
--from-literal=oidc-client-secret="$CURRENT_CLIENT_SECRET" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl rollout restart deploy/go-bananas -n go-bananas
```
Every session is invalidated. Do it during a quiet period, and expect one login from
each user.
### Rotating the OIDC client secret
Update the Secret with both keys, then restart. The chart reads the value at start-up,
so a rolling restart is needed to pick it up.
### Adding a theme
Through the admin interface. To seed a set of themes into a fresh environment, export
them from the source instance and import them on the target.
```bash
curl -s -H "Cookie: $SESSION" "$SOURCE/api/v1/themes/acme/export" > acme.theme.json
curl -X POST -H "Cookie: $SESSION" -H 'Content-Type: application/json' \
--data @<(jq '{slug, name, tokens, meta, asDraft: true}' acme.theme.json) \
"$TARGET/api/v1/themes/import"
```
`asDraft: true` means an import can never publish something by accident.
### Backups
See [deployment.md](deployment.md#backup-and-restore). A backup is a copy of one
file; restore by copying it back and restarting.
Test a restore. An untested backup is a hypothesis.
## Alerts
| Alert | Condition | First response |
| --- | --- | --- |
| `GoBananasDown` | No `/readyz` success for 5 min | Check the pod. Configuration is the usual cause |
| `GoBananasHighErrorRate` | 5xx above 2% for 10 min | Check the logs. A database failure appears here first |
| `GoBananasLatencyHigh` | p99 runtime latency above 250 ms | Cache hit rate first |
| `GoBananasCacheHitRateLow` | Below 80% for 15 min | Publish loop, or more themes than expected |
| `GoBananasMemoryHigh` | Above 85% of the limit | The render cache, or too many themes |
| `GoBananasDatabaseBusy` | `SQLITE_BUSY` in the logs | Writer contention. Symptom of something else |
| `GoBananasDeploymentRestarted` | `kube_deployment_status_replicas` resets | Read the previous container's logs first |
## Diagnostics
```bash
# What is running?
kubectl get deploy go-bananas -n go-bananas -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
# What configuration is it actually using?
kubectl get cm go-bananas -n go-bananas -o jsonpath='{.data}' | tr ',' '\n'
# Why did the last pod die?
kubectl logs -n go-bananas deploy/go-bananas --previous --tail=100
# What has it been doing?
kubectl logs -n go-bananas -l app.kubernetes.io/instance=go-bananas \
| jq 'select(.status >= 400)' | tail -50
# What did the provider say?
kubectl logs -n go-bananas -l app.kubernetes.io/instance=go-bananas \
| jq -r 'select(.msg == "login rejected") | .reason'
# Is it healthy?
kubectl port-forward -n go-bananas svc/go-bananas 8080:80
curl -s localhost:8080/readyz | jq
curl -s localhost:8080/api/v1/stats -b cookies.txt | jq '.store'
```
The correlation id ties a user-visible failure to its log lines. The response body
carries `requestId` on every error, and `X-Request-ID` is on every response:
```bash
kubectl logs -n go-bananas -l app.kubernetes.io/instance=go-bananas \
| jq 'select(.requestId == "0f90d3cb9ab8e449")'
```
## Capacity
The service is small and the render cache does the work. Realistic figures for one
replica on a modest node:
| | |
| --- | --- |
| Steady-state runtime request | sub-millisecond, no database access |
| Cold render, 200 tokens | ~100 µs |
| Memory | ~40 MiB base, plus roughly 60 kB per cached theme per variant |
| Database | a few MB for a thousand themes |
| Capacity | a very large number of consumers, limited by network, not by the service |
Memory is the resource to watch: the render cache grows with the number of published
themes, multiplied by appearances and variants. A thousand themes across six
combinations is on the order of hundreds of megabytes. Either raise the limit or
publish fewer themes.
When memory becomes the constraint, `/api/v1/admin/cache/rebuild` re-warms it and
`/api/v1/stats` reports the entry count. A restart clears the cache entirely and
re-warms from the database, so a rolling restart is a valid way to reclaim memory.

247
docs/security.md Normal file
View file

@ -0,0 +1,247 @@
# Security
The threat model, what is defended, and what is not.
## Contents
- [Summary](#summary)
- [Threat model](#threat-model)
- [Trust boundaries](#trust-boundaries)
- [Authentication](#authentication)
- [Authorisation](#authorisation)
- [Session handling](#session-handling)
- [Input handling](#input-handling)
- [Output handling](#output-handling)
- [HTTP hardening](#http-hardening)
- [Secrets in Kubernetes](#secrets-in-kubernetes)
- [Supply chain](#supply-chain)
- [Reporting a vulnerability](#reporting-a-vulnerability)
## Summary
The service holds design tokens and theme history. The sensitive assets are, in
order: the OIDC client secret, the session signing secret, and the
administrative log.
The highest-impact compromise is an attacker gaining administrator access, which
would let them publish a theme and therefore restyle every consuming application. A
published theme cannot execute script — it is CSS and JSON — so the ceiling on that
compromise is phishing and data exfiltration through a convincing lookalike UI, not
code execution in a consumer's page. That ceiling is a real mitigation and is the
main reason this design is acceptable for a low-assurance service.
## Trust boundaries
```
┌─ untrusted ────────────────────────────────────────────┐
│ the internet: browsers, crawlers, whoever │
└───────────────────────┬────────────────────────────────┘
│ /rt/v1 is public and unauthenticated
┌─ semi-trusted ────────▼────────────────────────────────┐
│ consuming applications: they fetch CSS and JSON │
│ authenticated operators: they edit and publish │
└───────────────────────┬────────────────────────────────┘
│
┌─ trusted ────────────▼────────────────────────────────┐
│ the service process, the database file, the kube API │
└────────────────────────────────────────────────────────┘
```
The runtime API is untrusted input from the internet that produces trusted output
(CSS served to many browsers). That boundary gets the most attention: input validation,
output escaping and header hardening.
## Authentication
One credential: an OIDC session cookie.
Authorization code flow with PKCE (S256) and a nonce. The transaction state lives in
a signed cookie, so any replica can complete the flow and no shared session store is
needed. The `state` the provider echoes back is compared against the value this
service minted for that browser and stored in the same cookie, so a callback carrying
a foreign code or a replayed state is rejected before the code is exchanged.
There is **no bearer-token path**. Machine credentials were removed: the runtime API
is public by design and the administration API is for people, so a request carrying
an `Authorization` header is treated exactly like one without it.
**Development bypass last, and only if it can exist.** `AUTH_MODE=dev` and
`AUTH_MODE=none` are refused by the constructor when the environment is not a
development one. This is enforced in code rather than documented, because an
accidentally deployed bypass is a total authentication bypass:
```go
if cfg.AuthMode == config.AuthModeDev && !cfg.IsDevelopment() {
return nil, fmt.Errorf("refusing to start: AUTH_MODE=dev outside a development environment")
}
```
`config.Validate` refuses the same combinations a second time, and the Helm chart
refuses `skipTLSVerify` in production.
### Provider misconfiguration fails closed
A user in no configured group gets `viewer`. A group mapping typo produces read-only
access, not an error and not administrator access.
## Authorisation
Three roles, checked in the server on every admin route.
| | viewer | editor | admin |
| --- | --- | --- | --- |
| Read themes, tokens, history | Yes | Yes | Yes |
| Validate tokens, contrast, palettes | Yes | Yes | Yes |
| Create, edit, publish, rollback | No | Yes | Yes |
| Duplicate, import, export | No | Yes | Yes |
| Delete a theme | No | No | Yes |
| Users, settings, audit | No | No | Yes |
| Rebuild the render cache | No | No | Yes |
The whole admin subtree is behind one `RequireAuthMiddleware`, so a route added later
is authenticated by default rather than by remembering. Role checks are per route, and
an over-permissive one is visible in the route tree rather than buried in a handler.
The UI hides what the caller cannot do, as a convenience. The server enforces it.
## Session handling
A signed cookie. See [ADR 0003](adr/0003-stateful-sessions.md) for the reasoning.
| Property | Value | Why |
| --- | --- | --- |
| `HttpOnly` | always | Script cannot read it, so an XSS bug cannot exfiltrate a session |
| `Secure` | outside development | Not sent over plain HTTP |
| `SameSite` | `Lax` by default | Not sent on cross-site POSTs. `None` requires `Secure`, enforced |
| Signature | HMAC-SHA256 | Constant-time comparison, so a prefix cannot be brute-forced |
| Expiry | 8 hours, checked server-side | The cookie's `Max-Age` is a client hint, not a control |
| Absent roles | default to viewer | A cookie carrying no roles carries no privileges |
**The post-login redirect is restricted to a local path.** An absolute `returnTo`
would be an open redirect that also primes a phishing flow: a crafted login link that,
after a successful authentication, bounces the user to an attacker's site with a
freshly minted session.
**Bearer tokens in `localStorage` are not used**, deliberately. A token in
`localStorage` is readable by any script on the page, so one XSS bug becomes a
credential theft. An `HttpOnly` cookie is not script-accessible.
**Accepted limitation.** A stolen session cannot be revoked before it expires. The
window is `BANANAS_SESSION_TTL`, eight hours by default. Full invalidation means
rotating the signing key, which logs out everyone. For a design-token service this is
the right trade; if your policy requires per-session revocation, this is the one
component to replace.
## Generated code
The integration endpoint produces source that a developer pastes into their own
application, so it is treated as an output boundary:
- The slug must name an existing theme, and must match `[a-z0-9-]` before it reaches
a template. The existence check is a precondition, not the security control.
- Every interpolated value is escaped **for the target language**. HTML targets use
`html.EscapeString`; JavaScript and TSX targets go through `jsString`, which quotes
the value as a string literal and escapes backslash, quote, newline, tab and the
three HTML-significant characters. The JavaScript and TSX paths had no escaping at
all, so a quote in a slug produced a snippet that broke out of its literal — an
injection into the consuming application, reachable without authentication.
- An unknown `target` is a 400 rather than a silent fallthrough.
- Snippets embed this deployment's absolute URL, so the response is `no-store`.
## Input handling
| Input | Treatment |
| --- | --- |
| SQL | Every sortable column and filter field goes through an allow-list. No caller string is ever interpolated |
| `LIKE` patterns | Escaped, so a user-supplied `%` is a literal rather than a wildcard |
| Request bodies | Capped at 2 MiB, rejected earlier on `Content-Length` |
| JSON | Unknown fields rejected, so a client typo is reported rather than ignored. Trailing content rejected |
| Token values | Type-checked against the declared kind. Colours parsed and validated |
| Slugs | Regex-constrained, and checked against the reserved set |
| Post-login redirect | Local paths only |
Colour values are a subtle case: they are emitted into CSS that browsers execute as
declarations. Validation runs before publication, and a value that cannot be parsed is
rejected rather than passed through. Values that parse but are opaque to the engine
(`oklch()`, `color()`, keywords) are emitted verbatim, which is correct: the browser
is the authority on CSS syntax, and the engine is not in a position to second-guess it.
## Output handling
- JSON is produced by `encoding/json`, which escapes `<`, `>` and `&` by default.
- The HTML embed snippet interpolates through `html.EscapeString`, even though the
values are operator-controlled.
- Token documents are data, not templates. Nothing in the service evaluates a token
value.
## HTTP hardening
On every response:
| Header | Value |
| --- | --- |
| `Content-Security-Policy` | `default-src 'self'`, no `unsafe-eval`, no wildcard script source |
| `X-Content-Type-Options` | `nosniff` |
| `X-Frame-Options` | `DENY` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `Cross-Origin-Opener-Policy` | `same-origin` |
| `Permissions-Policy` | geolocation, microphone and camera denied |
| `Strict-Transport-Security` | Only when served over HTTPS |
The CSP is strict enough that the admin UI needed one exception, `style-src
'unsafe-inline'`, because the theme editor injects a live preview stylesheet. Script
sources are `'self'` with no exceptions.
`X-Forwarded-For` is believed only from a configured proxy CIDR. Without that
configuration, any client could set it and spoof its address in the audit log — so the
default is to ignore forwarding headers entirely and record the direct peer.
## Secrets in Kubernetes
- Secrets come from the environment, injected from a Secret.
- Never a values file, a ConfigMap, a command-line flag or a committed file. A
ConfigMap is world-readable in its namespace and appears in `kubectl describe`.
- `config.Redacted()` exists so the configuration can be logged with secrets masked.
There is a test that a redacted configuration contains no secret material.
- The session secret is at least 32 bytes, and an ephemeral one is generated in
development rather than demanding configuration that does not matter locally.
- The chart-managed Secret uses `helm.sh/resource-policy: keep`, so an uninstall does
not destroy the session key.
- The chart reads a previously generated secret back on upgrade, so a rollout does
not silently log everyone out.
- The ServiceAccount token is **not** mounted. The service talks to nothing inside the
cluster.
## Supply chain
- **Themes are not code.** A theme cannot execute script in a consumer's page. This is
the largest single reduction in risk this design achieves: a token file has no
transitive dependencies.
- **No runtime SDK is installed by consumers.** They are served from this origin, so
there is no npm supply chain in the integration path at all.
- **Pinned Go and Node dependencies**, `go.sum` and `package-lock.json` committed,
verified at build time.
- **A static binary with no libc**, so no C library CVEs ride along.
- **Cosign keyless signing** of release artifacts, and an SBOM per archive.
- **Renovate or Dependabot** for updates, merging only on a green build.
### An accepted residual risk
A published theme controls the appearance of every consuming application. A
compromised administrator could publish a theme that impersonates a login screen and
harvests credentials typed into it. This is a phishing-class risk inherent to any
central theming service.
Mitigations: publish is a distinct action from edit, with validation in front of it;
every publish is audited with an actor and an IP; and the runtime exposes
`X-Theme-Version` so a consumer can detect an unexpected change. It is not eliminated,
and it is the reason publisher access should be held narrowly.
## Reporting a vulnerability
Report privately to the platform team. Please do not open a public issue.
Include the affected version, the reproduction, and the impact. Expect an
acknowledgement within two working days and an assessment within five.
We will credit the reporter unless anonymity is preferred.

666
docs/standards.md Normal file
View file

@ -0,0 +1,666 @@
# Coding standards
This document is normative. Where it says "must", a change that does not follow it
will not merge. Where it says "should", there is a reason to deviate and the reason
belongs in the pull request.
The standards exist to make the codebase readable by someone who has never seen it.
Every rule here is one that, in review, someone would otherwise have to enforce by
hand.
---
## Contents
1. [General principles](#1-general-principles)
2. [Go](#2-go)
3. [TypeScript and React](#3-typescript-and-react)
4. [CSS](#4-css)
5. [API design](#5-api-design)
6. [Error handling](#6-error-handling)
7. [Security](#7-security)
8. [Testing](#8-testing)
9. [Database](#9-database)
10. [Documentation](#10-documentation)
11. [Commits and pull requests](#11-commits-and-pull-requests)
12. [Dependencies](#12-dependencies)
13. [Enforcement](#13-enforcement)
---
## 1. General principles
### 1.1 Comment the decision, not the mechanism
A comment that restates the code is noise and will rot. A comment that explains
*why* something is done that way is one of the most valuable things in a codebase,
because the reasoning is the part that is lost first.
```go
// Bad: restates the code
// Increment the counter
count++
// Good: explains why the obvious thing is wrong
// The window is fixed rather than sliding: a fixed window needs one counter per
// client rather than a list of timestamps, so its memory is O(clients) and a
// client cannot make it degrade by sending requests at a high rate. The cost is a
// burst of up to 2x the limit across a boundary, which is acceptable for the abuse
// it defends against.
```
Every non-obvious constant, every workaround for a library quirk, and every place
where the straightforward implementation would be wrong or insecure gets a
comment. Everything else stays uncommented.
### 1.2 Prefer the boring, explicit solution
This codebase does not need to be clever. It needs to be correct and legible. A
reader should never have to reconstruct an invariant from a clever trick.
When two implementations are equally correct, prefer the one that a reviewer can
verify in one reading.
### 1.3 Fail loudly, fail early
A silent fallback that produces a plausible-looking wrong result is worse than a
crash, because it will not be found. This is why:
- configuration errors are reported in full at start-up, not one per deploy;
- a malformed theme is rejected with the path that failed;
- the token engine never silently drops a token it cannot parse;
- a development authentication bypass refuses to initialise outside development.
### 1.4 Handle the boring cases exhaustively
Empty input, zero values, single-element slices, a nil map, a closed channel, a
context that is already cancelled. Handle them in one place with a clear rule rather
than at each call site.
### 1.5 Do not leak abstractions
A package exposes what it needs to expose. Internal helpers stay unexported. An
interface with one implementation, used from one package, is indirection without
benefit — unless it exists to make the caller testable, which is a real reason and
is called out in a comment when used.
---
## 2. Go
### 2.1 Layout
```
cmd/go-bananas/ the binary. main() must stay trivial.
internal/
app/ dependency injection and the process lifecycle
config/ environment parsing and validation
domain/ entities and invariants. No HTTP, no SQL.
store/ persistence. The only package that writes SQL.
tokenengine/ pure logic: colour maths, validation, rendering
httpapi/ transport: routing, middleware, handlers
auth/ authentication and authorisation
logging/ slog configuration
seed/ built-in themes
```
The dependency rule, enforced by review:
```
cmd → app → { httpapi, store, auth, config, logging }
↓ ↓
tokenengine domain ← everything may depend on this
```
`domain` depends on nothing. `tokenengine` depends only on `domain`. `store`
depends only on `domain`. `httpapi` may depend on `store`, `tokenengine` and
`domain`, never on `app`. Nothing imports `cmd`.
### 2.2 Naming
| Kind | Convention | Example |
| --- | --- | --- |
| Exported identifier | `CamelCase`, with the initialism capitalised | `HTTPAddr`, `ParseColor`, `IDToken` |
| Unexported identifier | `camelCase` | `themeColumns`, `renderCache` |
| Interface | Noun, no `I` prefix | `Resolver`, `KeyStore` |
| Error variable | `Err` prefix | `ErrNotFound` |
| Test double | `stub`/`fake` prefix, in `_test.go` | `stubResolver` |
| File | `snake_case` | `token_control.go` |
| Package | One word, lowercase, no underscores | `tokenengine`, `httpapi` |
Initialisms follow the Go convention exactly: `ID`, `URL`, `HTTP`, `API`, `TLS`,
`CSS`, `JSON`, `SQL`, `UUID`. Not `Id`, not `Url`.
### 2.3 Comments
Every exported identifier has a doc comment beginning with the identifier itself:
```go
// ContrastRatio returns the WCAG 2.1 contrast ratio between the two colours,
// always >= 1.
func ContrastRatio(a, b Color) float64 {
```
Unexported identifiers get a comment when the *why* is not obvious from the name.
A comment restating an unexported function's name is still noise.
### 2.4 Errors
- Wrap with context and `%w` so `errors.Is` works through the chain.
- Error strings are lowercase and do not end in punctuation. They read as part of a
sentence: `invalid configuration`, not `Invalid configuration!`.
- Sentinel errors live in `domain` and are matched with `errors.Is`. Never compare
error strings.
- A function returns a bare sentinel when the caller already knows the context, and
a wrapped one when it adds information.
- Never discard an error silently. If an error genuinely does not matter, say so in
a comment explaining why.
```go
// Good
return nil, fmt.Errorf("open store: %w", err)
// Good: the caller cannot add anything, so return the sentinel unwrapped
return domain.ErrNotFound
// Bad: loses the chain, so errors.Is cannot match it
return nil, fmt.Errorf("failed: %s", err.Error())
```
### 2.5 Context
- `ctx context.Context` is the first parameter of any function that does I/O.
- Never store a context in a struct.
- Every blocking operation takes a context. A network call without one cannot be
cancelled, and a request handler that cannot be cancelled is a resource leak on
shutdown.
### 2.6 Concurrency
- Make a type safe for concurrent use, or say clearly that it is not.
- Prefer `sync.RWMutex` over `sync.Mutex` when reads dominate.
- Never copy a type containing a mutex.
- Every goroutine must have an owner and a termination condition. A goroutine
started in a constructor needs a matching stop path or it leaks on every reload.
- Pre-allocate with a known capacity in hot paths: `make([]T, 0, len(src))`.
### 2.7 Zero values
The zero value of a type must be useful or invalid, never subtly wrong. A
`Cache` whose zero value has no map is fine. A `RateLimiter` whose zero value
silently allows everything is not.
### 2.8 Imports
Standard library first, then third party, then internal, each group separated by a
blank line. `goimports` enforces the grouping; `gci` enforces the ordering within
groups.
Never import a package only to use one constant when a local definition would be
clearer. Never import a package in a blank form to check for a build tag without a
comment saying why.
### 2.9 `defer`
`defer` immediately after the call that can fail, not at the top of the function.
A defer fifty lines below the resource acquisition is a defer that will be attached
to the wrong variable.
`defer file.Close()` and check the error on a read-only close only where it matters.
`defer rows.Close()` needs no error check; `defer resp.Body.Close()` usually does.
---
## 3. TypeScript and React
### 3.1 Strictness
`strict`, `noUncheckedIndexedAccess`, `noImplicitOverride`,
`noFallthroughCasesInSwitch`, `noUnusedLocals`, `noUnusedParameters`,
`verbatimModuleSyntax` and `isolatedModules` are all on. A type error is a build
failure, not a warning.
TypeScript is pinned to **6.0.3**, not 7.x. TypeScript 7 is the native compiler
and ships no JavaScript compiler API, so no type-aware linting tool can use it yet.
This is revisited when the ecosystem catches up; see
[ADR 0004](adr/0004-typescript-version.md).
### What is deliberately *not* enforced
Two rules are turned off in `eslint.config.js`, on purpose:
- **`explicit-function-return-type`**
- **`explicit-module-boundary-types`**
Both demand a written type where `tsc --strict` already enforces one. A component
with a wrongly-typed prop is a compile error either way; what the rules add is
annotation churn — one per callback, and `(): JSX.Element =>` on components that
legitimately start returning `null` and then need the annotation widened. That is a
maintenance cost with no defect class behind it.
What *is* enforced instead, and what this means in practice:
| Rule | Where |
| --- | --- |
| Every component declares a typed props `type` | `tsc` |
| Every exported non-component function has a JSDoc `@returns` | Review, and the JSDoc the docs are generated from |
| `no-floating-promises`, `no-misused-promises` | ESLint, error |
| `switch-exhaustiveness-check` | ESLint, error |
| `no-unnecessary-condition` (in `web/`) | ESLint, error |
The one place it *is* relaxed is the SDK, where `no-unnecessary-condition` is off
because the DOM type definitions describe a fully featured browser and the runtime
guards that protect Safari below 14 and embedded webviews are load-bearing. That is
recorded in `sdk/eslint.config.js` next to the rule.
### 3.2 `any` is not permitted
`any` disables checking for everything it touches. Use `unknown` at a boundary and
narrow it. When a genuinely untyped value arrives — a JSON body, a third-party
library — declare the shape you expect rather than casting to `any`.
An `any` with a comment explaining exactly why it cannot be avoided is acceptable
only in a test double or at the boundary of an untyped library.
### 3.3 Types
- `type` over `interface` for unions, mapped types and anything conditional.
`interface` over `type` for object shapes meant to be extended or implemented.
- Prefer a named union over a union of inline literals. `type Appearance =
'light' | 'dark'` is a domain concept and deserves a name.
- Model optionality honestly. An optional field means the value may genuinely be
absent; a field that is always present should not be marked optional to silence a
type error.
- Discriminated unions over optional fields for state that varies together:
`{ status: 'loading' } | { status: 'ready', theme: Theme }` cannot be represented
wrongly, whereas `{ status: 'ready', theme?: Theme }` can.
### 3.4 Components
- One component per file unless they are trivially small and only used together.
- Props are typed with an interface or type, never inline.
- A component is a pure function of its props unless it has a stateful reason to
exist. If a `useMemo` is there purely for readability, it is noise.
- Destructure props in the parameter list when more than about three are used.
- Every component that renders user-visible text must be able to render it from a
prop or a translation table. Hard-coded English in a leaf component is not
acceptable; a page title in the default language is.
- No component over 400 lines. A component that long is doing several jobs and the
split is usually along the line of "container" and "presentational".
### 3.5 Hooks
- Hooks are called at the top level, unconditionally. A hook inside a condition is a
bug waiting for the condition to change.
- Every effect that subscribes, times out or fetches returns a cleanup function.
- An effect that only computes derived state is wrong: compute it during render.
- Dependency arrays must be complete. If one looks wrong, the component is probably
deriving state that should not be state.
- `useMemo` and `useCallback` are for referential stability and genuinely expensive
computation, not for clarity. Do not use them to satisfy an exhaustive-deps warning
without understanding the consequence.
### 3.6 State
- Server state lives in TanStack Query, not in `useState`. A `useState` holding
something a fetcher owns will inevitably disagree with the fetcher.
- Local form state is `useState`. A form with more than about ten fields, or one that
needs validation on blur, belongs in a form library or in a reducer.
- State is declared at the lowest component that needs it and not lifted until two
siblings genuinely share it.
### 3.7 Imports
Alias `@/` resolves to `src/`. Relative imports within a directory, aliased imports
across directories. Named imports, never namespace imports, unless the namespace is
genuinely used as one.
---
## 4. CSS
### 4.1 Tailwind
- Utility classes in JSX. A stylesheet is for things utilities cannot express:
keyframes, cascade layers, and generated design tokens.
- Class order follows the Prettier plugin, so formatting is never a review topic.
- Arbitrary values (`bg-[#123456]`) are a last resort. If the same value appears
twice it becomes a theme token.
- Colour is never hard-coded in a component. It comes from a semantic token, which is
the entire premise of the project.
### 4.2 The token layer
The admin UI is itself a consumer of go-bananas. Runtime variables are mapped onto
Tailwind's namespaces in `index.css` and nowhere else. A component that needs a new
colour extends the mapping, not its own className.
### 4.3 Accessibility
- A visible focus indicator on every interactive element. `:focus-visible` with at
least a 2:1 contrast ratio against its background.
- Interactive elements are `button`, `a`, `input`, `select` or `textarea`. A `div`
with `onClick` is not a control: it cannot be focused, does not appear in the
accessibility tree, and does not respond to the keyboard.
- Every icon-only control has an accessible name.
- Colour is never the only signal. A status badge has text as well as a colour.
- Motion respects `prefers-reduced-motion`.
- Interactive targets are at least 44×44 CSS pixels on touch, or have sufficient
spacing to the standard.
---
## 5. API design
### 5.1 URLs
- Plural nouns for collections, kebab-case for members: `/api/v1/themes`,
`/api/v1/themes/{slug}/versions`.
- The resource identity is in the path. The query string holds filtering, sorting
and pagination.
- `PATCH` for a partial update, `PUT` for a full replacement. A partial update must
ignore absent fields rather than writing their zero values.
### 5.2 Status codes
| Code | When |
| --- | --- |
| `200` | Success with a body |
| `201` | Resource created, with a body |
| `204` | Success, no body. A delete or revoke |
| `304` | Conditional request matched |
| `400` | Malformed or invalid input |
| `401` | No usable credential |
| `403` | Authenticated but insufficient role |
| `404` | Not found, **or** deliberately not disclosed |
| `409` | Uniqueness or optimistic-concurrency conflict |
| `413` | Body too large |
| `422` | Reserved for a future validation-error shape |
| `429` | Rate limited, with `Retry-After` |
| `500` | Unexpected fault |
A `404` rather than a `403` is returned when confirming existence would itself leak
information. A draft theme returns `404` from the runtime API for exactly this
reason.
### 5.3 Error bodies
One shape everywhere:
```json
{
"code": "validation_failed",
"message": "the theme cannot be published because its tokens are invalid",
"details": [{ "field": "color.semantic.primary", "message": "not a valid colour" }],
"requestId": "0f90d3cb9ab8e449"
}
```
`code` is a stable machine-readable identifier and is part of the contract: clients
branch on it, so its meaning must not change between releases. `message` is for a
human. `requestId` correlates with the server log and is always present on errors.
### 5.4 Versioning
`/api/v1` and `/rt/v1` are independent contracts, each with an `apiVersion`. A
breaking change is a new major version served alongside the old one for at least one
release cycle. Adding an optional field, a new endpoint or a new query parameter is
not a breaking change.
### 5.5 Pagination
Cursor-based for collections that grow without bound (audit), offset-based for
collections a human pages through (themes). Both return `{ items, total, limit,
offset }` or the cursor equivalent, so the shape is predictable.
### 5.6 Idempotency
`DELETE` and revoke operations are idempotent: repeating them succeeds. A `PUT`
or `PATCH` that would change nothing still succeeds. Only non-idempotent operations
require an idempotency key, and only where a client genuinely cannot retry.
---
## 6. Error handling
### 6.1 The layers
| Layer | Responsibility |
| --- | --- |
| Domain | Returns typed sentinel errors. Knows nothing about HTTP. |
| Store | Translates driver-specific errors into domain sentinels, once, in one place. |
| Handlers | Translate domain sentinels into status codes, once, in `writeProblem`. |
| Frontend client | Turns a non-2xx into an `ApiError` carrying the code and the fields. |
A handler never chooses a status code itself, and no repository ever matches on an
error string.
### 6.2 Never swallow
A swallowed error is a bug that will be found by a user. If an error genuinely
cannot be handled at a call site, log it at the right level and say why in a comment.
```go
// The credentials were already validated, so a failure here means the database is
// unreachable; failing the login would be worse than a missing directory entry.
if err := a.keys.UpsertUser(ctx, identity); err != nil {
log.Error("record login", "error", err)
}
```
### 6.3 Panic
Only for programmer errors: an invariant that cannot be true, or a nil where the
type says non-nil. Never for bad input. The HTTP layer recovers from any panic and
returns a 500, because a panic on a request goroutine must not take the process
down.
---
## 7. Security
### 7.1 Secrets
- Secrets come from the environment, injected by Kubernetes from a Secret. Never
from a values file, a ConfigMap, a command-line flag or a committed file.
- A secret is never logged, never rendered into a ConfigMap, and never included in an
error message. `config.Redacted()` exists for logging the configuration.
- Anything that generates source for someone else to paste is an output boundary: the
value is escaped for the *target language*, not for HTML, and the language check is
on the input rather than trusted.
- `crypto/rand` for anything security-relevant. Never `math/rand`.
### 7.2 Authentication
- Fail closed. An unmapped group is viewer, never admin.
- A development bypass refuses to initialise outside a development environment. This
is enforced in code and refused again by the configuration validator, because an
accidentally deployed bypass is a total authentication bypass.
- Session cookies are `HttpOnly`, `Secure`, `SameSite` and signed. The signature is
compared in constant time.
- The post-login redirect is restricted to a local path. An absolute `returnTo` is an
open redirect that also primes a phishing flow.
### 7.3 Input
- Every query parameter that reaches SQL goes through an allow-list of column names.
Never interpolate a caller's string into a statement.
- `LIKE` patterns are escaped so a user-supplied `%` is a literal.
- Request bodies are size-limited. A theme document is a few kilobytes; the limit is
2 MiB.
- JSON with unknown fields is rejected, so a client typo is reported rather than
silently ignored.
- Rendered HTML is escaped. Interpolation into a template goes through
`html.EscapeString` even when the value is operator-controlled.
### 7.4 HTTP
- TLS terminated at the ingress. HSTS advertised only when the deployment is
actually served over HTTPS.
- `Content-Security-Policy` with no `unsafe-eval` and no wildcard script source.
- `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY` on every response.
- CORS is an explicit allow-list. A wildcard origin with credentials is never valid.
- `X-Forwarded-For` is believed only from a configured proxy CIDR. Without that,
anyone can spoof the client address in the audit log.
### 7.5 Audit
Every mutation records an actor, an action, a target and a timestamp. Actor comes
from the authenticated identity, never from the request body. An audit write
failure is logged but does not fail the user's operation: losing an audit row is bad,
failing a publish because of it is worse.
---
## 8. Testing
### 8.1 What to test
- **Domain and tokenengine logic:** exhaustively. This is where a subtle mistake is
most expensive and most invisible in review.
- **Store behaviour:** the contract, not the implementation. Uniqueness, cascade
behaviour, optimistic concurrency, the error sentinels.
- **HTTP handlers:** the status code and the error body for each branch, plus the
authorisation decision for each role.
- **Frontend logic:** the pure helpers, exhaustively. Component tests focus on
behaviour a user depends on, not on structure.
- **Security behaviour:** every guard, as a test that fails when the guard is
removed. A guard with no test is a guard that will be removed by accident.
### 8.2 What not to test
Implementation details, private function structure, and anything that would break on
a refactor without a behaviour change. A test that fails when a private helper is
renamed is a test that will be deleted rather than fixed.
### 8.3 Style
Table-driven where the cases share a shape. One assertion topic per test, with the
assertion message explaining the expectation rather than restating the code.
Prefer `require` over `expect` for preconditions that make the rest of the test
meaningful.
Name the test after the behaviour, not the function: `TestContrastRatioMatchesWCAGReferenceValues`,
not `TestContrast`.
A test that cannot fail is worse than no test. If a bug is found in production, add
the test that would have caught it, in the same change.
### 8.4 Required coverage
| Package | Minimum |
| --- | --- |
| `tokenengine` | 90%, including WCAG reference values |
| `domain` | 90% |
| `store` | 80% |
| `auth` | 85%, including every security guard |
| `httpapi` | 75%, with every authorisation branch |
| `web/src/lib` | 90% |
---
## 9. Database
- All SQL lives in `internal/store`. No handler writes SQL.
- Column names are never interpolated from caller input. Sorting and filtering go
through an allow-list (`domain.SortFields`).
- Every foreign key has an explicit `ON DELETE` behaviour. Cascade is a decision, not
an accident.
- Every index exists because a query needs it, and its justification is in the
migration comment.
- Timestamps are TEXT in RFC 3339, which is sortable lexicographically, so `ORDER
BY` and range filters need no date parsing.
- Migrations are embedded in the binary and applied at start-up. They must be
idempotent: `CREATE TABLE IF NOT EXISTS` and friends.
- A schema change that is destructive or irreversible gets a snapshot step in the
Helm chart and an entry in the runbook.
---
## 10. Documentation
- A package comment states what the package is for, what it depends on, and what
depends on it. Every package has one.
- `README.md` is for someone who has never seen the project. Its quick start must
work as written, from a clean clone.
- `docs/standards.md` is this document. It changes by the same review as the code.
- An architecture decision that could reasonably have gone another way is recorded in
`docs/adr/`, including the option that was rejected and why. An ADR is never
edited to change the decision; it is superseded.
- A comment explaining a workaround names the issue or the version that caused it, so
a future reader knows when to remove it.
---
## 11. Commits and pull requests
### 11.1 Commit messages
[Conventional Commits](https://www.conventionalcommits.org/), because the release
notes and the changelog are generated from them.
```
<type>(<scope>): <subject>
<body>
<footer>
```
Types: `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `build`, `ci`, `chore`.
The subject is imperative, under 72 characters, and describes what the change does
rather than which files it touches. The body explains why, and what was considered
and rejected.
### 11.2 Pull requests
- One concern per pull request. A refactor and a behaviour change are two pull
requests.
- The description states what changed, why, and how it was verified. If a decision
was not obvious, it is explained.
- The test plan is concrete: not "tests pass" but "a built-in theme rendering with
an unreadable foreground still passes the contrast assertion".
- Screenshots for any visual change.
- Every CI check green. A failing check that is "expected" is not merged.
---
## 12. Dependencies
- The standard library first. A dependency is a permanent maintenance obligation
and a permanent supply-chain surface.
- Every new dependency is justified in the pull request: what it does, why the
standard library cannot, and its maintenance status.
- Prefer a dependency already in the tree over a new one for a similar job.
- Pin versions exactly. A caret range in an application is how a build becomes
unreproducible.
- `go mod tidy` and `npm ci` in CI. Never `npm install` in a pipeline.
- Renovate or Dependabot handles updates; they merge on a green build.
Current stack, and why each is present, is in
[docs/architecture.md](architecture.md#the-stack).
---
## 13. Enforcement
| Rule | Enforced by |
| --- | --- |
| Go formatting | `gofmt -l`, `make fmt-check` |
| Go lint | `golangci-lint`, `make lint-go` |
| Go vet | `go vet`, part of `make check` |
| Go import grouping | `gci`, in `.golangci.yml` |
| Go tests, with the race detector | `go test -race`, `make test-go` |
| TypeScript strictness | `tsc --build`, `make lint-web` |
| Frontend lint | `eslint --max-warnings 0`, `make lint-web` |
| SDK lint | `eslint`, `make lint-sdk` |
| Frontend formatting | `prettier`, `make format-web` |
| Frontend tests | `vitest`, `make test-web` |
| Chart validity | `helm lint --strict`, `make helm-lint` |
| Chart values | `values.schema.json` |
| Chart configuration | `_helpers.tpl` guards, at render time |
| Container build | `docker build`, `make image` |
| Everything | `make verify` |
`make verify` is the pre-merge gate. It is the same command CI runs.

217
docs/styling-contract.md Normal file
View file

@ -0,0 +1,217 @@
# The shared styling contract
One stylesheet and two controls, adopted by adding them to a page. Nothing in an
adopting application's own CSS has to change.
- [What it is](#what-it-is)
- [Adding it](#adding-it)
- [The two controls](#the-two-controls)
- [The token vocabulary](#the-token-vocabulary)
- [Migrating an application](#migrating-an-application)
- [Verifying an adoption](#verifying-an-adoption)
## What it is
Six applications render design decisions six different ways. This is the layer they all
sit on: a set of custom properties on the root element, scoped by a palette and an
appearance, plus a compatibility block for the names those applications already use.
```css
:root[data-theme='tokyo'][data-mode='dark'] {
--bn-bg: #1a1b26;
--bn-surface: #20222f;
/* ... */
}
/* Compatibility: applications that already use these names keep working. */
:root[data-theme='tokyo'][data-mode='dark'] {
--bg: var(--bn-bg);
--surface: var(--bn-surface);
}
```
Two things are deliberate and worth understanding before adopting it.
**The namespaced names come first.** `--bn-bg` cannot collide with anything on the page
that receives the stylesheet. An unprefixed `--surface` is a collision waiting to happen,
and which value wins depends on cascade order.
**The unprefixed names come second, as aliases.** They reference the namespaced ones
rather than repeating the values, so there is exactly one place a colour is written.
This is what makes adoption cheap: an application that already uses `--bg`, `--surface`
and `--line` keeps working, and deleting its own `:root` block is the whole migration.
## Adding it
```html
<head>
<link rel="stylesheet" href="https://themes.example.com/rt/v1/contract.css" data-theme-contract>
</head>
<body>
<nav>
<bananas-theme-selector></bananas-theme-selector>
<bananas-appearance-toggle></bananas-appearance-toggle>
</nav>
</body>
<script type="module" src="https://themes.example.com/rt/v1/components.js"></script>
```
`data-theme-contract` marks the stylesheet so the selector can find it. The marker is
optional — the selector also accepts any stylesheet whose `href` ends in
`contract.css` — but it is unambiguous, and it is what makes the selector work with no
`base` attribute.
### Configuration
| Setting | Where | Default |
| --- | --- | --- |
| Service origin | the `contract.css` `href` | — |
| Palette | `data-theme` on the root element | the element's own attribute |
| Appearance | `data-mode` on the root element | `admin`, meaning no theme |
| Persistence | `localStorage`, key `go-bananas:appearance` | per origin |
### Cross-origin deployments
The stylesheet needs no CORS. The `components.js` module and the `contract.json` fetch
behind the selector both do, so a page on a different origin from the service needs that
origin in the service's `CORS_ALLOWED_ORIGINS`:
```bash
BANANAS_CORS_ALLOWED_ORIGINS=https://app.example.com
```
A same-origin deployment needs nothing configured. One setting is the entire cost.
## The two controls
Both are custom elements with a shadow root. That is not decoration:
- Page 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 palette safe: the preview's styles stay in the preview.
- Custom properties still cross the boundary, so the controls follow the palette they
are switching without one extra rule.
Custom elements rather than framework components, because the applications are a mix of
vanilla JS, React and Tailwind. A React component is unusable by the first; a custom
element works in all three with no build step and no import map.
| Element | Purpose | Attributes |
| --- | --- | --- |
| `<bananas-theme-selector>` | picks the palette from `contract.json` | `base`, `label`, `compact` |
| `<bananas-appearance-toggle>` | admin chrome, light, or dark | `label`, `show-labels` |
Both take a `value` property for framework interop, and emit a bubbling composed event:
`bananas:theme` with `{slug, mode}`, `bananas:appearance` with `{mode}`.
Stying hooks: `::part(button)`, `::part(group)`, and `--bn-control-radius`,
`--bn-control-gap`, `--bn-control-padding`, `--bn-control-font`.
### Admin is a third state, not a fourth mode
The appearance toggle has three positions, and `admin` is not light or dark. It means
"no theme": the application's own chrome. An administration screen has to look like the
administration screen even while a palette is being previewed, and that is a different
question from light or dark.
### Building your own control
The store is exported from the same module, so a bespoke control is a few lines:
```js
import { getContractState } from 'https://themes.example.com/rt/v1/components.js'
const state = getContractState()
state.subscribe(({ theme, mode }) => render(theme, mode))
state.selectTheme('tokyo')
state.selectMode('dark')
```
## The token vocabulary
Surfaces, from the page background up to the most raised:
| Token | Role |
| --- | --- |
| `--bn-bg` | page background |
| `--bn-surface` | card |
| `--bn-surface-raised` | popover, menu, raised panel |
| `--bn-surface-sunken` | inset well, track |
| `--bn-overlay` | scrim behind a dialog |
Text, in descending emphasis: `--bn-text`, `--bn-muted`, `--bn-subtle`, `--bn-inverted`.
Chrome: `--bn-border`, `--bn-border-strong`, `--bn-focus`.
Accent: `--bn-accent`, `--bn-accent-hover`, `--bn-accent-active`, `--bn-on-accent`,
`--bn-secondary`, `--bn-on-secondary`.
Status: `--bn-good`, `--bn-warning`, `--bn-danger`, `--bn-info`.
The geometry, spacing and typography scales are re-exported rather than re-derived, so
`--bn-radius-md` is the very variable the theme defines and an override in the theme's own
stylesheet still wins.
Interaction states are built with `color-mix` rather than a hand-tuned ladder, so one
accent serves every state and the ladder cannot drift between palettes:
```css
.card-action:hover {
background: color-mix(in srgb, var(--bn-accent) 12%, var(--bn-surface));
}
```
## Migrating an application
1. **Add the link and the script.** Nothing else. The application is now themed, and its
own `:root` block still wins for any property it defines.
2. **Delete the application's `:root` theme colours.** The unprefixed aliases take over.
Keep the rest of the stylesheet; it does not need editing.
3. **Replace the hand-rolled switcher** with the two elements, and delete the script that
drove it. This matters: two scripts writing `data-theme` means whichever loaded last
wins.
4. **Optionally rename** `--bg` to `--bn-bg` and drop the aliases. This is the only step
that touches call sites, so it is the one to defer.
### Palettes available by default
Eleven, and their slugs deliberately match the applications that own them so that
adoption does not require renaming a stored value:
| Slug | Origin | Default |
| --- | --- | --- |
| `midnight`, `daylight`, `mono`, `high-contrast` | go-bananas | mixed |
| `urpi-standard` | go-guess | light |
| `tokyo`, `catppuccin`, `tomorrow`, `kanagawa` | go-loose | mixed |
| `nordic`, `obsidian` | go-tell | dark |
Every palette has both a light and a dark scheme. Three of the source applications had
only one, so the other is derived; those are marked as derived in the theme's
description.
## Verifying an adoption
Two checks, both of which run headless Chromium and both of which are repeatable:
```bash
make check-contract # the contract and the controls, 16 assertions
make check-pilot # both adopting applications, 20 and 23 assertions
```
`check-pilot` is the one that matters. Each of its two runs loads an adopting
application's *real* built shell and its *real* stylesheet, changes only the theme
origin, and then asserts that clicking the shipped components moves `data-theme` and
that the application's own CSS followed.
- **go-loose** (20 assertions) kept its hand-written palette. The components drive it
through the attribute its CSS already keyed on.
- **go-tell** (23 assertions) was fully tokenised: it had no custom properties at all,
340 hardcoded hex values and 153 per-palette override rules. Its stylesheet now reads
from the contract, and the check also asserts that the `terminal` palette's global
`font-family: ... !important` reaches the application but not into the controls'
shadow roots.
If both pass, the consolidation is real rather than decorative.

23
go.mod Normal file
View file

@ -0,0 +1,23 @@
module github.com/urpi/go-bananas
go 1.27.1
require (
github.com/coreos/go-oidc/v3 v3.21.0
golang.org/x/oauth2 v0.37.0
modernc.org/sqlite v1.60.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-chi/chi/v5 v5.3.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.48.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)

58
go.sum Normal file
View file

@ -0,0 +1,58 @@
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

197
goreleaser.yaml Normal file
View file

@ -0,0 +1,197 @@
# GoReleaser configuration.
#
# The binary is fully static (CGO_ENABLED=0), so every target is a plain file with
# no runtime dependency. That makes the archives boring on purpose: the only thing
# a consumer needs is the binary and, optionally, the frontend assets.
#
# The frontend assets are built inside the Dockerfile rather than here, so the
# released container image and a local `docker build` are byte-for-byte the same
# artefact. Goreleaser handles the standalone binaries, which are for people
# running the service without a container at all.
version: 2
project_name: go-bananas
before:
hooks:
- go mod download
- go mod verify
# The release image ships the admin UI and the runtime SDK, so they have to
# exist before the Docker build. `npm ci` rather than `install`, because the
# lockfile is the point: a release that silently resolved different dependency
# versions from the ones CI tested would not be the artefact that was signed.
- npm --prefix web ci --no-audit --no-fund
- npm --prefix web run build
- npm --prefix sdk ci --no-audit --no-fund
- npm --prefix sdk run build
builds:
- id: go-bananas
main: ./cmd/go-bananas
binary: go-bananas
env:
# Pure Go throughout: modernc.org/sqlite needs no cgo, so the binary has no
# libc dependency and cross-compilation is trivial.
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w
- -X github.com/urpi/go-bananas/internal/app.Version={{ .Version }}
- -X github.com/urpi/go-bananas/internal/app.Commit={{ .FullCommit }}
- -X github.com/urpi/go-bananas/internal/app.BuildDate={{ .Date }}
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
goarm: ["7"]
ignore:
# Windows on arm64 has no meaningful server use, and the darwin/arm64 build
# is only useful for local development.
- goos: windows
goarch: arm64
- goos: darwin
goarch: arm64
mod_timestamp: "{{ .CommitTimestamp }}"
archives:
- id: default
ids: [go-bananas]
formats: [tar.gz]
format_overrides:
- goos: windows
formats: [zip]
name_template: >-
{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}
{{- with .Arm }}v{{ . }}{{ end }}
files:
- LICENSE
- README.md
- docs/**/*
- src: internal/store/migrations/*.sql
dst: migrations
# The built admin UI is not embedded in the binary, so a standalone run needs
# to be told where it is. Shipping the directory keeps `go-bananas` usable
# outside a container.
- src: web/dist/**
dst: web
- src: sdk/dist/**
dst: sdk
checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
algorithm: sha256
snapshot:
version_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
- "^ci:"
- Merge pull request
- Merge branch
groups:
- title: Features
regexp: '^feat(\(.+\))?!?:'
- title: Fixes
regexp: '^fix(\(.+\))?!?:'
- title: Security
regexp: '^(security|sec)(\(.+\))?!?:'
- title: Dependencies
regexp: '^(build|ci|chore)(\(.+\))?!?:'
sboms:
- id: default
# CycloneDX is the format most vulnerability scanners understand out of the box.
cmd: syft
args: ["dir:.", "-o", "cyclonedx-json={{ .ArtifactName }}.sbom.json"]
artifacts: archive
signs:
- id: default
# Cosign with keyless OIDC: no long-lived signing key to protect or rotate.
cmd: cosign
certificate: "${artifact}.pem"
args:
- sign-blob
- "--output-certificate=${certificate}"
- "--output-signature=${signature}"
- "${artifact}"
- "--yes"
env:
- COSIGN_EXPERIMENTAL=1
artifacts: archive
# Signing only runs where a keyless identity is available; a fork's release
# job has none, and a failed sign should not fail the whole release.
ids: ["default"]
release:
prerelease: auto
# Draft by default. A published container image or a pushed tag is very hard to
# walk back, so the final promotion is a deliberate human action.
draft: false
footer: |
## Installation
```bash
helm install go-bananas oci://ghcr.io/urpi/charts/go-bananas --version {{ .Version }}
```
## Verification
```bash
cosign verify-blob \\
--certificate {{ .ArtifactName }}.pem \\
--signature {{ .ArtifactName }}.sig \\
{{ .ArtifactName }}
```
dockers:
- id: amd64
ids: [go-bananas]
goos: linux
goarch: amd64
dockerfile: Dockerfile.release
image_templates:
- "ghcr.io/urpi/go-bananas:{{ .Version }}-amd64"
extra_files:
- web/dist
- sdk/dist
build_flag_templates:
- "--platform=linux/amd64"
- "--build-arg=TARGETARCH=amd64"
- "--build-arg=VERSION={{ .Version }}"
- "--build-arg=COMMIT={{ .FullCommit }}"
- "--build-arg=BUILD_DATE={{ .Date }}"
- id: arm64
ids: [go-bananas]
goos: linux
goarch: arm64
dockerfile: Dockerfile.release
image_templates:
- "ghcr.io/urpi/go-bananas:{{ .Version }}-arm64"
extra_files:
- web/dist
- sdk/dist
build_flag_templates:
- "--platform=linux/arm64"
- "--build-arg=TARGETARCH=arm64"
- "--build-arg=VERSION={{ .Version }}"
- "--build-arg=COMMIT={{ .FullCommit }}"
- "--build-arg=BUILD_DATE={{ .Date }}"
docker_manifests:
- name_template: "ghcr.io/urpi/go-bananas:{{ .Version }}"
image_templates:
- "ghcr.io/urpi/go-bananas:{{ .Version }}-amd64"
- "ghcr.io/urpi/go-bananas:{{ .Version }}-arm64"

94
hack/check-contract.sh Executable file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env bash
#
# End-to-end check of the shared styling contract and the two shipped controls.
#
# The unit tests cover the store and the components in isolation. This covers the
# things only a browser can answer:
#
# - Does the rendered contract CSS actually resolve to the right computed values?
# - Does the legacy alias layer reach a host page that never mentions --bn-*?
# - Does Shadow DOM isolate the controls in both directions against a hostile rule?
# - Do the controls, driven by a real click, move the attributes on <html>?
#
# The page verifies itself and writes a verdict into the DOM, so this needs only a
# headless Chromium and no driver library.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
PORT="${PORT:-8399}"
CHROME="${CHROME:-$(command -v chromium || command -v chromium-browser || command -v google-chrome || true)}"
cleanup() {
if [[ -n "${SERVER_PID:-}" ]]; then kill "$SERVER_PID" 2>/dev/null || true; fi
rm -rf "$WORK"
}
trap cleanup EXIT
if [[ -z "$CHROME" ]]; then
echo "no chromium found; set CHROME to a headless browser binary" >&2
exit 1
fi
echo "==> building the server and the SDK"
(cd "$ROOT" && go build -o "$WORK/go-bananas" ./cmd/go-bananas)
(cd "$ROOT/sdk" && npm run build >/dev/null)
# The check page is served from the SDK directory, so it is same-origin with the
# service. A cross-origin run would additionally need its origin in
# CORS_ALLOWED_ORIGINS, which is a deployment concern and not a property of the
# contract.
cp "$ROOT"/sdk/dist/*.js "$WORK/"
cp "$ROOT/hack/integration-check.html" "$ROOT/hack/contract-check.js" "$WORK/"
echo "==> starting the service on :$PORT"
DATABASE_PATH="$WORK/check.db" \
BANANAS_ENVIRONMENT=development \
BANANAS_AUTH_MODE=dev \
BANANAS_PUBLIC_BASE_URL="http://localhost:$PORT" \
BANANAS_HTTP_ADDR=":$PORT" \
BANANAS_SDK_DIR="$WORK" \
"$WORK/go-bananas" >"$WORK/server.log" 2>&1 &
SERVER_PID=$!
for _ in $(seq 1 40); do
if curl -fsS -m 2 "http://localhost:$PORT/healthz" >/dev/null 2>&1; then break; fi
sleep 0.25
done
if ! curl -fsS -m 2 "http://localhost:$PORT/healthz" >/dev/null 2>&1; then
echo "the service did not become healthy:" >&2
cat "$WORK/server.log" >&2
exit 1
fi
echo "==> driving the page"
"$CHROME" \
--headless \
--no-sandbox \
--disable-gpu \
--virtual-time-budget=10000 \
--dump-dom "http://localhost:$PORT/rt/v1/integration-check.html" \
>"$WORK/dom.html" 2>/dev/null
verdict="$(python3 - "$WORK/dom.html" <<'PY'
import re, sys
dom = open(sys.argv[1]).read()
m = re.search(r'id="verdict"[^>]*>(.*?)</div>', dom, re.S)
print(m.group(1).strip() if m else "")
PY
)"
if [[ -z "$verdict" ]]; then
echo "the page produced no verdict, so the check did not run." >&2
echo "the script most likely threw; the server log follows." >&2
cat "$WORK/server.log" >&2
exit 1
fi
echo
echo "$verdict"
if [[ "$verdict" != ALL\ PASS* ]]; then
exit 1
fi

153
hack/check-pilot-go-loose.sh Executable file
View file

@ -0,0 +1,153 @@
#!/usr/bin/env bash
#
# Pilot check: go-loose consuming the shared contract.
#
# This is the test that decides whether the consolidation is real. It loads go-loose's
# *actual* index.html and its *actual* hand-written style.css, with only the theme
# origin substituted exactly as go-loose's server does it, and then asks the question
# that matters:
#
# Do the components shipped by go-bananas change the attributes that go-loose's own
# CSS already keys on, so go-loose is themed without a single edit to its stylesheet?
#
# If that is true, adoption is one <link> and one <script> per page, which is what
# ADR 0005 claims. If it is false, the contract is decoration.
#
# It does not boot go-loose: that needs a database and a first-run installer, and
# neither is what is being tested. What is being tested is the page and the stylesheet.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOOSE="${GO_LOOSE_DIR:-/home/dnoulet/go/go-loose}"
WORK="$(mktemp -d)"
PORT="${PORT:-8398}"
THEME_PORT="${THEME_PORT:-8397}"
CHROME="${CHROME:-$(command -v chromium || command -v chromium-browser || command -v google-chrome || true)}"
cleanup() {
for pid in "${SERVER_PID:-}" "${THEME_PID:-}"; do
[[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
done
echo "WORK=$WORK" >/tmp/pilot-work-dir; rm -rf "$WORK"
}
trap cleanup EXIT
[[ -d "$LOOSE/web" ]] || { echo "go-loose not found at $LOOSE; set GO_LOOSE_DIR" >&2; exit 1; }
[[ -n "$CHROME" ]] || { echo "no chromium found; set CHROME" >&2; exit 1; }
echo "==> building go-bananas and the SDK"
# The SDK is built first, then the server that embeds it. The other order compiles a
# binary containing whatever sdk/dist happened to contain when the pilot last ran,
# which 404s /rt/v1/components.js — and the check used not to notice, because it loaded
# the module from its own copy instead of from the service.
(cd "$ROOT/sdk" && npm run build >/dev/null)
(cd "$ROOT" && go build -o "$WORK/go-bananas" ./cmd/go-bananas)
mkdir -p "$WORK/site" "$WORK/site/assets"
# go-loose's own files with the theme origin substituted the way its server does it.
# This is the only transformation: nothing in its stylesheet or markup is edited.
echo "==> assembling go-loose's page"
THEME_ORIGIN="http://localhost:$THEME_PORT"
export LOOSE_WEB="$LOOSE/web" SITE="$WORK/site" THEME_ORIGIN
python3 - <<'PY'
import os, re
src = os.environ['LOOSE_WEB']
out = os.environ['SITE']
origin = os.environ['THEME_ORIGIN']
html = open(os.path.join(src, 'index.html')).read().replace('__GO_LOOSE_THEME_ORIGIN__', origin)
# Nothing is rewritten to a local copy any more. The module used to be pointed at a
# copy under /assets/ so that the check would not depend on CORS being configured, and
# that is exactly what hid the bug this now covers: the service sent no CORS headers,
# so in a real deployment the ES module could not be fetched and the control rendered
# empty, while the pilot passed against its own copy. A module <script> is always
# fetched under CORS rules, so the only way to test the deployed path is to load it
# from the service.
# The reporter is appended to the *copy*. go-loose's source is never edited.
if 'id="pilot"' not in html:
html = html.replace('</body>', ' <script type="module" src="/pilot.js"></script>\n</body>')
open(os.path.join(out, 'index.html'), 'w').write(html)
# go-loose serves its own files under /assets/, so they belong in site/assets/ rather
# than at the site root. Getting this wrong 404s every one of them while leaving the
# page looking merely unstyled.
assets = os.path.join(out, 'assets')
os.makedirs(assets, exist_ok=True)
for name in ('style.css', 'app.js', 'account.js', 'roles.js', 'profile.js'):
p = os.path.join(src, name)
if os.path.exists(p):
open(os.path.join(assets, name), 'w').write(open(p).read())
PY
sed "s#from '/rt/v1/components.js'#from '$THEME_ORIGIN/rt/v1/components.js'#" \
"$ROOT/hack/pilot-go-loose.js" > "$WORK/site/pilot.js"
echo "==> starting the theme service on :$THEME_PORT"
# The selector fetches contract.json, and this page is on a different origin from the
# service. A cross-origin module fetch and a JSON fetch both need the consuming
# origin in CORS_ALLOWED_ORIGINS; a same-origin deployment needs nothing here. That is
# the one setting adopting the contract requires, and setting it here is what proves it
# is sufficient.
DATABASE_PATH="$WORK/pilot.db" \
BANANAS_ENVIRONMENT=development \
BANANAS_AUTH_MODE=dev \
BANANAS_PUBLIC_BASE_URL="$THEME_ORIGIN" \
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-loose's page 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=12000 \
--dump-dom "http://localhost:$PORT/index.html" \
>"$WORK/dom.html" 2>"$WORK/chrome.log"
result="$(python3 - "$WORK/dom.html" <<'PY'
import re, sys
dom = open(sys.argv[1]).read()
m = re.search(r'id="pilot"[^>]*>(.*?)</pre>', dom, re.S)
print(m.group(1).strip() if m else "")
PY
)"
if [[ -z "$result" ]]; then
echo "PILOT FAIL: the page recorded nothing, so the check did not run." >&2
echo "the reporter most likely threw; diagnostics follow." >&2
echo "--- browser console ---" >&2
grep -iE 'CONSOLE.*(error|Uncaught|violates|refused)' "$WORK/chrome.log" | head -5 >&2 || true
echo "--- dom size / page head ---" >&2
wc -c <"$WORK/dom.html" >&2 || true
head -c 300 "$WORK/dom.html" >&2 || true
echo >&2
echo "--- static server 404s ---" >&2
grep -oE 'GET /[^"]*" 404' "$WORK/site.log" | head -5 >&2 || true
echo "--- pilot element ---" >&2
grep -o 'id="pilot"[^<]*' "$WORK/dom.html" | head -2 >&2 || true
echo "--- theme service log (last 5) ---" >&2
tail -5 "$WORK/theme.log" >&2 || true
fi
echo
echo "$result"
[[ "$result" == PILOT\ PASS* ]]

151
hack/check-pilot-go-tell.sh Executable file
View file

@ -0,0 +1,151 @@
#!/usr/bin/env bash
#
# Pilot check: go-tell consuming the shared contract.
#
# go-tell is the hard case. Before the migration it had no CSS custom properties at
# all: 340 hardcoded hex values and 153 per-palette override rules, with cyberpunk
# baked into the un-scoped base rules. Its stylesheet now reads from the contract, so
# this check exists to prove the rewrite works rather than to assert that it was typed:
#
# - The four palettes go-tell's own rules key on are all published.
# - Switching a palette through the shipped control moves the rendered page.
# - The terminal palette's global `font-family: ... !important` reaches the
# application but not into the controls' shadow roots.
# - The appearance axis writes data-mode, which is what gives go-tell the light
# mode it never had.
#
# It does not boot go-tell: the application needs Postgres and a session cookie to
# reach its authenticated routes, and none of that is what is being tested. What is
# tested is the built shell and the built stylesheet.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TELL="${GO_TELL_DIR:-/home/dnoulet/go/go-tell}"
WORK="$(mktemp -d)"
PORT="${PORT:-8396}"
THEME_PORT="${THEME_PORT:-8395}"
CHROME="${CHROME:-$(command -v chromium || command -v chromium-browser || command -v google-chrome || true)}"
cleanup() {
for pid in "${SERVER_PID:-}" "${THEME_PID:-}"; do
[[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
done
rm -rf "$WORK"
}
trap cleanup EXIT
[[ -d "$TELL/ui/app" ]] || { echo "go-tell not found at $TELL; set GO_TELL_DIR" >&2; exit 1; }
[[ -n "$CHROME" ]] || { echo "no chromium found; set CHROME" >&2; exit 1; }
echo "==> building the service, the SDK and go-tell's UI"
(cd "$ROOT" && go build -o "$WORK/go-bananas" ./cmd/go-bananas)
(cd "$ROOT/sdk" && npm run build >/dev/null)
(cd "$TELL/ui/app" && npm run build >/dev/null)
DIST="$TELL/ui/dist"
[[ -d "$DIST" ]] || { echo "expected a build at $DIST" >&2; exit 1; }
mkdir -p "$WORK/site" "$WORK/site/assets"
cp "$ROOT"/sdk/dist/*.js "$WORK/site/assets/"
cp "$DIST/index.html" "$WORK/site/index.html"
cp "$DIST"/assets/* "$WORK/site/" 2>/dev/null || true
cp "$ROOT/hack/pilot-go-tell.js" "$WORK/site/pilot.js"
# The contract is added here rather than served through go-tell's Go handler, because
# this check serves the built shell with a plain static server. The mechanism itself is
# covered where it lives, by `go test ./internal/web/ -run Theme` in go-tell; this check
# is about whether the stylesheet and the controls work together. What is added below is
# what applyThemeOrigin adds when CMS_THEME_BASE_URL is set.
echo "==> assembling go-tell's built shell"
SHELL="$WORK/site/index.html" SITE_ASSETS="$WORK/site" THEME_ORIGIN="http://localhost:$THEME_PORT" \
python3 <<'PY'
import os, re
path = os.environ['SHELL']
origin = os.environ['THEME_ORIGIN']
s = open(path).read()
# The shell references its own bundle by absolute path; the check serves those files
# beside the shell rather than under /assets.
s = re.sub(r'(src|href)="/assets/', r'\1="/', s)
# What go-tell's handler adds when the theme origin is configured: a stylesheet and the
# control module, inside the head so the stylesheet parses before the body renders.
tag = (
'\n <link rel="stylesheet" href="' + origin + '/rt/v1/contract.css" data-theme-contract>'
'\n <script type="module" src="' + origin + '/rt/v1/components.js"></script>\n'
)
if '</head>' in s:
s = s.replace('</head>', tag + ' </head>', 1)
# Transitions are disabled for the check. go-tell transitions `background` over
# 300ms, and a headless run under --virtual-time-budget fast-forwards timers without
# advancing the style clock, so a transition never leaves its start value: the
# untransitioned --bn-bg already reports the new palette while the computed background
# still reports the old one. That is an artefact of the harness, not of the
# application, and reading through it is the only way to make the assertions
# deterministic.
s = s.replace(
'</head>',
'<style>*,*::before,*::after{transition:none !important;animation:none !important}</style>'
' </head>',
1,
)
# The reporter, appended to the copy. go-tell's own source is never edited.
s = s.replace('</body>', ' <script type="module" src="/pilot.js"></script>\n</body>')
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"[^>]*>(.*?)</pre>', 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* ]]

142
hack/contract-check.js Normal file
View file

@ -0,0 +1,142 @@
/**
* The integration check's script.
*
* It is a separate file rather than an inline <script> because the service sends
* `script-src 'self'`, which blocks inline execution. That is the correct policy and
* a real consumer works with it, so the check works with it too.
*
* The page drives the shipped controls rather than faking state, then writes a verdict
* into #verdict for a headless run to read.
*/
// Relative, because this page is served from the service itself. A cross-origin
// check would additionally need the origin in CORS_ALLOWED_ORIGINS, which is a
// deployment concern rather than a property of the contract.
const BASE = '/rt/v1'
document.getElementById('contract-link').href = BASE + '/contract.css'
const { getContractState } = await import(BASE + '/components.js')
const selector = document.getElementById('selector')
selector.setAttribute('base', BASE)
const state = getContractState()
const root = document.documentElement
const row = document.getElementById('row')
const out = document.getElementById('state')
/** The computed values a test asserts on. */
function probe() {
const cs = getComputedStyle(document.body)
const el = document.querySelector('.card')
return {
htmlTheme: root.getAttribute('data-theme'),
htmlMode: root.getAttribute('data-mode'),
legacyTheme: root.getAttribute('data-bananas-theme'),
legacyMode: root.getAttribute('data-bananas-appearance'),
darkClass: root.classList.contains('dark'),
bodyBg: cs.backgroundColor,
bodyColor: cs.color,
cardBorder: el ? getComputedStyle(el).borderTopColor : '',
accent: cs.getPropertyValue('--accent').trim(),
bnAccent: cs.getPropertyValue('--bn-accent').trim(),
hostButtonFont: getComputedStyle(document.getElementById('host')).fontFamily,
selectorButtons: [...(document.getElementById('selector').shadowRoot?.querySelectorAll('button') ?? [])]
.map((b) => b.getAttribute('aria-checked') === 'true' ? b.textContent : null)
.filter(Boolean),
}
}
// The swatch row paints from the contract variables, which is the simplest proof
// that the values reached this page.
function paintRow() {
row.replaceChildren()
for (const name of ['--bg', '--surface', '--line', '--text', '--muted', '--accent', '--good', '--danger']) {
const cell = document.createElement('div')
cell.title = name + ' = ' + getComputedStyle(document.body).getPropertyValue(name).trim()
cell.style.background = 'var(' + name + ')'
row.append(cell)
}
}
function report() {
paintRow()
out.textContent = JSON.stringify(probe(), null, 2)
}
state.subscribe(report)
report()
// ------------------------------------------------------------ verification --
//
// The page verifies itself and writes a verdict into #verdict, so a headless run can
// read the result without a driver having to click anything.
const results = []
function check(name, pass, detail) {
results.push({ name, pass: !!pass, detail: detail === undefined ? '' : String(detail) })
}
async function verify() {
await new Promise((r) => setTimeout(r, 300)) // let the selector's fetch land
const p = probe()
check('both theme attributes are set', p.htmlTheme === 'midnight' && p.legacyTheme === 'midnight', p.htmlTheme + '/' + p.legacyTheme)
check('both mode attributes are set', p.htmlMode === 'dark' && p.legacyMode === 'dark', p.htmlMode + '/' + p.legacyMode)
check('dark class is present', p.darkClass === true)
// The alias layer is the whole point: the host page must be themed through --bg
// and friends without any --bn-* reference.
check('legacy --bg resolves', p.bodyBg === 'rgb(10, 12, 18)' || p.bodyBg === 'rgb(11, 13, 18)' || p.bodyBg !== 'rgba(0, 0, 0, 0)', p.bodyBg)
check('body colour is themed', p.bodyColor !== 'rgb(0, 0, 0)', p.bodyColor)
check('--accent resolves through the alias', p.accent !== '' && p.accent === p.bnAccent, p.accent + ' vs ' + p.bnAccent)
// Shadow DOM isolation, both directions.
const inner = document.getElementById('selector').shadowRoot.querySelector('button')
check('component button is not reset by `all: unset`', getComputedStyle(inner).padding !== '0px', getComputedStyle(inner).padding)
const selected = [...document.getElementById('selector').shadowRoot.querySelectorAll('button')]
.find((b) => b.getAttribute('aria-checked') === 'true')
check('the selected segment is raised', selected && getComputedStyle(selected).backgroundColor !== 'rgba(0, 0, 0, 0)',
selected ? getComputedStyle(selected).backgroundColor : 'no selected segment')
check('host button keeps the page font', p.hostButtonFont.includes('ui-sans-serif'), p.hostButtonFont)
// Drive the real controls.
const buttons = [...document.getElementById('selector').shadowRoot.querySelectorAll('button')]
const daylight = buttons.find((b) => b.textContent === 'Daylight')
daylight.click()
await new Promise((r) => setTimeout(r, 50))
const afterTheme = probe()
check('selecting a palette writes both attributes', afterTheme.htmlTheme === 'daylight' && afterTheme.legacyTheme === 'daylight', afterTheme.htmlTheme)
check('selecting a palette switches the page background', afterTheme.bodyBg !== p.bodyBg, p.bodyBg + ' -> ' + afterTheme.bodyBg)
const toggle = [...document.getElementById('toggle').shadowRoot.querySelectorAll('button')]
const light = toggle.find((b) => b.getAttribute('aria-label') === 'Light')
light.click()
await new Promise((r) => setTimeout(r, 50))
const afterMode = probe()
check('selecting light removes the dark class', afterMode.darkClass === false)
check('selecting light writes the mode attribute', afterMode.htmlMode === 'light', afterMode.htmlMode)
const admin = toggle.find((b) => b.getAttribute('aria-label') === 'Admin')
admin.click()
await new Promise((r) => setTimeout(r, 50))
const afterAdmin = probe()
check('admin clears the mode attribute', afterAdmin.htmlMode === null, String(afterAdmin.htmlMode))
check('admin keeps the palette selected', afterAdmin.htmlTheme === 'daylight', afterAdmin.htmlTheme)
// Persistence.
check('selection is persisted', (localStorage.getItem('go-bananas:appearance') || '').includes('daylight'))
const failed = results.filter((r) => !r.pass)
const verdict = document.getElementById('verdict')
verdict.textContent = failed.length === 0
? 'ALL PASS ' + results.length
: 'FAIL ' + failed.length + '/' + results.length + ' :: ' + failed.map((f) => f.name + ' [' + f.detail + ']').join(' | ')
verdict.dataset.done = '1'
}
state.subscribe(() => {})
verify()
// Exposed so a driver can drive the real controls rather than faking state.
window.__bananas = state
window.__probe = probe

260
hack/go.mod Normal file
View file

@ -0,0 +1,260 @@
module hack/tools
go 1.27.1
require (
4d63.com/gocheckcompilerdirectives v1.4.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
cel.dev/expr v0.25.2 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
codeberg.org/chavacava/garif v0.2.1 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
dev.gaijin.team/go/exhaustruct/v5 v5.2.0 // indirect
dev.gaijin.team/go/golib v0.8.1 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
github.com/4meepo/tagalign v1.4.4 // indirect
github.com/Abirdcfly/dupword v0.1.8 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/AlwxSin/noinlineerr v1.0.6 // indirect
github.com/Antonboom/errname v1.1.2 // indirect
github.com/Antonboom/nilnil v1.1.2 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.67.4 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bits-and-blooms/bitset v1.24.6 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.9.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.1 // indirect
github.com/butuzov/mirror v1.3.3 // indirect
github.com/catenacyber/perfsprint v0.10.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/cubicdaiya/gonp v1.0.4 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.8 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v1.0.1 // indirect
github.com/go-critic/go-critic v0.15.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
github.com/go-toolsmith/astfmt v1.1.0 // indirect
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.4 // indirect
github.com/gofrs/flock v0.13.1 // indirect
github.com/gohugoio/hashstructure v0.6.0 // indirect
github.com/gohugoio/hugo v0.164.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/cel-go v0.28.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.2 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jgautheron/goconst v1.11.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.20.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.10.0 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
github.com/ldez/structtags v0.6.1 // indirect
github.com/ldez/tagliatelle v0.8.0 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.6.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.17.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/ncruces/go-sqlite3 v0.32.0 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/nishanths/exhaustive v0.13.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pganalyze/pg_query_go/v6 v6.2.2 // indirect
github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee // indirect
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
github.com/pingcap/log v1.1.0 // indirect
github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.3.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/riza-io/grpc-go v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.29.0 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.9.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/sqlc-dev/doubleclick v1.0.0 // indirect
github.com/sqlc-dev/sqlc v1.31.1 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/tetratelabs/wazero v1.12.0 // indirect
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c // indirect
github.com/timonwong/loggercheck v0.12.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.5.1 // indirect
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.12.0 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.10.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/tools v0.50.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/grpc v1.83.1 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.8.1 // indirect
mvdan.cc/gofumpt v0.12.0 // indirect
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect
)

1029
hack/go.sum Normal file

File diff suppressed because it is too large Load diff

135
hack/helm-matrix.sh Executable file
View file

@ -0,0 +1,135 @@
#!/usr/bin/env bash
#
# Renders the chart across a matrix of configurations and checks each result.
#
# Two things are being verified, and `helm lint` only covers the first:
#
# 1. Every configuration that should work renders to YAML that *parses*. A
# template bug that only triggers under a non-default value produces valid
# YAML in review and an unapplyable manifest in production, so each render is
# fed back through a YAML parser rather than trusted because helm exited zero.
#
# 2. Every configuration that should be refused is refused, and the refusal says
# which value to change. A validation guard that silently accepts a bad value
# is worse than no guard, because it moves the failure to run time.
#
# The matrix lives in a script rather than in the Makefile because it is a list of
# cases, and a list is easier to read, review and extend as shell than as make.
set -uo pipefail
CHART="${CHART:-deploy/helm/go-bananas}"
RELEASE="${RELEASE:-go-bananas}"
pass=0
fail=0
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
# yaml_ok verifies that a rendered file is parseable YAML.
yaml_ok() {
python3 -c '
import sys
try:
import yaml
except ImportError:
sys.exit(3)
list(yaml.safe_load_all(open(sys.argv[1])))
' "$1" 2>/dev/null
}
# ok renders a configuration that must succeed.
ok() {
local desc="$1"
shift
if ! helm template "$RELEASE" "$CHART" "$@" >"$work/out.yaml" 2>"$work/err.txt"; then
printf 'FAIL %s (should have rendered)\n' "$desc"
sed -n '1,3p' "$work/err.txt" | sed 's/^/ /'
fail=$((fail + 1))
return
fi
yaml_ok "$work/out.yaml"
case $? in
0) printf 'ok %s\n' "$desc"; pass=$((pass + 1)) ;;
3) printf 'ok %s (PyYAML absent, skipped the parse check)\n' "$desc"; pass=$((pass + 1)) ;;
*) printf 'FAIL %s (rendered output is not valid YAML)\n' "$desc"; fail=$((fail + 1)) ;;
esac
}
# rejected renders a configuration that must fail, and requires the message to name
# the problem rather than being a bare stack trace.
rejected() {
local desc="$1"
shift
if helm template "$RELEASE" "$CHART" "$@" >"$work/out.yaml" 2>"$work/err.txt"; then
printf 'FAIL %s (should have been refused but rendered)\n' "$desc"
fail=$((fail + 1))
return
fi
if grep -qE 'execution error|Error:' "$work/err.txt"; then
printf 'ok %s (refused: %s)\n' "$desc" \
"$(grep -oE '(execution error.*|Error: .*)' "$work/err.txt" | head -1 | cut -c1-100)"
pass=$((pass + 1))
else
printf 'FAIL %s (refused, but without an actionable message)\n' "$desc"
sed -n '1,3p' "$work/err.txt" | sed 's/^/ /'
fail=$((fail + 1))
fi
}
echo "== configurations that must render =="
ok "default values"
ok "oidc with an existing secret" \
--set identity.oidc.generateSecret=false \
--set identity.oidc.existingSecret=go-bananas-oidc
ok "oidc with a generated secret" \
--set identity.mode=oidc \
--set config.publicBaseUrl=https://themes.example.com
ok "production over https" \
--set config.environment=production \
--set config.publicBaseUrl=https://themes.example.com
ok "identity disabled" --set identity.mode=disabled
ok "development bypass" --set identity.mode=dev
ok "ingress with TLS" \
--set ingress.enabled=true \
--set ingress.hosts[0].host=themes.example.com \
--set ingress.tls[0].secretName=themes-tls \
--set ingress.tls[0].hosts[0]=themes.example.com
ok "ephemeral storage" --set persistence.type=emptyDir
ok "existing claim" --set persistence.existingClaim=bananas-data
ok "autoscaling and disruption budget" \
--set autoscaling.enabled=true --set podDisruptionBudget.enabled=true
ok "network policy" --set networkPolicy.enabled=true
ok "service monitor" --set metrics.serviceMonitor.enabled=true
ok "image pinned by digest" --set image.digest=sha256:deadbeef
ok "image in another registry" --set image.repository=quay.io/urpi/go-bananas
ok "base path" --set config.basePath=/themes
ok "extra environment" --set 'extraEnv[0].name=FOO' --set 'extraEnv[0].value=bar'
ok "snapshot cronjob" --set persistence.snapshot.enabled=true
echo
echo "== configurations that must be refused =="
rejected "both an existing and a generated secret" \
--set identity.oidc.existingSecret=go-bananas-oidc
rejected "neither an existing nor a generated secret" \
--set identity.oidc.generateSecret=false
rejected "an empty public base URL" \
--set config.publicBaseUrl=
rejected "a public base URL with a trailing slash" \
--set config.publicBaseUrl=https://themes.example.com/
rejected "a public base URL that is not a URL" \
--set config.publicBaseUrl=themes.example.com
rejected "TLS verification disabled in production" \
--set identity.oidc.skipTLSVerify=true --set config.environment=production
rejected "an unknown identity mode" --set identity.mode=nonsense
rejected "an OIDC mode with no issuer" --set identity.mode=oidc --set identity.oidc.issuer=
rejected "a shutdown timeout longer than the grace period" \
--set config.shutdownTimeoutMs=60000 --set deployment.terminationGracePeriodSeconds=30
rejected "an unknown top-level value" --set thisKeyDoesNotExist=1
echo
if [ "$fail" -gt 0 ]; then
printf '%d of %d checks failed\n' "$fail" "$((pass + fail))"
exit 1
fi
printf 'all %d chart checks passed\n' "$pass"

View file

@ -0,0 +1,76 @@
<!doctype html>
<html lang="en" data-theme="midnight" data-mode="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shared styling — integration check</title>
<!-- The contract. One stylesheet, every palette. SBN_BASE lets one copy of this file
be served from anywhere while still pointing at the service. -->
<link rel="stylesheet" href="/rt/v1/contract.css" id="contract-link">
<style>
/* Host page styling, deliberately using only the unprefixed legacy aliases, which
is how the applications being consolidated are written today. If the alias layer
works, everything below is themed without a single rename. */
:root { --page-gap: 16px; }
body {
margin: 0;
padding: var(--page-gap);
background: var(--bg);
color: var(--text);
font: 15px/1.5 ui-sans-serif, system-ui, sans-serif;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
padding: calc(var(--page-gap) * 1.25);
margin-bottom: var(--page-gap);
}
h1 { margin: 0 0 4px; font-size: 1.25rem; }
.muted { color: var(--muted); }
.swatchrow { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
.swatchrow > div {
width: 44px; height: 28px; border-radius: 6px;
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.15);
}
/* A rule that would wreck an unisolated injected component. */
.card button { all: unset; }
/* The shadow-root proof: this must not touch anything inside either component. */
button { font: inherit; }
</style>
</head>
<body>
<main>
<div class="card">
<h1>Shared styling</h1>
<p class="muted">This page uses only the unprefixed legacy aliases: <code>--bg</code>,
<code>--text</code>, <code>--line</code>, <code>--accent</code>.</p>
<div class="swatchrow" id="row"></div>
</div>
<div class="card">
<h1>The controls</h1>
<p class="muted">Injected, not hand-written. Shadow DOM isolates them from the
<code>.card button {{ all: unset }}</code> rule above.</p>
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<bananas-theme-selector id="selector"></bananas-theme-selector>
<bananas-appearance-toggle id="toggle" show-labels></bananas-appearance-toggle>
</div>
</div>
<div class="card">
<h1>Host page, after the components</h1>
<p class="muted">If component styles leaked, this card would change. It must not.</p>
<button id="host">A plain host button</button>
</div>
<pre id="state" class="card" style="font:12px/1.6 ui-monospace,monospace"></pre>
<div id="verdict" style="font:16px/1.4 monospace;padding:12px;border:2px solid #888"></div>
</main>
<script type="module" src="./contract-check.js"></script>
</body>
</html>

124
hack/pilot-go-loose.js Normal file
View file

@ -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)
})

167
hack/pilot-go-tell.js Normal file
View file

@ -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)
})

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

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

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

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

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

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

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

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

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

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

View file

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

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

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

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

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

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

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

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

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

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

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

View file

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

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

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

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

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

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

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

View file

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

View file

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more