feat(plugins): public_pages content negotiation — HTML shell for browser, raw bundle for clients#56
Merged
Conversation
…ser, raw bundle for clients
The portal_page handler at /p/{slug}/{route_prefix}/{token} previously served
the plugin's JS bundle with mimetype application/javascript regardless of the
caller. Browsers navigating to a portal URL saw the JS source instead of a
rendered page. Discovered during evonexus-plugin-nutri Step 5.
Changes
- portal_page: when the Accept header includes text/html and NOT
application/javascript, render a minimal HTML shell that loads the bundle
as <script type="module" src="/p/{slug}/public-assets/{file}"> and
instantiates the plugin's declared custom_element_name. Token reaches the
element via data-token attribute (no need for the bundle to re-parse
window.location).
- _serve_html_shell: new helper. Defensive: validates bundle path is inside
ui/public/ and custom_element_name matches alphanum-dash before emitting.
Sets X-Content-Type-Options: nosniff + a tight CSP (default-src 'self',
frame-ancestors 'none', no external scripts).
- Programmatic clients (curl, fetch with Accept: application/javascript)
keep getting the raw bundle — backwards compatible.
- Bundle is fetched from the existing /p/{slug}/public-assets/{path} route
(no token), which is safe because the bundle contains no patient data —
data lives behind the token-gated /data endpoint.
Tests
- tests/backend/test_plugin_public_pages_html_shell.py — 9 cases:
HTML accept renders shell, JS accept returns bundle, default Accept (*/*)
returns bundle, invalid token returns 404 even with HTML accept, CSP +
X-Content-Type-Options present, custom element name appears exactly once,
XSS-safe (single <script> tag, token only inside data-token), legacy
programmatic fetches unchanged.
Compat
- Existing public-page consumers using fetch() with Accept: application/json
or default see no behaviour change. Plugins that already shipped a bundle
start rendering correctly in browsers without any plugin update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Sorry @DavidsonGomes, you have reached your weekly rate limit of 500000 diff characters.
Please try again later or upgrade to continue using Sourcery
3 tasks
DavidsonGomes
added a commit
that referenced
this pull request
Apr 26, 2026
) Bumps version to 0.33.0 so the plugin nutri (which requires this version) can install. Bundles the five plugin-contract PRs (#52→#56) merged today into a single release. Plus a UX fix on the install wizard so 409s say why they conflicted. The fix - lib/api.ts buildError now falls back to data.conflicts[0] when the standard error/message fields are absent. The plugin preview endpoint returns {conflicts: string[], manifest, ...} on 409 — without this fix the wizard showed only "409 CONFLICT" with the actual reason hidden. - PluginInstallModal: conflicts type was Record<string, unknown>, backend always returned string[]; the JSON.keys() coercion produced index strings. Now typed as string[] and rendered as a list. Tested - Frontend tsc --noEmit clean - Plugin nutri 200 pytest still pass after the 11 `# nosec B603` markers added to subprocess.run calls (false positives from regex security scan — all calls use list args, no shell=True) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jbmendonca
added a commit
to jbmendonca/evo-nexus
that referenced
this pull request
Apr 27, 2026
v0.33.0 — plugin contract bundle (PRs evolution-foundation#52→evolution-foundation#56) + install error surfacing
jbmendonca
pushed a commit
to jbmendonca/evo-nexus
that referenced
this pull request
May 15, 2026
…ser, raw bundle for clients (evolution-foundation#56) The portal_page handler at /p/{slug}/{route_prefix}/{token} previously served the plugin's JS bundle with mimetype application/javascript regardless of the caller. Browsers navigating to a portal URL saw the JS source instead of a rendered page. Discovered during evonexus-plugin-nutri Step 5. Changes - portal_page: when the Accept header includes text/html and NOT application/javascript, render a minimal HTML shell that loads the bundle as <script type="module" src="/p/{slug}/public-assets/{file}"> and instantiates the plugin's declared custom_element_name. Token reaches the element via data-token attribute (no need for the bundle to re-parse window.location). - _serve_html_shell: new helper. Defensive: validates bundle path is inside ui/public/ and custom_element_name matches alphanum-dash before emitting. Sets X-Content-Type-Options: nosniff + a tight CSP (default-src 'self', frame-ancestors 'none', no external scripts). - Programmatic clients (curl, fetch with Accept: application/javascript) keep getting the raw bundle — backwards compatible. - Bundle is fetched from the existing /p/{slug}/public-assets/{path} route (no token), which is safe because the bundle contains no patient data — data lives behind the token-gated /data endpoint. Tests - tests/backend/test_plugin_public_pages_html_shell.py — 9 cases: HTML accept renders shell, JS accept returns bundle, default Accept (*/*) returns bundle, invalid token returns 404 even with HTML accept, CSP + X-Content-Type-Options present, custom element name appears exactly once, XSS-safe (single <script> tag, token only inside data-token), legacy programmatic fetches unchanged. Compat - Existing public-page consumers using fetch() with Accept: application/json or default see no behaviour change. Plugins that already shipped a bundle start rendering correctly in browsers without any plugin update. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jbmendonca
pushed a commit
to jbmendonca/evo-nexus
that referenced
this pull request
May 15, 2026
…volution-foundation#57) Bumps version to 0.33.0 so the plugin nutri (which requires this version) can install. Bundles the five plugin-contract PRs (evolution-foundation#52→evolution-foundation#56) merged today into a single release. Plus a UX fix on the install wizard so 409s say why they conflicted. The fix - lib/api.ts buildError now falls back to data.conflicts[0] when the standard error/message fields are absent. The plugin preview endpoint returns {conflicts: string[], manifest, ...} on 409 — without this fix the wizard showed only "409 CONFLICT" with the actual reason hidden. - PluginInstallModal: conflicts type was Record<string, unknown>, backend always returned string[]; the JSON.keys() coercion produced index strings. Now typed as string[] and rendered as a list. Tested - Frontend tsc --noEmit clean - Plugin nutri 200 pytest still pass after the 11 `# nosec B603` markers added to subprocess.run calls (false positives from regex security scan — all calls use list args, no shell=True) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The
portal_pagehandler at/p/{slug}/{route_prefix}/{token}previously served the plugin's JS bundle with mimetypeapplication/javascriptregardless of the caller. Browsers navigating to a portal URL saw the JS source instead of a rendered page. Discovered during evonexus-plugin-nutri Step 5.This PR adds content negotiation: when the request Accept header includes
text/html(and notapplication/javascript), the host renders a minimal HTML shell that loads the plugin bundle as a module and instantiates the declared custom element. Programmatic clients keep getting the raw bundle — backwards compatible.Changes
portal_page: detect HTML accept and dispatch to_serve_html_shell_serve_html_shell: new helper — generates minimal HTML with<{custom_element} data-token=\"{token}\">+<script type=\"module\" src=\"/p/{slug}/public-assets/{bundle}\">. Validates bundle path containment + custom element name pattern (defense in depth — schema already validates at install). Tight CSP (default-src 'self',frame-ancestors 'none')./p/{slug}/public-assets/{path}(no token required — safe, no patient data in bundle).Why
Without this, plugin authors must either:
portal.htmlshell themselves and reference the bundle (extra file, drift risk)With this, a single
bundle: ui/public/portal.jsdeclaration produces a working browser experience.Compat
Accept: application/json, default*/*,Accept: application/javascript) see no change.Test plan
tests/backend/test_plugin_public_pages_html_shell.pydata-token, custom element instantiated, bundle script src points topublic-assets/*/*→ raw bundle (no surprise behaviour shift)X-Content-Type-Options: nosniffheaders present<script>tag (the module loader); token only insidedata-tokenattribute (html.escape'd)Accept: text/html, application/javascript→ treated as programmatic_compute_previewsignature drift seen in PR feat(plugins): writable_data requires_role + readonly_data current_user auto-injection #55)🤖 Generated with Claude Code