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

Everything here comes from a design token.

+
+ + +healthy +``` + +--- + +## The token model + +A theme is a [W3C Design Tokens Community Group](https://www.designtokens.org/) +document. That format was chosen deliberately: it is an existing standard, so +tokens exported from this service can be imported into Figma, Style Dictionary or +any other DTCG-aware tool. + +```json +{ + "color": { + "palette": { + "brand": { + "500": { "$type": "color", "$value": "#7c6bf5" }, + "600": { "$type": "color", "$value": "#6a58e8" } + } + }, + "semantic": { + "primary": { "$type": "color", "$value": "#7c6bf5" }, + "on-primary": { "$type": "color", "$value": "#0f0d1f" }, + "background": { "$type": "color", "$value": "#1a1d23" } + } + }, + "font": { + "family": { "sans": { "$type": "fontFamily", "$value": "Inter, system-ui, sans-serif" } }, + "size": { "md": { "$type": "fontSize", "$value": 14 } } + }, + "space": { "4": { "$type": "dimension", "$value": 16 } }, + + "$appearances": { + "dark": { + "color": { "semantic": { "background": { "$type": "color", "$value": "#0b1220" } } } + } + } +} +``` + +**Palettes are for building; semantic roles are for using.** A component consumes +`color.semantic.primary`, never `color.palette.brand.500`. That indirection is what +makes it possible to re-tint a whole product by changing one palette step, and it +is why the engine derives the foreground pairs automatically. + +**Light and dark are one document.** `$appearances` holds a partial token document +per colour scheme, deep-merged over the base. A token absent from the dark +overrides simply inherits its light value, so a theme with a dozen genuine +differences does not need two hundred. + +**The engine derives the rest.** For a theme that does not define them: + +| Derived | Rule | +| --- | --- | +| `--bn-color-on-*` | Black or white, whichever has the higher WCAG contrast ratio against the base colour | +| `--bn-color-*-hover` | The base mixed 8% toward the foreground of the scheme | +| `--bn-color-*-active` | Mixed 16% | +| `--bn-color-*-subtle` | The base at 12% alpha, for badge and hover-row backgrounds | +| `--bn-color-*-tint` | The base mixed 14% into the background | + +Anything you define yourself wins: derivation only fills gaps. + +### Validation + +Publishing is gated on validation. The engine checks structure, value types and +colour syntax, and warns about a missing dark scheme, an unused recommended token +and text that fails WCAG AA. + +Every built-in theme is asserted against WCAG AA for body text on every surface it +is likely to sit on, in both schemes — a test, not a review. The `high-contrast` +theme is held to AAA. + +--- + +## Built-in themes + +Four ship inside the binary and are reconciled at start-up. A built-in theme an +operator has edited is never overwritten; the reconciler compares the version. + +| Slug | Scheme | Purpose | +| --- | --- | --- | +| `midnight` | dark | The default. Violet-accented dark interface on a slate ramp. | +| `daylight` | light | The same brand hue, inverted, with softer shadows. | +| `mono` | light | Near-greyscale and dense. For tables, consoles and data tooling. | +| `high-contrast` | both | Pure black on pure white, thick strokes, no translucency. AAA. | + +To make one of your own, duplicate it and edit the copy. + +--- + +## The runtime API + +Public by default. A theming service that required a credential could not be +consumed from a stylesheet link, which is the primary use case. Every response is +cacheable and carries an `ETag`. + +| Endpoint | Purpose | +| --- | --- | +| `GET /rt/v1/theme.css?slug=…` | A stylesheet. `appearance`, `variant` and `v` are optional. | +| `GET /rt/v1/theme.json?slug=…` | Resolved tokens, flat and nested. | +| `GET /rt/v1/manifest.json` | The theme list, for building a picker. | +| `GET /rt/v1/embed?slug=…` | A ready-to-paste snippet for the target framework. | +| `GET /rt/v1/runtime.js` | The framework-free ES module. | +| `GET /rt/v1/react.js` | The React provider and hooks. | +| `GET /rt/v1/global.js` | The same API on `window.Bananas`. | +| `GET /rt/v1/embed.js` | The one-line script. | + +```bash +curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight' +curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&v=7' # pin a version +curl 'https://themes.example.com/rt/v1/manifest.json' | jq '.themes[].slug' +``` + +Path-addressable equivalents exist for consumers that prefer REST shapes or a CDN +configured with path patterns: `/rt/v1/themes/{slug}/theme.css`. + +### Cache behaviour + +Runtime responses are immutable by URL: publishing a theme changes its version, +and a version-pinned URL never changes. That lets a CDN cache them hard and +revalidate in a few milliseconds. `If-None-Match` is honoured, so a redeploy costs +a revalidation and no body transfer. + +--- + +## The administration API + +Authenticated, never cached, and every mutation audited. See +[docs/api.md](docs/api.md). + +| Method | Path | Role | Purpose | +| --- | --- | --- | --- | +| `GET` | `/api/v1/themes` | viewer | List and search | +| `GET` | `/api/v1/themes/{slug}` | viewer | One theme with its tokens | +| `POST` | `/api/v1/themes` | editor | Create a draft | +| `PATCH` | `/api/v1/themes/{slug}` | editor | Update, with optimistic concurrency | +| `POST` | `/api/v1/themes/{slug}/publish` | editor | Publish | +| `DELETE` | `/api/v1/themes/{slug}` | admin | Delete | +| `GET` | `/api/v1/themes/{slug}/versions` | viewer | History | +| `POST` | `/api/v1/themes/{slug}/versions/{v}/rollback` | editor | Restore as a new version | +| `POST` | `/api/v1/tokens/validate` | viewer | Validate without saving | +| `GET` | `/api/v1/tokens/contrast?fg=…&bg=…` | viewer | WCAG contrast | +| `GET` | `/api/v1/tokens/palette?hue=…` | viewer | Generate a ramp | +| `GET` | `/api/v1/admin/audit` | admin | The activity log | + +--- + +## Architecture + +``` + ┌──────────────────────────────────────┐ + Browser ───────▶│ /rt/v1 runtime, public, cached │──┐ + , fetch │ /api/v1 admin, authenticated │ │ in-memory + │ /auth/* OIDC login flow │ │ render cache + └──────────────────────────────────────┘ │ + │ │ + chi router + middleware │ + │ │ + ┌────────────────▼─────────────────────────┴────┐ + │ auth OIDC · sessions · RBAC │ + │ httpapi handlers, no business logic │ + │ tokenengine colour maths, CSS and JSON render │ + │ domain entities and invariants │ + │ store SQLite (WAL), repositories │ + └──────────────────┬──────────────────────────────┘ + │ + SQLite on a local volume +``` + +The load-bearing decisions, each argued in `docs/adr/`: + +- [ADR 0001](docs/adr/0001-embedded-sqlite.md) — why SQLite and not Postgres +- [ADR 0002](docs/adr/0002-runtime-delivery.md) — why delivery is at run time +- [ADR 0003](docs/adr/0003-stateful-sessions.md) — stateless signed cookies + +The properties that matter: + +- **The admin UI consumes the runtime.** It loads themes from `/rt/v1` and maps the + variables onto Tailwind's namespaces. A change that breaks theme delivery breaks + the editor, immediately, rather than being discovered by a customer. +- **Handlers hold no business logic.** They decode, call, encode and translate + errors. Anything worth reasoning about lives in `domain` or `tokenengine` where it + is testable without an HTTP server. +- **The render cache is pre-warmed** at start-up, so the first request after a + deploy is already fast. A write invalidates exactly one theme's entries. +- **The database connection pool is deliberately small.** SQLite serialises + writers; more connections buy contention, not throughput. + +--- + +## Why SQLite + +Themes are a few thousand JSON documents, read constantly and written rarely. +That is precisely the shape an embedded engine is good at, and a client/server +database would add a network hop, a connection pool, a second container, +credentials to rotate and a schema migration process to a service whose entire +dataset comfortably fits in a few megabytes. + +The configuration that makes it fast rather than merely adequate: + +| Setting | Why | +| --- | --- | +| `journal_mode=WAL` | Readers never block the writer, and the writer never blocks readers | +| `synchronous=NORMAL` | Durable across a process crash; risks only the last transactions on an OS crash. A re-done publish is not an outage | +| `cache_size=-16000` | A 16 MiB cache holds the whole dataset | +| `mmap_size=256 MiB` | The page cache serves reads without a copy | +| Max 8 connections | SQLite serialises writers; a large pool adds contention | +| Prepared statements | The hot runtime query is compiled once | +| In-memory render cache | The steady-state runtime request touches no database at all | + +The engine is `modernc.org/sqlite`, a pure-Go translation. No cgo, so the release +image builds static and the cross-compiled binaries are genuinely portable. + +**Scaling past one replica** needs either a shared filesystem or a different +database. Both are documented in [docs/deployment.md](docs/deployment.md#scaling). + +--- + +## Deployment + +The Helm chart validates its own configuration at render time and refuses to +produce a manifest it knows is wrong: + +```bash +$ helm template g deploy/helm/go-bananas --set config.shutdownTimeoutMs=60000 +Error: config.shutdownTimeoutMs (60000) must be less than +deployment.terminationGracePeriodSeconds (45s), otherwise Kubernetes sends +SIGKILL before graceful shutdown finishes and in-flight requests are cut off. +``` + +Guarded at render time: a missing or malformed `publicBaseUrl`, an unknown auth +mode, a missing OIDC secret, both secret sources at once, `skipTLSVerify` in +production, and a shutdown timeout that exceeds the grace period. Failing at render +time means a bad release never reaches the API server, so there is no half-created +Deployment and no `CrashLoopBackOff` to diagnose. + +Security posture by default: non-root, read-only root filesystem, all capabilities +dropped, no API token mounted, no CPU limit, security headers on every response, and +a Content-Security-Policy strict enough that the admin UI needed no exceptions for +script sources. + +--- + +## Development + +```bash +make help # every task +make install # dependencies +make dev-all # API on :8080, admin UI on :5173 +make check # format, lint and test everything +``` + +Requires Go 1.27 and Node 22 or later. `make tools` installs `air`, +`golangci-lint` and `sqlc`. + +## Documentation + +| Document | Contents | +| --- | --- | +| [docs/architecture.md](docs/architecture.md) | How the pieces fit, and why | +| [docs/standards.md](docs/standards.md) | **Coding standards and conventions. Read this before contributing** | +| [docs/integration.md](docs/integration.md) | Consuming themes, every framework | +| [docs/api.md](docs/api.md) | The HTTP API, with examples | +| [docs/deployment.md](docs/deployment.md) | Running it, at any scale | +| [docs/development.md](docs/development.md) | Working on it | +| [docs/operations.md](docs/operations.md) | Monitoring, backup, runbook | +| [docs/security.md](docs/security.md) | Threat model and hardening | +| [docs/styling-contract.md](docs/styling-contract.md) | The shared styling contract, and how an application adopts it | +| [docs/adr/](docs/adr/) | Architecture decision records | + +## Licence + +MIT. \ No newline at end of file diff --git a/cat b/cat new file mode 100644 index 0000000..e69de29 diff --git a/cmd/go-bananas/main.go b/cmd/go-bananas/main.go new file mode 100644 index 0000000..a413227 --- /dev/null +++ b/cmd/go-bananas/main.go @@ -0,0 +1,95 @@ +// Command go-bananas is the theme service: an administrative UI for authoring +// design-token themes, and a runtime API that serves those themes to any +// application at run time. +// +// It reads its entire configuration from the environment so that the Helm chart +// is the single source of truth for how a deployment is configured. +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "syscall" + + "github.com/urpi/go-bananas/internal/app" +) + +func main() { + os.Exit(run(os.Args[1:])) +} + +// run dispatches on the subcommand and returns the process exit status. +// +// The subcommand is checked before the signal handler is installed: a backup is a +// short batch job that should exit on its own, and a context cancelled by an +// interrupt would make a snapshot look like it failed when it was in fact told to +// stop. +func run(args []string) int { + // The server is the default, so an invocation with no arguments, or one whose + // first argument is not a known subcommand, starts it. + if len(args) > 0 { + switch args[0] { + case "backup": + if err := app.RunBackup(context.Background(), args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "Go Bananas!! backup: %v\n", err) + return 1 + } + return 0 + case "version": + fmt.Printf("Go Bananas!! %s\n", app.Version) + return 0 + case "help", "-h", "--help": + usage(os.Stdout) + return 0 + } + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := app.Run(ctx); err != nil { + // Configuration and wiring failures are the expected failure mode at + // start-up, so they are reported plainly on stderr with no log decoration. + fmt.Fprintf(os.Stderr, "Go Bananas!!: %v\n", err) + return exitCodeFor(err) + } + return 0 +} + +// usage prints the top-level help. +func usage(w io.Writer) { + fmt.Fprint(w, `usage: go-bananas [command] + +Runs the theme service: an administrative UI for authoring design-token themes and +a runtime API that serves them to any application at run time. + +With no command, the HTTP server starts. Configuration comes entirely from the +environment; see docs/deployment.md. + +Commands: + backup Write a consistent snapshot of the database and exit + version Print the build version + help Print this message + +Examples: + go-bananas + go-bananas backup --to /srv/data/snapshots/go-bananas.db +`) +} + +// exitCodeFor maps an error onto a process exit status. +// +// 1 configuration or wiring failure +// 2 the context was cancelled before the server started +func exitCodeFor(err error) int { + switch { + case errors.Is(err, context.Canceled): + return 2 + default: + return 1 + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..c051152 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,143 @@ +# Local development stack. +# +# The default profile is the simplest thing that works: one container, the +# development authentication bypass, and a volume for the database. That is enough +# to work on the product. +# +# The `oidc` profile adds a mock OpenID Connect provider, so the authentication +# flow can be exercised end to end without pointing at a real realm. The `oidc` +# profile uses the mock because auth-dev.urpi.be is a shared development realm and a +# local stack should not depend on it being reachable. +# +# Usage: +# docker compose up --build # just the service, dev auth +# docker compose --profile oidc up --build # with a mock identity provider +# docker compose --profile postgres up # against a real PostgreSQL, for parity testing +# +# Ports: +# 8080 Go Bananas!! (admin UI and runtime API) +# 8081 mock OIDC provider (oidc profile) +# 5432 PostgreSQL (postgres profile) + +name: go-bananas + +services: + go-bananas: + build: + context: . + dockerfile: Dockerfile + args: + VERSION: ${VERSION:-dev} + COMMIT: ${COMMIT:-unknown} + BUILD_DATE: ${BUILD_DATE:-unknown} + image: ${IMAGE:-go-bananas:dev} + ports: + - "${PORT:-8080}:8080" + environment: + # Development environment. This is what unlocks the auth bypass; setting it + # to anything else makes the service refuse to start in dev mode, which is + # deliberate: an accidentally deployed bypass must not be possible. + BANANAS_ENVIRONMENT: development + BANANAS_AUTH_MODE: ${AUTH_MODE:-dev} + + BANANAS_HTTP_ADDR: :8080 + BANANAS_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:8080} + + # An explicit secret so that a session survives `docker compose restart`. + # This is a local-only value and is intentionally obvious about it. + BANANAS_SESSION_SECRET: ${SESSION_SECRET:-local-development-secret-do-not-use-in-production-000000} + + BANANAS_LOG_LEVEL: ${LOG_LEVEL:-debug} + BANANAS_LOG_FORMAT: ${LOG_FORMAT:-text} + + BANANAS_DATABASE_PATH: /srv/data/go-bananas.db + BANANAS_SEED_ON_START: "true" + + # Present only in the oidc profile; Compose passes the value through and the + # service ignores these when AUTH_MODE is dev. + BANANAS_OIDC_ISSUER: ${OIDC_ISSUER:-} + BANANAS_OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + BANANAS_OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + BANANAS_OIDC_SKIP_TLS_VERIFY: ${OIDC_SKIP_TLS_VERIFY:-false} + BANANAS_OIDC_ADMIN_GROUPS: ${OIDC_ADMIN_GROUPS:-} + BANANAS_OIDC_EDITOR_GROUPS: ${OIDC_EDITOR_GROUPS:-} + BANANAS_SESSION_SECURE: ${SESSION_SECURE:-false} + volumes: + - go-bananas-data:/srv/data + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8080/healthz"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 5s + restart: unless-stopped + # The Compose file is a local development convenience. The resource limits here + # are not a production sizing recommendation; use the Helm chart for that. + deploy: + resources: + limits: + cpus: "2.0" + memory: 512M + + # ------------------------------------------------------------ oidc profile -- + + keycloak: + image: quay.io/keycloak/keycloak:26.3 + profiles: ["oidc"] + command: + - start-dev + - --import-realm + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + KC_HOSTNAME_STRICT: "false" + # Lets the issuer be reached from the host and from inside the network under + # the same name, which is what makes the discovery document work in both. + KC_HOSTNAME_URL: http://keycloak:8080 + KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true" + volumes: + - ./deploy/dev/realm-go-bananas.json:/opt/keycloak/data/import/realm-go-bananas.json:ro + ports: + - "${KEYCLOAK_PORT:-8081}:8080" + healthcheck: + # Keycloak's own health endpoint is the only reliable readiness signal; the + # port is open well before the realm is actually ready. + test: + [ + "CMD-SHELL", + "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'", + ] + interval: 10s + timeout: 5s + retries: 20 + start_period: 20s + + # ------------------------------------------------------- postgres profile -- + # + # go-bananas does not need PostgreSQL. This profile exists so that a future + # backend can be compared against it for parity, not because it is part of the + # product's architecture. + + postgres: + image: postgres:18-alpine + profiles: ["postgres"] + environment: + POSTGRES_DB: go_bananas + POSTGRES_USER: go_bananas + POSTGRES_PASSWORD: local-only-password + volumes: + - go-bananas-postgres:/var/lib/postgresql/data + ports: + - "${POSTGRES_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U go_bananas -d go_bananas"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + go-bananas-data: + driver: local + go-bananas-postgres: + driver: local diff --git a/deploy/dev/realm-go-bananas.json b/deploy/dev/realm-go-bananas.json new file mode 100644 index 0000000..e4b9457 --- /dev/null +++ b/deploy/dev/realm-go-bananas.json @@ -0,0 +1,120 @@ +{ + "realm": "go-bananas-dev", + "enabled": true, + "displayName": "go-bananas (development)", + "sslRequired": "none", + "registrationAllowed": true, + "registrationEmailAsUsername": true, + "rememberMe": true, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "clients": [ + { + "clientId": "go-bananas", + "name": "go-bananas", + "description": "The theme service: admin UI and runtime API.", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "go-bananas-dev-secret", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:8080/auth/callback", + "http://localhost:5173/auth/callback" + ], + "webOrigins": [ + "http://localhost:8080", + "http://localhost:5173" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://localhost:8080/*" + }, + "defaultClientScopes": [ + "openid", + "profile", + "email", + "groups", + "roles" + ], + "optionalClientScopes": [ + "offline_access" + ] + } + ], + "roles": { + "realm": [ + { "name": "bananas-admin", "description": "Full administrative access." }, + { "name": "bananas-editor", "description": "Create and publish themes." }, + { "name": "bananas-viewer", "description": "Read-only access." } + ] + }, + "groups": [ + { + "name": "bananas-admins", + "path": "/bananas-admins", + "attributes": { "displayName": "go-bananas administrators" } + }, + { + "name": "bananas-editors", + "path": "/bananas-editors", + "attributes": { "displayName": "go-bananas editors" } + }, + { + "name": "bananas-viewers", + "path": "/bananas-viewers", + "attributes": { "displayName": "go-bananas viewers" } + } + ], + "users": [ + { + "username": "admin", + "email": "admin@go-bananas.local", + "firstName": "Dev", + "lastName": "Admin", + "emailVerified": true, + "enabled": true, + "credentials": [ + { "type": "password", "value": "admin", "temporary": false } + ], + "groups": ["/bananas-admins"], + "realmRoles": ["bananas-admin"] + }, + { + "username": "editor", + "email": "editor@go-bananas.local", + "firstName": "Dev", + "lastName": "Editor", + "emailVerified": true, + "enabled": true, + "credentials": [ + { "type": "password", "value": "editor", "temporary": false } + ], + "groups": ["/bananas-editors"], + "realmRoles": ["bananas-editor"] + }, + { + "username": "viewer", + "email": "viewer@go-bananas.local", + "firstName": "Dev", + "lastName": "Viewer", + "emailVerified": true, + "enabled": true, + "credentials": [ + { "type": "password", "value": "viewer", "temporary": false } + ], + "groups": ["/bananas-viewers"], + "realmRoles": ["bananas-viewer"] + } + ] +} \ No newline at end of file diff --git a/deploy/dev/values-dev-cluster.yaml b/deploy/dev/values-dev-cluster.yaml new file mode 100644 index 0000000..cc6c09c --- /dev/null +++ b/deploy/dev/values-dev-cluster.yaml @@ -0,0 +1,90 @@ +# go-bananas on the development cluster. +# +# Reproduce with: +# +# helm upgrade --install go-bananas deploy/helm/go-bananas \ +# --namespace go-insane-dev -f deploy/dev/values-dev-cluster.yaml \ +# --server-side=false --disable-openapi-validation \ +# --wait +# +# Two flags are needed for this cluster specifically, and neither is a chart defect. +# +# --server-side=false The cluster's OpenAPI validation is incomplete: +# it rejects `imagePullPolicy` and `lifecycle` at +# `spec.template.spec` as undeclared fields on a +# k8s 1.36 API server. A hand-written Deployment +# with those two fields fails the same way. Helm 4 +# uses server-side apply by default, which hits it. +# --disable-openapi-validation The same broken schema, on the client side. +# +# Nothing else in the cluster needs these; they are here so this release is +# reproducible rather than "whatever worked when it was deployed". + +image: + # The development cluster pulls from a private registry whose certificate is valid + # for a different name, so the host and the port have to stay in one piece: putting + # the port in `repository` renders an image reference without it, and the kubelet + # then resolves the registry over https on port 443. + registry: batty1039.startdedicated.net:5000 + repository: go-bananas + # The development registry is tagged `latest` rather than pinned to a version, to + # match every other release in this namespace. Consequently the pull policy must be + # Always: with IfNotPresent a node keeps serving the image it already has, and a + # redeploy silently does nothing. + tag: latest + pullPolicy: Always + +config: + # development, which is what permits the dev identity mode below. The application + # refuses both of these outside a development environment, so this pair cannot be + # promoted to production by accident. + environment: development + publicBaseUrl: https://bananas-dev.urpi.be + +identity: + # dev, not oidc. + # + # OIDC is the correct production mode and the chart is wired for it, but it cannot + # be used here yet: the cluster's CoreDNS returns NXDOMAIN for public names, so the + # issuer is unreachable from inside the cluster and the pod crash-loops in OIDC + # discovery. That is a cluster DNS problem, not a chart one, and it also explains why + # no certificate in this namespace has been issued. + # + # To switch, create the secret and set the OIDC values: + # + # kubectl create secret generic go-bananas-auth -n go-insane-dev \ + # --from-literal=session-secret="$(openssl rand -base64 48)" \ + # --from-literal=oidc-client-secret='' + # + # helm upgrade ... --set identity.mode=oidc \ + # --set identity.oidc.generateSecret=false \ + # --set identity.oidc.existingSecret=go-bananas-auth \ + # --set identity.oidc.issuer=https://auth-dev.urpi.be \ + # --set identity.oidc.clientId=go-bananas + mode: dev + oidc: + issuer: https://auth-dev.urpi.be + clientId: go-bananas + existingSecret: go-bananas-auth + generateSecret: false + +ingress: + enabled: true + annotations: + cert-manager.io/cluster-issuer: letsencrypt-staging + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: bananas-dev.urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-bananas-tls + hosts: + - bananas-dev.urpi.be + +deployment: + # One replica. The volume is ReadWriteOnce, so a second replica cannot mount it and + # would sit in ImagePullBackOff-equivalent limbo forever. Scaling needs either this + # changed to 1 or a ReadWriteMany filesystem. + replicaCount: 1 \ No newline at end of file diff --git a/deploy/helm/go-bananas/.helmignore b/deploy/helm/go-bananas/.helmignore new file mode 100644 index 0000000..9073086 --- /dev/null +++ b/deploy/helm/go-bananas/.helmignore @@ -0,0 +1,19 @@ +# Files excluded from the packaged chart. Anything matched here is neither sent to +# the cluster nor included in `helm package`, which keeps the artifact small and +# stops a local editor's swap file from ever reaching a release. +.DS_Store +.git/ +.gitignore +.idea/ +.vscode/ +*.swp +*.bak +*.tmp +*.orig +*.tgz +*.md + +# Documentation is excluded from the packaged chart: it is several hundred +# kilobytes of prose that the cluster does not need, and a chart artifact +# should contain only what is required to deploy. +*.md diff --git a/deploy/helm/go-bananas/Chart.yaml b/deploy/helm/go-bananas/Chart.yaml new file mode 100644 index 0000000..08e49f8 --- /dev/null +++ b/deploy/helm/go-bananas/Chart.yaml @@ -0,0 +1,36 @@ +apiVersion: v2 +name: go-bananas +description: >- + Runtime design-token service. Author design-token themes in a web interface and + serve them to any application as CSS custom properties at run time, with no npm + dependency and no build step on the consumer side. +type: application + +# The chart version. Bumped for every change to the chart itself. +version: 1.0.0 + +# The version of the application this chart deploys, which follows the image tag. +# Keeping the two in step is what `helm upgrade --reuse-values` needs to roll +# forward correctly. +appVersion: "1.0.0" + +kubeVersion: ">=1.28.0-0" + +keywords: + - design-system + - design-tokens + - theming + - css-variables + - frontend + +icon: https://raw.githubusercontent.com/urpi/go-bananas/main/docs/assets/logo.svg +home: https://github.com/urpi/go-bananas +sources: + - https://github.com/urpi/go-bananas +maintainers: + - name: urpi platform team + url: https://github.com/urpi + +annotations: + category: DeveloperTools + licenses: MIT diff --git a/deploy/helm/go-bananas/templates/NOTES.txt b/deploy/helm/go-bananas/templates/NOTES.txt new file mode 100644 index 0000000..b4ef892 --- /dev/null +++ b/deploy/helm/go-bananas/templates/NOTES.txt @@ -0,0 +1,89 @@ +{{- $name := include "go-bananas.fullname" . }} + +go-bananas {{ .Chart.AppVersion }} is deployed as {{ $name }} in namespace {{ .Release.Namespace }}. + +──────────────────────────────────────────────────────────────────────────── +{{- if .Values.ingress.enabled }} + +Your instance is reachable at: + +{{- range .Values.ingress.hosts }} +{{- $scheme := "http" }} +{{- if $.Values.ingress.tls }}{{ $scheme = "https" }}{{ end }} +{{- range $.Values.ingress.hosts }} + {{ $scheme }}://{{ .host }} +{{- end }} +{{- break }} +{{- end }} + +Follow the admin interface from there. Sign in with your identity provider. +{{- else if eq .Values.service.type "NodePort" }} + +Your instance is on node port {{ .Values.service.nodePort }}. Find the address with: + + kubectl get nodes -o wide + kubectl port-forward --address 0.0.0.0 svc/{{ $name }} 8080:{{ .Values.service.port }} +{{- else }} + +Port-forward to reach the admin interface: + + kubectl port-forward --namespace {{ .Release.Namespace }} svc/{{ $name }} 8080:{{ .Values.service.port }} + +Then open http://localhost:8080 +{{- end }} + +──────────────────────────────────────────────────────────────────────────── +Runtime integration + +{{- if eq .Values.identity.mode "oidc" }} + +Consumers fetch themes with no dependency and no build step. Add this to any +application's : + + + +Full instructions are at {{ include "go-bananas.publicUrl" . }}/rt/v1/embed?slug=midnight + +Available themes: + +{{- $base := printf "%s/rt/v1" (include "go-bananas.publicUrl" .) }} +{{- $base }}{{ "/theme.css?slug=" }} +{{- $base }}{{ "/theme.json?slug=" }} +{{- $base }}{{ "/manifest.json" }} +{{- $base }}{{ "/runtime.js" }} (framework-free ES module) +{{- $base }}{{ "/react.js" }} (React provider and hooks) +{{- $base }}{{ "/embed.js" }} (one-line script tag) +{{- else }} + +Authentication is set to "{{ .Values.identity.mode }}". Every request is currently +treated as an administrator. Before exposing this to a network, set: + + identity.mode=oidc + identity.oidc.issuer=https://auth-dev.urpi.be/realms/ + identity.oidc.clientId=go-bananas + identity.oidc.existingSecret= +{{- end }} + +──────────────────────────────────────────────────────────────────────────── +Verify it is working + + kubectl --namespace {{ .Release.Namespace }} get pods -l app.kubernetes.io/instance={{ .Release.Name }} + kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ $name }} 8080:{{ .Values.service.port }} + curl -s localhost:{{ .Values.service.port }}/readyz +{{- if .Values.config.enableMetrics }} + + curl -s localhost:{{ .Values.service.port }}/metrics | head +{{- end }} + +──────────────────────────────────────────────────────────────────────────── +Notes + + * The database is a single SQLite file on a {{ .Values.persistence.type | default "disabled" }} volume +{{- if and .Values.persistence.enabled (eq .Values.persistence.type "persistentVolumeClaim") }} + at {{ .Values.persistence.mountPath }}. It is retained on uninstall. +{{- end }} + * {{ .Values.deployment.replicaCount }} replica(s). With a ReadWriteOnce volume, more than one + replica needs a filesystem that permits concurrent mounts. + * Built-in themes are reconciled at start-up. A built-in theme an operator has + edited is never overwritten. diff --git a/deploy/helm/go-bananas/templates/_helpers.tpl b/deploy/helm/go-bananas/templates/_helpers.tpl new file mode 100644 index 0000000..d94981e --- /dev/null +++ b/deploy/helm/go-bananas/templates/_helpers.tpl @@ -0,0 +1,256 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "go-bananas.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +A fully qualified app name, truncated to the 63-character DNS label limit. + +The release name is included because a chart may legitimately be installed more +than once in a namespace: one for staging themes and one for production. Without +it, the two would collide on every object name. +*/}} +{{- define "go-bananas.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Chart name and version, as used by the standard chart label. +*/}} +{{- define "go-bananas.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Selector labels. + +These are deliberately stable: a Deployment's selector is immutable, so changing +these labels on an existing release makes the upgrade fail. That failure is the +point — it prevents the controller from orphaning pods. +*/}} +{{- define "go-bananas.selectorLabels" -}} +app.kubernetes.io/name: {{ include "go-bananas.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Common labels, including the recommended Kubernetes labels and the operator's own. +*/}} +{{- define "go-bananas.labels" -}} +helm.sh/chart: {{ include "go-bananas.chart" . }} +{{ include "go-bananas.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: go-bananas +{{- with .Values.global.extraLabels }} +{{ toYaml . }} +{{- end }} +{{- end }} + +{{/* +The image reference, honouring the digest override. + +A digest is preferred over a tag when both are available: it is the only way to +guarantee that a rollout is running exactly the image that was reviewed, rather +than whatever a mutable tag points at today. + +Both branches build the reference the same way — registry, then repository path, +then the tag or digest — so the digest branch cannot drift into producing a +doubled registry prefix such as ghcr.io/ghcr.io/urpi/go-bananas. +*/}} +{{- define "go-bananas.image" -}} +{{- $registry := .Values.image.registry -}} +{{- if .Values.global.imageRegistry -}} +{{- $registry = .Values.global.imageRegistry -}} +{{- end -}} +{{- if not $registry -}} +{{- fail "image.registry must not be empty. It is the registry host, for example ghcr.io or registry.example.com." -}} +{{- end -}} +{{- $repository := .Values.image.repository -}} +{{- /* Convention: image.registry is the host and image.repository is the path. + But an operator who writes the host into repository should get the reference + they asked for, not ghcr.io/quay.io/other/app. A first path segment + containing a dot or a colon, or equal to localhost, is a registry host + rather than a namespace. */ -}} +{{- $first := first (splitList "/" $repository) -}} +{{- $looksQualified := or (eq $repository $registry) (hasPrefix (printf "%s/" $registry) $repository) (contains "." $first) (contains ":" $first) (eq $first "localhost") -}} +{{- $name := $repository -}} +{{- if not $looksQualified -}} +{{- $name = printf "%s/%s" $registry $repository -}} +{{- end -}} +{{- if .Values.image.digest -}} +{{- printf "%s@%s" $name .Values.image.digest -}} +{{- else -}} +{{- printf "%s:%s" $name (default .Chart.AppVersion .Values.image.tag) -}} +{{- end -}} +{{- end }} + +{{/* +The ServiceAccount name to use. +*/}} +{{- define "go-bananas.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "go-bananas.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Whether a generated Secret should be produced. + +Either mode needs a Secret, but the two differ in ownership. An existing Secret is +the operator's, so the chart only reads it. A generated one is created when +generateSecret is true, and the value is preserved across upgrades so that a +rolling update does not silently invalidate every session. +*/}} +{{- define "go-bananas.usesExistingSecret" -}} +{{- if and .Values.identity.oidc.existingSecret .Values.identity.oidc.generateSecret -}} +{{- fail "identity.oidc.existingSecret and identity.oidc.generateSecret are mutually exclusive. Set generateSecret to false when supplying an existing Secret." -}} +{{- end -}} +{{- if not (or .Values.identity.oidc.existingSecret .Values.identity.oidc.generateSecret) -}} +{{- fail "identity.oidc needs a Secret. Set identity.oidc.existingSecret to a Secret you manage, or identity.oidc.generateSecret to true for a chart-managed one." -}} +{{- end -}} +{{- .Values.identity.oidc.existingSecret -}} +{{- end }} + +{{/* +The name of the Secret holding the session and client secrets. +*/}} +{{- define "go-bananas.secretName" -}} +{{- if .Values.identity.oidc.existingSecret -}} +{{- .Values.identity.oidc.existingSecret -}} +{{- else -}} +{{- printf "%s-auth" (include "go-bananas.fullname" .) -}} +{{- end -}} +{{- end }} + +{{/* +The key inside the Secret holding the session signing secret. +*/}} +{{- define "go-bananas.sessionSecretKey" -}} +{{- .Values.identity.sessionSecretKey -}} +{{- end }} + +{{/* +The key inside the Secret holding the OIDC client secret. +*/}} +{{- define "go-bananas.clientSecretKey" -}} +{{- default .Values.identity.oidc.clientSecretKey .Values.identity.oidc.existingSecretKeys.clientSecret -}} +{{- end }} + +{{/* +Validate the identity configuration before anything is rendered. + +Failing at template time is much better than failing at run time: a misconfigured +release never reaches the API server, so there is no half-created Deployment and no +CrashLoopBackOff to diagnose. Each check names the value to change. +*/}} +{{- define "go-bananas.validate" -}} +{{- $mode := .Values.identity.mode -}} +{{- if not (has $mode (list "oidc" "dev" "disabled")) -}} +{{- fail (printf "identity.mode %q is not valid. Use oidc, dev or disabled." $mode) -}} +{{- end -}} + +{{- if eq $mode "oidc" -}} + {{- if not .Values.identity.oidc.issuer -}} + {{- fail "identity.oidc.issuer is required when identity.mode is oidc. For the development realm this is https://auth-dev.urpi.be/realms/." -}} + {{- end -}} + {{- if not (regexMatch "^https?://" .Values.identity.oidc.issuer) -}} + {{- fail (printf "identity.oidc.issuer %q is not a URL. It must include the scheme." .Values.identity.oidc.issuer) -}} + {{- end -}} + {{- if not .Values.identity.oidc.clientId -}} + {{- fail "identity.oidc.clientId is required when identity.mode is oidc." -}} + {{- end -}} + {{- /* The result is discarded on purpose. This call exists for its fail-fast side + effect, and returning the Secret *name* from the helper would write it into + the rendered manifest as a bare line above `apiVersion`, which is invalid + YAML. It only went unnoticed because with the default generateSecret the + helper returns the empty string. */ -}} + {{- $_ := include "go-bananas.usesExistingSecret" . -}} +{{- end -}} + +{{- if eq $mode "oidc" -}} + {{- /* Refused in production because it disables certificate verification on the + connection to the identity provider, which is the one connection where a + man-in-the-middle would be most damaging. */}} + {{- if and .Values.identity.oidc.skipTLSVerify (eq .Values.config.environment "production") -}} + {{- fail "identity.oidc.skipTLSVerify cannot be combined with config.environment: production. It disables certificate verification against the identity provider. Set config.environment to development, or supply a trusted CA instead." -}} + {{- end -}} +{{- end -}} + +{{- if not .Values.config.publicBaseUrl -}} +{{- fail "config.publicBaseUrl is required. It is used to build the OIDC redirect URI, the absolute URLs in the runtime manifest, and the HSTS decision." -}} +{{- end -}} +{{- if not (regexMatch "^https?://[^/]+" .Values.config.publicBaseUrl) -}} +{{- fail (printf "config.publicBaseUrl %q must be an absolute URL with a scheme and host, for example https://themes.example.com" .Values.config.publicBaseUrl) -}} +{{- end -}} +{{- if hasSuffix "/" .Values.config.publicBaseUrl -}} +{{- fail (printf "config.publicBaseUrl %q must not end with a slash. Use https://themes.example.com, not https://themes.example.com/." .Values.config.publicBaseUrl) -}} +{{- end -}} + +{{- if and (gt (int .Values.config.shutdownTimeoutMs) 0) (gt (int .Values.deployment.terminationGracePeriodSeconds) 0) -}} + {{- /* The grace period is in seconds and the timeout in milliseconds, so the + comparison converts one to the other rather than comparing 25000 to 45. */}} + {{- $graceMs := mul (int .Values.deployment.terminationGracePeriodSeconds) 1000 -}} + {{- if ge (int .Values.config.shutdownTimeoutMs) $graceMs -}} + {{- fail (printf "config.shutdownTimeoutMs (%d) must be less than deployment.terminationGracePeriodSeconds (%ds), otherwise Kubernetes sends SIGKILL before graceful shutdown finishes and in-flight requests are cut off." (int .Values.config.shutdownTimeoutMs) (int .Values.deployment.terminationGracePeriodSeconds)) -}} + {{- end -}} +{{- end -}} + +{{- end }} + +{{/* +The public origin, with the base path removed so that it can be concatenated with +a route. +*/}} +{{- define "go-bananas.publicUrl" -}} +{{- .Values.config.publicBaseUrl -}} +{{- end }} + +{{/* +The base path, normalised to either an empty string or a leading-slash path with no +trailing slash. +*/}} +{{- define "go-bananas.basePath" -}} +{{- .Values.config.basePath | trimSuffix "/" -}} +{{- end }} + +{{/* +Whether the deployment is served over TLS, which decides the Secure cookie flag and +whether HSTS is advertised. +*/}} +{{- define "go-bananas.isSecure" -}} +{{- if .Values.service.sessionSecure -}}true{{- end -}} +{{- end }} + +{{/* +The database file path inside the container. +*/}} +{{- define "go-bananas.databasePath" -}} +{{- printf "%s/go-bananas.db" (trimSuffix "/" .Values.persistence.mountPath) -}} +{{- end }} + +{{/* +The data volume name, whether it is a PVC or an emptyDir. +*/}} +{{- define "go-bananas.dataVolumeName" -}} +{{- if .Values.persistence.existingClaim -}} +{{- .Values.persistence.existingClaim -}} +{{- else -}} +{{- printf "%s-data" (include "go-bananas.fullname" .) -}} +{{- end -}} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/configmap.yaml b/deploy/helm/go-bananas/templates/configmap.yaml new file mode 100644 index 0000000..48f6678 --- /dev/null +++ b/deploy/helm/go-bananas/templates/configmap.yaml @@ -0,0 +1,93 @@ +{{/* +The ConfigMap holding every non-secret setting. + +Splitting configuration from secrets is the point of this file: a ConfigMap is +readable by anyone with namespace access and appears in `kubectl describe`, so +nothing sensitive can end up in one by accident. +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- with .Values.global.extraAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +data: + # ---------------------------------------------------------------- runtime --- + BANANAS_ENVIRONMENT: {{ .Values.config.environment | quote }} + BANANAS_HTTP_ADDR: ":8080" + BANANAS_PUBLIC_BASE_URL: {{ include "go-bananas.publicUrl" . | quote }} + BANANAS_BASE_PATH: {{ include "go-bananas.basePath" . | quote }} + BANANAS_STATIC_DIR: "/srv/web" + BANANAS_SDK_DIR: "/srv/sdk" + + BANANAS_LOG_LEVEL: {{ .Values.config.logLevel | quote }} + BANANAS_LOG_FORMAT: {{ .Values.config.logFormat | quote }} + + # ---------------------------------------------------------------- storage --- + BANANAS_DATABASE_PATH: {{ include "go-bananas.databasePath" . | quote }} + BANANAS_DB_MAX_CONNS: {{ .Values.config.database.maxConns | quote }} + BANANAS_DB_BUSY_TIMEOUT: {{ printf "%dms" (int .Values.config.database.busyTimeoutMs) | quote }} + BANANAS_SEED_ON_START: "true" + + # ------------------------------------------------------------------- http --- + BANANAS_HTTP_READ_TIMEOUT: "15s" + BANANAS_HTTP_WRITE_TIMEOUT: "30s" + BANANAS_HTTP_IDLE_TIMEOUT: "120s" + BANANAS_SHUTDOWN_TIMEOUT: {{ printf "%dms" (int .Values.config.shutdownTimeoutMs) | quote }} + BANANAS_RATE_LIMIT_PER_MINUTE: {{ .Values.config.rateLimitPerMinute | quote }} + BANANAS_ENABLE_METRICS: {{ .Values.config.enableMetrics | quote }} + BANANAS_ENABLE_PROFILER: {{ .Values.config.enableProfiler | quote }} + + # Rendered as a comma-separated list because that is what the config loader + # parses. An empty value is deliberate: it means forwarding headers are ignored, + # so the audit log records the real peer rather than a spoofable header. + BANANAS_TRUSTED_PROXIES: {{ join "," .Values.config.trustedProxies | quote }} + BANANAS_CORS_ALLOWED_ORIGINS: {{ join "," .Values.config.corsAllowedOrigins | quote }} + + # ----------------------------------------------------------------- runtime --- + BANANAS_RUNTIME_PUBLIC: "true" + BANANAS_RUNTIME_CACHE_CONTROL: "public, max-age=60, s-maxage=300, stale-while-revalidate=86400" + # Set this when assets are served from a CDN, so the manifest hands consumers the + # CDN URL rather than this service. + BANANAS_RUNTIME_CDN_BASE_URL: {{ .Values.config.runtimeCdnBaseUrl | default "" | quote }} + + # ------------------------------------------------------------- identity --- + BANANAS_AUTH_MODE: {{ .Values.identity.mode | quote }} + + {{- if eq .Values.identity.mode "oidc" }} + BANANAS_OIDC_ISSUER: {{ .Values.identity.oidc.issuer | quote }} + BANANAS_OIDC_CLIENT_ID: {{ .Values.identity.oidc.clientId | quote }} + BANANAS_OIDC_SCOPES: {{ join "," .Values.identity.oidc.scopes | quote }} + BANANAS_OIDC_REDIRECT_URL: {{ .Values.identity.oidc.redirectURL | default "" | quote }} + BANANAS_OIDC_POST_LOGOUT_REDIRECT_URL: {{ .Values.identity.oidc.postLogoutRedirectURL | default "" | quote }} + {{- with .Values.identity.oidc.adminGroups }} + BANANAS_OIDC_ADMIN_GROUPS: {{ join "," . | quote }} + {{- end }} + {{- with .Values.identity.oidc.editorGroups }} + BANANAS_OIDC_EDITOR_GROUPS: {{ join "," . | quote }} + {{- end }} + {{- with .Values.identity.oidc.viewerGroups }} + BANANAS_OIDC_VIEWER_GROUPS: {{ join "," . | quote }} + {{- end }} + {{- if .Values.identity.oidc.skipTLSVerify }} + BANANAS_OIDC_SKIP_TLS_VERIFY: "true" + {{- end }} + {{- end }} + + {{- if eq .Values.identity.mode "dev" }} + # Development bypass: every request is a local administrator. The application + # refuses to start in this mode outside a development environment, so this block + # is refused by config.environment: production above. + BANANAS_DEV_FALLBACK_ROLE: "admin" + {{- end }} + + # ----------------------------------------------------------------- session --- + BANANAS_SESSION_COOKIE_NAME: "go-bananas-session" + BANANAS_SESSION_TTL: {{ printf "%dh" (int .Values.config.sessionTTLHours) | quote }} + BANANAS_SESSION_SECURE: {{ .Values.service.sessionSecure | quote }} + BANANAS_SESSION_SAMESITE: {{ .Values.service.sessionSameSite | quote }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/deployment.yaml b/deploy/helm/go-bananas/templates/deployment.yaml new file mode 100644 index 0000000..f42cdb3 --- /dev/null +++ b/deploy/helm/go-bananas/templates/deployment.yaml @@ -0,0 +1,218 @@ +{{- include "go-bananas.validate" . }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + app.kubernetes.io/component: server + {{- with .Values.global.extraAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.deployment.replicaCount }} + {{- end }} + revisionHistoryLimit: {{ .Values.deployment.revisionHistoryLimit }} + minReadySeconds: {{ .Values.deployment.minReadySeconds }} + progressDeadlineSeconds: {{ .Values.deployment.progressDeadlineSeconds }} + strategy: + {{- toYaml .Values.deployment.strategy | nindent 4 }} + selector: + matchLabels: + {{- include "go-bananas.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + # Rolling the pods when the ConfigMap changes is what makes `helm upgrade` + # actually apply a configuration change: without this annotation the + # Deployment spec is unchanged and Kubernetes would not restart anything. + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- if and .Values.persistence.enabled (eq .Values.persistence.type "persistentVolumeClaim") (not .Values.persistence.existingClaim) (gt (int .Values.deployment.replicaCount) 1) }} + # A ReadWriteOnce volume can be mounted by one pod at a time. With more than + # one replica the others will fail to start unless the storage class + # supports concurrent mounts. Set deployment.replicaCount to 1, or use a + # ReadWriteMany filesystem. + go-bananas.urpi.be/storage-note: >- + ReadWriteOnce volume with {{ .Values.deployment.replicaCount }} replicas. + Only one replica can mount it unless the storage class permits concurrent + mounts. + {{- end }} + {{- if and (eq .Values.identity.mode "oidc") .Values.identity.oidc.generateSecret }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- end }} + {{- with .Values.deployment.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "go-bananas.selectorLabels" . | nindent 8 }} + {{- with .Values.deployment.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.image.pullPolicy }} + imagePullPolicy: {{ . }} + {{- end }} + serviceAccountName: {{ include "go-bananas.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.deployment.priorityClassName }} + priorityClassName: {{ . }} + {{- end }} + {{- with .Values.deployment.hostAliases }} + hostAliases: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }} + {{- if .Values.deployment.lifecycle.preStop.enabled }} + # A preStop sleep gives the Service endpoints controller time to remove this + # pod from the endpoint list before the process starts refusing connections. + # Without it, a rolling update can drop requests that were already routed. + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "sleep {{ .Values.deployment.lifecycle.preStop.sleepSeconds }}"] + {{- end }} + containers: + - name: go-bananas + image: {{ include "go-bananas.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.deployment.extraArgs }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + envFrom: + - configMapRef: + name: {{ include "go-bananas.fullname" . }} + {{- if ne .Values.identity.mode "disabled" }} + env: + {{- /* + Secrets are injected through env rather than a mounted volume. Both + are equally protected by the kubelet; env avoids the temptation to + read the value from a file path into a log line, and the application + never echoes it back. + */}} + - name: BANANAS_SESSION_SECRET + valueFrom: + secretKeyRef: + name: {{ include "go-bananas.secretName" . }} + key: {{ include "go-bananas.sessionSecretKey" . }} + optional: false + {{- if and (eq .Values.identity.mode "oidc") (not (empty (include "go-bananas.usesExistingSecret" .))) }} + - name: BANANAS_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "go-bananas.secretName" . }} + key: {{ include "go-bananas.clientSecretKey" . }} + optional: false + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + {{- if .Values.probes.startup.enabled }} + startupProbe: + httpGet: + path: {{ .Values.probes.startup.path }} + port: http + periodSeconds: {{ .Values.probes.startup.periodSeconds }} + failureThreshold: {{ .Values.probes.startup.failureThreshold }} + {{- end }} + {{- if .Values.probes.liveness.enabled }} + # Liveness deliberately uses /healthz, which does not touch the database. + # A probe that fails on a slow database would have the kubelet restart a + # process that is otherwise healthy, turning a dependency blip into an + # outage. + livenessProbe: + httpGet: + path: {{ .Values.probes.liveness.path }} + port: http + initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.liveness.failureThreshold }} + {{- end }} + {{- if .Values.probes.readiness.enabled }} + # Readiness does check the database: a pod that cannot reach it cannot + # serve anything, and removing it from the endpoints is the correct + # response. + readinessProbe: + httpGet: + path: {{ .Values.probes.readiness.path }} + port: http + initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.readiness.failureThreshold }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: {{ .Values.persistence.mountPath }} + {{- /* + The root filesystem is read-only, so /tmp has to be an explicit + emptyDir. Go writes temporary files there during compression and + the SQLite driver uses it for spill, so without this the process + fails in ways that look unrelated. + */}} + - name: tmp + mountPath: /tmp + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: data + {{- if not .Values.persistence.enabled }} + emptyDir: {} + {{- else if .Values.persistence.existingClaim }} + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim }} + readOnly: false + {{- else if eq .Values.persistence.type "persistentVolumeClaim" }} + persistentVolumeClaim: + claimName: {{ include "go-bananas.dataVolumeName" . }} + readOnly: false + {{- else }} + emptyDir: {} + {{- end }} + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 32Mi + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.deployment.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.deployment.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.deployment.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.deployment.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/hpa.yaml b/deploy/helm/go-bananas/templates/hpa.yaml new file mode 100644 index 0000000..b5bb96f --- /dev/null +++ b/deploy/helm/go-bananas/templates/hpa.yaml @@ -0,0 +1,145 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- with .Values.global.extraAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "go-bananas.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} + {{- with .Values.autoscaling.behavior }} + behavior: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} +{{- if .Values.podDisruptionBudget.enabled }} +--- +{{/* +A PodDisruptionBudget only earns its keep if it does not also block a node drain. +minAvailable of 1 with two replicas permits exactly one disruption, which is the +useful setting: a drain can proceed, but not all pods at once. +*/}} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- with .Values.global.extraAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} + {{- else }} + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + {{- end }} + {{- with .Values.podDisruptionBudget.unhealthyPodEvictionPolicy }} + unhealthyPodEvictionPolicy: {{ . }} + {{- end }} + selector: + matchLabels: + {{- include "go-bananas.selectorLabels" . | nindent 6 }} +{{- end }} +{{- if .Values.networkPolicy.enabled }} +--- +{{/* +Default-deny ingress with explicit allowances. + +The default posture is deny: the administration API performs writes, so a cluster +where every pod can reach it means every pod can change every theme. The two +allowances are the ingress controller, which is how real users arrive, and the +metrics paths, so Prometheus can still scrape. +*/}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- with .Values.global.extraAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + podSelector: + matchLabels: + {{- include "go-bananas.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + ingress: + {{- if .Values.networkPolicy.allowedNamespaces }} + - from: + {{- range .Values.networkPolicy.allowedNamespaces }} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ . }} + {{- end }} + ports: + - protocol: TCP + port: 8080 + {{- else }} + # An empty allowedNamespaces list means the cluster's ingress controllers are + # allowed, identified by their well-known labels rather than by a namespace + # name, which differs between distributions. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: TCP + port: 8080 + {{- end }} + {{- if .Values.config.enableMetrics }} + - from: + {{- if .Values.networkPolicy.allowedNamespaces }} + {{- range .Values.networkPolicy.allowedNamespaces }} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ . }} + {{- end }} + {{- else }} + - namespaceSelector: {} + {{- end }} + ports: + - protocol: TCP + port: 8080 + {{- end }} +{{- end }} +{{- with .Values.extraManifests }} +--- +{{ toYaml . }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/ingress.yaml b/deploy/helm/go-bananas/templates/ingress.yaml new file mode 100644 index 0000000..d4a4f5f --- /dev/null +++ b/deploy/helm/go-bananas/templates/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.ingress.enabled }} +{{- $fullName := include "go-bananas.fullname" . }} +{{- $servicePort := .Values.service.port }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- if or .Values.ingress.annotations .Values.global.extraAnnotations }} + annotations: + {{- with .Values.global.extraAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ $fullName }} + port: + number: {{ $servicePort }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/pvc.yaml b/deploy/helm/go-bananas/templates/pvc.yaml new file mode 100644 index 0000000..d1fe88d --- /dev/null +++ b/deploy/helm/go-bananas/templates/pvc.yaml @@ -0,0 +1,161 @@ +{{- if .Values.persistence.enabled }} +{{- if eq .Values.persistence.type "persistentVolumeClaim" }} +{{- if not .Values.persistence.existingClaim }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "go-bananas.dataVolumeName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + annotations: + {{- /* + Retain the volume when the release is uninstalled. The themes in it are the + only copy of an operator's work: deleting a volume on uninstall would make + `helm uninstall` destructive in a way that is very easy to do by accident. + */}} + helm.sh/resource-policy: keep + {{- with .Values.global.extraAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- with .Values.persistence.storageClassName }} + storageClassName: {{ . | quote }} + {{- end }} +{{- end }} +{{- end }} +{{- if and .Values.persistence.snapshot.enabled (eq .Values.persistence.type "persistentVolumeClaim") }} +--- +{{/* +A scheduled SQLite snapshot. + +Three things this gets right that the obvious implementation does not: + +1. *It is a CronJob, not a Helm hook.* Combining `schedule` with + `helm.sh/hook: pre-upgrade` is a contradiction: a hook runs on every upgrade + regardless of the schedule, and with `hook-succeeded` the resource is then + deleted. As a hook it would both ignore its own schedule and never produce + anything to restore. + +2. *The snapshots land on the same volume.* An `emptyDir` destination would be + discarded the moment the pod exits, which makes the whole exercise pointless. + +3. *It runs the service's own `backup` command.* Copying a database file while a + write is in flight produces a torn copy. `go-bananas backup` uses SQLite's + VACUUM INTO, which reads through the database's own backup machinery and is + consistent without blocking writers. Using the application binary rather than a + `sqlite3` from some other image also guarantees the SQLite library is the same + version the schema was written for, and means no third-party image and no + package installation into a read-only root filesystem. + +The known limitation is stated rather than hidden: the job mounts the same +ReadWriteOnce volume as the Deployment, so on a cluster whose CSI driver enforces +single-node attachment strictly the job can fail to mount while the application is +running. If your cluster reports MultiAttach errors, give the job a node affinity +that pins it alongside the running pod, or take snapshots from outside the cluster. +A release using `emptyDir` data has nothing to snapshot and is unaffected. + +Restoring is manual, because a restore should be a deliberate act: + + kubectl -n exec -- /usr/local/bin/go-bananas backup --to /tmp/check.db + # then stop the service, replace the database file, and start it again. +*/}} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "go-bananas.fullname" . }}-snapshot + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + annotations: + {{- with .Values.global.extraAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + schedule: {{ .Values.persistence.snapshot.schedule | quote }} + concurrencyPolicy: Forbid + # Snapshots are named with a timestamp, so two runs cannot collide, and an + # overlap is a sign the volume is too slow rather than a problem to solve by + # running them at once. + startingDeadlineSeconds: 600 + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + metadata: + labels: + {{- include "go-bananas.labels" . | nindent 8 }} + spec: + backoffLimit: 2 + template: + metadata: + labels: + {{- include "go-bananas.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: snapshot + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + containers: + - name: snapshot + # The service image itself: the snapshot command is part of the + # binary, so there is no second image to keep in step with this one. + image: {{ include "go-bananas.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + env: + - name: DATABASE_PATH + value: {{ include "go-bananas.databasePath" . | quote }} + - name: SNAPSHOT_RETENTION_DAYS + value: {{ .Values.persistence.snapshot.retention | quote }} + # A shell, because the snapshot filename needs a timestamp and an argv + # list cannot expand one. + command: ["/bin/sh", "-euc"] + args: + - | + dest="/snapshot/go-bananas-$(date -u +%Y%m%dT%H%M%SZ).db" + /usr/local/bin/go-bananas backup --to "$dest" + + # Retention by age, run here rather than as a lifecycle hook so it + # happens on the same schedule as the snapshot and cannot be skipped + # by a container that failed to start. An old snapshot is removed even + # though the Job that produced it has long since been garbage + # collected. + find /snapshot -name 'go-bananas-*.db' \ + -mtime "+$SNAPSHOT_RETENTION_DAYS" -delete + ls -la /snapshot + volumeMounts: + - name: data + mountPath: {{ .Values.persistence.mountPath }} + readOnly: true + - name: snapshot + # The writable half of the same volume. Only the snapshot + # directory is writable; the database file itself is mounted + # read-only above. + mountPath: /snapshot + subPath: snapshots + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "go-bananas.dataVolumeName" .) }} + readOnly: true + - name: snapshot + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "go-bananas.dataVolumeName" .) }} + # Writable, and only for the subPath above. The pod as a whole can + # still reach the database, which is why the read-only mount and the + # `runAsNonRoot` security context are both kept. + readOnly: false +{{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/secret.yaml b/deploy/helm/go-bananas/templates/secret.yaml new file mode 100644 index 0000000..c3d21a5 --- /dev/null +++ b/deploy/helm/go-bananas/templates/secret.yaml @@ -0,0 +1,63 @@ +{{/* +The Secret holding the session signing key and, unless an existing Secret is +referenced, the OIDC client secret. + +Two things are deliberate here: + + * `lookup` is used to read an already-generated value back on upgrade. Without it, + every `helm upgrade` would rotate the session secret and silently sign every + user out. The client secret is not looked up: rotating a client secret is + occasionally the intent, and an operator who wants to keep it uses + `existingSecret`. + * Values are base64-encoded, not stringData, because stringData is transformed by + the API server and would make the stored manifest differ from what Helm holds, + which in turn makes the upgrade detection above unreliable. +*/}} +{{- if and (eq .Values.identity.mode "oidc") .Values.identity.oidc.generateSecret }} +{{- $secretName := include "go-bananas.secretName" . }} +{{- $sessionKey := include "go-bananas.sessionSecretKey" . }} +{{- $clientKey := include "go-bananas.clientSecretKey" . }} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secretName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + annotations: + # A hook annotation would be tempting, but a Secret must exist before the + # Deployment starts and a pre-install hook cannot guarantee ordering with a + # plain Deployment. + helm.sh/resource-policy: keep + {{- with .Values.global.extraAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +type: Opaque +data: + {{- /* Reuse the previous session secret so an upgrade does not sign everyone out. */}} + {{- $session := "" }} + {{- if $existing }} + {{- $session = (index $existing.data $sessionKey | default "") }} + {{- end }} + {{- if $session }} + {{ $sessionKey }}: {{ $session }} + {{- else }} + {{ $sessionKey }}: {{ randAlphaNum 64 | b64enc }} + {{- end }} + + {{- /* + The OIDC client secret is only generated when one was not supplied out of + band. Leaving it empty produces a deployment that fails to authenticate, which + the application reports clearly at start-up rather than as a login loop. + */}} + {{- $client := "" }} + {{- if $existing }} + {{- $client = (index $existing.data $clientKey | default "") }} + {{- end }} + {{- if $client }} + {{ $clientKey }}: {{ $client }} + {{- else }} + {{ $clientKey }}: {{ "" | b64enc }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/service.yaml b/deploy/helm/go-bananas/templates/service.yaml new file mode 100644 index 0000000..b00e6a4 --- /dev/null +++ b/deploy/helm/go-bananas/templates/service.yaml @@ -0,0 +1,64 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + app.kubernetes.io/component: server + {{- if or .Values.service.annotations .Values.global.extraAnnotations }} + annotations: + {{- with .Values.global.extraAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.service.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +spec: + type: {{ .Values.service.type }} + {{- with .Values.service.clusterIP }} + clusterIP: {{ . }} + {{- end }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} + selector: + {{- include "go-bananas.selectorLabels" . | nindent 4 }} + {{- if and (eq .Values.persistence.type "persistentVolumeClaim") (gt (int .Values.deployment.replicaCount) 1) }} + # More than one replica against a ReadWriteOnce volume needs a rollout strategy + # that does not overlap the two mounts. The values validator warns about this + # combination; this annotation records the intent in the live object. + sessionAffinity: None + {{- end }} + {{- with .Values.service.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- if and .Values.metrics.serviceMonitor.enabled .Values.config.enableMetrics }} +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- with .Values.metrics.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "go-bananas.selectorLabels" . | nindent 6 }} + endpoints: + - port: http + path: /metrics + interval: {{ .Values.metrics.serviceMonitor.interval }} + scrapeTimeout: {{ .Values.metrics.serviceMonitor.scrapeTimeout }} + honorLabels: {{ .Values.metrics.serviceMonitor.honorLabels }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/templates/serviceaccount.yaml b/deploy/helm/go-bananas/templates/serviceaccount.yaml new file mode 100644 index 0000000..c5f92a5 --- /dev/null +++ b/deploy/helm/go-bananas/templates/serviceaccount.yaml @@ -0,0 +1,56 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "go-bananas.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- if or .Values.serviceAccount.annotations .Values.global.extraAnnotations }} + annotations: + {{- with .Values.global.extraAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.serviceAccount.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +{{- /* + The API token is not mounted. The service talks to nothing inside the cluster: + its database is a local file and its identity provider is external. Mounting a + token it does not need would be a credential available to any container escape. +*/}} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} +{{- end }} +{{- if .Values.rbac.create }} +--- +apiVersion: rbac.authoriz.k8s.io/v1 +kind: Role +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} + {{- with .Values.global.extraAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +rules: + {{- toYaml .Values.rbac.rules | nindent 2 }} +--- +apiVersion: rbac.authoriz.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "go-bananas.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "go-bananas.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "go-bananas.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "go-bananas.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/go-bananas/values-dev.yaml b/deploy/helm/go-bananas/values-dev.yaml new file mode 100644 index 0000000..17c354f --- /dev/null +++ b/deploy/helm/go-bananas/values-dev.yaml @@ -0,0 +1,44 @@ +# Dev values for go-bananas +nameOverride: "" +fullnameOverride: "" + +replicaCount: 1 + +image: + repository: nexus.urpi.be/docker-hosted/go-bananas + tag: "0.0.1" + pullPolicy: Always + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-staging + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: bananas-dev.intern-urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-bananas-tls + hosts: + - bananas-dev.intern-urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-staging + kind: ClusterIssuer + +resources: + limits: + cpu: 300m + memory: 256Mi + requests: + cpu: 50m + memory: 128Mi diff --git a/deploy/helm/go-bananas/values-lcl.yaml b/deploy/helm/go-bananas/values-lcl.yaml new file mode 100644 index 0000000..366b2da --- /dev/null +++ b/deploy/helm/go-bananas/values-lcl.yaml @@ -0,0 +1,51 @@ +# Local (LCL) values for go-bananas. +# Served at https://bananas-lcl.intern-urpi.be by the docker TLS edge. +# +# NOTE: this chart validates values against values.schema.json, which allows +# only the keys defined there -- no certManager and no replicaCount here +# (values-dev.yaml still carries both and fails `helm template`). +nameOverride: "" +fullnameOverride: "" + +image: + registry: localhost:5000 + repository: go-bananas + tag: "0.0.1" + pullPolicy: IfNotPresent + +service: + type: ClusterIP + port: 80 + +# Local environment: development unlocks the local authentication bypass, so +# the deployment works without the shared Keycloak realm at auth-dev.urpi.be +# (go-loose does not serve /realms/... endpoints, so the oidc default cannot +# be pointed at it either). +config: + environment: development + publicBaseUrl: "https://bananas-lcl.intern-urpi.be" + +identity: + mode: dev + +ingress: + enabled: true + className: traefik + annotations: {} + hosts: + - host: bananas-lcl.intern-urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: local-dev-cert + hosts: + - bananas-lcl.intern-urpi.be + +resources: + limits: + cpu: 300m + memory: 256Mi + requests: + cpu: 50m + memory: 128Mi diff --git a/deploy/helm/go-bananas/values-prd-fixed.yaml b/deploy/helm/go-bananas/values-prd-fixed.yaml new file mode 100644 index 0000000..22e290d --- /dev/null +++ b/deploy/helm/go-bananas/values-prd-fixed.yaml @@ -0,0 +1,59 @@ +# PRD values for go-bananas +global: + imagePullSecrets: + - name: nexus-registry + +image: + registry: nexus.urpi.be + repository: docker-hosted/go-bananas + tag: "dev-1791360078" + pullPolicy: Always + +deployment: + replicaCount: 1 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: bananas.urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-bananas-tls + hosts: + - bananas.urpi.be + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + +config: + environment: production + publicBaseUrl: "https://bananas.urpi.be" + +identity: + mode: oidc + oidc: + issuer: "https://auth.urpi.be/realms/urpi" + clientId: "go-bananas" + existingSecret: "go-bananas-auth" + generateSecret: false + scopes: + - profile + - email + - groups + adminGroups: ["go-bananas-admin"] + editorGroups: ["go-bananas-editor"] \ No newline at end of file diff --git a/deploy/helm/go-bananas/values-prd.yaml b/deploy/helm/go-bananas/values-prd.yaml new file mode 100644 index 0000000..f86abb3 --- /dev/null +++ b/deploy/helm/go-bananas/values-prd.yaml @@ -0,0 +1,47 @@ +# PRD values for go-bananas +nameOverride: "" +fullnameOverride: "" + +replicaCount: 1 + +imagePullSecrets: + - name: nexus-registry + +image: + repository: nexus.urpi.be/docker-hosted/go-bananas + tag: "0.0.1" + pullPolicy: Always + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: bananas.urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-bananas-tls + hosts: + - bananas.urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi diff --git a/deploy/helm/go-bananas/values-tst.yaml b/deploy/helm/go-bananas/values-tst.yaml new file mode 100644 index 0000000..7dd90c1 --- /dev/null +++ b/deploy/helm/go-bananas/values-tst.yaml @@ -0,0 +1,44 @@ +# TST values for go-bananas +nameOverride: "" +fullnameOverride: "" + +replicaCount: 1 + +image: + repository: nexus.urpi.be/docker-hosted/go-bananas + tag: "0.0.1" + pullPolicy: Always + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-staging + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: bananas-tst.intern-urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-bananas-tls + hosts: + - bananas-tst.intern-urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-staging + kind: ClusterIssuer + +resources: + limits: + cpu: 300m + memory: 256Mi + requests: + cpu: 50m + memory: 128Mi diff --git a/deploy/helm/go-bananas/values.schema.json b/deploy/helm/go-bananas/values.schema.json new file mode 100644 index 0000000..56edb14 --- /dev/null +++ b/deploy/helm/go-bananas/values.schema.json @@ -0,0 +1,338 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "Go Bananas!! Helm values", + "description": "Validation schema for the Go Bananas!! chart. Editors use this to offer completion and to catch a mistyped key before the chart is rendered.", + "type": "object", + "additionalProperties": false, + "required": ["image", "identity", "config", "service"], + "properties": { + "global": { + "type": "object", + "properties": { + "imageRegistry": { "type": "string" }, + "imagePullSecrets": { + "type": "array", + "items": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + }, + "extraLabels": { "type": "object" }, + "extraAnnotations": { "type": "object" } + } + }, + + "nameOverride": { "type": "string", "maxLength": 63 }, + "fullnameOverride": { "type": "string", "maxLength": 63 }, + + "image": { + "type": "object", + "additionalProperties": false, + "required": ["repository"], + "properties": { + "registry": { "type": "string" }, + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string" }, + "digest": { "type": "string" }, + "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] } + } + }, + + "deployment": { + "type": "object", + "additionalProperties": false, + "properties": { + "replicaCount": { "type": "integer", "minimum": 0, "maximum": 100 }, + "extraArgs": { "type": "array", "items": { "type": "string" } }, + "revisionHistoryLimit": { "type": "integer", "minimum": 0 }, + "minReadySeconds": { "type": "integer", "minimum": 0 }, + "progressDeadlineSeconds": { "type": "integer", "minimum": 1 }, + "terminationGracePeriodSeconds": { "type": "integer", "minimum": 0 }, + "strategy": { "type": "object" }, + "podAnnotations": { "type": "object" }, + "podLabels": { "type": "object" }, + "hostAliases": { "type": "array" }, + "nodeSelector": { "type": "object" }, + "tolerations": { "type": "array" }, + "affinity": { "type": "object" }, + "topologySpreadConstraints": { "type": "array" }, + "priorityClassName": { "type": "string" }, + "lifecycle": { + "type": "object", + "properties": { + "preStop": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "sleepSeconds": { "type": "integer", "minimum": 0, "maximum": 60 } + } + } + } + } + } + }, + + "resources": { "type": "object" }, + + "config": { + "type": "object", + "additionalProperties": false, + "required": ["environment", "publicBaseUrl"], + "properties": { + "environment": { + "type": "string", + "minLength": 1, + "description": "The deployment name. Anything other than a development or test value disables the dev authentication bypass." + }, + "logLevel": { "enum": ["debug", "info", "warn", "error"] }, + "logFormat": { "enum": ["json", "text"] }, + "publicBaseUrl": { + "type": "string", + "pattern": "^https?://[^/]+", + "description": "The externally reachable origin, with no trailing slash." + }, + "basePath": { + "type": "string", + "pattern": "^$|^/", + "description": "A route prefix for a path-based ingress. Empty or a leading-slash path." + }, + "rateLimitPerMinute": { "type": "integer", "minimum": 0 }, + "trustedProxies": { "type": "array", "items": { "type": "string" } }, + "corsAllowedOrigins": { "type": "array", "items": { "type": "string" } }, + "enableMetrics": { "type": "boolean" }, + "enableProfiler": { "type": "boolean" }, + "shutdownTimeoutMs": { "type": "integer", "minimum": 0, "maximum": 300000 }, + "runtimeCdnBaseUrl": { "type": "string" }, + "database": { + "type": "object", + "additionalProperties": false, + "properties": { + "maxConns": { "type": "integer", "minimum": 1, "maximum": 128 }, + "busyTimeoutMs": { "type": "integer", "minimum": 0, "maximum": 60000 } + } + }, + "sessionTTLHours": { "type": "integer", "minimum": 1, "maximum": 720 } + } + }, + + "persistence": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "type": { "enum": ["persistentVolumeClaim", "emptyDir"] }, + "accessMode": { + "enum": ["ReadWriteOnce", "ReadWriteMany", "ReadWriteOncePod", "ReadOnlyMany"] + }, + "size": { "type": "string", "pattern": "^[0-9]+(Ei|Pi|Ti|Gi|Mi|Ki|E|P|T|G|M|K)?$" }, + "storageClassName": { "type": "string" }, + "existingClaim": { "type": "string" }, + "mountPath": { "type": "string", "pattern": "^/" }, + "snapshot": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "schedule": { "type": "string", "minLength": 1 }, + "retention": { "type": "integer", "minimum": 1, "maximum": 365 } + } + } + } + }, + + "service": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "nodePort": { "type": ["integer", "string"] }, + "clusterIP": { "type": "string" }, + "sessionSecure": { "type": "boolean" }, + "sessionSameSite": { "enum": ["Lax", "Strict", "None"] }, + "annotations": { "type": "object" }, + "labels": { "type": "object" } + } + }, + + "ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "className": { "type": "string" }, + "annotations": { "type": "object" }, + "hosts": { + "type": "array", + "items": { + "type": "object", + "required": ["host"], + "properties": { + "host": { "type": "string", "minLength": 1 }, + "paths": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { "type": "string", "pattern": "^/" }, + "pathType": { "enum": ["Prefix", "Exact", "ImplementationSpecific"] } + } + } + } + } + } + }, + "tls": { + "type": "array", + "items": { + "type": "object", + "required": ["secretName"], + "properties": { + "secretName": { "type": "string" }, + "hosts": { "type": "array", "items": { "type": "string" } } + } + } + } + } + }, + + "identity": { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": { + "enum": ["oidc", "dev", "disabled"], + "description": "oidc is the production choice. dev and disabled are refused by the application outside a development environment." + }, + "sessionSecretKey": { "type": "string", "minLength": 1 }, + "oidc": { + "type": "object", + "additionalProperties": false, + "properties": { + "issuer": { "type": "string", "pattern": "^https?://" }, + "clientId": { "type": "string" }, + "clientSecretKey": { "type": "string", "minLength": 1 }, + "existingSecret": { "type": "string" }, + "existingSecretKeys": { "type": "object" }, + "generateSecret": { "type": "boolean" }, + "scopes": { "type": "array", "items": { "type": "string" } }, + "adminGroups": { "type": "array", "items": { "type": "string" } }, + "editorGroups": { "type": "array", "items": { "type": "string" } }, + "viewerGroups": { "type": "array", "items": { "type": "string" } }, + "redirectURL": { "type": "string" }, + "postLogoutRedirectURL": { "type": "string" }, + "logoutURL": { "type": "string" }, + "skipTLSVerify": { "type": "boolean" }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 300 } + } + } + } + }, + + "probes": { + "type": "object", + "additionalProperties": false, + "properties": { + "liveness": { "$ref": "#/definitions/probe" }, + "readiness": { "$ref": "#/definitions/probe" }, + "startup": { "$ref": "#/definitions/probe" } + } + }, + + "autoscaling": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "minReplicas": { "type": "integer", "minimum": 1 }, + "maxReplicas": { "type": "integer", "minimum": 1 }, + "targetCPUUtilizationPercentage": { "type": "integer", "minimum": 1, "maximum": 100 }, + "targetMemoryUtilizationPercentage": { "type": "integer", "minimum": 0, "maximum": 100 }, + "behavior": { "type": "object" } + } + }, + + "podDisruptionBudget": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "minAvailable": { "type": ["integer", "string"] }, + "maxUnavailable": { "type": ["integer", "string"] }, + "unhealthyPodEvictionPolicy": { "enum": ["IfHealthyBudget", "AlwaysAllow"] } + } + }, + + "serviceAccount": { + "type": "object", + "additionalProperties": false, + "properties": { + "create": { "type": "boolean" }, + "name": { "type": "string" }, + "annotations": { "type": "object" }, + "automountServiceAccountToken": { "type": "boolean" } + } + }, + + "rbac": { + "type": "object", + "additionalProperties": false, + "properties": { + "create": { "type": "boolean" }, + "rules": { "type": "array" } + } + }, + + "networkPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "allowedNamespaces": { "type": "array", "items": { "type": "string" } }, + "metricsPaths": { "type": "array", "items": { "type": "string" } } + } + }, + + "podSecurityContext": { "type": "object" }, + "securityContext": { "type": "object" }, + "extraEnv": { "type": "array" }, + "extraVolumes": { "type": "array" }, + "extraVolumeMounts": { "type": "array" }, + + "metrics": { + "type": "object", + "additionalProperties": false, + "properties": { + "serviceMonitor": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "interval": { "type": "string" }, + "scrapeTimeout": { "type": "string" }, + "labels": { "type": "object" }, + "honorLabels": { "type": "boolean" } + } + } + } + }, + + "extraManifests": { "type": "array" } + }, + + "definitions": { + "probe": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "path": { "type": "string", "pattern": "^/" }, + "initialDelaySeconds": { "type": "integer", "minimum": 0 }, + "periodSeconds": { "type": "integer", "minimum": 1 }, + "timeoutSeconds": { "type": "integer", "minimum": 1 }, + "failureThreshold": { "type": "integer", "minimum": 1 } + } + } + } +} diff --git a/deploy/helm/go-bananas/values.yaml b/deploy/helm/go-bananas/values.yaml new file mode 100644 index 0000000..c8950b0 --- /dev/null +++ b/deploy/helm/go-bananas/values.yaml @@ -0,0 +1,481 @@ +# Go Bananas!! Helm values +# +# Every value has a safe default so that `helm install` works with no arguments at +# all. The exceptions are the authentication credentials, which are required when +# auth.mode is oidc and are supplied through an existing Secret. +# +# The defaults are tuned for a small internal deployment. Before running this in +# production, read the annotated sections: replica count, the ingress, the resource +# requests and the persistence choice all deserve a deliberate decision. + +# ------------------------------------------------------------------ global --- + +global: + # imageRegistry overrides the registry for every image the chart pulls. Used by a + # cluster with a mirrored registry or an air-gapped pull-through cache. + imageRegistry: "" + # imagePullSecrets are added to every ServiceAccount. + imagePullSecrets: + - name: nexus-registry + # extraLabels are added to every object the chart creates, for cost allocation + # and for a Prometheus `release` label if the cluster does not add one. + extraLabels: {} + # extraAnnotations are added to every object. + extraAnnotations: {} + +# ------------------------------------------------------------------- image --- + +image: + registry: nexus.urpi.be + repository: docker-hosted/go-bananas + # tag defaults to the chart's appVersion, so upgrading the chart upgrades the + # image. Set it to an immutable digest for a reproducible deployment. + tag: "0.0.1" + # digest pins the image by content. Takes precedence over tag when set, which is + # the strongest guarantee that a rollout is running exactly what was reviewed. + digest: "" + pullPolicy: IfNotPresent + +# --------------------------------------------------------------- deployment --- + +deployment: + # Two replicas by default: the service is stateless apart from the SQLite file, + # so horizontal scaling is only safe when persistence is a shared volume or a + # network filesystem. See the persistence section. + replicaCount: 2 + + # extraArgs are appended to the container command. The binary takes no positional + # arguments; this exists for future flags without a chart change. + extraArgs: [] + + # revisionHistoryLimit keeps the last N ReplicaSets so a rollback is possible + # without the Deployment controller's default of ten consuming etcd history. + revisionHistoryLimit: 5 + + # minReadySeconds makes a new pod count as ready only after it has been healthy + # for this long, which stops a rolling update from proceeding on the strength of + # a single lucky probe. + minReadySeconds: 10 + + # progressDeadlineSeconds aborts a rollout that cannot complete, rather than + # leaving a Deployment stuck in progress indefinitely. + progressDeadlineSeconds: 600 + + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + + podAnnotations: {} + podLabels: {} + + # hostAliases is mostly useful for a development cluster where the identity + # provider is not in public DNS. + hostAliases: [] + + # terminationGracePeriodSeconds must exceed the application's shutdown timeout + # (config.shutdownTimeoutMs) or in-flight requests are cut off mid-response. + # The chart refuses to render when it does not. + terminationGracePeriodSeconds: 45 + + # A preStop sleep gives the Service endpoints controller time to remove this pod + # from the endpoint list before the process starts refusing connections. + # Without it, a rolling update drops requests that were already routed. + lifecycle: + preStop: + enabled: true + sleepSeconds: 3 + + # nodeSelector, tolerations, affinity and topologySpreadConstraints are passed + # through to the pod spec unchanged. + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + + # priorityClassName for a cluster where the API tier matters. + priorityClassName: "" + +# ---------------------------------------------------------------- resources --- + +resources: + requests: + cpu: 50m + memory: 96Mi + limits: + # No CPU limit is set deliberately. A CPU limit throttles the process when the + # node is busy, which for a latency-sensitive asset server produces exactly the + # tail latency it is meant to prevent. The memory limit is the one that matters: + # it is what keeps a runaway cache from taking the node down. + memory: 384Mi + +# ------------------------------------------------------------------- config --- + +config: + # environment is the deployment name. Anything other than development or test + # turns off the dev authentication bypass and the TLS-skip switch. + environment: production + + logLevel: info + # json in production so the log is structured for a collector. + logFormat: json + + # publicBaseUrl is the externally reachable origin, with no trailing slash. + # It is used to build the OIDC redirect URI, the absolute URLs in the runtime + # manifest and the HSTS decision, so getting it wrong means a login loop. + # + # The default points at a local hostname so that `helm install` works with no + # arguments. Override it for any real deployment. + publicBaseUrl: "http://themes.go-bananas.local" + + # basePath prefixes every route, for deployments behind a path-based ingress + # such as https://example.com/themes/. Leave empty for the default of /. + basePath: "" + + # rateLimitPerMinute caps requests per client per minute on the runtime API. + # The default is high because the legitimate pattern is one request per page + # view; the limit exists to stop abuse, not to ration traffic. + rateLimitPerMinute: 3000 + + # trustedProxies are the CIDRs whose X-Forwarded-For header may be believed. + # Empty means forwarding headers are ignored entirely and the direct peer is + # recorded, which is the safe default. Set this to the ingress controller's + # address or the audit log will record the ingress pod instead of the client. + trustedProxies: [] + + # corsAllowedOrigins are browser origins allowed to call the admin API + # cross-origin. The runtime API is public by design and is not affected. + corsAllowedOrigins: [] + + # enableMetrics exposes /metrics in Prometheus format. + enableMetrics: true + # enableProfiler exposes net/http/pprof. Off by default: a profiler on a + # network-reachable endpoint is a denial-of-service surface, so it is opt-in and + # should be paired with a NetworkPolicy. + enableProfiler: false + + # shutdownTimeoutMs bounds graceful shutdown. Must be lower than + # deployment.terminationGracePeriodSeconds. + shutdownTimeoutMs: 25000 + + # runtimeCdnBaseUrl is the absolute base the runtime manifest hands to + # consumers. Set it when the stylesheets are served from a CDN or a separate + # asset host, so that consumers fetch from there instead of from this service. + runtimeCdnBaseUrl: "" + + # database tuning. The defaults suit an embedded SQLite file; see docs on + # persistence before changing them. + database: + # maxConns bounds the connection pool. SQLite serialises writers, so a small + # pool is faster than a large one, not slower. + maxConns: 8 + # busyTimeoutMs is how long a blocked writer waits before failing. + busyTimeoutMs: 5000 + + # sessionTTLHours is how long an administrative session lasts. The session is a + # signed cookie with no server-side record, so an expiry cannot be revoked + # early — see the identity section for the trade-off this represents. + sessionTTLHours: 8 + +# -------------------------------------------------------------- persistence --- +# +# The database is a single SQLite file. Two deployment shapes are supported: +# +# type: emptyDir A per-pod temporary file. Every theme change is lost when a +# pod restarts. Correct only for a demo or for evaluating the +# service; the built-in themes are re-seeded automatically so +# the instance still comes up, but operator work is not kept. +# +# type: persistentVolumeClaim A ReadWriteOnce volume. Correct for a +# single-replica deployment, which is the shape this chart +# recommends in production. +# +# Note on replicas: RWO volumes cannot be attached to two pods at once. With +# replicaCount > 1 and a PVC, only one pod mounts the volume successfully and the +# others crash-loop. Running more than one replica therefore requires either +# replicaCount: 1, or a filesystem that permits multiple mounts. + +persistence: + enabled: true + type: persistentVolumeClaim + accessMode: ReadWriteOnce + size: 2Gi + # storageClassName: "" uses the cluster default. + storageClassName: "" + # existingClaim mounts a volume the operator manages. Its lifecycle is then the + # operator's responsibility: this chart will not delete or resize it. + existingClaim: "" + mountPath: /srv/data + + # A snapshot taken before an upgrade, when the upgrade involves a destructive + # change to the schema. Restoring is a manual kubectl apply. + snapshot: + enabled: false + # CronJob schedule in the cluster's timezone. + schedule: "0 3 * * *" + # How long to keep snapshots. + retention: 14 + +# ------------------------------------------------------------------ service --- + +service: + type: ClusterIP + port: 80 + # nodePort, only used when type is NodePort. + nodePort: "" + # ClusterIP: set to None for a headless service, which a StatefulSet needs. + clusterIP: "" + # The session cookie's Secure attribute and SameSite=None both require the + # browser to see the service over HTTPS. Set to true when TLS terminates at the + # ingress. + sessionSecure: true + sessionSameSite: Lax + annotations: {} + labels: {} + +# ----------------------------------------------------------------- ingress --- + +ingress: + enabled: false + className: nginx + annotations: {} + hosts: + - host: themes.example.com + paths: + - path: / + pathType: Prefix + # tls is empty for a plain-HTTP installation. For the usual production shape, + # point it at a cert-manager cluster issuer so the certificate is renewed + # without anyone remembering to. + tls: [] + # - secretName: go-bananas-tls + # hosts: + # - themes.example.com + +# ---------------------------------------------------------------- identity --- +# +# This is the part that most often goes wrong in a deployment, so it is worth being +# explicit about the three modes. + +identity: + # disabled No authentication. Every request is granted administrator access. + # Refused by the application outside a development environment, so + # this cannot be deployed to production by accident. + # dev Every request is authenticated as a local administrator. + # Also refused outside development. + # oidc OpenID Connect against a real provider. The production choice. + mode: oidc + + # sessionSecretKey is the key inside the Secret that holds the session signing + # secret. The secret must be at least 32 bytes. + # + # kubectl create secret generic go-bananas-auth \ + # --from-literal=session-secret="$(openssl rand -base64 48)" \ + # --from-literal=oidc-client-secret="..." + # + # The secret is never templated into a ConfigMap or written to a log. Rotating + # it invalidates every open session at once. + sessionSecretKey: session-secret + + oidc: + # issuer is the OIDC issuer URL. Everything else — the authorization, + # token, userinfo and JWKS endpoints — is discovered from + # /.well-known/openid-configuration, so this is the only provider-specific + # value needed. + # + # The default is the shared development realm. The realm path is part of the + # issuer: this string must match the `iss` claim in the ID token exactly, + # including a trailing slash if the provider emits one. A mismatch here is the + # single most common cause of "invalid issuer" at the callback. + issuer: "https://auth-dev.urpi.be/realms/urpi" + + # clientId is the client identifier registered with the provider. + clientId: "go-bananas" + + # clientSecretKey names the key inside existingSecret (or generatedSecret) + # holding the client secret. Referencing it by key name rather than by value + # is what keeps the secret out of values.yaml. + clientSecretKey: oidc-client-secret + + # existingSecret is a Secret you manage. Preferred for production: the chart + # will not create, rotate or delete it, and its contents never appear in a + # rendered manifest. + # + # kubectl create secret generic go-bananas-auth \ + # --from-literal=session-secret="$(openssl rand -base64 48)" \ + # --from-literal=oidc-client-secret="…" + existingSecret: "" + # existingSecretKeys overrides the key names inside existingSecret, for a + # Secret managed by an external secrets operator with its own naming. + existingSecretKeys: {} + + # generatedSecret creates a Secret owned by this release. The session secret is + # preserved across upgrades by reading the live Secret back, so an upgrade does + # not sign every user out. + # + # The OIDC client secret cannot be generated — it has to come from the provider. + # A generated Secret therefore contains an empty client secret, which means the + # deployment will fail to authenticate until one is supplied. For anything real, + # use existingSecret. + generateSecret: true + + # scopes requested in addition to openid. The email scope is what makes + # `email` appear in the session; groups is what the role mapping reads. + scopes: + - profile + - email + - groups + + # Group-to-role mapping, read from the `groups` claim. + # + # The most privileged matching group wins; a user in no configured group gets + # viewer. The default is deliberately least privilege, so a mapping mistake + # fails closed rather than granting access nobody intended. + adminGroups: [] + editorGroups: [] + viewerGroups: [] + # With all three empty, every authenticated user is a viewer. Populate these + # from the provider's actual group names before rolling this out to real users; + # the group names are realm-specific and are not guessed here. + + # redirectURL overrides the computed callback. Leave empty unless the + # provider has a different redirect URI registered, which happens behind a + # path-based ingress or when several environments share a provider client. + redirectURL: "" + + postLogoutRedirectURL: "" + # End-session endpoint, for providers that do not follow the RP-initiated + # logout convention. + logoutURL: "" + + # skipTLSVerify disables certificate verification against the provider. It + # exists so that a development provider presenting a self-signed certificate + # can be used, and the application refuses to start with it enabled outside + # development. + skipTLSVerify: false + + # timeout and insecureSkipVerify exist for the same development reason. + timeoutSeconds: 15 + +# ------------------------------------------------------------------- probes --- + +probes: + liveness: + enabled: true + path: /healthz + initialDelaySeconds: 3 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readiness: + enabled: true + path: /readyz + initialDelaySeconds: 2 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startup: + enabled: true + path: /healthz + periodSeconds: 3 + failureThreshold: 30 + +# --------------------------------------------------------------- autoscaling --- + +autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + # targetMemoryUtilizationPercentage is left unset by default: memory here is + # dominated by the render cache, which grows with the number of themes rather + # than with load, so scaling on it would respond to a content change rather than + # to traffic. + targetMemoryUtilizationPercentage: 0 + behavior: {} + +# ------------------------------------------------------- disruption budgets --- +# +# A PDB only helps if it does not also block a node drain. minAvailable: 1 with two +# replicas permits exactly one disruption, which is the useful setting. + +podDisruptionBudget: + enabled: true + minAvailable: 1 + # maxUnavailable: 1 + # unhealthyPodEvictionPolicy: AlwaysAllow + +# --------------------------------------------------------- service account --- + +serviceAccount: + create: true + name: "" + annotations: {} + automountServiceAccountToken: false + +rbac: + # create is off by default: the service talks to nothing in the cluster, so the + # default ServiceAccount needs no rules at all. + create: false + rules: [] + +# ------------------------------------------------------------ network policy --- +# +# A default-deny ingress with an explicit allowance for the ingress controller and +# for Prometheus. Without this, any pod in the cluster can reach the admin API. + +networkPolicy: + enabled: false + # ingressNamespaces restricts which namespaces may reach the service. Empty means + # the whole cluster. + allowedNamespaces: [] + # metricsPaths is the additional path allowed from the metrics sources. + metricsPaths: ["/metrics"] + +# ------------------------------------------------------------------- extras --- + +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + +# extraEnv is for one-off variables that do not warrant a chart value. Anything +# added here is the operator's responsibility to keep consistent across replicas. +extraEnv: [] + +# extraVolumes and extraVolumeMounts are for a sidecar or a config file the chart +# does not model. +extraVolumes: [] +extraVolumeMounts: [] + +# metricsServiceMonitor creates a Prometheus Operator ServiceMonitor. Requires the +# Prometheus Operator CRDs to be installed. +metrics: + serviceMonitor: + enabled: false + interval: 30s + scrapeTimeout: 10s + labels: {} + honorLabels: true + +# ------------------------------------------------------------------- extras --- +# +# `extraManifests` is an escape hatch for a resource this chart does not model. It +# is templated, so a value can reference another template's output. +extraManifests: [] +# - apiVersion: v1 +# kind: ConfigMap +# metadata: +# name: {{ include "go-bananas.fullname" . }}-extra +# data: +# example: "value" diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000..5128764 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,90 @@ +# Docker Bake definition. +# +# One place that knows how to build every target variant, so that `docker buildx +# bake`, the GitHub Actions workflow and a developer's terminal all produce the same +# image from the same inputs. +# +# docker buildx bake # build the default tag +# docker buildx bake --push # build and push +# docker buildx bake --print # show what would run +# docker buildx bake --set *.platform=linux/arm64 + +variable "VERSION" { + default = "dev" +} + +variable "COMMIT" { + default = "unknown" +} + +variable "BUILD_DATE" { + default = "unknown" +} + +variable "REGISTRY" { + default = "ghcr.io/urpi" +} + +variable "IMAGE" { + default = "${REGISTRY}/go-bananas" +} + +variable "TAGS" { + default = "${IMAGE}:${VERSION}" +} + +# `platforms` is a list rather than a single value so that one invocation produces +# a manifest list covering both architectures. +group "default" { + targets = ["image"] +} + +target "_common" { + context = "." + dockerfile = "Dockerfile" + args = { + VERSION = VERSION + COMMIT = COMMIT + BUILD_DATE = BUILD_DATE + } + labels = { + "org.opencontainers.image.title" = "go-bananas" + "org.opencontainers.image.description" = "Runtime design-token service" + "org.opencontainers.image.version" = VERSION + "org.opencontainers.image.revision" = COMMIT + "org.opencontainers.image.created" = BUILD_DATE + "org.opencontainers.image.source" = "https://github.com/urpi/go-bananas" + } +} + +target "image" { + inherits = ["_common"] + tags = ["${IMAGE}:${VERSION}", "${IMAGE}:latest"] + platforms = ["linux/amd64", "linux/arm64"] + output = ["type=image"] +} + +target "image-push" { + inherits = ["_common"] + tags = split("\n", TAGS) + platforms = ["linux/amd64", "linux/arm64"] + output = ["type=image,push=true"] +} + +# A local single-architecture image for `docker compose up` and for a quick look +# on a laptop, where a manifest list only adds a layer of indirection. +target "dev" { + inherits = ["_common"] + tags = ["go-bananas:dev"] + platforms = ["linux/amd64"] + output = ["type=docker"] + cache-to = ["type=gha,mode=max"] +} + +# The cache-only target, for warming a CI runner before the real build. +target "cache" { + inherits = ["_common"] + platforms = ["linux/amd64"] + cache-to = ["type=gha,mode=max"] + cache-from = ["type=gha"] +} diff --git a/docs/adr/0001-embedded-sqlite.md b/docs/adr/0001-embedded-sqlite.md new file mode 100644 index 0000000..79b4263 --- /dev/null +++ b/docs/adr/0001-embedded-sqlite.md @@ -0,0 +1,176 @@ +# ADR 0001: Embedded SQLite rather than a client/server database + +- **Status:** accepted +- **Date:** 2026-10-04 +- **Deciders:** platform team +- **Applies to:** all deployments of the current major version + +## Context + +go-bananas stores themes. A theme is a slug, some metadata and a JSON document of +design tokens, typically 10–60 kB. A useful instance has tens to a few thousand of +them. + +The workload shape is: + +- **Reads:** constant and bursty. Every page view in every consuming application + requests CSS. A service with ten consumer applications and a few hundred users + can easily see thousands of stylesheet requests a minute, and 95%+ of them are + the same handful of bytes served from a render cache. +- **Writes:** rare. A theme is edited by a human. A busy week might be fifty writes. +- **Total size:** a few megabytes. Even ten thousand large themes would fit in + fifty. + +The conventional answer for a service with this shape is PostgreSQL. That answer is +right for almost every service and wrong for this one, for reasons worth writing +down rather than rediscovering in a year. + +## Decision + +Use SQLite through `modernc.org/sqlite`, a pure-Go translation, on a local volume. + +## Rationale + +### What a client/server database would add + +For this dataset, Postgres buys nothing and costs: + +- **A network hop per query.** A local SQLite read is microseconds; a query over a + pod network is a millisecond and a tail-latency distribution with a long right + tail. For a stylesheet endpoint on the critical path of every page view, that + tail is the difference between a fast site and a janky one. +- **A connection pool to get wrong.** Every deployment needs its own tuning, and the + failure mode of under-tuning is a saturated pool under load. +- **Credentials to create, distribute, rotate and revoke.** Another secret in + Kubernetes, another thing to get wrong. +- **A schema migration process.** For a dataset that fits in a few megabytes, this + is a disproportionate amount of ceremony. +- **A second thing to operate.** Backups, upgrades, monitoring, incident response, + for a component whose whole job is to hold some JSON. +- **A second failure mode.** The database becoming unavailable is now the most + likely way the theme service goes down. + +### What SQLite gives up + +Honesty requires stating the costs: + +- **One writer at a time.** Correct at this write rate. A theme edit is a + millisecond-scale transaction; even a hundred concurrent editors would not + saturate it. +- **Single-node.** This is the real constraint. See Scaling below. +- **No network access.** A consuming application cannot query the database directly. + It does not need to; the runtime API is the interface. + +### Why this particular configuration is fast + +The default SQLite configuration is not fast. These settings are what make it fast +enough that the question does not arise: + +| Setting | Effect | +| --- | --- | +| `journal_mode=WAL` | Readers never block the writer and the writer never blocks readers. This is the single most important setting. | +| `synchronous=NORMAL` | In WAL mode this is durable across an application crash and risks only the last transactions on an operating-system crash. A lost publish is re-doable; an outage is not. | +| `cache_size=-16000` | A 16 MiB page cache, which holds the entire dataset. | +| `mmap_size=268435456` | The file is read through the page cache directly, removing a copy from the read path. | +| `temp_store=MEMORY` | Sort and group-by spill stays in memory. | +| Max 8 connections | SQLite serialises writers. A large pool adds contention, not throughput. This is counter-intuitive and correct. | +| In-memory render cache | The steady-state runtime request performs **no database access at all**. | + +The render cache is the decisive optimisation. Rendering a theme traverses its whole +token document; serving the cached string is one map lookup. Because the cache key +includes the theme version, publishing invalidates exactly one theme's entries and +disturbs nothing else. The database is consulted only on a cache miss, which is a +publish rather than a read. + +### Why `modernc.org/sqlite` rather than `mattn/go-sqlite3` + +`mattn/go-sqlite3` is a cgo binding to the C library. It is faster, and it means: + +- `CGO_ENABLED=1`, so the release image needs a C toolchain at build time and libc + at run time, and cross-compilation needs a cross-C toolchain; +- a statically linked binary is impossible, so the image carries a C library whose + CVEs need tracking; +- `go install` from source fails on a machine without a compiler. + +`modernc.org/sqlite` is a transpiled Go implementation: no cgo, a fully static +binary, and cross-compilation is `GOOS=linux GOARCH=arm64` and nothing else. The +performance difference is irrelevant here because the hot path is the in-memory +render cache, not SQLite. + +## Alternatives considered + +**PostgreSQL.** The conventional choice. Rejected on the grounds above. It remains +the right answer if the product changes shape — see Scaling. + +**Redis.** Excellent for this read-heavy workload, and a good fit for a render +cache. But it is another service to operate for a cache that is already +process-local and loses nothing on restart, because the source of truth is the +database. Rejected as unnecessary operational surface. Worth revisiting if a +many-node deployment needs a shared cache; the render cache is an interface, so +that change is contained. + +**bbolt.** Faster than SQLite for pure key/value workloads and simpler. Rejected +because the schema needs indexes for listing, filtering and sorting, and because +SQLite's query planner handles the "a few hundred rows filtered by status and +ordered by date" shape without the ceremony of managing bbolt buckets by hand. + +**A JSON file per theme on disk.** No dependency at all. Rejected because concurrent +writes need locking, and because every listing requires reading and parsing every +file. SQLite is a better version of this idea that is already written. + +**An in-memory store with write-through to object storage.** Attractive for scale. +Rejected for v1: it removes the transactional guarantee that makes version history +and rollback correct, and it adds an object-storage dependency. The write path +here is not hot enough to justify losing the invariant. + +## Scaling + +SQLite on a local volume is single-node. Two shapes are supported: + +1. **One replica, one volume.** The recommended production shape. Horizontally + scalable only in the sense that the service is so cheap that one replica serves + a very large number of consumers. + +2. **Many replicas, a shared filesystem.** Works when the storage class supports + concurrent mounts — NFS, CephFS, or a ReadWriteMany block device. WAL mode + requires the filesystem to support shared memory mapping, which these do. The + Helm chart warns when a ReadWriteOnce volume is combined with more than one + replica, because that combination cannot work. + +If the workload ever outgrows both — genuinely concurrent multi-writer access across +nodes — the migration is to Postgres. It is contained: `internal/store` is the only +package that writes SQL, and its interface is defined by the methods it exposes +rather than by SQLite's features. + +## Consequences + +**Good** + +- One less thing to operate, back up, monitor and secure. +- No credential to leak. +- A static binary and a small image with no libc. +- A backup is `cp` of one file. There is no consistent-snapshot dance. +- The render cache can be process-local because there is only one process. + +**Bad** + +- Cannot scale horizontally past one node without a shared filesystem. +- A shared-filesystem deployment is slower than local disk, and WAL over NFS is + sensitive to latency. Measure before choosing it. +- Single-writer means a pathological write loop would queue. Nothing in the + application can produce one. + +**Neutral** + +- The `store` package has a wider interface than a repository might otherwise need, + because it must express listing, filtering and sorting. Moving to another engine + means reimplementing that surface. + +## Revisit when + +- A single instance cannot serve the load, and profiling shows the database rather + than the render cache is the bottleneck. +- Concurrent writers become a real requirement, for example if themes can be edited + by an automated pipeline from several deployments. +- The dataset outgrows a few hundred megabytes, at which point the time to back up + by copying a file becomes its own problem. diff --git a/docs/adr/0002-runtime-delivery.md b/docs/adr/0002-runtime-delivery.md new file mode 100644 index 0000000..a09f8bc --- /dev/null +++ b/docs/adr/0002-runtime-delivery.md @@ -0,0 +1,142 @@ +# ADR 0002: Deliver themes at run time rather than as a package + +- **Status:** accepted +- **Date:** 2026-10-04 + +## Context + +The conventional way to ship a design system to applications is a package: an npm +package, a Go package, a Maven artifact. The consuming application installs it, +imports from it, and rebuilds when it changes. + +That model is good, and it is the right answer for most design-system artefacts. +The question here is whether it is the right answer for *design tokens specifically*. + +## Decision + +Themes are served over HTTP at run time as CSS custom properties and JSON. The +runtime SDKs are ES modules loaded by URL through an import map. Nothing is +installed by the consumer. + +## Rationale + +### The cost of the package model, for tokens specifically + +Design tokens are unusual among design-system artefacts. They are: + +- **entirely data.** No behaviour to tree-shake, no components to code-split; +- **consumed at a single moment.** At first paint, by a stylesheet link; +- **changing on a different cadence from code.** A designer adjusts a colour; no + application code needs to change. + +The package model imposes costs that fit code perfectly and fit data badly: + +| Cost | Fits code? | Fits tokens? | +| --- | --- | --- | +| Version resolution and peer conflicts | Yes | No. Nothing here has a peer. | +| A rebuild of every consumer | Necessary | Pure overhead. The CSS output is byte-identical. | +| Coordinated deploys on version skew | Necessary | Avoidable: a runtime fetch has no skew. | +| A published version to track | Necessary | A database row, which an operator already tracks. | +| Install friction | Usually fine | Often the blocker. A Go service with a templated frontend cannot `npm install` at all. | +| Tree-shaking and chunk sizing | Necessary | Nothing to shake. | + +The specific motivating case: a service that serves server-rendered HTML cannot +consume a design-system npm package without introducing a Node build into its +release pipeline. That is a large change made to deliver a CSS file. Serving the +CSS directly removes it. + +### What runtime delivery buys + +**Republishing is a database write.** A designer adjusts a colour, clicks publish, +and every application picks it up on its next request. There is no version to cut, +no build to trigger, no consumer to upgrade. + +**No skew.** An application never holds a stale copy of a theme in its +`node_modules`, because it never held one. Rolling back a theme is a database write +too, and is atomic across every consumer. + +**No supply chain.** A token file is not code. It cannot execute anything, it has +no transitive dependencies, and it cannot be compromised in the way an npm package +can. This is a genuinely large reduction in attack surface. + +**Language neutrality.** Any environment that can make an HTTP request and read CSS +can consume a theme: Go, Rust, PHP, Jinja, Django templates, a static site +generator, a server-rendered email. + +### What it costs + +Honesty requires stating the costs: + +**A network hop at first paint.** A stylesheet link is a blocking request. Mitigated +by immutable-by-URL caching: a CDN serves it from an edge, a publish changes the +version and therefore the URL, and a revalidation costs a few milliseconds and no +body. This is the same cost model as a web font, which every application already +pays. + +**A runtime dependency.** The service must be up for a consuming application to look +right. Mitigated by a hard failure being a *fallback to the application's own +styles*, not an error page: a failed theme load leaves the page unstyled rather than +broken, which is exactly the right degradation. + +**CSS custom properties as the only interface.** A token cannot be compiled into a +Tailwind class, a Sass mixin or a styled-component prop. Anything that needs +compilation is not served by this system. That is the boundary of the approach, and +it is a real boundary rather than a detail. + +**A version-pinning story is needed.** Runtime delivery means a consumer can be +mid-request while a theme is republished. The answer is that a published theme's +version never changes — a publish creates version N+1 — so a URL pinned to version +N is immutable forever, and `/rt/v1/themes/{slug}` resolves to the latest. This is +the same model as an immutable container tag. + +## Alternatives considered + +**An npm package of design tokens.** Rejected for the reasons above. It remains the +right answer for anything requiring compilation, and the two models compose: an +application can build a Tailwind preset from a theme at its own build time, using +`/rt/v1/theme.json`, and then use the runtime for the parts that must be live. + +**A CSS file on a CDN, uploaded by hand.** The delivery mechanism without the +management. It removes the coupling but gives up atomic publish, rollback, history +and per-theme authorisation. This is what the service automates. + +**Server-side rendering of inline styles.** Each application renders the variables +into its own HTML. Zero latency, but every application must implement a fetcher, a +cache, a fallback and a revalidation policy, and they will all get it subtly +different. Centralising it means one correct implementation. + +**Web Components for the tokens.** Not applicable. Tokens are not components. + +**A GraphQL API.** Rejected: the payload is one object, so a single GET is simpler, +cacheable at the HTTP layer, and readable with `curl`. GraphQL would add a schema, +a server and a client library to deliver a JSON document. + +## Consequences + +**Good** + +- Publishing is immediate, atomic and reversible. +- No consumer rebuild, no version skew, no package to track. +- Consumers in any language, including ones that cannot use npm at all. +- A theme cannot execute code. +- Caching is a solved problem rather than a design decision, because URLs are + immutable. + +**Bad** + +- A network hop on the critical rendering path, mitigated by aggressive caching. +- Only expressible as CSS custom properties. Anything needing compilation is out of + scope. +- The service is a runtime dependency for every consumer's first paint. +- Debugging "why does this consumer look wrong" requires knowing which theme and + version it resolved. Mitigated by `X-Theme-Version` and `X-Theme-Updated-At` + response headers, and by the `sourceURL` comment in the generated CSS that makes + browser devtools navigate back to the endpoint. + +**Neutral** + +- The admin UI is itself a consumer, which means the delivery path is exercised on + every interaction. A change that breaks delivery breaks the tool that maintains + themes, which is a useful continuous check. +- The runtime SDKs are built and served rather than published, so they are versioned + by the service's release rather than by a registry. diff --git a/docs/adr/0003-stateful-sessions.md b/docs/adr/0003-stateful-sessions.md new file mode 100644 index 0000000..edded1a --- /dev/null +++ b/docs/adr/0003-stateful-sessions.md @@ -0,0 +1,101 @@ +# ADR 0003: Stateless signed session cookies + +- **Status:** accepted +- **Date:** 2026-10-04 + +## Context + +The administration API needs a session. The service may run more than one replica +behind a load balancer, and the login callback may be handled by a different pod +from the request that started it. + +## Decision + +A session is a signed JSON payload in an `HttpOnly`, `Secure`, `SameSite` cookie. +There is no server-side session table. + +## Rationale + +**The load-balancer problem.** With a server-side session store, a session created +by one replica must be readable by another. That means either sticky sessions (which +lose the pod on every rollout) or a shared store (Redis or a database table), which +is the operational surface [ADR 0001](0001-embedded-sqlite.md) set out to avoid. +A signed cookie removes the question entirely. + +**The transaction state has the same problem.** The OIDC flow carries a PKCE +verifier and a return path from the authorize redirect to the callback. Keeping that +in server-side state reintroduces the same requirement. It is a second signed cookie, +read and consumed exactly once. + +**The lifetime is short.** An administrative session is eight hours by default. +The cost of not being able to revoke a session before it expires is bounded by that +window. + +**What it costs.** Two things, stated plainly: + +1. **Revocation.** A stolen session cannot be invalidated before it expires. The + mitigations are an eight-hour lifetime and, for a full invalidation, rotating the + signing key, which logs out every session at once. Rotating is a deployment, not + a button. +2. **Payload size.** The cookie carries identity and roles. That is a few hundred + bytes, comfortably inside the 4 kB browser limit. A larger claim set would push + against it. + +## What was rejected + +**A server-side session table in SQLite.** Would work, and would add revocation. +Rejected because it adds a table, a write on every login, a sweep job, and a +migration path for a problem the eight-hour window already bounds. + +**Redis.** Would work. Rejected as operational surface for the same reason the +render cache stayed process-local. + +**JWT as a bearer token in localStorage.** Rejected on security grounds. A bearer +token in `localStorage` is readable by any script on the page, so one XSS bug +becomes a credential theft. An `HttpOnly` cookie is not readable by script, and +`SameSite=Lax` plus the session cookie's scope mean a cross-site form post cannot +ride it. The session is a cookie precisely so that it is not script-accessible. + +**Signed but not encrypted.** The payload is readable by the user holding the +cookie. It contains a subject, an email, a display name and roles — no secrets. It +is signed, not encrypted, so that a client can read it and debugging is possible. +Encrypting it would add a key for no confidentiality gain on this content. + +## Implementation requirements + +Every one of these is a security property, and each has a test: + +- HMAC-SHA256 signature, compared with `subtle.ConstantTimeCompare`. A + byte-by-byte early return leaks the correct prefix length to an attacker measuring + responses. +- `HttpOnly`, so script cannot read it. +- `Secure` in any non-development environment, enforced by configuration + validation. +- `SameSite`, defaulted to `Lax`. `None` requires `Secure`, enforced by validation, + because browsers reject the combination and the session would silently never + survive a redirect. +- Payload expiry, checked server-side on every request. The cookie's own `Max-Age` + is a client hint, not a control. +- An absent or invalid role set defaults to viewer. A cookie carrying no roles must + never be treated as carrying privileges. +- The signing secret is at least 32 bytes, and an ephemeral one is generated in + development rather than requiring configuration that does not matter locally. + +## Consequences + +**Good** + +- Any replica can authenticate any request, with no stickiness and no shared store. +- A rollback of the Deployment does not log anyone out. +- Nothing to sweep, nothing to migrate. + +**Bad** + +- No per-session revocation. Bounded by the session lifetime. +- Rotating the key logs out everyone, not one session. + +**Neutral** + +- Logout must clear the cookie rather than delete a record, so a client that keeps + a copy can still replay until expiry. Mitigated by the short lifetime. +- The payload is readable by its holder. Acceptable because it contains no secrets. diff --git a/docs/adr/0004-typescript-version.md b/docs/adr/0004-typescript-version.md new file mode 100644 index 0000000..56300da --- /dev/null +++ b/docs/adr/0004-typescript-version.md @@ -0,0 +1,70 @@ +# ADR 0004: TypeScript 6.0.3 rather than 7.x + +- **Status:** accepted +- **Date:** 2026-10-04 +- **Revisit when:** typescript-eslint ships TypeScript 7 support + +## Context + +The brief was to take the latest version of everything. For TypeScript, the latest +release is 7.0.2, which is the native Go port of the compiler (`tsgo`). + +## Decision + +Pin TypeScript to 6.0.3 in both `web/` and `sdk/`. + +## Rationale + +TypeScript 7.0.2 ships `tsc.js` and a version module. It does **not** ship a +JavaScript compiler API: + +```console +$ npm install typescript@7.0.2 +$ node -e "const ts=require('typescript'); console.log(typeof ts.createProgram)" +undefined +``` + +That API is what every type-aware linting tool consumes. `typescript-eslint@8.71.0`, +the current release, declares: + +``` +peerDependencies: { typescript: ">=4.8.4 <6.1.0" } +``` + +So TypeScript 7 fails to install at all alongside the standard linting stack, and +would fail at run time if forced past the peer check. + +The choice is therefore between: + +- **TypeScript 7.x** — a much faster compiler, and no type-aware ESLint rules at all, + because the tooling that provides them cannot run. `npm install` fails outright + without `--legacy-peer-deps`, which is a smell in a project rather than a solution. +- **TypeScript 6.0.3** — the newest release that ships both the CLI and the + JavaScript API, satisfying every tool in the toolchain. + +## Why not upgrade anyway and lose the linting + +The project's standards require type-aware linting: `@typescript-eslint` rules that +need the compiler's type information, which is what catches the mistakes that matter +in a codebase using `unknown` at API boundaries. Dropping it to gain compiler speed +is a bad trade: the speed is a developer-experience improvement, and the linting is +a correctness control. + +## What this costs + +TypeScript 6.0.3 is slower than 7.0.2. On this codebase the typecheck completes in +well under a second, so the difference is not observable, and the compilation cost +that matters is Vite's, which uses its own transform. + +## When to revisit + +When typescript-eslint declares support for TypeScript 7, which requires the +JavaScript API or a native alternative to it. That is a one-line version bump plus +a `make check` run. This ADR exists so that the pin is a decision with a reason +rather than an accident that outlives its justification. + +## A related note: `baseUrl` is deprecated + +TypeScript 6 deprecates `compilerOptions.baseUrl`. The project resolves `paths` +relative to the tsconfig instead, which is the documented replacement and removes a +compiler option that only ever existed to make `paths` work. diff --git a/docs/adr/0005-shared-token-contract.md b/docs/adr/0005-shared-token-contract.md new file mode 100644 index 0000000..67872eb --- /dev/null +++ b/docs/adr/0005-shared-token-contract.md @@ -0,0 +1,119 @@ +# ADR 0005: One token contract for every application, with a legacy alias layer + +- **Status:** accepted +- **Date:** 2026-10-05 +- **Revisit when:** an application adopts Tailwind v4 as its only styling mechanism, which + would let the alias layer be dropped + +## Context + +Six applications consume design decisions today, each in its own dialect: + +| Application | Mechanism | Palettes | Light/dark | Token indirection | +| --- | --- | --- | --- | --- | +| `go-doc` | Tailwind v4 `@theme` | 1, dark hardcoded | none | `--color-ink-*` | +| `go-guess` | one 2800-line CSS file | 1, light hardcoded | none | 11 `:root` vars, mostly bypassed by ~120 hardcoded hexes | +| `go-loose` | plain CSS | 4 × 2 modes | works | `[data-theme=X]` + `[data-mode=light]` | +| `go-mad` | plain CSS, copied from `go-loose` | 1 × 2 modes | CSS present, JS dead | `[data-mode=X]` | +| `go-places` | JS-injected `' + ' ', + 1, +) + +# The reporter, appended to the copy. go-tell's own source is never edited. +s = s.replace('', ' \n') +open(path, 'w').write(s) +PY + +echo "==> starting the theme service on :$THEME_PORT" +DATABASE_PATH="$WORK/tell.db" \ +BANANAS_ENVIRONMENT=development \ +BANANAS_AUTH_MODE=dev \ +BANANAS_PUBLIC_BASE_URL="http://localhost:$THEME_PORT" \ +BANANAS_HTTP_ADDR=":$THEME_PORT" \ +BANANAS_SDK_DIR="$WORK/site/assets" \ +BANANAS_CORS_ALLOWED_ORIGINS="http://localhost:$PORT" \ + "$WORK/go-bananas" >"$WORK/theme.log" 2>&1 & +THEME_PID=$! + +for _ in $(seq 1 40); do + curl -fsS -m 2 "http://localhost:$THEME_PORT/healthz" >/dev/null 2>&1 && break + sleep 0.25 +done + +echo "==> serving go-tell's shell on :$PORT" +(cd "$WORK/site" && exec python3 -m http.server "$PORT") >"$WORK/site.log" 2>&1 & +SERVER_PID=$! +for _ in $(seq 1 40); do + curl -fsS -m 2 "http://localhost:$PORT/index.html" >/dev/null 2>&1 && break + sleep 0.25 +done + +echo "==> driving the page" +"$CHROME" \ + --headless --no-sandbox --disable-gpu \ + --virtual-time-budget=15000 \ + --dump-dom "http://localhost:$PORT/index.html" \ + >"$WORK/dom.html" 2>"$WORK/chrome.log" + +result="$(python3 - "$WORK/dom.html" <<'PYEOF' +import re, sys +dom = open(sys.argv[1]).read() +m = re.search(r'id="pilot"[^>]*>(.*?)', dom, re.S) +print(m.group(1).strip() if m else "") +PYEOF +)" + +if [[ -z "$result" ]]; then + echo "PILOT FAIL: the page recorded nothing, so the check did not run." >&2 + echo "--- browser console ---" >&2 + grep -iE 'CONSOLE.*(error|Uncaught|violates|refused)' "$WORK/chrome.log" | head -5 >&2 || true + echo "--- dom size ---" >&2 + wc -c <"$WORK/dom.html" >&2 || true + exit 1 +fi + +echo +echo "$result" +[[ "$result" == PILOT\ PASS* ]] \ No newline at end of file diff --git a/hack/contract-check.js b/hack/contract-check.js new file mode 100644 index 0000000..845acd5 --- /dev/null +++ b/hack/contract-check.js @@ -0,0 +1,142 @@ +/** + * The integration check's script. + * + * It is a separate file rather than an inline + + \ No newline at end of file diff --git a/hack/pilot-go-loose.js b/hack/pilot-go-loose.js new file mode 100644 index 0000000..3ff46b3 --- /dev/null +++ b/hack/pilot-go-loose.js @@ -0,0 +1,124 @@ +/** + * The pilot check's reporter. + * + * It is injected into a *copy* of go-loose's page; go-loose's own source is untouched. + * + * The question it answers is narrow and specific: does the component shipped by + * go-bananas change the attributes that go-loose's own hand-written stylesheet already + * keys on? If it does, go-loose is themed by the shared contract without a single edit + * to its CSS, which is the entire claim of ADR 0005. + */ +import { getContractState } from '/rt/v1/components.js' + +const results = [] +/** @param {string} name @param {boolean} pass @param {string} [detail] */ +const check = (name, pass, detail = '') => results.push({ name, pass: !!pass, detail: String(detail) }) + +const root = document.documentElement +const state = getContractState() + +/** The computed background of go-loose's own page surface. */ +function pageBackground() { + return getComputedStyle(document.body).backgroundColor +} + +/** + * The value go-loose's page sees for a contract custom property. + * + * This is the claim worth testing: go-loose's stylesheet never mentions --bn-* or + * --surface being defined anywhere, yet reading the property off its own body proves + * the contract reached the page through the one stylesheet link. + */ +function contractValueOnHost(name) { + return getComputedStyle(document.body).getPropertyValue(name).trim() +} + +/** The text colour go-loose's own CSS takes from --text. */ +function pageText() { + return getComputedStyle(document.body).color +} + +async function run() { + // The selector fetches its palette list from the service. The wait has to cover a + // real cross-origin round trip, not the instant rejection that used to happen when + // the service sent no CORS headers: that failure was fast enough to hide behind a + // short settle time, and the wait was tuned to it. + await new Promise((r) => setTimeout(r, 4000)) + + const selector = document.querySelector('bananas-theme-selector') + const toggle = document.querySelector('bananas-appearance-toggle') + const shadow = (el) => /** @type {ShadowRoot} */ (el.shadowRoot) + + check('the selector element upgraded', !!shadow(selector), selector ? selector.tagName : 'absent') + check('the toggle element upgraded', !!shadow(toggle), toggle ? toggle.tagName : 'absent') + + const buttons = [...shadow(selector).querySelectorAll('button')] + const labels = buttons.map((b) => b.textContent.trim()) + check('palettes are listed from the service', buttons.length > 1, labels.join(', ')) + + // go-loose's four palettes must all be offered, because those are the slugs its own + // CSS has blocks for. + for (const slug of ['Tokyo Night', 'Catppuccin', 'Tomorrow', 'Kanagawa']) { + check(`go-loose's "${slug}" is offered`, labels.includes(slug)) + } + + // The contract stylesheet loaded and reached go-loose's page. + const bgTokyo = pageBackground() + check('go-loose\'s page is themed through the contract', bgTokyo !== 'rgba(0, 0, 0, 0)' && bgTokyo !== '', bgTokyo) + + // The decisive test: change the palette with the component, and confirm go-loose's + // OWN stylesheet responded. Nothing in style.css was edited. + const startBg = bgTokyo + const startText = pageText() + const kanagawa = buttons.find((b) => b.textContent.trim() === 'Kanagawa') + if (!kanagawa) { + check('the Kanagawa button was found', false, labels.join(', ')) + } else { + kanagawa.click() + await new Promise((r) => setTimeout(r, 60)) + check('selecting a palette writes data-theme', root.getAttribute('data-theme') === 'kanagawa', root.getAttribute('data-theme')) + check('selecting a palette writes the legacy attribute', root.getAttribute('data-bananas-theme') === 'kanagawa', root.getAttribute('data-bananas-theme')) + check("go-loose's own CSS followed the palette", pageBackground() !== startBg, startBg + ' -> ' + pageBackground()) + // The alias layer reached a page that never referenced it. + check('go-loose\'s page reads --surface from the contract', contractValueOnHost('--surface') !== '', contractValueOnHost('--surface')) + check('go-loose\'s page reads --bn-surface from the contract', contractValueOnHost('--bn-surface') !== '', contractValueOnHost('--bn-surface')) + check('the alias and the contract agree', contractValueOnHost('--surface') === contractValueOnHost('--bn-surface'), + contractValueOnHost('--surface') + ' vs ' + contractValueOnHost('--bn-surface')) + } + + // And the appearance axis, which go-loose has never had. + const toggleButtons = [...shadow(toggle).querySelectorAll('button')] + check('the appearance toggle has three states', toggleButtons.length === 3, toggleButtons.map((b) => b.getAttribute('aria-label')).join(', ')) + const light = toggleButtons.find((b) => b.getAttribute('aria-label') === 'Light') + if (light) { + light.click() + await new Promise((r) => setTimeout(r, 60)) + check('light writes data-mode', root.getAttribute('data-mode') === 'light', root.getAttribute('data-mode')) + check("go-loose's own CSS followed the appearance", pageBackground() !== 'rgb(0, 0, 0)', pageBackground()) + } + + // Isolation: go-loose has a broad `.card button { all: unset }`-style surface, and a + // control that leaked would be reset by it. + const first = shadow(selector).querySelector('button') + const style = first ? getComputedStyle(first) : null + check('the control is not reset by the host page', !!style && style.padding !== '0px', style ? style.padding : 'no button') + check('the control is not inheriting the page font', !!style && !style.fontFamily.includes('DM Sans'), style ? style.fontFamily : '') + + // The store is reachable, so go-loose can read or set the selection itself. + check('the contract store is reachable from the module', state && typeof state.selectTheme === 'function') + + const failed = results.filter((r) => !r.pass) + const out = document.getElementById('pilot') || document.createElement('pre') + out.id = 'pilot' + out.textContent = failed.length + ? 'PILOT FAIL ' + failed.length + '/' + results.length + ' :: ' + failed.map((f) => f.name + ' [' + f.detail + ']').join(' | ') + : 'PILOT PASS ' + results.length + ' :: ' + results.map((f) => f.name).join(' | ') + document.body.append(out) +} + +run().catch((error) => { + const out = document.createElement('pre') + out.id = 'pilot' + out.textContent = 'PILOT FAIL the check threw: ' + (error instanceof Error ? error.message : String(error)) + document.body.append(out) +}) \ No newline at end of file diff --git a/hack/pilot-go-tell.js b/hack/pilot-go-tell.js new file mode 100644 index 0000000..c0bf7af --- /dev/null +++ b/hack/pilot-go-tell.js @@ -0,0 +1,167 @@ +/** + * The go-tell pilot check. + * + * Injected into a *copy* of go-tell's shell; go-tell's own source is untouched. + * + * go-tell is the hard case among the applications: before this it had no CSS custom + * properties at all, 340 hardcoded hex values and 153 per-palette override rules. Its + * stylesheet now reads from the shared contract, so the question this answers is + * whether that rewrite actually works, and whether the two shipped controls can drive + * an application whose mechanism is `html[data-theme="…"]` rather than a class or a + * `--theme` variable. + */ +import { getContractState } from '/assets/components.js' + +const results = [] +const check = (name, pass, detail = '') => results.push({ name, pass: !!pass, detail: String(detail) }) + +const root = document.documentElement +const state = getContractState() + +/** The computed background of the page, which is what a palette actually looks like. */ +const pageBg = () => getComputedStyle(document.body).backgroundColor +/** The computed colour of a heading, which proves the text role resolved. */ +const headingColour = () => { + const el = document.querySelector('.brand, h1, .header') + return el ? getComputedStyle(el).color : '' +} + +/** + * Waits past this application's own CSS transitions. + * + * go-tell transitions `background` over 300ms, so reading a computed background sooner + * than that returns a value mid-animation and makes a working palette switch look + * broken. The wait is deliberately longer than the longest transition in the + * stylesheet rather than merely "long enough today". + */ +async function settle() { + await new Promise((r) => setTimeout(r, 450)) +} + +async function run() { + await new Promise((r) => setTimeout(r, 600)) // the selector's fetch + + const selector = document.querySelector('bananas-theme-selector') + const toggle = document.querySelector('bananas-appearance-toggle') + const shadow = (el) => /** @type {ShadowRoot} */ (el.shadowRoot) + + check('the controls upgraded', !!selector && !!toggle && !!shadow(selector) && !!shadow(toggle)) + if (!selector || !toggle) return finish() + + const buttons = [...shadow(selector).querySelectorAll('button')] + const labels = buttons.map((b) => b.textContent.trim()) + check('palettes are listed from the service', buttons.length > 1, labels.join(', ')) + + // go-tell's own four palettes must all be offered, because those are the slugs its + // stylesheet and its rules key on. + for (const name of ['Cyberpunk', 'Nordic Moss', 'Deep Space Obsidian', 'Terminal Retro']) { + check(`go-tell's "${name}" is offered`, labels.some((l) => l.includes(name))) + } + + // The decisive one: the contract must reach go-tell's page, which no longer + // defines a single custom property of its own. + const surface = getComputedStyle(document.body).getPropertyValue('--bn-surface').trim() + + // Diagnostics, because "the contract did not reach the page" has three very + // different causes and the symptom alone cannot tell them apart. + const link = document.querySelector('link[data-theme-contract]') + const linkHref = link ? link.href : '(no link element)' + let linkLoaded = 'unknown' + if (link && link.sheet) { + try { + linkLoaded = String(link.sheet.cssRules.length) + ' rules' + } catch { + // A cross-origin sheet is unreadable by design. Reaching this branch means the + // stylesheet loaded and the browser is refusing to expose its rules, which is + // the healthy case for a cross-origin link. + linkLoaded = 'loaded (cross-origin, rules not readable)' + } + } else if (link) { + linkLoaded = 'not loaded' + } + + check('the contract link is present', !!link, linkHref) + check('the contract stylesheet loaded', linkLoaded !== 'not loaded', linkLoaded) + check('the contract reaches go-tell', surface !== '', 'href=' + linkHref + ' loaded=' + linkLoaded + ' data-theme=' + root.getAttribute('data-theme') + ' data-mode=' + root.getAttribute('data-mode')) + check('--bn-surface has a real value', surface !== '' && surface !== 'rgba(0, 0, 0, 0)', surface) + + // Switching a palette must move the page, through go-tell's own rules. + let startBg = pageBg() + const startText = headingColour() + + for (const name of ['Nordic Moss', 'Deep Space Obsidian', 'Terminal Retro', 'Cyberpunk']) { + const button = buttons.find((b) => b.textContent.trim().includes(name)) + if (!button) { + check(`selecting ${name}`, false, 'button not found') + continue + } + + // The loop returns to Cyberpunk at the end, which is where it started. Selecting + // the palette that is already showing cannot change anything, so the "did the + // page move" assertion does not apply to that step. + const alreadyShowing = buttons.find((b) => b.getAttribute('aria-checked') === 'true') + const wasThis = alreadyShowing && alreadyShowing.textContent.trim().includes(name) + + button.click() + await settle() + + const slug = root.getAttribute('data-theme') + const contractBg = getComputedStyle(document.body).getPropertyValue('--bn-bg').trim() + check(`${name} writes data-theme`, !!slug, slug) + if (wasThis) { + check(`${name} is a no-op when already showing`, pageBg() === startBg, pageBg()) + } else { + check(`${name} changes the page`, pageBg() !== startBg || headingColour() !== startText, + `slug=${slug} mode=${root.getAttribute('data-mode')} bodyBg=${pageBg()} --bn-bg=${contractBg} text=${headingColour()}`) + } + startBg = pageBg() + } + + // The terminal palette forces a monospace face with !important on every element. + // Shadow DOM is supposed to make that impossible to reach the controls in; if the + // controls inherit the terminal face here, the isolation has failed. + const terminal = buttons.find((b) => b.textContent.trim().includes('Terminal')) + if (terminal) { + terminal.click() + await settle() + const font = getComputedStyle(terminal).fontFamily + check('the controls are not restyled by the terminal palette', !font.includes('Courier'), font) + check('the app itself did adopt the monospace face', + getComputedStyle(document.body).fontFamily.includes('Courier') || + getComputedStyle(document.body).fontFamily.includes('monospace'), + getComputedStyle(document.body).fontFamily) + } + + // The appearance axis: go-tell had no light mode before this. The control writing + // data-mode is what makes one possible. + const toggleButtons = [...shadow(toggle).querySelectorAll('button')] + check('the appearance toggle has three states', toggleButtons.length === 3, + toggleButtons.map((b) => b.getAttribute('aria-label')).join(', ')) + + const light = toggleButtons.find((b) => b.getAttribute('aria-label') === 'Light') + if (light) { + light.click() + await settle() + check('light writes data-mode', root.getAttribute('data-mode') === 'light', root.getAttribute('data-mode')) + } + + check('the contract store is reachable', state && typeof state.selectTheme === 'function') + finish() +} + +function finish() { + const failed = results.filter((r) => !r.pass) + const out = document.createElement('pre') + out.id = 'pilot' + out.textContent = failed.length + ? 'PILOT FAIL ' + failed.length + '/' + results.length + ' :: ' + failed.map((f) => f.name + ' [' + f.detail + ']').join(' | ') + : 'PILOT PASS ' + results.length + ' :: ' + results.map((f) => f.name).join(' | ') + document.body.append(out) +} + +run().catch((error) => { + const out = document.createElement('pre') + out.id = 'pilot' + out.textContent = 'PILOT FAIL the check threw: ' + (error instanceof Error ? error.message : String(error)) + document.body.append(out) +}) \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..b9d254a --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,524 @@ +// Package app wires the application together: configuration, store, auth, +// HTTP, and the lifecycle that starts and stops them. +// +// Keeping the wiring in one place means main() is a dozen lines, the whole +// object graph can be constructed in a test, and there is exactly one place to +// look to answer "what actually runs in production". +package app + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/urpi/go-bananas/internal/auth" + "github.com/urpi/go-bananas/internal/config" + "github.com/urpi/go-bananas/internal/domain" + "github.com/urpi/go-bananas/internal/httpapi" + "github.com/urpi/go-bananas/internal/logging" + "github.com/urpi/go-bananas/internal/seed" + "github.com/urpi/go-bananas/internal/store" +) + +// Build information, stamped at link time with -ldflags -X. +// +// Every value has a usable default so that `go run ./cmd/go-bananas` and a plain +// `go build` both work without ceremony, and so that a binary built by hand still +// reports something meaningful rather than an empty string in a log line. +var ( + // Version is the release version, e.g. 1.4.2. + Version = "dev" + // Commit is the source revision. + Commit = "unknown" + // BuildDate is the RFC 3339 build timestamp. + BuildDate = "unknown" +) + +// App is the assembled application. +type App struct { + Config config.Config + Log logging.Logger + Store *store.Store + Cache *httpapi.ThemeCache + Auth *auth.Authenticator + Router http.Handler + Server *http.Server + + metrics *httpapi.Metrics + // listener is bound before Serve so that the ready log line can report the + // real address, which matters when the configured port is 0. + listener net.Listener +} + +// Options are the inputs needed to build an App. +type Options struct { + // Config is the resolved configuration. + Config config.Config + // Version is the build version. + Version string + // Logger overrides the constructed logger, for tests. + Logger logging.Logger +} + +// New builds the application without starting it. +// +// Construction validates the whole object graph and reconciles the built-in +// themes, so a misconfigured deployment or a broken migration fails here rather +// than on the first request. +func New(ctx context.Context, opts Options) (*App, error) { + cfg := opts.Config + if cfg.StaticDir == "" && cfg.SDKDir == "" { + return nil, errors.New("neither STATIC_DIR nor SDK_DIR is set") + } + + log := opts.Logger + if cfg.EphemeralSessionSecret() { + // Worth a warning: an operator debugging a session that "keeps logging + // me out" in local development needs to know why. + if log == nil { + log = logging.New(logging.Options{ + Level: cfg.LogLevel, Format: cfg.LogFormat, + Service: "Go Bananas!!", Environment: cfg.Environment, Version: opts.Version, + }) + } + log.Warn("SESSION_SECRET was not set; an ephemeral one was generated. " + + "Sessions will not survive a restart. Set SESSION_SECRET to at least 32 bytes.") + } + if log == nil { + log = logging.New(logging.Options{ + Level: cfg.LogLevel, + Format: cfg.LogFormat, + Service: "Go Bananas!!", + Environment: cfg.Environment, + Version: opts.Version, + }) + } + + st, err := store.Open(ctx, store.Options{ + Path: cfg.DatabasePath, + BusyTimeout: cfg.DatabaseBusyTimeout, + MaxConns: cfg.DatabaseMaxConns, + }) + if err != nil { + return nil, fmt.Errorf("open store: %w", err) + } + + cache := httpapi.NewThemeCache() + + if cfg.SeedOnStart { + inserted, refreshed, err := st.Seed(ctx, seed.Themes()) + if err != nil { + st.Close() + return nil, fmt.Errorf("seed built-in themes: %w", err) + } + if inserted > 0 || refreshed > 0 { + log.Info("built-in themes reconciled", + "inserted", inserted, "refreshed", refreshed, "total", len(seed.Slugs())) + } + } + + authenticator, err := buildAuthenticator(ctx, cfg, st, log) + if err != nil { + st.Close() + return nil, err + } + + // Warming the render cache at start-up means the first request after a deploy + // is already fast. Failure is not fatal: the cache is an optimisation. + warmed := 0 + if themes, err := st.ListRuntime(ctx); err == nil { + for _, theme := range themes { + warmed += cache.Prefetch(theme, nil) + } + } + if warmed > 0 { + log.Debug("render cache warmed", "artefacts", warmed) + } + + metrics := httpapi.NewMetrics().WithCache(cache) + metrics.RegisterCounter("bananas_theme_publishes_total", "Themes moved to the published status.") + metrics.RegisterCounter("bananas_theme_updates_total", "Theme documents written.") + metrics.RegisterCounter("bananas_auth_failures_total", "Requests rejected for want of a usable credential.") + + app := &App{ + Config: cfg, + Log: log, + Store: st, + Cache: cache, + Auth: authenticator, + metrics: metrics, + } + + app.Router = httpapi.NewRouter(httpapi.Deps{ + Config: cfg, + Store: st, + Cache: cache, + Authenticator: authenticator, + Log: log, + Version: opts.Version, + Metrics: metrics, + StaticHandler: staticHandler(cfg.StaticDir), + SDKHandler: sdkHandler(cfg.SDKDir), + // Resolved per request rather than captured at start-up, so that changing the + // default theme in the admin UI takes effect without a restart. + // + // A missing setting yields the supplied default, which is the normal case. + // A database error is different: it would otherwise look like "no default + // configured" and every runtime request would fall through to asking the + // consumer for a theme. It is logged rather than propagated because this runs + // inside a request handler whose job is to serve a theme, and failing the + // whole request over a settings lookup would be a worse outcome than serving + // the fallback. + RuntimeDefaults: func() (string, domain.Appearance) { + slug, err := st.Setting(ctx, domain.SettingDefaultTheme, "") + if err != nil { + log.Warn("could not read the default theme setting", "error", err) + } + appearance, err := st.Setting(ctx, domain.SettingDefaultAppearance, string(domain.AppearanceLight)) + if err != nil { + log.Warn("could not read the default appearance setting", "error", err) + } + return slug, domain.Appearance(appearance) + }, + }) + + app.Server = &http.Server{ + Addr: cfg.HTTPAddr, + Handler: app.Router, + ReadTimeout: cfg.ReadTimeout, + ReadHeaderTimeout: 10 * time.Second, + WriteTimeout: cfg.WriteTimeout, + IdleTimeout: cfg.IdleTimeout, + MaxHeaderBytes: 1 << 20, + ErrorLog: nil, + BaseContext: func(_ net.Listener) context.Context { return context.WithoutCancel(ctx) }, + } + + return app, nil +} + +// buildAuthenticator constructs the auth stack for the configured mode. +func buildAuthenticator(ctx context.Context, cfg config.Config, st *store.Store, log logging.Logger) (*auth.Authenticator, error) { + switch cfg.AuthMode { + case config.AuthModeOIDC: + state := auth.NewStateStore( + cfg.SessionSecret, + "go-bananas-oidc-state", + cfg.SessionSecure, + sameSiteFrom(cfg.SessionSameSite), + 10*time.Minute, + ) + provider, err := auth.NewOIDCProvider(ctx, cfg, state) + if err != nil { + return nil, fmt.Errorf("identity provider: %w", err) + } + log.Info("identity provider configured", + "issuer", cfg.OIDCIssuer, + "clientId", cfg.OIDCClientID, + "redirectUrl", cfg.RedirectURL(), + "adminGroups", cfg.OIDCAdminGroups, + "editorGroups", cfg.OIDCEditorGroups, + ) + return auth.New(cfg, provider, st) + + case config.AuthModeDev: + log.Warn("AUTH MODE IS DEV: every request is authenticated as a local administrator. " + + "This mode is refused outside a development environment.") + return auth.New(cfg, nil, st) + + case config.AuthModeNone: + log.Warn("AUTH MODE IS NONE: every request is granted administrator access. " + + "This mode is refused outside a development environment.") + return auth.New(cfg, nil, st) + + default: + return nil, fmt.Errorf("unknown AUTH_MODE %q", cfg.AuthMode) + } +} + +// sameSiteFrom maps a configuration value onto an http.SameSite. +func sameSiteFrom(raw string) (result sameSite) { + switch strings.ToLower(raw) { + case "strict": + return sameSiteStrict + case "none": + return sameSiteNone + default: + return sameSiteLax + } +} + +// Run starts the server and blocks until ctx is cancelled. +// +// The shutdown sequence is the part worth reading: on cancellation the listener +// is closed first so no new connection is accepted, then in-flight requests get +// ShutdownTimeout to finish, and only then is the store closed. Closing the store +// while a request is still reading would surface as a confusing 500 to a user +// who did nothing wrong. +func (a *App) Run(ctx context.Context) error { + listenCfg := net.ListenConfig{} + listener, err := listenCfg.Listen(ctx, "tcp", a.Config.HTTPAddr) + if err != nil { + return fmt.Errorf("listen on %s: %w", a.Config.HTTPAddr, err) + } + a.listener = listener + + a.Log.Info("starting Go Bananas!!", + "address", listener.Addr().String(), + "publicUrl", a.Config.Origin(), + "authMode", a.Config.AuthMode, + "database", a.Config.DatabasePath, + "environment", a.Config.Environment, + "version", Version, + "commit", Commit, + "built", BuildDate, + ) + + serverErrors := make(chan error, 1) + go func() { + err := a.Server.Serve(listener) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + serverErrors <- err + }() + + select { + case err := <-serverErrors: + if err != nil { + return fmt.Errorf("serve: %w", err) + } + return nil + + case <-ctx.Done(): + a.Log.Info("shutdown signal received", "graceful", true) + } + + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), a.Config.ShutdownTimeout) + defer cancel() + + if err := a.Server.Shutdown(shutdownCtx); err != nil { + // A timeout here means a request outlived the grace period. Closing hard + // is better than hanging, and the error is logged so it is visible. + a.Log.Error("graceful shutdown timed out; closing connections", "error", err) + if closeErr := a.Server.Close(); closeErr != nil { + a.Log.Error("force close failed", "error", closeErr) + } + } + + if err := <-serverErrors; err != nil { + return fmt.Errorf("serve: %w", err) + } + a.Log.Info("stopped cleanly") + return nil +} + +// Addr returns the bound address, useful when the configured port is 0. +func (a *App) Addr() string { + if a.listener == nil { + return a.Config.HTTPAddr + } + return a.listener.Addr().String() +} + +// Close releases the application's resources. +func (a *App) Close() error { + if a.Store == nil { + return nil + } + return a.Store.Close() +} + +// Run is the top-level entrypoint: load configuration, build, run, shut down. +func Run(ctx context.Context) error { + cfg, err := config.Load() + if err != nil { + // Configuration errors are reported plainly rather than through the logger, + // because the logger's own configuration may be part of what is wrong. + return fmt.Errorf("%w", err) + } + + application, err := New(ctx, Options{Config: cfg, Version: Version}) + if err != nil { + return err + } + defer application.Close() + + return application.Run(ctx) +} + +// staticHandler serves the built admin UI, or nil when no build is present. +// +// A missing UI is not fatal: the API is independently useful, and a deployment +// that only serves the runtime endpoints has no reason to carry the SPA. +func staticHandler(dir string) http.Handler { + if dir == "" { + return nil + } + index := filepath.Join(dir, "index.html") + if _, err := os.Stat(index); err != nil { + return nil + } + fileServer := spaHandler{root: dir, files: http.FileServer(http.Dir(dir))} + return fileServer +} + +// spaHandler serves the admin single-page application. +// +// Any GET that does not resolve to a real file falls through to index.html so +// that client-side routes survive a page reload. An unknown /api or /rt path must +// still 404 as JSON rather than returning HTML, which is handled by the router +// before this handler is reached. +type spaHandler struct { + root string + files http.Handler +} + +// withinRoot reports whether path is root or lives beneath it. +// +// It compares path components rather than string prefixes, because a prefix test +// cannot tell "/srv/web" from "/srv/web-secrets": the first contains the second as +// a leading substring, and a request for the second would pass a HasPrefix check +// against the first. +func withinRoot(root, path string) bool { + absRoot, err := filepath.Abs(root) + if err != nil { + return false + } + // The root is resolved when it exists. When it does not, Abs still gives an + // absolute path to compare against, which is the best available answer. + if resolvedRoot, resolveErr := filepath.EvalSymlinks(absRoot); resolveErr == nil { + absRoot = resolvedRoot + } + + absPath, err := filepath.Abs(path) + if err != nil { + return false + } + + rel, err := filepath.Rel(absRoot, absPath) + if err != nil { + return false + } + // ".." is the only relative path that leaves the root. "." is the root itself, + // which is allowed; a path equal to the root is not a file but reaching it is + // not an escape. + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +// ServeHTTP implements http.Handler. +func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Anchoring the path with a leading slash and cleaning it removes every ".." + // segment, so Join below cannot escape the root. The check after it is a + // belt-and-braces assertion that the result really is inside, rather than a + // prefix test: a plain HasPrefix against the root would accept a sibling + // directory whose name merely starts with the root's. + cleaned := filepath.Clean("/" + strings.TrimPrefix(r.URL.Path, "/")) + candidate := filepath.Join(h.root, cleaned) + + if !withinRoot(h.root, candidate) { + http.Error(w, "not found", http.StatusNotFound) + return + } + + // The static file server resolves symlinks, so a symlink inside the web + // directory pointing at /etc would otherwise be served. Resolving the real + // path and re-checking it is what actually closes that: the root itself is + // resolved too, so a root reached through a symlink does not trip the check. + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil { + // A path that does not exist is not an error here: the SPA fallback below + // serves the shell for it. Only a real failure is reported. + resolved = candidate + } + if !withinRoot(h.root, resolved) { + http.Error(w, "not found", http.StatusNotFound) + return + } + + //nolint:gosec // G703: as on the ServeFile call below; the taint source is + // r.URL.Path and both paths it reaches are checked by withinRoot. + if info, err := os.Stat(resolved); err == nil && !info.IsDir() { + setStaticCacheHeaders(w, resolved) + // ServeFile rather than the file server, because the request URL is the + // uncleaned path and the file to serve is the resolved one. The directory + // case cannot reach here, which is the only case in which ServeFile + // redirects. + // + //nolint:gosec // G703: gosec's taint analysis cannot see withinRoot. The + // containment predicate compares path components rather than prefixes, so a + // sibling directory named "web-secrets" beside "web" is rejected, and + // resolved above is the symlink-resolved path that was re-checked against it. + // TestSPARejectsTraversal, TestSPARejectsSymlinkEscape and TestWithinRoot are + // what hold this in place. + http.ServeFile(w, r, resolved) + return + } + + // Serve the shell for a client-side route. The request path is rewritten to + // the directory root rather than to index.html: http.FileServer answers a + // request for /index.html with a 301 to /, which would turn every deep link + // into a redirect loop against the SPA router. + w.Header().Set("Cache-Control", "no-cache") + h.files.ServeHTTP(w, indexRequest(r)) +} + +// indexRequest rewrites a request onto the SPA entry point. +func indexRequest(r *http.Request) *http.Request { + clone := r.Clone(r.Context()) + clone.URL.Path = "/" + return clone +} + +// staticAssetSuffixes are the file extensions that may be cached indefinitely, +// because Vite emits content-hashed names for them. +var staticAssetSuffixes = []string{".js", ".css", ".woff", ".woff2", ".svg", ".png", ".jpg", ".webp", ".avif"} + +// setStaticCacheHeaders chooses a cache policy for a static asset. +func setStaticCacheHeaders(w http.ResponseWriter, path string) { + for _, suffix := range staticAssetSuffixes { + if strings.HasSuffix(path, suffix) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + return + } + } + w.Header().Set("Cache-Control", "public, max-age=300") +} + +// sameSite aliases keep the http.SameSite constants out of this file's imports, +// which keeps the mapping in one readable block. +type sameSite = http.SameSite + +// Cookie SameSite modes, re-exported for the configuration mapping above. +const ( + sameSiteLax = http.SameSiteLaxMode + sameSiteStrict = http.SameSiteStrictMode + sameSiteNone = http.SameSiteNoneMode +) + +// sdkHandler serves the built runtime SDK bundles, or nil when no build is +// present. +// +// The SDK is served from the same origin as the runtime API so that a consuming +// application can load it with a plain URL and no build step, which is the whole +// point of delivering themes at runtime rather than through a package registry. +func sdkHandler(dir string) http.Handler { + if dir == "" { + return nil + } + if _, err := os.Stat(filepath.Join(dir, "runtime.js")); err != nil { + return nil + } + return http.StripPrefix("/rt/v1/", http.FileServer(http.Dir(dir))) +} diff --git a/internal/app/backup.go b/internal/app/backup.go new file mode 100644 index 0000000..b23a936 --- /dev/null +++ b/internal/app/backup.go @@ -0,0 +1,105 @@ +package app + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/urpi/go-bananas/internal/config" + "github.com/urpi/go-bananas/internal/domain" + "github.com/urpi/go-bananas/internal/store" +) + +// RunBackup is the `go-bananas backup` subcommand: it writes a consistent copy of +// the SQLite database and exits. +// +// It exists as a subcommand rather than as a separate tool, or as a shell script +// copying the file, for two reasons. +// +// The first is correctness. `cp` on a live database produces a torn copy, and the +// result can open without complaint while being corrupt. SQLite's VACUUM INTO +// reads through the database's own backup machinery and produces a consistent +// snapshot while the service keeps serving. +// +// The second is packaging. The alternative is a container that ships a `sqlite3` +// binary, which means either a third-party image or installing a package into a +// container whose root filesystem is deliberately read-only. The SQLite library is +// already linked into this binary, at exactly the version the schema was written +// for, so using it removes both problems. +// +// The Helm chart's snapshot CronJob runs this command. A deployment that does not +// use the chart can run it from anywhere the binary is. +func RunBackup(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("backup", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + var ( + destination = fs.String("to", "", "Path to write the snapshot to. Required.") + database = fs.String("database", "", "Path to the SQLite database. Defaults to DATABASE_PATH.") + timeout = fs.Duration("timeout", 5*time.Minute, "Give up after this long.") + ) + fs.Usage = func() { + fmt.Fprintf(os.Stderr, + "usage: go-bananas backup --to [--database ]\n\n"+ + "Writes a consistent snapshot of the database using SQLite's online backup.\n"+ + "It is safe to run against a database that is being written to.\n\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return fmt.Errorf("%w: %w", domain.ErrInvalid, err) + } + if *destination == "" { + fs.Usage() + return fmt.Errorf("%w: --to is required", domain.ErrInvalid) + } + if fs.NArg() > 0 { + return fmt.Errorf("%w: unexpected argument %q", domain.ErrInvalid, fs.Arg(0)) + } + + // The database path comes from the same environment variable the server reads, + // so a CronJob and the service cannot disagree about which file is being + // snapshotted. DATABASE_PATH alone is enough: the rest of the configuration is + // irrelevant to a backup, and requiring it would mean a snapshot job failing + // because an unrelated setting is absent. + path := *database + if path == "" { + path = os.Getenv("DATABASE_PATH") + } + if path == "" { + path = config.Defaults().DatabasePath + } + if !store.IsPersistentPath(path) { + return fmt.Errorf("%w: cannot snapshot an ephemeral database (%s); it holds no data once the process exits", + domain.ErrInvalid, path) + } + + // Bounded, because a snapshot that hangs holds the CronJob's slot forever and a + // snapshot of a very large database is genuinely slow. + ctx, cancel := context.WithTimeout(ctx, *timeout) + defer cancel() + + st, err := store.Open(ctx, store.Options{Path: path}) + if err != nil { + return fmt.Errorf("open database for snapshot: %w", err) + } + defer func() { _ = st.Close() }() + + target := *destination + if !filepath.IsAbs(target) { + // Made absolute so the log line says where the file actually is, rather than + // leaving the reader to resolve it against whatever the working directory was. + abs, absErr := filepath.Abs(target) + if absErr == nil { + target = abs + } + } + + if err := st.Snapshot(ctx, target); err != nil { + return err + } + fmt.Fprintf(os.Stdout, "snapshot written to %s\n", target) + return nil +} diff --git a/internal/app/spa_test.go b/internal/app/spa_test.go new file mode 100644 index 0000000..d4f1a28 --- /dev/null +++ b/internal/app/spa_test.go @@ -0,0 +1,224 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// These tests cover the static file handler, which is the one place in the service +// where a request path is turned into a filesystem path. Everything else operates on +// database rows or JSON, so the containment logic here is the only traversal +// defence in the codebase and it needs its own tests. + +// newSPAFixture builds a web root with an index, a stylesheet and a secret file +// outside the root. +func newSPAFixture(t *testing.T) (root, outside string, handler http.Handler) { + t.Helper() + + base := t.TempDir() + root = filepath.Join(base, "web") + outside = filepath.Join(base, "secret.txt") + + if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeFile(t, filepath.Join(root, "index.html"), "app") + writeFile(t, filepath.Join(root, "assets", "app.css"), "body{color:red}") + // The name Vite actually emits: an extension from staticAssetSuffixes with a + // content hash in it. + writeFile(t, filepath.Join(root, "assets", "app-CyCU_ony.js"), "export const x=1") + writeFile(t, outside, "top secret") + + handler = spaHandler{root: root, files: http.FileServer(http.Dir(root))} + return root, outside, handler +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)) + return rec +} + +// TestSPAServesTheShellForClientRoutes covers the single-page-app fallback: an +// unknown path that is not a file is the router's problem, not a 404. +func TestSPAServesTheShellForClientRoutes(t *testing.T) { + _, _, handler := newSPAFixture(t) + + for _, path := range []string{"/", "/themes/midnight", "/keys", "/a/deep/client/route"} { + t.Run(path, func(t *testing.T) { + rec := get(t, handler, path) + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "app") { + t.Errorf("did not serve the shell: %q", rec.Body.String()) + } + }) + } +} + +// TestSPAServesRealFiles covers the assets themselves, including a nested path. +func TestSPAServesRealFiles(t *testing.T) { + _, _, handler := newSPAFixture(t) + + rec := get(t, handler, "/assets/app.css") + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "color:red") { + t.Errorf("unexpected body %q", rec.Body.String()) + } +} + +// TestSPACacheHeaders pins the caching contract, which is what decides whether a +// deploy reaches users holding a cached copy. +// +// The rule is extension-based: an asset extension is assumed to be content-hashed by +// the frontend build, and is therefore immutable for a year. Everything else gets a +// short max-age, because a document that is replaced under the same name must not +// be cached indefinitely. +func TestSPACacheHeaders(t *testing.T) { + _, _, handler := newSPAFixture(t) + + cases := []struct { + path string + immutable bool + }{ + {"/assets/app-CyCU_ony.js", true}, + {"/assets/app.css", true}, + {"/index.html", false}, + {"/themes/midnight", false}, // the SPA shell for a client route + } + + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + cc := get(t, handler, tc.path).Header().Get("Cache-Control") + if immutable := strings.Contains(cc, "immutable"); immutable != tc.immutable { + t.Errorf("Cache-Control %q, immutable=%v, want %v", cc, immutable, tc.immutable) + } + }) + } +} + +// TestSPARejectsTraversal is the security test. Every one of these paths is an +// attempt to read a file outside the web root, and each must fail to return the +// file's contents. +// +// The interesting cases are the encoded and mixed ones: a naive handler that +// strips ".." once, or that compares paths with HasPrefix, passes at least one. +func TestSPARejectsTraversal(t *testing.T) { + root, _, handler := newSPAFixture(t) + // A sibling directory whose name starts with the root's name. A HasPrefix + // containment check accepts this; a component comparison does not. + sibling := root + "-secrets" + if err := os.MkdirAll(sibling, 0o755); err != nil { + t.Fatalf("mkdir sibling: %v", err) + } + writeFile(t, filepath.Join(sibling, "leak.txt"), "sibling secret") + + cases := []string{ + "/../secret.txt", + "/../../etc/passwd", + "/assets/../../secret.txt", + "/./../secret.txt", + "/%2e%2e/secret.txt", + "/..%2fsecret.txt", + "/assets/%2e%2e%2f%2e%2e%2fsecret.txt", + "/../web-secrets/leak.txt", + } + + for _, path := range cases { + t.Run(path, func(t *testing.T) { + rec := get(t, handler, path) + body := rec.Body.String() + if strings.Contains(body, "top secret") || strings.Contains(body, "sibling secret") { + t.Fatalf("leaked a file outside the root: %q", body) + } + // Serving the shell instead of a 404 is acceptable; that is what the SPA + // fallback does for any path that is not a file. + if rec.Code == http.StatusOK && !strings.Contains(body, "app") { + t.Errorf("unexpected 200 body: %q", body) + } + }) + } +} + +// TestSPARejectsSymlinkEscape covers the case a string comparison cannot: the path +// is inside the root and contains no "..", but a symlink points outside it. +func TestSPARejectsSymlinkEscape(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "web") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeFile(t, filepath.Join(root, "index.html"), "app") + + target := filepath.Join(base, "secret.txt") + writeFile(t, target, "top secret") + + // Skipped where symlinks are unavailable, which is a Windows privilege rather + // than a reason to fail the suite. + if err := os.Symlink(target, filepath.Join(root, "link.txt")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + handler := spaHandler{root: root, files: http.FileServer(http.Dir(root))} + rec := get(t, handler, "/link.txt") + if strings.Contains(rec.Body.String(), "top secret") { + t.Errorf("served a file through a symlink out of the root: %q", rec.Body.String()) + } +} + +// TestSPARejectsNonGETMethods keeps the handler read-only. It has no mutating route, +// so a POST should not be answered at all rather than quietly treated as a GET. +func TestSPARejectsNonGETMethods(t *testing.T) { + _, _, handler := newSPAFixture(t) + + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} { + t.Run(method, func(t *testing.T) { + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), method, "/", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("got %d, want 405", rec.Code) + } + }) + } +} + +// TestWithinRoot covers the containment predicate directly, including the sibling +// directory that separates it from a prefix comparison. +func TestWithinRoot(t *testing.T) { + root := filepath.Join(t.TempDir(), "web") + + inside := []string{root, filepath.Join(root, "a.txt"), filepath.Join(root, "a", "b", "c.txt")} + for _, path := range inside { + if !withinRoot(root, path) { + t.Errorf("withinRoot(%q, %q) = false, want true", root, path) + } + } + + outside := []string{ + filepath.Join(root, "..", "secret.txt"), + root + "-secrets", + filepath.Join(root, ".."), + "/etc/passwd", + root + "X", + } + for _, path := range outside { + if withinRoot(root, path) { + t.Errorf("withinRoot(%q, %q) = true, want false", root, path) + } + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..1a906db --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,715 @@ +// Package auth authenticates callers and authorises their actions. +// +// Three mechanisms are supported and are resolved by Authenticator in a fixed +// order, most specific first: +// +// 1. An API key, presented as a bearer token. Machine access. +// 2. An OIDC session, carried in a signed cookie. Human access to the admin +// API, with a full login redirect flow. +// 3. A development bypass, available only when the configuration explicitly +// enables it and the process is running in a development environment. +// +// The development bypass refuses to initialise outside development. That check +// is the single most important line of defence in this package: an accidentally +// deployed dev mode is a total authentication bypass, so it is made impossible +// to construct rather than merely documented as unsupported. +package auth + +import ( + "context" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/urpi/go-bananas/internal/config" + "github.com/urpi/go-bananas/internal/domain" +) + +// Errors returned by this package. They wrap the domain sentinels so the HTTP +// layer can map them to status codes without importing this package's internals. +var ( + ErrNoSession = fmt.Errorf("%w: no session", domain.ErrUnauthorized) + ErrSessionExpired = fmt.Errorf("%w: session expired", domain.ErrUnauthorized) + ErrSessionInvalid = fmt.Errorf("%w: session is invalid", domain.ErrUnauthorized) + ErrDevBypassDenied = fmt.Errorf("%w: development bypass is not available", domain.ErrUnauthorized) +) + +// Identity, Role and the role constants are re-exported so that callers of this +// package need not also import the domain package. +type ( + // Identity is the authenticated caller. + Identity = domain.Identity + // Role is a coarse authorisation level. + Role = domain.Role +) + +// Role constants, re-exported for the same reason. +const ( + // RoleViewer may read published themes. + RoleViewer = domain.RoleViewer + // RoleEditor may create, edit and publish themes. + RoleEditor = domain.RoleEditor + // RoleAdmin may additionally manage users, API keys and settings. + RoleAdmin = domain.RoleAdmin +) + +// Resolver is the identity-provider half of the interface. It is an interface so +// that the session store can be tested without a live provider, and so that a +// future provider (SAML, a service mesh) can be added without touching the HTTP +// layer. +// Login is the outcome of a completed login. +type Login struct { + // Identity is the authenticated caller. + Identity *Identity + // ReturnTo is the sanitised local path to send the browser to. It is always + // a path on this service, never an absolute URL. + ReturnTo string +} + +// SafeReturnTo restricts a post-login redirect to a local path. +// +// Without this check an attacker could craft a login link that, after a +// successful authentication, bounces the user to a site of their choosing with the +// session cookie freshly minted: an open redirect that also primes a phishing flow. +// Absolute URLs, protocol-relative URLs, backslashes and control characters are all +// rejected, because browsers normalise several of those into an origin. +func SafeReturnTo(returnTo string) string { + trimmed := strings.TrimSpace(returnTo) + if trimmed == "" { + return "/" + } + if strings.ContainsAny(trimmed, "\\\r\n\t") || + strings.Contains(trimmed, "://") || strings.HasPrefix(trimmed, "//") { + return "/" + } + if !strings.HasPrefix(trimmed, "/") { + return "/" + } + return trimmed +} + +type Resolver interface { + // Begin starts a login by redirecting the browser to the provider. + Begin(ctx context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error + // Complete finishes a login, returning the identity and where to send the + // browser next. + Complete(ctx context.Context, r *http.Request) (*Login, error) + // End starts a logout. + End(ctx context.Context, redirect string) string + // Enabled reports whether the resolver can authenticate at all. + Enabled() bool + // ClearState discards any pending login transaction. + ClearState(w http.ResponseWriter) + // LogoutURL returns the provider's end-session endpoint. + LogoutURL(redirect string) string +} + +// KeyStore is the persistence the authenticator needs beyond sessions: recording +// the users it has seen, so that an operator can see who has access. +// +// It was once also the API-key store. Machine credentials were removed, so a bearer +// token is no longer a credential: the runtime API is public and the admin API is +// for people. A request carrying a bearer token is now simply unauthenticated, +// rather than being given editor rights by whatever it presents. +type KeyStore interface { + UpsertUser(ctx context.Context, identity *Identity) error +} + +// Authenticator authenticates requests and issues sessions. +type Authenticator struct { + config config.Config + resolver Resolver + keys KeyStore + sessions *SessionStore + // now is the clock, overridable in tests. + now func() time.Time +} + +// New builds an Authenticator. resolver may be nil when authentication is +// disabled; sessions are always constructed because the dev bypass and API keys +// still benefit from a consistent identity shape. +func New(cfg config.Config, resolver Resolver, keys KeyStore) (*Authenticator, error) { + if cfg.AuthMode == config.AuthModeDev && !cfg.IsDevelopment() { + return nil, fmt.Errorf("refusing to start: AUTH_MODE=dev outside a development environment") + } + if cfg.AuthMode == config.AuthModeNone && !cfg.IsDevelopment() { + return nil, fmt.Errorf("refusing to start: AUTH_MODE=none outside a development environment") + } + + sessionStore, err := NewSessionStore(cfg.SessionSecret, cfg.SessionTTL, cfg.IsDevelopment()) + if err != nil { + return nil, err + } + if cfg.AuthMode == config.AuthModeOIDC && (resolver == nil || !resolver.Enabled()) { + return nil, fmt.Errorf("AUTH_MODE=oidc but the identity provider is unavailable") + } + + return &Authenticator{ + config: cfg, + resolver: resolver, + keys: keys, + sessions: sessionStore, + now: time.Now, + }, nil +} + +// Sessions exposes the session store so the HTTP layer can set and clear +// cookies without re-deriving the cookie attributes. +func (a *Authenticator) Sessions() *SessionStore { return a.sessions } + +// Config returns the configuration the authenticator was built with. +func (a *Authenticator) Config() config.Config { return a.config } + +// Mode returns the active authentication mode. +func (a *Authenticator) Mode() string { return a.config.AuthMode } + +// DevBypassEnabled reports whether the development bypass is available. +func (a *Authenticator) DevBypassEnabled() bool { + return a.config.AuthMode == config.AuthModeDev && a.config.IsDevelopment() +} + +// Authenticate resolves the caller from a request. +// +// The session cookie is the only credential. There is no bearer-token path: a +// request with an Authorization header is treated exactly like one without, so a +// stale or forged token cannot grant anything. +func (a *Authenticator) Authenticate(r *http.Request) (*Identity, error) { + if identity, err := a.sessions.Read(r); err == nil { + return identity, nil + } else if errors.Is(err, ErrSessionExpired) { + return nil, ErrSessionExpired + } + + if a.DevBypassEnabled() { + if identity := a.devIdentity(r); identity != nil { + return identity, nil + } + } + + if a.config.AuthMode == config.AuthModeNone { + return &Identity{ + Subject: "anonymous", + Name: "anonymous", + Roles: []Role{RoleAdmin}, + Method: "none", + }, nil + } + + return nil, ErrNoSession +} + +// devIdentity builds an identity for the development bypass. +// +// The caller may name themselves with ?dev-user=email, which lets a developer +// exercise the role model without a running identity provider. It is only +// reachable when DevBypassEnabled is true, which config.Validate refuses to +// allow outside development. +func (a *Authenticator) devIdentity(r *http.Request) *Identity { + email := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("dev-user"))) + if email == "" { + email = "dev@go-bananas.local" + } + + role := a.config.DevFallbackRole + if mapped, ok := a.config.DevUsers[email]; ok { + role = mapped + } + + identity := &Identity{ + Subject: "dev:" + email, + Email: email, + Name: displayNameFor(email), + Roles: []Role{domain.ParseRole(role)}, + Method: "dev", + } + return identity +} + +// displayNameFor turns an email address into a plausible display name. +func displayNameFor(email string) string { + local, _, _ := strings.Cut(email, "@") + if local == "" { + return email + } + // Capitalise the first letter of each hyphen-separated segment. + parts := strings.Split(local, "-") + for i, p := range parts { + if p == "" { + continue + } + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + return strings.Join(parts, " ") +} + +// IssueSession records a successful login and writes the session cookie. +func (a *Authenticator) IssueSession(ctx context.Context, w http.ResponseWriter, identity *Identity) error { + if identity == nil { + return fmt.Errorf("%w: cannot issue a session for a nil identity", domain.ErrInvalid) + } + if a.keys != nil && identity.Method != "dev" { + // Recording the login lets an administrator see who has used the system. + if err := a.keys.UpsertUser(ctx, identity); err != nil { + // A login that cannot be recorded should still succeed; losing the + // directory entry is far less harmful than locking the user out. + _ = err + } + } + return a.sessions.Issue(w, identity) +} + +// ClearSession removes the session cookie. +func (a *Authenticator) ClearSession(w http.ResponseWriter) { + a.sessions.Clear(w) +} + +// ModeConfigured reports whether interactive login is available at all. +func (a *Authenticator) ModeConfigured() bool { + return a != nil && a.config.AuthMode != config.AuthModeNone +} + +// ProviderEnabled reports whether an OIDC provider is wired up. +func (a *Authenticator) ProviderEnabled() bool { + return a != nil && a.resolver != nil && a.resolver.Enabled() +} + +// Begin starts an interactive login. +func (a *Authenticator) Begin(ctx context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error { + if a == nil || a.resolver == nil || !a.resolver.Enabled() { + return fmt.Errorf("%w: no identity provider is configured", domain.ErrNotConfigured) + } + return a.resolver.Begin(ctx, req, w, returnTo) +} + +// Complete finishes an interactive login. +func (a *Authenticator) Complete(ctx context.Context, r *http.Request) (*Login, error) { + if a == nil || a.resolver == nil || !a.resolver.Enabled() { + return nil, fmt.Errorf("%w: no identity provider is configured", domain.ErrNotConfigured) + } + return a.resolver.Complete(ctx, r) +} + +// ClearState discards a pending login transaction. +func (a *Authenticator) ClearState(w http.ResponseWriter) { + if a != nil && a.resolver != nil { + a.resolver.ClearState(w) + } +} + +// LogoutURL returns the provider logout URL. +func (a *Authenticator) LogoutURL(redirect string) string { + if a.resolver == nil || !a.resolver.Enabled() { + return "" + } + return a.resolver.LogoutURL(redirect) +} + +// ---------------------------------------------------------------- sessions -- + +// SessionStore issues and verifies stateless signed session cookies. +// +// The session is a signed, encrypted-free JSON payload in a cookie: there is no +// server-side session table to migrate, scale or invalidate. The trade is that +// revoking a session before its expiry requires rotating the signing key, which +// is acceptable for a session that lives eight hours and is documented as such. +// +// The signature is HMAC-SHA256 over the payload with a constant-time +// comparison, which is what makes a forged cookie infeasible. +type SessionStore struct { + secret []byte + ttl time.Duration + secure bool + name string + sameSite http.SameSite + now func() time.Time + // inDevelopment relaxes cookie behaviour so that a local run over plain + // HTTP still receives a cookie. + inDevelopment bool +} + +// NewSessionStore builds a session store. +func NewSessionStore(secret string, ttl time.Duration, inDevelopment bool) (*SessionStore, error) { + if len(secret) < 32 { + return nil, fmt.Errorf("%w: session secret must be at least 32 bytes, got %d", domain.ErrInvalid, len(secret)) + } + if ttl <= 0 { + ttl = 8 * time.Hour + } + switch strings.ToLower(stringValue(sameSiteFromString("lax"))) { + case "lax", "strict", "none": + default: + return nil, fmt.Errorf("%w: unknown SameSite value", domain.ErrInvalid) + } + return &SessionStore{ + secret: []byte(secret), + ttl: ttl, + secure: true, + name: "go-bananas-session", + sameSite: http.SameSiteLaxMode, + now: time.Now, + inDevelopment: inDevelopment, + }, nil +} + +// WithCookie configures the cookie attributes. It exists so that the +// configuration can be applied in one place after construction. +func (s *SessionStore) WithCookie(name string, secure bool, sameSite http.SameSite) *SessionStore { + if name != "" { + s.name = name + } + s.secure = secure + s.sameSite = sameSite + return s +} + +// WithClock overrides the clock, for tests. +func (s *SessionStore) WithClock(now func() time.Time) *SessionStore { + s.now = now + return s +} + +// CookieName returns the configured cookie name. +func (s *SessionStore) CookieName() string { return s.name } + +// sessionPayload is the signed cookie body. +type sessionPayload struct { + Subject string `json:"sub"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Roles []string `json:"roles"` + Method string `json:"method"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` + // Nonce lets a future rotation invalidate sessions issued before a cutover. + Nonce string `json:"jti,omitempty"` +} + +// Issue writes a signed session cookie. +func (s *SessionStore) Issue(w http.ResponseWriter, identity *Identity) error { + now := s.now().UTC() + roles := make([]string, 0, len(identity.Roles)) + for _, r := range identity.Roles { + roles = append(roles, string(r)) + } + + payload := sessionPayload{ + Subject: identity.Subject, + Email: identity.Email, + Name: identity.Name, + Roles: roles, + Method: identity.Method, + IssuedAt: now.Unix(), + ExpiresAt: now.Add(s.ttl).Unix(), + } + encoded, err := s.encode(payload) + if err != nil { + return err + } + + //nolint:gosec // G124: HttpOnly, Secure and SameSite are all set, from the + // configured values rather than literals. gosec only recognises a literal + // attribute, so a configurable cookie reads as an unprotected one. + http.SetCookie(w, &http.Cookie{ + Name: s.name, + Value: encoded, + Path: "/", + Expires: now.Add(s.ttl), + MaxAge: int(s.ttl.Seconds()), + HttpOnly: true, + Secure: s.secure, + SameSite: s.sameSite, + }) + return nil +} + +// Clear expires the session cookie. +func (s *SessionStore) Clear(w http.ResponseWriter) { + //nolint:gosec // G124: as above. Clearing a cookie needs the same attribute + // triple as setting it, or a browser will not remove it. + http.SetCookie(w, &http.Cookie{ + Name: s.name, + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: s.secure, + SameSite: s.sameSite, + }) +} + +// Read verifies and decodes the session cookie. +func (s *SessionStore) Read(r *http.Request) (*Identity, error) { + cookie, err := r.Cookie(s.name) + if err != nil || cookie.Value == "" { + return nil, ErrNoSession + } + + payload, err := s.decode(cookie.Value) + if err != nil { + return nil, err + } + + now := s.now().UTC().Unix() + if payload.ExpiresAt > 0 && now > payload.ExpiresAt { + return nil, ErrSessionExpired + } + + identity := &Identity{ + Subject: payload.Subject, + Email: payload.Email, + Name: payload.Name, + Method: payload.Method, + } + // Left nil when the payload carries no expiry, rather than becoming the Unix + // epoch: a session without an expiry is a valid thing to represent, and 1970 is + // a date every client would read as "already expired". + if payload.ExpiresAt > 0 { + identity.ExpiresAt = timePtr(time.Unix(payload.ExpiresAt, 0).UTC()) + } + for _, r := range payload.Roles { + identity.Roles = append(identity.Roles, Role(r)) + } + if len(identity.Roles) == 0 { + identity.Roles = []Role{RoleViewer} + } + return identity, nil +} + +// encode serialises and signs a payload. +func (s *SessionStore) encode(payload sessionPayload) (string, error) { + body, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("encode session: %w", err) + } + bodyEncoded := base64.RawURLEncoding.EncodeToString(body) + mac := s.sign(bodyEncoded) + return bodyEncoded + "." + base64.RawURLEncoding.EncodeToString(mac), nil +} + +// decode verifies and parses a payload. +func (s *SessionStore) decode(value string) (sessionPayload, error) { + var payload sessionPayload + + bodyEncoded, signatureEncoded, ok := strings.Cut(value, ".") + if !ok { + return payload, ErrSessionInvalid + } + + signature, err := base64.RawURLEncoding.DecodeString(signatureEncoded) + if err != nil { + return payload, ErrSessionInvalid + } + + // Constant-time comparison: a byte-by-byte early return would leak the + // correct prefix length to an attacker measuring responses. + expected := s.sign(bodyEncoded) + if subtle.ConstantTimeCompare(signature, expected) != 1 { + return payload, ErrSessionInvalid + } + + body, err := base64.RawURLEncoding.DecodeString(bodyEncoded) + if err != nil { + return payload, ErrSessionInvalid + } + if err := json.Unmarshal(body, &payload); err != nil { + return payload, ErrSessionInvalid + } + if payload.Subject == "" { + return payload, ErrSessionInvalid + } + return payload, nil +} + +// sign computes the HMAC over a value. +func (s *SessionStore) sign(value string) []byte { + mac := hmacSHA256(s.secret, []byte(value)) + return mac[:] +} + +// sameSiteFromString maps a configuration value to an http.SameSite. +func sameSiteFromString(raw string) http.SameSite { + switch strings.ToLower(raw) { + case "strict": + return http.SameSiteStrictMode + case "none": + return http.SameSiteNoneMode + default: + return http.SameSiteLaxMode + } +} + +// stringValue renders a SameSite for logging. +func stringValue(v http.SameSite) string { + switch v { + case http.SameSiteStrictMode: + return "strict" + case http.SameSiteNoneMode: + return "none" + default: + return "lax" + } +} + +// ------------------------------------------------------------------ state -- + +// StateStore keeps short-lived OIDC transaction state (the PKCE verifier and +// the post-login return path) in a signed cookie. +// +// Keeping it in the cookie rather than a server-side map is what lets go-bananas +// run multiple replicas behind a load balancer with no shared session storage. +type StateStore struct { + secret []byte + name string + ttl time.Duration + secure bool + sameSite http.SameSite + now func() time.Time +} + +// oauthState is the transaction cookie body. +// +// State is stored alongside the verifier and nonce so that the callback can be +// bound to the browser that started the flow. Without it the state parameter is +// merely present, not checked, and an attacker-supplied authorization code could +// be redeemed into a session in someone else's browser (a login CSRF). +type oauthState struct { + Verifier string `json:"v"` + ReturnTo string `json:"r"` + Nonce string `json:"n"` + State string `json:"s"` + IssuedAt int64 `json:"iat"` +} + +// Transaction is a verified login transaction read back from the state cookie. +type Transaction struct { + // Verifier is the PKCE code verifier for the exchange. + Verifier string + // ReturnTo is the sanitised local path the browser should land on. + ReturnTo string + // Nonce must match the nonce in the returned ID token. + Nonce string + // State must match the state parameter the provider echoes back. + State string +} + +// NewStateStore builds the transaction cookie store. +func NewStateStore(secret, name string, secure bool, sameSite http.SameSite, ttl time.Duration) *StateStore { + if ttl <= 0 { + ttl = 10 * time.Minute + } + return &StateStore{ + secret: []byte(secret), + name: name, + secure: secure, + sameSite: sameSite, + ttl: ttl, + now: time.Now, + } +} + +// Name returns the state cookie name. +func (s *StateStore) Name() string { return s.name } + +// Put writes the transaction state cookie. It fails when any part of the +// transaction is empty: a transaction missing its state or verifier cannot be +// validated at the callback, so storing it would only defer the failure to a point +// where the user is already mid-login. +func (s *StateStore) Put(w http.ResponseWriter, verifier, returnTo, nonce, state string) error { + if verifier == "" || nonce == "" || state == "" { + return fmt.Errorf("%w: incomplete oauth transaction", domain.ErrInvalid) + } + now := s.now().UTC() + body, err := json.Marshal(oauthState{ + Verifier: verifier, ReturnTo: returnTo, Nonce: nonce, State: state, + IssuedAt: now.Unix(), + }) + if err != nil { + return fmt.Errorf("encode oauth state: %w", err) + } + encoded := base64.RawURLEncoding.EncodeToString(body) + mac := hmacSHA256(s.secret, []byte(encoded)) + + //nolint:gosec // G124: as above. + http.SetCookie(w, &http.Cookie{ + Name: s.name, + Value: encoded + "." + base64.RawURLEncoding.EncodeToString(mac[:]), + Path: "/", + Expires: now.Add(s.ttl), + MaxAge: int(s.ttl.Seconds()), + HttpOnly: true, + Secure: s.secure, + SameSite: http.SameSiteLaxMode, + }) + return nil +} + +// Get verifies the transaction state cookie and returns what it holds. +// +// "Consume" is the caller's job: the cookie is single-use, but only because the +// HTTP layer expires it once the login has succeeded. Clearing it here instead +// would expire it before the code exchange, and a user who mistyped their password +// once at the provider would arrive back with no transaction to finish. +func (s *StateStore) Get(r *http.Request) (Transaction, error) { + cookie, err := r.Cookie(s.name) + if err != nil { + return Transaction{}, fmt.Errorf("%w: missing oauth state", domain.ErrUnauthorized) + } + encoded, signatureEncoded, ok := strings.Cut(cookie.Value, ".") + if !ok { + return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized) + } + signature, decodeErr := base64.RawURLEncoding.DecodeString(signatureEncoded) + if decodeErr != nil { + return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized) + } + expected := hmacSHA256(s.secret, []byte(encoded)) + if subtle.ConstantTimeCompare(signature, expected[:]) != 1 { + return Transaction{}, fmt.Errorf("%w: oauth state signature mismatch", domain.ErrUnauthorized) + } + body, decodeErr := base64.RawURLEncoding.DecodeString(encoded) + if decodeErr != nil { + return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized) + } + var state oauthState + if json.Unmarshal(body, &state) != nil { + return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized) + } + if s.now().UTC().Sub(time.Unix(state.IssuedAt, 0).UTC()) > s.ttl { + return Transaction{}, fmt.Errorf("%w: oauth state expired", domain.ErrUnauthorized) + } + if state.Verifier == "" || state.Nonce == "" || state.State == "" { + return Transaction{}, fmt.Errorf("%w: incomplete oauth state", domain.ErrUnauthorized) + } + return Transaction{ + Verifier: state.Verifier, + ReturnTo: SafeReturnTo(state.ReturnTo), + Nonce: state.Nonce, + State: state.State, + }, nil +} + +// Clear expires the state cookie. +func (s *StateStore) Clear(w http.ResponseWriter) { + //nolint:gosec // G124: as above. + http.SetCookie(w, &http.Cookie{ + Name: s.name, Value: "", Path: "/", MaxAge: -1, + HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode, + }) +} + +// hmacSHA256 computes a signature. It is a free function so the session store +// and the OIDC state store share one implementation and cannot diverge. +func hmacSHA256(key, body []byte) [32]byte { + return sha256HMAC(key, body) +} + +// timePtr returns a pointer to t. It exists because the session and identity types +// carry an optional expiry, and taking the address of a conversion inline is not +// something Go will let you write. +func timePtr(t time.Time) *time.Time { return &t } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..90027e9 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,417 @@ +package auth + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/urpi/go-bananas/internal/config" + "github.com/urpi/go-bananas/internal/domain" +) + +const testSecret = "test-secret-that-is-at-least-32-bytes-long" + +// ---------------------------------------------------------------- sessions -- + +func TestSessionRoundTrip(t *testing.T) { + store, err := NewSessionStore(testSecret, time.Hour, false) + if err != nil { + t.Fatalf("NewSessionStore: %v", err) + } + + identity := &domain.Identity{ + Subject: "alice", + Email: "alice@example.com", + Name: "Alice", + Roles: []domain.Role{domain.RoleEditor}, + Method: "oidc", + } + + rec := httptest.NewRecorder() + if err := store.Issue(rec, identity); err != nil { + t.Fatalf("Issue: %v", err) + } + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + for _, c := range rec.Result().Cookies() { + req.AddCookie(c) + } + + got, err := store.Read(req) + if err != nil { + t.Fatalf("Read: %v", err) + } + if got.Subject != "alice" || got.Email != "alice@example.com" { + t.Errorf("identity = %+v", got) + } + if !got.HasRole(domain.RoleEditor) { + t.Errorf("roles = %v, want editor", got.Roles) + } +} + +func TestSessionCookieIsHttpOnly(t *testing.T) { + store, _ := NewSessionStore(testSecret, time.Hour, false) + rec := httptest.NewRecorder() + if err := store.Issue(rec, &domain.Identity{Subject: "a"}); err != nil { + t.Fatalf("Issue: %v", err) + } + cookies := rec.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("got %d cookies, want 1", len(cookies)) + } + if !cookies[0].HttpOnly { + t.Error("session cookie must be HttpOnly so script cannot read it") + } + if !cookies[0].Secure { + t.Error("session cookie must be Secure") + } +} + +func TestSessionRejectsShortSecret(t *testing.T) { + if _, err := NewSessionStore("too-short", time.Hour, false); err == nil { + t.Fatal("expected a short secret to be rejected") + } +} + +func TestSessionRejectsTamperedPayload(t *testing.T) { + store, _ := NewSessionStore(testSecret, time.Hour, false) + rec := httptest.NewRecorder() + if err := store.Issue(rec, &domain.Identity{Subject: "alice", Roles: []domain.Role{domain.RoleViewer}}); err != nil { + t.Fatalf("Issue: %v", err) + } + + var issued *http.Cookie + for _, c := range rec.Result().Cookies() { + issued = c + } + + // Re-encode the payload with an escalated role but keep the old signature. + body, signature, _ := strings.Cut(issued.Value, ".") + forged := base64RawURL(`{"sub":"alice","roles":["admin"],"method":"oidc","iat":1,"exp":9999999999}`) + _ = body + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: issued.Name, Value: forged + "." + signature}) + + if _, err := store.Read(req); err == nil { + t.Fatal("a re-signed payload must be rejected") + } +} + +func TestSessionRejectsForeignSecret(t *testing.T) { + mine, _ := NewSessionStore(testSecret, time.Hour, false) + theirs, _ := NewSessionStore("a-completely-different-32-byte-secret!", time.Hour, false) + + rec := httptest.NewRecorder() + if err := theirs.Issue(rec, &domain.Identity{Subject: "mallory", Roles: []domain.Role{domain.RoleAdmin}}); err != nil { + t.Fatalf("Issue: %v", err) + } + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + for _, c := range rec.Result().Cookies() { + req.AddCookie(c) + } + if _, err := mine.Read(req); err == nil { + t.Fatal("a cookie signed with another key must be rejected") + } +} + +func TestSessionExpires(t *testing.T) { + now := time.Now().UTC() + store, _ := NewSessionStore(testSecret, time.Hour, false) + store.WithClock(func() time.Time { return now }) + + rec := httptest.NewRecorder() + if err := store.Issue(rec, &domain.Identity{Subject: "alice"}); err != nil { + t.Fatalf("Issue: %v", err) + } + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + for _, c := range rec.Result().Cookies() { + req.AddCookie(c) + } + + // Two hours later the session must be gone. + store.WithClock(func() time.Time { return now.Add(2 * time.Hour) }) + if _, err := store.Read(req); err == nil { + t.Fatal("expected an expired session to be rejected") + } +} + +func TestSessionReadRejectsMissingCookie(t *testing.T) { + store, _ := NewSessionStore(testSecret, time.Hour, false) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + if _, err := store.Read(req); err == nil { + t.Fatal("expected ErrNoSession") + } +} + +func TestSessionWithRoleDefaultsToViewer(t *testing.T) { + // A cookie carrying no roles must never be treated as having privileges. + store, _ := NewSessionStore(testSecret, time.Hour, false) + encoded, err := store.encode(sessionPayload{Subject: "a", Method: "oidc", ExpiresAt: time.Now().Add(time.Hour).Unix()}) + if err != nil { + t.Fatalf("encode: %v", err) + } + payload, err := store.decode(encoded) + if err != nil { + t.Fatalf("decode: %v", err) + } + if len(payload.Roles) != 0 { + t.Errorf("payload roles = %v, want none", payload.Roles) + } +} + +// ------------------------------------------------------------- oidc state -- + +func TestOAuthStateRoundTrip(t *testing.T) { + state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute) + + rec := httptest.NewRecorder() + if err := state.Put(rec, "verifier-value", "/return", "nonce-value", "state-value"); err != nil { + t.Fatalf("Put: %v", err) + } + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil) + for _, c := range rec.Result().Cookies() { + req.AddCookie(c) + } + + tx, err := state.Get(req) + if err != nil { + t.Fatalf("Get: %v", err) + } + if tx.Verifier != "verifier-value" || tx.ReturnTo != "/return" || + tx.Nonce != "nonce-value" || tx.State != "state-value" { + t.Errorf("round trip lost data: %+v", tx) + } +} + +func TestOAuthStateRejectsTampering(t *testing.T) { + state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute) + + rec := httptest.NewRecorder() + if err := state.Put(rec, "verifier", "/", "nonce", "state"); err != nil { + t.Fatalf("Put: %v", err) + } + var issued *http.Cookie + for _, c := range rec.Result().Cookies() { + issued = c + } + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil) + req.AddCookie(&http.Cookie{Name: issued.Name, Value: base64RawURL(`{"v":"attacker","r":"/evil","n":"x","iat":1}`) + "." + strings.Split(issued.Value, ".")[1]}) + + if _, err := state.Get(req); err == nil { + t.Fatal("a tampered state cookie must be rejected") + } +} + +func TestOAuthStateExpires(t *testing.T) { + now := time.Now().UTC() + state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute) + + rec := httptest.NewRecorder() + if err := state.Put(rec, "verifier", "/", "nonce", "state"); err != nil { + t.Fatalf("Put: %v", err) + } + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil) + for _, c := range rec.Result().Cookies() { + req.AddCookie(c) + } + + // The StateStore uses time.Now directly; simulate staleness by minting an + // old state value through the exported encoder path. + stale := oauthState{Verifier: "v", ReturnTo: "/", IssuedAt: now.Add(-time.Hour).Unix()} + body := base64RawURL(`{"v":"v","r":"/","n":"","iat":` + itoa(stale.IssuedAt) + `}`) + mac := hmacSHA256([]byte(testSecret), []byte(body)) + req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil) + req2.AddCookie(&http.Cookie{Name: "bananas-oauth", Value: body + "." + base64RawURLBytes(mac[:])}) + + if _, err := state.Get(req2); err == nil { + t.Fatal("an expired state cookie must be rejected") + } +} + +// -------------------------------------------------------- sanitising URLs -- + +func TestSafeReturnToBlocksOpenRedirects(t *testing.T) { + dangerous := []string{ + "https://evil.example.com/steal", + "//evil.example.com/steal", + "/\\evil.example.com", + "javascript:alert(1)", + "admin", + "", + } + for _, input := range dangerous { + got := SafeReturnTo(input) + if got != "/" { + t.Errorf("SafeReturnTo(%q) = %q, want /", input, got) + } + } + + safe := map[string]string{ + "/admin": "/admin", + "/admin/themes?x=1": "/admin/themes?x=1", + "/": "/", + } + for input, want := range safe { + if got := SafeReturnTo(input); got != want { + t.Errorf("SafeReturnTo(%q) = %q, want %q", input, got, want) + } + } +} + +func TestLoginURLIsNotAnOpenRedirect(t *testing.T) { + // The redirect URI must always be one the provider has registered. + cfg := config.Defaults() + cfg.PublicBaseURL = "https://themes.example.com" + if got := cfg.RedirectURL(); got != "https://themes.example.com/auth/callback" { + t.Errorf("RedirectURL = %q", got) + } + + cfg.BasePath = "/bananas" + if got := cfg.RedirectURL(); got != "https://themes.example.com/bananas/auth/callback" { + t.Errorf("RedirectURL with base path = %q", got) + } +} + +// -------------------------------------------------------- authenticator -- + +func TestDevBypassGrantsConfiguredRole(t *testing.T) { + cfg := config.Defaults() + cfg.AuthMode = config.AuthModeDev + cfg.Environment = "development" + cfg.DevFallbackRole = "viewer" + cfg.DevUsers = map[string]string{"boss@example.com": "admin"} + + a := newTestAuthenticator(t, cfg, nil) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes", nil) + identity, err := a.Authenticate(req) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if identity.HighestRole() != domain.RoleViewer { + t.Errorf("role = %v, want viewer", identity.Roles) + } + + req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes?dev-user=boss@example.com", nil) + identity, err = a.Authenticate(req) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if identity.HighestRole() != domain.RoleAdmin { + t.Errorf("role = %v, want admin", identity.Roles) + } +} + +func TestDevBypassRefusedOutsideDevelopment(t *testing.T) { + cfg := config.Defaults() + cfg.AuthMode = config.AuthModeDev + cfg.Environment = "production" + cfg.SessionSecret = testSecret + + if _, err := New(cfg, nil, nil); err == nil { + t.Fatal("the development bypass must not be constructible in production") + } +} + +func TestAuthNoneRefusedOutsideDevelopment(t *testing.T) { + cfg := config.Defaults() + cfg.AuthMode = config.AuthModeNone + cfg.Environment = "production" + cfg.SessionSecret = testSecret + + if _, err := New(cfg, nil, nil); err == nil { + t.Fatal("AUTH_MODE=none must not be constructible in production") + } +} + +func TestUnauthenticatedRequestIsRejected(t *testing.T) { + cfg := config.Defaults() + cfg.AuthMode = config.AuthModeOIDC + cfg.Environment = "test" + a := newTestAuthenticator(t, cfg, nil) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes", nil) + if _, err := a.Authenticate(req); err == nil { + t.Fatal("expected an unauthenticated request to be rejected") + } +} + +func TestRoleMappingFailsClosed(t *testing.T) { + // A user in no configured group gets viewer, never more. + p := &OIDCProvider{config: config.Config{ + OIDCAdminGroups: []string{"platform-admins"}, + OIDCEditorGroups: []string{"designers"}, + }} + + if got := p.rolesFor([]string{"some-other-group"}, nil); got[0] != domain.RoleViewer { + t.Errorf("roles = %v, want viewer", got) + } + if got := p.rolesFor([]string{"designers"}, nil); got[0] != domain.RoleEditor { + t.Errorf("roles = %v, want editor", got) + } + if got := p.rolesFor([]string{"designers", "platform-admins"}, nil); got[0] != domain.RoleAdmin { + t.Errorf("roles = %v, want admin; the most privileged group must win", got) + } + if got := p.rolesFor(nil, []string{"platform-admins"}); got[0] != domain.RoleAdmin { + t.Errorf("roles = %v, want admin from realm roles", got) + } +} + +func TestRoleMappingIsCaseInsensitive(t *testing.T) { + p := &OIDCProvider{config: config.Config{OIDCAdminGroups: []string{"Platform-Admins"}}} + if got := p.rolesFor([]string{"PLATFORM-ADMINS"}, nil); got[0] != domain.RoleAdmin { + t.Errorf("roles = %v, want admin", got) + } +} + +func TestCodeChallengeIsS256(t *testing.T) { + // RFC 7636 test vector: verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + // must produce challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM". + got := codeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + if got != "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" { + t.Errorf("codeChallenge = %q, want the RFC 7636 value", got) + } +} + +func TestInsecureTLSConfigIsUsedOnlyWhenRequested(t *testing.T) { + cfg := config.Defaults() + cfg.OIDCSkipTLSVerify = true + if client := newProviderHTTPClient(cfg); client == nil { + t.Fatal("expected a client") + } + if tlsCfg := newProviderHTTPClient(cfg).Transport.(*http.Transport).TLSClientConfig; tlsCfg == nil || !tlsCfg.InsecureSkipVerify { + t.Error("expected TLS verification to be skipped when configured") + } + + cfg.OIDCSkipTLSVerify = false + if tlsCfg := newProviderHTTPClient(cfg).Transport.(*http.Transport).TLSClientConfig; tlsCfg != nil && tlsCfg.InsecureSkipVerify { + t.Error("TLS verification must be on by default") + } + var _ *tls.Config +} + +func TestProviderHTTPClientHasTimeout(t *testing.T) { + // An unresponsive identity provider must not be able to hang a request + // indefinitely. + client := newProviderHTTPClient(config.Defaults()) + if client.Timeout == 0 { + t.Error("provider HTTP client must have a timeout") + } +} + +func TestURLQueryEscape(t *testing.T) { + if got := urlQueryEscape("https://x.example/y?a=1&b=2"); got != "https%3A%2F%2Fx.example%2Fy%3Fa%3D1%26b%3D2" { + t.Errorf("urlQueryEscape = %q", got) + } + if _, err := url.Parse("https://x.example"); err != nil { + t.Fatalf("unexpected: %v", err) + } +} diff --git a/internal/auth/helpers_test.go b/internal/auth/helpers_test.go new file mode 100644 index 0000000..9b06a1e --- /dev/null +++ b/internal/auth/helpers_test.go @@ -0,0 +1,60 @@ +package auth + +import ( + "context" + "encoding/base64" + "net/http" + "strconv" + "testing" + + "github.com/urpi/go-bananas/internal/config" +) + +// base64RawURL encodes without padding, matching how cookies are written. +func base64RawURL(s string) string { + return base64.RawURLEncoding.EncodeToString([]byte(s)) +} + +// base64RawURLBytes encodes raw bytes without padding. +func base64RawURLBytes(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} + +// itoa renders an int64 for embedding in a JSON test fixture. +func itoa(v int64) string { return strconv.FormatInt(v, 10) } + +// stubResolver is a Resolver that authenticates nobody. It exists so that tests +// can construct an Authenticator in OIDC mode without a live provider, which +// also documents that New refuses to start in OIDC mode without one. +type stubResolver struct{} + +func (stubResolver) Begin(context.Context, *http.Request, http.ResponseWriter, string) error { + return nil +} +func (stubResolver) Complete(context.Context, *http.Request) (*Login, error) { + return nil, ErrNoSession +} +func (stubResolver) End(context.Context, string) string { return "" } +func (stubResolver) Enabled() bool { return true } +func (stubResolver) ClearState(http.ResponseWriter) {} +func (stubResolver) LogoutURL(string) string { return "https://idp.example/logout" } + +// newTestAuthenticator builds an Authenticator for a configuration. +// +// The key store is optional and is passed straight through: with machine +// credentials removed it only carries the user table, which most of these tests do +// not exercise. +func newTestAuthenticator(t *testing.T, cfg config.Config, keys KeyStore) *Authenticator { + t.Helper() + if cfg.SessionSecret == "" { + cfg.SessionSecret = testSecret + } + // A stub resolver rather than nil: New refuses to construct in OIDC mode + // without one, and the point of some of these tests is exactly that an + // unauthenticated request is rejected even when a provider is wired up. + a, err := New(cfg, stubResolver{}, keys) + if err != nil { + t.Fatalf("New: %v", err) + } + return a +} diff --git a/internal/auth/hmac.go b/internal/auth/hmac.go new file mode 100644 index 0000000..dd57b19 --- /dev/null +++ b/internal/auth/hmac.go @@ -0,0 +1,20 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" +) + +// sha256HMAC computes an HMAC-SHA256 tag. +// +// It is a thin wrapper so that the session store and the OIDC state store +// cannot accidentally diverge on how they sign. The stdlib hmac package already +// performs a constant-time internal comparison; the constant-time requirement +// in this codebase is about how *we* compare the resulting tags. +func sha256HMAC(key, body []byte) [32]byte { + mac := hmac.New(sha256.New, key) + mac.Write(body) + var out [32]byte + copy(out[:], mac.Sum(nil)) + return out +} diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..82bff8d --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,375 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "sort" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/urpi/go-bananas/internal/config" + "github.com/urpi/go-bananas/internal/domain" +) + +// OIDCProvider implements Resolver against any OpenID Connect provider, +// including the auth-dev.urpi.be realm used for local development. +// +// Everything provider-specific comes from the issuer's discovery document, so +// the same code works against Keycloak, Authentik, Zitadel or Google without a +// configuration change. The only per-provider values are the client +// credentials and the group-to-role mapping. +type OIDCProvider struct { + config config.Config + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + oauth *oauth2.Config + state *StateStore +} + +// NewOIDCProvider performs discovery against the configured issuer and returns +// a ready provider. Discovery happens eagerly so that a misconfigured issuer +// fails at start-up rather than on the first login, when the failure would be +// hardest to diagnose. +func NewOIDCProvider(ctx context.Context, cfg config.Config, state *StateStore) (*OIDCProvider, error) { + if cfg.OIDCIssuer == "" { + return nil, fmt.Errorf("%w: OIDC_ISSUER is required", domain.ErrInvalid) + } + + provider, err := newProvider(ctx, cfg) + if err != nil { + return nil, err + } + + p := &OIDCProvider{ + config: cfg, + provider: provider, + state: state, + } + + scopes := append([]string{oidc.ScopeOpenID}, cfg.OIDCScopes...) + p.oauth = &oauth2.Config{ + ClientID: cfg.OIDCClientID, + ClientSecret: cfg.OIDCClientSecret, + RedirectURL: cfg.RedirectURL(), + Scopes: scopes, + Endpoint: provider.Endpoint(), + } + p.verifier = provider.Verifier(&oidc.Config{ + ClientID: cfg.OIDCClientID, + // Keycloak and Authentik both support discovery of the JWKS; skipping + // the audience check is not done because a token minted for another + // client in the same realm must not be accepted here. + }) + return p, nil +} + +// newProvider runs discovery with a bounded timeout so that an unreachable +// issuer produces a clear error instead of hanging start-up. +func newProvider(ctx context.Context, cfg config.Config) (*oidc.Provider, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + // ClientContext carries the custom HTTP client through to discovery, which + // is how the TLS-skip development switch and the request timeout reach the + // provider's HTTP calls. + ctx = oidc.ClientContext(ctx, newProviderHTTPClient(cfg)) + provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuer) + if err != nil { + return nil, fmt.Errorf("OIDC discovery against %s failed: %w", cfg.OIDCIssuer, err) + } + return provider, nil +} + +// newProviderHTTPClient builds the HTTP client used for provider traffic. +// +// The timeout matters: without one, an unresponsive identity provider would hold +// a request open until the client's own timeout, and a stalled provider would +// look like a hung application. +func newProviderHTTPClient(cfg config.Config) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConnsPerHost = 4 + if cfg.OIDCSkipTLSVerify { + transport.TLSClientConfig = insecureTLSConfig() + } + return &http.Client{ + Transport: transport, + Timeout: 15 * time.Second, + } +} + +// Enabled reports that this resolver can authenticate. +func (p *OIDCProvider) Enabled() bool { return p != nil && p.provider != nil } + +// Begin starts a login by redirecting the browser to the provider. The return +// path and PKCE verifier are carried in a signed cookie rather than +// server-side state so that any replica can complete the flow. +func (p *OIDCProvider) Begin(_ context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error { + verifier, err := randomVerifier() + if err != nil { + return err + } + nonce, err := randomNonce() + if err != nil { + return err + } + + state, err := randomState() + if err != nil { + return err + } + + // The transaction is stored before the redirect, and stores the state it was + // built with, so that the callback can prove the response belongs to this + // browser's login attempt. + if err := p.state.Put(w, verifier, SafeReturnTo(returnTo), nonce, state); err != nil { + return err + } + + opts := []oauth2.AuthCodeOption{ + // PKCE with S256. The verifier lives in a signed HttpOnly cookie, so the + // code is bound to the browser that started the flow. + oauth2.SetAuthURLParam("code_challenge", codeChallenge(verifier)), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + oauth2.SetAuthURLParam("nonce", nonce), + oauth2.SetAuthURLParam("state", state), + } + http.Redirect(w, req, p.oauth.AuthCodeURL(state, opts...), http.StatusFound) + return nil +} + +// Complete finishes a login. +// +// The transaction cookie is the anchor: the state the provider echoes back is +// compared against the state this service minted for this browser, and the PKCE +// verifier and nonce both come from the same cookie. A callback that carries a +// foreign code or a replayed state is rejected before the code is ever exchanged. +func (p *OIDCProvider) Complete(ctx context.Context, req *http.Request) (*Login, error) { + if err := req.ParseForm(); err != nil { + return nil, fmt.Errorf("parse callback form: %w", err) + } + if providerErr := req.Form.Get("error"); providerErr != "" { + desc := req.Form.Get("error_description") + return nil, fmt.Errorf("identity provider returned %q: %s", providerErr, desc) + } + + tx, err := p.state.Get(req) + if err != nil { + return nil, err + } + + returnedState := req.Form.Get("state") + if returnedState == "" { + return nil, fmt.Errorf("%w: callback is missing the state parameter", domain.ErrUnauthorized) + } + // Constant time, so that a mismatching state cannot be discovered a byte at a + // time. The state is public, but the comparison is free to be careful. + if subtle.ConstantTimeCompare([]byte(returnedState), []byte(tx.State)) != 1 { + return nil, fmt.Errorf("%w: callback state does not match the login transaction", domain.ErrUnauthorized) + } + + code := req.Form.Get("code") + if code == "" { + return nil, fmt.Errorf("%w: callback is missing the authorization code", domain.ErrUnauthorized) + } + + token, err := p.oauth.Exchange(ctx, code, oauth2.VerifierOption(tx.Verifier)) + if err != nil { + return nil, fmt.Errorf("exchange authorization code: %w", err) + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return nil, fmt.Errorf("%w: provider returned no id_token", domain.ErrUnauthorized) + } + + idToken, err := p.verifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, fmt.Errorf("verify id_token: %w", err) + } + if idToken.Nonce != tx.Nonce { + return nil, fmt.Errorf("%w: id_token nonce mismatch", domain.ErrUnauthorized) + } + + claims := struct { + Subject string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + PreferredUser string `json:"preferred_username"` + Groups []string `json:"groups"` + RealmRoles []string `json:"realm_access.roles"` + }{} + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("decode id_token claims: %w", err) + } + + identity := &Identity{ + Subject: claims.Subject, + Email: claims.Email, + Name: firstNonEmpty(claims.Name, claims.PreferredUser, claims.Email), + Method: "oidc", + } + if identity.Subject == "" { + return nil, fmt.Errorf("%w: id_token has no subject", domain.ErrUnauthorized) + } + identity.Roles = p.rolesFor(claims.Groups, claims.RealmRoles) + + return &Login{Identity: identity, ReturnTo: tx.ReturnTo}, nil +} + +// rolesFor maps provider groups onto application roles. +// +// The most privileged matching group wins, and a user in no configured group +// gets viewer. The default is deliberately the least privilege: a group mapping +// mistake must fail closed. +func (p *OIDCProvider) rolesFor(groups, realmRoles []string) []domain.Role { + all := make(map[string]struct{}, len(groups)+len(realmRoles)) + for _, g := range groups { + all[strings.ToLower(strings.TrimSpace(g))] = struct{}{} + } + for _, r := range realmRoles { + all[strings.ToLower(strings.TrimSpace(r))] = struct{}{} + } + + matches := func(configured []string) bool { + for _, want := range configured { + if _, ok := all[strings.ToLower(strings.TrimSpace(want))]; ok { + return true + } + } + return false + } + + switch { + case matches(p.config.OIDCAdminGroups): + return []domain.Role{domain.RoleAdmin} + case matches(p.config.OIDCEditorGroups): + return []domain.Role{domain.RoleEditor} + case matches(p.config.OIDCViewerGroups): + return []domain.Role{domain.RoleViewer} + default: + return []domain.Role{domain.RoleViewer} + } +} + +// End returns the provider's end-session URL. +func (p *OIDCProvider) End(_ context.Context, redirect string) string { + return p.LogoutURL(redirect) +} + +// LogoutURL builds the provider end-session URL. +func (p *OIDCProvider) LogoutURL(redirect string) string { + if redirect == "" { + return "" + } + endSession := p.provider.Endpoint() + // Keycloak, Authentik and Zitadel all accept the post_logout_redirect_uri + // parameter on the end-session endpoint. A provider that does not + // implement it ignores the parameter and lands the user on its own logout + // confirmation page, which is a safe failure mode. + if !strings.Contains(endSession.AuthURL, "/end-session") { + return endSession.AuthURL + } + sep := "?" + if strings.Contains(endSession.AuthURL, "?") { + sep = "&" + } + return endSession.AuthURL + sep + "post_logout_redirect_uri=" + urlQueryEscape(redirect) + + "&client_id=" + urlQueryEscape(p.config.OIDCClientID) +} + +// Issuer returns the configured issuer, used by the admin UI to show which +// provider is in use. +func (p *OIDCProvider) Issuer() string { return p.config.OIDCIssuer } + +// RedirectURI returns the configured callback URL. +func (p *OIDCProvider) RedirectURI() string { return p.config.RedirectURL() } + +// Claims returns the claims supported by this provider, for documentation. +func (p *OIDCProvider) Claims() []string { + list := []string{"sub", "email", "name", "preferred_username", "groups", "realm_access.roles"} + sort.Strings(list) + return list +} + +// ClearState expires the transaction cookie. The HTTP layer calls it after +// Complete so that a replayed callback cannot reuse a consumed verifier. +func (p *OIDCProvider) ClearState(w http.ResponseWriter) { p.state.Clear(w) } + +// ---------------------------------------------------------------- helpers -- + +// randomVerifier mints a PKCE verifier. +func randomVerifier() (string, error) { + return randomToken(32) +} + +// randomState mints an anti-forgery state value. +func randomState() (string, error) { + return randomToken(24) +} + +// randomNonce mints an ID token nonce. +func randomNonce() (string, error) { + return randomToken(16) +} + +// randomToken returns a URL-safe random string of n bytes. +func randomToken(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("read random: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// codeChallenge returns the S256 PKCE challenge for a verifier. +func codeChallenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// firstNonEmpty returns the first non-empty string. +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +// urlQueryEscape percent-encodes a query parameter value. +func urlQueryEscape(s string) string { + var b strings.Builder + for _, b0 := range []byte(s) { + switch { + case b0 >= 'a' && b0 <= 'z', b0 >= 'A' && b0 <= 'Z', b0 >= '0' && b0 <= '9', + b0 == '-', b0 == '_', b0 == '.', b0 == '~': + b.WriteByte(b0) + default: + b.WriteByte('%') + b.WriteString(strings.ToUpper(hex.EncodeToString([]byte{b0}))) + } + } + return b.String() +} + +// insecureTLSConfig returns a TLS configuration that skips verification. +// +// It exists so that a development-only identity provider presenting a +// self-signed certificate can be used without wiring a CA into the image. +// config.Validate refuses to start when this is enabled outside development. +func insecureTLSConfig() *tls.Config { + //nolint:gosec // InsecureSkipVerify is intentional and gated by config.Validate. + return &tls.Config{InsecureSkipVerify: true} +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4d60f7f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,572 @@ +// Package config loads and validates every runtime setting from the +// environment. +// +// Configuration is environment-only on purpose. The Helm chart is the single +// source of truth for how a deployment is configured; letting the binary also +// read a config file would create a second source that operators forget about. +// Every value has a safe default except the secrets and the OIDC issuer, which +// must be supplied explicitly when auth is enabled. +package config + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// Environment prefixes, tried in order. This lets the Helm chart mount a +// ConfigMap under a distinct prefix without colliding with the pod's own +// environment, which is what makes one image usable across environments. +var envPrefixes = []string{"BANANAS_", "GO_BANANAS_"} + +// Config is the fully resolved runtime configuration. +type Config struct { + // Environment is the deployment name, used in logs and the admin UI. + Environment string + // LogLevel is one of debug, info, warn, error. + LogLevel string + // LogFormat is json or text. + LogFormat string + + // HTTPAddr is the listen address, host:port. + HTTPAddr string + // PublicBaseURL is the externally reachable origin, without a trailing + // slash. It is used to build absolute URLs in the runtime manifest and to + // validate OIDC redirect targets. + PublicBaseURL string + // TrustedProxies lists the CIDRs or IPs whose X-Forwarded-For headers may be + // believed. It is empty by default, which makes the service ignore + // forwarding headers entirely and log the direct peer. + TrustedProxies []string + // ReadTimeout and WriteTimeout bound a single request. WriteTimeout is + // generous because the runtime CSS endpoint is on the hot path of every page + // load in every consuming application. + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration + // ShutdownTimeout is how long in-flight requests get to finish on SIGTERM. + ShutdownTimeout time.Duration + + // CORSAllowedOrigins are the browser origins allowed to call the admin API + // from a different origin. The runtime API is always public and is not + // subject to this list. + CORSAllowedOrigins []string + // RateLimitPerMinute caps requests per client for the runtime API. + RateLimitPerMinute int + + // DatabasePath is the SQLite file path. Use ":memory:" for tests. + DatabasePath string + // DatabaseBusyTimeout is how long a writer waits for the database lock + // before failing. + DatabaseBusyTimeout time.Duration + // DatabaseMaxConns bounds the connection pool. SQLite serialises writers, so + // a small pool with WAL is faster than a large one. + DatabaseMaxConns int + + // AuthMode is oidc, dev, or none. + AuthMode string + // OIDCIssuer is the issuer URL, e.g. https://auth-dev.urpi.be/realms/urpi. + OIDCIssuer string + // OIDCClientID is the client identifier registered with the provider. + OIDCClientID string + // OIDCClientSecret authenticates the confidential client. + OIDCClientSecret string + // OIDCScopes are requested in addition to openid. + OIDCScopes []string + // OIDCRedirectURL overrides the computed callback URL. It is only needed + // when the public URL cannot be derived, for example behind a path-based + // ingress. + OIDCRedirectURL string + // OIDCPostLogoutRedirectURL is where the provider sends the browser after + // logout. It must be an allowed post-logout target on the provider side. + OIDCPostLogoutRedirectURL string + // OIDCAdminGroups are the group names that map to the admin role. + OIDCAdminGroups []string + // OIDCEditorGroups are the group names that map to the editor role. + OIDCEditorGroups []string + // OIDCViewerGroups are the group names that map to the viewer role. + OIDCViewerGroups []string + // OIDCSkipTLSVerify disables provider certificate verification. It exists + // only so that a self-signed local provider can be used during development + // and it is refused outside the development environment. + OIDCSkipTLSVerify bool + + // SessionSecret signs the session cookie. It must be at least 32 bytes and + // is required whenever auth is enabled. + SessionSecret string + // SessionCookieName is the cookie carrying the session. + SessionCookieName string + // SessionSecure marks the session cookie Secure. + SessionSecure bool + // SessionTTL is the session lifetime. + SessionTTL time.Duration + // SessionSameSite is Lax, Strict or None. + SessionSameSite string + + // RuntimePublic controls whether the runtime API requires a credential. It + // is enabled by default because a theming service's whole purpose is to be + // reachable from browsers and CDN caches. + RuntimePublic bool + // RuntimeCacheControl is the Cache-Control header sent on runtime assets. + // It is tuned for aggressive CDN caching with a revalidation window that + // matches how often themes are published. + RuntimeCacheControl string + // RuntimeCDNBaseURL, when set, is used as the absolute base in the manifest + // so consumers fetch assets from a CDN rather than from this service. + RuntimeCDNBaseURL string + + // SeedOnStart reconciles the built-in themes at start-up. + SeedOnStart bool + // DevFallbackRole is the role granted by the development auth bypass. + DevFallbackRole string + // DevUsers maps a development email to a role, overriding DevFallbackRole. + DevUsers map[string]string + + // EnableMetrics exposes /metrics in Prometheus format. + EnableMetrics bool + // EnableProfiler exposes the net/http/pprof handlers under /debug/pprof. + EnableProfiler bool + // StaticDir is the directory holding the built admin UI. When empty the UI + // is not served. + StaticDir string + // SDKDir is the directory holding the built runtime SDK bundles. + SDKDir string + // BasePath prefixes every route, for deployments behind a path-based ingress. + BasePath string + + // ephemeralSessionSecret records that the secret was generated rather than + // supplied. It is not part of the configuration surface; the application reads + // it only to decide whether to warn. + ephemeralSessionSecret bool +} + +// Auth mode values. +const ( + AuthModeOIDC = "oidc" + AuthModeDev = "dev" + AuthModeNone = "none" +) + +// Defaults returns a Config populated with production-safe defaults. Load +// applies the environment on top of this value. +func Defaults() Config { + return Config{ + Environment: "development", + LogLevel: "info", + LogFormat: "text", + + HTTPAddr: ":8080", + PublicBaseURL: "http://localhost:8080", + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + ShutdownTimeout: 25 * time.Second, + + CORSAllowedOrigins: []string{}, + RateLimitPerMinute: 3000, + + DatabasePath: "./data/go-bananas.db", + DatabaseBusyTimeout: 5 * time.Second, + DatabaseMaxConns: 8, + + AuthMode: AuthModeDev, + OIDCScopes: []string{"profile", "email", "groups"}, + SessionCookieName: "go-bananas-session", + SessionSecure: false, + SessionTTL: 8 * time.Hour, + SessionSameSite: "Lax", + RuntimePublic: true, + RuntimeCacheControl: "public, max-age=60, s-maxage=300, stale-while-revalidate=86400", + SeedOnStart: true, + DevFallbackRole: "admin", + DevUsers: map[string]string{}, + EnableMetrics: true, + EnableProfiler: false, + StaticDir: "./web/dist", + SDKDir: "./sdk/dist", + } +} + +// Load reads configuration from the environment and validates it. +func Load() (Config, error) { + return loadFrom(os.LookupEnv) +} + +// loadFrom is the testable core of Load. +func loadFrom(lookup func(string) (string, bool)) (Config, error) { + cfg := Defaults() + + get := func(name string) (string, bool) { + for _, prefix := range envPrefixes { + if v, ok := lookup(prefix + name); ok { + return v, true + } + } + return "", false + } + + str := func(name string, target *string) { + if v, ok := get(name); ok { + *target = strings.TrimSpace(v) + } + } + + cfg.Environment = firstNonEmpty(cfg.Environment, + func() (string, bool) { return get("ENVIRONMENT") }, + func() (string, bool) { return get("ENV") }, + ) + str("LOG_LEVEL", &cfg.LogLevel) + str("LOG_FORMAT", &cfg.LogFormat) + + str("HTTP_ADDR", &cfg.HTTPAddr) + str("PUBLIC_BASE_URL", &cfg.PublicBaseURL) + str("BASE_PATH", &cfg.BasePath) + str("STATIC_DIR", &cfg.StaticDir) + str("SDK_DIR", &cfg.SDKDir) + + dur := func(name string, target *time.Duration) error { + v, ok := get(name) + if !ok { + return nil + } + parsed, err := time.ParseDuration(strings.TrimSpace(v)) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + *target = parsed + return nil + } + + num := func(name string, target *int) error { + v, ok := get(name) + if !ok { + return nil + } + parsed, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + *target = parsed + return nil + } + + boolean := func(name string, target *bool) error { + v, ok := get(name) + if !ok { + return nil + } + parsed, err := strconv.ParseBool(strings.TrimSpace(v)) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + *target = parsed + return nil + } + + list := func(name string, target *[]string) { + if v, ok := get(name); ok { + *target = splitList(v) + } + } + + var err error + for _, f := range []func() error{ + func() error { return dur("HTTP_READ_TIMEOUT", &cfg.ReadTimeout) }, + func() error { return dur("HTTP_WRITE_TIMEOUT", &cfg.WriteTimeout) }, + func() error { return dur("HTTP_IDLE_TIMEOUT", &cfg.IdleTimeout) }, + func() error { return dur("SHUTDOWN_TIMEOUT", &cfg.ShutdownTimeout) }, + func() error { return dur("SESSION_TTL", &cfg.SessionTTL) }, + func() error { return dur("DB_BUSY_TIMEOUT", &cfg.DatabaseBusyTimeout) }, + func() error { return num("RATE_LIMIT_PER_MINUTE", &cfg.RateLimitPerMinute) }, + func() error { return num("DB_MAX_CONNS", &cfg.DatabaseMaxConns) }, + func() error { return boolean("RUNTIME_PUBLIC", &cfg.RuntimePublic) }, + func() error { return boolean("SEED_ON_START", &cfg.SeedOnStart) }, + func() error { return boolean("ENABLE_METRICS", &cfg.EnableMetrics) }, + func() error { return boolean("ENABLE_PROFILER", &cfg.EnableProfiler) }, + func() error { return boolean("SESSION_SECURE", &cfg.SessionSecure) }, + func() error { return boolean("OIDC_SKIP_TLS_VERIFY", &cfg.OIDCSkipTLSVerify) }, + } { + if err = f(); err != nil { + return cfg, err + } + } + + list("TRUSTED_PROXIES", &cfg.TrustedProxies) + list("CORS_ALLOWED_ORIGINS", &cfg.CORSAllowedOrigins) + list("OIDC_SCOPES", &cfg.OIDCScopes) + list("OIDC_ADMIN_GROUPS", &cfg.OIDCAdminGroups) + list("OIDC_EDITOR_GROUPS", &cfg.OIDCEditorGroups) + list("OIDC_VIEWER_GROUPS", &cfg.OIDCViewerGroups) + + str("AUTH_MODE", &cfg.AuthMode) + str("OIDC_ISSUER", &cfg.OIDCIssuer) + str("OIDC_CLIENT_ID", &cfg.OIDCClientID) + str("OIDC_CLIENT_SECRET", &cfg.OIDCClientSecret) + str("OIDC_REDIRECT_URL", &cfg.OIDCRedirectURL) + str("OIDC_POST_LOGOUT_REDIRECT_URL", &cfg.OIDCPostLogoutRedirectURL) + str("SESSION_SECRET", &cfg.SessionSecret) + str("SESSION_COOKIE_NAME", &cfg.SessionCookieName) + str("SESSION_SAMESITE", &cfg.SessionSameSite) + str("DEV_FALLBACK_ROLE", &cfg.DevFallbackRole) + str("RUNTIME_CACHE_CONTROL", &cfg.RuntimeCacheControl) + str("RUNTIME_CDN_BASE_URL", &cfg.RuntimeCDNBaseURL) + str("DATABASE_PATH", &cfg.DatabasePath) + + if v, ok := get("DEV_USERS"); ok { + cfg.DevUsers = parseDevUsers(v) + } + + // A development run should not require the operator to invent a 32-byte + // secret before the first request. An ephemeral one is generated instead, + // which is safe because nothing durable is signed with it: sessions simply do + // not survive a restart, which a local run never notices. + if cfg.AuthMode != AuthModeNone && cfg.SessionSecret == "" && cfg.IsDevelopment() { + generated, genErr := randomSecret() + if genErr != nil { + return cfg, genErr + } + cfg.SessionSecret = generated + cfg.ephemeralSessionSecret = true + } + + return cfg, cfg.Validate() +} + +// EphemeralSessionSecret reports whether the session secret was generated rather +// than supplied. The caller logs it as a warning, because it means sessions +// survive only until the process restarts. +func (c Config) EphemeralSessionSecret() bool { return c.ephemeralSessionSecret } + +// randomSecret returns a 48-byte cryptographically random value, base64 encoded. +func randomSecret() (string, error) { + buf := make([]byte, 48) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate session secret: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// Validate checks the configuration for internal consistency. It returns every +// problem it finds rather than only the first, because fixing configuration one +// error per deploy is a miserable loop. +func (c Config) Validate() error { + var problems []string + add := func(format string, args ...any) { + problems = append(problems, fmt.Sprintf(format, args...)) + } + + switch c.AuthMode { + case AuthModeOIDC, AuthModeDev, AuthModeNone: + default: + add("AUTH_MODE %q must be one of oidc, dev or none", c.AuthMode) + } + + if c.AuthMode == AuthModeOIDC { + if c.OIDCIssuer == "" { + add("OIDC_ISSUER is required when AUTH_MODE=oidc") + } else if parsed, err := url.Parse(c.OIDCIssuer); err != nil { + add("OIDC_ISSUER is not a valid URL: %v", err) + } else if parsed.Scheme != "https" && !isLocalHost(parsed.Hostname()) { + add("OIDC_ISSUER must use https outside local development, got %q", parsed.Scheme) + } + if c.OIDCClientID == "" { + add("OIDC_CLIENT_ID is required when AUTH_MODE=oidc") + } + if c.OIDCClientSecret == "" { + add("OIDC_CLIENT_SECRET is required when AUTH_MODE=oidc") + } + if c.OIDCSkipTLSVerify && !c.IsDevelopment() { + add("OIDC_SKIP_TLS_VERIFY is refused outside development; it disables certificate verification on the identity provider connection") + } + } + + if c.AuthMode != AuthModeNone { + if len(c.SessionSecret) < 32 { + add("SESSION_SECRET must be at least 32 bytes when auth is enabled, got %d", len(c.SessionSecret)) + } + } + + if c.PublicBaseURL != "" { + parsed, err := url.Parse(c.PublicBaseURL) + switch { + case err != nil: + add("PUBLIC_BASE_URL is not a valid URL: %v", err) + case parsed.Scheme == "": + add("PUBLIC_BASE_URL must include a scheme, got %q", c.PublicBaseURL) + case parsed.Host == "": + add("PUBLIC_BASE_URL must include a host, got %q", c.PublicBaseURL) + } + } + + if c.BasePath != "" && !strings.HasPrefix(c.BasePath, "/") { + add("BASE_PATH must start with a slash, got %q", c.BasePath) + } + + switch strings.ToLower(c.SessionSameSite) { + case "lax", "strict", "none": + default: + add("SESSION_SAMESITE %q must be lax, strict or none", c.SessionSameSite) + } + + if strings.EqualFold(c.SessionSameSite, "none") && !c.SessionSecure { + // Browsers reject SameSite=None without Secure, so this combination + // silently produces a session that never survives a redirect. + add("SESSION_SAMESITE=none requires SESSION_SECURE=true") + } + + if !c.SessionSecure && !c.IsDevelopment() && c.PublicBaseURL != "" { + if strings.HasPrefix(c.PublicBaseURL, "https://") { + add("SESSION_SECURE must be true when PUBLIC_BASE_URL is https") + } + } + + if c.DatabasePath == "" { + add("DATABASE_PATH must not be empty") + } + if c.DatabaseMaxConns < 1 { + add("DB_MAX_CONNS must be at least 1, got %d", c.DatabaseMaxConns) + } + if c.RateLimitPerMinute < 0 { + add("RATE_LIMIT_PER_MINUTE must not be negative") + } + if !c.IsDevelopment() && c.AuthMode == AuthModeDev { + add("AUTH_MODE=dev is refused outside development; it grants admin to anyone who can reach the service") + } + if c.AuthMode == AuthModeNone && !c.IsDevelopment() { + add("AUTH_MODE=none is refused outside development") + } + + if len(problems) > 0 { + return errors.New("invalid configuration:\n - " + strings.Join(problems, "\n - ")) + } + return nil +} + +// IsDevelopment reports whether the service is running in development. +func (c Config) IsDevelopment() bool { + switch strings.ToLower(c.Environment) { + case "development", "dev", "local", "test", "testing", "ci": + return true + default: + return false + } +} + +// RedirectURL returns the OIDC callback URL, derived from the public base when +// not overridden. +func (c Config) RedirectURL() string { + if c.OIDCRedirectURL != "" { + return c.OIDCRedirectURL + } + return c.origin() + c.path("/auth/callback") +} + +// PostLogoutRedirectURL returns the OIDC end-session target. +func (c Config) PostLogoutRedirectURL() string { + if c.OIDCPostLogoutRedirectURL != "" { + return c.OIDCPostLogoutRedirectURL + } + return c.origin() + c.path("/") +} + +// Origin returns the public origin with no trailing slash. +func (c Config) Origin() string { return c.origin() } + +// origin normalises the public base URL. +func (c Config) origin() string { + return strings.TrimRight(c.PublicBaseURL, "/") +} + +// path prefixes a route with the configured base path. +func (c Config) path(route string) string { + base := strings.TrimRight(c.BasePath, "/") + if base == "" { + return route + } + return base + route +} + +// Path exposes base-path prefixing to the HTTP layer. +func (c Config) Path(route string) string { return c.path(route) } + +// CDNBasis returns the absolute base that runtime asset URLs are built from, +// honouring the CDN override. +func (c Config) CDNBasis() string { + if c.RuntimeCDNBaseURL != "" { + return strings.TrimRight(c.RuntimeCDNBaseURL, "/") + } + return c.origin() + c.path("/rt/v1") +} + +// splitList parses a comma or whitespace separated environment list. +func splitList(raw string) []string { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == '\n' || r == '\t' || r == ' ' + }) + out := make([]string, 0, len(fields)) + for _, f := range fields { + if trimmed := strings.TrimSpace(f); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// parseDevUsers parses the development role map in "email=role,email=role" form. +func parseDevUsers(raw string) map[string]string { + out := make(map[string]string) + for _, pair := range splitList(raw) { + email, role, ok := strings.Cut(pair, "=") + if !ok { + continue + } + email = strings.ToLower(strings.TrimSpace(email)) + role = strings.ToLower(strings.TrimSpace(role)) + if email == "" || role == "" { + continue + } + out[email] = role + } + return out +} + +// firstNonEmpty returns the first non-empty value from a default and a list of +// candidate environment lookups. +func firstNonEmpty(def string, lookups ...func() (string, bool)) string { + for _, lookup := range lookups { + if v, ok := lookup(); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return def +} + +// isLocalHost reports whether host refers to the local machine. +func isLocalHost(host string) bool { + switch strings.ToLower(host) { + case "localhost", "127.0.0.1", "::1", "keycloak", "idp", "auth": + return true + default: + return strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".localhost") + } +} + +// Redacted returns a copy of the configuration with secrets masked, suitable +// for logging or for the diagnostics endpoint. +func (c Config) Redacted() Config { + mask := func(s string) string { + if s == "" { + return "" + } + return "***redacted***" + } + c.OIDCClientSecret = mask(c.OIDCClientSecret) + c.SessionSecret = mask(c.SessionSecret) + return c +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go new file mode 100644 index 0000000..1b4762b --- /dev/null +++ b/internal/domain/domain.go @@ -0,0 +1,466 @@ +// Package domain holds the core entities of go-bananas. It has no knowledge of +// HTTP, SQL or any delivery mechanism: it is the shared vocabulary that the +// store, the HTTP API and the token engine all agree on. +// +// Coding standard: types in this package are plain data. Behaviour that is +// genuinely domain logic (not transport or persistence) is allowed, but it must +// be pure and deterministic. +package domain + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" +) + +// TimeFormat is the single wire format used for every timestamp crossing a +// process boundary. RFC 3339 with second precision keeps SQLite TEXT columns +// lexicographically sortable, which the store relies on for ORDER BY. +const TimeFormat = "2006-01-02T15:04:05Z07:00" + +// ThemeStatus is the publication state of a theme. +type ThemeStatus string + +const ( + // StatusDraft is editable by editors but invisible to runtime consumers. + StatusDraft ThemeStatus = "draft" + // StatusPublished is served to runtime consumers. + StatusPublished ThemeStatus = "published" + // StatusArchived is hidden from listings and runtime by default, but the + // last published revision stays resolvable for pinned consumers. + StatusArchived ThemeStatus = "archived" +) + +// Valid reports whether s is a known status. +func (s ThemeStatus) Valid() bool { + switch s { + case StatusDraft, StatusPublished, StatusArchived: + return true + default: + return false + } +} + +// Publishable lists the statuses a theme may be transitioned into. +func (s ThemeStatus) Publishable() bool { + return s == StatusDraft || s == StatusPublished || s == StatusArchived +} + +// Appearance selects a colour scheme from a theme. +type Appearance string + +const ( + // AppearanceLight selects the light colour scheme. + AppearanceLight Appearance = "light" + // AppearanceDark selects the dark colour scheme. + AppearanceDark Appearance = "dark" +) + +// Valid reports whether a is a known appearance. +func (a Appearance) Valid() bool { return a == AppearanceLight || a == AppearanceDark } + +// slugPattern constrains theme slugs to something safe for URLs, filenames and +// CSS selectors without quoting. +var slugPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$`) + +// ValidateSlug returns an error if slug is not a valid theme slug. +func ValidateSlug(slug string) error { + if slug == "" { + return errors.New("slug must not be empty") + } + if len(slug) > 64 { + return errors.New("slug must be at most 64 characters") + } + if !slugPattern.MatchString(slug) { + return fmt.Errorf("slug %q must match %s", slug, slugPattern) + } + return nil +} + +// ReservedSlugs may not be used by user-created themes because the runtime +// delivery API exposes them as fixed paths. +var ReservedSlugs = map[string]struct{}{ + "api": {}, + "rt": {}, + "admin": {}, + "healthz": {}, + "readyz": {}, + "livez": {}, + "metrics": {}, + "assets": {}, + "sdk": {}, +} + +// ValidateAppearance parses and validates a raw appearance string, falling back +// to light when empty. +func ParseAppearance(raw string) (Appearance, error) { + if raw == "" { + return AppearanceLight, nil + } + a := Appearance(strings.ToLower(raw)) + if !a.Valid() { + return "", fmt.Errorf("unknown appearance %q: want %q or %q", raw, AppearanceLight, AppearanceDark) + } + return a, nil +} + +// ThemeMeta is free-form, human-supplied metadata. It is deliberately opaque to +// the engine so consumers can carry whatever they need (author, homepage, +// licence, tags) without a schema migration. +type ThemeMeta map[string]any + +// Theme is the aggregate root: an identified, versioned bundle of design +// tokens plus the metadata needed to present and govern it. +type Theme struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Status ThemeStatus `json:"status"` + DefaultAppearance Appearance `json:"defaultAppearance"` + // Tokens is the canonical design-token document. See the tokenengine + // package for the schema, validation rules and rendering. + Tokens TokenDoc `json:"tokens"` + Meta ThemeMeta `json:"meta,omitempty"` + // System marks built-in themes that ship with the binary. They may be + // duplicated and re-published but never deleted, so consumers pinned to a + // built-in slug never break on upgrade. + System bool `json:"system"` + Version int64 `json:"version"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CreatedBy string `json:"createdBy,omitempty"` + UpdatedBy string `json:"updatedBy,omitempty"` +} + +// Clone returns a deep-enough copy that mutating the result's token leaves or +// meta map cannot affect the receiver. Token leaf values are treated as opaque +// scalars, which is how the engine guarantees they are. +func (t *Theme) Clone() *Theme { + if t == nil { + return nil + } + out := *t + out.Tokens = t.Tokens.Clone() + if t.Meta != nil { + out.Meta = make(ThemeMeta, len(t.Meta)) + for k, v := range t.Meta { + out.Meta[k] = v + } + } + return &out +} + +// ThemeVersion is an immutable snapshot of a theme taken on every mutation. +type ThemeVersion struct { + ThemeID string `json:"themeId"` + Version int64 `json:"version"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Status ThemeStatus `json:"status"` + DefaultAppearance Appearance `json:"defaultAppearance"` + Tokens TokenDoc `json:"tokens"` + Message string `json:"message,omitempty"` + Author string `json:"author,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// TokenDoc is a design-token document. The outer map is a set of token groups; +// each group is a nested tree whose leaves carry a "$type" and a "$value". It is +// modelled as a plain map so that consumers can extend a theme without an API +// change, while the engine enforces the parts it needs to render CSS. +type TokenDoc map[string]any + +// Clone deep-copies the token document. +func (d TokenDoc) Clone() TokenDoc { + if d == nil { + return nil + } + out := make(TokenDoc, len(d)) + for k, v := range d { + out[k] = cloneAny(v) + } + return out +} + +func cloneAny(v any) any { + switch t := v.(type) { + case TokenDoc: + return t.Clone() + case map[string]any: + out := make(map[string]any, len(t)) + for k, vv := range t { + out[k] = cloneAny(vv) + } + return out + case []any: + out := make([]any, len(t)) + for i, vv := range t { + out[i] = cloneAny(vv) + } + return out + default: + return v + } +} + +// ThemeQuery narrows a theme listing. The zero value matches every theme. +type ThemeQuery struct { + // Search matches slug, name or description, case-insensitively. + Search string + // Status filters by publication state. Empty matches all states. + Status ThemeStatus + // System filters on the built-in flag. Nil matches both. + System *bool + // Tag requires the theme meta to contain the given tag. + Tag string + Limit int + Offset int + // Sort is one of "updated", "created", "name", "slug". + Sort string + // Order is "asc" or "desc". + Order string +} + +// SortFields is the allow-list of sortable columns. Values are compared against +// a fixed set so that caller input never reaches the SQL statement verbatim. +var SortFields = map[string]string{ + "updated": "updated_at", + "created": "created_at", + "name": "name", + "slug": "slug", +} + +// ThemePage is a page of themes plus the total match count. +type ThemePage struct { + Items []*Theme `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// Role is a coarse authorisation level, resolved from the identity provider. +type Role string + +const ( + // RoleViewer may read published themes. + RoleViewer Role = "viewer" + // RoleEditor may create, edit and publish themes. + RoleEditor Role = "editor" + // RoleAdmin may additionally manage users, API keys and instance settings. + RoleAdmin Role = "admin" +) + +// Rank returns a comparable privilege level. +func (r Role) Rank() int { + switch r { + case RoleAdmin: + return 3 + case RoleEditor: + return 2 + case RoleViewer: + return 1 + default: + return 0 + } +} + +// AtLeast reports whether r carries at least the privilege of want. +func (r Role) AtLeast(want Role) bool { return r.Rank() >= want.Rank() } + +// ParseRole maps a raw claim value onto a Role, defaulting to viewer so that an +// unexpected claim can never grant more than read access. +func ParseRole(raw string) Role { + switch Role(strings.ToLower(strings.TrimSpace(raw))) { + case RoleAdmin: + return RoleAdmin + case RoleEditor: + return RoleEditor + case RoleViewer: + return RoleViewer + default: + return RoleViewer + } +} + +// Identity is the authenticated caller, normalised across every auth mode +// (OIDC session, API key, or development bypass). +type Identity struct { + Subject string `json:"subject"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Roles []Role `json:"roles"` + Method string `json:"method"` + // ExpiresAt is a pointer because omitempty does not work on time.Time: a zero + // time is not the empty value to encoding/json, so a plain time.Time here + // serialised as "0001-01-01T00:00:00Z" on every identity that has no expiry. + // That is a real value to a client, and an API key with no expiry is normal. + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} + +// UserPreferences is what one person has chosen for their own interface. +// +// The preference names a published theme rather than a copy of it, so editing or +// unpublishing that theme is reflected everywhere without touching a row here. An +// empty SelectedTheme means nothing has been chosen and the client should apply +// its own default; SelectedAppearance is then ignored. +type UserPreferences struct { + SelectedTheme string `json:"selectedTheme"` + SelectedAppearance Appearance `json:"selectedAppearance"` +} + +// Validate reports whether p is storable. An empty theme is the absence of a +// choice and is always valid; a named theme must be a syntactically valid slug and +// the appearance must be one this build knows. +func (p UserPreferences) Validate() error { + if p.SelectedTheme == "" { + if p.SelectedAppearance != "" && !p.SelectedAppearance.Valid() { + return fmt.Errorf("%w: selectedAppearance %q is not light or dark", ErrInvalid, p.SelectedAppearance) + } + return nil + } + if err := ValidateSlug(p.SelectedTheme); err != nil { + // ValidateSlug reports a bare error because it is also used for messages + // that are not about a request. Wrapping it here is what makes a bad slug + // a 400 through writeProblem rather than a 500. + return fmt.Errorf("%w: %s", ErrInvalid, err.Error()) + } + if p.SelectedAppearance != "" && !p.SelectedAppearance.Valid() { + return fmt.Errorf("%w: selectedAppearance %q is not light or dark", ErrInvalid, p.SelectedAppearance) + } + return nil +} + +// HighestRole returns the caller's most privileged role. +func (i *Identity) HighestRole() Role { + var best Role + for _, r := range i.Roles { + if r.Rank() > best.Rank() { + best = r + } + } + if best == "" { + return RoleViewer + } + return best +} + +// HasRole reports whether the identity carries the given role. +func (i *Identity) HasRole(r Role) bool { + for _, have := range i.Roles { + if have == r { + return true + } + } + return false +} + +// DisplayName returns the friendliest available label for the identity. +func (i *Identity) DisplayName() string { + if i == nil { + return "anonymous" + } + switch { + case i.Name != "": + return i.Name + case i.Email != "": + return i.Email + case i.Subject != "": + return i.Subject + default: + return "anonymous" + } +} + +// Setting is one instance-level configuration value. +// +// Settings live in the database rather than in the environment because they are +// editable from the admin UI while the service is running. Changing the default +// theme takes effect on the next request, with no restart. +type Setting struct { + Key string `json:"key"` + Value string `json:"value"` + UpdatedAt time.Time `json:"updatedAt"` + UpdatedBy string `json:"updatedBy,omitempty"` +} + +// Setting keys. They are constants rather than free strings so a typo is a compile +// error, and so the keys in the database have one definition. +const ( + // SettingDefaultTheme is the slug served when a consumer names no theme. + SettingDefaultTheme = "default.theme" + // SettingDefaultAppearance is the appearance used when the default theme + // declares none. + SettingDefaultAppearance = "default.appearance" + // SettingRuntimeAPIBase overrides the runtime API base URL advertised in the + // manifest, for a deployment that serves it from a CDN. + SettingRuntimeAPIBase = "runtime.apiBase" + // SettingRuntimePublic lists the origins allowed to embed the runtime SDK. + SettingRuntimePublic = "runtime.public" +) + +// AuditAction is the kind of change an audit entry records. +type AuditAction string + +// The audit actions this build records. +const ( + ActionThemeCreate AuditAction = "theme.create" + ActionThemeUpdate AuditAction = "theme.update" + ActionThemeDelete AuditAction = "theme.delete" + ActionThemePublish AuditAction = "theme.publish" + ActionThemeArchive AuditAction = "theme.archive" + ActionThemeDuplicate AuditAction = "theme.duplicate" + ActionThemeRollback AuditAction = "theme.rollback" + ActionThemeImport AuditAction = "theme.import" + ActionAuthLogin AuditAction = "auth.login" + ActionAuthLogout AuditAction = "auth.logout" +) + +// AuditEntry is one recorded action. +// +// The audit log is append-only and is the record of who changed what. The actor is +// taken from the authenticated identity at the point of writing rather than from +// the request body, so it cannot be forged by a client. +type AuditEntry struct { + ID int64 `json:"id"` + At time.Time `json:"at"` + Actor string `json:"actor"` + ActorEmail string `json:"actorEmail,omitempty"` + Action AuditAction `json:"action"` + EntityType string `json:"entityType,omitempty"` + EntityID string `json:"entityId,omitempty"` + Summary string `json:"summary,omitempty"` + Detail map[string]any `json:"detail,omitempty"` + RemoteIP string `json:"remoteIp,omitempty"` +} + +// Error sentinels. +// +// Handlers match on these with errors.Is rather than comparing strings, so the +// mapping from a domain failure to an HTTP status lives in exactly one place and a +// wrapped error still classifies correctly. +var ( + // ErrInvalid means the request was malformed or failed validation. + ErrInvalid = errors.New("invalid input") + // ErrNotFound means the addressed thing does not exist. + ErrNotFound = errors.New("not found") + // ErrUnauthorized means the caller is not authenticated, or the credential + // presented did not identify anyone. + ErrUnauthorized = errors.New("unauthorized") + // ErrForbidden means the caller is known but not permitted. + ErrForbidden = errors.New("forbidden") + // ErrConflict means the change collides with existing state, such as a slug + // that is already taken or a stale base version. + ErrConflict = errors.New("conflict") + // ErrImmutable means the target is one this service owns and will not let an + // operator change, such as a built-in theme. It is distinct from ErrForbidden: + // the caller is allowed to ask, and the answer is no. + ErrImmutable = errors.New("immutable") +) + +// ErrNotConfigured is returned when a feature is unavailable because of how the +// service was configured, as distinct from a request that was made wrongly. +var ErrNotConfigured = errors.New("not configured") diff --git a/internal/httpapi/admin.go b/internal/httpapi/admin.go new file mode 100644 index 0000000..d0754d7 --- /dev/null +++ b/internal/httpapi/admin.go @@ -0,0 +1,931 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/urpi/go-bananas/internal/domain" + "github.com/urpi/go-bananas/internal/store" + "github.com/urpi/go-bananas/internal/tokenengine" +) + +// AdminHandlers serves the authenticated administrative API. +type AdminHandlers struct { + store *store.Store + cache *ThemeCache + log Logger + // sessionSecretCookieName is used when reporting session state. + version string + started time.Time +} + +// NewAdminHandlers builds the admin handler set. +func NewAdminHandlers(st *store.Store, cache *ThemeCache, log Logger, version string) *AdminHandlers { + return &AdminHandlers{ + store: st, + cache: cache, + log: log, + version: version, + started: time.Now(), + } +} + +// ------------------------------------------------------------- meta & auth -- + +// Meta returns build and instance information the UI displays. +func (h *AdminHandlers) Meta(w http.ResponseWriter, r *http.Request) { + settings, err := h.store.AllSettings(r.Context()) + if err != nil { + writeProblem(w, r, err) + return + } + writeJSON(w, r, http.StatusOK, map[string]any{ + "version": h.version, + "uptime": time.Since(h.started).Round(time.Second).String(), + "environment": settings["environment"].Value, + "settings": settings, + }) +} + +// listThemes returns a page of themes. +func (h *AdminHandlers) listThemes(w http.ResponseWriter, r *http.Request) { + q := domain.ThemeQuery{ + Search: strings.TrimSpace(r.URL.Query().Get("q")), + Status: domain.ThemeStatus(strings.TrimSpace(r.URL.Query().Get("status"))), + Tag: strings.TrimSpace(r.URL.Query().Get("tag")), + Limit: queryInt(r, "limit", 50, 1, 200), + Offset: queryInt(r, "offset", 0, 0, 1<<20), + Sort: r.URL.Query().Get("sort"), + Order: r.URL.Query().Get("order"), + } + if q.Status != "" && !q.Status.Valid() { + writeError(w, r, http.StatusBadRequest, CodeBadRequest, + "status must be one of draft, published, archived", nil) + return + } + if raw := r.URL.Query().Get("system"); raw != "" { + value := raw == "true" || raw == "1" + q.System = &value + } + + page, err := h.store.List(r.Context(), q) + if err != nil { + writeProblem(w, r, err) + return + } + // A listing does not need token documents; they are the bulk of each row. + writeJSON(w, r, http.StatusOK, map[string]any{ + "items": page.Items, + "total": page.Total, + "limit": page.Limit, + "offset": page.Offset, + }) +} + +// getTheme returns one theme with its full token document. +func (h *AdminHandlers) getTheme(w http.ResponseWriter, r *http.Request) { + theme, err := h.store.Get(r.Context(), chi.URLParam(r, "slug")) + if err != nil { + writeProblem(w, r, err) + return + } + writeJSON(w, r, http.StatusOK, theme) +} + +// themeMutation is the request body for create and update. +type themeMutation struct { + Slug *string `json:"slug"` + Name *string `json:"name"` + Description *string `json:"description"` + Status *string `json:"status"` + DefaultAppearance *string `json:"defaultAppearance"` + Tokens *domain.TokenDoc `json:"tokens"` + Meta *domain.ThemeMeta `json:"meta"` + Message string `json:"message"` + // BaseVersion enables optimistic concurrency: the write is rejected when the + // theme has moved on since the editor loaded it, which stops two people + // editing the same theme from silently overwriting each other. + BaseVersion int64 `json:"baseVersion"` +} + +// createTheme creates a theme. +func (h *AdminHandlers) createTheme(w http.ResponseWriter, r *http.Request) { + var body themeMutation + if err := decodeJSON(r, &body); err != nil { + writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil) + return + } + + if body.Name == nil || strings.TrimSpace(*body.Name) == "" { + writeError(w, r, http.StatusBadRequest, CodeValidation, "a theme name is required", + []FieldError{{Field: "name", Message: "must not be empty"}}) + return + } + if body.Slug == nil || strings.TrimSpace(*body.Slug) == "" { + writeError(w, r, http.StatusBadRequest, CodeValidation, "a theme slug is required", + []FieldError{{Field: "slug", Message: "must not be empty"}}) + return + } + if err := domain.ValidateSlug(*body.Slug); err != nil { + writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), + []FieldError{{Field: "slug", Message: err.Error()}}) + return + } + if _, reserved := domain.ReservedSlugs[*body.Slug]; reserved { + writeError(w, r, http.StatusBadRequest, CodeValidation, + fmt.Sprintf("slug %q is reserved by the runtime API", *body.Slug), + []FieldError{{Field: "slug", Message: "reserved"}}) + return + } + + theme := &domain.Theme{ + Slug: strings.TrimSpace(*body.Slug), + Name: strings.TrimSpace(*body.Name), + Status: domain.StatusDraft, + Tokens: domain.TokenDoc{}, + DefaultAppearance: domain.AppearanceLight, + CreatedBy: mustIdentity(r.Context()).Subject, + UpdatedBy: mustIdentity(r.Context()).Subject, + } + if body.Description != nil { + theme.Description = *body.Description + } + if body.Status != nil { + theme.Status = domain.ThemeStatus(*body.Status) + } + if body.DefaultAppearance != nil { + theme.DefaultAppearance = domain.Appearance(*body.DefaultAppearance) + } + if body.Tokens != nil { + theme.Tokens = *body.Tokens + } + if body.Meta != nil { + theme.Meta = *body.Meta + } + + // Publishing requires a valid token document; a draft does not, so that a + // theme can be built up incrementally without tripping validation. + if theme.Status == domain.StatusPublished { + if result := tokenengine.Validate(theme.Tokens); !result.Valid { + writeError(w, r, http.StatusBadRequest, CodeValidation, + "the theme cannot be published because its tokens are invalid", + issuesToFieldErrors(result.Errors())) + return + } + } + + if err := h.store.Create(r.Context(), theme, body.Message); err != nil { + writeProblem(w, r, err) + return + } + + created, err := h.store.Get(r.Context(), theme.Slug) + if err != nil { + writeProblem(w, r, err) + return + } + h.audit(r, domain.ActionThemeCreate, "theme", created.Slug, "created "+created.Slug, nil) + writeJSON(w, r, http.StatusCreated, created) +} + +// updateTheme applies a partial update. +func (h *AdminHandlers) updateTheme(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + + var body themeMutation + if err := decodeJSON(r, &body); err != nil { + writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil) + return + } + + current, err := h.store.Get(r.Context(), slug) + if err != nil { + writeProblem(w, r, err) + return + } + if body.BaseVersion > 0 && current.Version != body.BaseVersion { + writeError(w, r, http.StatusConflict, CodeConflict, + fmt.Sprintf("this theme has changed since you loaded it (you have version %d, the current version is %d); reload before saving", + body.BaseVersion, current.Version), nil) + return + } + + fields := store.UpdateFields{Message: body.Message, Editor: mustIdentity(r.Context()).Subject} + fields.Slug = body.Slug + fields.Name = body.Name + fields.Description = body.Description + if body.Status != nil { + status := domain.ThemeStatus(*body.Status) + fields.Status = &status + } + if body.DefaultAppearance != nil { + appearance := domain.Appearance(*body.DefaultAppearance) + fields.DefaultAppearance = &appearance + } + fields.Tokens = body.Tokens + fields.Meta = body.Meta + + // Validate the resulting document before it can become a published theme. + // Merging happens in the store, so the check is performed on a projection of + // the current theme with the request applied. + projected := current.Clone() + if fields.Slug != nil { + projected.Slug = *fields.Slug + } + if fields.Name != nil { + projected.Name = *fields.Name + } + if fields.Description != nil { + projected.Description = *fields.Description + } + if fields.Status != nil { + projected.Status = *fields.Status + } + if fields.DefaultAppearance != nil { + projected.DefaultAppearance = *fields.DefaultAppearance + } + if fields.Tokens != nil { + projected.Tokens = *fields.Tokens + } + if projected.Status == domain.StatusPublished { + if result := tokenengine.Validate(projected.Tokens); !result.Valid { + writeError(w, r, http.StatusBadRequest, CodeValidation, + "the theme cannot be published because its tokens are invalid", + issuesToFieldErrors(result.Errors())) + return + } + } + + updated, err := h.store.Update(r.Context(), slug, fields) + if err != nil { + writeProblem(w, r, err) + return + } + + // A write invalidates the rendered artefacts; the next request re-renders. + h.cache.Invalidate(updated.Slug) + h.cache.Invalidate(slug) + h.audit(r, domain.ActionThemeUpdate, "theme", updated.Slug, + "updated "+updated.Slug+" to version "+strconv.FormatInt(updated.Version, 10), nil) + + writeJSON(w, r, http.StatusOK, updated) +} + +// deleteTheme removes a theme. +func (h *AdminHandlers) deleteTheme(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + if err := h.store.Delete(r.Context(), slug); err != nil { + writeProblem(w, r, err) + return + } + h.cache.Invalidate(slug) + h.audit(r, domain.ActionThemeDelete, "theme", slug, "deleted "+slug, nil) + w.WriteHeader(http.StatusNoContent) +} + +// duplicateTheme copies a theme. +func (h *AdminHandlers) duplicateTheme(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + + var body struct { + Slug string `json:"slug"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil) + return + } + if strings.TrimSpace(body.Slug) == "" { + writeError(w, r, http.StatusBadRequest, CodeValidation, + "a slug for the copy is required", + []FieldError{{Field: "slug", Message: "must not be empty"}}) + return + } + + copied, err := h.store.Duplicate(r.Context(), slug, strings.TrimSpace(body.Slug), mustIdentity(r.Context()).Subject) + if err != nil { + writeProblem(w, r, err) + return + } + h.audit(r, domain.ActionThemeDuplicate, "theme", copied.Slug, + "duplicated "+slug+" to "+copied.Slug, nil) + writeJSON(w, r, http.StatusCreated, copied) +} + +// publishTheme transitions a theme's status. +func (h *AdminHandlers) publishTheme(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + h.setStatus(w, r, slug, domain.StatusPublished, domain.ActionThemePublish) +} + +// archiveTheme transitions a theme to archived. +func (h *AdminHandlers) archiveTheme(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + h.setStatus(w, r, slug, domain.StatusArchived, domain.ActionThemeArchive) +} + +// setStatus is the shared implementation behind the publish and archive routes. +func (h *AdminHandlers) setStatus(w http.ResponseWriter, r *http.Request, slug string, status domain.ThemeStatus, action domain.AuditAction) { + current, err := h.store.Get(r.Context(), slug) + if err != nil { + writeProblem(w, r, err) + return + } + + if status == domain.StatusPublished { + if result := tokenengine.Validate(current.Tokens); !result.Valid { + writeError(w, r, http.StatusBadRequest, CodeValidation, + "the theme cannot be published because its tokens are invalid", + issuesToFieldErrors(result.Errors())) + return + } + } + + message := "published " + slug + if status == domain.StatusArchived { + message = "archived " + slug + } + + updated, err := h.store.Update(r.Context(), slug, store.UpdateFields{ + Status: &status, + Message: message, + Editor: mustIdentity(r.Context()).Subject, + }) + if err != nil { + writeProblem(w, r, err) + return + } + + h.cache.Invalidate(slug) + h.audit(r, action, "theme", slug, message, map[string]any{"version": updated.Version}) + writeJSON(w, r, http.StatusOK, updated) +} + +// ---------------------------------------------------------------- history -- + +// listVersions returns a theme's history. +func (h *AdminHandlers) listVersions(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + versions, err := h.store.Versions(r.Context(), slug, queryInt(r, "limit", 50, 1, 200)) + if err != nil { + writeProblem(w, r, err) + return + } + // History is only useful if it does not carry a full copy of every token + // document, so the summary omits them. + summaries := make([]map[string]any, 0, len(versions)) + for _, v := range versions { + summaries = append(summaries, map[string]any{ + "version": v.Version, + "name": v.Name, + "description": v.Description, + "status": v.Status, + "message": v.Message, + "author": v.Author, + "createdAt": v.CreatedAt, + }) + } + writeJSON(w, r, http.StatusOK, map[string]any{"items": summaries}) +} + +// getVersion returns one historical snapshot including its tokens. +func (h *AdminHandlers) getVersion(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + version, err := versionParam(r) + if err != nil { + writeError(w, r, http.StatusBadRequest, CodeBadRequest, err.Error(), nil) + return + } + + snapshot, err := h.store.Version(r.Context(), slug, version) + if err != nil { + writeProblem(w, r, err) + return + } + writeJSON(w, r, http.StatusOK, snapshot) +} + +// rollbackTheme restores a historical snapshot as a new version. +func (h *AdminHandlers) rollbackTheme(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + version, err := versionParam(r) + if err != nil { + writeError(w, r, http.StatusBadRequest, CodeBadRequest, err.Error(), nil) + return + } + + restored, err := h.store.Rollback(r.Context(), slug, version, mustIdentity(r.Context()).Subject) + if err != nil { + writeProblem(w, r, err) + return + } + h.cache.Invalidate(slug) + h.audit(r, domain.ActionThemeRollback, "theme", slug, + "rolled "+slug+" back to version "+strconv.FormatInt(version, 10), nil) + writeJSON(w, r, http.StatusOK, restored) +} + +// diffVersions compares two snapshots. +func (h *AdminHandlers) diffVersions(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + from, err := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64) + if err != nil { + writeError(w, r, http.StatusBadRequest, CodeBadRequest, "from must be an integer version", nil) + return + } + to, err := strconv.ParseInt(r.URL.Query().Get("to"), 10, 64) + if err != nil { + writeError(w, r, http.StatusBadRequest, CodeBadRequest, "to must be an integer version", nil) + return + } + + diff, err := h.store.DiffVersions(r.Context(), slug, from, to) + if err != nil { + writeProblem(w, r, err) + return + } + writeJSON(w, r, http.StatusOK, diff) +} + +// versionParam reads the {version} route parameter. +func versionParam(r *http.Request) (int64, error) { + raw := chi.URLParam(r, "version") + version, err := strconv.ParseInt(raw, 10, 64) + if err != nil || version < 1 { + return 0, fmt.Errorf("version must be a positive integer, got %q", raw) + } + return version, nil +} + +// --------------------------------------------------------- import/export -- + +// exportTheme serves the portable token document. +// +// Import and export are exact inverses, which is what makes themes portable +// between environments and makes a migration a file copy rather than a project. +func (h *AdminHandlers) exportTheme(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + theme, err := h.store.Get(r.Context(), slug) + if err != nil { + writeProblem(w, r, err) + return + } + + document := tokenengine.ExportDocument(theme) + payload := map[string]any{ + "$schema": tokenengine.SchemaID, + "slug": theme.Slug, + "name": theme.Name, + "status": theme.Status, + "version": theme.Version, + "tokens": document, + "meta": theme.Meta, + "exportedAt": time.Now().UTC().Format(domain.TimeFormat), + "exportedBy": mustIdentity(r.Context()).Subject, + } + + w.Header().Set("Content-Disposition", + fmt.Sprintf("attachment; filename=%q", theme.Slug+".theme.json")) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, r, http.StatusOK, payload) +} + +// importRequest is the body accepted by the import route. +type importRequest struct { + // Slug overrides the slug in the document. Required when the document has + // no slug of its own, and used to create a copy when it does. + Slug string `json:"slug"` + AsDraft bool `json:"asDraft"` + Overwrite bool `json:"overwrite"` + Name string `json:"name"` + Tokens *domain.TokenDoc `json:"tokens"` + Meta *domain.ThemeMeta `json:"meta"` + Message string `json:"message"` +} + +// importTheme creates or replaces a theme from a portable document. +func (h *AdminHandlers) importTheme(w http.ResponseWriter, r *http.Request) { + var body importRequest + if err := decodeJSON(r, &body); err != nil { + writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil) + return + } + if body.Tokens == nil { + writeError(w, r, http.StatusBadRequest, CodeValidation, "a tokens document is required", + []FieldError{{Field: "tokens", Message: "must not be empty"}}) + return + } + if strings.TrimSpace(body.Slug) == "" { + writeError(w, r, http.StatusBadRequest, CodeValidation, "a slug is required", + []FieldError{{Field: "slug", Message: "must not be empty"}}) + return + } + if err := domain.ValidateSlug(body.Slug); err != nil { + writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), + []FieldError{{Field: "slug", Message: err.Error()}}) + return + } + + result := tokenengine.Validate(*body.Tokens) + if !result.Valid { + writeError(w, r, http.StatusBadRequest, CodeValidation, "the imported tokens are invalid", + issuesToFieldErrors(result.Errors())) + return + } + + identity := mustIdentity(r.Context()).Subject + status := domain.StatusPublished + if body.AsDraft { + status = domain.StatusDraft + } + + _, err := h.store.Get(r.Context(), body.Slug) + switch { + case err == nil && !body.Overwrite: + writeError(w, r, http.StatusConflict, CodeConflict, + fmt.Sprintf("a theme with slug %q already exists; set overwrite to replace it", body.Slug), nil) + return + case err == nil && body.Overwrite: + fields := store.UpdateFields{ + Tokens: body.Tokens, + Message: defaultString(body.Message, "imported"), + Editor: identity, + Status: &status, + } + if body.Name != "" { + fields.Name = &body.Name + } + if body.Meta != nil { + fields.Meta = body.Meta + } + updated, err := h.store.Update(r.Context(), body.Slug, fields) + if err != nil { + writeProblem(w, r, err) + return + } + h.cache.Invalidate(body.Slug) + h.audit(r, domain.ActionThemeImport, "theme", body.Slug, + "imported over "+body.Slug, map[string]any{"version": updated.Version}) + writeJSON(w, r, http.StatusOK, updated) + return + + case err != nil && !errors.Is(err, domain.ErrNotFound): + writeProblem(w, r, err) + return + } + + theme := &domain.Theme{ + Slug: strings.TrimSpace(body.Slug), + Name: defaultString(body.Name, body.Slug), + Status: status, + DefaultAppearance: domain.AppearanceLight, + Tokens: *body.Tokens, + CreatedBy: identity, + UpdatedBy: identity, + } + if body.Meta != nil { + theme.Meta = *body.Meta + } + + if err := h.store.Create(r.Context(), theme, defaultString(body.Message, "imported")); err != nil { + writeProblem(w, r, err) + return + } + h.audit(r, domain.ActionThemeImport, "theme", theme.Slug, "imported "+theme.Slug, nil) + + created, err := h.store.Get(r.Context(), theme.Slug) + if err != nil { + writeProblem(w, r, err) + return + } + writeJSON(w, r, http.StatusCreated, created) +} + +// ---------------------------------------------------------- token tooling -- + +// validateTokens runs the engine's validator without persisting anything. The +// editor calls it on every keystroke-with-blur, which is why it must not write. +func (h *AdminHandlers) validateTokens(w http.ResponseWriter, r *http.Request) { + var body struct { + Tokens domain.TokenDoc `json:"tokens"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil) + return + } + result := tokenengine.Validate(body.Tokens) + writeJSON(w, r, http.StatusOK, result) +} + +// generatePalette produces a full ramp from a hue, so the editor's palette +// builder does not have to reimplement the ramp curve. +func (h *AdminHandlers) generatePalette(w http.ResponseWriter, r *http.Request) { + opts := tokenengine.DefaultRampOptions() + query := r.URL.Query() + + if raw := query.Get("hue"); raw != "" { + if hue, err := strconv.ParseFloat(raw, 64); err == nil { + opts.Hue = hue + } + } + if raw := query.Get("saturation"); raw != "" { + if v, err := strconv.ParseFloat(raw, 64); err == nil { + opts.Saturation = v + } + } + if raw := query.Get("lightness"); raw != "" { + if v, err := strconv.ParseFloat(raw, 64); err == nil { + opts.Lightness = v + } + } + if raw := query.Get("hueSpread"); raw != "" { + if v, err := strconv.ParseFloat(raw, 64); err == nil { + opts.HueSpread = v + } + } + + ramp := tokenengine.GenerateRamp(opts) + writeJSON(w, r, http.StatusOK, map[string]any{ + "options": opts.Normalize(), + "shades": tokenengine.RampShades(), + "ramp": ramp, + }) +} + +// contrast checks a foreground against a background and reports the WCAG result, +// so the editor can flag an unreadable combination before it is saved. +func (h *AdminHandlers) contrast(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + foreground := strings.TrimSpace(query.Get("fg")) + background := strings.TrimSpace(query.Get("bg")) + + if foreground == "" || background == "" { + writeError(w, r, http.StatusBadRequest, CodeBadRequest, + "both fg and bg are required as colour values", nil) + return + } + + fg, err := tokenengine.ParseColor(foreground) + if err != nil { + writeError(w, r, http.StatusBadRequest, CodeBadRequest, "fg: "+err.Error(), nil) + return + } + bg, err := tokenengine.ParseColor(background) + if err != nil { + writeError(w, r, http.StatusBadRequest, CodeBadRequest, "bg: "+err.Error(), nil) + return + } + if !fg.Numeric || !bg.Numeric { + writeError(w, r, http.StatusBadRequest, CodeBadRequest, + "contrast can only be computed for colours the engine understands", nil) + return + } + + ratio := tokenengine.ContrastRatio(fg, bg) + writeJSON(w, r, http.StatusOK, map[string]any{ + "foreground": fg.Hex(), + "background": bg.Hex(), + "ratio": ratio, + "rounded": round2(ratio), + "level": wcagLevel(ratio), + "thresholds": map[string]float64{ + "aaLarge": tokenengine.ContrastAALarge, + "aa": tokenengine.ContrastAA, + "aaa": tokenengine.ContrastAAA, + }, + "bestForeground": tokenengine.BestForeground(bg).Hex(), + }) +} + +// wcagLevel names the highest WCAG conformance level a ratio satisfies. +func wcagLevel(ratio float64) string { + switch { + case ratio >= tokenengine.ContrastAAA: + return "aaa" + case ratio >= tokenengine.ContrastAA: + return "aa" + case ratio >= tokenengine.ContrastAALarge: + return "aa-large" + default: + return "fail" + } +} + +// round2 rounds a float to two decimal places. +func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 } + +// ------------------------------------------------------------------ users -- + +// listUsers returns known accounts. +func (h *AdminHandlers) listUsers(w http.ResponseWriter, r *http.Request) { + users, err := h.store.ListUsers(r.Context(), queryInt(r, "limit", 100, 1, 500)) + if err != nil { + writeProblem(w, r, err) + return + } + writeJSON(w, r, http.StatusOK, map[string]any{"items": users}) +} + +// listAudit returns the activity log. +func (h *AdminHandlers) listAudit(w http.ResponseWriter, r *http.Request) { + filter := store.AuditFilter{ + Actor: strings.TrimSpace(r.URL.Query().Get("actor")), + Action: strings.TrimSpace(r.URL.Query().Get("action")), + EntityType: strings.TrimSpace(r.URL.Query().Get("entityType")), + EntityID: strings.TrimSpace(r.URL.Query().Get("entityId")), + Limit: queryInt(r, "limit", 50, 1, 500), + Offset: queryInt(r, "offset", 0, 0, 1<<20), + } + if raw := r.URL.Query().Get("since"); raw != "" { + if since, err := time.Parse(time.RFC3339, raw); err == nil { + filter.Since = &since + } + } + + entries, total, err := h.store.ListAudit(r.Context(), filter) + if err != nil { + writeProblem(w, r, err) + return + } + writeJSON(w, r, http.StatusOK, map[string]any{ + "items": entries, "total": total, "limit": filter.Limit, "offset": filter.Offset, + }) +} + +// listSettings returns instance settings. +func (h *AdminHandlers) listSettings(w http.ResponseWriter, r *http.Request) { + settings, err := h.store.AllSettings(r.Context()) + if err != nil { + writeProblem(w, r, err) + return + } + writeJSON(w, r, http.StatusOK, map[string]any{"items": settings}) +} + +// updateSettings writes instance settings. +func (h *AdminHandlers) updateSettings(w http.ResponseWriter, r *http.Request) { + var body map[string]string + if err := decodeJSON(r, &body); err != nil { + writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil) + return + } + + actor := mustIdentity(r.Context()).Subject + updated := make([]string, 0, len(body)) + for key, value := range body { + if !knownSetting(key) { + writeError(w, r, http.StatusBadRequest, CodeValidation, + "unknown setting "+key, []FieldError{{Field: key, Message: "not a known setting"}}) + return + } + if err := h.store.SetSetting(r.Context(), key, value, actor); err != nil { + writeProblem(w, r, err) + return + } + updated = append(updated, key) + } + sortStrings(updated) + + // The default theme is baked into the runtime manifest, so a change has to + // clear whatever the manifest cached. + h.cache.Flush() + + writeJSON(w, r, http.StatusOK, map[string]any{"updated": updated}) +} + +// knownSetting reports whether a setting key is recognised. +func knownSetting(key string) bool { + switch key { + case domain.SettingDefaultTheme, domain.SettingDefaultAppearance, + domain.SettingRuntimePublic, domain.SettingRuntimeAPIBase: + return true + default: + return false + } +} + +// stats returns dashboard counters. +func (h *AdminHandlers) stats(w http.ResponseWriter, r *http.Request) { + summary, err := h.store.Stats(r.Context()) + if err != nil { + writeProblem(w, r, err) + return + } + writeJSON(w, r, http.StatusOK, map[string]any{ + "store": summary, + "cache": h.cache.Stats(), + "runtime": map[string]any{"apiVersion": tokenengine.RuntimeAPIVersion}, + }) +} + +// rebuildCache clears and re-warms the render cache. +func (h *AdminHandlers) rebuildCache(w http.ResponseWriter, r *http.Request) { + h.cache.Flush() + + themes, err := h.store.ListRuntime(r.Context()) + if err != nil { + writeProblem(w, r, err) + return + } + warmed := 0 + for _, theme := range themes { + warmed += h.cache.Prefetch(theme, nil) + } + writeJSON(w, r, http.StatusOK, map[string]any{ + "themes": len(themes), "artefacts": warmed, + }) +} + +// ------------------------------------------------------------------ audit -- + +// audit appends an audit entry. A failure is logged rather than returned, +// because losing an audit row must not fail the user's operation. +func (h *AdminHandlers) audit(r *http.Request, action domain.AuditAction, entityType, entityID, summary string, detail map[string]any) { + subject, email := actorFrom(r.Context()) + entry := &domain.AuditEntry{ + Actor: subject, + ActorEmail: email, + Action: action, + EntityType: entityType, + EntityID: entityID, + Summary: summary, + Detail: detail, + RemoteIP: clientIPFrom(r.Context()), + } + if err := h.store.AppendAudit(r.Context(), entry); err != nil { + h.log.Error("append audit entry", "error", err, "action", string(action), "entity", entityID) + } +} + +// ------------------------------------------------------------------ input -- + +// decodeJSON reads a JSON request body. +// +// The body is closed, unknown fields are rejected so a typo in a client is +// reported rather than silently ignored, and a trailing-value error is caught +// because json.Decoder stops at the first complete value. +func decodeJSON(r *http.Request, target any) error { + if r.Body == nil { + return fmt.Errorf("%w: request body is empty", domain.ErrInvalid) + } + decoder := json.NewDecoder(io.LimitReader(r.Body, MaxBodyBytes)) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(target); err != nil { + var syntaxErr *json.SyntaxError + var typeErr *json.UnmarshalTypeError + switch { + case errors.As(err, &syntaxErr): + return fmt.Errorf("%w: request body is not valid JSON (at byte %d)", domain.ErrInvalid, syntaxErr.Offset) + case errors.As(err, &typeErr): + return fmt.Errorf("%w: field %q expects %s", domain.ErrInvalid, typeErr.Field, typeErr.Type) + case errors.Is(err, io.EOF): + return fmt.Errorf("%w: request body is empty", domain.ErrInvalid) + case strings.HasPrefix(err.Error(), "json: unknown field "): + field := strings.TrimPrefix(err.Error(), "json: unknown field ") + return fmt.Errorf("%w: unknown field %s", domain.ErrInvalid, strings.Trim(field, `"`)) + default: + return fmt.Errorf("%w: %s", domain.ErrInvalid, cleanError(err)) + } + } + + // Reject trailing content so that two concatenated documents cannot be + // accepted where one was intended. + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return fmt.Errorf("%w: request body must contain exactly one JSON document", domain.ErrInvalid) + } + return nil +} + +// issuesToFieldErrors converts engine validation issues into API field errors. +func issuesToFieldErrors(issues []tokenengine.Issue) []FieldError { + out := make([]FieldError, 0, len(issues)) + for _, issue := range issues { + out = append(out, FieldError{Field: issue.Path, Message: issue.Message}) + } + return out +} + +// defaultString returns value when it is non-empty, otherwise fallback. +func defaultString(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} + +// sortStrings orders a slice in place. +func sortStrings(values []string) { + for i := 1; i < len(values); i++ { + for j := i; j > 0 && values[j] < values[j-1]; j-- { + values[j], values[j-1] = values[j-1], values[j] + } + } +} diff --git a/internal/httpapi/auth_handlers.go b/internal/httpapi/auth_handlers.go new file mode 100644 index 0000000..9c5de14 --- /dev/null +++ b/internal/httpapi/auth_handlers.go @@ -0,0 +1,282 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/urpi/go-bananas/internal/auth" + "github.com/urpi/go-bananas/internal/config" + "github.com/urpi/go-bananas/internal/domain" + "github.com/urpi/go-bananas/internal/store" + "github.com/urpi/go-bananas/internal/tokenengine" +) + +// AuthHandlers serves the browser-facing authentication routes and the session +// introspection the admin UI needs. +type AuthHandlers struct { + authenticator *auth.Authenticator + // store is used only to read the caller's own interface preferences, which + // are reported alongside the session so the UI can preselect a theme from the + // same round trip that establishes who the caller is. It is nil in tests that + // do not exercise preferences. + store *store.Store + log Logger + config config.Config +} + +// NewAuthHandlers builds the auth handler set. +func NewAuthHandlers(authenticator *auth.Authenticator, st *store.Store, log Logger, cfg config.Config) *AuthHandlers { + return &AuthHandlers{authenticator: authenticator, store: st, log: log, config: cfg} +} + +// Login starts the OIDC login flow. +func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { + // Sanitised here as well as in the state store, so that the value that is + // reflected back to the provider-side cookie is already known to be local. + returnTo := auth.SafeReturnTo(r.URL.Query().Get("returnTo")) + + if h.authenticator == nil || !h.authenticator.ModeConfigured() { + writeError(w, r, http.StatusNotFound, CodeNotConfigured, + "this deployment does not use interactive login", nil) + return + } + + if err := h.authenticator.Begin(r.Context(), r, w, returnTo); err != nil { + h.log.Error("begin login", "error", err) + writeError(w, r, http.StatusBadGateway, CodeInternal, + "could not start the login flow with the identity provider", nil) + } +} + +// Callback completes the OIDC login flow. +func (h *AuthHandlers) Callback(w http.ResponseWriter, r *http.Request) { + if h.authenticator == nil || !h.authenticator.ModeConfigured() { + writeError(w, r, http.StatusNotFound, CodeNotConfigured, + "this deployment does not use interactive login", nil) + return + } + + login, err := h.authenticator.Complete(r.Context(), r) + if err != nil { + // A failed login is a normal occurrence, not a server fault: the user may + // have declined consent or the state cookie may have expired. Log it and + // show a clear message rather than a stack trace. + if errors.Is(err, domain.ErrUnauthorized) { + h.log.Info("login rejected", "reason", err.Error(), "ip", clientIPFrom(r.Context())) + writeError(w, r, http.StatusUnauthorized, CodeUnauthorized, + "the login could not be completed: "+cleanError(err), nil) + return + } + h.log.Error("complete login", "error", err) + writeError(w, r, http.StatusBadGateway, CodeInternal, + "the identity provider returned an unexpected response", nil) + return + } + + // The login transaction is single-use; discard it so a replayed callback + // cannot reuse the PKCE verifier. + h.authenticator.ClearState(w) + + if err := h.authenticator.IssueSession(r.Context(), w, login.Identity); err != nil { + h.log.Error("issue session", "error", err) + writeError(w, r, http.StatusInternalServerError, CodeInternal, + "could not establish a session", nil) + return + } + + h.log.Info("login succeeded", + "subject", login.Identity.Subject, + "email", login.Identity.Email, + "roles", login.Identity.Roles, + "ip", clientIPFrom(r.Context()), + ) + + // The destination comes from the transaction cookie, which was sanitised when + // the login began and signed, so it cannot have been influenced by whatever + // the provider chose to append to the callback URL. + //nolint:gosec // G710: the value is not the raw request parameter. It is the + // sanitised destination the signed transaction cookie carried, and SafeReturnTo + // is the only way to produce one: it rejects absolute URLs, protocol-relative + // URLs, backslashes and control characters, and falls back to "/" otherwise. + http.Redirect(w, r, auth.SafeReturnTo(login.ReturnTo), http.StatusFound) +} + +// Logout ends the session and, when an OIDC provider is configured, the provider +// session too. +func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) { + if h.authenticator != nil { + identity := mustIdentity(r.Context()) + h.authenticator.ClearSession(w) + if identity.Method != "anonymous" { + h.log.Info("logout", "subject", identity.Subject, "ip", clientIPFrom(r.Context())) + } + } + + if h.authenticator == nil || !h.authenticator.ProviderEnabled() { + http.Redirect(w, r, "/", http.StatusFound) + return + } + + target := h.authenticator.LogoutURL(h.config.PostLogoutRedirectURL()) + if target == "" { + http.Redirect(w, r, "/", http.StatusFound) + return + } + http.Redirect(w, r, target, http.StatusFound) +} + +// Me returns the current session, if any. +// +// It always answers 200 with an explicit authenticated flag rather than 401 when +// there is no session, because "not logged in yet" is the normal state on first +// load and is not an error the UI should have to handle as one. +func (h *AuthHandlers) Me(w http.ResponseWriter, r *http.Request) { + identity, err := identityFrom(r.Context()) + authenticated := err == nil && identity != nil && identity.Method != "anonymous" + + payload := map[string]any{ + "authenticated": authenticated, + "mode": h.authenticator.Mode(), + } + if authenticated { + payload["identity"] = identity + payload["permissions"] = permissionsFor(identity) + // Reported with the session because the UI needs it to decide what to + // paint on the first frame. A failure is reported as no choice rather than + // as an error: a theme is a preference, not something worth refusing a + // login over. + if h.store != nil { + if prefs, err := h.store.Preferences(r.Context(), identity.Subject); err != nil { + h.log.Warn("read preferences for session", "subject", identity.Subject, "error", err) + payload["preferences"] = domain.UserPreferences{} + } else { + payload["preferences"] = prefs + } + } + } + if h.authenticator != nil && h.authenticator.DevBypassEnabled() { + payload["devBypass"] = true + } + if h.authenticator != nil && h.authenticator.ProviderEnabled() { + // Deliberately this service's /auth/login rather than the provider's + // authorization endpoint. A direct link to the provider would skip the + // transaction cookie and the PKCE verifier, so the callback could not be + // validated and the login would fail. + payload["loginUrl"] = h.config.Path("/auth/login") + "?returnTo=%2F" + } + + writeJSON(w, r, http.StatusOK, payload) +} + +// Config returns the client-visible configuration the UI needs before login. +func (h *AuthHandlers) Config(w http.ResponseWriter, r *http.Request) { + payload := map[string]any{ + "mode": h.authenticator.Mode(), + "issuer": h.config.OIDCIssuer, + "runtimeBase": h.config.CDNBasis(), + "publicBase": h.config.Origin(), + "basePath": h.config.BasePath, + "runtimeApiVersion": tokenengine.RuntimeAPIVersion, + "variablePrefix": tokenengine.VariablePrefix, + "defaultTheme": "", + } + writeJSON(w, r, http.StatusOK, payload) +} + +// permissionsFor describes what the identity may do, so the UI can hide controls +// the API would reject anyway. The server still enforces every check: this is a +// convenience, not a security boundary. +func permissionsFor(identity *domain.Identity) map[string]bool { + role := identity.HighestRole() + return map[string]bool{ + "read": role.AtLeast(domain.RoleViewer), + "write": role.AtLeast(domain.RoleEditor), + "publish": role.AtLeast(domain.RoleEditor), + "delete": role.AtLeast(domain.RoleAdmin), + "manageKeys": role.AtLeast(domain.RoleAdmin), + "manageUsers": role.AtLeast(domain.RoleAdmin), + "manageSettings": role.AtLeast(domain.RoleAdmin), + } +} + +// -------------------------------------------------------- health probes -- + +// HealthHandlers serves the liveness and readiness probes. +type HealthHandlers struct { + store *store.Store + cache *ThemeCache + version string + config config.Config + // started is used to report uptime. + started time.Time +} + +// NewHealthHandlers builds the probe handlers. +func NewHealthHandlers(st *store.Store, cache *ThemeCache, version string, cfg config.Config) *HealthHandlers { + return &HealthHandlers{ + store: st, + cache: cache, + version: version, + config: cfg, + started: time.Now(), + } +} + +// Live is the liveness probe. It answers as long as the process can serve HTTP at +// all, and deliberately does not touch the database: a liveness probe that failed +// on a slow database would have the orchestrator restart a process that is +// perfectly healthy. +func (h *HealthHandlers) Live(w http.ResponseWriter, r *http.Request) { + writeJSON(w, r, http.StatusOK, map[string]any{ + "status": "ok", + "version": h.version, + "uptime": time.Since(h.started).Round(time.Second).String(), + }) +} + +// Ready is the readiness probe. It reports whether the service can actually serve +// traffic, which means the database must answer within a bounded time. +func (h *HealthHandlers) Ready(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + + checks := map[string]any{} + ready := true + + if err := h.store.Ping(ctx); err != nil { + ready = false + checks["database"] = map[string]any{"status": "error", "error": cleanError(err)} + } else { + entry := map[string]any{"status": "ok"} + if h.store.InMemory() { + // An in-memory database is legitimate in a test or a throwaway + // container but never in production, so it is surfaced loudly rather + // than silently accepted. + entry["warning"] = "database is in-memory; all data is lost on restart" + } + checks["database"] = entry + } + + if stats, err := h.store.Stats(ctx); err == nil { + checks["themes"] = map[string]any{ + "status": "ok", + "count": stats.Themes, + "databaseBytes": stats.DatabaseSize, + } + } + + if h.cache != nil { + checks["cache"] = map[string]any{"status": "ok", "entries": h.cache.Stats().Entries} + } + + status := http.StatusOK + state := "ready" + if !ready { + status = http.StatusServiceUnavailable + state = "not ready" + } + + writeJSON(w, r, status, map[string]any{"status": state, "checks": checks}) +} diff --git a/internal/httpapi/cache.go b/internal/httpapi/cache.go new file mode 100644 index 0000000..6b8143b --- /dev/null +++ b/internal/httpapi/cache.go @@ -0,0 +1,287 @@ +package httpapi + +import ( + "crypto/rand" + "encoding/hex" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/urpi/go-bananas/internal/domain" + "github.com/urpi/go-bananas/internal/logging" + "github.com/urpi/go-bananas/internal/tokenengine" +) + +// Logger is an alias for the logging interface so that middleware files do not +// need to import the logging package. +type Logger = logging.Logger + +// randomHex returns n random bytes as hex. +func randomHex(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +// itoa is a small helper so middleware does not import strconv everywhere. +func itoa(v int) string { return strconv.Itoa(v) } + +// ------------------------------------------------------------- theme cache -- + +// ThemeCache memoises rendered theme artefacts. +// +// This is the single most important performance component in the service. A +// themed application requests CSS on every page view, usually from several +// pages at once; rendering a theme costs a full traversal of the token document, +// while serving a cached string costs one map lookup. Because the cache key +// includes the theme version, a publish invalidates precisely one entry and +// nothing else is disturbed. +// +// Entries are never evicted: themes are bounded by human authorship, so the +// working set is small and stable. Replacing a theme's entry happens on write, +// not on a timer, which removes any chance of serving a stale theme after a +// publish. +type ThemeCache struct { + mu sync.RWMutex + css map[string]cssEntry + json map[string]jsonEntry + metrics CacheStats + // clock allows the stats window to be advanced in tests. + now func() int64 +} + +// CacheStats reports hit rates for the metrics endpoint. +type CacheStats struct { + CSSHits int64 + CSSMisses int64 + JSONHits int64 + JSONMisses int64 + Entries int + LastEviction int64 +} + +// cssEntry is one rendered stylesheet. +type cssEntry struct { + css string + etag string + version int64 + updatedAt int64 +} + +// jsonEntry is one rendered JSON payload. +type jsonEntry struct { + payload tokenengine.ResolvedTheme + etag string + version int64 +} + +// NewThemeCache builds an empty cache. +func NewThemeCache() *ThemeCache { + return &ThemeCache{ + css: make(map[string]cssEntry, 16), + json: make(map[string]jsonEntry, 16), + now: func() int64 { return nowUnix() }, + } +} + +// CacheKey identifies one rendered artefact. +type CacheKey struct { + Slug string + Appearance domain.Appearance + // Variant distinguishes the option sets the API can render, so that a + // request for the compact stylesheet never receives the component stylesheet. + Variant string +} + +// key renders a CacheKey as a map key. +func (k CacheKey) key() string { + return k.Slug + "|" + string(k.Appearance) + "|" + k.Variant +} + +// CSS returns the cached stylesheet, rendering it with render when absent. +func (c *ThemeCache) CSS(theme *domain.Theme, opts tokenengine.RenderOptions, render func() string) (string, string) { + key := CacheKey{Slug: theme.Slug, Appearance: opts.Appearance, Variant: opts.Variant()} + + c.mu.RLock() + entry, ok := c.css[key.key()] + c.mu.RUnlock() + + if ok && entry.version == theme.Version { + atomic.AddInt64(&c.metrics.CSSHits, 1) + return entry.css, entry.etag + } + + atomic.AddInt64(&c.metrics.CSSMisses, 1) + css := render() + etag := computeETag(css) + + c.mu.Lock() + c.css[key.key()] = cssEntry{css: css, etag: etag, version: theme.Version, updatedAt: c.now()} + c.mu.Unlock() + + return css, etag +} + +// JSON returns the cached JSON payload, rendering it when absent. +func (c *ThemeCache) JSON(theme *domain.Theme, appearance domain.Appearance, render func() tokenengine.ResolvedTheme) (tokenengine.ResolvedTheme, string) { + key := CacheKey{Slug: theme.Slug, Appearance: appearance, Variant: "json"} + + c.mu.RLock() + entry, ok := c.json[key.key()] + c.mu.RUnlock() + + if ok && entry.version == theme.Version { + atomic.AddInt64(&c.metrics.JSONHits, 1) + return entry.payload, entry.etag + } + + atomic.AddInt64(&c.metrics.JSONMisses, 1) + payload := render() + etag := computeETag(theme.Slug + ":" + strconv.FormatInt(theme.Version, 10) + ":" + + string(appearance) + ":" + payload.Theme.UpdatedAt) + + c.mu.Lock() + c.json[key.key()] = jsonEntry{payload: payload, etag: etag, version: theme.Version} + c.mu.Unlock() + + return payload, etag +} + +// Invalidate drops every cached artefact for one slug. +func (c *ThemeCache) Invalidate(slug string) int { + c.mu.Lock() + defer c.mu.Unlock() + + removed := 0 + for key := range c.css { + if strings.HasPrefix(key, slug+"|") { + delete(c.css, key) + removed++ + } + } + for key := range c.json { + if strings.HasPrefix(key, slug+"|") { + delete(c.json, key) + removed++ + } + } + c.metrics.LastEviction = c.now() + return removed +} + +// Prefetch renders and caches a theme for both appearances, so that the first +// request after a deploy is already warm. It is called once per theme at +// start-up. +func (c *ThemeCache) Prefetch(theme *domain.Theme, options func(tokenengine.RenderOptions)) int { + warmed := 0 + for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} { + for _, variant := range tokenengine.VariantNames() { + opts := tokenengine.RenderOptions{ + Slug: theme.Slug, + Appearance: appearance, + Components: variant == tokenengine.VariantFull, + Derive: tokenengine.DefaultDerive(), + } + if options != nil { + options(opts) + } + themeCopy := theme + _, _ = c.CSS(themeCopy, opts, func() string { + return tokenengine.Render(themeCopy, opts) + }) + warmed++ + + _, _ = c.JSON(themeCopy, appearance, func() tokenengine.ResolvedTheme { + return tokenengine.JSONPayload(themeCopy, appearance) + }) + } + } + return warmed +} + +// Stats returns a snapshot of cache counters. +func (c *ThemeCache) Stats() CacheStats { + c.mu.RLock() + entries := len(c.css) + len(c.json) + c.mu.RUnlock() + + return CacheStats{ + CSSHits: atomic.LoadInt64(&c.metrics.CSSHits), + CSSMisses: atomic.LoadInt64(&c.metrics.CSSMisses), + JSONHits: atomic.LoadInt64(&c.metrics.JSONHits), + JSONMisses: atomic.LoadInt64(&c.metrics.JSONMisses), + Entries: entries, + LastEviction: atomic.LoadInt64(&c.metrics.LastEviction), + } +} + +// Flush empties the cache. It exists for tests and for the admin "rebuild cache" +// action. +func (c *ThemeCache) Flush() { + c.mu.Lock() + defer c.mu.Unlock() + clear(c.css) + clear(c.json) +} + +// computeETag derives a strong validator from a payload. +// +// A content hash is the right choice here rather than a version counter: two +// themes with different slugs can produce byte-identical CSS, and a content +// hash lets a shared CDN deduplicate them. It is also self-correcting, because a +// validator can never disagree with the body it describes. +func computeETag(content string) string { + return `"` + shortHash(content) + `"` +} + +// shortHash returns a 128-bit hash rendered as hex. +func shortHash(content string) string { + // FNV-1a over two offset bases gives a 128-bit value using only the standard + // library. The collision risk is irrelevant for cache validation, where a + // collision would at worst serve one theme's bytes for another's. + const ( + offset64 = 1469598103934665603 + prime64 = 1099511628211 + ) + var a, b uint64 = offset64, offset64 ^ 0x9e3779b97f4a7c15 + for i := 0; i < len(content); i++ { + a ^= uint64(content[i]) + a *= prime64 + b ^= uint64(content[len(content)-1-i]) + b *= prime64 + } + var out [16]byte + // The conversion to one byte per eight bits of the digest is the point of this + // function: it produces a fixed-width, hex-encodable cache key. Discarding the + // upper bits cannot overflow, because the shift count is bounded by the loop + // bound, and it costs no key entropy that matters for a content-addressed key. + // + //nolint:gosec // G115: see above. This is a digest truncation, not a narrowing + // of a length or a count. + for i := 0; i < 8; i++ { + out[i] = byte(a >> (8 * i)) + out[8+i] = byte(b >> (8 * i)) + } + return hex.EncodeToString(out[:]) +} + +// nowUnix returns the current Unix time in seconds. +func nowUnix() int64 { return timeNow().Unix() } + +// sortedKeys returns a map's keys in sorted order, for stable output. +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// timeNow is the clock used by the cache, isolated so tests can reason about it. +func timeNow() time.Time { return time.Now() } diff --git a/internal/httpapi/embed_test.go b/internal/httpapi/embed_test.go new file mode 100644 index 0000000..c745cd6 --- /dev/null +++ b/internal/httpapi/embed_test.go @@ -0,0 +1,225 @@ +package httpapi_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// embed is the decoded shape of /rt/v1/embed. +type embed struct { + Slug string `json:"slug"` + Name string `json:"name"` + Version int64 `json:"version"` + Language string `json:"language"` + Snippet string `json:"snippet"` + Note string `json:"note"` + BaseURL string `json:"baseUrl"` +} + +// embedFor requests the snippet endpoint and fails the test unless it answers 200 +// with JSON. +func embedFor(t *testing.T, h http.Handler, query string) embed { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed?"+query, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("content type %q, want JSON. The admin UI fetches this with a JSON parser.", ct) + } + var out embed + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("response is not JSON: %v", err) + } + return out +} + +// TestEmbedAlwaysAnswersJSON is the regression test for the bug that made the +// Integrate page unusable: the default target answered text/html while the admin +// UI asked for JSON, so every load reported "the server returned a response that +// could not be parsed". +func TestEmbedAlwaysAnswersJSON(t *testing.T) { + h := newRuntimeRouter(t) + + for _, target := range []string{"html", "react", "javascript", ""} { + t.Run("target="+target, func(t *testing.T) { + query := "slug=midnight" + if target != "" { + query += "&target=" + target + } + got := embedFor(t, h, query) + if got.Snippet == "" { + t.Error("snippet is empty") + } + }) + } +} + +// TestEmbedRejectsUnknownTheme checks that a snippet is never generated for a theme +// that does not exist. The endpoint used to echo back whatever slug it was given. +func TestEmbedRejectsUnknownTheme(t *testing.T) { + h := newRuntimeRouter(t) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed?slug=no-such-theme", nil)) + if rec.Code != http.StatusNotFound { + t.Errorf("got %d, want 404: %s", rec.Code, rec.Body.String()) + } +} + +// TestEmbedRejectsMalformedSlug covers the shape check. Slugs are interpolated into +// generated source, so a slug carrying a quote has to be refused at the door rather +// than trusted to be escaped later. +func TestEmbedRejectsMalformedSlug(t *testing.T) { + h := newRuntimeRouter(t) + + // Each of these breaks out of a JavaScript string literal or a CSS selector if + // it is not rejected. + slugs := []string{ + "x';fetch('//evil.example')//", + `x"+alert(1)+"`, + "x", + "../etc/passwd", + "UPPERCASE", + "has space", + "trailing-", + "-leading", + } + + for _, slug := range slugs { + t.Run(slug, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, + "/rt/v1/embed?slug="+urlQueryEscape(slug)+"&target=javascript", nil)) + if rec.Code == http.StatusOK { + t.Errorf("accepted a malformed slug and returned a snippet: %s", rec.Body.String()) + } + }) + } +} + +// urlQueryEscape percent-encodes a query parameter value. +func urlQueryEscape(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~' { + b.WriteByte(c) + continue + } + const hexDigits = "0123456789ABCDEF" + b.WriteByte('%') + b.WriteByte(hexDigits[c>>4]) + b.WriteByte(hexDigits[c&0x0f]) + } + return b.String() +} + +// TestEmbedSnippetsAreSyntacticallyPlausible is the property that actually matters: +// the snippet a developer pastes has to be parseable code for its language. A +// balanced-quote check is a cheap proxy that catches the whole class of escaping +// bug without pulling in a JavaScript parser. +func TestEmbedSnippetsAreSyntacticallyPlausible(t *testing.T) { + h := newRuntimeRouter(t) + + for _, target := range []string{"html", "react", "javascript"} { + t.Run(target, func(t *testing.T) { + got := embedFor(t, h, "slug=midnight&target="+target) + assertBalancedSingleQuotes(t, got.Snippet) + assertBalancedBackticks(t, got.Snippet) + if strings.Count(got.Snippet, "\n") < 2 { + t.Errorf("snippet looks truncated:\n%s", got.Snippet) + } + }) + } +} + +// assertBalancedSingleQuotes fails when a snippet has an odd number of single +// quotes outside a comment, which is what an unescaped value looks like. +func assertBalancedSingleQuotes(t *testing.T, snippet string) { + t.Helper() + inSingle, inDouble, inBacktick := false, false, false + for i := 0; i < len(snippet); i++ { + c := snippet[i] + switch { + case c == '\\' && (inSingle || inDouble || inBacktick): + i++ // skip the escaped character + case c == '\'' && !inDouble && !inBacktick: + inSingle = !inSingle + case c == '"' && !inSingle && !inBacktick: + inDouble = !inDouble + case c == '`' && !inSingle && !inDouble: + inBacktick = !inBacktick + } + } + if inSingle || inDouble || inBacktick { + t.Errorf("snippet has an unterminated string literal:\n%s", snippet) + } +} + +// assertBalancedBackticks fails on an odd number of backticks. +func assertBalancedBackticks(t *testing.T, snippet string) { + t.Helper() + if strings.Count(snippet, "`")%2 != 0 { + t.Errorf("snippet has an unbalanced backtick:\n%s", snippet) + } +} + +// TestEmbedRejectsUnknownTarget checks that a typo is reported rather than silently +// returning the HTML page. +func TestEmbedRejectsUnknownTarget(t *testing.T) { + h := newRuntimeRouter(t) + + for _, target := range []string{"xml", "HTML", "ts", ".."} { + t.Run(target, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, + "/rt/v1/embed?slug=midnight&target="+urlQueryEscape(target), nil)) + if rec.Code != http.StatusBadRequest { + t.Errorf("got %d, want 400: %s", rec.Code, rec.Body.String()) + } + }) + } +} + +// TestEmbedIsNotCacheable guards the header. Every snippet embeds this deployment's +// absolute URL, so a cached copy would keep serving the wrong origin after a +// hostname change. +func TestEmbedIsNotCacheable(t *testing.T) { + h := newRuntimeRouter(t) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed?slug=midnight", nil)) + if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { + t.Errorf("Cache-Control %q, want no-store", cc) + } +} + +// TestEmbedPageIsHTML checks the standalone page is still reachable and still HTML, +// since it is a separate handler serving a different kind of caller. +func TestEmbedPageIsHTML(t *testing.T) { + h := newRuntimeRouter(t) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed-page?slug=midnight", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("content type %q, want text/html", ct) + } + body := rec.Body.String() + if !strings.Contains(body, "") { + t.Error("not an HTML document") + } + // The page must not inline a raw script from the slug: every sample is escaped + // for display inside a
.
+	if strings.Contains(body, "`,
+	}, "\n")
+}
+
+// javascriptIntegrationSnippet is the same thing without the stylesheet link.
+func javascriptIntegrationSnippet(base, slug string) string {
+	return strings.Join([]string{
+		`import { applyTheme, watchSystemAppearance } from ` + jsString(base+"/runtime.js") + `;`,
+		``,
+		`applyTheme(` + jsString(slug) + `);`,
+		`watchSystemAppearance();`,
+	}, "\n")
+}
+
+// reactIntegrationSnippet wires the provider component.
+func reactIntegrationSnippet(base, slug string) string {
+	return strings.Join([]string{
+		``,
+		``,
+	}, "\n")
+}
+
+// EmbedPage serves a standalone, shareable page that shows the snippet and renders
+// a live preview of the theme.
+//
+// It is separate from EmbedSnippet because the two have different callers: this one
+// is a page a human opens, the other is data the admin UI fetches. Having one serve
+// both is what produced the parse failure that motivated the split.
+//
+// Every code sample on the page comes from the same builders EmbedSnippet uses, so
+// what a developer copies here is byte-for-byte what the admin UI shows.
+func (h *RuntimeHandlers) EmbedPage(w http.ResponseWriter, r *http.Request) {
+	slug := strings.TrimSpace(r.URL.Query().Get("slug"))
+	if slug == "" {
+		slug = themeSlugFromPath(r)
+	}
+	if slug == "" {
+		if themes, err := h.store.ListRuntime(r.Context()); err == nil && len(themes) > 0 {
+			slug = themes[0].Slug
+		}
+	}
+	if slug == "" {
+		writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
+		return
+	}
+	if !isSlugShaped(slug) {
+		writeError(w, r, http.StatusBadRequest, CodeBadRequest, "invalid theme slug", nil)
+		return
+	}
+
+	base := h.config.CDNBasis()
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.Header().Set("Cache-Control", "no-store")
+	//nolint:gosec // G705: embedPageHTML escapes the slug with htmlEscape at every
+	// interpolation point, and the code samples it embeds come from the same builders
+	// the JSON endpoint uses. gosec's taint analysis does not follow those calls.
+	_, _ = w.Write([]byte(embedPageHTML(base, slug)))
+}
+
+// embedPageHTML builds the standalone integration page.
+func embedPageHTML(base, slug string) string {
+	samples := []struct {
+		title string
+		body  string
+	}{
+		{"Any HTML page", htmlIntegrationSnippet(base, slug)},
+		{"Plain JavaScript", javascriptIntegrationSnippet(base, slug)},
+		{"React", reactIntegrationSnippet(base, slug)},
+	}
+
+	sections := make([]string, 0, len(samples))
+	for i, sample := range samples {
+		sections = append(sections, `  
+

`+htmlEscape(sample.title)+`

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

Integrate ` + htmlEscape(slug) + `

+

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

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

Live preview

+
+ + + +
+
Card heading
+

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

+
+

published + draft + archived

+ +
+
+
+ + +` +} + +// htmlEscape escapes a string for interpolation into HTML text or an attribute. +// +// Every interpolated value here is a slug or a base URL, both of which the +// operator controls, but escaping them anyway means a malformed configuration +// cannot turn the snippet page into an injection vector. +func htmlEscape(s string) string { + return html.EscapeString(s) +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..111bbc4 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,130 @@ +// Package logging configures structured logging for the service. +// +// It wraps log/slog rather than a third-party logger so that the output is +// plain JSON or plain text with no vendor in the dependency graph, and so that +// the level and format can be chosen from the environment the Helm chart sets. +package logging + +import ( + "context" + "io" + "log/slog" + "os" + "strings" +) + +// Logger is the logging interface the application depends on. Declaring it here +// keeps handlers and middleware testable with a recorder, and keeps them from +// importing slog directly. +type Logger interface { + Debug(msg string, args ...any) + Info(msg string, args ...any) + Warn(msg string, args ...any) + Error(msg string, args ...any) + With(args ...any) Logger +} + +// Options configures a logger. +type Options struct { + // Level is debug, info, warn or error. + Level string + // Format is json or text. + Format string + // Service is the value of the service.name attribute. + Service string + // Environment is the deployment name. + Environment string + // Version is the build version, reported in logs. + Version string + // Commit is the source revision, reported in logs. + Commit string + // BuildDate is the build timestamp, reported in logs. + BuildDate string + // Writer overrides the destination, for tests. + Writer io.Writer +} + +// New builds a logger. +func New(opts Options) Logger { + writer := opts.Writer + if writer == nil { + writer = os.Stdout + } + + handlerOpts := &slog.HandlerOptions{Level: parseLevel(opts.Level)} + + var handler slog.Handler + if strings.EqualFold(opts.Format, "text") { + handler = slog.NewTextHandler(writer, handlerOpts) + } else { + handler = slog.NewJSONHandler(writer, handlerOpts) + } + + logger := slog.New(handler).With( + slog.String("service", orDefault(opts.Service, "Go Bananas!!")), + slog.String("environment", opts.Environment), + slog.String("version", opts.Version), + slog.String("commit", orDefault(opts.Commit, "unknown")), + slog.String("built", orDefault(opts.BuildDate, "unknown")), + ) + return &slogLogger{logger: logger} +} + +// parseLevel maps a configuration string onto a slog level, defaulting to info +// so that a typo never silences the service. +func parseLevel(raw string) slog.Level { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +// orDefault returns value when it is not empty, otherwise fallback. +func orDefault(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +// slogLogger adapts *slog.Logger to the Logger interface. +type slogLogger struct { + logger *slog.Logger +} + +func (l *slogLogger) Debug(msg string, args ...any) { l.logger.Debug(msg, args...) } +func (l *slogLogger) Info(msg string, args ...any) { l.logger.Info(msg, args...) } +func (l *slogLogger) Warn(msg string, args ...any) { l.logger.Warn(msg, args...) } +func (l *slogLogger) Error(msg string, args ...any) { l.logger.Error(msg, args...) } + +func (l *slogLogger) With(args ...any) Logger { + return &slogLogger{logger: l.logger.With(args...)} +} + +// Discard returns a logger that drops everything. It exists so that tests and +// one-off commands do not have to thread a logger through just to satisfy the +// dependency. +func Discard() Logger { + return &slogLogger{logger: slog.New(slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 8}))} +} + +// FromContext returns the logger stored on a context, or a discarding logger. +func FromContext(ctx context.Context) Logger { + if logger, ok := ctx.Value(loggerKey{}).(Logger); ok { + return logger + } + return Discard() +} + +type loggerKey struct{} + +// WithContext stores a logger on a context. +func WithContext(ctx context.Context, logger Logger) context.Context { + return context.WithValue(ctx, loggerKey{}, logger) +} diff --git a/internal/seed/consolidated.go b/internal/seed/consolidated.go new file mode 100644 index 0000000..dad8a03 --- /dev/null +++ b/internal/seed/consolidated.go @@ -0,0 +1,605 @@ +package seed + +import ( + "github.com/urpi/go-bananas/internal/domain" + "github.com/urpi/go-bananas/internal/tokenengine" +) + +// Palettes consolidated from the sibling applications. +// +// Each of these is a real palette lifted from an application that is already running +// with it, rather than a generated ramp. That is the point of consolidating them: the +// applications can adopt the shared contract and keep the look their users already +// have, instead of being handed a new design. +// +// The colours are the roles those applications actually use, mapped onto the contract's +// roles. A source application had only one appearance for some roles and a different +// one for others, so both schemes are filled in here and the contract supplies the +// second axis. +// +// Provenance is recorded in the description. A palette nobody can trace back to the +// screen it came from becomes an orphan within a year. + +// Colours and values repeated across the palettes below. +// +// A consolidated palette set is mostly repetition by nature — every dark scheme has +// the same white for reversed text, every go-loose theme has the same card radius — +// so these are named rather than repeated inline. That makes a change to "what white +// means here" a one-line edit instead of a search. +const ( + white = "#ffffff" + paperLight = "#fffaf0" + cardRadius = "16px" + // tightRadius is the corner radius of the two palettes whose source application + // uses tighter geometry than go-loose's 16px cards. + tightRadius = "10px" + + // interStack is the sans stack four of the six applications ask for by name. None + // of them load the file, so in practice this resolves to the system UI font; it + // is kept because it is what the applications declare, and a future font load + // should not have to touch six stylesheets. + interStack = "'Inter', ui-sans-serif, system-ui, sans-serif" +) + +// scheme is one appearance's worth of literal colours. +type scheme struct { + Background string + Surface string + SurfaceRaised string + SurfaceSunken string + Border string + BorderStrong string + Text string + TextMuted string + TextSubtle string + TextInverted string + Primary string + PrimaryHover string + PrimaryActive string + OnPrimary string + Secondary string + OnSecondary string + Success string + Warning string + Danger string + Info string + FocusRing string + Overlay string + Ramp tokenengine.RampOptions + FontSans string + RadiusCard string +} + +// consolidate assembles a seeded theme from two literal schemes. +// +// Ramps are still generated, for the palette and status families the contract exposes +// as scales (`--bn-palette-*`). The semantic roles are literal, because those are the +// values the source applications actually render and inventing a ramp here would +// change the appearance the consolidation is meant to preserve. +func consolidate( + slug, name, description string, + defaultAppearance domain.Appearance, + tags []string, + light, dark scheme, +) SeededTheme { + palettes := map[string]any{ + roleBrand: tokenengine.GenerateRamp(light.Ramp), + roleNeutral: tokenengine.GenerateRamp(neutralRamp(light.Ramp)), + roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 142, Saturation: 55, Lightness: 42, HueSpread: 10}), + roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 38, Saturation: 88, Lightness: 50, HueSpread: 8}), + roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 358, Saturation: 68, Lightness: 50, HueSpread: 8}), + } + + fontSans := light.FontSans + if fontSans == "" { + fontSans = interStack + } + typography := typeScale(fontSans, 0.875, 1.25, 1.625) + typography["family"] = map[string]any{ + "sans": family(fontSans), + "mono": family("ui-monospace, SFMono-Regular, Menlo, monospace"), + "display": family(fontSans), + } + + shadowSet := map[string]any{ + "none": shadow("none"), + "sm": shadow("0 1px 2px rgba(0,0,0,0.06)"), + "md": shadow("0 6px 20px rgba(0,0,0,0.08)"), + "lg": shadow("0 18px 48px rgba(0,0,0,0.14)"), + "xl": shadow("0 28px 80px rgba(0,0,0,0.20)"), + "border": shadow("inset 0 0 0 1px rgba(0,0,0,0.08)"), + } + + doc := assemble(palettes, + literalSemantics(light), + literalSemantics(dark), + typography, + shadowSet, + ) + + // The transcribed corner radius becomes a real token rather than a note in a + // struct field. A consumer writing `border-radius: var(--radius-md)` then gets + // the source application's actual radius, which is the whole reason for copying + // its palette rather than approximating one. + applyCardRadius(doc, light.RadiusCard) + + return SeededTheme{ + Slug: slug, + Name: name, + Description: description, + Appearance: defaultAppearance, + Tags: tags, + Tokens: doc, + } +} + +// applyCardRadius sets the radius scale so that its `md` step is the source +// application's card radius, with the surrounding steps kept in proportion. +// +// A palette whose corners are visibly rounder or squarer than the application it came +// from does not read as that application, and the radius is one of the first things a +// user notices. +func applyCardRadius(doc domain.TokenDoc, card string) { + if card == "" { + return + } + group, ok := doc["radius"].(map[string]any) + if !ok { + group = map[string]any{} + doc["radius"] = group + } + // Proportions taken from the scale the other built-in themes use, so a + // consolidated palette and an original one are the same shape. + for step, factor := range map[string]string{ + "sm": "0.5", + "md": "1", + "lg": "1.5", + "xl": "2", + } { + if step == "md" { + group[step] = dim(card) + continue + } + group[step] = dim("calc(" + card + " * " + factor + ")") + } +} + +// neutralRamp derives a low-saturation neutral from a brand ramp, so a palette and its +// greys come from one description rather than two guesses. +func neutralRamp(brand tokenengine.RampOptions) tokenengine.RampOptions { + return tokenengine.RampOptions{ + Hue: brand.Hue, + Saturation: minFloat(brand.Saturation*0.18, 22), + Lightness: minFloat(brand.Lightness*0.72, 52), + HueSpread: 4, + } +} + +// minFloat returns the smaller of two values. +func minFloat(a, b float64) float64 { + if a < b { + return a + } + return b +} + +// literalSemantics turns a scheme into the contract's semantic colour roles. +// +// Every role is filled explicitly. A missing role would fall back to the engine's +// light-mode default, which for a dark-first palette is a pale colour in the middle of +// a dark interface — a bug that is easy to miss and impossible to debug from a +// screenshot. +func literalSemantics(s scheme) map[string]any { + out := map[string]any{ + "background": color(s.Background), + "surface": color(s.Surface), + "surface-raised": color(s.SurfaceRaised), + "surface-sunken": color(s.SurfaceSunken), + "border": color(s.Border), + "border-strong": color(s.BorderStrong), + "text": color(s.Text), + "text-muted": color(s.TextMuted), + "text-subtle": color(s.TextSubtle), + "text-inverted": color(s.TextInverted), + "primary": color(s.Primary), + "primary-hover": color(s.PrimaryHover), + "primary-active": color(s.PrimaryActive), + "on-primary": color(s.OnPrimary), + "secondary": color(s.Secondary), + "on-secondary": color(s.OnSecondary), + "success": color(s.Success), + "warning": color(s.Warning), + "danger": color(s.Danger), + "info": color(s.Info), + "focus-ring": color(s.FocusRing), + "overlay": color(s.Overlay), + } + return out +} + +// The four palettes below come from four different applications. The colours are +// theirs, transcribed; where an application had only one appearance, the other is +// derived by inverting the surface and text roles and keeping the accent family, which +// is what its own stylesheet effectively does. + +// tokyoNight is go-loose's default palette, the one its switcher opens on. +// +// The slug is `tokyo`, not `tokyo-night`, because that is the value go-loose already +// writes into its data-theme attribute. Matching it is the whole point: adopting the +// contract must not require the adopting application to change a single stored value. +func tokyoNight() SeededTheme { + blue := tokenengine.RampOptions{Hue: 224, Saturation: 60, Lightness: 58, HueSpread: 10} + + return consolidate( + "tokyo", "Tokyo Night", + "go-loose's default palette. Low-contrast blue-grey with a bright accent; "+ + "chosen for long sessions against a terminal.", + domain.AppearanceDark, + []string{appearanceDark, tagDefault, "blue", "go-loose"}, + scheme{ + Background: "#e6e7ed", Surface: white, SurfaceRaised: white, + SurfaceSunken: "#dfe2ed", Border: "#b4b5c3", BorderStrong: "#8b8da1", + Text: "#343b58", TextMuted: "#34548a", TextSubtle: "#4a5a80", + TextInverted: white, + Primary: "#0f4b6e", PrimaryHover: "#0c3d5a", PrimaryActive: "#093249", + OnPrimary: white, + Secondary: "#5a4a78", OnSecondary: white, + Success: "#485e30", Warning: "#8a6a1f", Danger: "#8c4351", Info: "#2f6b8f", + FocusRing: "#0f4b6e", Overlay: "rgba(52, 59, 88, 0.32)", + Ramp: blue, FontSans: interStack, + RadiusCard: cardRadius, + }, + scheme{ + Background: "#1a1b26", Surface: "#20222f", SurfaceRaised: "#282a37", + SurfaceSunken: "#16161e", Border: "#292e42", BorderStrong: "#3b4261", + Text: "#c0caf5", TextMuted: "#9aa5ce", TextSubtle: "#7aa2f7", + TextInverted: "#1a1b26", + Primary: "#7aa2f7", PrimaryHover: "#93b1ff", PrimaryActive: "#aac7ff", + OnPrimary: "#1a1b26", + Secondary: "#bb9af7", OnSecondary: "#1a1b26", + Success: "#9ece6a", Warning: "#e0af68", Danger: "#f7768e", Info: "#7dcfff", + FocusRing: "#7aa2f7", Overlay: "rgba(0, 0, 0, 0.55)", + Ramp: blue, FontSans: interStack, + RadiusCard: cardRadius, + }, + ) +} + +// catppuccin is go-loose's second palette, a soft pastel set. +func catppuccin() SeededTheme { + lavender := tokenengine.RampOptions{Hue: 232, Saturation: 62, Lightness: 62, HueSpread: 12} + + return consolidate( + "catppuccin", "Catppuccin", + "go-loose's pastel palette. Softer contrast than Tokyo Night, which suits a "+ + "content-heavy screen better than a dense one.", + domain.AppearanceDark, + []string{appearanceDark, "pastel", "lavender", "go-loose"}, + scheme{ + Background: "#eff1f5", Surface: white, SurfaceRaised: white, + SurfaceSunken: "#e6e9ef", Border: "#bcc0cc", BorderStrong: "#9499b0", + Text: "#4c4f69", TextMuted: "#5c61a8", TextSubtle: "#6c6f85", + TextInverted: white, + Primary: "#1e66f5", PrimaryHover: "#1a56db", PrimaryActive: "#1647b8", + OnPrimary: white, + Secondary: "#8839ef", OnSecondary: white, + Success: "#40a02b", Warning: "#df8e1d", Danger: "#d20f39", Info: "#179299", + FocusRing: "#1e66f5", Overlay: "rgba(76, 79, 105, 0.28)", + Ramp: lavender, FontSans: interStack, + RadiusCard: cardRadius, + }, + scheme{ + Background: "#1e1e2e", Surface: "#181825", SurfaceRaised: "#313244", + SurfaceSunken: "#11111b", Border: "#313244", BorderStrong: "#45475a", + Text: "#cdd6f4", TextMuted: "#a6adc8", TextSubtle: "#9399b2", + TextInverted: "#1e1e2e", + Primary: "#89b4fa", PrimaryHover: "#a3c3f5", PrimaryActive: "#b8d0f6", + OnPrimary: "#1e1e2e", + Secondary: "#cba6f7", OnSecondary: "#1e1e2e", + Success: "#a6e3a1", Warning: "#f9e2af", Danger: "#f38ba8", Info: "#94e2d5", + FocusRing: "#89b4fa", Overlay: "rgba(0, 0, 0, 0.5)", + Ramp: lavender, FontSans: interStack, + RadiusCard: cardRadius, + }, + ) +} + +// nordicMoss is go-tell's green palette, slugged `nordic` to match its own value. Its source theme is dark-only; the light +// scheme is the inversion with the green family kept. +func nordicMoss() SeededTheme { + moss := tokenengine.RampOptions{Hue: 132, Saturation: 32, Lightness: 44, HueSpread: 12} + + return consolidate( + "nordic", "Nordic Moss", + "go-tell's green palette. Desaturated and low-contrast; the light scheme is "+ + "derived, because the source theme is dark-only.", + domain.AppearanceDark, + []string{appearanceDark, "green", "calm", "go-tell"}, + scheme{ + Background: "#e9f0e8", Surface: white, SurfaceRaised: "#f4f8f3", + SurfaceSunken: "#dfe9de", Border: "#c2d6c4", BorderStrong: "#94a899", + Text: "#16241c", TextMuted: "#3b5645", TextSubtle: "#588157", + TextInverted: white, + Primary: "#2e4435", PrimaryHover: "#25392d", PrimaryActive: "#1a2820", + OnPrimary: "#e9f0e8", + Secondary: "#6f894e", OnSecondary: white, + Success: "#3f6b46", Warning: "#8a7434", Danger: "#a1453f", Info: "#3a6a70", + FocusRing: "#2e4435", Overlay: "rgba(22, 36, 28, 0.28)", + Ramp: moss, FontSans: interStack, + RadiusCard: tightRadius, + }, + scheme{ + Background: "#121c16", Surface: "#1e3025", SurfaceRaised: "#25392d", + SurfaceSunken: "#0e1712", Border: "#2e4435", BorderStrong: "#3b5645", + Text: "#e9f0e8", TextMuted: "#a3c9a8", TextSubtle: "#8fa193", + TextInverted: "#121c16", + Primary: "#a3c9a8", PrimaryHover: "#b8d8bb", PrimaryActive: "#c9e2cb", + OnPrimary: "#121c16", + Secondary: "#8fbc8f", OnSecondary: "#121c16", + Success: "#8fbc8f", Warning: "#e2c044", Danger: "#e07a72", Info: "#7fb4ca", + FocusRing: "#a3c9a8", Overlay: "rgba(0, 0, 0, 0.52)", + Ramp: moss, FontSans: interStack, + RadiusCard: tightRadius, + }, + ) +} + +// deepSpace is go-tell's `obsidian` palette, and the one that most needs a light +// scheme: its source is a near-black space theme, so an untouched inversion would be +// unusable. +func deepSpace() SeededTheme { + sky := tokenengine.RampOptions{Hue: 199, Saturation: 88, Lightness: 58, HueSpread: 12} + + return consolidate( + "obsidian", "Deep Space Obsidian", + "go-tell's near-black blue palette. The light scheme is authored rather than "+ + "inverted, because the source is a dark-first space theme.", + domain.AppearanceDark, + []string{appearanceDark, "blue", "space", "go-tell"}, + scheme{ + Background: "#f1f5f9", Surface: white, SurfaceRaised: "#f8fafc", + SurfaceSunken: "#e8eef5", Border: "#d3dce6", BorderStrong: "#a8b6c6", + Text: "#0f172a", TextMuted: "#475569", TextSubtle: "#64748b", + TextInverted: "#f8fafc", + Primary: "#0284c7", PrimaryHover: "#0369a1", PrimaryActive: "#075985", + OnPrimary: white, + Secondary: "#1e293b", OnSecondary: "#f8fafc", + Success: "#15803d", Warning: "#b45309", Danger: "#b91c1c", Info: "#0e7490", + FocusRing: "#0284c7", Overlay: "rgba(15, 23, 42, 0.3)", + Ramp: sky, FontSans: interStack, + RadiusCard: tightRadius, + }, + scheme{ + Background: "#060709", Surface: "#0f172a", SurfaceRaised: "#151d2a", + SurfaceSunken: "#020617", Border: "#1e293b", BorderStrong: "#334155", + Text: "#e2e8f0", TextMuted: "#94a3b8", TextSubtle: "#64748b", + TextInverted: "#060709", + Primary: "#38bdf8", PrimaryHover: "#7dd3fc", PrimaryActive: "#bae6fd", + OnPrimary: "#060709", + Secondary: "#60a5fa", OnSecondary: "#060709", + Success: "#4ade80", Warning: "#fbbf24", Danger: "#f87171", Info: "#22d3ee", + FocusRing: "#38bdf8", Overlay: "rgba(0, 0, 0, 0.6)", + Ramp: sky, FontSans: interStack, + RadiusCard: tightRadius, + }, + ) +} + +// urpiStandard is go-guess's palette, which is the only light-first application of the +// six and therefore the one whose light scheme is the authoritative one. +func urpiStandard() SeededTheme { + cyan := tokenengine.RampOptions{Hue: 190, Saturation: 78, Lightness: 40, HueSpread: 10} + + return consolidate( + "urpi-standard", "urpi Standard", + "go-guess's palette: a cyan accent and lime highlight on a cool grey. The "+ + "light scheme is authoritative, being the application's only one.", + domain.AppearanceLight, + []string{"light", tagDefault, "cyan", "go-guess"}, + scheme{ + Background: "#f3f7f8", Surface: white, SurfaceRaised: white, + SurfaceSunken: "#edf2f3", Border: "#dce5e8", BorderStrong: "#c2d2d6", + Text: "#142b3a", TextMuted: "#526872", TextSubtle: "#78909c", + TextInverted: white, + Primary: "#007f98", PrimaryHover: "#006b81", PrimaryActive: "#00586a", + OnPrimary: white, + Secondary: "#d9ef4d", OnSecondary: "#0b573c", + Success: "#13704d", Warning: "#925a09", Danger: "#934141", Info: "#087187", + FocusRing: "#007f98", Overlay: "rgba(14, 42, 56, 0.34)", + Ramp: cyan, FontSans: "'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif", + RadiusCard: cardRadius, + }, + scheme{ + Background: "#081c2c", Surface: "#0e2a3f", SurfaceRaised: "#123449", + SurfaceSunken: "#04131f", Border: "#1d4258", BorderStrong: "#2c5f7a", + Text: "#e3ebed", TextMuted: "#a9bec8", TextSubtle: "#82949d", + TextInverted: "#081c2c", + Primary: "#00a9c8", PrimaryHover: "#33bcd6", PrimaryActive: "#66cde0", + OnPrimary: "#04131f", + Secondary: "#d9ef4d", OnSecondary: "#0b573c", + Success: "#16a274", Warning: "#eda83c", Danger: "#e6988c", Info: "#43c99c", + FocusRing: "#00a9c8", Overlay: "rgba(0, 0, 0, 0.55)", + Ramp: cyan, FontSans: "'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif", + RadiusCard: cardRadius, + }, + ) +} + +// tomorrow is go-loose's third palette: a warm grey with a muted teal. +func tomorrow() SeededTheme { + warm := tokenengine.RampOptions{Hue: 40, Saturation: 8, Lightness: 42, HueSpread: 4} + teal := tokenengine.RampOptions{Hue: 178, Saturation: 34, Lightness: 40, HueSpread: 8} + + return consolidate( + "tomorrow", "Tomorrow", + "go-loose's warm grey palette. The least saturated of the four, and the "+ + "easiest to read for long periods.", + domain.AppearanceDark, + []string{appearanceDark, "warm", "muted", "go-loose"}, + scheme{ + Background: "#f5f5f5", Surface: white, SurfaceRaised: white, + SurfaceSunken: "#e8e8e8", Border: "#c9c9c9", BorderStrong: "#a3a3a3", + Text: "#4d4d4c", TextMuted: "#6e6e6c", TextSubtle: "#8a8a88", + TextInverted: white, + Primary: "#3e999f", PrimaryHover: "#348389", PrimaryActive: "#2b6d72", + OnPrimary: white, + Secondary: "#8959a8", OnSecondary: white, + Success: "#718c00", Warning: "#b58900", Danger: "#c82829", Info: "#3e999f", + FocusRing: "#3e999f", Overlay: "rgba(77, 77, 76, 0.3)", + Ramp: teal, FontSans: interStack, + RadiusCard: cardRadius, + }, + scheme{ + Background: "#1d1f21", Surface: "#282a2e", SurfaceRaised: "#373b41", + SurfaceSunken: "#1a1c1e", Border: "#4d5057", BorderStrong: "#6b6f77", + Text: "#c5c8c6", TextMuted: "#a0a4a2", TextSubtle: "#81a2be", + TextInverted: "#1d1f21", + Primary: "#8abeb7", PrimaryHover: "#a5d1cb", PrimaryActive: "#bcdfd9", + OnPrimary: "#1d1f21", + Secondary: "#b294bb", OnSecondary: "#1d1f21", + Success: "#b5bd68", Warning: "#e0af68", Danger: "#cc6666", Info: "#81a2be", + FocusRing: "#8abeb7", Overlay: "rgba(0, 0, 0, 0.52)", + Ramp: warm, FontSans: interStack, + RadiusCard: cardRadius, + }, + ) +} + +// kanagawa is go-loose's fourth palette: a warm paper light scheme and a stone dark one. +func kanagawa() SeededTheme { + wave := tokenengine.RampOptions{Hue: 28, Saturation: 26, Lightness: 56, HueSpread: 10} + stone := tokenengine.RampOptions{Hue: 20, Saturation: 10, Lightness: 46, HueSpread: 6} + + return consolidate( + "kanagawa", "Kanagawa", + "go-loose's paper palette. A warm off-white light scheme, which is the "+ + "warmest surface in the set.", + domain.AppearanceLight, + []string{"light", "warm", "paper", "go-loose"}, + scheme{ + Background: "#f2ecde", Surface: paperLight, SurfaceRaised: paperLight, + SurfaceSunken: "#e7dba9", Border: "#c8c093", BorderStrong: "#a89b6e", + Text: "#545464", TextMuted: "#5e5e70", TextSubtle: "#78788c", + TextInverted: paperLight, + Primary: "#4d7f8c", PrimaryHover: "#406d78", PrimaryActive: "#345c65", + OnPrimary: paperLight, + Secondary: "#624c83", OnSecondary: paperLight, + Success: "#6f894e", Warning: "#9a7d2e", Danger: "#c34043", Info: "#4d7f8c", + FocusRing: "#4d7f8c", Overlay: "rgba(84, 84, 100, 0.3)", + Ramp: stone, FontSans: interStack, + RadiusCard: cardRadius, + }, + scheme{ + Background: "#1f1f28", Surface: "#2a2a37", SurfaceRaised: "#363646", + SurfaceSunken: "#191921", Border: "#54546d", BorderStrong: "#6b6b85", + Text: "#dcd7ba", TextMuted: "#b4b098", TextSubtle: "#a8a28c", + TextInverted: "#1f1f28", + Primary: "#7fb4ca", PrimaryHover: "#98c4d6", PrimaryActive: "#b0d2e0", + OnPrimary: "#1f1f28", + Secondary: "#957fb8", OnSecondary: "#1f1f28", + Success: "#98bb6c", Warning: "#e0c07a", Danger: "#e46876", Info: "#7fb4ca", + FocusRing: "#7fb4ca", Overlay: "rgba(0, 0, 0, 0.5)", + Ramp: wave, FontSans: interStack, + RadiusCard: cardRadius, + }, + ) +} + +// cyberpunk is go-tell's default palette: a near-black violet field with a hot pink +// and a bright cyan. +// +// It is the one palette in the set whose identity is two accents rather than one, and +// that is why it is expressed with two status roles doing unusual work: pink carries +// the brand, cyan carries interaction. Keeping pink on `--bn-accent` would lose the +// pink-versus-cyan distinction that the whole theme is built on, so the secondary +// carries cyan instead and the pair reads in the order the design intends. +func cyberpunk() SeededTheme { + violet := tokenengine.RampOptions{Hue: 288, Saturation: 72, Lightness: 44, HueSpread: 20} + + return consolidate( + "cyberpunk", "Cyberpunk", + "go-tell's default palette. A hot-pink and cyan pair on near-black violet, "+ + "which is the one palette here built on two accents rather than one.", + domain.AppearanceDark, + []string{appearanceDark, tagDefault, "pink", "cyan", "go-tell"}, + scheme{ + Background: "#f2e8ee", Surface: "#ffffff", SurfaceRaised: "#fdf5f9", + SurfaceSunken: "#e9dde5", Border: "#c9b3c0", BorderStrong: "#a68b9c", + Text: "#2a1226", TextMuted: "#5c4457", TextSubtle: "#7f6577", + TextInverted: "#f5e0e8", + Primary: "#c2185b", PrimaryHover: "#a5144d", PrimaryActive: "#8c1042", + OnPrimary: white, + // Cyan as the secondary: see the note above. + Secondary: "#00838f", OnSecondary: white, + Success: "#2e7d32", Warning: "#c77800", Danger: "#c62828", Info: "#0277bd", + FocusRing: "#c2185b", Overlay: "rgba(42, 18, 38, 0.5)", + Ramp: violet, FontSans: interStack, RadiusCard: tightRadius, + }, + scheme{ + Background: "#120826", Surface: "#1b1035", SurfaceRaised: "#22153d", + SurfaceSunken: "#0c0519", Border: "#3d2366", BorderStrong: "#4a2d7a", + Text: "#f5e0e8", TextMuted: "#a6adc8", TextSubtle: "#7f849c", + TextInverted: "#120826", + // The source has no separate hover or active step; these are the two + // lighter pinks it uses on hover and on the cursor. + Primary: "#ff2a85", PrimaryHover: "#ff4d9a", PrimaryActive: "#ff6b9d", + OnPrimary: "#120826", + // Cyan as the secondary, so a consumer's --bn-secondary reaches the + // interaction colour the theme is known for. + Secondary: "#00f0ff", OnSecondary: "#120826", + Success: "#00e5a0", Warning: "#ffd166", Danger: "#ef233c", Info: "#00f0ff", + FocusRing: "#00f0ff", Overlay: "rgba(0, 0, 0, 0.62)", + Ramp: violet, FontSans: interStack, RadiusCard: tightRadius, + }, + ) +} + +// terminalRetro is go-tell's green-on-black palette. +// +// Two of its traits are not colours and cannot be expressed as tokens: it forces a +// monospace face on every element, and it adds a text glow. Both are recorded in the +// description and left in the application's own stylesheet, because a token is a +// value and these are rules. +func terminalRetro() SeededTheme { + green := tokenengine.RampOptions{Hue: 138, Saturation: 100, Lightness: 45, HueSpread: 16} + + return consolidate( + "terminal", "Terminal Retro", + "go-tell's green-on-black palette. The source theme also forces a monospace "+ + "face on every element and adds a text glow; those are rules rather than "+ + "values, so they stay in the application's stylesheet.", + domain.AppearanceDark, + []string{"dark", "terminal", "mono", "green", "go-tell"}, + scheme{ + Background: "#e8f5e9", Surface: "#ffffff", SurfaceRaised: "#f2fbf3", + SurfaceSunken: "#dcecdc", Border: "#a8d8ad", BorderStrong: "#7fbf86", + Text: "#041404", TextMuted: "#1e5c22", TextSubtle: "#3d7a42", + TextInverted: "#041404", + Primary: "#007a12", PrimaryHover: "#006410", PrimaryActive: "#004e0d", + OnPrimary: "#dcfce0", + Secondary: "#7a5c00", OnSecondary: "#fff8dc", + Success: "#00a32b", Warning: "#8a6d00", Danger: "#b30000", Info: "#007a12", + FocusRing: "#00a32b", Overlay: "rgba(4, 20, 4, 0.55)", + Ramp: green, + FontSans: "'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace", + RadiusCard: "4px", + }, + scheme{ + Background: "#040804", Surface: "#081608", SurfaceRaised: "#0e1a0e", + SurfaceSunken: "#020402", Border: "#00aa44", BorderStrong: "#00ff66", + Text: "#00ff66", TextMuted: "#33ff33", TextSubtle: "#00aa44", + TextInverted: "#040804", + // The source's brightest green is its interactive colour, and its mid green + // is the hover step. + Primary: "#00ff66", PrimaryHover: "#33ff33", PrimaryActive: "#00dd55", + OnPrimary: "#040804", + // The Mac traffic-light yellow the source uses in its window chrome. + Secondary: "#ffcc00", OnSecondary: "#261e00", + Success: "#00ff66", Warning: "#ffcc00", Danger: "#ff3333", Info: "#00aa44", + FocusRing: "#00ff66", Overlay: "rgba(0, 0, 0, 0.7)", + Ramp: green, + FontSans: "'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace", + RadiusCard: "4px", + }, + ) +} diff --git a/internal/seed/consolidated_test.go b/internal/seed/consolidated_test.go new file mode 100644 index 0000000..1b0bb78 --- /dev/null +++ b/internal/seed/consolidated_test.go @@ -0,0 +1,80 @@ +package seed_test + +import ( + "strings" + "testing" + + "github.com/urpi/go-bananas/internal/domain" + "github.com/urpi/go-bananas/internal/seed" + "github.com/urpi/go-bananas/internal/tokenengine" +) + +// TestGoTellSlugsAreSeeded is the one that matters for adoption. +// +// go-tell's theme switcher offers cyberpunk, nordic, obsidian and terminal, and its +// stylesheet keys on html[data-theme=""]. If any of those slugs is missing from +// the service, selecting it sets an attribute that resolves to nothing and the +// application falls back to whichever rule happens to match last. That is invisible in +// a smoke test and obvious to a user, so it is asserted here. +func TestGoTellSlugsAreSeeded(t *testing.T) { + want := []string{"cyberpunk", "nordic", "obsidian", "terminal"} + + have := map[string]bool{} + for _, slug := range seed.Slugs() { + have[slug] = true + } + + for _, slug := range want { + if !have[slug] { + t.Errorf("go-tell's %q palette is not seeded, so selecting it would do nothing", slug) + } + } +} + +// TestEveryConsolidatedPaletteResolvesInBothAppearances is the property the contract +// depends on, and it is expressed through the engine rather than through the token +// document's shape. +// +// The shape is deliberately not the assertion. In this schema the base document *is* +// the light scheme and `$appearances` carries only the dark override, so a theme with +// both schemes still has one key under `$appearances`. What matters is that resolving +// a consolidated palette in each appearance yields a different background, which is +// what makes the appearance control do anything at all. +func TestEveryConsolidatedPaletteResolvesInBothAppearances(t *testing.T) { + for _, def := range seed.All() { + if !strings.Contains(def.Description, "go-") { + continue // an original go-bananas theme, not a consolidated one + } + + theme := def.Theme() + light := tokenengine.Resolve(theme, domain.AppearanceLight) + dark := tokenengine.Resolve(theme, domain.AppearanceDark) + + read := func(doc *tokenengine.Doc, path string) string { + token, ok := doc.Lookup(path) + if !ok { + return "" + } + return token.CSS + } + + lightBg := read(light, "color.semantic.background") + darkBg := read(dark, "color.semantic.background") + + if lightBg == "" || darkBg == "" { + t.Errorf("%s does not resolve a background in both appearances (light=%q dark=%q)", + def.Slug, lightBg, darkBg) + continue + } + if lightBg == darkBg { + t.Errorf("%s resolves the same background in both appearances (%s), "+ + "so the appearance control would do nothing for it", def.Slug, lightBg) + } + + // Text has to differ too, or a palette with a dark background and dark text + // would pass the background check and be unreadable. + if read(light, "color.semantic.text") == read(dark, "color.semantic.text") { + t.Errorf("%s resolves the same text colour in both appearances", def.Slug) + } + } +} diff --git a/internal/seed/seed.go b/internal/seed/seed.go new file mode 100644 index 0000000..965d6cc --- /dev/null +++ b/internal/seed/seed.go @@ -0,0 +1,586 @@ +// Package seed holds the built-in themes that ship inside the binary. +// +// Reconciliation rule: a built-in theme is (re)created when missing, and +// refreshed only while the stored copy is still at its seed version. Once an +// operator edits a built-in theme its version moves past the seed and it is +// never touched again. This gives operators a safe upgrade path without the +// risk of a deploy silently reverting deliberate work. +// +// Themes are declared in Go rather than as embedded JSON so that palette values +// stay type-checked and the seed stays in step with the engine's schema. +package seed + +import ( + "fmt" + "strings" + + "github.com/urpi/go-bananas/internal/domain" + "github.com/urpi/go-bananas/internal/tokenengine" +) + +// SeedVersion is bumped whenever a built-in theme's tokens change. The +// reconciler compares it against the stored version to decide whether to +// refresh a built-in copy. +const SeedVersion int64 = 2 + +// SeededTheme is one built-in theme definition. +type SeededTheme struct { + Slug string + Name string + Description string + Appearance domain.Appearance + Tags []string + Tokens domain.TokenDoc +} + +// All returns every built-in theme in a stable order. +func All() []SeededTheme { + return []SeededTheme{ + midnight(), daylight(), mono(), highContrast(), + // The palettes consolidated from the sibling applications. They come after + // the original four so the default listing still leads with the house + // styles, and they are all dark-first or light-first as their source was. + urpiStandard(), tokyoNight(), catppuccin(), tomorrow(), kanagawa(), + nordicMoss(), deepSpace(), cyberpunk(), terminalRetro(), + } +} + +// Slugs returns the slugs of every built-in theme. +func Slugs() []string { + defs := All() + out := make([]string, len(defs)) + for i, d := range defs { + out[i] = d.Slug + } + return out +} + +// IsBuiltIn reports whether slug belongs to a built-in theme. +func IsBuiltIn(slug string) bool { + for _, s := range Slugs() { + if s == slug { + return true + } + } + return false +} + +// Vocabulary shared by every built-in theme. +// +// These are group names inside a token document and an appearance value, so they +// repeat once per theme by construction. Naming them means the contract layer, the +// renderer and the seeds all agree on one spelling, and a change to one is a +// single-line diff rather than a search across every palette. +const ( + roleSuccess = "success" + roleWarning = "warning" + roleDanger = "danger" + roleBrand = "brand" + roleNeutral = "neutral" + + appearanceDark = "dark" + // tagDefault marks a palette as a sensible starting point in the selector, rather + // than one that happens to be published. + tagDefault = "default" +) + +// ---------------------------------------------------------------- builders -- + +func leaf(value any, kind tokenengine.Kind) map[string]any { + return map[string]any{tokenengine.TypeKey: string(kind), tokenengine.ValueKey: value} +} + +func color(value string) map[string]any { return leaf(value, tokenengine.KindColor) } +func dim(value any) map[string]any { return leaf(value, tokenengine.KindDimension) } +func num(value any) map[string]any { return leaf(value, tokenengine.KindNumber) } +func weight(value any) map[string]any { return leaf(value, tokenengine.KindFontWeight) } +func leading(value any) map[string]any { return leaf(value, tokenengine.KindLineHeight) } +func dur(ms any) map[string]any { return leaf(ms, tokenengine.KindDuration) } +func family(v string) map[string]any { return leaf(v, tokenengine.KindFontFamily) } +func shadow(v string) map[string]any { return leaf(v, tokenengine.KindShadow) } +func bezier(a, b, c, d float64) map[string]any { + return leaf([]any{a, b, c, d}, tokenengine.KindCubicBezier) +} + +// scales returns the non-colour groups shared by every built-in theme: the +// spacing scale, radii, borders, motion, z-index, opacity and breakpoints. +// Themes differ in colour and typography only. +func scales() map[string]any { + return map[string]any{ + "space": map[string]any{ + "0": dim(0), "px": dim(1), + "1": dim(0.25), "2": dim(0.5), "3": dim(0.75), + "4": dim(1), "5": dim(1.5), "6": dim(2), + "8": dim(3), "10": dim(4), "12": dim(6), "16": dim(8), + }, + "radius": map[string]any{ + "none": dim(0), "sm": dim(0.25), "md": dim(0.5), + "lg": dim(0.75), "xl": dim(1.25), "2xl": dim(1.5), "full": dim(9999), + }, + "border": map[string]any{ + "width": map[string]any{ + "thin": dim(1), "default": dim(1), "thick": dim(2), "focus": dim(3), + }, + }, + "duration": map[string]any{ + "instant": dur(0), "fast": dur(120), "normal": dur(200), "slow": dur(320), + }, + "ease": map[string]any{ + "linear": bezier(0, 0, 1, 1), "standard": bezier(0.2, 0, 0, 1), + "entrance": bezier(0, 0, 0.2, 1), "exit": bezier(0.4, 0, 1, 1), + }, + "zIndex": map[string]any{ + "base": num(0), "dropdown": num(1000), "sticky": num(1100), + "overlay": num(1200), "modal": num(1300), "toast": num(1400), + }, + "opacity": map[string]any{ + "disabled": num(0.45), "muted": num(0.7), "strong": num(0.9), + }, + "breakpoint": map[string]any{ + "sm": dim(640), "md": dim(768), "lg": dim(1024), + "xl": dim(1280), "2xl": dim(1536), + }, + } +} + +// typeScale builds the typography groups from a modular scale: each step is +// baseSize * ratio^index, which keeps the ramp mathematically consistent in a +// way that hand-tuned type ramps rarely are. +func typeScale(sans string, baseSize, ratio, lineScale float64) map[string]any { + sizes := make(map[string]any) + steps := []struct { + name string + index float64 + }{ + {"xs", -2}, {"sm", -1}, {"md", 0}, {"lg", 1}, {"xl", 2}, {"2xl", 3}, {"3xl", 4}, + } + for _, s := range steps { + sizes[s.name] = leaf(round2(baseSize*powf(ratio, s.index)), tokenengine.KindFontSize) + } + return map[string]any{ + "family": map[string]any{ + "sans": family(sans), + "mono": family("ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace"), + "display": family(sans), + }, + "size": sizes, + "weight": map[string]any{ + "normal": weight(400), "medium": weight(500), + "semibold": weight(600), "bold": weight(700), + }, + "lineHeight": map[string]any{ + "none": leading(1), "tight": leading(1.2), "snug": leading(1.375), + "normal": leading(1.5), "relaxed": leading(1.65), "loose": leading(2), + }, + "letterSpacing": map[string]any{ + "tighter": dim(-0.05), "tight": dim(-0.025), "normal": dim(0), + "wide": dim(0.025), "wider": dim(0.05), + }, + "style": map[string]any{"normal": leaf("normal", tokenengine.KindFontStyle), "italic": leaf("italic", tokenengine.KindFontStyle)}, + "scale": num(round2(ratio)), + "lineScale": num(round2(lineScale)), + } +} + +// shadows builds a shadow ramp. Shadows tint towards slate rather than pure +// black, which is the difference between elevation that looks natural and +// elevation that looks like a drop shadow from 2004. +func shadows(strength float64) map[string]any { + tint := "15 23 42" + a := func(multiplier float64) string { return fmt.Sprintf("%.2f", strength*multiplier) } + return map[string]any{ + "none": shadow("none"), + "xs": shadow(fmt.Sprintf("0 1px 2px 0 rgb(%s / %s)", tint, a(0.08))), + "sm": shadow(fmt.Sprintf("0 1px 3px 0 rgb(%s / %s), 0 1px 2px -1px rgb(%s / %s)", tint, a(0.12), tint, a(0.08))), + "md": shadow(fmt.Sprintf("0 4px 6px -1px rgb(%s / %s), 0 2px 4px -2px rgb(%s / %s)", tint, a(0.12), tint, a(0.1))), + "lg": shadow(fmt.Sprintf("0 10px 15px -3px rgb(%s / %s), 0 4px 6px -4px rgb(%s / %s)", tint, a(0.12), tint, a(0.1))), + "xl": shadow(fmt.Sprintf("0 20px 25px -5px rgb(%s / %s), 0 8px 10px -6px rgb(%s / %s)", tint, a(0.14), tint, a(0.1))), + "focus": shadow("0 0 0 3px rgb(99 102 241 / 0.4)"), + "color": leaf(tint, tokenengine.KindColor), + } +} + +// semantics assembles the semantic colour group for one scheme. +// +// Every role points at a concrete palette step rather than at an alias token: +// published themes must not be able to change colour underneath a consumer when +// somebody later edits the underlying palette. +func semantics(ramp tokenengine.RampOptions, scheme domain.Appearance, overrides map[string]any) map[string]any { + step := func(shade string) map[string]any { return color(tokenengine.RampStep(ramp, shade)) } + + // Scheme-dependent step selection. In a light scheme surfaces are near-white + // and text is near-black; a dark scheme inverts both. + light := scheme == domain.AppearanceLight + + pick := func(lightShade, darkShade string) map[string]any { + if light { + return step(lightShade) + } + return step(darkShade) + } + + out := map[string]any{ + "background": pick("50", "950"), + "surface": pick("50", "900"), + "surface-raised": pick("100", "800"), + "surface-sunken": pick("100", "950"), + "border": pick("200", "800"), + "border-strong": pick("300", "700"), + "text": pick("900", "100"), + "text-muted": pick("700", "300"), + "text-subtle": pick("600", "400"), + "text-inverted": pick("50", "950"), + + "primary": step("600"), + "primary-hover": step("700"), + "primary-active": step("800"), + "on-primary": color(foregroundFor(tokenengine.RampStep(ramp, "600"))), + + "secondary": pick("200", "800"), + "secondary-hover": pick("300", "700"), + "on-secondary": color(foregroundFor(tokenengine.RampStep(ramp, pickShade(light, "200", "800")))), + + "accent": step("500"), + "on-accent": color(foregroundFor(tokenengine.RampStep(ramp, "500"))), + "link": step("600"), + "link-hover": step("700"), + + // Status colours are fixed rather than generated: green must mean + // success in every theme, or the interface lies to the user. + "success": color("#16a34a"), + "on-success": color("#f0fdf4"), + "warning": color("#d97706"), + "on-warning": color("#fffbeb"), + "danger": color("#dc2626"), + "on-danger": color("#fef2f2"), + "info": color("#2563eb"), + "on-info": color("#eff6ff"), + "focus-ring": color(tokenengine.RampStep(ramp, "500")), + "overlay": color(pickColor(light, "#0f172a66", "#020617b3")), + "selection": color(tokenengine.RampStep(ramp, pickShade(light, "200", "800"))), + "scrollbar-thumb": pick("300", "700"), + } + + for role, value := range overrides { + out[role] = value + } + return out +} + +func pickColor(cond bool, whenTrue, whenFalse string) string { + if cond { + return whenTrue + } + return whenFalse +} + +func pickShade(light bool, lightShade, darkShade string) string { + if light { + return lightShade + } + return darkShade +} + +// foregroundFor returns whichever of black or white has the higher WCAG +// contrast ratio against the given colour. +func foregroundFor(hex string) string { + bg, err := tokenengine.ParseColor(hex) + if err != nil || !bg.Numeric { + // A colour that cannot be parsed cannot have a computed contrast, so white is + // the safe default: it is the readable choice against every ramp in this file. + return white + } + return tokenengine.BestForeground(bg).Hex() +} + +// assemble combines the shared scales with a theme's colour and typography. +func assemble(palettes map[string]any, light, dark map[string]any, typography, shadowSet map[string]any) domain.TokenDoc { + doc := domain.TokenDoc{ + tokenengine.SchemaKey: tokenengine.SchemaID, + "color": map[string]any{"semantic": light}, + "font": typography, + "shadow": shadowSet, + tokenengine.AppearancesKey: map[string]any{ + appearanceDark: map[string]any{ + "color": map[string]any{"semantic": dark}, + }, + }, + } + colorGroup := doc["color"].(map[string]any) + for name, ramp := range palettes { + colorGroup[name] = ramp + } + for k, v := range scales() { + doc[k] = v + } + return doc +} + +// ------------------------------------------------------------------ themes -- + +// midnight is the default theme: a violet-accented dark interface. +func midnight() SeededTheme { + brand := tokenengine.RampOptions{Hue: 258, Saturation: 84, Lightness: 58, HueSpread: 14} + neutral := tokenengine.RampOptions{Hue: 224, Saturation: 28, Lightness: 46, HueSpread: 8} + + palettes := map[string]any{ + roleBrand: tokenengine.GenerateRamp(brand), + roleNeutral: tokenengine.GenerateRamp(neutral), + roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 142, Saturation: 62, Lightness: 44, HueSpread: 10}), + roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 38, Saturation: 92, Lightness: 50, HueSpread: 8}), + roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 0, Saturation: 74, Lightness: 52, HueSpread: 6}), + } + + // The dark scheme leads; the light variant is the derived inversion. + return SeededTheme{ + Slug: "midnight", + Name: "Midnight", + Description: "Violet-accented dark interface with a slate neutral ramp. The Go Bananas!! default.", + Appearance: domain.AppearanceDark, + Tags: []string{appearanceDark, tagDefault, "violet", "slate"}, + Tokens: assemble(palettes, + semantics(neutral, domain.AppearanceLight, nil), + semantics(neutral, domain.AppearanceDark, map[string]any{ + "primary": color(tokenengine.RampStep(brand, "500")), + "primary-hover": color(tokenengine.RampStep(brand, "400")), + "primary-active": color(tokenengine.RampStep(brand, "300")), + "on-primary": color(foregroundFor(tokenengine.RampStep(brand, "500"))), + "accent": color(tokenengine.RampStep(brand, "400")), + "on-accent": color(foregroundFor(tokenengine.RampStep(brand, "400"))), + "link": color(tokenengine.RampStep(brand, "400")), + "link-hover": color(tokenengine.RampStep(brand, "300")), + "focus-ring": color(tokenengine.RampStep(brand, "400")), + "selection": color(tokenengine.RampStep(brand, "800")), + }), + typeScale("Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", 0.875, 1.2, 1.5), + shadows(1.0), + ), + } +} + +// daylight is a crisp light interface built on the same violet brand hue. +func daylight() SeededTheme { + brand := tokenengine.RampOptions{Hue: 258, Saturation: 78, Lightness: 55, HueSpread: 14} + neutral := tokenengine.RampOptions{Hue: 220, Saturation: 18, Lightness: 48, HueSpread: 6} + + palettes := map[string]any{ + roleBrand: tokenengine.GenerateRamp(brand), + roleNeutral: tokenengine.GenerateRamp(neutral), + roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 142, Saturation: 70, Lightness: 40, HueSpread: 10}), + roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 38, Saturation: 94, Lightness: 48, HueSpread: 8}), + roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 358, Saturation: 76, Lightness: 50, HueSpread: 6}), + } + + return SeededTheme{ + Slug: "daylight", + Name: "Daylight", + Description: "High-contrast light interface with generous radii and soft shadows.", + Appearance: domain.AppearanceLight, + Tags: []string{"light", "default", "violet", "crisp"}, + Tokens: assemble(palettes, + semantics(neutral, domain.AppearanceLight, nil), + semantics(neutral, domain.AppearanceDark, map[string]any{ + "primary": color(tokenengine.RampStep(brand, "500")), + "link": color(tokenengine.RampStep(brand, "600")), + "focus-ring": color(tokenengine.RampStep(brand, "500")), + "primary-hover": color(tokenengine.RampStep(brand, "700")), + }), + typeScale("Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", 0.9375, 1.2, 1.5), + shadows(0.7), + ), + } +} + +// mono is a typographic, near-greyscale theme for dense interfaces. +func mono() SeededTheme { + brand := tokenengine.RampOptions{Hue: 210, Saturation: 12, Lightness: 45, HueSpread: 4} + neutral := tokenengine.RampOptions{Hue: 220, Saturation: 8, Lightness: 48, HueSpread: 2} + + palettes := map[string]any{ + roleBrand: tokenengine.GenerateRamp(brand), + roleNeutral: tokenengine.GenerateRamp(neutral), + roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 145, Saturation: 45, Lightness: 38, HueSpread: 4}), + roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 40, Saturation: 60, Lightness: 46, HueSpread: 4}), + roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 358, Saturation: 62, Lightness: 48, HueSpread: 4}), + } + + // A tighter type scale and larger base suits data-dense screens. + typography := typeScale("'IBM Plex Sans', Inter, ui-sans-serif, system-ui, sans-serif", 0.8125, 1.125, 1.5) + typography["family"] = map[string]any{ + "sans": family("'IBM Plex Sans', Inter, ui-sans-serif, system-ui, sans-serif"), + "mono": family("'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace"), + "display": family("'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif"), + } + + return SeededTheme{ + Slug: "mono", + Name: "Mono", + Description: "Near-greyscale, tight and dense. Built for tables, consoles and data tooling.", + Appearance: domain.AppearanceLight, + Tags: []string{"light", "greyscale", "dense", "data"}, + Tokens: assemble(palettes, + semantics(neutral, domain.AppearanceLight, map[string]any{ + // A greyscale theme still needs a distinguishable accent or + // nothing on screen reads as interactive. + "primary": color("#111827"), + "link": color("#111827"), + "focus-ring": color("#111827"), + }), + semantics(neutral, domain.AppearanceDark, map[string]any{ + "primary": color("#f8fafc"), + "link": color("#f8fafc"), + "focus-ring": color("#f8fafc"), + }), + typography, + shadows(0.5), + ), + } +} + +// highContrast maximises legibility: near-black on white, thick borders, no +// tints, no translucency. It is the theme to ship when accessibility is the +// primary requirement. +func highContrast() SeededTheme { + black := "#000000" + // Reserved high-contrast overrides. The light scheme is pure black on pure + // white; the dark scheme is the exact inversion, because white-on-black is + // just as legible and some users need it for photophobia or low-vision. + hcLight := highContrastRoles(black, white, "#7a3e00", "#004d00", "#8c0000", "#00308c") + hcDark := highContrastRoles(white, black, "#ffd8a8", "#b9f6ca", "#ffc9c9", "#b3d9ff") + + brand := tokenengine.RampOptions{Hue: 220, Saturation: 0, Lightness: 50, HueSpread: 0} + palettes := map[string]any{ + roleBrand: tokenengine.GenerateRamp(brand), + roleNeutral: tokenengine.GenerateRamp(brand), + } + + // Thicker borders and no shadow blur: at maximum contrast a soft shadow + // disappears, so separation must come from a stroke. + typography := typeScale("Verdana, Tahoma, 'Segoe UI', system-ui, sans-serif", 1, 1.25, 1.6) + shadowSet := map[string]any{ + "none": shadow("none"), "xs": shadow("none"), "sm": shadow("none"), + "md": shadow("none"), "lg": shadow("none"), "xl": shadow("none"), + "focus": shadow("0 0 0 3px " + black), + } + + return SeededTheme{ + Slug: "high-contrast", + Name: "High Contrast", + Description: "Maximum legibility. Pure black on pure white, thick strokes, no translucency. Inverts for a white-on-black variant.", + Appearance: domain.AppearanceLight, + Tags: []string{"light", "dark", "accessibility", "wcag", "aaa"}, + Tokens: assemble(palettes, hcLight, hcDark, typography, shadowSet), + } +} + +// highContrastRoles builds the full semantic set for a maximum-contrast scheme +// from two anchor colours plus four status colours that keep their hue but are +// darkened or lightened to stay at AAA against the anchor background. +func highContrastRoles(foreground, background, warning, success, danger, info string) map[string]any { + return map[string]any{ + "background": color(background), + "surface": color(background), + "surface-raised": color(background), + "surface-sunken": color(background), + "border": color(foreground), + "border-strong": color(foreground), + "text": color(foreground), + "text-muted": color(foreground), + "text-subtle": color(foreground), + "text-inverted": color(background), + + "primary": color(foreground), + "primary-hover": color(foreground), + "primary-active": color(foreground), + "on-primary": color(background), + + "secondary": color(background), + "secondary-hover": color(background), + "on-secondary": color(foreground), + + "accent": color(foreground), + "on-accent": color(background), + "link": color(foreground), + "link-hover": color(foreground), + "focus-ring": color(foreground), + "overlay": color(foreground), + "selection": color(foreground), + "scrollbar-thumb": color(foreground), + + "success": color(success), + "on-success": color(background), + "warning": color(warning), + "on-warning": color(background), + "danger": color(danger), + "on-danger": color(background), + "info": color(info), + "on-info": color(background), + } +} + +// -------------------------------------------------------------- conversion -- + +// systemThemeID derives a stable identifier for a built-in theme so that the +// reconciler never has to search by slug. +func systemThemeID(slug string) string { + return "sys_" + strings.ReplaceAll(slug, "-", "_") +} + +// Theme converts a seeded definition into a persistable domain theme. +func (s SeededTheme) Theme() *domain.Theme { + return &domain.Theme{ + ID: systemThemeID(s.Slug), + Slug: s.Slug, + Name: s.Name, + Description: s.Description, + Status: domain.StatusPublished, + DefaultAppearance: s.Appearance, + Tokens: s.Tokens.Clone(), + Meta: domain.ThemeMeta{ + "tags": s.Tags, + "builtIn": true, + "seedVersion": SeedVersion, + "author": "go-bananas", + }, + System: true, + Version: SeedVersion, + CreatedBy: "system", + UpdatedBy: "system", + } +} + +// Themes converts every seeded definition into persistable domain themes. +func Themes() []*domain.Theme { + defs := All() + out := make([]*domain.Theme, 0, len(defs)) + for _, d := range defs { + out = append(out, d.Theme()) + } + return out +} + +// ------------------------------------------------------------------- maths -- + +// round2 rounds to two decimal places, which is finer than any CSS length +// resolution and keeps generated values readable. +func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 } + +// powf is integer-step exponentiation, which is all a modular scale needs. +func powf(base, exp float64) float64 { + if exp == 0 { + return 1 + } + neg := exp < 0 + n := int(exp) + if neg { + n = -n + } + result := 1.0 + for i := 0; i < n; i++ { + result *= base + } + if neg { + return 1 / result + } + return result +} diff --git a/internal/seed/seed_test.go b/internal/seed/seed_test.go new file mode 100644 index 0000000..9f0f3e8 --- /dev/null +++ b/internal/seed/seed_test.go @@ -0,0 +1,157 @@ +package seed + +import ( + "testing" + + "github.com/urpi/go-bananas/internal/domain" + "github.com/urpi/go-bananas/internal/tokenengine" +) + +func TestEveryBuiltInThemeValidates(t *testing.T) { + for _, def := range All() { + t.Run(def.Slug, func(t *testing.T) { + theme := def.Theme() + result := tokenengine.Validate(theme.Tokens) + if !result.Valid { + for _, issue := range result.Errors() { + t.Errorf("%s %s: %s (%s)", issue.Severity, issue.Path, issue.Message, issue.Hint) + } + t.Fatal("built-in theme must be valid") + } + if result.TokenCount < 50 { + t.Errorf("theme has only %d tokens; a built-in should be complete", result.TokenCount) + } + }) + } +} + +func TestEveryBuiltInThemeRendersBothAppearances(t *testing.T) { + for _, def := range All() { + t.Run(def.Slug, func(t *testing.T) { + theme := def.Theme() + for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} { + css := tokenengine.Render(theme, tokenengine.RenderOptions{ + Slug: theme.Slug, + Appearance: appearance, + Components: true, + Derive: tokenengine.DefaultDerive(), + }) + if css == "" { + t.Fatalf("%s/%s rendered an empty stylesheet", theme.Slug, appearance) + } + if !contains(css, "--bn-color-semantic-background") { + t.Errorf("%s/%s is missing the background variable", theme.Slug, appearance) + } + if !contains(css, "--bn-color-semantic-text") { + t.Errorf("%s/%s is missing the text variable", theme.Slug, appearance) + } + } + }) + } +} + +func TestBuiltInThemesDeclareDistinctDarkOverrides(t *testing.T) { + // A theme that declares a dark appearance but supplies no dark overrides + // would render the light palette in dark mode, which is never intentional. + for _, def := range All() { + theme := def.Theme() + light := tokenengine.Resolve(theme, domain.AppearanceLight) + dark := tokenengine.Resolve(theme, domain.AppearanceDark) + + lightBG, _ := light.Lookup("color.semantic.background") + darkBG, _ := dark.Lookup("color.semantic.background") + if lightBG.CSS == darkBG.CSS { + t.Errorf("%s: light and dark backgrounds are identical (%s)", def.Slug, lightBG.CSS) + } + } +} + +func TestTextContrastMeetsAAForEveryBuiltIn(t *testing.T) { + // This is the check that makes a built-in theme trustworthy: body text must + // be readable on every surface it is likely to sit on. + pairs := []struct { + fgPath, bgPath string + }{ + {"color.semantic.text", "color.semantic.background"}, + {"color.semantic.text", "color.semantic.surface"}, + {"color.semantic.text", "color.semantic.surface-raised"}, + {"color.semantic.text-muted", "color.semantic.background"}, + {"color.on-primary", "color.semantic.primary"}, + {"color.on-accent", "color.semantic.accent"}, + {"color.on-danger", "color.semantic.danger"}, + } + + for _, def := range All() { + theme := def.Theme() + for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} { + doc := tokenengine.Resolve(theme, appearance) + + for _, pair := range pairs { + fg, ok := doc.Lookup(pair.fgPath) + if !ok { + continue + } + bg, ok := doc.Lookup(pair.bgPath) + if !ok { + continue + } + fgColor, err := tokenengine.ParseColor(fg.CSS) + if err != nil { + continue + } + bgColor, err := tokenengine.ParseColor(bg.CSS) + if err != nil { + continue + } + ratio := tokenengine.ContrastRatio(fgColor, bgColor) + minimum := tokenengine.ContrastAA + // The high-contrast theme is the escape hatch and is held to the + // stricter AAA bar instead. + if def.Slug == "high-contrast" { + minimum = tokenengine.ContrastAAA + } + if ratio < minimum { + t.Errorf("%s/%s: %s (%s) on %s (%s) = %.2f:1, want at least %.1f:1", + def.Slug, appearance, pair.fgPath, fg.CSS, pair.bgPath, bg.CSS, ratio, minimum) + } + } + } + } +} + +func TestBuiltInSlugsAreValidAndReservedSafe(t *testing.T) { + for _, def := range All() { + if err := domain.ValidateSlug(def.Slug); err != nil { + t.Errorf("slug %q is invalid: %v", def.Slug, err) + } + if _, reserved := domain.ReservedSlugs[def.Slug]; reserved { + t.Errorf("slug %q collides with a reserved runtime path", def.Slug) + } + if def.Appearance == "" { + t.Errorf("theme %q has no default appearance", def.Slug) + } + } +} + +func TestBuiltInThemesHaveUniqueIDs(t *testing.T) { + seen := make(map[string]string) + for _, theme := range Themes() { + if previous, dup := seen[theme.ID]; dup { + t.Errorf("id %q is used by both %q and %q", theme.ID, previous, theme.Slug) + } + seen[theme.ID] = theme.Slug + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0 +} + +func indexOf(haystack, needle string) int { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return i + } + } + return -1 +} diff --git a/internal/store/backup.go b/internal/store/backup.go new file mode 100644 index 0000000..78d5142 --- /dev/null +++ b/internal/store/backup.go @@ -0,0 +1,62 @@ +package store + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/urpi/go-bananas/internal/domain" +) + +// Snapshot writes a consistent copy of the database to dest. +// +// It uses SQLite's VACUUM INTO rather than copying the file. Copying a database +// while a write is in flight yields a torn copy: the header can say one thing and +// the pages say another, and the result may open successfully while being +// corrupt. VACUUM INTO reads through SQLite's own backup machinery, so the result +// is consistent even while the service is serving traffic, and it does not block +// writers for the length of the copy. +// +// Two further properties come from the statement itself rather than from code +// here: it refuses to overwrite an existing file, so a stale snapshot can never be +// mistaken for a fresh one, and it writes to a temporary name and renames on +// success, so a snapshot that fails halfway is not left looking complete. +// +// The connection must not be read-only: VACUUM INTO needs to read the schema and +// pages through a normal connection even though it only ever writes to the new +// file. +func (s *Store) Snapshot(ctx context.Context, dest string) error { + trimmed := strings.TrimSpace(dest) + if trimmed == "" { + return fmt.Errorf("%w: snapshot destination must not be empty", domain.ErrInvalid) + } + if dir := filepath.Dir(trimmed); dir != "" && dir != "." { + // Created by the process rather than by SQLite, which has no mkdir. The + // snapshot directory normally already exists (the Helm chart's subPath mount + // creates it), so this is a convenience for a manual invocation. + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("create snapshot directory %s: %w", dir, err) + } + } + + // VACUUM INTO takes an SQL string literal rather than a bound parameter on some + // SQLite builds, so the path is escaped into one here. Doubling the quote is + // the documented escape, and rejecting an embedded NUL keeps the literal + // well-formed regardless. + if strings.ContainsRune(trimmed, 0) { + return fmt.Errorf("%w: snapshot destination contains a NUL byte", domain.ErrInvalid) + } + literal := "'" + strings.ReplaceAll(trimmed, "'", "''") + "'" + + //nolint:gosec // G202: VACUUM INTO takes an SQL string literal rather than a + // bound parameter on some SQLite builds. The literal is escaped above by doubling + // any single quote, which is the documented escape, and NUL bytes are rejected so + // the literal cannot be broken out of. TestSnapshotEscapesQuotesInThePath covers + // a path containing a quote. + if _, err := s.db.ExecContext(ctx, "VACUUM INTO "+literal); err != nil { + return fmt.Errorf("write snapshot to %s: %w", trimmed, err) + } + return nil +} diff --git a/internal/store/backup_test.go b/internal/store/backup_test.go new file mode 100644 index 0000000..6986ebc --- /dev/null +++ b/internal/store/backup_test.go @@ -0,0 +1,185 @@ +package store_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/urpi/go-bananas/internal/domain" + "github.com/urpi/go-bananas/internal/store" +) + +// openTemp opens a file-backed store in a temporary directory, which is the only +// kind worth snapshotting: an in-memory database has nothing to preserve. +func openTemp(t *testing.T) *store.Store { + t.Helper() + path := filepath.Join(t.TempDir(), "live.db") + st, err := store.Open(context.Background(), store.Options{Path: path}) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +// TestSnapshotProducesAReadableDatabase is the property the backup exists for: the +// file that comes out must be a database, not merely a file with the right size. +func TestSnapshotProducesAReadableDatabase(t *testing.T) { + st := openTemp(t) + dest := filepath.Join(t.TempDir(), "snapshots", "go-bananas.db") + + if err := st.Snapshot(context.Background(), dest); err != nil { + t.Fatalf("Snapshot: %v", err) + } + + info, err := os.Stat(dest) + if err != nil { + t.Fatalf("stat snapshot: %v", err) + } + if info.Size() == 0 { + t.Fatal("snapshot is empty") + } + + // Reopening is the check that matters. A truncated or torn copy often fails + // here rather than at the point of use, which is exactly the failure a restore + // would hit at the worst possible moment. + reopened, err := store.Open(context.Background(), store.Options{Path: dest, ReadOnly: true}) + if err != nil { + t.Fatalf("snapshot is not a readable database: %v", err) + } + defer func() { _ = reopened.Close() }() + + // The schema must have come across, not just the bytes: an empty database with + // no tables would also open cleanly. + var count int + if err := reopened.DB().QueryRowContext(context.Background(), + "SELECT count(*) FROM sqlite_master WHERE type = 'table'").Scan(&count); err != nil { + t.Fatalf("query snapshot schema: %v", err) + } + if count == 0 { + t.Error("snapshot has no tables; the schema did not come across") + } +} + +// TestSnapshotRefusesToOverwrite guards the failure this feature is easiest to get +// wrong: a snapshot that silently overwrites the previous good one turns a failed +// run into two data-loss events instead of one. +func TestSnapshotRefusesToOverwrite(t *testing.T) { + st := openTemp(t) + dest := filepath.Join(t.TempDir(), "snap.db") + + if err := st.Snapshot(context.Background(), dest); err != nil { + t.Fatalf("first snapshot: %v", err) + } + first, err := os.Stat(dest) + if err != nil { + t.Fatalf("stat: %v", err) + } + + if err := st.Snapshot(context.Background(), dest); err == nil { + t.Fatal("expected the second snapshot to be refused") + } + + second, err := os.Stat(dest) + if err != nil { + t.Fatalf("stat: %v", err) + } + if !first.ModTime().Equal(second.ModTime()) { + t.Error("the existing snapshot was modified by the refused write") + } +} + +// TestSnapshotCreatesTheParentDirectory covers the manual-invocation path, where +// the operator names a directory that does not exist yet. +func TestSnapshotCreatesTheParentDirectory(t *testing.T) { + st := openTemp(t) + base := t.TempDir() + dest := filepath.Join(base, "a", "b", "c", "snap.db") + + if err := st.Snapshot(context.Background(), dest); err != nil { + t.Fatalf("Snapshot: %v", err) + } + if _, err := os.Stat(dest); err != nil { + t.Errorf("snapshot not written: %v", err) + } +} + +// TestSnapshotRejectsEmptyDestination checks the argument validation rather than +// letting SQLite interpret an empty filename as the current directory. +func TestSnapshotRejectsEmptyDestination(t *testing.T) { + st := openTemp(t) + if err := st.Snapshot(context.Background(), " "); err == nil { + t.Error("expected an error for a blank destination") + } else if !errors.Is(err, domain.ErrInvalid) { + t.Errorf("got %v, want an invalid-input error", err) + } +} + +// TestSnapshotEscapesQuotesInThePath guards the SQL-literal construction. A path +// containing a single quote must not be able to terminate the literal and append +// statements of its own. +func TestSnapshotEscapesQuotesInThePath(t *testing.T) { + st := openTemp(t) + dest := filepath.Join(t.TempDir(), "it's a snap.db") + + if err := st.Snapshot(context.Background(), dest); err != nil { + t.Fatalf("Snapshot: %v", err) + } + if _, err := os.Stat(dest); err != nil { + t.Errorf("snapshot not written to the quoted path: %v", err) + } +} + +// TestSnapshotOfAnInMemoryDatabaseIsRefused documents that the store-level guard +// exists, rather than leaving it to the command line. +func TestIsPersistentPath(t *testing.T) { + for _, path := range []string{":memory:", "file::memory:?cache=shared", "file:x?mode=memory"} { + if store.IsPersistentPath(path) { + t.Errorf("%q should be reported as ephemeral", path) + } + } + for _, path := range []string{"/srv/data/go-bananas.db", "./data/db.sqlite"} { + if !store.IsPersistentPath(path) { + t.Errorf("%q should be reported as persistent", path) + } + } +} + +// TestSnapshotUnderLoadStaysConsistent is a smoke test for the reason VACUUM INTO +// is used rather than a file copy: writing while the snapshot is taken must not +// corrupt the result. +func TestSnapshotUnderLoadStaysConsistent(t *testing.T) { + st := openTemp(t) + ctx := context.Background() + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 20; i++ { + if _, err := st.DB().ExecContext(ctx, + `INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?)`, + "concurrent.key", strconv.Itoa(i), time.Now().UTC().Format(time.RFC3339Nano), "test"); err != nil { + return + } + } + }() + + dest := filepath.Join(t.TempDir(), "under-load.db") + if err := st.Snapshot(ctx, dest); err != nil { + t.Fatalf("Snapshot during writes: %v", err) + } + <-done + + reopened, err := store.Open(ctx, store.Options{Path: dest, ReadOnly: true}) + if err != nil { + t.Fatalf("snapshot taken during writes is unreadable: %v", err) + } + defer func() { _ = reopened.Close() }() + if err := reopened.DB().PingContext(ctx); err != nil { + t.Errorf("snapshot failed an integrity check: %v", err) + } +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go new file mode 100644 index 0000000..5216a15 --- /dev/null +++ b/internal/store/migrations.go @@ -0,0 +1,250 @@ +package store + +import ( + "context" + "crypto/rand" + "database/sql" + "embed" + "encoding/base64" + "errors" + "fmt" + "io/fs" + "strings" + "sync" + + "github.com/urpi/go-bananas/internal/domain" +) + +// migrationsFS holds the schema that ships inside the binary, so a fresh +// container needs no migration job and no schema files mounted beside it. +// +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// Migration is one applied schema change. +type Migration struct { + Name string + // Statements is the number of statements the file contains, reported for + // logging only. + Statements int +} + +// migrationLock serialises migration across goroutines within a process. SQLite +// already serialises writers across processes, so no cross-process lock is +// needed, but two replicas starting simultaneously would otherwise both log the +// same applied migration. +var migrationLock sync.Mutex + +// migrate applies every embedded migration that has not yet been recorded. +// +// Migrations are idempotent by construction: each file uses CREATE ... IF NOT +// EXISTS, so re-running is harmless. The applied set is tracked in a table so +// that a future migration which is not idempotent can still be introduced +// safely. +func (s *Store) migrate(ctx context.Context) error { + migrationLock.Lock() + defer migrationLock.Unlock() + + if _, err := s.db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL + )`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + applied, err := s.appliedMigrations(ctx) + if err != nil { + return err + } + + entries, err := fs.ReadDir(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("read embedded migrations: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + if _, done := applied[entry.Name()]; done { + continue + } + + body, err := fs.ReadFile(migrationsFS, "migrations/"+entry.Name()) + if err != nil { + return fmt.Errorf("read migration %s: %w", entry.Name(), err) + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin migration %s: %w", entry.Name(), err) + } + + if _, err := tx.ExecContext(ctx, string(body)); err != nil { + tx.Rollback() + return fmt.Errorf("apply migration %s: %w", entry.Name(), err) + } + + if _, err := tx.ExecContext(ctx, + `INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`, + entry.Name(), s.now().UTC().Format(domain.TimeFormat), + ); err != nil { + tx.Rollback() + return fmt.Errorf("record migration %s: %w", entry.Name(), err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %s: %w", entry.Name(), err) + } + } + return nil +} + +// appliedMigrations returns the set of recorded migration names. +func (s *Store) appliedMigrations(ctx context.Context) (map[string]struct{}, error) { + rows, err := s.db.QueryContext(ctx, `SELECT name FROM schema_migrations`) + if err != nil { + return nil, fmt.Errorf("read schema_migrations: %w", err) + } + defer rows.Close() + + applied := make(map[string]struct{}) + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("scan schema_migrations: %w", err) + } + applied[name] = struct{}{} + } + return applied, rows.Err() +} + +// Seed reconciles the built-in themes with what is stored. +// +// The rules are: +// - a missing built-in is inserted; +// - a built-in still at its seed version is refreshed, so an upgrade picks up +// palette and schema fixes; +// - a built-in an operator has edited (version above the seed) is left alone; +// - a non-built-in theme is never touched. +// +// It returns how many themes were inserted and how many were refreshed. +func (s *Store) Seed(ctx context.Context, builtins []*domain.Theme) (inserted, refreshed int, err error) { + if len(builtins) == 0 { + return 0, 0, nil + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, 0, fmt.Errorf("begin seed: %w", err) + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + + now := s.now().UTC().Format(domain.TimeFormat) + + for _, theme := range builtins { + tokensJSON, err := marshalJSON(theme.Tokens) + if err != nil { + return 0, 0, fmt.Errorf("marshal tokens for %s: %w", theme.Slug, err) + } + metaJSON, err := marshalJSON(theme.Meta) + if err != nil { + return 0, 0, fmt.Errorf("marshal meta for %s: %w", theme.Slug, err) + } + + var existingVersion int64 + err = tx.QueryRowContext(ctx, + `SELECT version FROM themes WHERE id = ? OR slug = ?`, theme.ID, theme.Slug, + ).Scan(&existingVersion) + + switch { + case errors.Is(err, sql.ErrNoRows): + if err := insertTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil { + return 0, 0, fmt.Errorf("insert built-in theme %s: %w", theme.Slug, err) + } + if err := insertVersion(ctx, tx, theme, tokensJSON, "built-in seed", "system", now); err != nil { + return 0, 0, fmt.Errorf("insert built-in version %s: %w", theme.Slug, err) + } + inserted++ + + case err != nil: + return 0, 0, fmt.Errorf("look up built-in theme %s: %w", theme.Slug, err) + + default: + // Refresh only when the stored copy predates this seed. An equal + // version means the theme is already current; a higher version means + // an operator has edited it, and their work wins either way. + if existingVersion >= theme.Version { + continue + } + if err := refreshTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil { + return 0, 0, fmt.Errorf("refresh built-in theme %s: %w", theme.Slug, err) + } + if err := insertVersion(ctx, tx, theme, tokensJSON, "refreshed by Go Bananas!! upgrade", "system", now); err != nil { + return 0, 0, fmt.Errorf("insert built-in version %s: %w", theme.Slug, err) + } + refreshed++ + } + } + + if err = tx.Commit(); err != nil { + return 0, 0, fmt.Errorf("commit seed: %w", err) + } + return inserted, refreshed, nil +} + +// insertTheme writes a new theme row. +func insertTheme(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, metaJSON, now string) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO themes ( + id, slug, name, description, status, default_appearance, + tokens_json, meta_json, is_system, version, + created_at, updated_at, created_by, updated_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + theme.ID, theme.Slug, theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance), + tokensJSON, metaJSON, boolToInt(theme.System), theme.Version, + now, now, theme.CreatedBy, theme.UpdatedBy, + ) + return translateError(err) +} + +// refreshTheme overwrites an untouched built-in theme in place. +func refreshTheme(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, metaJSON, now string) error { + _, err := tx.ExecContext(ctx, ` + UPDATE themes SET + name = ?, description = ?, status = ?, default_appearance = ?, + tokens_json = ?, meta_json = ?, version = ?, updated_at = ?, updated_by = ? + WHERE slug = ?`, + theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance), + tokensJSON, metaJSON, theme.Version, now, "system", + theme.Slug, + ) + return translateError(err) +} + +// insertVersion appends an immutable snapshot to a theme's history. +func insertVersion(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, message, author, now string) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO theme_versions ( + theme_id, version, slug, name, description, status, + default_appearance, tokens_json, message, author, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + theme.ID, theme.Version, theme.Slug, theme.Name, theme.Description, string(theme.Status), + string(theme.DefaultAppearance), tokensJSON, message, author, now, + ) + return translateError(err) +} + +// randomID returns a URL-safe random identifier. +func randomID() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("read random: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} diff --git a/internal/store/migrations/0001_init.sql b/internal/store/migrations/0001_init.sql new file mode 100644 index 0000000..ce27e5f --- /dev/null +++ b/internal/store/migrations/0001_init.sql @@ -0,0 +1,144 @@ +-- go-bananas schema. +-- +-- Design notes +-- ------------ +-- * Timestamps are TEXT in RFC 3339 (see domain.TimeFormat). SQLite has no +-- native date type, and storing a sortable string removes every date-parsing +-- ambiguity from ORDER BY and range filters. +-- * JSON columns are TEXT holding a JSON document. The token document is read +-- and written as a whole value, never queried into, so a JSON column is the +-- correct shape: it avoids a join table whose only purpose would be to +-- reassemble a tree the application always wants whole. +-- * Every table a consumer might read carries an explicit covering index. + +PRAGMA foreign_keys = ON; + +-- ---------------------------------------------------------------- themes ---- + +CREATE TABLE IF NOT EXISTS themes ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'published', 'archived')), + default_appearance TEXT NOT NULL DEFAULT 'light' + CHECK (default_appearance IN ('light', 'dark')), + tokens_json TEXT NOT NULL, + meta_json TEXT NOT NULL DEFAULT '{}', + is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0, 1)), + version INTEGER NOT NULL DEFAULT 1 CHECK (version >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', + updated_by TEXT NOT NULL DEFAULT '' +); + +-- The runtime API resolves by slug on every page load, so this index is the +-- single most important one in the schema. +CREATE UNIQUE INDEX IF NOT EXISTS idx_themes_slug ON themes (slug); + +-- Listing is always ordered by recency within a status filter. +CREATE INDEX IF NOT EXISTS idx_themes_status_updated + ON themes (status, updated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_themes_updated ON themes (updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_themes_created ON themes (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_themes_name ON themes (name COLLATE NOCASE); + +-- Free-text search over slug, name and description. SQLite's LIKE with a +-- leading wildcard cannot use an index, so this stays a scan; the table is +-- small by construction (themes are hand-authored, not machine-generated) and +-- the scan is served from the page cache. +CREATE INDEX IF NOT EXISTS idx_themes_search + ON themes (name COLLATE NOCASE, slug, description); + +-- ---------------------------------------------------------- theme_versions -- + +CREATE TABLE IF NOT EXISTS theme_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + theme_id TEXT NOT NULL REFERENCES themes (id) ON DELETE CASCADE, + version INTEGER NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + default_appearance TEXT NOT NULL, + tokens_json TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE (theme_id, version) +); + +-- Rollback resolves a theme's history newest-first. +CREATE INDEX IF NOT EXISTS idx_versions_theme + ON theme_versions (theme_id, version DESC); + +-- ---------------------------------------------------------------- api_keys -- + +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + -- prefix is the non-secret leading segment shown in the UI so a key can be + -- identified after its secret has been forgotten. + prefix TEXT NOT NULL, + -- secret_hash is a SHA-256 of the full key. The plaintext is never stored, + -- so a database disclosure does not yield usable credentials. + secret_hash TEXT NOT NULL, + scopes TEXT NOT NULL, + identity TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT, + expires_at TEXT, + revoked_at TEXT +); + +-- Lookup by secret hash happens on every machine request. +CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys (secret_hash); +-- The UI lists live keys newest-first and hides revoked ones by default. +CREATE INDEX IF NOT EXISTS idx_api_keys_live ON api_keys (revoked_at, created_at DESC); + +-- ----------------------------------------------------------------- users ---- + +CREATE TABLE IF NOT EXISTS users ( + subject TEXT PRIMARY KEY, + email TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + roles TEXT NOT NULL DEFAULT '[]', + method TEXT NOT NULL DEFAULT '', + first_seen TEXT NOT NULL, + last_login TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); + +-- ------------------------------------------------------------- audit_log ---- + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT '', + actor_email TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + entity_type TEXT NOT NULL DEFAULT '', + entity_id TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + detail_json TEXT NOT NULL DEFAULT '{}', + remote_ip TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log (at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor, at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log (entity_type, entity_id, at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action, at DESC); + +-- -------------------------------------------------------------- settings ---- + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL DEFAULT '' +); \ No newline at end of file diff --git a/internal/store/migrations/0002_drop_api_keys.sql b/internal/store/migrations/0002_drop_api_keys.sql new file mode 100644 index 0000000..8235e4a --- /dev/null +++ b/internal/store/migrations/0002_drop_api_keys.sql @@ -0,0 +1,19 @@ +-- Drop the machine-credential table. +-- +-- API keys were removed: the runtime API is public by design and the admin API is +-- for people, so there is no machine caller left to authenticate. The table is +-- dropped rather than left behind, because an unused table holding SHA-256 digests +-- of credentials that no longer authenticate anything is a liability with no +-- upside, and its presence in the schema implies a capability that no longer exists. +-- +-- The rows go with it. That is destructive by definition, which is why this is a +-- migration rather than a code change: it happens once, in order, and is recorded in +-- schema_migrations so a restore from an older backup is visible rather than silent. +-- +-- A backup taken before this migration still contains the table and its rows. The +-- credentials in it do not work against any version of this service, so restoring +-- one is harmless. + +DROP INDEX IF EXISTS idx_api_keys_hash; +DROP INDEX IF EXISTS idx_api_keys_live; +DROP TABLE IF EXISTS api_keys; \ No newline at end of file diff --git a/internal/store/migrations/0002_user_preferences.sql b/internal/store/migrations/0002_user_preferences.sql new file mode 100644 index 0000000..9a75724 --- /dev/null +++ b/internal/store/migrations/0002_user_preferences.sql @@ -0,0 +1,26 @@ +-- Per-user interface preferences. +-- +-- These live in their own table rather than as columns on users, because users is +-- a projection of the identity provider's claims: UpsertUser rewrites it on every +-- login. A row appears the first time somebody saves a preference and is keyed by +-- the same subject, so the choice survives re-login and follows the account to +-- another browser. +-- +-- An empty selected_theme means the person has not chosen a theme, which the UI +-- renders as its own default rather than as a stored value. +-- +-- There is deliberately no foreign key to users. That table is a projection of the +-- identity provider's claims and can be rebuilt from a backup, so a cascade from it +-- would silently discard people's choices, and requiring the row to exist would make +-- saving a preference depend on projection state that has nothing to do with the +-- preference. A row whose subject never logs in again is inert and costs nothing. +CREATE TABLE IF NOT EXISTS user_preferences ( + subject TEXT NOT NULL PRIMARY KEY, + selected_theme TEXT NOT NULL DEFAULT '', + selected_appearance TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL +); + +-- Lets an operator answer "who is using this theme?" without scanning every row. +CREATE INDEX IF NOT EXISTS idx_user_preferences_theme + ON user_preferences (selected_theme); \ No newline at end of file diff --git a/internal/store/repositories.go b/internal/store/repositories.go new file mode 100644 index 0000000..7e96b22 --- /dev/null +++ b/internal/store/repositories.go @@ -0,0 +1,369 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/urpi/go-bananas/internal/domain" +) + +// ------------------------------------------------------------------- users -- + +// UpsertUser records a successful login. Roles are overwritten from the +// identity provider on every login rather than merged, so that a user who is +// removed from a group actually loses access. +func (s *Store) UpsertUser(ctx context.Context, identity *domain.Identity) error { + if identity == nil || identity.Subject == "" { + return fmt.Errorf("%w: identity subject must not be empty", domain.ErrInvalid) + } + roles, err := json.Marshal(identity.Roles) + if err != nil { + return fmt.Errorf("encode roles: %w", err) + } + + now := s.timestamp() + _, err = s.db.ExecContext(ctx, ` + INSERT INTO users (subject, email, display_name, roles, method, first_seen, last_login) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(subject) DO UPDATE SET + email = excluded.email, + display_name = excluded.display_name, + roles = excluded.roles, + method = excluded.method, + last_login = excluded.last_login`, + identity.Subject, identity.Email, identity.Name, string(roles), identity.Method, now, now, + ) + return translateError(err) +} + +// User is a stored account projection. +type User struct { + Subject string `json:"subject"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Roles []string `json:"roles"` + Method string `json:"method"` + FirstSeen time.Time `json:"firstSeen"` + LastLogin time.Time `json:"lastLogin"` +} + +// ListUsers returns every known account, most recently seen first. +func (s *Store) ListUsers(ctx context.Context, limit int) ([]User, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT subject, email, display_name, roles, method, first_seen, last_login + FROM users ORDER BY last_login DESC LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("list users: %w", translateError(err)) + } + defer rows.Close() + + out := make([]User, 0, limit) + for rows.Next() { + var ( + u User + rolesJSON string + firstSeen string + lastLogin string + ) + if err := rows.Scan(&u.Subject, &u.Email, &u.Name, &rolesJSON, &u.Method, &firstSeen, &lastLogin); err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + // Deliberately degraded, not accidentally ignored: a malformed roles blob + // yields a user with no roles, which the authorisation layer treats as + // read-only. Failing the whole listing because one row has a corrupt column + // would be worse than degrading that single user, and it would mean one bad + // row hides every other user from the admin. + // + //nolint:errcheck // see above + _ = json.Unmarshal([]byte(rolesJSON), &u.Roles) + if u.Roles == nil { + u.Roles = []string{} + } + if u.FirstSeen, err = parseTime(firstSeen); err != nil { + return nil, err + } + if u.LastLogin, err = parseTime(lastLogin); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +// UserCount returns the number of known accounts. +func (s *Store) UserCount(ctx context.Context) (int, error) { + var count int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil { + return 0, fmt.Errorf("count users: %w", err) + } + return count, nil +} + +// ---------------------------------------------------------------- settings -- + +// Setting returns a setting value, or fallback when it is absent. +func (s *Store) Setting(ctx context.Context, key, fallback string) (string, error) { + var value string + err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&value) + switch { + case errors.Is(err, sql.ErrNoRows): + return fallback, nil + case err != nil: + return "", fmt.Errorf("read setting %s: %w", key, err) + default: + return value, nil + } +} + +// SetSetting writes a setting. +func (s *Store) SetSetting(ctx context.Context, key, value, actor string) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by`, + key, value, s.timestamp(), actor) + return translateError(err) +} + +// AllSettings returns every setting. +func (s *Store) AllSettings(ctx context.Context) (map[string]domain.Setting, error) { + rows, err := s.db.QueryContext(ctx, `SELECT key, value, updated_at, updated_by FROM settings`) + if err != nil { + return nil, fmt.Errorf("list settings: %w", translateError(err)) + } + defer rows.Close() + + out := make(map[string]domain.Setting) + for rows.Next() { + var ( + setting domain.Setting + updatedAt string + ) + if err := rows.Scan(&setting.Key, &setting.Value, &updatedAt, &setting.UpdatedBy); err != nil { + return nil, fmt.Errorf("scan setting: %w", err) + } + if setting.UpdatedAt, err = parseTime(updatedAt); err != nil { + return nil, err + } + out[setting.Key] = setting + } + return out, rows.Err() +} + +// ------------------------------------------------------------------- audit -- + +// AuditFilter narrows the activity log. +type AuditFilter struct { + Actor string + Action string + EntityType string + EntityID string + Since *time.Time + Limit int + Offset int +} + +// AppendAudit writes one audit entry. Failures are returned but callers on the +// request path generally log and continue: losing an audit row is bad, failing +// a user's publish because of it is worse. +func (s *Store) AppendAudit(ctx context.Context, entry *domain.AuditEntry) error { + if entry == nil { + return fmt.Errorf("%w: audit entry must not be nil", domain.ErrInvalid) + } + if entry.Action == "" { + return fmt.Errorf("%w: audit entry requires an action", domain.ErrInvalid) + } + if entry.At.IsZero() { + entry.At = s.now().UTC() + } + + detail, err := json.Marshal(entry.Detail) + if err != nil { + detail = []byte("{}") + } + + _, err = s.db.ExecContext(ctx, ` + INSERT INTO audit_log (at, actor, actor_email, action, entity_type, entity_id, summary, detail_json, remote_ip) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + entry.At.UTC().Format(domain.TimeFormat), entry.Actor, entry.ActorEmail, + string(entry.Action), entry.EntityType, entry.EntityID, entry.Summary, + string(detail), entry.RemoteIP, + ) + return translateError(err) +} + +// ListAudit returns audit entries newest first. +func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) ([]domain.AuditEntry, int, error) { + var ( + where []string + args []any + ) + if filter.Actor != "" { + where = append(where, `actor = ?`) + args = append(args, filter.Actor) + } + if filter.Action != "" { + where = append(where, `action = ?`) + args = append(args, filter.Action) + } + if filter.EntityType != "" { + where = append(where, `entity_type = ?`) + args = append(args, filter.EntityType) + } + if filter.EntityID != "" { + where = append(where, `entity_id = ?`) + args = append(args, filter.EntityID) + } + if filter.Since != nil { + where = append(where, `at >= ?`) + args = append(args, filter.Since.UTC().Format(domain.TimeFormat)) + } + + clause := "" + if len(where) > 0 { + clause = " WHERE " + strings.Join(where, " AND ") + } + + limit := filter.Limit + if limit <= 0 || limit > 500 { + limit = 50 + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + + var total int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log`+clause, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count audit entries: %w", err) + } + + rows, err := s.db.QueryContext(ctx, + `SELECT id, at, actor, actor_email, action, entity_type, entity_id, summary, detail_json, remote_ip + FROM audit_log`+clause+` ORDER BY id DESC LIMIT ? OFFSET ?`, + append(append([]any{}, args...), limit, offset)...) + if err != nil { + return nil, 0, fmt.Errorf("list audit entries: %w", translateError(err)) + } + defer rows.Close() + + out := make([]domain.AuditEntry, 0, limit) + for rows.Next() { + var ( + entry domain.AuditEntry + action string + at string + detailJSON string + ) + if err := rows.Scan(&entry.ID, &at, &entry.Actor, &entry.ActorEmail, &action, + &entry.EntityType, &entry.EntityID, &entry.Summary, &detailJSON, &entry.RemoteIP); err != nil { + return nil, 0, fmt.Errorf("scan audit entry: %w", err) + } + entry.Action = domain.AuditAction(action) + if entry.At, err = parseTime(at); err != nil { + return nil, 0, err + } + if detailJSON != "" { + // Same reasoning as the roles column: an audit entry whose detail cannot + // be decoded is still worth returning, because the point of the audit log + // is that the record of the action exists. + // + //nolint:errcheck // see above + _ = json.Unmarshal([]byte(detailJSON), &entry.Detail) + } + out = append(out, entry) + } + return out, total, rows.Err() +} + +// AuditRetention returns the age of the oldest audit entry, used by the +// retention job. +func (s *Store) AuditRetention(ctx context.Context) (time.Time, error) { + var oldest sql.NullString + if err := s.db.QueryRowContext(ctx, `SELECT MIN(at) FROM audit_log`).Scan(&oldest); err != nil { + return time.Time{}, fmt.Errorf("audit retention: %w", err) + } + if !oldest.Valid { + return s.now().UTC(), nil + } + return parseTime(oldest.String) +} + +// PurgeAudit removes entries older than the cutoff and returns the count. +func (s *Store) PurgeAudit(ctx context.Context, before time.Time) (int64, error) { + result, err := s.db.ExecContext(ctx, + `DELETE FROM audit_log WHERE at < ?`, before.UTC().Format(domain.TimeFormat)) + if err != nil { + return 0, fmt.Errorf("purge audit: %w", translateError(err)) + } + return result.RowsAffected() +} + +// Stats summarises the instance for the admin dashboard. +type Stats struct { + Themes int `json:"themes"` + ThemesByStatus map[string]int `json:"themesByStatus"` + Users int `json:"users"` + AuditEntries int `json:"auditEntries"` + DatabaseSize int64 `json:"databaseSizeBytes"` + VersionsRetaind int `json:"versionsRetained"` +} + +// Stats gathers dashboard counters in a single pass. +func (s *Store) Stats(ctx context.Context) (Stats, error) { + stats := Stats{ThemesByStatus: map[string]int{}} + + rows, err := s.db.QueryContext(ctx, `SELECT status, COUNT(*) FROM themes GROUP BY status`) + if err != nil { + return stats, fmt.Errorf("stats: %w", translateError(err)) + } + defer rows.Close() + for rows.Next() { + var ( + status string + count int + ) + if err := rows.Scan(&status, &count); err != nil { + return stats, fmt.Errorf("stats: %w", err) + } + stats.ThemesByStatus[status] = count + stats.Themes += count + } + // rows.Next() returning false means either no rows or an error. Checking Err + // distinguishes them, and without it a failure part way through the result set + // reports success with silently incomplete counters. + if err := rows.Err(); err != nil { + return stats, fmt.Errorf("stats: %w", err) + } + + for _, query := range []struct { + sql string + dest *int + }{ + {`SELECT COUNT(*) FROM users`, &stats.Users}, + {`SELECT COUNT(*) FROM audit_log`, &stats.AuditEntries}, + {`SELECT COUNT(*) FROM theme_versions`, &stats.VersionsRetaind}, + } { + if err := s.db.QueryRowContext(ctx, query.sql).Scan(query.dest); err != nil { + return stats, fmt.Errorf("stats: %w", translateError(err)) + } + } + + // page_count * page_size is the on-disk size of the main database file. It + // is a cheap approximation, which is all a dashboard needs. + var pageCount, pageSize int64 + if err := s.db.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pageCount); err == nil { + if err := s.db.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err == nil { + stats.DatabaseSize = pageCount * pageSize + } + } + return stats, nil +} diff --git a/internal/store/sort.go b/internal/store/sort.go new file mode 100644 index 0000000..f665abe --- /dev/null +++ b/internal/store/sort.go @@ -0,0 +1,13 @@ +package store + +import ( + "sort" +) + +// sortStrings orders a string slice in place. +func sortStrings(values []string) { sort.Strings(values) } + +// sortSlice orders a slice in place using the supplied comparison. +func sortSlice[T any](values []T, less func(a, b T) bool) { + sort.Slice(values, func(i, j int) bool { return less(values[i], values[j]) }) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..388bf03 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,307 @@ +// Package store is the persistence layer. It owns the SQLite connection, the +// schema and every query the application runs. +// +// # Why SQLite +// +// Go Bananas!! stores themes: a few thousand rows of JSON documents that are read +// constantly and written rarely. That workload is exactly what an embedded +// engine is good at, and a client/server database would add a network hop, a +// connection pool, a second container, credentials to rotate and a schema +// migration process to a service whose entire dataset comfortably fits in a few +// megabytes. +// +// The configuration that makes this genuinely fast rather than merely adequate: +// +// - WAL journalling, so readers never block the writer and vice versa. +// - synchronous=NORMAL, which in WAL mode is durable across process crashes +// and only risks the last transactions on an OS crash. For a design-token +// store that is the correct trade: a lost publish is re-doable, an outage +// is not. +// - A deliberately small connection pool. SQLite serialises writers, so many +// connections buy contention, not throughput. +// - Prepared statements cached by database/sql, so the hot runtime query is +// compiled once at start-up. +// - Rendered themes memoised in memory, so the steady-state runtime request +// performs no database access at all. +// +// The engine is modernc.org/sqlite, a pure-Go translation of SQLite. It needs no +// cgo, so the release image builds from a static base with no libc and the +// cross-compiled binaries are genuinely portable. +// +// # Concurrency +// +// A Store is safe for concurrent use. The connection pool handles concurrency; +// write transactions are serialised by SQLite's own locking, which at this write +// volume is not a bottleneck. Callers must not hold a *sql.Tx across an HTTP +// boundary; use the callback forms so that a panic rolls the transaction back. +package store + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + _ "modernc.org/sqlite" // pure-Go SQLite driver + + "github.com/urpi/go-bananas/internal/domain" +) + +// marshalJSON encodes a value for a TEXT column. The store always writes JSON +// through this helper so that a value which cannot be marshalled fails at the +// boundary rather than producing a corrupt row. +func marshalJSON(v any) (string, error) { + raw, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("marshal json: %w", err) + } + return string(raw), nil +} + +// boolToInt renders a bool for a SQLite INTEGER column. +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// intToBool is the inverse of boolToInt. +func intToBool(v int) bool { return v != 0 } + +// Store is the persistence facade. +type Store struct { + db *sql.DB + now func() time.Time + // inMemory reports whether the store is backed by an ephemeral database. + inMemory bool +} + +// Options configures a Store. +type Options struct { + // Path is the database file, or ":memory:" for an ephemeral database. + Path string + // BusyTimeout is how long a blocked writer waits before failing. + BusyTimeout time.Duration + // MaxConns bounds the connection pool. + MaxConns int + // ReadOnly opens the database without write access, for replicas. + ReadOnly bool + // Now overrides the clock, for deterministic tests. + Now func() time.Time +} + +// sentinelMapping translates the SQLite driver's typed errors into the domain +// sentinels the HTTP layer matches on. Doing this in one place keeps every +// repository from re-implementing driver-specific string matching. +var sentinelMapping = map[string]error{ + "constraint failed: UNIQUE constraint failed": domain.ErrConflict, + "constraint failed: CHECK constraint failed": domain.ErrInvalid, + "constraint failed: NOT NULL constraint failed": domain.ErrInvalid, + "constraint failed: FOREIGN KEY constraint failed": domain.ErrConflict, +} + +// Open connects to the database, applies the schema and verifies it. +func Open(ctx context.Context, opts Options) (*Store, error) { + if opts.Path == "" { + return nil, fmt.Errorf("%w: database path must not be empty", domain.ErrInvalid) + } + if opts.BusyTimeout <= 0 { + opts.BusyTimeout = 5 * time.Second + } + if opts.MaxConns <= 0 { + opts.MaxConns = 8 + } + if opts.Now == nil { + opts.Now = time.Now + } + + inMemory := isMemoryPath(opts.Path) + dsn := buildDSN(opts.Path, opts.BusyTimeout, opts.ReadOnly) + + if !inMemory && !opts.ReadOnly { + if dir := filepath.Dir(opts.Path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, fmt.Errorf("create database directory: %w", err) + } + } + } + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + // A small pool is deliberate. See the package comment. + db.SetMaxOpenConns(opts.MaxConns) + db.SetMaxIdleConns(opts.MaxConns) + db.SetConnMaxIdleTime(time.Minute) + + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + s := &Store{db: db, now: opts.Now, inMemory: inMemory} + if !opts.ReadOnly { + if err := s.migrate(ctx); err != nil { + db.Close() + return nil, err + } + } + return s, nil +} + +// buildDSN assembles the SQLite connection string. +// +// modernc.org/sqlite takes its PRAGMAs as query parameters. Using the _pragma +// form rather than executing PRAGMA statements after opening is what makes the +// settings apply to every pooled connection instead of only the first. +func buildDSN(path string, busyTimeout time.Duration, readOnly bool) string { + params := url.Values{} + + // WAL lets readers and the writer proceed concurrently. + if !readOnly && !isMemoryPath(path) { + params.Add("_pragma", "journal_mode(WAL)") + } + // NORMAL is durable across application crashes in WAL mode. See the package + // comment for why that is the right trade here. + params.Add("_pragma", "synchronous(NORMAL)") + params.Add("_pragma", "foreign_keys(ON)") + params.Add("_pragma", "busy_timeout("+fmt.Sprint(busyTimeout.Milliseconds())+")") + // A negative cache size is a KiB budget; 16 MiB comfortably holds the whole + // dataset in memory. + params.Add("_pragma", "cache_size(-16000)") + // temp_store=MEMORY keeps sort and group-by spill out of the filesystem. + params.Add("_pragma", "temp_store(MEMORY)") + // mmap lets SQLite read the file through the page cache directly, which + // removes a copy on the hot read path. + params.Add("_pragma", "mmap_size(268435456)") + if readOnly { + params.Add("mode", "ro") + } + + base := path + if isMemoryPath(path) { + // A shared cache is required for an in-memory database to be visible + // across the pooled connections, otherwise each connection would get its + // own empty database. + switch { + case strings.Contains(path, "?"): + // The caller supplied a full URI with its own query. + base = path + case nameFromPath(path) != "": + base = "file:" + nameFromPath(path) + "?mode=memory&cache=shared" + default: + // A bare ":memory:" carries no name. Naming it uniquely keeps two + // stores in one process isolated from each other, which is what a test + // creating a second store expects; leaving it unnamed would hand every + // caller the same database and let one test's rows appear in another's. + base = "file:" + ephemeralMemoryName() + "?mode=memory&cache=shared" + } + } + + // The separator depends on whether the base already carries a query. Joining + // with "?" unconditionally produces "cache=shared?_pragma=...", which SQLite + // reads as a cache mode literally named "shared?_pragma=..." and rejects. + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + return base + sep + params.Encode() +} + +// ephemeralMemoryName returns a random name for an unnamed in-memory database. +func ephemeralMemoryName() string { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + // A fixed name is wrong but not fatal: the store still opens and the only + // consequence is that two concurrent unnamed stores would share a database. + // Failing to open over an entropy hiccup would be worse than that. + return "memory-default" + } + return "memory-" + hex.EncodeToString(buf) +} + +// IsPersistentPath reports whether a database path holds data that outlives the +// process. An ephemeral database cannot be snapshotted, and a caller that tries is +// better served by being told so than by producing an empty file. +func IsPersistentPath(path string) bool { return !isMemoryPath(path) } + +// isMemoryPath reports whether the path refers to an ephemeral database. +func isMemoryPath(path string) bool { + return path == ":memory:" || strings.HasPrefix(path, "file::memory:") || strings.Contains(path, "mode=memory") +} + +// nameFromPath derives a stable shared-cache identifier from a path, so that +// two in-memory stores created with the same name are genuinely the same +// database and two created with different names are isolated. +func nameFromPath(path string) string { + name := strings.TrimPrefix(path, ":memory:") + name = strings.TrimPrefix(name, "file:") + if idx := strings.IndexByte(name, '?'); idx >= 0 { + name = name[:idx] + } + name = strings.Trim(name, "/") + if name == "" { + return "go-bananas-memory" + } + // Names are hashed rather than escaped so that any path can be used safely. + return "go-bananas-" + fmt.Sprintf("%x", len(name)) + "-" + strings.ReplaceAll(name, "/", "_") +} + +// Close releases the database. +func (s *Store) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} + +// DB exposes the underlying handle for health checks and tests. +func (s *Store) DB() *sql.DB { return s.db } + +// Ping verifies connectivity, used by the readiness probe. +func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } + +// InMemory reports whether the store is ephemeral. +func (s *Store) InMemory() bool { return s.inMemory } + +// Now returns the store's clock. +func (s *Store) Now() time.Time { return s.now() } + +// translateError maps a driver error onto a domain sentinel. +func translateError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: %s", domain.ErrNotFound, err.Error()) + } + msg := err.Error() + for fragment, sentinel := range sentinelMapping { + if strings.Contains(msg, fragment) { + return fmt.Errorf("%w: %s", sentinel, msg) + } + } + return err +} + +// wrapNotFound attaches context to an ErrNotFound result. +func wrapNotFound(err error, what string) error { + if err == nil { + return nil + } + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: %s", domain.ErrNotFound, what) + } + return translateError(err) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..2619c0b --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,717 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/urpi/go-bananas/internal/domain" + "github.com/urpi/go-bananas/internal/seed" +) + +// newTestStore returns a store backed by a temporary on-disk database, so that +// the test exercises the same file-based code path as production including WAL. +func newTestStore(t *testing.T) *Store { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + store, err := Open(context.Background(), Options{Path: path}) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} + +// sampleTheme returns a minimal valid theme for store tests. +func sampleTheme(slug string) *domain.Theme { + return &domain.Theme{ + Slug: slug, + Name: "Sample " + slug, + Description: "a theme used in tests", + Status: domain.StatusDraft, + DefaultAppearance: domain.AppearanceLight, + Tokens: domain.TokenDoc{ + "color": map[string]any{ + "semantic": map[string]any{ + "background": map[string]any{"$type": "color", "$value": "#ffffff"}, + "primary": map[string]any{"$type": "color", "$value": "#4f46e5"}, + }, + }, + }, + Meta: domain.ThemeMeta{"tags": []string{"test"}}, + CreatedBy: "tester", + } +} + +func TestOpenAppliesSchema(t *testing.T) { + store := newTestStore(t) + + for _, table := range []string{"themes", "theme_versions", "users", "audit_log", "settings", "schema_migrations"} { + var name string + err := store.DB().QueryRowContext(t.Context(), + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&name) + if err != nil { + t.Errorf("table %s missing: %v", table, err) + } + } +} + +func TestCreateAndGetTheme(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + theme := sampleTheme("acme") + if err := store.Create(ctx, theme, "initial"); err != nil { + t.Fatalf("Create: %v", err) + } + + loaded, err := store.Get(ctx, "acme") + if err != nil { + t.Fatalf("Get: %v", err) + } + if loaded.ID == "" { + t.Error("expected an assigned id") + } + if loaded.Version != 1 { + t.Errorf("version = %d, want 1", loaded.Version) + } + if loaded.Tokens["color"] == nil { + t.Error("tokens did not round-trip") + } + if loaded.System { + t.Error("a user-created theme must not be flagged as built in") + } +} + +func TestCreateRejectsDuplicateSlug(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { + t.Fatalf("first Create: %v", err) + } + err := store.Create(ctx, sampleTheme("acme"), "") + if !errors.Is(err, domain.ErrConflict) { + t.Fatalf("duplicate slug error = %v, want ErrConflict", err) + } +} + +func TestCreateRejectsReservedSlug(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + err := store.Create(ctx, sampleTheme("api"), "") + if !errors.Is(err, domain.ErrInvalid) { + t.Fatalf("reserved slug error = %v, want ErrInvalid", err) + } +} + +func TestCreateRejectsInvalidStatus(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + theme := sampleTheme("acme") + theme.Status = "sideways" + if err := store.Create(ctx, theme, ""); !errors.Is(err, domain.ErrInvalid) { + t.Fatalf("invalid status error = %v, want ErrInvalid", err) + } +} + +func TestUpdateBumpsVersionAndRecordsHistory(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if err := store.Create(ctx, sampleTheme("acme"), "created"); err != nil { + t.Fatalf("Create: %v", err) + } + + name := "Renamed" + updated, err := store.Update(ctx, "acme", UpdateFields{Name: &name, Message: "renamed"}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Version != 2 { + t.Errorf("version = %d, want 2", updated.Version) + } + if updated.Name != name { + t.Errorf("name = %q, want %q", updated.Name, name) + } + + versions, err := store.Versions(ctx, "acme", 10) + if err != nil { + t.Fatalf("Versions: %v", err) + } + if len(versions) != 2 { + t.Fatalf("got %d versions, want 2", len(versions)) + } + // Newest first. + if versions[0].Version != 2 || versions[0].Message != "renamed" { + t.Errorf("unexpected newest version: %+v", versions[0]) + } +} + +func TestPartialUpdateLeavesOtherFieldsAlone(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { + t.Fatalf("Create: %v", err) + } + description := "updated description" + if _, err := store.Update(ctx, "acme", UpdateFields{Description: &description}); err != nil { + t.Fatalf("Update: %v", err) + } + + loaded, err := store.Get(ctx, "acme") + if err != nil { + t.Fatalf("Get: %v", err) + } + if loaded.Name != "Sample acme" { + t.Errorf("name changed to %q by a description-only update", loaded.Name) + } + if loaded.Description != description { + t.Errorf("description = %q, want %q", loaded.Description, description) + } +} + +func TestRenameChangesSlugAndKeepsHistory(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { + t.Fatalf("Create: %v", err) + } + newSlug := "acme-v2" + if _, err := store.Update(ctx, "acme", UpdateFields{Slug: &newSlug}); err != nil { + t.Fatalf("Update: %v", err) + } + + if _, err := store.Get(ctx, "acme"); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("old slug should be gone, got %v", err) + } + if _, err := store.Get(ctx, "acme-v2"); err != nil { + t.Errorf("new slug should resolve: %v", err) + } +} + +func TestDeleteRefusesBuiltInThemes(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if _, _, err := store.Seed(ctx, seed.Themes()); err != nil { + t.Fatalf("Seed: %v", err) + } + err := store.Delete(ctx, "midnight") + if !errors.Is(err, domain.ErrImmutable) { + t.Fatalf("deleting a built-in theme = %v, want ErrImmutable", err) + } +} + +func TestDeleteRemovesThemeAndCascadesHistory(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.Delete(ctx, "acme"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := store.Get(ctx, "acme"); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("theme should be gone, got %v", err) + } + + var versions int + if err := store.DB().QueryRowContext(t.Context(), + `SELECT COUNT(*) FROM theme_versions WHERE slug = ?`, "acme").Scan(&versions); err != nil { + t.Fatalf("count versions: %v", err) + } + if versions != 0 { + t.Errorf("history rows survived deletion: %d", versions) + } +} + +func TestDeleteUnknownThemeReportsNotFound(t *testing.T) { + store := newTestStore(t) + if err := store.Delete(context.Background(), "nope"); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("error = %v, want ErrNotFound", err) + } +} + +func TestDuplicateProducesIndependentDraft(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { + t.Fatalf("Create: %v", err) + } + copied, err := store.Duplicate(ctx, "acme", "acme-copy", "tester") + if err != nil { + t.Fatalf("Duplicate: %v", err) + } + + if copied.Status != domain.StatusDraft { + t.Errorf("status = %q, want draft", copied.Status) + } + if copied.Version != 1 { + t.Errorf("version = %d, want 1", copied.Version) + } + if copied.System { + t.Error("a duplicate must not remain flagged as built in") + } + + // Mutating the copy must not touch the original. + name := "Changed" + if _, err := store.Update(ctx, "acme-copy", UpdateFields{Name: &name}); err != nil { + t.Fatalf("Update copy: %v", err) + } + original, err := store.Get(ctx, "acme") + if err != nil { + t.Fatalf("Get original: %v", err) + } + if original.Name == "Changed" { + t.Error("mutating the duplicate changed the original") + } +} + +func TestRollbackAppendsRatherThanRewritesHistory(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { + t.Fatalf("Create: %v", err) + } + name := "Second" + if _, err := store.Update(ctx, "acme", UpdateFields{Name: &name}); err != nil { + t.Fatalf("Update: %v", err) + } + + restored, err := store.Rollback(ctx, "acme", 1, "tester") + if err != nil { + t.Fatalf("Rollback: %v", err) + } + if restored.Name != "Sample acme" { + t.Errorf("name = %q, want the version 1 value", restored.Name) + } + if restored.Version != 3 { + t.Errorf("version = %d, want 3; a rollback must append", restored.Version) + } + + versions, err := store.Versions(ctx, "acme", 10) + if err != nil { + t.Fatalf("Versions: %v", err) + } + if len(versions) != 3 { + t.Errorf("got %d versions, want 3 (history must be append-only)", len(versions)) + } +} + +func TestDiffVersions(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { + t.Fatalf("Create: %v", err) + } + tokens := sampleTheme("acme").Tokens + tokens["color"].(map[string]any)["semantic"].(map[string]any)["primary"] = + map[string]any{"$type": "color", "$value": "#ff0000"} + if _, err := store.Update(ctx, "acme", UpdateFields{Tokens: &tokens, Message: "recolour"}); err != nil { + t.Fatalf("Update: %v", err) + } + + diff, err := store.DiffVersions(ctx, "acme", 1, 2) + if err != nil { + t.Fatalf("DiffVersions: %v", err) + } + if len(diff.Changed) != 1 { + t.Fatalf("got %d changed tokens, want 1: %+v", len(diff.Changed), diff.Changed) + } + change := diff.Changed[0] + if change.Path != "color.semantic.primary" { + t.Errorf("path = %q", change.Path) + } + if change.From != "#4f46e5" || change.To != "#ff0000" { + t.Errorf("change = %s -> %s", change.From, change.To) + } +} + +// TestDiffVersionsOfIdenticalRevisionsMarshalAsLists pins the wire contract for a +// diff with nothing in it. +// +// Two revisions with no differences is an ordinary outcome, not an edge case: a save +// that only changed a description, or an operator comparing the wrong pair. The JSON +// still has to carry three lists, because that is what the API promises and what a +// typed client reads the length of. +func TestDiffVersionsOfIdenticalRevisionsMarshalAsLists(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { + t.Fatalf("Create: %v", err) + } + // A second save of the same token document, so the versions differ only in their + // message and the diff has nothing to report. + tokens := sampleTheme("acme").Tokens + if _, err := store.Update(ctx, "acme", UpdateFields{Tokens: &tokens, Message: "no-op save"}); err != nil { + t.Fatalf("Update: %v", err) + } + + diff, err := store.DiffVersions(ctx, "acme", 1, 2) + if err != nil { + t.Fatalf("DiffVersions: %v", err) + } + if diff.Added == nil || diff.Removed == nil || diff.Changed == nil { + t.Fatalf("empty lists must be non-nil: %+v", diff) + } + if len(diff.Added)+len(diff.Removed)+len(diff.Changed) != 0 { + t.Errorf("identical revisions should diff to nothing: %+v", diff) + } + + // The assertion that matters to a client: null would break every consumer that + // reads a length, and JSON is the contract, not the Go struct. + encoded, err := json.Marshal(diff) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + for _, field := range []string{`"added":[]`, `"removed":[]`, `"changed":[]`} { + if !strings.Contains(string(encoded), field) { + t.Errorf("diff JSON missing %s: %s", field, encoded) + } + } + for _, field := range []string{`"added":null`, `"removed":null`, `"changed":null`} { + if strings.Contains(string(encoded), field) { + t.Errorf("diff JSON has %s, which a typed client cannot read a length from: %s", field, encoded) + } + } +} + +func TestListFiltersAndSorts(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + for _, slug := range []string{"alpha", "beta", "gamma"} { + if err := store.Create(ctx, sampleTheme(slug), ""); err != nil { + t.Fatalf("Create %s: %v", slug, err) + } + } + published := domain.StatusPublished + if _, err := store.Update(ctx, "beta", UpdateFields{Status: &published}); err != nil { + t.Fatalf("Update: %v", err) + } + + page, err := store.List(ctx, domain.ThemeQuery{Status: domain.StatusPublished}) + if err != nil { + t.Fatalf("List: %v", err) + } + if page.Total != 1 || len(page.Items) != 1 || page.Items[0].Slug != "beta" { + t.Errorf("published filter returned %+v", page) + } + + page, err = store.List(ctx, domain.ThemeQuery{Search: "alph"}) + if err != nil { + t.Fatalf("List search: %v", err) + } + if page.Total != 1 || page.Items[0].Slug != "alpha" { + t.Errorf("search returned %+v", page) + } + + page, err = store.List(ctx, domain.ThemeQuery{Sort: "name", Order: "asc"}) + if err != nil { + t.Fatalf("List sorted: %v", err) + } + if len(page.Items) != 3 || page.Items[0].Slug != "alpha" { + t.Errorf("sorted page = %+v", page.Items) + } +} + +func TestListIgnoresUnknownSortColumn(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { + t.Fatalf("Create: %v", err) + } + + // An unknown sort must fall back to the default rather than reaching SQL. + if _, err := store.List(ctx, domain.ThemeQuery{Sort: "slug; DROP TABLE themes"}); err != nil { + t.Fatalf("List: %v", err) + } +} + +func TestListSearchTreatsWildcardsLiterally(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + for _, slug := range []string{"alpha", "beta"} { + if err := store.Create(ctx, sampleTheme(slug), ""); err != nil { + t.Fatalf("Create: %v", err) + } + } + + page, err := store.List(ctx, domain.ThemeQuery{Search: "%"}) + if err != nil { + t.Fatalf("List: %v", err) + } + if page.Total != 0 { + t.Errorf("a literal %% matched %d themes; wildcards must be escaped", page.Total) + } +} + +func TestListRuntimeExcludesDrafts(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if err := store.Create(ctx, sampleTheme("draft-one"), ""); err != nil { + t.Fatalf("Create: %v", err) + } + published := domain.StatusPublished + if err := store.Create(ctx, sampleTheme("live-one"), ""); err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := store.Update(ctx, "live-one", UpdateFields{Status: &published}); err != nil { + t.Fatalf("Update: %v", err) + } + + themes, err := store.ListRuntime(ctx) + if err != nil { + t.Fatalf("ListRuntime: %v", err) + } + if len(themes) != 1 || themes[0].Slug != "live-one" { + t.Errorf("ListRuntime returned %d themes, want only the published one", len(themes)) + } +} + +func TestSeedIsIdempotent(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + inserted, _, err := store.Seed(ctx, seed.Themes()) + if err != nil { + t.Fatalf("Seed: %v", err) + } + if inserted == 0 { + t.Fatal("first seed inserted nothing") + } + + // Declared here rather than alongside the first call, so the first call's + // "refreshed" is not an assignment that is immediately overwritten. + var refreshed int + inserted, refreshed, err = store.Seed(ctx, seed.Themes()) + if err != nil { + t.Fatalf("second Seed: %v", err) + } + if inserted != 0 || refreshed != 0 { + t.Errorf("second seed inserted=%d refreshed=%d, want 0/0", inserted, refreshed) + } + + for _, slug := range seed.Slugs() { + theme, err := store.Get(ctx, slug) + if err != nil { + t.Errorf("built-in %s missing: %v", slug, err) + continue + } + if !theme.System { + t.Errorf("theme %s is not flagged as built in", slug) + } + } +} + +func TestSeedLeavesEditedBuiltInsAlone(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if _, _, err := store.Seed(ctx, seed.Themes()); err != nil { + t.Fatalf("Seed: %v", err) + } + + // Simulate an operator having edited the built-in theme. + custom := "operator's midnight" + if _, err := store.Update(ctx, "midnight", UpdateFields{Name: &custom}); err != nil { + t.Fatalf("Update: %v", err) + } + editedVersion, err := store.Get(ctx, "midnight") + if err != nil { + t.Fatalf("Get: %v", err) + } + + // Re-seeding must not revert the operator's work. + if _, _, err := store.Seed(ctx, seed.Themes()); err != nil { + t.Fatalf("re-Seed: %v", err) + } + after, err := store.Get(ctx, "midnight") + if err != nil { + t.Fatalf("Get after reseed: %v", err) + } + if after.Name != custom { + t.Errorf("name = %q, want the operator's %q", after.Name, custom) + } + if after.Version != editedVersion.Version { + t.Errorf("version = %d, want %d; a reseed must not bump an edited theme", after.Version, editedVersion.Version) + } +} + +func TestAuditRoundTrip(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + entry := &domain.AuditEntry{ + Actor: "alice", + ActorEmail: "alice@example.com", + Action: domain.ActionThemePublish, + EntityType: "theme", + EntityID: "acme", + Summary: "published acme", + Detail: map[string]any{"version": 3}, + RemoteIP: "10.0.0.1", + } + if err := store.AppendAudit(ctx, entry); err != nil { + t.Fatalf("AppendAudit: %v", err) + } + + entries, total, err := store.ListAudit(ctx, AuditFilter{EntityID: "acme"}) + if err != nil { + t.Fatalf("ListAudit: %v", err) + } + if total != 1 || len(entries) != 1 { + t.Fatalf("got %d entries (total %d), want 1", len(entries), total) + } + if entries[0].Detail["version"] != float64(3) { + t.Errorf("detail = %v", entries[0].Detail) + } + + if _, _, err := store.ListAudit(ctx, AuditFilter{Actor: "bob"}); err != nil { + t.Fatalf("ListAudit by actor: %v", err) + } +} + +func TestPurgeAudit(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + old := store.Now().Add(-90 * 24 * time.Hour) + if err := store.AppendAudit(ctx, &domain.AuditEntry{At: old, Action: domain.ActionAuthLogin, Actor: "old"}); err != nil { + t.Fatalf("AppendAudit: %v", err) + } + if err := store.AppendAudit(ctx, &domain.AuditEntry{Action: domain.ActionAuthLogin, Actor: "new"}); err != nil { + t.Fatalf("AppendAudit: %v", err) + } + + removed, err := store.PurgeAudit(ctx, store.Now().Add(-30*24*time.Hour)) + if err != nil { + t.Fatalf("PurgeAudit: %v", err) + } + if removed != 1 { + t.Errorf("removed %d entries, want 1", removed) + } +} + +func TestSettings(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + value, err := store.Setting(ctx, domain.SettingDefaultTheme, "fallback") + if err != nil || value != "fallback" { + t.Fatalf("Setting default = %q, %v; want the fallback", value, err) + } + if err := store.SetSetting(ctx, domain.SettingDefaultTheme, "midnight", "tester"); err != nil { + t.Fatalf("SetSetting: %v", err) + } + value, err = store.Setting(ctx, domain.SettingDefaultTheme, "fallback") + if err != nil || value != "midnight" { + t.Fatalf("Setting = %q, %v; want midnight", value, err) + } +} + +func TestUpsertUserOverwritesRoles(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + identity := &domain.Identity{ + Subject: "sub-1", Email: "a@example.com", Name: "A", + Roles: []domain.Role{domain.RoleAdmin}, Method: "oidc", + } + if err := store.UpsertUser(ctx, identity); err != nil { + t.Fatalf("UpsertUser: %v", err) + } + + identity.Roles = []domain.Role{domain.RoleViewer} + if err := store.UpsertUser(ctx, identity); err != nil { + t.Fatalf("UpsertUser again: %v", err) + } + + users, err := store.ListUsers(ctx, 10) + if err != nil { + t.Fatalf("ListUsers: %v", err) + } + if len(users) != 1 { + t.Fatalf("got %d users, want 1 (upsert must not duplicate)", len(users)) + } + if len(users[0].Roles) != 1 || users[0].Roles[0] != string(domain.RoleViewer) { + t.Errorf("roles = %v, want the latest value", users[0].Roles) + } +} + +func TestStats(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if _, _, err := store.Seed(ctx, seed.Themes()); err != nil { + t.Fatalf("Seed: %v", err) + } + + stats, err := store.Stats(ctx) + if err != nil { + t.Fatalf("Stats: %v", err) + } + if stats.Themes != len(seed.Slugs()) { + t.Errorf("themes = %d, want %d", stats.Themes, len(seed.Slugs())) + } + if stats.DatabaseSize <= 0 { + t.Error("expected a non-zero database size") + } +} + +func TestConcurrentReadsAndWrites(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + if err := store.Create(ctx, sampleTheme("shared"), ""); err != nil { + t.Fatalf("Create: %v", err) + } + + // WAL is supposed to let readers run while a writer holds the lock. This + // test would deadlock or error without it. + errs := make(chan error, 20) + done := make(chan struct{}) + + go func() { + for i := 0; i < 10; i++ { + name := "name" + if _, err := store.Update(ctx, "shared", UpdateFields{Name: &name}); err != nil { + errs <- err + return + } + } + close(done) + }() + + for i := 0; i < 10; i++ { + if _, err := store.Get(ctx, "shared"); err != nil { + errs <- err + break + } + } + + select { + case <-done: + case err := <-errs: + t.Fatalf("concurrent access failed: %v", err) + } + close(errs) + for err := range errs { + t.Errorf("concurrent access: %v", err) + } +} diff --git a/internal/store/themes.go b/internal/store/themes.go new file mode 100644 index 0000000..00cd6cd --- /dev/null +++ b/internal/store/themes.go @@ -0,0 +1,733 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/urpi/go-bananas/internal/domain" +) + +// themeColumns is the canonical SELECT list. Keeping it in one constant means +// the scan helper and every query can never drift out of sync, which is the +// classic source of "sql: expected N destination arguments" at runtime. +const themeColumns = ` + id, slug, name, description, status, default_appearance, + tokens_json, meta_json, is_system, version, + created_at, updated_at, created_by, updated_by` + +// themeRow is the flat representation used for scanning. +type themeRow struct { + domain.Theme + tokensJSON string + metaJSON string + isSystem int +} + +// List returns a page of themes matching the query. +func (s *Store) List(ctx context.Context, q domain.ThemeQuery) (domain.ThemePage, error) { + var ( + where []string + args []any + ) + + if q.Search != "" { + // LIKE with an escaped pattern. The ESCAPE clause stops a user-supplied + // % or _ from turning the filter into a full scan of everything. + where = append(where, `(slug LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\' OR description LIKE ? ESCAPE '\')`) + pattern := "%" + escapeLike(q.Search) + "%" + args = append(args, pattern, pattern, pattern) + } + if q.Status != "" { + where = append(where, `status = ?`) + args = append(args, string(q.Status)) + } + if q.System != nil { + where = append(where, `is_system = ?`) + args = append(args, boolToInt(*q.System)) + } + if q.Tag != "" { + // Tags live inside the opaque meta document. A substring match on the + // serialised form is sufficient: the value is a short JSON string and + // the row count is small. + where = append(where, `meta_json LIKE ? ESCAPE '\'`) + args = append(args, "%\""+escapeLike(q.Tag)+"\"%") + } + + clause := "" + if len(where) > 0 { + clause = " WHERE " + strings.Join(where, " AND ") + } + + // The sort column is resolved through an allow-list rather than + // interpolated, so caller input can never reach the SQL text. + sortColumn := domain.SortFields["updated"] + if mapped, ok := domain.SortFields[q.Sort]; ok { + sortColumn = mapped + } + direction := "DESC" + if strings.EqualFold(q.Order, "asc") { + direction = "ASC" + } + + limit := q.Limit + if limit <= 0 || limit > 200 { + limit = 50 + } + offset := q.Offset + if offset < 0 { + offset = 0 + } + + total, err := s.countThemes(ctx, clause, args) + if err != nil { + return domain.ThemePage{}, err + } + + // The token document is the bulk of the row. A listing only needs metadata, + // so it is excluded here and fetched by Get. + //nolint:gosec // G202: sortColumn is validated against a fixed whitelist by the + // caller and direction is one of two literals, so neither can carry SQL. The + // values are still bound for the LIMIT and OFFSET, which do support parameters. + query := `SELECT` + themeColumns + ` FROM themes` + clause + + fmt.Sprintf(" ORDER BY %s %s, slug ASC LIMIT ? OFFSET ?", sortColumn, direction) + + rows, err := s.db.QueryContext(ctx, query, append(append([]any{}, args...), limit, offset)...) + if err != nil { + return domain.ThemePage{}, fmt.Errorf("list themes: %w", translateError(err)) + } + defer rows.Close() + + page := domain.ThemePage{Items: []*domain.Theme{}, Limit: limit, Offset: offset} + for rows.Next() { + row, err := scanThemeRow(rows) + if err != nil { + return domain.ThemePage{}, err + } + theme, err := row.theme() + if err != nil { + return domain.ThemePage{}, err + } + page.Items = append(page.Items, theme) + } + if err := rows.Err(); err != nil { + return domain.ThemePage{}, fmt.Errorf("iterate themes: %w", err) + } + page.Total = total + return page, nil +} + +// countThemes counts the rows matching a WHERE clause. +func (s *Store) countThemes(ctx context.Context, clause string, args []any) (int, error) { + var total int + query := `SELECT COUNT(*) FROM themes` + clause + if err := s.db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil { + return 0, fmt.Errorf("count themes: %w", translateError(err)) + } + return total, nil +} + +// ListRuntime returns the themes a runtime consumer may resolve. Drafts are +// excluded; archived themes are included because a pinned consumer must keep +// working after a theme is archived. +func (s *Store) ListRuntime(ctx context.Context) ([]*domain.Theme, error) { + rows, err := s.db.QueryContext(ctx, `SELECT`+themeColumns+ + ` FROM themes WHERE status IN ('published', 'archived') ORDER BY name ASC`) + if err != nil { + return nil, fmt.Errorf("list runtime themes: %w", translateError(err)) + } + defer rows.Close() + + var out []*domain.Theme + for rows.Next() { + row, err := scanThemeRow(rows) + if err != nil { + return nil, err + } + theme, err := row.theme() + if err != nil { + return nil, err + } + out = append(out, theme) + } + return out, rows.Err() +} + +// Get loads a theme by slug. +func (s *Store) Get(ctx context.Context, slug string) (*domain.Theme, error) { + row, err := scanThemeRow(s.db.QueryRowContext(ctx, + `SELECT`+themeColumns+` FROM themes WHERE slug = ?`, slug)) + if err != nil { + return nil, wrapNotFound(err, "theme "+slug) + } + return row.theme() +} + +// GetByID loads a theme by identifier. +func (s *Store) GetByID(ctx context.Context, id string) (*domain.Theme, error) { + row, err := scanThemeRow(s.db.QueryRowContext(ctx, + `SELECT`+themeColumns+` FROM themes WHERE id = ?`, id)) + if err != nil { + return nil, wrapNotFound(err, "theme "+id) + } + return row.theme() +} + +// Create inserts a new theme and its first history entry. +func (s *Store) Create(ctx context.Context, theme *domain.Theme, message string) error { + if err := domain.ValidateSlug(theme.Slug); err != nil { + return fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) + } + if _, reserved := domain.ReservedSlugs[theme.Slug]; reserved { + return fmt.Errorf("%w: slug %q is reserved by the runtime API", domain.ErrInvalid, theme.Slug) + } + if theme.Name == "" { + return fmt.Errorf("%w: theme name must not be empty", domain.ErrInvalid) + } + if theme.Status == "" { + theme.Status = domain.StatusDraft + } + if !theme.Status.Valid() { + return fmt.Errorf("%w: unknown status %q", domain.ErrInvalid, theme.Status) + } + if theme.DefaultAppearance == "" { + theme.DefaultAppearance = domain.AppearanceLight + } + if !theme.DefaultAppearance.Valid() { + return fmt.Errorf("%w: unknown appearance %q", domain.ErrInvalid, theme.DefaultAppearance) + } + if theme.Version < 1 { + theme.Version = 1 + } + if theme.ID == "" { + id, err := randomID() + if err != nil { + return err + } + theme.ID = "thm_" + id + } + + tokensJSON, err := marshalJSON(theme.Tokens) + if err != nil { + return fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) + } + metaJSON, err := marshalJSON(theme.Meta) + if err != nil { + return fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) + } + + now := s.now().UTC().Format(domain.TimeFormat) + theme.CreatedAt = s.now().UTC() + theme.UpdatedAt = theme.CreatedAt + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin create theme: %w", err) + } + defer tx.Rollback() + + if err := insertTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil { + return err + } + if err := insertVersion(ctx, tx, theme, tokensJSON, defaultMessage(message, theme, "created"), theme.CreatedBy, now); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit create theme: %w", err) + } + return nil +} + +// UpdateFields carries the mutable attributes of a theme. A nil pointer leaves +// the field unchanged, which is what lets the API accept partial edits without +// a read-modify-write race in the handler. +type UpdateFields struct { + Slug *string + Name *string + Description *string + Status *domain.ThemeStatus + DefaultAppearance *domain.Appearance + Tokens *domain.TokenDoc + Meta *domain.ThemeMeta + Message string + Editor string +} + +// Update applies a partial update and records a new version. +func (s *Store) Update(ctx context.Context, slug string, fields UpdateFields) (*domain.Theme, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin update theme: %w", err) + } + defer tx.Rollback() + + current, err := scanThemeRow(tx.QueryRowContext(ctx, + `SELECT`+themeColumns+` FROM themes WHERE slug = ?`, slug)) + if err != nil { + return nil, wrapNotFound(err, "theme "+slug) + } + theme, err := current.theme() + if err != nil { + return nil, err + } + + if fields.Slug != nil && *fields.Slug != theme.Slug { + if err := domain.ValidateSlug(*fields.Slug); err != nil { + return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) + } + if _, reserved := domain.ReservedSlugs[*fields.Slug]; reserved { + return nil, fmt.Errorf("%w: slug %q is reserved by the runtime API", domain.ErrInvalid, *fields.Slug) + } + theme.Slug = *fields.Slug + } + if fields.Name != nil { + theme.Name = *fields.Name + } + if fields.Description != nil { + theme.Description = *fields.Description + } + if fields.Status != nil { + if !fields.Status.Valid() { + return nil, fmt.Errorf("%w: unknown status %q", domain.ErrInvalid, *fields.Status) + } + theme.Status = *fields.Status + } + if fields.DefaultAppearance != nil { + if !fields.DefaultAppearance.Valid() { + return nil, fmt.Errorf("%w: unknown appearance %q", domain.ErrInvalid, *fields.DefaultAppearance) + } + theme.DefaultAppearance = *fields.DefaultAppearance + } + if fields.Tokens != nil { + theme.Tokens = *fields.Tokens + } + if fields.Meta != nil { + theme.Meta = *fields.Meta + } + if theme.Name == "" { + return nil, fmt.Errorf("%w: theme name must not be empty", domain.ErrInvalid) + } + + tokensJSON, err := marshalJSON(theme.Tokens) + if err != nil { + return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) + } + metaJSON, err := marshalJSON(theme.Meta) + if err != nil { + return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) + } + + // Every write bumps the version: the version is the cache key for the + // rendered CSS, so it must change whenever the rendered output could. + theme.Version++ + theme.UpdatedAt = s.now().UTC() + theme.UpdatedBy = fields.Editor + now := theme.UpdatedAt.Format(domain.TimeFormat) + + if _, err := tx.ExecContext(ctx, ` + UPDATE themes SET + slug = ?, name = ?, description = ?, status = ?, default_appearance = ?, + tokens_json = ?, meta_json = ?, version = ?, updated_at = ?, updated_by = ? + WHERE id = ?`, + theme.Slug, theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance), + tokensJSON, metaJSON, theme.Version, now, fields.Editor, theme.ID, + ); err != nil { + return nil, fmt.Errorf("update theme: %w", translateError(err)) + } + + if err := insertVersion(ctx, tx, theme, tokensJSON, defaultMessage(fields.Message, theme, "updated"), fields.Editor, now); err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit update theme: %w", err) + } + return theme, nil +} + +// Delete removes a theme and its history. Built-in themes cannot be deleted. +func (s *Store) Delete(ctx context.Context, slug string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM themes WHERE slug = ? AND is_system = 0`, slug) + if err != nil { + return fmt.Errorf("delete theme: %w", translateError(err)) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("delete theme: %w", err) + } + if affected == 0 { + // Distinguish "does not exist" from "is built in" so the API can return + // the right status code. + var isSystem int + err := s.db.QueryRowContext(ctx, `SELECT is_system FROM themes WHERE slug = ?`, slug).Scan(&isSystem) + switch { + case errors.Is(err, sql.ErrNoRows): + return fmt.Errorf("%w: theme %s", domain.ErrNotFound, slug) + case err != nil: + return fmt.Errorf("delete theme: %w", err) + case intToBool(isSystem): + return fmt.Errorf("%w: theme %s is built in; duplicate it instead", domain.ErrImmutable, slug) + default: + return fmt.Errorf("%w: theme %s", domain.ErrNotFound, slug) + } + } + return nil +} + +// Duplicate copies a theme under a new slug. +func (s *Store) Duplicate(ctx context.Context, slug, newSlug, editor string) (*domain.Theme, error) { + source, err := s.Get(ctx, slug) + if err != nil { + return nil, err + } + if err := domain.ValidateSlug(newSlug); err != nil { + return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) + } + if _, reserved := domain.ReservedSlugs[newSlug]; reserved { + return nil, fmt.Errorf("%w: slug %q is reserved by the runtime API", domain.ErrInvalid, newSlug) + } + + copied := source.Clone() + copied.ID = "" + copied.Slug = newSlug + copied.Name = source.Name + " (copy)" + copied.Status = domain.StatusDraft + // A duplicate starts at version 1: its history is its own, and copying the + // version number would collide with nothing but would confuse the rollback + // UI by implying revisions that never existed for this slug. + copied.Version = 1 + copied.System = false + copied.CreatedBy = editor + copied.UpdatedBy = editor + + // The copy is no longer a built-in even though it starts from one. + if copied.Meta != nil { + delete(copied.Meta, "builtIn") + delete(copied.Meta, "seedVersion") + } + + if err := s.Create(ctx, copied, "duplicated from "+slug); err != nil { + return nil, err + } + return s.Get(ctx, newSlug) +} + +// ---------------------------------------------------------------- history -- + +// Versions returns a theme's history, newest first. +func (s *Store) Versions(ctx context.Context, slug string, limit int) ([]domain.ThemeVersion, error) { + if _, err := s.Get(ctx, slug); err != nil { + return nil, err + } + if limit <= 0 || limit > 200 { + limit = 50 + } + + rows, err := s.db.QueryContext(ctx, ` + SELECT theme_id, version, slug, name, description, status, + default_appearance, tokens_json, message, author, created_at + FROM theme_versions WHERE theme_id = (SELECT id FROM themes WHERE slug = ?) + ORDER BY version DESC LIMIT ?`, slug, limit) + if err != nil { + return nil, fmt.Errorf("list versions: %w", translateError(err)) + } + defer rows.Close() + + out := make([]domain.ThemeVersion, 0, limit) + for rows.Next() { + var ( + v domain.ThemeVersion + status string + appearance string + tokensJSON string + createdAt string + ) + if err := rows.Scan(&v.ThemeID, &v.Version, &v.Slug, &v.Name, &v.Description, &status, + &appearance, &tokensJSON, &v.Message, &v.Author, &createdAt); err != nil { + return nil, fmt.Errorf("scan version: %w", err) + } + v.Status = domain.ThemeStatus(status) + v.DefaultAppearance = domain.Appearance(appearance) + if err := json.Unmarshal([]byte(tokensJSON), &v.Tokens); err != nil { + return nil, fmt.Errorf("decode version tokens: %w", err) + } + if v.CreatedAt, err = parseTime(createdAt); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +// Version loads one historical snapshot. +func (s *Store) Version(ctx context.Context, slug string, version int64) (*domain.ThemeVersion, error) { + var ( + v domain.ThemeVersion + status string + appearance string + tokensJSON string + createdAt string + ) + err := s.db.QueryRowContext(ctx, ` + SELECT theme_id, version, slug, name, description, status, + default_appearance, tokens_json, message, author, created_at + FROM theme_versions + WHERE theme_id = (SELECT id FROM themes WHERE slug = ?) AND version = ?`, + slug, version, + ).Scan(&v.ThemeID, &v.Version, &v.Slug, &v.Name, &v.Description, &status, + &appearance, &tokensJSON, &v.Message, &v.Author, &createdAt) + if err != nil { + return nil, wrapNotFound(err, fmt.Sprintf("version %d of theme %s", version, slug)) + } + + v.Status = domain.ThemeStatus(status) + v.DefaultAppearance = domain.Appearance(appearance) + if err := json.Unmarshal([]byte(tokensJSON), &v.Tokens); err != nil { + return nil, fmt.Errorf("decode version tokens: %w", err) + } + if v.CreatedAt, err = parseTime(createdAt); err != nil { + return nil, err + } + return &v, nil +} + +// Rollback restores a historical snapshot as a new version, which keeps history +// append-only. Rewriting history would break the guarantee that a pinned +// consumer can always resolve the version it was built against. +func (s *Store) Rollback(ctx context.Context, slug string, version int64, editor string) (*domain.Theme, error) { + snapshot, err := s.Version(ctx, slug, version) + if err != nil { + return nil, err + } + current, err := s.Get(ctx, slug) + if err != nil { + return nil, err + } + + fields := UpdateFields{ + Name: &snapshot.Name, + Description: &snapshot.Description, + Status: &snapshot.Status, + DefaultAppearance: &snapshot.DefaultAppearance, + Tokens: &snapshot.Tokens, + Message: fmt.Sprintf("rolled back to version %d", version), + Editor: editor, + } + if current.System { + // Rolling a built-in theme back must not clear its built-in flag, but + // the store's update path already leaves it untouched. + fields.Editor = editor + } + return s.Update(ctx, slug, fields) +} + +// Diff compares two versions of a theme. Only the token tree is compared; +// metadata changes are reported as a boolean rather than field by field. +type Diff struct { + FromVersion int64 `json:"fromVersion"` + ToVersion int64 `json:"toVersion"` + Added []string `json:"added"` + Removed []string `json:"removed"` + Changed []TokenDiff `json:"changed"` + MetaChanged bool `json:"metaChanged"` +} + +// TokenDiff is one changed token. +type TokenDiff struct { + Path string `json:"path"` + From string `json:"from"` + To string `json:"to"` +} + +// DiffVersions compares two snapshots of the same theme. +func (s *Store) DiffVersions(ctx context.Context, slug string, from, to int64) (*Diff, error) { + fromVersion, err := s.Version(ctx, slug, from) + if err != nil { + return nil, err + } + toVersion, err := s.Version(ctx, slug, to) + if err != nil { + return nil, err + } + + // The three lists are allocated empty rather than left nil. A nil slice marshals + // to JSON null, and the contract declares these as arrays -- so comparing two + // revisions that differ in nothing, which is an ordinary outcome rather than an + // edge case, would hand every client a null where it expects a list. The same + // reason ListUsers normalises an absent roles column. + result := &Diff{ + FromVersion: from, + ToVersion: to, + Added: []string{}, + Removed: []string{}, + Changed: []TokenDiff{}, + } + fromTokens := flattenForDiff(fromVersion.Tokens) + toTokens := flattenForDiff(toVersion.Tokens) + + for path, fromValue := range fromTokens { + toValue, ok := toTokens[path] + if !ok { + result.Removed = append(result.Removed, path) + continue + } + if fromValue != toValue { + result.Changed = append(result.Changed, TokenDiff{Path: path, From: fromValue, To: toValue}) + } + } + for path := range toTokens { + if _, ok := fromTokens[path]; !ok { + result.Added = append(result.Added, path) + } + } + + sortStrings(result.Added) + sortStrings(result.Removed) + sortSlice(result.Changed, func(a, b TokenDiff) bool { return a.Path < b.Path }) + + return result, nil +} + +// flattenForDiff reduces a token document to a path to rendered value map. +// +// The walk is deliberately local to the store rather than delegating to the +// token engine: a diff must compare exactly what was stored, including tokens +// the current engine schema no longer recognises, so that rolling back across a +// schema change still shows a faithful comparison. +func flattenForDiff(doc domain.TokenDoc) map[string]string { + flat := make(map[string]string, 128) + walkForDiff(doc, "", flat) + return flat +} + +// walkForDiff recurses through a token group. +func walkForDiff(node map[string]any, prefix string, out map[string]string) { + for key, raw := range node { + if strings.HasPrefix(key, "$") { + continue + } + child, ok := raw.(map[string]any) + if !ok { + continue + } + path := key + if prefix != "" { + path = prefix + "." + key + } + if value, hasValue := child["$value"]; hasValue { + out[path] = stringifyTokenValue(value) + continue + } + walkForDiff(child, path, out) + } +} + +// stringifyTokenValue renders a token value for comparison. +func stringifyTokenValue(value any) string { + switch v := value.(type) { + case string: + return v + case float64: + // JSON numbers decode as float64; format them without an exponent so + // that 1 and 1.0 do not show up as a spurious change. + return strconv.FormatFloat(v, 'f', -1, 64) + case bool: + return strconv.FormatBool(v) + case []any: + parts := make([]string, len(v)) + for i, p := range v { + parts[i] = stringifyTokenValue(p) + } + return strings.Join(parts, ", ") + default: + return fmt.Sprint(v) + } +} + +// ---------------------------------------------------------------- helpers -- + +// scanThemeRow reads one theme row from a scanner. +func scanThemeRow(scanner interface{ Scan(...any) error }) (*themeRow, error) { + var ( + row themeRow + status string + appearance string + createdAt string + updatedAt string + ) + err := scanner.Scan( + &row.ID, &row.Slug, &row.Name, &row.Description, &status, &appearance, + &row.tokensJSON, &row.metaJSON, &row.isSystem, &row.Version, + &createdAt, &updatedAt, &row.CreatedBy, &row.UpdatedBy, + ) + if err != nil { + return nil, err + } + + row.Status = domain.ThemeStatus(status) + row.DefaultAppearance = domain.Appearance(appearance) + row.System = intToBool(row.isSystem) + row.CreatedAt, err = parseTime(createdAt) + if err != nil { + return nil, err + } + row.UpdatedAt, err = parseTime(updatedAt) + if err != nil { + return nil, err + } + return &row, nil +} + +// theme materialises a scanned row into a domain theme. +func (r *themeRow) theme() (*domain.Theme, error) { + tokens := domain.TokenDoc{} + if r.tokensJSON != "" { + if err := json.Unmarshal([]byte(r.tokensJSON), &tokens); err != nil { + return nil, fmt.Errorf("decode tokens for theme %s: %w", r.Slug, err) + } + } + meta := domain.ThemeMeta{} + if r.metaJSON != "" { + if err := json.Unmarshal([]byte(r.metaJSON), &meta); err != nil { + return nil, fmt.Errorf("decode meta for theme %s: %w", r.Slug, err) + } + } + out := r.Theme + out.Tokens = tokens + out.Meta = meta + return &out, nil +} + +// parseTime converts a stored timestamp back into a time.Time. +func parseTime(raw string) (time.Time, error) { + if raw == "" { + return time.Time{}, nil + } + parsed, err := time.Parse(domain.TimeFormat, raw) + if err != nil { + return time.Time{}, fmt.Errorf("parse timestamp %q: %w", raw, err) + } + return parsed, nil +} + +// escapeLike neutralises LIKE wildcards in user input. +func escapeLike(s string) string { + replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return replacer.Replace(s) +} + +// defaultMessage returns the supplied message or a generated one. +func defaultMessage(message string, theme *domain.Theme, verb string) string { + if trimmed := strings.TrimSpace(message); trimmed != "" { + return trimmed + } + return verb + " " + theme.Name +} + +// Now is exposed for the audit log, which shares the store's clock. +func (s *Store) timestamp() string { return s.now().UTC().Format(domain.TimeFormat) } diff --git a/internal/store/user_preferences.go b/internal/store/user_preferences.go new file mode 100644 index 0000000..c651735 --- /dev/null +++ b/internal/store/user_preferences.go @@ -0,0 +1,152 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/urpi/go-bananas/internal/domain" +) + +// Preferences returns what the subject has chosen for their own interface. +// +// A subject with no row, which is everybody until they save a preference once, +// reads as empty rather than as an error: the zero value is the documented "no +// choice made" state and every caller renders a default for it. +// +// A stored theme that has since been deleted or archived is reported as no +// choice, for the same reason. Resolving the name against the themes table on +// read keeps a stale preference from pinning the UI to something that no longer +// exists, and it means an unpublish is reflected without a migration. +func (s *Store) Preferences(ctx context.Context, subject string) (domain.UserPreferences, error) { + if subject == "" { + return domain.UserPreferences{}, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid) + } + + var stored domain.UserPreferences + err := s.db.QueryRowContext(ctx, ` + SELECT selected_theme, selected_appearance + FROM user_preferences + WHERE subject = ?`, subject). + Scan(&stored.SelectedTheme, &stored.SelectedAppearance) + if errors.Is(err, sql.ErrNoRows) { + return domain.UserPreferences{}, nil + } + if err != nil { + return domain.UserPreferences{}, translateError(err) + } + + if stored.SelectedTheme != "" { + usable, err := s.themeUsable(ctx, stored.SelectedTheme) + if err != nil { + return domain.UserPreferences{}, err + } + if !usable { + return domain.UserPreferences{}, nil + } + } + if stored.SelectedAppearance != "" && !stored.SelectedAppearance.Valid() { + // A build that no longer knows an appearance must not hand it to a + // client, which would set an attribute nothing styles. + return domain.UserPreferences{}, nil + } + return stored, nil +} + +// SetPreferences records the subject's choice and returns what was stored. +// +// The theme must already exist and be published or archived. Drafts are rejected +// because they are invisible to runtime consumers, so a preference pointing at one +// would leave the UI rendering a theme it cannot load. +func (s *Store) SetPreferences(ctx context.Context, subject string, prefs domain.UserPreferences) (domain.UserPreferences, error) { + if subject == "" { + return domain.UserPreferences{}, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid) + } + if err := prefs.Validate(); err != nil { + return domain.UserPreferences{}, err + } + + if prefs.SelectedTheme != "" { + usable, err := s.themeUsable(ctx, prefs.SelectedTheme) + if err != nil { + return domain.UserPreferences{}, err + } + if !usable { + return domain.UserPreferences{}, fmt.Errorf( + "%w: selectedTheme %q is not a published theme", domain.ErrInvalid, prefs.SelectedTheme) + } + } else { + // An appearance without a theme has nothing to select a scheme from, so it + // is dropped rather than stored. Clearing a choice by sending an empty + // theme therefore works even if the client leaves the appearance set. + prefs.SelectedAppearance = "" + } + + _, err := s.db.ExecContext(ctx, ` + INSERT INTO user_preferences (subject, selected_theme, selected_appearance, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(subject) DO UPDATE SET + selected_theme = excluded.selected_theme, + selected_appearance = excluded.selected_appearance, + updated_at = excluded.updated_at`, + subject, prefs.SelectedTheme, string(prefs.SelectedAppearance), s.timestamp()) + if err != nil { + return domain.UserPreferences{}, translateError(err) + } + return prefs, nil +} + +// ClearPreferences forgets the subject's choice, which is how the UI returns to +// its own default. It reports whether a row was removed. +func (s *Store) ClearPreferences(ctx context.Context, subject string) (bool, error) { + if subject == "" { + return false, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid) + } + result, err := s.db.ExecContext(ctx, `DELETE FROM user_preferences WHERE subject = ?`, subject) + if err != nil { + return false, translateError(err) + } + affected, err := result.RowsAffected() + if err != nil { + // Drivers that cannot report this are not a reason to fail the request: + // the row is gone either way, which is all the caller asked for. + return true, nil //nolint:nilerr // see comment above + } + return affected > 0, nil +} + +// ThemePreferenceCount reports how many subjects have chosen a theme, which the +// admin UI shows so an editor can see who a change would affect before making it. +func (s *Store) ThemePreferenceCount(ctx context.Context, slug string) (int, error) { + if err := domain.ValidateSlug(slug); err != nil { + return 0, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) + } + var count int + err := s.db.QueryRowContext(ctx, ` + SELECT count(*) FROM user_preferences WHERE selected_theme = ?`, slug).Scan(&count) + if err != nil { + return 0, translateError(err) + } + return count, nil +} + +// themeUsable reports whether a slug names a theme a runtime consumer may load. +// Archived themes qualify because the last published revision of an archived theme +// stays resolvable for consumers pinned to it. +func (s *Store) themeUsable(ctx context.Context, slug string) (bool, error) { + var status string + err := s.db.QueryRowContext(ctx, `SELECT status FROM themes WHERE slug = ?`, slug).Scan(&status) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, translateError(err) + } + switch domain.ThemeStatus(status) { + case domain.StatusPublished, domain.StatusArchived: + return true, nil + default: + return false, nil + } +} diff --git a/internal/store/user_preferences_test.go b/internal/store/user_preferences_test.go new file mode 100644 index 0000000..02be00e --- /dev/null +++ b/internal/store/user_preferences_test.go @@ -0,0 +1,262 @@ +package store + +import ( + "context" + "errors" + "testing" + + "github.com/urpi/go-bananas/internal/domain" +) + +func TestPreferencesDefaultToNoChoice(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + // A subject nobody has saved anything for reads as empty rather than failing, + // because empty is the documented "no choice" state. + prefs, err := s.Preferences(ctx, "nobody") + if err != nil { + t.Fatalf("read preferences: %v", err) + } + if prefs.SelectedTheme != "" || prefs.SelectedAppearance != "" { + t.Fatalf("preferences = %+v, want the zero value", prefs) + } +} + +func TestPreferencesRoundTrip(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + published := publishTheme(t, s, "midnight") + + stored, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ + SelectedTheme: published.Slug, + SelectedAppearance: domain.AppearanceDark, + }) + if err != nil { + t.Fatalf("set preferences: %v", err) + } + if stored.SelectedTheme != "midnight" || stored.SelectedAppearance != domain.AppearanceDark { + t.Fatalf("stored = %+v", stored) + } + + read, err := s.Preferences(ctx, "user-1") + if err != nil { + t.Fatalf("read preferences: %v", err) + } + if read != stored { + t.Fatalf("read = %+v, want %+v", read, stored) + } +} + +func TestPreferencesCanBeClearedAndOverwritten(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + publishTheme(t, s, "midnight") + + if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ + SelectedTheme: "midnight", + SelectedAppearance: domain.AppearanceLight, + }); err != nil { + t.Fatalf("set preferences: %v", err) + } + + // Writing again replaces rather than accumulating. + if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ + SelectedTheme: "midnight", + SelectedAppearance: domain.AppearanceDark, + }); err != nil { + t.Fatalf("overwrite preferences: %v", err) + } + read, err := s.Preferences(ctx, "user-1") + if err != nil { + t.Fatalf("read preferences: %v", err) + } + if read.SelectedAppearance != domain.AppearanceDark { + t.Fatalf("appearance = %q, want dark", read.SelectedAppearance) + } + + // An empty theme is the absence of a choice and is storable. + if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{}); err != nil { + t.Fatalf("clear by writing empty: %v", err) + } + read, err = s.Preferences(ctx, "user-1") + if err != nil { + t.Fatalf("read preferences: %v", err) + } + if read.SelectedTheme != "" { + t.Fatalf("theme = %q, want empty", read.SelectedTheme) + } + + removed, err := s.ClearPreferences(ctx, "user-1") + if err != nil { + t.Fatalf("clear preferences: %v", err) + } + if !removed { + t.Error("clear reported no row removed") + } +} + +// TestPreferencesRejectUnknownAndDraftThemes covers the two ways a name can fail: +// it does not exist, or it exists but runtime consumers cannot load it. +func TestPreferencesRejectUnknownAndDraftThemes(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + if err := s.Create(ctx, sampleTheme("draft-one"), ""); err != nil { + t.Fatalf("create theme: %v", err) + } + + for _, slug := range []string{"no-such-theme", "draft-one"} { + _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ + SelectedTheme: slug, + SelectedAppearance: domain.AppearanceLight, + }) + if !errors.Is(err, domain.ErrInvalid) { + t.Errorf("SetPreferences(%q) = %v, want ErrInvalid", slug, err) + } + } +} + +// TestPreferencesFallBackWhenTheThemeDisappears is the reason Preferences resolves +// the name on read: a theme that is later deleted must not leave somebody pinned to +// a selection the UI cannot load. +func TestPreferencesFallBackWhenTheThemeDisappears(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + theme := publishTheme(t, s, "midnight") + + if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ + SelectedTheme: theme.Slug, + SelectedAppearance: domain.AppearanceDark, + }); err != nil { + t.Fatalf("set preferences: %v", err) + } + if err := s.Delete(ctx, theme.Slug); err != nil { + t.Fatalf("delete theme: %v", err) + } + + read, err := s.Preferences(ctx, "user-1") + if err != nil { + t.Fatalf("read preferences: %v", err) + } + if read.SelectedTheme != "" || read.SelectedAppearance != "" { + t.Fatalf("preferences = %+v, want the zero value once the theme is gone", read) + } +} + +func TestPreferencesRejectMalformedInput(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + publishTheme(t, s, "midnight") + + rejected := map[string]domain.UserPreferences{ + "uppercase slug": {SelectedTheme: "Midnight"}, + "slug with a space": {SelectedTheme: "mid night"}, + "leading dash": {SelectedTheme: "-midnight"}, + "unknown appearance": { + SelectedTheme: "midnight", + SelectedAppearance: "sepia", + }, + } + for name, prefs := range rejected { + t.Run(name, func(t *testing.T) { + if _, err := s.SetPreferences(ctx, "user-1", prefs); !errors.Is(err, domain.ErrInvalid) { + t.Fatalf("SetPreferences(%+v) = %v, want ErrInvalid", prefs, err) + } + }) + } + + // Clearing with an empty theme is valid, and an appearance sent alongside it + // is dropped rather than stored as a scheme with nothing to apply it to. + stored, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ + SelectedTheme: "", + SelectedAppearance: domain.AppearanceDark, + }) + if err != nil { + t.Fatalf("clearing with a stray appearance: %v", err) + } + if stored.SelectedAppearance != "" { + t.Fatalf("stored = %+v, want the appearance dropped", stored) + } + + if _, err := s.Preferences(ctx, ""); !errors.Is(err, domain.ErrInvalid) { + t.Errorf("Preferences(\"\") = %v, want ErrInvalid", err) + } + if _, err := s.SetPreferences(ctx, "", domain.UserPreferences{}); !errors.Is(err, domain.ErrInvalid) { + t.Errorf("SetPreferences with empty subject = %v, want ErrInvalid", err) + } +} + +// TestPreferencesSurviveLogin matters because UpsertUser rewrites the users row on +// every login, which is exactly why preferences are not columns on it. +func TestPreferencesSurviveLogin(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + publishTheme(t, s, "midnight") + + if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ + SelectedTheme: "midnight", + SelectedAppearance: domain.AppearanceDark, + }); err != nil { + t.Fatalf("set preferences: %v", err) + } + for range 2 { + if err := s.UpsertUser(ctx, &domain.Identity{ + Subject: "user-1", Email: "u@example.test", Roles: []domain.Role{domain.RoleViewer}, + }); err != nil { + t.Fatalf("upsert user: %v", err) + } + } + + read, err := s.Preferences(ctx, "user-1") + if err != nil { + t.Fatalf("read preferences: %v", err) + } + if read.SelectedTheme != "midnight" || read.SelectedAppearance != domain.AppearanceDark { + t.Fatalf("preferences = %+v, want them preserved across logins", read) + } +} + +func TestThemePreferenceCount(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + publishTheme(t, s, "midnight") + + for _, subject := range []string{"a", "b"} { + if _, err := s.SetPreferences(ctx, subject, domain.UserPreferences{ + SelectedTheme: "midnight", + SelectedAppearance: domain.AppearanceLight, + }); err != nil { + t.Fatalf("set preferences for %s: %v", subject, err) + } + } + + count, err := s.ThemePreferenceCount(ctx, "midnight") + if err != nil { + t.Fatalf("count: %v", err) + } + if count != 2 { + t.Fatalf("count = %d, want 2", count) + } + if _, err := s.ThemePreferenceCount(ctx, "Not A Slug"); !errors.Is(err, domain.ErrInvalid) { + t.Errorf("ThemePreferenceCount with a bad slug = %v, want ErrInvalid", err) + } +} + +// publishTheme creates a theme and publishes it, returning the stored value. +// +// StatusPublished is a constant rather than a variable, so the pointer +// UpdateFields wants has to be taken from a local. +func publishTheme(t *testing.T, s *Store, slug string) *domain.Theme { + t.Helper() + ctx := context.Background() + if err := s.Create(ctx, sampleTheme(slug), ""); err != nil { + t.Fatalf("create theme %s: %v", slug, err) + } + published := domain.StatusPublished + theme, err := s.Update(ctx, slug, UpdateFields{Status: &published}) + if err != nil { + t.Fatalf("publish theme %s: %v", slug, err) + } + return theme +} diff --git a/internal/tokenengine/color.go b/internal/tokenengine/color.go new file mode 100644 index 0000000..c51387b --- /dev/null +++ b/internal/tokenengine/color.go @@ -0,0 +1,426 @@ +package tokenengine + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// RGB is an sRGB colour with an alpha channel in the range 0..1. +type RGB struct { + R, G, B float64 + A float64 +} + +// Color is a parsed CSS colour. Only the subset that can be reasoned about +// numerically is modelled; any other valid CSS colour syntax is kept verbatim +// in Raw so that it can still be emitted, but comparisons and contrast +// computations fall back to Neutral when it is not parseable. +type Color struct { + RGB + // Raw is the original author-written token, preserved so that rendering + // never rewrites what a human typed. + Raw string + // Numeric reports whether RGB is meaningful. + Numeric bool +} + +// Neutral is the fallback colour used when a token cannot be parsed. It is a +// mid grey: it is intentionally low-contrast against both light and dark +// backgrounds so that a broken token is visible rather than silently plausible. +var Neutral = Color{RGB: RGB{R: 0.5, G: 0.5, B: 0.5, A: 1}, Raw: "#80808080", Numeric: false} + +// NeutralHex is the plain-text form of Neutral. +const NeutralHex = "#808080" + +const ( + // alphaEpsilon is the rounding tolerance used when snapping alpha to 0 or 1 + // so that #ffffff00 and #ffffff render identically. + alphaEpsilon = 1e-6 + // channelEpsilon guards against float drift when comparing components. + channelEpsilon = 1e-9 +) + +// ParseColor parses the CSS colour syntaxes a theme author is realistically +// going to use: #hex in 3/4/6/8 digit forms, rgb()/rgba(), hsl()/hsla() and the +// functional-notation whitespace/comma variants. Anything else is returned as a +// non-numeric colour that still round-trips. +func ParseColor(input string) (Color, error) { + raw := strings.TrimSpace(input) + if raw == "" { + return Color{}, fmt.Errorf("empty colour") + } + lower := strings.ToLower(raw) + + // Modern CSS colour spaces are valid CSS but cannot be interpreted with + // sRGB math. Pass them through untouched. + for _, fn := range []string{"oklch(", "oklab(", "lab(", "lch(", "color(", "color-mix(", "light-dark("} { + if strings.HasPrefix(lower, fn) { + return Color{Raw: raw}, nil + } + } + // CSS-wide keywords resolve to unpredictable values depending on the host + // page; treating them as opaque keeps rendering faithful. + if lower == "inherit" || lower == "initial" || lower == "unset" || lower == "currentcolor" || lower == "transparent" { + return Color{Raw: raw}, nil + } + + if strings.HasPrefix(lower, "#") { + return parseHex(raw, lower[1:]) + } + + open := strings.IndexByte(lower, '(') + if open < 0 || !strings.HasSuffix(lower, ")") { + return Color{Raw: raw}, nil + } + name := strings.TrimSpace(lower[:open]) + body := lower[open+1 : len(lower)-1] + + args := splitArgs(body) + switch name { + case "rgb", "rgba": + return parseRGBFunc(raw, args) + case "hsl", "hsla": + return parseHSLFunc(raw, args) + default: + return Color{Raw: raw}, nil + } +} + +// parseHex decodes #rgb, #rgba, #rrggbb and #rrggbbaa. +func parseHex(raw, digits string) (Color, error) { + expand := func(s string) (float64, error) { + n, err := strconv.ParseUint(s, 16, 8) + if err != nil { + return 0, err + } + return float64(n) / 255, nil + } + + switch len(digits) { + case 3, 4: + var comps [4]float64 + for i := 0; i < len(digits); i++ { + v, err := expand(string(digits[i]) + string(digits[i])) + if err != nil { + return Color{}, fmt.Errorf("invalid hex colour %q", raw) + } + comps[i] = v + } + if len(digits) == 3 { + comps[3] = 1 + } + return numericColor(comps[0], comps[1], comps[2], comps[3], raw), nil + case 6, 8: + var comps [4]float64 + for i := 0; i < len(digits)/2; i++ { + v, err := expand(digits[i*2 : i*2+2]) + if err != nil { + return Color{}, fmt.Errorf("invalid hex colour %q", raw) + } + comps[i] = v + } + if len(digits) == 6 { + comps[3] = 1 + } + return numericColor(comps[0], comps[1], comps[2], comps[3], raw), nil + default: + return Color{}, fmt.Errorf("invalid hex colour %q: expected 3, 4, 6 or 8 digits, got %d", raw, len(digits)) + } +} + +// splitArgs splits a CSS function argument list on commas or whitespace, +// ignoring separators nested inside parentheses. +func splitArgs(body string) []string { + var ( + out []string + depth int + cur strings.Builder + ) + for _, r := range body { + switch { + case r == '(': + depth++ + cur.WriteRune(r) + case r == ')': + depth-- + cur.WriteRune(r) + case (r == ',' || r == '/' || r == ' ' || r == '\t') && depth == 0: + if s := strings.TrimSpace(cur.String()); s != "" { + out = append(out, s) + } + cur.Reset() + default: + cur.WriteRune(r) + } + } + if s := strings.TrimSpace(cur.String()); s != "" { + out = append(out, s) + } + return out +} + +// parseRGBFunc handles rgb(255 0 0 / 50%) and rgba(255, 0, 0, 0.5). +func parseRGBFunc(raw string, args []string) (Color, error) { + if len(args) < 3 || len(args) > 4 { + return Color{}, fmt.Errorf("invalid rgb() colour %q: expected 3 or 4 components", raw) + } + var comps [4]float64 + for i := 0; i < 3; i++ { + v, err := parseChannel(args[i]) + if err != nil { + return Color{}, fmt.Errorf("invalid rgb() colour %q: %w", raw, err) + } + comps[i] = v + } + comps[3] = 1 + if len(args) == 4 { + v, err := parseAlpha(args[3]) + if err != nil { + return Color{}, fmt.Errorf("invalid rgb() colour %q: %w", raw, err) + } + comps[3] = v + } + return numericColor(comps[0], comps[1], comps[2], comps[3], raw), nil +} + +// parseHSLFunc handles both comma and space separated hsl notation. +func parseHSLFunc(raw string, args []string) (Color, error) { + if len(args) < 3 || len(args) > 4 { + return Color{}, fmt.Errorf("invalid hsl() colour %q: expected 3 or 4 components", raw) + } + h, err := parseChannel(args[0]) + if err != nil { + return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err) + } + s, err := parseChannel(args[1]) + if err != nil { + return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err) + } + l, err := parseChannel(args[2]) + if err != nil { + return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err) + } + r, g, b := hslToRGB(h, s, l) + a := 1.0 + if len(args) == 4 { + v, err := parseAlpha(args[3]) + if err != nil { + return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err) + } + a = v + } + return numericColor(r, g, b, a, raw), nil +} + +// parseChannel accepts a bare number or a percentage and normalises to 0..1. +func parseChannel(s string) (float64, error) { + s = strings.TrimSpace(s) + if strings.HasSuffix(s, "%") { + v, err := strconv.ParseFloat(strings.TrimSuffix(s, "%"), 64) + if err != nil { + return 0, err + } + return clamp01(v / 100), nil + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, err + } + return clamp01(v / 255), nil +} + +// parseAlpha accepts a bare 0..1 number or a percentage. +func parseAlpha(s string) (float64, error) { + s = strings.TrimSpace(s) + if strings.HasSuffix(s, "%") { + v, err := strconv.ParseFloat(strings.TrimSuffix(s, "%"), 64) + if err != nil { + return 0, err + } + return clamp01(v / 100), nil + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, err + } + return clamp01(v), nil +} + +// numericColor clamps and normalises a parsed colour, snapping alpha that is +// effectively 0 or 1 so that round-tripping is stable. +func numericColor(r, g, b, a float64, raw string) Color { + a = clamp01(a) + if a < alphaEpsilon { + a = 0 + } + if a > 1-alphaEpsilon { + a = 1 + } + return Color{RGB: RGB{R: clamp01(r), G: clamp01(g), B: clamp01(b), A: a}, Raw: raw, Numeric: true} +} + +func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) } + +// hslToRGB converts normalised hue (0..1), saturation and lightness to sRGB. +func hslToRGB(h, s, l float64) (float64, float64, float64) { + if s <= channelEpsilon { + return l, l, l + } + var q float64 + if l < 0.5 { + q = l * (1 + s) + } else { + q = l + s - l*s + } + p := 2*l - q + return hueToChannel(p, q, h+1.0/3.0), hueToChannel(p, q, h), hueToChannel(p, q, h-1.0/3.0) +} + +// hueToChannel is the standard hue-to-channel helper from the CSS Color spec. +func hueToChannel(p, q, t float64) float64 { + if t < 0 { + t++ + } + if t > 1 { + t-- + } + switch { + case t < 1.0/6.0: + return p + (q-p)*6*t + case t < 1.0/2.0: + return q + case t < 2.0/3.0: + return p + (q-p)*(2.0/3.0-t)*6 + default: + return p + } +} + +// Hex renders the colour as #rrggbb, or #rrggbbaa when translucent. +func (c Color) Hex() string { + if !c.Numeric { + return c.Raw + } + r := int(math.Round(c.R * 255)) + g := int(math.Round(c.G * 255)) + b := int(math.Round(c.B * 255)) + if c.A >= 1-alphaEpsilon { + return fmt.Sprintf("#%02x%02x%02x", r, g, b) + } + return fmt.Sprintf("#%02x%02x%02x%02x", r, g, b, int(math.Round(c.A*255))) +} + +// WithAlpha returns the colour with a replaced alpha channel. +func (c Color) WithAlpha(a float64) Color { + if !c.Numeric { + return Color{Raw: c.Raw} + } + return numericColor(c.R, c.G, c.B, a, c.Raw) +} + +// Mix blends this colour toward other by t in 0..1, carrying alpha through the +// CSS compositing rules so that translucent tokens do not darken incorrectly. +func (c Color) Mix(other Color, t float64) Color { + if !c.Numeric || !other.Numeric { + return Neutral + } + t = clamp01(t) + // Straight (non-premultiplied) alpha compositing. + alpha := c.A*(1-t) + other.A*t + if alpha < alphaEpsilon { + return numericColor(0, 0, 0, 0, "") + } + r := (c.R*c.A*(1-t) + other.R*other.A*t) / alpha + g := (c.G*c.A*(1-t) + other.G*other.A*t) / alpha + b := (c.B*c.A*(1-t) + other.B*other.A*t) / alpha + return numericColor(r, g, b, alpha, "") +} + +// Lighten mixes toward white by t. +func (c Color) Lighten(t float64) Color { + return c.Mix(Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}, t) +} + +// Darken mixes toward black by t. +func (c Color) Darken(t float64) Color { return c.Mix(Color{RGB: RGB{A: 1}, Numeric: true}, t) } + +// LightenDark adapts a lightening or darkening step for a dark colour scheme, +// where "more prominent" means lighter rather than darker. +func (c Color) LightenDark(t float64, dark bool) Color { + if dark { + return c.Lighten(t) + } + return c.Darken(t) +} + +// RelativeLuminance implements WCAG 2.1 relative luminance. +func (c Color) RelativeLuminance() float64 { + if !c.Numeric { + return 0.5 + } + return 0.2126*linearize(c.R) + 0.7152*linearize(c.G) + 0.0722*linearize(c.B) +} + +func linearize(v float64) float64 { + if v <= 0.04045 { + return v / 12.92 + } + return math.Pow((v+0.055)/1.055, 2.4) +} + +// ContrastRatio returns the WCAG 2.1 contrast ratio between the two colours, +// always >= 1. +func ContrastRatio(a, b Color) float64 { + la, lb := a.RelativeLuminance(), b.RelativeLuminance() + if la < lb { + la, lb = lb, la + } + return (la + 0.05) / (lb + 0.05) +} + +// WCAG contrast thresholds for normal-weight text. +const ( + // ContrastAA is the WCAG 2.1 AA minimum for body text (4.5:1). + ContrastAA = 4.5 + // ContrastAAA is the WCAG 2.1 AAA minimum for body text (7:1). + ContrastAAA = 7.0 + // ContrastAALarge is the AA minimum for large text and UI components (3:1). + ContrastAALarge = 3.0 +) + +// BestForeground picks whichever of the light or dark anchor contrasts more +// strongly with bg. This is how the engine derives readable "on-colour" +// tokens without asking the author to maintain a second palette. +func BestForeground(bg Color) Color { + light := Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true} + dark := Color{RGB: RGB{A: 1}, Numeric: true} + if ContrastRatio(bg, light) >= ContrastRatio(bg, dark) { + return light + } + return dark +} + +// IsDark reports whether a colour is perceived as dark, using the WCAG +// luminance threshold that matches ContrastRatio's midpoint. +func IsDark(c Color) bool { return c.RelativeLuminance() < 0.4 } + +// FormatNumber renders a float for CSS output: no trailing zeros, no +// exponent for the small magnitudes we emit, and a hard cap on precision so +// that generated CSS is byte-stable across platforms. +func FormatNumber(v float64) string { + if math.IsNaN(v) || math.IsInf(v, 0) { + return "0" + } + rounded := math.Round(v*10000) / 10000 + if rounded == 0 { + return "0" + } + s := strconv.FormatFloat(rounded, 'f', -1, 64) + if strings.Contains(s, ".") { + s = strings.TrimRight(s, "0") + s = strings.TrimSuffix(s, ".") + } + return s +} diff --git a/internal/tokenengine/color_test.go b/internal/tokenengine/color_test.go new file mode 100644 index 0000000..debfc8d --- /dev/null +++ b/internal/tokenengine/color_test.go @@ -0,0 +1,216 @@ +package tokenengine + +import ( + "math" + "testing" +) + +func TestParseColorFormats(t *testing.T) { + cases := []struct { + name string + input string + want Color + }{ + {"hex6", "#1a2b3c", Color{RGB: RGB{R: 0x1a / 255.0, G: 0x2b / 255.0, B: 0x3c / 255.0, A: 1}, Raw: "#1a2b3c", Numeric: true}}, + {"hex3", "#abc", Color{RGB: RGB{R: 0xaa / 255.0, G: 0xbb / 255.0, B: 0xcc / 255.0, A: 1}, Raw: "#abc", Numeric: true}}, + {"hex8 alpha", "#00000080", Color{RGB: RGB{R: 0, G: 0, B: 0, A: 128 / 255.0}, Raw: "#00000080", Numeric: true}}, + {"rgb legacy comma", "rgb(255, 0, 0)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "rgb(255, 0, 0)", Numeric: true}}, + {"rgb modern space", "rgb(255 0 0)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "rgb(255 0 0)", Numeric: true}}, + {"rgba alpha", "rgba(0, 0, 0, 0.5)", Color{RGB: RGB{A: 0.5}, Raw: "rgba(0, 0, 0, 0.5)", Numeric: true}}, + {"hsl", "hsl(0, 100%, 50%)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "hsl(0, 100%, 50%)", Numeric: true}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseColor(tc.input) + if err != nil { + t.Fatalf("ParseColor(%q) error: %v", tc.input, err) + } + if !got.Numeric { + t.Fatalf("ParseColor(%q) returned a non-numeric colour", tc.input) + } + assertClose(t, got.R, tc.want.R, 0.002) + assertClose(t, got.G, tc.want.G, 0.002) + assertClose(t, got.B, tc.want.B, 0.002) + assertClose(t, got.A, tc.want.A, 0.002) + }) + } +} + +func TestParseColorRejectsGarbage(t *testing.T) { + for _, bad := range []string{"", "#", "#12", "#1234567", "rgb(1,2)", "#gggggg"} { + if _, err := ParseColor(bad); err == nil { + t.Errorf("ParseColor(%q) should have failed", bad) + } + } +} + +func TestParseColorPassesThroughModernSyntax(t *testing.T) { + // These are valid CSS the engine cannot reason about numerically, but they + // must round-trip rather than be rejected. + for _, ok := range []string{"oklch(0.7 0.1 200)", "color(display-p3 1 0 0)", "light-dark(#fff, #000)", "currentColor", "transparent"} { + got, err := ParseColor(ok) + if err != nil { + t.Errorf("ParseColor(%q) error: %v", ok, err) + } + if got.Numeric { + t.Errorf("ParseColor(%q) should not be numeric", ok) + } + if got.Raw != ok { + t.Errorf("ParseColor(%q).Raw = %q, want the original", ok, got.Raw) + } + } +} + +func TestContrastRatioMatchesWCAGReferenceValues(t *testing.T) { + // Reference values from the WCAG 2.1 relative-luminance definition. + cases := []struct { + a, b string + want float64 + }{ + {"#ffffff", "#000000", 21}, + {"#ffffff", "#ffffff", 1}, + {"#000000", "#000000", 1}, + {"#777777", "#ffffff", 4.48}, + {"#767676", "#ffffff", 4.54}, // the canonical AA boundary grey + } + for _, tc := range cases { + a, err := ParseColor(tc.a) + if err != nil { + t.Fatalf("%v", err) + } + b, err := ParseColor(tc.b) + if err != nil { + t.Fatalf("%v", err) + } + got := ContrastRatio(a, b) + if math.Abs(got-tc.want) > 0.05 { + t.Errorf("ContrastRatio(%s, %s) = %.3f, want %.3f", tc.a, tc.b, got, tc.want) + } + } +} + +func TestBestForegroundPicksHigherContrast(t *testing.T) { + light := Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true} + dark := Color{RGB: RGB{A: 1}, Numeric: true} + + if got := BestForeground(Color{RGB: RGB{A: 1}, Numeric: true}); got.R != light.R { + t.Errorf("foreground for black should be white, got %s", got.Hex()) + } + if got := BestForeground(Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}); got.R != dark.R { + t.Errorf("foreground for white should be black, got %s", got.Hex()) + } +} + +func TestHexRoundTrip(t *testing.T) { + for _, hex := range []string{"#000000", "#ffffff", "#4f46e5", "#4f46e580"} { + c, err := ParseColor(hex) + if err != nil { + t.Fatalf("%v", err) + } + if got := c.Hex(); got != hex { + t.Errorf("round trip of %q produced %q", hex, got) + } + } +} + +func TestMixProducesIntermediateColour(t *testing.T) { + black, _ := ParseColor("#000000") + white, _ := ParseColor("#ffffff") + + mid := black.Mix(white, 0.5) + if got := mid.Hex(); got != "#808080" && got != "#7f7f7f" { + t.Errorf("50%% black/white mix = %s, want roughly #808080", got) + } + if got := black.Mix(white, 0).Hex(); got != "#000000" { + t.Errorf("0%% mix = %s, want #000000", got) + } + if got := black.Mix(white, 1).Hex(); got != "#ffffff" { + t.Errorf("100%% mix = %s, want #ffffff", got) + } +} + +func TestLightenDarkInvertsDirectionForDarkSchemes(t *testing.T) { + base, _ := ParseColor("#4f46e5") + + light := base.LightenDark(0.1, false) + dark := base.LightenDark(0.1, true) + + if light.RelativeLuminance() >= base.RelativeLuminance() { + t.Error("light scheme emphasis should reduce luminance") + } + if dark.RelativeLuminance() <= base.RelativeLuminance() { + t.Error("dark scheme emphasis should increase luminance") + } +} + +func TestGenerateRampIsOrderedAndComplete(t *testing.T) { + ramp := GenerateRamp(DefaultRampOptions()) + + shades := StandardPaletteShades() + if len(ramp) != len(shades) { + t.Fatalf("ramp has %d steps, want %d", len(ramp), len(shades)) + } + + previous := math.Inf(1) + for _, shade := range shades { + leaf, ok := ramp[shade].(map[string]any) + if !ok { + t.Fatalf("shade %s is not a token leaf", shade) + } + value, ok := leaf[ValueKey].(string) + if !ok { + t.Fatalf("shade %s has no string value", shade) + } + col, err := ParseColor(value) + if err != nil { + t.Fatalf("shade %s value %q is not a colour: %v", shade, value, err) + } + luminance := col.RelativeLuminance() + if luminance > previous { + t.Errorf("shade %s is lighter than the preceding shade", shade) + } + previous = luminance + } +} + +func TestGenerateRampNormalisesOutOfRangeInput(t *testing.T) { + opts := RampOptions{Hue: 400, Saturation: -20, Lightness: 999, HueSpread: 1000}.Normalize() + + if opts.Hue < 0 || opts.Hue >= 360 { + t.Errorf("hue = %f, want it wrapped into 0..360", opts.Hue) + } + if opts.Saturation != 0 { + t.Errorf("saturation = %f, want it clamped to 0", opts.Saturation) + } + if opts.Lightness != 80 { + t.Errorf("lightness = %f, want it clamped to 80", opts.Lightness) + } + if opts.HueSpread != 40 { + t.Errorf("hueSpread = %f, want it clamped to 40", opts.HueSpread) + } +} + +func TestFormatNumberIsStable(t *testing.T) { + cases := map[float64]string{ + 0: "0", + 1: "1", + 1.5: "1.5", + 0.1250001: "0.125", + -0.5: "-0.5", + 100: "100", + } + for in, want := range cases { + if got := FormatNumber(in); got != want { + t.Errorf("FormatNumber(%v) = %q, want %q", in, got, want) + } + } +} + +// assertClose fails the test when want and got differ by more than tolerance. +func assertClose(t *testing.T, got, want, tolerance float64) { + t.Helper() + if math.Abs(got-want) > tolerance { + t.Errorf("got %v, want %v (tolerance %v)", got, want, tolerance) + } +} diff --git a/internal/tokenengine/contract.go b/internal/tokenengine/contract.go new file mode 100644 index 0000000..7088003 --- /dev/null +++ b/internal/tokenengine/contract.go @@ -0,0 +1,311 @@ +package tokenengine + +import ( + "fmt" + "strings" + + "github.com/urpi/go-bananas/internal/domain" +) + +// The two-attribute contract. +// +// Every application this service feeds has its own way of naming "which palette" and +// "which appearance", and only two of them have both. Rather than ask each of them to +// rename its call sites, the engine emits one contract and an alias layer under it: +// +// :root[data-theme='tokyo-night'][data-mode='dark'] { +// --bn-bg: #1a1b26; +// --bn-surface: #20222f; +// /* ... the short names the applications already use ... */ +// } +// +// /* Compatibility: applications that already use these names keep working. */ +// :root[data-theme='tokyo-night'][data-mode='dark'] { +// --bg: var(--bn-bg); +// --surface: var(--bn-surface); +// /* ... */ +// } +// +// Two attribute names are accepted, because two generations of applications exist. The +// short pair (`data-theme`, `data-mode`) is what go-loose and go-mad already put on +// ; the prefixed pair is what this engine has always emitted. A rule matching +// either is the whole point: an application adopts the contract by adding one link. +const ( + // ContractThemeAttribute is the short theme attribute. + ContractThemeAttribute = "data-theme" + // ContractModeAttribute is the short appearance attribute. + ContractModeAttribute = "data-mode" +) + +// ContractMode is a storage key and DOM value for the appearance axis. It matches the +// attribute so there is one word for the concept in the CSS, the DOM and localStorage. +type ContractMode = domain.Appearance + +// contractRole is one short name standing for a full token path. +type contractRole struct { + // Name is the short custom property, without the prefix. + Name string + // Path is the dotted token path whose value it carries. + Path string + // Fallback is emitted when the theme does not define Path, so the contract is + // never silently incomplete. A missing alias makes an application render with an + // inherited value from the host page, which is worse than an explicit default. + Fallback string +} + +// contractRoles is the shared vocabulary. The names are chosen to match the tokens the +// applications already use, so the alias layer below is a rename rather than a +// translation. +// +// The colour roles are spelled out rather than derived, because a derived name +// (`--bn-color-semantic-background` shortened by rule) would be guessable but not +// predictable, and a consumer cannot be asked to remember which abbreviation applies. +var contractRoles = []contractRole{ + // Surfaces, from the page background up to the most raised. + {"bg", "color.semantic.background", "#ffffff"}, + {"surface", "color.semantic.surface", "#ffffff"}, + {"surface-raised", "color.semantic.surface-raised", "#f8fafc"}, + {"surface-sunken", "color.semantic.surface-sunken", "#eef2f6"}, + {"overlay", "color.semantic.overlay", "rgba(15, 23, 42, 0.5)"}, + + // Text, in descending emphasis. + {"text", "color.semantic.text", "#0f172a"}, + {"muted", "color.semantic.text-muted", "#475569"}, + {"subtle", "color.semantic.text-subtle", "#64748b"}, + {"inverted", "color.semantic.text-inverted", "#ffffff"}, + + // Borders. + {"border", "color.semantic.border", "#e2e8f0"}, + {"border-strong", "color.semantic.border-strong", "#cbd5e1"}, + + // The accent. Two names for one role, because "primary" is what a token author + // calls it and "accent" is what a component author looks for. + {"accent", "color.semantic.primary", "#2563eb"}, + {"accent-hover", "color.semantic.primary-hover", "#1d4ed8"}, + {"accent-active", "color.semantic.primary-active", "#1e40af"}, + {"on-accent", "color.semantic.on-primary", "#ffffff"}, + {"secondary", "color.semantic.secondary", "#e2e8f0"}, + {"on-secondary", "color.semantic.on-secondary", "#0f172a"}, + + // Status. + {"good", "color.semantic.success", "#16a34a"}, + {"warning", "color.semantic.warning", "#d97706"}, + {"danger", "color.semantic.danger", "#dc2626"}, + {"info", "color.semantic.info", "#0284c7"}, + {"focus", "color.semantic.focus-ring", "#2563eb"}, +} + +// contractFamilies are the token groups that are already short enough to alias +// wholesale. Geometry, spacing and typography have no sensible per-role renaming, so +// the whole group is re-exported under its own prefix rather than picked apart. +var contractFamilies = []string{"radius", "space", "font", "shadow", "border.width", "duration", "ease", "zIndex", "opacity", "breakpoint"} + +// legacyAliases are the unprefixed names the applications already use, mapped to the +// contract name that replaces them. +// +// This is the compatibility half of the contract. Emitting it means an application +// adopts the shared styling by adding one and deleting its own :root block, with +// no call site edited. Dropping it means the unprefixed custom properties stop existing +// at all, which is a coordinated change across every application. +var legacyAliases = map[string]string{ + "bg": "bg", + "surface": "surface", + "surface2": "surface-raised", + "text": "text", + "muted": "muted", + "line": "border", + "accent": "accent", + "accent2": "secondary", + "good": "good", + "danger": "danger", +} + +// contractSelector returns the selector matching a theme and mode under either +// attribute convention. +// +// The two groups are comma-separated rather than a compound selector, so an +// application that uses one convention still gets the values. +// +// Each group appears twice: once rooted at :root, and once bare. The bare form is +// what makes a palette adoptable by any element rather than only by the document +// root, which is what a theme preview is. A preview has to sit next to the palette +// it is previewing, inside a control that renders into a shadow root, where :root +// matches nothing -- a stylesheet in the document does not select into a shadow tree +// at all. Emitting the bare form lets the preview carry data-theme and pick the +// palette up, so it shows the theme rather than a swatch of it. +func contractSelector(slug string, mode domain.Appearance) string { + return fmt.Sprintf(":root[%s=%s][%s=%s], :root[%s=%s][%s=%s], [%s=%s][%s=%s], [%s=%s][%s=%s]", + ContractThemeAttribute, selectorValue(slug), + ContractModeAttribute, selectorValue(string(mode)), + ThemeAttribute, selectorValue(slug), + AppearanceAttribute, selectorValue(string(mode)), + ContractThemeAttribute, selectorValue(slug), + ContractModeAttribute, selectorValue(string(mode)), + ThemeAttribute, selectorValue(slug), + AppearanceAttribute, selectorValue(string(mode))) +} + +// contractDefaultSelector matches a palette with no explicit appearance. +// +// This exists because requiring both attributes is a trap. An application that +// declares only `data-theme` -- which is what go-tell did, and what any application +// written before the appearance axis existed still does -- would match no rule at all +// and render entirely on its fallbacks, with no error anywhere. Matching the palette +// alone and treating the absence of an appearance as "the designer's choice" is what +// the axis means anyway, and it is what the theme's own default appearance says. +// +// As with contractSelector, the bare form follows the :root one so a preview can +// carry the palette without an appearance, and inherit the designer's choice with it. +func contractDefaultSelector(slug string) string { + return fmt.Sprintf(":root[%s=%s], :root[%s=%s], [%s=%s], [%s=%s]", + ContractThemeAttribute, selectorValue(slug), + ThemeAttribute, selectorValue(slug), + ContractThemeAttribute, selectorValue(slug), + ThemeAttribute, selectorValue(slug)) +} + +// selectorValue quotes a value for use inside an attribute selector. +func selectorValue(v string) string { + return fmt.Sprintf("%q", v) +} + +// Contract renders the contract layer for one theme and appearance. +// +// It emits the short --bn-* names and the unprefixed aliases. It does not emit the +// theme's own tokens: those are already in the stylesheet, and duplicating them would +// double the payload for no gain. This is the layer that makes a long token path +// reachable under a name a component author would guess. +func Contract(theme *domain.Theme, mode domain.Appearance) string { + doc := Resolve(theme, mode) + + var b strings.Builder + fmt.Fprintf(&b, "@layer %s.base;\n", LayerName) + writeContractRule(&b, contractSelector(theme.Slug, mode), doc) + return b.String() +} + +// ContractAll renders the contract layer for every theme at once, for both modes. +// +// This is what makes a theme selector instant. Selecting a palette becomes a change to +// one attribute rather than a fetch, because every palette is already in the document. +// The cost is that the file grows with the number of published themes, which is why it +// is served separately from theme.css: a single-theme consumer should not download the +// others. +func ContractAll(themes []*domain.Theme) string { + var b strings.Builder + fmt.Fprintf(&b, "/*!\n * Go Bananas!! shared token contract\n") + fmt.Fprintf(&b, " * %d theme(s), both appearances each.\n", len(themes)) + fmt.Fprintf(&b, " * Set %s and %s on ; nothing is fetched when you switch.\n", + ContractThemeAttribute, ContractModeAttribute) + fmt.Fprintf(&b, " */\n") + fmt.Fprintf(&b, "@layer %s.base;\n", LayerName) + + for _, theme := range themes { + // The default first, then the explicit overrides. Order matters only as a + // readability property: the mode selectors carry two attributes against the + // default's one, so they win on specificity regardless of source order. + def := theme.DefaultAppearance + if def == "" { + def = domain.AppearanceDark + } + writeContractRule(&b, contractDefaultSelector(theme.Slug), Resolve(theme, def)) + + for _, mode := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} { + writeContractRule(&b, contractSelector(theme.Slug, mode), Resolve(theme, mode)) + } + } + return b.String() +} + +// writeContractRule writes one selector's declarations and its alias block. +func writeContractRule(b *strings.Builder, selector string, doc *Doc) { + decls := make([]string, 0, len(contractRoles)*2) + + for _, role := range contractRoles { + value := role.Fallback + if token, ok := doc.Lookup(role.Path); ok && token.CSS != "" { + value = token.CSS + } + decls = append(decls, VariablePrefix+"-"+role.Name+": "+value+";") + } + + // The families whose names are already short are re-exported rather than + // re-derived, so `--bn-radius-md` is the same variable the theme defines and an + // override in the theme's own stylesheet still wins. + for _, family := range contractFamilies { + prefix := family + "." + for _, token := range doc.Tokens { + if !strings.HasPrefix(token.Path, prefix) { + continue + } + name := VariablePrefix + "-" + strings.ReplaceAll(token.Path, ".", "-") + if token.CSS != "" { + decls = append(decls, name+": "+token.CSS+";") + } + } + } + + if len(decls) == 0 { + return + } + + b.WriteString(selector + " {\n") + for _, d := range decls { + b.WriteString(" " + d + "\n") + } + + // The alias block, in the same rule so it is impossible to emit one without the + // other. Aliases reference the contract names rather than repeating the values, so + // there is exactly one place a value is written. + aliases := make([]string, 0, len(legacyAliases)) + for legacy, target := range legacyAliases { + aliases = append(aliases, fmt.Sprintf("--%s: var(%s-%s);", legacy, VariablePrefix, target)) + } + // Sorted, because a map iteration order would make the output differ between + // builds and defeat any caching or diffing of this file. + sortStrings(aliases) + for _, a := range aliases { + b.WriteString(" " + a + "\n") + } + + b.WriteString("}\n\n") +} + +// sortStrings sorts in place. It exists so this file does not need to import sort for +// one call. +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +// ContractManifestEntry describes one theme in the contract, for a selector to render +// without having to parse CSS. +type ContractManifestEntry struct { + Slug string `json:"slug"` + Name string `json:"name"` + Version int64 `json:"version"` + // DefaultMode is the appearance the theme was designed for. It is what a + // consumer shows before the user expresses a preference. + DefaultMode string `json:"defaultMode"` +} + +// ContractManifest returns the entries a theme selector needs. +func ContractManifest(themes []*domain.Theme) []ContractManifestEntry { + out := make([]ContractManifestEntry, 0, len(themes)) + for _, t := range themes { + mode := t.DefaultAppearance + if mode == "" { + mode = domain.AppearanceDark + } + out = append(out, ContractManifestEntry{ + Slug: t.Slug, + Name: t.Name, + Version: t.Version, + DefaultMode: string(mode), + }) + } + return out +} diff --git a/internal/tokenengine/contract_test.go b/internal/tokenengine/contract_test.go new file mode 100644 index 0000000..dea3b9b --- /dev/null +++ b/internal/tokenengine/contract_test.go @@ -0,0 +1,82 @@ +package tokenengine + +import ( + "strings" + "testing" + + "github.com/urpi/go-bananas/internal/domain" +) + +// The aggregate contract is consumed two ways: as a stylesheet the application +// links, which can only be styled from :root, and as a stylesheet a control adopts +// into its own shadow root, where :root matches nothing because a stylesheet in the +// document does not select into a shadow tree. The bare selector forms are what make +// the second use possible, and a palette preview depends on them, so their absence is +// a silent failure: the selector still lists every palette and every preview renders +// in whatever the host's fallbacks happen to be. + +// assertSelectorPair asserts that a selector list contains both halves of a pair as +// distinct comma-separated entries. +// +// The assertion is on the joined pair rather than on each half alone, because one half +// cannot tell the two forms apart: the string `[data-theme="acme"][data-mode="light"]` +// occurs inside `:root[data-theme="acme"][data-mode="light"]`, so a bare Contains +// reports a bare selector as present when only the rooted form was emitted. Joining +// them is what discriminates, because in the rooted-only list the two halves are +// separated by `:root`. +func assertSelectorPair(t *testing.T, css, want string) { + t.Helper() + if !strings.Contains(css, want) { + t.Errorf("contract does not emit %q as a pair of entries, so a palette preview cannot adopt it", want) + } +} + +func TestContractEmitsBareSelectorsForPreviewScope(t *testing.T) { + theme := testTheme() + theme.Slug = "acme" + theme.DefaultAppearance = domain.AppearanceDark + css := ContractAll([]*domain.Theme{theme}) + + // The palette on its own, so a preview that declares no appearance still adopts + // the designer's choice. + assertSelectorPair(t, css, `[data-theme="acme"], [data-bananas-theme="acme"]`) + + // Each appearance, in both attribute conventions. + assertSelectorPair(t, css, `[data-theme="acme"][data-mode="light"], [data-bananas-theme="acme"][data-bananas-appearance="light"]`) + assertSelectorPair(t, css, `[data-theme="acme"][data-mode="dark"], [data-bananas-theme="acme"][data-bananas-appearance="dark"]`) + + // The rooted forms have to survive: every application that links the contract + // rather than adopting it styles itself from :root. + assertSelectorPair(t, css, `:root[data-theme="acme"], :root[data-bananas-theme="acme"]`) + assertSelectorPair(t, css, `:root[data-theme="acme"][data-mode="light"], :root[data-bananas-theme="acme"][data-bananas-appearance="light"]`) +} + +// TestContractBareSelectorsCarryTheSameValues guards against the two forms drifting +// apart, which would mean a preview shows a different palette from the one applying +// to the page. Both forms are emitted from the same declarations, so this is a +// statement that the pairing is not an accident worth breaking. +func TestContractBareSelectorsCarryTheSameValues(t *testing.T) { + css := ContractAll([]*domain.Theme{testTheme()}) + + for _, want := range []string{ + `--bn-bg: #ffffff;`, + `--bn-text: #0f172a;`, + `--bn-accent: #4f46e5;`, + } { + if !strings.Contains(css, want) { + t.Errorf("contract no longer emits %q", want) + } + } +} + +// TestContractLegacyAliasesStillExist protects the compatibility promise: an +// application that already uses the unprefixed names must keep working. +func TestContractLegacyAliasesStillExist(t *testing.T) { + css := ContractAll([]*domain.Theme{testTheme()}) + + for legacy := range legacyAliases { + if !strings.Contains(css, "--"+legacy+":") { + t.Errorf("legacy alias --%s is no longer emitted", legacy) + } + } +} diff --git a/internal/tokenengine/css.go b/internal/tokenengine/css.go new file mode 100644 index 0000000..d311e2b --- /dev/null +++ b/internal/tokenengine/css.go @@ -0,0 +1,617 @@ +package tokenengine + +import ( + "fmt" + "strings" + + "github.com/urpi/go-bananas/internal/domain" +) + +// LayerName is the cascade layer the generated component styles are placed in. +// Everything the engine emits is layered so that a consuming application's own +// unlayered CSS always wins the cascade without needing !important. +const LayerName = "bananas" + +// RenderOptions controls stylesheet generation. +type RenderOptions struct { + // Slug scopes the theme to an attribute selector so that several themes can + // coexist on one page. When empty the tokens are emitted on :root instead. + Slug string + // Appearance is the scheme to render as the default for the selector. + Appearance domain.Appearance + // ApplyToRoot also emits the tokens on :root, making the theme the page + // default. Ignored when Slug is set unless ForceRoot is true. + ApplyToRoot bool + // ForceRoot emits :root rules even when Slug is set. + ForceRoot bool + // AutoDark adds a prefers-color-scheme: dark block that swaps in the dark + // tokens when the slug selector is not explicitly pinned to light. + AutoDark bool + // Components appends the ready-made component layer. + Components bool + // Derive computes engine-supplied tokens (foreground pairs, interaction + // states, tinted surfaces) for values the author did not define. + Derive DeriveOptions + // Minify strips comments and collapses whitespace. + Minify bool + // SourceURL appends a source comment pointing at the runtime URL the + // stylesheet was generated from, which makes browser devtools navigable. + SourceURL string +} + +// DeriveOptions selects which engine-computed token families to emit. +type DeriveOptions struct { + // Foreground adds `--bn-color-on-*` roles derived by WCAG contrast. + Foreground bool + // Interaction adds `-hover` and `-active` variants for interactive roles. + Interaction bool + // Tint adds subtle tinted surfaces derived from a role's own hue. + Tint bool +} + +// DefaultDerive returns the derivation set used when a caller does not care. +func DefaultDerive() DeriveOptions { + return DeriveOptions{Foreground: true, Interaction: true, Tint: true} +} + +// RenderOptionsFrom builds options from a theme with sensible defaults for the +// given delivery channel. +func RenderOptionsFrom(theme *domain.Theme, appearance domain.Appearance) RenderOptions { + return RenderOptions{ + Slug: theme.Slug, + Appearance: appearance, + Derive: DefaultDerive(), + Components: true, + } +} + +// pair is one CSS custom property declaration. +type pair struct { + name string + value string +} + +// Render produces the stylesheet for a theme. +func Render(theme *domain.Theme, opts RenderOptions) string { + appearance := opts.Appearance + if appearance == "" { + appearance = theme.DefaultAppearance + } + + var b strings.Builder + if !opts.Minify { + writeHeader(&b, theme, opts, appearance) + } + + light := Resolve(theme, domain.AppearanceLight) + dark := Resolve(theme, domain.AppearanceDark) + + // The scoped rule carries the theme's *default* appearance, so that setting a + // single attribute is enough to get the theme the author designed. The + // alternate scheme is emitted under its own appearance attribute. + base := light + alternate := dark + if appearance == domain.AppearanceDark { + base, alternate = dark, light + } + baseDecls := renderDeclarations(base, opts, appearance) + alternateDecls := renderDeclarations(alternate, opts, opposite(appearance)) + darkDecls := renderDeclarations(dark, opts, domain.AppearanceDark) + + rootSelector := opts.ApplyToRoot || opts.ForceRoot || opts.Slug == "" + scope := themeSelector(opts.Slug) + + if !opts.Minify { + fmt.Fprintf(&b, "@layer %s.base;\n", LayerName) + } + + // The default scheme. + switch { + case rootSelector && opts.Slug != "": + // Emit on :root so the theme works with zero configuration, and again on + // the scoped selector so a page can host several themes side by side. + writeRule(&b, ":root", baseDecls, opts.Minify) + writeRule(&b, scope, baseDecls, opts.Minify) + case rootSelector: + writeRule(&b, ":root", baseDecls, opts.Minify) + default: + writeRule(&b, scope, baseDecls, opts.Minify) + } + + // The alternate scheme, selected explicitly. This is what a consumer's theme + // switcher sets, and it is the only selector needed to override the default. + if opts.Slug != "" { + writeRule(&b, darkScopeSelector(opts.Slug, opposite(appearance)), alternateDecls, opts.Minify) + } else { + writeRule(&b, ":root["+AppearanceAttribute+"="+string(opposite(appearance))+"], :root."+darkClass, + alternateDecls, opts.Minify) + } + + // A `.dark` ancestor class covers the common Tailwind and shadcn convention + // where dark mode is toggled by a class on rather than an attribute. + // It always means dark, regardless of the theme's default. + if opts.Slug != "" && appearance != domain.AppearanceDark { + fmt.Fprintf(&b, ".dark %s, %s.%s {\n", scope, scope, darkClass) + writeDeclarations(&b, darkDecls, opts.Minify, "") + b.WriteString("}\n\n") + } + + if opts.AutoDark { + writeAutoDark(&b, scope, darkDecls, opts.Minify) + } + + if opts.Components { + writeComponents(&b, opts) + } + + if !opts.Minify && opts.SourceURL != "" { + fmt.Fprintf(&b, "/*# sourceURL=%s */\n", opts.SourceURL) + } + return strings.TrimRight(b.String(), "\n") + "\n" +} + +// opposite returns the other colour scheme. +func opposite(a domain.Appearance) domain.Appearance { + if a == domain.AppearanceDark { + return domain.AppearanceLight + } + return domain.AppearanceDark +} + +// writeHeader emits a provenance comment. It is what makes a generated +// stylesheet debuggable: it names the theme, version and generation time. +func writeHeader(b *strings.Builder, theme *domain.Theme, opts RenderOptions, appearance domain.Appearance) { + fmt.Fprintf(b, "/*!\n") + fmt.Fprintf(b, " * Go Bananas!! theme: %s\n", theme.Name) + fmt.Fprintf(b, " * slug: %s | version: %d | status: %s | appearance: %s\n", + theme.Slug, theme.Version, theme.Status, appearance) + fmt.Fprintf(b, " * variables are prefixed %q; override them in an unlayered rule to win the cascade\n", VariablePrefix) + fmt.Fprintf(b, " * generated: %s\n", theme.UpdatedAt.UTC().Format(domain.TimeFormat)) + fmt.Fprintf(b, " */\n") +} + +// themeSelector returns the attribute selector that scopes a theme. +func themeSelector(slug string) string { + return fmt.Sprintf("[%s=%q]", ThemeAttribute, selectorSafe(slug)) +} + +// darkScopeSelector returns the selector for the dark scheme of a scoped theme. +func darkScopeSelector(slug string, appearance domain.Appearance) string { + base := themeSelector(slug) + return fmt.Sprintf("%s[%s=%s]", base, AppearanceAttribute, appearance) +} + +// writeAutoDark emits the prefers-color-scheme block. The `:not()` guard makes +// the media query yield to an explicit appearance attribute so that a manual +// override always beats the OS preference. +func writeAutoDark(b *strings.Builder, scope string, decls []pair, minify bool) { + b.WriteString("@media (prefers-color-scheme: dark) {\n") + var selector string + if scope == ":root" { + selector = fmt.Sprintf(":root:not([%s=%s])", AppearanceAttribute, domain.AppearanceLight) + } else { + selector = fmt.Sprintf("%s:not([%s=%s]):not(.dark)", scope, AppearanceAttribute, domain.AppearanceLight) + } + inner := " " + writeRuleIndent(b, selector, decls, minify, inner) + b.WriteString("}\n\n") +} + +// renderDeclarations builds the ordered declaration list for one appearance. +func renderDeclarations(doc *Doc, opts RenderOptions, appearance domain.Appearance) []pair { + decls := make([]pair, 0, len(doc.Tokens)+32) + seen := make(map[string]struct{}, len(doc.Tokens)) + + add := func(name, value string) { + if _, dup := seen[name]; dup { + return + } + seen[name] = struct{}{} + decls = append(decls, pair{name: name, value: value}) + } + + for _, t := range doc.Tokens { + add(VariableName(t.Path), t.CSS) + } + + if opts.Derive.Foreground { + for _, d := range deriveForeground(doc, appearance) { + add(d.name, d.value) + } + } + if opts.Derive.Interaction { + for _, d := range deriveInteraction(doc, appearance) { + add(d.name, d.value) + } + } + if opts.Derive.Tint { + for _, d := range deriveTints(doc, appearance) { + add(d.name, d.value) + } + } + return decls +} + +// deriveForeground produces the `--bn-color-on-*` family. For every palette +// step and every semantic role that is a solid colour, it picks black or white +// whichever has the higher WCAG contrast ratio. Authors who want an explicit +// value simply define it: derived names are only emitted when absent. +func deriveForeground(doc *Doc, appearance domain.Appearance) []pair { + out := make([]pair, 0, 48) + for _, t := range doc.Tokens { + if t.Kind != KindColor || !strings.HasPrefix(t.Path, "color.") { + continue + } + // Skip roles that are not solid backgrounds and skip anything already + // spelled `on-*`. + rest := strings.TrimPrefix(t.Path, "color.") + if strings.HasPrefix(rest, "on-") { + continue + } + col, err := ParseColor(t.CSS) + if err != nil || !col.Numeric || col.A < 0.5 { + continue + } + if _, exists := doc.Lookup("color.on-" + rest); exists { + continue + } + out = append(out, pair{ + name: VariableName("color.on-" + rest), + value: BestForeground(col).Hex(), + }) + } + return out +} + +// interactionRatios are the mix steps used to derive hover and active states. +// Hover moves 8% toward the foreground, active 16%: subtle enough to read as +// the same colour, strong enough to be perceptible. +var ( + hoverRatio = 0.08 + activeRatio = 0.16 +) + +// interactiveRoles are the semantic roles that get state variants. They are the +// ones a component stylesheet can reasonably apply :hover and :active to. +var interactiveRoles = []string{ + "semantic.primary", + "semantic.secondary", + "semantic.accent", + "semantic.success", + "semantic.warning", + "semantic.danger", + "semantic.info", + "semantic.surface", + "semantic.surface-raised", +} + +// deriveInteraction produces `-hover`, `-active` and `-subtle` companions for +// the interactive semantic roles that exist in the document. +func deriveInteraction(doc *Doc, appearance domain.Appearance) []pair { + out := make([]pair, 0, 48) + for _, role := range interactiveRoles { + t, ok := doc.Lookup("color." + role) + if !ok || t.Kind != KindColor { + continue + } + col, err := ParseColor(t.CSS) + if err != nil || !col.Numeric { + continue + } + // In a dark scheme the base colour is already light, so "emphasis" means + // getting lighter, not darker. + for _, suffix := range []struct { + name string + amount float64 + }{{"hover", hoverRatio}, {"active", activeRatio}} { + path := "color." + role + "-" + suffix.name + if _, exists := doc.Lookup(path); exists { + continue + } + out = append(out, pair{ + name: VariableName(path), + value: col.LightenDark(suffix.amount, appearance == domain.AppearanceDark).Hex(), + }) + } + if _, exists := doc.Lookup("color." + role + "-subtle"); !exists { + out = append(out, pair{ + name: VariableName("color." + role + "-subtle"), + value: col.WithAlpha(0.12).Hex(), + }) + } + } + return out +} + +// tintRoles are the solid roles that get very light/dark companions for badge, +// callout and hover-row backgrounds. +var tintRoles = []string{ + "semantic.primary", + "semantic.success", + "semantic.warning", + "semantic.danger", + "semantic.info", +} + +// deriveTints mixes a role toward its scheme's background, producing a surface +// that reads as "this role, but quiet". +func deriveTints(doc *Doc, appearance domain.Appearance) []pair { + background, ok := doc.Lookup("color.semantic.background") + if !ok { + return nil + } + bg, err := ParseColor(background.CSS) + if err != nil || !bg.Numeric { + return nil + } + + out := make([]pair, 0, 16) + for _, role := range tintRoles { + t, exists := doc.Lookup("color." + role) + if !exists || t.Kind != KindColor { + continue + } + col, err := ParseColor(t.CSS) + if err != nil || !col.Numeric { + continue + } + for _, suffix := range []struct { + name string + amount float64 + }{{"tint", 0.14}, {"tint-strong", 0.26}} { + path := "color." + role + "-" + suffix.name + if _, exists := doc.Lookup(path); exists { + continue + } + out = append(out, pair{ + name: VariableName(path), + value: bg.Mix(col, suffix.amount).Hex(), + }) + } + } + return out +} + +// writeRule emits a complete CSS rule. +func writeRule(b *strings.Builder, selector string, decls []pair, minify bool) { + writeRuleIndent(b, selector, decls, minify, "") +} + +// writeRuleIndent emits a CSS rule with every line prefixed by indent. +func writeRuleIndent(b *strings.Builder, selector string, decls []pair, minify bool, indent string) { + if len(decls) == 0 { + return + } + b.WriteString(indent) + b.WriteString(selector) + b.WriteString(" {\n") + writeDeclarations(b, decls, minify, indent+" ") + b.WriteString(indent) + b.WriteString("}\n") + if !minify { + b.WriteString("\n") + } +} + +// writeDeclarations emits the body of a rule. In minified mode the whole body is +// written on one line with no indentation, which is what a minifier would +// produce and keeps the output byte-identical to what a CDN would serve. +func writeDeclarations(b *strings.Builder, decls []pair, minify bool, indent string) { + if minify { + indent = "" + } + for _, d := range decls { + b.WriteString(indent) + b.WriteString(d.name) + b.WriteString(":") + if !minify { + b.WriteString(" ") + } + b.WriteString(d.value) + b.WriteString(";") + if !minify { + b.WriteByte('\n') + } + } +} + +// writeComponents appends the ready-made component layer. It is intentionally +// small: enough for a host application to look correct immediately, expressed +// entirely in terms of the generated variables so that re-theming is free. +func writeComponents(b *strings.Builder, opts RenderOptions) { + fmt.Fprintf(b, "@layer %s.components {\n", LayerName) + componentCSS(b, opts.Minify) + b.WriteString("}\n") +} + +// componentRules is the body of the component layer. It uses only generated +// variables plus layout primitives that carry no colour of their own. +const componentRules = ` +.bn-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--bn-space-2, 0.5rem); + padding: var(--bn-space-2, 0.5rem) var(--bn-space-4, 1rem); + border: var(--bn-border-width-default, 1px) solid transparent; + border-radius: var(--bn-radius-md, 0.375rem); + background: var(--bn-color-semantic-primary, currentColor); + color: var(--bn-color-on-primary, #fff); + font-family: var(--bn-font-family-sans, system-ui, sans-serif); + font-size: var(--bn-font-size-sm, 0.875rem); + font-weight: var(--bn-font-weight-medium, 500); + line-height: var(--bn-line-height-normal, 1.5); + cursor: pointer; + text-decoration: none; + transition: background-color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease), + color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease), + border-color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease); +} +.bn-btn:hover { background: var(--bn-color-semantic-primary-hover, var(--bn-color-semantic-primary)); } +.bn-btn:active { background: var(--bn-color-semantic-primary-active, var(--bn-color-semantic-primary)); } +.bn-btn:focus-visible { + outline: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-focus-ring, currentColor); + outline-offset: var(--bn-space-1, 0.25rem); +} +.bn-btn[disabled] { opacity: var(--bn-opacity-disabled, 0.5); cursor: not-allowed; } +.bn-btn--secondary { + background: var(--bn-color-semantic-secondary, transparent); + color: var(--bn-color-semantic-text, inherit); + border-color: var(--bn-color-semantic-border, currentColor); +} +.bn-btn--secondary:hover { background: var(--bn-color-semantic-secondary-hover, transparent); } +.bn-btn--ghost { background: transparent; color: var(--bn-color-semantic-primary, inherit); } +.bn-btn--ghost:hover { background: var(--bn-color-semantic-primary-subtle, transparent); } +.bn-btn--danger { background: var(--bn-color-semantic-danger, currentColor); color: var(--bn-color-on-danger, #fff); } +.bn-btn--sm { padding: var(--bn-space-1, 0.25rem) var(--bn-space-3, 0.75rem); font-size: var(--bn-font-size-xs, 0.75rem); } +.bn-btn--lg { padding: var(--bn-space-3, 0.75rem) var(--bn-space-6, 1.5rem); font-size: var(--bn-font-size-md, 1rem); } + +.bn-card { + background: var(--bn-color-semantic-surface, Canvas); + color: var(--bn-color-semantic-text, CanvasText); + border: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, transparent); + border-radius: var(--bn-radius-lg, 0.5rem); + box-shadow: var(--bn-shadow-sm, none); + padding: var(--bn-space-6, 1.5rem); + font-family: var(--bn-font-family-sans, system-ui, sans-serif); +} +.bn-card--raised { background: var(--bn-color-semantic-surface-raised, Canvas); box-shadow: var(--bn-shadow-md, none); } +.bn-card__header { margin-bottom: var(--bn-space-4, 1rem); font-size: var(--bn-font-size-lg, 1.125rem); font-weight: var(--bn-font-weight-semibold, 600); } +.bn-card__body { font-size: var(--bn-font-size-sm, 0.875rem); color: var(--bn-color-semantic-text-muted, inherit); } + +.bn-input, +.bn-select, +.bn-textarea { + width: 100%; + padding: var(--bn-space-2, 0.5rem) var(--bn-space-3, 0.75rem); + border: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, currentColor); + border-radius: var(--bn-radius-md, 0.375rem); + background: var(--bn-color-semantic-background, Canvas); + color: var(--bn-color-semantic-text, CanvasText); + font-family: inherit; + font-size: var(--bn-font-size-sm, 0.875rem); + line-height: var(--bn-line-height-normal, 1.5); +} +.bn-input:focus-visible, +.bn-select:focus-visible, +.bn-textarea:focus-visible { + outline: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-focus-ring, currentColor); + outline-offset: var(--bn-space-1, 0.25rem); + border-color: var(--bn-color-semantic-primary, currentColor); +} +.bn-input::placeholder { color: var(--bn-color-semantic-text-subtle, inherit); opacity: 1; } +.bn-input:disabled { opacity: var(--bn-opacity-disabled, 0.5); cursor: not-allowed; } +.bn-label { display: block; margin-bottom: var(--bn-space-1, 0.25rem); font-size: var(--bn-font-size-xs, 0.75rem); font-weight: var(--bn-font-weight-medium, 500); color: var(--bn-color-semantic-text-muted, inherit); } +.bn-field { margin-bottom: var(--bn-space-4, 1rem); } +.bn-field__error { margin-top: var(--bn-space-1, 0.25rem); font-size: var(--bn-font-size-xs, 0.75rem); color: var(--bn-color-semantic-danger, red); } + +.bn-badge { + display: inline-flex; + align-items: center; + gap: var(--bn-space-1, 0.25rem); + padding: var(--bn-space-1, 0.25rem) var(--bn-space-2, 0.5rem); + border-radius: var(--bn-radius-full, 9999px); + font-size: var(--bn-font-size-xs, 0.75rem); + font-weight: var(--bn-font-weight-medium, 500); + background: var(--bn-color-semantic-primary-subtle, transparent); + color: var(--bn-color-semantic-primary, inherit); +} +.bn-badge--success { background: var(--bn-color-semantic-success-tint, transparent); color: var(--bn-color-semantic-success, inherit); } +.bn-badge--warning { background: var(--bn-color-semantic-warning-tint, transparent); color: var(--bn-color-semantic-warning, inherit); } +.bn-badge--danger { background: var(--bn-color-semantic-danger-tint, transparent); color: var(--bn-color-semantic-danger, inherit); } +.bn-badge--neutral { background: var(--bn-color-semantic-surface-raised, transparent); color: var(--bn-color-semantic-text-muted, inherit); } + +.bn-alert { + display: flex; + gap: var(--bn-space-3, 0.75rem); + padding: var(--bn-space-4, 1rem); + border-radius: var(--bn-radius-md, 0.375rem); + border-left: var(--bn-border-width-thick, 3px) solid var(--bn-color-semantic-info, currentColor); + background: var(--bn-color-semantic-info-tint, transparent); + color: var(--bn-color-semantic-text, inherit); + font-size: var(--bn-font-size-sm, 0.875rem); +} +.bn-alert--success { border-color: var(--bn-color-semantic-success, currentColor); background: var(--bn-color-semantic-success-tint, transparent); } +.bn-alert--warning { border-color: var(--bn-color-semantic-warning, currentColor); background: var(--bn-color-semantic-warning-tint, transparent); } +.bn-alert--danger { border-color: var(--bn-color-semantic-danger, currentColor); background: var(--bn-color-semantic-danger-tint, transparent); } + +.bn-link { color: var(--bn-color-semantic-primary, currentColor); text-decoration: underline; text-underline-offset: var(--bn-space-1, 0.25rem); } +.bn-link:hover { color: var(--bn-color-semantic-primary-hover, inherit); } + +.bn-table { width: 100%; border-collapse: collapse; font-size: var(--bn-font-size-sm, 0.875rem); } +.bn-table th { text-align: left; font-weight: var(--bn-font-weight-semibold, 600); color: var(--bn-color-semantic-text-muted, inherit); padding: var(--bn-space-2, 0.5rem) var(--bn-space-3, 0.75rem); border-bottom: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, transparent); } +.bn-table td { padding: var(--bn-space-3, 0.75rem); border-bottom: var(--bn-border-width-thin, 1px) solid var(--bn-color-semantic-border, transparent); } +.bn-table tbody tr:hover { background: var(--bn-color-semantic-surface-raised, transparent); } + +.bn-stack { display: flex; flex-direction: column; gap: var(--bn-space-4, 1rem); } +.bn-cluster { display: flex; flex-wrap: wrap; align-items: center; gap: var(--bn-space-2, 0.5rem); } +.bn-divider { height: var(--bn-border-width-thin, 1px); background: var(--bn-color-semantic-border, transparent); border: 0; } +` + +// componentCSS writes the component layer, optionally stripped of comments and +// blank lines. +func componentCSS(b *strings.Builder, minify bool) { + css := componentRules + if minify { + var out strings.Builder + for _, line := range strings.Split(css, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + out.WriteString(trimmed) + out.WriteByte('\n') + } + css = out.String() + } + b.WriteString(css) +} + +// Render variant identifiers. A variant is a named, cacheable option set: the +// stylesheet a browser fetches and the compact stylesheet a server-side +// renderer inlines are different artefacts and must not share a cache entry. +const ( + // VariantFull is the complete stylesheet: tokens, derived tokens and the + // component layer. + VariantFull = "full" + // VariantTokens is tokens and derived tokens only, for applications that + // ship their own components. + VariantTokens = "tokens" + // VariantMinified is the compact, comment-free stylesheet for production. + VariantMinified = "minified" +) + +// VariantNames lists the render variants in a stable order. +func VariantNames() []string { return []string{VariantFull, VariantTokens, VariantMinified} } + +// Variant identifies the option set that produced a stylesheet. It is part of +// the cache key so two variants can never be confused. +func (o RenderOptions) Variant() string { + switch { + case o.Minify: + return VariantMinified + case o.Components: + return VariantFull + default: + return VariantTokens + } +} + +// OptionsForVariant returns the render options for a named variant. +func OptionsForVariant(variant string, slug string, appearance domain.Appearance) RenderOptions { + opts := RenderOptions{ + Slug: slug, + Appearance: appearance, + Derive: DefaultDerive(), + } + switch variant { + case VariantMinified: + opts.Minify = true + opts.Components = true + case VariantFull: + opts.Components = true + default: + opts.Components = false + } + return opts +} diff --git a/internal/tokenengine/json.go b/internal/tokenengine/json.go new file mode 100644 index 0000000..9503b7b --- /dev/null +++ b/internal/tokenengine/json.go @@ -0,0 +1,165 @@ +package tokenengine + +import ( + "sort" + "strings" + + "github.com/urpi/go-bananas/internal/domain" +) + +// ResolvedTheme is the JSON payload served to runtime consumers. It carries +// both a flat variable map (what a browser or a design tool wants) and the +// nested resolved document (what an application that maps tokens onto component +// props wants), so consumers never have to choose. +type ResolvedTheme struct { + Theme ThemeRef `json:"theme"` + Tokens domain.TokenDoc `json:"tokens"` + Variable map[string]string `json:"variables"` + Meta domain.ThemeMeta `json:"meta,omitempty"` +} + +// ThemeRef is the identity block repeated in every runtime payload. +type ThemeRef struct { + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version int64 `json:"version"` + Appearance domain.Appearance `json:"appearance"` + DefaultAppearance domain.Appearance `json:"defaultAppearance"` + UpdatedAt string `json:"updatedAt"` +} + +// JSONPayload builds the runtime JSON payload for one appearance. +func JSONPayload(theme *domain.Theme, appearance domain.Appearance) ResolvedTheme { + if appearance == "" { + appearance = theme.DefaultAppearance + } + doc := Resolve(theme, appearance) + return ResolvedTheme{ + Theme: ThemeRef{ + Slug: theme.Slug, + Name: theme.Name, + Description: theme.Description, + Version: theme.Version, + Appearance: appearance, + DefaultAppearance: theme.DefaultAppearance, + UpdatedAt: theme.UpdatedAt.UTC().Format(domain.TimeFormat), + }, + Tokens: resolvedDocument(doc), + Variable: doc.Variables(), + Meta: theme.Meta, + } +} + +// resolvedDocument renders the resolved tokens back into nested form, keeping +// the DTCG `$type`/`$value` shape so that the output can be fed straight into +// any DTCG-aware tool. +func resolvedDocument(doc *Doc) domain.TokenDoc { + out := make(domain.TokenDoc, len(Groups)+1) + for _, t := range doc.Tokens { + insertResolved(out, strings.Split(t.Path, "."), t) + } + return out +} + +// insertResolved places a token into the nested output document, creating the +// intermediate group objects on the way down. +func insertResolved(root domain.TokenDoc, segments []string, t FlatToken) { + current := root + for _, seg := range segments[:len(segments)-1] { + child, ok := current[seg].(map[string]any) + if !ok { + child = map[string]any{} + current[seg] = child + } + // Re-widen so that the next iteration can index it as a TokenDoc. + current = child + } + leaf := map[string]any{ValueKey: t.Value, TypeKey: string(t.Kind)} + if t.Description != "" { + leaf[DescKey] = t.Description + } + current[segments[len(segments)-1]] = leaf +} + +// ExportDocument produces a portable, self-describing token document for a +// theme. Unlike the runtime payload it keeps the appearance overrides intact, +// because the point of an export is that it can be re-imported losslessly. +func ExportDocument(theme *domain.Theme) domain.TokenDoc { + out := domain.TokenDoc{SchemaKey: SchemaID} + merged := make(domain.TokenDoc, len(theme.Tokens)+1) + for k, v := range theme.Tokens { + merged[k] = v + } + if _, ok := merged[SchemaKey]; !ok { + out[SchemaKey] = SchemaID + } + delete(merged, SchemaKey) + for k, v := range merged { + out[k] = v + } + return out +} + +// ManifestEntry describes one theme in the runtime manifest: the small payload +// a loader needs in order to build a theme picker without fetching every theme. +type ManifestEntry struct { + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DefaultAppearance domain.Appearance `json:"defaultAppearance"` + Version int64 `json:"version"` + Tags []string `json:"tags,omitempty"` + CSS string `json:"css"` + Tokens string `json:"tokens"` +} + +// Manifest is the discovery document served from the runtime API. +type Manifest struct { + // APIVersion is the runtime contract version. Consumers should refuse to + // load a manifest whose major version they do not understand. + APIVersion string `json:"apiVersion"` + // Default is the slug applied when a consumer does not choose. + Default string `json:"default,omitempty"` + // BaseURL is the absolute runtime base the entries are relative to. + BaseURL string `json:"baseUrl"` + Themes []ManifestEntry `json:"themes"` + Extra map[string]string `json:"extra,omitempty"` +} + +// RuntimeAPIVersion is the current runtime contract version. +const RuntimeAPIVersion = "1" + +// Tags extracts theme tags from the opaque meta block. Tags are the one piece +// of meta the engine understands, because the manifest needs them. +func Tags(meta domain.ThemeMeta) []string { + raw, ok := meta["tags"] + if !ok { + return nil + } + switch t := raw.(type) { + case []string: + return t + case []any: + out := make([]string, 0, len(t)) + for _, v := range t { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + sort.Strings(out) + return out + case string: + parts := strings.Split(t, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + out = append(out, s) + } + } + sort.Strings(out) + return out + default: + return nil + } +} diff --git a/internal/tokenengine/palette.go b/internal/tokenengine/palette.go new file mode 100644 index 0000000..e681fd2 --- /dev/null +++ b/internal/tokenengine/palette.go @@ -0,0 +1,147 @@ +package tokenengine + +import ( + "fmt" + "math" + "strconv" +) + +// RampOptions describes a generated colour ramp. It is the programmatic form of +// "here is a brand colour, give me the other ten shades", which the editor UI +// exposes as a one-click palette builder and which the built-in themes use to +// stay tonally consistent. +type RampOptions struct { + // Hue in degrees, 0..360. Defaults to 250 (indigo) when zero-value. + Hue float64 + // Saturation at the mid steps, in percent, 0..100. + Saturation float64 + // Lightness at step 500, in percent, 0..100. + Lightness float64 + // HueSpread drifts the hue from the lightest to the darkest step. Modern + // perceptual ramps lean warm at the light end and cool at the dark end; + // a value of 12 reproduces that effect. + HueSpread float64 +} + +// DefaultRampOptions returns the ramp the palette builder starts from. +func DefaultRampOptions() RampOptions { + return RampOptions{Hue: 250, Saturation: 78, Lightness: 55, HueSpread: 12} +} + +// Normalize clamps the options into usable ranges and fills in defaults. +func (o RampOptions) Normalize() RampOptions { + out := o + out.Hue = math.Mod(math.Mod(out.Hue, 360)+360, 360) + out.Saturation = clampRange(out.Saturation, 0, 100) + out.Lightness = clampRange(out.Lightness, 20, 80) + out.HueSpread = clampRange(out.HueSpread, 0, 40) + return out +} + +func clampRange(v, lo, hi float64) float64 { + if math.IsNaN(v) { + return lo + } + return math.Max(lo, math.Min(hi, v)) +} + +// RampEndpoints are the lightness percentages used for shade 50 and shade 950. +// They sit just short of pure white and pure black so that the lightest step is +// still usable as a surface and the darkest still holds text. +const ( + RampLightest = 97.0 + RampDarkest = 12.0 +) + +// GenerateRamp builds a canonical 11-step colour ramp as token leaves. +// +// Lightness is interpolated along a curve rather than linearly: the middle steps +// carry the overwhelming majority of UI usage (a button's resting colour is +// step 500 or 600), so they get the most separation. Saturation tapers at both +// ends because a fully saturated near-white or near-black reads as a fluorescent +// smear rather than a tint. +func GenerateRamp(opts RampOptions) map[string]any { + opts = opts.Normalize() + shades := StandardPaletteShades() + out := make(map[string]any, len(shades)) + + for i, shade := range shades { + pos := float64(i) / float64(len(shades)-1) + + light := rampLightness(pos, opts.Lightness) + sat := opts.Saturation * saturationEnvelope(pos) + hue := opts.Hue + opts.HueSpread*(0.5-pos) + + out[shade] = map[string]any{ + TypeKey: string(KindColor), + ValueKey: HSLHex(hue, sat, light), + } + } + return out +} + +// rampLightness returns the lightness percentage for a ramp position. +// +// The curve blends a full linear sweep across the ramp endpoints with a sweep +// anchored on the caller's requested step-500 lightness. The anchored sweep +// guarantees step 500 lands exactly where the caller asked, while the linear +// term keeps the extremes reaching near-white and near-black instead of +// bunching up in the middle. +func rampLightness(pos, midLight float64) float64 { + linear := RampLightest + (RampDarkest-RampLightest)*pos + + // Anchored sweep: walk from midLight out to the nearest endpoint. + var anchored float64 + if pos <= 0.5 { + anchored = midLight + (RampLightest-midLight)*2*(0.5-pos) + } else { + anchored = midLight - (midLight-RampDarkest)*2*(pos-0.5) + } + return linear*0.35 + anchored*0.65 +} + +// saturationEnvelope tapers saturation towards both ends of the ramp. +func saturationEnvelope(pos float64) float64 { + // 1 at the midpoint, ~0.5 at the extremes. + return 0.5 + 0.5*math.Sin(math.Pi*pos) +} + +// HSLHex converts hue/saturation/lightness (hue in degrees, the others in +// percent) to a hex colour. +func HSLHex(hue, saturation, light float64) string { + h := math.Mod(math.Mod(hue, 360)+360, 360) / 360 + s := clampRange(saturation, 0, 100) / 100 + l := clampRange(light, 0, 100) / 100 + r, g, b := hslToRGB(h, s, l) + return fmt.Sprintf("#%02x%02x%02x", int(r*255+0.5), int(g*255+0.5), int(b*255+0.5)) +} + +// RampStep returns the hex colour at one step of a generated ramp. It exists so +// that callers such as the semantic-group builder can reference a concrete +// colour without materialising the whole ramp. +func RampStep(opts RampOptions, shade string) string { + opts = opts.Normalize() + shades := StandardPaletteShades() + for i, s := range shades { + if s == shade { + pos := float64(i) / float64(len(shades)-1) + light := rampLightness(pos, opts.Lightness) + sat := opts.Saturation * saturationEnvelope(pos) + hue := opts.Hue + opts.HueSpread*(0.5-pos) + return HSLHex(hue, sat, light) + } + } + // Unknown shade: fall back to the mid lightness rather than failing, so a + // typo in a configuration file cannot produce an invalid colour. + return HSLHex(opts.Hue, opts.Saturation, opts.Lightness) +} + +// RampShades lists the canonical shade steps. +func RampShades() []string { return StandardPaletteShades() } + +// ParseShade extracts the numeric step from a shade name, reporting whether it +// is a plain number. +func ParseShade(shade string) (int, bool) { + n, err := strconv.Atoi(shade) + return n, err == nil +} diff --git a/internal/tokenengine/schema.go b/internal/tokenengine/schema.go new file mode 100644 index 0000000..ec343c8 --- /dev/null +++ b/internal/tokenengine/schema.go @@ -0,0 +1,508 @@ +// Package tokenengine turns a Go Bananas!! design-token document into the +// artefacts that browsers and applications consume: CSS custom properties, +// resolved JSON tokens, and a flat variable map. +// +// The package is deliberately pure. It performs no I/O, holds no global state +// and is safe for concurrent use, which lets the HTTP layer cache rendered +// themes aggressively and pre-warm them at start-up. +package tokenengine + +import ( + "fmt" + "sort" + "strings" + + "github.com/urpi/go-bananas/internal/domain" +) + +// Kind is a token value type. The set mirrors the W3C Design Tokens Community +// Group format so that exported JSON is portable to other token tooling. +type Kind string + +const ( + KindColor Kind = "color" + KindDimension Kind = "dimension" + KindFontFamily Kind = "fontFamily" + KindFontSize Kind = "fontSize" + KindFontWeight Kind = "fontWeight" + KindLineHeight Kind = "lineHeight" + KindLetterSpacing Kind = "letterSpacing" + KindDuration Kind = "duration" + KindCubicBezier Kind = "cubicBezier" + KindNumber Kind = "number" + KindShadow Kind = "shadow" + KindFontStyle Kind = "fontStyle" +) + +// Valid reports whether k is a known kind. +func (k Kind) Valid() bool { + switch k { + case KindColor, KindDimension, KindFontFamily, KindFontSize, KindFontWeight, + KindLineHeight, KindLetterSpacing, KindDuration, KindCubicBezier, + KindNumber, KindShadow, KindFontStyle: + return true + default: + return false + } +} + +// UnitSuffix is appended to raw numeric values of unit-bearing kinds when +// rendering CSS, so a bare `4` in a `space` group becomes `4px`. +func (k Kind) UnitSuffix() string { + switch k { + case KindDimension, KindFontSize, KindLetterSpacing: + return "px" + case KindDuration: + return "ms" + default: + return "" + } +} + +// ValueKey and TypeKey are the DTCG property names. +const ( + ValueKey = "$value" + TypeKey = "$type" + // DescKey carries human documentation for a token. + DescKey = "$description" + // ExtensionsKey holds non-standard metadata that the engine ignores. + ExtensionsKey = "$extensions" + // SchemaKey identifies the token document format. + SchemaKey = "$schema" + // AppearancesKey holds per-appearance token overrides. It is the only + // structural key that is not itself a token group. + AppearancesKey = "$appearances" +) + +// SchemaID identifies the canonical document format. +const SchemaID = "https://urpi.be/schemas/go-bananas/tokens/v1" + +// VariablePrefix namespaces every custom property this engine emits so that a +// consuming application can never collide with go-bananas' own CSS. +const VariablePrefix = "--bn" + +// ThemeAttribute is the data attribute that scopes a theme to a subtree. +// Setting it on or is the supported way to apply a theme. +const ThemeAttribute = "data-bananas-theme" + +// AppearanceAttribute selects the colour scheme within a themed subtree. +const AppearanceAttribute = "data-bananas-appearance" + +// darkClass is the conventional class name used by Tailwind and most design +// systems for "this subtree is dark". The generated CSS honours it in addition +// to the explicit appearance attribute so that consumers do not have to choose. +const darkClass = "dark" + +// GroupSpec describes one top-level token group. +type GroupSpec struct { + // Name is the group key as it appears in the document. + Name string + // Kind is the default type applied to leaves that omit "$type". + Kind Kind + // Freeform allows an arbitrary number of nested levels below the group. + Freeform bool + // Required is the minimum depth: paths listed here must exist. + Required []string + // Recommended paths are validated when present but never required. They + // drive warnings in the editor rather than hard failures. + Recommended []string + // Description is shown in the editor UI and emitted as $description. + Description string +} + +// paletteShades is the canonical 11-step scale every generated palette defines. +var paletteShades = []string{"50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"} + +// StandardPaletteShades returns a copy of the canonical scale steps. +func StandardPaletteShades() []string { + out := make([]string, len(paletteShades)) + copy(out, paletteShades) + return out +} + +// Groups is the canonical group registry, in the order the editor renders them. +var Groups = []GroupSpec{ + { + Name: "color", + Kind: KindColor, + Freeform: true, + Description: "Raw palettes and semantic colour roles. Semantic roles are what components consume.", + Recommended: []string{ + "palette.brand." + "500", + "palette.brand.600", + "palette.neutral.500", + "palette.neutral.900", + "semantic.background", + "semantic.surface", + "semantic.surface-raised", + "semantic.border", + "semantic.border-strong", + "semantic.text", + "semantic.text-muted", + "semantic.text-subtle", + "semantic.text-inverted", + "semantic.primary", + "semantic.primary-hover", + "semantic.primary-active", + "semantic.on-primary", + "semantic.secondary", + "semantic.accent", + "semantic.on-accent", + "semantic.success", + "semantic.warning", + "semantic.danger", + "semantic.info", + "semantic.overlay", + "semantic.focus-ring", + }, + }, + { + Name: "font", + Kind: KindDimension, + Freeform: true, + Recommended: []string{"family.sans", "family.mono", "size.sm", "size.md", "size.lg", "size.xl", "weight.normal", "weight.medium", "weight.semibold", "weight.bold", "lineHeight.tight", "lineHeight.normal", "lineHeight.relaxed", "letterSpacing.tight", "letterSpacing.normal", "letterSpacing.wide"}, + }, + { + Name: "space", + Kind: KindDimension, + Freeform: true, + Recommended: []string{"0", "1", "2", "3", "4", "5", "6", "8", "10", "12", "16"}, + }, + { + Name: "radius", + Kind: KindDimension, + Freeform: true, + Recommended: []string{"none", "sm", "md", "lg", "xl", "full"}, + }, + { + Name: "border", + Kind: KindDimension, + Freeform: true, + Recommended: []string{"width.thin", "width.default", "width.thick", "color.default", "color.strong"}, + }, + { + Name: "shadow", + Kind: KindShadow, + Freeform: true, + Recommended: []string{"none", "sm", "md", "lg", "xl"}, + }, + { + Name: "duration", + Kind: KindDuration, + Freeform: true, + Recommended: []string{"instant", "fast", "normal", "slow"}, + }, + { + Name: "ease", + Kind: KindCubicBezier, + Freeform: true, + Recommended: []string{"linear", "standard", "entrance", "exit"}, + }, + { + Name: "zIndex", + Kind: KindNumber, + Freeform: true, + Recommended: []string{"base", "dropdown", "sticky", "overlay", "modal", "toast"}, + }, + { + Name: "opacity", + Kind: KindNumber, + Freeform: true, + Recommended: []string{"disabled", "muted", "strong"}, + }, + { + Name: "breakpoint", + Kind: KindDimension, + Freeform: true, + Recommended: []string{"sm", "md", "lg", "xl", "2xl"}, + }, +} + +// ReservedKeys are document-level keys that are not token groups. +var ReservedKeys = map[string]struct{}{ + SchemaKey: {}, + AppearancesKey: {}, + DescKey: {}, + ExtensionsKey: {}, +} + +// LookupGroup returns the spec for a group name. +func LookupGroup(name string) (GroupSpec, bool) { + for _, g := range Groups { + if g.Name == name { + return g, true + } + } + return GroupSpec{}, false +} + +// GroupNames returns the canonical group names in render order. +func GroupNames() []string { + out := make([]string, len(Groups)) + for i, g := range Groups { + out[i] = g.Name + } + return out +} + +// paletteNames returns the palette identifiers defined in a document, sorted so +// that generated CSS is byte-stable. +func paletteNames(doc map[string]any) []string { + colorGroup, ok := asMap(doc["color"]) + if !ok { + return nil + } + var names []string + for key := range colorGroup { + if key == "semantic" { + continue + } + if _, isGroup := asMap(colorGroup[key]); isGroup { + names = append(names, key) + } + } + sort.Strings(names) + return names +} + +// violation is an internal validation finding. +type violation struct { + // Path is the dotted token path, e.g. "color.semantic.background". + Path string + // Severity separates blocking problems from style advice. + Severity Severity + // Message explains the problem in one sentence. + Message string + // Hint suggests a concrete fix. + Hint string +} + +// Severity classifies a validation finding. +type Severity string + +const ( + // SeverityError blocks publication. + SeverityError Severity = "error" + // SeverityWarning does not block publication but should be addressed. + SeverityWarning Severity = "warning" + // SeverityInfo is purely advisory. + SeverityInfo Severity = "info" +) + +// Issue is a single validation finding, safe to serialise straight to the UI. +type Issue struct { + Path string `json:"path"` + Severity Severity `json:"severity"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` +} + +// ValidationResult aggregates every finding for a document. +type ValidationResult struct { + // Valid is true when there are no error-severity issues. + Valid bool `json:"valid"` + // TokenCount is the number of leaf tokens discovered. + TokenCount int `json:"tokenCount"` + // Issues is ordered by severity then path so output is deterministic. + Issues []Issue `json:"issues"` +} + +// Errors returns only the blocking issues. +func (v ValidationResult) Errors() []Issue { + out := make([]Issue, 0, len(v.Issues)) + for _, i := range v.Issues { + if i.Severity == SeverityError { + out = append(out, i) + } + } + return out +} + +// Warnings returns only the advisory issues. +func (v ValidationResult) Warnings() []Issue { + out := make([]Issue, 0, len(v.Issues)) + for _, i := range v.Issues { + if i.Severity == SeverityWarning { + out = append(out, i) + } + } + return out +} + +// severityRank orders severities for deterministic sorting. +func severityRank(s Severity) int { + switch s { + case SeverityError: + return 0 + case SeverityWarning: + return 1 + default: + return 2 + } +} + +// asMap extracts a nested object from an untyped token document. +// +// Both a plain map and a domain.TokenDoc are accepted. The distinction matters: +// a type assertion to map[string]any fails for a named map type, so without this +// second case every document handed to the engine as a domain.TokenDoc would be +// silently reported as "not a group". +func asMap(v any) (map[string]any, bool) { + switch t := v.(type) { + case map[string]any: + return t, true + case domain.TokenDoc: + return map[string]any(t), true + default: + return nil, false + } +} + +// keyAliases maps authored camelCase token keys onto their kebab-case CSS +// equivalents. Only keys the DTCG spec spells in camelCase need an entry; the +// mapping is an explicit allow-list rather than a generic camelCase splitter so +// that a token genuinely named "brandDark" cannot be silently rewritten. +var keyAliases = map[string]string{ + "lineHeight": "line-height", + "fontSize": "font-size", + "letterSpacing": "letter-spacing", + "fontFamily": "font-family", + "fontWeight": "font-weight", + "cubicBezier": "cubic-bezier", + "borderRadius": "border-radius", + "zIndex": "z-index", + "surfaceMuted": "surface-muted", + "fontStyle": "font-style", + "lineScale": "line-scale", + "seedVersion": "seed-version", +} + +// normalizeKey applies the alias table to one authored token key. +func normalizeKey(key string) string { + if alias, ok := keyAliases[key]; ok { + return alias + } + return key +} + +// isLeaf reports whether a node is a token leaf rather than a nested group. +func isLeaf(node map[string]any) bool { + _, hasValue := node[ValueKey] + return hasValue +} + +// cssVarSegment normalises one path segment for use in a custom property name. +func cssVarSegment(s string) string { + s = normalizeKey(s) + var b strings.Builder + b.Grow(len(s)) + prevDash := false + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + prevDash = false + case r == '-' || r == '_' || r == ' ' || r == '.' || r == '/': + // Collapse every separator class into a single dash so that + // "line Height" and "line-height" cannot produce two variables. + if !prevDash && b.Len() > 0 { + b.WriteByte('-') + prevDash = true + } + default: + if !prevDash && b.Len() > 0 { + b.WriteByte('-') + prevDash = true + } + } + } + return strings.Trim(b.String(), "-") +} + +// VariableName builds the CSS custom property for a dotted token path. +func VariableName(path string) string { + segments := strings.Split(path, ".") + cleaned := make([]string, 0, len(segments)) + for _, s := range segments { + if c := cssVarSegment(s); c != "" { + cleaned = append(cleaned, c) + } + } + if len(cleaned) == 0 { + return VariablePrefix + } + return VariablePrefix + "-" + strings.Join(cleaned, "-") +} + +// selectorSafe converts a token path into something usable inside a CSS +// attribute selector value. +func selectorSafe(s string) string { + return strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + return r + default: + return '-' + } + }, s) +} + +// NumberHint returns the canonical shade list, exposed for the editor's palette +// builder so the UI and the validator never disagree. +func NumberHint() []string { return StandardPaletteShades() } + +// validateKindValue checks a single leaf value against its kind, returning an +// error describing the problem or nil when the value is acceptable. +func validateKindValue(kind Kind, value any) error { + switch kind { + case KindColor: + s, ok := value.(string) + if !ok { + return fmt.Errorf("expected a colour string, got %T", value) + } + if _, err := ParseColor(s); err != nil { + return err + } + case KindDimension, KindFontSize, KindLetterSpacing: + if err := validateCSSLength(value); err != nil { + return err + } + case KindDuration: + if err := validateCSSLength(value); err != nil { + return err + } + case KindNumber, KindFontWeight, KindLineHeight: + if err := validateNumeric(value); err != nil { + return err + } + case KindCubicBezier: + parts, ok := value.([]any) + if !ok { + return fmt.Errorf("expected a 4-number array, got %T", value) + } + if len(parts) != 4 { + return fmt.Errorf("expected 4 numbers, got %d", len(parts)) + } + for i, p := range parts { + if err := validateNumeric(p); err != nil { + return fmt.Errorf("component %d: %w", i, err) + } + } + case KindShadow: + if _, ok := value.(string); !ok { + return fmt.Errorf("expected a CSS box-shadow string, got %T", value) + } + case KindFontFamily: + if _, ok := value.(string); !ok { + return fmt.Errorf("expected a font stack string, got %T", value) + } + case KindFontStyle: + if _, ok := value.(string); !ok { + return fmt.Errorf("expected a font style keyword, got %T", value) + } + default: + // Unknown kinds are tolerated so that consumers can introduce their own + // token types without the engine needing a release first. + } + return nil +} diff --git a/internal/tokenengine/tokens.go b/internal/tokenengine/tokens.go new file mode 100644 index 0000000..3e2de1d --- /dev/null +++ b/internal/tokenengine/tokens.go @@ -0,0 +1,751 @@ +package tokenengine + +import ( + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + + "github.com/urpi/go-bananas/internal/domain" +) + +// FlatToken is one resolved token: its dotted path, its resolved type and the +// exact CSS-ready value. +type FlatToken struct { + // Path is the dotted token path, e.g. "color.semantic.background". + Path string + // Kind is the resolved token type. + Kind Kind + // Value is the raw author-supplied value. + Value any + // CSS is the value as it appears in a custom property declaration. + CSS string + // Description is the token's $description, if any. + Description string + // Derived marks tokens computed by the engine rather than authored. + Derived bool +} + +// Doc is a parsed, resolved token document for one appearance. +type Doc struct { + // Theme is the theme the document was derived from. + Theme *domain.Theme + // Appearance is the colour scheme that was resolved. + Appearance domain.Appearance + // Tokens are the leaves in deterministic path order. + Tokens []FlatToken + // byPath indexes Tokens for O(1) lookup. + byPath map[string]FlatToken + // palettes lists palette names discovered in the document. + palettes []string +} + +// Lookup returns a token by dotted path. +func (d *Doc) Lookup(path string) (FlatToken, bool) { + t, ok := d.byPath[path] + return t, ok +} + +// Palettes returns the palette names present in the document. +func (d *Doc) Palettes() []string { return d.palettes } + +// PalettesOf returns the shade steps defined for one palette, sorted so that +// scale output is deterministic. +func (d *Doc) PalettesOf(palette string) []string { + var steps []string + prefix := "color." + palette + "." + for _, t := range d.Tokens { + if strings.HasPrefix(t.Path, prefix) { + steps = append(steps, strings.TrimPrefix(t.Path, prefix)) + } + } + sort.Slice(steps, func(i, j int) bool { return shadeLess(steps[i], steps[j]) }) + return steps +} + +// Variables renders the document as CSS custom property name to value. +func (d *Doc) Variables() map[string]string { + out := make(map[string]string, len(d.Tokens)) + for _, t := range d.Tokens { + out[VariableName(t.Path)] = t.CSS + } + return out +} + +// VariableOrder returns the CSS variable names in deterministic order, which is +// the order the generated stylesheet uses. +func (d *Doc) VariableOrder() []string { + out := make([]string, 0, len(d.Tokens)) + for _, t := range d.Tokens { + out = append(out, VariableName(t.Path)) + } + return out +} + +// shadeLess orders numeric shades numerically and anything else lexically, so +// 50, 100, 200 ... 950 and custom names both come out sensibly. +func shadeLess(a, b string) bool { + an, aerr := strconv.Atoi(a) + bn, berr := strconv.Atoi(b) + if aerr == nil && berr == nil { + return an < bn + } + if aerr == nil { + return true + } + if berr == nil { + return false + } + return a < b +} + +// Resolve merges the base token document with the overrides for the requested +// appearance and returns a flat, ordered token list. +// +// Overrides are a partial document of the same shape as the base, deep-merged +// one level at a time. A leaf in the override replaces the corresponding leaf +// in the base; a nested object in the override merges with its counterpart. +func Resolve(theme *domain.Theme, appearance domain.Appearance) *Doc { + base := theme.Tokens.Clone() + if base == nil { + base = domain.TokenDoc{} + } + merged := applyOverrides(base, overridesFor(base, appearance)) + + doc := &Doc{ + Theme: theme, + Appearance: appearance, + byPath: make(map[string]FlatToken), + palettes: paletteNames(merged), + } + collect(merged, "", nil, doc) + sort.Slice(doc.Tokens, func(i, j int) bool { return doc.Tokens[i].Path < doc.Tokens[j].Path }) + for _, t := range doc.Tokens { + doc.byPath[t.Path] = t + } + return doc +} + +// overridesFor extracts the override sub-document for one appearance. +func overridesFor(doc domain.TokenDoc, appearance domain.Appearance) map[string]any { + appearances, ok := asMap(doc[AppearancesKey]) + if !ok { + return nil + } + override, _ := asMap(appearances[string(appearance)]) + return override +} + +// applyOverrides deep-merges override into base, returning a new document. +func applyOverrides(base domain.TokenDoc, override map[string]any) domain.TokenDoc { + out := make(domain.TokenDoc, len(base)) + for k, v := range base { + out[k] = v + } + for k, v := range override { + if overrideMap, ok := asMap(v); ok { + if baseMap, ok := asMap(out[k]); ok { + out[k] = map[string]any(applyOverrides(domain.TokenDoc(baseMap), overrideMap)) + continue + } + } + out[k] = v + } + return out +} + +// inheritedKind carries the type down the tree so that a group may declare +// "$type" once and have every leaf inherit it, as DTCG specifies. +func collect(node map[string]any, prefix string, inherited *Kind, doc *Doc) { + if node == nil { + return + } + if isLeaf(node) { + collectLeaf(node, prefix, inherited, doc) + return + } + if raw, ok := node[TypeKey].(string); ok { + k := Kind(raw) + inherited = &k + } + keys := make([]string, 0, len(node)) + for k := range node { + if strings.HasPrefix(k, "$") { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + child, ok := asMap(node[k]) + if !ok { + continue + } + path := k + if prefix != "" { + path = prefix + "." + k + } + collect(child, path, inherited, doc) + } +} + +// collectLeaf records a single token leaf. +func collectLeaf(node map[string]any, path string, inherited *Kind, doc *Doc) { + kind := Kind("") + if inherited != nil { + kind = *inherited + } + if raw, ok := node[TypeKey].(string); ok { + kind = Kind(raw) + } + value := node[ValueKey] + + if kind == "" { + kind = inferKind(value) + } + if kind == "" { + return + } + + t := FlatToken{ + Path: path, + Kind: kind, + Value: value, + CSS: renderValue(kind, value), + } + if d, ok := node[DescKey].(string); ok { + t.Description = d + } + doc.Tokens = append(doc.Tokens, t) + doc.byPath[path] = t +} + +// inferKind guesses a leaf's type when neither the leaf nor an ancestor +// declared one. Guessing keeps hand-written documents usable; validation still +// reports the inferred type so authors can make it explicit. +func inferKind(v any) Kind { + switch t := v.(type) { + case string: + if _, err := ParseColor(t); err == nil && strings.HasPrefix(strings.TrimSpace(t), "#") { + return KindColor + } + if strings.ContainsAny(t, "()") { + return KindShadow + } + return KindFontFamily + case float64, int, json.Number: + return KindNumber + case []any: + return KindCubicBezier + default: + return "" + } +} + +// renderValue converts a token value into its CSS form, appending the +// canonical unit for unit-bearing kinds when the author omitted it. +func renderValue(kind Kind, value any) string { + switch kind { + case KindDimension, KindFontSize, KindLetterSpacing, KindDuration: + if n, ok := toFloat(value); ok { + return FormatNumber(n) + kind.UnitSuffix() + } + case KindNumber, KindFontWeight, KindLineHeight: + if n, ok := toFloat(value); ok { + return FormatNumber(n) + } + } + return stringify(value) +} + +// stringify renders any supported value as a CSS literal. +func stringify(value any) string { + switch t := value.(type) { + case nil: + return "" + case string: + return t + case bool: + return strconv.FormatBool(t) + case float64: + return FormatNumber(t) + case int: + return FormatNumber(float64(t)) + case json.Number: + return t.String() + case []any: + parts := make([]string, len(t)) + for i, p := range t { + parts[i] = stringify(p) + } + return strings.Join(parts, ", ") + default: + // Complex values are stored as pre-formatted CSS strings by the editor, + // so this is the only safe rendering. + return fmt.Sprint(t) + } +} + +func toFloat(v any) (float64, bool) { + switch t := v.(type) { + case float64: + return t, true + case int: + return float64(t), true + case int64: + return float64(t), true + case json.Number: + f, err := t.Float64() + return f, err == nil + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(t), 64) + return f, err == nil + default: + return 0, false + } +} + +// validateNumeric accepts a JSON number or a numeric string. +func validateNumeric(v any) error { + switch t := v.(type) { + case float64: + if math.IsNaN(t) || math.IsInf(t, 0) { + return fmt.Errorf("value must be finite") + } + return nil + case int, int64: + return nil + case json.Number: + f, err := t.Float64() + if err != nil { + return fmt.Errorf("value %q is not numeric", t.String()) + } + if math.IsNaN(f) || math.IsInf(f, 0) { + return fmt.Errorf("value must be finite") + } + return nil + case string: + if _, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err != nil { + return fmt.Errorf("value %q is not numeric", t) + } + return nil + default: + return fmt.Errorf("expected a number, got %T", v) + } +} + +// cssLengthUnits are the units accepted for a bare dimension value. +var cssLengthUnits = []string{"px", "rem", "em", "%", "vh", "vw", "vmin", "vmax", "ch", "ex", "cm", "mm", "in", "pt", "pc", "svh", "lvh", "dvh", "ms", "s", "fr"} + +// validateCSSLength accepts a number, a numeric string with a unit, or one of +// the CSS global keywords. +func validateCSSLength(v any) error { + if err := validateNumeric(v); err != nil { + if s, ok := v.(string); ok { + trimmed := strings.ToLower(strings.TrimSpace(s)) + switch trimmed { + case "auto", "min-content", "max-content", "fit-content", "inherit", "initial", "unset", "normal": + return nil + } + for _, unit := range cssLengthUnits { + if strings.HasSuffix(trimmed, unit) { + if _, err := strconv.ParseFloat(strings.TrimSuffix(trimmed, unit), 64); err == nil { + return nil + } + } + } + if strings.HasPrefix(trimmed, "clamp(") || strings.HasPrefix(trimmed, "calc(") || + strings.HasPrefix(trimmed, "min(") || strings.HasPrefix(trimmed, "max(") { + // Function-based values are opaque to the validator; the browser + // is the authority and will discard anything malformed. + return nil + } + return fmt.Errorf("value %q is not a valid length", s) + } + return err + } + return nil +} + +// Validate checks a token document and returns every finding. +func Validate(doc domain.TokenDoc) ValidationResult { + result := ValidationResult{Valid: true, Issues: []Issue{}} + + issues := make([]violation, 0, 32) + known := make(map[string]GroupSpec, len(Groups)) + for _, g := range Groups { + known[g.Name] = g + } + + for key, raw := range doc { + if _, reserved := ReservedKeys[key]; reserved { + continue + } + node, ok := asMap(raw) + if !ok { + issues = append(issues, violation{ + Path: key, Severity: SeverityError, + Message: fmt.Sprintf("group %q must be an object", key), + Hint: "top-level keys are token groups, or one of " + strings.Join(sortedReservedKeys(), ", "), + }) + continue + } + spec, ok := known[key] + if !ok { + issues = append(issues, violation{ + Path: key, Severity: SeverityError, + Message: fmt.Sprintf("unknown token group %q", key), + Hint: "known groups: " + strings.Join(GroupNames(), ", "), + }) + continue + } + validateGroup(spec, node, key, &issues) + } + + if appearances, ok := asMap(doc[AppearancesKey]); ok { + for name, raw := range appearances { + if name != string(domain.AppearanceLight) && name != string(domain.AppearanceDark) { + issues = append(issues, violation{ + Path: AppearancesKey + "." + name, Severity: SeverityError, + Message: fmt.Sprintf("unknown appearance %q", name), + Hint: "use \"light\" or \"dark\"", + }) + continue + } + node, ok := asMap(raw) + if !ok { + issues = append(issues, violation{ + Path: AppearancesKey + "." + name, Severity: SeverityError, + Message: "appearance overrides must be an object", + }) + continue + } + for group, groupRaw := range node { + spec, ok := known[group] + if !ok { + issues = append(issues, violation{ + Path: AppearancesKey + "." + name + "." + group, Severity: SeverityError, + Message: fmt.Sprintf("unknown token group %q", group), + }) + continue + } + groupNode, ok := asMap(groupRaw) + if !ok { + continue + } + // Overrides are partial by design: only value validity is + // checked, never completeness. + validateLeaves(spec, groupNode, spec.Kind, group, AppearancesKey+"."+name+"."+group, &issues) + } + } + } else if _, present := doc[AppearancesKey]; present { + issues = append(issues, violation{ + Path: AppearancesKey, Severity: SeverityError, + Message: AppearancesKey + " must be an object keyed by appearance", + Hint: `example: {"dark": {"color": {"semantic": {"background": {"$value": "#0b0f19"}}}}}`, + }) + } + + // A theme with no dark overrides will render identically in both schemes, + // which is almost always a mistake for a theme that declares a dark + // appearance as its default. + appearancesMap, _ := asMap(doc[AppearancesKey]) + if _, hasDark := asMap(appearancesMap[string(domain.AppearanceDark)]); !hasDark { + issues = append(issues, violation{ + Path: AppearancesKey, Severity: SeverityWarning, + Message: "no dark appearance overrides are defined", + Hint: `add {"$appearances": {"dark": {...}}} so the dark scheme is intentional rather than accidental`, + }) + } + + resolved := countLeaves(doc) + result.TokenCount = resolved + result.Issues = toIssues(issues) + for _, i := range result.Issues { + if i.Severity == SeverityError { + result.Valid = false + break + } + } + return result +} + +// toIssues converts and deterministically orders internal violations. +func toIssues(in []violation) []Issue { + out := make([]Issue, 0, len(in)) + for _, v := range in { + out = append(out, Issue(v)) + } + sort.SliceStable(out, func(i, j int) bool { + if ri, rj := severityRank(out[i].Severity), severityRank(out[j].Severity); ri != rj { + return ri < rj + } + return out[i].Path < out[j].Path + }) + return out +} + +// sortedReservedKeys returns the reserved document keys in sorted order. +func sortedReservedKeys() []string { + out := make([]string, 0, len(ReservedKeys)) + for k := range ReservedKeys { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// validateGroup checks one group's required and recommended tokens. +func validateGroup(spec GroupSpec, node map[string]any, prefix string, issues *[]violation) { + validateLeaves(spec, node, spec.Kind, prefix, prefix, issues) + for _, path := range spec.Recommended { + if _, err := LookupIn(node, path); err != nil { + *issues = append(*issues, violation{ + Path: prefix + "." + path, Severity: SeverityWarning, + Message: fmt.Sprintf("recommended token %q is missing", path), + Hint: spec.Description, + }) + } + } +} + +// validateLeaves walks a group tree checking every leaf value's validity. +func validateLeaves(spec GroupSpec, node map[string]any, kindDefault Kind, prefix, path string, issues *[]violation) { + if isLeaf(node) { + kind := kindDefault + if raw, ok := node[TypeKey].(string); ok { + kind = Kind(raw) + } else if inferred := inferKind(node[ValueKey]); inferred != "" && kind == kindDefault { + kind = inferred + } + if !kind.Valid() { + *issues = append(*issues, violation{ + Path: path, Severity: SeverityError, + Message: fmt.Sprintf("unknown $type %q", kind), + Hint: "omit $type to inherit the group's type", + }) + return + } + if err := validateKindValue(kind, node[ValueKey]); err != nil { + *issues = append(*issues, violation{ + Path: path, Severity: SeverityError, + Message: err.Error(), + Hint: fmt.Sprintf("expected a %s value", kind), + }) + } + return + } + + inherited := kindDefault + if raw, ok := node[TypeKey].(string); ok { + inherited = Kind(raw) + } + for _, key := range sortedMapKeys(node) { + if strings.HasPrefix(key, "$") { + continue + } + child, ok := asMap(node[key]) + if !ok { + *issues = append(*issues, violation{ + Path: path + "." + key, Severity: SeverityError, + Message: fmt.Sprintf("token must be an object with a %q property, got %T", ValueKey, node[key]), + Hint: `example: {"$type": "color", "$value": "#6366f1"}`, + }) + continue + } + validateLeaves(spec, child, inherited, prefix, path+"."+key, issues) + } +} + +// sortedMapKeys returns the keys of m in sorted order. +func sortedMapKeys(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// LookupIn walks a token document by dotted path and returns the leaf. +func LookupIn(doc map[string]any, path string) (map[string]any, error) { + var current any = doc + for _, segment := range strings.Split(path, ".") { + node, ok := asMap(current) + if !ok { + return nil, fmt.Errorf("path %q: %q is not a group", path, segment) + } + next, exists := node[segment] + if !exists { + return nil, fmt.Errorf("path %q: %q not found", path, segment) + } + current = next + } + leaf, ok := asMap(current) + if !ok || !isLeaf(leaf) { + return nil, fmt.Errorf("path %q is not a token leaf", path) + } + return leaf, nil +} + +// SetIn writes a token leaf at a dotted path, creating intermediate groups. +func SetIn(doc domain.TokenDoc, path string, value any, kind Kind, description string) error { + segments := strings.Split(path, ".") + if len(segments) == 0 || segments[0] == "" { + return fmt.Errorf("%w: empty token path", domain.ErrInvalid) + } + // Reject paths that would collide with structural keys. + for i, seg := range segments { + if _, reserved := ReservedKeys[seg]; reserved && i == 0 { + return fmt.Errorf("%w: %q is a reserved key", domain.ErrInvalid, seg) + } + if seg == "" { + return fmt.Errorf("%w: token path %q has an empty segment", domain.ErrInvalid, path) + } + } + if _, ok := LookupGroup(segments[0]); !ok { + return fmt.Errorf("%w: unknown token group %q", domain.ErrInvalid, segments[0]) + } + + current := doc + for _, seg := range segments[:len(segments)-1] { + child, ok := asMap(current[seg]) + if !ok { + child = map[string]any{} + current[seg] = child + } + current = child + } + leaf := map[string]any{ValueKey: value, TypeKey: string(kind)} + if description != "" { + leaf[DescKey] = description + } + current[segments[len(segments)-1]] = leaf + return nil +} + +// DeleteIn removes a token leaf at a dotted path. It reports whether the token +// existed so callers can distinguish a no-op delete from a real one. +func DeleteIn(doc domain.TokenDoc, path string) (bool, error) { + segments := strings.Split(path, ".") + if len(segments) < 2 { + return false, fmt.Errorf("%w: refusing to delete top-level group %q", domain.ErrInvalid, path) + } + current := any(doc) + for _, seg := range segments[:len(segments)-1] { + node, ok := asMap(current) + if !ok { + return false, nil + } + child, exists := node[seg] + if !exists { + return false, nil + } + current = child + } + node, ok := asMap(current) + if !ok { + return false, nil + } + last := segments[len(segments)-1] + if _, exists := node[last]; !exists { + return false, nil + } + delete(node, last) + pruneEmpty(node) + return true, nil +} + +// pruneEmpty removes group objects left empty by a delete, so that the stored +// document does not accumulate `{}` noise that would fail validation. +func pruneEmpty(node map[string]any) { + for key, raw := range node { + child, ok := asMap(raw) + if !ok || len(child) == 0 { + if ok && len(child) == 0 { + delete(node, key) + } + continue + } + pruneEmpty(child) + if len(child) == 0 { + delete(node, key) + } + } +} + +// countLeaves counts leaf tokens, excluding appearance overrides. +func countLeaves(node map[string]any) int { + total := 0 + for key, raw := range node { + if key == AppearancesKey || strings.HasPrefix(key, "$") { + continue + } + child, ok := asMap(raw) + if !ok { + continue + } + if isLeaf(child) { + total++ + continue + } + total += countLeaves(child) + } + return total +} + +// RenameToken moves a token leaf to a new dotted path, preserving its value and +// description. It refuses to clobber an existing token unless overwrite is set. +func RenameToken(doc domain.TokenDoc, from, to string, overwrite bool) error { + leaf, err := LookupIn(doc, from) + if err != nil { + return fmt.Errorf("%w: %s", domain.ErrNotFound, err.Error()) + } + if _, err := LookupIn(doc, to); err == nil && !overwrite { + return fmt.Errorf("%w: token %q already exists", domain.ErrConflict, to) + } + if _, err := DeleteIn(doc, from); err != nil { + return err + } + value := leaf[ValueKey] + description, _ := leaf[DescKey].(string) + kind, ok := leaf[TypeKey].(string) + if !ok { + kind = string(inferKind(value)) + } + return SetIn(doc, to, value, Kind(kind), description) +} + +// EnsureDefaults fills any missing recommended token with the supplied default. +// It is used when duplicating a theme so the copy is immediately usable, and is +// deliberately not used on the request path for existing themes. +func EnsureDefaults(doc domain.TokenDoc, defaults domain.TokenDoc) int { + applied := 0 + for key, raw := range defaults { + if _, exists := doc[key]; exists { + continue + } + if err := deepMergeMissing(doc, key, raw); err == nil { + applied++ + } + } + return applied +} + +// deepMergeMissing merges src into dst only where dst has no value. +func deepMergeMissing(dst domain.TokenDoc, key string, src any) error { + srcMap, srcOK := asMap(src) + dstMap, dstOK := asMap(dst[key]) + if srcOK && dstOK && !isLeaf(srcMap) { + for k, v := range srcMap { + if err := deepMergeMissing(dstMap, k, v); err != nil { + return err + } + } + return nil + } + dst[key] = src + return nil +} diff --git a/internal/tokenengine/tokens_test.go b/internal/tokenengine/tokens_test.go new file mode 100644 index 0000000..cf42341 --- /dev/null +++ b/internal/tokenengine/tokens_test.go @@ -0,0 +1,251 @@ +package tokenengine + +import ( + "strings" + "testing" + + "github.com/urpi/go-bananas/internal/domain" +) + +// testTheme builds a minimal but complete theme for engine tests. +func testTheme() *domain.Theme { + return &domain.Theme{ + Slug: "acme", + Name: "Acme", + Status: domain.StatusPublished, + DefaultAppearance: domain.AppearanceLight, + Version: 3, + Tokens: domain.TokenDoc{ + "color": map[string]any{ + "palette": map[string]any{ + "brand": map[string]any{ + "500": map[string]any{TypeKey: string(KindColor), ValueKey: "#4f46e5"}, + "600": map[string]any{TypeKey: string(KindColor), ValueKey: "#4338ca"}, + }, + }, + "semantic": map[string]any{ + "background": map[string]any{TypeKey: string(KindColor), ValueKey: "#ffffff"}, + "surface": map[string]any{TypeKey: string(KindColor), ValueKey: "#f8fafc"}, + "text": map[string]any{TypeKey: string(KindColor), ValueKey: "#0f172a"}, + "primary": map[string]any{TypeKey: string(KindColor), ValueKey: "#4f46e5"}, + }, + }, + "font": map[string]any{ + "family": map[string]any{ + "sans": map[string]any{TypeKey: string(KindFamilyAlias()), ValueKey: "Inter, sans-serif"}, + }, + }, + "space": map[string]any{ + "4": map[string]any{TypeKey: string(KindDimension), ValueKey: 1}, + }, + }, + } +} + +// KindFamilyAlias exists only so the fixture reads clearly; it resolves to the +// canonical font-family kind. +func KindFamilyAlias() Kind { return KindFontFamily } + +func TestResolveFlattensNestedTokens(t *testing.T) { + doc := Resolve(testTheme(), domain.AppearanceLight) + + if got, ok := doc.Lookup("color.semantic.background"); !ok { + t.Fatalf("expected color.semantic.background to resolve, got %d tokens", len(doc.Tokens)) + } else if got.CSS != "#ffffff" { + t.Errorf("background = %q, want #ffffff", got.CSS) + } + + space, ok := doc.Lookup("space.4") + if !ok { + t.Fatal("expected space.4 to resolve") + } + // A bare number in a dimension group must pick up the px suffix. + if space.CSS != "1px" { + t.Errorf("space.4 = %q, want 1px", space.CSS) + } +} + +func TestResolveIsDeterministic(t *testing.T) { + first := Resolve(testTheme(), domain.AppearanceLight) + second := Resolve(testTheme(), domain.AppearanceLight) + + if len(first.Tokens) != len(second.Tokens) { + t.Fatalf("token count differs: %d vs %d", len(first.Tokens), len(second.Tokens)) + } + for i := range first.Tokens { + if first.Tokens[i].Path != second.Tokens[i].Path { + t.Fatalf("token %d out of order: %q vs %q", i, first.Tokens[i].Path, second.Tokens[i].Path) + } + } +} + +func TestAppearanceOverridesWinOverBase(t *testing.T) { + theme := testTheme() + theme.Tokens[AppearancesKey] = map[string]any{ + "dark": map[string]any{ + "color": map[string]any{ + "semantic": map[string]any{ + "background": map[string]any{TypeKey: string(KindColor), ValueKey: "#0b1220"}, + }, + }, + }, + } + + light := Resolve(theme, domain.AppearanceLight) + dark := Resolve(theme, domain.AppearanceDark) + + if got, _ := light.Lookup("color.semantic.background"); got.CSS != "#ffffff" { + t.Errorf("light background = %q, want the base value #ffffff", got.CSS) + } + if got, _ := dark.Lookup("color.semantic.background"); got.CSS != "#0b1220" { + t.Errorf("dark background = %q, want the override #0b1220", got.CSS) + } + // Tokens not overridden must fall through to the base value. + if got, _ := dark.Lookup("color.semantic.primary"); got.CSS != "#4f46e5" { + t.Errorf("dark primary = %q, want the base value #4f46e5", got.CSS) + } +} + +func TestRenderEmitsScopedSelectorsAndDerivedTokens(t *testing.T) { + css := Render(testTheme(), RenderOptions{ + Slug: "acme", + Appearance: domain.AppearanceLight, + Derive: DefaultDerive(), + Components: true, + }) + + for _, want := range []string{ + `[data-bananas-theme="acme"]`, + `[data-bananas-theme="acme"][data-bananas-appearance=dark]`, + "--bn-color-semantic-background: #ffffff;", + // Derived foreground must pick white on a dark primary. + "--bn-color-on-semantic-primary: #ffffff;", + "--bn-color-semantic-primary-hover:", + "--bn-space-4: 1px;", + "@layer bananas", + ".bn-btn", + `.dark [data-bananas-theme="acme"]`, + } { + if !strings.Contains(css, want) { + t.Errorf("rendered CSS missing %q\n---\n%s", want, css) + } + } +} + +func TestRenderMinifiedOmitsCommentsAndWhitespace(t *testing.T) { + css := Render(testTheme(), RenderOptions{Slug: "acme", Minify: true, Derive: DefaultDerive()}) + + if strings.Contains(css, "/*") { + t.Error("minified CSS must not contain comments") + } + if strings.Contains(css, "\n\n") { + t.Error("minified CSS must not contain blank lines") + } +} + +func TestValidateAcceptsACompleteTheme(t *testing.T) { + result := Validate(testTheme().Tokens) + for _, issue := range result.Errors() { + // The fixture is intentionally sparse; it must only trip the "unknown + // group" rule if the schema drifted, never on the tokens it defines. + if strings.Contains(issue.Message, "unknown token group") { + t.Errorf("unexpected schema error: %+v", issue) + } + } + if result.TokenCount != 8 { + t.Errorf("TokenCount = %d, want 8", result.TokenCount) + } +} + +func TestValidateRejectsMalformedColour(t *testing.T) { + doc := testTheme().Tokens + doc["color"].(map[string]any)["semantic"].(map[string]any)["primary"] = map[string]any{ + TypeKey: string(KindColor), ValueKey: "#nothex", + } + + result := Validate(doc) + if result.Valid { + t.Fatal("expected validation to fail on a malformed colour") + } + found := false + for _, issue := range result.Issues { + if issue.Path == "color.semantic.primary" { + found = true + } + } + if !found { + t.Errorf("expected an issue on color.semantic.primary, got %+v", result.Issues) + } +} + +func TestValidateRejectsUnknownGroup(t *testing.T) { + doc := testTheme().Tokens + doc["colour"] = map[string]any{"typo": map[string]any{ValueKey: "#fff"}} + + result := Validate(doc) + if result.Valid { + t.Fatal("expected validation to reject an unknown top-level group") + } +} + +func TestVariableNameNormalisation(t *testing.T) { + cases := map[string]string{ + "color.semantic.background": "--bn-color-semantic-background", + "space.4": "--bn-space-4", + "font.lineHeight.tight": "--bn-font-line-height-tight", + "font.line height": "--bn-font-line-height", + "weird.Name_Here": "--bn-weird-name-here", + } + for path, want := range cases { + if got := VariableName(path); got != want { + t.Errorf("VariableName(%q) = %q, want %q", path, got, want) + } + } +} + +func TestSetAndDeleteToken(t *testing.T) { + doc := testTheme().Tokens + + if err := SetIn(doc, "space.6", 1.5, KindDimension, ""); err != nil { + t.Fatalf("SetIn: %v", err) + } + if got, err := LookupIn(doc, "space.6"); err != nil { + t.Fatalf("LookupIn: %v", err) + } else if got[ValueKey] != 1.5 { + t.Errorf("space.6 = %v, want 1.5", got[ValueKey]) + } + + deleted, err := DeleteIn(doc, "space.6") + if err != nil || !deleted { + t.Fatalf("DeleteIn = %v, %v; want true, nil", deleted, err) + } + if _, err := LookupIn(doc, "space.6"); err == nil { + t.Error("token should be gone after DeleteIn") + } +} + +func TestSetInRejectsUnknownGroup(t *testing.T) { + doc := domain.TokenDoc{} + err := SetIn(doc, "colour.typo", "#fff", KindColor, "") + if err == nil { + t.Fatal("expected SetIn to reject an unknown group") + } +} + +func TestJSONPayloadCarriesFlatAndNestedForms(t *testing.T) { + payload := JSONPayload(testTheme(), domain.AppearanceDark) + + if payload.Theme.Slug != "acme" { + t.Errorf("slug = %q, want acme", payload.Theme.Slug) + } + if payload.Variable["--bn-color-semantic-primary"] != "#4f46e5" { + t.Errorf("flat map missing primary: %v", payload.Variable) + } + semantic, ok := payload.Tokens["color"].(map[string]any) + if !ok { + t.Fatal("nested tokens missing the color group") + } + if _, ok := semantic["semantic"]; !ok { + t.Error("nested tokens missing the semantic group") + } +} diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..b213610 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,2691 @@ +openapi: 3.1.0 +info: + title: go-bananas + version: 1.0.0 + summary: Runtime design-token service. + description: 'Author design-token themes and serve them to any application at run time. + + + ## Two surfaces, two postures + + + `/rt/v1` is the **runtime** API: public, aggressively cacheable, and designed to be consumed from + a `` tag with no credential. A theming service that required a credential could not be consumed + the way a theme is actually consumed. + + + `/api/v1` is the **administrative** API: authenticated, never cached, and every mutation audited. + + + ## Versioning + + + Both surfaces are independently versioned. Adding an optional field, a new endpoint or a new query + parameter is not a breaking change. Changing the meaning of an existing one is, and gets a new major + version served alongside the old one for at least one cycle.' + license: + name: MIT + identifier: MIT + contact: + name: urpi platform team + url: https://github.com/urpi/go-bananas +servers: +- url: '{origin}' + description: This deployment. + variables: + origin: + default: https://themes.example.com + description: The externally reachable origin. +tags: +- name: runtime + description: Public. Consumed by applications and browsers. +- name: themes + description: Theme management. Requires authentication. +- name: history + description: Version history, diffing and rollback. +- name: tokens + description: Read-only analysis of a token document. +- name: auth + description: Session introspection and the login flow. +- name: preferences + description: The caller's own interface preferences. Any authenticated role. +- name: admin + description: Keys, users, settings and the audit log. Admin role. +- name: operational + description: Probes and metrics. +paths: + /rt/v1/theme.css: + get: + summary: A theme stylesheet + description: 'Returns the theme rendered as CSS custom properties, plus a small component layer + in `@layer bananas`, so an application''s own unlayered CSS always wins the cascade. + + + Responses are **immutable by URL**: publishing a theme changes its version, and a version-pinned + URL never changes. That lets a CDN cache them hard. `If-None-Match` and `If-Modified-Since` are + both honoured, so a redeploy costs a revalidation and no body. + + + A draft theme returns **404 rather than 403**, so a probe cannot confirm that it exists.' + tags: + - runtime + responses: + '200': + description: The stylesheet. + content: + text/css: + schema: + type: string + example: "[data-bananas-theme=\"midnight\"] {\n --bn-color-semantic-primary: #7c6bf5;\n\ + }" + headers: &id001 + ETag: + description: A strong validator for the response. + schema: + type: string + X-Theme-Version: + description: The version served. + schema: + type: string + X-Theme-Updated-At: + description: When it was published. + schema: + type: string + Cache-Control: + description: The caching policy applied. + schema: + type: string + X-Request-ID: + description: Correlation id for the server log. + schema: + type: string + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: query + required: true + description: The theme slug. + schema: + type: string + - name: appearance + in: query + required: false + description: The colour scheme. Defaults to the theme's own default, which is what makes a dark-first + theme correct without the parameter being passed. + schema: + type: string + enum: + - light + - dark + - name: variant + in: query + required: false + description: '`full` includes the component layer, `tokens` omits it, `minified` strips comments + for production.' + schema: + type: string + enum: + - full + - tokens + - minified + - name: v + in: query + required: false + description: 'Pin a version. This URL never changes, which is what makes a safe deploy possible: + build against version 7 and the render cannot change underneath you.' + schema: + type: integer + minimum: 1 + security: [] + /rt/v1/theme.json: + get: + summary: A theme's resolved tokens + description: Carries both a flat custom-property map and the nested DTCG document, so a consumer + can read either without a transformation step. Derived tokens such as the `on-*` foreground pairs + and the `-hover` / `-active` interaction states are included. + tags: + - runtime + responses: + '200': + description: The resolved theme. + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedTheme' + headers: *id001 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: query + required: true + description: The theme slug. + schema: + type: string + - name: appearance + in: query + required: false + description: The colour scheme. Defaults to the theme's default. + schema: + type: string + enum: + - light + - dark + security: [] + /rt/v1/manifest.json: + get: + summary: The theme manifest + description: 'The discovery document a loader fetches once to build a theme picker. Deliberately + small: no token data, so a page with a picker does not download every theme. Cached briefly, because + it changes whenever a theme is published.' + tags: + - runtime + responses: + '200': + description: The manifest. + content: + application/json: + schema: + $ref: '#/components/schemas/Manifest' + headers: + Cache-Control: + description: Short-lived. + schema: + type: string + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] + /rt/v1/embed: + get: + summary: An integration snippet + description: 'Generates the markup to paste, from this deployment''s own configuration, so it cannot + drift from reality. Always answers JSON, whatever the target: the admin UI fetches this with a + JSON parser and an HTML response for the default target is what made the Integrate page report + that it could not parse the reply. The slug must name an existing theme, and every interpolated + value is escaped for the target language, because the output is pasted into someone else''s application.' + tags: + - runtime + responses: + '200': + description: The snippet. + content: + application/json: + schema: + $ref: '#/components/schemas/EmbedSnippet' + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: No such theme. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: query + required: false + description: The theme to generate for. + schema: + type: string + - name: target + in: query + required: false + description: The integration shape. + schema: + type: string + enum: + - html + - react + - javascript + security: [] + /rt/v1/themes/{slug}/theme.css: + get: + summary: A theme stylesheet, by path + description: A REST-shaped equivalent of `/rt/v1/theme.css`, for consumers that prefer a path to + a query string, or a CDN configured with path patterns. + tags: + - runtime + responses: + '200': + description: The stylesheet. + content: + text/css: + schema: + type: string + headers: *id001 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: [] + /rt/v1/themes/{slug}/theme.json: + get: + summary: A theme's resolved tokens, by path + description: A REST-shaped equivalent of `/rt/v1/theme.json`. + tags: + - runtime + responses: + '200': + description: The resolved theme. + content: + application/json: + schema: + $ref: '#/components/schemas/ResolvedTheme' + headers: *id001 + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: [] + /rt/v1/runtime.js: + get: + summary: The runtime.js bundle + description: 'The framework-free ES module: `applyTheme`, `setAppearance`, `loadManifest`, `getVariables`, + `watchSystemAppearance`, `subscribe`. + + + Served from this origin so a consuming application needs no build step and no npm dependency. + React is imported as a **bare specifier** and resolved by the consumer''s own import map, which + is what guarantees a single React instance in the page: two copies would each have their own hook + dispatcher.' + tags: + - runtime + responses: + '200': + description: The bundle. + content: + application/javascript: + schema: + type: string + headers: + Cache-Control: + description: Immutable. The filename is stable so an import map can name it. + schema: + type: string + security: [] + /rt/v1/react.js: + get: + summary: The react.js bundle + description: 'The React provider and hooks: `BananasProvider`, `useBananas`, `useThemeVariable`, + `useThemeVariables`, `useAvailableThemes`. + + + Served from this origin so a consuming application needs no build step and no npm dependency. + React is imported as a **bare specifier** and resolved by the consumer''s own import map, which + is what guarantees a single React instance in the page: two copies would each have their own hook + dispatcher.' + tags: + - runtime + responses: + '200': + description: The bundle. + content: + application/javascript: + schema: + type: string + headers: + Cache-Control: + description: Immutable. The filename is stable so an import map can name it. + schema: + type: string + security: [] + /rt/v1/global.js: + get: + summary: The global.js bundle + description: 'The same API attached to `window.Bananas`, for a page that cannot use modules. + + + Served from this origin so a consuming application needs no build step and no npm dependency. + React is imported as a **bare specifier** and resolved by the consumer''s own import map, which + is what guarantees a single React instance in the page: two copies would each have their own hook + dispatcher.' + tags: + - runtime + responses: + '200': + description: The bundle. + content: + application/javascript: + schema: + type: string + headers: + Cache-Control: + description: Immutable. The filename is stable so an import map can name it. + schema: + type: string + security: [] + /rt/v1/embed.js: + get: + summary: The embed.js bundle + description: 'The one-line script-tag embed. Configured with data attributes. + + + Served from this origin so a consuming application needs no build step and no npm dependency. + React is imported as a **bare specifier** and resolved by the consumer''s own import map, which + is what guarantees a single React instance in the page: two copies would each have their own hook + dispatcher.' + tags: + - runtime + responses: + '200': + description: The bundle. + content: + application/javascript: + schema: + type: string + headers: + Cache-Control: + description: Immutable. The filename is stable so an import map can name it. + schema: + type: string + security: [] + /auth/login: + get: + summary: Begin the login flow + description: Redirects to the identity provider. The return path and the PKCE verifier are carried + in a signed cookie rather than server-side state, so any replica behind the load balancer can + complete the flow. + tags: + - auth + responses: + '302': + description: Redirect to the provider. + '404': + description: No identity provider is configured. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: returnTo + in: query + required: false + description: 'A **local** path to return to. An absolute URL is rejected: it would be an open + redirect that also primes a phishing flow.' + schema: + type: string + security: [] + /auth/callback: + get: + summary: Complete the login flow + description: Exchanges the authorization code, verifies the ID token signature, issuer, audience + and nonce, resolves roles from the user's groups, and sets the session cookie. The transaction + state is single-use. + tags: + - auth + responses: + '302': + description: Redirect to the return path, with a session cookie set. + '401': + description: The login could not be completed. The message names the reason, because "invalid + state" and "user declined" need different actions. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '502': + description: The provider returned an unexpected response. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] + /auth/logout: + get: + summary: End the session + description: Clears the session cookie and, when an OIDC provider is configured, ends the provider + session too. + tags: + - auth + responses: + '302': + description: Redirect, to the provider when configured. + security: + - sessionCookie: [] + - bearerToken: [] + post: + summary: End the session + description: Clears the session cookie and, when an OIDC provider is configured, ends the provider + session too. + tags: + - auth + responses: + '302': + description: Redirect, to the provider when configured. + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/auth/me: + get: + summary: The current session + description: The session, and what the caller may do. + tags: + - auth + responses: + '200': + description: The session. + content: + application/json: + schema: + $ref: '#/components/schemas/Session' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] + /api/v1/auth/config: + get: + summary: Client-visible configuration + description: What the UI needs before a session exists. Contains no secret material. + tags: + - auth + responses: + '200': + description: The configuration. + content: + application/json: + schema: + $ref: '#/components/schemas/AppConfig' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] + /api/v1/me/preferences: + get: + summary: The caller's interface preferences + description: Empty when nothing is stored, which is every account until it chooses once. + tags: + - preferences + responses: + '200': + description: The stored preference. + content: + application/json: + schema: + $ref: '#/components/schemas/UserPreferences' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + put: + summary: Record the caller's interface preferences + description: 'Any authenticated role may call this. The preference is keyed to the caller''s own + subject and grants nothing. An empty selectedTheme clears the choice. The response is authoritative: + the server rejects a theme that is not published, and reports a stored theme that has since disappeared + as no choice. tags: - preferences requestBody: required: true content: application/json: schema: + $ref: ' + responses: + '200': + description: What the server stored. + content: + application/json: + schema: + $ref: '#/components/schemas/UserPreferences' + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + delete: + summary: Forget the caller's preferences + description: Returns the interface to its defaults. + tags: + - preferences + responses: + '200': + description: The empty preference. + content: + application/json: + schema: + $ref: '#/components/schemas/UserPreferences' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + /api/v1/themes: + get: + summary: List themes + description: A page of themes, without their token documents. + tags: + - themes + responses: + '200': + description: A page of themes. + content: + application/json: + schema: + $ref: '#/components/schemas/ThemePage' + headers: &id002 + Cache-Control: &id003 + description: '`no-store`. Administration responses are per-user and change on every write.' + schema: + type: string + X-Request-ID: &id004 + description: Correlation id for the server log. + schema: + type: string + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: q + in: query + required: false + description: Matches slug, name or description. LIKE wildcards are escaped, so a literal `%` matches + a literal `%`. + schema: + type: string + - name: status + in: query + required: false + description: Filter by publication state. + schema: &id005 + $ref: '#/components/schemas/ThemeStatus' + - name: system + in: query + required: false + description: Filter on the built-in flag. + schema: + type: boolean + - name: tag + in: query + required: false + description: Requires the tag in the metadata. + schema: + type: string + - name: limit + in: query + required: false + description: '' + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - name: offset + in: query + required: false + description: '' + schema: + type: integer + minimum: 0 + default: 0 + - name: sort + in: query + required: false + description: Resolved through an allow-list. An unrecognised value falls back to the default rather + than reaching the SQL statement. + schema: + type: string + enum: + - updated + - created + - name + - slug + default: updated + - name: order + in: query + required: false + description: '' + schema: + type: string + enum: + - asc + - desc + default: desc + security: + - sessionCookie: [] + - bearerToken: [] + post: + summary: Create a theme + description: 'Creates a **draft**. A draft may be incomplete: validation gates publication, not + editing, which is what makes building a theme up incrementally practical.' + tags: + - themes + responses: + '201': + description: The created theme. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '409': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ThemeMutation' + example: + name: Acme Product + slug: acme + description: The default theme for customer-facing applications + status: draft + tokens: + color: + semantic: + primary: + $type: color + $value: '#4f46e5' + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}: + get: + summary: Get a theme + description: The theme with its full token document. + tags: + - themes + responses: + '200': + description: The theme. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: + - sessionCookie: [] + - bearerToken: [] + patch: + summary: Update a theme + description: 'A partial update: absent fields are left unchanged. Every write bumps the version, + because the version is the render cache key and must change whenever the rendered output could. + + + `baseVersion` gives optimistic concurrency. Without it, two editors silently overwrite each other; + with it, the loser gets a 409 naming both versions.' + tags: + - themes + responses: + '200': + description: The updated theme. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '409': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ThemeMutation' + example: + tokens: + color: + semantic: + primary: + $type: color + $value: '#7c6bf5' + baseVersion: 3 + message: rebalanced the neutrals + security: + - sessionCookie: [] + - bearerToken: [] + delete: + summary: Delete a theme + description: 'Removes the theme and its history. Refused for a built-in theme with 409: built-ins + are immutable so that a consumer pinned to the slug cannot break on upgrade. Duplicate it instead.' + tags: + - themes + responses: + '204': + description: Deleted. + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '409': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/publish: + post: + summary: Publish a theme + description: 'Validates the token document first: errors block, warnings do not. On success the + theme is served to every consuming application within one `s-maxage` window. A built-in theme + can be published but not deleted.' + tags: + - themes + responses: + '200': + description: The published theme. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/archive: + post: + summary: Archive a theme + description: Hidden from listings and from the runtime manifest. The last published revision stays + resolvable for a pinned consumer, so archiving does not break a deployment in progress. + tags: + - themes + responses: + '200': + description: The archived theme. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/duplicate: + post: + summary: Duplicate a theme + description: Copies a theme under a new slug as a draft, with its own history starting at version + 1. The copy is no longer flagged as built-in, so it can then be deleted. + tags: + - themes + responses: + '201': + description: The copy. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '409': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The source theme slug. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - slug + properties: + slug: + type: string + description: The slug for the copy. + example: + slug: acme-v2 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/export: + get: + summary: Export a theme + description: A portable document. Import and export are exact inverses, so migrating a theme between + environments is a file copy rather than a project. + tags: + - themes + responses: + '200': + description: The export. + content: + application/json: + schema: + $ref: '#/components/schemas/ThemeExport' + headers: + Cache-Control: *id003 + X-Request-ID: *id004 + Content-Disposition: + description: A download filename. + schema: + type: string + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/import: + post: + summary: Import a theme + description: Creates or replaces a theme from a portable document. The document is validated before + anything is written, so a malformed import never reaches the database. + tags: + - themes + responses: + '201': + description: The imported theme. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '409': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ImportRequest' + example: + slug: acme + name: Acme + asDraft: true + tokens: {} + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/versions: + get: + summary: A theme's history + description: 'Summaries newest first, without token documents: a hundred revisions of a hundred-token + theme would otherwise be a very large response for no benefit.' + tags: + - history + responses: + '200': + description: The history. + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/ThemeVersion' + headers: *id002 + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + - name: limit + in: query + required: false + description: '' + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/versions/{version}: + get: + summary: A historical snapshot + description: One immutable revision, including its token document. + tags: + - history + responses: + '200': + description: The snapshot. + content: + application/json: + schema: + $ref: '#/components/schemas/ThemeVersion' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + - name: version + in: path + required: true + description: The revision number. + schema: + type: integer + minimum: 1 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/versions/{version}/rollback: + post: + summary: Roll back to a revision + description: Restores a snapshot as a **new** version rather than rewinding. History is append-only, + so an application deployed against version 7 can never have version 7 taken away from it. + tags: + - history + responses: + '200': + description: The theme after rollback. + content: + application/json: + schema: + $ref: '#/components/schemas/Theme' + headers: *id002 + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + - name: version + in: path + required: true + description: The revision to restore. + schema: + type: integer + minimum: 1 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/themes/{slug}/diff: + get: + summary: Diff two revisions + description: A token-level comparison. Only the token tree is compared; a metadata change is reported + as a boolean rather than field by field. + tags: + - history + responses: + '200': + description: The differences. + content: + application/json: + schema: + $ref: '#/components/schemas/VersionDiff' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: slug + in: path + required: true + description: The theme slug. + schema: + type: string + - name: from + in: query + required: true + description: The base revision. + schema: + type: integer + minimum: 1 + - name: to + in: query + required: true + description: The comparison revision. + schema: + type: integer + minimum: 1 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/tokens/validate: + post: + summary: Validate a token document + description: Runs the engine's validator without persisting anything. The editor calls it as the + user works, which is exactly why it must not write. + tags: + - tokens + responses: + '200': + description: The findings. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationResult' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - tokens + properties: + tokens: &id006 + $ref: '#/components/schemas/TokenDocument' + example: + tokens: + color: + semantic: + primary: + $type: color + $value: '#4f46e5' + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/tokens/contrast: + get: + summary: Measure WCAG contrast + description: Reports the ratio and the highest conformance level it satisfies. The editor uses this + to flag an unreadable combination before it is saved rather than after it is published. + tags: + - tokens + responses: + '200': + description: The measurement. + content: + application/json: + schema: + $ref: '#/components/schemas/ContrastResult' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: fg + in: query + required: true + description: The foreground colour. + schema: + type: string + - name: bg + in: query + required: true + description: The background colour. + schema: + type: string + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/tokens/palette: + get: + summary: Generate a colour ramp + description: Produces the canonical 11-step ramp for a hue. Exposed so the editor's palette builder + and the built-in themes cannot diverge from the engine's ramp curve. + tags: + - tokens + responses: + '200': + description: The ramp. + content: + application/json: + schema: + $ref: '#/components/schemas/RampResponse' + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: hue + in: query + required: false + description: Hue in degrees. + schema: + type: number + minimum: 0 + maximum: 360 + default: 250 + - name: saturation + in: query + required: false + description: Saturation at the mid steps, percent. + schema: + type: number + minimum: 0 + maximum: 100 + default: 78 + - name: lightness + in: query + required: false + description: Lightness at step 500, percent. + schema: + type: number + minimum: 20 + maximum: 80 + default: 55 + - name: hueSpread + in: query + required: false + description: Hue drift from the lightest to the darkest step. A value of 12 reproduces the warm-to-cool + lean of a modern perceptual ramp. + schema: + type: number + minimum: 0 + maximum: 40 + default: 12 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/tokens/schema: + get: + summary: The token schema + description: The group and kind registry, so a client renders the right control per token type instead + of duplicating a list that would drift. + tags: + - tokens + responses: + '200': + description: The schema. + content: + application/json: + schema: + $ref: '#/components/schemas/TokenSchema' + headers: + Cache-Control: + description: Cacheable. + schema: + type: string + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/admin/users: + get: + summary: List known accounts + description: Accounts seen at least once at login. Roles are overwritten from the provider on every + login rather than merged, so a user removed from a group actually loses access. + tags: + - admin + responses: + '200': + description: The users. + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/User' + headers: *id002 + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: limit + in: query + required: false + description: '' + schema: + type: integer + minimum: 1 + maximum: 500 + default: 100 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/admin/audit: + get: + summary: Read the activity log + description: Every mutation in the administration API. + tags: + - admin + responses: + '200': + description: A page of entries. + content: + application/json: + schema: + $ref: '#/components/schemas/AuditPage' + headers: *id002 + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + parameters: + - name: actor + in: query + required: false + description: Filter by actor. + schema: + type: string + - name: action + in: query + required: false + description: Filter by action. + schema: + type: string + - name: entityType + in: query + required: false + description: '' + schema: + type: string + - name: entityId + in: query + required: false + description: '' + schema: + type: string + - name: since + in: query + required: false + description: '' + schema: + type: string + format: date-time + - name: limit + in: query + required: false + description: '' + schema: + type: integer + minimum: 1 + maximum: 500 + default: 50 + - name: offset + in: query + required: false + description: '' + schema: + type: integer + minimum: 0 + default: 0 + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/admin/settings: + get: + summary: Read instance settings + description: The settings the engine reads at run time. Everything requiring a restart is environment + configuration and belongs in the Helm chart, not in a database row. + tags: + - admin + responses: + '200': + description: The settings. + content: + application/json: + schema: + type: object + properties: + items: + type: object + additionalProperties: + $ref: '#/components/schemas/Setting' + headers: *id002 + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: + - sessionCookie: [] + - bearerToken: [] + put: + summary: Write instance settings + description: Writing the default theme clears the render cache, because it is baked into the runtime + manifest. Unrecognised keys are rejected rather than silently stored. + tags: + - admin + responses: + '200': + description: The keys written. + content: + application/json: + schema: + type: object + properties: + updated: + type: array + items: + type: string + headers: *id002 + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: + type: string + description: 'Setting keys to values. Recognised keys: `default.theme`, `default.appearance`, + `runtime.public`, `runtime.apiBase`.' + example: + default.theme: midnight + default.appearance: dark + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/admin/cache/rebuild: + post: + summary: Rebuild the render cache + description: 'Clears and re-warms it. Useful after a bulk change, or to reclaim memory: the cache + holds every published theme multiplied by appearance and variant.' + tags: + - admin + responses: + '200': + description: What was warmed. + content: + application/json: + schema: + type: object + properties: + themes: + type: integer + artefacts: + type: integer + headers: *id002 + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: + - sessionCookie: [] + - bearerToken: [] + /api/v1/stats: + get: + summary: Dashboard counters + description: Theme, key, user and audit counts, plus the render cache hit rate. + tags: + - themes + responses: + '200': + description: The counters. + content: + application/json: + schema: + $ref: '#/components/schemas/Stats' + headers: *id002 + '401': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: + - sessionCookie: [] + - bearerToken: [] + /healthz: + get: + summary: Liveness, with build information + description: Does **not** touch the database, deliberately. A liveness probe that failed on a slow + database would have the orchestrator restart a process that is otherwise healthy, turning a dependency + blip into an outage. + tags: + - operational + responses: + '200': + description: Healthy. + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + '500': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] + /livez: + get: + summary: Liveness + description: Answers as long as the process can serve HTTP. + tags: + - operational + responses: + '200': + description: Healthy. + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + '500': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] + /readyz: + get: + summary: Readiness + description: Reports whether the service can actually serve traffic, which means the database must + answer within a bounded time. Warns when the database is in-memory, which is legitimate in a test + and never in production. + tags: + - operational + responses: + '200': + description: Ready. + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + headers: *id002 + '503': + description: Not ready. + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + security: [] + /metrics: + get: + summary: Prometheus metrics + description: Request counts labelled by **route pattern** rather than concrete path — a thousand + distinct theme slugs must not become a thousand time series — a latency histogram, the render + cache hit rate, and Go runtime gauges. Disabled when `config.enableMetrics` is false. + tags: + - operational + responses: + '200': + description: The exposition text. + content: + text/plain: + schema: + type: string + headers: *id002 + security: [] + /rt/v1/embed-page: + get: + summary: A shareable integration page + description: 'A complete HTML page showing the snippet for a theme next to a live preview of it. + Separate from /rt/v1/embed because the two have different callers: this one is a page a human + opens, that one is data a UI fetches. Every code sample on the page comes from the same builders, + so what is copied here is byte-for-byte what the admin UI shows.' + tags: + - runtime + parameters: + - name: slug + in: query + required: false + description: The theme to show. + schema: + type: string + responses: + '200': + description: The page. + content: + text/html: + schema: + type: string + '400': + description: Invalid slug. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: No such theme. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + security: [] +components: + securitySchemes: + sessionCookie: + type: apiKey + in: cookie + name: go-bananas-session + description: An HMAC-signed session cookie. Set `HttpOnly`, `Secure` and `SameSite`. Issued by the + OIDC login flow. There is no server-side session table, so any replica can authenticate any request. + bearerToken: + type: http + scheme: bearer + description: An API key. May also be supplied as a `token` query parameter, because a stylesheet + link cannot carry a header. Stored as a SHA-256 digest. + schemas: + ApiError: + type: object + description: 'The uniform error body. `code` is stable and part of the contract: clients branch + on it, so its meaning must not change between releases.' + required: + - code + - message + properties: + code: + type: string + description: A stable machine-readable code. + examples: + - validation_failed + message: + type: string + description: A human-readable explanation. + details: + type: array + items: + $ref: '#/components/schemas/FieldError' + requestId: + type: string + description: Correlates the response with the server log. + FieldError: + type: object + required: + - field + - message + properties: + field: + type: string + description: The JSON path or dotted token path. + message: + type: string + Token: + type: object + required: + - $value + description: A single token leaf, in the W3C Design Tokens Community Group format. + properties: + $type: + type: string + description: The token type. + examples: + - color + - dimension + - fontSize + - cubicBezier + $value: + description: 'The value: a string, number, boolean or number array.' + oneOf: + - type: string + - type: integer + - type: boolean + - type: array + items: + type: integer + $description: + type: string + TokenDocument: + type: object + additionalProperties: true + description: A design-token document. `$appearances` holds a partial document per colour scheme, + deep-merged over the base, so a token absent from the dark overrides simply inherits its light + value. + properties: + $schema: + type: string + $appearances: + type: object + additionalProperties: true + ThemeStatus: + type: string + enum: + - draft + - published + - archived + description: Publication state. Only `published` is served to the runtime. + Appearance: + type: string + enum: + - light + - dark + description: Colour scheme. + Theme: + type: object + required: + - id + - slug + - name + - status + - defaultAppearance + - tokens + - system + - version + - createdAt + - updatedAt + properties: + id: + type: string + slug: + type: string + pattern: ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$ + name: + type: string + description: + type: string + status: *id005 + defaultAppearance: &id007 + $ref: '#/components/schemas/Appearance' + tokens: *id006 + meta: + type: object + additionalProperties: true + description: Opaque to the engine apart from `tags`. + system: + type: boolean + description: A built-in theme. Cannot be deleted. + version: + type: integer + minimum: 1 + description: Bumped on every mutation. The render cache key. + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + createdBy: + type: string + updatedBy: + type: string + ThemeSummary: + type: object + description: 'A theme without its token document. Listings never include them: they are the bulk + of each row and a listing does not need them.' + properties: + id: + type: string + slug: + type: string + name: + type: string + description: + type: string + status: *id005 + defaultAppearance: *id007 + meta: + type: object + additionalProperties: true + system: + type: boolean + version: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + createdBy: + type: string + updatedBy: + type: string + ThemePage: + type: object + required: + - items + - total + - limit + - offset + properties: + items: + type: array + items: + $ref: '#/components/schemas/ThemeSummary' + total: + type: integer + limit: + type: integer + offset: + type: integer + ThemeMutation: + type: object + description: Create or update. Absent fields are left unchanged on update. + properties: + slug: + type: string + pattern: ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$ + name: + type: string + description: + type: string + status: *id005 + defaultAppearance: *id007 + tokens: *id006 + meta: + type: object + additionalProperties: true + message: + type: string + description: Recorded in the version history. + baseVersion: + type: integer + description: Optimistic concurrency. The write is rejected with 409 when the stored version + differs, which stops two editors silently overwriting each other. + ThemeVersion: + type: object + description: 'An immutable snapshot. History is append-only: a rollback restores a snapshot as a + new version rather than rewinding, so a deployed application can never lose the revision it was + built against.' + required: + - themeId + - version + - slug + - name + - createdAt + properties: + themeId: + type: string + version: + type: integer + slug: + type: string + name: + type: string + description: + type: string + status: *id005 + defaultAppearance: *id007 + tokens: *id006 + message: + type: string + author: + type: string + createdAt: + type: string + format: date-time + VersionDiff: + type: object + required: + - fromVersion + - toVersion + - added + - removed + - changed + properties: + fromVersion: + type: integer + toVersion: + type: integer + added: + type: array + items: + type: string + removed: + type: array + items: + type: string + changed: + type: array + items: + $ref: '#/components/schemas/TokenDiff' + metaChanged: + type: boolean + TokenDiff: + type: object + required: + - path + - from + - to + properties: + path: + type: string + from: + type: string + to: + type: string + ValidationIssue: + type: object + required: + - path + - severity + - message + properties: + path: + type: string + severity: + type: string + enum: + - error + - warning + - info + message: + type: string + hint: + type: string + ValidationResult: + type: object + required: + - valid + - tokenCount + - issues + properties: + valid: + type: boolean + description: False when any issue is an error. Errors block publication; warnings do not. + tokenCount: + type: integer + issues: + type: array + items: + $ref: '#/components/schemas/ValidationIssue' + ContrastResult: + type: object + required: + - foreground + - background + - ratio + - level + properties: + foreground: + type: string + background: + type: string + ratio: + type: number + rounded: + type: number + level: + type: string + enum: + - fail + - aa-large + - aa + - aaa + thresholds: + type: object + properties: + aaLarge: + type: number + aa: + type: number + aaa: + type: number + bestForeground: + type: string + RampResponse: + type: object + description: A generated colour ramp. + properties: + options: + type: object + properties: + Hue: + type: number + Saturation: + type: number + Lightness: + type: number + HueSpread: + type: number + shades: + type: array + items: + type: string + ramp: + type: object + additionalProperties: + $ref: '#/components/schemas/Token' + TokenSchema: + type: object + description: The engine's group and kind registry, so a client renders the right control per token + type instead of duplicating a list that would drift. + properties: + id: + type: string + prefix: + type: string + kinds: + type: array + items: + type: string + groups: + type: array + items: + type: object + properties: + name: + type: string + kind: + type: string + description: + type: string + recommended: + type: array + items: + type: string + shades: + type: array + items: + type: string + ImportRequest: + type: object + required: + - slug + - tokens + properties: + slug: + type: string + name: + type: string + tokens: *id006 + meta: + type: object + additionalProperties: true + asDraft: + type: boolean + default: true + description: Import as a draft, so an import can never publish something by accident. + overwrite: + type: boolean + description: Replace an existing theme. Without it, an existing slug is a 409 rather than a + silent overwrite. + message: + type: string + ThemeExport: + type: object + description: The portable document. Import and export are exact inverses, so migrating a theme between + environments is a file copy rather than a project. + properties: + $schema: + type: string + slug: + type: string + name: + type: string + status: *id005 + version: + type: integer + tokens: *id006 + meta: + type: object + additionalProperties: true + exportedAt: + type: string + format: date-time + exportedBy: + type: string + Session: + type: object + description: 'The current session. Always answers 200: an absent session is the normal state on + first load, not an error the UI should have to handle as one.' + required: + - authenticated + - mode + properties: + authenticated: + type: boolean + mode: + type: string + enum: + - oidc + - dev + - none + identity: + type: object + properties: + subject: + type: string + email: + type: string + name: + type: string + roles: + type: array + items: + type: string + enum: + - viewer + - editor + - admin + method: + type: string + expiresAt: + type: string + format: date-time + permissions: + type: object + additionalProperties: + type: boolean + description: A UI convenience only. The server enforces every check, so hiding a control is + never the control. + preferences: + $ref: '#/components/schemas/UserPreferences' + description: Present once authenticated. Reported with the session so the UI can preselect a + theme without a second request. + devBypass: + type: boolean + loginUrl: + type: string + UserPreferences: + type: object + description: 'Per-account interface preferences. Both fields empty means nothing has been chosen, + which is a valid state rather than a missing one: the client then applies its own default.' + properties: + selectedTheme: + type: string + description: A published theme slug, or empty for no choice. Names the theme rather than copying + it, so editing or unpublishing that theme shows through everywhere. A stored value naming + a theme that has since been deleted or archived reads back as empty. + selectedAppearance: + $ref: '#/components/schemas/Appearance' + description: Which of the theme''s two colour schemes to render. Ignored, and dropped on write, + when selectedTheme is empty. + AppConfig: + type: object + description: What the UI needs before a session exists. Contains no secret material. + properties: + mode: + type: string + issuer: + type: string + runtimeBase: + type: string + publicBase: + type: string + basePath: + type: string + runtimeApiVersion: + type: string + variablePrefix: + type: string + defaultTheme: + type: string + User: + type: object + properties: + subject: + type: string + email: + type: string + name: + type: string + roles: + type: array + items: + type: string + method: + type: string + firstSeen: + type: string + format: date-time + lastLogin: + type: string + format: date-time + AuditEntry: + type: object + description: One recorded action. The actor comes from the authenticated identity, never from the + request body. + required: + - id + - at + - actor + - action + properties: + id: + type: integer + at: + type: string + format: date-time + actor: + type: string + actorEmail: + type: string + action: + type: string + enum: + - theme.create + - theme.update + - theme.delete + - theme.publish + - theme.archive + - theme.duplicate + - theme.rollback + - theme.import + - key.create + - key.revoke + - auth.login + - auth.logout + entityType: + type: string + entityId: + type: string + summary: + type: string + detail: + type: object + additionalProperties: true + remoteIp: + type: string + AuditPage: + type: object + required: + - items + - total + - limit + - offset + properties: + items: + type: array + items: + $ref: '#/components/schemas/AuditEntry' + total: + type: integer + limit: + type: integer + offset: + type: integer + Setting: + type: object + required: + - key + - value + - updatedAt + properties: + key: + type: string + value: + type: string + updatedAt: + type: string + format: date-time + updatedBy: + type: string + Stats: + type: object + properties: + store: + type: object + properties: + themes: + type: integer + themesByStatus: + type: object + additionalProperties: + type: integer + users: + type: integer + auditEntries: + type: integer + databaseSizeBytes: + type: integer + versionsRetaind: + type: integer + cache: + type: object + properties: + CSSHits: + type: integer + CSSMisses: + type: integer + JSONHits: + type: integer + JSONMisses: + type: integer + Entries: + type: integer + ResolvedTheme: + type: object + description: A theme with every token resolved for one appearance, including derived tokens, because + consumers should not reimplement the derivation. + required: + - theme + - tokens + - variables + properties: + theme: + type: object + properties: + slug: + type: string + name: + type: string + description: + type: string + version: + type: integer + appearance: *id007 + defaultAppearance: *id007 + updatedAt: + type: string + format: date-time + tokens: *id006 + variables: + type: object + additionalProperties: + type: string + description: Flat CSS custom property name to rendered value. + meta: + type: object + additionalProperties: true + ManifestEntry: + type: object + required: + - slug + - name + - defaultAppearance + - version + - css + - tokens + properties: + slug: + type: string + name: + type: string + description: + type: string + defaultAppearance: *id007 + version: + type: integer + tags: + type: array + items: + type: string + css: + type: string + format: uri + tokens: + type: string + format: uri + Manifest: + type: object + description: 'The discovery document a loader fetches once to build a theme picker. Deliberately + small: no token data, so a page with a picker does not download every theme.' + required: + - apiVersion + - baseUrl + - themes + properties: + apiVersion: + type: string + default: + type: string + baseUrl: + type: string + format: uri + themes: + type: array + items: + $ref: '#/components/schemas/ManifestEntry' + Health: + type: object + properties: + status: + type: string + enum: + - ok + - ready + - not ready + version: + type: string + uptime: + type: string + checks: + type: object + additionalProperties: true + EmbedSnippet: + type: object + description: A ready-to-paste integration snippet for one theme. + required: + - slug + - name + - version + - baseUrl + - language + - snippet + properties: + slug: + type: string + description: 'The theme, which exists: the endpoint validates it.' + name: + type: string + description: The theme's display name. + version: + type: integer + description: The version the snippet was generated against. + baseUrl: + type: string + description: The runtime base URL baked into the snippet. + language: + type: string + enum: + - html + - js + - tsx + description: The syntax of the snippet. + snippet: + type: string + description: The code to paste. Values interpolated into it are escaped for the language. + note: + type: string + description: Anything else the integrator needs to know. +security: +- sessionCookie: [] +- bearerToken: [] diff --git a/sdk/eslint.config.js b/sdk/eslint.config.js new file mode 100644 index 0000000..efa3741 --- /dev/null +++ b/sdk/eslint.config.js @@ -0,0 +1,95 @@ +import js from '@eslint/js' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +/** + * ESLint configuration for the runtime SDK. + * + * The SDK is written in JavaScript with JSDoc types rather than TypeScript, + * because it is served as source to be read as well as to be executed. That + * choice drives the rule set: + * + * - The rules designed for a `.ts` boundary (`explicit-module-boundary-types`) + * are redundant here: a JSDoc `@returns` annotation is the same information, + * and the linter cannot see it as an annotation. + * - The rules that catch real defects — floating promises, misused promises, + * unreachable switch cases, unused variables — stay, and are errors. + * - `no-console` is an error rather than a warning, because this module runs in + * someone else's page and must not write to their console on a path they did + * not ask for. The two exceptions below are deliberate diagnostics that only + * fire when the theme itself has already failed. + * + * The SDK is loaded by third-party pages, so anything it gets wrong is somebody + * else's incident. The strictness here is proportionate to that. + */ +export default tseslint.config( + { ignores: ['dist', 'node_modules', '*.config.js'] }, + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + { + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + globals: { ...globals.browser, ...globals.es2022 }, + }, + plugins: { '@typescript-eslint': tseslint.plugin }, + rules: { + // JSDoc already declares every boundary type in a JavaScript project. + '@typescript-eslint/explicit-module-boundary-types': 'off', + // A fetch stub that returns a Response directly has nothing to await. + '@typescript-eslint/require-await': 'off', + // `||` is deliberate where an empty string should fall through, which is + // not what `??` does. + '@typescript-eslint/prefer-nullish-coalescing': 'off', + + // --- correctness ----------------------------------------------------- + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/switch-exhaustiveness-check': 'error', + // Disabled in this package. The DOM type definitions describe a fully + // featured modern browser, but the runtime guards below exist for Safari + // below 14 (no `MediaQueryList.addEventListener`) and for embedded + // webviews with no `matchMedia` at all. Those guards are load-bearing for + // the consumers this package exists to serve, and the rule reports them as + // dead code. + '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, + ], + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }, + ], + + // --- host safety ----------------------------------------------------- + 'no-console': ['error', { allow: ['warn', 'error'] }], + eqeqeq: ['error', 'always', { null: 'ignore' }], + 'prefer-const': 'error', + 'no-var': 'error', + 'object-shorthand': 'error', + // An unsubscribe function that does nothing is the correct return value + // when there is nothing to unsubscribe from. + '@typescript-eslint/no-empty-function': [ + 'error', + { allow: ['arrowFunctions'] }, + ], + }, + }, + { + // Tests build doubles that are deliberately loosely typed, and a test's + // console output is how a failure is diagnosed. + files: ['**/*.test.js'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/unbound-method': 'off', + 'no-console': 'off', + }, + }, +) \ No newline at end of file diff --git a/sdk/package-lock.json b/sdk/package-lock.json new file mode 100644 index 0000000..9660554 --- /dev/null +++ b/sdk/package-lock.json @@ -0,0 +1,2981 @@ +{ + "name": "@go-bananas/sdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@go-bananas/sdk", + "version": "1.0.0", + "license": "SEE LICENSE IN ../LICENSE", + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/react": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "eslint": "10.12.0", + "globals": "17.13.0", + "jsdom": "30.1.2", + "react": "19.3.0", + "react-dom": "19.3.0", + "typescript": "6.0.3", + "typescript-eslint": "8.71.0", + "vite": "8.3.2", + "vitest": "5.0.3" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", + "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.2", + "@csstools/css-color-parser": "^4.2.5", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsdom": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.11.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..a08bd6c --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,36 @@ +{ + "name": "@go-bananas/sdk", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Runtime theme delivery SDKs served by Go Bananas!!. Built, not published: consumers load these from the service origin.", + "license": "SEE LICENSE IN ../LICENSE", + "scripts": { + "build": "vite build", + "dev": "vite build --watch --mode development", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "peerDependencies": { + "react": ">=18" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/react": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "eslint": "10.12.0", + "globals": "17.13.0", + "jsdom": "30.1.2", + "react": "19.3.0", + "react-dom": "19.3.0", + "typescript": "6.0.3", + "typescript-eslint": "8.71.0", + "vite": "8.3.2", + "vitest": "5.0.3" + }, + "engines": { + "node": ">=22" + } +} diff --git a/sdk/src/components.js b/sdk/src/components.js new file mode 100644 index 0000000..bd4ed8b --- /dev/null +++ b/sdk/src/components.js @@ -0,0 +1,731 @@ +/** + * The two shipped controls: a theme selector and an appearance toggle. + * + * They are custom elements rather than framework components on purpose. The + * applications this service feeds are a mix of vanilla JS, React and Tailwind, and a + * React component cannot be used by the first while a plain custom element works in + * all three with no build step and no import map. + * + * Both render into a shadow root, which is what makes them safe to inject into + * somebody else's page: + * + * - The page's CSS cannot reach inside, so a global `button { all: unset }` or a + * Tailwind preflight cannot restyle them. + * - Their CSS cannot leak out, so they cannot restyle the page. This is the same + * property that makes a live preview of a theme safe: the preview's styles stay in + * the preview. + * - Theming still works, because a shadow root inherits the custom properties that + * cross the boundary. `--bn-bg` set on is visible inside, so the controls + * follow the palette they are switching between without a single extra rule. + * + * Styling hooks: `::part(button)`, `::part(list)` and so on, plus the `--bn-control-*` + * custom properties below. Nothing else is reachable, which is the point. + * + * ```html + * + * + * + * ``` + * + * @module go-bananas/components + */ + +import { + ADMIN_MODE, + ContractState, + DEFAULT_STORAGE_KEY, + getContractState, + LEGACY_MODE_ATTRIBUTE, + LEGACY_THEME_ATTRIBUTE, + MODE_ATTRIBUTE, + MODES, + readMode, + resetContractState, + THEME_ATTRIBUTE, +} from './contract.js' + +/** + * The base the selector falls back to. + * + * It is only a fallback: a page that has already linked the contract stylesheet has + * said where the service is, and the component reads it from there rather than being + * configured a second time. Two places to set the same value is two places to get + * wrong. + */ +const DEFAULT_BASE = '/rt/v1' + +/** The marker attribute on the contract stylesheet the selector looks for. */ +const CONTRACT_LINK_MARKER = 'data-theme-contract' + +/** + * Finds the runtime base from the contract stylesheet already on the page. + * + * @returns {string|null} The directory, or null when no link was found. + */ +function baseFromContractLink() { + if (typeof document === 'undefined') return null + + // The marker attribute is preferred, because it is unambiguous. A link whose href + // ends in contract.css is accepted as a fallback so a page that predates the marker + // still works. + let link = document.querySelector(`link[${CONTRACT_LINK_MARKER}]`) + if (!link) { + for (const candidate of document.querySelectorAll('link[rel~="stylesheet"][href]')) { + const href = candidate.getAttribute('href') || '' + if (href.endsWith('contract.css')) { + link = candidate + break + } + } + } + if (!link) return null + + const href = link.getAttribute('href') + if (!href) return null + + // Strip the stylesheet filename, leaving the runtime directory. + const withoutFile = href.replace(/\/[^/]*$/, '') + return withoutFile === href ? null : withoutFile +} + +/** + * Constructed contract stylesheets, cached per contract URL. + * + * A stylesheet in the document does not select into a shadow root, so the selector + * cannot borrow the one the page already linked. It has to fetch the contract itself + * and adopt it. Constructed sheets are shareable, so one fetch and one parse serve + * every control on the page. + * + * @type {Map>} + */ +const contractSheets = new Map() + +/** + * Fetches the contract and parses it into a stylesheet the controls can adopt. + * + * Returns null rather than throwing when the contract cannot be adopted. The palette + * names still come from `/contract.json`, so a control with no stylesheet is a + * working text-only selector rather than a broken one, and that is the right trade: + * the preview is the thing that degrades, not the ability to pick a theme. + * + * @param {string} base + * @returns {Promise} + */ +async function adoptContractSheet(base) { + const url = `${base}/contract.css` + // The capability is probed by name rather than by reading the method off the + // prototype: holding the function would detach it from the constructor it belongs + // to. `in` asks the same question without a value to misuse. + const supported = + typeof CSSStyleSheet !== 'undefined' && + 'replaceSync' in CSSStyleSheet.prototype && + typeof fetch === 'function' + if (!supported) return null + + let pending = contractSheets.get(url) + if (!pending) { + pending = (async () => { + try { + const response = await fetch(url, { credentials: 'omit' }) + if (!response.ok) throw new Error(`contract.css responded ${response.status}`) + const css = await response.text() + const sheet = new CSSStyleSheet() + // The contract is generated, not authored, so there is nothing to await a + // progressive render for. replaceSync also keeps adoption off the microtask + // queue, which is what stops the preview flashing unstyled. + sheet.replaceSync(css) + return sheet + } catch { + return null + } + })() + contractSheets.set(url, pending) + } + + const sheet = await pending + if (!sheet) { + // Only failures are evicted, so a control mounted later can retry a contract that + // was momentarily unavailable. Concurrent controls still share the one attempt, + // because they were all awaiting the same promise. + contractSheets.delete(url) + return null + } + return sheet +} + +/** + * The shadow stylesheet for both controls. + * + * Colours come from the contract custom properties with a literal fallback for each, + * so the controls are legible even on a page that has not loaded contract.css. The + * fallbacks are the light-mode values: a control that has to guess should guess + * legibly rather than guess dark text on a dark background. + */ +const STYLES = ` + :host { + /* Overridable by the host application without reaching into the shadow root. */ + --bn-control-radius: 8px; + --bn-control-gap: 2px; + --bn-control-padding: 6px 10px; + --bn-control-font: 13px/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif; + + display: inline-block; + font: var(--bn-control-font); + color: var(--bn-text, #0f172a); + /* The control is a control, so it does not inherit the page's text colour. */ + -webkit-font-smoothing: antialiased; + } + + :host([hidden]) { display: none; } + + /* A segmented control: one bordered group with an inset selected segment. Built + from a plain flex row rather than a list so there is no marker semantics to + suppress, and so it reads the same in every engine. */ + .group { + display: inline-flex; + gap: var(--bn-control-gap); + padding: 2px; + background: var(--bn-surface-sunken, #eef2f6); + border: 1px solid var(--bn-border, #e2e8f0); + border-radius: var(--bn-control-radius); + } + + button { + appearance: none; + font: inherit; + margin: 0; + cursor: pointer; + padding: var(--bn-control-padding); + color: var(--bn-muted, #475569); + background: transparent; + border: 0; + border-radius: calc(var(--bn-control-radius) - 2px); + white-space: nowrap; + transition: background-color 120ms ease, color 120ms ease; + } + + button:hover { color: var(--bn-text, #0f172a); } + + button:focus-visible { + outline: 2px solid var(--bn-focus, #2563eb); + outline-offset: 1px; + } + + button[aria-pressed='true'], + button[aria-checked='true'] { + background: var(--bn-surface, #fff); + color: var(--bn-text, #0f172a); + /* A border rather than a shadow: it survives a forced-colours mode where a + shadow is not painted. */ + box-shadow: inset 0 0 0 1px var(--bn-border, #e2e8f0); + } + + button[disabled] { cursor: not-allowed; opacity: 0.5; } + + label { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + /* The palette preview: a miniature of the application, not a row of colour chips. + Every colour is read from the contract, so what the preview shows is what the + theme does -- there is no hardcoded palette here to fall out of step with the + server. A chip of the accent colour was tried first and told you almost nothing: + two themes sharing an accent looked identical, which is the case the preview + exists to resolve. */ + .preview { + display: block; + flex: none; + width: 28px; + height: 18px; + overflow: hidden; + border-radius: 3px; + background: var(--bn-bg, #fff); + box-shadow: inset 0 0 0 1px var(--bn-border, #e2e8f0); + } + + /* A title bar, then a body with two rules of text and an accent control: enough + surface that the palette's surface, border and text all show at once. */ + .preview-bar { + display: block; + height: 5px; + background: var(--bn-surface, #f8fafc); + border-bottom: 1px solid var(--bn-border, #e2e8f0); + } + + .preview-body { + display: flex; + align-items: center; + gap: 2px; + padding: 2px; + } + + .preview-rule { + display: block; + height: 2px; + flex: 1; + border-radius: 1px; + background: var(--bn-text, #0f172a); + /* Faded rather than a second colour: the miniature has to work on a palette whose + muted role is very close to its text role. */ + opacity: 0.4; + } + + .preview-accent { + display: block; + flex: none; + width: 9px; + height: 4px; + border-radius: 1px; + background: var(--bn-accent, #4f46e5); + } + + .spinner { opacity: 0.6; } +` + +/** + * A control's shared behaviour: shadow root, styles, subscription, attributes. + * + * @abstract + */ +class BananasControl extends HTMLElement { + constructor() { + super() + this._root = this.attachShadow({ mode: 'open' }) + const style = document.createElement('style') + style.textContent = STYLES + this._root.append(style) + this._unsub = null + } + + /** @returns {ShadowRoot} */ + get shadowRoot_() { + return this._root + } + + connectedCallback() { + this._unsub = getContractState().subscribe((state) => this._render(state)) + } + + disconnectedCallback() { + if (this._unsub) this._unsub() + this._unsub = null + } + + /** + * @param {string} name + * @param {string} [fallback] + * @returns {string} + */ + _attr(name, fallback = '') { + const value = this.getAttribute(name) + return typeof value === 'string' ? value : fallback + } + + /** + * @param {import('./contract.js').Selection} _state + * @returns {void} + */ + _render(_state) { + throw new Error('not implemented') + } + + /** + * Fires a bubbling composed event so a host framework can react without having to + * reach into the shadow root. + * + * @param {string} type + * @param {Record} detail + * @returns {void} + */ + _emit(type, detail) { + this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true })) + } +} + +/** + * Narrows the contract payload's theme list to what the control can render. + * + * @param {unknown} raw + * @returns {Array<{slug: string, name: string, defaultMode: string}>} + */ +function normaliseThemes(raw) { + if (!Array.isArray(raw)) return [] + const out = [] + for (const entry of raw) { + if (typeof entry !== 'object' || entry === null) continue + /** @type {unknown} */ + const asUnknown = entry + const item = /** @type {Record} */ (asUnknown) + if (typeof item.slug !== 'string' || item.slug === '') continue + out.push({ + slug: item.slug, + name: typeof item.name === 'string' ? item.name : item.slug, + defaultMode: item.defaultMode === 'light' ? 'light' : 'dark', + }) + } + return out +} + +/** + * Builds the miniature that previews a palette. + * + * The palette is carried on the element as an attribute rather than applied from the + * component, because that is the same mechanism the application uses. The preview is + * then styled by the real contract rules, which is the only way it can be honest: if + * the server changes how a palette is built, the preview changes with it instead of + * disagreeing with the page it is previewing. + * + * @param {{slug: string, defaultMode: string}} theme + * @returns {HTMLSpanElement} + */ +function previewFor(theme) { + const preview = document.createElement('span') + preview.className = 'preview' + preview.setAttribute('part', 'preview') + // The palette name is the button's text and therefore already announced. Announcing + // the picture as well would read the same theme twice. + preview.setAttribute('aria-hidden', 'true') + preview.setAttribute(THEME_ATTRIBUTE, theme.slug) + preview.setAttribute(MODE_ATTRIBUTE, theme.defaultMode) + + const bar = document.createElement('span') + bar.className = 'preview-bar' + const body = document.createElement('span') + body.className = 'preview-body' + const rule = document.createElement('span') + rule.className = 'preview-rule' + const accent = document.createElement('span') + accent.className = 'preview-accent' + body.append(rule, accent) + preview.append(bar, body) + return preview +} + +/** + * `` — picks the palette. + * + * It reads the palette list from `/contract.json` on the service, so a host + * application publishes a theme and it appears here with no code change on either side. + * + * Each palette is previewed with a miniature of the application styled by the contract. + * The contract itself is fetched separately and adopted into the shadow root, because a + * stylesheet in the document does not select into a shadow root. When it cannot be + * fetched the selector still works, listing palette names only. + * + * Attributes: + * base service runtime base; defaults to /rt/v1 + * label accessible label; defaults to "Theme" + * compact omit the "No theme" entry when present + * no-preview list palette names without the miniature + * + * Properties (for framework interop): + * value the selected slug, or '' + * + * Events: `bananas:theme` with `detail: { slug, mode }`. + */ +export class BananasThemeSelector extends BananasControl { + /** @type {Array<{slug: string, name: string, defaultMode: string}>} */ + _themes = [] + + /** @type {'idle'|'loading'|'ready'|'error'} */ + _status = 'idle' + + /** @type {CSSStyleSheet|null} */ + _sheet = null + + /** @override */ + connectedCallback() { + // The DOM is built before subscribing. Subscription renders immediately, so + // subscribing first would render into a group that does not exist yet and the + // control would come up empty until something else changed the store. + this._root.append( + (this._label = document.createElement('label')), + (this._group = document.createElement('div')), + ) + this._label.className = 'sr' + this._label.textContent = this._attr('label', 'Theme') + this._group.className = 'group' + this._group.setAttribute('part', 'group') + this._group.setAttribute('role', 'radiogroup') + this._group.setAttribute('aria-label', this._label.textContent) + super.connectedCallback() + // Not awaited: connectedCallback cannot be async, and the control renders a + // loading state until the store resolves. The method handles its own failures. + void this._load() + } + + /** @returns {string} */ + get value() { + return getContractState().state.theme || '' + } + + set value(slug) { + getContractState().selectTheme(slug || null) + } + + /** @returns {Promise} */ + async _load() { + if (this._status === 'loading' || this._status === 'ready') return + this._status = 'loading' + + // The explicit attribute wins; otherwise the contract stylesheet on the page says + // where the service is; otherwise the same-origin default. + const configured = this.getAttribute('base') + const base = (configured || baseFromContractLink() || DEFAULT_BASE).replace(/\/$/, '') + // The contract is adopted alongside the palette list rather than before it. Waiting + // for it would delay the control for a decoration, and the palette names are what + // the control is actually for. + void this._adoptContract(base) + try { + const response = await fetch(`${base}/contract.json`, { credentials: 'omit' }) + if (!response.ok) throw new Error(`contract.json responded ${response.status}`) + // json() is untyped, so the payload is narrowed here rather than trusted: a + // contract document from a mismatched build must not throw inside the render. + // The value is landed in an `unknown`-typed variable first. `json()` returns + // `any`, and assigning an `any` straight into a typed position is exactly the + // hole no-unsafe-assignment exists to close. + /** @type {unknown} */ + const body = await response.json() + const payload = /** @type {{themes?: unknown}} */ (body) + this._themes = normaliseThemes(payload.themes) + this._status = 'ready' + this._render(getContractState().state) + } catch (error) { + this._status = 'error' + this._render(getContractState().state) + this._emit('bananas:error', { error: error instanceof Error ? error.message : String(error) }) + } + } + + /** + * Adopts the contract into the shadow root, then repaints with the previews on. + * + * @param {string} base + * @returns {Promise} + */ + async _adoptContract(base) { + const sheet = await adoptContractSheet(base) + // A null sheet is not a failure here: the control renders without previews, which + // is a lesser outcome, not a broken one. + if (sheet) { + this._sheet = sheet + // Read defensively: the property is absent in some DOM implementations, and + // spreading undefined throws where appending a sheet is otherwise harmless. + const adopted = this._root.adoptedStyleSheets + this._root.adoptedStyleSheets = adopted ? [...adopted, sheet] : [sheet] + } + if (this._status === 'ready') this._render(getContractState().state) + } + + /** + * @override + * @param {import('./contract.js').Selection} state + * @returns {void} + */ + _render(state) { + const group = this._group + if (!group) return + group.replaceChildren() + + if (this._status === 'error') { + const note = document.createElement('button') + note.type = 'button' + note.className = 'spinner' + note.textContent = 'No themes' + note.disabled = true + group.append(note) + return + } + + if (this._status !== 'ready') { + const note = document.createElement('button') + note.type = 'button' + note.className = 'spinner' + note.textContent = 'Loading' + note.disabled = true + group.append(note) + return + } + + if (!this.hasAttribute('compact')) { + group.append(this._button(null, 'None', state.theme === null)) + } + + for (const theme of this._themes) { + group.append(this._button(theme, theme.name, state.theme === theme.slug)) + } + } + + /** + * @param {{slug: string, name: string, defaultMode: string}|null} theme + * @param {string} label + * @param {boolean} selected + * @returns {HTMLButtonElement} + */ + _button(theme, label, selected) { + const button = document.createElement('button') + button.type = 'button' + button.setAttribute('part', 'button') + button.setAttribute('role', 'radio') + button.setAttribute('aria-checked', selected ? 'true' : 'false') + button.title = theme ? `${label} (${theme.defaultMode})` : 'Use no theme' + if (selected) button.setAttribute('aria-current', 'true') + + button.addEventListener('click', () => { + const slug = theme ? theme.slug : null + getContractState().selectTheme(slug, theme ? theme.defaultMode : null) + this._emit('bananas:theme', { slug, mode: theme ? theme.defaultMode : ADMIN_MODE }) + }) + + // A preview needs the contract to mean anything, and the "None" entry has no + // palette to preview, so only real palettes get one. + if (theme && this._sheet && !this.hasAttribute('no-preview')) { + button.prepend(previewFor(theme)) + } + button.append(document.createTextNode(label)) + return button + } +} + +/** + * `` — picks between admin chrome, light and dark. + * + * Three states, not two, because "light or dark" is the wrong question for an + * administration screen: it has to be able to fall back to the application's own + * chrome while a palette is being previewed. + * + * Attributes: + * label accessible label; defaults to "Appearance" + * show-labels render the text labels rather than icons only + * + * Events: `bananas:appearance` with `detail: { mode }`. + */ +export class BananasAppearanceToggle extends BananasControl { + /** @override */ + connectedCallback() { + this._root.append((this._group = document.createElement('div'))) + this._group.className = 'group' + this._group.setAttribute('part', 'group') + this._group.setAttribute('role', 'radiogroup') + this._group.setAttribute('aria-label', this._attr('label', 'Appearance')) + super.connectedCallback() + } + + /** @returns {'admin'|'light'|'dark'} */ + get value() { + return getContractState().state.mode + } + + set value(mode) { + if (MODES.includes(mode)) getContractState().selectMode(mode) + } + + /** + * @override + * @param {import('./contract.js').Selection} state + * @returns {void} + */ + _render(state) { + const group = this._group + if (!group) return + group.replaceChildren() + + const withLabels = this.hasAttribute('show-labels') + /** @type {Array<{mode: 'admin'|'light'|'dark', icon: string, label: string}>} */ + const entries = [ + { mode: ADMIN_MODE, icon: '◧', label: 'Admin' }, + { mode: 'light', icon: '☀', label: 'Light' }, + { mode: 'dark', icon: '☾', label: 'Dark' }, + ] + + for (const entry of entries) { + const button = document.createElement('button') + button.type = 'button' + button.setAttribute('part', 'button') + button.setAttribute('role', 'radio') + // The label is always present for assistive technology; only the visible form + // changes. An icon-only button with no accessible name is unusable with a screen + // reader, which is why the text is not dropped but hidden. + button.setAttribute('aria-label', entry.label) + button.setAttribute('aria-checked', state.mode === entry.mode ? 'true' : 'false') + button.title = entry.label + + const icon = document.createElement('span') + icon.setAttribute('aria-hidden', 'true') + icon.textContent = entry.icon + button.append(icon) + + if (withLabels) { + const text = document.createElement('span') + text.textContent = ` ${entry.label}` + button.append(text) + } + + button.addEventListener('click', () => { + getContractState().selectMode(entry.mode) + this._emit('bananas:appearance', { mode: entry.mode }) + }) + + group.append(button) + } + } +} + +/** + * Defines both elements. Safe to call more than once. + * + * The registration is guarded on the constructor's own name rather than + * `customElements.get`, because two copies of this module in one page (two bundles, + * or an import map plus a direct import) is a realistic scenario and the second + * registration would otherwise throw and take the page down with it. + * + * @returns {void} + */ +export function defineComponents() { + if (typeof customElements === 'undefined') return + for (const [name, ctor] of /** @type {const} */ ([ + ['bananas-theme-selector', BananasThemeSelector], + ['bananas-appearance-toggle', BananasAppearanceToggle], + ])) { + if (!customElements.get(name)) customElements.define(name, ctor) + } +} + +if (typeof customElements !== 'undefined') defineComponents() + +// Expose getContractState globally so consumers loading via script tag can access it. +// This is needed because dynamic import() may not find the module loaded via script tag. +if (typeof window !== 'undefined') { + window.__BANANAS_GET_CONTRACT_STATE__ = getContractState +} + +// The contract API is re-exported so that loading this one module is enough. A +// consumer that wants to build its own control, or to read the current selection, has +// no reason to know that the store lives in a different file — and requiring a second +// import is exactly the kind of friction that gets a consumer writing their own +// storage code instead. +export { + ADMIN_MODE, + ContractState, + DEFAULT_STORAGE_KEY, + LEGACY_MODE_ATTRIBUTE, + LEGACY_THEME_ATTRIBUTE, + MODE_ATTRIBUTE, + THEME_ATTRIBUTE, + getContractState, + readMode, + resetContractState, +} \ No newline at end of file diff --git a/sdk/src/components.test.js b/sdk/src/components.test.js new file mode 100644 index 0000000..144ae0b --- /dev/null +++ b/sdk/src/components.test.js @@ -0,0 +1,675 @@ +/** + * Tests for the contract store and the two controls. + * + * The store is tested directly because both controls are thin views over it. The + * controls are tested for the two properties that matter and are easy to lose: the + * attributes land on , and the shadow root actually isolates style in both + * directions. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + ADMIN_MODE, + ContractState, + DEFAULT_STORAGE_KEY, + LEGACY_MODE_ATTRIBUTE, + LEGACY_THEME_ATTRIBUTE, + MODE_ATTRIBUTE, + THEME_ATTRIBUTE, + getContractState, + resetContractState, +} from './contract.js' +import { BananasAppearanceToggle, BananasThemeSelector, defineComponents } from './components.js' + +/** Clears the document and storage between tests. */ +function reset() { + resetContractState() + localStorage.clear() + for (const attr of [THEME_ATTRIBUTE, MODE_ATTRIBUTE, LEGACY_THEME_ATTRIBUTE, LEGACY_MODE_ATTRIBUTE]) { + document.documentElement.removeAttribute(attr) + } + document.documentElement.classList.remove('dark') + document.documentElement.removeAttribute('style') +} + +/** + * The shadow root of a custom element, typed. + * + * `shadowRoot` is `ShadowRoot | null` in the DOM types even though these elements + * always open one, so the cast lives in one place instead of at twenty call sites. + * + * @param {Element} el + * @returns {ShadowRoot} + */ +function shadowOf(el) { + const root = /** @type {ShadowRoot | null} */ (el.shadowRoot) + if (!root) throw new Error('element has no shadow root') + return root +} + +/** Builds a state wired to the live document. */ +function liveState() { + return new ContractState({ storageKey: 'test:contract' }) +} + +/** + * Fetches a contract payload for the selector. + * + * @param {Array<{slug: string, name: string, defaultMode: string}>} themes + * @returns {void} + */ +function stubContract(themes) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ themes }), + })), + ) +} + +/** + * A contract stylesheet with the rules a preview needs. + * + * Bare attribute selectors, because that is what the control adopts: a document + * stylesheet reaches the shadow root only through an adopted sheet, where `:root` + * matches nothing. + */ +const CONTRACT_CSS = ` + [data-theme="midnight"] { --bn-bg: #101014; --bn-surface: #1a1a20; --bn-border: #2e2e38; --bn-text: #cfd0e0; --bn-accent: #6f7dff; } + [data-theme="midnight"][data-mode="dark"] { --bn-bg: #0b0b0e; --bn-accent: #8a94ff; } + [data-theme="daylight"] { --bn-bg: #fdfdfd; --bn-surface: #f2f2f4; --bn-border: #d8d8de; --bn-text: #17171a; --bn-accent: #0a5ad6; } + [data-theme="daylight"][data-mode="light"] { --bn-bg: #ffffff; --bn-accent: #0a5ad6; } +` + +/** + * Serves both halves of the runtime the selector reads: the palette list and the + * contract it previews from. + * + * Each call gets its own base URL. The component caches a parsed contract per URL for + * the life of the page, which is right in a browser and wrong in a test file: without + * a fresh URL every test would inherit whichever contract the first one parsed, and a + * test stubbing a 404 would still get the sheet an earlier test warmed. + * + * @param {Array<{slug: string, name: string, defaultMode: string}>} themes + * @param {{css?: string, failCss?: boolean}} [options] + * @returns {{base: string, calls: () => Array}} The base to mount on, and + * the recorded requests. + */ +function stubRuntime(themes, options = {}) { + const css = options.css === undefined ? CONTRACT_CSS : options.css + /** @type {Array} */ + const calls = [] + const base = `/rt/v1-test-${++runtimeSeq}` + vi.stubGlobal( + 'fetch', + vi.fn(async (/** @type {string} */ url) => { + calls.push([url]) + if (url.endsWith('contract.css')) { + if (options.failCss) return { ok: false, status: 404, text: async () => '' } + return { ok: true, status: 200, text: async () => css } + } + return { ok: true, status: 200, json: async () => ({ themes }) } + }), + ) + return { base, calls: () => calls } +} + +/** Counts bases so no two tests share a contract cache entry. */ +let runtimeSeq = 0 + +/** + * Mounts a selector and waits for it to settle. + * + * The control fires two unawaited promises -- the palette list and the contract -- and + * which one finishes last decides whether previews are on the first paint. Waiting for + * both is the only way to assert the finished state rather than a race. + * + * @param {Record} [attributes] + * @returns {Promise} + */ +async function mountSelector(attributes = {}) { + const el = /** @type {BananasThemeSelector} */ (document.createElement('bananas-theme-selector')) + for (const [name, value] of Object.entries(attributes)) el.setAttribute(name, value) + document.body.append(el) + await vi.waitFor(() => { + expect(shadowOf(el).querySelectorAll('button').length).toBeGreaterThan(0) + }) + return el +} + +describe('ContractState', () => { + beforeEach(reset) + + it('writes the palette to both the short and the prefixed attribute', () => { + const state = liveState() + state.selectTheme('midnight') + + // Both, because which convention a host's CSS reads is not knowable from here. + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('midnight') + expect(document.documentElement.getAttribute(LEGACY_THEME_ATTRIBUTE)).toBe('midnight') + }) + + it('writes the appearance to both attributes and sets the dark class', () => { + const state = liveState() + state.selectMode('dark') + + expect(document.documentElement.getAttribute(MODE_ATTRIBUTE)).toBe('dark') + expect(document.documentElement.getAttribute(LEGACY_MODE_ATTRIBUTE)).toBe('dark') + expect(document.documentElement.classList.contains('dark')).toBe(true) + // colorScheme is what makes native form controls and scrollbars follow. + expect(document.documentElement.style.colorScheme).toBe('dark') + }) + + it('removes both appearance attributes for admin chrome', () => { + const state = liveState() + state.selectMode('dark') + state.selectMode(ADMIN_MODE) + + expect(document.documentElement.hasAttribute(MODE_ATTRIBUTE)).toBe(false) + expect(document.documentElement.hasAttribute(LEGACY_MODE_ATTRIBUTE)).toBe(false) + expect(document.documentElement.classList.contains('dark')).toBe(false) + }) + + it('restores the theme default appearance when a palette is chosen', () => { + const state = liveState() + state.selectMode('light') + state.selectTheme('midnight', 'dark') + + expect(state.state.theme).toBe('midnight') + expect(state.state.mode).toBe('dark') + }) + + it('drops the palette and returns to admin chrome when there is none', () => { + const state = liveState() + state.selectTheme('midnight', 'dark') + state.selectTheme(null) + + expect(state.state.theme).toBeNull() + expect(state.state.mode).toBe(ADMIN_MODE) + expect(document.documentElement.hasAttribute(THEME_ATTRIBUTE)).toBe(false) + }) + + it('persists and restores across instances', () => { + liveState().selectTheme('midnight', 'dark') + + // A second store over the same key is what a page reload produces. + const restored = new ContractState({ storageKey: 'test:contract' }) + expect(restored.state.theme).toBe('midnight') + expect(restored.state.mode).toBe('dark') + }) + + it('ignores a corrupt stored value instead of failing', () => { + localStorage.setItem('test:contract', '{not json') + expect(() => new ContractState({ storageKey: 'test:contract' })).not.toThrow() + }) + + it('survives localStorage throwing, as it does in private browsing', () => { + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError') + }) + const state = liveState() + + expect(() => state.selectTheme('midnight')).not.toThrow() + // The selection still applies to this page view even though it cannot persist. + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('midnight') + + setItem.mockRestore() + }) + + it('notifies subscribers and reports who wrote', () => { + const state = liveState() + /** @type {import('./contract.js').Selection[]} */ + const seen = [] + state.subscribe((s) => seen.push(s)) + + state.selectMode('dark') + + // Once on subscribe, once on the change. + expect(seen).toHaveLength(2) + expect(seen[1]?.mode).toBe('dark') + expect(seen[1]?.source).toBe('toggle') + }) + + it('hands out copies so a subscriber cannot mutate the store', () => { + const state = liveState() + /** @type {import('./contract.js').Selection|undefined} */ + let captured + state.subscribe((s) => { + captured = s + }) + if (!captured) throw new Error('subscribe did not deliver a value') + captured.theme = 'tampered' + + expect(state.state.theme).not.toBe('tampered') + }) + + it('does not apply anything when there is no document', () => { + // A build step or a test environment can import this without a DOM. + const state = new ContractState({ storageKey: 'test:contract', root: null }) + expect(() => state.selectTheme('midnight')).not.toThrow() + }) + + it('shares one store between callers', () => { + const a = getContractState({ storageKey: DEFAULT_STORAGE_KEY }) + const b = getContractState() + expect(a).toBe(b) + }) +}) + +describe('components', () => { + beforeEach(() => { + reset() + defineComponents() + }) + + it('registers both elements without throwing on a second call', () => { + expect(() => defineComponents()).not.toThrow() + expect(customElements.get('bananas-theme-selector')).toBe(BananasThemeSelector) + expect(customElements.get('bananas-appearance-toggle')).toBe(BananasAppearanceToggle) + }) + + it('renders the appearance toggle with three states', () => { + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + + const buttons = shadowOf(el).querySelectorAll('button') + expect(buttons).toHaveLength(3) + // Each is named for assistive technology even though only the icon is visible. + expect([...buttons].map((b) => b.getAttribute('aria-label'))).toEqual(['Admin', 'Light', 'Dark']) + }) + + it('marks the current appearance as checked', () => { + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + getContractState().selectMode('dark') + + const checked = [...shadowOf(el).querySelectorAll('button')].find( + (b) => b.getAttribute('aria-checked') === 'true', + ) + expect(checked?.getAttribute('aria-label')).toBe('Dark') + }) + + it('changes the appearance when a button is pressed', () => { + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + + const dark = [...shadowOf(el).querySelectorAll('button')].find( + (b) => b.getAttribute('aria-label') === 'Dark', + ) + dark?.click() + + expect(getContractState().state.mode).toBe('dark') + expect(document.documentElement.getAttribute(MODE_ATTRIBUTE)).toBe('dark') + }) + + it('emits an appearance event that crosses the shadow boundary', () => { + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + + /** @type {Array<{mode: string}>} */ + const seen = [] + // Listening on document, not on the element: the event has to be composed for a + // host framework to see it without a shadow-root reference. + document.addEventListener('bananas:appearance', (event) => { + seen.push(/** @type {CustomEvent<{mode: string}>} */ (event).detail) + }) + + const light = [...shadowOf(el).querySelectorAll('button')].find( + (b) => b.getAttribute('aria-label') === 'Light', + ) + light?.click() + + expect(seen).toEqual([{ mode: 'light' }]) + }) + + it('loads palettes from the service and renders one button each', async () => { + stubContract([ + { slug: 'midnight', name: 'Midnight', defaultMode: 'dark' }, + { slug: 'daylight', name: 'Daylight', defaultMode: 'light' }, + ]) + const el = document.createElement('bananas-theme-selector') + document.body.append(el) + + await vi.waitFor(() => { + expect(shadowOf(el).querySelectorAll('button').length).toBe(3) + }) + const labels = [...shadowOf(el).querySelectorAll('button')].map((b) => b.textContent) + expect(labels).toContain('Midnight') + expect(labels).toContain('None') + }) + + it('surfaces a service failure without throwing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) })), + ) + const el = document.createElement('bananas-theme-selector') + document.body.append(el) + + await vi.waitFor(() => { + const button = shadowOf(el).querySelector('button') + expect(button?.textContent).toBe('No themes') + }) + }) + + it('keeps its styles out of the page and the page out of it', () => { + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + + // The shadow root exists, which is the mechanism: page CSS cannot select into it + // and this stylesheet has no way to reach a page rule. + const shadow = shadowOf(el) + expect(shadow.querySelector('style')).toBeTruthy() + + // Nothing was written to the document head, so nothing can leak out. + const leaked = [...document.head.querySelectorAll('style')].filter((s) => + (s.textContent || '').includes('--bn-control-radius'), + ) + expect(leaked).toHaveLength(0) + }) + + it('inherits the contract custom properties through the shadow boundary', () => { + // This is what makes a control follow the palette it is switching: custom + // properties cross the shadow boundary, so --bn-text set on reaches in. + document.documentElement.style.setProperty('--bn-text', 'rgb(1, 2, 3)') + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + + const sheet = shadowOf(el).querySelector('style') + expect(sheet?.textContent).toContain('var(--bn-text') + document.documentElement.style.removeProperty('--bn-text') + }) + + it('removes its subscription when disconnected', () => { + const el = document.createElement('bananas-appearance-toggle') + document.body.append(el) + el.remove() + + // A store change after removal must not reach a detached element. + expect(() => getContractState().selectMode('dark')).not.toThrow() + }) + + it('reflects the value property both ways', () => { + const selector = /** @type {BananasThemeSelector} */ ( + document.createElement('bananas-theme-selector') + ) + const toggle = /** @type {BananasAppearanceToggle} */ ( + document.createElement('bananas-appearance-toggle') + ) + document.body.append(selector, toggle) + + selector.value = 'midnight' + expect(getContractState().state.theme).toBe('midnight') + + toggle.value = 'light' + expect(getContractState().state.mode).toBe('light') + }) +}) + +describe('BananasThemeSelector previews', () => { + const themes = [ + { slug: 'midnight', name: 'Midnight', defaultMode: 'dark' }, + { slug: 'daylight', name: 'Daylight', defaultMode: 'light' }, + ] + + beforeEach(reset) + + it('gives every palette a miniature that carries the palette it previews', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + await vi.waitFor(() => { + expect(shadowOf(el).querySelectorAll('.preview').length).toBe(2) + }) + const previews = [...shadowOf(el).querySelectorAll('.preview')] + // The palette is on the element as an attribute, so the contract styles the + // miniature rather than the component hardcoding a colour per theme. + expect(previews.map((p) => p.getAttribute(THEME_ATTRIBUTE))).toEqual(['midnight', 'daylight']) + // Each palette previews in its own default appearance, because the default is the + // appearance the theme is actually chosen for. + expect(previews.map((p) => p.getAttribute(MODE_ATTRIBUTE))).toEqual(['dark', 'light']) + }) + + it('builds the miniature out of the contract tokens rather than fixed colours', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + const preview = await vi.waitFor(() => { + const found = shadowOf(el).querySelector('.preview') + expect(found).toBeTruthy() + return /** @type {Element} */ (found) + }) + // A title bar, a text rule and an accent control: enough of the application for + // surface, border, text and accent to all be visible at once. + expect(preview.querySelectorAll('.preview-bar')).toHaveLength(1) + expect(preview.querySelectorAll('.preview-rule')).toHaveLength(1) + expect(preview.querySelectorAll('.preview-accent')).toHaveLength(1) + + // Every colour is a contract token with a fallback, so no palette value is baked + // into the component and none can fall out of step with the server. + const css = shadowOf(el).querySelector('style')?.textContent || '' + for (const token of ['--bn-bg', '--bn-surface', '--bn-border', '--bn-text', '--bn-accent']) { + expect(css).toContain(`var(${token},`) + } + }) + + it('hides the miniature from assistive technology', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + await vi.waitFor(() => { + expect(shadowOf(el).querySelectorAll('.preview').length).toBe(2) + }) + // The palette name is the button's text, so announcing the picture too would read + // the same theme twice. + for (const preview of shadowOf(el).querySelectorAll('.preview')) { + expect(preview.getAttribute('aria-hidden')).toBe('true') + } + }) + + it('leaves the name readable and the miniature beside it', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + const button = await vi.waitFor(() => { + const found = [...shadowOf(el).querySelectorAll('button')].find( + (b) => b.textContent === 'Midnight' && b.querySelector('.preview'), + ) + expect(found).toBeTruthy() + return /** @type {Element} */ (found) + }) + // textContent ignores the picture, so the accessible name is still the palette. + expect(button.textContent).toBe('Midnight') + expect(button.querySelector('.preview')).toBeTruthy() + // The miniature comes first, which is the order the two read in visually. + expect(button.firstElementChild?.className).toBe('preview') + }) + + it('adopts the contract into the shadow root', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + await vi.waitFor(() => { + expect(shadowOf(el).adoptedStyleSheets.length).toBe(1) + }) + // The reason the control fetches the contract at all: a stylesheet in the document + // does not select into a shadow root, so linking it would leave every preview + // rendering from its fallbacks. + const sheet = shadowOf(el).adoptedStyleSheets[0] + if (!sheet) throw new Error('no stylesheet was adopted') + expect(sheet.cssRules.length).toBeGreaterThan(0) + // The contract's own selectors are what style the miniature, so the adopted sheet + // has to carry them rather than just the component's styles. + const selectors = [...sheet.cssRules] + .filter((rule) => rule instanceof CSSStyleRule) + .map((rule) => rule.selectorText) + .join('\n') + expect(selectors).toContain('[data-theme="midnight"]') + }) + + it('fetches the contract once for every control on the page', async () => { + const rt = stubRuntime(themes) + await mountSelector({ base: rt.base }) + await mountSelector({ base: rt.base }) + + const cssCalls = rt.calls().filter((call) => String(call[0]).endsWith('contract.css')) + // Constructed stylesheets are shareable, so a second control costs no second + // request. + expect(cssCalls).toHaveLength(1) + }) + + it('lists the palettes without a miniature when the contract is unavailable', async () => { + const rt = stubRuntime(themes, { failCss: true }) + const el = await mountSelector({ base: rt.base }) + + await vi.waitFor(() => { + expect(shadowOf(el).querySelectorAll('button').length).toBe(3) + }) + // Let the failed contract request settle, so the assertions below are about the + // outcome of the failure rather than about not having got there yet. + await vi.waitFor(() => { + expect(rt.calls().filter((call) => String(call[0]).endsWith('contract.css')).length).toBe(1) + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + // Degrading to names, not to a broken control: picking a theme still has to work. + expect(shadowOf(el).querySelectorAll('.preview')).toHaveLength(0) + // Read tolerantly: the property is absent in some DOM implementations, and the + // claim under test is that nothing was adopted, not that an empty list came back. + expect(shadowOf(el).adoptedStyleSheets?.length ?? 0).toBe(0) + const labels = [...shadowOf(el).querySelectorAll('button')].map((b) => b.textContent) + expect(labels).toContain('Midnight') + expect(labels).toContain('Daylight') + }) + + it('does not preview the None entry, which has no palette', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + await vi.waitFor(() => { + expect(shadowOf(el).querySelectorAll('.preview').length).toBe(2) + }) + const none = [...shadowOf(el).querySelectorAll('button')].find((b) => b.textContent === 'None') + expect(none?.querySelector('.preview')).toBeNull() + }) + + it('omits every miniature when asked', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base, 'no-preview': '' }) + + // Wait for the contract to land before asserting that no miniature was drawn. + // Without that, "no miniature" would also hold simply because none had been drawn + // yet, and the assertion would prove nothing about the attribute. + await vi.waitFor(() => { + expect(shadowOf(el).adoptedStyleSheets.length).toBe(1) + }) + // The escape hatch for a toolbar too narrow to hold them. + expect(shadowOf(el).querySelectorAll('.preview')).toHaveLength(0) + }) + + it('keeps the miniature in step with the selection', async () => { + const rt = stubRuntime(themes) + const el = await mountSelector({ base: rt.base }) + + /** @returns {Promise} */ + const midnightButton = () => + vi.waitFor(() => { + const found = [...shadowOf(el).querySelectorAll('button')].find( + (b) => b.textContent === 'Midnight', + ) + if (!(found instanceof HTMLButtonElement)) throw new Error('no Midnight button') + return found + }) + + expect((await midnightButton()).getAttribute('aria-checked')).toBe('false') + ;(await midnightButton()).click() + + // The group is rebuilt on every state change, so the button that was clicked is + // replaced; the check has to be made against the one now on screen. + await vi.waitFor(async () => { + expect((await midnightButton()).getAttribute('aria-checked')).toBe('true') + }) + // The previews survive the re-render, so the palette that was previewed is still + // the one on screen after selecting it. + expect(shadowOf(el).querySelectorAll('.preview').length).toBe(2) + }) +}) + +describe('base discovery', () => { + beforeEach(() => { + reset() + defineComponents() + }) + + afterEach(() => { + for (const link of document.querySelectorAll('link')) link.remove() + }) + + it('finds the runtime base from the contract stylesheet', () => { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.setAttribute('data-theme-contract', '') + link.href = 'https://themes.example.com/rt/v1/contract.css' + document.head.append(link) + + stubContract([{ slug: 'midnight', name: 'Midnight', defaultMode: 'dark' }]) + const el = document.createElement('bananas-theme-selector') + document.body.append(el) + + return vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + 'https://themes.example.com/rt/v1/contract.json', + expect.anything(), + ) + }) + }) + + it('accepts a link without the marker when the href is recognisable', () => { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = 'https://cdn.example.com/rt/v1/contract.css' + document.head.append(link) + + stubContract([]) + const el = document.createElement('bananas-theme-selector') + document.body.append(el) + + return vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + 'https://cdn.example.com/rt/v1/contract.json', + expect.anything(), + ) + }) + }) + + it('lets an explicit base attribute win over the stylesheet', () => { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.setAttribute('data-theme-contract', '') + link.href = 'https://from-link.example.com/rt/v1/contract.css' + document.head.append(link) + + stubContract([]) + const el = document.createElement('bananas-theme-selector') + el.setAttribute('base', 'https://explicit.example.com/rt/v1') + document.body.append(el) + + return vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + 'https://explicit.example.com/rt/v1/contract.json', + expect.anything(), + ) + }) + }) + + it('falls back to the same-origin default with no link', () => { + stubContract([]) + const el = document.createElement('bananas-theme-selector') + document.body.append(el) + + return vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith('/rt/v1/contract.json', expect.anything()) + }) + }) +}) diff --git a/sdk/src/contract.js b/sdk/src/contract.js new file mode 100644 index 0000000..d0df723 --- /dev/null +++ b/sdk/src/contract.js @@ -0,0 +1,275 @@ +/** + * The contract state shared by the shipped components. + * + * This module is the single place that knows how a palette and an appearance turn + * into attributes on the document element. Both components read and write it, and a + * host application can use it directly to build its own controls. + * + * The attributes are configurable rather than hard-coded, because a host page may + * already have committed to a different pair — two generations of applications do use + * different ones — and because a component that cannot be told where to write cannot + * be adopted by either. + * + * @module go-bananas/contract + */ + +/** The attribute a palette is selected with. */ +export const THEME_ATTRIBUTE = 'data-theme' + +/** The attribute an appearance is selected with. */ +export const MODE_ATTRIBUTE = 'data-mode' + +/** The prefixed attributes this service emitted before the contract existed. */ +export const LEGACY_THEME_ATTRIBUTE = 'data-bananas-theme' +export const LEGACY_MODE_ATTRIBUTE = 'data-bananas-appearance' + +/** + * The appearance that means "no theme": the application's own chrome. + * + * It is a third state rather than a fourth mode, because it is a different question + * from light or dark. An administration screen has to look like the administration + * screen even while a palette is being previewed. + */ +export const ADMIN_MODE = 'admin' + +/** Every appearance value the shipped components use. */ +export const MODES = /** @type {const} */ ([ADMIN_MODE, 'light', 'dark']) + +/** The default storage key. Namespaced by origin so two apps on one host do not collide. */ +export const DEFAULT_STORAGE_KEY = 'go-bananas:appearance' + +/** + * @typedef {object} Selection + * @property {string|null} theme The selected palette slug, or null for none. + * @property {'admin'|'light'|'dark'} mode The selected appearance. + * @property {boolean} system Follow the operating system preference for mode. + * @property {string|null} source The component that last wrote, for change events. + */ + +/** + * A tiny observable holding the current selection. + * + * The alternative is a custom event on `document` plus a global variable, which is + * what a single component needs. With two components that have to agree, a shared + * store with subscribers is less code and fewer edge cases. + */ +export class ContractState { + /** @type {Selection} */ + #state + + /** @type {Set<(state: Selection) => void>} */ + #subscribers = new Set() + + /** @type {string} */ + #storageKey + + /** + * @param {object} [options] + * @param {string} [options.storageKey] + * @param {Element|null} [options.root] The element carrying the attributes. + * Defaults to the document element, so a caller with no DOM can pass null. + */ + constructor(options = {}) { + this.#storageKey = options.storageKey || DEFAULT_STORAGE_KEY + const root = + options.root !== undefined + ? options.root + : typeof document === 'undefined' + ? null + : document.documentElement + + this.#state = { + theme: root?.getAttribute(THEME_ATTRIBUTE) || root?.getAttribute(LEGACY_THEME_ATTRIBUTE) || null, + mode: readMode(root), + system: true, + source: null, + } + this.#readStored() + + // Normalised on construction rather than on the first change. A host page that + // sets only `data-theme` would otherwise keep the two conventions out of step + // until the user touched a control, and a consumer whose CSS reads the prefixed + // attribute would render unthemed in the meantime. Writing both on load is one + // attribute write and removes the whole class of "why is nothing themed". + this.#applyToDocument() + } + + /** @returns {Selection} A copy, so a subscriber cannot mutate the store. */ + get state() { + return { ...this.#state } + } + + /** @returns {string} */ + get storageKey() { + return this.#storageKey + } + + /** + * Applies a partial change, writes it to the document, persists it, and notifies. + * + * @param {Partial} patch + * @param {string|null} [source] + * @returns {Selection} + */ + set(patch, source = null) { + const next = { ...this.#state, ...patch, source } + this.#state = next + this.#applyToDocument() + this.#persist() + for (const fn of this.#subscribers) fn(this.state) + return this.state + } + + /** + * @param {(state: Selection) => void} fn + * @returns {() => void} An unsubscribe function. + */ + subscribe(fn) { + this.#subscribers.add(fn) + fn(this.state) + return () => this.#subscribers.delete(fn) + } + + /** + * Selects a palette. + * + * Selecting the palette a theme's own default came from also restores that default + * appearance, because "I want this theme" and "as its designer intended" are the same + * request far more often than not. + * + * @param {string|null} slug + * @param {string|null} [defaultMode] + * @returns {Selection} + */ + selectTheme(slug, defaultMode = null) { + if (!slug) return this.set({ theme: null, mode: ADMIN_MODE }, 'selector') + return this.set( + defaultMode ? { theme: slug, mode: /** @type {'light'|'dark'} */ (defaultMode) } : { theme: slug }, + 'selector', + ) + } + + /** + * @param {'admin'|'light'|'dark'} mode + * @returns {Selection} + */ + selectMode(mode) { + return this.set({ mode, system: false }, 'toggle') + } + + /** + * Follows the operating system appearance, ignoring any explicit choice. + * + * @returns {Selection} + */ + followSystem() { + return this.set({ system: true }, 'toggle') + } + + /** @returns {void} */ + #applyToDocument() { + if (typeof document === 'undefined') return + const root = document.documentElement + + for (const attr of [THEME_ATTRIBUTE, LEGACY_THEME_ATTRIBUTE]) { + if (this.#state.theme) root.setAttribute(attr, this.#state.theme) + else root.removeAttribute(attr) + } + + // `admin` means no appearance, so both attributes come off. Anything else sets + // both conventions, because which one a consumer's CSS reads is not knowable from + // here and setting both is free. + const mode = this.#state.mode + if (mode === ADMIN_MODE || !mode) { + for (const attr of [MODE_ATTRIBUTE, LEGACY_MODE_ATTRIBUTE]) root.removeAttribute(attr) + } else { + for (const attr of [MODE_ATTRIBUTE, LEGACY_MODE_ATTRIBUTE]) root.setAttribute(attr, mode) + } + + root.classList.toggle('dark', mode === 'dark') + root.style.colorScheme = mode === 'dark' ? 'dark' : 'light' + } + + /** @returns {void} */ + #readStored() { + if (typeof localStorage === 'undefined') return + try { + const raw = localStorage.getItem(this.#storageKey) + if (!raw) return + // Landed in `unknown` first: JSON.parse returns `any`, and assigning an `any` + // into a typed position is the hole no-unsafe-assignment exists to close. + /** @type {unknown} */ + const body = JSON.parse(raw) + const parsed = /** @type {{theme?: unknown, mode?: unknown, system?: unknown}} */ (body) + if (typeof parsed !== 'object' || parsed === null) return + + /** @type {Partial} */ + const patch = {} + if (typeof parsed.theme === 'string' || parsed.theme === null) patch.theme = parsed.theme + if (typeof parsed.mode === 'string' && MODES.includes(/** @type {'admin'} */ (parsed.mode))) { + patch.mode = /** @type {'admin'|'light'|'dark'} */ (parsed.mode) + } + if (typeof parsed.system === 'boolean') patch.system = parsed.system + this.#state = { ...this.#state, ...patch } + this.#applyToDocument() + } catch { + // A corrupt or unreadable value is not worth failing a page load over. The + // attributes already on the element are the fallback. + } + } + + /** @returns {void} */ + #persist() { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem( + this.#storageKey, + JSON.stringify({ + theme: this.#state.theme, + mode: this.#state.mode, + system: this.#state.system, + }), + ) + } catch { + // Private browsing and a full quota both raise here. The selection still works + // for this page view, which is the important part. + } + } +} + +/** + * Reads the appearance already on an element, tolerating either attribute or neither. + * + * @param {Element|null|undefined} el + * @returns {'admin'|'light'|'dark'} + */ +export function readMode(el) { + if (!el) return ADMIN_MODE + const value = el.getAttribute(MODE_ATTRIBUTE) || el.getAttribute(LEGACY_MODE_ATTRIBUTE) + if (value === 'light' || value === 'dark') return value + if (el.classList.contains('dark')) return 'dark' + return ADMIN_MODE +} + +/** @type {ContractState|null} */ +let shared = null + +/** + * The process-wide store, created on first use. + * + * Both components default to this so that a page with two of them agrees without the + * host having to wire them together. + * + * @param {object} [options] + * @param {string} [options.storageKey] + * @returns {ContractState} + */ +export function getContractState(options = {}) { + if (!shared) shared = new ContractState(options) + return shared +} + +/** @returns {void} Resets the shared store. For tests. */ +export function resetContractState() { + shared = null +} \ No newline at end of file diff --git a/sdk/src/embed.js b/sdk/src/embed.js new file mode 100644 index 0000000..243841f --- /dev/null +++ b/sdk/src/embed.js @@ -0,0 +1,64 @@ +/** + * go-bananas one-line embed. + * + * The smallest possible integration: a single script tag that applies a theme + * before the first paint. There is no module, no import map and no configuration, + * which makes this the right snippet for a quick trial or for a page that cannot + * be modified beyond adding one tag. + * + * ```html + * + * ``` + * + * The data attributes configure it: + * + * data-theme slug to apply + * data-appearance light | dark | system + * data-variant full | tokens | minified + * data-storage "false" to skip localStorage + * data-auto-dark "true" to follow the operating system + * + * @module go-bananas/embed + */ + +import { applyTheme, watchSystemAppearance } from './runtime.js' + +/** Attribute that marks this script so a second copy is a no-op. */ +const MARKER = 'data-bananas-embedded' + +// Reading the currentScript synchronously is the only way to discover the +// element's data attributes: the script is async, so document.currentScript is +// already null by the time this module body runs. +// document.currentScript is null by the time this module body runs, because the +// script is async. The element is found by selector instead, which works whether +// the tag was loaded by the parser or injected. +const script = /** @type {HTMLScriptElement|null} */ ( + document.querySelector('script[src*="/rt/v1/embed.js"]') +) + +/** Already embedded on this page. */ +if (script?.hasAttribute(MARKER)) { + // A second copy would race the first over the same link element. +} else if (script) { + script.setAttribute(MARKER, 'true') + + const slug = script.dataset.theme || 'default' + const appearance = /** @type {'light'|'dark'|'system'} */ ( + script.dataset.appearance || 'system' + ) + const autoDark = script.dataset.autoDark === 'true' || appearance === 'system' + const variant = script.dataset.variant || 'full' + const storage = script.dataset.storage !== 'false' + + // Following the operating system has to be set up before the first apply, so + // that a user whose OS is already dark never sees a light flash. + if (autoDark) watchSystemAppearance() + + applyTheme(slug, { appearance, variant, storage }).catch((error) => { + // A theme that fails to load must never take the host page down with it. + console.warn('Go Bananas!!: could not apply theme', slug, error) + }) +} + +export { applyTheme } diff --git a/sdk/src/global.js b/sdk/src/global.js new file mode 100644 index 0000000..d0e27a0 --- /dev/null +++ b/sdk/src/global.js @@ -0,0 +1,48 @@ +/** + * go-bananas runtime — global build. + * + * The ESM build is the right choice for modern applications, but a plain + * + * + * ``` + * + * @module go-bananas/global + */ + +import * as runtime from './runtime.js' +import * as components from './components.js' + +const api = { + ...runtime, + // Registering on load means a consumer that only adds a + * ``` + * + * React itself is imported as a bare specifier so the host application's copy is + * used. Importing a second copy of React would break hooks, because each copy has + * its own dispatcher state. + * + * @module go-bananas/runtime-react + */ + +import { + createContext, + createElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' + +import { + APPEARANCE_ATTRIBUTE, + THEME_ATTRIBUTE, + applyTheme, + assetUrl, + clearTheme, + getAppearance, + getSlug, + getVariables, + loadManifest, + loadTheme, + setAppearance, + subscribe, + version, +} from './runtime.js' + +export { + APPEARANCE_ATTRIBUTE, + THEME_ATTRIBUTE, + applyTheme, + assetUrl, + clearTheme, + getAppearance, + getSlug, + getVariables, + loadManifest, + loadTheme, + setAppearance, + version, +} + +/** + * @typedef {'light'|'dark'|'system'} Appearance + */ + +/** + * @typedef {import('./runtime.js').Manifest} Manifest + * @typedef {import('./runtime.js').ResolvedTheme} ResolvedTheme + */ + +/** + * @typedef {object} BananasContextValue + * @property {string|null} slug + * @property {Appearance} appearance + * @property {'light'|'dark'} resolvedAppearance + * @property {boolean} prefersDark + * @property {ResolvedTheme|null} theme Resolved token payload, once loaded. + * @property {Manifest|null} manifest Available themes, once loaded. + * @property {'idle'|'loading'|'ready'|'error'} status + * @property {Error|null} error + * @property {(slug: string) => Promise} setTheme + * @property {(appearance: Appearance) => void} setAppearance + */ + +/** @type {import('react').Context} */ +const BananasContext = createContext(/** @type {BananasContextValue|null} */ (null)) + +BananasContext.displayName = 'Bananas' + +/** + * Subscribes to the vanilla runtime's state from React. + * + * useSyncExternalStore is used rather than a useEffect subscription so that a + * theme change made outside React — a theme switcher in another part of the + * application, or a browser back button — cannot tear the tree. + * + * @returns {{slug: string|null, appearance: Appearance, prefersDark: boolean}} + */ +function useExternalState() { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +} + +/** Reads the runtime snapshot outside of React's render cycle. */ +function getSnapshot() { + return { + slug: getSlug(), + appearance: getAppearance(), + prefersDark: + typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches, + } +} + +/** Server snapshot: a stable object so hydration does not mismatch. */ +function getServerSnapshot() { + return { slug: null, appearance: /** @type {Appearance} */ ('system'), prefersDark: false } +} + +/** + * @typedef {object} BananasProviderProps + * @property {string} [slug] Theme to apply. Controlled when supplied. + * @property {Appearance} [appearance='system'] + * @property {string} [baseUrl] Runtime base URL. + * @property {boolean} [autoDark] Follow the operating system preference. + * @property {boolean} [storage=true] Persist the user's choice. + * @property {string} [variant] Render variant. + * @property {boolean} [loadManifest] Fetch the theme list on mount. + * @property {boolean} [suspense] Suspend until the theme is ready. + * @property {(error: Error) => void} [onError] + * @property {import('react').ReactNode} children + */ + +/** + * Applies a theme and shares its state with the tree. + * + * @param {BananasProviderProps} props + * @returns {import('react').ReactElement} + */ +export function BananasProvider(props) { + const { + slug, + appearance = 'system', + baseUrl, + autoDark = false, + storage = true, + variant = 'full', + loadManifest: shouldLoadManifest = false, + onError, + children, + } = props + + const [theme, setTheme] = useState(/** @type {ResolvedTheme|null} */ (null)) + const [manifest, setManifest] = useState(/** @type {Manifest|null} */ (null)) + const [status, setStatus] = useState(/** @type {'idle'|'loading'|'ready'|'error'} */ ('idle')) + const [error, setError] = useState(/** @type {Error|null} */ (null)) + const external = useExternalState() + + // Configuration is applied once, through a module-level singleton inside the + // vanilla runtime. The effect runs on mount only; changing baseUrl later + // requires a reload, which is documented rather than silently supported. + useEffect(() => { + const controller = new AbortController() + let cancelled = false + + async function start() { + setStatus('loading') + try { + if (baseUrl) { + // The runtime reads its base from the module URL by default. An explicit + // override is published on the global, which is what `resolveBaseUrl` + // checks before falling back to the module URL. + /** @type {{ __BANANAS_BASE__?: string }} */ (globalThis).__BANANAS_BASE__ = baseUrl + } + + const target = autoDark ? 'system' : appearance + + if (slug) { + const loaded = await applyTheme(slug, { appearance: target, storage, variant }) + if (!cancelled) { + setTheme(loaded) + setStatus('ready') + } + } + + if (shouldLoadManifest) { + const loadedManifest = await loadManifest() + if (!cancelled) setManifest(loadedManifest) + } + + if (!slug && shouldLoadManifest) setStatus('ready') + } catch (caught) { + if (cancelled) return + const failure = + caught instanceof Error ? caught : new Error(String(caught)) + setError(failure) + setStatus('error') + onError?.(failure) + } + } + + void start() + return () => { + cancelled = true + controller.abort() + } + // The effect intentionally depends on nothing that can change per render. + }, []) + + const changeTheme = useCallback( + /** + * Changes the applied theme. + * + * @param {string} next + * @returns {Promise} + */ + async (next) => { + setStatus('loading') + try { + const loaded = await applyTheme(next, { appearance: external.appearance }) + setTheme(loaded) + setError(null) + setStatus('ready') + } catch (caught) { + const failure = caught instanceof Error ? caught : new Error(String(caught)) + setError(failure) + setStatus('error') + onError?.(failure) + } + }, + [external.appearance, onError], + ) + + const changeAppearance = useCallback( + /** + * Changes the colour scheme. + * + * @param {Appearance} next + * @returns {void} + */ + (next) => { + setAppearance(next) + }, + [], + ) + + const value = useMemo( + () => ({ + slug: external.slug, + appearance: external.appearance, + resolvedAppearance: + external.appearance === 'system' + ? external.prefersDark + ? 'dark' + : 'light' + : external.appearance, + prefersDark: external.prefersDark, + theme, + manifest, + status, + error, + setTheme: changeTheme, + setAppearance: changeAppearance, + }), + [external, theme, manifest, status, error, changeTheme, changeAppearance], + ) + + return createElement(BananasContext.Provider, { value }, children) +} + +/** + * Reads the theme context. + * + * @returns {BananasContextValue} + * @throws when used outside a BananasProvider, because a silent default would + * let a component render with the wrong theme and never report the mistake. + */ +export function useBananas() { + const value = useContext(BananasContext) + if (!value) { + throw new Error( + 'go-bananas: useBananas must be used inside a . ' + + 'Wrap your application in one, or import { BananasProvider } from this module.', + ) + } + return value +} + +/** + * Returns the resolved value of one design token. + * + * @param {string} name Token name, with or without the `--bn-` prefix. + * @returns {string|undefined} + */ +export function useThemeVariable(name) { + const { theme } = useBananas() + return useMemo(() => { + if (!theme) return undefined + const key = name.startsWith('--') ? name : `--bn-${name}` + return theme.variables[key] + }, [theme, name]) +} + +/** + * Returns every resolved token as a record. + * + * @returns {Record} + */ +export function useThemeVariables() { + const { theme } = useBananas() + // An empty record rather than undefined: a consumer spreading or indexing the + // result should not have to guard for the pre-load window. + return useMemo(() => theme?.variables ?? {}, [theme]) +} + +/** + * Returns the list of available themes. + * + * @returns {{themes: any[], loading: boolean, error: Error|null}} + */ +export function useAvailableThemes() { + const { manifest } = useBananas() + const [local, setLocal] = useState(/** @type {Manifest|null} */ (null)) + const [error, setError] = useState(/** @type {Error|null} */ (null)) + + useEffect(() => { + let cancelled = false + if (manifest === null) { + loadManifest() + .then((loaded) => { + if (!cancelled) setLocal(loaded) + }) + .catch((caught) => { + if (!cancelled) setError(caught instanceof Error ? caught : new Error(String(caught))) + }) + } + return () => { + cancelled = true + } + }, [manifest]) + + const source = manifest ?? local + return { + themes: source?.themes ?? [], + loading: !source && !error, + error, + } +} + +export { BananasContext } + +/* -------------------------------------------------------------------------- */ +/* Palette and appearance controls */ +/* -------------------------------------------------------------------------- */ + +/** + * The element interface the palette control presents. Declared here rather than + * cast to `any`, because a wrapper that cannot see the element's own API is how a + * ref-based binding silently stops working. + * + * @typedef {object} ThemeSelectorElement + * @property {string} value + * @property {string|null} base + * @property {(type: string, listener: (event: any) => void) => void} addEventListener + * @property {(type: string, listener: (event: any) => void) => void} removeEventListener + */ + +/** + * The element interface the appearance control presents. + * + * @typedef {object} AppearanceToggleElement + * @property {'admin'|'light'|'dark'} value + * @property {(type: string, listener: (event: any) => void) => void} addEventListener + * @property {(type: string, listener: (event: any) => void) => void} removeEventListener + */ + +/** + * @typedef {object} ThemeChangeDetail + * @property {string|null} slug + */ + +/** + * @typedef {object} AppearanceChangeDetail + * @property {'admin'|'light'|'dark'} mode + */ + +/** + * @typedef {object} ThemeSelectorProps + * @property {string} [base] Service runtime base. Defaults to the contract + * stylesheet already on the page. + * @property {string} [label] Accessible label. + * @property {boolean} [compact] Omit the "no theme" entry. + * @property {string} [value] The selected slug. Controlled when supplied. + * @property {(slug: string) => void} [onChange] + */ + +/** + * The palette control, as a React component. + * + * A thin wrapper around the custom element rather than a React reimplementation: + * the element owns its rendering, its Shadow DOM and its accessibility tree, so a + * React version would be a second implementation that drifts. + * + * It exists because using the bare element from JSX is workable but awkward. The + * element's `value` is a property rather than a React-controlled one, so a caller + * that wants to drive it from state needs a ref and an effect. This accepts ordinary + * props instead. + * + * @param {ThemeSelectorProps & Record} props + * @returns {any} + */ +export function ThemeSelector({ base, label, compact, value, onChange, ...rest }) { + const ref = useRef(/** @type {ThemeSelectorElement|null} */ (null)) + + useEffect(() => { + const el = ref.current + if (value === undefined || !el) return + if (el.value !== value) el.value = value + }, [value]) + + useEffect(() => { + const el = ref.current + if (!el || !onChange) return undefined + // The event is composed and crosses the shadow boundary, so listening on the + // element rather than on document is enough. + /** @param {Event} raw */ + const handler = (raw) => { + const event = /** @type {CustomEvent} */ (raw) + onChange(event.detail?.slug ?? '') + } + el.addEventListener('bananas:theme', handler) + return () => el.removeEventListener('bananas:theme', handler) + }, [onChange]) + + return createElement('bananas-theme-selector', { + ...rest, + ref, + base, + label, + compact: compact ? '' : undefined, + }) +} + +/** + * @typedef {object} AppearanceToggleProps + * @property {string} [label] Accessible label. + * @property {boolean} [showLabels] Render text labels rather than icons only. + * @property {'admin'|'light'|'dark'} [value] Controlled when supplied. + * @property {(mode: 'admin'|'light'|'dark') => void} [onChange] + */ + +/** + * The appearance control, as a React component. Wraps the custom element for the + * same reason as {@link ThemeSelector}. + * + * @param {AppearanceToggleProps & Record} props + * @returns {any} + */ +export function AppearanceToggle({ label, showLabels, value, onChange, ...rest }) { + const ref = useRef(/** @type {AppearanceToggleElement|null} */ (null)) + + useEffect(() => { + const el = ref.current + if (value === undefined || !el) return + if (el.value !== value) el.value = value + }, [value]) + + useEffect(() => { + const el = ref.current + if (!el || !onChange) return undefined + /** @param {Event} raw */ + const handler = (raw) => { + const event = /** @type {CustomEvent} */ (raw) + onChange(event.detail?.mode ?? 'admin') + } + el.addEventListener('bananas:appearance', handler) + return () => el.removeEventListener('bananas:appearance', handler) + }, [onChange]) + + return createElement('bananas-appearance-toggle', { + ...rest, + ref, + label, + showLabels: showLabels ? '' : undefined, + }) +} diff --git a/sdk/src/runtime.js b/sdk/src/runtime.js new file mode 100644 index 0000000..84efc9f --- /dev/null +++ b/sdk/src/runtime.js @@ -0,0 +1,627 @@ +/** + * go-bananas runtime — framework-free. + * + * Applies a theme to the page at run time. There is nothing to install: load + * this module with a URL and the theme is applied by fetching CSS from the + * service. That is the entire point of the runtime delivery model — a theme + * becomes a deployment-time concern rather than a dependency. + * + * @module go-bananas/runtime + */ + +/** + * @typedef {object} ThemeRef + * @property {string} slug + * @property {string} name + * @property {string} [description] + * @property {number} version + * @property {Appearance} appearance + * @property {Appearance} defaultAppearance + * @property {string} updatedAt + */ + +/** + * @typedef {object} ManifestTheme + * @property {string} slug + * @property {string} name + * @property {string} [description] + * @property {Appearance} defaultAppearance + * @property {number} version + * @property {string[]} [tags] + * @property {string} css + * @property {string} tokens + */ + +/** + * @typedef {object} Manifest + * @property {string} apiVersion + * @property {string} [default] + * @property {string} baseUrl + * @property {ManifestTheme[]} themes + */ + +/** + * The payload the runtime JSON endpoint returns. + * + * Typed here rather than as `any` so that a consumer importing these typedefs + * gets the real shape, and so that a change to the payload is a type error rather + * than a surprise at run time. + * + * @typedef {object} ResolvedTheme + * @property {ThemeRef} theme + * @property {Record} tokens + * @property {Record} variables + * @property {Record} [meta] + */ + +/** Attribute that scopes a theme to a subtree. */ +export const THEME_ATTRIBUTE = 'data-bananas-theme' +/** Attribute that selects the colour scheme within a themed subtree. */ +export const APPEARANCE_ATTRIBUTE = 'data-bananas-appearance' +/** Contract version this module understands. */ +export const API_VERSION = '1' + +/** Local storage key used to remember the user's appearance choice. */ +const STORAGE_KEY = 'go-bananas:appearance' +/** Local storage key used to remember the applied slug. */ +const SLUG_KEY = 'go-bananas:theme' + +/** + * @typedef {'light'|'dark'|'system'} Appearance + */ + +/** + * @typedef {object} BananasOptions + * @property {string} [baseUrl] Runtime base URL. Defaults to the origin the + * module was loaded from, which is what makes a same-origin deployment work + * with no configuration at all. + * @property {string} [slug] Theme to apply on load. + * @property {Appearance} [appearance='system'] Initial colour scheme. + * @property {Element} [target] Element to scope to. Defaults to + * `document.documentElement`. + * @property {boolean} [storage=true] Persist the appearance choice. + * @property {string} [variant] Render variant, e.g. `full` or `tokens`. + * @property {(err: Error) => void} [onError] Error callback. + */ + +/** @type {BananasOptions} */ +let options = { + appearance: 'system', + storage: true, + variant: 'full', +} + +/** + * Current state, exposed so consumers can render a theme picker. + * + * The shape is annotated explicitly because an object literal infers `null` for + * an initialised-to-null field and `string` rather than a literal union for an + * appearance, which then makes every later assignment a type error under strict + * checking. Declaring the types once here keeps the rest of the module clean. + * + * @type {{ + * slug: string|null, + * appearance: Appearance, + * cache: Map, + * manifest: Manifest | null, + * listeners: Set<(state: {slug: string|null, appearance: Appearance, dark: boolean}) => void>, + * mediaQuery: MediaQueryList|null, + * dark: boolean, + * }} + */ +const state = { + slug: null, + appearance: 'system', + cache: new Map(), + manifest: null, + listeners: new Set(), + mediaQuery: null, + dark: false, +} + +/* -------------------------------------------------------------------------- */ +/* URL construction */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns the runtime base URL, inferred from where this module was loaded. + * + * Inferring beats configuring here because the overwhelmingly common deployment + * serves the SDK and the runtime API from the same origin, so the correct answer + * is knowable without being told. + * + * @returns {string} + */ +export function resolveBaseUrl() { + if (options.baseUrl) return stripTrailingSlash(options.baseUrl) + + const configured = /** @type {{ __BANANAS_BASE__?: string }} */ (globalThis).__BANANAS_BASE__ + if (typeof configured === 'string' && configured) { + return stripTrailingSlash(configured) + } + + if (typeof document !== 'undefined') { + // currentScript is typed as an SVG-or-HTML script element; only an HTML one + // has a src, so the narrowing is explicit rather than a cast on the value. + const script = document.currentScript + if (script instanceof HTMLScriptElement && script.src) { + return new URL('.', script.src).toString() + } + } + + if (typeof location !== 'undefined') { + return `${stripTrailingSlash(location.origin)}${basePath()}` + } + throw new Error('go-bananas: cannot determine the runtime base URL; pass options.baseUrl') +} + +/** + * Reads the deployment's base path from the document, for installations served + * under a sub-path such as `/themes`. + * + * @returns {string} + */ +function basePath() { + if (typeof document !== 'undefined') { + const configured = document.documentElement.getAttribute('data-bananas-base-path') + if (configured) return configured.replace(/\/$/, '') + } + return '' +} + +/** + * Strips trailing slashes from a URL. + * + * @param {string} value + * @returns {string} + */ +function stripTrailingSlash(value) { + return value.replace(/\/+$/, '') +} + +/** + * Builds the URL of a runtime asset. + * + * @param {string} path e.g. `theme.css` + * @param {Record} [query] + * @returns {string} + */ +export function assetUrl(path, query = {}) { + const url = new URL(`${resolveBaseUrl()}/${path}`) + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue + url.searchParams.set(key, String(value)) + } + return url.toString() +} + +/* -------------------------------------------------------------------------- */ +/* Appearance */ +/* -------------------------------------------------------------------------- */ + +/** + * Reports whether the operating system currently prefers a dark colour scheme. + * + * @returns {boolean} + */ +export function prefersDark() { + // matchMedia is absent in some embedded webviews, and the theme attribute + // cannot be resolved without it. + if (typeof window === 'undefined' || !window.matchMedia) return false + return window.matchMedia('(prefers-color-scheme: dark)').matches +} + +/** + * Resolves an appearance choice against the operating system preference. + * + * @param {Appearance} appearance + * @returns {'light'|'dark'} + */ +export function resolveAppearance(appearance) { + if (appearance === 'light' || appearance === 'dark') return appearance + return prefersDark() ? 'dark' : 'light' +} + +/** + * Subscribes to operating-system appearance changes. + * + * The returned function unsubscribes. When the user has chosen `system`, a change + * in the operating system preference re-applies the theme; when they have chosen + * an explicit scheme, nothing happens, which is the behaviour a user who picked + * "light" on a dark laptop expects. + * + * @param {(dark: boolean) => void} [onChange] + * @returns {() => void} + */ +export function watchSystemAppearance(onChange) { + // There is nothing to watch without matchMedia. Returning a no-op unsubscribe + // keeps the caller's cleanup symmetric. + if (typeof window === 'undefined' || !window.matchMedia) return () => undefined + + const query = window.matchMedia('(prefers-color-scheme: dark)') + /** @param {MediaQueryListEvent} event */ + const listener = (event) => { + state.dark = event.matches + if (state.appearance === 'system') { + applyToDocument() + notify() + } + onChange?.(event.matches) + } + + // addEventListener is the modern API; addListener is kept for Safari < 14. + if (query.addEventListener) query.addEventListener('change', listener) + else query.addListener?.(listener) + + state.mediaQuery = query + state.dark = query.matches + + return () => { + if (query.removeEventListener) query.removeEventListener('change', listener) + else query.removeListener?.(listener) + state.mediaQuery = null + } +} + +/* -------------------------------------------------------------------------- */ +/* Theme application */ +/* -------------------------------------------------------------------------- */ + +/** + * Loads and applies a theme. + * + * The stylesheet is injected as a `` rather than fetched and evaluated, so + * the browser's own cache, preload scanner and CDN revalidation all apply. That + * is both faster and safer than injecting a constructed `