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

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

View file

@ -0,0 +1,176 @@
# ADR 0001: Embedded SQLite rather than a client/server database
- **Status:** accepted
- **Date:** 2026-10-04
- **Deciders:** platform team
- **Applies to:** all deployments of the current major version
## Context
go-bananas stores themes. A theme is a slug, some metadata and a JSON document of
design tokens, typically 10–60 kB. A useful instance has tens to a few thousand of
them.
The workload shape is:
- **Reads:** constant and bursty. Every page view in every consuming application
requests CSS. A service with ten consumer applications and a few hundred users
can easily see thousands of stylesheet requests a minute, and 95%+ of them are
the same handful of bytes served from a render cache.
- **Writes:** rare. A theme is edited by a human. A busy week might be fifty writes.
- **Total size:** a few megabytes. Even ten thousand large themes would fit in
fifty.
The conventional answer for a service with this shape is PostgreSQL. That answer is
right for almost every service and wrong for this one, for reasons worth writing
down rather than rediscovering in a year.
## Decision
Use SQLite through `modernc.org/sqlite`, a pure-Go translation, on a local volume.
## Rationale
### What a client/server database would add
For this dataset, Postgres buys nothing and costs:
- **A network hop per query.** A local SQLite read is microseconds; a query over a
pod network is a millisecond and a tail-latency distribution with a long right
tail. For a stylesheet endpoint on the critical path of every page view, that
tail is the difference between a fast site and a janky one.
- **A connection pool to get wrong.** Every deployment needs its own tuning, and the
failure mode of under-tuning is a saturated pool under load.
- **Credentials to create, distribute, rotate and revoke.** Another secret in
Kubernetes, another thing to get wrong.
- **A schema migration process.** For a dataset that fits in a few megabytes, this
is a disproportionate amount of ceremony.
- **A second thing to operate.** Backups, upgrades, monitoring, incident response,
for a component whose whole job is to hold some JSON.
- **A second failure mode.** The database becoming unavailable is now the most
likely way the theme service goes down.
### What SQLite gives up
Honesty requires stating the costs:
- **One writer at a time.** Correct at this write rate. A theme edit is a
millisecond-scale transaction; even a hundred concurrent editors would not
saturate it.
- **Single-node.** This is the real constraint. See Scaling below.
- **No network access.** A consuming application cannot query the database directly.
It does not need to; the runtime API is the interface.
### Why this particular configuration is fast
The default SQLite configuration is not fast. These settings are what make it fast
enough that the question does not arise:
| Setting | Effect |
| --- | --- |
| `journal_mode=WAL` | Readers never block the writer and the writer never blocks readers. This is the single most important setting. |
| `synchronous=NORMAL` | In WAL mode this is durable across an application crash and risks only the last transactions on an operating-system crash. A lost publish is re-doable; an outage is not. |
| `cache_size=-16000` | A 16 MiB page cache, which holds the entire dataset. |
| `mmap_size=268435456` | The file is read through the page cache directly, removing a copy from the read path. |
| `temp_store=MEMORY` | Sort and group-by spill stays in memory. |
| Max 8 connections | SQLite serialises writers. A large pool adds contention, not throughput. This is counter-intuitive and correct. |
| In-memory render cache | The steady-state runtime request performs **no database access at all**. |
The render cache is the decisive optimisation. Rendering a theme traverses its whole
token document; serving the cached string is one map lookup. Because the cache key
includes the theme version, publishing invalidates exactly one theme's entries and
disturbs nothing else. The database is consulted only on a cache miss, which is a
publish rather than a read.
### Why `modernc.org/sqlite` rather than `mattn/go-sqlite3`
`mattn/go-sqlite3` is a cgo binding to the C library. It is faster, and it means:
- `CGO_ENABLED=1`, so the release image needs a C toolchain at build time and libc
at run time, and cross-compilation needs a cross-C toolchain;
- a statically linked binary is impossible, so the image carries a C library whose
CVEs need tracking;
- `go install` from source fails on a machine without a compiler.
`modernc.org/sqlite` is a transpiled Go implementation: no cgo, a fully static
binary, and cross-compilation is `GOOS=linux GOARCH=arm64` and nothing else. The
performance difference is irrelevant here because the hot path is the in-memory
render cache, not SQLite.
## Alternatives considered
**PostgreSQL.** The conventional choice. Rejected on the grounds above. It remains
the right answer if the product changes shape — see Scaling.
**Redis.** Excellent for this read-heavy workload, and a good fit for a render
cache. But it is another service to operate for a cache that is already
process-local and loses nothing on restart, because the source of truth is the
database. Rejected as unnecessary operational surface. Worth revisiting if a
many-node deployment needs a shared cache; the render cache is an interface, so
that change is contained.
**bbolt.** Faster than SQLite for pure key/value workloads and simpler. Rejected
because the schema needs indexes for listing, filtering and sorting, and because
SQLite's query planner handles the "a few hundred rows filtered by status and
ordered by date" shape without the ceremony of managing bbolt buckets by hand.
**A JSON file per theme on disk.** No dependency at all. Rejected because concurrent
writes need locking, and because every listing requires reading and parsing every
file. SQLite is a better version of this idea that is already written.
**An in-memory store with write-through to object storage.** Attractive for scale.
Rejected for v1: it removes the transactional guarantee that makes version history
and rollback correct, and it adds an object-storage dependency. The write path
here is not hot enough to justify losing the invariant.
## Scaling
SQLite on a local volume is single-node. Two shapes are supported:
1. **One replica, one volume.** The recommended production shape. Horizontally
scalable only in the sense that the service is so cheap that one replica serves
a very large number of consumers.
2. **Many replicas, a shared filesystem.** Works when the storage class supports
concurrent mounts — NFS, CephFS, or a ReadWriteMany block device. WAL mode
requires the filesystem to support shared memory mapping, which these do. The
Helm chart warns when a ReadWriteOnce volume is combined with more than one
replica, because that combination cannot work.
If the workload ever outgrows both — genuinely concurrent multi-writer access across
nodes — the migration is to Postgres. It is contained: `internal/store` is the only
package that writes SQL, and its interface is defined by the methods it exposes
rather than by SQLite's features.
## Consequences
**Good**
- One less thing to operate, back up, monitor and secure.
- No credential to leak.
- A static binary and a small image with no libc.
- A backup is `cp` of one file. There is no consistent-snapshot dance.
- The render cache can be process-local because there is only one process.
**Bad**
- Cannot scale horizontally past one node without a shared filesystem.
- A shared-filesystem deployment is slower than local disk, and WAL over NFS is
sensitive to latency. Measure before choosing it.
- Single-writer means a pathological write loop would queue. Nothing in the
application can produce one.
**Neutral**
- The `store` package has a wider interface than a repository might otherwise need,
because it must express listing, filtering and sorting. Moving to another engine
means reimplementing that surface.
## Revisit when
- A single instance cannot serve the load, and profiling shows the database rather
than the render cache is the bottleneck.
- Concurrent writers become a real requirement, for example if themes can be edited
by an automated pipeline from several deployments.
- The dataset outgrows a few hundred megabytes, at which point the time to back up
by copying a file becomes its own problem.

View file

@ -0,0 +1,142 @@
# ADR 0002: Deliver themes at run time rather than as a package
- **Status:** accepted
- **Date:** 2026-10-04
## Context
The conventional way to ship a design system to applications is a package: an npm
package, a Go package, a Maven artifact. The consuming application installs it,
imports from it, and rebuilds when it changes.
That model is good, and it is the right answer for most design-system artefacts.
The question here is whether it is the right answer for *design tokens specifically*.
## Decision
Themes are served over HTTP at run time as CSS custom properties and JSON. The
runtime SDKs are ES modules loaded by URL through an import map. Nothing is
installed by the consumer.
## Rationale
### The cost of the package model, for tokens specifically
Design tokens are unusual among design-system artefacts. They are:
- **entirely data.** No behaviour to tree-shake, no components to code-split;
- **consumed at a single moment.** At first paint, by a stylesheet link;
- **changing on a different cadence from code.** A designer adjusts a colour; no
application code needs to change.
The package model imposes costs that fit code perfectly and fit data badly:
| Cost | Fits code? | Fits tokens? |
| --- | --- | --- |
| Version resolution and peer conflicts | Yes | No. Nothing here has a peer. |
| A rebuild of every consumer | Necessary | Pure overhead. The CSS output is byte-identical. |
| Coordinated deploys on version skew | Necessary | Avoidable: a runtime fetch has no skew. |
| A published version to track | Necessary | A database row, which an operator already tracks. |
| Install friction | Usually fine | Often the blocker. A Go service with a templated frontend cannot `npm install` at all. |
| Tree-shaking and chunk sizing | Necessary | Nothing to shake. |
The specific motivating case: a service that serves server-rendered HTML cannot
consume a design-system npm package without introducing a Node build into its
release pipeline. That is a large change made to deliver a CSS file. Serving the
CSS directly removes it.
### What runtime delivery buys
**Republishing is a database write.** A designer adjusts a colour, clicks publish,
and every application picks it up on its next request. There is no version to cut,
no build to trigger, no consumer to upgrade.
**No skew.** An application never holds a stale copy of a theme in its
`node_modules`, because it never held one. Rolling back a theme is a database write
too, and is atomic across every consumer.
**No supply chain.** A token file is not code. It cannot execute anything, it has
no transitive dependencies, and it cannot be compromised in the way an npm package
can. This is a genuinely large reduction in attack surface.
**Language neutrality.** Any environment that can make an HTTP request and read CSS
can consume a theme: Go, Rust, PHP, Jinja, Django templates, a static site
generator, a server-rendered email.
### What it costs
Honesty requires stating the costs:
**A network hop at first paint.** A stylesheet link is a blocking request. Mitigated
by immutable-by-URL caching: a CDN serves it from an edge, a publish changes the
version and therefore the URL, and a revalidation costs a few milliseconds and no
body. This is the same cost model as a web font, which every application already
pays.
**A runtime dependency.** The service must be up for a consuming application to look
right. Mitigated by a hard failure being a *fallback to the application's own
styles*, not an error page: a failed theme load leaves the page unstyled rather than
broken, which is exactly the right degradation.
**CSS custom properties as the only interface.** A token cannot be compiled into a
Tailwind class, a Sass mixin or a styled-component prop. Anything that needs
compilation is not served by this system. That is the boundary of the approach, and
it is a real boundary rather than a detail.
**A version-pinning story is needed.** Runtime delivery means a consumer can be
mid-request while a theme is republished. The answer is that a published theme's
version never changes — a publish creates version N+1 — so a URL pinned to version
N is immutable forever, and `/rt/v1/themes/{slug}` resolves to the latest. This is
the same model as an immutable container tag.
## Alternatives considered
**An npm package of design tokens.** Rejected for the reasons above. It remains the
right answer for anything requiring compilation, and the two models compose: an
application can build a Tailwind preset from a theme at its own build time, using
`/rt/v1/theme.json`, and then use the runtime for the parts that must be live.
**A CSS file on a CDN, uploaded by hand.** The delivery mechanism without the
management. It removes the coupling but gives up atomic publish, rollback, history
and per-theme authorisation. This is what the service automates.
**Server-side rendering of inline styles.** Each application renders the variables
into its own HTML. Zero latency, but every application must implement a fetcher, a
cache, a fallback and a revalidation policy, and they will all get it subtly
different. Centralising it means one correct implementation.
**Web Components for the tokens.** Not applicable. Tokens are not components.
**A GraphQL API.** Rejected: the payload is one object, so a single GET is simpler,
cacheable at the HTTP layer, and readable with `curl`. GraphQL would add a schema,
a server and a client library to deliver a JSON document.
## Consequences
**Good**
- Publishing is immediate, atomic and reversible.
- No consumer rebuild, no version skew, no package to track.
- Consumers in any language, including ones that cannot use npm at all.
- A theme cannot execute code.
- Caching is a solved problem rather than a design decision, because URLs are
immutable.
**Bad**
- A network hop on the critical rendering path, mitigated by aggressive caching.
- Only expressible as CSS custom properties. Anything needing compilation is out of
scope.
- The service is a runtime dependency for every consumer's first paint.
- Debugging "why does this consumer look wrong" requires knowing which theme and
version it resolved. Mitigated by `X-Theme-Version` and `X-Theme-Updated-At`
response headers, and by the `sourceURL` comment in the generated CSS that makes
browser devtools navigate back to the endpoint.
**Neutral**
- The admin UI is itself a consumer, which means the delivery path is exercised on
every interaction. A change that breaks delivery breaks the tool that maintains
themes, which is a useful continuous check.
- The runtime SDKs are built and served rather than published, so they are versioned
by the service's release rather than by a registry.

