diff --git a/src/embeddings/embedding_repo_test.lua b/src/embeddings/embedding_repo_test.lua index 90f0190..02b9c20 100644 --- a/src/embeddings/embedding_repo_test.lua +++ b/src/embeddings/embedding_repo_test.lua @@ -254,7 +254,7 @@ local function define_tests() test.is_nil(err) test.ok(#results > 0) - local entry_id = results[1].entry_id + local entry_id = (results :: any)[1].entry_id local delete_result, del_err = embedding_repo.delete_by_entry(entry_id) test.is_nil(del_err) diff --git a/src/facade/Makefile b/src/facade/Makefile index b2331c2..8280fac 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.21 +WEB_HOST_CDN = https://web-host.wippy.ai/webcomponents-1.0.23 # 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 6e76be5..94f63a1 100644 --- a/src/facade/README.md +++ b/src/facade/README.md @@ -47,7 +47,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.21` | CDN base URL for the Web Host frontend bundle | +| `fe_facade_url` | `https://web-host.wippy.ai/webcomponents-1.0.23` | CDN base URL for the Web Host frontend bundle | | `fe_entry_path` | `/iframe.html` | Iframe HTML entry point path (appended to `fe_facade_url`) | ### App Identity @@ -189,9 +189,9 @@ Only override what differs from defaults. ```json { - "facade_url": "https://web-host.wippy.ai/webcomponents-1.0.21", + "facade_url": "https://web-host.wippy.ai/webcomponents-1.0.23", "iframe_origin": "https://web-host.wippy.ai", - "iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.21/iframe.html?waitForCustomConfig", + "iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.23/iframe.html?waitForCustomConfig", "login_path": "/login.html", "env": { "APP_API_URL": "http://localhost:8085", diff --git a/src/facade/_index.yaml b/src/facade/_index.yaml index 36a15bb..57b3cce 100644 --- a/src/facade/_index.yaml +++ b/src/facade/_index.yaml @@ -33,7 +33,7 @@ entries: targets: - entry: wippy.facade:fe_facade_url path: .default - default: https://web-host.wippy.ai/webcomponents-1.0.21 + default: https://web-host.wippy.ai/webcomponents-1.0.23 - name: fe_entry_path kind: ns.requirement diff --git a/src/facade/config_handler.lua b/src/facade/config_handler.lua index da84fb6..b9973cd 100644 --- a/src/facade/config_handler.lua +++ b/src/facade/config_handler.lua @@ -136,9 +136,6 @@ local function handler() } local api_routes = non_empty_map_or_nil(get_req_json_any("api_routes")) - if api_routes then - host_config.apiRoutes = api_routes - end local additional_nav = non_empty_map_or_nil(get_req_json_any("additional_nav_items")) if additional_nav then @@ -174,6 +171,7 @@ local function handler() APP_WEBSOCKET_URL = ws_url, }, routePrefix = non_empty_or_nil(api_url), + apiRoutes = api_routes, axiosDefaults = axios_defaults, theming = { global = global_scope, diff --git a/src/facade/config_handler_test.lua b/src/facade/config_handler_test.lua index e157663..e719b6c 100644 --- a/src/facade/config_handler_test.lua +++ b/src/facade/config_handler_test.lua @@ -112,7 +112,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.21" + local facade_url = "https://web-host.wippy.ai/webcomponents-1.0.23" local origin = facade_url:match("^(https?://[^/]+)") test.eq(origin, "https://web-host.wippy.ai") diff --git a/src/facade/public/@wippy-fe/loading.js b/src/facade/public/@wippy-fe/loading.js index a2ec20a..f40c151 100644 --- a/src/facade/public/@wippy-fe/loading.js +++ b/src/facade/public/@wippy-fe/loading.js @@ -1,4 +1,4 @@ -var WippyLoading=function(t){"use strict";var y=Object.defineProperty;var w=(t,i,o)=>i in t?y(t,i,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[i]=o;var n=(t,i,o)=>w(t,typeof i!="symbol"?i+"":i,o);const p={circle:'',triangle:'',sad:''},f=` +var WippyLoading=(function(t){"use strict";var y=Object.defineProperty;var w=(t,i,o)=>i in t?y(t,i,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[i]=o;var n=(t,i,o)=>w(t,typeof i!="symbol"?i+"":i,o);const p={circle:'',triangle:'',sad:''},f=` *, *::before, *::after { box-sizing: border-box; } :host { @@ -14,6 +14,8 @@ var WippyLoading=function(t){"use strict";var y=Object.defineProperty;var w=(t,i height: 100dvh; } + :host([no-bg]) .container { background: transparent; } + .container { display: flex; flex-direction: column; @@ -83,7 +85,7 @@ var WippyLoading=function(t){"use strict";var y=Object.defineProperty;var w=(t,i color: var(--p-text-muted-color, #a1a1aa); } } -`;class s extends HTMLElement{constructor(){super(...arguments);n(this,"_iconEl",null);n(this,"_titleEl",null);n(this,"_messageEl",null)}connectedCallback(){const e=this.shadowRoot??this.attachShadow({mode:"open"});e.textContent="";const l=document.createElement("style");l.textContent=f,e.appendChild(l);const r=document.createElement("div");r.className="container",this._iconEl=document.createElement("div"),this._iconEl.className="icon",this._iconEl.setAttribute("part","icon"),this._titleEl=document.createElement("div"),this._titleEl.className="title",this._titleEl.setAttribute("part","title"),this._messageEl=document.createElement("div"),this._messageEl.className="message",this._messageEl.setAttribute("part","message"),this._update(),r.append(this._iconEl,this._titleEl,this._messageEl),e.appendChild(r)}disconnectedCallback(){this._iconEl=null,this._titleEl=null,this._messageEl=null}attributeChangedCallback(){this._update()}_update(){if(this._iconEl){const e=this.getAttribute("icon")??"circle";this._iconEl.innerHTML=p[e]??p.circle}this._titleEl&&(this._titleEl.textContent=this.getAttribute("title")??"Something went wrong"),this._messageEl&&(this._messageEl.textContent=this.getAttribute("message")??"")}}n(s,"observedAttributes",["title","message","icon","severity"]);const m=3e3,g=2e3,E=` +`;class l extends HTMLElement{constructor(){super(...arguments);n(this,"_iconEl",null);n(this,"_titleEl",null);n(this,"_messageEl",null)}connectedCallback(){const e=this.shadowRoot??this.attachShadow({mode:"open"});e.textContent="";const s=document.createElement("style");s.textContent=f,e.appendChild(s);const r=document.createElement("div");r.className="container",this._iconEl=document.createElement("div"),this._iconEl.className="icon",this._iconEl.setAttribute("part","icon"),this._titleEl=document.createElement("div"),this._titleEl.className="title",this._titleEl.setAttribute("part","title"),this._messageEl=document.createElement("div"),this._messageEl.className="message",this._messageEl.setAttribute("part","message"),this._update(),r.append(this._iconEl,this._titleEl,this._messageEl),e.appendChild(r)}disconnectedCallback(){this._iconEl=null,this._titleEl=null,this._messageEl=null}attributeChangedCallback(){this._update()}_update(){if(this._iconEl){const e=this.getAttribute("icon")??"circle";this._iconEl.innerHTML=p[e]??p.circle}this._titleEl&&(this._titleEl.textContent=this.getAttribute("title")??"Something went wrong"),this._messageEl&&(this._messageEl.textContent=this.getAttribute("message")??"")}}n(l,"observedAttributes",["title","message","icon","severity"]);const m=3e3,g=2e3,E=` *, *::before, *::after { box-sizing: border-box; } :host { @@ -191,4 +193,5 @@ var WippyLoading=function(t){"use strict";var y=Object.defineProperty;var w=(t,i from { transform: translate(-50%, -50%) rotate(0deg); } to { transform: translate(-50%, -50%) rotate(-360deg); } } -`;class a extends HTMLElement{constructor(){super(...arguments);n(this,"_titleEl",null);n(this,"_subtitleEl",null)}connectedCallback(){const e=this.shadowRoot??this.attachShadow({mode:"open"});e.textContent="";const l=document.createElement("style");l.textContent=E,e.appendChild(l);const r=document.createElement("div");r.className="container";const c=document.createElement("div");c.className="spinner";const d=document.createElement("div");d.className="ring outer";const h=document.createElement("div");h.className="ring inner",c.append(d,h),this._titleEl=document.createElement("div"),this._titleEl.className="title",this._titleEl.setAttribute("part","title"),this._subtitleEl=document.createElement("div"),this._subtitleEl.className="subtitle",this._subtitleEl.setAttribute("part","subtitle"),this._updateText(),r.append(c,this._titleEl,this._subtitleEl),e.appendChild(r);const b=Date.now();d.style.animationDelay=`-${b%m}ms`,h.style.animationDelay=`-${b%g}ms`}disconnectedCallback(){this._titleEl=null,this._subtitleEl=null}attributeChangedCallback(){this._updateText()}_updateText(){this._titleEl&&(this._titleEl.textContent=this.getAttribute("title")??""),this._subtitleEl&&(this._subtitleEl.textContent=this.getAttribute("subtitle")??"")}}n(a,"observedAttributes",["title","subtitle"]);function u(){customElements.get("wippy-loading")||customElements.define("wippy-loading",a),customElements.get("wippy-error")||customElements.define("wippy-error",s)}return u(),t.WippyErrorElement=s,t.WippyLoadingElement=a,t.register=u,Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t}({}); +`;class a extends HTMLElement{constructor(){super(...arguments);n(this,"_titleEl",null);n(this,"_subtitleEl",null)}connectedCallback(){const e=this.shadowRoot??this.attachShadow({mode:"open"});e.textContent="";const s=document.createElement("style");s.textContent=E,e.appendChild(s);const r=document.createElement("div");r.className="container";const c=document.createElement("div");c.className="spinner";const d=document.createElement("div");d.className="ring outer";const h=document.createElement("div");h.className="ring inner",c.append(d,h),this._titleEl=document.createElement("div"),this._titleEl.className="title",this._titleEl.setAttribute("part","title"),this._subtitleEl=document.createElement("div"),this._subtitleEl.className="subtitle",this._subtitleEl.setAttribute("part","subtitle"),this._updateText(),r.append(c,this._titleEl,this._subtitleEl),e.appendChild(r);const b=Date.now();d.style.animationDelay=`-${b%m}ms`,h.style.animationDelay=`-${b%g}ms`}disconnectedCallback(){this._titleEl=null,this._subtitleEl=null}attributeChangedCallback(){this._updateText()}_updateText(){this._titleEl&&(this._titleEl.textContent=this.getAttribute("title")??""),this._subtitleEl&&(this._subtitleEl.textContent=this.getAttribute("subtitle")??"")}}n(a,"observedAttributes",["title","subtitle"]);function u(){customElements.get("wippy-loading")||customElements.define("wippy-loading",a),customElements.get("wippy-error")||customElements.define("wippy-error",l)}return u(),t.WippyErrorElement=l,t.WippyLoadingElement=a,t.register=u,Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t})({}); +//# sourceMappingURL=loading.js.map diff --git a/src/facade/public/@wippy-fe/loading.js.map b/src/facade/public/@wippy-fe/loading.js.map new file mode 100644 index 0000000..f3c8cde --- /dev/null +++ b/src/facade/public/@wippy-fe/loading.js.map @@ -0,0 +1 @@ +{"version":3,"file":"loading.js","sources":["../../npm/@wippy-fe--loading/src/wippy-error.ts","../../npm/@wippy-fe--loading/src/wippy-loading.ts","../../npm/@wippy-fe--loading/src/index.ts"],"sourcesContent":["const ICON_CIRCLE = ''\n\nconst ICON_TRIANGLE = ''\n\nconst ICON_SAD = ''\n\nconst ICONS: Record = {\n circle: ICON_CIRCLE,\n triangle: ICON_TRIANGLE,\n sad: ICON_SAD,\n}\n\nconst STYLES = `\n *, *::before, *::after { box-sizing: border-box; }\n\n :host {\n display: block;\n width: 100%;\n height: 100%;\n min-height: 120px;\n overflow: hidden;\n }\n\n :host(:only-child) {\n height: 100vh;\n height: 100dvh;\n }\n\n :host([no-bg]) .container { background: transparent; }\n\n .container {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n width: 100%;\n height: 100%;\n background: var(--p-surface-0, #fff);\n font-family: system-ui, -apple-system, sans-serif;\n }\n\n .icon {\n width: 48px;\n height: 48px;\n margin-bottom: 16px;\n }\n\n .icon svg {\n width: 100%;\n height: 100%;\n }\n\n :host([severity=\"warning\"]) .icon,\n :host([severity=\"warning\"]) .title {\n color: var(--p-warn-500, rgb(249, 115, 22));\n }\n\n :host(:not([severity=\"warning\"])) .icon,\n :host(:not([severity=\"warning\"])) .title {\n color: var(--p-danger-500, rgb(239, 68, 68));\n }\n\n .title {\n font-size: 16px;\n font-weight: 600;\n text-align: center;\n padding: 0 16px;\n }\n\n .title:empty { display: none; }\n\n .message {\n font-size: 13px;\n color: var(--p-text-muted-color, #71717a);\n margin-top: 6px;\n text-align: center;\n padding: 0 16px;\n max-width: 480px;\n line-height: 1.4;\n }\n\n .message:empty { display: none; }\n\n @media (prefers-color-scheme: dark) {\n .container {\n background: var(--p-surface-900, #1c1a19);\n }\n :host([severity=\"warning\"]) .icon,\n :host([severity=\"warning\"]) .title {\n color: var(--p-warn-400, color-mix(in srgb, rgb(249, 115, 22) 40%, white));\n }\n :host(:not([severity=\"warning\"])) .icon,\n :host(:not([severity=\"warning\"])) .title {\n color: var(--p-danger-400, color-mix(in srgb, rgb(239, 68, 68) 40%, white));\n }\n .message {\n color: var(--p-text-muted-color, #a1a1aa);\n }\n }\n`\n\nexport class WippyErrorElement extends HTMLElement {\n static observedAttributes = ['title', 'message', 'icon', 'severity']\n\n private _iconEl: HTMLElement | null = null\n private _titleEl: HTMLElement | null = null\n private _messageEl: HTMLElement | null = null\n\n connectedCallback() {\n const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open' })\n shadow.textContent = ''\n\n const style = document.createElement('style')\n style.textContent = STYLES\n shadow.appendChild(style)\n\n const container = document.createElement('div')\n container.className = 'container'\n\n this._iconEl = document.createElement('div')\n this._iconEl.className = 'icon'\n this._iconEl.setAttribute('part', 'icon')\n\n this._titleEl = document.createElement('div')\n this._titleEl.className = 'title'\n this._titleEl.setAttribute('part', 'title')\n\n this._messageEl = document.createElement('div')\n this._messageEl.className = 'message'\n this._messageEl.setAttribute('part', 'message')\n\n this._update()\n\n container.append(this._iconEl, this._titleEl, this._messageEl)\n shadow.appendChild(container)\n }\n\n disconnectedCallback() {\n this._iconEl = null\n this._titleEl = null\n this._messageEl = null\n }\n\n attributeChangedCallback() {\n this._update()\n }\n\n private _update() {\n if (this._iconEl) {\n const iconKey = this.getAttribute('icon') ?? 'circle'\n this._iconEl.innerHTML = ICONS[iconKey] ?? ICONS.circle\n }\n if (this._titleEl)\n this._titleEl.textContent = this.getAttribute('title') ?? 'Something went wrong'\n if (this._messageEl)\n this._messageEl.textContent = this.getAttribute('message') ?? ''\n }\n}\n","const OUTER_DURATION = 3000\nconst INNER_DURATION = 2000\n\nconst STYLES = `\n *, *::before, *::after { box-sizing: border-box; }\n\n :host {\n display: block;\n width: 100%;\n height: 100%;\n min-height: 120px;\n overflow: hidden;\n }\n\n :host(:only-child) {\n height: 100vh;\n height: 100dvh;\n }\n\n :host([no-bg]) .container { background: transparent; }\n\n .container {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n width: 100%;\n height: 100%;\n background: var(--p-surface-0, #fff);\n font-family: system-ui, -apple-system, sans-serif;\n }\n\n .spinner {\n position: relative;\n width: 48px;\n height: 48px;\n margin-bottom: 16px;\n }\n\n .ring {\n position: absolute;\n border-radius: 50%;\n box-sizing: border-box;\n }\n\n .ring.outer {\n width: 48px;\n height: 48px;\n border: 3px solid var(--p-surface-300, #d4d4d8);\n animation: spin-outer ${OUTER_DURATION}ms linear infinite;\n }\n\n .ring.inner {\n width: 56px;\n height: 56px;\n top: 50%;\n left: 50%;\n border: 3px solid transparent;\n border-top-color: var(--p-primary, rgb(0, 95, 178));\n border-bottom-color: var(--p-primary, rgb(0, 95, 178));\n animation: spin-inner ${INNER_DURATION}ms linear infinite;\n }\n\n .title {\n font-size: 14px;\n font-weight: 500;\n color: var(--p-text-color, #3f3f46);\n text-align: center;\n padding: 0 16px;\n }\n\n .title:empty { display: none; }\n\n .subtitle {\n font-size: 12px;\n color: var(--p-text-muted-color, #71717a);\n margin-top: 4px;\n text-align: center;\n padding: 0 16px;\n }\n\n .subtitle:empty { display: none; }\n\n @media (prefers-color-scheme: dark) {\n .container {\n background: var(--p-surface-900, #1c1a19);\n }\n .ring.outer {\n border-color: var(--p-surface-500, #71717a);\n }\n .ring.inner {\n border-top-color: var(--p-primary, rgb(0, 125, 178));\n border-bottom-color: var(--p-primary, rgb(0, 125, 178));\n }\n .title {\n color: var(--p-text-color, #fff);\n }\n .subtitle {\n color: var(--p-text-muted-color, #a1a1aa);\n }\n }\n\n @keyframes spin-outer {\n from { transform: rotate(0deg); }\n to { transform: rotate(360deg); }\n }\n\n @keyframes spin-inner {\n from { transform: translate(-50%, -50%) rotate(0deg); }\n to { transform: translate(-50%, -50%) rotate(-360deg); }\n }\n`\n\nexport class WippyLoadingElement extends HTMLElement {\n static observedAttributes = ['title', 'subtitle']\n\n private _titleEl: HTMLElement | null = null\n private _subtitleEl: HTMLElement | null = null\n\n connectedCallback() {\n const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open' })\n shadow.textContent = ''\n\n const style = document.createElement('style')\n style.textContent = STYLES\n shadow.appendChild(style)\n\n const container = document.createElement('div')\n container.className = 'container'\n\n const spinner = document.createElement('div')\n spinner.className = 'spinner'\n\n const outer = document.createElement('div')\n outer.className = 'ring outer'\n\n const inner = document.createElement('div')\n inner.className = 'ring inner'\n\n spinner.append(outer, inner)\n\n this._titleEl = document.createElement('div')\n this._titleEl.className = 'title'\n this._titleEl.setAttribute('part', 'title')\n\n this._subtitleEl = document.createElement('div')\n this._subtitleEl.className = 'subtitle'\n this._subtitleEl.setAttribute('part', 'subtitle')\n\n this._updateText()\n\n container.append(spinner, this._titleEl, this._subtitleEl)\n shadow.appendChild(container)\n\n // Time-synced animation — phase matches wall-clock time\n const now = Date.now()\n outer.style.animationDelay = `-${now % OUTER_DURATION}ms`\n inner.style.animationDelay = `-${now % INNER_DURATION}ms`\n }\n\n disconnectedCallback() {\n this._titleEl = null\n this._subtitleEl = null\n }\n\n attributeChangedCallback() {\n this._updateText()\n }\n\n private _updateText() {\n if (this._titleEl)\n this._titleEl.textContent = this.getAttribute('title') ?? ''\n if (this._subtitleEl)\n this._subtitleEl.textContent = this.getAttribute('subtitle') ?? ''\n }\n}\n","import { WippyErrorElement } from './wippy-error'\nimport { WippyLoadingElement } from './wippy-loading'\n\nexport { WippyErrorElement, WippyLoadingElement }\n\nexport function register() {\n if (!customElements.get('wippy-loading'))\n customElements.define('wippy-loading', WippyLoadingElement)\n if (!customElements.get('wippy-error'))\n customElements.define('wippy-error', WippyErrorElement)\n}\n\n// Auto-register when loaded as a script\nregister()\n"],"names":["ICONS","STYLES","WippyErrorElement","__publicField","shadow","style","container","iconKey","OUTER_DURATION","INNER_DURATION","WippyLoadingElement","spinner","outer","inner","now","register"],"mappings":"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"} \ No newline at end of file diff --git a/src/facade/public/index.html b/src/facade/public/index.html index f60b247..eb09f9e 100644 --- a/src/facade/public/index.html +++ b/src/facade/public/index.html @@ -99,6 +99,7 @@ auth: { token: token, expiresAt: new Date(Date.now() + 86400000).toISOString() }, env: cfg.env, routePrefix: cfg.routePrefix, + apiRoutes: cfg.apiRoutes, theming: cfg.theming, hostConfig: cfg.hostConfig, context: { resourceId: '', resourceType: 'page' }, diff --git a/src/llm/src/claude/client.lua b/src/llm/src/claude/client.lua index ea02e6e..70991b2 100644 --- a/src/llm/src/claude/client.lua +++ b/src/llm/src/claude/client.lua @@ -205,7 +205,7 @@ function claude_client.request(endpoint_path, payload, options) } end - local parsed, parse_err = json.decode(response.body) + local parsed, parse_err = json.decode(response.body or "") if parse_err then return nil, { status_code = response.status_code, diff --git a/src/llm/src/google/client.lua b/src/llm/src/google/client.lua index 94355aa..1ff79bf 100644 --- a/src/llm/src/google/client.lua +++ b/src/llm/src/google/client.lua @@ -285,7 +285,7 @@ function client.request(method, url, http_options) if response.status_code < 200 or response.status_code >= 300 then if http_options.stream and response.stream and not response.body then - local body_data = response.stream:read() + local body_data = response.stream:read(4096) response.body = body_data end local parsed_error = parse_error_response(response) @@ -297,7 +297,7 @@ function client.request(method, url, http_options) return handle_stream_response(response, http_options) end - local parsed, parse_err = json.decode(response.body) + local parsed, parse_err = json.decode(response.body or "") if parse_err then local parse_error = { status_code = response.status_code, diff --git a/src/llm/src/google/mapper_test.lua b/src/llm/src/google/mapper_test.lua index 8ae7ca7..ccb0f75 100644 --- a/src/llm/src/google/mapper_test.lua +++ b/src/llm/src/google/mapper_test.lua @@ -405,7 +405,7 @@ local function define_tests() } } - local google_tools = mapper.map_tools(contract_tools) + local google_tools = mapper.map_tools(contract_tools) :: any tests.eq(#google_tools, 2) tests.eq(google_tools[1].name, "get_weather") @@ -435,7 +435,7 @@ local function define_tests() } } - local google_tools = mapper.map_tools(contract_tools) + local google_tools = mapper.map_tools(contract_tools) :: any tests.eq(#google_tools, 1) tests.is_nil(google_tools[1].parameters.properties.count.multipleOf) @@ -457,7 +457,7 @@ local function define_tests() } } - local google_tools = mapper.map_tools(contract_tools) + local google_tools = mapper.map_tools(contract_tools) :: any tests.eq(#google_tools, 1) tests.is_nil(google_tools[1].parameters.examples) @@ -487,7 +487,7 @@ local function define_tests() } } - local google_tools = mapper.map_tools(contract_tools) + local google_tools = mapper.map_tools(contract_tools) :: any tests.eq(#google_tools, 1) tests.is_nil(google_tools[1].parameters.properties.nested.multipleOf) @@ -530,7 +530,7 @@ local function define_tests() } } - local google_tools = mapper.map_tools(contract_tools) + local google_tools = mapper.map_tools(contract_tools) :: any tests.eq(#google_tools, 1) tests.eq(google_tools[1].name, "valid_tool") @@ -570,7 +570,7 @@ local function define_tests() } } - local google_tools = mapper.map_tools(contract_tools) + local google_tools = mapper.map_tools(contract_tools) :: any tests.eq(#google_tools, 1) local params = google_tools[1].parameters @@ -618,7 +618,7 @@ local function define_tests() } } - local google_tools = mapper.map_tools(contract_tools) + local google_tools = mapper.map_tools(contract_tools) :: any tests.eq(#google_tools, 1) local address = google_tools[1].parameters.properties.address diff --git a/src/llm/src/google/oauth2/oauth2.lua b/src/llm/src/google/oauth2/oauth2.lua index f0b575a..350f5ef 100644 --- a/src/llm/src/google/oauth2/oauth2.lua +++ b/src/llm/src/google/oauth2/oauth2.lua @@ -45,7 +45,7 @@ function oauth2.get_token() return nil, "Failed to retrieve OAuth2 token: " .. tostring(err) end - return json.decode(response.body) + return json.decode(response.body or "") end return oauth2 diff --git a/src/llm/src/openai/client.lua b/src/llm/src/openai/client.lua index 49f6cb9..763c00c 100644 --- a/src/llm/src/openai/client.lua +++ b/src/llm/src/openai/client.lua @@ -244,7 +244,7 @@ function openai_client.request(endpoint_path, payload, options) end -- Parse non-streaming response - local parsed, parse_err = json.decode(response.body) + local parsed, parse_err = json.decode(response.body or "") if parse_err then local parse_error = { status_code = response.status_code, diff --git a/src/views/_index.yaml b/src/views/_index.yaml index 8feda7f..bcdac07 100644 --- a/src/views/_index.yaml +++ b/src/views/_index.yaml @@ -28,6 +28,10 @@ entries: path: .meta.router - entry: wippy.views.api:public_url.endpoint path: .meta.router + - entry: wippy.views.api:list_routes + path: .meta.router + - entry: wippy.views.api:list_routes.endpoint + path: .meta.router - name: public_api_url kind: env.variable meta: diff --git a/src/views/api/_index.yaml b/src/views/api/_index.yaml index 1944417..aebd155 100644 --- a/src/views/api/_index.yaml +++ b/src/views/api/_index.yaml @@ -93,3 +93,27 @@ entries: func: public_url method: GET path: /pages/public/{id} + - name: list_routes + kind: function.lua + meta: + comment: Returns mountRoute → pageId map for pages claiming top-level URLs + description: Pages mount route listing endpoint + router: api_router + source: file://list_routes.lua + imports: + page_registry: wippy.views:page_registry + method: handler + modules: + - http + pool: + size: 4 + workers: 4 + - name: list_routes.endpoint + kind: http.endpoint + meta: + comment: Endpoint that lists all accessible mount routes + description: View pages mount routes listing endpoint + router: api_router + func: list_routes + method: GET + path: /pages/routes diff --git a/src/views/api/list_pages.lua b/src/views/api/list_pages.lua index e2a48e4..7ac3203 100644 --- a/src/views/api/list_pages.lua +++ b/src/views/api/list_pages.lua @@ -17,6 +17,7 @@ type PageResponse = { internal: string, hidden: number, configOverrides: {[string]: any}?, + mountRoute: string?, } local function handler() @@ -56,6 +57,7 @@ local function handler() internal = type(page.internal) == "string" and page.internal or "", hidden = page.inline and 1 or 0, configOverrides = page.config_overrides :: {[string]: any}?, + mountRoute = page.mount_route :: string?, } table.insert(pages, page_info) diff --git a/src/views/api/list_routes.lua b/src/views/api/list_routes.lua new file mode 100644 index 0000000..de1ef86 --- /dev/null +++ b/src/views/api/list_routes.lua @@ -0,0 +1,70 @@ +local http = require("http") +local page_registry = require("page_registry") + +-- GET /pages/routes +-- Returns { success, count, routes } where routes is a map of +-- mountRoute pattern → page id. Only pages whose mountRoute passes v1 +-- syntax validation AND doesn't conflict with any other page appear. +-- Unlike /pages/list, this endpoint does NOT filter by `announced` — +-- a hidden page that claims a URL still needs its URL to resolve. +-- +-- On validation failure (syntax error or duplicate mount route), responds +-- with HTTP 500 and a detailed error body. The frontend treats this as a +-- fatal state and renders a fullscreen. +local function handler() + local res = http.response() + local req = http.request() + + if not res or not req then + return nil, "Failed to get HTTP context" + end + + -- Single fetch + validation pass — avoids the two-call race. + local all_pages, list_err = page_registry.find_all() + if list_err then + res:set_status(http.STATUS.INTERNAL_ERROR) + res:write_json({ + success = false, + error = list_err, + }) + return + end + + local routes_map, issues = page_registry.validate_mount_routes(all_pages or {}) + if #issues > 0 then + res:set_status(http.STATUS.INTERNAL_ERROR) + res:write_json({ + success = false, + error = table.concat(issues, "\n"), + }) + return + end + + -- Apply access control: a mount route on a secure page should not be + -- exposed to unauthorized callers. Does NOT filter by announced — + -- hidden pages can still claim URLs. + local accessible: {[string]: string} = {} + local count = 0 + for _, page in ipairs(all_pages) do + local mr = page.mount_route + if mr and routes_map[mr] == page.id and (not page.secure or page_registry.can_access(page)) then + accessible[mr] = page.id + count = count + 1 + end + end + + res:set_content_type(http.CONTENT.JSON) + res:set_status(http.STATUS.OK) + -- Force-object marker: many Lua JSON libs serialize empty tables as `[]`. + -- We explicitly encode the map alongside an array of keys to give the + -- consumer a way to detect the empty case without ambiguity. + res:write_json({ + success = true, + count = count, + routes = accessible, + }) +end + +return { + handler = handler, +} diff --git a/src/views/api/render.lua b/src/views/api/render.lua index ac21cf8..be87331 100644 --- a/src/views/api/render.lua +++ b/src/views/api/render.lua @@ -92,7 +92,7 @@ local function handler() primevue = css.prime_vue or false, markdown = css.markdown or false, customCss = css.custom_css or false, - customVariabled = css.custom_variables or false, + customVariables = css.custom_variables or false, }, tailwindConfig = proxy.tailwind_config or false, resizeObserver = proxy.resize_observer or false, diff --git a/src/views/page_registry.lua b/src/views/page_registry.lua index 07cee57..3e4587d 100644 --- a/src/views/page_registry.lua +++ b/src/views/page_registry.lua @@ -39,6 +39,7 @@ type PageInfo = { kind: string, url: string?, internal: string?, + mount_route: string?, } type PageDetail = PageInfo & { @@ -134,6 +135,7 @@ local function extract_page_info(entry) internal = meta.internal or "", kind = kind, config_overrides = meta.config_overrides, + mount_route = meta.mountRoute, } if kind == "component" then @@ -211,6 +213,7 @@ function pages.get(page_id) kind = kind, content_type = entry.meta.content_type or "text/html", config_overrides = entry.meta.config_overrides, + mount_route = entry.meta.mountRoute, } if kind == "template" then @@ -277,6 +280,143 @@ function pages.resolve_base_url(page) return base_url end +-- Mount route v1 syntax validator. +-- Canonical forms: +-- /:part(.*)* root mount +-- //:part(.*)* prefix mount (one or more literal segments) +-- ///:part(.*)* nested prefix mount +-- Literal segments: lowercase alphanumerics plus hyphens. +-- The `:part` param name matches the gen-2-chat frontend convention. +local MOUNT_ROUTE_SUFFIX = "/:part(.*)*" + +local function validate_mount_route_syntax(route) + if type(route) ~= "string" or #route < #MOUNT_ROUTE_SUFFIX then + return false + end + + -- Required catch-all suffix + local tail = route:sub(-#MOUNT_ROUTE_SUFFIX) + if tail ~= MOUNT_ROUTE_SUFFIX then + return false + end + + -- Everything before the suffix is the literal prefix + local prefix = route:sub(1, #route - #MOUNT_ROUTE_SUFFIX) + + -- Root mount: "/:part(.*)*" → prefix is empty, legal + if prefix == "" then + return true + end + + -- Must start with / + if prefix:sub(1, 1) ~= "/" then + return false + end + + -- Parse segments: each must match [a-z0-9-]+, joined by single / + local rebuilt = "" + for segment in prefix:gmatch("/([^/]+)") do + if not segment:match("^[a-z0-9%-]+$") then + return false + end + rebuilt = rebuilt .. "/" .. segment + end + + -- Ensure rebuilt prefix matches input (catches trailing slash, //, etc.) + return rebuilt == prefix +end + +-- Build an error message for an invalid-syntax mountRoute. +local function mount_route_syntax_error(page_id, route) + return string.format( + '[views] page "%s" has invalid mountRoute "%s"' + .. ' — v1 only supports "//:part(.*)*" and "/:part(.*)*".' + .. ' Literal segments must be lowercase alphanumerics plus hyphens.' + .. ' Use ":part" (not ":pathMatch") to match the gen-2-chat system-route convention.', + tostring(page_id), tostring(route) + ) +end + +-- Build an error message for a conflicting mountRoute claimed by two pages. +local function mount_route_conflict_error(route, page_a, page_b) + return string.format( + '[views] mount route conflict: pages "%s" and "%s" both claim "%s".' + .. ' Mount routes must be unique across all registered view.page entries.' + .. ' Remove mountRoute from one of the pages, pick a different path,' + .. ' or override in the top-level app by re-declaring the page id with a different mountRoute.', + tostring(page_a), tostring(page_b), tostring(route) + ) +end + +-- Validate all mountRoute fields in a page list. +-- Returns (routes_map, issues_list). +-- routes_map is a map from mountRoute → page_id (only populated for valid, non-conflicting entries). +-- issues_list is an array of error message strings. +-- +-- Nil / missing mountRoute is silently skipped (pages without the field are fine). +-- Any non-nil, non-string value (number, boolean, table) is treated as a syntax +-- error — we refuse to silently swallow `mountRoute: false` or `mountRoute: 42` +-- because that would hide typos in the YAML. +function pages.validate_mount_routes(all_pages) + local routes_map = {} + local issues = {} + local seen = {} -- route → page_id (first-seen) + + if type(all_pages) ~= "table" then + return routes_map, issues + end + + for _, page in ipairs(all_pages) do + local mr = page.mount_route + if mr ~= nil and mr ~= "" then + if type(mr) ~= "string" or not validate_mount_route_syntax(mr) then + table.insert(issues, mount_route_syntax_error(page.id, mr)) + else + local page_id = page.id + if type(page_id) ~= "string" or page_id == "" then + -- Defensive: a page entry without a valid id would otherwise + -- collide with other id-less entries on the first dup check. + table.insert(issues, string.format( + '[views] page with mountRoute "%s" is missing a valid id.', + tostring(mr) + )) + else + local previous = seen[mr] + if previous and previous ~= page_id then + table.insert(issues, mount_route_conflict_error(mr, page_id, previous)) + else + seen[mr] = page_id + routes_map[mr] = page_id + end + end + end + end + end + + return routes_map, issues +end + +-- Find mount routes across all pages. +-- Returns (routes_map, error_string). +-- On validation failure, routes_map is nil and error_string is a newline-joined +-- list of every issue found (syntax errors AND conflicts). +function pages.find_mount_routes() + local all_pages, err = pages.find_all() + if err then + return nil, err + end + if not all_pages or #all_pages == 0 then + return {} + end + + local routes_map, issues = pages.validate_mount_routes(all_pages) + if #issues > 0 then + return nil, table.concat(issues, "\n") + end + + return routes_map +end + -- Check if the current actor can access a page function pages.can_access(page) if not page.secure then diff --git a/src/views/page_registry_test.lua b/src/views/page_registry_test.lua index 4504203..1e94611 100644 --- a/src/views/page_registry_test.lua +++ b/src/views/page_registry_test.lua @@ -466,6 +466,321 @@ local function define_tests() test.eq(page.kind, "template") end) end) + + test.describe("mount routes — pure validator", function() + test.it("valid: root mount /:part(.*)*", function() + local map, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/:part(.*)*" }, + }) + test.eq(#issues, 0) + test.eq(map["/:part(.*)*"], "ns:a") + end) + + test.it("valid: single-segment prefix", function() + local map, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/keeper/:part(.*)*" }, + }) + test.eq(#issues, 0) + test.eq(map["/keeper/:part(.*)*"], "ns:a") + end) + + test.it("valid: nested prefix", function() + local map, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/admin/users/:part(.*)*" }, + }) + test.eq(#issues, 0) + test.eq(map["/admin/users/:part(.*)*"], "ns:a") + end) + + test.it("valid: hyphens in segments", function() + local map, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/my-app/sub-page/:part(.*)*" }, + }) + test.eq(#issues, 0) + test.eq(map["/my-app/sub-page/:part(.*)*"], "ns:a") + end) + + test.it("valid: ignores pages without mount_route", function() + local map, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/foo/:part(.*)*" }, + { id = "ns:b" }, + { id = "ns:c", mount_route = "" }, + }) + test.eq(#issues, 0) + test.eq(map["/foo/:part(.*)*"], "ns:a") + end) + + test.it("invalid: missing catch-all tail", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/keeper" }, + }) + test.eq(#issues, 1) + test.is_true(issues[1]:find("invalid mountRoute") ~= nil) + end) + + test.it("invalid: alternative param name :pathMatch", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/:pathMatch(.*)*" }, + }) + test.eq(#issues, 1) + end) + + test.it("invalid: custom named params", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/user/:id" }, + }) + test.eq(#issues, 1) + end) + + test.it("invalid: trailing slash after catch-all", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/foo/:part(.*)*/" }, + }) + test.eq(#issues, 1) + end) + + test.it("invalid: uppercase segments rejected", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/Admin/:part(.*)*" }, + }) + test.eq(#issues, 1) + end) + + test.it("invalid: double slash rejected", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "//foo/:part(.*)*" }, + }) + test.eq(#issues, 1) + end) + + test.it("invalid: underscore in segment rejected", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/my_app/:part(.*)*" }, + }) + test.eq(#issues, 1) + end) + + test.it("duplicate: two pages claiming same route", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/keeper/:part(.*)*" }, + { id = "ns:b", mount_route = "/keeper/:part(.*)*" }, + }) + test.eq(#issues, 1) + test.is_true(issues[1]:find("conflict") ~= nil) + test.is_true(issues[1]:find("ns:a") ~= nil) + test.is_true(issues[1]:find("ns:b") ~= nil) + end) + + test.it("duplicate: same page id listed twice is idempotent", function() + local map, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/foo/:part(.*)*" }, + { id = "ns:a", mount_route = "/foo/:part(.*)*" }, + }) + test.eq(#issues, 0) + test.eq(map["/foo/:part(.*)*"], "ns:a") + end) + + test.it("duplicate: reports all conflicts, not just first", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = "/x/:part(.*)*" }, + { id = "ns:b", mount_route = "/x/:part(.*)*" }, + { id = "ns:c", mount_route = "/y/:part(.*)*" }, + { id = "ns:d", mount_route = "/y/:part(.*)*" }, + }) + test.eq(#issues, 2) + end) + + test.it("empty list returns empty map and no issues", function() + local map, issues = page_registry.validate_mount_routes({}) + test.eq(#issues, 0) + local count = 0 + for _ in pairs(map) do + count = count + 1 + end + test.eq(count, 0) + end) + + test.it("nil input returns empty map", function() + local map, issues = page_registry.validate_mount_routes(nil) + test.eq(#issues, 0) + test.not_nil(map) + end) + + test.it("invalid type: boolean mount_route surfaces error", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = false }, + }) + -- `false` is not nil and not an empty string; the type check + -- catches it and emits a clear syntax error so YAML typos + -- (e.g. `mountRoute: false`) don't silently drop. + test.eq(#issues, 1) + test.is_true(issues[1]:find("invalid mountRoute") ~= nil) + end) + + test.it("invalid type: number mount_route surfaces error", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = 42 }, + }) + test.eq(#issues, 1) + test.is_true(issues[1]:find("invalid mountRoute") ~= nil) + end) + + test.it("invalid type: table mount_route surfaces error", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "ns:a", mount_route = { "/foo" } }, + }) + test.eq(#issues, 1) + end) + + test.it("missing id with valid mount_route surfaces error", function() + local _, issues = page_registry.validate_mount_routes({ + { mount_route = "/foo/:part(.*)*" }, + }) + test.eq(#issues, 1) + test.is_true(issues[1]:find("missing a valid id") ~= nil) + end) + + test.it("empty id with valid mount_route surfaces error", function() + local _, issues = page_registry.validate_mount_routes({ + { id = "", mount_route = "/foo/:part(.*)*" }, + }) + test.eq(#issues, 1) + end) + end) + + test.describe("mount routes — registry integration", function() + local MR_IDS = { + "mr_home", "mr_keeper", "mr_admin", "mr_no_mount", + } + + local function setup_mr_pages() + local snap = registry.snapshot() + local changes = snap:changes() + changes:create({ + id = NS .. "mr_home", + kind = "registry.entry", + meta = { + type = "view.page", + name = "home", + title = "Home", + announced = true, + public = true, + mountRoute = "/:part(.*)*", + url = "https://cdn.example.com/home/index.html", + }, + }) + changes:create({ + id = NS .. "mr_keeper", + kind = "registry.entry", + meta = { + type = "view.page", + name = "keeper", + title = "Keeper", + announced = true, + public = true, + mountRoute = "/keeper/:part(.*)*", + url = "https://cdn.example.com/keeper/index.html", + }, + }) + changes:create({ + id = NS .. "mr_admin", + kind = "registry.entry", + meta = { + type = "view.page", + name = "admin-users", + title = "Admin Users", + announced = true, + public = true, + mountRoute = "/admin/users/:part(.*)*", + url = "https://cdn.example.com/admin/users/index.html", + }, + }) + changes:create({ + id = NS .. "mr_no_mount", + kind = "registry.entry", + meta = { + type = "view.page", + name = "plain", + title = "Plain", + announced = true, + public = true, + url = "https://cdn.example.com/plain/index.html", + }, + }) + changes:apply() + end + + local function teardown_mr_pages() + local snap = registry.snapshot() + local changes = snap:changes() + for _, name in ipairs(MR_IDS) do + changes:delete(NS .. name) + end + changes:apply() + end + + test.before_each(function() + setup_mr_pages() + end) + test.after_each(function() + teardown_mr_pages() + end) + + test.it("find_all exposes mount_route when present", function() + local all, err = page_registry.find_all() + test.is_nil(err) + local found = {} + for _, page in ipairs(all) do + if page.id:find("^" .. NS .. "mr_") then + found[page.id] = page.mount_route + end + end + test.eq(found[NS .. "mr_home"], "/:part(.*)*") + test.eq(found[NS .. "mr_keeper"], "/keeper/:part(.*)*") + test.eq(found[NS .. "mr_admin"], "/admin/users/:part(.*)*") + end) + + test.it("find_all returns nil mount_route when not set", function() + local all, err = page_registry.find_all() + test.is_nil(err) + for _, page in ipairs(all) do + if page.id == NS .. "mr_no_mount" then + test.is_nil(page.mount_route) + return + end + end + test.ok(false, "mr_no_mount not found") + end) + + test.it("get returns mount_route on page detail", function() + local page, err = page_registry.get(NS .. "mr_keeper") + test.is_nil(err) + test.eq(page.mount_route, "/keeper/:part(.*)*") + end) + + test.it("get returns nil mount_route when not set", function() + local page, err = page_registry.get(NS .. "mr_no_mount") + test.is_nil(err) + test.is_nil(page.mount_route) + end) + + test.it("find_mount_routes returns full map for valid state", function() + local routes, err = page_registry.find_mount_routes() + test.is_nil(err) + routes = test.not_nil(routes) + test.eq(routes["/:part(.*)*"], NS .. "mr_home") + test.eq(routes["/keeper/:part(.*)*"], NS .. "mr_keeper") + test.eq(routes["/admin/users/:part(.*)*"], NS .. "mr_admin") + end) + + test.it("find_mount_routes excludes pages without mount_route", function() + local routes, err = page_registry.find_mount_routes() + test.is_nil(err) + for _, pid in pairs(routes) do + test.is_true(pid ~= NS .. "mr_no_mount") + end + end) + end) end) end diff --git a/src/views/renderer.lua b/src/views/renderer.lua index dcb90fe..e651a9b 100644 --- a/src/views/renderer.lua +++ b/src/views/renderer.lua @@ -24,7 +24,7 @@ function renderer.get_page_data(page, params, query) -- Use the functions module to call the data function local executor = funcs.new() - local result, err = executor:call(page.data_func, context) + local result, err = executor:call(page.data_func :: string, context) if err then -- Return nil for data, and the error message as the second value @@ -111,7 +111,7 @@ function renderer.render(page_id, params, query) end -- Render the template using the correctly built context - local content, render_err = tmpl:render(page.template_name, render_context) + local content, render_err = tmpl:render(page.template_name :: string, render_context) -- IMPORTANT: Release the template resource now that we're done with it tmpl:release()