CMS settings — one module for the whole subject: reading and writing values, plus the registry of declared settings the admin page renders.
Reading & writing
Reads come from the ETS cache (MishkaCmsCore.Runtime.States.SettingsState) — lock-free, safe on
every request; writes go to the durable MishkaCmsCore.Runtime.Setting resource and refresh the
cache. Scalars are stored wrapped as %{"value" => term}. Settings are multitenant: a site_id of
nil is the GLOBAL value (shared by every site), a site_id is that site's override, and a site
read resolves site value → global → default.
Settings.get("cms.offline", false) # global read
Settings.get("site.title", "MishkaCMS", site_id) # site value, falling back to global
Settings.put("cms.offline", true) # global write
Settings.put("site.title", "Acme", site_id: id) # per-site writeput/3 is the trusted infra path — boot seeding and plugin registration, where there is no user
to speak for — and it skips authorization, multitenancy and the master check together. Anything
an admin typed goes through put_as/4 instead, which routes to the resource's own :set /
:master_set action with the actor attached, so MishkaCmsCore.Runtime.Setting's policies decide
which site's row they may touch.
put/3 holds the resource's PubSub notifications back (return_notifications?: true), refreshes
the cache, and only then sends them. The order matters: subscribers of "setting:updated" react by
reading the cache, so announcing the write first hands every one of them the value it replaced.
Declared settings (the registry)
A definition is a plain map (see define/3) describing a key, its input type, default, and where
it shows on the settings page. load_definitions/0 runs once at boot: it aggregates definitions
from three sources, caches them in :persistent_term, and seeds any missing default into the store.
Core —
core_definitions/0below.Extensions — modules listed in
config :mishka_cms, :settings_providers, each exporting adefinitions/0returning a list ofdefine/3maps:# config/config.exs config :mishka_cms, :settings_providers, [MishkaBlog.Settings] defmodule MishkaBlog.Settings do def definitions do [MishkaCmsCore.Settings.define("blog.posts_per_page", :integer, default: 10, category: "blog", source: :extension, label: "Posts per page")] end endRuntime plugins — anything answering the
settings:registermishka_installer hook (Hook.call("settings:register", %{definitions: [...]})).
Secrets
A :secret setting (a provider's API key) is stored sealed with the endpoint's
secret_key_base (Phoenix.Token.encrypt/4), so the row, the cache, the "setting:updated"
broadcast and the cms_core:setting:after_* plugin hooks carry only the sealed text. get/3 hands
back that sealed text; secret/2 opens it, and secret_hint/2 gives the last four characters for
the settings page, which never draws a stored secret into a field. Rotating secret_key_base
makes stored secrets unreadable, and they are entered again.
See MishkaCmsCore.Runtime.Setting and MishkaCmsCore.Runtime.States.SettingsState.
Summary
Functions
All GLOBAL settings as %{key => scalar_value} (from the RAM cache) — used for default seeding.
Coerces a submitted value against the definition that declared it — the one gate both editors go through, so a number typed on the settings page and the same number picked in a dashboard card's gear are bounded identically.
The distinct categories, in first-seen order (the settings-page tabs).
Builds a setting definition map. opts: :default, :label, :description, :category
(default "general", the settings-page TAB — usually the owning area/extension), :group
(an optional sub-section HEADING within the tab, e.g. "Notifications"; nil = no heading),
:source (:core | :extension | :plugin, default :core), :scope
(:global one value for all sites, read-only to tenants | :site each tenant owns its own |
:both a global default a site may override | :master global AND master-only, hidden from
tenants; default :global), :editable? (default true), :options (choices for a :select),
:attention_on_default (default false) — set it when the default is only a placeholder an admin
is expected to replace, and the settings page marks the tab with a red dot until they do —
:permission (default nil = admin:dashboard:setting; one name or a list of them, where
holding ANY is enough) — what writing the setting takes, and who sees it on the settings page, e.g.
"admin:dashboard:setting:deploy". A :user setting is seen by whoever holds its :permission
(everyone when nil) and written by its user alone. A tab whose every setting is gated away
disappears with them — and
:validate (default nil), a one-argument function answering whether a value cast/2 has
already typed may be stored, for a rule the type alone does not hold: site.languages is a list of
strings, and only language codes — :resolve (default :override, a site's value replaces the
global one), set to :any for a boolean whose global and site values are OR-ed, as cms.offline's
are: a site's view then shows the site's own value and says when the global one holds it on, or
:own for a value each scope sets for itself and never takes from the global one, as a site's
email provider and its key are (read it with resolved/3) — and
:global_label (default nil, the :label everywhere), the label in the Global view when it reads
differently there — and :visible (default true), or fn values -> boolean end over the tab's
values as the admin has them on screen (key → value, typed or saved), for a field that only means
something beside another one's choice: a provider's keys while that provider is chosen. The
settings page draws and saves it only while it holds; see visible?/2 and shown_when/3.
The registered definition for a key, or nil.
All registered definitions, in the order they were declared — core first, then config providers, then runtime plugins, each key keeping the position it was first declared at while a later redeclaration of the same key still wins on content.
Registered definitions in a category (a settings-page tab).
Read a setting's scalar value from the RAM cache, or default if unset. With a site_id the read
resolves the site's override first, then the global value, then default.
A site's own value for a setting, or default when the site holds none. Unlike get/3 it never
falls back to the global value — it is what the site itself set. nil for the site is the
installation's own value: the global one.
Read a PER-USER preference (:user scope) — a value only that user sees, stored under a
user-namespaced key so it never touches the site/global value. default when unset.
Aggregates every definition (core + config providers + the settings:register hook), caches them in
:persistent_term, and seeds any missing default into the store. Run once at boot; returns the
definitions.
Whether a site is serving a holding page instead of itself.
Which switches hold a site offline: :global for the one every site answers to, :site for the
site's own. [] when it is online; nil asks about the global switch alone.
Whether user may see something that declares a :permission — a setting definition, or anything
else carrying the field, such as a dashboard widget's descriptor.
Creates or updates a setting (durable), refreshes the RAM cache from the committed row, and notifies
plugins via the cms_core:setting:after_master_set hook. Options: :site_id (default nil ⇒
global; a uuid ⇒ that site's override), :category (default "general"), :source
(:core | :extension | :plugin, default :core), :description, :editable? (default
true). Returns {:ok, value} or {:error, changeset}.
Writes a setting as a user, through the resource's own tenant/master action with the actor
attached and authorization ON — the path every admin-facing editor takes. put/3 stays the
trusted infra path (boot seeding, plugin registration) and deliberately skips all three of
authorization, multitenancy and the master check; this one skips none of them.
Write a per-user preference (see get_user/3). Returns {:ok, value} | {:error, changeset}.
A :secret setting opened for site_id, resolved like resolved/3; nil when none is stored or
it cannot be opened. See "Secrets" above.
What the settings page may say about a stored :secret for site_id: its last four characters;
:unreadable when one is stored but was sealed under another SECRET_KEY_BASE — the server's key
changed since — so it must be typed again; nil when none is stored.
definition, shown only while the setting key holds value — and while its own :visible
holds. How a provider's fields follow the provider select: each provider's settings pass through
it, so no provider has to say so itself.
A definition's :label, :description, :group, :global_label or option label, in the reader's
language.
Whether definition shows beside the tab's values (key → value): its :visible, true by
default.
Whether user may write the setting definition declares (write_permission/1). A definition of
scope: :user is the user's own preference, and is theirs to write whenever they may see it
(permitted?/2).
What writing a setting takes, as a list where holding ANY entry is enough: the :permission its
definition declares, or admin:dashboard:setting when it declares none. Takes a definition, or a
key. A key no definition names takes admin:dashboard:setting too, except the builder's own —
its Studio switches (studio.*) and component presets (builder:preset:*) — which take any name
that opens a builder: a runtime area of a document the builder edits, or admin:dashboard:code.
Types
@type definition() :: %{ key: String.t(), type: input_type(), default: term(), label: String.t() | nil, description: String.t() | nil, category: String.t(), group: String.t() | nil, source: :core | :extension | :plugin, scope: scope(), editable?: boolean(), options: [String.t()], min: integer() | nil, max: integer() | nil, attention_on_default: boolean(), permission: MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement() | [MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement()] | nil, validate: (term() -> boolean()) | nil, resolve: :override | :any | :own, global_label: String.t() | nil, visible: boolean() | (map() -> boolean()), collapsed: boolean(), layout: :table | nil, inverted: boolean(), under: String.t() | nil, trim: boolean() }
@type input_type() ::
:string | :text | :boolean | :integer | :select | :list | :secret | :image
@type scope() :: :global | :site | :both | :master | :user
Functions
All GLOBAL settings as %{key => scalar_value} (from the RAM cache) — used for default seeding.
@spec cast(definition() | map(), term()) :: {:ok, term()} | :error
Coerces a submitted value against the definition that declared it — the one gate both editors go through, so a number typed on the settings page and the same number picked in a dashboard card's gear are bounded identically.
:integer parses and clamps to the definition's :min/:max; :boolean reads the form's
"on"/"true" (and a missing checkbox as false); :select must name one of the declared
:options, given either as bare values or {label, value} pairs; :string/:text take any
binary; :list takes a list of strings or the comma-separated text the settings page sends, each
entry trimmed and blanks and repeats dropped. Anything else — an unparsable number, a value off a
select's list, a list holding something other than strings, an unknown type — is :error, and so
is a value of the right type that the definition's :validate answers false for. Nothing is
coerced to a silent zero or nil: a value that cannot be trusted is refused and the caller decides
what to tell the admin.
@spec categories() :: [String.t()]
The distinct categories, in first-seen order (the settings-page tabs).
@spec define(String.t(), input_type(), keyword()) :: definition()
Builds a setting definition map. opts: :default, :label, :description, :category
(default "general", the settings-page TAB — usually the owning area/extension), :group
(an optional sub-section HEADING within the tab, e.g. "Notifications"; nil = no heading),
:source (:core | :extension | :plugin, default :core), :scope
(:global one value for all sites, read-only to tenants | :site each tenant owns its own |
:both a global default a site may override | :master global AND master-only, hidden from
tenants; default :global), :editable? (default true), :options (choices for a :select),
:attention_on_default (default false) — set it when the default is only a placeholder an admin
is expected to replace, and the settings page marks the tab with a red dot until they do —
:permission (default nil = admin:dashboard:setting; one name or a list of them, where
holding ANY is enough) — what writing the setting takes, and who sees it on the settings page, e.g.
"admin:dashboard:setting:deploy". A :user setting is seen by whoever holds its :permission
(everyone when nil) and written by its user alone. A tab whose every setting is gated away
disappears with them — and
:validate (default nil), a one-argument function answering whether a value cast/2 has
already typed may be stored, for a rule the type alone does not hold: site.languages is a list of
strings, and only language codes — :resolve (default :override, a site's value replaces the
global one), set to :any for a boolean whose global and site values are OR-ed, as cms.offline's
are: a site's view then shows the site's own value and says when the global one holds it on, or
:own for a value each scope sets for itself and never takes from the global one, as a site's
email provider and its key are (read it with resolved/3) — and
:global_label (default nil, the :label everywhere), the label in the Global view when it reads
differently there — and :visible (default true), or fn values -> boolean end over the tab's
values as the admin has them on screen (key → value, typed or saved), for a field that only means
something beside another one's choice: a provider's keys while that provider is chosen. The
settings page draws and saves it only while it holds; see visible?/2 and shown_when/3.
:trim (default false) strips the whitespace around a :string or :text before it is checked
and stored.
:collapsed (default false) makes the setting's :group an accordion on the settings page: a
group with any shown setting that declares it is drawn as a closed <details> titled by the group,
whatever its settings hold. It opens by itself only when the last save refused a value of one of
its settings, so the message under the field is in view, and otherwise stays as the admin left it.
It needs a :group, whose heading it becomes, and works for any tab, so an extension's optional
settings can stay out of the way until they are used. The accordion stands where the group's first
setting is declared:
Settings.define("site.theme_color", :string,
group: gettext_noop("App & home screen"), collapsed: true, default: "", scope: :site):layout (default nil) set to :table draws the setting's :group as one table, a row per
setting — for a group of switches read together, such as the ways of signing in. Like
:collapsed, one setting declaring it is enough.
:under (default nil), in a :table group, names the switch whose row the setting is drawn
in, below that switch's description, rather than as a row of its own — a provider's keys under the
provider. Pair it with shown_when/3 on that switch to draw it only while the switch is on.
:inverted (default false) draws a :boolean as its opposite: the switch is on while the stored
value is false. A *.closed key reads as the way it closes, on while open, beside switches that
are on while on.
@spec definition(String.t()) :: definition() | nil
The registered definition for a key, or nil.
@spec definitions() :: [definition()]
All registered definitions, in the order they were declared — core first, then config providers, then runtime plugins, each key keeping the position it was first declared at while a later redeclaration of the same key still wins on content.
The order is the settings page's tab order, and handle_params falls back to the FIRST category
when no tab is named, so it has to be stable. It was not: the registry was a plain map and this
returned Map.values/1, so both the tab order and which tab opened by default were map iteration
order — stable only by luck, and reshuffling as soon as enough definitions were registered.
@spec definitions(String.t()) :: [definition()]
Registered definitions in a category (a settings-page tab).
Read a setting's scalar value from the RAM cache, or default if unset. With a site_id the read
resolves the site's override first, then the global value, then default.
A site's own value for a setting, or default when the site holds none. Unlike get/3 it never
falls back to the global value — it is what the site itself set. nil for the site is the
installation's own value: the global one.
Read a PER-USER preference (:user scope) — a value only that user sees, stored under a
user-namespaced key so it never touches the site/global value. default when unset.
@spec load_definitions() :: [definition()]
Aggregates every definition (core + config providers + the settings:register hook), caches them in
:persistent_term, and seeds any missing default into the store. Run once at boot; returns the
definitions.
Whether a site is serving a holding page instead of itself.
Takes a site struct, a site id, or nil for "no site resolved". Two ETS reads, no query.
Settings.offline?(conn.assigns[:current_site])cms.offline is the one setting that does not resolve the usual way. Everywhere else a site's
value replaces the global one; here the two are OR-ed, so a master switching every site off cannot
be undone by a site switching itself on. A site's own value only ever adds to it.
| global | site | result |
|---|---|---|
| off | off | online |
| off | on | offline |
| on | off | offline |
| on | unset | offline |
Which switches hold a site offline: :global for the one every site answers to, :site for the
site's own. [] when it is online; nil asks about the global switch alone.
Settings.offline_by(site) # => [:global, :site]Two ETS reads, no query. offline?/1 is offline_by/1 != [].
Whether user may see something that declares a :permission — a setting definition, or anything
else carrying the field, such as a dashboard widget's descriptor.
One entry or a list of them, where holding ANY is enough — a name, or {:under, prefix} for
anyone holding anything under prefix (MishkaCmsCore.Accounts.Checks.AccessPermissions). nil
and [] gate nothing and mean everyone who got this far. Nobody (a nil user) is never
permitted, whatever is declared — an absent user is not an anonymous one with open access.
Creates or updates a setting (durable), refreshes the RAM cache from the committed row, and notifies
plugins via the cms_core:setting:after_master_set hook. Options: :site_id (default nil ⇒
global; a uuid ⇒ that site's override), :category (default "general"), :source
(:core | :extension | :plugin, default :core), :description, :editable? (default
true). Returns {:ok, value} or {:error, changeset}.
Writes a setting as a user, through the resource's own tenant/master action with the actor
attached and authorization ON — the path every admin-facing editor takes. put/3 stays the
trusted infra path (boot seeding, plugin registration) and deliberately skips all three of
authorization, multitenancy and the master check; this one skips none of them.
A master (site_id nil) writes the global row or any site's, via :master_set. A tenant writes
its own site's row via :set, and naming a different site is refused by the resource's policies
rather than clamped here — the caller finds out it asked for something it may not have, instead
of quietly getting something else. opts are put/3's, where :site_id names the target row
(a tenant omitting it means its own site).
Returns {:ok, value} or {:error, reason}; anything without a :site_id key — nil included —
is {:error, :no_actor}, never a silent write.
Write a per-user preference (see get_user/3). Returns {:ok, value} | {:error, changeset}.
A setting's value for site_id the way its definition resolves it: the scope's own value for
resolve: :own (see get_own/3), else get/3's site → global → default.
A :secret setting opened for site_id, resolved like resolved/3; nil when none is stored or
it cannot be opened. See "Secrets" above.
What the settings page may say about a stored :secret for site_id: its last four characters;
:unreadable when one is stored but was sealed under another SECRET_KEY_BASE — the server's key
changed since — so it must be typed again; nil when none is stored.
@spec shown_when(definition(), String.t(), term()) :: definition()
definition, shown only while the setting key holds value — and while its own :visible
holds. How a provider's fields follow the provider select: each provider's settings pass through
it, so no provider has to say so itself.
Settings.shown_when(definition, "mailer.provider", "resend")
A definition's :label, :description, :group, :global_label or option label, in the reader's
language.
Definitions are cached at boot, before anybody's language is known, so each holds its English
words marked with gettext_noop/1, and the settings page translates them as it draws them. A text
built from another is {msgid, bindings}, each binding translated in turn:
Settings.text(gettext_noop("Theme")) #=> "پوسته"
Settings.text({gettext_noop("%{label} — position"), label: "Blog"}) #=> "وبلاگ — جایگاه"
@spec visible?(definition() | map(), map()) :: boolean()
Whether definition shows beside the tab's values (key → value): its :visible, true by
default.
@spec writable?(definition(), map() | nil) :: boolean()
Whether user may write the setting definition declares (write_permission/1). A definition of
scope: :user is the user's own preference, and is theirs to write whenever they may see it
(permitted?/2).
@spec write_permission(definition() | map() | String.t() | nil) :: [ MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement() ]
What writing a setting takes, as a list where holding ANY entry is enough: the :permission its
definition declares, or admin:dashboard:setting when it declares none. Takes a definition, or a
key. A key no definition names takes admin:dashboard:setting too, except the builder's own —
its Studio switches (studio.*) and component presets (builder:preset:*) — which take any name
that opens a builder: a runtime area of a document the builder edits, or admin:dashboard:code.
Settings.write_permission("site.title") #=> ["admin:dashboard:setting"]
Settings.write_permission("studio.lock_tokens") #=> ["admin:dashboard:runtime:page", …]