From 04e3aa869663a36be9b21d422bdbb41719446445 Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Fri, 3 Jul 2026 11:42:03 -0400 Subject: [PATCH 1/2] feat(facade): optional view-config resolver contract Add an optional wippy.facade:resolver contract.definition that lets an application override facade requirement values (css_variables, custom_css, app_title, ...) at request time. When no implementation is bound the config and CSS-vars handlers fall back to the static ns.requirement defaults, so behavior is unchanged for existing deployments. theming_helpers gains resolve_overrides() (structural :implementations() gate, ambient-actor open, pcall-hardened to {} on any failure) and requirement() (override string wins over the static default). Both handlers resolve overrides once per request and route every requirement read through the override-aware getter. --- src/facade/_index.yaml | 39 ++++++++++++++++ src/facade/config_handler.lua | 79 ++++++++++++++++----------------- src/facade/css_vars_handler.lua | 19 ++++---- src/facade/theming_helpers.lua | 75 +++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 52 deletions(-) diff --git a/src/facade/_index.yaml b/src/facade/_index.yaml index a0088fb..11c0899 100644 --- a/src/facade/_index.yaml +++ b/src/facade/_index.yaml @@ -486,6 +486,42 @@ entries: path: /index.html func: index_handler + # Optional view-config resolution contract (bound by the application, if at all) + - name: resolver + kind: contract.definition + meta: + comment: Optional view-config resolution contract — override facade requirement values at request time + description: | + Optional contract for overriding facade requirement values (css_variables, + custom_css, app_title, app_icon, theme_mode, ...) at request time. An + application binds an implementation to drive the view config from runtime + state (settings, per-tenant branding). When no binding exists, the facade + config and CSS-vars handlers fall back to the static ns.requirement defaults + — detected structurally via :implementations() (an empty list), not by + inspecting the contract open error. Runs under the request's ambient actor. + methods: + - name: resolve + description: | + Return a map of facade requirement name -> override value (string). Values + use the same format as the static requirement default (JSON-encoded for the + JSON requirements such as css_variables). Omitted keys fall back to the + static default. + input_schemas: + - definition: | + { + "type": "object", + "description": "Optional resolution context (reserved; empty today)." + } + format: application/schema+json + output_schemas: + - definition: | + { + "type": "object", + "description": "Map of facade requirement name to override value string; omitted keys fall back to the static ns.requirement default.", + "additionalProperties": { "type": "string" } + } + format: application/schema+json + # Shared helpers: file:// resolution and CSS variable generation - name: theming_helpers kind: library.lua @@ -495,6 +531,9 @@ entries: modules: - fs - json + - registry + - contract + - logger # Config endpoint - name: config_handler diff --git a/src/facade/config_handler.lua b/src/facade/config_handler.lua index c523d05..444e6f5 100644 --- a/src/facade/config_handler.lua +++ b/src/facade/config_handler.lua @@ -1,31 +1,8 @@ local http = require("http") -local registry = require("registry") local json = require("json") local env = require("env") local theming = require("theming_helpers") -local NS = "wippy.facade:" - -local function get_req(name: string): string - local entry, _ = registry.get(NS .. name) - if entry and entry.data then - return entry.data.default or "" - end - return "" -end - -local function get_req_json_any(name: string): {[string]: any} - local raw = get_req(name) - if raw == "" then - return {} - end - local val, err = json.decode(raw) - if err then - return {} - end - return (val :: {[string]: any}) or {} -end - local function derive_ws_url(api_url: string): string if api_url == "" then return "" @@ -54,30 +31,50 @@ local function non_empty_array_or_nil(a: any): {any}? return a :: {any} end --- Build a theming scope from requirement name prefixes. --- file_sys is optional: when set, "file://path" values are resolved to file content. -local function build_theming_scope(css_req: string, vars_req: string, icon_sets_req: string?, file_sys: any): {[string]: any}? - local custom_css = non_empty_or_nil(theming.resolve_css(get_req(css_req), file_sys)) - local css_vars = non_empty_map_or_nil(theming.resolve_json(get_req(vars_req), file_sys) :: {[string]: any}) - -- icon_sets are always inline JSON blobs; file:// refs are not expected here - local icon_sets = icon_sets_req and non_empty_map_or_nil(get_req_json_any(icon_sets_req)) or nil - - if not custom_css and not css_vars and not icon_sets then - return nil - end - return { - customCSS = custom_css, - cssVariables = css_vars, - iconSets = icon_sets, - } -end - local function handler() local res = http.response() if not res then return nil, "no HTTP context" end + -- Optional view-config resolver (wippy.facade:resolver): when an application + -- binds one, its overrides replace matching requirement values below; with no + -- binding this is {} and every read falls back to the static ns.requirement. + local overrides = theming.resolve_overrides() + + local function get_req(name: string): string + return theming.requirement(name, overrides) + end + + local function get_req_json_any(name: string): {[string]: any} + local raw = get_req(name) + if raw == "" then + return {} + end + local val, err = json.decode(raw) + if err then + return {} + end + return (val :: {[string]: any}) or {} + end + + -- Build a theming scope from requirement name prefixes. file_sys is optional: + -- when set, "fs://path" values are resolved to file content. + local function build_theming_scope(css_req: string, vars_req: string, icon_sets_req: string?, file_sys: any): {[string]: any}? + local custom_css = non_empty_or_nil(theming.resolve_css(get_req(css_req), file_sys)) + local css_vars = non_empty_map_or_nil(theming.resolve_json(get_req(vars_req), file_sys) :: {[string]: any}) + -- icon_sets are always inline JSON blobs; fs:// refs are not expected here + local icon_sets = icon_sets_req and non_empty_map_or_nil(get_req_json_any(icon_sets_req)) or nil + if not custom_css and not css_vars and not icon_sets then + return nil + end + return { + customCSS = custom_css, + cssVariables = css_vars, + iconSets = icon_sets, + } + end + local facade_url = get_req("fe_facade_url") local entry_path = get_req("fe_entry_path") local fe_mode = get_req("fe_mode") diff --git a/src/facade/css_vars_handler.lua b/src/facade/css_vars_handler.lua index 712c82f..6582321 100644 --- a/src/facade/css_vars_handler.lua +++ b/src/facade/css_vars_handler.lua @@ -1,23 +1,20 @@ local http = require("http") -local registry = require("registry") local theming = require("theming_helpers") -local NS = "wippy.facade:" - -local function get_req(name: string): string - local entry, _ = registry.get(NS .. name) - if entry and entry.data then - return entry.data.default or "" - end - return "" -end - local function handler() local res = http.response() if not res then return nil, "no HTTP context" end + -- Optional view-config resolver (wippy.facade:resolver): overrides win over the + -- static requirement, so this CSS endpoint tones from the same source as + -- /facade/config. No binding => {} => static defaults. + local overrides = theming.resolve_overrides() + local function get_req(name: string): string + return theming.requirement(name, overrides) + end + local file_sys = theming.get_fs(get_req("content_fs")) local vars = theming.resolve_json(get_req("css_variables"), file_sys) diff --git a/src/facade/theming_helpers.lua b/src/facade/theming_helpers.lua index 59e6e8d..aa74435 100644 --- a/src/facade/theming_helpers.lua +++ b/src/facade/theming_helpers.lua @@ -1,8 +1,19 @@ local fs = require("fs") local json = require("json") +local registry = require("registry") +local contract = require("contract") +local logger = require("logger") local helpers = {} +local NS = "wippy.facade:" +-- Optional contract an application binds to override facade requirement values at +-- request time (see the `resolver` contract.definition). "No resolver configured" +-- is detected structurally via :implementations() (an empty list) -- the contract +-- open sentinel is a plain errors.New with no Kind, so it surfaces as kind=Unknown +-- and can't be told apart from a real failure by kind; the binding count can. +local RESOLVER_CONTRACT = "wippy.facade:resolver" + -- Get a named filesystem instance by registry entry ID. -- Returns nil (does not raise) when entry_id is empty or the named filesystem -- does not exist, so callers degrade gracefully when content_fs is unconfigured. @@ -149,4 +160,68 @@ function helpers.build_variables_css(vars: {[string]: any}): string return table.concat(css_parts, "\n") end +-- resolve_overrides: optionally consult a bound wippy.facade:resolver to override +-- facade requirement values at request time. Returns a name->value(string) map, or +-- {} when no resolver is bound (the common case) or resolution fails. The view must +-- always render, so any failure degrades to the static requirement defaults; an +-- absent resolver (no binding) is silent, a bound-but-failing one is logged. +function helpers.resolve_overrides(): {[string]: any} + -- The config/CSS endpoints are load-bearing, so the resolver must never break + -- them: every failure path (and any unexpected throw) degrades to {} = static. + local ok, result = pcall(function(): {[string]: any} + local c, get_err = contract.get(RESOLVER_CONTRACT) + if get_err or not c then + return {} + end + -- Structural "is a resolver bound?" check -- no error-string/kind coupling. + local impls, impl_err = (c :: any):implementations() + if impl_err then + logger:warn("facade view config resolver: implementations() failed", { error = tostring(impl_err) }) + return {} + end + if not impls or #(impls :: {any}) == 0 then + return {} + end + -- Ambient actor/scope: the resolver runs as the request's caller. + local instance, open_err = (c :: any):open() + if open_err or not instance then + logger:warn("facade view config resolver: open failed", { error = tostring(open_err) }) + return {} + end + local overrides, resolve_err = (instance :: any):resolve({}) + if resolve_err then + logger:warn("facade view config resolver: resolve failed", { error = tostring(resolve_err) }) + return {} + end + if type(overrides) ~= "table" then + return {} + end + return overrides :: {[string]: any} + end) + if not ok then + logger:warn("facade view config resolver: errored", { error = tostring(result) }) + return {} + end + return result :: {[string]: any} +end + +-- requirement: the value for a facade requirement, preferring a resolver override +-- (a string) over the static registry default. Mirrors the handlers' get_req so a +-- resolver can replace any requirement (css_variables, custom_css, app_title, ...). +-- Override values use the same format as the static default (JSON-encoded for the +-- JSON requirements such as css_variables); omitted keys fall back to the default. +function helpers.requirement(name: string, overrides: {[string]: any}?): string + if overrides then + local ov = (overrides :: {[string]: any})[name] + if type(ov) == "string" then + return ov :: string + end + end + local entry, _ = registry.get(NS .. name) + if entry and entry.data then + return entry.data.default or "" + end + return "" +end + return helpers From 0f332f20a9c6c3279146b89ebaa6f89fdd112c5e Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Fri, 3 Jul 2026 13:08:01 -0400 Subject: [PATCH 2/2] facade: resolver drop implementations() probe (lean open()+degrade) The endpoints degrade to static on any resolver failure, so unbound and errored are indistinguishable and need no distinguishing; open() fails cleanly when nothing is bound. Removes the implementations() round-trip and the unused logger. --- src/facade/_index.yaml | 1 - src/facade/theming_helpers.lua | 31 +++++++------------------------ 2 files changed, 7 insertions(+), 25 deletions(-) diff --git a/src/facade/_index.yaml b/src/facade/_index.yaml index 11c0899..88149d1 100644 --- a/src/facade/_index.yaml +++ b/src/facade/_index.yaml @@ -533,7 +533,6 @@ entries: - json - registry - contract - - logger # Config endpoint - name: config_handler diff --git a/src/facade/theming_helpers.lua b/src/facade/theming_helpers.lua index aa74435..9f5fd57 100644 --- a/src/facade/theming_helpers.lua +++ b/src/facade/theming_helpers.lua @@ -2,16 +2,13 @@ local fs = require("fs") local json = require("json") local registry = require("registry") local contract = require("contract") -local logger = require("logger") local helpers = {} local NS = "wippy.facade:" -- Optional contract an application binds to override facade requirement values at --- request time (see the `resolver` contract.definition). "No resolver configured" --- is detected structurally via :implementations() (an empty list) -- the contract --- open sentinel is a plain errors.New with no Kind, so it surfaces as kind=Unknown --- and can't be told apart from a real failure by kind; the binding count can. +-- request time (see the `resolver` contract.definition). When unbound, open() fails +-- and every read falls back to the static ns.requirement default. local RESOLVER_CONTRACT = "wippy.facade:resolver" -- Get a named filesystem instance by registry entry ID. @@ -166,40 +163,26 @@ end -- always render, so any failure degrades to the static requirement defaults; an -- absent resolver (no binding) is silent, a bound-but-failing one is logged. function helpers.resolve_overrides(): {[string]: any} - -- The config/CSS endpoints are load-bearing, so the resolver must never break - -- them: every failure path (and any unexpected throw) degrades to {} = static. + -- Any failure degrades to {} (static defaults) so the load-bearing config/CSS + -- endpoints never break. open() alone suffices: it fails cleanly when unbound, + -- and unbound vs errored both fall back to static, so nothing gates the open. + -- Ambient actor/scope; pcall guards against unexpected throws. local ok, result = pcall(function(): {[string]: any} local c, get_err = contract.get(RESOLVER_CONTRACT) if get_err or not c then return {} end - -- Structural "is a resolver bound?" check -- no error-string/kind coupling. - local impls, impl_err = (c :: any):implementations() - if impl_err then - logger:warn("facade view config resolver: implementations() failed", { error = tostring(impl_err) }) - return {} - end - if not impls or #(impls :: {any}) == 0 then - return {} - end - -- Ambient actor/scope: the resolver runs as the request's caller. local instance, open_err = (c :: any):open() if open_err or not instance then - logger:warn("facade view config resolver: open failed", { error = tostring(open_err) }) return {} end local overrides, resolve_err = (instance :: any):resolve({}) - if resolve_err then - logger:warn("facade view config resolver: resolve failed", { error = tostring(resolve_err) }) - return {} - end - if type(overrides) ~= "table" then + if resolve_err or type(overrides) ~= "table" then return {} end return overrides :: {[string]: any} end) if not ok then - logger:warn("facade view config resolver: errored", { error = tostring(result) }) return {} end return result :: {[string]: any}