Skip to content

feat(webui): serve a Mini App page through its own loopback proxy - #264

Closed
Fectivnfy112357 wants to merge 3 commits into
feat/miniapp-ownership-probefrom
feat/miniapp-proxy
Closed

Fectivnfy112357 wants to merge 3 commits into
feat/miniapp-ownership-probefrom
feat/miniapp-proxy

Conversation

@Fectivnfy112357

Copy link
Copy Markdown

Part of #255. Stacked on #263 → #262 → #261; merge top-down and GitHub retargets each PR as its base
merges.

Change

A Mini App page is served through one loopback listener per Mini App, with the credential and the page
bridge injected, and without enabling the capability for this client's runtime owner.

  • One listener per Mini App, on a port derived per plugin. A page refers to its own origin by absolute
    path, so paths are forwarded unchanged rather than hidden behind a prefix that would have to be patched
    back by intercepting every fetch, XMLHttpRequest and absolute URL. The derived port is deliberate:
    the page's localStorage and IndexedDB are keyed by its origin, so a port that moved between runs
    would silently discard the page's own state.
  • The credential is injected outbound and never returns. It is absent from the response, from
    viewUrl, and from everything the client sees.
  • Upgrades are forwarded under the same rules, and a closed peer tears down its pair — a half-closed
    pipe holds the upstream server open forever.
  • The document is rewritten to be framable and to carry the bridge. X-Frame-Options is dropped,
    frame-ancestors is removed from the page's own CSP with its other directives preserved, and a script
    tag for the bridge goes before </head> (or after <body>, or ahead of the document). The bridge
    script is served by the proxy at its own path and never forwarded upstream. Content-Length is
    restated and the upstream's Transfer-Encoding dropped with it.
  • ADR 0020's open question about framing is answered by those rules and removed from its open list.

Server-only by placement: the proxy takes the upstream origin and the credential as input and returns
viewUrl. It lives in packages/webui/src/server/, whose allowed imports are shared and
runtime-port, so everything touching a real origin stays on the server side of the facade.

Validation

  • pnpm typecheck:webui — server, client and test projects, passed
  • pnpm check:source after node scripts/source-inventory.mjs --write — 5459 files, passed
  • packages/webui/test/unit/miniapp-proxy.test.ts — 9 cases against a real upstream http.Server:
    unchanged path, response body, credential injected outbound and absent from the response, upgrade
    forwarded with the credential, bridge injected before </head> / after <body> / ahead of the
    document, X-Frame-Options and frame-ancestors gone with other CSP directives kept, bridge served
    locally, view URL identical across a restart
  • pnpm check:webui-dependency fails, and not on this branch: document.fonts.ready used from
    client/features/workspace/ui/WorkspacePanels.tsx:489,534, already failing at the base commit
    9713c4cf; the new module contributes zero violations. Flagged rather than fixed because it is a
    bindings-layer decision in another file and outside this ticket.
  • NOT RUN: pnpm verify in full; browser, package and release steps are left to CI

Not in this PR

Starting a Mini App with explicit options and producing its view URL — moved to #256 with your
agreement. The only legal route to a Mini App's origin and credential is a runtime-port capability
member, which packages/webui/AGENTS.md says fails pnpm typecheck:webui without a binding; its consumer
is #256's open operation.

Publication and contribution checks

  • I have permission to contribute these changes under the existing licenses applicable to the changed files/packages; imported material and its provenance are identified and existing notices are preserved.
  • No credentials, account data, real user content, internal source history or private review material is included.
  • Added/removed source files were reviewed before regenerating release/public-source.json; new tests are declared in test/vitest-suites.json where applicable.
  • Shared English/Chinese documentation and capability/verification records are updated where applicable. Mock/offline results are not described as live-service acceptance.

Maintainer handoff

Publication scope or license changes: none.

Shared-source port: not needed — the proxy is written against the shipped behaviour described in ADR
0020, not copied from internal code.

