Skip to content

Mount a plugin's own surface behind the portal - #955

Open
importantitemzl-tech wants to merge 1 commit into
yc-software:mainfrom
importantitemzl-tech:feat/portal-plugin-upstreams
Open

Mount a plugin's own surface behind the portal#955
importantitemzl-tech wants to merge 1 commit into
yc-software:mainfrom
importantitemzl-tech:feat/portal-plugin-upstreams

Conversation

@importantitemzl-tech

@importantitemzl-tech importantitemzl-tech commented Sep 5, 2026

Copy link
Copy Markdown

The problem

A deployment can run a plugin with an HTTP surface of its own, but there is no
way to put that surface behind the portal. UPSTREAMS is a fixed record of
web-ui and admin, and any other first path segment falls through to
web-ui, so a deployment has two options and both are bad: publish a second
address with a second sign-in, or reimplement session sealing, PKCE and CSRF
inside the plugin — which is the portal's job and an authorization surface no
plugin should own.

The change

A plugin entry may declare portalPath:

"services": ["core", "web-ui", "admin", "portal"],
"plugins": [{ "name": "reports", "portalPath": "reports" }]

https://<portal>/reports/... is then proxied to the reports plugin exactly
as /admin is proxied to admin: same session requirement, same prefix
stripping, same signed x-portal-identity. The plugin verifies that header and
passes it to core, which stays the only thing deciding what a person may read.

The deployment target derives the plugin's own internal address — a network
alias on docker, <appPrefix>-<name>.internal on fly — and sets
PORTAL_PLUGIN_UPSTREAMS on the portal and PORTAL_BASE_PATH on the plugin, so
an operator never writes a container or app URL by hand and the surface gets
back the prefix the portal strips.

proxyToSurface already took an arbitrary upstream base, so this extends one
record by configuration rather than adding routing.

What is refused, and why it is refused rather than documented

All three of these fail quietly, which is what makes them worth code:

  • A path the portal answers itselfadmin, auth, v1, web-ui,
    healthz, connect, drop, d, deployments, and the broker prefix. A
    plugin mounted on one of these would take that route over.
  • An upstream that is not on the deployment's own network. The portal mints
    an identity header for whatever it is pointed at, so a public URL here would
    hand a person's verified identity to a third party. A bare label with no dot
    is admitted, because that is how every container already addresses every
    other one on the docker target.
  • A path claimed twice.

The CLI rejects all three when the config loads. The portal rejects them again
at boot and refuses to start rather than dropping the entry — a dropped entry is
silent, and the request would then reach web-ui instead of 404ing. The
portal's copy is the one that has to hold: it does not trust its own
environment.

Anonymous playground sessions are refused a plugin surface, on the same
reasoning as /connect and /drop: the portal cannot know what a deployment's
own surface does with an identity it is handed.

Compatibility

Additive. A deployment that declares no portalPath sets no new variable and
routes identically — PORTAL_PLUGIN_UPSTREAMS is absent, the upstream map is
the same two entries, and the fall-through to web-ui is unchanged.

Tests

  • plugins/portal/test/plugin-surface.test.ts — the proxy path end to end
    (prefix stripped, identity header present, root reachable, an adjacent segment
    still falling through to web-ui), the anon refusal, the 401 with no session,
    the parser's whole refusal table, and two boot refusals driven by spawning the
    portal and asserting it exits non-zero.
  • cli/test/portal-plugin-surface.test.ts — the config schema, both targets'
    derived environment, the reserved-path and duplicate-path refusals, and
    PORTAL_BASE_PATH being the target's to set.

Suites run: root 4295 passed / 0 failed (159 skipped — Postgres), CLI 534,
portal plugin 124. typecheck, lint, lint:ox, lint:knip and
format:check clean.

No new rendered surface: the page a visitor sees under /reports is the
plugin's own, unchanged, and the only HTML this adds a path to is the existing
playground-restricted 403. Happy to put a mock plugin behind a booted portal and
attach the result if a reviewer wants to see it rendered.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

A deployment that runs a plugin with an HTTP surface of its own has no way
to put it behind the portal. The upstream map is a fixed record of web-ui
and admin, and any other first path segment falls through to web-ui, so the
only options are publishing a second address with a second sign-in, or
reimplementing session sealing, PKCE and CSRF inside the plugin.

A plugin entry may now declare `portalPath`. The target derives the plugin's
own internal address — a network alias on docker, `<appPrefix>-<name>.internal`
on fly — and sets `PORTAL_PLUGIN_UPSTREAMS` on the portal and `PORTAL_BASE_PATH`
on the plugin, so an operator never writes a container or app URL by hand. The
surface is then proxied exactly as admin is: same session requirement, same
prefix stripping, same signed `x-portal-identity`. `proxyToSurface` already
took an arbitrary upstream, so this extends a record rather than adding routing.

Three things are refused rather than documented, because all three fail
quietly:

- A path the portal answers itself. A plugin mounted on `admin` would take
  that route over.
- An upstream that is not on the deployment's own network. The portal mints
  an identity header for whatever it is pointed at, so a public URL here
  would hand a person's verified identity to a third party. A bare label with
  no dot is admitted because that is how every container already addresses
  every other one on the docker target.
- A path claimed twice.

The CLI rejects all three when the config loads; the portal rejects them
again at boot and refuses to start, because a dropped entry is silent and
the request would then reach web-ui instead of 404ing. The portal's copy is
the one that has to hold — it does not trust its own environment.

Anonymous playground sessions are refused a plugin surface, on the same
reasoning as `/connect` and `/drop`: the portal cannot know what a
deployment's own surface does with an identity it is handed.
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