initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
176
docs/adr/0001-embedded-sqlite.md
Normal file
176
docs/adr/0001-embedded-sqlite.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# ADR 0001: Embedded SQLite rather than a client/server database
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-04
|
||||
- **Deciders:** platform team
|
||||
- **Applies to:** all deployments of the current major version
|
||||
|
||||
## Context
|
||||
|
||||
go-bananas stores themes. A theme is a slug, some metadata and a JSON document of
|
||||
design tokens, typically 10–60 kB. A useful instance has tens to a few thousand of
|
||||
them.
|
||||
|
||||
The workload shape is:
|
||||
|
||||
- **Reads:** constant and bursty. Every page view in every consuming application
|
||||
requests CSS. A service with ten consumer applications and a few hundred users
|
||||
can easily see thousands of stylesheet requests a minute, and 95%+ of them are
|
||||
the same handful of bytes served from a render cache.
|
||||
- **Writes:** rare. A theme is edited by a human. A busy week might be fifty writes.
|
||||
- **Total size:** a few megabytes. Even ten thousand large themes would fit in
|
||||
fifty.
|
||||
|
||||
The conventional answer for a service with this shape is PostgreSQL. That answer is
|
||||
right for almost every service and wrong for this one, for reasons worth writing
|
||||
down rather than rediscovering in a year.
|
||||
|
||||
## Decision
|
||||
|
||||
Use SQLite through `modernc.org/sqlite`, a pure-Go translation, on a local volume.
|
||||
|
||||
## Rationale
|
||||
|
||||
### What a client/server database would add
|
||||
|
||||
For this dataset, Postgres buys nothing and costs:
|
||||
|
||||
- **A network hop per query.** A local SQLite read is microseconds; a query over a
|
||||
pod network is a millisecond and a tail-latency distribution with a long right
|
||||
tail. For a stylesheet endpoint on the critical path of every page view, that
|
||||
tail is the difference between a fast site and a janky one.
|
||||
- **A connection pool to get wrong.** Every deployment needs its own tuning, and the
|
||||
failure mode of under-tuning is a saturated pool under load.
|
||||
- **Credentials to create, distribute, rotate and revoke.** Another secret in
|
||||
Kubernetes, another thing to get wrong.
|
||||
- **A schema migration process.** For a dataset that fits in a few megabytes, this
|
||||
is a disproportionate amount of ceremony.
|
||||
- **A second thing to operate.** Backups, upgrades, monitoring, incident response,
|
||||
for a component whose whole job is to hold some JSON.
|
||||
- **A second failure mode.** The database becoming unavailable is now the most
|
||||
likely way the theme service goes down.
|
||||
|
||||
### What SQLite gives up
|
||||
|
||||
Honesty requires stating the costs:
|
||||
|
||||
- **One writer at a time.** Correct at this write rate. A theme edit is a
|
||||
millisecond-scale transaction; even a hundred concurrent editors would not
|
||||
saturate it.
|
||||
- **Single-node.** This is the real constraint. See Scaling below.
|
||||
- **No network access.** A consuming application cannot query the database directly.
|
||||
It does not need to; the runtime API is the interface.
|
||||
|
||||
### Why this particular configuration is fast
|
||||
|
||||
The default SQLite configuration is not fast. These settings are what make it fast
|
||||
enough that the question does not arise:
|
||||
|
||||
| Setting | Effect |
|
||||
| --- | --- |
|
||||
| `journal_mode=WAL` | Readers never block the writer and the writer never blocks readers. This is the single most important setting. |
|
||||
| `synchronous=NORMAL` | In WAL mode this is durable across an application crash and risks only the last transactions on an operating-system crash. A lost publish is re-doable; an outage is not. |
|
||||
| `cache_size=-16000` | A 16 MiB page cache, which holds the entire dataset. |
|
||||
| `mmap_size=268435456` | The file is read through the page cache directly, removing a copy from the read path. |
|
||||
| `temp_store=MEMORY` | Sort and group-by spill stays in memory. |
|
||||
| Max 8 connections | SQLite serialises writers. A large pool adds contention, not throughput. This is counter-intuitive and correct. |
|
||||
| In-memory render cache | The steady-state runtime request performs **no database access at all**. |
|
||||
|
||||
The render cache is the decisive optimisation. Rendering a theme traverses its whole
|
||||
token document; serving the cached string is one map lookup. Because the cache key
|
||||
includes the theme version, publishing invalidates exactly one theme's entries and
|
||||
disturbs nothing else. The database is consulted only on a cache miss, which is a
|
||||
publish rather than a read.
|
||||
|
||||
### Why `modernc.org/sqlite` rather than `mattn/go-sqlite3`
|
||||
|
||||
`mattn/go-sqlite3` is a cgo binding to the C library. It is faster, and it means:
|
||||
|
||||
- `CGO_ENABLED=1`, so the release image needs a C toolchain at build time and libc
|
||||
at run time, and cross-compilation needs a cross-C toolchain;
|
||||
- a statically linked binary is impossible, so the image carries a C library whose
|
||||
CVEs need tracking;
|
||||
- `go install` from source fails on a machine without a compiler.
|
||||
|
||||
`modernc.org/sqlite` is a transpiled Go implementation: no cgo, a fully static
|
||||
binary, and cross-compilation is `GOOS=linux GOARCH=arm64` and nothing else. The
|
||||
performance difference is irrelevant here because the hot path is the in-memory
|
||||
render cache, not SQLite.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**PostgreSQL.** The conventional choice. Rejected on the grounds above. It remains
|
||||
the right answer if the product changes shape — see Scaling.
|
||||
|
||||
**Redis.** Excellent for this read-heavy workload, and a good fit for a render
|
||||
cache. But it is another service to operate for a cache that is already
|
||||
process-local and loses nothing on restart, because the source of truth is the
|
||||
database. Rejected as unnecessary operational surface. Worth revisiting if a
|
||||
many-node deployment needs a shared cache; the render cache is an interface, so
|
||||
that change is contained.
|
||||
|
||||
**bbolt.** Faster than SQLite for pure key/value workloads and simpler. Rejected
|
||||
because the schema needs indexes for listing, filtering and sorting, and because
|
||||
SQLite's query planner handles the "a few hundred rows filtered by status and
|
||||
ordered by date" shape without the ceremony of managing bbolt buckets by hand.
|
||||
|
||||
**A JSON file per theme on disk.** No dependency at all. Rejected because concurrent
|
||||
writes need locking, and because every listing requires reading and parsing every
|
||||
file. SQLite is a better version of this idea that is already written.
|
||||
|
||||
**An in-memory store with write-through to object storage.** Attractive for scale.
|
||||
Rejected for v1: it removes the transactional guarantee that makes version history
|
||||
and rollback correct, and it adds an object-storage dependency. The write path
|
||||
here is not hot enough to justify losing the invariant.
|
||||
|
||||
## Scaling
|
||||
|
||||
SQLite on a local volume is single-node. Two shapes are supported:
|
||||
|
||||
1. **One replica, one volume.** The recommended production shape. Horizontally
|
||||
scalable only in the sense that the service is so cheap that one replica serves
|
||||
a very large number of consumers.
|
||||
|
||||
2. **Many replicas, a shared filesystem.** Works when the storage class supports
|
||||
concurrent mounts — NFS, CephFS, or a ReadWriteMany block device. WAL mode
|
||||
requires the filesystem to support shared memory mapping, which these do. The
|
||||
Helm chart warns when a ReadWriteOnce volume is combined with more than one
|
||||
replica, because that combination cannot work.
|
||||
|
||||
If the workload ever outgrows both — genuinely concurrent multi-writer access across
|
||||
nodes — the migration is to Postgres. It is contained: `internal/store` is the only
|
||||
package that writes SQL, and its interface is defined by the methods it exposes
|
||||
rather than by SQLite's features.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Good**
|
||||
|
||||
- One less thing to operate, back up, monitor and secure.
|
||||
- No credential to leak.
|
||||
- A static binary and a small image with no libc.
|
||||
- A backup is `cp` of one file. There is no consistent-snapshot dance.
|
||||
- The render cache can be process-local because there is only one process.
|
||||
|
||||
**Bad**
|
||||
|
||||
- Cannot scale horizontally past one node without a shared filesystem.
|
||||
- A shared-filesystem deployment is slower than local disk, and WAL over NFS is
|
||||
sensitive to latency. Measure before choosing it.
|
||||
- Single-writer means a pathological write loop would queue. Nothing in the
|
||||
application can produce one.
|
||||
|
||||
**Neutral**
|
||||
|
||||
- The `store` package has a wider interface than a repository might otherwise need,
|
||||
because it must express listing, filtering and sorting. Moving to another engine
|
||||
means reimplementing that surface.
|
||||
|
||||
## Revisit when
|
||||
|
||||
- A single instance cannot serve the load, and profiling shows the database rather
|
||||
than the render cache is the bottleneck.
|
||||
- Concurrent writers become a real requirement, for example if themes can be edited
|
||||
by an automated pipeline from several deployments.
|
||||
- The dataset outgrows a few hundred megabytes, at which point the time to back up
|
||||
by copying a file becomes its own problem.
|
||||
142
docs/adr/0002-runtime-delivery.md
Normal file
142
docs/adr/0002-runtime-delivery.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# ADR 0002: Deliver themes at run time rather than as a package
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-04
|
||||
|
||||
## Context
|
||||
|
||||
The conventional way to ship a design system to applications is a package: an npm
|
||||
package, a Go package, a Maven artifact. The consuming application installs it,
|
||||
imports from it, and rebuilds when it changes.
|
||||
|
||||
That model is good, and it is the right answer for most design-system artefacts.
|
||||
The question here is whether it is the right answer for *design tokens specifically*.
|
||||
|
||||
## Decision
|
||||
|
||||
Themes are served over HTTP at run time as CSS custom properties and JSON. The
|
||||
runtime SDKs are ES modules loaded by URL through an import map. Nothing is
|
||||
installed by the consumer.
|
||||
|
||||
## Rationale
|
||||
|
||||
### The cost of the package model, for tokens specifically
|
||||
|
||||
Design tokens are unusual among design-system artefacts. They are:
|
||||
|
||||
- **entirely data.** No behaviour to tree-shake, no components to code-split;
|
||||
- **consumed at a single moment.** At first paint, by a stylesheet link;
|
||||
- **changing on a different cadence from code.** A designer adjusts a colour; no
|
||||
application code needs to change.
|
||||
|
||||
The package model imposes costs that fit code perfectly and fit data badly:
|
||||
|
||||
| Cost | Fits code? | Fits tokens? |
|
||||
| --- | --- | --- |
|
||||
| Version resolution and peer conflicts | Yes | No. Nothing here has a peer. |
|
||||
| A rebuild of every consumer | Necessary | Pure overhead. The CSS output is byte-identical. |
|
||||
| Coordinated deploys on version skew | Necessary | Avoidable: a runtime fetch has no skew. |
|
||||
| A published version to track | Necessary | A database row, which an operator already tracks. |
|
||||
| Install friction | Usually fine | Often the blocker. A Go service with a templated frontend cannot `npm install` at all. |
|
||||
| Tree-shaking and chunk sizing | Necessary | Nothing to shake. |
|
||||
|
||||
The specific motivating case: a service that serves server-rendered HTML cannot
|
||||
consume a design-system npm package without introducing a Node build into its
|
||||
release pipeline. That is a large change made to deliver a CSS file. Serving the
|
||||
CSS directly removes it.
|
||||
|
||||
### What runtime delivery buys
|
||||
|
||||
**Republishing is a database write.** A designer adjusts a colour, clicks publish,
|
||||
and every application picks it up on its next request. There is no version to cut,
|
||||
no build to trigger, no consumer to upgrade.
|
||||
|
||||
**No skew.** An application never holds a stale copy of a theme in its
|
||||
`node_modules`, because it never held one. Rolling back a theme is a database write
|
||||
too, and is atomic across every consumer.
|
||||
|
||||
**No supply chain.** A token file is not code. It cannot execute anything, it has
|
||||
no transitive dependencies, and it cannot be compromised in the way an npm package
|
||||
can. This is a genuinely large reduction in attack surface.
|
||||
|
||||
**Language neutrality.** Any environment that can make an HTTP request and read CSS
|
||||
can consume a theme: Go, Rust, PHP, Jinja, Django templates, a static site
|
||||
generator, a server-rendered email.
|
||||
|
||||
### What it costs
|
||||
|
||||
Honesty requires stating the costs:
|
||||
|
||||
**A network hop at first paint.** A stylesheet link is a blocking request. Mitigated
|
||||
by immutable-by-URL caching: a CDN serves it from an edge, a publish changes the
|
||||
version and therefore the URL, and a revalidation costs a few milliseconds and no
|
||||
body. This is the same cost model as a web font, which every application already
|
||||
pays.
|
||||
|
||||
**A runtime dependency.** The service must be up for a consuming application to look
|
||||
right. Mitigated by a hard failure being a *fallback to the application's own
|
||||
styles*, not an error page: a failed theme load leaves the page unstyled rather than
|
||||
broken, which is exactly the right degradation.
|
||||
|
||||
**CSS custom properties as the only interface.** A token cannot be compiled into a
|
||||
Tailwind class, a Sass mixin or a styled-component prop. Anything that needs
|
||||
compilation is not served by this system. That is the boundary of the approach, and
|
||||
it is a real boundary rather than a detail.
|
||||
|
||||
**A version-pinning story is needed.** Runtime delivery means a consumer can be
|
||||
mid-request while a theme is republished. The answer is that a published theme's
|
||||
version never changes — a publish creates version N+1 — so a URL pinned to version
|
||||
N is immutable forever, and `/rt/v1/themes/{slug}` resolves to the latest. This is
|
||||
the same model as an immutable container tag.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**An npm package of design tokens.** Rejected for the reasons above. It remains the
|
||||
right answer for anything requiring compilation, and the two models compose: an
|
||||
application can build a Tailwind preset from a theme at its own build time, using
|
||||
`/rt/v1/theme.json`, and then use the runtime for the parts that must be live.
|
||||
|
||||
**A CSS file on a CDN, uploaded by hand.** The delivery mechanism without the
|
||||
management. It removes the coupling but gives up atomic publish, rollback, history
|
||||
and per-theme authorisation. This is what the service automates.
|
||||
|
||||
**Server-side rendering of inline styles.** Each application renders the variables
|
||||
into its own HTML. Zero latency, but every application must implement a fetcher, a
|
||||
cache, a fallback and a revalidation policy, and they will all get it subtly
|
||||
different. Centralising it means one correct implementation.
|
||||
|
||||
**Web Components for the tokens.** Not applicable. Tokens are not components.
|
||||
|
||||
**A GraphQL API.** Rejected: the payload is one object, so a single GET is simpler,
|
||||
cacheable at the HTTP layer, and readable with `curl`. GraphQL would add a schema,
|
||||
a server and a client library to deliver a JSON document.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Good**
|
||||
|
||||
- Publishing is immediate, atomic and reversible.
|
||||
- No consumer rebuild, no version skew, no package to track.
|
||||
- Consumers in any language, including ones that cannot use npm at all.
|
||||
- A theme cannot execute code.
|
||||
- Caching is a solved problem rather than a design decision, because URLs are
|
||||
immutable.
|
||||
|
||||
**Bad**
|
||||
|
||||
- A network hop on the critical rendering path, mitigated by aggressive caching.
|
||||
- Only expressible as CSS custom properties. Anything needing compilation is out of
|
||||
scope.
|
||||
- The service is a runtime dependency for every consumer's first paint.
|
||||
- Debugging "why does this consumer look wrong" requires knowing which theme and
|
||||
version it resolved. Mitigated by `X-Theme-Version` and `X-Theme-Updated-At`
|
||||
response headers, and by the `sourceURL` comment in the generated CSS that makes
|
||||
browser devtools navigate back to the endpoint.
|
||||
|
||||
**Neutral**
|
||||
|
||||
- The admin UI is itself a consumer, which means the delivery path is exercised on
|
||||
every interaction. A change that breaks delivery breaks the tool that maintains
|
||||
themes, which is a useful continuous check.
|
||||
- The runtime SDKs are built and served rather than published, so they are versioned
|
||||
by the service's release rather than by a registry.
|
||||
101
docs/adr/0003-stateful-sessions.md
Normal file
101
docs/adr/0003-stateful-sessions.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# ADR 0003: Stateless signed session cookies
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-04
|
||||
|
||||
## Context
|
||||
|
||||
The administration API needs a session. The service may run more than one replica
|
||||
behind a load balancer, and the login callback may be handled by a different pod
|
||||
from the request that started it.
|
||||
|
||||
## Decision
|
||||
|
||||
A session is a signed JSON payload in an `HttpOnly`, `Secure`, `SameSite` cookie.
|
||||
There is no server-side session table.
|
||||
|
||||
## Rationale
|
||||
|
||||
**The load-balancer problem.** With a server-side session store, a session created
|
||||
by one replica must be readable by another. That means either sticky sessions (which
|
||||
lose the pod on every rollout) or a shared store (Redis or a database table), which
|
||||
is the operational surface [ADR 0001](0001-embedded-sqlite.md) set out to avoid.
|
||||
A signed cookie removes the question entirely.
|
||||
|
||||
**The transaction state has the same problem.** The OIDC flow carries a PKCE
|
||||
verifier and a return path from the authorize redirect to the callback. Keeping that
|
||||
in server-side state reintroduces the same requirement. It is a second signed cookie,
|
||||
read and consumed exactly once.
|
||||
|
||||
**The lifetime is short.** An administrative session is eight hours by default.
|
||||
The cost of not being able to revoke a session before it expires is bounded by that
|
||||
window.
|
||||
|
||||
**What it costs.** Two things, stated plainly:
|
||||
|
||||
1. **Revocation.** A stolen session cannot be invalidated before it expires. The
|
||||
mitigations are an eight-hour lifetime and, for a full invalidation, rotating the
|
||||
signing key, which logs out every session at once. Rotating is a deployment, not
|
||||
a button.
|
||||
2. **Payload size.** The cookie carries identity and roles. That is a few hundred
|
||||
bytes, comfortably inside the 4 kB browser limit. A larger claim set would push
|
||||
against it.
|
||||
|
||||
## What was rejected
|
||||
|
||||
**A server-side session table in SQLite.** Would work, and would add revocation.
|
||||
Rejected because it adds a table, a write on every login, a sweep job, and a
|
||||
migration path for a problem the eight-hour window already bounds.
|
||||
|
||||
**Redis.** Would work. Rejected as operational surface for the same reason the
|
||||
render cache stayed process-local.
|
||||
|
||||
**JWT as a bearer token in localStorage.** Rejected on security grounds. A bearer
|
||||
token in `localStorage` is readable by any script on the page, so one XSS bug
|
||||
becomes a credential theft. An `HttpOnly` cookie is not readable by script, and
|
||||
`SameSite=Lax` plus the session cookie's scope mean a cross-site form post cannot
|
||||
ride it. The session is a cookie precisely so that it is not script-accessible.
|
||||
|
||||
**Signed but not encrypted.** The payload is readable by the user holding the
|
||||
cookie. It contains a subject, an email, a display name and roles — no secrets. It
|
||||
is signed, not encrypted, so that a client can read it and debugging is possible.
|
||||
Encrypting it would add a key for no confidentiality gain on this content.
|
||||
|
||||
## Implementation requirements
|
||||
|
||||
Every one of these is a security property, and each has a test:
|
||||
|
||||
- HMAC-SHA256 signature, compared with `subtle.ConstantTimeCompare`. A
|
||||
byte-by-byte early return leaks the correct prefix length to an attacker measuring
|
||||
responses.
|
||||
- `HttpOnly`, so script cannot read it.
|
||||
- `Secure` in any non-development environment, enforced by configuration
|
||||
validation.
|
||||
- `SameSite`, defaulted to `Lax`. `None` requires `Secure`, enforced by validation,
|
||||
because browsers reject the combination and the session would silently never
|
||||
survive a redirect.
|
||||
- Payload expiry, checked server-side on every request. The cookie's own `Max-Age`
|
||||
is a client hint, not a control.
|
||||
- An absent or invalid role set defaults to viewer. A cookie carrying no roles must
|
||||
never be treated as carrying privileges.
|
||||
- The signing secret is at least 32 bytes, and an ephemeral one is generated in
|
||||
development rather than requiring configuration that does not matter locally.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Good**
|
||||
|
||||
- Any replica can authenticate any request, with no stickiness and no shared store.
|
||||
- A rollback of the Deployment does not log anyone out.
|
||||
- Nothing to sweep, nothing to migrate.
|
||||
|
||||
**Bad**
|
||||
|
||||
- No per-session revocation. Bounded by the session lifetime.
|
||||
- Rotating the key logs out everyone, not one session.
|
||||
|
||||
**Neutral**
|
||||
|
||||
- Logout must clear the cookie rather than delete a record, so a client that keeps
|
||||
a copy can still replay until expiry. Mitigated by the short lifetime.
|
||||
- The payload is readable by its holder. Acceptable because it contains no secrets.
|
||||
70
docs/adr/0004-typescript-version.md
Normal file
70
docs/adr/0004-typescript-version.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# ADR 0004: TypeScript 6.0.3 rather than 7.x
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-04
|
||||
- **Revisit when:** typescript-eslint ships TypeScript 7 support
|
||||
|
||||
## Context
|
||||
|
||||
The brief was to take the latest version of everything. For TypeScript, the latest
|
||||
release is 7.0.2, which is the native Go port of the compiler (`tsgo`).
|
||||
|
||||
## Decision
|
||||
|
||||
Pin TypeScript to 6.0.3 in both `web/` and `sdk/`.
|
||||
|
||||
## Rationale
|
||||
|
||||
TypeScript 7.0.2 ships `tsc.js` and a version module. It does **not** ship a
|
||||
JavaScript compiler API:
|
||||
|
||||
```console
|
||||
$ npm install typescript@7.0.2
|
||||
$ node -e "const ts=require('typescript'); console.log(typeof ts.createProgram)"
|
||||
undefined
|
||||
```
|
||||
|
||||
That API is what every type-aware linting tool consumes. `typescript-eslint@8.71.0`,
|
||||
the current release, declares:
|
||||
|
||||
```
|
||||
peerDependencies: { typescript: ">=4.8.4 <6.1.0" }
|
||||
```
|
||||
|
||||
So TypeScript 7 fails to install at all alongside the standard linting stack, and
|
||||
would fail at run time if forced past the peer check.
|
||||
|
||||
The choice is therefore between:
|
||||
|
||||
- **TypeScript 7.x** — a much faster compiler, and no type-aware ESLint rules at all,
|
||||
because the tooling that provides them cannot run. `npm install` fails outright
|
||||
without `--legacy-peer-deps`, which is a smell in a project rather than a solution.
|
||||
- **TypeScript 6.0.3** — the newest release that ships both the CLI and the
|
||||
JavaScript API, satisfying every tool in the toolchain.
|
||||
|
||||
## Why not upgrade anyway and lose the linting
|
||||
|
||||
The project's standards require type-aware linting: `@typescript-eslint` rules that
|
||||
need the compiler's type information, which is what catches the mistakes that matter
|
||||
in a codebase using `unknown` at API boundaries. Dropping it to gain compiler speed
|
||||
is a bad trade: the speed is a developer-experience improvement, and the linting is
|
||||
a correctness control.
|
||||
|
||||
## What this costs
|
||||
|
||||
TypeScript 6.0.3 is slower than 7.0.2. On this codebase the typecheck completes in
|
||||
well under a second, so the difference is not observable, and the compilation cost
|
||||
that matters is Vite's, which uses its own transform.
|
||||
|
||||
## When to revisit
|
||||
|
||||
When typescript-eslint declares support for TypeScript 7, which requires the
|
||||
JavaScript API or a native alternative to it. That is a one-line version bump plus
|
||||
a `make check` run. This ADR exists so that the pin is a decision with a reason
|
||||
rather than an accident that outlives its justification.
|
||||
|
||||
## A related note: `baseUrl` is deprecated
|
||||
|
||||
TypeScript 6 deprecates `compilerOptions.baseUrl`. The project resolves `paths`
|
||||
relative to the tsconfig instead, which is the documented replacement and removes a
|
||||
compiler option that only ever existed to make `paths` work.
|
||||
119
docs/adr/0005-shared-token-contract.md
Normal file
119
docs/adr/0005-shared-token-contract.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# ADR 0005: One token contract for every application, with a legacy alias layer
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-05
|
||||
- **Revisit when:** an application adopts Tailwind v4 as its only styling mechanism, which
|
||||
would let the alias layer be dropped
|
||||
|
||||
## Context
|
||||
|
||||
Six applications consume design decisions today, each in its own dialect:
|
||||
|
||||
| Application | Mechanism | Palettes | Light/dark | Token indirection |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `go-doc` | Tailwind v4 `@theme` | 1, dark hardcoded | none | `--color-ink-*` |
|
||||
| `go-guess` | one 2800-line CSS file | 1, light hardcoded | none | 11 `:root` vars, mostly bypassed by ~120 hardcoded hexes |
|
||||
| `go-loose` | plain CSS | 4 × 2 modes | works | `[data-theme=X]` + `[data-mode=light]` |
|
||||
| `go-mad` | plain CSS, copied from `go-loose` | 1 × 2 modes | CSS present, JS dead | `[data-mode=X]` |
|
||||
| `go-places` | JS-injected `<style>` | 2 objects | broken | inline custom properties |
|
||||
| `go-tell` | plain CSS, **no custom properties at all** | 4, all dark | none | 340 hardcoded hexes |
|
||||
|
||||
Four problems follow from that, and only one of them is aesthetic:
|
||||
|
||||
1. **No shared vocabulary.** `--navy` in `go-guess` and `--surface` in `go-loose` mean
|
||||
unrelated things, so nothing can be shared and no token can be changed in one place.
|
||||
2. **No light mode in three of six.** A theme is the wrong level of abstraction to fix
|
||||
this: the missing axis is *appearance*, and a theme that is dark-only cannot express
|
||||
it.
|
||||
3. **Two incompatible theme mechanisms.** `go-loose` sets `data-theme` and `data-mode`
|
||||
as separate attributes; `go-tell` sets `data-theme` only, with the mode baked into
|
||||
each palette. An application can adopt one or the other, not both.
|
||||
4. **Tailwind is a different language.** A Tailwind application cannot consume a
|
||||
`:root` block; it needs `@theme`.
|
||||
|
||||
The brief was to consolidate, find the middle ground, and make that styling available to
|
||||
every application.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt `go-loose`'s two-attribute model as the contract, because it is the only
|
||||
mechanism that is both working and already copied by another application:
|
||||
|
||||
```html
|
||||
<html data-theme="tokyo-night" data-mode="dark">
|
||||
```
|
||||
|
||||
Two orthogonal axes. `data-theme` is the palette, `data-mode` is the appearance. Every
|
||||
theme therefore has both a light and a dark rendering, which fixes problem 2 without
|
||||
inventing a second concept.
|
||||
|
||||
Token names are namespaced with `--bn-`, and the rendered stylesheet emits a **legacy
|
||||
alias block** underneath:
|
||||
|
||||
```css
|
||||
:root[data-theme='tokyo-night'][data-mode='dark'] {
|
||||
--bn-bg: #1a1b26;
|
||||
--bn-surface: #20222f;
|
||||
/* ... */
|
||||
}
|
||||
|
||||
/* Compatibility with applications that already use these names. */
|
||||
:root[data-theme='tokyo-night'][data-mode='dark'] {
|
||||
--bg: var(--bn-bg);
|
||||
--surface: var(--bn-surface);
|
||||
--text: var(--bn-text);
|
||||
--muted: var(--bn-muted);
|
||||
--line: var(--bn-border);
|
||||
--accent: var(--bn-accent);
|
||||
--good: var(--bn-good);
|
||||
--danger: var(--bn-danger);
|
||||
--radius: var(--bn-radius);
|
||||
}
|
||||
```
|
||||
|
||||
The alias block is the middle ground made literal. `go-loose` and `go-mad` keep working
|
||||
unchanged, and the other four applications can migrate by deleting their own `:root`
|
||||
block and renaming nothing.
|
||||
|
||||
## Rationale
|
||||
|
||||
**Namespaced names, because the stylesheet is injected into someone else's page.** An
|
||||
unprefixed `--surface` on a host page is a collision waiting to happen, and the host's
|
||||
value wins or loses depending on order. `--bn-bg` cannot collide with anything.
|
||||
|
||||
**Aliases, because a rename is a coordinated six-application change.** Emitting both
|
||||
means an application adopts the contract by adding one `<link>`, with no call sites
|
||||
edited. That is the difference between a migration that happens and one that is
|
||||
proposed.
|
||||
|
||||
**Two axes rather than N themes.** Six applications currently have 13 palettes between
|
||||
them, several of which are the same palette twice. Splitting palette from appearance
|
||||
turns 13 one-dimensional choices into 7 palettes × 2 modes, and makes "give me a light
|
||||
version" a solvable problem instead of a request for a new theme.
|
||||
|
||||
**Attributes, not classes.** `[data-theme=X]` beats `.theme-x` because an attribute is
|
||||
one axis for two features, and because a consumer can set it in the `<head>` before
|
||||
first paint. A class list forces the two axes into an ordering the cascade then has to
|
||||
be told about.
|
||||
|
||||
**`color-mix`, not extra tokens.** `go-loose` and `go-mad` already derive hover and
|
||||
subtle states with `color-mix(in srgb, var(--bn-accent) 12%, var(--bn-surface))`. That
|
||||
is kept: it means one accent serves every interaction state, instead of a hand-tuned
|
||||
ladder that drifts between themes.
|
||||
|
||||
**`--bn-font-*` and `--bn-radius` join the set.** Both applications that have a scale
|
||||
disagree (`go-loose` uses 16px cards, `go-mad` 8px; `go-doc` uses Tailwind defaults).
|
||||
Consolidation without them would leave typography and geometry app-local.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The rendered stylesheet grows by the alias block. It is small, it is compressible, and
|
||||
it is removed entirely once an application has renamed its own references.
|
||||
- `go-doc` needs a three-line Tailwind bridge, because `@theme` cannot read a runtime
|
||||
custom property. It becomes `@theme inline { --color-bn-bg: var(--bn-bg) }`.
|
||||
- `go-tell`'s four themes are palettes with the appearance baked in. They become four
|
||||
`data-theme` values with their dark values as the `dark` mode, and their light
|
||||
counterparts have to be authored. That work is the honest cost of the migration.
|
||||
- Appearance preference is per-application. Two applications on the same browser do not
|
||||
share a `data-mode` unless they agree on a storage key; the shipped components use one
|
||||
key per origin and document it.
|
||||
397
docs/api.md
Normal file
397
docs/api.md
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
# HTTP API
|
||||
|
||||
Two surfaces with different postures:
|
||||
|
||||
| | `/api/v1` | `/rt/v1` |
|
||||
| --- | --- | --- |
|
||||
| Audience | Administrators | Applications and browsers |
|
||||
| Authentication | Required | None, by design |
|
||||
| Caching | `no-store` | Aggressive, immutable by URL |
|
||||
| Audit | Every mutation | No |
|
||||
| Rate limit | Generous | Applied |
|
||||
|
||||
A theming service that required a credential could not be consumed from a
|
||||
`<link>` tag, which is the primary use case.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
- All bodies are JSON. One shape, everywhere.
|
||||
- Errors: `{ code, message, details?, requestId? }`. `code` is a stable
|
||||
machine-readable identifier and is part of the contract.
|
||||
- `requestId` is on every error and in `X-Request-ID` on every response.
|
||||
- Timestamps are RFC 3339 in UTC.
|
||||
- Every mutable request accepts `baseVersion` for optimistic concurrency.
|
||||
|
||||
| Code | Status | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `bad_request` | 400 | Malformed |
|
||||
| `validation_failed` | 400 | Invalid, with per-field `details` |
|
||||
| `unauthorized` | 401 | No usable credential |
|
||||
| `forbidden` | 403 | Insufficient role |
|
||||
| `not_found` | 404 | Missing, or deliberately not disclosed |
|
||||
| `conflict` | 409 | Uniqueness, or an optimistic-concurrency conflict |
|
||||
| `immutable` | 409 | A built-in theme cannot be deleted |
|
||||
| `rate_limited` | 429 | With `Retry-After` |
|
||||
| `internal_error` | 500 | Unexpected |
|
||||
|
||||
---
|
||||
|
||||
## Runtime API
|
||||
|
||||
### `GET /rt/v1/theme.css`
|
||||
|
||||
A theme's stylesheet.
|
||||
|
||||
| Parameter | Required | Default | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `slug` | Yes | — | The theme |
|
||||
| `appearance` | No | The theme's default | `light` or `dark` |
|
||||
| `variant` | No | `full` | `full`, `tokens`, `minified` |
|
||||
| `v` | No | Latest | Pin a version |
|
||||
|
||||
```bash
|
||||
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight'
|
||||
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&appearance=dark'
|
||||
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&variant=tokens'
|
||||
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&v=7'
|
||||
```
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/css; charset=utf-8
|
||||
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=86400
|
||||
ETag: "a1f4b5833f60d17d42328a90213b20f6"
|
||||
X-Theme-Version: 7
|
||||
X-Theme-Updated-At: 2026-10-04T09:14:27Z
|
||||
Vary: Accept-Encoding
|
||||
```
|
||||
|
||||
A draft theme returns **404**, not 403, so a probe cannot confirm it exists.
|
||||
|
||||
### `GET /rt/v1/theme.json`
|
||||
|
||||
Resolved tokens.
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": {
|
||||
"slug": "midnight",
|
||||
"name": "Midnight",
|
||||
"version": 7,
|
||||
"appearance": "dark",
|
||||
"defaultAppearance": "dark",
|
||||
"updatedAt": "2026-10-04T09:14:27Z"
|
||||
},
|
||||
"variables": {
|
||||
"--bn-color-semantic-primary": "#7c6bf5",
|
||||
"--bn-color-on-primary": "#0f0d1f",
|
||||
"--bn-radius-lg": "0.75rem",
|
||||
"--bn-space-4": "1rem"
|
||||
},
|
||||
"tokens": {
|
||||
"color": { "semantic": { "primary": { "$type": "color", "$value": "#7c6bf5" } } }
|
||||
},
|
||||
"meta": { "tags": ["dark", "violet"] }
|
||||
}
|
||||
```
|
||||
|
||||
`variables` is a flat map for anything reading values. `tokens` is the nested DTCG
|
||||
document for mapping onto component props.
|
||||
|
||||
### `GET /rt/v1/manifest.json`
|
||||
|
||||
The discovery document for building a picker. Deliberately small: no token data.
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": "1",
|
||||
"default": "midnight",
|
||||
"baseUrl": "https://themes.example.com/rt/v1",
|
||||
"themes": [
|
||||
{
|
||||
"slug": "midnight",
|
||||
"name": "Midnight",
|
||||
"description": "Violet-accented dark interface.",
|
||||
"defaultAppearance": "dark",
|
||||
"version": 7,
|
||||
"tags": ["dark", "violet"],
|
||||
"css": "https://themes.example.com/rt/v1/theme.css?slug=midnight",
|
||||
"tokens": "https://themes.example.com/rt/v1/theme.json?slug=midnight"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /rt/v1/embed`
|
||||
|
||||
A ready-to-paste snippet. `?target=html` (the default, a full preview page), `react`,
|
||||
or `javascript`.
|
||||
|
||||
### SDK bundles
|
||||
|
||||
`GET /rt/v1/runtime.js`, `/react.js`, `/global.js`, `/embed.js`. Immutable, served
|
||||
with a one-year `Cache-Control`, and CORS-open, since they are public assets loaded
|
||||
cross-origin.
|
||||
|
||||
### Path-addressable forms
|
||||
|
||||
`/rt/v1/themes/{slug}/theme.css`, `/rt/v1/themes/{slug}/theme.json`,
|
||||
`/rt/v1/themes/{slug}/embed`. For consumers that prefer REST shapes or a CDN
|
||||
configured with path patterns rather than query strings.
|
||||
|
||||
---
|
||||
|
||||
## Administration API
|
||||
|
||||
Requires a session cookie. Every mutation is audited.
|
||||
|
||||
There is no machine credential: API keys were removed. The runtime API is public by
|
||||
design, and the administration API is for people, so a bearer token is not a
|
||||
credential here and a request carrying one is treated as unauthenticated.
|
||||
|
||||
### Auth
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/auth/me` | The current session. Always 200 |
|
||||
| `GET` | `/api/v1/auth/config` | Client-visible configuration |
|
||||
| `GET` | `/auth/login` | Start the OIDC flow |
|
||||
| `GET` | `/auth/callback` | Complete it |
|
||||
| `GET`/`POST` | `/auth/logout` | End the session and the provider session |
|
||||
|
||||
`/auth/me` always answers 200 with `authenticated: false` when there is no session,
|
||||
because "not logged in yet" is the normal state on first load and not an error the UI
|
||||
should handle as one.
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"mode": "oidc",
|
||||
"identity": {
|
||||
"subject": "a1b2-c3d4",
|
||||
"email": "designer@example.com",
|
||||
"name": "A Designer",
|
||||
"roles": ["editor"],
|
||||
"method": "oidc"
|
||||
},
|
||||
"permissions": {
|
||||
"read": true, "write": true, "publish": true,
|
||||
"delete": false, "manageKeys": false, "manageUsers": false, "manageSettings": false
|
||||
},
|
||||
"preferences": {
|
||||
"selectedTheme": "midnight",
|
||||
"selectedAppearance": "dark"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`preferences` is present only when `authenticated` is true. It rides along with the
|
||||
session so the UI can preselect a theme from the same round trip that establishes who
|
||||
the caller is, rather than making a second request before it can paint.
|
||||
|
||||
### Preferences
|
||||
|
||||
Per-account interface preferences: which published theme the person has chosen, and
|
||||
which of its two colour schemes to render. Any authenticated role may read and write
|
||||
these, because a preference only affects what that one person sees and is keyed to
|
||||
their own subject.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/me/preferences` | The caller's preference, or empty when none is stored |
|
||||
| `PUT` | `/api/v1/me/preferences` | Record a preference and echo back what was stored |
|
||||
| `DELETE` | `/api/v1/me/preferences` | Forget it, returning the interface defaults |
|
||||
|
||||
```http
|
||||
PUT /api/v1/me/preferences
|
||||
{"selectedTheme": "midnight", "selectedAppearance": "dark"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"selectedTheme": "midnight", "selectedAppearance": "dark"}
|
||||
```
|
||||
|
||||
Both fields empty means nothing has been chosen, and that is a valid state rather than
|
||||
a missing one: the client applies its own default. An empty `selectedTheme` clears the
|
||||
choice, and a `selectedAppearance` sent with it is dropped rather than stored, because
|
||||
a scheme has nothing to select from.
|
||||
|
||||
`selectedTheme` names a **published** theme rather than copying it, so editing or
|
||||
unpublishing that theme shows through everywhere without touching a stored value. Two
|
||||
consequences are worth knowing:
|
||||
|
||||
- Saving a draft theme slug is a 400. Drafts are invisible to runtime consumers, so a
|
||||
preference pointing at one would leave the UI rendering a theme it cannot load.
|
||||
- A preference naming a theme that has since been deleted or archived **reads back as
|
||||
no choice**, so a stale value cannot pin the interface to something that no longer
|
||||
exists. The row is left alone; only the read is filtered.
|
||||
|
||||
The response is authoritative and should be applied in place of the request. It is the
|
||||
only way a client learns that its value was filtered or cleared.
|
||||
|
||||
Preferences are deliberately **not** claims in the session token. The token is issued by
|
||||
the identity provider and this service does not control it, so a choice made here has
|
||||
to be read back from this API. They are also stored per account rather than per
|
||||
browser, so they survive a re-login (`UpsertUser` rewrites the `users` projection on
|
||||
every login) and follow the person to another device.
|
||||
|
||||
### Themes
|
||||
|
||||
| Method | Path | Role |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/themes` | viewer |
|
||||
| `GET` | `/api/v1/themes/{slug}` | viewer |
|
||||
| `GET` | `/api/v1/themes/{slug}/export` | viewer |
|
||||
| `POST` | `/api/v1/themes` | editor |
|
||||
| `PATCH` | `/api/v1/themes/{slug}` | editor |
|
||||
| `POST` | `/api/v1/themes/{slug}/publish` | editor |
|
||||
| `POST` | `/api/v1/themes/{slug}/archive` | editor |
|
||||
| `POST` | `/api/v1/themes/{slug}/duplicate` | editor |
|
||||
| `POST` | `/api/v1/themes/import` | editor |
|
||||
| `DELETE` | `/api/v1/themes/{slug}` | admin |
|
||||
|
||||
`GET /api/v1/themes` takes `q`, `status`, `system`, `tag`, `limit`, `offset`, `sort`
|
||||
(`updated`, `created`, `name`, `slug`) and `order`. An unrecognised `sort` falls back
|
||||
to the default rather than reaching SQL.
|
||||
|
||||
Listings omit token documents. They are the bulk of each row and a listing never needs
|
||||
them.
|
||||
|
||||
```bash
|
||||
curl -X POST https://themes.example.com/api/v1/themes \
|
||||
-H 'Content-Type: application/json' \
|
||||
-b cookies.txt \
|
||||
-d '{
|
||||
"name": "Acme Product",
|
||||
"slug": "acme",
|
||||
"description": "The default theme for customer-facing applications",
|
||||
"status": "draft",
|
||||
"tokens": { "color": { "semantic": { "primary": { "$type": "color", "$value": "#4f46e5" } } } }
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X PATCH https://themes.example.com/api/v1/themes/acme \
|
||||
-H 'Content-Type: application/json' -b cookies.txt \
|
||||
-d '{ "tokens": { … }, "baseVersion": 3, "message": "rebalanced the neutrals" }'
|
||||
```
|
||||
|
||||
`baseVersion` gives optimistic concurrency. If the theme has moved on, the write is
|
||||
rejected with 409 and a message naming both versions, rather than silently overwriting
|
||||
someone else's work.
|
||||
|
||||
Publishing validates first:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "validation_failed",
|
||||
"message": "the theme cannot be published because its tokens are invalid",
|
||||
"details": [
|
||||
{ "field": "color.semantic.primary", "message": "not a valid colour: \"#nothex\"" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### History
|
||||
|
||||
| Method | Path | Role |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/themes/{slug}/versions` | viewer |
|
||||
| `GET` | `/api/v1/themes/{slug}/versions/{version}` | viewer |
|
||||
| `GET` | `/api/v1/themes/{slug}/diff?from=1&to=2` | viewer |
|
||||
| `POST` | `/api/v1/themes/{slug}/versions/{version}/rollback` | editor |
|
||||
|
||||
Rollback **appends**: the snapshot becomes the current state as a new version.
|
||||
History is never rewritten, so an application deployed against version 7 can never
|
||||
have version 7 taken away.
|
||||
|
||||
```json
|
||||
{
|
||||
"fromVersion": 6,
|
||||
"toVersion": 7,
|
||||
"added": ["color.semantic.tertiary"],
|
||||
"removed": [],
|
||||
"changed": [
|
||||
{ "path": "color.semantic.primary", "from": "#4f46e5", "to": "#7c6bf5" }
|
||||
],
|
||||
"metaChanged": false
|
||||
}
|
||||
```
|
||||
|
||||
### Token tooling
|
||||
|
||||
Read-only analysis of a submitted document. Any authenticated user may call these.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/v1/tokens/validate` | Validate without saving |
|
||||
| `GET` | `/api/v1/tokens/schema` | The group and kind registry |
|
||||
| `GET` | `/api/v1/tokens/palette?hue=&saturation=&lightness=&hueSpread=` | Generate a ramp |
|
||||
| `GET` | `/api/v1/tokens/contrast?fg=…&bg=…` | WCAG contrast |
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"tokenCount": 214,
|
||||
"issues": [
|
||||
{ "path": "color.semantic.primary", "severity": "error", "message": "not a valid colour: \"#nothex\"" },
|
||||
{ "path": "$appearances", "severity": "warning", "message": "no dark appearance overrides are defined", "hint": "add {\"$appearances\":{\"dark\":{…}}}" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"foreground": "#7c6bf5",
|
||||
"background": "#1a1d23",
|
||||
"ratio": 6.42,
|
||||
"rounded": 6.42,
|
||||
"level": "aa",
|
||||
"thresholds": { "aaLarge": 3, "aa": 4.5, "aaa": 7 },
|
||||
"bestForeground": "#ffffff"
|
||||
}
|
||||
```
|
||||
|
||||
### Administration
|
||||
|
||||
| Method | Path | Role |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/stats` | viewer |
|
||||
| `GET` | `/api/v1/admin/users` | admin |
|
||||
| `GET` | `/api/v1/admin/audit` | admin |
|
||||
| `GET` | `/api/v1/admin/settings` | admin |
|
||||
| `PUT` | `/api/v1/admin/settings` | admin |
|
||||
| `POST` | `/api/v1/admin/cache/rebuild` | admin |
|
||||
|
||||
Creating a key returns the plaintext **once**:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": { "id": "key_x7f2", "name": "CI publish", "prefix": "gb_a1b2c3d4", "scopes": ["themes:write"] },
|
||||
"secret": "gb_a1b2c3d4_…",
|
||||
"warning": "This secret is shown once and cannot be recovered. Store it now."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Operational endpoints
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `/livez`, `/healthz` | Liveness, with build information on `/healthz` |
|
||||
| `/readyz` | Readiness. Checks the database |
|
||||
| `/metrics` | Prometheus, when `config.enableMetrics` |
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ready",
|
||||
"checks": {
|
||||
"database": { "status": "ok" },
|
||||
"themes": { "status": "ok", "count": 42, "databaseBytes": 212992 },
|
||||
"cache": { "status": "ok", "entries": 24 }
|
||||
}
|
||||
}
|
||||
```
|
||||
348
docs/architecture.md
Normal file
348
docs/architecture.md
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
# Architecture
|
||||
|
||||
How go-bananas is put together, and why each decision was made. The reasoning
|
||||
behind the load-bearing choices lives in [the ADRs](adr/); this document is the map.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [The shape of the system](#the-shape-of-the-system)
|
||||
- [Packages](#packages)
|
||||
- [The dependency rule](#the-dependency-rule)
|
||||
- [Request lifecycle](#request-lifecycle)
|
||||
- [The token engine](#the-token-engine)
|
||||
- [The render cache](#the-render-cache)
|
||||
- [Publishing](#publishing)
|
||||
- [The database](#the-database)
|
||||
- [The stack](#the-stack)
|
||||
- [Cross-cutting concerns](#cross-cutting-concerns)
|
||||
- [Performance](#performance)
|
||||
- [What is deliberately absent](#what-is-deliberately-absent)
|
||||
|
||||
---
|
||||
|
||||
## The shape of the system
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
browser ──────────────▶│ /rt/v1 runtime public, cached │
|
||||
<link>, fetch, import │ /api/v1 admin authenticated │
|
||||
│ /auth/* OIDC login flow │
|
||||
│ /metrics Prometheus │
|
||||
│ /healthz /readyz /livez │
|
||||
│ /* admin SPA (static) │
|
||||
└───────────────────┬────────────────────────┘
|
||||
│
|
||||
chi router (v5)
|
||||
│
|
||||
┌─────────────────────┼─────────────────────┐
|
||||
│ │ │
|
||||
┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
│ middleware │ │ router │ │ handlers │
|
||||
│ 13 layers │ │ route tree │ │ no logic │
|
||||
└─────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
┌─────▼──────┐ │ ┌──────▼──────┐
|
||||
│ auth │ │ │ tokenengine │
|
||||
│ OIDC·keys │ │ │ colour·CSS │
|
||||
│ ·RBAC │ │ │ ·validate │
|
||||
└─────┬──────┘ │ └──────┬──────┘
|
||||
│ │ │
|
||||
└──────────┬──────────┴─────────────────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ domain │ entities, invariants
|
||||
└─────────┬──────────┘ errors, validation rules
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ store │ the only package with SQL
|
||||
│ SQLite (WAL) │
|
||||
└─────────<E29480><E29480>──────────┘
|
||||
│
|
||||
SQLite file on disk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Responsibility | May import |
|
||||
| --- | --- | --- |
|
||||
| `cmd/go-bananas` | `main()`. Signal handling and an exit code. Nothing else. | `app` |
|
||||
| `app` | Dependency injection, the process lifecycle, static file serving. | everything except `cmd` |
|
||||
| `config` | Environment parsing, validation, defaults. | stdlib |
|
||||
| `domain` | Entities, invariants, sentinel errors, slug rules. | stdlib |
|
||||
| `tokenengine` | Colour maths, validation, CSS and JSON rendering, palettes. | `domain` |
|
||||
| `store` | The only package that writes SQL. Migrations, repositories. | `domain` |
|
||||
| `auth` | OIDC, session cookies, role resolution. | `config`, `domain` |
|
||||
| `httpapi` | Routing, middleware, request and response encoding. | `domain`, `store`, `tokenengine`, `auth`, `config` |
|
||||
| `logging` | slog configuration and the `Logger` interface. | stdlib |
|
||||
| `seed` | The four built-in themes, and their reconciliation rules. | `domain`, `tokenengine` |
|
||||
| `observability` | Reserved for OpenTelemetry wiring. | — |
|
||||
|
||||
### The dependency rule
|
||||
|
||||
```
|
||||
cmd → app → httpapi → auth → config
|
||||
↓ ↓
|
||||
tokenengine store
|
||||
↓ ↓
|
||||
domain ←──┘
|
||||
```
|
||||
|
||||
Enforced by review, not by tooling, because a linter for this is more trouble than
|
||||
it is worth. The rule that matters most: **`store` is the only package that contains
|
||||
SQL**. A handler never writes a query, which is what makes replacing the database a
|
||||
change to one package.
|
||||
|
||||
---
|
||||
|
||||
## Request lifecycle
|
||||
|
||||
A runtime stylesheet request, which is the overwhelming majority of traffic:
|
||||
|
||||
```
|
||||
1. RequestIDMiddleware assign or propagate a correlation id
|
||||
2. RecoverMiddleware a panic becomes a 500, not a dead process
|
||||
3. LoggingMiddleware one structured line, on completion
|
||||
4. TrustedProxyMiddleware resolve the client address, believing X-Forwarded-For
|
||||
only from a configured proxy
|
||||
5. CORSMiddleware preflight, for the admin API
|
||||
6. SecurityHeaders CSP, nosniff, frame-deny, HSTS when TLS
|
||||
7. BodyLimitMiddleware 2 MiB cap
|
||||
8. AuthMiddleware resolve the caller; never rejects
|
||||
9. MetricsMiddleware record route pattern, status and latency
|
||||
10. RateLimitMiddleware fixed window, keyed by route class and client
|
||||
11. Handler
|
||||
store.Get(slug) ← the only database access, on a cache miss
|
||||
cache.CSS(...) ← one map lookup on a cache hit
|
||||
renderDeclarations(...) ← only on a miss
|
||||
writeCacheable(...) ETag, Cache-Control, 304 when unchanged
|
||||
```
|
||||
|
||||
Layers 1–3 are outermost so that everything after them is covered by the panic
|
||||
recovery. Layer 8 authenticates without rejecting, so a public endpoint can serve
|
||||
while a protected one responds 401 with a proper JSON body rather than a bare
|
||||
`net/http` rejection.
|
||||
|
||||
The steady-state cost is one map lookup, one string write, and no database access.
|
||||
|
||||
---
|
||||
|
||||
## The token engine
|
||||
|
||||
Pure functions over a token document. No I/O, no global state, safe for concurrent
|
||||
use — which is what lets the HTTP layer cache rendered themes aggressively and
|
||||
pre-warm them at start-up.
|
||||
|
||||
### Validation
|
||||
|
||||
`Validate(doc) → ValidationResult` returns every issue, ordered by severity then
|
||||
path so the output is deterministic. Three tiers:
|
||||
|
||||
- **Errors** block publication: an unknown group, a value of the wrong type, a
|
||||
malformed colour, a reserved key used as a group.
|
||||
- **Warnings** do not block: a missing recommended token, no dark scheme declared.
|
||||
- **Info** is advisory.
|
||||
|
||||
### Rendering
|
||||
|
||||
`Render(theme, options) → string` produces the stylesheet:
|
||||
|
||||
1. Resolve the base document.
|
||||
2. Deep-merge the requested appearance's overrides on top.
|
||||
3. Flatten to leaves, in sorted path order.
|
||||
4. Append derived tokens for values the author did not define.
|
||||
5. Emit scoped selectors: the theme's default appearance, the alternate, a `.dark`
|
||||
ancestor, and optionally `prefers-color-scheme`.
|
||||
6. Emit the component layer inside `@layer bananas`.
|
||||
|
||||
**Sorted path order** is not cosmetic. It makes the output byte-stable, so the ETag
|
||||
is stable, so a re-render of an unchanged theme produces a cache hit at the CDN rather
|
||||
than a 200 with a fresh body.
|
||||
|
||||
### Colour
|
||||
|
||||
Everything the engine needs is computed from WCAG 2.1 relative luminance:
|
||||
`ContrastRatio`, `BestForeground` (black or white, whichever wins), `Mix` with
|
||||
straight-alpha compositing, `HSLHex`, and `GenerateRamp` — the palette builder behind
|
||||
the editor's one-click ramp and the reason the built-in themes are tonally consistent.
|
||||
|
||||
Non-`#rrggbb` syntaxes (`oklch()`, `color()`, CSS keywords) parse, render faithfully,
|
||||
and are excluded from contrast computation rather than guessed at.
|
||||
|
||||
---
|
||||
|
||||
## The render cache
|
||||
|
||||
The single most important performance component.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Key | `slug \| appearance \| variant` |
|
||||
| Value | the rendered string, plus a content-hash ETag |
|
||||
| Invalidated | on write, for exactly one theme's entries |
|
||||
| Eviction | none: themes are bounded by human authorship |
|
||||
| Pre-warmed | both appearances × all three variants, at start-up |
|
||||
|
||||
Rendering a theme traverses its whole token document. Serving the cached string is one
|
||||
map lookup. Because the key includes the version, publishing invalidates precisely
|
||||
that theme and nothing else, and a re-render after a restart produces byte-identical
|
||||
output, so a CDN's existing entry stays valid.
|
||||
|
||||
The ETag is a content hash rather than a version counter. Two themes with different
|
||||
slugs can produce byte-identical CSS, and a content hash lets a shared CDN
|
||||
deduplicate them. It is also self-correcting: a validator can never disagree with the
|
||||
body it describes.
|
||||
|
||||
---
|
||||
|
||||
## Publishing
|
||||
|
||||
```
|
||||
POST /api/v1/themes/{slug}/publish
|
||||
│
|
||||
├─ 1. load the theme
|
||||
├─ 2. Validate(tokens) ← errors block; warnings do not
|
||||
├─ 3. transaction:
|
||||
│ UPDATE themes SET status='published', version=version+1, …
|
||||
│ INSERT INTO theme_versions … ← immutable snapshot
|
||||
├─ 4. cache.Invalidate(slug) ← exactly this theme
|
||||
└─ 5. audit append ← actor, action, target, IP
|
||||
```
|
||||
|
||||
Every mutation bumps `version`, because the version is the cache key for the rendered
|
||||
CSS and must change whenever the rendered output could.
|
||||
|
||||
History is **append-only**. A rollback restores an old snapshot as a *new* version
|
||||
rather than rewinding, so an application already deployed against version 7 can never
|
||||
have version 7 silently taken away.
|
||||
|
||||
---
|
||||
|
||||
## The database
|
||||
|
||||
See [ADR 0001](adr/0001-embedded-sqlite.md) for the full argument. In brief: a single
|
||||
file, WAL journalling, `synchronous=NORMAL`, a 16 MiB page cache, mmap reads, and a
|
||||
deliberately small connection pool.
|
||||
|
||||
Migrations are embedded with `go:embed`, applied at start-up, and tracked in a
|
||||
`schema_migrations` table. They are idempotent by construction.
|
||||
|
||||
Timestamps are TEXT in RFC 3339, which sorts lexicographically, so `ORDER BY` and range
|
||||
filters need no date parsing and no timezone ambiguity.
|
||||
|
||||
Token documents are JSON columns. They are always read and written whole, never
|
||||
queried into — which is exactly the case where a JSON column is the right shape and a
|
||||
join table would be ceremony with no benefit.
|
||||
|
||||
---
|
||||
|
||||
## The stack
|
||||
|
||||
| Layer | Choice | Why |
|
||||
| --- | --- | --- |
|
||||
| Language | Go 1.27 | Static binaries, trivial cross-compilation, a good standard library |
|
||||
| Router | chi v5 | Small, `net/http`-compatible, middleware composition, no magic |
|
||||
| Database | `modernc.org/sqlite` | Pure Go: no cgo, static binary, no libc |
|
||||
| OIDC | `coreos/go-oidc` v3 | Discovery-driven: provider-agnostic from one issuer URL |
|
||||
| Session | HMAC-signed cookie | Replica-agnostic, no shared state |
|
||||
| Logging | `log/slog` | Standard library; no vendor in the graph |
|
||||
| Metrics | hand-written collector | ~200 lines, no dependency in a security-sensitive service |
|
||||
| Frontend | React 19 | The most common consumer, and the admin UI's own choice |
|
||||
| Build | Vite 8 | Fast, and the SDK is served rather than published |
|
||||
| Styling | Tailwind 4 | The admin UI consumes runtime variables through Tailwind's namespaces |
|
||||
| Data | TanStack Query 5 | Server-state management without inventing a cache |
|
||||
| Validation | Zod 4 | Runtime parsing at the network boundary |
|
||||
| Routing | React Router 7 | Data router; `lazy` route splitting |
|
||||
| Language | TypeScript 6.0.3 | Newest release with the compiler API the linters need — [ADR 0004](adr/0004-typescript-version.md) |
|
||||
|
||||
Deliberately absent: no ORM (the queries are simple and an ORM would hide them), no
|
||||
DI framework (constructor injection is enough), no GraphQL (a stylesheet is a GET), no
|
||||
Redis (the cache is process-local), no component library in the admin UI (it would
|
||||
fight the runtime variables).
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting concerns
|
||||
|
||||
### Authentication
|
||||
|
||||
Two mechanisms: an OIDC session cookie, and a development bypass that refuses to
|
||||
initialise outside development.
|
||||
|
||||
The session cookie is the only credential. There is no bearer-token path, so a
|
||||
request carrying an `Authorization` header is treated exactly like one without: a
|
||||
stale or forged token cannot grant anything. Machine credentials were removed
|
||||
because there is no machine caller left — the runtime API is public and the
|
||||
administration API is for people.
|
||||
|
||||
Roles come from provider groups. The mapping fails **closed**: a user in no
|
||||
configured group is a viewer. A mapping mistake must grant nothing, not everything.
|
||||
|
||||
### Authorisation
|
||||
|
||||
Enforced in the server on every admin route. The UI hides what the caller cannot do,
|
||||
as a convenience — never as the control.
|
||||
|
||||
### Audit
|
||||
|
||||
Every mutation records actor, action, target, timestamp and IP. The actor comes from
|
||||
the authenticated identity, never from the request body. A write failure is logged,
|
||||
not propagated: losing an audit row is bad, failing a publish because of it is worse.
|
||||
|
||||
### Observability
|
||||
|
||||
Structured JSON logs with a correlation id on every line. Prometheus metrics with
|
||||
request counts by route pattern (never by concrete path — a thousand theme slugs must
|
||||
not become a thousand time series), a latency histogram, cache hit rate, and Go
|
||||
runtime gauges. Three probes: `/livez` for liveness, `/healthz` for liveness with
|
||||
build information, `/readyz` for readiness, which is the only one that touches the
|
||||
database.
|
||||
|
||||
### Errors
|
||||
|
||||
One vocabulary. The domain returns sentinel errors; the store translates driver
|
||||
errors once; `writeProblem` maps sentinels to status codes once; the frontend client
|
||||
turns a non-2xx into an `ApiError` carrying the code and the per-field messages. A
|
||||
handler never chooses a status code itself, and no repository matches an error string.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
What the runtime path actually costs, with a warm cache:
|
||||
|
||||
| Operation | Cost |
|
||||
| --- | --- |
|
||||
| Stylesheet hit | one map lookup + one string write |
|
||||
| Stylesheet miss | token document traversal, ~100 µs for 200 tokens |
|
||||
| Manifest | one query, small result, cached by the CDN for 30 s |
|
||||
| Admin list | one indexed query |
|
||||
| Save | one transaction, two writes, one cache invalidation |
|
||||
|
||||
Measured on the built-in themes, a cold stylesheet render is sub-millisecond, which is
|
||||
why the render cache is an optimisation rather than a requirement. It exists to remove
|
||||
the work entirely under load, and to make the first request after a deploy fast.
|
||||
|
||||
The design consequence: **the hot path is not the database**. That is what makes an
|
||||
embedded database viable here, and it is why the connection pool is small on purpose.
|
||||
|
||||
---
|
||||
|
||||
## What is deliberately absent
|
||||
|
||||
| Not used | Why |
|
||||
| --- | --- |
|
||||
| An ORM | The queries are ten lines each; an ORM would hide the SQL that matters for the hot path |
|
||||
| Redis | The render cache is process-local and loses nothing on restart. A second service to operate for a cache is a bad trade — until multi-node, where it becomes a good one |
|
||||
| DI framework | Constructor injection in `app.New` is clearer than a container |
|
||||
| GraphQL | A stylesheet is a GET. A schema, a server and a client library to deliver a JSON document is a poor trade |
|
||||
| A component library in the admin UI | It would fight the runtime variables, which are the point |
|
||||
| Server-side rendering of tokens per request | Every consumer would implement its own fetcher, cache, fallback and revalidation, and they would all get it subtly wrong |
|
||||
| A message queue | The write path is a human clicking publish |
|
||||
| Feature flags | The chart covers configuration; a flag system for four booleans is a second way to be wrong |
|
||||
|
||||
Each of these would be the right answer under different constraints, and the ADR
|
||||
process is how one of them becomes the answer here.
|
||||
547
docs/deployment.md
Normal file
547
docs/deployment.md
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
# Deployment
|
||||
|
||||
Running go-bananas, from a laptop to a production cluster.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [The deployment checklist](#the-deployment-checklist)
|
||||
- [Configuration](#configuration)
|
||||
- [Authentication](#authentication)
|
||||
- [Helm](#helm)
|
||||
- [The production checklist](#the-production-checklist)
|
||||
- [Scaling](#scaling)
|
||||
- [Backup and restore](#backup-and-restore)
|
||||
- [Behind an ingress](#behind-an-ingress)
|
||||
- [Monitoring](#monitoring)
|
||||
- [Upgrading](#upgrading)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## The deployment checklist
|
||||
|
||||
Four things must be right. Everything else has a working default.
|
||||
|
||||
1. **`config.publicBaseUrl`** — the externally reachable origin, no trailing slash.
|
||||
It builds the OIDC redirect URI, the absolute URLs in the runtime manifest and the
|
||||
HSTS decision. Wrong means a login loop.
|
||||
2. **`identity.mode`** — `oidc` in production. `dev` and `disabled` are refused by the
|
||||
application outside a development environment.
|
||||
3. **The OIDC client is registered** with that exact redirect URI:
|
||||
`{publicBaseUrl}/auth/callback`.
|
||||
4. **`identity.oidc.existingSecret`** — a Secret you manage, holding a session secret
|
||||
of at least 32 bytes and the provider's client secret.
|
||||
|
||||
The Helm chart refuses to render a manifest it knows is wrong, so items 1–2 fail at
|
||||
`helm template` time rather than at run time.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything is an environment variable. Both `BANANAS_` and `GO_BANANAS_` prefixes are
|
||||
accepted, so a ConfigMap can be mounted under a distinct prefix without colliding
|
||||
with the pod's own environment. That is what makes one image usable across
|
||||
environments.
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `BANANAS_ENVIRONMENT` | `development` | `production` disables every development affordance |
|
||||
| `BANANAS_HTTP_ADDR` | `:8080` | |
|
||||
| `BANANAS_PUBLIC_BASE_URL` | `http://localhost:8080` | No trailing slash. Required in practice |
|
||||
| `BANANAS_BASE_PATH` | — | For a path-based ingress |
|
||||
| `BANANAS_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
||||
| `BANANAS_LOG_FORMAT` | `text` | `json` in production |
|
||||
| `BANANAS_HTTP_READ_TIMEOUT` | `15s` | |
|
||||
| `BANANAS_HTTP_WRITE_TIMEOUT` | `30s` | Generous: this is the hot asset path |
|
||||
| `BANANAS_SHUTDOWN_TIMEOUT` | `25s` | Must be below the pod's grace period |
|
||||
|
||||
### Storage
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `BANANAS_DATABASE_PATH` | `./data/go-bananas.db` | |
|
||||
| `BANANAS_DB_MAX_CONNS` | `8` | Small on purpose. SQLite serialises writers |
|
||||
| `BANANAS_DB_BUSY_TIMEOUT` | `5s` | How long a blocked writer waits |
|
||||
| `BANANAS_SEED_ON_START` | `true` | Reconciles the built-in themes |
|
||||
|
||||
### Runtime
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `BANANAS_RUNTIME_PUBLIC` | `true` | The runtime API requires no credential by design |
|
||||
| `BANANAS_RUNTIME_CACHE_CONTROL` | `public, max-age=60, s-maxage=300, stale-while-revalidate=86400` | |
|
||||
| `BANANAS_RUNTIME_CDN_BASE_URL` | — | Absolute base for the manifest, when assets come from a CDN |
|
||||
| `BANANAS_RATE_LIMIT_PER_MINUTE` | `3000` | High: the pattern is one request per page view |
|
||||
|
||||
### Network
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `BANANAS_TRUSTED_PROXIES` | — | CIDRs whose `X-Forwarded-For` is believed. Empty means forwarding headers are ignored entirely |
|
||||
| `BANANAS_CORS_ALLOWED_ORIGINS` | — | For the admin API only. The runtime API is public by design |
|
||||
| `BANANAS_ENABLE_METRICS` | `true` | |
|
||||
| `BANANAS_ENABLE_PROFILER` | `false` | A network-reachable profiler is a DoS surface. Pair with a NetworkPolicy |
|
||||
|
||||
### Authentication
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `BANANAS_AUTH_MODE` | `dev` | `oidc`, `dev`, `none`. The latter two are refused outside development |
|
||||
| `BANANAS_SESSION_SECRET` | ephemeral in dev | At least 32 bytes. Rotating it logs out everyone |
|
||||
| `BANANAS_SESSION_TTL` | `8h` | |
|
||||
| `BANANAS_SESSION_SECURE` | `true` outside dev | |
|
||||
| `BANANAS_SESSION_SAMESITE` | `Lax` | `None` requires `Secure` |
|
||||
| `BANANAS_OIDC_ISSUER` | — | Must match the `iss` claim exactly |
|
||||
| `BANANAS_OIDC_CLIENT_ID` | — | |
|
||||
| `BANANAS_OIDC_CLIENT_SECRET` | — | From a Secret, never a ConfigMap |
|
||||
| `BANANAS_OIDC_SCOPES` | `profile,email,groups` | `groups` is what the role mapping reads |
|
||||
| `BANANAS_OIDC_ADMIN_GROUPS` | — | Comma separated |
|
||||
| `BANANAS_OIDC_EDITOR_GROUPS` | — | |
|
||||
| `BANANAS_OIDC_VIEWER_GROUPS` | — | |
|
||||
| `BANANAS_OIDC_REDIRECT_URL` | derived | Override only if the provider has a different URI registered |
|
||||
| `BANANAS_OIDC_SKIP_TLS_VERIFY` | `false` | Development only. Refused in production |
|
||||
|
||||
Full reference in `internal/config/config.go`, which is the single source of truth and
|
||||
carries the reasoning for each default.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### The three modes
|
||||
|
||||
| Mode | Behaviour | Allowed in production |
|
||||
| --- | --- | --- |
|
||||
| `oidc` | OpenID Connect against a real provider | **Yes. This is the mode.** |
|
||||
| `dev` | Every request is a local administrator | No. Refused by the application |
|
||||
| `none` | Every request is granted administrator access | No. Refused by the application |
|
||||
|
||||
The refusal is in the code, not in documentation, and it is a hard `fail` in the
|
||||
constructor — an accidentally deployed bypass is a total authentication bypass, so it
|
||||
is made impossible to construct.
|
||||
|
||||
### The shared development realm
|
||||
|
||||
The chart defaults to `https://auth-dev.urpi.be/realms/urpi`, so a development cluster
|
||||
works with no identity configuration.
|
||||
|
||||
The realm path is part of the issuer. The value must match the `iss` claim in the ID
|
||||
token **exactly**, including a trailing slash if the provider emits one. A mismatch
|
||||
here is the single most common cause of `invalid issuer` at the callback, and it is
|
||||
worth checking first when a login loop appears.
|
||||
|
||||
### Registering the client
|
||||
|
||||
Redirect URIs, exact:
|
||||
|
||||
```
|
||||
https://themes.example.com/auth/callback
|
||||
https://bananas-dev.example.com/auth/callback
|
||||
```
|
||||
|
||||
Post-logout redirect, if the provider requires one:
|
||||
|
||||
```
|
||||
https://themes.example.com/
|
||||
```
|
||||
|
||||
Web origins, for a cross-origin admin UI (same-origin needs none):
|
||||
|
||||
```
|
||||
https://app.example.com
|
||||
```
|
||||
|
||||
### Group-to-role mapping
|
||||
|
||||
```yaml
|
||||
identity:
|
||||
oidc:
|
||||
adminGroups: [platform-admins]
|
||||
editorGroups: [design-team]
|
||||
viewerGroups: [everyone]
|
||||
```
|
||||
|
||||
The most privileged matching group wins. **A user in no configured group gets
|
||||
viewer.** That is deliberate: a mapping mistake must fail closed.
|
||||
|
||||
Group names are realm-specific and are deliberately not guessed by the chart. Read
|
||||
them from the provider:
|
||||
|
||||
```bash
|
||||
curl -s "$ISSUER/protocol/openid-connect/certs" | jq -r '.keys[].kid'
|
||||
# or, with a token:
|
||||
curl -s -H "Authorization: Bearer $TOKEN" "$ISSUER/admin/realms/urpi/groups" | jq '.[].name'
|
||||
```
|
||||
|
||||
### A note on sessions
|
||||
|
||||
The session is a signed cookie with no server-side record
|
||||
([ADR 0003](adr/0003-stateful-sessions.md)). Concretely:
|
||||
|
||||
- any replica authenticates any request, so a rollout logs nobody out;
|
||||
- a stolen session cannot be revoked before it expires — bounded by
|
||||
`BANANAS_SESSION_TTL`, eight hours by default;
|
||||
- full invalidation means rotating the session secret, which logs out everyone.
|
||||
|
||||
For a design-token service this is the right trade. If you need per-session
|
||||
revocation, or your organisation's policy requires it, this is the one component to
|
||||
replace.
|
||||
|
||||
---
|
||||
|
||||
## Helm
|
||||
|
||||
```bash
|
||||
helm upgrade --install go-bananas oci://ghcr.io/urpi/charts/go-bananas \
|
||||
--namespace go-bananas --create-namespace \
|
||||
--set config.publicBaseUrl=https://themes.example.com \
|
||||
--set identity.oidc.issuer=https://auth.example.com/realms/urpi \
|
||||
--set identity.oidc.clientId=go-bananas \
|
||||
--set identity.oidc.existingSecret=go-bananas-auth \
|
||||
--set identity.oidc.adminGroups[0]=platform-admins \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=themes.example.com \
|
||||
--set ingress.tls[0].secretName=go-bananas-tls \
|
||||
--set ingress.tls[0].hosts[0]=themes.example.com \
|
||||
--wait --timeout 5m
|
||||
```
|
||||
|
||||
### The Secret
|
||||
|
||||
```bash
|
||||
kubectl create secret generic go-bananas-auth \
|
||||
--namespace go-bananas \
|
||||
--from-literal=session-secret="$(openssl rand -base64 48)" \
|
||||
--from-literal=oidc-client-secret="$CLIENT_SECRET_FROM_YOUR_SECRET_MANAGER"
|
||||
```
|
||||
|
||||
Key names are configurable through `identity.sessionSecretKey`,
|
||||
`identity.oidc.clientSecretKey` and `identity.oidc.existingSecretKeys`, for a
|
||||
Secret managed by an external secrets operator.
|
||||
|
||||
A chart-managed Secret (`identity.oidc.generateSecret=true`) generates the session key
|
||||
and preserves it across upgrades by reading the live Secret back — so an upgrade does
|
||||
not sign everyone out. It **cannot** generate the OIDC client secret, because that has
|
||||
to come from the provider. A generated Secret therefore has an empty client secret,
|
||||
which means the deployment fails to authenticate until one is supplied. For anything
|
||||
real, use `existingSecret`.
|
||||
|
||||
### Validation at render time
|
||||
|
||||
The chart fails rather than producing a manifest it knows is wrong:
|
||||
|
||||
| Misconfiguration | Message |
|
||||
| --- | --- |
|
||||
| `publicBaseUrl` empty | Required; names what it is used for |
|
||||
| `publicBaseUrl` has a trailing slash | The exact acceptable form |
|
||||
| `shutdownTimeoutMs` ≥ grace period | Kubernetes would SIGKILL mid-shutdown |
|
||||
| `identity.mode` unknown | The three valid modes |
|
||||
| `identity.mode=oidc`, no issuer | Required, with the development-realm form |
|
||||
| `identity.mode=oidc`, no secret | Names both ways to supply one |
|
||||
| Both `existingSecret` and `generateSecret` | Mutually exclusive |
|
||||
| `skipTLSVerify` with `environment: production` | Refused, with the reason |
|
||||
| Any value outside the schema | The JSON-schema path |
|
||||
|
||||
Failing at render time means a bad release never reaches the API server: there is no
|
||||
half-created Deployment and no `CrashLoopBackOff` to diagnose at 3am.
|
||||
|
||||
### Render it before you apply it
|
||||
|
||||
```bash
|
||||
make helm-template # defaults
|
||||
make helm-template-auth # configured for OIDC
|
||||
helm lint deploy/helm/go-bananas --strict
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The production checklist
|
||||
|
||||
- [ ] `config.environment: production`
|
||||
- [ ] `config.publicBaseUrl` is the real HTTPS origin, no trailing slash
|
||||
- [ ] `identity.mode: oidc`
|
||||
- [ ] The OIDC client registered with `{publicBaseUrl}/auth/callback`
|
||||
- [ ] `identity.oidc.issuer` verified against the provider's discovery document
|
||||
- [ ] Group-to-role mapping populated and **tested with a real account** in each role
|
||||
- [ ] `identity.oidc.existingSecret` set; the session secret is at least 32 random bytes
|
||||
- [ ] Ingress with TLS; `service.sessionSecure: true`
|
||||
- [ ] `config.trustedProxies` set to the ingress controller, so the audit log records real client addresses
|
||||
- [ ] Persistence enabled; a snapshot scheduled
|
||||
- [ ] `networkPolicy.enabled: true`
|
||||
- [ ] Resources set, with a memory limit
|
||||
- [ ] Prometheus scraping `/metrics`
|
||||
- [ ] A backup job that copies the SQLite file
|
||||
- [ ] `make helm-template` reviewed
|
||||
|
||||
### Verify a deployment
|
||||
|
||||
```bash
|
||||
kubectl --namespace go-bananas get pods -l app.kubernetes.io/instance=go-bananas
|
||||
kubectl --namespace go-bananas port-forward svc/go-bananas 8080:80
|
||||
|
||||
curl -s localhost:8080/readyz | jq # "status": "ready"
|
||||
curl -s localhost:8080/rt/v1/manifest.json | jq '.themes[].slug'
|
||||
curl -sI 'localhost:8080/rt/v1/theme.css?slug=midnight' | grep -i x-theme-version
|
||||
```
|
||||
|
||||
Then open the admin interface and confirm a real account can sign in and see a theme.
|
||||
|
||||
---
|
||||
|
||||
## Scaling
|
||||
|
||||
### Read scaling: put a CDN in front
|
||||
|
||||
The runtime is the hot path and it is entirely cacheable. Immutable-by-URL responses
|
||||
mean a CDN is a complete answer for read scaling.
|
||||
|
||||
```yaml
|
||||
config:
|
||||
runtimeCdnBaseUrl: https://cdn.example.com/themes
|
||||
```
|
||||
|
||||
The manifest then hands consumers the CDN URL, so they never reach the service at all.
|
||||
|
||||
### The one real constraint: one writer
|
||||
|
||||
SQLite on a local volume is single-node. Three shapes:
|
||||
|
||||
**One replica, one volume.** The recommended production shape. One replica serves a
|
||||
very large number of consumers, because after the render cache a request is a map
|
||||
lookup.
|
||||
|
||||
**Many replicas, a shared filesystem.** Works with NFS, CephFS or a ReadWriteMany
|
||||
volume — anything that permits concurrent mounts and shared mmap, which WAL mode
|
||||
requires. Expect lower throughput than local disk; measure before choosing it.
|
||||
|
||||
```yaml
|
||||
persistence:
|
||||
type: persistentVolumeClaim
|
||||
accessMode: ReadWriteMany
|
||||
size: 2Gi
|
||||
storageClassName: nfs-client
|
||||
deployment:
|
||||
replicaCount: 3
|
||||
```
|
||||
|
||||
The chart annotates the Deployment when a ReadWriteOnce volume is combined with more
|
||||
than one replica, because that combination cannot work: only one pod mounts the
|
||||
volume and the others crash-loop.
|
||||
|
||||
### If you outgrow SQLite
|
||||
|
||||
Move to PostgreSQL. The change is contained to `internal/store` — the only package
|
||||
with SQL — and its interface is the set of methods it exposes rather than SQLite's
|
||||
features. [ADR 0001](adr/0001-embedded-sqlite.md) covers when that point arrives.
|
||||
|
||||
---
|
||||
|
||||
## Backup and restore
|
||||
|
||||
A backup is a copy of one file, and the supported way to take it is the `backup`
|
||||
subcommand:
|
||||
|
||||
```bash
|
||||
kubectl exec -n go-bananas deploy/go-bananas -- \
|
||||
/usr/local/bin/go-bananas backup --to /srv/data/snapshots/$(date -u +%Y%m%dT%H%M%SZ).db
|
||||
```
|
||||
|
||||
It uses SQLite's `VACUUM INTO`, which reads through the database's own backup
|
||||
machinery. Two consequences matter:
|
||||
|
||||
- **It is safe against a running service.** The snapshot is consistent even while
|
||||
the application is serving writes, and it does not block them for the length of
|
||||
the copy. There is no need to scale to zero.
|
||||
- **Do not substitute `cp` for it.** Copying a SQLite file while a write is in
|
||||
flight produces a torn copy: the header can describe one set of pages while the
|
||||
file holds another. The result frequently opens without complaint and is corrupt.
|
||||
Copying the `-wal` file alongside it is worse, because the two files are then
|
||||
captured at different instants and the pair may not correspond to any point in
|
||||
time.
|
||||
|
||||
The command refuses to overwrite an existing file, so a stale snapshot can never be
|
||||
mistaken for a fresh one, and it will not snapshot an in-memory database, which has
|
||||
nothing to preserve.
|
||||
|
||||
Run it from cron, a Kubernetes CronJob, or by hand. When you do it by hand, the
|
||||
output path is printed in full so you can confirm where the file went.
|
||||
|
||||
### Restoring
|
||||
|
||||
```bash
|
||||
kubectl scale deploy/go-bananas -n go-bananas --replicas=0
|
||||
kubectl cp ./backup.db go-bananas-0:/srv/data/go-bananas.db
|
||||
kubectl delete pod -n go-bananas -l app.kubernetes.io/instance=go-bananas
|
||||
kubectl scale deploy/go-bananas -n go-bananas --replicas=1
|
||||
```
|
||||
|
||||
A restored database serves immediately. The built-in themes are reconciled at
|
||||
start-up, and a built-in theme an operator had edited is recognised by its version and
|
||||
left alone.
|
||||
|
||||
Scale to zero before replacing the file. Copying over a database that another
|
||||
process has open produces exactly the torn copy described above, and the running
|
||||
service will keep writing to the file you just replaced.
|
||||
|
||||
### Retention
|
||||
|
||||
The audit log is the only unbounded table. Everything else is bounded by the number of
|
||||
themes and their versions. Prune it on a schedule:
|
||||
|
||||
```sql
|
||||
DELETE FROM audit_log WHERE at < datetime('now', '-180 days');
|
||||
```
|
||||
|
||||
The chart's snapshot CronJob (`persistence.snapshot.enabled`) writes to
|
||||
`<mountPath>/snapshots` on the same volume, so a snapshot survives the pod that took
|
||||
it. Restore from there with the same `backup` machinery in reverse.
|
||||
|
||||
---
|
||||
|
||||
## Behind an ingress
|
||||
|
||||
### Path-based
|
||||
|
||||
```yaml
|
||||
config:
|
||||
publicBaseUrl: https://example.com/themes
|
||||
basePath: /themes
|
||||
```
|
||||
|
||||
Every route, including the OIDC redirect, is prefixed. The provider must be registered
|
||||
with `https://example.com/themes/auth/callback`.
|
||||
|
||||
### Correct client addresses
|
||||
|
||||
`X-Forwarded-For` is believed only from a configured proxy. Without this, any client
|
||||
can set it and spoof its address in the audit log.
|
||||
|
||||
```yaml
|
||||
config:
|
||||
trustedProxies:
|
||||
- 10.42.0.0/16 # the ingress controller's node or pod CIDR
|
||||
```
|
||||
|
||||
### TLS
|
||||
|
||||
Terminate at the ingress. `service.sessionSecure: true` marks the session cookie
|
||||
`Secure`, and HSTS is advertised only when `publicBaseUrl` is `https://`.
|
||||
|
||||
Behind a proxy that terminates TLS and forwards `X-Forwarded-Proto`, the service
|
||||
derives scheme from `publicBaseUrl`, not from the header, which is the correct trust
|
||||
boundary: the operator states the truth in configuration rather than a client
|
||||
asserting it.
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Metrics
|
||||
|
||||
| Metric | Type | Alert when |
|
||||
| --- | --- | --- |
|
||||
| `bananas_http_requests_total` | counter | Rate collapses, or 5xx ratio climbs |
|
||||
| `bananas_http_request_duration_seconds` | histogram | p99 above ~250 ms |
|
||||
| `bananas_cache_hits_total` / `_misses_total` | counter | Hit rate below ~80% after warm-up |
|
||||
| `bananas_go_memory_bytes` | gauge | Approaches the limit |
|
||||
| `bananas_uptime_seconds` | gauge | Resets: the process restarted |
|
||||
|
||||
A sustained cache miss rate means a publish loop, a version-keyed cache storm, or
|
||||
more themes than expected. It is the first thing to look at when latency rises.
|
||||
|
||||
### Logs
|
||||
|
||||
One JSON line per request, carrying `requestId`, `route`, `status`, `duration`,
|
||||
`clientIp`, `subject` and `query`. The `subject` field is the authenticated identity,
|
||||
so "who did this" needs no second query.
|
||||
|
||||
```bash
|
||||
kubectl logs -n go-bananas -l app.kubernetes.io/instance=go-bananas \
|
||||
| jq 'select(.status >= 500)'
|
||||
```
|
||||
|
||||
### Probes
|
||||
|
||||
| Path | Use | Touches the database |
|
||||
| --- | --- | --- |
|
||||
| `/livez` | Liveness | No |
|
||||
| `/healthz` | Liveness, with build information | No |
|
||||
| `/readyz` | Readiness | **Yes** |
|
||||
|
||||
The split is deliberate. A liveness probe that touched the database would have the
|
||||
kubelet restart a process that is otherwise healthy, turning a dependency blip into
|
||||
an outage. Readiness does check the database, because a pod that cannot reach it
|
||||
cannot serve.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
helm upgrade go-bananas oci://ghcr.io/urpi/charts/go-bananas \
|
||||
--namespace go-bananas \
|
||||
--set image.tag=1.4.2 \
|
||||
--wait --timeout 5m
|
||||
```
|
||||
|
||||
Migrations apply at start-up and are idempotent. The built-in themes are reconciled:
|
||||
missing ones are created, and ones an operator has edited are recognised by version
|
||||
and left alone.
|
||||
|
||||
Enable `persistence.snapshot.enabled` for a scheduled snapshot. It is a CronJob
|
||||
that runs before each upgrade, and it costs a few megabytes.
|
||||
|
||||
### Rolling back
|
||||
|
||||
```bash
|
||||
helm rollback go-bananas 3 --namespace go-bananas --wait
|
||||
```
|
||||
|
||||
Safe, because the schema is additive and migrations are idempotent. If a future change
|
||||
is destructive, it gets a snapshot step in the chart and an entry in
|
||||
[the runbook](operations.md).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**A login loop.** The redirect URI does not match what the provider has registered.
|
||||
Check in this order:
|
||||
|
||||
1. `{publicBaseUrl}/auth/callback` in the provider's client configuration.
|
||||
2. `publicBaseUrl` has no trailing slash and matches the scheme exactly.
|
||||
3. `basePath` is reflected in the registered URI.
|
||||
4. `identity.oidc.issuer` matches the discovery document's `issuer` field **exactly**.
|
||||
|
||||
```bash
|
||||
kubectl exec -n go-bananas deploy/go-bananas -- \
|
||||
wget -qO- "$ISSUER/.well-known/openid-configuration" | jq -r .issuer
|
||||
```
|
||||
|
||||
**`invalid issuer`.** The `iss` claim in the ID token differs from the configured
|
||||
issuer. Usually a trailing slash, or a realm path that is not the one configured.
|
||||
|
||||
**`no such user in the roles mapping`.** Not an error this service produces: a user in
|
||||
no configured group gets viewer, silently. Check the `groups` claim is present —
|
||||
`groups` must be in `OIDC_SCOPES`.
|
||||
|
||||
**A pod stuck in `CrashLoopBackOff`.** Almost always configuration. The first log line
|
||||
is the reason; every configuration problem is reported in full at start-up.
|
||||
|
||||
**`SQLITE_BUSY`.** A writer is waiting for the lock. Raise
|
||||
`config.database.busyTimeoutMs`, or check for a publish loop.
|
||||
|
||||
**Replicas crash-looping with a ReadWriteOnce volume.** Exactly one pod can mount it.
|
||||
Set `deployment.replicaCount: 1`, or use a filesystem that permits concurrent mounts.
|
||||
|
||||
**A theme looks wrong in one application.** Compare `X-Theme-Version` across
|
||||
applications. A CDN or browser holding an old version explains it; the runtime
|
||||
manifest's `baseUrl` will show whether a CDN is in the path.
|
||||
|
||||
**The stylesheet is stale after a publish.** Expected within one `s-maxage` window
|
||||
(300 s by default). To force it, lower `config.runtimeCacheControl` or purge the CDN.
|
||||
223
docs/development.md
Normal file
223
docs/development.md
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# Development
|
||||
|
||||
Working on go-bananas.
|
||||
|
||||
## Requirements
|
||||
|
||||
| Tool | Version | Why |
|
||||
| --- | --- | --- |
|
||||
| Go | 1.27 or later | Static binaries, `net/http` routing primitives |
|
||||
| Node.js | 22 or later | Vite 8 requires it |
|
||||
| Docker | 24 or later | BuildKit for the multi-stage image |
|
||||
| Helm | 3.14 or later | Chart validation and `helm lint --strict` |
|
||||
|
||||
Optional, installed by `make tools`: `air` for hot reload, `golangci-lint` for
|
||||
linting, `sqlc` if you add a query.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/urpi/go-bananas
|
||||
cd go-bananas
|
||||
make install # Go modules, admin UI dependencies, SDK dependencies
|
||||
make tools # air, golangci-lint, sqlc
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
make dev-all # API on :8080, admin UI on :5173 with a proxy
|
||||
```
|
||||
|
||||
Open <http://localhost:5173>. The Vite dev server proxies `/api`, `/rt` and `/auth`
|
||||
to the Go process, so the session cookie works exactly as it will in production.
|
||||
Same-origin matters: a cross-origin setup would not authenticate at all, and the
|
||||
problem would look like a bug rather than a topology difference.
|
||||
|
||||
Other entry points:
|
||||
|
||||
```bash
|
||||
make dev-api # the API alone, with hot reload
|
||||
make dev-web # the admin UI alone
|
||||
make run # build everything, run the real binary
|
||||
```
|
||||
|
||||
### Trying the theme preference locally
|
||||
|
||||
The theme switcher in the header writes to the account, not just to the browser, so the
|
||||
selection follows you to another browser and survives a re-login. To see that end to
|
||||
end without a second browser:
|
||||
|
||||
```bash
|
||||
# 1. Start the stack and sign in. `make dev-all` defaults to AUTH_MODE=dev, so the
|
||||
# dev-user query parameter is enough to be somebody:
|
||||
# http://localhost:5173/?dev-user=designer@example.test
|
||||
|
||||
# 2. Pick a published theme and a scheme in the header switcher. That is a PUT to
|
||||
# /api/v1/me/preferences.
|
||||
|
||||
# 3. Read back what the server decided, which is the authoritative value:
|
||||
curl -s localhost:8080/api/v1/me/preferences \
|
||||
-H 'Cookie: go-bananas-session=...' | jq
|
||||
|
||||
# 4. Confirm it arrives with the session, which is how the UI gets it on first paint:
|
||||
curl -s localhost:8080/api/v1/auth/me \
|
||||
-H 'Cookie: go-bananas-session=...' | jq .preferences
|
||||
|
||||
# 5. Prove it is account state rather than cookie state by clearing local storage,
|
||||
# or by asking in a second browser profile: the theme you chose is still selected.
|
||||
```
|
||||
|
||||
Two things are deliberately not local state:
|
||||
|
||||
- **The theme is a preference, not a session claim.** The token comes from the identity
|
||||
provider, so this service cannot put anything in it; `/api/v1/me/preferences` is the
|
||||
contract instead.
|
||||
- **`localStorage` is still read on the first render.** A server round trip cannot
|
||||
happen before paint, and waiting for one flashes the wrong theme on every load. The
|
||||
stored preference is adopted as soon as the session arrives, and a change writes both
|
||||
places, so the next paint is correct even offline.
|
||||
|
||||
Pointing it at a draft theme returns 400, and pointing it at a theme that has since been
|
||||
archived reads back as no choice. Both are covered in `docs/api.md`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
cmd/go-bananas/ main(): signals, exit codes, nothing else
|
||||
internal/
|
||||
app/ dependency injection and the process lifecycle
|
||||
auth/ OIDC, sessions, roles
|
||||
config/ environment parsing and validation
|
||||
domain/ entities, invariants, sentinel errors
|
||||
httpapi/ routing, middleware, handlers
|
||||
logging/ slog configuration
|
||||
seed/ the built-in themes
|
||||
store/ SQLite: migrations and repositories
|
||||
tokenengine/ pure logic: colour, validation, rendering
|
||||
store/migrations/ embedded SQL, applied at start-up
|
||||
web/ the administration UI (React, Vite, Tailwind)
|
||||
sdk/ the runtime SDKs, served from /rt/v1
|
||||
deploy/helm/go-bananas/ the chart
|
||||
docs/ this, plus the ADRs
|
||||
```
|
||||
|
||||
See [architecture.md](architecture.md) for the dependency rule and
|
||||
[standards.md](standards.md) for the conventions. Read both before a substantial
|
||||
change.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
make test # everything
|
||||
make test-go # Go, with the race detector
|
||||
make test-web # the admin UI
|
||||
make test-sdk # the runtime SDK
|
||||
make test-cover # Go, with a coverage profile
|
||||
make bench # the token engine benchmarks
|
||||
```
|
||||
|
||||
Every test run in CI is `go test -race`. A data race in a cache or a store is a
|
||||
production incident waiting for load.
|
||||
|
||||
## The pre-merge gate
|
||||
|
||||
```bash
|
||||
make verify # check + helm lint
|
||||
```
|
||||
|
||||
This is exactly what CI runs. A pull request that does not pass it will not merge.
|
||||
|
||||
## Things that will surprise you
|
||||
|
||||
**The admin UI consumes the runtime.** It loads themes from `/rt/v1` and maps the
|
||||
variables onto Tailwind's namespaces. Switching the UI to the "Light" or "Dark" mode
|
||||
in the top bar loads a published theme's real stylesheet. A change to the token engine
|
||||
that breaks delivery breaks the editor, immediately.
|
||||
|
||||
**Publishing bumps the version, and the version is the cache key.** Every mutation
|
||||
does, not only publication. If you change `store.Update`, check whether the version
|
||||
needs to move.
|
||||
|
||||
**History is append-only.** A rollback restores a snapshot as a *new* version. There
|
||||
is no code path that removes a version, by design: an application deployed against
|
||||
version 7 must never have version 7 taken away.
|
||||
|
||||
**The built-in themes are reconciled, not overwritten.** `internal/seed` compares the
|
||||
stored version with the seed version. An edited built-in theme is recognised by its
|
||||
higher version and left alone.
|
||||
|
||||
**Validation gates publication, not editing.** A draft can be incomplete. That is
|
||||
what makes building a theme up incrementally practical.
|
||||
|
||||
## Common changes
|
||||
|
||||
### Adding a token group
|
||||
|
||||
1. Add a `GroupSpec` to `tokenengine.Groups`.
|
||||
2. Add the tokens to the built-in themes in `internal/seed`.
|
||||
3. Run `make test-go`. The seed tests validate every built-in theme and check WCAG
|
||||
contrast for body text on every surface, so an inaccessible colour fails the build.
|
||||
|
||||
### Adding an API endpoint
|
||||
|
||||
1. The handler in `internal/httpapi`. No business logic; call the store, encode the
|
||||
result.
|
||||
2. The route in `internal/httpapi/router.go`, with the role it requires.
|
||||
3. The type in `web/src/lib/types.ts` and the client function in `web/src/lib/api.ts`.
|
||||
4. The documentation in `docs/api.md`.
|
||||
5. Tests for each branch and each authorisation decision.
|
||||
|
||||
### Adding a configuration variable
|
||||
|
||||
1. The field and default in `internal/config`.
|
||||
2. The parsing in `loadFrom`.
|
||||
3. A validation rule in `Validate` if it can be wrong.
|
||||
4. The environment variable in the chart's `configmap.yaml` and `values.yaml`, and in
|
||||
`values.schema.json`.
|
||||
5. The table in `docs/deployment.md`.
|
||||
|
||||
A variable with no validation is a variable that will be set to something wrong.
|
||||
|
||||
### Changing the runtime contract
|
||||
|
||||
Add, do not change. `/rt/v1` is a contract with applications that have no way to
|
||||
upgrade in step with you. Adding an optional query parameter or a response field is
|
||||
safe. Changing the meaning of an existing one is not.
|
||||
|
||||
## Conventions
|
||||
|
||||
Enforced, not suggested. `make check` is the same command CI runs.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Go formatting | `gofmt`, `make fmt-check` |
|
||||
| Go lint | `golangci-lint`, `make lint-go` |
|
||||
| Go imports | `gci`, configured in `.golangci.yml` |
|
||||
| TypeScript | `tsc --build`, strict |
|
||||
| Frontend lint | `eslint --max-warnings 0` |
|
||||
| Frontend format | `prettier`, including the Tailwind plugin |
|
||||
| Chart | `helm lint --strict` plus `values.schema.json` |
|
||||
|
||||
Read [standards.md](standards.md) before contributing. It is the difference between a
|
||||
change that fits and one that has to be rewritten.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Port 8080 in use.** `make dev-api` with `BANANAS_HTTP_ADDR=:8081`, or stop whatever
|
||||
holds the port.
|
||||
|
||||
**The admin UI cannot reach the API.** The Vite proxy targets
|
||||
`http://localhost:8080`. If the API is elsewhere, edit `server.proxy` in
|
||||
`vite.config.ts`.
|
||||
|
||||
**`npm ci` fails.** A stale `node_modules`. `make clean-cache`.
|
||||
|
||||
**`go build` fails after a dependency change.** `make tidy`, then `make install`.
|
||||
|
||||
**A test is flaky.** Do not re-run it and hope. Find the ordering dependency. The most
|
||||
common cause here is module-level state in a frontend test: use `vi.resetModules()`
|
||||
and a fresh module instance.
|
||||
|
||||
**The chart will not render.** Read the error. Every chart failure names the value to
|
||||
change — that is deliberate, and the alternative is a failure at run time instead.
|
||||
565
docs/integration.md
Normal file
565
docs/integration.md
Normal file
|
|
@ -0,0 +1,565 @@
|
|||
# Integrating a go-bananas theme
|
||||
|
||||
Everything here works without installing anything. A theme is data delivered over
|
||||
HTTP, so the integration is a URL, not a dependency.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [The thirty-second version](#the-thirty-second-version)
|
||||
2. [How theming works](#how-theming-works)
|
||||
3. [Choosing an integration](#choosing-an-integration)
|
||||
4. [Any HTML page](#1-any-html-page)
|
||||
5. [Plain JavaScript](#2-plain-javascript)
|
||||
6. [React](#3-react)
|
||||
7. [Server-rendered frameworks](#4-server-rendered-frameworks)
|
||||
8. [Tailwind](#5-tailwind)
|
||||
9. [Reading tokens in JavaScript](#reading-tokens-in-javascript)
|
||||
10. [Email and documents](#email-and-documents)
|
||||
11. [Runtime switching](#runtime-switching)
|
||||
12. [Versioning and caching](#versioning-and-caching)
|
||||
13. [Offline and failure behaviour](#offline-and-failure-behaviour)
|
||||
14. [Multiple themes on one page](#multiple-themes-on-one-page)
|
||||
|
||||
---
|
||||
|
||||
## The thirty-second version
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="https://themes.example.com/rt/v1/theme.css?slug=midnight">
|
||||
```
|
||||
|
||||
Done. Every `--bn-*` variable is now defined, and the bundled `bn-*` component
|
||||
classes are available.
|
||||
|
||||
---
|
||||
|
||||
## How theming works
|
||||
|
||||
A theme renders to a stylesheet of CSS custom properties, all prefixed `--bn-`, plus
|
||||
a small component layer.
|
||||
|
||||
```css
|
||||
[data-bananas-theme="midnight"] {
|
||||
--bn-color-semantic-background: #1a1d23;
|
||||
--bn-color-semantic-text: #d9dbe5;
|
||||
--bn-color-semantic-primary: #7c6bf5;
|
||||
--bn-space-4: 1rem;
|
||||
--bn-radius-lg: 0.75rem;
|
||||
}
|
||||
|
||||
[data-bananas-theme="midnight"][data-bananas-appearance="dark"] { /* … */ }
|
||||
|
||||
.dark [data-bananas-theme="midnight"],
|
||||
[data-bananas-theme="midnight"].dark { /* … */ }
|
||||
```
|
||||
|
||||
Two attributes do all the work:
|
||||
|
||||
| Attribute | Values | Effect |
|
||||
| --- | --- | --- |
|
||||
| `data-bananas-theme` | a theme slug | Activates that theme on the element and its subtree |
|
||||
| `data-bananas-appearance` | `light`, `dark` | Selects the colour scheme |
|
||||
|
||||
Put `data-bananas-theme` on `<html>` for the whole page, or on any element for a
|
||||
subtree. Several themes can coexist.
|
||||
|
||||
The stylesheet also honours a `.dark` ancestor class and `prefers-color-scheme`, so it
|
||||
fits an application that already toggles dark mode the Tailwind or shadcn way.
|
||||
|
||||
### The component layer
|
||||
|
||||
The generated stylesheet includes a small set of classes written entirely in terms of
|
||||
the variables: `.bn-btn`, `.bn-card`, `.bn-input`, `.bn-badge`, `.bn-alert`,
|
||||
`.bn-table`, `.bn-link`.
|
||||
|
||||
Use them to look correct immediately, then replace them with your own components as
|
||||
soon as you want them. They exist so that the first integration needs no CSS work at
|
||||
all.
|
||||
|
||||
They are placed in `@layer bananas`, so your own unlayered CSS always wins the
|
||||
cascade. You will never need `!important` to override them.
|
||||
|
||||
Request them with `?variant=tokens` if you want the variables without the component
|
||||
layer.
|
||||
|
||||
---
|
||||
|
||||
## Choosing an integration
|
||||
|
||||
| Your stack | Use |
|
||||
| --- | --- |
|
||||
| Server-rendered HTML, any framework | [Any HTML page](#1-any-html-page) |
|
||||
| A SPA you control | [Plain JavaScript](#2-plain-javascript) |
|
||||
| React | [React](#3-react) |
|
||||
| Django, Rails, Laravel, Go templates, Jinja | [Server-rendered](#4-server-rendered-frameworks) |
|
||||
| Tailwind | [Tailwind](#5-tailwind) |
|
||||
| Canvas, charts, PDFs, email | [Reading tokens](#reading-tokens-in-javascript) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Any HTML page
|
||||
|
||||
The stylesheet link. Two tags if you want the theme to follow the operating system.
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="https://themes.example.com/rt/v1/theme.css?slug=midnight">
|
||||
<script async src="https://themes.example.com/rt/v1/embed.js"
|
||||
data-theme="midnight"
|
||||
data-appearance="system"></script>
|
||||
```
|
||||
|
||||
`embed.js` is a single file with no dependencies. It sets the attributes and fetches
|
||||
the stylesheet before first paint.
|
||||
|
||||
Configure it with data attributes:
|
||||
|
||||
| Attribute | Values | Default |
|
||||
| --- | --- | --- |
|
||||
| `data-theme` | a theme slug | `default` |
|
||||
| `data-appearance` | `light`, `dark`, `system` | `system` |
|
||||
| `data-variant` | `full`, `tokens`, `minified` | `full` |
|
||||
| `data-storage` | `false` to skip localStorage | persists |
|
||||
| `data-auto-dark` | `true` to follow the system | follows `data-appearance` |
|
||||
|
||||
A generated snippet for any theme is at `/rt/v1/embed?slug=<slug>`, including a live
|
||||
preview of what it looks like.
|
||||
|
||||
---
|
||||
|
||||
## 2. Plain JavaScript
|
||||
|
||||
An ES module loaded by URL.
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import { applyTheme, watchSystemAppearance } from 'https://themes.example.com/rt/v1/runtime.js'
|
||||
|
||||
watchSystemAppearance() // follow the operating system preference
|
||||
await applyTheme('midnight') // fetch and apply
|
||||
</script>
|
||||
```
|
||||
|
||||
Or with a `package.json`-free bundler, by aliasing the URL:
|
||||
|
||||
```js
|
||||
// vite.config.js, webpack, esbuild — the tool does not care which.
|
||||
resolve: { alias: { 'go-bananas/runtime': 'https://themes.example.com/rt/v1/runtime.js' } }
|
||||
```
|
||||
|
||||
The module API:
|
||||
|
||||
| Export | Purpose |
|
||||
| --- | --- |
|
||||
| `applyTheme(slug, options?)` | Fetch and apply. Returns the resolved payload. |
|
||||
| `setAppearance(appearance)` | Change the scheme without re-fetching. |
|
||||
| `getAppearance()`, `getSlug()` | Read the current state. |
|
||||
| `clearTheme()` | Remove the theme, restoring the page's own styles. |
|
||||
| `loadManifest()` | Fetch the available themes. |
|
||||
| `loadTheme(slug, appearance?)` | Fetch one theme's resolved tokens. |
|
||||
| `getVariables(name?)` | Read a token by name, or all of them. |
|
||||
| `watchSystemAppearance(callback?)` | Follow the operating system. Returns an unsubscribe. |
|
||||
| `subscribe(listener)` | Observe theme changes. Returns an unsubscribe. |
|
||||
| `assetUrl(path, query?)` | Build a runtime asset URL, for your own fetches. |
|
||||
|
||||
There is also a global build that attaches the same API to `window.Bananas`, for a
|
||||
page that cannot use modules:
|
||||
|
||||
```html
|
||||
<script src="https://themes.example.com/rt/v1/global.js"></script>
|
||||
<script>Bananas.applyTheme('midnight')</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. React
|
||||
|
||||
A provider and hooks, loaded through an import map. **React stays your own copy.**
|
||||
The module imports the bare specifier `react`, which the import map resolves to the
|
||||
application's own React — two copies of React in one page break hooks, because each
|
||||
copy has its own dispatcher state.
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import { BananasProvider } from 'https://themes.example.com/rt/v1/react.js'
|
||||
import { createRoot } from 'https://esm.sh/react-dom@19/client'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<BananasProvider slug="midnight" autoDark>
|
||||
<App />
|
||||
</BananasProvider>,
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
Or in a bundled application, alias the URL to a bare specifier:
|
||||
|
||||
```js
|
||||
// vite.config.js
|
||||
resolve: { alias: { 'go-bananas/runtime': 'https://themes.example.com/rt/v1/react.js' } }
|
||||
```
|
||||
|
||||
```jsx
|
||||
import { BananasProvider, useBananas, useThemeVariable } from 'go-bananas/runtime'
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `slug` | — | The theme to apply |
|
||||
| `appearance` | `system` | `light`, `dark` or `system` |
|
||||
| `autoDark` | `false` | Follow the operating system |
|
||||
| `baseUrl` | inferred | Runtime base, for a cross-origin deployment |
|
||||
| `storage` | `true` | Persist the user's choice |
|
||||
| `variant` | `full` | Render variant |
|
||||
| `loadManifest` | `false` | Fetch the theme list on mount |
|
||||
| `onError` | — | Called when loading fails |
|
||||
|
||||
### Hooks
|
||||
|
||||
```jsx
|
||||
import { useBananas, useThemeVariable, useThemeVariables, useAvailableThemes } from 'go-bananas/runtime'
|
||||
|
||||
function ThemeSwitcher() {
|
||||
const { slug, appearance, setTheme, setAppearance, status } = useBananas()
|
||||
const { themes } = useAvailableThemes()
|
||||
|
||||
if (status === 'loading') return <Spinner />
|
||||
|
||||
return (
|
||||
<>
|
||||
<select value={slug ?? ''} onChange={(event) => setTheme(event.target.value)}>
|
||||
{themes.map((theme) => (
|
||||
<option key={theme.slug} value={theme.slug}>{theme.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button onClick={() => setAppearance(appearance === 'dark' ? 'light' : 'dark')}>
|
||||
{appearance === 'dark' ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemedButton() {
|
||||
// Returns the resolved CSS value, so the component works in any scheme.
|
||||
const background = useThemeVariable('color-semantic-primary')
|
||||
const foreground = useThemeVariable('on-primary')
|
||||
return <button style={{ background, color: foreground }}>Save</button>
|
||||
}
|
||||
```
|
||||
|
||||
`useBananas()` throws when used outside a provider, rather than returning a default.
|
||||
A silent default would let a component render with the wrong theme and never report
|
||||
the mistake.
|
||||
|
||||
### Cross-application consistency
|
||||
|
||||
`useSyncExternalStore` is used under the provider, so a theme change made outside
|
||||
React — a switcher in another part of the page, a browser navigation, a
|
||||
`postMessage` from an iframe — repaints the tree rather than tearing it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Server-rendered frameworks
|
||||
|
||||
The stylesheet link is a `<link>` tag. It works identically in Django, Rails,
|
||||
Laravel, Go's `html/template`, Jinja, ERB and anywhere else, because the mechanism is
|
||||
the browser's, not a framework's.
|
||||
|
||||
```jinja
|
||||
{# Django #}
|
||||
<link rel="stylesheet" href="{{ theme_service_url }}/rt/v1/theme.css?slug={{ theme_slug }}">
|
||||
```
|
||||
|
||||
```erb
|
||||
<%# Rails ERB %>
|
||||
<link rel="stylesheet" href="<%= themes_url %>/rt/v1/theme.css?slug=<%= @theme_slug %>">
|
||||
```
|
||||
|
||||
```go
|
||||
{{/* Go html/template */}}
|
||||
<link rel="stylesheet" href="{{ .ThemeBase }}/rt/v1/theme.css?slug={{ .ThemeSlug }}">
|
||||
```
|
||||
|
||||
For a per-request theme, resolve the slug in your handler and template it in. The
|
||||
stylesheet is immutable by URL and safe to cache at any layer, so rendering the link
|
||||
per request costs nothing.
|
||||
|
||||
### Go
|
||||
|
||||
```go
|
||||
func themeStylesheet(base, slug string) template.HTML {
|
||||
return template.HTML(fmt.Sprintf(
|
||||
`<link rel="stylesheet" href="%s/rt/v1/theme.css?slug=%s">`, base, url.PathEscape(slug)))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Tailwind
|
||||
|
||||
Two options, depending on whether the theme must be live.
|
||||
|
||||
### Live: consume the variables
|
||||
|
||||
Works with any Tailwind version, needs no rebuild, and a theme publish applies
|
||||
immediately.
|
||||
|
||||
```css
|
||||
/* app.css */
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme inline {
|
||||
--color-primary: var(--bn-color-semantic-primary);
|
||||
--color-background: var(--bn-color-semantic-background);
|
||||
--color-foreground: var(--bn-color-semantic-text);
|
||||
--color-muted: var(--bn-color-semantic-text-muted);
|
||||
--color-border: var(--bn-color-semantic-border);
|
||||
--color-danger: var(--bn-color-semantic-danger);
|
||||
--radius-card: var(--bn-radius-lg);
|
||||
}
|
||||
```
|
||||
|
||||
Then use the semantic names normally: `bg-background text-foreground bg-primary
|
||||
text-primary rounded-card`.
|
||||
|
||||
### Build-time: generate a preset
|
||||
|
||||
For a theme that changes rarely and should be baked into the CSS.
|
||||
|
||||
```bash
|
||||
curl -s "$THEMES/rt/v1/theme.json?slug=acme" > tokens.json
|
||||
npx @tokens-studio/sd-transform-cli tokens.json tokens.json
|
||||
npx tailwindcss --theme ./tokens.json -i app.css -o out.css
|
||||
```
|
||||
|
||||
The JSON is a standard [DTCG](https://www.designtokens.org/) document, so it feeds
|
||||
any token pipeline rather than only this one.
|
||||
|
||||
---
|
||||
|
||||
## Reading tokens in JavaScript
|
||||
|
||||
Some things cannot read CSS: a canvas chart, a PDF generator, an email template, a
|
||||
colour picker that needs to show a palette.
|
||||
|
||||
```js
|
||||
const { variables, tokens, theme } = await fetch(
|
||||
`${THEMES}/rt/v1/theme.json?slug=midnight`,
|
||||
).then((response) => response.json())
|
||||
|
||||
theme.slug // 'midnight'
|
||||
theme.version // 3
|
||||
variables['--bn-color-semantic-primary'] // '#7c6bf5'
|
||||
variables['--bn-radius-lg'] // '0.75rem'
|
||||
```
|
||||
|
||||
The payload has three parts:
|
||||
|
||||
| Field | Shape | Use |
|
||||
| --- | --- | --- |
|
||||
| `theme` | identity | Which theme and version this is |
|
||||
| `variables` | flat map | CSS custom property name to value |
|
||||
| `tokens` | nested DTCG | The tree, for mapping onto component props |
|
||||
|
||||
The same is available from the runtime module as `getVariables()` and `loadTheme()`,
|
||||
with caching.
|
||||
|
||||
### Chart example
|
||||
|
||||
```js
|
||||
const { variables } = await loadTheme('midnight')
|
||||
|
||||
new Chart(canvas, {
|
||||
data,
|
||||
options: {
|
||||
plugins: {
|
||||
legend: { labels: { color: variables['--bn-color-semantic-text-muted'] } },
|
||||
scales: {
|
||||
x: { grid: { color: variables['--bn-color-semantic-border'] } },
|
||||
y: { grid: { color: variables['--bn-color-semantic-border'] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Email and documents
|
||||
|
||||
Email clients have no CSS custom property support. Resolve the variables server-side
|
||||
and inline the values.
|
||||
|
||||
```go
|
||||
func themeForEmail(base, slug string) map[string]string {
|
||||
resp, err := http.Get(base + "/rt/v1/theme.json?slug=" + url.QueryEscape(slug))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var payload struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
}
|
||||
if json.NewDecoder(resp.Body).Decode(&payload) != nil {
|
||||
return nil
|
||||
}
|
||||
return payload.Variables
|
||||
}
|
||||
|
||||
// Inline the values; a <style> block is stripped by most clients.
|
||||
html := fmt.Sprintf(
|
||||
`<td style="background:%s;color:%s;font-family:%s">…</td>`,
|
||||
v["--bn-color-semantic-surface-raised"],
|
||||
v["--bn-color-semantic-text"],
|
||||
v["--bn-font-family-sans"],
|
||||
)
|
||||
```
|
||||
|
||||
Resolve once per message and cache it. A theme changes rarely, so a five-minute cache
|
||||
is plenty.
|
||||
|
||||
---
|
||||
|
||||
## Runtime switching
|
||||
|
||||
```js
|
||||
// A theme picker.
|
||||
import { applyTheme, loadManifest } from '.../rt/v1/runtime.js'
|
||||
|
||||
const { themes } = await loadManifest()
|
||||
for (const theme of themes) {
|
||||
button.onclick = () => applyTheme(theme.slug)
|
||||
}
|
||||
```
|
||||
|
||||
Or with no JavaScript at all:
|
||||
|
||||
```html
|
||||
<select onchange="document.documentElement.dataset.bananasTheme = this.value">
|
||||
<option value="midnight">Midnight</option>
|
||||
<option value="daylight">Daylight</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
The theme picker must be outside the themed subtree, or it restyles itself on every
|
||||
change. Put it in a fixed header above the themed container.
|
||||
|
||||
---
|
||||
|
||||
## Versioning and caching
|
||||
|
||||
Runtime responses are immutable by URL.
|
||||
|
||||
```bash
|
||||
# The latest version. This URL changes when the theme is republished.
|
||||
/rt/v1/theme.css?slug=midnight
|
||||
|
||||
# A specific version. This URL never changes, ever.
|
||||
/rt/v1/theme.css?slug=midnight&v=7
|
||||
```
|
||||
|
||||
Pinning `v` is what makes a safe deploy possible: build against version 7, and the
|
||||
render cannot change underneath you. The `/rt/v1/themes/{slug}` path form resolves
|
||||
to the latest version.
|
||||
|
||||
| Header | Use |
|
||||
| --- | --- |
|
||||
| `ETag` | Revalidation. Answer a `304` and transfer no body. |
|
||||
| `Cache-Control` | `max-age=60, s-maxage=300, stale-while-revalidate=86400` |
|
||||
| `X-Theme-Version` | Which version was served. Log it. |
|
||||
| `X-Theme-Updated-At` | When it was published. |
|
||||
| `sourceURL` comment in the CSS | Navigate from browser devtools to the endpoint |
|
||||
|
||||
Log `X-Theme-Version` alongside your request id. "The theme looks wrong" becomes
|
||||
answerable: you know which version that request resolved.
|
||||
|
||||
### Recommended policy
|
||||
|
||||
**Consumers:** CDN with `s-maxage` in the hundreds and a long
|
||||
`stale-while-revalidate`. Revalidation is a few milliseconds and no body.
|
||||
|
||||
**Browsers:** leave the `Cache-Control` as served. The version-scoped URL means the
|
||||
cache entry is never stale.
|
||||
|
||||
**Rollback:** publishing is a new version, and the latest-version URL follows it. A
|
||||
rollback propagates within one `s-maxage` window.
|
||||
|
||||
---
|
||||
|
||||
## Offline and failure behaviour
|
||||
|
||||
A theme that fails to load leaves the page as it was, with the application's own
|
||||
styles. It does not break the page.
|
||||
|
||||
That is deliberate. A theming service is a presentation concern; it should never be
|
||||
able to take an application down. Every entry point catches its own failure:
|
||||
|
||||
```js
|
||||
applyTheme('midnight').catch((error) => {
|
||||
reportError(error) // the page is still usable
|
||||
})
|
||||
```
|
||||
|
||||
If you need a theme to be non-optional, add a `<style>` block in your own CSS with
|
||||
your brand values as the fallback. The custom properties will be overridden when the
|
||||
theme arrives, and used when it does not.
|
||||
|
||||
---
|
||||
|
||||
## Multiple themes on one page
|
||||
|
||||
Theme attributes scope to a subtree, so a documentation site with per-product themes
|
||||
is one attribute per section.
|
||||
|
||||
```html
|
||||
<body>
|
||||
<header data-bananas-theme="midnight">
|
||||
<!-- always the dark theme -->
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section data-bananas-theme="daylight">
|
||||
<!-- light theme, even though the page default is dark -->
|
||||
</section>
|
||||
<section data-bananas-theme="high-contrast">
|
||||
<!-- maximum contrast -->
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
```
|
||||
|
||||
Two rules worth knowing:
|
||||
|
||||
- **Only one theme stylesheet is loaded at a time.** Scoping means several themes on
|
||||
one page share the variables in the most recently applied stylesheet. Themes whose
|
||||
token *sets* differ will collide.
|
||||
- **Appearance is per subtree.** `data-bananas-appearance` on a section overrides the
|
||||
document's scheme for that section only.
|
||||
|
||||
```html
|
||||
<section data-bananas-theme="daylight" data-bananas-appearance="dark">
|
||||
<!-- the daylight theme, in its dark scheme -->
|
||||
</section>
|
||||
```
|
||||
|
||||
If you need genuinely different variable sets on one page, request each theme's
|
||||
stylesheet as a separate `<link>` and scope each to its own subtree. That works
|
||||
because the selectors are attribute-scoped; the later link does not overwrite the
|
||||
earlier one.
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] A `<link>` to the theme stylesheet, or `applyTheme` called before first paint
|
||||
- [ ] The theme attribute set on `<html>`, for a page-wide theme
|
||||
- [ ] Components consuming semantic tokens (`color.semantic.primary`), not palette steps
|
||||
- [ ] Dark mode handled: `autoDark`, or `data-bananas-appearance` on `<html>`
|
||||
- [ ] A fallback, so a theme that fails to load leaves the page usable
|
||||
- [ ] `X-Theme-Version` logged with your request id
|
||||
- [ ] For a multi-theme page, one stylesheet per distinct token set, correctly scoped
|
||||
273
docs/operations.md
Normal file
273
docs/operations.md
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
# Operations
|
||||
|
||||
Running go-bananas in production: what to watch, what to do when something breaks,
|
||||
and how to change things safely.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Health](#health)
|
||||
- [The runbook](#the-runbook)
|
||||
- [Routine operations](#routine-operations)
|
||||
- [Alerts](#alerts)
|
||||
- [Diagnostics](#diagnostics)
|
||||
- [Capacity](#capacity)
|
||||
|
||||
## Health
|
||||
|
||||
Three endpoints, deliberately different in what they check.
|
||||
|
||||
| Endpoint | Checks | Touches the database | Used by |
|
||||
| --- | --- | --- | --- |
|
||||
| `/livez` | Nothing. The process can serve HTTP | No | Liveness probe |
|
||||
| `/healthz` | The process can serve HTTP, plus build information | No | Human, uptime checks |
|
||||
| `/readyz` | The database answers | Yes | Readiness probe |
|
||||
|
||||
The liveness probe does not touch the database on purpose. A probe that failed on a
|
||||
slow database would have the kubelet restart a process that is otherwise healthy,
|
||||
turning a dependency blip into an outage. Readiness does check, because a pod that
|
||||
cannot reach its database cannot serve anything and should leave the load balancer.
|
||||
|
||||
`/readyz` warns when the database is in-memory. That is legitimate in a test and
|
||||
never legitimate in production, so it is surfaced rather than quietly accepted.
|
||||
|
||||
## The runbook
|
||||
|
||||
### The service will not start
|
||||
|
||||
The first log line is the reason. Every configuration problem is reported in full at
|
||||
start-up, listing each problem rather than only the first, so one deploy tells you
|
||||
everything that is wrong.
|
||||
|
||||
```
|
||||
go-bananas: invalid configuration:
|
||||
- SESSION_SECRET must be at least 32 bytes when auth is enabled, got 8
|
||||
- OIDC_ISSUER must use https outside local development, got "http"
|
||||
```
|
||||
|
||||
Configuration never reaches the log renderer, because the logger's own configuration
|
||||
may be part of what is wrong.
|
||||
|
||||
### Users cannot sign in
|
||||
|
||||
Work down this list; the first three cover almost every case.
|
||||
|
||||
1. **The redirect URI.** It must be exactly `{publicBaseUrl}/auth/callback`, including
|
||||
the base path if there is one.
|
||||
|
||||
```bash
|
||||
kubectl exec -n go-bananas deploy/go-bananas -- \
|
||||
wget -qO- "$ISSUER/.well-known/openid-configuration" | jq -r .authorization_endpoint
|
||||
```
|
||||
|
||||
Register that callback with the provider, then compare it to `publicBaseUrl`.
|
||||
|
||||
2. **The issuer string.** It must match the `iss` claim in the ID token exactly,
|
||||
including a trailing slash. Check both:
|
||||
|
||||
```bash
|
||||
kubectl get cm go-bananas -n go-bananas -o jsonpath='{.data.BANANAS_OIDC_ISSUER}{"\n"}'
|
||||
curl -s "$ISSUER/.well-known/openid-configuration" | jq -r .issuer
|
||||
```
|
||||
|
||||
3. **The client secret.** A stale one produces an invalid-grant error at the token
|
||||
endpoint, which surfaces as a redirect back with an error parameter.
|
||||
|
||||
4. **The groups claim.** Absent when `groups` is not in `OIDC_SCOPES`. Users then land
|
||||
with viewer rights and no way to tell why.
|
||||
|
||||
5. **The cookie.** A session rejected because `Secure` is set but the site is served
|
||||
over plain HTTP, or `SameSite=None` without `Secure`. The browser refuses to store
|
||||
it and the loop looks like an authentication failure.
|
||||
|
||||
### Everything can sign in but nobody can edit
|
||||
|
||||
The role mapping. A user in no configured group gets viewer, by design. The mapping
|
||||
fails closed, so a typo in a group name silently produces read-only access rather
|
||||
than an error.
|
||||
|
||||
```bash
|
||||
kubectl get cm go-bananas -n go-bananas -o jsonpath='{.data.BANANAS_OIDC_ADMIN_GROUPS}{"\n"}'
|
||||
```
|
||||
|
||||
### The runtime is slow
|
||||
|
||||
1. **Cache hit rate.** Below 80% after warm-up is the first thing to look at.
|
||||
|
||||
```bash
|
||||
curl -s :8080/metrics | grep bananas_cache_
|
||||
```
|
||||
|
||||
A sustained low rate means a publish loop, a version-keyed cache storm, or more
|
||||
themes than expected.
|
||||
|
||||
2. **p99 latency.** Over about 250 ms for the runtime path means a cache miss rate
|
||||
problem, not a CPU problem.
|
||||
|
||||
3. **Is a CDN in front?** If not, that is the fix. Runtime responses are
|
||||
immutable by URL and designed to be cached hard.
|
||||
|
||||
4. **Database contention.** `SQLITE_BUSY` in the logs means writers are queueing.
|
||||
At the expected write rate that is a symptom, not a cause.
|
||||
|
||||
### The database is growing
|
||||
|
||||
Expected: a few megabytes for thousands of themes. `bananas_database_size_bytes` is
|
||||
exposed on `/api/v1/stats`.
|
||||
|
||||
The audit log is the only table that grows without bound. Everything else is bounded
|
||||
by the number of themes and their versions.
|
||||
|
||||
```sql
|
||||
DELETE FROM audit_log WHERE at < datetime('now', '-180 days');
|
||||
```
|
||||
|
||||
### A theme is not appearing for users
|
||||
|
||||
1. Is it published? A draft returns 404 from the runtime API by design, so a probe
|
||||
cannot confirm it exists.
|
||||
|
||||
```bash
|
||||
curl -s "$THEMES/rt/v1/manifest.json" | jq '.themes[].slug'
|
||||
```
|
||||
|
||||
2. Which version did they get? Compare the response header across applications.
|
||||
|
||||
```bash
|
||||
curl -sI "$THEMES/rt/v1/theme.css?slug=acme" | grep -i 'x-theme-version'
|
||||
```
|
||||
|
||||
3. **Cached.** Expected within one `s-maxage` window, 300 s by default. To force it,
|
||||
purge the CDN or lower `config.runtimeCacheControl`.
|
||||
|
||||
4. **Pinned.** A URL with `?v=7` is immutable by construction. If a consumer pinned a
|
||||
version, it will keep getting that version, which is the point.
|
||||
|
||||
### A replica is crash-looping
|
||||
|
||||
`ReadWriteOnce` volume with more than one replica. Exactly one pod can mount it; the
|
||||
others fail. The chart annotates the Deployment when it detects this combination.
|
||||
|
||||
```bash
|
||||
kubectl get deploy go-bananas -n go-bananas -o jsonpath='{.spec.template.metadata.annotations}'
|
||||
```
|
||||
|
||||
Fix: `deployment.replicaCount: 1`, or a filesystem that permits concurrent mounts.
|
||||
|
||||
## Routine operations
|
||||
|
||||
### Publishing a theme
|
||||
|
||||
An operator action in the admin interface, not a deployment. No rollout, no restart, no
|
||||
cache flush. Consumers pick it up within one `s-maxage` window.
|
||||
|
||||
For a controlled release, publish a draft, pin consumers to the version, then
|
||||
re-point. Two-step, and the old version remains available throughout.
|
||||
|
||||
### Rotating the session secret
|
||||
|
||||
```bash
|
||||
kubectl create secret generic go-bananas-auth \
|
||||
--namespace go-bananas \
|
||||
--from-literal=session-secret="$(openssl rand -base64 48)" \
|
||||
--from-literal=oidc-client-secret="$CURRENT_CLIENT_SECRET" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl rollout restart deploy/go-bananas -n go-bananas
|
||||
```
|
||||
|
||||
Every session is invalidated. Do it during a quiet period, and expect one login from
|
||||
each user.
|
||||
|
||||
### Rotating the OIDC client secret
|
||||
|
||||
Update the Secret with both keys, then restart. The chart reads the value at start-up,
|
||||
so a rolling restart is needed to pick it up.
|
||||
|
||||
### Adding a theme
|
||||
|
||||
Through the admin interface. To seed a set of themes into a fresh environment, export
|
||||
them from the source instance and import them on the target.
|
||||
|
||||
```bash
|
||||
curl -s -H "Cookie: $SESSION" "$SOURCE/api/v1/themes/acme/export" > acme.theme.json
|
||||
curl -X POST -H "Cookie: $SESSION" -H 'Content-Type: application/json' \
|
||||
--data @<(jq '{slug, name, tokens, meta, asDraft: true}' acme.theme.json) \
|
||||
"$TARGET/api/v1/themes/import"
|
||||
```
|
||||
|
||||
`asDraft: true` means an import can never publish something by accident.
|
||||
|
||||
### Backups
|
||||
|
||||
See [deployment.md](deployment.md#backup-and-restore). A backup is a copy of one
|
||||
file; restore by copying it back and restarting.
|
||||
|
||||
Test a restore. An untested backup is a hypothesis.
|
||||
|
||||
## Alerts
|
||||
|
||||
| Alert | Condition | First response |
|
||||
| --- | --- | --- |
|
||||
| `GoBananasDown` | No `/readyz` success for 5 min | Check the pod. Configuration is the usual cause |
|
||||
| `GoBananasHighErrorRate` | 5xx above 2% for 10 min | Check the logs. A database failure appears here first |
|
||||
| `GoBananasLatencyHigh` | p99 runtime latency above 250 ms | Cache hit rate first |
|
||||
| `GoBananasCacheHitRateLow` | Below 80% for 15 min | Publish loop, or more themes than expected |
|
||||
| `GoBananasMemoryHigh` | Above 85% of the limit | The render cache, or too many themes |
|
||||
| `GoBananasDatabaseBusy` | `SQLITE_BUSY` in the logs | Writer contention. Symptom of something else |
|
||||
| `GoBananasDeploymentRestarted` | `kube_deployment_status_replicas` resets | Read the previous container's logs first |
|
||||
|
||||
## Diagnostics
|
||||
|
||||
```bash
|
||||
# What is running?
|
||||
kubectl get deploy go-bananas -n go-bananas -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
|
||||
|
||||
# What configuration is it actually using?
|
||||
kubectl get cm go-bananas -n go-bananas -o jsonpath='{.data}' | tr ',' '\n'
|
||||
|
||||
# Why did the last pod die?
|
||||
kubectl logs -n go-bananas deploy/go-bananas --previous --tail=100
|
||||
|
||||
# What has it been doing?
|
||||
kubectl logs -n go-bananas -l app.kubernetes.io/instance=go-bananas \
|
||||
| jq 'select(.status >= 400)' | tail -50
|
||||
|
||||
# What did the provider say?
|
||||
kubectl logs -n go-bananas -l app.kubernetes.io/instance=go-bananas \
|
||||
| jq -r 'select(.msg == "login rejected") | .reason'
|
||||
|
||||
# Is it healthy?
|
||||
kubectl port-forward -n go-bananas svc/go-bananas 8080:80
|
||||
curl -s localhost:8080/readyz | jq
|
||||
curl -s localhost:8080/api/v1/stats -b cookies.txt | jq '.store'
|
||||
```
|
||||
|
||||
The correlation id ties a user-visible failure to its log lines. The response body
|
||||
carries `requestId` on every error, and `X-Request-ID` is on every response:
|
||||
|
||||
```bash
|
||||
kubectl logs -n go-bananas -l app.kubernetes.io/instance=go-bananas \
|
||||
| jq 'select(.requestId == "0f90d3cb9ab8e449")'
|
||||
```
|
||||
|
||||
## Capacity
|
||||
|
||||
The service is small and the render cache does the work. Realistic figures for one
|
||||
replica on a modest node:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Steady-state runtime request | sub-millisecond, no database access |
|
||||
| Cold render, 200 tokens | ~100 µs |
|
||||
| Memory | ~40 MiB base, plus roughly 60 kB per cached theme per variant |
|
||||
| Database | a few MB for a thousand themes |
|
||||
| Capacity | a very large number of consumers, limited by network, not by the service |
|
||||
|
||||
Memory is the resource to watch: the render cache grows with the number of published
|
||||
themes, multiplied by appearances and variants. A thousand themes across six
|
||||
combinations is on the order of hundreds of megabytes. Either raise the limit or
|
||||
publish fewer themes.
|
||||
|
||||
When memory becomes the constraint, `/api/v1/admin/cache/rebuild` re-warms it and
|
||||
`/api/v1/stats` reports the entry count. A restart clears the cache entirely and
|
||||
re-warms from the database, so a rolling restart is a valid way to reclaim memory.
|
||||
247
docs/security.md
Normal file
247
docs/security.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# Security
|
||||
|
||||
The threat model, what is defended, and what is not.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Summary](#summary)
|
||||
- [Threat model](#threat-model)
|
||||
- [Trust boundaries](#trust-boundaries)
|
||||
- [Authentication](#authentication)
|
||||
- [Authorisation](#authorisation)
|
||||
- [Session handling](#session-handling)
|
||||
- [Input handling](#input-handling)
|
||||
- [Output handling](#output-handling)
|
||||
- [HTTP hardening](#http-hardening)
|
||||
- [Secrets in Kubernetes](#secrets-in-kubernetes)
|
||||
- [Supply chain](#supply-chain)
|
||||
- [Reporting a vulnerability](#reporting-a-vulnerability)
|
||||
|
||||
## Summary
|
||||
|
||||
The service holds design tokens and theme history. The sensitive assets are, in
|
||||
order: the OIDC client secret, the session signing secret, and the
|
||||
administrative log.
|
||||
|
||||
The highest-impact compromise is an attacker gaining administrator access, which
|
||||
would let them publish a theme and therefore restyle every consuming application. A
|
||||
published theme cannot execute script — it is CSS and JSON — so the ceiling on that
|
||||
compromise is phishing and data exfiltration through a convincing lookalike UI, not
|
||||
code execution in a consumer's page. That ceiling is a real mitigation and is the
|
||||
main reason this design is acceptable for a low-assurance service.
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
```
|
||||
┌─ untrusted ────────────────────────────────────────────┐
|
||||
│ the internet: browsers, crawlers, whoever │
|
||||
└───────────────────────┬────────────────────────────────┘
|
||||
│ /rt/v1 is public and unauthenticated
|
||||
┌─ semi-trusted ────────▼────────────────────────────────┐
|
||||
│ consuming applications: they fetch CSS and JSON │
|
||||
│ authenticated operators: they edit and publish │
|
||||
└───────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
┌─ trusted ────────────▼────────────────────────────────┐
|
||||
│ the service process, the database file, the kube API │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The runtime API is untrusted input from the internet that produces trusted output
|
||||
(CSS served to many browsers). That boundary gets the most attention: input validation,
|
||||
output escaping and header hardening.
|
||||
|
||||
## Authentication
|
||||
|
||||
One credential: an OIDC session cookie.
|
||||
|
||||
Authorization code flow with PKCE (S256) and a nonce. The transaction state lives in
|
||||
a signed cookie, so any replica can complete the flow and no shared session store is
|
||||
needed. The `state` the provider echoes back is compared against the value this
|
||||
service minted for that browser and stored in the same cookie, so a callback carrying
|
||||
a foreign code or a replayed state is rejected before the code is exchanged.
|
||||
|
||||
There is **no bearer-token path**. Machine credentials were removed: the runtime API
|
||||
is public by design and the administration API is for people, so a request carrying
|
||||
an `Authorization` header is treated exactly like one without it.
|
||||
|
||||
**Development bypass last, and only if it can exist.** `AUTH_MODE=dev` and
|
||||
`AUTH_MODE=none` are refused by the constructor when the environment is not a
|
||||
development one. This is enforced in code rather than documented, because an
|
||||
accidentally deployed bypass is a total authentication bypass:
|
||||
|
||||
```go
|
||||
if cfg.AuthMode == config.AuthModeDev && !cfg.IsDevelopment() {
|
||||
return nil, fmt.Errorf("refusing to start: AUTH_MODE=dev outside a development environment")
|
||||
}
|
||||
```
|
||||
|
||||
`config.Validate` refuses the same combinations a second time, and the Helm chart
|
||||
refuses `skipTLSVerify` in production.
|
||||
|
||||
### Provider misconfiguration fails closed
|
||||
|
||||
A user in no configured group gets `viewer`. A group mapping typo produces read-only
|
||||
access, not an error and not administrator access.
|
||||
|
||||
## Authorisation
|
||||
|
||||
Three roles, checked in the server on every admin route.
|
||||
|
||||
| | viewer | editor | admin |
|
||||
| --- | --- | --- | --- |
|
||||
| Read themes, tokens, history | Yes | Yes | Yes |
|
||||
| Validate tokens, contrast, palettes | Yes | Yes | Yes |
|
||||
| Create, edit, publish, rollback | No | Yes | Yes |
|
||||
| Duplicate, import, export | No | Yes | Yes |
|
||||
| Delete a theme | No | No | Yes |
|
||||
| Users, settings, audit | No | No | Yes |
|
||||
| Rebuild the render cache | No | No | Yes |
|
||||
|
||||
The whole admin subtree is behind one `RequireAuthMiddleware`, so a route added later
|
||||
is authenticated by default rather than by remembering. Role checks are per route, and
|
||||
an over-permissive one is visible in the route tree rather than buried in a handler.
|
||||
|
||||
The UI hides what the caller cannot do, as a convenience. The server enforces it.
|
||||
|
||||
## Session handling
|
||||
|
||||
A signed cookie. See [ADR 0003](adr/0003-stateful-sessions.md) for the reasoning.
|
||||
|
||||
| Property | Value | Why |
|
||||
| --- | --- | --- |
|
||||
| `HttpOnly` | always | Script cannot read it, so an XSS bug cannot exfiltrate a session |
|
||||
| `Secure` | outside development | Not sent over plain HTTP |
|
||||
| `SameSite` | `Lax` by default | Not sent on cross-site POSTs. `None` requires `Secure`, enforced |
|
||||
| Signature | HMAC-SHA256 | Constant-time comparison, so a prefix cannot be brute-forced |
|
||||
| Expiry | 8 hours, checked server-side | The cookie's `Max-Age` is a client hint, not a control |
|
||||
| Absent roles | default to viewer | A cookie carrying no roles carries no privileges |
|
||||
|
||||
**The post-login redirect is restricted to a local path.** An absolute `returnTo`
|
||||
would be an open redirect that also primes a phishing flow: a crafted login link that,
|
||||
after a successful authentication, bounces the user to an attacker's site with a
|
||||
freshly minted session.
|
||||
|
||||
**Bearer tokens in `localStorage` are not used**, deliberately. A token in
|
||||
`localStorage` is readable by any script on the page, so one XSS bug becomes a
|
||||
credential theft. An `HttpOnly` cookie is not script-accessible.
|
||||
|
||||
**Accepted limitation.** A stolen session cannot be revoked before it expires. The
|
||||
window is `BANANAS_SESSION_TTL`, eight hours by default. Full invalidation means
|
||||
rotating the signing key, which logs out everyone. For a design-token service this is
|
||||
the right trade; if your policy requires per-session revocation, this is the one
|
||||
component to replace.
|
||||
|
||||
## Generated code
|
||||
|
||||
The integration endpoint produces source that a developer pastes into their own
|
||||
application, so it is treated as an output boundary:
|
||||
|
||||
- The slug must name an existing theme, and must match `[a-z0-9-]` before it reaches
|
||||
a template. The existence check is a precondition, not the security control.
|
||||
- Every interpolated value is escaped **for the target language**. HTML targets use
|
||||
`html.EscapeString`; JavaScript and TSX targets go through `jsString`, which quotes
|
||||
the value as a string literal and escapes backslash, quote, newline, tab and the
|
||||
three HTML-significant characters. The JavaScript and TSX paths had no escaping at
|
||||
all, so a quote in a slug produced a snippet that broke out of its literal — an
|
||||
injection into the consuming application, reachable without authentication.
|
||||
- An unknown `target` is a 400 rather than a silent fallthrough.
|
||||
- Snippets embed this deployment's absolute URL, so the response is `no-store`.
|
||||
|
||||
## Input handling
|
||||
|
||||
| Input | Treatment |
|
||||
| --- | --- |
|
||||
| SQL | Every sortable column and filter field goes through an allow-list. No caller string is ever interpolated |
|
||||
| `LIKE` patterns | Escaped, so a user-supplied `%` is a literal rather than a wildcard |
|
||||
| Request bodies | Capped at 2 MiB, rejected earlier on `Content-Length` |
|
||||
| JSON | Unknown fields rejected, so a client typo is reported rather than ignored. Trailing content rejected |
|
||||
| Token values | Type-checked against the declared kind. Colours parsed and validated |
|
||||
| Slugs | Regex-constrained, and checked against the reserved set |
|
||||
| Post-login redirect | Local paths only |
|
||||
|
||||
Colour values are a subtle case: they are emitted into CSS that browsers execute as
|
||||
declarations. Validation runs before publication, and a value that cannot be parsed is
|
||||
rejected rather than passed through. Values that parse but are opaque to the engine
|
||||
(`oklch()`, `color()`, keywords) are emitted verbatim, which is correct: the browser
|
||||
is the authority on CSS syntax, and the engine is not in a position to second-guess it.
|
||||
|
||||
## Output handling
|
||||
|
||||
- JSON is produced by `encoding/json`, which escapes `<`, `>` and `&` by default.
|
||||
- The HTML embed snippet interpolates through `html.EscapeString`, even though the
|
||||
values are operator-controlled.
|
||||
- Token documents are data, not templates. Nothing in the service evaluates a token
|
||||
value.
|
||||
|
||||
## HTTP hardening
|
||||
|
||||
On every response:
|
||||
|
||||
| Header | Value |
|
||||
| --- | --- |
|
||||
| `Content-Security-Policy` | `default-src 'self'`, no `unsafe-eval`, no wildcard script source |
|
||||
| `X-Content-Type-Options` | `nosniff` |
|
||||
| `X-Frame-Options` | `DENY` |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
||||
| `Cross-Origin-Opener-Policy` | `same-origin` |
|
||||
| `Permissions-Policy` | geolocation, microphone and camera denied |
|
||||
| `Strict-Transport-Security` | Only when served over HTTPS |
|
||||
|
||||
The CSP is strict enough that the admin UI needed one exception, `style-src
|
||||
'unsafe-inline'`, because the theme editor injects a live preview stylesheet. Script
|
||||
sources are `'self'` with no exceptions.
|
||||
|
||||
`X-Forwarded-For` is believed only from a configured proxy CIDR. Without that
|
||||
configuration, any client could set it and spoof its address in the audit log — so the
|
||||
default is to ignore forwarding headers entirely and record the direct peer.
|
||||
|
||||
## Secrets in Kubernetes
|
||||
|
||||
- Secrets come from the environment, injected from a Secret.
|
||||
- Never a values file, a ConfigMap, a command-line flag or a committed file. A
|
||||
ConfigMap is world-readable in its namespace and appears in `kubectl describe`.
|
||||
- `config.Redacted()` exists so the configuration can be logged with secrets masked.
|
||||
There is a test that a redacted configuration contains no secret material.
|
||||
- The session secret is at least 32 bytes, and an ephemeral one is generated in
|
||||
development rather than demanding configuration that does not matter locally.
|
||||
- The chart-managed Secret uses `helm.sh/resource-policy: keep`, so an uninstall does
|
||||
not destroy the session key.
|
||||
- The chart reads a previously generated secret back on upgrade, so a rollout does
|
||||
not silently log everyone out.
|
||||
- The ServiceAccount token is **not** mounted. The service talks to nothing inside the
|
||||
cluster.
|
||||
|
||||
## Supply chain
|
||||
|
||||
- **Themes are not code.** A theme cannot execute script in a consumer's page. This is
|
||||
the largest single reduction in risk this design achieves: a token file has no
|
||||
transitive dependencies.
|
||||
- **No runtime SDK is installed by consumers.** They are served from this origin, so
|
||||
there is no npm supply chain in the integration path at all.
|
||||
- **Pinned Go and Node dependencies**, `go.sum` and `package-lock.json` committed,
|
||||
verified at build time.
|
||||
- **A static binary with no libc**, so no C library CVEs ride along.
|
||||
- **Cosign keyless signing** of release artifacts, and an SBOM per archive.
|
||||
- **Renovate or Dependabot** for updates, merging only on a green build.
|
||||
|
||||
### An accepted residual risk
|
||||
|
||||
A published theme controls the appearance of every consuming application. A
|
||||
compromised administrator could publish a theme that impersonates a login screen and
|
||||
harvests credentials typed into it. This is a phishing-class risk inherent to any
|
||||
central theming service.
|
||||
|
||||
Mitigations: publish is a distinct action from edit, with validation in front of it;
|
||||
every publish is audited with an actor and an IP; and the runtime exposes
|
||||
`X-Theme-Version` so a consumer can detect an unexpected change. It is not eliminated,
|
||||
and it is the reason publisher access should be held narrowly.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Report privately to the platform team. Please do not open a public issue.
|
||||
|
||||
Include the affected version, the reproduction, and the impact. Expect an
|
||||
acknowledgement within two working days and an assessment within five.
|
||||
|
||||
We will credit the reporter unless anonymity is preferred.
|
||||
666
docs/standards.md
Normal file
666
docs/standards.md
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
# Coding standards
|
||||
|
||||
This document is normative. Where it says "must", a change that does not follow it
|
||||
will not merge. Where it says "should", there is a reason to deviate and the reason
|
||||
belongs in the pull request.
|
||||
|
||||
The standards exist to make the codebase readable by someone who has never seen it.
|
||||
Every rule here is one that, in review, someone would otherwise have to enforce by
|
||||
hand.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [General principles](#1-general-principles)
|
||||
2. [Go](#2-go)
|
||||
3. [TypeScript and React](#3-typescript-and-react)
|
||||
4. [CSS](#4-css)
|
||||
5. [API design](#5-api-design)
|
||||
6. [Error handling](#6-error-handling)
|
||||
7. [Security](#7-security)
|
||||
8. [Testing](#8-testing)
|
||||
9. [Database](#9-database)
|
||||
10. [Documentation](#10-documentation)
|
||||
11. [Commits and pull requests](#11-commits-and-pull-requests)
|
||||
12. [Dependencies](#12-dependencies)
|
||||
13. [Enforcement](#13-enforcement)
|
||||
|
||||
---
|
||||
|
||||
## 1. General principles
|
||||
|
||||
### 1.1 Comment the decision, not the mechanism
|
||||
|
||||
A comment that restates the code is noise and will rot. A comment that explains
|
||||
*why* something is done that way is one of the most valuable things in a codebase,
|
||||
because the reasoning is the part that is lost first.
|
||||
|
||||
```go
|
||||
// Bad: restates the code
|
||||
// Increment the counter
|
||||
count++
|
||||
|
||||
// Good: explains why the obvious thing is wrong
|
||||
// The window is fixed rather than sliding: a fixed window needs one counter per
|
||||
// client rather than a list of timestamps, so its memory is O(clients) and a
|
||||
// client cannot make it degrade by sending requests at a high rate. The cost is a
|
||||
// burst of up to 2x the limit across a boundary, which is acceptable for the abuse
|
||||
// it defends against.
|
||||
```
|
||||
|
||||
Every non-obvious constant, every workaround for a library quirk, and every place
|
||||
where the straightforward implementation would be wrong or insecure gets a
|
||||
comment. Everything else stays uncommented.
|
||||
|
||||
### 1.2 Prefer the boring, explicit solution
|
||||
|
||||
This codebase does not need to be clever. It needs to be correct and legible. A
|
||||
reader should never have to reconstruct an invariant from a clever trick.
|
||||
|
||||
When two implementations are equally correct, prefer the one that a reviewer can
|
||||
verify in one reading.
|
||||
|
||||
### 1.3 Fail loudly, fail early
|
||||
|
||||
A silent fallback that produces a plausible-looking wrong result is worse than a
|
||||
crash, because it will not be found. This is why:
|
||||
|
||||
- configuration errors are reported in full at start-up, not one per deploy;
|
||||
- a malformed theme is rejected with the path that failed;
|
||||
- the token engine never silently drops a token it cannot parse;
|
||||
- a development authentication bypass refuses to initialise outside development.
|
||||
|
||||
### 1.4 Handle the boring cases exhaustively
|
||||
|
||||
Empty input, zero values, single-element slices, a nil map, a closed channel, a
|
||||
context that is already cancelled. Handle them in one place with a clear rule rather
|
||||
than at each call site.
|
||||
|
||||
### 1.5 Do not leak abstractions
|
||||
|
||||
A package exposes what it needs to expose. Internal helpers stay unexported. An
|
||||
interface with one implementation, used from one package, is indirection without
|
||||
benefit — unless it exists to make the caller testable, which is a real reason and
|
||||
is called out in a comment when used.
|
||||
|
||||
---
|
||||
|
||||
## 2. Go
|
||||
|
||||
### 2.1 Layout
|
||||
|
||||
```
|
||||
cmd/go-bananas/ the binary. main() must stay trivial.
|
||||
internal/
|
||||
app/ dependency injection and the process lifecycle
|
||||
config/ environment parsing and validation
|
||||
domain/ entities and invariants. No HTTP, no SQL.
|
||||
store/ persistence. The only package that writes SQL.
|
||||
tokenengine/ pure logic: colour maths, validation, rendering
|
||||
httpapi/ transport: routing, middleware, handlers
|
||||
auth/ authentication and authorisation
|
||||
logging/ slog configuration
|
||||
seed/ built-in themes
|
||||
```
|
||||
|
||||
The dependency rule, enforced by review:
|
||||
|
||||
```
|
||||
cmd → app → { httpapi, store, auth, config, logging }
|
||||
↓ ↓
|
||||
tokenengine domain ← everything may depend on this
|
||||
```
|
||||
|
||||
`domain` depends on nothing. `tokenengine` depends only on `domain`. `store`
|
||||
depends only on `domain`. `httpapi` may depend on `store`, `tokenengine` and
|
||||
`domain`, never on `app`. Nothing imports `cmd`.
|
||||
|
||||
### 2.2 Naming
|
||||
|
||||
| Kind | Convention | Example |
|
||||
| --- | --- | --- |
|
||||
| Exported identifier | `CamelCase`, with the initialism capitalised | `HTTPAddr`, `ParseColor`, `IDToken` |
|
||||
| Unexported identifier | `camelCase` | `themeColumns`, `renderCache` |
|
||||
| Interface | Noun, no `I` prefix | `Resolver`, `KeyStore` |
|
||||
| Error variable | `Err` prefix | `ErrNotFound` |
|
||||
| Test double | `stub`/`fake` prefix, in `_test.go` | `stubResolver` |
|
||||
| File | `snake_case` | `token_control.go` |
|
||||
| Package | One word, lowercase, no underscores | `tokenengine`, `httpapi` |
|
||||
|
||||
Initialisms follow the Go convention exactly: `ID`, `URL`, `HTTP`, `API`, `TLS`,
|
||||
`CSS`, `JSON`, `SQL`, `UUID`. Not `Id`, not `Url`.
|
||||
|
||||
### 2.3 Comments
|
||||
|
||||
Every exported identifier has a doc comment beginning with the identifier itself:
|
||||
|
||||
```go
|
||||
// ContrastRatio returns the WCAG 2.1 contrast ratio between the two colours,
|
||||
// always >= 1.
|
||||
func ContrastRatio(a, b Color) float64 {
|
||||
```
|
||||
|
||||
Unexported identifiers get a comment when the *why* is not obvious from the name.
|
||||
A comment restating an unexported function's name is still noise.
|
||||
|
||||
### 2.4 Errors
|
||||
|
||||
- Wrap with context and `%w` so `errors.Is` works through the chain.
|
||||
- Error strings are lowercase and do not end in punctuation. They read as part of a
|
||||
sentence: `invalid configuration`, not `Invalid configuration!`.
|
||||
- Sentinel errors live in `domain` and are matched with `errors.Is`. Never compare
|
||||
error strings.
|
||||
- A function returns a bare sentinel when the caller already knows the context, and
|
||||
a wrapped one when it adds information.
|
||||
- Never discard an error silently. If an error genuinely does not matter, say so in
|
||||
a comment explaining why.
|
||||
|
||||
```go
|
||||
// Good
|
||||
return nil, fmt.Errorf("open store: %w", err)
|
||||
|
||||
// Good: the caller cannot add anything, so return the sentinel unwrapped
|
||||
return domain.ErrNotFound
|
||||
|
||||
// Bad: loses the chain, so errors.Is cannot match it
|
||||
return nil, fmt.Errorf("failed: %s", err.Error())
|
||||
```
|
||||
|
||||
### 2.5 Context
|
||||
|
||||
- `ctx context.Context` is the first parameter of any function that does I/O.
|
||||
- Never store a context in a struct.
|
||||
- Every blocking operation takes a context. A network call without one cannot be
|
||||
cancelled, and a request handler that cannot be cancelled is a resource leak on
|
||||
shutdown.
|
||||
|
||||
### 2.6 Concurrency
|
||||
|
||||
- Make a type safe for concurrent use, or say clearly that it is not.
|
||||
- Prefer `sync.RWMutex` over `sync.Mutex` when reads dominate.
|
||||
- Never copy a type containing a mutex.
|
||||
- Every goroutine must have an owner and a termination condition. A goroutine
|
||||
started in a constructor needs a matching stop path or it leaks on every reload.
|
||||
- Pre-allocate with a known capacity in hot paths: `make([]T, 0, len(src))`.
|
||||
|
||||
### 2.7 Zero values
|
||||
|
||||
The zero value of a type must be useful or invalid, never subtly wrong. A
|
||||
`Cache` whose zero value has no map is fine. A `RateLimiter` whose zero value
|
||||
silently allows everything is not.
|
||||
|
||||
### 2.8 Imports
|
||||
|
||||
Standard library first, then third party, then internal, each group separated by a
|
||||
blank line. `goimports` enforces the grouping; `gci` enforces the ordering within
|
||||
groups.
|
||||
|
||||
Never import a package only to use one constant when a local definition would be
|
||||
clearer. Never import a package in a blank form to check for a build tag without a
|
||||
comment saying why.
|
||||
|
||||
### 2.9 `defer`
|
||||
|
||||
`defer` immediately after the call that can fail, not at the top of the function.
|
||||
A defer fifty lines below the resource acquisition is a defer that will be attached
|
||||
to the wrong variable.
|
||||
|
||||
`defer file.Close()` and check the error on a read-only close only where it matters.
|
||||
`defer rows.Close()` needs no error check; `defer resp.Body.Close()` usually does.
|
||||
|
||||
---
|
||||
|
||||
## 3. TypeScript and React
|
||||
|
||||
### 3.1 Strictness
|
||||
|
||||
`strict`, `noUncheckedIndexedAccess`, `noImplicitOverride`,
|
||||
`noFallthroughCasesInSwitch`, `noUnusedLocals`, `noUnusedParameters`,
|
||||
`verbatimModuleSyntax` and `isolatedModules` are all on. A type error is a build
|
||||
failure, not a warning.
|
||||
|
||||
TypeScript is pinned to **6.0.3**, not 7.x. TypeScript 7 is the native compiler
|
||||
and ships no JavaScript compiler API, so no type-aware linting tool can use it yet.
|
||||
This is revisited when the ecosystem catches up; see
|
||||
[ADR 0004](adr/0004-typescript-version.md).
|
||||
|
||||
### What is deliberately *not* enforced
|
||||
|
||||
Two rules are turned off in `eslint.config.js`, on purpose:
|
||||
|
||||
- **`explicit-function-return-type`**
|
||||
- **`explicit-module-boundary-types`**
|
||||
|
||||
Both demand a written type where `tsc --strict` already enforces one. A component
|
||||
with a wrongly-typed prop is a compile error either way; what the rules add is
|
||||
annotation churn — one per callback, and `(): JSX.Element =>` on components that
|
||||
legitimately start returning `null` and then need the annotation widened. That is a
|
||||
maintenance cost with no defect class behind it.
|
||||
|
||||
What *is* enforced instead, and what this means in practice:
|
||||
|
||||
| Rule | Where |
|
||||
| --- | --- |
|
||||
| Every component declares a typed props `type` | `tsc` |
|
||||
| Every exported non-component function has a JSDoc `@returns` | Review, and the JSDoc the docs are generated from |
|
||||
| `no-floating-promises`, `no-misused-promises` | ESLint, error |
|
||||
| `switch-exhaustiveness-check` | ESLint, error |
|
||||
| `no-unnecessary-condition` (in `web/`) | ESLint, error |
|
||||
|
||||
The one place it *is* relaxed is the SDK, where `no-unnecessary-condition` is off
|
||||
because the DOM type definitions describe a fully featured browser and the runtime
|
||||
guards that protect Safari below 14 and embedded webviews are load-bearing. That is
|
||||
recorded in `sdk/eslint.config.js` next to the rule.
|
||||
|
||||
### 3.2 `any` is not permitted
|
||||
|
||||
`any` disables checking for everything it touches. Use `unknown` at a boundary and
|
||||
narrow it. When a genuinely untyped value arrives — a JSON body, a third-party
|
||||
library — declare the shape you expect rather than casting to `any`.
|
||||
|
||||
An `any` with a comment explaining exactly why it cannot be avoided is acceptable
|
||||
only in a test double or at the boundary of an untyped library.
|
||||
|
||||
### 3.3 Types
|
||||
|
||||
- `type` over `interface` for unions, mapped types and anything conditional.
|
||||
`interface` over `type` for object shapes meant to be extended or implemented.
|
||||
- Prefer a named union over a union of inline literals. `type Appearance =
|
||||
'light' | 'dark'` is a domain concept and deserves a name.
|
||||
- Model optionality honestly. An optional field means the value may genuinely be
|
||||
absent; a field that is always present should not be marked optional to silence a
|
||||
type error.
|
||||
- Discriminated unions over optional fields for state that varies together:
|
||||
`{ status: 'loading' } | { status: 'ready', theme: Theme }` cannot be represented
|
||||
wrongly, whereas `{ status: 'ready', theme?: Theme }` can.
|
||||
|
||||
### 3.4 Components
|
||||
|
||||
- One component per file unless they are trivially small and only used together.
|
||||
- Props are typed with an interface or type, never inline.
|
||||
- A component is a pure function of its props unless it has a stateful reason to
|
||||
exist. If a `useMemo` is there purely for readability, it is noise.
|
||||
- Destructure props in the parameter list when more than about three are used.
|
||||
- Every component that renders user-visible text must be able to render it from a
|
||||
prop or a translation table. Hard-coded English in a leaf component is not
|
||||
acceptable; a page title in the default language is.
|
||||
- No component over 400 lines. A component that long is doing several jobs and the
|
||||
split is usually along the line of "container" and "presentational".
|
||||
|
||||
### 3.5 Hooks
|
||||
|
||||
- Hooks are called at the top level, unconditionally. A hook inside a condition is a
|
||||
bug waiting for the condition to change.
|
||||
- Every effect that subscribes, times out or fetches returns a cleanup function.
|
||||
- An effect that only computes derived state is wrong: compute it during render.
|
||||
- Dependency arrays must be complete. If one looks wrong, the component is probably
|
||||
deriving state that should not be state.
|
||||
- `useMemo` and `useCallback` are for referential stability and genuinely expensive
|
||||
computation, not for clarity. Do not use them to satisfy an exhaustive-deps warning
|
||||
without understanding the consequence.
|
||||
|
||||
### 3.6 State
|
||||
|
||||
- Server state lives in TanStack Query, not in `useState`. A `useState` holding
|
||||
something a fetcher owns will inevitably disagree with the fetcher.
|
||||
- Local form state is `useState`. A form with more than about ten fields, or one that
|
||||
needs validation on blur, belongs in a form library or in a reducer.
|
||||
- State is declared at the lowest component that needs it and not lifted until two
|
||||
siblings genuinely share it.
|
||||
|
||||
### 3.7 Imports
|
||||
|
||||
Alias `@/` resolves to `src/`. Relative imports within a directory, aliased imports
|
||||
across directories. Named imports, never namespace imports, unless the namespace is
|
||||
genuinely used as one.
|
||||
|
||||
---
|
||||
|
||||
## 4. CSS
|
||||
|
||||
### 4.1 Tailwind
|
||||
|
||||
- Utility classes in JSX. A stylesheet is for things utilities cannot express:
|
||||
keyframes, cascade layers, and generated design tokens.
|
||||
- Class order follows the Prettier plugin, so formatting is never a review topic.
|
||||
- Arbitrary values (`bg-[#123456]`) are a last resort. If the same value appears
|
||||
twice it becomes a theme token.
|
||||
- Colour is never hard-coded in a component. It comes from a semantic token, which is
|
||||
the entire premise of the project.
|
||||
|
||||
### 4.2 The token layer
|
||||
|
||||
The admin UI is itself a consumer of go-bananas. Runtime variables are mapped onto
|
||||
Tailwind's namespaces in `index.css` and nowhere else. A component that needs a new
|
||||
colour extends the mapping, not its own className.
|
||||
|
||||
### 4.3 Accessibility
|
||||
|
||||
- A visible focus indicator on every interactive element. `:focus-visible` with at
|
||||
least a 2:1 contrast ratio against its background.
|
||||
- Interactive elements are `button`, `a`, `input`, `select` or `textarea`. A `div`
|
||||
with `onClick` is not a control: it cannot be focused, does not appear in the
|
||||
accessibility tree, and does not respond to the keyboard.
|
||||
- Every icon-only control has an accessible name.
|
||||
- Colour is never the only signal. A status badge has text as well as a colour.
|
||||
- Motion respects `prefers-reduced-motion`.
|
||||
- Interactive targets are at least 44×44 CSS pixels on touch, or have sufficient
|
||||
spacing to the standard.
|
||||
|
||||
---
|
||||
|
||||
## 5. API design
|
||||
|
||||
### 5.1 URLs
|
||||
|
||||
- Plural nouns for collections, kebab-case for members: `/api/v1/themes`,
|
||||
`/api/v1/themes/{slug}/versions`.
|
||||
- The resource identity is in the path. The query string holds filtering, sorting
|
||||
and pagination.
|
||||
- `PATCH` for a partial update, `PUT` for a full replacement. A partial update must
|
||||
ignore absent fields rather than writing their zero values.
|
||||
|
||||
### 5.2 Status codes
|
||||
|
||||
| Code | When |
|
||||
| --- | --- |
|
||||
| `200` | Success with a body |
|
||||
| `201` | Resource created, with a body |
|
||||
| `204` | Success, no body. A delete or revoke |
|
||||
| `304` | Conditional request matched |
|
||||
| `400` | Malformed or invalid input |
|
||||
| `401` | No usable credential |
|
||||
| `403` | Authenticated but insufficient role |
|
||||
| `404` | Not found, **or** deliberately not disclosed |
|
||||
| `409` | Uniqueness or optimistic-concurrency conflict |
|
||||
| `413` | Body too large |
|
||||
| `422` | Reserved for a future validation-error shape |
|
||||
| `429` | Rate limited, with `Retry-After` |
|
||||
| `500` | Unexpected fault |
|
||||
|
||||
A `404` rather than a `403` is returned when confirming existence would itself leak
|
||||
information. A draft theme returns `404` from the runtime API for exactly this
|
||||
reason.
|
||||
|
||||
### 5.3 Error bodies
|
||||
|
||||
One shape everywhere:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "validation_failed",
|
||||
"message": "the theme cannot be published because its tokens are invalid",
|
||||
"details": [{ "field": "color.semantic.primary", "message": "not a valid colour" }],
|
||||
"requestId": "0f90d3cb9ab8e449"
|
||||
}
|
||||
```
|
||||
|
||||
`code` is a stable machine-readable identifier and is part of the contract: clients
|
||||
branch on it, so its meaning must not change between releases. `message` is for a
|
||||
human. `requestId` correlates with the server log and is always present on errors.
|
||||
|
||||
### 5.4 Versioning
|
||||
|
||||
`/api/v1` and `/rt/v1` are independent contracts, each with an `apiVersion`. A
|
||||
breaking change is a new major version served alongside the old one for at least one
|
||||
release cycle. Adding an optional field, a new endpoint or a new query parameter is
|
||||
not a breaking change.
|
||||
|
||||
### 5.5 Pagination
|
||||
|
||||
Cursor-based for collections that grow without bound (audit), offset-based for
|
||||
collections a human pages through (themes). Both return `{ items, total, limit,
|
||||
offset }` or the cursor equivalent, so the shape is predictable.
|
||||
|
||||
### 5.6 Idempotency
|
||||
|
||||
`DELETE` and revoke operations are idempotent: repeating them succeeds. A `PUT`
|
||||
or `PATCH` that would change nothing still succeeds. Only non-idempotent operations
|
||||
require an idempotency key, and only where a client genuinely cannot retry.
|
||||
|
||||
---
|
||||
|
||||
## 6. Error handling
|
||||
|
||||
### 6.1 The layers
|
||||
|
||||
| Layer | Responsibility |
|
||||
| --- | --- |
|
||||
| Domain | Returns typed sentinel errors. Knows nothing about HTTP. |
|
||||
| Store | Translates driver-specific errors into domain sentinels, once, in one place. |
|
||||
| Handlers | Translate domain sentinels into status codes, once, in `writeProblem`. |
|
||||
| Frontend client | Turns a non-2xx into an `ApiError` carrying the code and the fields. |
|
||||
|
||||
A handler never chooses a status code itself, and no repository ever matches on an
|
||||
error string.
|
||||
|
||||
### 6.2 Never swallow
|
||||
|
||||
A swallowed error is a bug that will be found by a user. If an error genuinely
|
||||
cannot be handled at a call site, log it at the right level and say why in a comment.
|
||||
|
||||
```go
|
||||
// The credentials were already validated, so a failure here means the database is
|
||||
// unreachable; failing the login would be worse than a missing directory entry.
|
||||
if err := a.keys.UpsertUser(ctx, identity); err != nil {
|
||||
log.Error("record login", "error", err)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Panic
|
||||
|
||||
Only for programmer errors: an invariant that cannot be true, or a nil where the
|
||||
type says non-nil. Never for bad input. The HTTP layer recovers from any panic and
|
||||
returns a 500, because a panic on a request goroutine must not take the process
|
||||
down.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security
|
||||
|
||||
### 7.1 Secrets
|
||||
|
||||
- Secrets come from the environment, injected by Kubernetes from a Secret. Never
|
||||
from a values file, a ConfigMap, a command-line flag or a committed file.
|
||||
- A secret is never logged, never rendered into a ConfigMap, and never included in an
|
||||
error message. `config.Redacted()` exists for logging the configuration.
|
||||
- Anything that generates source for someone else to paste is an output boundary: the
|
||||
value is escaped for the *target language*, not for HTML, and the language check is
|
||||
on the input rather than trusted.
|
||||
- `crypto/rand` for anything security-relevant. Never `math/rand`.
|
||||
|
||||
### 7.2 Authentication
|
||||
|
||||
- Fail closed. An unmapped group is viewer, never admin.
|
||||
- A development bypass refuses to initialise outside a development environment. This
|
||||
is enforced in code and refused again by the configuration validator, because an
|
||||
accidentally deployed bypass is a total authentication bypass.
|
||||
- Session cookies are `HttpOnly`, `Secure`, `SameSite` and signed. The signature is
|
||||
compared in constant time.
|
||||
- The post-login redirect is restricted to a local path. An absolute `returnTo` is an
|
||||
open redirect that also primes a phishing flow.
|
||||
|
||||
### 7.3 Input
|
||||
|
||||
- Every query parameter that reaches SQL goes through an allow-list of column names.
|
||||
Never interpolate a caller's string into a statement.
|
||||
- `LIKE` patterns are escaped so a user-supplied `%` is a literal.
|
||||
- Request bodies are size-limited. A theme document is a few kilobytes; the limit is
|
||||
2 MiB.
|
||||
- JSON with unknown fields is rejected, so a client typo is reported rather than
|
||||
silently ignored.
|
||||
- Rendered HTML is escaped. Interpolation into a template goes through
|
||||
`html.EscapeString` even when the value is operator-controlled.
|
||||
|
||||
### 7.4 HTTP
|
||||
|
||||
- TLS terminated at the ingress. HSTS advertised only when the deployment is
|
||||
actually served over HTTPS.
|
||||
- `Content-Security-Policy` with no `unsafe-eval` and no wildcard script source.
|
||||
- `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY` on every response.
|
||||
- CORS is an explicit allow-list. A wildcard origin with credentials is never valid.
|
||||
- `X-Forwarded-For` is believed only from a configured proxy CIDR. Without that,
|
||||
anyone can spoof the client address in the audit log.
|
||||
|
||||
### 7.5 Audit
|
||||
|
||||
Every mutation records an actor, an action, a target and a timestamp. Actor comes
|
||||
from the authenticated identity, never from the request body. An audit write
|
||||
failure is logged but does not fail the user's operation: losing an audit row is bad,
|
||||
failing a publish because of it is worse.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 What to test
|
||||
|
||||
- **Domain and tokenengine logic:** exhaustively. This is where a subtle mistake is
|
||||
most expensive and most invisible in review.
|
||||
- **Store behaviour:** the contract, not the implementation. Uniqueness, cascade
|
||||
behaviour, optimistic concurrency, the error sentinels.
|
||||
- **HTTP handlers:** the status code and the error body for each branch, plus the
|
||||
authorisation decision for each role.
|
||||
- **Frontend logic:** the pure helpers, exhaustively. Component tests focus on
|
||||
behaviour a user depends on, not on structure.
|
||||
- **Security behaviour:** every guard, as a test that fails when the guard is
|
||||
removed. A guard with no test is a guard that will be removed by accident.
|
||||
|
||||
### 8.2 What not to test
|
||||
|
||||
Implementation details, private function structure, and anything that would break on
|
||||
a refactor without a behaviour change. A test that fails when a private helper is
|
||||
renamed is a test that will be deleted rather than fixed.
|
||||
|
||||
### 8.3 Style
|
||||
|
||||
Table-driven where the cases share a shape. One assertion topic per test, with the
|
||||
assertion message explaining the expectation rather than restating the code.
|
||||
Prefer `require` over `expect` for preconditions that make the rest of the test
|
||||
meaningful.
|
||||
|
||||
Name the test after the behaviour, not the function: `TestContrastRatioMatchesWCAGReferenceValues`,
|
||||
not `TestContrast`.
|
||||
|
||||
A test that cannot fail is worse than no test. If a bug is found in production, add
|
||||
the test that would have caught it, in the same change.
|
||||
|
||||
### 8.4 Required coverage
|
||||
|
||||
| Package | Minimum |
|
||||
| --- | --- |
|
||||
| `tokenengine` | 90%, including WCAG reference values |
|
||||
| `domain` | 90% |
|
||||
| `store` | 80% |
|
||||
| `auth` | 85%, including every security guard |
|
||||
| `httpapi` | 75%, with every authorisation branch |
|
||||
| `web/src/lib` | 90% |
|
||||
|
||||
---
|
||||
|
||||
## 9. Database
|
||||
|
||||
- All SQL lives in `internal/store`. No handler writes SQL.
|
||||
- Column names are never interpolated from caller input. Sorting and filtering go
|
||||
through an allow-list (`domain.SortFields`).
|
||||
- Every foreign key has an explicit `ON DELETE` behaviour. Cascade is a decision, not
|
||||
an accident.
|
||||
- Every index exists because a query needs it, and its justification is in the
|
||||
migration comment.
|
||||
- Timestamps are TEXT in RFC 3339, which is sortable lexicographically, so `ORDER
|
||||
BY` and range filters need no date parsing.
|
||||
- Migrations are embedded in the binary and applied at start-up. They must be
|
||||
idempotent: `CREATE TABLE IF NOT EXISTS` and friends.
|
||||
- A schema change that is destructive or irreversible gets a snapshot step in the
|
||||
Helm chart and an entry in the runbook.
|
||||
|
||||
---
|
||||
|
||||
## 10. Documentation
|
||||
|
||||
- A package comment states what the package is for, what it depends on, and what
|
||||
depends on it. Every package has one.
|
||||
- `README.md` is for someone who has never seen the project. Its quick start must
|
||||
work as written, from a clean clone.
|
||||
- `docs/standards.md` is this document. It changes by the same review as the code.
|
||||
- An architecture decision that could reasonably have gone another way is recorded in
|
||||
`docs/adr/`, including the option that was rejected and why. An ADR is never
|
||||
edited to change the decision; it is superseded.
|
||||
- A comment explaining a workaround names the issue or the version that caused it, so
|
||||
a future reader knows when to remove it.
|
||||
|
||||
---
|
||||
|
||||
## 11. Commits and pull requests
|
||||
|
||||
### 11.1 Commit messages
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/), because the release
|
||||
notes and the changelog are generated from them.
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `build`, `ci`, `chore`.
|
||||
|
||||
The subject is imperative, under 72 characters, and describes what the change does
|
||||
rather than which files it touches. The body explains why, and what was considered
|
||||
and rejected.
|
||||
|
||||
### 11.2 Pull requests
|
||||
|
||||
- One concern per pull request. A refactor and a behaviour change are two pull
|
||||
requests.
|
||||
- The description states what changed, why, and how it was verified. If a decision
|
||||
was not obvious, it is explained.
|
||||
- The test plan is concrete: not "tests pass" but "a built-in theme rendering with
|
||||
an unreadable foreground still passes the contrast assertion".
|
||||
- Screenshots for any visual change.
|
||||
- Every CI check green. A failing check that is "expected" is not merged.
|
||||
|
||||
---
|
||||
|
||||
## 12. Dependencies
|
||||
|
||||
- The standard library first. A dependency is a permanent maintenance obligation
|
||||
and a permanent supply-chain surface.
|
||||
- Every new dependency is justified in the pull request: what it does, why the
|
||||
standard library cannot, and its maintenance status.
|
||||
- Prefer a dependency already in the tree over a new one for a similar job.
|
||||
- Pin versions exactly. A caret range in an application is how a build becomes
|
||||
unreproducible.
|
||||
- `go mod tidy` and `npm ci` in CI. Never `npm install` in a pipeline.
|
||||
- Renovate or Dependabot handles updates; they merge on a green build.
|
||||
|
||||
Current stack, and why each is present, is in
|
||||
[docs/architecture.md](architecture.md#the-stack).
|
||||
|
||||
---
|
||||
|
||||
## 13. Enforcement
|
||||
|
||||
| Rule | Enforced by |
|
||||
| --- | --- |
|
||||
| Go formatting | `gofmt -l`, `make fmt-check` |
|
||||
| Go lint | `golangci-lint`, `make lint-go` |
|
||||
| Go vet | `go vet`, part of `make check` |
|
||||
| Go import grouping | `gci`, in `.golangci.yml` |
|
||||
| Go tests, with the race detector | `go test -race`, `make test-go` |
|
||||
| TypeScript strictness | `tsc --build`, `make lint-web` |
|
||||
| Frontend lint | `eslint --max-warnings 0`, `make lint-web` |
|
||||
| SDK lint | `eslint`, `make lint-sdk` |
|
||||
| Frontend formatting | `prettier`, `make format-web` |
|
||||
| Frontend tests | `vitest`, `make test-web` |
|
||||
| Chart validity | `helm lint --strict`, `make helm-lint` |
|
||||
| Chart values | `values.schema.json` |
|
||||
| Chart configuration | `_helpers.tpl` guards, at render time |
|
||||
| Container build | `docker build`, `make image` |
|
||||
| Everything | `make verify` |
|
||||
|
||||
`make verify` is the pre-merge gate. It is the same command CI runs.
|
||||
217
docs/styling-contract.md
Normal file
217
docs/styling-contract.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# The shared styling contract
|
||||
|
||||
One stylesheet and two controls, adopted by adding them to a page. Nothing in an
|
||||
adopting application's own CSS has to change.
|
||||
|
||||
- [What it is](#what-it-is)
|
||||
- [Adding it](#adding-it)
|
||||
- [The two controls](#the-two-controls)
|
||||
- [The token vocabulary](#the-token-vocabulary)
|
||||
- [Migrating an application](#migrating-an-application)
|
||||
- [Verifying an adoption](#verifying-an-adoption)
|
||||
|
||||
## What it is
|
||||
|
||||
Six applications render design decisions six different ways. This is the layer they all
|
||||
sit on: a set of custom properties on the root element, scoped by a palette and an
|
||||
appearance, plus a compatibility block for the names those applications already use.
|
||||
|
||||
```css
|
||||
:root[data-theme='tokyo'][data-mode='dark'] {
|
||||
--bn-bg: #1a1b26;
|
||||
--bn-surface: #20222f;
|
||||
/* ... */
|
||||
}
|
||||
|
||||
/* Compatibility: applications that already use these names keep working. */
|
||||
:root[data-theme='tokyo'][data-mode='dark'] {
|
||||
--bg: var(--bn-bg);
|
||||
--surface: var(--bn-surface);
|
||||
}
|
||||
```
|
||||
|
||||
Two things are deliberate and worth understanding before adopting it.
|
||||
|
||||
**The namespaced names come first.** `--bn-bg` cannot collide with anything on the page
|
||||
that receives the stylesheet. An unprefixed `--surface` is a collision waiting to happen,
|
||||
and which value wins depends on cascade order.
|
||||
|
||||
**The unprefixed names come second, as aliases.** They reference the namespaced ones
|
||||
rather than repeating the values, so there is exactly one place a colour is written.
|
||||
This is what makes adoption cheap: an application that already uses `--bg`, `--surface`
|
||||
and `--line` keeps working, and deleting its own `:root` block is the whole migration.
|
||||
|
||||
## Adding it
|
||||
|
||||
```html
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://themes.example.com/rt/v1/contract.css" data-theme-contract>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<bananas-theme-selector></bananas-theme-selector>
|
||||
<bananas-appearance-toggle></bananas-appearance-toggle>
|
||||
</nav>
|
||||
</body>
|
||||
|
||||
<script type="module" src="https://themes.example.com/rt/v1/components.js"></script>
|
||||
```
|
||||
|
||||
`data-theme-contract` marks the stylesheet so the selector can find it. The marker is
|
||||
optional — the selector also accepts any stylesheet whose `href` ends in
|
||||
`contract.css` — but it is unambiguous, and it is what makes the selector work with no
|
||||
`base` attribute.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Setting | Where | Default |
|
||||
| --- | --- | --- |
|
||||
| Service origin | the `contract.css` `href` | — |
|
||||
| Palette | `data-theme` on the root element | the element's own attribute |
|
||||
| Appearance | `data-mode` on the root element | `admin`, meaning no theme |
|
||||
| Persistence | `localStorage`, key `go-bananas:appearance` | per origin |
|
||||
|
||||
### Cross-origin deployments
|
||||
|
||||
The stylesheet needs no CORS. The `components.js` module and the `contract.json` fetch
|
||||
behind the selector both do, so a page on a different origin from the service needs that
|
||||
origin in the service's `CORS_ALLOWED_ORIGINS`:
|
||||
|
||||
```bash
|
||||
BANANAS_CORS_ALLOWED_ORIGINS=https://app.example.com
|
||||
```
|
||||
|
||||
A same-origin deployment needs nothing configured. One setting is the entire cost.
|
||||
|
||||
## The two controls
|
||||
|
||||
Both are custom elements with a shadow root. That is not decoration:
|
||||
|
||||
- Page CSS cannot reach inside, so a global `button { all: unset }` or a Tailwind
|
||||
preflight cannot restyle them.
|
||||
- Their CSS cannot leak out, so they cannot restyle the page. This is the same property
|
||||
that makes a live preview of a palette safe: the preview's styles stay in the preview.
|
||||
- Custom properties still cross the boundary, so the controls follow the palette they
|
||||
are switching without one extra rule.
|
||||
|
||||
Custom elements rather than framework components, because the applications are a mix of
|
||||
vanilla JS, React and Tailwind. A React component is unusable by the first; a custom
|
||||
element works in all three with no build step and no import map.
|
||||
|
||||
| Element | Purpose | Attributes |
|
||||
| --- | --- | --- |
|
||||
| `<bananas-theme-selector>` | picks the palette from `contract.json` | `base`, `label`, `compact` |
|
||||
| `<bananas-appearance-toggle>` | admin chrome, light, or dark | `label`, `show-labels` |
|
||||
|
||||
Both take a `value` property for framework interop, and emit a bubbling composed event:
|
||||
`bananas:theme` with `{slug, mode}`, `bananas:appearance` with `{mode}`.
|
||||
|
||||
Stying hooks: `::part(button)`, `::part(group)`, and `--bn-control-radius`,
|
||||
`--bn-control-gap`, `--bn-control-padding`, `--bn-control-font`.
|
||||
|
||||
### Admin is a third state, not a fourth mode
|
||||
|
||||
The appearance toggle has three positions, and `admin` is not light or dark. It means
|
||||
"no theme": the application's own chrome. An administration screen has to look like the
|
||||
administration screen even while a palette is being previewed, and that is a different
|
||||
question from light or dark.
|
||||
|
||||
### Building your own control
|
||||
|
||||
The store is exported from the same module, so a bespoke control is a few lines:
|
||||
|
||||
```js
|
||||
import { getContractState } from 'https://themes.example.com/rt/v1/components.js'
|
||||
|
||||
const state = getContractState()
|
||||
|
||||
state.subscribe(({ theme, mode }) => render(theme, mode))
|
||||
state.selectTheme('tokyo')
|
||||
state.selectMode('dark')
|
||||
```
|
||||
|
||||
## The token vocabulary
|
||||
|
||||
Surfaces, from the page background up to the most raised:
|
||||
|
||||
| Token | Role |
|
||||
| --- | --- |
|
||||
| `--bn-bg` | page background |
|
||||
| `--bn-surface` | card |
|
||||
| `--bn-surface-raised` | popover, menu, raised panel |
|
||||
| `--bn-surface-sunken` | inset well, track |
|
||||
| `--bn-overlay` | scrim behind a dialog |
|
||||
|
||||
Text, in descending emphasis: `--bn-text`, `--bn-muted`, `--bn-subtle`, `--bn-inverted`.
|
||||
|
||||
Chrome: `--bn-border`, `--bn-border-strong`, `--bn-focus`.
|
||||
|
||||
Accent: `--bn-accent`, `--bn-accent-hover`, `--bn-accent-active`, `--bn-on-accent`,
|
||||
`--bn-secondary`, `--bn-on-secondary`.
|
||||
|
||||
Status: `--bn-good`, `--bn-warning`, `--bn-danger`, `--bn-info`.
|
||||
|
||||
The geometry, spacing and typography scales are re-exported rather than re-derived, so
|
||||
`--bn-radius-md` is the very variable the theme defines and an override in the theme's own
|
||||
stylesheet still wins.
|
||||
|
||||
Interaction states are built with `color-mix` rather than a hand-tuned ladder, so one
|
||||
accent serves every state and the ladder cannot drift between palettes:
|
||||
|
||||
```css
|
||||
.card-action:hover {
|
||||
background: color-mix(in srgb, var(--bn-accent) 12%, var(--bn-surface));
|
||||
}
|
||||
```
|
||||
|
||||
## Migrating an application
|
||||
|
||||
1. **Add the link and the script.** Nothing else. The application is now themed, and its
|
||||
own `:root` block still wins for any property it defines.
|
||||
2. **Delete the application's `:root` theme colours.** The unprefixed aliases take over.
|
||||
Keep the rest of the stylesheet; it does not need editing.
|
||||
3. **Replace the hand-rolled switcher** with the two elements, and delete the script that
|
||||
drove it. This matters: two scripts writing `data-theme` means whichever loaded last
|
||||
wins.
|
||||
4. **Optionally rename** `--bg` to `--bn-bg` and drop the aliases. This is the only step
|
||||
that touches call sites, so it is the one to defer.
|
||||
|
||||
### Palettes available by default
|
||||
|
||||
Eleven, and their slugs deliberately match the applications that own them so that
|
||||
adoption does not require renaming a stored value:
|
||||
|
||||
| Slug | Origin | Default |
|
||||
| --- | --- | --- |
|
||||
| `midnight`, `daylight`, `mono`, `high-contrast` | go-bananas | mixed |
|
||||
| `urpi-standard` | go-guess | light |
|
||||
| `tokyo`, `catppuccin`, `tomorrow`, `kanagawa` | go-loose | mixed |
|
||||
| `nordic`, `obsidian` | go-tell | dark |
|
||||
|
||||
Every palette has both a light and a dark scheme. Three of the source applications had
|
||||
only one, so the other is derived; those are marked as derived in the theme's
|
||||
description.
|
||||
|
||||
## Verifying an adoption
|
||||
|
||||
Two checks, both of which run headless Chromium and both of which are repeatable:
|
||||
|
||||
```bash
|
||||
make check-contract # the contract and the controls, 16 assertions
|
||||
make check-pilot # both adopting applications, 20 and 23 assertions
|
||||
```
|
||||
|
||||
`check-pilot` is the one that matters. Each of its two runs loads an adopting
|
||||
application's *real* built shell and its *real* stylesheet, changes only the theme
|
||||
origin, and then asserts that clicking the shipped components moves `data-theme` and
|
||||
that the application's own CSS followed.
|
||||
|
||||
- **go-loose** (20 assertions) kept its hand-written palette. The components drive it
|
||||
through the attribute its CSS already keyed on.
|
||||
- **go-tell** (23 assertions) was fully tokenised: it had no custom properties at all,
|
||||
340 hardcoded hex values and 153 per-palette override rules. Its stylesheet now reads
|
||||
from the contract, and the check also asserts that the `terminal` palette's global
|
||||
`font-family: ... !important` reaches the application but not into the controls'
|
||||
shadow roots.
|
||||
|
||||
If both pass, the consolidation is real rather than decorative.
|
||||
Loading…
Add table
Add a link
Reference in a new issue