Skip to content

feat(plugins): public_pages content negotiation — HTML shell for browser, raw bundle for clients#56

Merged
DavidsonGomes merged 1 commit into
developfrom
feat/plugin-public-page-html-shell
Apr 25, 2026
Merged

feat(plugins): public_pages content negotiation — HTML shell for browser, raw bundle for clients#56
DavidsonGomes merged 1 commit into
developfrom
feat/plugin-public-page-html-shell

Conversation

@DavidsonGomes
Copy link
Copy Markdown
Member

Summary

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.

This PR adds content negotiation: when the request Accept header includes text/html (and not application/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').
  • Bundle continues to be served from existing /p/{slug}/public-assets/{path} (no token required — safe, no patient data in bundle).

Why

Without this, plugin authors must either:

  1. Ship a static portal.html shell themselves and reference the bundle (extra file, drift risk)
  2. Tell users to never visit the URL in a browser (poor UX)

With this, a single bundle: ui/public/portal.js declaration produces a working browser experience.

Compat

  • Existing programmatic fetches (Accept: application/json, default */*, Accept: application/javascript) see no change.
  • Plugins that already shipped a bundle start rendering correctly in browsers without any plugin code change.

Test plan

  • 9 new pytest cases in tests/backend/test_plugin_public_pages_html_shell.py
    • Browser accept HTML → shell rendered with token in data-token, custom element instantiated, bundle script src points to public-assets/
    • JS accept → raw bundle (legacy)
    • Default Accept */* → raw bundle (no surprise behaviour shift)
    • Invalid token → 404 even with HTML accept (no shell leak before validation)
    • CSP + X-Content-Type-Options: nosniff headers present
    • XSS safe — exactly one <script> tag (the module loader); token only inside data-token attribute (html.escape'd)
    • Accept: text/html, application/javascript → treated as programmatic
  • Backend regression: 100 passed (2 unrelated cache-test failures pre-existing — same _compute_preview signature drift seen in PR feat(plugins): writable_data requires_role + readonly_data current_user auto-injection #55)

🤖 Generated with Claude Code

…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>
Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @DavidsonGomes, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@DavidsonGomes DavidsonGomes merged commit 33f490e into develop Apr 25, 2026
4 checks passed
@DavidsonGomes DavidsonGomes deleted the feat/plugin-public-page-html-shell branch April 25, 2026 20:19
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#52evolution-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#52evolution-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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant