chore: adding docs

This commit is contained in:
Daniel Noulet 2026-10-07 15:05:48 +02:00
parent 23816d00f7
commit 81c5112409
39 changed files with 7257 additions and 2 deletions

2
.gitignore vendored
View file

@ -6,8 +6,6 @@
!/backend/webui/dist/index.html
# Flat-file store: the seed document is tracked, generated fixtures are not
/data/docs/*
!/data/docs/go-doc.md
/data/comments/
!data/comments/.gitkeep

View file

@ -0,0 +1,182 @@
<!-- go-doc:generated:begin source=go-bananas:docs/adr/0001-embedded-sqlite.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/adr/0001-embedded-sqlite.md in the go-bananas repository.
---
# 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.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,148 @@
<!-- go-doc:generated:begin source=go-bananas:docs/adr/0002-runtime-delivery.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/adr/0002-runtime-delivery.md in the go-bananas repository.
---
# 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.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,107 @@
<!-- go-doc:generated:begin source=go-bananas:docs/adr/0003-stateful-sessions.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/adr/0003-stateful-sessions.md in the go-bananas repository.
---
# 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.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,76 @@
<!-- go-doc:generated:begin source=go-bananas:docs/adr/0004-typescript-version.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/adr/0004-typescript-version.md in the go-bananas repository.
---
# 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.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,125 @@
<!-- go-doc:generated:begin source=go-bananas:docs/adr/0005-shared-token-contract.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/adr/0005-shared-token-contract.md in the go-bananas repository.
---
# 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.
<!-- go-doc:generated:end -->

403
data/docs/go-bananas-api.md Normal file
View file

@ -0,0 +1,403 @@
<!-- go-doc:generated:begin source=go-bananas:docs/api.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/api.md in the go-bananas repository.
---
# 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 }
}
}
```
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,354 @@
<!-- go-doc:generated:begin source=go-bananas:docs/architecture.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/architecture.md in the go-bananas repository.
---
# 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.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,553 @@
<!-- go-doc:generated:begin source=go-bananas:docs/deployment.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/deployment.md in the go-bananas repository.
---
# 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.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,229 @@
<!-- go-doc:generated:begin source=go-bananas:docs/development.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/development.md in the go-bananas repository.
---
# 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.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,571 @@
<!-- go-doc:generated:begin source=go-bananas:docs/integration.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/integration.md in the go-bananas repository.
---
# 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
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,279 @@
<!-- go-doc:generated:begin source=go-bananas:docs/operations.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/operations.md in the go-bananas repository.
---
# 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.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,253 @@
<!-- go-doc:generated:begin source=go-bananas:docs/security.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/security.md in the go-bananas repository.
---
# 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.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,672 @@
<!-- go-doc:generated:begin source=go-bananas:docs/standards.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/standards.md in the go-bananas repository.
---
# 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.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,223 @@
<!-- go-doc:generated:begin source=go-bananas:docs/styling-contract.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
Imported from docs/styling-contract.md in the go-bananas repository.
---
# 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.
<!-- go-doc:generated:end -->

227
data/docs/go-bananas.md Normal file
View file

@ -0,0 +1,227 @@
<!-- go-doc:generated:begin source=go-bananas signature=14ab62420e5091c8fd2da3a6c2a07232 generated=2026-10-07 09:29 UTC -->
# go-bananas
**Runtime design-token service.** Author design-token themes in a web interface, serve them to any application as CSS custom properties at run time. Consumers install nothing and have no build step.
| | |
| --- | --- |
| Module | github.com/urpi/go-bananas · go 1.27.1 · 49 Go files |
| Endpoints | 30 endpoints |
| Components | 0 component directories |
| Tests | 128 in 14 files |
| Go lines | 16314 lines |
> Everything between the generated markers is rewritten by every sync. Notes written outside the markers are kept.
## For Users
### What this application does
**Runtime design-token service.** Author design-token themes in a web interface, serve them to any application as CSS custom properties at run time. Consumers install nothing and have no build step.
The project ships its own README at `README.md`, which is the long form of this page. The three audience tabs split the same facts three ways so you only read the part that concerns you.
### Reading this in go-doc
Open the page and pick the tab that matches you:
- **User** — you use the finished application. This section is all you need.
- **Operator** — you run it. Configuration, ports and deployment notes are under that tab.
- **Developer** — you integrate with it or change it. The API and layout tables are under that tab.
The feedback panel stores a note per audience, tagged with your badge, so operators and developers can see which audience raised it.
### Where the original documentation lives
This project already carries 15 Markdown documents. They stay in the project repository: this page points at them instead of copying them, so there is always exactly one copy of the truth.
| Document | Size |
| --- | --- |
| `README.md` | 18070 bytes |
| `docs/adr/0001-embedded-sqlite.md` | 8480 bytes |
| `docs/adr/0002-runtime-delivery.md` | 6721 bytes |
| `docs/adr/0003-stateful-sessions.md` | 4438 bytes |
| `docs/adr/0004-typescript-version.md` | 2624 bytes |
| `docs/adr/0005-shared-token-contract.md` | 5659 bytes |
| `docs/api.md` | 12685 bytes |
| `docs/architecture.md` | 16634 bytes |
| `docs/deployment.md` | 20188 bytes |
| `docs/development.md` | 8483 bytes |
| `docs/integration.md` | 17511 bytes |
| `docs/operations.md` | 10048 bytes |
| `docs/security.md` | 12400 bytes |
| `docs/standards.md` | 26476 bytes |
| `docs/styling-contract.md` | 8545 bytes |
---
## For Operators
### Running it locally
This project has no root Makefile, so the binary or container is run directly.
### Local containers
The compose file defines these services:
| Service | Image | Published ports |
| --- | --- | --- |
| go-bananas | `${IMAGE:-go-bananas:dev}` | internal only |
| keycloak | `quay.io/keycloak/keycloak:26.3` | internal only |
| postgres | `postgres:18-alpine` | internal only |
### Image
`Dockerfile` builds from `node:${NODE_VERSION}-alpine`, `golang:${GO_VERSION}-alpine`, `alpine:${ALPINE_VERSION}`, `runtime` in stages: assets, build, runtime, final and exposes 8080.
### Configuration
Every environment variable found in the source or in a `.env` file:
| Variable | Example | Found in |
| --- | --- | --- |
| `BANANAS_AUTH_MODE` | `dev` | .env file |
| `BANANAS_DATABASE_PATH` | `./data/go-bananas.db` | .env file |
| `BANANAS_DB_BUSY_TIMEOUT` | `5s` | .env file |
| `BANANAS_DB_MAX_CONNS` | `8` | .env file |
| `BANANAS_DEV_FALLBACK_ROLE` | `admin` | .env file |
| `BANANAS_ENABLE_METRICS` | `true` | .env file |
| `BANANAS_ENABLE_PROFILER` | `false` | .env file |
| `BANANAS_ENVIRONMENT` | `development` | .env file |
| `BANANAS_HTTP_ADDR` | `:8080` | .env file |
| `BANANAS_HTTP_IDLE_TIMEOUT` | `120s` | .env file |
| `BANANAS_HTTP_READ_TIMEOUT` | `15s` | .env file |
| `BANANAS_HTTP_WRITE_TIMEOUT` | `30s` | .env file |
| `BANANAS_LOG_FORMAT` | `text` | .env file |
| `BANANAS_LOG_LEVEL` | `info` | .env file |
| `BANANAS_OIDC_CLIENT_ID` | `go-bananas` | .env file |
| `BANANAS_OIDC_ISSUER` | `https://auth-dev.urpi.be/realms/urpi` | .env file |
| `BANANAS_PUBLIC_BASE_URL` | `http://localhost:8080` | .env file |
| `BANANAS_RATE_LIMIT_PER_MINUTE` | `600` | .env file |
| `BANANAS_RUNTIME_CACHE_CONTROL` | `public, max-age=60, stale-while-revalidate=86400` | .env file |
| `BANANAS_SEED_ON_START` | `true` | .env file |
| `BANANAS_SESSION_COOKIE_NAME` | `go-bananas-session` | .env file |
| `BANANAS_SESSION_SAMESITE` | `Lax` | .env file |
| `BANANAS_SESSION_SECURE` | `false` | .env file |
| `BANANAS_SESSION_TTL` | `8h` | .env file |
| `BANANAS_SHUTDOWN_TIMEOUT` | `25s` | .env file |
| `DATABASE_PATH` | — | source |
Command-line flags override the environment where both exist:
| Flag | Default | Purpose |
| --- | --- | --- |
| `-backup=flag.ContinueOnError` | `flag.ContinueOnError` | — |
### Network surface
Ports referenced across the project: `8080`.
### Deployment artefacts
Helm 1 chart found:
- `deploy/helm/go-bananas`
### Operations documentation that already exists
Read these in place rather than duplicating them here:
- `docs/deployment.md` — Deployment
- `docs/operations.md` — Operations
---
## For Developers
### Module layout
- go.mod — module github.com/urpi/go-bananas, go 1.27.1, 3 direct and 11 indirect dependencies
- hack/go.mod — module hack/tools, go 1.27.1, 0 direct and 254 indirect dependencies
### HTTP API
Every route registration found in the source:
| Method | Path | Handler |
| --- | --- | --- |
| `GET` | `/*` | `ServeHTTP` |
| `HEAD` | `/*` | `ServeHTTP` |
| `GET` | `/auth/callback` | `Callback` |
| `GET` | `/auth/login` | `Login` |
| `GET` | `/auth/logout` | `Logout` |
| `POST` | `/auth/logout` | `Logout` |
| `GET` | `/contract.css` | `ContractCSS` |
| `GET` | `/contract.json` | `ContractJSON` |
| `GET` | `/contrast` | `contrast` |
| `GET` | `/embed` | `EmbedSnippet` |
| `GET` | `/embed-page` | `EmbedPage` |
| `GET` | `/healthz` | `Live` |
| `GET` | `/livez` | `Live` |
| `GET` | `/manifest.json` | `Manifest` |
| `GET` | `/meta` | `Meta` |
| `GET` | `/metrics` | — |
| `GET` | `/palette` | `generatePalette` |
| `DELETE` | `/preferences` | `Reset` |
| `GET` | `/preferences` | `Get` |
| `PUT` | `/preferences` | `Update` |
| `GET` | `/readyz` | `Ready` |
| `GET` | `/schema` | `tokenSchema` |
| `GET` | `/stats` | `stats` |
| `GET` | `/theme.css` | `ThemeCSS` |
| `GET` | `/theme.json` | `ThemeJSON` |
| `GET` | `/themes/{slug}/embed` | `EmbedSnippet` |
| `GET` | `/themes/{slug}/embed-page` | `EmbedPage` |
| `GET` | `/themes/{slug}/theme.css` | `ThemeCSS` |
| `GET` | `/themes/{slug}/theme.json` | `ThemeJSON` |
| `POST` | `/validate` | `validateTokens` |
### Direct dependencies
- `github.com/coreos/go-oidc/v3` v3.21.0
- `golang.org/x/oauth2` v0.37.0
- `modernc.org/sqlite` v1.60.1
### Directory map
| Directory | Files | Lines | Likely purpose |
| --- | --- | --- | --- |
| `internal/` | 51 | 16405 | unimportable application internals |
| `web/` | 43 | 7897 | frontend assets |
| `deploy/` | 21 | 274 | deployment manifests |
| `docs/` | 14 | 0 | project documentation |
| `sdk/` | 14 | 3261 | unknown — inspect directly |
| `hack/` | 10 | 2248 | operational tooling |
| `cmd/` | 1 | 95 | binary entry points |
| `data/` | 1 | 186 | flat-file data |
### Languages
- **Go** — 49 files, 16314 lines
- **JSON** — 11 files, 8329 lines
- **TypeScript** — 33 files, 7763 lines
- **YAML** — 21 files, 5290 lines
- **Markdown** — 15 files, 4560 lines
- **JavaScript** — 15 files, 3820 lines
- **Shell** — 4 files, 529 lines
- **Templates** — 1 file, 255 lines
- **CSS** — 1 file, 211 lines
- **SQL** — 3 files, 186 lines
### Developer documentation that already exists
- `docs/adr/0005-shared-token-contract.md` — ADR 0005: One token contract for every application, with a legacy alias layer
- `docs/api.md` — HTTP API
- `docs/architecture.md` — Architecture
- `docs/development.md` — Development
- `docs/integration.md` — Integrating a go-bananas theme
- `docs/styling-contract.md` — The shared styling contract
<!-- go-doc:generated:end -->
---
## Related Documentation
- [go-doc](go-doc.md) — The documentation platform itself
- [go-loose](go-loose.md) — Auth middleware (referenced in integration docs)

197
data/docs/go-first.md Normal file
View file

@ -0,0 +1,197 @@
<!-- go-doc:generated:begin source=go-first signature=b2d9663478587eb442ff68c6d7e66369 generated=2026-10-07 09:29 UTC -->
# go-first
> **URPI** — Web Application & Backend Development Experts > Live at: [https://www.urpi.be](https://www.urpi.be)
| | |
| --- | --- |
| Module | github.com/dnoulet/go-first · go 1.23 · 12 Go files |
| Endpoints | 0 endpoints |
| Components | 0 component directories |
| Tests | none detected |
| Go lines | 1383 lines |
> Everything between the generated markers is rewritten by every sync. Notes written outside the markers are kept.
## For Users
### What this application does
> **URPI** — Web Application & Backend Development Experts > Live at: [https://www.urpi.be](https://www.urpi.be)
The project ships its own README at `README.md`, which is the long form of this page. The three audience tabs split the same facts three ways so you only read the part that concerns you.
### Reading this in go-doc
Open the page and pick the tab that matches you:
- **User** — you use the finished application. This section is all you need.
- **Operator** — you run it. Configuration, ports and deployment notes are under that tab.
- **Developer** — you integrate with it or change it. The API and layout tables are under that tab.
The feedback panel stores a note per audience, tagged with your badge, so operators and developers can see which audience raised it.
### Where the original documentation lives
This project already carries 4 Markdown documents. They stay in the project repository: this page points at them instead of copying them, so there is always exactly one copy of the truth.
| Document | Size |
| --- | --- |
| `README.md` | 9681 bytes |
| `docs/API.md` | 8052 bytes |
| `docs/DEPLOYMENT.md` | 9465 bytes |
| `docs/DEVELOPMENT.md` | 11918 bytes |
---
## For Operators
### Running it locally
The root Makefile defines 30 targets:
- `make build-backend`
- `make build-frontend`
- `make cert-renew`
- `make cert-status`
- `make check-deps`
- `make ci-build`
- `make ci-install`
- `make ci-test`
- `make clean-all`
- `make db-migrate-create`
- `make db-migrate-down`
- `make db-migrate-up`
- `make deploy-staging`
- `make dev-backend`
- `make dev-frontend`
- `make generate`
- `make install`
- `make lint-backend`
- `make lint-frontend`
- `make logs`
- `make release-major`
- `make release-minor`
- `make release-patch`
- `make restart`
- `make start`
- `make status`
- `make stop`
- `make test-backend`
- `make test-frontend`
- `make version`
### Local containers
The compose file defines these services:
| Service | Image | Published ports |
| --- | --- | --- |
| app | built locally | internal only |
| postgres | `postgres:16-alpine` | internal only |
| redis | `redis:7-alpine` | internal only |
| nginx | `nginx:alpine` | internal only |
### Image
`Dockerfile` builds from `golang:1.23-alpine`, `node:20-alpine`, `alpine:3.20` in stages: backend-builder, frontend-builder and exposes 8080.
### Configuration
Every environment variable found in the source or in a `.env` file:
| Variable | Example | Found in |
| --- | --- | --- |
| `CORS_ALLOWED_ORIGINS` | `http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173` | .env file |
| `DATABASE_HOST` | `localhost` | .env file |
| `DATABASE_NAME` | `go_first_dev` | .env file |
| `DATABASE_PASSWORD` | `postgres` | .env file |
| `DATABASE_PORT` | `5432` | .env file |
| `DATABASE_SSL_MODE` | `disable` | .env file |
| `DATABASE_USER` | `postgres` | .env file |
| `EMAIL_FROM_ADDRESS` | `noreply@localhost` | .env file |
| `EMAIL_FROM_NAME` | `URPI Local` | .env file |
| `EMAIL_PASSWORD` | — | .env file |
| `EMAIL_SMTP_HOST` | — | .env file |
| `EMAIL_SMTP_PORT` | `587` | .env file |
| `EMAIL_USERNAME` | — | .env file |
| `FRONTEND_BUILD_PATH` | `./web/dist` | .env file |
| `FRONTEND_SPA_FALLBACK` | `true` | .env file |
| `JWT_EXPIRES_IN` | `24` | .env file |
| `JWT_REFRESH_EXPIRES_IN` | `168` | .env file |
| `JWT_SECRET` | `local_development_secret_key_change_in_production` | .env file |
| `LOGGING_FORMAT` | `console` | .env file |
| `LOGGING_LEVEL` | `debug` | .env file |
| `LOGGING_OUTPUT` | `stdout` | .env file |
| `REDIS_DB` | `0` | .env file |
| `REDIS_HOST` | `localhost` | .env file |
| `REDIS_PASSWORD` | — | .env file |
| `REDIS_PORT` | `6379` | .env file |
| `SERVER_HOST` | `0.0.0.0` | .env file |
| `SERVER_MODE` | `debug` | .env file |
| `SERVER_PORT` | `8080` | .env file |
### Network surface
Ports referenced across the project: `5432`, `8080`.
### Deployment artefacts
No Helm chart was found, so deployment is expected to be plain Docker or a managed platform.
### Operations documentation that already exists
Read these in place rather than duplicating them here:
- `docs/DEPLOYMENT.md` — Deployment Guide — go-first / URPI
> No HTTP route registrations were detected. If this is a server, the API table is incomplete — check for routes built dynamically.
---
## For Developers
### Module layout
- go.mod — module github.com/dnoulet/go-first, go 1.23, 5 direct and 26 indirect dependencies
### HTTP API
No route registrations were detected, which is expected for a CLI, a library or a worker.
### Direct dependencies
- `github.com/gin-gonic/gin` v1.10.0
- `github.com/google/uuid` v1.6.0
- `github.com/joho/godotenv` v1.5.1
- `github.com/rs/zerolog` v1.33.0
- `gopkg.in/yaml.v3` v3.0.1
### Directory map
| Directory | Files | Lines | Likely purpose |
| --- | --- | --- | --- |
| `web/` | 37 | 2366 | frontend assets |
| `internal/` | 11 | 1266 | unimportable application internals |
| `docs/` | 3 | 0 | project documentation |
| `deploy/` | 2 | 316 | deployment manifests |
| `cmd/` | 1 | 117 | binary entry points |
### Languages
- **JSON** — 4 files, 6107 lines
- **TypeScript** — 27 files, 2262 lines
- **Markdown** — 4 files, 1784 lines
- **Go** — 12 files, 1383 lines
- **YAML** — 4 files, 540 lines
- **out** — 1 file, 354 lines
- **conf** — 1 file, 185 lines
- **CSS** — 1 file, 161 lines
- **Shell** — 1 file, 131 lines
- **JavaScript** — 2 files, 94 lines
### Developer documentation that already exists
- `docs/API.md` — API Documentation — go-first / URPI
- `docs/DEVELOPMENT.md` — Development Guide — go-first / URPI
<!-- go-doc:generated:end -->

101
data/docs/go-fish.md Normal file
View file

@ -0,0 +1,101 @@
<!-- go-doc:generated:begin source=go-fish signature=7f744c2508fdc6f1c8a4f727d5ccb83b generated=2026-10-07 08:28 UTC -->
# go-fish
A web-based implementation of the classic Go Fish card game.
| | |
| --- | --- |
| Module | github.com/danyel/go-fish · go 1.21 · 6 Go files |
| Endpoints | 5 endpoints |
| Components | 0 component directories |
| Tests | none detected |
| Go lines | 645 lines |
> Everything between the generated markers is rewritten by every sync. Notes written outside the markers are kept.
## For Users
### What this application does
A web-based implementation of the classic Go Fish card game.
The project ships its own README at `README.md`, which is the long form of this page. The three audience tabs split the same facts three ways so you only read the part that concerns you.
### Reading this in go-doc
Open the page and pick the tab that matches you:
- **User** — you use the finished application. This section is all you need.
- **Operator** — you run it. Configuration, ports and deployment notes are under that tab.
- **Developer** — you integrate with it or change it. The API and layout tables are under that tab.
The feedback panel stores a note per audience, tagged with your badge, so operators and developers can see which audience raised it.
### Where the original documentation lives
This project already carries 1 Markdown document. They stay in the project repository: this page points at them instead of copying them, so there is always exactly one copy of the truth.
| Document | Size |
| --- | --- |
| `README.md` | 1829 bytes |
---
## For Operators
### Running it locally
This project has no root Makefile, so the binary or container is run directly.
### Configuration
No environment variables were found in the source or in a `.env` file.
### Network surface
No explicit port declaration was found. Check the README and the compose file before assuming a default.
### Deployment artefacts
No Helm chart was found, so deployment is expected to be plain Docker or a managed platform.
> No Dockerfile was found at any level.
---
## For Developers
### Module layout
- go.mod — module github.com/danyel/go-fish, go 1.21, 1 direct and 0 indirect dependencies
### HTTP API
Every route registration found in the source:
| Method | Path | Handler |
| --- | --- | --- |
| `GET` | `/` | `homeHandler` |
| `POST` | `/ask` | — |
| `POST` | `/join` | — |
| `GET` | `/play/{id}` | — |
| `GET` | `/waiting` | — |
### Direct dependencies
- `github.com/go-chi/chi/v5` v5.0.0
### Directory map
| Directory | Files | Lines | Likely purpose |
| --- | --- | --- | --- |
| `internal/` | 5 | 353 | unimportable application internals |
| `web/` | 3 | 0 | frontend assets |
| `cmd/` | 1 | 292 | binary entry points |
### Languages
- **Go** — 6 files, 645 lines
- **HTML** — 3 files, 251 lines
- **Markdown** — 1 file, 71 lines
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,124 @@
<!-- go-doc:generated:begin source=go-guess:.github/copilot-instructions.md signature=781b9d03c5914e6fd446714cc17f6867 generated=2026-10-05 18:14 UTC -->
Imported from .github/copilot-instructions.md in the go-guess repository.
---
# Copilot instructions
## Build, test, and lint
Run repository-wide commands from the root:
```bash
make dev # run PostgreSQL, RabbitMQ, API, and frontend with development fixtures
make test # complete suite, including disposable infrastructure integration tests
make test-integration # Go tests; starts PostgreSQL and RabbitMQ Testcontainers
make test-env # isolated seeded UI stack at http://localhost:15173
make test-env-down # remove the test stack and its PostgreSQL volume
make lint # go vet and ESLint
make build # compile the API and production frontend
```
Focused tests:
```bash
cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercent$'
cd backend && go test -tags=integration ./internal/integration -run '^TestCompleteInterviewWorkflow$'
cd frontend && npm test -- src/app/App.test.tsx -t "filters jobs by title"
```
The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use
`cd frontend && npm run format:check` for frontend formatting and `gofmt` for Go.
## Architecture
- `backend/cmd/api` wires configuration, PostgreSQL, security, services, handlers,
and the Chi router. Requests flow through `internal/web/{router,handler,model,mapper}`
to `internal/service/{model,services}` and then
`internal/database/{repository,mapper,entity}`. Never expose GORM entities from
handlers.
- `backend/cmd/seed` applies embedded environment-aware fixtures.
- `backend/internal/eventbus` publishes note and shared-document events to the
durable `go-guess.interview-events` RabbitMQ fanout exchange. Each API instance
consumes through its own exclusive queue and distributes interview-filtered
events to authenticated interviewer SSE streams or token-authorized participant
streams.
- `frontend/src/app` owns application routing and providers, `src/features` groups
feature pages and components, `src/components` contains reusable UI,
`src/api/client.ts` is the typed HTTP boundary, `src/types` contains client
domain types, and `src/styles/global.css` contains application-wide styles.
Keep feature-specific components inside their feature directory. Vite proxies
`/api` to the Go service in development.
- Multi-tenant URLs use `<tenant>.<app domain>`, where the app domain is
`guess-dev.urpi.be` in local development and the Kubernetes development
profile, and `guess.urpi.be` in production. The frontend derives the tenant from
the hostname, calls same-origin `/api` through the HTTPS proxy, and sends
`X-Tenant-Id` on every request type. Generated participant links must use the
validated matching tenant origin. Keep this contract documented in `README.md`
and `frontend/README.md`.
- Protected interviewer routes require a bearer JWT only. Go Loose is used for
browser login, not for API-key authorization.
Goose files in `backend/migrations` are schema-only and run in every environment.
`internal/database/seed/common.sql` contains shared reference data;
`development.sql` and `test.sql` contain idempotent environment-specific fixtures.
Every entity change requires a migration plus corresponding fixture and integration
test updates. Never put demo users or test records in schema migrations.
Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are
protected except `/api/health`, `/api/openapi.json`, `/api/auth/login`,
`/api/auth/callback`, `/api/auth/logout`, `/api/auth/session`, and opaque-token
participant interview routes. Any tenant host starts browser login with
`github.com/danyel/go-loose/client`: `/api/auth/login` redirects to
`https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>/connect/authorize` and Go Loose returns to
`/api/auth/callback`. `GO_LOOSE_AUTH_DOMAIN` is `auth-dev.urpi.be` locally and in
the development profile, and `auth.urpi.be` in production; `GO_LOOSE_APP_DOMAIN`
is the matching application domain. Both default to the development domains.
`GET /api/auth/session` answers `404` when no Go Loose login is configured for the
request host, and the frontend uses that to fall back to the password form instead
of redirecting. Go Loose client credentials come from uncommitted environment
variables. The tenant comes from `X-Tenant-Id`, or from the request host and
`X-Forwarded-Host` when the header is absent; missing tenant context uses the
public schema. Never expose password hashes, CV/photo bytes, or interviewer
reference answers in public JSON.
Questions are permanent reusable records linked to jobs through
`job_posting_questions`; detach links rather than deleting questions. Deprecated
questions remain searchable but cannot be newly attached. Question values are
`open`, `multiple_choice`, `radio`, and `code_review`. Choice types require at least
two options. Open/code-review types require `referenceAnswer`; code review also
requires `codeSnippet`.
Jobs move through `draft`, `published`, and `deprecated`. Publishing requires a
question, only published jobs can create invitations, and candidate matching uses
labels plus both skill groups with a 60% threshold. Invitation URLs use
`/participant/:invitationId`, where the identifier is an opaque token. Participant
questions are acceptance-gated and answers are saved before final submission;
interviewer review uses
`GET /api/jobs/{id}/invitations/{invitationId}`.
Only completed assessment invitations can receive a `passed` or `failed` outcome,
and only passed candidates can be scheduled for an interview. Scheduled
interviews include the creator plus selected co-interviewers. Attendees manage
their invitation through `/api/invitations`, see accepted interviews in `/api/schedule`,
and can write notes only after the interview has started. Notes are private to
authenticated attendees. The opaque `/participant/meeting/:candidateToken` view
exposes only candidate-safe schedule and shared-document data.
## Repository conventions
- Prefix every Go interface name with `I`.
- Keep web, service, and persistence models separate and convert them in adjacent
`mapper` packages.
- Participant creation uses multipart fields `firstName`, `lastName`, `birthday`,
`email`, `contactInfo`, `photo`, and `cv`.
- Return explicit errors; map expected failures to HTTP status codes and log
unexpected failures instead of silently falling back.
- Update the directly related local documentation whenever behavior,
configuration, architecture, commands, or public API surfaces change.
- Register backend routes through the typed OpenAPI-aware router helper. The
runtime `/api/openapi.json` document is the API source of truth; do not add a
manually maintained route inventory or require a generation command.
- Commit every repository change with a meaningful conventional prefix such as
`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, or `chore:`.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,124 @@
<!-- go-doc:generated:begin source=go-guess:frontend/README.md signature=781b9d03c5914e6fd446714cc17f6867 generated=2026-10-05 18:14 UTC -->
Imported from frontend/README.md in the go-guess repository.
---
# Go Guess frontend
React and TypeScript recruitment workspace built with Vite.
## Commands
```bash
npm install
npm run dev
npm run typecheck
npm run lint
npm run format
npm run format:check
npm test
npm run build
```
Run one test:
```bash
npm test -- src/app/App.test.tsx -t "filters jobs by title"
```
## Source structure
- `src/app` contains the application shell, providers, routes, and route-level tests.
- `src/features` groups pages and components by business capability.
- `src/components` contains UI shared by multiple features.
- `src/config/navigation.ts` defines configurable footer sitemap and useful-link groups.
- `src/api/client.ts` is the typed HTTP boundary.
- `src/types` contains API and domain types.
- `src/styles/global.css` contains shared tokens and layout styles.
Keep feature-only UI close to its feature instead of adding more screens to the
application shell.
## API integration
For tenant hosts such as `nmbs.guess-dev.urpi.be` locally and
`nmbs.guess.urpi.be` in production, `src/api/client.ts` derives tenant `nmbs`,
calls same-origin `/api` through the shared HTTPS proxy, and sends
`X-Tenant-Id: nmbs` on every JSON, multipart, file-download, and event-stream
request. `ypto.guess-dev.urpi.be` behaves identically for tenant `ypto`. Other
hostnames use same-origin `/api`; Vite retains its `/api` proxy for that case.
`src/components/actions/Account.tsx` treats any host whose first DNS label is a
tenant as a possible Go Loose host and asks `/api/auth/session` whether browser
login is available there. A `404` means the API has no Go Loose login for the
host, so the password form is shown; any other unauthenticated answer means Go
Loose is configured and the app redirects to `/api/auth/login`. The callback
exchanges that session for the bearer token used by the typed API client.
Invitation and participant-meeting URLs use the validated request origin so
links stay on the tenant hostname. Copy actions fall back to a temporary selected
textarea when the Clipboard API is unavailable on local HTTP domains.
Login stores the returned JWT in
`sessionStorage`; protected requests send it as `Authorization: Bearer <token>`. API failures are
shown in the UI and are never replaced with demo data. A `401` from a protected
JSON, file, or event-stream request clears authentication and returns the user to
`/login`. On a tenant host with Go Loose configured, the app restores
`/api/auth/session` or redirects to `/api/auth/login`; participant routes stay
token-based. The app also schedules logout from the JWT expiration claim so an idle
expired session cannot remain on a protected page.
The machine-readable OpenAPI 3.0 contract at `/api/openapi.json` is the source
of truth for supported routes and schemas. It is assembled by the backend from
the same typed calls that register Chi routes, so there is no generated file or
manual update command. Do not duplicate a route inventory here; inspect the
contract instead.
Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`,
`contactInfo`, `photo`, and `cv`.
## Interview workflow
Authenticated interviewers create and edit reusable questions at `/questions`, configure job
status and duration, attach active library questions, and generate invitations for each eligible
participant from a job detail page. Multiple-choice and radio questions require at least two
explicitly added options.
Detaching a question removes only the job association; questions can be deprecated but not deleted.
Open-answer and code-review questions include an interviewer-only reference answer that is never
rendered in the participant interview. Code-review questions also require a code snippet.
Accepted and completed invitations can be opened from the job to review every participant answer,
possible choice options, reference answers, and code-review context; unanswered questions are
called out explicitly.
Completed assessments can be marked as passed or failed. Passed candidates expose a scheduling
form for the date, location, co-interviewers, and candidate-visible shared documentation.
The authenticated workspace also includes:
- **Users** for listing and creating co-interviewers.
- **Invitations** in the top-right account navigation for accepting or declining assignments.
- **Profile** in the top-right account navigation for the logged-in user's identity and role.
- **Schedule** for interviews grouped by date.
- **Interviewer sessions** for changing status, editing prominent shared documentation, and
posting private notes while an interview is started. Notes and shared-document changes arrive
live through an authenticated streaming `fetch`; the app intentionally does not use
`EventSource`, because the request needs the JWT authorization header.
The authenticated layout ends with a sitemap and useful-links footer. Update
`src/config/navigation.ts` to add, remove, or reorder footer destinations.
Participants open the generated `/participant/:invitationId` URL without signing in. The opaque
invitation ID authorizes the existing `/api/interviews/:token` API. Code-review questions are shown
in a pull-request-style code panel with a review comment field. Answers are saved when navigating
between questions and as they change, then **Submit** completes the interview. The countdown is
derived from the backend `acceptedAt` timestamp plus the job duration so refreshing cannot reset
it. Questions stay hidden until the participant explicitly accepts the invitation.
Scheduled candidates use `/participant/meeting/:token`. This public view shows the interview date,
location, status, and read-only shared documentation. A token-authorized event stream updates the
document immediately when an interviewer saves it in another browser. The participant view never
receives interviewer notes or assessment reference answers.
## Container
Build with `docker build -t go-guess-frontend .`. The production Nginx server
serves the SPA and proxies `/api` to the Docker Compose service `api:8080`.
<!-- go-doc:generated:end -->

208
data/docs/go-guess.md Normal file
View file

@ -0,0 +1,208 @@
<!-- go-doc:generated:begin source=go-guess signature=90ddd0df374708488503ed13e3d69a04 generated=2026-10-07 08:28 UTC -->
# go-guess
Recruitment workspace for interviewers to define job profiles and a reusable question library, manage participants, extract known traits from uploaded CVs, invite candidates who match at least 60% of a posting's labels and skills, and run timed assessments followed by collaborative interviews.
| | |
| --- | --- |
| Module | github.com/danyel/go-guess/backend · go 1.27.1 · 37 Go files |
| Endpoints | 48 endpoints |
| Components | 1 component directory |
| Tests | 65 in 13 files |
| Go lines | 7331 lines |
> Everything between the generated markers is rewritten by every sync. Notes written outside the markers are kept.
## For Users
### What this application does
Recruitment workspace for interviewers to define job profiles and a reusable question library, manage participants, extract known traits from uploaded CVs, invite candidates who match at least 60% of a posting's labels and skills, and run timed assessments followed by collaborative interviews.
The project ships its own README at `README.md`, which is the long form of this page. The three audience tabs split the same facts three ways so you only read the part that concerns you.
### Reading this in go-doc
Open the page and pick the tab that matches you:
- **User** — you use the finished application. This section is all you need.
- **Operator** — you run it. Configuration, ports and deployment notes are under that tab.
- **Developer** — you integrate with it or change it. The API and layout tables are under that tab.
The feedback panel stores a note per audience, tagged with your badge, so operators and developers can see which audience raised it.
### Where the original documentation lives
This project already carries 5 Markdown documents. They stay in the project repository: this page points at them instead of copying them, so there is always exactly one copy of the truth.
| Document | Size |
| --- | --- |
| `.github/copilot-instructions.md` | 6859 bytes |
| `README.md` | 16487 bytes |
| `deploy/helm/go-guess/README.md` | 11878 bytes |
| `deploy/nginx/README.md` | 2385 bytes |
| `frontend/README.md` | 8725 bytes |
---
## For Operators
### Running it locally
The root Makefile defines 3 targets:
- `make docker`
- `make docker-push`
- `make docker-run`
### Local containers
The compose file defines these services:
| Service | Image | Published ports |
| --- | --- | --- |
| db | `postgres:18-alpine` | internal only |
| rabbitmq | `rabbitmq:4-alpine` | internal only |
| app | built locally | internal only |
| bdd | built locally | internal only |
| db | `postgres:18-alpine` | internal only |
| rabbitmq | `rabbitmq:4-management-alpine` | internal only |
| api | `nexus.urpi.be/docker-hosted/go-guess-api:0.0.1` | internal only |
| web | `nexus.urpi.be/docker-hosted/go-guess-web:0.0.1` | internal only |
### Image
`Dockerfile` builds from `node:26-alpine`, `golang:1.27.1-alpine`, `alpine:3.22` in stages: frontend, backend, tools and exposes 8080.
### Configuration
Every environment variable found in the source or in a `.env` file:
| Variable | Example | Found in |
| --- | --- | --- |
| `APP_ENV` | — | source |
| `DATABASE_URL` | — | source |
| `GO_LOOSE_BASE_URL` | — | source |
| `GO_LOOSE_CA_FILE` | — | source |
| `JWT_SECRET` | — | source |
### Network surface
Ports referenced across the project: `5432`, `8080`.
### Deployment artefacts
Helm 1 chart found:
- `deploy/helm/go-guess`
### Operations documentation that already exists
Read these in place rather than duplicating them here:
- `deploy/helm/go-guess/README.md` — Go Guess Helm chart
- `deploy/nginx/README.md` — Dedicated-server reverse proxy
---
## For Developers
### Module layout
- backend/go.mod — module github.com/danyel/go-guess/backend, go 1.27.1, 13 direct and 58 indirect dependencies
### HTTP API
Every route registration found in the source:
| Method | Path | Handler |
| --- | --- | --- |
| `GET` | `/api/auth/callback` | `CompleteExternalLogin` |
| `GET` | `/api/auth/login` | `BeginExternalLogin` |
| `POST` | `/api/auth/login` | `Login` |
| `GET` | `/api/auth/logout` | `ExternalLogout` |
| `POST` | `/api/auth/logout` | `ExternalLogout` |
| `GET` | `/api/auth/session` | `ExternalSession` |
| `GET` | `/api/health` | `Health` |
| `GET` | `/api/interviews/{token}` | `GetInterview` |
| `POST` | `/api/interviews/{token}/accept` | `AcceptInterview` |
| `PUT` | `/api/interviews/{token}/answers/{questionId}` | `SaveAnswer` |
| `POST` | `/api/interviews/{token}/finish` | `FinishInterview` |
| `GET` | `/api/invitations` | `ListInbox` |
| `PATCH` | `/api/invitations/{id}` | `UpdateInbox` |
| `GET` | `/api/jobs` | `ListJobs` |
| `POST` | `/api/jobs` | `CreateJob` |
| `GET` | `/api/jobs/{id}` | `GetJob` |
| `PATCH` | `/api/jobs/{id}` | `UpdateJob` |
| `GET` | `/api/jobs/{id}/candidates` | `Candidates` |
| `GET` | `/api/jobs/{id}/interviews` | `ListScheduledInterviews` |
| `POST` | `/api/jobs/{id}/interviews` | `CreateScheduledInterview` |
| `GET` | `/api/jobs/{id}/invitations` | `ListInvitations` |
| `POST` | `/api/jobs/{id}/invitations` | `CreateInvitation` |
| `GET` | `/api/jobs/{id}/invitations/{invitationId}` | `ReviewInvitation` |
| `PATCH` | `/api/jobs/{id}/invitations/{invitationId}/outcome` | `SetInvitationOutcome` |
| `DELETE` | `/api/jobs/{id}/questions/{questionId}` | `DetachQuestion` |
| `POST` | `/api/jobs/{id}/questions/{questionId}` | `AttachQuestion` |
| `GET` | `/api/openapi.json` | `ServeHTTP` |
| `GET` | `/api/participant-meetings/{token}` | `GetParticipantMeeting` |
| `GET` | `/api/participant-meetings/{token}/events` | `ParticipantMeetingEvents` |
| `GET` | `/api/participants` | `ListParticipants` |
| `POST` | `/api/participants` | `CreateParticipant` |
| `GET` | `/api/participants/{id}` | `GetParticipant` |
| `GET` | `/api/participants/{id}/cv` | `ParticipantCV` |
| `PUT` | `/api/participants/{id}/cv` | `UpdateParticipantCV` |
| `GET` | `/api/participants/{id}/photo` | `ParticipantPhoto` |
| `GET` | `/api/questions` | `ListQuestions` |
| `POST` | `/api/questions` | `CreateQuestion` |
| `PATCH` | `/api/questions/{id}` | `SetQuestionDeprecated` |
| `PUT` | `/api/questions/{id}` | `UpdateQuestion` |
| `GET` | `/api/schedule` | `ListCalendar` |
| `GET` | `/api/scheduled-interviews/{id}` | `GetScheduledInterview` |
| `PATCH` | `/api/scheduled-interviews/{id}/document` | `UpdateScheduledInterviewDocument` |
| `GET` | `/api/scheduled-interviews/{id}/events` | `InterviewEvents` |
| `GET` | `/api/scheduled-interviews/{id}/notes` | `ListInterviewNotes` |
| `POST` | `/api/scheduled-interviews/{id}/notes` | `CreateInterviewNote` |
| `PATCH` | `/api/scheduled-interviews/{id}/status` | `UpdateScheduledInterviewStatus` |
| `GET` | `/api/users` | `ListUsers` |
| `POST` | `/api/users` | `CreateUser` |
### Direct dependencies
- `github.com/danyel/go-loose` v0.0.0-20261005194805-49ab89ae46fd
- `github.com/go-chi/chi/v5` v5.2.3
- `github.com/golang-jwt/jwt/v5` v5.3.0
- `github.com/google/uuid` v1.6.0
- `github.com/jackc/pgx/v5` v5.11.0
- `github.com/ledongthuc/pdf` v0.0.0-20260907135840-6c8c28e0e8a0
- `github.com/pressly/goose/v3` v3.26.0
- `github.com/rabbitmq/amqp091-go` v1.15.0
- `github.com/testcontainers/testcontainers-go` v0.44.0
- `github.com/testcontainers/testcontainers-go/modules/postgres` v0.44.0
- `golang.org/x/crypto` v0.54.0
- `gorm.io/driver/postgres` v1.6.0
- `gorm.io/gorm` v1.31.0
### Directory map
| Directory | Files | Lines | Likely purpose |
| --- | --- | --- | --- |
| `frontend/` | 93 | 7311 | frontend assets |
| `backend/` | 50 | 114904 | unknown — inspect directly |
| `deploy/` | 28 | 135 | deployment manifests |
| `bdd/` | 10 | 253 | tests |
| `docker/` | 2 | 80 | operational tooling |
| `scripts/` | 1 | 28 | operational tooling |
### Languages
- **Go** — 37 files, 7331 lines
- **TypeScript** — 74 files, 7207 lines
- **JSON** — 8 files, 5116 lines
- **CSS** — 1 file, 2983 lines
- **YAML** — 28 files, 2740 lines
- **Markdown** — 5 files, 984 lines
- **SQL** — 10 files, 388 lines
- **JavaScript** — 6 files, 185 lines
- **Templates** — 1 file, 109 lines
- **conf** — 3 files, 88 lines
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,86 @@
<!-- go-doc:generated:begin source=go-loose:docs/architecture.md signature=ca7dc4b7a850b8d1f05e6f11002bd8a0 generated=2026-10-05 18:14 UTC -->
Imported from docs/architecture.md in the go-loose repository.
---
# Architecture and decisions
## Scope
Go Loose is an authorization control plane, not an API gateway. Protected applications keep serving their own traffic and use the client middleware to ask Go Loose whether an incoming key is authorized. This minimizes coupling and lets a service adopt Go Loose one route group at a time.
## Components
```text
cmd/go-loose
configuration → migrations → HTTP server
internal/server
OIDC/session auth, management API, authorization endpoint, embedded web UI
internal/store
tenant-isolated PostgreSQL queries and authorization audit writes
internal/key
cryptographically random key generation and one-way lookup hashing
internal/contract
OpenAPI 2/3 JSON/YAML normalization and endpoint extraction
client
public net/http middleware package used by other Go modules
```
PostgreSQL is the source of truth. The frontend is dependency-free HTML/CSS/JavaScript embedded in the Go binary. This creates one immutable artifact and avoids a second package ecosystem while the product is young.
## Tenant model
- A **tenant** is an organization such as `nmbs`.
- An **application** is a protected API such as `guess` and always belongs to one tenant.
- An **API key** belongs to exactly one application and tenant.
- A **membership** grants a user `owner`, `admin`, or `viewer` access to a tenant.
- An **application access grant** allows a user to sign in to one client application.
- `allowed_hosts` optionally binds keys for an application to hosts such as `nmbs.guess.local`.
- Tenant and application slugs are sent by middleware, so identical application names can safely exist in different tenants.
Hostnames are authorization context, not tenant identity. Do not trust a host alone: the key, expected tenant, expected application, and optional allowed host must all agree.
## Security decisions
1. **Fail closed.** Missing keys, Go Loose outages, malformed responses, inactive keys, expired keys, scope mismatches, and host mismatches deny the request.
2. **No plaintext key persistence.** Keys contain 256 random bits and are stored as SHA-256 digests. High-entropy secrets do not require a slow password hash for offline resistance.
3. **Session isolation.** Management uses signed, HTTP-only, SameSite cookies. Mutations additionally require the configured same origin.
4. **OIDC validation.** The server uses provider discovery and validates issuer, audience, signature, and expiry through `go-oidc`.
5. **SSRF resistance.** Contract import allows only HTTP(S), resolves hostnames, and blocks loopback/private/link-local destinations unless explicitly enabled.
6. **Auditing.** Every runtime decision records tenant, application, key, route context, outcome, and reason. Raw keys are never logged.
7. **Least privilege.** Management writes require tenant `owner` or `admin`. Read access requires membership.
8. **Separate login purpose.** Client login sessions cannot open the management console. Hosted login uses exact callback matching, one-time codes, confidential client secrets, and opaque revocable sessions.
For production, add rate limiting at the ingress, PostgreSQL backups, metrics/tracing, audit retention, membership invitations, key rotation overlap, and a tightly controlled egress proxy for contract scanning.
## OpenAPI contracts
Imported OpenAPI 2.x/3.x documents are normalized to JSON and stored unchanged in meaning. Methods, paths, and operation IDs are extracted into relational rows for discovery. Contracts are currently informational: they do not restrict runtime paths. Enforcing contract endpoints later should be opt-in per application to avoid breaking existing consumers.
## Decision log
| Decision | Choice | Reason |
|---|---|---|
| Deployment | One Go binary | Simple operations and atomic frontend/backend releases |
| Database | PostgreSQL | Strong tenant relations, transactions, JSONB contracts, and mature operations |
| Router | Go `http.ServeMux` | Modern method/path routing without another framework |
| UI | Embedded vanilla web assets | Fast startup, small supply chain, no Node runtime/build |
| Identity | OIDC Authorization Code flow | Works with Keycloak, Entra ID, Auth0, Okta, and other compliant IdPs |
| API keys | Random opaque secret + SHA-256 digest | Efficient indexed validation without recoverable secrets |
| Migrations | Embedded ordered SQL | Reviewable schema history and automatic transactional upgrades |
| Runtime integration | Remote authorization middleware | Immediate revocation and centralized audit; availability must be engineered |
## Planned evolution
1. Membership invitations and IdP group-to-role mapping.
2. Key rotation pairs and usage analytics.
3. Optional short-lived signed authorization assertions to reduce control-plane latency.
4. Per-application contract enforcement and route-level scopes.
5. Webhooks and audit export.
6. High-availability deployment, telemetry, and policy-based rate limits.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,246 @@
<!-- go-doc:generated:begin source=go-loose:docs/client-login.md signature=ca7dc4b7a850b8d1f05e6f11002bd8a0 generated=2026-10-05 18:14 UTC -->
Imported from docs/client-login.md in the go-loose repository.
---
# Client application login
Go Loose can host login for browser-facing Go applications. Your application never handles the user's IdP password. It redirects the browser to Go Loose, receives a short-lived one-time authorization code, exchanges that code from its backend using a client secret, and stores the returned opaque session in an HTTP-only cookie.
## 1. Configure the application in Go Loose
1. Open **Applications** and choose **Configure client login**.
2. Add the exact callback URL, for example `https://nmbs.guess.local/auth/callback`.
3. Save and copy the client secret. It is shown once.
4. Open **Users**, invite the user by the email returned by the IdP, and choose **Manage** to grant access to the application.
The invitation requires an initial password. Tenant login hosts accept database credentials only; SSO is reserved for system administration on the bare authentication domain.
Saving client configuration rotates the secret. Callback URLs require HTTPS, except `localhost` and `127.0.0.1` while Go Loose development login is enabled.
## 2. Add the Go client
```bash
go get github.com/danyel/go-loose/client
```
Configure the hosted login and register its handlers:
```go
package main
import (
"fmt"
"log"
"net/http"
"os"
"time"
goloose "github.com/danyel/go-loose/client"
)
func main() {
auth, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
BaseURL: "https://nmbs.auth.example.com",
ClientID: os.Getenv("GO_LOOSE_CLIENT_ID"),
ClientSecret: os.Getenv("GO_LOOSE_CLIENT_SECRET"),
RedirectURL: "https://nmbs.guess.local/auth/callback",
AfterLoginURL: "/app",
AfterLogoutURL: "/",
LoginPath: "/login",
CookieSecure: true,
HTTPClient: &http.Client{Timeout: 3 * time.Second},
})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /login", auth.LoginHandler)
mux.HandleFunc("GET /auth/callback", auth.CallbackHandler)
mux.HandleFunc("POST /logout", auth.LogoutHandler)
protected := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := goloose.UserFromContext(r.Context())
if !ok {
http.Error(w, "user missing", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "Hello %s from tenant %s", user.DisplayName, user.TenantSlug)
}))
mux.Handle("GET /app", protected)
mux.Handle("GET /api/profile", protected)
log.Fatal(http.ListenAndServe(":8090", mux))
}
```
Keep `GO_LOOSE_CLIENT_SECRET` in a secret manager, never source control. In local development, configure an exact callback such as `http://localhost:8090/auth/callback` and set `CookieSecure: false`.
## Tenant login hostname
Each tenant has its own login origin:
```text
https://<tenant-slug>.<GO_LOOSE_AUTH_DOMAIN>
```
For tenant `nmbs` with `GO_LOOSE_AUTH_DOMAIN=auth.dev`, configure the Go client with:
```go
BaseURL: "http://nmbs.auth.dev:18080"
```
For local development, add the tenant hostname to `/etc/hosts`:
```text
127.0.0.1 nmbs.auth.dev
```
Production should configure wildcard DNS and TLS for `*.auth.example.com`, route those hosts to Go Loose, and use:
```dotenv
GO_LOOSE_BASE_URL=https://auth.example.com
GO_LOOSE_AUTH_DOMAIN=auth.example.com
GO_LOOSE_OIDC_REDIRECT_URL=https://auth.example.com/auth/callback
```
The client package initially redirects to `/connect/authorize`. Go Loose verifies the registered client and exact callback, canonicalizes the browser onto the tenant hostname, and renders `<Tenant name> login` for the requested application.
## 3. Browser flow
```text
Browser → GET /login on client application
→ GET /connect/authorize on Go Loose
→ separate Go Loose client login
→ organization OIDC provider
→ Go Loose validates user + application grant
→ client /auth/callback?code=...&state=...
Client → POST /connect/token with Authorization: Basic <client-id:secret>
← opaque 12-hour session token
Browser ← HTTP-only SameSite session cookie on client application
Request → client middleware → GET /connect/userinfo → protected handler
Authorization: Bearer <opaque-session>
```
The authorization code expires after two minutes and can be used once. Redirect URIs must match exactly. Removing a user's application grant immediately makes `/connect/userinfo` reject existing sessions. Logout revokes the central session and clears the local cookie.
## Role and profile picture
Both `/connect/token` and `/connect/userinfo` return the caller's `role`, `permissions`, and absolute `avatar_url`, resolved against the tenant that owns the application:
```json
{
"id": "9f0f...",
"email": "ada@example.com",
"display_name": "Ada Lovelace",
"avatar_url": "https://loose.example.com/api/v1/avatars/kZ3...",
"tenant_id": "2b41...",
"tenant_slug": "nmbs",
"application_id": "7c02...",
"application": "guess",
"role": "developer",
"permissions": [
"console.read",
"users.read",
"applications.manage",
"keys.manage",
"contracts.manage",
"users.manage",
"profile.edit"
]
}
```
There is no `can` object on the wire. `avatar_url` is public by design, so it can be embedded directly with a plain image tag; it is an empty string when the user has not uploaded a picture.
Read `permissions` rather than testing `role` by name, because a tenant may define its own roles. The client package exposes the same check as a method:
```go
if user.Can("contracts.manage") {
// the role behind this session grants it
}
```
A user with no membership in the application's tenant still authenticates, but falls back to the built-in `user` role, whose only permission is `profile.edit`. See [roles, permissions, and profiles](roles.md).
## Security and availability
- Use TLS for both Go Loose and the client application.
- Keep client secrets server-side and rotate them after suspected disclosure.
- Do not replace the middleware's fail-closed behavior with an allow-on-error fallback.
- Apply CSRF protection to state-changing routes in the client application; the package handles login `state`, not your application forms.
- The middleware checks Go Loose on every request for immediate revocation. Deploy Go Loose highly available and use a bounded HTTP timeout.
- Signing out in Go Loose revokes every hosted-login session the user holds here, so
the client applications stop accepting their tokens on the next request. See
[Signing out across the family](#signing-out-across-the-family).
- API-key authentication and browser-user authentication are independent. Use `client.Middleware` for service/API keys and `BrowserAuth.Middleware` for logged-in users.
- **A `user_application_access` grant is the only thing that admits a person to your
application.** The system administrator is not an exception: they own every tenant,
which is what lets them administer it, but ownership does not sign them in here.
Nobody, administrator included, reaches your application without a grant recorded
against your `client_id`. If support needs to see the application as a user, grant
it deliberately from **Users & access** so it is attributable and revocable, rather
than assuming the administrator is already inside.
## Signing out across the family
Applications in this family share one identity, so signing out has to mean more than
clearing one cookie. `POST /auth/logout` does three things, in this order:
1. **Revokes every hosted-login session the user holds.** Go Loose issues the bearer
tokens these applications authenticate with, so this is what actually signs them
out: their tokens stop resolving immediately, without the application being told
or having to ask. This step reads the session first, which is why it happens before
the cookie is cleared.
2. **Clears the local management cookies** on the identity domain.
3. **Sends the browser to the identity provider's logout endpoint**, when the provider
advertises one in its discovery document, so the single sign-on session ends too.
Two consequences worth designing around:
- **A client application is signed out on its next request, not by being notified.**
Its own session cookie still exists; the bearer token inside it has been revoked. An
application that only checks the session locally will appear signed in until its
next call to Go Loose, at which point it should treat the rejection as a sign-out and
return the browser to `/login`.
- **Applications that authenticate against a different provider are not covered.**
The provider endpoint is read from the issuer Go Loose itself is configured with, so
this covers the applications that sign in through Go Loose. One pointed at a separate
identity provider needs its own end-session redirect.
A provider that does not advertise `end_session_endpoint` is not an error: step 3 is
skipped and the user lands back on the sign-in page, having still been signed out
locally and revoked. Go Loose sends `client_id` and `post_logout_redirect_uri` rather
than an `id_token_hint`, because a hint would mean keeping a live credential in the
browser cookie between login and logout.
## Applications on the local platform
The local platform signs in two client applications, both with the wildcard
identity domain `auth.dev`:
| Application | Tenant | Callback URL |
|---|---|---|
| Go Guess | `nmbs` | `https://nmbs.guess.dev/api/auth/callback` |
| Go Guess | `ypto` | `https://ypto.guess.dev/api/auth/callback` |
| Go Tell | `tell` | `https://tell.dev/api/auth/callback` |
Go Guess has one application per tenant because it serves one host per tenant.
Go Tell has a single application: it is served from one host with no tenant
routing, so one client covers every request.
Client secrets are shown once when an application is configured. The Compose
stack reads them from a gitignored `.env`, and the Kubernetes releases receive
them through their release secret. Go Loose hashes stored secrets, so a lost
secret can only be replaced, never recovered.
Cluster clients cannot reach the workstation Traefik that serves `auth.dev`.
Both releases therefore mount the platform CA and expect a reachable identity
address, and a `502` on `/api/auth/callback` means the pod cannot reach Go
Loose rather than that the client is misconfigured.
## Troubleshooting `403`
`403 your account does not have access to this application` means authentication succeeded but the user lacks an application grant. In Go Loose, open **Users**, select the user, choose **Manage**, and enable the target application. A missing or incorrect token header returns `401`, not `403`.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,53 @@
<!-- go-doc:generated:begin source=go-loose:docs/coding-standards.md signature=ca7dc4b7a850b8d1f05e6f11002bd8a0 generated=2026-10-05 18:14 UTC -->
Imported from docs/coding-standards.md in the go-loose repository.
---
# Coding standards
## Go
- Use the standard library unless a dependency clearly reduces security or protocol risk.
- Keep public APIs small, documented, and backward compatible.
- Pass `context.Context` through I/O boundaries and always bound network calls with timeouts.
- Wrap errors with the operation; never log secrets, cookies, authorization headers, or full API keys.
- Return explicit errors. Runtime authorization must fail closed.
- Keep SQL tenant-aware: management queries must join through membership, and writes must enforce role in the query or transaction.
- Derive authorization from the database role and permission tables instead of hardcoding role names. Resolve a member's capabilities by joining `role_permissions`, render controls from the capability lists the server returns, and keep `internal/role` limited to the permission vocabulary shared with the migrations.
- Use cryptographically secure randomness for credentials and constant-time comparison for signatures.
- Format with `gofmt`; run `go test -race ./...`, `go vet ./...`, and `go build ./...`.
## HTTP API
- Routes are versioned under `/api/v1`; health and authentication routes are infrastructure exceptions.
- JSON handlers reject unknown fields, extra JSON values, and oversized bodies.
- Errors use `{"error":"human-readable message"}` without internal implementation details.
- Use `401` for absent/unusable authentication, `403` for authenticated credentials outside policy, `404` when existence must not disclose access, and `422` for semantically invalid contracts.
- Any management mutation requires a valid user session and same-origin browser request.
- Update `web/openapi.json` whenever endpoint behavior changes.
## Database
- Use UUID primary keys for tenant data and UTC `timestamptz` timestamps.
- Foreign keys declare deletion behavior; frequently queried authorization fields get explicit indexes.
- Applied migrations are immutable and forward-compatible.
- Multi-step writes use transactions. Do not add application-side checks that can race when a database constraint can enforce the invariant.
## Frontend
- Preserve keyboard access, semantic elements, visible focus, responsive layout, and sufficient contrast in every theme.
- Escape all server-provided strings before assigning HTML. Prefer `textContent` for new dynamic surfaces.
- Store only visual preferences in local storage; authentication remains in HTTP-only cookies.
- Keep the console dependency-free unless complexity justifies a reviewed build tool decision.
## Tests and reviews
- New domain behavior needs table-driven unit tests and negative cases.
- Security boundaries need explicit tests for missing, malformed, expired, cross-tenant, and unauthorized inputs.
- Integration tests that need PostgreSQL should use a disposable database and never shared developer data.
- Reviews prioritize tenant isolation, fail-closed behavior, migration compatibility, secret handling, and error visibility.
## Commit style
Use focused commits with imperative subjects, for example `Add scoped API key authorization`. Do not mix unrelated refactors with behavior changes. Explain migration and security implications in the commit body.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,99 @@
<!-- go-doc:generated:begin source=go-loose:docs/installation.md signature=ca7dc4b7a850b8d1f05e6f11002bd8a0 generated=2026-10-05 18:14 UTC -->
Imported from docs/installation.md in the go-loose repository.
---
# First-run installation and demo data
## Clean installation
Start Go Loose with PostgreSQL and a stable `GO_LOOSE_SESSION_SECRET` of at least 32 characters. No OIDC environment variables or bootstrap account are required:
```bash
docker compose up --build
```
Start the shared Traefik and certificate setup documented in
`/home/dnoulet/go/infrasctruture/docs/APPLICATIONS.md`, add
`127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev` to `/etc/hosts`, then open
`https://auth.dev/install`. The installer asks for a Google OAuth client ID and
client secret. In Google Cloud Console, configure this authorized redirect URI
before submitting:
```text
https://auth.dev/auth/callback
```
The supplied **CMS Demo** client ID is prefilled:
```text
554628171917-forvpfs44pqajv77dfhl503prfqnhor3.apps.googleusercontent.com
```
The Google client secret is not included in the repository and must be copied from Google Cloud Console. Go Loose validates Google OIDC discovery, encrypts the secret with AES-GCM using a key derived from `GO_LOOSE_SESSION_SECRET`, and stores only the ciphertext in PostgreSQL. Keep the session secret stable: losing it makes the installed OIDC secret undecryptable.
Installation is a one-time operation. Submitting the installer immediately starts Google login. Google redirects to `/auth/callback`, where Go Loose validates the ID token, creates a signed HTTP-only session cookie, and redirects to the management console. Browser redirects cannot carry a caller-defined `Authorization` header, so no bearer token is exposed in the URL or to frontend JavaScript.
Later requests to `/install` redirect to login, and duplicate submissions are rejected.
## First administrator and waiting room
The first user who completes Google SSO becomes the system administrator. A PostgreSQL advisory transaction lock makes this first-user claim atomic when multiple users sign in concurrently. The system administrator receives owner access to every tenant that exists at that moment.
Owning a tenant is what lets the administrator administer it; it does not let them sign in to the applications running inside it. Access to a client application is always an explicit grant, so an administrator who wants to use one attaches themselves from **Users & access** like anybody else. The grant is visible, attributable and revocable, and revoking it signs them out of that application without affecting their console access.
Later first-time SSO users are created without tenant membership. Their management page shows only the waiting room. A system administrator approves a waiting user from **Users & access**, selecting:
- the tenant;
- tenant role (`viewer`, `admin`, or `owner`);
- the applications the user may log in to.
Tenant owners and administrators can manage users, applications, API keys, client login, and OpenAPI contracts only inside their assigned tenants. A user assigned to multiple tenants can switch the active tenant from the console header. Database membership checks—not the selector—enforce isolation.
## Optional Guess demonstration seed
The installer selects the demonstration seed by default. It creates the `nmbs` and `ypto` tenants, one `guess` application in each tenant, and these local database users:
| Tenant | Email | Password | Role |
|---|---|---|---|
| NMBS | `interview@nmbs.auth.dev` | `admin123` | Tenant administrator |
| YPTO | `interview@ypto.auth.dev` | `admin123` | Tenant administrator |
| NMBS | `reviewer@nmbs.auth.dev` | `Demo-Nmbs-2026!` | Tenant administrator |
| YPTO | `reviewer@ypto.auth.dev` | `Demo-Ypto-2026!` | Tenant administrator |
All four users receive access only to their tenant's Guess application. Passwords are stored as Argon2id hashes; plaintext credentials are never written to PostgreSQL.
These accounts and especially `admin123` are intentionally non-production credentials. Disable the seed for production, or delete/rotate every demo account before exposing the service.
For local hosted-login testing, add:
```text
127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev
```
The tenant login origins are then:
```text
https://nmbs.auth.dev
https://ypto.auth.dev
```
An application must first have exact redirect URIs and a generated client secret configured in the management console before `/connect/authorize` can start its login flow.
## Environment-configured compatibility mode
Existing deployments may continue to supply all three `GO_LOOSE_OIDC_ISSUER`, `GO_LOOSE_OIDC_CLIENT_ID`, and `GO_LOOSE_OIDC_CLIENT_SECRET` variables. Go Loose treats that as an already configured deployment and bypasses the installer. Supplying `GO_LOOSE_LOCAL_LOGIN=true` together with a non-empty `GO_LOOSE_LOCAL_ADMIN_PASSWORD` does the same for a database-only development deployment.
For new installations, prefer the web installer. `GO_LOOSE_LOCAL_LOGIN=true` without a bootstrap password enables seeded and invited database users but does not bypass installation.
Two rules make that second mode usable rather than merely possible:
- The local administrator claims the system administrator role when the installation has none. Creating tenants and approving waiting-room users both require that role, and only a first Google sign-in otherwise grants it, so a database-only deployment would otherwise have nobody able to do either. The claim is a no-op once any system administrator exists, so an installation already owned by a Google account is untouched.
- Bootstrapping grants ownership of a tenant only while that tenant has no owner. It deliberately does not care whether the tenant has other members, because the demonstration seed creates tenants holding administrators but no owner; refusing ownership there would leave a local administrator bootstrapped afterwards with an account that can never reach the console. A tenant that already has an owner is never taken over.
Supplying `GO_LOOSE_LOCAL_ADMIN_PASSWORD` after a Google account has already claimed the role changes nothing.
## Verifying an installation
`make behaviour-test` installs the application into an empty database, signs in, walks every flow, and then restarts the process to confirm that everything was persisted rather than held in memory. See the [README](../README.md#behaviour-suite) for what it needs.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,70 @@
<!-- go-doc:generated:begin source=go-loose:docs/integration.md signature=ca7dc4b7a850b8d1f05e6f11002bd8a0 generated=2026-10-05 18:14 UTC -->
Imported from docs/integration.md in the go-loose repository.
---
# Integrating a Go API
Install the module from its eventual repository location:
```bash
go get github.com/danyel/go-loose/client
```
Wrap a router or selected route group:
```go
package main
import (
"log"
"net/http"
"time"
goloose "github.com/danyel/go-loose/client"
)
func main() {
access, err := goloose.New(goloose.Config{
BaseURL: "https://nmbs.goloose.local",
Tenant: "nmbs",
Application: "guess",
HTTPClient: &http.Client{Timeout: 2 * time.Second},
})
if err != nil {
log.Fatal(err)
}
protected := access.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, _ := goloose.PrincipalFromContext(r.Context())
w.Write([]byte("authorized by key " + principal.APIKeyName))
}))
mux := http.NewServeMux()
mux.Handle("GET /api/guesses", protected)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Fatal(http.ListenAndServe(":8090", mux))
}
```
Call the protected API:
```bash
curl -H 'X-API-Key: gl_REPLACE_WITH_ISSUED_SECRET' \
https://nmbs.guess.local/api/guesses
```
The middleware sends the original method, URL path, and request host to Go Loose. It denies missing/invalid keys and control-plane errors, then removes `X-API-Key` before invoking the application handler. Read the authorized tenant/application/key identity with `PrincipalFromContext`.
## Importing the service contract
Expose an OpenAPI JSON or YAML route from the service, then use **Contracts → Import contract → Scan URL**. Private/internal URLs are blocked by default; production deployments that need internal discovery should use a restricted egress network and explicitly set `GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS=true`.
Contracts can also be pasted into the console, which avoids server-side egress entirely.
## Availability and latency
The current client validates remotely on every request so revocation is immediate. Run Go Loose highly available and set a strict client timeout. Do not implement an “allow on error” fallback. A future signed-assertion mode can reduce latency while retaining bounded revocation delay.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,86 @@
<!-- go-doc:generated:begin source=go-loose:docs/operations.md signature=ca7dc4b7a850b8d1f05e6f11002bd8a0 generated=2026-10-05 18:14 UTC -->
Imported from docs/operations.md in the go-loose repository.
---
# Operations
## First-run installer
New deployments need only PostgreSQL, the public URL settings, and a stable session secret. Open `/install` to configure Google SSO and optional Guess demo data. See [First-run installation and demo data](installation.md).
The first Google SSO user is claimed as system administrator. All later SSO users remain in the waiting room until that administrator grants tenant membership and application access.
## OIDC / SSO
Register an OIDC confidential web client with this callback:
```text
https://<go-loose-host>/auth/callback
```
The web installer is preferred. Environment configuration remains available for existing or fully automated deployments:
```dotenv
GO_LOOSE_BASE_URL=https://auth.example.com
GO_LOOSE_AUTH_DOMAIN=auth.example.com
GO_LOOSE_OIDC_ISSUER=https://id.example.com/realms/platform
GO_LOOSE_OIDC_CLIENT_ID=go-loose
GO_LOOSE_OIDC_CLIENT_SECRET=<from-secret-manager>
GO_LOOSE_OIDC_REDIRECT_URL=https://auth.example.com/auth/callback
GO_LOOSE_ALLOWED_EMAIL_DOMAINS=example.com
GO_LOOSE_DEV_LOGIN=false
```
System-administrator SSO is served on `https://auth.example.com`. Client applications and tenant password login use `https://<tenant>.auth.example.com`; configure wildcard DNS and TLS for `*.auth.example.com`.
Database authentication is controlled by `GO_LOOSE_LOCAL_LOGIN`. It may remain enabled for seeded or invited users without creating a bootstrap account. Set `GO_LOOSE_LOCAL_ADMIN_PASSWORD` only when compatibility bootstrap behavior is explicitly required, and provide it through a secret manager.
The provider must return `sub`, `email`, and preferably `name` claims.
## Migrations
Migration files live in `internal/database/migrations` and are embedded into the binary. Files run lexicographically once, in individual transactions.
A migration run holds a session-level advisory lock, so several replicas may start at the same time against one database. Without it the run races with itself: PostgreSQL's `CREATE TABLE IF NOT EXISTS` is not race free and can fail with a duplicate `pg_type` row, and two replicas can try to apply the same file. The second replica waits and then finds the work already recorded.
Create upgrades with an immutable numbered file:
```text
0002_membership_invitations.sql
```
Use this structure:
```sql
-- +goose Up
CREATE TABLE membership_invitations (...);
-- +goose Down
DROP TABLE membership_invitations;
```
The built-in runner applies only the `Up` section. Down sections document rollback intent but are never run automatically. Never edit an applied migration; create a new one. Prefer backward-compatible expansion first, deploy code that understands both shapes, then remove old fields in a later release.
Back up the database before production upgrades. Restore and test backups regularly. Startup fails if a migration fails, preventing the application from running against a partially upgraded schema.
## Production checklist
- TLS terminates at a trusted ingress and forwards the original host.
- `GO_LOOSE_BASE_URL` is the exact HTTPS browser origin.
- Session and OIDC secrets come from a secret manager.
- Demo users are not seeded, or every demo credential is removed/rotated before exposure.
- Development login and unrestricted private URL scanning are disabled.
- PostgreSQL uses TLS, backups, point-in-time recovery, and least-privilege credentials.
- `/healthz` is used for readiness; logs are shipped without request headers.
- Authorization endpoint rate limits and network policies are configured at ingress.
- Multiple replicas share PostgreSQL; rolling deployment starts only after migration compatibility is verified.
- The external Swagger UI assets are mirrored locally if internet-free operation is required.
## Key incident response
1. Revoke the exposed key in the console.
2. Issue a replacement and update the consumer through its secret manager.
3. Review `authorization_events` for the key ID and affected time window.
4. Investigate the exposure source; never restore a revoked key.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,56 @@
<!-- go-doc:generated:begin source=go-loose:docs/password-authentication.md signature=ca7dc4b7a850b8d1f05e6f11002bd8a0 generated=2026-10-05 18:14 UTC -->
Imported from docs/password-authentication.md in the go-loose repository.
---
# Database user authentication
Go Loose supports local email/password accounts stored in PostgreSQL. Passwords are never stored in plaintext: the server uses Argon2id with a random 16-byte salt, 64 MiB of memory, three iterations, and two lanes.
## Seeded Docker credentials
On a fresh Compose deployment, use `/install` and keep **Seed NMBS and YPTO Guess demo data** selected. The complete credential table is in the [installation guide](installation.md). The two primary interview accounts are:
```text
interview@nmbs.auth.dev / admin123
interview@ypto.auth.dev / admin123
```
For system-administrator and tenant login, add `127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev` to `/etc/hosts`.
```text
https://nmbs.auth.dev
```
Use `https://auth.dev/login` for system-administrator SSO and
`https://nmbs.auth.dev/login` for tenant database login. Browser TLS is
terminated by the shared infrastructure Traefik using its local CA certificate.
## Configuration
```dotenv
GO_LOOSE_LOCAL_LOGIN=true
GO_LOOSE_LOCAL_ADMIN_EMAIL=admin@example.com # optional bootstrap
GO_LOOSE_LOCAL_ADMIN_PASSWORD=<at-least-12-characters> # optional bootstrap
```
`GO_LOOSE_LOCAL_LOGIN=true` enables database-backed users on tenant hostnames. A bootstrap account is created only when `GO_LOOSE_LOCAL_ADMIN_PASSWORD` is non-empty; its password is applied only when the account has no password hash. Changing the environment variable later does not silently overwrite an existing database password. If a bootstrap account is required, inject its password from a secret manager and never document a production credential.
## Creating client users
1. Sign in to the management console.
2. Open **Users** and choose **Invite user**.
3. Enter the user's email, display name, initial password, and tenant role.
4. Choose **Manage** for that user and grant the required applications.
5. Give the initial credential to the user through a secure channel.
The user can then sign in on `<tenant>.auth.dev` or the production tenant auth hostname. Re-inviting an existing email explicitly replaces its password and display name.
## Brute-force and audit behavior
- Five failed attempts for the same IP/email pair within ten minutes cause a fifteen-minute lock.
- Invalid emails and invalid passwords return the same response.
- Unknown-user attempts still run Argon2id verification to reduce timing differences.
- Successful, failed, and rate-limited attempts are written to `user_login_events`.
- Authentication sessions remain signed HTTP-only cookies. Tenant client-login sessions cannot access the management console.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,97 @@
<!-- go-doc:generated:begin source=go-loose:docs/roles.md signature=ca7dc4b7a850b8d1f05e6f11002bd8a0 generated=2026-10-05 18:14 UTC -->
Imported from docs/roles.md in the go-loose repository.
---
# Roles, permissions, and profiles
Authorization is database-driven. Permissions and roles are rows, not Go constants, so a tenant can define its own vocabulary without a code change or a migration.
## Data model
- `permissions` is the closed vocabulary. Every row carries a `name`, a `label`, a `description`, and an `is_system` flag. System permissions cannot be edited or deleted.
- `roles` holds the roles. A `NULL tenant_id` marks a built-in role that every tenant sees and nobody may modify. A custom role belongs to exactly one tenant.
- `role_permissions` joins roles to permissions.
- `memberships.role_id` points at a role row. The older `memberships.role` text column was dropped in `0006_database_roles.sql`.
Nine permissions ship as system rows:
| Permission | Label |
| --- | --- |
| `tenants.manage` | Manage tenants |
| `roles.manage` | Manage roles |
| `users.manage` | Manage members |
| `applications.manage` | Manage applications |
| `keys.manage` | Manage API keys |
| `contracts.manage` | Manage contracts |
| `users.read` | Read members |
| `console.read` | Read console |
| `profile.edit` | Edit own profile |
A tenant may add further permission names. They are grantable and reported through `/connect/userinfo`, but the console does not enforce them.
## Built-in roles
`0006_database_roles.sql` seeds these six roles. Note that `developer` and `operator` hold `users.manage`: any tenant member who runs the console can maintain their own tenant's members. Minting roles stays with `owner` and `admin` through `roles.manage`, because defining privilege is the operation that actually escalates it.
| Permission | owner | admin | developer | operator | viewer | user |
| --- | --- | --- | --- | --- | --- | --- |
| `console.read` | yes | yes | yes | yes | yes | no |
| `users.read` | yes | yes | yes | yes | yes | no |
| `applications.manage` | yes | yes | yes | no | no | no |
| `keys.manage` | yes | yes | yes | yes | no | no |
| `contracts.manage` | yes | yes | yes | yes | no | no |
| `users.manage` | yes | yes | yes | yes | no | no |
| `roles.manage` | yes | yes | no | no | no | no |
| `tenants.manage` | yes | no | no | no | no | no |
| `profile.edit` | yes | yes | yes | yes | yes | yes |
`user` members have no `console.read`, so a session that lands on `/` is redirected to `/profile`. A `user` with no membership at all still sees the waiting room.
## Managing roles
`/roles` is the console page. The API is:
| Route | Purpose |
| --- | --- |
| `GET /api/v1/roles` | List roles with their permissions and member counts |
| `POST /api/v1/roles` | Create a custom role for one tenant |
| `PUT /api/v1/roles/{id}` | Rename a role or change its permissions |
| `DELETE /api/v1/roles/{id}` | Delete a custom role |
| `GET /api/v1/permissions` | List the permission catalog |
| `POST /api/v1/permissions` | Add a permission to the catalog |
| `DELETE /api/v1/permissions/{name}` | Remove an unused permission |
Two rules keep this from becoming a privilege-escalation hole:
- **Assignability is a subset rule.** A role is offered as `assignable` only when every permission it grants is one the requesting administrator already holds. `ListRoles` computes this per caller, so managing members can never hand out more authority than the administrator has.
- **Roles in use cannot be deleted.** `memberships.role_id` is `ON DELETE RESTRICT` and `DeleteRole` returns `ErrRoleInUse`, so a role cannot be pulled out from under somebody. Built-in roles are likewise undeletable.
`role_permissions.permission` is a foreign key onto `permissions.name`, so a role cannot reference a capability that is not in the catalog.
## Go side
`internal/role` holds only the permission vocabulary used to build the seed data and to keep the Go and SQL spellings in step. It intentionally has no role-to-permission matrix: that mapping lives in the database, so queries resolve a member's real capabilities with a join on `role_permissions` rather than against a hardcoded list.
Store methods enforce capabilities in SQL inside the same transaction as the write, so a request cannot pass a handler check and then fail, or the reverse.
## Display names and profile pictures
Every management session can open `/profile` and set a display name and a picture, independent of tenant role.
Setting the display name sets `name_customized`, which stops the identity provider from overwriting it on the next login. The row is still matched by email on every login, so customization never breaks sign-in.
Pictures are stored as bytes in PostgreSQL under a random 256-bit key and served from the public `GET /api/v1/avatars/{key}` route. The URL is unguessable and carries no user identity, so applications can embed it directly:
```html
<img src="https://loose.example.com/api/v1/avatars/kZ3..." alt="Ada Lovelace" width="64" height="64">
```
Accepted formats are JPEG, PNG, and GIF, up to 2 MiB and 4096 by 4096 pixels. Replacing a picture issues a new key and retires the old one, so cached images never go stale and revoked URLs stop resolving.
## Clients
`/connect/token` and `/connect/userinfo` return the caller's `role`, `permissions`, and absolute `avatar_url`, resolved against the tenant that owns the application. `client.User.Can` exposes the same checks to Go code, so a client can adapt its UI without duplicating the vocabulary.
`avatar_url` is public by design and is an empty string when no picture is set. A user with no membership in the application's tenant still authenticates and falls back to the built-in `user` role, whose only permission is `profile.edit`.
<!-- go-doc:generated:end -->

220
data/docs/go-loose.md Normal file
View file

@ -0,0 +1,220 @@
<!-- go-doc:generated:begin source=go-loose signature=c496f8bf189e8116afcb0f0c9ef9e283 generated=2026-10-07 08:28 UTC -->
# go-loose
Go Loose is a multi-tenant API-key control plane for Go HTTP services. It gives operators a web console to register tenant applications, issue and revoke keys, import OpenAPI contracts, and inspect API metadata. Applications use the public `client` package as fail-closed middleware: requests without a valid key never reach the protected handler.
| | |
| --- | --- |
| Module | github.com/danyel/go-loose · go 1.27.1 · 65 Go files |
| Endpoints | 41 endpoints |
| Components | 1 component directory |
| Tests | 118 in 35 files |
| Go lines | 11906 lines |
> Everything between the generated markers is rewritten by every sync. Notes written outside the markers are kept.
## For Users
### What this application does
Go Loose is a multi-tenant API-key control plane for Go HTTP services. It gives operators a web console to register tenant applications, issue and revoke keys, import OpenAPI contracts, and inspect API metadata. Applications use the public `client` package as fail-closed middleware: requests without a valid key never reach the protected handler.
The project ships its own README at `README.md`, which is the long form of this page. The three audience tabs split the same facts three ways so you only read the part that concerns you.
### Reading this in go-doc
Open the page and pick the tab that matches you:
- **User** — you use the finished application. This section is all you need.
- **Operator** — you run it. Configuration, ports and deployment notes are under that tab.
- **Developer** — you integrate with it or change it. The API and layout tables are under that tab.
The feedback panel stores a note per audience, tagged with your badge, so operators and developers can see which audience raised it.
### Where the original documentation lives
This project already carries 9 Markdown documents. They stay in the project repository: this page points at them instead of copying them, so there is always exactly one copy of the truth.
| Document | Size |
| --- | --- |
| `README.md` | 7729 bytes |
| `docs/architecture.md` | 4958 bytes |
| `docs/client-login.md` | 10667 bytes |
| `docs/coding-standards.md` | 3213 bytes |
| `docs/installation.md` | 6279 bytes |
| `docs/integration.md` | 2163 bytes |
| `docs/operations.md` | 4046 bytes |
| `docs/password-authentication.md` | 2615 bytes |
| `docs/roles.md` | 5608 bytes |
---
## For Operators
### Running it locally
This project has no root Makefile, so the binary or container is run directly.
### Local containers
The compose file defines these services:
| Service | Image | Published ports |
| --- | --- | --- |
| postgres | `postgres:17-alpine` | internal only |
| go-loose | built locally | internal only |
| postgres | `postgres:17-alpine` | internal only |
| go-loose | built locally | internal only |
| postgres | `postgres:17-alpine` | internal only |
| go-loose | built locally | internal only |
### Image
`Dockerfile` builds from `golang:1.27.1-alpine`, `gcr.io/distroless/static-debian12:nonroot` in stages: build and exposes 8080.
### Configuration
Every environment variable found in the source or in a `.env` file:
| Variable | Example | Found in |
| --- | --- | --- |
| `GO_LOOSE_ADDR` | `:8080` | .env file |
| `GO_LOOSE_ALLOWED_EMAIL_DOMAINS` | — | .env file |
| `GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS` | `false` | .env file |
| `GO_LOOSE_AUTH_DOMAIN` | `auth.dev` | .env file |
| `GO_LOOSE_BASE_URL` | `https://auth.dev` | .env file |
| `GO_LOOSE_BOOTSTRAP_APP` | `guess` | .env file |
| `GO_LOOSE_BOOTSTRAP_TENANT` | `nmbs` | .env file |
| `GO_LOOSE_DATABASE_URL` | `postgres://goloose:goloose@localhost:5432/goloose?sslmode=disable` | .env file |
| `GO_LOOSE_DEV_LOGIN` | `false` | .env file |
| `GO_LOOSE_DEV_USER` | `admin@local.test` | .env file |
| `GO_LOOSE_LOCAL_ADMIN_EMAIL` | `admin@goloose.local` | .env file |
| `GO_LOOSE_LOCAL_ADMIN_PASSWORD` | — | .env file |
| `GO_LOOSE_LOCAL_LOGIN` | `true` | .env file |
| `GO_LOOSE_OIDC_CLIENT_ID` | — | .env file |
| `GO_LOOSE_OIDC_CLIENT_SECRET` | — | .env file |
| `GO_LOOSE_OIDC_ISSUER` | — | .env file |
| `GO_LOOSE_OIDC_REDIRECT_URL` | `https://auth.dev/auth/callback` | .env file |
| `GO_LOOSE_SESSION_SECRET` | `replace-with-at-least-32-random-characters` | .env file |
| `GO_LOOSE_THEME_BASE_URL` | — | source |
### Network surface
Ports referenced across the project: `5433`, `8080`.
### Deployment artefacts
Helm 1 chart found:
- `deploy/helm/go-loose`
### Operations documentation that already exists
Read these in place rather than duplicating them here:
- `docs/installation.md` — First-run installation and demo data
- `docs/operations.md` — Operations
---
## For Developers
### Module layout
- go.mod — module github.com/danyel/go-loose, go 1.27.1, 5 direct and 9 indirect dependencies
### HTTP API
Every route registration found in the source:
| Method | Path | Handler |
| --- | --- | --- |
| `POST` | `/api/v1/applications` | `requireSession` |
| `POST` | `/api/v1/applications/{id}/client-config` | `requireSession` |
| `POST` | `/api/v1/authorize` | `authorize` |
| `GET` | `/api/v1/avatars/{key}` | `avatar` |
| `POST` | `/api/v1/contracts` | `requireSession` |
| `GET` | `/api/v1/dashboard` | `requireSession` |
| `POST` | `/api/v1/keys` | `requireSession` |
| `POST` | `/api/v1/keys/{id}/revoke` | `requireSession` |
| `GET` | `/api/v1/permissions` | `requireSession` |
| `POST` | `/api/v1/permissions` | `requireSession` |
| `DELETE` | `/api/v1/permissions/{name}` | `requireSession` |
| `GET` | `/api/v1/profile` | `requireSession` |
| `PUT` | `/api/v1/profile` | `requireSession` |
| `GET` | `/api/v1/roles` | `requireSession` |
| `POST` | `/api/v1/roles` | `requireSession` |
| `DELETE` | `/api/v1/roles/{id}` | `requireSession` |
| `PUT` | `/api/v1/roles/{id}` | `requireSession` |
| `POST` | `/api/v1/tenants` | `requireSession` |
| `POST` | `/api/v1/users` | `requireSession` |
| `PUT` | `/api/v1/users/{id}/access` | `requireSession` |
| `POST` | `/api/v1/users/{id}/approve` | `requireSession` |
| `GET` | `/assets/{name}` | `asset` |
| `GET` | `/auth/callback` | `authCallback` |
| `GET` | `/auth/dev` | `authDev` |
| `POST` | `/auth/logout` | `authLogout` |
| `POST` | `/auth/password` | `passwordLogin` |
| `GET` | `/auth/start` | `authStart` |
| `GET` | `/client/login` | `clientLoginPage` |
| `GET` | `/connect/authorize` | `clientAuthorize` |
| `POST` | `/connect/logout` | `clientLogout` |
| `POST` | `/connect/token` | `clientToken` |
| `GET` | `/connect/userinfo` | `clientUserInfo` |
| `GET` | `/docs` | `requireSession` |
| `GET` | `/healthz` | `health` |
| `GET` | `/install` | `installPage` |
| `POST` | `/install` | `install` |
| `GET` | `/login` | `loginPage` |
| `GET` | `/openapi.json` | `requireSession` |
| `GET` | `/profile` | `requireSession` |
| `GET` | `/roles` | `requireSession` |
| `GET` | `/{$}` | `requireSession` |
### Direct dependencies
- `github.com/coreos/go-oidc/v3` v3.21.0
- `github.com/jackc/pgx/v5` v5.11.0
- `golang.org/x/crypto` v0.40.0
- `golang.org/x/oauth2` v0.37.0
- `gopkg.in/yaml.v3` v3.0.1
### Directory map
| Directory | Files | Lines | Likely purpose |
| --- | --- | --- | --- |
| `internal/` | 67 | 11684 | unimportable application internals |
| `web/` | 15 | 827 | frontend assets |
| `deploy/` | 11 | 59 | deployment manifests |
| `docs/` | 8 | 0 | project documentation |
| `client/` | 4 | 533 | frontend assets |
| `cmd/` | 1 | 77 | binary entry points |
### Languages
- **Go** — 65 files, 11906 lines
- **Markdown** — 9 files, 863 lines
- **JavaScript** — 4 files, 819 lines
- **YAML** — 12 files, 626 lines
- **JSON** — 2 files, 470 lines
- **HTML** — 8 files, 426 lines
- **SQL** — 8 files, 396 lines
- **Templates** — 1 file, 59 lines
- **CSS** — 1 file, 56 lines
- **toml** — 1 file, 32 lines
### Developer documentation that already exists
- `docs/architecture.md` — Architecture and decisions
- `docs/coding-standards.md` — Coding standards
- `docs/integration.md` — Integrating a Go API
<!-- go-doc:generated:end -->
---
## Related Documentation
- [go-doc](go-doc.md) — The documentation platform itself
- [go-bananas](go-bananas.md) — Design-token service (uses go-loose for auth)
- [go-tell](go-tell.md) — Content CMS (uses go-loose for auth)
- [go-places](go-places.md) — Location services (uses go-loose)

View file

@ -0,0 +1,102 @@
<!-- go-doc:generated:begin source=go-mad:docs/installation.md signature=b649bc122c59343cfbc62626f6a7f52d generated=2026-10-05 18:14 UTC -->
Imported from docs/installation.md in the go-mad repository.
---
# Installation
## Prerequisites
- Go 1.27.1 or later
- Docker and Docker Compose (for containerized deployment)
- Node.js and npm (for React frontend development)
- PostgreSQL 15+ (or use Docker)
## Local Development
### 1. Clone the repository
```bash
git clone https://github.com/your-organization/go-mad.git
cd go-mad
```
### 2. Install dependencies
```bash
# Go dependencies
go mod download
# Node dependencies
npm install
```
### 3. Set up the database
```bash
# Using Docker Compose
make compose-up
# Or manually
createdb mad
# Or set up PostgreSQL with user 'mad' and database 'mad'
```
### 4. Configure the application
```bash
# Create config.yaml
cp config.example.yaml config.yaml
# Edit config.yaml with your settings
```
### 5. Run migrations
```bash
make test
# Or run manually
go run ./cmd/go-mad
```
The application will automatically run migrations on startup.
### 6. Start the development server
```bash
# Development mode with hot reload
make web-dev
# Or run the Go binary
make run
```
Access the application at `http://localhost:8080`
## Docker Deployment
```bash
# Build and start
docker compose up --build -d
# View logs
docker compose logs -f
# Stop
docker compose down
```
## Kubernetes Deployment
```bash
helm install go-mad oci://your-registry/go-mad/helm/go-mad
```
## Production Checklist
- [ ] Change the default JWT secret
- [ ] Configure proper PostgreSQL credentials
- [ ] Set up HTTPS/TLS via ingress
- [ ] Configure backups for the PostgreSQL data volume
- [ ] Set up monitoring and logging
- [ ] Review role-based access control settings
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,116 @@
<!-- go-doc:generated:begin source=go-mad:docs/operations.md signature=b649bc122c59343cfbc62626f6a7f52d generated=2026-10-05 18:14 UTC -->
Imported from docs/operations.md in the go-mad repository.
---
# Operations
## Migration Scripts
go-mad uses goose-style SQL migration scripts stored in `internal/migration/`. Each migration file contains `-- +goose Up` and `-- +goose Down` markers.
### Migration Format
```sql
-- +goose Up
CREATE TABLE users (
id SERIAL PRIMARY KEY,
tenant_id INTEGER REFERENCES tenants(id) ON DELETE CASCADE,
username VARCHAR(255) NOT NULL UNIQUE
);
-- +goose Down
DROP TABLE IF EXISTS users CASCADE;
```
### Running Migrations
Migrations are automatically run at application startup via `database.Migrate()`. To run migrations manually or for a specific tenant:
```bash
# Using the API
curl -X POST http://localhost:8080/api/v1/databases/{id}/migrations
# Or via command line (if exposing migration tooling)
go run ./cmd/go-mad --migrate
```
### Creating New Migrations
```bash
# Create a new migration file
touch internal/migration/0004_permissions.sql
# Write up/down sections
-- +goose Up
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE
);
-- +goose Down
DROP TABLE IF EXISTS permissions CASCADE;
```
### Checking Migration Status
The application tracks applied migrations in a `schema_migrations` table. To check which migrations have been applied, query the database:
```sql
SELECT * FROM schema_migrations ORDER BY version;
```
## Backup and Restore
### PostgreSQL Backup
```bash
# Using docker exec
docker exec go-mad-postgres pg_dump -U mad -d mad > backup.sql
# Or using pg_dump directly
pg_dump -U mad -d mad > backup.sql
```
### Restore
```bash
psql -U mad -d mad < backup.sql
```
## Monitoring
The application exposes a `/health` endpoint for liveness and readiness probes:
```bash
curl http://localhost:8080/health
```
## Logs
```bash
# Docker compose logs
make compose-logs
# Or kubectl
kubectl logs deployment/go-mad
```
## Scaling
The application is designed to be stateless (except for the database connection pool). Multiple replicas can be run behind a load balancer.
## Upgrading
```bash
# Pull new image
docker pull go-mad:latest
# Or rebuild
make docker-build
docker push go-mad:latest
# Deploy
helm upgrade go-mad oci://your-registry/go-mad/helm/go-mad
```
<!-- go-doc:generated:end -->

141
data/docs/go-mad.md Normal file
View file

@ -0,0 +1,141 @@
<!-- go-doc:generated:begin source=go-mad signature=9b2e6fd6778d65d26378ad5507111fec generated=2026-10-07 08:28 UTC -->
# go-mad
A web application for managing tenant database connections and running migration scripts.
| | |
| --- | --- |
| Module | github.com/dnoulet/go-mad · go 1.27.1 · 4 Go files |
| Endpoints | 0 endpoints |
| Components | 0 component directories |
| Tests | none detected |
| Go lines | 362 lines |
> Everything between the generated markers is rewritten by every sync. Notes written outside the markers are kept.
## For Users
### What this application does
A web application for managing tenant database connections and running migration scripts.
The project ships its own README at `docs/README.md`, which is the long form of this page. The three audience tabs split the same facts three ways so you only read the part that concerns you.
### Reading this in go-doc
Open the page and pick the tab that matches you:
- **User** — you use the finished application. This section is all you need.
- **Operator** — you run it. Configuration, ports and deployment notes are under that tab.
- **Developer** — you integrate with it or change it. The API and layout tables are under that tab.
The feedback panel stores a note per audience, tagged with your badge, so operators and developers can see which audience raised it.
### Where the original documentation lives
This project already carries 3 Markdown documents. They stay in the project repository: this page points at them instead of copying them, so there is always exactly one copy of the truth.
| Document | Size |
| --- | --- |
| `docs/README.md` | 3197 bytes |
| `docs/installation.md` | 1600 bytes |
| `docs/operations.md` | 2193 bytes |
---
## For Operators
### Running it locally
The root Makefile defines 6 targets:
- `make compose-logs`
- `make docker-build`
- `make docker-push`
- `make integration-test`
- `make run`
- `make web-build`
### Local containers
The compose file defines these services:
| Service | Image | Published ports |
| --- | --- | --- |
| postgres | `postgres:15-alpine` | internal only |
| go-mad | built locally | internal only |
### Image
`Dockerfile` builds from `golang:1.27.1-alpine`, `gcr.io/distroless/static-debian12:nonroot` in stages: builder and exposes 8080.
### Configuration
Every environment variable found in the source or in a `.env` file:
| Variable | Example | Found in |
| --- | --- | --- |
| `GO_MAD_DATABASE_URL` | — | source |
| `GO_MAD_JWT_SECRET` | — | source |
| `GO_MAD_SERVER_HOST` | — | source |
| `GO_MAD_SERVER_PORT` | — | source |
### Network surface
Ports referenced across the project: `8080`.
### Deployment artefacts
Helm 1 chart found:
- `helm/go-mad`
### Operations documentation that already exists
Read these in place rather than duplicating them here:
- `docs/installation.md` — Installation
- `docs/operations.md` — Operations
> No HTTP route registrations were detected. If this is a server, the API table is incomplete — check for routes built dynamically.
---
## For Developers
### Module layout
- go.mod — module github.com/dnoulet/go-mad, go 1.27.1, 2 direct and 9 indirect dependencies
### HTTP API
No route registrations were detected, which is expected for a CLI, a library or a worker.
### Direct dependencies
- `github.com/jackc/pgx/v5` v5.11.0
- `gopkg.in/yaml.v3` v3.0.1
### Directory map
| Directory | Files | Lines | Likely purpose |
| --- | --- | --- | --- |
| `helm/` | 12 | 329 | deployment manifests |
| `web/` | 8 | 355 | frontend assets |
| `internal/` | 6 | 332 | unimportable application internals |
| `docs/` | 3 | 0 | project documentation |
| `cmd/` | 1 | 63 | binary entry points |
### Languages
- **JSON** — 2 files, 976 lines
- **CSS** — 1 file, 585 lines
- **YAML** — 11 files, 478 lines
- **Go** — 4 files, 362 lines
- **Markdown** — 3 files, 349 lines
- **tgz** — 1 file, 319 lines
- **JSX** — 1 file, 241 lines
- **TypeScript** — 1 file, 109 lines
- **SQL** — 3 files, 33 lines
- **HTML** — 1 file, 13 lines
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,168 @@
<!-- go-doc:generated:begin source=go-places:DEVELOPER.md signature=0fcd11f2310a851002924fdb59e5ad85 generated=2026-10-05 18:14 UTC -->
Imported from DEVELOPER.md in the go-places repository.
---
# Developer Workflow Guide for Go Places Project
This document outlines the standard operating procedure for building, testing, and deploying **go-places**, the service in this repository. It authenticates callers against **go-loose**, a separate SSO service that is not part of this repository.
## 🚀 Core Dependencies & Structure
The system relies on the following structure:
- **Root:** `/home/dnoulet/go/go-places/`
- **API:** `api/` — the Go module root (`module go-places-api`)
- **Dependency:** `go-loose` — the SSO/authentication service. It lives in a **separate repository** and is *not* present here; only its Helm chart (`charts/go-loose/`) is kept in this repository.
- **Web:** `web/` — React front-end
- **Infrastructure:** all Kubernetes deployment definitions are in `charts/`
## 🛠️ Local Development & Building (The Makefile)
All development tasks should begin by referencing the `Makefile` in the project root.
### 1. Building Local Binaries (For testing/local runs)
This simulates the container image build process locally.
```bash
make build
```
* `make build-api`: compiles the service to `bin/go-places`.
* `make build-loose`: **not available here.** `go-loose` is a separate repository, so this target reports that clearly instead of failing obscurely.
### 2. Running Locally
```bash
make run
```
Starts the API with `AUTH_MODE=dev` on `:8080`, backed by a local SQLite file and `./data/media`. Open <http://localhost:8080/graphql> in a browser for a small query console.
### 3. Testing
```bash
make test # go test ./...
make race # go test -race ./...
make vet # go vet ./...
make fmt # go fmt ./...
make check # everything CI runs: fmt, vet, race, web typecheck, helm lint
```
### 4. Validating Infrastructure
Before attempting deployment, always lint the Helm charts to ensure all YAML templates are valid.
```bash
make validate
```
This runs `helm lint` against `charts/api` (go-places) and `charts/go-loose`.
### 5. Container Image
```bash
docker build -t go-places:latest .
```
The build context is the repository root, because the Dockerfile copies `api/`. The container runs unprivileged and exposes the API on port 8080.
## ☁️ Deployment to Cluster (Helm/Rancher)
The production deployment strategy leverages Kubernetes via Helm, managed by Rancher.
```bash
make deploy
```
This runs `helm upgrade --install` for the `go-places` chart (from `charts/api`, whose internal name is `go-places`) and then the `go-loose` chart, both into the `dev` namespace.
## 📡 HTTP Surface
| Route | Auth | Purpose |
| --- | --- | --- |
| `GET /healthz` | no | Liveness probe. |
| `GET /readyz` | no | Readiness probe; pings the database. |
| `POST /graphql` | bearer | Queries and mutations. This is the only route that executes a query. |
| `GET /graphql` | no | Browser query console. A `?query=` parameter is rejected, because this route is unauthenticated. |
| `POST /places/{placeId}/images` | bearer | Multipart picture upload (`image` field, optional `caption`). |
| `POST /upload` | bearer | Same upload, with `placeId` given as a form field. |
| `GET /media/{key}` | no | Serves a stored picture. |
| `GET /api/places` | bearer | REST list, `ENABLE_REST=true`. |
| `GET /api/places/{placeId}` | bearer | REST read, including `relatedPlaces` and each picture's `linkedImages`. |
| `POST /api/places` | bearer | REST create. |
| `GET /api/places/{placeId}/images` | bearer | REST picture list. |
Health probes and `/media/` are the only unauthenticated data routes, and neither
carries user data: a kubelet cannot present a token, and an `<img>` tag cannot
attach one. Everything else requires a bearer token.
Every setting is read from the environment by `internal/config`; see `api/internal/config/config.go` for the full list and its defaults.
**Authentication.** With `AUTH_MODE=dev`, the token `valid_token` identifies the user `dev`. With `AUTH_MODE=jwt`, an HS256 token issued by `go-loose` must carry `sub` and `exp`, and may carry `username` and `email`. The algorithm is pinned to HS256, so a token cannot select `none` or an asymmetric algorithm.
**Uploads.** Analysis runs inline: the picture is stored, decoded, and turned into badges before the response is written. A picture whose bytes cannot be decoded is still stored, with `analysisStatus: failed` and the reason in `analysisError`.
**Linking.** `Image.linkedImages` and `Place.relatedPlaces` are computed on read from the pictures' own coordinates (EXIF GPS when present, otherwise the place's coordinate). Nothing is persisted, so links always reflect the current set of pictures.
## 💾 Storage and durability
SQLite and the uploaded pictures share a single volume, mounted at `/app/data` (`persistence.mountPath`). The chart requests a `ReadWriteOnce` claim and forces a `Recreate` strategy, because SQLite permits one writer and a rolling update would briefly run two pods against the same database file. `replicaCount` must stay at `1`.
The volume is what makes the travel log survive a pod being rescheduled. To confirm:
```bash
kubectl delete pod -n dev -l app.kubernetes.io/name=go-places
```
A place and its pictures are still there afterwards. The image runs as uid `10001`, and `securityContext.fsGroup` is set to the same value so the non-root process can create the database file inside the volume.
Two limits worth knowing:
- **The claim cannot be resized.** The cluster's `local-path` StorageClass does not allow volume expansion, so editing `persistence.size` makes the API server reject the resized claim. `helm upgrade` then aborts part-way and leaves the release in `STATUS=failed` (the running pod and its data are unaffected); recover with `helm rollback` or by upgrading again without the size change. Grow the storage or add an expandable StorageClass first.
- **`local-path` is node-local.** Data lives on the node's own disk under `/var/lib/rancher/k3s/storage`, so it survives pod replacement but not loss of that node. It also means the service is pinned to one node. Its reclaim policy is `Retain`, so deleting the claim does not destroy the data directory.
Set `persistence.enabled=false` to fall back to an `emptyDir`. That is useful for a throwaway demo and wrong for anything real: the database and every picture are lost on each pod replacement.
## 📚 Workflow Summary
1. **Develop:** make code changes in `api/` or `web/`.
2. **Test:** `make build && make check`.
3. **Run:** `make run`, then `cd web && npm run dev`.
4. **Validate:** `make validate`.
5. **Deploy:** `make deploy`.
## 🖥️ Web front-end
`web/` is a Vite + React client for the REST routes above, not for GraphQL.
```bash
cd web
npm install
npm run dev # http://localhost:5173
```
The dev server proxies `/api`, `/media`, `/healthz` and `/graphql` to the API, so
the browser only ever makes same-origin requests and CORS never enters the
picture. It expects the API on `http://localhost:8080`; override with
`VITE_PROXY_TARGET`.
The client has no login flow: identity belongs to go-loose, so it asks for a
token and keeps it in `localStorage`. `VITE_API_TOKEN` bakes one into the bundle
at build time, and `VITE_API_BASE` points the client at an API on another origin.
A build without either asks for a token, which is what you want in production —
a token in a public bundle is public.
```bash
cd web
npm run build # -> web/dist, a static bundle
npx tsc --noEmit # type check
```
`web/dist` is not served by the API image and `charts/api` does not deploy it.
See the front-end section of `INFRASTRUCTURE.md` for the two ways to publish it.
## 🚫 Known Gaps / TODOs
- There is no Docker Compose setup, so RabbitMQ and a remote inference endpoint are not exercised locally by default. Without `AMQP_URL` the service logs events instead.
- Object recognition is heuristic. Detecting an arbitrary subject ("a dog") needs the remote analyzer at `ANALYZER_URL/v1/analyze`; the local analyzer only produces scene and property badges such as Water, Sky, Vivid and Nature.
- `shared-interfaces/schema/models.graphql` documents the domain structs. It is maintained by hand, so the executable schema in `api/internal/transport/schema.go` stays the source of truth and a drift check is still worth adding.
- `go-loose` is consumed purely as an HTTP/SSO dependency; there is no in-process client to it.
- Only comments can be read over REST. Adding one, or deleting a place or picture from the UI, needs a GraphQL call or new REST routes.
This guide assumes familiarity with Go, Docker, and Kubernetes concepts.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,108 @@
<!-- go-doc:generated:begin source=go-places:INFRASTRUCTURE.md signature=0fcd11f2310a851002924fdb59e5ad85 generated=2026-10-05 18:14 UTC -->
Imported from INFRASTRUCTURE.md in the go-places repository.
---
# 🏰 Ecosystem Installation Playbook: End-to-End Guide
**Target System:** Kubernetes Cluster managed by Rancher
**Ecosystem Components:** go-places (this repository), go-loose, go-guess, go-tell, etc.
**Goal:** Achieve a "Press Start" configuration where the entire stack runs reliably, persistently, and securely.
**Author:** AI Assistant
**Date Generated:** 2026-10-03
---
## ⚠️ IMPORTANT WARNINGS & SAFETY CHECKLIST
*** DO NOT PROCEED WITHOUT READING THIS SECTION. ***
* **Destructive Actions:** This guide includes commands to prune/reset system environments (e.g., Docker). Ensure all necessary repositories are backed up *before* executing Phase 0 cleanup steps.
* **Security:** All networking relies on the Ingress Controller and Cert-Manager for TLS termination. Ensure your DNS points correctly to the Ingress LoadBalancer IP.
* **Service Mesh:** An innovative Service Mesh (e.g., Istio/Linkerd) will be implemented to handle mTLS, traffic routing, and observability. This adds significant complexity; proceed with caution.
---
## ⚙️ Phase 0: Prerequisites, Cleanup, and Backup (The "Start From Scratch" State)
This phase ensures a known, clean state. **Execute these steps only when ready for a fresh deployment.**
### 0.1 Tool Installation
* Install Docker Engine.
* Install Helm 3.
* Install `kubectl` and configure access to the target K8s cluster (`kubectl config use-context <target-cluster>`).
### 0.2 System Cleanup (Mandatory for Clean Slate)
* *Action:* Run the following commands to stop all services and remove all unused images/volumes.
* *Command:* `docker stop $(docker ps -q) && docker rm $(docker ps -aq) && docker image prune -f && docker system prune -a -f`
### 0.3 Repository Persistence Setup (Forgejo Mounts)
To ensure code history persists across host reboots, we must map local paths to persistent volumes.
* *Action:* Identify the root path for the source code (e.g., `/home/dnoulet/go/`).
* *Command (Example):* Configure the K8s PersistentVolumeClaim (PVC) for Forgejo to mount at `/mnt/forgejo-repo`.
* *Guidance:* Refer to the Forgejo installation guide for exact PVC definitions.
## 🗺️ Phase 1: Core Infrastructure Deployment (The Foundation)
This phase sets up the networking and security backbone before deploying any application logic.
### 1.1 Service Mesh Implementation
* *Recommendation:* Implement **Istio** for a robust Service Mesh.
* *Action:* Apply Istio's base installation manifest (`istioctl install --set profile=default`).
### 1.2 TLS Management (Cert-Manager)
* *Action:* Deploy `cert-manager`. This allows dynamic issuance of certificates for external traffic.
* *Resource:* Define the necessary `Certificate` resources referencing your public domains.
### 1.3 Entry Point (Ingress Controller)
* *Action:* Deploy the Ingress Controller (e.g., Nginx Ingress Controller).
* *Configuration:* Configure the Ingress resource to reference the `Certificate` Secret generated by Cert-Manager.
## 🛠️ Phase 2: Application Build and Deployment (The Go Code)
This phase builds the Go services and deploys them into the cluster, using the Service Mesh for communication.
### 2.1 Building Docker Images
* *Action:* Navigate to the service root and build the image.
* *Command:* `make build`, then `docker build -t go-places:latest .` (build context is the repository root).
### 2.2 Deploying Services via Helm
* **Order of Deployment is Critical:**
1. **`go-loose` (Auth):** Deploy first as it is a core dependency.
`helm upgrade --install go-loose ./charts/go-loose -n dev`
2. **`go-places`:** Deploy next. It reaches `go-loose` only by verifying that service's JWTs, so there is no service URL to wire up; set `AUTH_MODE=jwt` and `JWT_SECRET` to the shared signing secret.
`helm upgrade --install go-places ./charts/api -n dev`
3. **`go-guess`, `go-tell`:** Repeat the above for all other applications.
## ✅ Phase 3: Verification and Finalization
* **Service Health Check:** Verify all pods are running (`kubectl get pods -n dev`).
* **Dependency Checks:** `curl -fsS https://api.yourdomain.com/healthz` and `curl -fsS https://api.yourdomain.com/readyz`. Both are unauthenticated so a probe or a quick `curl` works; `/readyz` additionally pings SQLite and answers `503` when the database is unreachable.
* **API Functionality Test:** Send an authenticated request. The GraphQL executor only answers `POST`, so a browser link cannot leak data:
```bash
curl -H "Authorization: Bearer $GO_LOOSE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"query":"{ places { id title } }"}' \
https://api.yourdomain.com/graphql
```
* **Media Check:** `curl -I https://api.yourdomain.com/media/<key>` should return `200` and `Cache-Control: public, max-age=31536000, immutable`. Media is deliberately unauthenticated so `<img>` tags work; if that is not acceptable for your data, put the Ingress in front of it with an auth subrequest.
## 🌐 Phase 4: Web Front-end
The React app in `web/` is a static bundle. It is **not** served by the `go-places` image and **not** deployed by `charts/api`; the API has no front-end assets. Two ways to publish it:
* **Recommended:** build once and serve from a static host (or an nginx Deployment) on a subdomain, e.g. `https://places.yourdomain.com`. Point it at the API by configuring the CORS origins allowed by the Ingress, or serve it under the same host so the browser makes same-origin requests.
* **Quick path:** serve `web/dist` from any web server and use the Vite dev proxy locally (see `DEVELOPER.md`), which avoids CORS entirely because everything is same-origin.
The client needs two things from its build environment:
| Variable | Meaning |
| --- | --- |
| `VITE_API_BASE` | Optional. Base URL for the API when it is not same-origin. Empty means same-origin. |
| `VITE_API_TOKEN` | Optional. A token baked into the bundle. Leave unset and let the user paste a go-loose token into the "Set token" field in the UI. |
Baking a token into a public bundle publishes it to every visitor, so only do that for a throwaway demo.
---
*End of Initial Playbook Draft.*
<!-- go-doc:generated:end -->

149
data/docs/go-places.md Normal file
View file

@ -0,0 +1,149 @@
<!-- go-doc:generated:begin source=go-places signature=2ccc0e464627498e334eed4a4ee84aa8 generated=2026-10-07 08:28 UTC -->
# go-places
This project has no README yet. The analyser found Go module `go-places-api`, 33 Go source files, 10 HTTP endpoints and 8 environment variables.
| | |
| --- | --- |
| Module | go-places-api · go 1.24 · 33 Go files |
| Endpoints | 10 endpoints |
| Components | 1 component directory |
| Tests | 106 in 8 files |
| Go lines | 9856 lines |
> Everything between the generated markers is rewritten by every sync. Notes written outside the markers are kept.
## For Users
### What this application does
This project has no README yet. The analyser found Go module `go-places-api`, 33 Go source files, 10 HTTP endpoints and 8 environment variables.
This project ships no README, so the summary above was derived from the source tree. If that is wrong, write a README and re-run the sync.
### Reading this in go-doc
Open the page and pick the tab that matches you:
- **User** — you use the finished application. This section is all you need.
- **Operator** — you run it. Configuration, ports and deployment notes are under that tab.
- **Developer** — you integrate with it or change it. The API and layout tables are under that tab.
The feedback panel stores a note per audience, tagged with your badge, so operators and developers can see which audience raised it.
### Where the original documentation lives
This project already carries 2 Markdown documents. They stay in the project repository: this page points at them instead of copying them, so there is always exactly one copy of the truth.
| Document | Size |
| --- | --- |
| `DEVELOPER.md` | 8602 bytes |
| `INFRASTRUCTURE.md` | 6216 bytes |
---
## For Operators
### Running it locally
The root Makefile defines 1 target:
- `make web-dev`
### Image
`Dockerfile` builds from `golang:1.24-alpine`, `alpine:3.20` in stages: builder and exposes 8080.
### Configuration
Every environment variable found in the source or in a `.env` file:
| Variable | Example | Found in |
| --- | --- | --- |
| `ADDR` | `:8080 # Go API listen address` | .env file |
| `ALLOWED_IMAGE_TYPES` | — | source |
| `AUTH_MODE` | `dev # Authentication mode: dev or jwt` | .env file |
| `ENABLE_REST` | `true # Enable REST API endpoints (/api/places, etc.)` | .env file |
| `LOG_FORMAT` | — | source |
| `LOG_LEVEL` | — | source |
| `VITE_PROXY_TARGET` | `http://localhost:8080 # API target for Vite proxy` | .env file |
### Network surface
Ports referenced across the project: `8080`.
### Deployment artefacts
Helm 2 charts found:
- `charts/api`
- `charts/go-loose`
### Operations documentation that already exists
Read these in place rather than duplicating them here:
- `INFRASTRUCTURE.md` — 🏰 Ecosystem Installation Playbook: End-to-End Guide
> This project ships no README, so the summary was derived from the tree layout. Add one and re-run the sync.
---
## For Developers
### Module layout
- api/go.mod — module go-places-api, go 1.24, 6 direct and 8 indirect dependencies
### HTTP API
Every route registration found in the source:
| Method | Path | Handler |
| --- | --- | --- |
| `GET` | `/api/places` | `protect` |
| `POST` | `/api/places` | `protect` |
| `GET` | `/api/places/` | `protect` |
| `GET` | `/api/places/{placeId}/images` | `protect` |
| `GET` | `/graphql` | `handleGraphQLConsole` |
| `POST` | `/graphql` | `graphqlHandler` |
| `GET` | `/healthz` | `handleHealthz` |
| `POST` | `/places/{placeId}/images` | `upload` |
| `GET` | `/readyz` | `handleReadyz` |
| `POST` | `/upload` | `upload` |
### Direct dependencies
- `github.com/golang-jwt/jwt/v5` v5.3.1
- `github.com/google/uuid` v1.6.0
- `github.com/graphql-go/graphql` v0.8.1
- `github.com/rabbitmq/amqp091-go` v1.15.0
- `golang.org/x/image` v0.23.0
- `modernc.org/sqlite` v1.34.5
### Directory map
| Directory | Files | Lines | Likely purpose |
| --- | --- | --- | --- |
| `api/` | 38 | 10024 | HTTP handlers and routing |
| `charts/` | 18 | 399 | deployment manifests |
| `web/` | 10 | 695 | frontend assets |
| `shared-interfaces/` | 1 | 160 | unknown — inspect directly |
### Languages
- **Go** — 33 files, 9856 lines
- **JSON** — 5 files, 1672 lines
- **TypeScript** — 5 files, 695 lines
- **YAML** — 14 files, 686 lines
- **tgz** — 1 file, 319 lines
- **CSS** — 1 file, 285 lines
- **Markdown** — 2 files, 264 lines
- **graphql** — 1 file, 160 lines
- **Templates** — 2 files, 80 lines
- **db-wal** — 1 file, 14 lines
### Developer documentation that already exists
- `DEVELOPER.md` — Developer Workflow Guide for Go Places Project
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,106 @@
<!-- go-doc:generated:begin source=go-tell:ui/changelog.md signature=4802e214d6466316d2951953a1d67e05 generated=2026-10-05 18:14 UTC -->
Imported from ui/changelog.md in the go-tell repository.
---
# Prompt and implementation changelog
## 2026-09-19
### Prompt
Create a CMS React application with a Go backend, initialize a Git repository, and
prepare the security foundation first. Add an extensible external identity-provider
login beginning with Google, a protected test page, and an admin login at
`/admin/login` whose link is never exposed in user-facing pages. Use Goose for
migrations and GORM for persistence. Structure the backend into web handlers,
web-to-domain mapping, domain-only services, database models, repositories, and
domain/database mappers. Prefix interfaces with `I`. Use Go 1.27.1 and the latest
React. Keep this changelog in `ui/changelog.md`.
### Tasks completed
- Initialized a local Git repository on the `main` branch; GitHub remote
configuration is intentionally left for later.
- Added a Go 1.27.1 backend module with configuration via environment variables.
- Added layered backend packages for domain, web handlers and mapping, services,
repositories, persistence models, and persistence mapping.
- Added GORM with SQLite as the local database driver and Goose migrations for
users, administrators, and sessions.
- Added an OAuth provider abstraction and Google OAuth authorization-code flow with
state validation, userinfo lookup, and an HTTP-only session cookie.
- Added health, session, Google auth, protected test, and hidden admin login routes.
- Added a React/Vite frontend with a public Google sign-in page, protected `/test`
page, and `/admin/login` route without a visible navigation link.
- Added environment examples, ignore rules, README documentation, and backend
unit tests.
- Verified Go tests and prepared frontend typecheck/build scripts.
## 2026-09-19 — Content and authorization
### Prompt
Fix the missing protected page and add a blog-like content experience with
infinite scrolling summaries, detail links, capability-controlled editing and
saving. Persist logged-in users so authorities can be assigned. Add a content
data model with creation/edit audit information and a history table recording
changes. Implement the domain model, mappers, web handlers, services,
repositories, migrations, and pages.
### Tasks completed
- Added persisted user role/editor authorization fields and generic `canEdit`
capability evaluation; administrators can edit through the same capability.
- Added content and content-history domain models, GORM models, explicit
mappers, repositories, services, and a Goose PostgreSQL migration.
- Added authenticated content listing/detail/update endpoints with pagination,
validation, 401/403 handling, audit fields, and transactional history
snapshots.
- Added `/protected` and `/api/protected`; retained `/test` and
`/api/test/protected` compatibility routes.
- Added the authenticated infinite-scroll content page, detail page, and
capability-gated edit/save mode.
- Exposed the authenticated user and generic capabilities in the session
response; no admin link was added to user-facing navigation.
- Revalidated with `go test ./...`, `npm run typecheck`, and `npm run build`.
## 2026-09-19 — Single sign-on
### Prompt
Change the security model: the owner is the only person who enters the application, so use SSO.
When the token is absent the visitor is anonymous; when it is present the owner can change the
application. Remove sign-in entirely, along with the admin database and the setup.
### Tasks completed
- Replaced the Google OAuth flow, direct login, and admin login with a single SSO seam: the
upstream identity-aware proxy authenticates the owner and the backend verifies the forwarded
header (`internal/security`).
- Removed the identity package, the auth service, the SQLite admin database, the admin migrations,
the admin login route, and every Google/OAuth setting and credential file.
- Reads are anonymous; `PUT /api/content/:slug` returns `401` unless the SSO header is present.
- Added the `GET /api/auth/session` capability response (`authenticated`, `canEdit`), which is
`false`/`false` for anonymous visitors and `true`/`true` for the owner.
- Dropped `users`, `sessions`, and all actor columns from PostgreSQL through migration `004_sso`;
content history keeps its snapshot timeline without an actor.
- Reworked the React app: no sign-in screen or sign-out button, public content browsing, an
SSO/anonymous mode badge in the header, and an Edit button gated on `canEdit`.
- Added backend tests for anonymous read-only access and proxy-authenticated editing.
- Revalidated with `gofmt`, `go build ./...`, `go test ./...`, `npm run typecheck`, and `npm run build`.
## 2026-09-19 — Google sign-in button
### Prompt
Add a button that redirects to a Google single sign-on.
### Tasks completed
- Added a **Sign in with Google** button to the header, shown only while the visitor is anonymous.
- It redirects to `VITE_SSO_LOGIN_URL` with the current page as `rd`, so oauth2-proxy-style
endpoints return the owner to where they were; an empty value hides the button entirely.
- Kept the SSO model intact: the button only navigates to the identity provider, the CMS still
holds no client secret and creates no session — the proxy asserts the identity header.
- Documented the new setting in `ui/app/.env.example`, `ui/app/README.md`, and the root README.
<!-- go-doc:generated:end -->

View file

@ -0,0 +1,26 @@
<!-- go-doc:generated:begin source=go-tell:ui/app/README.md signature=4802e214d6466316d2951953a1d67e05 generated=2026-10-05 18:14 UTC -->
Imported from ui/app/README.md in the go-tell repository.
---
# CMS frontend
This Vite/React app lives in `ui/app` and is intentionally independent of the Go backend. Run
`npm install`, then `npm run dev` or `npm run build`.
There is no sign-in screen and no session is ever created. Content is public and read-only for
anonymous visitors; the Edit button appears only when the backend reports `canEdit: true`, which
happens when the upstream SSO proxy asserted the owner's identity on the request.
`/api/auth/session` returns `{ "authenticated": bool, "canEdit": bool }`.
Anonymous visitors also see a **Sign in with Google** button in the header. It redirects the
browser to `VITE_SSO_LOGIN_URL` (with the current page passed as `rd`), where the identity
provider authenticates you and sends you back with the identity header set. Leave
`VITE_SSO_LOGIN_URL` empty to hide the button — useful when the proxy gates the whole app
automatically instead. The app never performs an OAuth exchange, so it holds no client secret.
The API defaults to same-origin `/api/auth/session` and `/api/content`. Set `VITE_API_BASE_URL`
for a separate backend, or override the endpoint paths with `VITE_SESSION_ENDPOINT` and
`VITE_CONTENT_ENDPOINT`. Requests use cookies (`credentials: include`) so the edge proxy cookie
travels with them.
<!-- go-doc:generated:end -->

172
data/docs/go-tell.md Normal file
View file

@ -0,0 +1,172 @@
<!-- go-doc:generated:begin source=go-tell signature=911c111a08c2bfd4f631756f16920125 generated=2026-10-07 08:28 UTC -->
# go-tell
Go Tell is a small content CMS: one Go binary that serves the API and embeds the React frontend, backed by a JSON file. It is served from a single host, `https://tell.dev`, and it uses [Go Loose](https://github.com/danyel/go-loose) to decide who is allowed to edit.
| | |
| --- | --- |
| Module | github.com/danyel/go-tell · go 1.27.1 · 13 Go files |
| Endpoints | 14 endpoints |
| Components | 3 component directories |
| Tests | 26 in 5 files |
| Go lines | 3050 lines |
> Everything between the generated markers is rewritten by every sync. Notes written outside the markers are kept.
## For Users
### What this application does
Go Tell is a small content CMS: one Go binary that serves the API and embeds the React frontend, backed by a JSON file. It is served from a single host, `https://tell.dev`, and it uses [Go Loose](https://github.com/danyel/go-loose) to decide who is allowed to edit.
The project ships its own README at `README.md`, which is the long form of this page. The three audience tabs split the same facts three ways so you only read the part that concerns you.
### Reading this in go-doc
Open the page and pick the tab that matches you:
- **User** — you use the finished application. This section is all you need.
- **Operator** — you run it. Configuration, ports and deployment notes are under that tab.
- **Developer** — you integrate with it or change it. The API and layout tables are under that tab.
The feedback panel stores a note per audience, tagged with your badge, so operators and developers can see which audience raised it.
### Where the original documentation lives
This project already carries 4 Markdown documents. They stay in the project repository: this page points at them instead of copying them, so there is always exactly one copy of the truth.
| Document | Size |
| --- | --- |
| `README.md` | 7238 bytes |
| `deploy/helm/go-tell/README.md` | 9194 bytes |
| `ui/app/README.md` | 1266 bytes |
| `ui/changelog.md` | 5324 bytes |
---
## For Operators
### Running it locally
This project has no root Makefile, so the binary or container is run directly.
### Image
`Dockerfile` builds from `node:22-alpine`, `golang:1.27-alpine`, `alpine:3.20` in stages: ui, backend and exposes 8080.
### Configuration
Every environment variable found in the source or in a `.env` file:
| Variable | Example | Found in |
| --- | --- | --- |
| `CMS_ALLOWED_ORIGINS` | `http://localhost:5173` | .env file |
| `CMS_CONTENT_FILE` | — | .env file |
| `CMS_HTTP_ADDR` | `:8080` | .env file |
| `CMS_POSTGRES_URL` | — | .env file |
| `CMS_PROFILE` | `demo` | .env file |
| `CMS_SSO_COOKIE` | — | .env file |
| `CMS_SSO_HEADER` | `X-SSO-Token` | .env file |
| `CMS_SSO_TOKEN` | — | .env file |
| `GO_LOOSE_APP_DOMAIN` | `tell.dev` | .env file |
| `GO_LOOSE_AUTH_DOMAIN` | `auth.dev` | .env file |
| `GO_LOOSE_CA_FILE` | — | .env file |
| `GO_LOOSE_CLIENT_ID` | — | .env file |
| `GO_LOOSE_CLIENT_SECRET` | — | .env file |
| `GO_LOOSE_TENANT` | `tell` | .env file |
| `VITE_API_BASE_URL` | — | .env file |
| `VITE_CONTENT_ENDPOINT` | `/api/content` | .env file |
| `VITE_SESSION_ENDPOINT` | `/api/auth/session` | .env file |
| `VITE_SSO_LOGIN_URL` | — | .env file |
### Network surface
Ports referenced across the project: `8080`.
### Deployment artefacts
Helm 1 chart found:
- `deploy/helm/go-tell`
### Operations documentation that already exists
Read these in place rather than duplicating them here:
- `deploy/helm/go-tell/README.md` — Go Tell Helm chart
---
## For Developers
### Module layout
- go.mod — module github.com/danyel/go-tell, go 1.27.1, 13 direct and 58 indirect dependencies
### HTTP API
Every route registration found in the source:
| Method | Path | Handler |
| --- | --- | --- |
| ANY | `/` | `singlePageApp` |
| ANY | `/api/admin/reset` | `adminReset` |
| ANY | `/api/auth/session` | `session` |
| ANY | `/api/auth/sudo` | `sudo` |
| ANY | `/api/auth/users` | `users` |
| ANY | `/api/content` | `contentCollection` |
| ANY | `/api/content/` | `contentDetail` |
| ANY | `/api/content/badges` | `contentBadges` |
| ANY | `/api/content/categories` | `contentCategories` |
| ANY | `/api/content/clear` | `adminReset` |
| ANY | `/api/dev/null` | `contentCollection` |
| ANY | `/api/dev/null/` | `contentDetail` |
| ANY | `/api/protected` | — |
| ANY | `/health` | `func` |
### Direct dependencies
- `github.com/danyel/go-loose` v0.0.0-20261002224534-b011701522b1
- `github.com/go-chi/chi/v5` v5.2.3
- `github.com/golang-jwt/jwt/v5` v5.3.0
- `github.com/google/uuid` v1.6.0
- `github.com/jackc/pgx/v5` v5.11.0
- `github.com/ledongthuc/pdf` v0.0.0-20260907135840-6c8c28e0e8a0
- `github.com/pressly/goose/v3` v3.26.0
- `github.com/rabbitmq/amqp091-go` v1.15.0
- `github.com/testcontainers/testcontainers-go` v0.44.0
- `github.com/testcontainers/testcontainers-go/modules/postgres` v0.44.0
- `golang.org/x/crypto` v0.54.0
- `gorm.io/driver/postgres` v1.6.0
- `gorm.io/gorm` v1.31.0
### Directory map
| Directory | Files | Lines | Likely purpose |
| --- | --- | --- | --- |
| `ui/` | 26 | 1636 | frontend assets |
| `deploy/` | 20 | 81 | deployment manifests |
| `internal/` | 12 | 2988 | unimportable application internals |
| `cmd/` | 1 | 62 | binary entry points |
| `migrations/` | 1 | 9 | schema migrations |
### Languages
- **YAML** — 20 files, 14872 lines
- **Go** — 13 files, 3050 lines
- **JSON** — 5 files, 2025 lines
- **TypeScript** — 15 files, 1616 lines
- **Markdown** — 4 files, 499 lines
- **CSS** — 1 file, 383 lines
- **Templates** — 1 file, 76 lines
- **txt** — 1 file, 35 lines
- **HTML** — 1 file, 29 lines
- **SQL** — 1 file, 9 lines
<!-- go-doc:generated:end -->
---
## Related Documentation
- [go-doc](go-doc.md) — The documentation platform itself
- [go-loose](go-loose.md) — Auth middleware (used for SSO)