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
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:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
45
.air.toml
Normal file
45
.air.toml
Normal 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
76
.dockerignore
Normal 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
26
.editorconfig
Normal 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
166
.env.example
Normal 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
11
.gitattributes
vendored
Normal 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
289
.github/workflows/ci.yml
vendored
Normal 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
54
.gitignore
vendored
Normal 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
275
.golangci.yml
Normal 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
10
.prettierignore
Normal 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
12
.prettierrc.json
Normal 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
159
Dockerfile
Normal 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
68
Dockerfile.release
Normal 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
21
LICENSE
Normal 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
439
Makefile
Normal 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
469
README.md
Normal 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
0
cat
Normal file
95
cmd/go-bananas/main.go
Normal file
95
cmd/go-bananas/main.go
Normal 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
143
compose.yaml
Normal 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
|
||||||
120
deploy/dev/realm-go-bananas.json
Normal file
120
deploy/dev/realm-go-bananas.json
Normal 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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
90
deploy/dev/values-dev-cluster.yaml
Normal file
90
deploy/dev/values-dev-cluster.yaml
Normal 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
|
||||||
19
deploy/helm/go-bananas/.helmignore
Normal file
19
deploy/helm/go-bananas/.helmignore
Normal 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
|
||||||
36
deploy/helm/go-bananas/Chart.yaml
Normal file
36
deploy/helm/go-bananas/Chart.yaml
Normal 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
|
||||||
89
deploy/helm/go-bananas/templates/NOTES.txt
Normal file
89
deploy/helm/go-bananas/templates/NOTES.txt
Normal 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.
|
||||||
256
deploy/helm/go-bananas/templates/_helpers.tpl
Normal file
256
deploy/helm/go-bananas/templates/_helpers.tpl
Normal 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 }}
|
||||||
93
deploy/helm/go-bananas/templates/configmap.yaml
Normal file
93
deploy/helm/go-bananas/templates/configmap.yaml
Normal 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 }}
|
||||||
218
deploy/helm/go-bananas/templates/deployment.yaml
Normal file
218
deploy/helm/go-bananas/templates/deployment.yaml
Normal 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 }}
|
||||||
145
deploy/helm/go-bananas/templates/hpa.yaml
Normal file
145
deploy/helm/go-bananas/templates/hpa.yaml
Normal 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 }}
|
||||||
43
deploy/helm/go-bananas/templates/ingress.yaml
Normal file
43
deploy/helm/go-bananas/templates/ingress.yaml
Normal 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 }}
|
||||||
161
deploy/helm/go-bananas/templates/pvc.yaml
Normal file
161
deploy/helm/go-bananas/templates/pvc.yaml
Normal 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 }}
|
||||||
63
deploy/helm/go-bananas/templates/secret.yaml
Normal file
63
deploy/helm/go-bananas/templates/secret.yaml
Normal 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 }}
|
||||||
64
deploy/helm/go-bananas/templates/service.yaml
Normal file
64
deploy/helm/go-bananas/templates/service.yaml
Normal 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 }}
|
||||||
56
deploy/helm/go-bananas/templates/serviceaccount.yaml
Normal file
56
deploy/helm/go-bananas/templates/serviceaccount.yaml
Normal 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 }}
|
||||||
44
deploy/helm/go-bananas/values-dev.yaml
Normal file
44
deploy/helm/go-bananas/values-dev.yaml
Normal 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
|
||||||
51
deploy/helm/go-bananas/values-lcl.yaml
Normal file
51
deploy/helm/go-bananas/values-lcl.yaml
Normal 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
|
||||||
59
deploy/helm/go-bananas/values-prd-fixed.yaml
Normal file
59
deploy/helm/go-bananas/values-prd-fixed.yaml
Normal 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"]
|
||||||
47
deploy/helm/go-bananas/values-prd.yaml
Normal file
47
deploy/helm/go-bananas/values-prd.yaml
Normal 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
|
||||||
44
deploy/helm/go-bananas/values-tst.yaml
Normal file
44
deploy/helm/go-bananas/values-tst.yaml
Normal 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
|
||||||
338
deploy/helm/go-bananas/values.schema.json
Normal file
338
deploy/helm/go-bananas/values.schema.json
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
481
deploy/helm/go-bananas/values.yaml
Normal file
481
deploy/helm/go-bananas/values.yaml
Normal 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
90
docker-bake.hcl
Normal 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"]
|
||||||
|
}
|
||||||
176
docs/adr/0001-embedded-sqlite.md
Normal file
176
docs/adr/0001-embedded-sqlite.md
Normal 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.
|
||||||
142
docs/adr/0002-runtime-delivery.md
Normal file
142
docs/adr/0002-runtime-delivery.md
Normal 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.
|
||||||
101
docs/adr/0003-stateful-sessions.md
Normal file
101
docs/adr/0003-stateful-sessions.md
Normal 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.
|
||||||
70
docs/adr/0004-typescript-version.md
Normal file
70
docs/adr/0004-typescript-version.md
Normal 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.
|
||||||
119
docs/adr/0005-shared-token-contract.md
Normal file
119
docs/adr/0005-shared-token-contract.md
Normal 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
397
docs/api.md
Normal 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
348
docs/architecture.md
Normal 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
547
docs/deployment.md
Normal 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
223
docs/development.md
Normal 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
565
docs/integration.md
Normal 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
273
docs/operations.md
Normal 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
247
docs/security.md
Normal 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
666
docs/standards.md
Normal 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
217
docs/styling-contract.md
Normal 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
23
go.mod
Normal 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
58
go.sum
Normal 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
197
goreleaser.yaml
Normal 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
94
hack/check-contract.sh
Executable 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
153
hack/check-pilot-go-loose.sh
Executable 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
151
hack/check-pilot-go-tell.sh
Executable 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
142
hack/contract-check.js
Normal 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
260
hack/go.mod
Normal 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
1029
hack/go.sum
Normal file
File diff suppressed because it is too large
Load diff
135
hack/helm-matrix.sh
Executable file
135
hack/helm-matrix.sh
Executable 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"
|
||||||
76
hack/integration-check.html
Normal file
76
hack/integration-check.html
Normal 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
124
hack/pilot-go-loose.js
Normal 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
167
hack/pilot-go-tell.js
Normal 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
524
internal/app/app.go
Normal 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
105
internal/app/backup.go
Normal 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
224
internal/app/spa_test.go
Normal 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
715
internal/auth/auth.go
Normal 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
417
internal/auth/auth_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
internal/auth/helpers_test.go
Normal file
60
internal/auth/helpers_test.go
Normal 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
20
internal/auth/hmac.go
Normal 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
375
internal/auth/oidc.go
Normal 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
572
internal/config/config.go
Normal 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
466
internal/domain/domain.go
Normal 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
931
internal/httpapi/admin.go
Normal 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
282
internal/httpapi/auth_handlers.go
Normal file
282
internal/httpapi/auth_handlers.go
Normal 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
287
internal/httpapi/cache.go
Normal 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() }
|
||||||
225
internal/httpapi/embed_test.go
Normal file
225
internal/httpapi/embed_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
86
internal/httpapi/logging_middleware.go
Normal file
86
internal/httpapi/logging_middleware.go
Normal 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
371
internal/httpapi/metrics.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
513
internal/httpapi/middleware.go
Normal file
513
internal/httpapi/middleware.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
75
internal/httpapi/preferences.go
Normal file
75
internal/httpapi/preferences.go
Normal 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{})
|
||||||
|
}
|
||||||
232
internal/httpapi/preferences_test.go
Normal file
232
internal/httpapi/preferences_test.go
Normal 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
195
internal/httpapi/respond.go
Normal 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
280
internal/httpapi/router.go
Normal 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
421
internal/httpapi/router_test.go
Normal file
421
internal/httpapi/router_test.go
Normal 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
761
internal/httpapi/runtime.go
Normal 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 · 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
130
internal/logging/logging.go
Normal 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)
|
||||||
|
}
|
||||||
605
internal/seed/consolidated.go
Normal file
605
internal/seed/consolidated.go
Normal 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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
80
internal/seed/consolidated_test.go
Normal file
80
internal/seed/consolidated_test.go
Normal 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
586
internal/seed/seed.go
Normal 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
157
internal/seed/seed_test.go
Normal 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
62
internal/store/backup.go
Normal 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
|
||||||
|
}
|
||||||
185
internal/store/backup_test.go
Normal file
185
internal/store/backup_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
250
internal/store/migrations.go
Normal file
250
internal/store/migrations.go
Normal 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
|
||||||
|
}
|
||||||
144
internal/store/migrations/0001_init.sql
Normal file
144
internal/store/migrations/0001_init.sql
Normal 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 ''
|
||||||
|
);
|
||||||
19
internal/store/migrations/0002_drop_api_keys.sql
Normal file
19
internal/store/migrations/0002_drop_api_keys.sql
Normal 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
Loading…
Add table
Add a link
Reference in a new issue