@Fectivnfy112357 Fectivnfy112357 added enhancement New feature or request miniapp WebUI Mini App hosting programme (ADR 0020) labels Oct 26, 2026
First slice of #255. A Mini App page refers to its own origin by absolute path — the
scaffold serves `/dashboard`, and a real page also fetches `/api/...` and its assets — so
the proxy forwards paths unchanged rather than hiding them behind a prefix that would
have to be patched back by intercepting every fetch and absolute URL in the document.

`startMiniAppProxy` puts one listener on loopback per running Mini App, on a port derived
per plugin. That stability is required, not cosmetic: the page's `localStorage` and
`IndexedDB` are keyed by its origin, so a port that moved between runs would silently
discard the page's own state.

Requests are forwarded with the Mini App's credential injected, and the credential never
appears in a response. Upgrades are forwarded under the same rule, and a closed peer tears
down its pair — a half-closed pipe would hold the upstream server open forever, which is
how this showed up in the test teardown.

Server-only by placement: the proxy takes the upstream origin and the credential as input
and returns `viewUrl`, which is what a client frames. The client never assembles a Mini App
origin. It lives in `packages/webui/src/server/` because ADR 0020 puts the proxy in the
WebUI service and keeps everything that touches a real origin on the server side of the
facade; the server layer may import `shared` and `runtime-port` and nothing else.

Proven against a real upstream server: the path is forwarded unchanged, the response body
reaches the caller, the credential is injected outbound and absent from the response, an
upgrade is forwarded with the credential, and the derived port is stable per plugin and
inside the loopback range.

Still to do for #255: the document rewrite that injects the page bridge (the bridge itself
is #260), the per-session and per-presentation view URL, and wiring the proxy into the open
operation so it starts and stops with the Mini App.

Verified: pnpm typecheck:webui (server, client and test); the focused suite (4 cases);
pnpm check:source after the inventory write. pnpm check:webui-dependency still fails on a
pre-existing violation in WorkspacePanels.tsx — `document.fonts.ready` in a bindings file,
present at the base commit 9713c4c and untouched by this branch; the new module adds none.
…e bridge

Second slice of #255. The page is framed by this client on a different origin, so a page
must not be able to refuse being framed. The proxy drops `X-Frame-Options` from every
response it forwards and removes the `frame-ancestors` directive from a page's own CSP,
leaving its other directives alone — the Mini App's origin is separate from the client's,
so the rest of its policy governs a document that cannot reach the client anyway. Nothing
is added: no header the page did not ask for, no CSP of the proxy's own.

The document is the one response the proxy rewrites. The page bridge has to be in the
document before the page's own scripts run, so the proxy injects a script tag for it
before `</head>`, after the opening `<body>` tag when a page has no head, or ahead of the
document when it has neither; the body it sends is not the body it received, so it restates
`Content-Length` and drops the upstream's `Transfer-Encoding` with it. The bridge script is
served by the proxy itself at its own path and never forwarded upstream, which keeps the
seam in one place: this change owns which document, where the tag goes and what headers
survive, while the bridge body arrives with #260.

ADR 0020's open question about framing is settled by these rules and removed from the open
list; the bridge's method-level error mapping is the only one left.

Proven: the bridge is injected before `</head>`, before `<body>` on a headless document,
`X-Frame-Options` is gone, `frame-ancestors` is gone while `default-src` and `script-src`
survive, the bridge path is served locally without reaching the upstream, and paths and
bodies of ordinary responses are untouched.

Verified: pnpm typecheck:webui (server, client and test); the focused suite (8 cases);
pnpm check:source after the inventory write. pnpm check:webui-dependency still fails on the
pre-existing WorkspacePanels.tsx violation described in the previous commit.
Closes #255's stability criterion with evidence. The page's `localStorage` and
`IndexedDB` are keyed by its origin, so a port that moved between runs would silently
discard the page's own state. The derived port is a pure function of the plugin id, and
this asserts the property end to end: start on the derived port, close, start again from
a fresh derivation, and the view URL is identical.

Verified: pnpm typecheck:webui; the focused suite (9 cases).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request miniapp WebUI Mini App hosting programme (ADR 0020)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant