From 3ae69d920431c6f12fc12d5ea850f10836f00225 Mon Sep 17 00:00:00 2001 From: Andrei Kirkouski Date: Thu, 8 Oct 2026 12:04:54 +0200 Subject: [PATCH 1/2] fix(facade): expose shared chat policy for release 62 --- src/facade/CHANGELOG.md | 6 +++++ src/facade/Makefile | 2 +- src/facade/README.md | 29 ++++++++++++++-------- src/facade/_index.yaml | 6 ++--- src/facade/config_handler.lua | 9 ++++--- src/facade/index.jet | 11 +++++--- src/facade/public/index.html | 11 +++++--- src/facade/test/config_dependency_test.lua | 2 +- src/facade/test/config_handler_test.lua | 24 +++++++++++++----- 9 files changed, 67 insertions(+), 33 deletions(-) diff --git a/src/facade/CHANGELOG.md b/src/facade/CHANGELOG.md index 9783268..723c4dd 100644 --- a/src/facade/CHANGELOG.md +++ b/src/facade/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Target Web Host 1.0.62 and schema wippy-context-2.1.json. +- Forward model selection, session selector visibility, and sanitizer tags as shared top-level policy. Existing requirement names stay unchanged. +- Preserve Attention configuration and the existing transport and theming scopes. + ## [0.6.41](https://github.com/wippyai/framework/compare/facade-v0.6.40...facade-v0.6.41) (2026-10-01) diff --git a/src/facade/Makefile b/src/facade/Makefile index 9750407..82d2460 100644 --- a/src/facade/Makefile +++ b/src/facade/Makefile @@ -6,7 +6,7 @@ # Run `make sync` after every Wippy Web Host version bump to pull fresh copies. # Web Host CDN base — update version when Wippy Web Host releases -WEB_HOST_CDN = https://web-host.wippy.ai/webcomponents-1.0.59 +WEB_HOST_CDN = https://web-host.wippy.ai/webcomponents-1.0.62 # Files to sync from CDN into public/@wippy-fe/ CDN_FILES = loading.js diff --git a/src/facade/README.md b/src/facade/README.md index d5b46f0..45e4c33 100644 --- a/src/facade/README.md +++ b/src/facade/README.md @@ -78,7 +78,7 @@ These fields are NOT configurable via requirements — they are computed at runt | Requirement | Default | Description | |---|---|---| -| `fe_facade_url` | `https://web-host.wippy.ai/webcomponents-1.0.59` | CDN base URL for the Web Host frontend bundle | +| `fe_facade_url` | `https://web-host.wippy.ai/webcomponents-1.0.62` | CDN base URL for the Web Host frontend bundle | | `fe_entry_path` | `/iframe.html` | Iframe HTML entry point path (appended to `fe_facade_url`) | | `fe_mode` | `compat` | `compat` (default — loads `module.js`) or `managed` (loads `managed-layout.js` for declarative multi-panel apps). See [Modes](#modes) above | | `render_engine` | `iframe` | Global page render engine for packaged `view.page` micro-frontends: `iframe` (default — legacy srcdoc iframe) or `fragment` (Web Fragment / reframed realm reflected into a shadow root). `fragment` **requires** the `wippy/views` fragment gateway and a fragment-capable host bundle serving `/@wippy-fe/proxy-fragment.js`. Flows to the host as `hostConfig.renderEngine`; the child app is engine-agnostic (same package renders under either engine). | @@ -124,7 +124,7 @@ These accept JSON strings for complex configuration: | `api_routes` | `{}` | `apiRoutes` | API route overrides — top-level, not under hostConfig (e.g. `{"agents":{"list":"/custom/agents"}}`) | | `additional_nav_items` | `[]` | `hostConfig.additionalNavItems` | Extra sidebar nav items as JSON array | | `state_cache` | `{}` | `hostConfig.stateCache` | Child state LRU config (e.g. `{"maxPages":50,"maxSizePerPage":1048576}`) | -| `allow_additional_tags` | `{}` | `hostConfig.allowAdditionalTags` | HTML sanitizer tag whitelist (e.g. `{"w-chart":["data","type"]}`) | +| `allow_additional_tags` | `{}` | `allowAdditionalTags` | HTML sanitizer tag whitelist (e.g. `{"w-chart":["data","type"]}`) | | `chat` | `{}` | `hostConfig.chat` | Chat config (e.g. `{"convertPasteToFile":{"enabled":true,"minFileSize":1024,"allowHtml":false}}`) | | `axios_defaults` | `{}` | `axiosDefaults` | HTTP client defaults (e.g. `{"timeout":30000}`) — top-level, not under hostConfig | | `tanstack` | `{}` | `tanstack` | TanStack Query defaults — top-level, not under hostConfig. `{ default?, content?, lists? }`: `default` applies to all queries, `content` to single-resource renders, `lists` to navigation/index queries. Host default is `refetchOnWindowFocus:false` (e.g. `{"lists":{"refetchOnWindowFocus":true}}`) | @@ -365,7 +365,7 @@ Scripts are fetched in parallel and awaited before the Web Host bundle is import ### `GET /api/public/facade/config` -Returns the full facade configuration as JSON (wippy-context-2.0 format). Used by `index.html` on load; see [Config Response](#config-response) below. +Returns the full facade configuration as JSON (wippy-context-2.1 format). Used by `index.html` on load; see [Config Response](#config-response) below. ### `GET /api/public/facade/variables.css` @@ -386,13 +386,13 @@ Returns an empty body (200 OK) when no variables are configured. Response has `C ## Config Response -`GET /api/public/facade/config` returns (wippy-context-2.0 format): +`GET /api/public/facade/config` returns (wippy-context-2.1 format): ```json { - "facade_url": "https://web-host.wippy.ai/webcomponents-1.0.59", + "facade_url": "https://web-host.wippy.ai/webcomponents-1.0.62", "iframe_origin": "https://web-host.wippy.ai", - "iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.59/iframe.html?waitForCustomConfig", + "iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.62/iframe.html?waitForCustomConfig", "login_path": "/login.html", "login_redirect_param": null, "env": { @@ -401,6 +401,9 @@ Returns an empty body (200 OK) when no variables are configured. Response has `C "APP_WEBSOCKET_URL": "ws://localhost:8085" }, "routePrefix": "http://localhost:8085", + "allowSelectModel": false, + "hideSessionSelector": false, + "allowAdditionalTags": {}, "theming": { "global": { "customCSS": "@import url('https://fonts.googleapis.com/css2?family=Poppins...');" @@ -414,11 +417,9 @@ Returns an empty body (200 OK) when no variables are configured. Response has `C "session": { "type": "non-persistent" }, "history": "hash", "showAdmin": true, - "allowSelectModel": false, "startNavOpen": false, "hideNavBar": false, - "disableRightPanel": false, - "hideSessionSelector": false + "disableRightPanel": false }, "extraScripts": null } @@ -434,10 +435,18 @@ fetch /api/public/facade/config → listen for 'authExpired' and 'error' events → redirect to login_path ``` -The backend returns config in wippy-context-2.0 shape. `index.html` only adds `$schema` (from `facade_url`), `auth` (from localStorage), and `context` (empty default). All other fields pass through from the backend unchanged. +The backend returns config in wippy-context-2.1 shape. `index.html` only adds `$schema` (from `facade_url`), `auth` (from localStorage), and `context` (empty default). All other fields pass through from the backend unchanged. If any step fails (config fetch, CDN import, missing `initWippyApp`), the page shows a themed `` screen with title and details. No external CSS is required — both `` and `` are self-contained web components with Shadow DOM styles. +## Shared chat policy + +The existing `allow_select_model`, `hide_session_selector`, and `allow_additional_tags` requirements produce top-level camelCase fields. The Jet shell and static shell both forward them to Web Host 1.0.62. Existing deployment requirement names stay valid. An empty allowlist is encoded as `{}`. + +`hostConfig` describes the Web Host wrapper. Child applications do not receive it. A custom parent may still supply these fields in `hostConfig` to override built-in host chat; the shared reader uses `hostConfig[key] ?? config[key] ?? default`. A child inherits the top-level policy and can replace it through `wippy.configOverrides`. Chat WC attributes override the policy for one instance. Managed layouts have no built-in chat by default, so shared application policy belongs at the root. + +Use the matching 1.0.62 frontend with this facade output. Older facade output remains supported by the new frontend, including its host-only fields. + ## Publishing ```bash diff --git a/src/facade/_index.yaml b/src/facade/_index.yaml index 8ff5580..2f1de08 100644 --- a/src/facade/_index.yaml +++ b/src/facade/_index.yaml @@ -39,7 +39,7 @@ entries: targets: - entry: wippy.facade:fe_facade_url path: .default - default: https://web-host.wippy.ai/webcomponents-1.0.59 + default: https://web-host.wippy.ai/webcomponents-1.0.62 - name: fe_entry_path kind: ns.requirement @@ -130,7 +130,7 @@ entries: - name: allow_select_model kind: ns.requirement meta: - description: Allow user to select LLM model + description: Shared allowSelectModel policy for built-in chat and child chat components targets: - entry: wippy.facade:allow_select_model path: .default @@ -326,7 +326,7 @@ entries: path: .default default: "" - # Additional hostConfig options (JSON) + # Additional facade configuration options - name: hide_session_selector kind: ns.requirement meta: diff --git a/src/facade/config_handler.lua b/src/facade/config_handler.lua index d83f379..0328208 100644 --- a/src/facade/config_handler.lua +++ b/src/facade/config_handler.lua @@ -145,11 +145,9 @@ local function handler() session = { type = non_empty_or_nil(get_req("session_type")) }, history = non_empty_or_nil(get_req("history_mode")), showAdmin = get_req("show_admin") ~= "false", - allowSelectModel = get_req("allow_select_model") == "true", startNavOpen = get_req("start_nav_open") == "true", hideNavBar = get_req("hide_nav_bar") == "true", disableRightPanel = get_req("disable_right_panel") == "true", - hideSessionSelector = get_req("hide_session_selector") == "true", renderEngine = render_engine, } @@ -168,8 +166,8 @@ local function handler() local attention = non_empty_map_or_nil(get_req_json_any("attention")) local additional_tags = non_empty_map_or_nil(get_req_json_any("allow_additional_tags")) - if additional_tags then - host_config.allowAdditionalTags = additional_tags + if not additional_tags then + additional_tags = table.create(0, 1) end local chat_config = non_empty_map_or_nil(get_req_json_any("chat")) @@ -233,6 +231,9 @@ local function handler() themeStorageKey = theme_storage_key, apiRoutes = api_routes, attention = attention, + allowSelectModel = get_req("allow_select_model") == "true", + hideSessionSelector = get_req("hide_session_selector") == "true", + allowAdditionalTags = additional_tags, axiosDefaults = axios_defaults, tanstack = tanstack, extraScripts = extra_scripts, diff --git a/src/facade/index.jet b/src/facade/index.jet index d0645e8..d6540cb 100644 --- a/src/facade/index.jet +++ b/src/facade/index.jet @@ -79,11 +79,11 @@ } var stored = localStorage.getItem(STORAGE_KEY); - if (!stored) { goToLogin(); throw new Error('no token'); } + if (!stored) { goToLogin(); return; } var token; try { token = JSON.parse(stored).token; } - catch (e) { goToLogin(); throw new Error('bad token'); } + catch (e) { goToLogin(); return; } if (Array.isArray(cfg.extraScripts) && cfg.extraScripts.length) { await Promise.all(cfg.extraScripts.map(function (entry) { @@ -153,9 +153,9 @@ throw new Error('initWippyApp not found'); } - // Backend returns config in wippy-context-2.0 shape — just add auth + schema + // Backend returns config in wippy-context-2.1 shape — just add auth + schema var events = window.initWippyApp({ - $schema: cfg.facade_url + '/schemas/wippy-context-2.0.xsd', + $schema: cfg.facade_url + '/schemas/wippy-context-2.1.json', auth: { token: token, expiresAt: new Date(Date.now() + 86400000).toISOString() }, env: cfg.env, routePrefix: cfg.routePrefix, @@ -164,6 +164,9 @@ themeMode: (window.wippyThemePersist && window.wippyThemePersist.read()) || cfg.themeMode, apiRoutes: cfg.apiRoutes, attention: cfg.attention, + allowSelectModel: cfg.allowSelectModel, + hideSessionSelector: cfg.hideSessionSelector, + allowAdditionalTags: cfg.allowAdditionalTags, axiosDefaults: cfg.axiosDefaults, theming: cfg.theming, hostConfig: cfg.hostConfig, diff --git a/src/facade/public/index.html b/src/facade/public/index.html index 8ecdd37..06cd990 100644 --- a/src/facade/public/index.html +++ b/src/facade/public/index.html @@ -74,11 +74,11 @@ } var stored = localStorage.getItem(STORAGE_KEY); - if (!stored) { goToLogin(); throw new Error('no token'); } + if (!stored) { goToLogin(); return; } var token; try { token = JSON.parse(stored).token; } - catch (e) { goToLogin(); throw new Error('bad token'); } + catch (e) { goToLogin(); return; } if (Array.isArray(cfg.extraScripts) && cfg.extraScripts.length) { await Promise.all(cfg.extraScripts.map(function (entry) { @@ -148,9 +148,9 @@ throw new Error('initWippyApp not found'); } - // Backend returns config in wippy-context-2.0 shape — just add auth + schema + // Backend returns config in wippy-context-2.1 shape — just add auth + schema var events = window.initWippyApp({ - $schema: cfg.facade_url + '/schemas/wippy-context-2.0.xsd', + $schema: cfg.facade_url + '/schemas/wippy-context-2.1.json', auth: { token: token, expiresAt: new Date(Date.now() + 86400000).toISOString() }, env: cfg.env, routePrefix: cfg.routePrefix, @@ -159,6 +159,9 @@ themeMode: (window.wippyThemePersist && window.wippyThemePersist.read()) || cfg.themeMode, apiRoutes: cfg.apiRoutes, attention: cfg.attention, + allowSelectModel: cfg.allowSelectModel, + hideSessionSelector: cfg.hideSessionSelector, + allowAdditionalTags: cfg.allowAdditionalTags, axiosDefaults: cfg.axiosDefaults, theming: cfg.theming, hostConfig: cfg.hostConfig, diff --git a/src/facade/test/config_dependency_test.lua b/src/facade/test/config_dependency_test.lua index ce77a38..6ac5382 100644 --- a/src/facade/test/config_dependency_test.lua +++ b/src/facade/test/config_dependency_test.lua @@ -9,7 +9,7 @@ local function run() local entry = registry.get(NS .. "fe_facade_url") test.not_nil(entry) test.not_nil(entry.data) - test.eq(entry.data.default, "https://web-host.wippy.ai/webcomponents-1.0.59") + test.eq(entry.data.default, "https://web-host.wippy.ai/webcomponents-1.0.62") entry = registry.get(NS .. "app_title") test.not_nil(entry) diff --git a/src/facade/test/config_handler_test.lua b/src/facade/test/config_handler_test.lua index 75c8bd2..0c9d102 100644 --- a/src/facade/test/config_handler_test.lua +++ b/src/facade/test/config_handler_test.lua @@ -24,7 +24,7 @@ local REQ_NAMES: {string} = { local function setup_registry(overrides: {[string]: string}?) local defaults: {[string]: string} = { - fe_facade_url = "https://web-host.wippy.ai/webcomponents-1.0.59", + fe_facade_url = "https://web-host.wippy.ai/webcomponents-1.0.62", fe_entry_path = "/iframe.html", fe_mode = "compat", render_engine = "iframe", @@ -186,7 +186,7 @@ local function define_tests() end) test.it("extracts iframe origin from facade URL", function() - local facade_url = "https://web-host.wippy.ai/webcomponents-1.0.59" + local facade_url = "https://web-host.wippy.ai/webcomponents-1.0.62" local origin = facade_url:match("^(https?://[^/]+)") test.eq(origin, "https://web-host.wippy.ai") @@ -264,7 +264,7 @@ local function define_tests() test.it("returns all default requirement values", function() local entry = registry.get(NS .. "fe_facade_url") - test.eq(entry.data.default, "https://web-host.wippy.ai/webcomponents-1.0.59") + test.eq(entry.data.default, "https://web-host.wippy.ai/webcomponents-1.0.62") entry = registry.get(NS .. "fe_entry_path") test.eq(entry.data.default, "/iframe.html") @@ -427,7 +427,7 @@ local function define_tests() end) end) - test.describe("config JSON structure (wippy-context-2.0)", function() + test.describe("config JSON structure (wippy-context-2.1)", function() test.it("forwards global attention config through both facade shells", function() local source_fs, fs_err = fs.get('app.facade_test:source') test.is_nil(fs_err) @@ -440,6 +440,10 @@ local function define_tests() test.not_nil(static_shell) test.not_nil((string.find(template, "attention: cfg.attention", 1, true))) test.not_nil((string.find(static_shell, "attention: cfg.attention", 1, true))) + for _, field in ipairs({ "allowSelectModel", "hideSessionSelector", "allowAdditionalTags" }) do + test.not_nil((string.find(template, field .. ": cfg." .. field, 1, true))) + test.not_nil((string.find(static_shell, field .. ": cfg." .. field, 1, true))) + end end) test.it("builds complete config object", function() @@ -454,6 +458,9 @@ local function define_tests() APP_WEBSOCKET_URL = "ws://localhost:8085", }, routePrefix = "http://localhost:8085", + allowSelectModel = false, + hideSessionSelector = false, + allowAdditionalTags = table.create(0, 1), attention = { enabled = true, messageContext = { enabled = true, defaultInclude = false }, @@ -481,7 +488,6 @@ local function define_tests() session = { type = "non-persistent" }, history = "hash", showAdmin = true, - allowSelectModel = false, startNavOpen = false, hideNavBar = false, disableRightPanel = false, @@ -503,13 +509,19 @@ local function define_tests() test.is_nil(decoded.hostConfig.attention) test.eq(decoded.hostConfig.session.type, "non-persistent") test.is_true(decoded.hostConfig.showAdmin) - test.is_false(decoded.hostConfig.allowSelectModel) + test.is_false(decoded.allowSelectModel) + test.is_false(decoded.hideSessionSelector) + test.not_nil(decoded.allowAdditionalTags) + test.eq(next(decoded.allowAdditionalTags), nil) + test.is_nil(decoded.hostConfig.allowSelectModel) + test.is_nil(decoded.hostConfig.allowAdditionalTags) test.eq(decoded.theming.host.i18n.app.title, "Wippy") test.eq(decoded.themeMode, "auto") test.eq(decoded.themePersist, "cookie") test.eq(decoded.themeStorageKey, "@wippy-theme-mode") test.eq(decoded.login_path, "/login.html") end) + end) end) end From f6692104d0cd619c1db5507237b23b66d103fdc1 Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Fri, 9 Oct 2026 16:19:13 -0400 Subject: [PATCH 2/2] fix(facade): preserve shared policies for older hosts --- src/facade/README.md | 7 +- src/facade/_index.yaml | 13 ++ src/facade/config_handler.lua | 43 ++++- src/facade/test/_index.yaml | 15 ++ src/facade/test/config_handler_test.lua | 12 +- src/facade/test/policy_compatibility_test.lua | 169 ++++++++++++++++++ 6 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 src/facade/test/policy_compatibility_test.lua diff --git a/src/facade/README.md b/src/facade/README.md index 45e4c33..4b5c263 100644 --- a/src/facade/README.md +++ b/src/facade/README.md @@ -79,6 +79,7 @@ These fields are NOT configurable via requirements — they are computed at runt | Requirement | Default | Description | |---|---|---| | `fe_facade_url` | `https://web-host.wippy.ai/webcomponents-1.0.62` | CDN base URL for the Web Host frontend bundle | +| `host_policy_mode` | `auto` | `auto` detects official CDN versions and preserves legacy policy for unknown hosts; `legacy` mirrors policy into `hostConfig`; `shared` keeps root-only policy for custom modern hosts. Invalid values fail the config endpoint. | | `fe_entry_path` | `/iframe.html` | Iframe HTML entry point path (appended to `fe_facade_url`) | | `fe_mode` | `compat` | `compat` (default — loads `module.js`) or `managed` (loads `managed-layout.js` for declarative multi-panel apps). See [Modes](#modes) above | | `render_engine` | `iframe` | Global page render engine for packaged `view.page` micro-frontends: `iframe` (default — legacy srcdoc iframe) or `fragment` (Web Fragment / reframed realm reflected into a shadow root). `fragment` **requires** the `wippy/views` fragment gateway and a fragment-capable host bundle serving `/@wippy-fe/proxy-fragment.js`. Flows to the host as `hostConfig.renderEngine`; the child app is engine-agnostic (same package renders under either engine). | @@ -441,11 +442,13 @@ If any step fails (config fetch, CDN import, missing `initWippyApp`), the page s ## Shared chat policy -The existing `allow_select_model`, `hide_session_selector`, and `allow_additional_tags` requirements produce top-level camelCase fields. The Jet shell and static shell both forward them to Web Host 1.0.62. Existing deployment requirement names stay valid. An empty allowlist is encoded as `{}`. +The existing `allow_select_model`, `hide_session_selector`, and `allow_additional_tags` requirements produce top-level camelCase fields. With `host_policy_mode = auto`, official CDN Host versions before 1.0.62 also receive the same resolved values in `hostConfig`. Unknown and private URLs receive this legacy mirror too, preserving existing deployments. Official CDN Host 1.0.62 and newer keep the fields only at the root so live root policy updates remain effective. Both shells forward the supplied scopes. Existing deployment requirement names stay valid. An empty allowlist is encoded as `{}`. + +Set `host_policy_mode = shared` when a custom URL serves Host 1.0.62 or newer. Set `legacy` to force the mirror regardless of URL. The automatic detector accepts only the exact `https://web-host.wippy.ai/webcomponents-MAJOR.MINOR.PATCH` URL, with an optional trailing slash. Invalid modes return HTTP 500 with a config error naming the accepted values. `hostConfig` describes the Web Host wrapper. Child applications do not receive it. A custom parent may still supply these fields in `hostConfig` to override built-in host chat; the shared reader uses `hostConfig[key] ?? config[key] ?? default`. A child inherits the top-level policy and can replace it through `wippy.configOverrides`. Chat WC attributes override the policy for one instance. Managed layouts have no built-in chat by default, so shared application policy belongs at the root. -Use the matching 1.0.62 frontend with this facade output. Older facade output remains supported by the new frontend, including its host-only fields. +Web Host 1.0.62 uses the root policy for child chat components. Earlier hosts, including 1.0.61, use the mirrored `hostConfig` policy for built-in chat. Older facade output remains supported by the new frontend, including its host-only fields. ## Publishing diff --git a/src/facade/_index.yaml b/src/facade/_index.yaml index 2f1de08..4a6436a 100644 --- a/src/facade/_index.yaml +++ b/src/facade/_index.yaml @@ -41,6 +41,19 @@ entries: path: .default default: https://web-host.wippy.ai/webcomponents-1.0.62 + - name: host_policy_mode + kind: ns.requirement + meta: + description: | + Shared chat policy compatibility. auto uses root-only policy for + official CDN Host 1.0.62 and newer, and mirrors policy in hostConfig + for older or unknown hosts. shared opts custom modern hosts into + root-only policy; legacy forces the mirror. Invalid modes fail config. + targets: + - entry: wippy.facade:host_policy_mode + path: .default + default: auto + - name: fe_entry_path kind: ns.requirement meta: diff --git a/src/facade/config_handler.lua b/src/facade/config_handler.lua index 0328208..d9c4cb2 100644 --- a/src/facade/config_handler.lua +++ b/src/facade/config_handler.lua @@ -31,6 +31,29 @@ local function non_empty_array_or_nil(a: any): {any}? return a :: {any} end +local function needs_legacy_policy(facade_url: string, mode: string): (boolean?, string?) + if mode == "legacy" then + return true, nil + end + if mode == "shared" then + return false, nil + end + if mode ~= "auto" then + return nil, "invalid host_policy_mode: expected auto, legacy or shared" + end + local major, minor, patch = facade_url:match("^https://web%-host%.wippy%.ai/webcomponents%-(%d+)%.(%d+)%.(%d+)/?$") + if not major then + -- Private and unversioned hosts may still use the legacy policy shape. + return true, nil + end + local major_version = tonumber(major) or 0 + local minor_version = tonumber(minor) or 0 + local patch_version = tonumber(patch) or 0 + local shared = major_version > 1 + or (major_version == 1 and (minor_version > 0 or patch_version >= 62)) + return not shared, nil +end + local function handler() local res = http.response() if not res then @@ -76,6 +99,13 @@ local function handler() end local facade_url = get_req("fe_facade_url") + local legacy_policy, policy_err = needs_legacy_policy(facade_url, get_req("host_policy_mode")) + if policy_err then + res:set_status(http.STATUS.INTERNAL_SERVER_ERROR) + res:set_content_type(http.CONTENT.JSON) + res:write('{"error":"invalid host_policy_mode: expected auto, legacy or shared"}') + return nil, policy_err + end local entry_path = get_req("fe_entry_path") local fe_mode = get_req("fe_mode") @@ -141,6 +171,8 @@ local function handler() }, } + local allow_select_model = get_req("allow_select_model") == "true" + local hide_session_selector = get_req("hide_session_selector") == "true" local host_config: {[string]: any} = { session = { type = non_empty_or_nil(get_req("session_type")) }, history = non_empty_or_nil(get_req("history_mode")), @@ -169,6 +201,13 @@ local function handler() if not additional_tags then additional_tags = table.create(0, 1) end + -- Legacy hosts read policy only from hostConfig. Shared mode keeps policy + -- at the root so later root updates are not shadowed by a legacy mirror. + if legacy_policy then + host_config.allowSelectModel = allow_select_model + host_config.hideSessionSelector = hide_session_selector + host_config.allowAdditionalTags = additional_tags + end local chat_config = non_empty_map_or_nil(get_req_json_any("chat")) if chat_config then @@ -231,8 +270,8 @@ local function handler() themeStorageKey = theme_storage_key, apiRoutes = api_routes, attention = attention, - allowSelectModel = get_req("allow_select_model") == "true", - hideSessionSelector = get_req("hide_session_selector") == "true", + allowSelectModel = allow_select_model, + hideSessionSelector = hide_session_selector, allowAdditionalTags = additional_tags, axiosDefaults = axios_defaults, tanstack = tanstack, diff --git a/src/facade/test/_index.yaml b/src/facade/test/_index.yaml index fd6c120..a7749bd 100644 --- a/src/facade/test/_index.yaml +++ b/src/facade/test/_index.yaml @@ -64,6 +64,21 @@ entries: - registry # Tests + - name: policy_compatibility_test + kind: function.lua + meta: + type: test + suite: facade + comment: Real config endpoint policy compatibility for older and newer hosts + source: file://policy_compatibility_test.lua + method: run + imports: + test: wippy.test:test + modules: + - http_client + - json + - registry + - name: config_handler_test kind: function.lua meta: diff --git a/src/facade/test/config_handler_test.lua b/src/facade/test/config_handler_test.lua index 0c9d102..c457379 100644 --- a/src/facade/test/config_handler_test.lua +++ b/src/facade/test/config_handler_test.lua @@ -6,7 +6,7 @@ local fs = require("fs") local NS = "wippy.facade:" local REQ_NAMES: {string} = { - "fe_facade_url", "fe_entry_path", "fe_mode", "render_engine", "session_type", + "fe_facade_url", "host_policy_mode", "fe_entry_path", "fe_mode", "render_engine", "session_type", "history_mode", "show_admin", "start_nav_open", "allow_select_model", "hide_nav_bar", "disable_right_panel", "hide_session_selector", "custom_css", "css_variables", "icon_sets", @@ -25,6 +25,7 @@ local REQ_NAMES: {string} = { local function setup_registry(overrides: {[string]: string}?) local defaults: {[string]: string} = { fe_facade_url = "https://web-host.wippy.ai/webcomponents-1.0.62", + host_policy_mode = "auto", fe_entry_path = "/iframe.html", fe_mode = "compat", render_engine = "iframe", @@ -448,9 +449,9 @@ local function define_tests() test.it("builds complete config object", function() local config = { - facade_url = "https://front.wippy.ai", - iframe_origin = "https://front.wippy.ai", - iframe_url = "https://front.wippy.ai/iframe.html?waitForCustomConfig", + facade_url = "https://web-host.wippy.ai/webcomponents-1.0.62", + iframe_origin = "https://web-host.wippy.ai", + iframe_url = "https://web-host.wippy.ai/webcomponents-1.0.62/iframe.html?waitForCustomConfig", login_path = "/login.html", env = { APP_API_URL = "http://localhost:8085", @@ -500,7 +501,7 @@ local function define_tests() local decoded, derr = json.decode(body) test.is_nil(derr) - test.eq(decoded.facade_url, "https://front.wippy.ai") + test.eq(decoded.facade_url, "https://web-host.wippy.ai/webcomponents-1.0.62") test.eq(decoded.env.APP_API_URL, "http://localhost:8085") test.eq(decoded.env.APP_WEBSOCKET_URL, "ws://localhost:8085") test.is_true(decoded.attention.enabled) @@ -514,6 +515,7 @@ local function define_tests() test.not_nil(decoded.allowAdditionalTags) test.eq(next(decoded.allowAdditionalTags), nil) test.is_nil(decoded.hostConfig.allowSelectModel) + test.is_nil(decoded.hostConfig.hideSessionSelector) test.is_nil(decoded.hostConfig.allowAdditionalTags) test.eq(decoded.theming.host.i18n.app.title, "Wippy") test.eq(decoded.themeMode, "auto") diff --git a/src/facade/test/policy_compatibility_test.lua b/src/facade/test/policy_compatibility_test.lua new file mode 100644 index 0000000..cd466ee --- /dev/null +++ b/src/facade/test/policy_compatibility_test.lua @@ -0,0 +1,169 @@ +local test = require("test") +local http_client = require("http_client") +local json = require("json") +local registry = require("registry") + +local NS = "wippy.facade:" +local REQUIREMENTS = { + "fe_facade_url", "host_policy_mode", "allow_select_model", "hide_session_selector", "allow_additional_tags", +} + +local function set_requirements(values) + local changes = registry.snapshot():changes() + for name, value in pairs(values) do + changes:update({ id = NS .. name, kind = "ns.requirement", data = { default = value } }) + end + local _, err = changes:apply() + test.is_nil(err) +end + +local function request_config() + local gateway = registry.get("app:gateway") + test.not_nil(gateway) + local response, err = http_client.get("http://127.0.0.1" .. gateway.data.addr .. "/api/public/facade/config", { timeout = 5 }) + test.is_nil(err) + test.not_nil(response) + return response +end + +local function fetch_config() + local response = request_config() + test.eq(response.status_code, 200) + local body = response.body or "" + local config, decode_err = json.decode(body) + test.is_nil(decode_err) + return config, body +end + +local function define_tests() + test.describe("config endpoint policy compatibility", function() + local saved = {} + + test.before_each(function() + saved = {} + for _, name in ipairs(REQUIREMENTS) do + saved[name] = registry.get(NS .. name).data.default + end + end) + + test.after_each(function() + set_requirements(saved) + end) + + test.it("keeps model restrictions and nondefault policy for a Host 1.0.61 override", function() + set_requirements({ + fe_facade_url = "https://web-host.wippy.ai/webcomponents-1.0.61", + allow_select_model = "false", + hide_session_selector = "true", + allow_additional_tags = '{"w-chart":["data","type"]}', + }) + local config = fetch_config() + test.eq(config.facade_url, "https://web-host.wippy.ai/webcomponents-1.0.61") + test.is_false(config.allowSelectModel) + test.is_false(config.hostConfig.allowSelectModel) + test.is_true(config.hideSessionSelector) + test.is_true(config.hostConfig.hideSessionSelector) + test.eq(config.allowAdditionalTags["w-chart"][1], "data") + test.eq(config.allowAdditionalTags["w-chart"][2], "type") + local root_tags, root_err = json.encode(config.allowAdditionalTags) + local host_tags, host_err = json.encode(config.hostConfig.allowAdditionalTags) + test.is_nil(root_err) + test.is_nil(host_err) + test.eq(host_tags, root_tags) + end) + + test.it("exposes enabled root policy and an empty object allowlist for Host 1.0.62", function() + set_requirements({ + fe_facade_url = "https://web-host.wippy.ai/webcomponents-1.0.62", + allow_select_model = "true", + hide_session_selector = "false", + allow_additional_tags = "{}", + }) + local config, body = fetch_config() + test.is_true(config.allowSelectModel) + test.is_nil(config.hostConfig.allowSelectModel) + test.is_false(config.hideSessionSelector) + test.is_nil(config.hostConfig.hideSessionSelector) + test.not_nil(config.allowAdditionalTags) + test.is_nil(config.hostConfig.allowAdditionalTags) + test.is_nil(next(config.allowAdditionalTags)) + -- The root must encode an empty map as an object, never a JSON array. + local _, count = body:gsub('"allowAdditionalTags"%s*:%s*{}', "") + test.eq(count, 1) + end) + + test.it("recognizes newer official CDN versions without mirroring", function() + for _, url in ipairs({ + "https://web-host.wippy.ai/webcomponents-1.0.63", + "https://web-host.wippy.ai/webcomponents-1.1.0/", + "https://web-host.wippy.ai/webcomponents-2.0.0", + }) do + set_requirements({ fe_facade_url = url, allow_select_model = "true" }) + local config = fetch_config() + test.is_true(config.allowSelectModel) + test.is_nil(config.hostConfig.allowSelectModel) + test.is_nil(config.hostConfig.hideSessionSelector) + test.is_nil(config.hostConfig.allowAdditionalTags) + end + end) + + test.it("preserves legacy policy for private and unrecognized hosts in auto mode", function() + for _, url in ipairs({ + "https://private.example/host", + "https://custom.example/webcomponents-1.0.62", + "https://web-host.wippy.ai/unrelated/webcomponents-1.0.62", + }) do + set_requirements({ + fe_facade_url = url, host_policy_mode = "auto", allow_select_model = "false", + hide_session_selector = "true", allow_additional_tags = '{"w-chart":["data"]}', + }) + local config = fetch_config() + test.is_false(config.hostConfig.allowSelectModel) + test.is_true(config.hostConfig.hideSessionSelector) + test.eq(config.hostConfig.allowAdditionalTags["w-chart"][1], "data") + test.eq(config.allowAdditionalTags["w-chart"][1], "data") + end + end) + + test.it("lets custom modern hosts opt into shared root-only policy", function() + set_requirements({ + fe_facade_url = "https://private.example/host", host_policy_mode = "shared", + allow_select_model = "true", hide_session_selector = "false", allow_additional_tags = "{}", + }) + local config = fetch_config() + test.is_true(config.allowSelectModel) + test.is_false(config.hideSessionSelector) + test.is_nil(config.hostConfig.allowSelectModel) + test.is_nil(config.hostConfig.hideSessionSelector) + test.is_nil(config.hostConfig.allowAdditionalTags) + end) + + test.it("allows an explicit legacy mirror even for a modern CDN host", function() + set_requirements({ + fe_facade_url = "https://web-host.wippy.ai/webcomponents-1.0.62", host_policy_mode = "legacy", + allow_select_model = "false", hide_session_selector = "true", allow_additional_tags = "{}", + }) + local config = fetch_config() + test.is_false(config.hostConfig.allowSelectModel) + test.is_true(config.hostConfig.hideSessionSelector) + test.not_nil(config.hostConfig.allowAdditionalTags) + end) + + test.it("rejects an invalid policy mode with an actionable config error", function() + set_requirements({ host_policy_mode = "shraed" }) + local response = request_config() + test.eq(response.status_code, 500) + local body, err = json.decode(response.body or "") + test.is_nil(err) + test.eq(body.error, "invalid host_policy_mode: expected auto, legacy or shared") + end) + end) +end + +local run_cases = test.run_cases(define_tests) + +local function run(options) + return run_cases(options) +end + +return { run = run }