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

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

View file

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

View file

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

View file

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

View file

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

View file

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