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

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

466
internal/domain/domain.go Normal file
View file

@ -0,0 +1,466 @@
// Package domain holds the core entities of go-bananas. It has no knowledge of
// HTTP, SQL or any delivery mechanism: it is the shared vocabulary that the
// store, the HTTP API and the token engine all agree on.
//
// Coding standard: types in this package are plain data. Behaviour that is
// genuinely domain logic (not transport or persistence) is allowed, but it must
// be pure and deterministic.
package domain
import (
"errors"
"fmt"
"regexp"
"strings"
"time"
)
// TimeFormat is the single wire format used for every timestamp crossing a
// process boundary. RFC 3339 with second precision keeps SQLite TEXT columns
// lexicographically sortable, which the store relies on for ORDER BY.
const TimeFormat = "2006-01-02T15:04:05Z07:00"
// ThemeStatus is the publication state of a theme.
type ThemeStatus string
const (
// StatusDraft is editable by editors but invisible to runtime consumers.
StatusDraft ThemeStatus = "draft"
// StatusPublished is served to runtime consumers.
StatusPublished ThemeStatus = "published"
// StatusArchived is hidden from listings and runtime by default, but the
// last published revision stays resolvable for pinned consumers.
StatusArchived ThemeStatus = "archived"
)
// Valid reports whether s is a known status.
func (s ThemeStatus) Valid() bool {
switch s {
case StatusDraft, StatusPublished, StatusArchived:
return true
default:
return false
}
}
// Publishable lists the statuses a theme may be transitioned into.
func (s ThemeStatus) Publishable() bool {
return s == StatusDraft || s == StatusPublished || s == StatusArchived
}
// Appearance selects a colour scheme from a theme.
type Appearance string
const (
// AppearanceLight selects the light colour scheme.
AppearanceLight Appearance = "light"
// AppearanceDark selects the dark colour scheme.
AppearanceDark Appearance = "dark"
)
// Valid reports whether a is a known appearance.
func (a Appearance) Valid() bool { return a == AppearanceLight || a == AppearanceDark }
// slugPattern constrains theme slugs to something safe for URLs, filenames and
// CSS selectors without quoting.
var slugPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$`)
// ValidateSlug returns an error if slug is not a valid theme slug.
func ValidateSlug(slug string) error {
if slug == "" {
return errors.New("slug must not be empty")
}
if len(slug) > 64 {
return errors.New("slug must be at most 64 characters")
}
if !slugPattern.MatchString(slug) {
return fmt.Errorf("slug %q must match %s", slug, slugPattern)
}
return nil
}
// ReservedSlugs may not be used by user-created themes because the runtime
// delivery API exposes them as fixed paths.
var ReservedSlugs = map[string]struct{}{
"api": {},
"rt": {},
"admin": {},
"healthz": {},
"readyz": {},
"livez": {},
"metrics": {},
"assets": {},
"sdk": {},
}
// ValidateAppearance parses and validates a raw appearance string, falling back
// to light when empty.
func ParseAppearance(raw string) (Appearance, error) {
if raw == "" {
return AppearanceLight, nil
}
a := Appearance(strings.ToLower(raw))
if !a.Valid() {
return "", fmt.Errorf("unknown appearance %q: want %q or %q", raw, AppearanceLight, AppearanceDark)
}
return a, nil
}
// ThemeMeta is free-form, human-supplied metadata. It is deliberately opaque to
// the engine so consumers can carry whatever they need (author, homepage,
// licence, tags) without a schema migration.
type ThemeMeta map[string]any
// Theme is the aggregate root: an identified, versioned bundle of design
// tokens plus the metadata needed to present and govern it.
type Theme struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status ThemeStatus `json:"status"`
DefaultAppearance Appearance `json:"defaultAppearance"`
// Tokens is the canonical design-token document. See the tokenengine
// package for the schema, validation rules and rendering.
Tokens TokenDoc `json:"tokens"`
Meta ThemeMeta `json:"meta,omitempty"`
// System marks built-in themes that ship with the binary. They may be
// duplicated and re-published but never deleted, so consumers pinned to a
// built-in slug never break on upgrade.
System bool `json:"system"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CreatedBy string `json:"createdBy,omitempty"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
// Clone returns a deep-enough copy that mutating the result's token leaves or
// meta map cannot affect the receiver. Token leaf values are treated as opaque
// scalars, which is how the engine guarantees they are.
func (t *Theme) Clone() *Theme {
if t == nil {
return nil
}
out := *t
out.Tokens = t.Tokens.Clone()
if t.Meta != nil {
out.Meta = make(ThemeMeta, len(t.Meta))
for k, v := range t.Meta {
out.Meta[k] = v
}
}
return &out
}
// ThemeVersion is an immutable snapshot of a theme taken on every mutation.
type ThemeVersion struct {
ThemeID string `json:"themeId"`
Version int64 `json:"version"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status ThemeStatus `json:"status"`
DefaultAppearance Appearance `json:"defaultAppearance"`
Tokens TokenDoc `json:"tokens"`
Message string `json:"message,omitempty"`
Author string `json:"author,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// TokenDoc is a design-token document. The outer map is a set of token groups;
// each group is a nested tree whose leaves carry a "$type" and a "$value". It is
// modelled as a plain map so that consumers can extend a theme without an API
// change, while the engine enforces the parts it needs to render CSS.
type TokenDoc map[string]any
// Clone deep-copies the token document.
func (d TokenDoc) Clone() TokenDoc {
if d == nil {
return nil
}
out := make(TokenDoc, len(d))
for k, v := range d {
out[k] = cloneAny(v)
}
return out
}
func cloneAny(v any) any {
switch t := v.(type) {
case TokenDoc:
return t.Clone()
case map[string]any:
out := make(map[string]any, len(t))
for k, vv := range t {
out[k] = cloneAny(vv)
}
return out
case []any:
out := make([]any, len(t))
for i, vv := range t {
out[i] = cloneAny(vv)
}
return out
default:
return v
}
}
// ThemeQuery narrows a theme listing. The zero value matches every theme.
type ThemeQuery struct {
// Search matches slug, name or description, case-insensitively.
Search string
// Status filters by publication state. Empty matches all states.
Status ThemeStatus
// System filters on the built-in flag. Nil matches both.
System *bool
// Tag requires the theme meta to contain the given tag.
Tag string
Limit int
Offset int
// Sort is one of "updated", "created", "name", "slug".
Sort string
// Order is "asc" or "desc".
Order string
}
// SortFields is the allow-list of sortable columns. Values are compared against
// a fixed set so that caller input never reaches the SQL statement verbatim.
var SortFields = map[string]string{
"updated": "updated_at",
"created": "created_at",
"name": "name",
"slug": "slug",
}
// ThemePage is a page of themes plus the total match count.
type ThemePage struct {
Items []*Theme `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// Role is a coarse authorisation level, resolved from the identity provider.
type Role string
const (
// RoleViewer may read published themes.
RoleViewer Role = "viewer"
// RoleEditor may create, edit and publish themes.
RoleEditor Role = "editor"
// RoleAdmin may additionally manage users, API keys and instance settings.
RoleAdmin Role = "admin"
)
// Rank returns a comparable privilege level.
func (r Role) Rank() int {
switch r {
case RoleAdmin:
return 3
case RoleEditor:
return 2
case RoleViewer:
return 1
default:
return 0
}
}
// AtLeast reports whether r carries at least the privilege of want.
func (r Role) AtLeast(want Role) bool { return r.Rank() >= want.Rank() }
// ParseRole maps a raw claim value onto a Role, defaulting to viewer so that an
// unexpected claim can never grant more than read access.
func ParseRole(raw string) Role {
switch Role(strings.ToLower(strings.TrimSpace(raw))) {
case RoleAdmin:
return RoleAdmin
case RoleEditor:
return RoleEditor
case RoleViewer:
return RoleViewer
default:
return RoleViewer
}
}
// Identity is the authenticated caller, normalised across every auth mode
// (OIDC session, API key, or development bypass).
type Identity struct {
Subject string `json:"subject"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Roles []Role `json:"roles"`
Method string `json:"method"`
// ExpiresAt is a pointer because omitempty does not work on time.Time: a zero
// time is not the empty value to encoding/json, so a plain time.Time here
// serialised as "0001-01-01T00:00:00Z" on every identity that has no expiry.
// That is a real value to a client, and an API key with no expiry is normal.
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
// UserPreferences is what one person has chosen for their own interface.
//
// The preference names a published theme rather than a copy of it, so editing or
// unpublishing that theme is reflected everywhere without touching a row here. An
// empty SelectedTheme means nothing has been chosen and the client should apply
// its own default; SelectedAppearance is then ignored.
type UserPreferences struct {
SelectedTheme string `json:"selectedTheme"`
SelectedAppearance Appearance `json:"selectedAppearance"`
}
// Validate reports whether p is storable. An empty theme is the absence of a
// choice and is always valid; a named theme must be a syntactically valid slug and
// the appearance must be one this build knows.
func (p UserPreferences) Validate() error {
if p.SelectedTheme == "" {
if p.SelectedAppearance != "" && !p.SelectedAppearance.Valid() {
return fmt.Errorf("%w: selectedAppearance %q is not light or dark", ErrInvalid, p.SelectedAppearance)
}
return nil
}
if err := ValidateSlug(p.SelectedTheme); err != nil {
// ValidateSlug reports a bare error because it is also used for messages
// that are not about a request. Wrapping it here is what makes a bad slug
// a 400 through writeProblem rather than a 500.
return fmt.Errorf("%w: %s", ErrInvalid, err.Error())
}
if p.SelectedAppearance != "" && !p.SelectedAppearance.Valid() {
return fmt.Errorf("%w: selectedAppearance %q is not light or dark", ErrInvalid, p.SelectedAppearance)
}
return nil
}
// HighestRole returns the caller's most privileged role.
func (i *Identity) HighestRole() Role {
var best Role
for _, r := range i.Roles {
if r.Rank() > best.Rank() {
best = r
}
}
if best == "" {
return RoleViewer
}
return best
}
// HasRole reports whether the identity carries the given role.
func (i *Identity) HasRole(r Role) bool {
for _, have := range i.Roles {
if have == r {
return true
}
}
return false
}
// DisplayName returns the friendliest available label for the identity.
func (i *Identity) DisplayName() string {
if i == nil {
return "anonymous"
}
switch {
case i.Name != "":
return i.Name
case i.Email != "":
return i.Email
case i.Subject != "":
return i.Subject
default:
return "anonymous"
}
}
// Setting is one instance-level configuration value.
//
// Settings live in the database rather than in the environment because they are
// editable from the admin UI while the service is running. Changing the default
// theme takes effect on the next request, with no restart.
type Setting struct {
Key string `json:"key"`
Value string `json:"value"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
// Setting keys. They are constants rather than free strings so a typo is a compile
// error, and so the keys in the database have one definition.
const (
// SettingDefaultTheme is the slug served when a consumer names no theme.
SettingDefaultTheme = "default.theme"
// SettingDefaultAppearance is the appearance used when the default theme
// declares none.
SettingDefaultAppearance = "default.appearance"
// SettingRuntimeAPIBase overrides the runtime API base URL advertised in the
// manifest, for a deployment that serves it from a CDN.
SettingRuntimeAPIBase = "runtime.apiBase"
// SettingRuntimePublic lists the origins allowed to embed the runtime SDK.
SettingRuntimePublic = "runtime.public"
)
// AuditAction is the kind of change an audit entry records.
type AuditAction string
// The audit actions this build records.
const (
ActionThemeCreate AuditAction = "theme.create"
ActionThemeUpdate AuditAction = "theme.update"
ActionThemeDelete AuditAction = "theme.delete"
ActionThemePublish AuditAction = "theme.publish"
ActionThemeArchive AuditAction = "theme.archive"
ActionThemeDuplicate AuditAction = "theme.duplicate"
ActionThemeRollback AuditAction = "theme.rollback"
ActionThemeImport AuditAction = "theme.import"
ActionAuthLogin AuditAction = "auth.login"
ActionAuthLogout AuditAction = "auth.logout"
)
// AuditEntry is one recorded action.
//
// The audit log is append-only and is the record of who changed what. The actor is
// taken from the authenticated identity at the point of writing rather than from
// the request body, so it cannot be forged by a client.
type AuditEntry struct {
ID int64 `json:"id"`
At time.Time `json:"at"`
Actor string `json:"actor"`
ActorEmail string `json:"actorEmail,omitempty"`
Action AuditAction `json:"action"`
EntityType string `json:"entityType,omitempty"`
EntityID string `json:"entityId,omitempty"`
Summary string `json:"summary,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
RemoteIP string `json:"remoteIp,omitempty"`
}
// Error sentinels.
//
// Handlers match on these with errors.Is rather than comparing strings, so the
// mapping from a domain failure to an HTTP status lives in exactly one place and a
// wrapped error still classifies correctly.
var (
// ErrInvalid means the request was malformed or failed validation.
ErrInvalid = errors.New("invalid input")
// ErrNotFound means the addressed thing does not exist.
ErrNotFound = errors.New("not found")
// ErrUnauthorized means the caller is not authenticated, or the credential
// presented did not identify anyone.
ErrUnauthorized = errors.New("unauthorized")
// ErrForbidden means the caller is known but not permitted.
ErrForbidden = errors.New("forbidden")
// ErrConflict means the change collides with existing state, such as a slug
// that is already taken or a stale base version.
ErrConflict = errors.New("conflict")
// ErrImmutable means the target is one this service owns and will not let an
// operator change, such as a built-in theme. It is distinct from ErrForbidden:
// the caller is allowed to ask, and the answer is no.
ErrImmutable = errors.New("immutable")
)
// ErrNotConfigured is returned when a feature is unavailable because of how the
// service was configured, as distinct from a request that was made wrongly.
var ErrNotConfigured = errors.New("not configured")