// 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")