Reverse-proxy plugins
A reverse-proxy plugin lets an UnCorded server expose a self-hosted web app (a "upstream") to its members through the runtime's reverse proxy, behind a sidebar panel. Two canonical examples ship in the repo:
- n8n — the reference dedicated-hostname mount (
dedicated_hostname: true+reserveMount): an app that must own its origin root, sendsX-Frame-Options, and holds a push WebSocket open all day. - Foundry VTT — the reference path mount (
/proxy/<slug>/<mount>/) with a base-path-aware upstream and a bulk-sync WebSocket.
Start from whichever matches your app's shape.
Mental model
The proxy is runtime-owned. Your plugin never proxies bytes itself. You:
- Declare one or more
proxy_mountsin the manifest, each pointing at a setting that holds the upstream URL. - Surface a sidebar item from the backend (a few lines — no proxy logic).
- Render the mount from the frontend panel. Two choices, covered in Two ways to render a mount: let the host render it in its own surface —
sdk.proxy.reserveMount(name, el), a hardened<webview>on desktop / sandboxed<iframe>on web — or self-embed a nested iframe yourself —sdk.proxy.openMount(name).
The runtime handles approval gating, session cookies, access policy, and the actual HTTP/WebSocket forwarding under /proxy/<slug>/<mount>/*.
manifest proxy_mount ──▶ owner approves ──▶ runtime serves upstream
(upstream_setting) (Server settings) /proxy/<slug>/<mount>/*
│ ▲
▼ │
backend: sidebar item ──▶ frontend: reserveMount() / openMount()The backend SDK has no proxy API. Don't look for
createProxyMount()— everything proxy-related is declared in the manifest and driven from the frontend.
1. Manifest
Source of truth: packages/shared/src/manifest.ts. A reverse-proxy-only plugin is type: "standalone" (it owns no data and runs no logic of its own).
{
"name": "proxy-demo",
"version": "0.1.0",
"api_version": "^1.0",
"author": "you",
"description": "Proxy a self-hosted app into the UnCorded sidebar.",
"license": "MIT",
"type": "standalone",
"icon": "Globe",
"backend": { "entry": "backend/index.ts" },
"frontend": { "entry": "frontend/index.html" },
"permissions": ["proxy.http:self", "proxy.websocket:self"],
"sidebar": { "contributes": true, "section": "Apps" },
"settings": [
{
"key": "demo_upstream_url",
"label": "Upstream URL",
"description": "Base URL of the app to proxy. For a host app from the Docker runtime use http://host.docker.internal:<port>.",
"type": "string",
"default": "http://host.docker.internal:3011",
"required": true
}
],
"proxy_mounts": [
{ "name": "demo", "upstream_setting": "demo_upstream_url", "access": "members" }
]
}Top-level fields (required unless noted)
| Field | Type | Notes |
|---|---|---|
name | string | Lowercase slug. This is the plugin's slug everywhere (installed_plugins, URLs, the install folder name). |
version | string | Semver MAJOR.MINOR.PATCH. |
api_version | string | Semver range, e.g. ^1.0. |
author, description | string | Human-readable. |
type | "standalone" | "core" | "extension" | Proxy-only plugins are standalone. extension also needs extends. |
permissions | string[] | Must include the proxy permission(s) — see below. |
backend / frontend | { entry } | At least one required; a proxy panel needs both. |
settings | array | Declares the upstream setting(s) referenced by mounts. |
proxy_mounts | array | The mounts. Non-empty when present. |
sidebar | { contributes, section, ... } | Set contributes: true to show a sidebar item. |
license, icon | string | Optional. icon is a lucide icon name. |
proxy_mounts[]
| Field | Type | Notes |
|---|---|---|
name | string | Slug-safe and unique within the plugin: lowercase, starts with a letter, hyphen-separated segments ([a-z][a-z0-9]*(-[a-z0-9]+)* — no leading/trailing or doubled hyphens). Appears in the URL: /proxy/<slug>/<name>/*. |
upstream_setting | string | Key of a setting in this same manifest (type string or secret) whose value is the upstream URL. The manifest never carries the URL directly. |
access | "members" | "owner" | Optional, defaults to "members". owner restricts the mount to the server owner/admins. |
max_frame_bytes | integer | Optional. Caps the size of a single WebSocket frame relayed in either direction (bytes). Defaults to 65536 (64 KiB); raise it for sockets that bulk-sync. Range 1024–16777216 (1 KiB–16 MiB). See Real-time apps (WebSockets). |
dedicated_hostname | boolean | Optional, defaults to false. Requests that the mount be served at the root of its own platform-allocated hostname instead of under /proxy/<slug>/<name>/. For apps that can't run at a subpath (n8n, Foundry). The plugin only requests; the hostname value is always platform-assigned at approval. Flipping this drifts the mount and requires re-approval. See Dedicated hostnames. |
public_paths | string[] | Optional. Path prefixes served without an UnCorded session for inbound webhooks. Only valid with dedicated_hostname: true. Each entry must start and end with / (e.g. "/webhook/"); ≤8 entries. Your app must verify these requests itself (provider signatures / unguessable ids). Drifts the mount on change. See Public webhook paths. |
Permissions
Declare what the mount needs — WebSocket is not implied by HTTP:
proxy.http:self— forward HTTP requests to the upstream.proxy.websocket:self— forward WebSocket upgrades (needed for live apps, hot-reload, game sockets, etc.).
Validation rejects a manifest that declares proxy_mounts without at least one of these permissions.
2. Backend
The backend is tiny: register a sidebar item. No proxy code.
// backend/index.ts
import { createPlugin } from "@uncorded/plugin-sdk";
const plugin = createPlugin();
plugin.handle("sidebar.items", async () => ({
items: [
{
id: "demo",
label: "Proxy Demo",
icon: "Globe",
panelType: "plugin" as const,
slug: "proxy-demo", // must equal manifest "name"
section: "Apps",
},
],
}));createPlugin() returns a PluginHandle with the full backend surface (handle, request, events, db, kv, settings, broadcast, presence, fetch, …) — but a proxy-only plugin uses none of it beyond handle("sidebar.items", …).
Packaging — backends run as subprocesses
The runtime executes each backend as its own subprocess with the plugin directory as the working directory (Bun.spawn(["bun","--smol","run", entry], { cwd: pluginPath }) — see runtime/src/subprocess.ts). That means:
- Any
import(e.g.@uncorded/plugin-sdk) is resolved against the plugin's ownnode_modules. The runtime does not runbun install/npm installon your plugin. - Package your plugin with its dependencies present — ship
node_modulesin the installed folder, or include apackage.json+ lockfile and install into the folder before installing the plugin.
A backend that imports nothing (raw stdio only) will load without packaging, but real plugins use the SDK and therefore must be packaged with deps. Don't ship an SDK-importing backend without its
node_modules.
3. Frontend
The panel HTML loads the frontend SDK and renders the mount. First decide how the proxied app is rendered — that choice drives the rest of the panel.
Two ways to render a mount
sdk.proxy offers two render models, differing in who owns the surface the upstream loads into. Use one per panel.
openMount — self-embed | reserveMount — host-owned surface | |
|---|---|---|
| Who renders | your panel owns a nested <iframe> | the shell renders the surface; you only reserve a rect |
| Desktop (Electron) | a nested <iframe> | a dedicated hardened <webview> — escapes X-Frame-Options/frame-ancestors, isolated per-server session, native permission prompts, navigation pinned to the mount |
| Web (browser) | a nested <iframe> | a host-owned sandboxed <iframe> + "Open in browser" fallback |
Framing-hostile upstream (X-Frame-Options: DENY, strict frame-ancestors) | ❌ won't load (especially on desktop) | ✅ loads on desktop; web shows the open-in-browser prompt |
fetch()-driven WebGL/canvas app (Foundry VTT, maps) | ❌ canvas stays blank — null-origin texture CORS (see below) | ✅ real-origin surface, textures load same-origin |
| You get back | { iframeUrl, openUrl } (async) | an idempotent dispose function (sync) |
| Failures | throws ProxyError you handle | surfaced in the shell-owned UI |
Which to use:
- Reach for
reserveMountwhen the upstream refuses to be framed, when you want camera/mic/location behind a real permission prompt, or simply to get the best desktop experience. This is the recommended default for "load a whole self-hosted app" panels (Foundry VTT, dashboards, admin panels). - Reach for
openMountwhen you want your panel to own the iframe directly — to overlay your own chrome, read load events, or embed a cooperative app that frames fine. Simpler, but desktop gets a plain iframe and a framing-hostile upstream won't load.
⚠️
reserveMountis REQUIRED forfetch()-driven WebGL/canvas apps (Foundry VTT, map/whiteboard/streaming-tile tools). These apps load their textures with credentialedfetch().openMountself-embeds them in your panel's sandboxednull-origin iframe, and a credentialed fetch from anullorigin needsAccess-Control-Allow-Origin: null— which Chromium hard-blocks. The result: the frame loads, the page renders its chrome, but the canvas stays blank, with no error the shell can detect. There is no upstream header or CORS config that fixes this foropenMount; the only fix is a real-origin surface, which is exactly whatreserveMountprovides (an Electron<webview>on desktop, a host-managed<iframe>on web). On web,reserveMountalso keeps a persistent "Open in browser" affordance on the surface, because a browser tab is a real top-level origin and is the most reliable way for web users to view a heavy canvas app.
Both honor the same manifest, permissions, and approval; only the render surface differs. The runtime routes and headers in sections 5–7 below apply identically to both.
Option A — self-embed with openMount
Your panel owns a nested iframe, sets its src, and shows an "Open in browser" fallback. Adapted from plugins/foundry-vtt/frontend:
<!-- frontend/index.html -->
<body>
<p id="status">Connecting…</p>
<iframe id="frame" allow="fullscreen; clipboard-read; clipboard-write" title="Proxy Demo"></iframe>
<div id="fallback" hidden>
<span>Trouble loading?</span>
<a id="open-link" target="_blank" rel="noreferrer">Open in browser</a>
</div>
<!-- Served by the runtime; do not bundle it yourself. -->
<script src="/sdk/plugin-frontend.js"></script>
<script type="module">
const MOUNT = "demo"; // must equal a proxy_mounts[].name
const sdk = await window.UncodedPlugin.createPluginFrontend();
const status = document.getElementById("status");
const frame = document.getElementById("frame");
const link = document.getElementById("open-link");
const fallback = document.getElementById("fallback");
try {
const session = await sdk.proxy.openMount(MOUNT);
frame.src = session.iframeUrl; // proxied URL, cookie already minted
link.href = session.openUrl; // first-party "Open in browser" fallback
status.hidden = true;
fallback.hidden = false;
} catch (err) {
// err is a ProxyError — err.code tells you why (see table below)
status.textContent = `Couldn't open: ${err.code}`;
}
</script>
</body>sdk.proxy.openMount(name) returns a ProxyMountSession:
| Field | Use |
|---|---|
iframeUrl | Set as the panel iframe src. The proxy-session cookie is already minted. |
openUrl | Wire to an "Open in browser" link/target="_blank". Navigating top-level re-mints the cookie first-party — required where framed third-party cookies are blocked (Safari/WebKit), harmless elsewhere. |
Always render the openUrl affordance. It's the only path that works when the framed cookie is blocked.
Option B — host-owned surface with reserveMount
The shell renders the proxied app — a hardened <webview> on desktop, a sandboxed <iframe> on web — over a placeholder element you reserve. Your panel never sets a src; it lays out a box and hands it to the SDK.
<!-- frontend/index.html -->
<body>
<!-- The shell paints the proxied app over this element's rect. Give it a real
size (here it fills the panel); the SDK reports its layout to the shell. -->
<div id="mount" style="position:absolute; inset:0;"></div>
<!-- Served by the runtime; do not bundle it yourself. -->
<script src="/sdk/plugin-frontend.js"></script>
<script type="module">
const MOUNT = "demo"; // must equal a proxy_mounts[].name
const sdk = await window.UncodedPlugin.createPluginFrontend();
const el = document.getElementById("mount");
// The shell bootstraps the session and positions the surface over `el`.
// Returns an idempotent dispose fn that releases the viewport.
const release = sdk.proxy.reserveMount(MOUNT, el);
// Optional: release on teardown. The shell also cleans up when the iframe is
// destroyed, so this is belt-and-suspenders.
window.addEventListener("pagehide", () => release(), { once: true });
</script>
</body>reserveMount(name, el) is synchronous and returns an idempotent dispose function — there's no session object to read, because the shell owns the surface. Pass a non-empty mount name (it throws ProxyError("INVALID_ARGUMENT") otherwise); all other failures (bootstrap, not-approved, framing) surface in the shell-owned UI, not as a throw here. What the shell does for you, by platform:
| Desktop (Electron) | Web (browser) | |
|---|---|---|
| Surface | dedicated hardened <webview> | host-owned sandboxed <iframe> |
| Framing-hostile upstream | loads — a webview isn't bound by X-Frame-Options/frame-ancestors | can't be framed → shows an Open in browser prompt |
| Session isolation | own per-server partition (persist:proxy:<serverId>), separate cookie jar from the in-app browser | the browser's normal cookie rules; bootstrap uses the first-party path |
| Camera / mic / location / notifications / MIDI | native allow/deny dialog, remembered per mount | the browser's own prompt, subject to the iframe allow policy |
| Off-mount navigation | links to other origins open in the system browser, not in-surface | normal sandboxed-iframe behavior |
| Bootstrap URL | the first-party openUrl ticket, so the cookie lands inside the webview partition | the in-place url; the bootstrap Set-Cookie authorizes it |
You don't choose webview-vs-iframe — the shell picks based on whether it's running in the desktop app. The dispose function is the only thing you manage.
The mount name and the placeholder element are the only things your plugin supplies. The shell derives the server, plugin slug, and tunnel origin from the trusted panel context — never from the iframe's messages — and owns the surface's positioning, lifecycle, and teardown.
ProxyError
openMount() throws a ProxyError with a .code (and .status):
code | Meaning |
|---|---|
INVALID_ARGUMENT | Bad mount name passed to openMount(). |
UNAUTHORIZED | 401 — missing/expired session token. |
FORBIDDEN | 403 — owner-only mount, capability missing. |
NOT_FOUND | 404 — plugin/mount not declared. |
NOT_APPROVED | 409 — mount not approved by the server admin (the common one during setup). |
RATE_LIMITED | 429. |
NETWORK_ERROR | fetch rejected (offline / CORS / DNS). |
MALFORMED_RESPONSE | 2xx body missing url/openUrl. |
BOOTSTRAP_FAILED | any other non-2xx. |
4. Install & run (local testing)
Dropping a plugin folder is not enough. Three things must be true, in order.
a. Place the folder
Install under the server's plugin directory, named exactly the manifest name:
<server-data>/plugins/<slug>/
# e.g. C:\Users\you\.uncorded\servers\<server>\plugins\proxy-demo\
# manifest.json
# backend/index.ts (+ node_modules if it imports the SDK)
# frontend/index.htmlb. Register the slug in server.json
The runtime only loads plugins listed in installed_plugins. Add the slug to the server's server.json (the runtime reads it at boot — see runtime/src/main.ts):
{
"installed_plugins": ["proxy-demo"]
}c. Restart through the desktop app — not docker restart
The runtime reads installed_plugins only at boot, so the container must be recreated to pick up the change. Restart via the desktop app / orchestrator, which tears down and recreates the container.
⚠️ Never
docker restarta server using an authenticated Cloudflare tunnel. The tunnel token lives at/run/tunnel/tunnel.jsonon a tmpfs mount and is piped in over stdin when the desktop app creates the container. A baredocker restartdoes not re-pipe it, so the tunnel silently degrades. The container's restart policy isnoby design — the desktop app owns the lifecycle and rebuilds the container (re-piping the token) on launch. Always go through desktop.
"Plugin failed to load" right after install? Check the enable toggle first. A plugin's enabled/disabled state is persisted per server, and a freshly installed (or reinstalled) plugin whose slug was ever disabled stays disabled — the runtime logs
plugin is disabled in persisted settings; skipping loadand the UI reports it as stopped. Flip the plugin's toggle in Server Settings → Plugins and restart; nothing is broken.
Reaching a host app from the Docker runtime
The runtime container uses bridge networking, so localhost inside the container is the container, not your machine. To proxy an app running on your host, set the upstream setting to:
http://host.docker.internal:<port>(host.docker.internal is a Docker Desktop feature; it resolves to the host.)
The symptom of getting this wrong is a 502
PROXY_UPSTREAM_ERROR("The upstream service could not be reached") the moment the mount is opened — the approval succeeds, auth succeeds, and then the forward dies because the container is dialing itself. If your upstream setting sayslocalhost, this is almost certainly why. The approval row shows a "Docker host alias" note when the upstream useshost.docker.internal, confirming the runtime will resolve it to the machine running the container.
5. Making the proxied app load correctly
A mount is served at the subpath /proxy/<slug>/<mount>/, not at the root. Most "the panel is blank" problems are an app that assumes it lives at /.
The mount is a subpath — give your app its base path
The runtime rewrites root-absolute URLs in HTML and CSS (/styles/app.css → /proxy/<slug>/<mount>/styles/app.css) — including those in inline style="…url()…" and <base href> — so a static page loads. It does not rewrite URLs your app builds in JavaScript (fetch("/api/…"), dynamic import(), a WebSocket/socket.io connection URL), nor absolute URLs that hard-code the upstream's own host. Those still miss the mount.
So your app needs to know its public base path. The runtime tells it on every upstream request (HTTP and WebSocket) via:
X-Forwarded-Prefix: /proxy/<slug>/<mount>If your framework is reverse-proxy-aware it reads that header and emits URLs under the mount automatically — nothing to do. Otherwise set the app's own base-path option to that exact path:
| App / framework | Base-path setting |
|---|---|
| Foundry VTT | routePrefix (Configuration → or options.json) |
| Vite (dev) | --base /proxy/<slug>/<mount>/ (plus --host, see below) |
| Vite / Rollup (build) | base in vite.config |
| Next.js | basePath in next.config.js |
| Create React App | "homepage" in package.json (or PUBLIC_URL) |
| Express / Node | mount the router under the prefix, or read X-Forwarded-Prefix |
| Generic | a "base path" / "base href" / "script name" / "context path" setting |
Caveat — the prefix has three path segments (
proxy,<slug>,<mount>). A few apps only accept a single-segment route prefix — or none at all (n8n ignoresX-Forwarded-Prefixentirely; real Foundry builds URLs in JS). Those can't live under a subpath. Don't work around it with shims: declarededicated_hostname: trueand the platform serves the mount at the root of its own hostname.
Dedicated hostnames — serve at the root of your own origin
Some apps only work at /. For those, a mount can opt out of the subpath entirely:
"proxy_mounts": [
{ "name": "n8n", "upstream_setting": "n8n_url", "dedicated_hostname": true }
]When the owner approves the mount on a Transport server, the platform allocates a hostname like m-a1b2c3d4e5f6-0f9e8d7c.uncorded.app, routes it through the server's existing tunnel (no new tunnel, no restart), and serves the upstream at that host's root. The plugin never picks the name — hostnames are always platform-assigned, and the approval row shows exactly where the mount is served.
What changes for your app. Everything that makes subpaths painful goes away: the app is served at /, receives no X-Forwarded-Prefix, needs no base-path configuration, and its HTML/CSS is streamed untouched (no rewriting). Root-relative URLs, JS-built API calls, and push WebSockets all resolve naturally — the app behaves exactly as if it owned the origin, because as far as it can tell, it does.
Requirements. The server must use Transport — the hostname is a label under uncorded.app routed through the server's platform-managed tunnel, so demo/local/self-tunneled servers can't get one. On those servers the mount still works under /proxy/… (where the app tolerates it) and the approval UI shows a clear Needs Transport state instead of a hostname. There's no extra charge on Transport servers — adding a hostname to an existing tunnel is free.
Who can reach it — private by design. A dedicated hostname changes where the app is served, not who may reach it. Every request still requires the member session, established the first time a member opens the mount from UnCorded (a one-time first-party handoff on the new hostname; after that the browser just works). Anyone else hitting the hostname cold — a shared link, a crawler, a curious visitor — gets a sign-in-required page, never your app. Your upstream is not exposed to the anonymous internet.
That boundary has one consequence worth planning around: by default third-party services can't call in. An external service that can't sign in to UnCorded — GitHub firing a webhook, Stripe, a chat-bot callback — is rejected like any other anonymous request. In n8n terms: the editor, manual runs, schedule/cron triggers, polling triggers, and all outbound HTTP work fully; a workflow triggered by an inbound webhook needs the opt-in below.
Like every security-relevant mount fact, flipping dedicated_hostname (in either direction) drifts the mount: it keeps serving under the old shape until an owner re-approves.
Public webhook paths — let external services call in
When an app genuinely needs inbound webhooks, a dedicated-hostname mount can open specific path prefixes to the anonymous internet with public_paths:
"proxy_mounts": [
{
"name": "n8n",
"upstream_setting": "n8n_url",
"dedicated_hostname": true,
"public_paths": ["/webhook/", "/webhook-test/"]
}
]A request whose path falls under one of these prefixes on the mount's hostname is forwarded without an UnCorded session. Everything else on the host stays session-gated exactly as before — public_paths widens nothing but the prefixes you list. The field is only valid alongside dedicated_hostname: true; each entry must start and end with /, and matching is strict, decoded, per-segment prefix matching, so "/webhook/" exposes /webhook/<id> but never /webhook itself, and encoded-slash / .. / double-encoding tricks can't walk out of the prefix.
You must verify these requests yourself. This is a hard requirement, not a suggestion — a public path is reachable by anyone, so the app behind it is the only thing standing between a stranger and your workflow. Use the provider's own scheme: GitHub's X-Hub-Signature-256 HMAC, Stripe's Stripe-Signature, Discord's Ed25519 header, or at minimum an unguessable id in the path (n8n's webhook URLs already carry one). A public path with no verification is an open door; UnCorded deliberately does not paper over that for you.
What the runtime guarantees on a public request. Anonymous traffic can never be mistaken for a member: no X-Uncorded-User-Id is sent, no UnCorded cookie reaches your app, and an explicit X-Uncorded-Public: 1 header marks the request as sessionless. Public paths are HTTP-only (no anonymous WebSocket upgrades), carry their own stricter rate limits and a separate connection budget so a webhook flood can't starve your members, and — like every mount — only ever forward to the private upstream you approved. Requests that don't arrive through the platform tunnel are rejected before they reach your app.
public_paths is a security-relevant mount fact: the approval row lists every public prefix so the owner consents to exactly what's exposed, and changing the list (like flipping dedicated_hostname) drifts the mount until an owner re-approves.
Render it with the host-owned surface. Apps that need a dedicated hostname are very often the same apps that send X-Frame-Options / frame-ancestors (n8n sends X-Frame-Options: SAMEORIGIN) — and a dedicated-host mount is cross-origin to your panel by definition, so a self-embedded <iframe> (openMount) will be refused by the browser honoring the app's own header. Use reserveMount instead: on desktop the shell renders the mount in a hardened <webview>, which framing headers can't block, and on web it degrades to a first-party "Open in browser" handoff automatically.
Tell the app its public origin. Root serving removes the base-path problem, but apps still generate absolute URLs (OAuth callbacks, emails, displayed webhook addresses) from their own config — point that at the assigned hostname. The upstream URL stays the private address; only the app's self-identity changes. For n8n:
# The mount's assigned hostname, from the approval row ("Served at").
docker run -d --name n8n --restart unless-stopped \
-p 127.0.0.1:5678:5678 \
-e N8N_HOST=<assigned-hostname> \
-e N8N_PROTOCOL=https \
-e N8N_EDITOR_BASE_URL=https://<assigned-hostname>/ \
-e WEBHOOK_URL=https://<assigned-hostname>/ \
-e N8N_PROXY_HOPS=1 \
-v n8n_data:/home/node/.n8n \
docker.n8n.io/n8nio/n8nN8N_PROXY_HOPS=1 matters: the runtime strips inbound x-forwarded-* headers and sets its own (proto, host, for), so with one trusted hop the app sees real client IPs and knows it's behind HTTPS — no insecure-cookie workarounds needed. The persistent volume is n8n-specific but the principle isn't: a recreated container must keep its data (n8n's credentials encryption key lives in /home/node/.n8n).
Authentication — cookies or tokens both work
The proxy is auth-agnostic. Whatever your app uses to authenticate its own users flows through untouched:
- Session cookies — your app's
Set-Cookieis rewritten to the mount path and replayed by the browser on every request, including the WebSocket handshake. - Bearer / token-in-
localStorage— your app's ownAuthorization: Bearer …header is forwarded to its backend.
UnCorded's own session never reaches your app: the proxy-session cookie and the bootstrap Bearer are stripped, and the authenticated user is passed separately as X-Uncorded-User-Id.
What your app receives
Every forwarded request (HTTP and WS) carries:
| Header | Value |
|---|---|
Host | the upstream's own host — generate absolute URLs from X-Forwarded-Host instead if your app emits any |
X-Forwarded-Host | the public UnCorded host the user addressed |
X-Forwarded-Proto | https / http |
X-Forwarded-For | client IP |
X-Forwarded-Prefix | /proxy/<slug>/<mount> — your public base path |
X-Uncorded-User-Id | the authenticated UnCorded user — absent on public_paths requests |
X-Uncorded-Public | 1 on a sessionless public-path request; never set otherwise. When present, there is no user — verify the request yourself. |
Responses stream through as-is. The runtime requests uncompressed bodies from your app (Accept-Encoding: identity) so it can rewrite HTML/CSS reliably — you configure nothing, and the public edge still compresses to the end user.
Real-time apps (WebSockets)
Declare proxy.websocket:self in permissions. The proxy then bridges wss://…/proxy/<slug>/<mount>/* to the upstream and — on the handshake — forwards the same context it sends on HTTP: your app's cookies (so a socket authenticated by session cookie, like Foundry's, sees its session), any Authorization header, the x-forwarded-* identity headers, and X-Forwarded-Prefix. You don't configure any of this; it mirrors the HTTP path automatically.
Token auth over WebSockets: browsers can't set an
Authorizationheader on aWebSocket()— so token-auth realtime apps pass the token in the connection URL's query string or aSec-WebSocket-Protocolsubprotocol. Both pass through the proxy untouched. (The forwardedAuthorizationabove covers non-browser ws clients and server-to-server sockets.)
Origin checks: the runtime composes the upstream socket itself, so a WS server that enforces a strict
Originallowlist (somesocket.ioconfigs, Jupyter) may need the upstream's own origin allowed. Most apps authenticate the socket by cookie/token and don't require this.
Frame size — fixing 1009 closes. Each WebSocket frame is relayed whole (a frame can't be streamed), and the proxy caps it at 64 KiB by default. A frame larger than the cap is dropped and the socket closes with code 1009 ("message too big"). Apps that bulk-sync over a socket — Foundry VTT's world/scene sync, live collaborative editors, anything pushing a large JSON snapshot in one message — hit this. Raise the cap with max_frame_bytes on the mount:
{
"proxy_mounts": [
{ "name": "foundry", "upstream_setting": "foundry_upstream_url", "max_frame_bytes": 1048576 }
]
}It applies in both directions and accepts an integer in [1024, 16777216] (1 KiB–16 MiB). Set only what you need — the cap also bounds the in-flight buffer, so an unnecessarily large value raises memory headroom per connection. Changing it does not invalidate the mount approval (it's an operational tuning knob, not an upstream-identity change). Note this governs socket frames only: bulk asset bytes (map images, scene files, uploads) travel over the HTTP path, which streams without a frame cap — so a huge map doesn't need a huge max_frame_bytes, only the sync metadata frame does.
Voice rooms — give your proxied app LiveKit
If the app you're proxying has a LiveKit integration (virtual tabletops, whiteboards, anything with built-in A/V), it can use the UnCorded server's own hosted SFU — without ever holding a LiveKit API key. The page served through your mount POSTs same-origin to a runtime-intercepted path and gets a short-lived join token back:
POST /__uncorded/voice/token (dedicated-hostname mounts)
POST /proxy-voice-token/<slug>/<mount> (path-served mounts, on the server origin)// request (Content-Type: application/json)
{ "room": "table-1", "canPublishSources": ["microphone", "camera"], "ttlSeconds": 21600 }
// response 200
{
"token": "<livekit-jwt>",
"livekitUrl": "wss://<server-host>/ws/voice",
"expiresAt": 1783021600000,
"room": "server:<serverId>:ext:<slug>:table-1",
"iceServers": [ { "urls": ["turn:…"], "username": "…", "credential": "…" } ]
}Feed livekitUrl + token to livekit-client (and iceServers, when present, as rtcConfig.iceServers for TURN fallback). That's the whole integration — no keys to configure, nothing to rotate, and STUN/TURN come with it.
What the platform guarantees:
- Identity is the mount session. The runtime authenticates the request with the same proxy-session cookie the mount already uses (it rides same-origin
fetchautomatically). The token's identity is the signed-in UnCorded user — participants show real display names and avatars, and the request body cannot choose an identity. - Rooms are namespaced to your plugin.
roommust match^[a-z0-9_-]{1,64}$; tokens land inserver:<id>:ext:<yourSlug>:<room>and can never reach server voice channels or another plugin's rooms. Tokens are join-only — never a room-admin grant. - Bans cut live calls. A user banned from the server is disconnected from your rooms immediately, mid-session.
- TTL: defaults to 6 h (clients like Foundry's A/V modules mint once per session and never refresh);
ttlSecondsaccepts 60–43 200. LiveKit checks the token only at connect/reconnect, so expiry never drops an ongoing call. - Rate limit: 30 mints/min per user.
Manifest prerequisites — declare all of:
"permissions": ["proxy.http:self", "proxy.websocket:self", "voice.rooms:self"],
"managed_services": ["livekit"]Publishing camera additionally requires "runtime_capabilities": ["voice.media"]; screen share requires voice.screen_share (see Permissions).
Errors are the standard { error: { code, message } } envelope: 403 VOICE_ROOMS_CAPABILITY_MISSING (capability not declared), 409 VOICE_SERVICE_NOT_DECLARED (no managed_services: ["livekit"]), 503 VOICE_UNAVAILABLE (server hasn't provisioned voice), 422 INVALID_ROOM_NAME, 403 VOICE_SOURCE_NOT_PERMITTED.
Backend subprocess plugins mint the same tokens over IPC with plugin.voice.createRoomToken.
Session lifecycle & streaming — what the platform guarantees
The proxy plane carries a production session contract (internally: spec-28). You don't implement any of it, but knowing the guarantees tells you what your app can rely on and what it must tolerate:
Sessions survive a full working day.
- A member's proxy session renews itself invisibly while the app is in use (a refreshed cookie rides responses the app was already making — never a navigation or reload).
- Mounted-but-idle apps are kept alive by the shell: the web surface silently re-authenticates before the idle window closes; the desktop app pings a keep-alive route through the surface's own cookie jar. Your app never sees a dead session just because the user stopped clicking for an hour.
- Sessions have an absolute 24-hour cap. At the cap the shell shows an explicit "Sign in to resume" card; resuming re-authenticates and (on the web) preserves the app's DOM state. Design for it the way you'd design for a browser refresh at most once a day.
- Revocation is immediate: a banned/kicked member's session stops proxying on the next request, and a mount re-approval cuts live sessions instantly.
Streams are first-class.
- SSE and chunked responses are never buffered end-to-end. The only buffered bodies are HTML/XHTML/CSS on path mounts (for URL rewriting); dedicated-hostname mounts stream everything untouched.
- There is no total-duration limit on a response. A stream may run for hours; the only body deadline is an idle gap cap (60s between chunks) — send SSE keepalive comments/heartbeats more often than that (most apps already do).
- Upstream headers must arrive within 30s (
PROXY_UPSTREAM_TIMEOUTotherwise). - WebSockets: validated at upgrade, bridged until either side closes. If your plugin's upstream restarts, established sockets close cleanly (no zombie hangs) and the next connect gets a fresh upgrade — apps with reconnect logic (every serious realtime app) resume seamlessly.
Soak-test it. The repo ships the acceptance harness the platform itself uses: scripts/proxy-soak.ts drives a live mount for hours (authenticated probes, held WS/SSE streams, renewal tracking) and fails loudly on drops or missed renewals. Restart your plugin's upstream mid-run to prove the reconnect story.
Changing the upstream or port
- Editing the upstream setting invalidates the approval — re-approve after changing the URL (see Approval).
- Local host apps must bind all interfaces, not loopback. The runtime runs in a container and reaches your machine via
host.docker.internal. An app bound to127.0.0.1/[::1]refuses that connection (you'll seePROXY_UPSTREAM_ERROR/ 502). Bind0.0.0.0(e.g. Vite--host) and point the upstream setting athttp://host.docker.internal:<port>.
6. Approval — mounts fail closed
Proxy mounts are denied until an owner approves them. There is no implicit trust: with no approval row, every request to the mount returns PROXY_NOT_APPROVED (surfaced to the frontend as NOT_APPROVED / 409). See runtime/src/http/proxy.ts and the proxy_approvals table.
To approve: Server settings → Plugins → your plugin → Settings → Approve (per mount).
Approval is bound to the upstream value. Changing the upstream setting invalidates the approval — re-approve after editing the URL. (Internally the approval is keyed and version-bumped so old proxy-session cookies stop working.)
7. Reference — runtime routes
You won't call these directly (the SDK does), but they're useful when debugging:
| Route | Purpose |
|---|---|
POST /proxy-sessions/:slug/:mount | Bootstrap a proxy-session (Bearer auth). Returns { url, openUrl }. This is what sdk.proxy.openMount() calls. |
/proxy/:slug/:mount/* | The proxy itself. Validates the session cookie; forwards HTTP + WebSocket to the upstream. |
POST /proxy-voice-token/:slug/:mount | Mint a LiveKit join token for a plugin-owned voice room (cookie auth). Dedicated hosts serve the same handler at POST /__uncorded/voice/token. |
POST /admin/.../plugins/:slug/proxy-mounts/:mount/approve | Owner/admin approval (driven by the Server settings UI). |
Testing checklist
A quick gate before you say "it works":
- [ ] Manifest validates:
proxy_mounts[].upstream_settingreferences a realstring/secretsetting;permissionsincludeproxy.http:self(andproxy.websocket:selfif the app uses sockets). - [ ] Plugin folder is under
<server>/plugins/<slug>/and the slug is inserver.json→installed_plugins. - [ ] If the backend imports
@uncorded/plugin-sdk, itsnode_modulesis present in the installed folder. - [ ] Restarted via the desktop app (not
docker restart). - [ ] Upstream reachable from the container — host apps via
http://host.docker.internal:<port>. - [ ] Mount approved in Server settings → Plugins → Settings → Approve (re-approve if you changed the upstream).
- [ ] Panel loads: GET
/, asset requests, and (if used) the WebSocket upgrade all reach the upstream.
Marketplace conformance checklist
The bar a proxy plugin must clear before marketplace listing (internally: spec-28 §6). Everything in the testing checklist above, plus:
Rendering & surfaces
- [ ] The render model matches the app:
reserveMountfor framing-hostile, canvas/WebGL, or dedicated-hostname apps;openMountonly for cooperative embeds — and the panel always exposes theopenUrlaffordance when self-embedding. - [ ] A
dedicated_hostnamemount is only declared when the app genuinely can't live under a subpath, and the plugin's docs tell the operator how to set the app's public origin (see the n8n example). - [ ] The panel shows no dead-end states: every failure the plugin's own frontend surfaces (
ProxyErrorcodes) names its recovery.
Public paths (only if the mount declares public_paths)
- [ ] Every public prefix is genuinely needed for inbound webhooks — nothing session-worthy is exposed, and no prefix is broader than it must be.
- [ ] The app verifies every public request itself (provider signature or unguessable id); it never trusts a public request just because it arrived. Confirm a request with a bad/missing signature is rejected by the app.
- [ ] The app treats
X-Uncorded-Public: 1as "no user" — it never falls back to an ambient/admin identity on a sessionless request. - [ ] A real external webhook (e.g. a live GitHub delivery) reaches the app and fires its handler with no UnCorded session in play.
Session lifecycle (drive with a real member account, not the owner)
- [ ] A working session survives ≥ 2 hours of active use with zero app-visible interruptions (sliding renewal is invisible).
- [ ] Leave the panel mounted and idle past 1 hour: returning to it works without a reload on web and without a dead session on desktop.
- [ ] Ban/kick the member mid-session: the app stops receiving data on the next request (and the WS closes on its next reconnect).
- [ ] Re-approve the mount mid-session: same immediate cutoff.
Streaming & realtime
- [ ] Long streams (SSE, exports, event feeds) run past 30 seconds without being cut (and the app heartbeats more often than the 60s idle gap cap).
- [ ] Restart the upstream mid-use: sockets close cleanly, the app's own reconnect logic recovers, and no panel reload is required.
- [ ] Bulk-sync frames fit
max_frame_bytes(no1009closes at defaults, or the mount raises the cap deliberately). - [ ] A multi-hour
proxy-soakrun against the live mount passes, including one upstream restart mid-run.
Desktop parity
- [ ] OAuth / popup flows (
window.open) complete inside the desktop app — the captured popup carries the mount's session, andwindow.openerround-trips work. - [ ] File pickers / uploads / downloads behave under the desktop permission prompts; nothing silently no-ops.