feat: update API routing and CORS settings across deployment configurations

Kim committed May 29, 2026 at 16:27 UTC 6903d4eb0662120b51d2b91748ef8b3fb874a077
9 files changed +23 -9
docs/src/routes/deployment/+page.md
+2 -2
@@ -44,7 +44,7 @@ Tunnel clients and public app visitors
44 | Public request | nginx behavior | Upstream |
45 |---|---|---|
46 | `portal.example.com/`, `/admin`, SPA assets | Terminate TLS, HTTP proxy | `portal-frontend:8080` |
47 -| `/admin/auth/*`, `/sdk/*`, `/install.*`, `/discovery`, `/healthz`, `/x402/*` | Terminate TLS, HTTP proxy | `portal:4017` over HTTPS |
47 +| `/admin/auth/*`, `/sdk/*`, `/install.*`, `/discovery`, `/discovery/*`, `/healthz`, `/v1/sign`, `/x402/*` | Terminate TLS, HTTP proxy | `portal:4017` over HTTPS |
48 | `/state`, `/policy/*`, `/service/status`, `/thumbnail/*` | Terminate TLS, HTTP proxy | `portal-frontend:8080`, then `portal-api:8081` |
49 | `*.portal.example.com` | Raw TCP passthrough with `ssl_preread` | `portal` SNI listener |
50
@@ -165,7 +165,7 @@ TRUSTED_PROXY_CIDRS=
165 LANDING_PAGE_ENABLED=false
166 ```
167
168 -Keep `SNI_PORT=443` with the bundled nginx example because this is the public SNI port advertised to tunnel clients. The single-domain Compose example maps the relay container's SNI listener to `127.0.0.1:4443` on the host so nginx can own public `443/tcp` and still pass wildcard TCP traffic to the relay. Do not open `4443/tcp` publicly; it is only a host-local upstream in that example.
168 +`API_PORT` defaults to `4017`. If you change it, update the `portal_api` upstream in the bundled `nginx.conf` to the same port. Keep `SNI_PORT=443` because this is the public SNI port advertised to tunnel clients. The single-domain Compose example maps the relay container's SNI listener to `127.0.0.1:4443` on the host so nginx can own public `443/tcp` and still pass wildcard TCP traffic to the relay. Do not open `4443/tcp` publicly; it is only a host-local upstream in that example.
169
170 If the relay joins public discovery, set `BOOTSTRAPS` to at least one reachable relay URL and keep `WIREGUARD_PORT/udp` open.
171
docs/static/examples/nginx-proxy-multi-service/.env.example
+1
@@ -8,6 +8,7 @@ DISCOVERY=true
8 IDENTITY_PATH=/portal-certs
9
10 # Listener ports
11 +# If API_PORT changes, also update the portal_api upstream in nginx.conf.
12 API_PORT=4017
13 SNI_PORT=443
14 WIREGUARD_PORT=51820
docs/static/examples/nginx-proxy-multi-service/docker-compose.yaml
+2 -2
@@ -5,7 +5,7 @@
5 # Architecture:
6 # nginx:443 (L4 stream, ssl_preread)
7 # - portal.example.com -> nginx:8443 (L7 path split)
8 -# - relay-owned API paths -> portal:4017 (portal, HTTPS, Docker network only)
8 +# - relay-owned API paths -> portal:${API_PORT:-4017} (portal, HTTPS, Docker network only)
9 # - frontend/UI paths -> portal-frontend:8080 (HTTP)
10 # - *.portal.example.com -> portal:443 (portal SNI passthrough, Docker network only)
11 # - everything else -> nginx:8443 (L7 for other apps)
@@ -117,7 +117,7 @@ services:
117 # - headless-shell
118 environment:
119 PORT: 8081
120 - PORTAL_API_BASE_URL: https://portal:4017
120 + PORTAL_API_BASE_URL: "https://portal:${API_PORT:-4017}"
121 LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
122 PORTAL_FRONTEND_STATE_PATH: /portal-frontend-state/state.json
123 HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-}
docs/static/examples/nginx-proxy-multi-service/nginx.conf
+1 -1
@@ -170,7 +170,7 @@ http {
170 proxy_read_timeout 60s;
171 }
172
173 - location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
173 + location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery($|/)|healthz$|v1/sign$|x402(/|$)) {
174 proxy_pass https://portal_api;
175 proxy_ssl_verify off;
176 proxy_ssl_server_name on;
docs/static/examples/nginx-proxy/.env.example
+1
@@ -8,6 +8,7 @@ DISCOVERY=true
8 IDENTITY_PATH=/portal-certs
9
10 # Listener ports
11 +# If API_PORT changes, also update the portal_api upstream in nginx.conf.
12 API_PORT=4017
13 SNI_PORT=443
14 WIREGUARD_PORT=51820
docs/static/examples/nginx-proxy/docker-compose.yaml
+2 -2
@@ -4,7 +4,7 @@
4 # Architecture:
5 # nginx:443/tcp (L4 stream, ssl_preread)
6 # - portal.example.com -> 127.0.0.1:8443 (nginx L7, TLS termination)
7 -# - relay-owned API paths -> 127.0.0.1:4017 (portal, HTTPS, loopback only)
7 +# - relay-owned API paths -> 127.0.0.1:${API_PORT:-4017} (portal, HTTPS, loopback only)
8 # - frontend/UI paths -> 127.0.0.1:8080 (portal-frontend, HTTP, loopback only)
9 # - *.portal.example.com -> 127.0.0.1:4443 (portal SNI, raw TCP passthrough)
10 # portal:443/udp (QUIC tunnel listener, only if UDP_ENABLED=true)
@@ -113,7 +113,7 @@ services:
113 # - headless-shell
114 environment:
115 PORT: 8081
116 - PORTAL_API_BASE_URL: https://portal:4017
116 + PORTAL_API_BASE_URL: "https://portal:${API_PORT:-4017}"
117 LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
118 PORTAL_FRONTEND_STATE_PATH: /portal-frontend-state/state.json
119 HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-}
docs/static/examples/nginx-proxy/nginx.conf
+1 -1
@@ -135,7 +135,7 @@ http {
135 # Relay API/control endpoints belong to portal. Exact /admin is
136 # intentionally not matched so the React admin route can be served by
137 # portal-frontend.
138 - location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
138 + location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery($|/)|healthz$|v1/sign$|x402(/|$)) {
139 proxy_pass https://portal_api;
140 proxy_ssl_verify off;
141 proxy_ssl_server_name on;
frontend/api/server.ts
+12
@@ -32,6 +32,8 @@ const CDP_TIMEOUT_MS = 5_000;
32 const HTTP_TIMEOUT_MS = 5_000;
33 const JSON_LIMIT = 1 << 20;
34 const THUMBNAIL_CONTENT_TYPE = "image/jpeg";
35 +const CORS_ALLOW_HEADERS = "Accept, Authorization, Content-Type, X-Portal-Access-Token";
36 +const CORS_ALLOW_METHODS = "GET, HEAD, POST, DELETE, OPTIONS";
37
38 type APIEnvelope<T> =
39 | { ok: true; data: T }
@@ -841,6 +843,16 @@ async function serveThumbnail(req: IncomingMessage, res: ServerResponse): Promis
843
844 const server = createServer((req, res) => {
845 void (async () => {
846 + res.setHeader("Access-Control-Allow-Origin", "*");
847 + res.setHeader("Access-Control-Allow-Methods", CORS_ALLOW_METHODS);
848 + res.setHeader("Access-Control-Allow-Headers", CORS_ALLOW_HEADERS);
849 + res.setHeader("Access-Control-Max-Age", "600");
850 + if (req.method === "OPTIONS") {
851 + res.writeHead(204);
852 + res.end();
853 + return;
854 + }
855 +
856 const url = new URL(req.url || "/", "http://api.local");
857 if (url.pathname === "/healthz") {
858 writeData(res, 200, { status: "ok" });
frontend/nginx.conf
+1 -1
@@ -15,7 +15,7 @@ server {
15 proxy_pass http://portal-api:8081;
16 }
17
18 - location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
18 + location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery($|/)|healthz$|v1/sign$|x402(/|$)) {
19 proxy_http_version 1.1;
20 proxy_set_header Host $host;
21 proxy_set_header Authorization $http_authorization;