View file

@ -0,0 +1,101 @@
# ADR 0003: Stateless signed session cookies
- **Status:** accepted
- **Date:** 2026-10-04
## Context
The administration API needs a session. The service may run more than one replica
behind a load balancer, and the login callback may be handled by a different pod
from the request that started it.
## Decision
A session is a signed JSON payload in an `HttpOnly`, `Secure`, `SameSite` cookie.
There is no server-side session table.
## Rationale
**The load-balancer problem.** With a server-side session store, a session created
by one replica must be readable by another. That means either sticky sessions (which
lose the pod on every rollout) or a shared store (Redis or a database table), which
is the operational surface [ADR 0001](0001-embedded-sqlite.md) set out to avoid.
A signed cookie removes the question entirely.
**The transaction state has the same problem.** The OIDC flow carries a PKCE
verifier and a return path from the authorize redirect to the callback. Keeping that
in server-side state reintroduces the same requirement. It is a second signed cookie,
read and consumed exactly once.
**The lifetime is short.** An administrative session is eight hours by default.
The cost of not being able to revoke a session before it expires is bounded by that
window.
**What it costs.** Two things, stated plainly:
1. **Revocation.** A stolen session cannot be invalidated before it expires. The
mitigations are an eight-hour lifetime and, for a full invalidation, rotating the
signing key, which logs out every session at once. Rotating is a deployment, not
a button.
2. **Payload size.** The cookie carries identity and roles. That is a few hundred
bytes, comfortably inside the 4 kB browser limit. A larger claim set would push
against it.
## What was rejected
**A server-side session table in SQLite.** Would work, and would add revocation.
Rejected because it adds a table, a write on every login, a sweep job, and a
migration path for a problem the eight-hour window already bounds.
**Redis.** Would work. Rejected as operational surface for the same reason the
render cache stayed process-local.
**JWT as a bearer token in localStorage.** Rejected on security grounds. A bearer
token in `localStorage` is readable by any script on the page, so one XSS bug
becomes a credential theft. An `HttpOnly` cookie is not readable by script, and
`SameSite=Lax` plus the session cookie's scope mean a cross-site form post cannot
ride it. The session is a cookie precisely so that it is not script-accessible.
**Signed but not encrypted.** The payload is readable by the user holding the
cookie. It contains a subject, an email, a display name and roles — no secrets. It
is signed, not encrypted, so that a client can read it and debugging is possible.
Encrypting it would add a key for no confidentiality gain on this content.
## Implementation requirements
Every one of these is a security property, and each has a test:
- HMAC-SHA256 signature, compared with `subtle.ConstantTimeCompare`. A
byte-by-byte early return leaks the correct prefix length to an attacker measuring
responses.
- `HttpOnly`, so script cannot read it.
- `Secure` in any non-development environment, enforced by configuration
validation.
- `SameSite`, defaulted to `Lax`. `None` requires `Secure`, enforced by validation,
because browsers reject the combination and the session would silently never
survive a redirect.
- Payload expiry, checked server-side on every request. The cookie's own `Max-Age`
is a client hint, not a control.
- An absent or invalid role set defaults to viewer. A cookie carrying no roles must
never be treated as carrying privileges.
- The signing secret is at least 32 bytes, and an ephemeral one is generated in
development rather than requiring configuration that does not matter locally.
## Consequences
**Good**
- Any replica can authenticate any request, with no stickiness and no shared store.
- A rollback of the Deployment does not log anyone out.
- Nothing to sweep, nothing to migrate.
**Bad**
- No per-session revocation. Bounded by the session lifetime.
- Rotating the key logs out everyone, not one session.
**Neutral**
- Logout must clear the cookie rather than delete a record, so a client that keeps
a copy can still replay until expiry. Mitigated by the short lifetime.
- The payload is readable by its holder. Acceptable because it contains no secrets.

View file

@ -0,0 +1,70 @@
# ADR 0004: TypeScript 6.0.3 rather than 7.x
- **Status:** accepted
- **Date:** 2026-10-04
- **Revisit when:** typescript-eslint ships TypeScript 7 support
## Context
The brief was to take the latest version of everything. For TypeScript, the latest
release is 7.0.2, which is the native Go port of the compiler (`tsgo`).
## Decision
Pin TypeScript to 6.0.3 in both `web/` and `sdk/`.
## Rationale
TypeScript 7.0.2 ships `tsc.js` and a version module. It does **not** ship a
JavaScript compiler API:
```console
$ npm install typescript@7.0.2
$ node -e "const ts=require('typescript'); console.log(typeof ts.createProgram)"
undefined
```
That API is what every type-aware linting tool consumes. `typescript-eslint@8.71.0`,
the current release, declares:
```
peerDependencies: { typescript: ">=4.8.4 <6.1.0" }
```
So TypeScript 7 fails to install at all alongside the standard linting stack, and
would fail at run time if forced past the peer check.
The choice is therefore between:
- **TypeScript 7.x** — a much faster compiler, and no type-aware ESLint rules at all,
because the tooling that provides them cannot run. `npm install` fails outright
without `--legacy-peer-deps`, which is a smell in a project rather than a solution.
- **TypeScript 6.0.3** — the newest release that ships both the CLI and the
JavaScript API, satisfying every tool in the toolchain.
## Why not upgrade anyway and lose the linting
The project's standards require type-aware linting: `@typescript-eslint` rules that
need the compiler's type information, which is what catches the mistakes that matter
in a codebase using `unknown` at API boundaries. Dropping it to gain compiler speed
is a bad trade: the speed is a developer-experience improvement, and the linting is
a correctness control.
## What this costs
TypeScript 6.0.3 is slower than 7.0.2. On this codebase the typecheck completes in
well under a second, so the difference is not observable, and the compilation cost
that matters is Vite's, which uses its own transform.
## When to revisit
When typescript-eslint declares support for TypeScript 7, which requires the
JavaScript API or a native alternative to it. That is a one-line version bump plus
a `make check` run. This ADR exists so that the pin is a decision with a reason
rather than an accident that outlives its justification.
## A related note: `baseUrl` is deprecated
TypeScript 6 deprecates `compilerOptions.baseUrl`. The project resolves `paths`
relative to the tsconfig instead, which is the documented replacement and removes a
compiler option that only ever existed to make `paths` work.

View file

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

397
docs/api.md Normal file
View file

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

348
docs/architecture.md Normal file
View file

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

547
docs/deployment.md Normal file
View file

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

223
docs/development.md Normal file
View file

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

565
docs/integration.md Normal file
View file

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

273
docs/operations.md Normal file
View file

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

247
docs/security.md Normal file
View file

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

666
docs/standards.md Normal file
View file

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

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

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