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
466 lines
15 KiB
Go
466 lines
15 KiB
Go
// 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")
|