Refactor API paths and update NGINX configuration for presentation and relay services

- Updated NGINX configuration to route API requests to the correct services, including changes to the handling of `/sdk/*`, `/api/*`, and `/ui/*` paths. - Modified Docker Compose files to reflect new API path structure and service routing. - Refactored frontend API client and paths to align with new endpoint structure, ensuring proper routing for public and admin API calls. - Adjusted frontend documentation to clarify new API paths and their usage. - Updated tests to reflect changes in API paths and ensure functionality remains intact.

Kim committed Jun 1, 2026 at 12:04 UTC a7669b7aac5d8611192ea20d1c5d419bfc3b2daa
34 files changed +306 -311
README.md
+1 -1
@@ -72,7 +72,7 @@ portal expose --name myapp \
72
73 # Require x402 payment before a local HTTP upstream receives traffic
74 portal expose 3000 --name paid-api \
75 - --x402-facilitator-url https://portal.example.com/x402 \
75 + --x402-facilitator-url https://portal.example.com/api/x402 \
76 --x402-network eip155:8453 \
77 --x402-price "$0.001"
78
cmd/payment-app/main.go
+2 -2
@@ -63,7 +63,7 @@ func run(args []string) error {
63 utils.StringFlag(fs, &cfg.thumbnail, "thumbnail", defaultPhotoURL, "lease thumbnail")
64 utils.StringFlag(fs, &cfg.photoURL, "photo-url", defaultPhotoURL, "image URL revealed after payment")
65 utils.BoolFlag(fs, &cfg.hide, "hide", false, "hide this lease from listings")
66 - utils.StringFlag(fs, &cfg.x402.FacilitatorURL, "x402-facilitator-url", "https://gosunuts.xyz/x402", "x402 facilitator URL, such as https://relay.example.com:4017/x402")
66 + utils.StringFlag(fs, &cfg.x402.FacilitatorURL, "x402-facilitator-url", "https://gosunuts.xyz/api/x402", "x402 facilitator URL, such as https://relay.example.com:4017/api/x402")
67 utils.StringFlag(fs, &cfg.x402.Network, "x402-network", "eip155:84532", "x402 payment network, such as eip155:8453")
68 utils.StringFlag(fs, &cfg.x402.Price, "x402-price", "$0.001", "x402 price for the protected image, such as $0.01")
69 utils.StringFlag(fs, &cfg.x402.PayTo, "x402-pay-to", "", "x402 recipient address; empty uses the payment app identity address")
@@ -182,7 +182,7 @@ func printUsage(w io.Writer) {
182 []string{
183 "payment-app",
184 "payment-app --name paid-photo",
185 - "payment-app --x402-facilitator-url https://relay.example.com:4017/x402 --x402-network eip155:8453 --x402-price \"$0.01\"",
185 + "payment-app --x402-facilitator-url https://relay.example.com:4017/api/x402 --x402-network eip155:8453 --x402-price \"$0.01\"",
186 },
187 )
188 }
cmd/portal-tunnel/README.md
+7 -7
@@ -35,13 +35,13 @@ portal list
35 If your relay publishes its own installer, use that relay instead:
36
37 ```bash
38 -curl -sSL https://portal.example.com/install.sh | bash
38 +curl -sSL https://portal.example.com/api/install.sh | bash
39 portal expose 3000 --relays https://portal.example.com --discovery=false
40 ```
41
42 ```powershell
43 $ProgressPreference = 'SilentlyContinue'
44 -irm https://portal.example.com/install.ps1 | iex
44 +irm https://portal.example.com/api/install.ps1 | iex
45 portal expose 3000 --relays https://portal.example.com --discovery=false
46 ```
47
@@ -78,7 +78,7 @@ app receives the request:
78 ```text
79 portal expose 3000 --name paid-api \
80 --description "Paid API" \
81 - --x402-facilitator-url https://portal.example.com:4017/x402 \
81 + --x402-facilitator-url https://portal.example.com:4017/api/x402 \
82 --x402-network eip155:8453 \
83 --x402-price "$0.001" \
84 --x402-resource /
@@ -117,7 +117,7 @@ upstream = "http://127.0.0.1:3001"
117 network = "eip155:8453"
118 price = "$0.010"
119 pay_to = "identity"
120 -facilitator_url = "https://portal.example.com:4017/x402"
120 +facilitator_url = "https://portal.example.com:4017/api/x402"
121 resource = "/api/report"
122 mime_type = "application/json"
123
@@ -129,7 +129,7 @@ upstream = "http://127.0.0.1:3001"
129 network = "eip155:8453"
130 price = "$0.050"
131 pay_to = "identity"
132 -facilitator_url = "https://portal.example.com:4017/x402"
132 +facilitator_url = "https://portal.example.com:4017/api/x402"
133 resource = "/api/dataset"
134 mime_type = "application/json"
135 ```
@@ -153,7 +153,7 @@ protected, err := portalx402.NewHTTPRouteHandler(portalx402.HTTPRouteHandlerConf
153 `cmd/payment-app` includes this native x402 pattern. Run it with:
154
155 ```text
156 -payment-app --x402-facilitator-url https://portal.example.com:4017/x402 \
156 +payment-app --x402-facilitator-url https://portal.example.com:4017/api/x402 \
157 --x402-network eip155:8453 \
158 --x402-price "$0.01"
159 ```
@@ -388,7 +388,7 @@ upstream = "http://127.0.0.1:3000"
388 network = "eip155:8453"
389 price = "$0.001"
390 pay_to = "identity"
391 -facilitator_url = "https://portal.example.com:4017/x402"
391 +facilitator_url = "https://portal.example.com:4017/api/x402"
392 resource = "/"
393 ```
394
cmd/portal-tunnel/agent/dashboard.go
+1 -1
@@ -167,7 +167,7 @@ func RunDashboard(configPath, stateDir string) error {
167 metadataOwner: newAgentDashboardInlineInput("owner"),
168 metadataThumbnail: newAgentDashboardInlineInput("https://..."),
169 metadataHide: newAgentDashboardInlineInput("true or false"),
170 - x402FacilitatorURL: newAgentDashboardInlineInput("https://relay.example.com/x402"),
170 + x402FacilitatorURL: newAgentDashboardInlineInput("https://relay.example.com/api/x402"),
171 }
172 model.resizeInputs(0)
173
cmd/portal-tunnel/installer/installer.go
+4 -2
@@ -4,6 +4,8 @@ import (
4 _ "embed"
5 "errors"
6 "strings"
7 +
8 + "github.com/gosuda/portal-tunnel/v2/types"
9 )
10
11 //go:embed install.sh
@@ -47,7 +49,7 @@ func relayShellScript(portalURL, script string) string {
49 overrides := strings.Join([]string{
50 "BASE_URL=" + quoteShellValue(portalURL),
51 "RELAY_URL=" + quoteShellValue(portalURL),
50 - "BIN_PATH_PREFIX='install/bin'",
52 + "BIN_PATH_PREFIX=" + quoteShellValue(strings.Trim(types.PathInstallBinPrefix, "/")),
53 "",
54 }, "\n")
55 return insertAfterShebang(script, overrides)
@@ -57,7 +59,7 @@ func relayPowerShellScript(portalURL, script string) string {
59 overrides := strings.Join([]string{
60 "$env:BASE_URL = " + quotePowerShellValue(portalURL),
61 "$env:RELAY_URL = " + quotePowerShellValue(portalURL),
60 - "$env:BIN_PATH_PREFIX = 'install/bin'",
62 + "$env:BIN_PATH_PREFIX = " + quotePowerShellValue(strings.Trim(types.PathInstallBinPrefix, "/")),
63 "",
64 }, "\n")
65 return overrides + script
cmd/relay-server/api.go
+1 -1
@@ -171,7 +171,7 @@ func (api *RelayAPI) serveAdmin(w http.ResponseWriter, r *http.Request) {
171 }
172
173 switch path {
174 - case "/admin/metrics":
174 + case types.PathAdmin + "/metrics":
175 promhttp.Handler().ServeHTTP(w, r)
176 return
177 default:
cmd/relay-server/main.go
+1 -1
@@ -93,7 +93,7 @@ func runServeCommand(args []string) error {
93 utils.StringFlagEnv(fs, &cfg.AdminWallets, "admin-wallets", "", "admin wallet address allowlist, comma-separated; relay identity address is always allowed", "ADMIN_WALLETS")
94 utils.BoolFlagEnv(fs, &cfg.PProfEnabled, "pprof-enabled", false, "enable pprof diagnostics HTTP server", "PPROF_ENABLED")
95 utils.StringFlagEnv(fs, &cfg.PProfAddr, "pprof-addr", portal.DefaultPProfListenAddr, "pprof diagnostics listen address when enabled", "PPROF_ADDR")
96 - utils.BoolFlagEnv(fs, &cfg.X402Enabled, "x402-facilitator-enabled", false, "enable relay-local x402 facilitator endpoints under /x402", "X402_FACILITATOR_ENABLED")
96 + utils.BoolFlagEnv(fs, &cfg.X402Enabled, "x402-facilitator-enabled", false, "enable relay-local x402 facilitator endpoints under /api/x402", "X402_FACILITATOR_ENABLED")
97 utils.StringFlagEnv(fs, &cfg.X402Network, "x402-network", "", "x402 facilitator CAIP-2 network, such as eip155:8453", "X402_NETWORK")
98 utils.StringFlagEnv(fs, &cfg.X402RPCURL, "x402-rpc-url", "", "x402 facilitator RPC URL; empty uses the PublicNode default for supported networks", "X402_RPC_URL")
99
config.toml
+3 -3
@@ -2,9 +2,9 @@
2 # Bump protocol versions only when wire-level behavior changes.
3
4 [release]
5 -version = "v2.2.5"
5 +version = "v2.3.0"
6 base_url = "https://github.com/gosuda/portal-tunnel/releases"
7
8 [protocol]
9 -tunnel = "7"
10 -discovery = "7"
9 +tunnel = "8"
10 +discovery = "8"
docker-compose.yml
+1 -1
@@ -111,7 +111,7 @@ services:
111 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
112 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
113
114 - # Optional: relay-local x402 facilitator exposed under /x402
114 + # Optional: relay-local x402 facilitator exposed under /api/x402
115 X402_FACILITATOR_ENABLED: ${X402_FACILITATOR_ENABLED:-false}
116 X402_NETWORK: ${X402_NETWORK:-eip155:84532}
117 X402_RPC_URL: ${X402_RPC_URL:-https://base-sepolia-rpc.publicnode.com}
docs/src/lib/tunnel-command.ts
+2 -2
@@ -55,8 +55,8 @@ function buildTunnelCommandParts({
55 const relayURLValue = relayUrls.length > 0 ? relayUrls.join(',') : currentOrigin;
56
57 // Inlined install paths — no apiPaths dependency
58 - const installScriptURL = new URL('/install.sh', currentOrigin).toString();
59 - const installPowerShellURL = new URL('/install.ps1', currentOrigin).toString();
58 + const installScriptURL = new URL('/api/install.sh', currentOrigin).toString();
59 + const installPowerShellURL = new URL('/api/install.ps1', currentOrigin).toString();
60
61 const exposeArgs: string[] = [];
62 exposeArgs.push(`--name ${formatToken(nameValue, os)}`);
docs/src/routes/api-reference/+page.md
+24 -23
@@ -40,8 +40,8 @@ The envelope does not apply to streaming or delegated endpoints:
40 |------|--------|
41 | `/sdk/connect` | HTTP/1.1 connection hijack |
42 | `/v1/sign` | keyless TLS signer protocol |
43 -| `/install.sh`, `/install.ps1`, `/install/bin/*` | script or binary bytes |
44 -| `/x402/*` | x402 facilitator API |
43 +| `/api/install.sh`, `/api/install.ps1`, `/api/install/bin/*` | script or binary bytes |
44 +| `/api/x402/*` | x402 facilitator API |
45
46 Unknown routes may be handled by the frontend/proxy layer or return a normal
47 HTTP 404 outside the envelope.
@@ -67,24 +67,25 @@ interchangeable.
67 | Method | Path | Auth | Response |
68 |--------|------|------|----------|
69 | `GET` | `/` | None | service identity |
70 -| `GET` | `/healthz` | None | `{ "status": "ok" }` |
71 -| `GET` | `/state` | None | `PublicStateResponse` |
72 -| `GET`/`HEAD` | `/install.sh`, `/install.ps1` | None | install script |
73 -| `GET`/`HEAD` | `/install/bin/{slug}` | None | install binary or redirect |
70 +| `GET` | `/api/healthz` | None | `{ "status": "ok" }` |
71 +| `GET` | `/api/state` | None | `PublicStateResponse` |
72 +| `GET`/`HEAD` | `/api/install.sh`, `/api/install.ps1` | None | install script |
73 +| `GET`/`HEAD` | `/api/install/bin/{slug}` | None | install binary or redirect |
74
75 ### Frontend Presentation API
76
77 These paths are served by the TypeScript API service when the static frontend stack is
78 -enabled. They are derived from relay APIs plus frontend-owned presentation state.
78 +enabled. They live under `/ui/` and are derived from relay APIs plus frontend-owned
79 +presentation state.
80
81 | Method | Path | Auth | Response |
82 |--------|------|------|----------|
82 -| `GET` | `/state` | None | `PublicStateResponse` plus `landing_page_enabled` |
83 -| `GET` | `/service/status?hostname=...` | None | `ServiceStatusResponse` |
84 -| `GET` | `/policy/state` | Admin bearer | `PolicyStateResponse` plus `landing_page_enabled` in `policy` |
85 -| `GET`/`POST` | `/policy` | Admin bearer | `PolicySettings` plus `landing_page_enabled` |
86 -| `POST` | `/policy/leases`, `/policy/ips` | Admin bearer | relay policy update response |
87 -| `GET` | `/thumbnail/{hostname}` | None | generated image |
83 +| `GET` | `/ui/state` | None | `PublicStateResponse` plus `landing_page_enabled` |
84 +| `GET` | `/ui/service/status?hostname=...` | None | `ServiceStatusResponse` |
85 +| `GET` | `/ui/policy/state` | Admin bearer | `PolicyStateResponse` plus `landing_page_enabled` in `policy` |
86 +| `GET`/`POST` | `/ui/policy` | Admin bearer | `PolicySettings` plus `landing_page_enabled` |
87 +| `POST` | `/ui/policy/leases`, `/ui/policy/ips` | Admin bearer | relay policy update response |
88 +| `GET` | `/ui/thumbnail/{hostname}` | None | generated image |
89
90 ### SDK
91
@@ -104,10 +105,10 @@ SDK clients.
105
106 | Method | Path | Auth | Body | Response |
107 |--------|------|------|------|----------|
107 -| `POST` | `/admin/auth/challenge` | None | `WalletAuthChallengeRequest` | `WalletAuthChallengeResponse` |
108 -| `POST` | `/admin/auth/login` | SIWE signature body | `WalletAuthLoginRequest` | `WalletAuthLoginResponse` |
109 -| `GET` | `/admin/auth/status` | Optional admin bearer | none | `WalletAuthStatusResponse` |
110 -| `POST` | `/admin/auth/logout` | Admin bearer | none | `{}` |
108 +| `POST` | `/api/admin/auth/challenge` | None | `WalletAuthChallengeRequest` | `WalletAuthChallengeResponse` |
109 +| `POST` | `/api/admin/auth/login` | SIWE signature body | `WalletAuthLoginRequest` | `WalletAuthLoginResponse` |
110 +| `GET` | `/api/admin/auth/status` | Optional admin bearer | none | `WalletAuthStatusResponse` |
111 +| `POST` | `/api/admin/auth/logout` | Admin bearer | none | `{}` |
112
113 `/admin` itself is a frontend route, not a relay API endpoint.
114
@@ -115,11 +116,11 @@ SDK clients.
116
117 | Method | Path | Auth | Body | Response |
118 |--------|------|------|------|----------|
118 -| `GET` | `/policy` | Admin bearer | none | `PolicySettings` |
119 -| `POST` | `/policy` | Admin bearer | `PolicySettings` | `PolicySettings` |
120 -| `GET` | `/policy/state` | Admin bearer | none | `PolicyStateResponse` |
121 -| `POST` | `/policy/leases` | Admin bearer | `LeasePolicyUpdate` | `{}` |
122 -| `POST` | `/policy/ips` | Admin bearer | `IPPolicyUpdate` | `{}` |
119 +| `GET` | `/api/policy` | Admin bearer | none | `PolicySettings` |
120 +| `POST` | `/api/policy` | Admin bearer | `PolicySettings` | `PolicySettings` |
121 +| `GET` | `/api/policy/state` | Admin bearer | none | `PolicyStateResponse` |
122 +| `POST` | `/api/policy/leases` | Admin bearer | `LeasePolicyUpdate` | `{}` |
123 +| `POST` | `/api/policy/ips` | Admin bearer | `IPPolicyUpdate` | `{}` |
124
125 ### Relay And Payment
126
@@ -128,7 +129,7 @@ SDK clients.
129 | `GET` | `/discovery` | None | `DiscoveryResponse` |
130 | `POST` | `/discovery/announce` | Signed descriptor | `DiscoveryAnnounceResponse` |
131 | `POST` | `/v1/sign` | Lease token header | keyless signer response |
131 -| `ANY` | `/x402/*` | x402-specific | delegated facilitator response |
132 +| `ANY` | `/api/x402/*` | x402-specific | delegated facilitator response |
133
134 ## Shared Types
135
docs/src/routes/api-reference/admin/+page.md
+18 -18
@@ -9,16 +9,16 @@ Operator endpoints are the control surface for a relay. They all return
9 the standard JSON envelope described in [API Reference](/api-reference), except
10 for internal operational endpoints that are not part of the stable API.
11
12 -`/admin` is reserved for the frontend route and wallet auth endpoints. Relay
13 -enforcement settings live under `/policy`.
12 +`/admin` is reserved for the frontend route. Relay wallet auth endpoints live
13 +under `/api/admin`, and enforcement settings live under `/api/policy`.
14
15 ## Auth Flow
16
17 -1. `POST /admin/auth/challenge` with the wallet address.
17 +1. `POST /api/admin/auth/challenge` with the wallet address.
18 2. Sign the returned `siwe_message`.
19 -3. `POST /admin/auth/login` with the challenge id, message, and signature.
19 +3. `POST /api/admin/auth/login` with the challenge id, message, and signature.
20 4. Send the returned `access_token` as `Authorization: Bearer <token>`.
21 -5. `POST /admin/auth/logout` to invalidate the current token.
21 +5. `POST /api/admin/auth/logout` to invalidate the current token.
22
23 Admin bearer tokens are separate from SDK lease tokens.
24
@@ -26,15 +26,15 @@ Admin bearer tokens are separate from SDK lease tokens.
26
27 | Method | Path | Auth | Body | Data |
28 |--------|------|------|------|------|
29 -| `POST` | `/admin/auth/challenge` | None | `WalletAuthChallengeRequest` | `WalletAuthChallengeResponse` |
30 -| `POST` | `/admin/auth/login` | SIWE signature body | `WalletAuthLoginRequest` | `WalletAuthLoginResponse` |
31 -| `GET` | `/admin/auth/status` | Optional bearer | none | `WalletAuthStatusResponse` |
32 -| `POST` | `/admin/auth/logout` | Bearer | none | `{}` |
33 -| `GET` | `/policy` | Bearer | none | `PolicySettings` |
34 -| `POST` | `/policy` | Bearer | `PolicySettings` | `PolicySettings` |
35 -| `GET` | `/policy/state` | Bearer | none | `PolicyStateResponse` |
36 -| `POST` | `/policy/leases` | Bearer | `LeasePolicyUpdate` | `{}` |
37 -| `POST` | `/policy/ips` | Bearer | `IPPolicyUpdate` | `{}` |
29 +| `POST` | `/api/admin/auth/challenge` | None | `WalletAuthChallengeRequest` | `WalletAuthChallengeResponse` |
30 +| `POST` | `/api/admin/auth/login` | SIWE signature body | `WalletAuthLoginRequest` | `WalletAuthLoginResponse` |
31 +| `GET` | `/api/admin/auth/status` | Optional bearer | none | `WalletAuthStatusResponse` |
32 +| `POST` | `/api/admin/auth/logout` | Bearer | none | `{}` |
33 +| `GET` | `/api/policy` | Bearer | none | `PolicySettings` |
34 +| `POST` | `/api/policy` | Bearer | `PolicySettings` | `PolicySettings` |
35 +| `GET` | `/api/policy/state` | Bearer | none | `PolicyStateResponse` |
36 +| `POST` | `/api/policy/leases` | Bearer | `LeasePolicyUpdate` | `{}` |
37 +| `POST` | `/api/policy/ips` | Bearer | `IPPolicyUpdate` | `{}` |
38
39 ## Auth Payloads
40
@@ -76,7 +76,7 @@ Admin bearer tokens are separate from SDK lease tokens.
76
77 ## State
78
79 -`GET /policy/state` returns the full policy view:
79 +`GET /api/policy/state` returns the full policy view:
80
81 | Field | Type |
82 |-------|------|
@@ -100,7 +100,7 @@ and adds:
100
101 ## Policy
102
103 -Policy settings are written as one object through `POST /policy` and returned
103 +Policy settings are written as one object through `POST /api/policy` and returned
104 in the same shape:
105
106 ```json
@@ -128,7 +128,7 @@ Supported modes:
128
129 ## Lease Policy
130
131 -`POST /policy/leases` accepts a partial policy update for one identity:
131 +`POST /api/policy/leases` accepts a partial policy update for one identity:
132
133 | Field | Type | Effect |
134 |-------|------|--------|
@@ -142,7 +142,7 @@ Lease policy updates persist to `policy.json` and return `{}` on success.
142
143 ## IP Policy
144
145 -`POST /policy/ips` accepts:
145 +`POST /api/policy/ips` accepts:
146
147 ```json
148 { "ip": "203.0.113.10", "is_banned": true }
docs/src/routes/cli-reference/+page.md
+6 -6
@@ -29,7 +29,7 @@ irm https://github.com/gosuda/portal-tunnel/releases/latest/download/install.ps1
29 If your relay publishes its own installer:
30
31 ```bash
32 -curl -sSL https://portal.example.com/install.sh | bash
32 +curl -sSL https://portal.example.com/api/install.sh | bash
33 ```
34
35 The installer writes the `portal` binary only. It does not write a config file.
@@ -75,7 +75,7 @@ not supported.
75 |------|---------|-------|
76 | Default HTTPS stream | `portal expose 3000` | Relay routes by SNI; tunnel process terminates tenant TLS |
77 | Routed HTTP | `portal expose --http-route /api=3001 --http-route /=5173` | Tunnel process runs the HTTP reverse proxy |
78 -| Routed HTTP with x402 | `portal expose 3000 --x402-facilitator-url https://portal.example.com/x402 --x402-network eip155:8453 --x402-price "$0.001"` | Tunnel process enforces payment before proxying to the upstream |
78 +| Routed HTTP with x402 | `portal expose 3000 --x402-facilitator-url https://portal.example.com/api/x402 --x402-network eip155:8453 --x402-price "$0.001"` | Tunnel process enforces payment before proxying to the upstream |
79 | Dedicated raw TCP | `portal expose localhost:25565 --tcp` | Relay allocates a public TCP port |
80 | UDP relay | `portal expose 8080 --udp --udp-addr 19132` | Relay allocates a public UDP port |
81
@@ -155,7 +155,7 @@ Require x402 payment before a local upstream receives traffic:
155 portal expose 3000 --name paid-api \
156 --relays https://portal.example.com \
157 --discovery=false \
158 - --x402-facilitator-url https://portal.example.com/x402 \
158 + --x402-facilitator-url https://portal.example.com/api/x402 \
159 --x402-network eip155:8453 \
160 --x402-price "$0.001"
161 ```
@@ -183,7 +183,7 @@ upstream = "http://127.0.0.1:3001"
183 network = "eip155:8453"
184 price = "$0.010"
185 pay_to = "identity"
186 -facilitator_url = "https://portal.example.com/x402"
186 +facilitator_url = "https://portal.example.com/api/x402"
187 resource = "/api/report"
188 mime_type = "application/json"
189
@@ -195,7 +195,7 @@ upstream = "http://127.0.0.1:3001"
195 network = "eip155:8453"
196 price = "$0.050"
197 pay_to = "identity"
198 -facilitator_url = "https://portal.example.com/x402"
198 +facilitator_url = "https://portal.example.com/api/x402"
199 resource = "/api/dataset"
200 mime_type = "application/json"
201 ```
@@ -219,7 +219,7 @@ protected, err := portalx402.NewHTTPRouteHandler(portalx402.HTTPRouteHandlerConf
219 The payment app exposes the same pattern:
220
221 ```bash
222 -go run ./cmd/payment-app --x402-facilitator-url https://portal.example.com/x402 --x402-network eip155:8453 --x402-price "$0.01"
222 +go run ./cmd/payment-app --x402-facilitator-url https://portal.example.com/api/x402 --x402-network eip155:8453 --x402-price "$0.01"
223 ```
224
225 Expose a Minecraft server:
docs/src/routes/configuration/+page.md
+2 -2
@@ -62,7 +62,7 @@ The relay server (`relay-server`) reads configuration from environment variables
62
63 | Variable | Default | Type | Description |
64 |----------|---------|------|-------------|
65 -| `X402_FACILITATOR_ENABLED` | `false` | bool | Enable the relay-local x402 facilitator under `/x402` |
65 +| `X402_FACILITATOR_ENABLED` | `false` | bool | Enable the relay-local x402 facilitator under `/api/x402` |
66 | `X402_NETWORK` | | string | CAIP-2 network served by the facilitator, such as `eip155:8453` |
67 | `X402_RPC_URL` | | string | RPC URL used by the facilitator; empty uses the facilitator default for supported networks |
68
@@ -279,7 +279,7 @@ upstream = "http://127.0.0.1:3001"
279 network = "eip155:8453"
280 price = "$0.010"
281 pay_to = "identity"
282 -facilitator_url = "https://portal.example.com:4017/x402"
282 +facilitator_url = "https://portal.example.com:4017/api/x402"
283 resource = "/api/report"
284 mime_type = "application/json"
285 max_timeout_seconds = 0
docs/src/routes/deployment/+page.md
+11 -10
@@ -30,8 +30,8 @@ Browser
30 -> https://portal.example.com
31 -> nginx public TLS edge
32 -> portal-frontend for SPA routes and assets
33 - -> portal for relay-owned API paths
34 - -> portal-api for presentation-owned API paths
33 + -> portal for /sdk/*, /discovery*, /v1/sign, and /api/* relay paths
34 + -> portal-api for /ui/* presentation API paths
35
36 Tunnel clients and public app visitors
37 -> https://*.portal.example.com
@@ -44,8 +44,9 @@ 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`, `/discovery/*`, `/healthz`, `/v1/sign`, `/x402/*` | Terminate TLS, HTTP proxy | `portal:4017` over HTTPS |
48 -| `/state`, `/policy/*`, `/service/status`, `/thumbnail/*` | Terminate TLS, HTTP proxy | `portal-api:8081` |
47 +| `/sdk/*`, `/discovery*`, `/v1/sign` | Terminate TLS, HTTP proxy | `portal:4017` over HTTPS |
48 +| `/api/*` | Terminate TLS, HTTP proxy | `portal:4017` over HTTPS |
49 +| `/ui/*` | Terminate TLS, HTTP proxy | `portal-api:8081` |
50 | `*.portal.example.com` | Raw TCP passthrough with `ssl_preread` | `portal` SNI listener |
51
52 The root relay host needs HTTP path routing, so it is not TCP-passthrough. Wildcard app hosts need TCP passthrough, so nginx must not terminate TLS for them.
@@ -67,7 +68,7 @@ To keep the same practical security level as the embedded frontend deployment:
68
69 - Public users reach the dashboard only through `https://portal.example.com`.
70 - `portal:4017`, `portal-frontend:8080`, and `portal-api:8081` are not exposed directly to the internet.
70 -- Root-host API paths are HTTP reverse-proxied by nginx to either the relay API upstream or presentation API upstream.
71 +- Root-host relay protocol paths (`/sdk/*`, `/discovery*`, `/v1/sign`) and relay JSON API paths (`/api/*`) are HTTP reverse-proxied by nginx to the relay API upstream, while `/ui/*` presentation paths go to `portal-api`.
72 - Wildcard app hosts are TCP-passthrough to the relay SNI listener.
73 - The nginx browser certificate and the relay API certificate are separate operational concerns unless you intentionally share the same certificate files.
74
@@ -165,7 +166,7 @@ TRUSTED_PROXY_CIDRS=
166 LANDING_PAGE_ENABLED=false
167 ```
168
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 +`API_PORT` defaults to `4017`. If you change it, update the relay `proxy_pass` targets 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.
170
171 If the relay joins public discovery, set `BOOTSTRAPS` to at least one reachable relay URL and keep `WIREGUARD_PORT/udp` open.
172
@@ -346,10 +347,10 @@ Persist those values in `/etc/sysctl.conf` or a file under `/etc/sysctl.d/` if n
347
348 It owns:
349
349 -- `/state` composition with frontend-owned fields.
350 -- `/policy/*` composition, while relay-enforced policy changes are still forwarded to `portal`.
351 -- `/service/status`, derived from relay state for quick-start UI checks.
352 -- `/thumbnail/<hostname>`, when optional screenshot generation is enabled.
350 +- `/ui/state` composition with frontend-owned fields.
351 +- `/ui/policy/*` composition, while relay-enforced policy changes are still forwarded to `portal`.
352 +- `/ui/service/status`, derived from relay state for quick-start UI checks.
353 +- `/ui/thumbnail/<hostname>`, when optional screenshot generation is enabled.
354 - The landing-page flag persisted at `PORTAL_FRONTEND_STATE_PATH`; the bundled Compose files store it under `./.portal-certs/frontend-state/state.json`.
355
356 The Go relay remains the owner of authentication, policy enforcement, lease state, tunnel ingress, install scripts, discovery, and x402 facilitator paths.
docs/src/routes/security-model/+page.md
+1 -1
@@ -27,7 +27,7 @@ For relay-hosted names, the SDK builds a tenant-facing TLS server config backed
27
28 Relay API TLS is separate from tenant TLS:
29
30 -- Relay API HTTPS protects `/sdk/*`, `/discovery`, `/admin`, installers, and `/v1/sign`.
30 +- Relay API HTTPS protects `/sdk/*`, `/discovery`, `/api/admin`, installers, and `/v1/sign`.
31 - Tenant TLS protects end-user traffic for lease hostnames.
32 - The internal QUIC datagram backhaul uses `SNI_PORT/udp` with ALPN `portal-tunnel`.
33
docs/src/routes/self-hosting/+page.md
+4 -4
@@ -134,9 +134,9 @@ See [TCP/UDP Tunneling](/tcp-udp-tunneling) for usage details.
134
135 ## Optional: Enable x402 Facilitator
136
137 -The relay can expose a relay-local x402 facilitator at `/x402`. Frontends and
137 +The relay can expose a relay-local x402 facilitator at `/api/x402`. Frontends and
138 configuration tools can read `/sdk/domain` for the current relay's facilitator
139 -URL and network, then call `/x402/supported` for mechanism details when needed.
139 +URL and network, then call `/api/x402/supported` for mechanism details when needed.
140
141 ```yaml
142 environment:
@@ -155,7 +155,7 @@ For CLI-created x402 routes, pass the selected facilitator explicitly:
155 portal expose 3000 \
156 --relays https://relay.example.com:4017 \
157 --discovery=false \
158 - --x402-facilitator-url https://relay.example.com:4017/x402 \
158 + --x402-facilitator-url https://relay.example.com:4017/api/x402 \
159 --x402-network eip155:8453 \
160 --x402-price "$0.001"
161 ```
@@ -167,7 +167,7 @@ policy in the tunnel config. The payment app includes a native paid image route:
167 go run ./cmd/payment-app \
168 --relays https://relay.example.com:4017 \
169 --discovery=false \
170 - --x402-facilitator-url https://relay.example.com:4017/x402 \
170 + --x402-facilitator-url https://relay.example.com:4017/api/x402 \
171 --x402-network eip155:8453 \
172 --x402-price "$0.01"
173 ```
docs/src/routes/siwe-authentication/+page.md
+2 -2
@@ -36,9 +36,9 @@ protocol.
36
37 The relay admin UI uses browser wallet login:
38
39 -1. request `/admin/auth/challenge`
39 +1. request `/api/admin/auth/challenge`
40 2. sign the returned SIWE message with the connected wallet
41 -3. submit `/admin/auth/login`
41 +3. submit `/api/admin/auth/login`
42 4. use the returned `access_token` as `Authorization: Bearer <access_token>`
43
44 The relay identity address is allowed by default. Add more admin wallets with
docs/src/routes/wallet-and-ens/+page.md
+2 -2
@@ -79,9 +79,9 @@ jq -r .address .portal-certs/identity.json
79
80 Admin wallet flow:
81
82 -1. `POST /admin/auth/challenge` with `{ "address": "0x..." }`.
82 +1. `POST /api/admin/auth/challenge` with `{ "address": "0x..." }`.
83 2. Sign the returned `siwe_message` in the browser wallet.
84 -3. `POST /admin/auth/login` with the challenge id, exact SIWE message, and
84 +3. `POST /api/admin/auth/login` with the challenge id, exact SIWE message, and
85 signature.
86 4. The relay returns an `access_token`.
87 5. Admin endpoints require `Authorization: Bearer <access_token>`.
docs/static/examples/nginx-proxy-multi-service/docker-compose.yaml
+3 -2
@@ -5,8 +5,9 @@
5 # Architecture:
6 # nginx:443 (L4 stream, ssl_preread)
7 # - portal.example.com -> nginx:8443 (L7 path split)
8 -# - relay-owned API paths -> portal:${API_PORT:-4017} (portal, HTTPS, Docker network only)
9 -# - presentation API paths -> portal-api:8081 (HTTP)
8 +# - /sdk/*, /discovery*, /v1/sign -> portal:${API_PORT:-4017} (portal protocol, HTTPS, Docker network only)
9 +# - /api/* relay API -> portal:${API_PORT:-4017} (portal, HTTPS, Docker network only)
10 +# - /ui/* presentation API -> portal-api:8081 (HTTP)
11 # - frontend/UI paths -> portal-frontend:8080 (HTTP)
12 # - *.portal.example.com -> portal:443 (portal SNI passthrough, Docker network only)
13 # - everything else -> nginx:8443 (L7 for other apps)
docs/static/examples/nginx-proxy-multi-service/nginx.conf
+13 -33
@@ -66,22 +66,6 @@ http {
66 include /etc/nginx/mime.types;
67 default_type application/octet-stream;
68
69 - upstream portal_frontend {
70 - server portal-frontend:8080;
71 - keepalive 16;
72 - }
73 -
74 - upstream portal_api {
75 - # Portal API listener is HTTPS even though it is reached only internally.
76 - server portal:4017;
77 - keepalive 16;
78 - }
79 -
80 - upstream portal_presentation_api {
81 - server portal-api:8081;
82 - keepalive 16;
83 - }
84 -
69 upstream app_a_backend {
70 server app-a-api:8000;
71 keepalive 32;
@@ -125,7 +109,7 @@ http {
109 text/xml application/xml;
110
111 location = /sdk/connect {
128 - proxy_pass https://portal_api;
112 + proxy_pass https://portal:4017;
113 proxy_ssl_verify off;
114 proxy_ssl_server_name on;
115 proxy_ssl_name $host;
@@ -144,59 +128,55 @@ http {
128 proxy_send_timeout 86400s;
129 }
130
147 - # Presentation-owned API paths belong to portal-api.
148 - location = /state {
149 - proxy_pass http://portal_presentation_api;
150 - proxy_http_version 1.1;
131 + location ~ ^/(sdk/|discovery(?:/|$)|v1/sign$) {
132 + proxy_pass https://portal:4017;
133 + proxy_ssl_verify off;
134 + proxy_ssl_server_name on;
135 + proxy_ssl_name $host;
136
137 proxy_set_header Host $host;
138 proxy_set_header X-Real-IP $remote_addr;
139 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
140 proxy_set_header X-Forwarded-Proto https;
156 - proxy_set_header Connection "";
141
142 proxy_read_timeout 60s;
143 }
144
161 - location ~ ^/(policy($|/)|service/status$|thumbnail/) {
162 - proxy_pass http://portal_presentation_api;
163 - proxy_http_version 1.1;
145 + # Presentation-owned API paths are isolated under /ui/.
146 + location /ui/ {
147 + proxy_pass http://portal-api:8081;
148
149 proxy_set_header Host $host;
150 proxy_set_header X-Real-IP $remote_addr;
151 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
152 proxy_set_header X-Forwarded-Proto https;
153 proxy_set_header Authorization $http_authorization;
170 - proxy_set_header Connection "";
154
155 proxy_read_timeout 60s;
156 }
157
175 - location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery($|/)|healthz$|v1/sign$|x402(/|$)) {
176 - proxy_pass https://portal_api;
158 + location /api/ {
159 + proxy_pass https://portal:4017;
160 proxy_ssl_verify off;
161 proxy_ssl_server_name on;
162 proxy_ssl_name $host;
180 - proxy_http_version 1.1;
163
164 proxy_set_header Host $host;
165 proxy_set_header X-Real-IP $remote_addr;
166 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
167 proxy_set_header X-Forwarded-Proto https;
186 - proxy_set_header Connection "";
168 + proxy_set_header Authorization $http_authorization;
169
170 proxy_read_timeout 60s;
171 }
172
173 location / {
192 - proxy_pass http://portal_frontend;
193 - proxy_http_version 1.1;
174 + proxy_pass http://portal-frontend:8080;
175
176 proxy_set_header Host $host;
177 proxy_set_header X-Real-IP $remote_addr;
178 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
179 proxy_set_header X-Forwarded-Proto https;
199 - proxy_set_header Connection "";
180 }
181 }
182
docs/static/examples/nginx-proxy/docker-compose.yaml
+3 -2
@@ -4,8 +4,9 @@
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:${API_PORT:-4017} (portal, HTTPS, loopback only)
8 -# - presentation API paths -> 127.0.0.1:8081 (portal-api, HTTP, loopback only)
7 +# - /sdk/*, /discovery*, /v1/sign -> 127.0.0.1:${API_PORT:-4017} (portal protocol, HTTPS, loopback only)
8 +# - /api/* relay API -> 127.0.0.1:${API_PORT:-4017} (portal, HTTPS, loopback only)
9 +# - /ui/* presentation API -> 127.0.0.1:8081 (portal-api, HTTP, loopback only)
10 # - frontend/UI paths -> 127.0.0.1:8080 (portal-frontend, HTTP, loopback only)
11 # - *.portal.example.com -> 127.0.0.1:4443 (portal SNI, raw TCP passthrough)
12 # portal:443/udp (QUIC tunnel listener, only if UDP_ENABLED=true)
docs/static/examples/nginx-proxy/nginx.conf
+19 -40
@@ -8,9 +8,10 @@
8 # *.portal.example.com -> 127.0.0.1:4443 (portal SNI, raw TCP passthrough)
9 #
10 # L7 path routing on portal.example.com:
11 -# Relay API paths -> https://127.0.0.1:4017 (portal)
12 -# Presentation API paths -> http://127.0.0.1:8081 (portal-api)
13 -# Frontend/UI paths -> http://127.0.0.1:8080 (portal-frontend)
11 +# /sdk/*, /discovery*, /v1/sign -> https://127.0.0.1:4017 (portal protocols)
12 +# /api/* relay API -> https://127.0.0.1:4017 (portal)
13 +# /ui/* presentation API -> http://127.0.0.1:8081 (portal-api)
14 +# Frontend/UI paths -> http://127.0.0.1:8080 (portal-frontend)
15
16 events {
17 worker_connections 4096;
@@ -56,23 +57,6 @@ http {
57 gzip_types text/plain text/css application/json application/javascript
58 text/xml application/xml application/xml+rss text/javascript;
59
59 - upstream portal_api {
60 - # Portal API listener is HTTPS even though it is reached only internally.
61 - server 127.0.0.1:4017;
62 - keepalive 16;
63 - }
64 -
65 - upstream portal_frontend {
66 - # Static frontend nginx container.
67 - server 127.0.0.1:8080;
68 - keepalive 16;
69 - }
70 -
71 - upstream portal_presentation_api {
72 - server 127.0.0.1:8081;
73 - keepalive 16;
74 - }
75 -
60 server {
61 listen 8443 ssl;
62 server_name portal.example.com;
@@ -90,7 +74,7 @@ http {
74 # /sdk/connect is hijacked by the relay into a long-lived raw TCP
75 # reverse session. Keep HTTP/1.1, disable buffering, and use long timeouts.
76 location = /sdk/connect {
93 - proxy_pass https://portal_api;
77 + proxy_pass https://127.0.0.1:4017;
78 proxy_ssl_verify off;
79 proxy_ssl_server_name on;
80 proxy_ssl_name $host;
@@ -109,62 +93,57 @@ http {
93 proxy_send_timeout 86400s;
94 }
95
112 - # Presentation-owned API paths belong to portal-api.
113 - location = /state {
114 - proxy_pass http://portal_presentation_api;
115 - proxy_http_version 1.1;
96 + location ~ ^/(sdk/|discovery(?:/|$)|v1/sign$) {
97 + proxy_pass https://127.0.0.1:4017;
98 + proxy_ssl_verify off;
99 + proxy_ssl_server_name on;
100 + proxy_ssl_name $host;
101
102 proxy_set_header Host $host;
103 proxy_set_header X-Real-IP $remote_addr;
104 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
105 proxy_set_header X-Forwarded-Proto https;
121 - proxy_set_header Connection "";
106
107 proxy_read_timeout 60s;
108 }
109
126 - location ~ ^/(policy($|/)|service/status$|thumbnail/) {
127 - proxy_pass http://portal_presentation_api;
128 - proxy_http_version 1.1;
110 + # Presentation-owned API paths are isolated under /ui/.
111 + location /ui/ {
112 + proxy_pass http://127.0.0.1:8081;
113
114 proxy_set_header Host $host;
115 proxy_set_header X-Real-IP $remote_addr;
116 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
117 proxy_set_header X-Forwarded-Proto https;
118 proxy_set_header Authorization $http_authorization;
135 - proxy_set_header Connection "";
119
120 proxy_read_timeout 60s;
121 }
122
140 - # Relay API/control endpoints belong to portal. Exact /admin is
141 - # intentionally not matched so the React admin route can be served by
142 - # portal-frontend.
143 - location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery($|/)|healthz$|v1/sign$|x402(/|$)) {
144 - proxy_pass https://portal_api;
123 + # Relay API/control endpoints belong to portal. The frontend /admin
124 + # route falls through to portal-frontend.
125 + location /api/ {
126 + proxy_pass https://127.0.0.1:4017;
127 proxy_ssl_verify off;
128 proxy_ssl_server_name on;
129 proxy_ssl_name $host;
148 - proxy_http_version 1.1;
130
131 proxy_set_header Host $host;
132 proxy_set_header X-Real-IP $remote_addr;
133 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
134 proxy_set_header X-Forwarded-Proto https;
154 - proxy_set_header Connection "";
135 + proxy_set_header Authorization $http_authorization;
136
137 proxy_read_timeout 60s;
138 }
139
140 location / {
160 - proxy_pass http://portal_frontend;
161 - proxy_http_version 1.1;
141 + proxy_pass http://127.0.0.1:8080;
142
143 proxy_set_header Host $host;
144 proxy_set_header X-Real-IP $remote_addr;
145 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
146 proxy_set_header X-Forwarded-Proto https;
167 - proxy_set_header Connection "";
147
148 proxy_read_timeout 60s;
149 }
frontend/AGENTS.md
+10 -10
@@ -4,29 +4,29 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
4
5 ## Frontend-Backend Contracts
6
7 -1. **Public list data comes from `/state`.**
7 +1. **Public list data comes from `/ui/state`.**
8 Go relay returns leases only; `api/server.ts` adds frontend-owned presentation fields mirrored in `src/types/api.ts`.
9 - Why: the Go relay is API-only. Do not reintroduce Go HTML data injection for public lease state.
10
11 -2. **API path constants require dual maintenance.**
12 - Go relay definitions live in `../types/paths.go`; frontend facade paths live in `api/server.ts`, the edge nginx template, and `src/lib/apiPaths.ts`.
13 - - Why: no codegen. A path mismatch produces 404s.
11 +2. **Relay and presentation API paths have separate owners.**
12 + Relay paths are owned by `../types/paths.go` and mirrored in `src/lib/apiPaths.ts` for browser calls; presentation paths stay under `/ui/` in `src/lib/apiPaths.ts`, `api/server.ts`, and the edge nginx template.
13 + - Why: nginx should distinguish the BFF by one prefix instead of enumerating presentation endpoints.
14
15 3. **API envelope shape must match across Go and TS.**
16 All JSON control-plane responses use `{ ok, data?, error?: { code, message } }`.
17 Go shape is `types.APIEnvelope` in `../types/api.go`; Go writers live in `../utils/api.go`; TS parser lives in `src/lib/apiClient.ts`.
18 - Why: backend responses that skip the envelope surface as `invalid_envelope` in the frontend.
19
20 -4. **Admin auth uses bearer tokens returned by `/admin/auth/login`.**
21 - `src/hooks/useAuth.ts` stores the token through `src/lib/adminAuthToken.ts`; `src/lib/apiClient.ts` adds it to `/admin/*` and `/policy/*` requests as `Authorization: Bearer ...`.
20 +4. **Admin auth uses bearer tokens returned by `/api/admin/auth/login`.**
21 + `src/hooks/useAuth.ts` stores the token through `src/lib/adminAuthToken.ts`; `src/lib/apiClient.ts` adds it to `/api/admin/*`, `/api/policy/*`, and `/ui/policy/*` requests as `Authorization: Bearer ...`.
22 - Why: the relay admin API must be usable by any separately hosted frontend without credentialed cookie CORS state.
23
24 5. **`VITE_PORTAL_API_BASE_URL` is the only built-in API origin knob.**
25 Leave it empty for same-origin development/proxying, or set it at build/dev time for a separately hosted relay API.
26 - Why: runtime-generated config files couple the static frontend bundle back to deployment state.
27
28 -6. **Policy state reads are aggregated through `/policy/state`.**
29 - `src/hooks/useAdmin.ts` expects `{ policy, leases }`; policy settings writes go through `/policy`, while lease/IP actions use `/policy/leases` and `/policy/ips`.
28 +6. **Presentation policy state reads are aggregated through `/ui/policy/state`.**
29 + `src/hooks/useAdmin.ts` expects `{ policy, leases }`; presentation policy settings writes go through `/ui/policy`, while lease/IP actions use `/ui/policy/leases` and `/ui/policy/ips`.
30 - Why: splitting those reads across multiple endpoints reintroduces extra request coordination and drift in the admin bootstrap path.
31
32 7. **Lease/policy lease JSON casing is snake_case.**
@@ -34,7 +34,7 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
34 - Why: the frontend should not depend on Go's implicit PascalCase encoder output.
35
36 8. **Admin policy writes identify targets in the JSON body.**
37 - Lease policy writes use `/policy/leases` with `identity_key`; IP policy writes use `/policy/ips` with `ip`.
37 + Presentation lease policy writes use `/ui/policy/leases` with `identity_key`; IP policy writes use `/ui/policy/ips` with `ip`, then `api/server.ts` forwards to relay `/api/policy/*`.
38 - Why: path encoding rules add a second contract surface and are easy to drift across Go and TS.
39
40 9. **Lease metadata has a wire type and a UI parser.**
@@ -42,7 +42,7 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
42 - Why: API contract fields and UI fallback behavior should not be mixed.
43
44 10. **Presentation support is frontend-owned.**
45 - `api/server.ts` serves `/state`, `/policy/*`, `/service/status`, and `/thumbnail/{hostname}` by composing relay data with frontend-owned state.
45 + `api/server.ts` serves `/ui/state`, `/ui/policy/*`, `/ui/service/status`, and `/ui/thumbnail/{hostname}` by composing relay data with frontend-owned state.
46 - Why: landing-page flags, quick-start status, and generated screenshots are presentation support and should not add state or routes to the Go relay API.
47
48 11. **ApprovalMode is a closed two-value enum: `"auto"` | `"manual"`.**
frontend/README.md
+14 -14
@@ -19,10 +19,10 @@ The Go relay is API-only. This frontend is a standalone Vite app that talks to
19 the relay over the JSON API and does not receive server-side injected lease
20 data.
21
22 -- Public relay state is loaded from `/state`.
23 -- Operator policy state is loaded from `/policy/state`.
22 +- Public presentation state is loaded from `/ui/state`.
23 +- Operator presentation policy state is loaded from `/ui/policy/state`.
24 - All JSON API responses use the `{ ok, data?, error? }` envelope parsed by `src/lib/apiClient.ts`.
25 -- `VITE_PORTAL_API_BASE_URL` points the frontend at the same API surface exposed by the public edge nginx. Admin auth uses a bearer token returned by `/admin/auth/login`.
25 +- `VITE_PORTAL_API_BASE_URL` points the frontend at the same API surface exposed by the public edge nginx. Admin auth uses a bearer token returned by `/api/admin/auth/login`.
26
27 ## Project Structure
28
@@ -86,7 +86,7 @@ VITE_PORTAL_API_BASE_URL=https://portal.example.com npm run dev
86
87 The frontend Docker image serves the built Vite app with nginx over HTTP. It
88 does not own API path routing; the public edge nginx routes relay-owned paths to
89 -`portal:4017` and presentation-owned paths to `portal-api:8081`.
89 +`portal:4017` and `/ui/*` presentation paths to `portal-api:8081`.
90 TLS for public domains should live in the outer reverse proxy. The app uses
91 same-origin relative API paths, so it does not need runtime config file
92 generation.
@@ -112,23 +112,23 @@ docker compose up -d portal-frontend
112 Relay server exposes:
113
114 - `/` - relay API identity response
115 -- `/state` - public leases
116 -- `/install.sh` and `/install.ps1` - CLI installers
117 -- `/admin/auth/*` - admin wallet auth endpoints
118 -- `/policy/*` - relay policy endpoints
115 +- `/api/state` - public leases
116 +- `/api/install.sh` and `/api/install.ps1` - CLI installers
117 +- `/api/admin/auth/*` - admin wallet auth endpoints
118 +- `/api/policy/*` - relay policy endpoints
119 - `/sdk/*` - SDK/control endpoints
120 - `/discovery` - relay discovery when enabled
121
122 The TypeScript API service composes frontend-owned presentation
123 -state on top of relay data:
123 +state on top of relay data under the `/ui/` prefix:
124
125 -- `/state` - relay leases plus `landing_page_enabled`
126 -- `/policy/*` - relay policy, with `landing_page_enabled` composed into `/policy` and `/policy/state`
127 -- `/service/status` - hostname and service readiness derived from relay `/state`
128 -- `/thumbnail/{hostname}` - generated screenshots, disabled when `HEADLESS_SHELL_URL` is empty
125 +- `/ui/state` - relay leases plus `landing_page_enabled`
126 +- `/ui/policy/*` - relay policy, with `landing_page_enabled` composed into `/ui/policy` and `/ui/policy/state`
127 +- `/ui/service/status` - hostname and service readiness derived from relay `/api/state`
128 +- `/ui/thumbnail/{hostname}` - generated screenshots, disabled when `HEADLESS_SHELL_URL` is empty
129
130 ## Notes
131
132 -- Relay path constants live in Go (`types/paths.go`); frontend facade paths also need matching entries in `api/server.ts`, the edge nginx config, and `src/lib/apiPaths.ts`.
132 +- Relay path constants are owned by Go (`types/paths.go`) and mirrored in `src/lib/apiPaths.ts` for browser calls; presentation paths live under `/ui/` in `api/server.ts`, the edge nginx config, and `src/lib/apiPaths.ts`.
133 - Frontend API wire types live in `src/types/api.ts`.
134 - Radix Select values cannot be empty strings. Use stable values such as `"all"` and `"default"`.
frontend/api/server.ts
+26 -17
@@ -10,7 +10,7 @@ import { request as httpsRequest, type RequestOptions as HTTPSRequestOptions } f
10 import { dirname } from "node:path";
11 import { setTimeout as delay } from "node:timers/promises";
12 import { URL } from "node:url";
13 -import { API_PATHS } from "../src/lib/apiPaths.js";
13 +import { PRESENTATION_API_PATHS, RELAY_API_PATHS } from "../src/lib/apiPaths.js";
14 import { parseLeaseMetadata } from "../src/lib/metadata.js";
15
16 const PORT = parseIntegerEnv("PORT", 8081);
@@ -391,7 +391,7 @@ function authorizationHeader(req: IncomingMessage): string {
391 }
392
393 async function servePublicState(res: ServerResponse): Promise<void> {
394 - const relayResponse = await requestRelay<RelayPublicStateResponse>(API_PATHS.public.state);
394 + const relayResponse = await requestRelay<RelayPublicStateResponse>(RELAY_API_PATHS.public.state);
395 if (!relayResponse.envelope.ok) {
396 writeRelayEnvelope(res, relayResponse);
397 return;
@@ -400,7 +400,7 @@ async function servePublicState(res: ServerResponse): Promise<void> {
400 }
401
402 async function publicLeases(): Promise<Lease[]> {
403 - const relayResponse = await requestRelay<RelayPublicStateResponse>(API_PATHS.public.state);
403 + const relayResponse = await requestRelay<RelayPublicStateResponse>(RELAY_API_PATHS.public.state);
404 if (!relayResponse.envelope.ok) {
405 return [];
406 }
@@ -438,7 +438,7 @@ async function serveServiceStatus(req: IncomingMessage, res: ServerResponse): Pr
438 }
439
440 async function servePolicyState(req: IncomingMessage, res: ServerResponse): Promise<void> {
441 - const relayResponse = await requestRelay<RelayPolicyStateResponse>(API_PATHS.policy.state, {
441 + const relayResponse = await requestRelay<RelayPolicyStateResponse>(RELAY_API_PATHS.policy.state, {
442 authorization: authorizationHeader(req),
443 });
444 if (!relayResponse.envelope.ok) {
@@ -453,7 +453,7 @@ async function servePolicyState(req: IncomingMessage, res: ServerResponse): Prom
453
454 async function servePolicy(req: IncomingMessage, res: ServerResponse): Promise<void> {
455 if (req.method === "GET") {
456 - const relayResponse = await requestRelay<RelayPolicySettings>(API_PATHS.policy.root, {
456 + const relayResponse = await requestRelay<RelayPolicySettings>(RELAY_API_PATHS.policy.root, {
457 authorization: authorizationHeader(req),
458 });
459 if (!relayResponse.envelope.ok) {
@@ -479,7 +479,7 @@ async function servePolicy(req: IncomingMessage, res: ServerResponse): Promise<v
479 let relayBody = { ...body };
480 delete relayBody.landing_page_enabled;
481 if (!("approval_mode" in relayBody) || !("udp" in relayBody) || !("tcp_port" in relayBody)) {
482 - const current = await requestRelay<RelayPolicyStateResponse>(API_PATHS.policy.state, {
482 + const current = await requestRelay<RelayPolicyStateResponse>(RELAY_API_PATHS.policy.state, {
483 authorization: authorizationHeader(req),
484 });
485 if (!current.envelope.ok) {
@@ -493,7 +493,7 @@ async function servePolicy(req: IncomingMessage, res: ServerResponse): Promise<v
493 tcp_port: relayBody.tcp_port ?? currentSettings.tcp_port,
494 };
495 }
496 - const relayResponse = await requestRelay<RelayPolicySettings>(API_PATHS.policy.root, {
496 + const relayResponse = await requestRelay<RelayPolicySettings>(RELAY_API_PATHS.policy.root, {
497 method: "POST",
498 body: relayBody,
499 authorization: authorizationHeader(req),
@@ -840,12 +840,12 @@ function loadThumbnail(hostname: string): Promise<Buffer> {
840
841 function requestedThumbnailHostname(req: IncomingMessage): string {
842 const url = new URL(req.url || "/", "http://api.local");
843 - if (!url.pathname.startsWith(API_PATHS.thumbnail.prefix)) {
843 + if (!url.pathname.startsWith(PRESENTATION_API_PATHS.thumbnail.prefix)) {
844 return "";
845 }
846 try {
847 const hostname = normalizeHostname(
848 - decodeURIComponent(url.pathname.slice(API_PATHS.thumbnail.prefix.length))
848 + decodeURIComponent(url.pathname.slice(PRESENTATION_API_PATHS.thumbnail.prefix.length))
849 );
850 return hostname.includes("*") ? "" : hostname;
851 } catch {
@@ -904,7 +904,7 @@ const server = createServer((req, res) => {
904 writeData(res, 200, { status: "ok" });
905 return;
906 }
907 - if (url.pathname === API_PATHS.public.state) {
907 + if (url.pathname === PRESENTATION_API_PATHS.public.state) {
908 if (req.method !== "GET") {
909 writeMethodNotAllowed(res);
910 return;
@@ -912,7 +912,7 @@ const server = createServer((req, res) => {
912 await servePublicState(res);
913 return;
914 }
915 - if (url.pathname === API_PATHS.service.status) {
915 + if (url.pathname === PRESENTATION_API_PATHS.service.status) {
916 if (req.method !== "GET") {
917 writeMethodNotAllowed(res);
918 return;
@@ -920,7 +920,7 @@ const server = createServer((req, res) => {
920 await serveServiceStatus(req, res);
921 return;
922 }
923 - if (url.pathname === API_PATHS.policy.state) {
923 + if (url.pathname === PRESENTATION_API_PATHS.policy.state) {
924 if (req.method !== "GET") {
925 writeMethodNotAllowed(res);
926 return;
@@ -928,7 +928,7 @@ const server = createServer((req, res) => {
928 await servePolicyState(req, res);
929 return;
930 }
931 - if (url.pathname === API_PATHS.policy.root) {
931 + if (url.pathname === PRESENTATION_API_PATHS.policy.root) {
932 if (req.method !== "GET" && req.method !== "POST") {
933 writeMethodNotAllowed(res, "GET, POST");
934 return;
@@ -936,15 +936,24 @@ const server = createServer((req, res) => {
936 await servePolicy(req, res);
937 return;
938 }
939 - if (url.pathname === API_PATHS.policy.leases || url.pathname === API_PATHS.policy.ips) {
939 + if (
940 + url.pathname === PRESENTATION_API_PATHS.policy.leases ||
941 + url.pathname === PRESENTATION_API_PATHS.policy.ips
942 + ) {
943 if (req.method !== "POST") {
944 writeMethodNotAllowed(res, "POST");
945 return;
946 }
944 - await forwardPolicyUpdate(req, res, url.pathname);
947 + await forwardPolicyUpdate(
948 + req,
949 + res,
950 + url.pathname === PRESENTATION_API_PATHS.policy.leases
951 + ? RELAY_API_PATHS.policy.leases
952 + : RELAY_API_PATHS.policy.ips
953 + );
954 return;
955 }
947 - if (url.pathname.startsWith(API_PATHS.thumbnail.prefix)) {
956 + if (url.pathname.startsWith(PRESENTATION_API_PATHS.thumbnail.prefix)) {
957 await serveThumbnail(req, res);
958 return;
959 }
@@ -952,7 +961,7 @@ const server = createServer((req, res) => {
961 })().catch((error) => {
962 console.warn("portal api request failed", error);
963 const url = new URL(req.url || "/", "http://api.local");
955 - if (url.pathname.startsWith(API_PATHS.thumbnail.prefix)) {
964 + if (url.pathname.startsWith(PRESENTATION_API_PATHS.thumbnail.prefix)) {
965 writeNotFound(res);
966 return;
967 }
frontend/src/lib/apiClient.test.ts
+4 -4
@@ -40,9 +40,9 @@ describe("apiClient", () => {
40 jsonResponse({ ok: true, data: { status: "ok" } }),
41 );
42
43 - await apiClient.get("/state");
43 + await apiClient.get("/ui/state");
44
45 - expect(fetchMock.mock.calls[0]?.[0]).toBe("https://portal.example.com/api/state");
45 + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://portal.example.com/api/ui/state");
46 });
47
48 it("rejects successful non-envelope JSON payloads", async () => {
@@ -162,7 +162,7 @@ describe("apiClient", () => {
162 writeAdminAuthToken("admin-token");
163 fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, data: {} }));
164
165 - await apiClient.post("/admin/auth/logout");
165 + await apiClient.post("/api/admin/auth/logout");
166
167 const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
168 expect(init.credentials).toBe("same-origin");
@@ -176,7 +176,7 @@ describe("apiClient", () => {
176 writeAdminAuthToken("admin-token");
177 fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, data: {} }));
178
179 - await apiClient.post("/policy/leases", {
179 + await apiClient.post("/ui/policy/leases", {
180 identity_key: "relay:0x1",
181 is_approved: true,
182 });
frontend/src/lib/apiClient.ts
+21 -4
@@ -1,5 +1,5 @@
1 import { readAdminAuthToken } from "@/lib/adminAuthToken";
2 -import { API_PATHS } from "@/lib/apiPaths";
2 +import { API_PATHS, RELAY_API_PATHS } from "@/lib/apiPaths";
3 import type { APIEnvelope } from "@/types/api";
4
5 export class APIClientError extends Error {
@@ -29,9 +29,24 @@ function resolveAPIURL(path: string): string {
29 if (!baseURL) {
30 return path;
31 }
32 - const normalizedBase = baseURL.endsWith("/") ? baseURL.slice(0, -1) : baseURL;
32 const normalizedPath = path.startsWith("/") ? path : `/${path}`;
34 - return `${normalizedBase}${normalizedPath}`;
33 + const parsedBase = new URL(baseURL);
34 + const basePath = parsedBase.pathname.replace(/\/$/, "");
35 + if (
36 + basePath !== "" &&
37 + (normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`))
38 + ) {
39 + parsedBase.pathname = normalizedPath;
40 + } else {
41 + parsedBase.pathname = `${basePath}${normalizedPath}`;
42 + }
43 + parsedBase.search = "";
44 + parsedBase.hash = "";
45 + return parsedBase.toString();
46 +}
47 +
48 +function isPathOrChild(pathname: string, root: string): boolean {
49 + return pathname === root || pathname.startsWith(`${root}/`);
50 }
51
52 function ensureJsonEnvelope<T>(raw: unknown, path: string, status: number): APIEnvelope<T> {
@@ -97,7 +112,9 @@ async function request<T>(path: string, init: RequestInit): Promise<T> {
112 };
113 const pathname = new URL(path, window.location.origin).pathname;
114 const requiresAdminAuth =
100 - pathname === "/policy" || pathname.startsWith("/policy/") || pathname.startsWith("/admin/");
115 + isPathOrChild(pathname, API_PATHS.policy.root) ||
116 + isPathOrChild(pathname, RELAY_API_PATHS.policy.root) ||
117 + isPathOrChild(pathname, RELAY_API_PATHS.admin.root);
118 if (
119 requiresAdminAuth &&
120 pathname !== API_PATHS.admin.authChallenge &&
frontend/src/lib/apiPaths.ts
+38 -17
@@ -1,35 +1,56 @@
1 -export const API_PATHS = {
1 +export const RELAY_API_PATHS = {
2 public: {
3 - state: "/state",
3 + state: "/api/state",
4 },
5 admin: {
6 - authChallenge: "/admin/auth/challenge",
7 - authLogin: "/admin/auth/login",
8 - logout: "/admin/auth/logout",
9 - authStatus: "/admin/auth/status",
6 + root: "/api/admin",
7 + authChallenge: "/api/admin/auth/challenge",
8 + authLogin: "/api/admin/auth/login",
9 + logout: "/api/admin/auth/logout",
10 + authStatus: "/api/admin/auth/status",
11 },
12 policy: {
12 - root: "/policy",
13 - state: "/policy/state",
14 - leases: "/policy/leases",
15 - ips: "/policy/ips",
13 + root: "/api/policy",
14 + state: "/api/policy/state",
15 + leases: "/api/policy/leases",
16 + ips: "/api/policy/ips",
17 },
18 sdk: {
19 domain: "/sdk/domain",
20 },
21 + discovery: "/discovery",
22 + install: {
23 + shell: "/api/install.sh",
24 + powershell: "/api/install.ps1",
25 + },
26 +} as const;
27 +
28 +export const PRESENTATION_API_PATHS = {
29 + public: {
30 + state: "/ui/state",
31 + },
32 + policy: {
33 + root: "/ui/policy",
34 + state: "/ui/policy/state",
35 + leases: "/ui/policy/leases",
36 + ips: "/ui/policy/ips",
37 + },
38 service: {
21 - status: "/service/status",
39 + status: "/ui/service/status",
40 },
41 thumbnail: {
24 - prefix: "/thumbnail/",
25 - },
26 - discovery: "/discovery",
27 - install: {
28 - shell: "/install.sh",
29 - powershell: "/install.ps1",
42 + prefix: "/ui/thumbnail/",
43 },
44 } as const;
45
46 +export const API_PATHS = {
47 + ...RELAY_API_PATHS,
48 + public: PRESENTATION_API_PATHS.public,
49 + policy: PRESENTATION_API_PATHS.policy,
50 + service: PRESENTATION_API_PATHS.service,
51 + thumbnail: PRESENTATION_API_PATHS.thumbnail,
52 +} as const;
53 +
54 export const ROUTE_PATHS = {
55 home: "/",
56 serverDetail: "/server/:id",
frontend/src/lib/tunnelCommand.test.ts
+3 -3
@@ -23,7 +23,7 @@ describe("tunnelCommand", () => {
23
24 expect(command).toBe(
25 [
26 - "curl -ksSL https://localhost:4017/install.sh | bash",
26 + "curl -ksSL https://localhost:4017/api/install.sh | bash",
27 "portal expose 3000 --name my-app --relays https://localhost:4017",
28 ].join("\n")
29 );
@@ -46,14 +46,14 @@ describe("tunnelCommand", () => {
46 expect(buildTunnelCommand(options)).toBe(
47 [
48 `$ProgressPreference = 'SilentlyContinue'`,
49 - `irm https://relay.example.com/install.ps1 | iex`,
49 + `irm https://relay.example.com/api/install.ps1 | iex`,
50 `portal expose localhost:3000 --name my-app --relays https://relay.example.com --discovery=false --thumbnail https://example.com/thumb.png`,
51 ].join("\n")
52 );
53 expect(buildTunnelDisplayCommand(options)).toBe(
54 [
55 `$ProgressPreference = 'SilentlyContinue'`,
56 - `irm https://relay.example.com/install.ps1 | iex`,
56 + `irm https://relay.example.com/api/install.ps1 | iex`,
57 `portal expose localhost:3000 --name my-app`,
58 `--relays https://relay.example.com --discovery=false --thumbnail https://example.com/thumb.png`,
59 ].join("\n")
nginx.conf.template
+14 -34
@@ -57,21 +57,6 @@ http {
57 gzip_types text/plain text/css application/json application/javascript
58 text/xml application/xml application/xml+rss text/javascript;
59
60 - upstream portal_api {
61 - server portal:4017;
62 - keepalive 16;
63 - }
64 -
65 - upstream portal_presentation_api {
66 - server portal-api:8081;
67 - keepalive 16;
68 - }
69 -
70 - upstream portal_frontend {
71 - server portal-frontend:8080;
72 - keepalive 16;
73 - }
74 -
60 server {
61 listen 8443 ssl;
62 server_name ${PORTAL_HOST};
@@ -87,7 +72,7 @@ http {
72 ssl_session_timeout 10m;
73
74 location = /sdk/connect {
90 - proxy_pass https://portal_api;
75 + proxy_pass https://portal:4017;
76 proxy_ssl_verify off;
77 proxy_ssl_server_name on;
78 proxy_ssl_name $host;
@@ -106,59 +91,54 @@ http {
91 proxy_send_timeout 86400s;
92 }
93
109 - location = /state {
110 - proxy_pass http://portal_presentation_api;
111 - proxy_http_version 1.1;
94 + location ~ ^/(sdk/|discovery(?:/|$)|v1/sign$) {
95 + proxy_pass https://portal:4017;
96 + proxy_ssl_verify off;
97 + proxy_ssl_server_name on;
98 + proxy_ssl_name $host;
99
100 proxy_set_header Host $host;
101 proxy_set_header X-Real-IP $remote_addr;
102 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
103 proxy_set_header X-Forwarded-Proto https;
117 - proxy_set_header Connection "";
104
105 proxy_read_timeout 60s;
106 }
107
122 - location ~ ^/(policy($|/)|service/status$|thumbnail/) {
123 - proxy_pass http://portal_presentation_api;
124 - proxy_http_version 1.1;
108 + location /api/ {
109 + proxy_pass https://portal:4017;
110 + proxy_ssl_verify off;
111 + proxy_ssl_server_name on;
112 + proxy_ssl_name $host;
113
114 proxy_set_header Host $host;
115 proxy_set_header X-Real-IP $remote_addr;
116 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
117 proxy_set_header X-Forwarded-Proto https;
118 proxy_set_header Authorization $http_authorization;
131 - proxy_set_header Connection "";
119
120 proxy_read_timeout 60s;
121 }
122
136 - location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery($|/)|healthz$|v1/sign$|x402(/|$)) {
137 - proxy_pass https://portal_api;
138 - proxy_ssl_verify off;
139 - proxy_ssl_server_name on;
140 - proxy_ssl_name $host;
141 - proxy_http_version 1.1;
123 + location /ui/ {
124 + proxy_pass http://portal-api:8081;
125
126 proxy_set_header Host $host;
127 proxy_set_header X-Real-IP $remote_addr;
128 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
129 proxy_set_header X-Forwarded-Proto https;
130 proxy_set_header Authorization $http_authorization;
148 - proxy_set_header Connection "";
131
132 proxy_read_timeout 60s;
133 }
134
135 location / {
154 - proxy_pass http://portal_frontend;
155 - proxy_http_version 1.1;
136 + proxy_pass http://portal-frontend:8080;
137
138 proxy_set_header Host $host;
139 proxy_set_header X-Real-IP $remote_addr;
140 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
141 proxy_set_header X-Forwarded-Proto https;
161 - proxy_set_header Connection "";
142
143 proxy_read_timeout 60s;
144 }
portal/lease_test.go
+3 -3
@@ -351,7 +351,7 @@ func TestIssueRegisterChallengeBoundsPendingPerIP(t *testing.T) {
351 for i := 0; i < defaultRegisterChallengeOutstandingPerIP; i++ {
352 _, err := registry.issueRegisterChallenge(types.RegisterChallengeRequest{
353 Identity: newTestLeaseIdentity(t, fmt.Sprintf("demo-%d", i)),
354 - }, "example.com", "https://example.com/sdk/register", clientIP)
354 + }, "example.com", "https://example.com"+types.PathSDKRegister, clientIP)
355 if err != nil {
356 t.Fatalf("issueRegisterChallenge(%d) error = %v", i, err)
357 }
@@ -359,7 +359,7 @@ func TestIssueRegisterChallengeBoundsPendingPerIP(t *testing.T) {
359
360 _, err := registry.issueRegisterChallenge(types.RegisterChallengeRequest{
361 Identity: newTestLeaseIdentity(t, "overflow"),
362 - }, "example.com", "https://example.com/sdk/register", clientIP)
362 + }, "example.com", "https://example.com"+types.PathSDKRegister, clientIP)
363 if !errors.Is(err, errRegisterChallengePending) {
364 t.Fatalf("issueRegisterChallenge() error = %v, want pending limit", err)
365 }
@@ -377,7 +377,7 @@ func TestIssueRegisterChallengeBoundsPendingPerIP(t *testing.T) {
377
378 _, err = registry.issueRegisterChallenge(types.RegisterChallengeRequest{
379 Identity: newTestLeaseIdentity(t, "after-cleanup"),
380 - }, "example.com", "https://example.com/sdk/register", clientIP)
380 + }, "example.com", "https://example.com"+types.PathSDKRegister, clientIP)
381 if err != nil {
382 t.Fatalf("issueRegisterChallenge() after expired cleanup error = %v", err)
383 }
portal/server_test.go
+10 -10
@@ -220,20 +220,20 @@ func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
220
221 healthResp, err := client.Get("https://" + utils.HostPortOrLoopback(server.apiListener.Addr().String()) + types.PathHealthz)
222 if err != nil {
223 - t.Fatalf("GET /healthz error = %v", err)
223 + t.Fatalf("GET /api/healthz error = %v", err)
224 }
225 defer healthResp.Body.Close()
226
227 if healthResp.StatusCode != http.StatusOK {
228 - t.Fatalf("GET /healthz status = %d, want %d", healthResp.StatusCode, http.StatusOK)
228 + t.Fatalf("GET /api/healthz status = %d, want %d", healthResp.StatusCode, http.StatusOK)
229 }
230
231 var healthEnvelope types.APIEnvelope[map[string]string]
232 if err := json.NewDecoder(healthResp.Body).Decode(&healthEnvelope); err != nil {
233 - t.Fatalf("decode /healthz response: %v", err)
233 + t.Fatalf("decode /api/healthz response: %v", err)
234 }
235 if !healthEnvelope.OK || healthEnvelope.Data["status"] != "ok" {
236 - t.Fatalf("GET /healthz response = %+v, want ok status", healthEnvelope)
236 + t.Fatalf("GET /api/healthz response = %+v, want ok status", healthEnvelope)
237 }
238
239 signResp, err := client.Get("https://" + utils.HostPortOrLoopback(server.apiListener.Addr().String()) + types.PathV1Sign)
@@ -349,11 +349,11 @@ func TestServerStartDomainReportsCompatibilityInfo(t *testing.T) {
349 if envelope.Data.X402.NetworkName != "Base Sepolia" {
350 t.Fatalf("DomainResponse.X402.NetworkName = %q, want Base Sepolia", envelope.Data.X402.NetworkName)
351 }
352 - if envelope.Data.X402.URL != "https://localhost:4017/x402" {
353 - t.Fatalf("DomainResponse.X402.URL = %q, want https://localhost:4017/x402", envelope.Data.X402.URL)
352 + if envelope.Data.X402.URL != "https://localhost:4017/api/x402" {
353 + t.Fatalf("DomainResponse.X402.URL = %q, want https://localhost:4017/api/x402", envelope.Data.X402.URL)
354 }
355 - if envelope.Data.X402.SupportedURL != "https://localhost:4017/x402/supported" {
356 - t.Fatalf("DomainResponse.X402.SupportedURL = %q, want https://localhost:4017/x402/supported", envelope.Data.X402.SupportedURL)
355 + if envelope.Data.X402.SupportedURL != "https://localhost:4017/api/x402/supported" {
356 + t.Fatalf("DomainResponse.X402.SupportedURL = %q, want https://localhost:4017/api/x402/supported", envelope.Data.X402.SupportedURL)
357 }
358 }
359
@@ -420,12 +420,12 @@ func TestServerStartUsesManualCertificateWithoutACMEProvider(t *testing.T) {
420
421 healthResp, err := client.Get("https://" + utils.HostPortOrLoopback(server.apiListener.Addr().String()) + types.PathHealthz)
422 if err != nil {
423 - t.Fatalf("GET /healthz error = %v", err)
423 + t.Fatalf("GET /api/healthz error = %v", err)
424 }
425 defer healthResp.Body.Close()
426
427 if healthResp.StatusCode != http.StatusOK {
428 - t.Fatalf("GET /healthz status = %d, want %d", healthResp.StatusCode, http.StatusOK)
428 + t.Fatalf("GET /api/healthz status = %d, want %d", healthResp.StatusCode, http.StatusOK)
429 }
430 }
431
types/paths.go
+32 -29
@@ -1,43 +1,46 @@
1 package types
2
3 const (
4 - PathRoot = "/"
5 - PathV1Sign = "/v1/sign"
6 - PathHealthz = "/healthz"
7 - PathState = "/state"
8 -
9 - PathAdmin = "/admin"
10 - PathAdminPrefix = "/admin/"
11 - PathAdminAuthChallenge = "/admin/auth/challenge"
12 - PathAdminAuthLogin = "/admin/auth/login"
13 - PathAdminLogout = "/admin/auth/logout"
14 - PathAdminAuthStatus = "/admin/auth/status"
15 -
16 - PathPolicy = "/policy"
17 - PathPolicyPrefix = "/policy/"
4 + PathRoot = "/"
5 + PathAPIPrefix = "/api"
6 + PathHealthz = PathAPIPrefix + "/healthz"
7 + PathState = PathAPIPrefix + "/state"
8 +
9 + PathAdmin = PathAPIPrefix + "/admin"
10 + PathAdminPrefix = PathAdmin + "/"
11 + PathAdminAuthChallenge = PathAdmin + "/auth/challenge"
12 + PathAdminAuthLogin = PathAdmin + "/auth/login"
13 + PathAdminLogout = PathAdmin + "/auth/logout"
14 + PathAdminAuthStatus = PathAdmin + "/auth/status"
15 +
16 + PathPolicy = PathAPIPrefix + "/policy"
17 + PathPolicyPrefix = PathPolicy + "/"
18 PathPolicyState = PathPolicy + "/state"
19 PathPolicyLeases = PathPolicy + "/leases"
20 PathPolicyIPs = PathPolicy + "/ips"
21
22 - PathInstallShell = "/install.sh"
23 - PathInstallPowerShell = "/install.ps1"
24 - PathInstallBinPrefix = "/install/bin/"
22 + PathInstallShell = PathAPIPrefix + "/install.sh"
23 + PathInstallPowerShell = PathAPIPrefix + "/install.ps1"
24 + PathInstallBinPrefix = PathAPIPrefix + "/install/bin/"
25
26 - PathSDKDomain = "/sdk/domain"
27 - PathSDKRegisterChallenge = "/sdk/register/challenge"
28 - PathSDKRegister = "/sdk/register"
29 - PathSDKRenew = "/sdk/renew"
30 - PathSDKUnregister = "/sdk/unregister"
31 - PathSDKHop = "/sdk/hop"
32 - PathSDKConnect = "/sdk/connect"
33 -
34 - PathDiscovery = "/discovery"
35 - PathDiscoveryAnnounce = "/discovery/announce"
36 -
37 - PathX402Facilitator = "/x402"
26 + PathX402Facilitator = PathAPIPrefix + "/x402"
27 X402SupportedPath = PathX402Facilitator + "/supported"
28 X402VerifyPath = PathX402Facilitator + "/verify"
29 X402SettlePath = PathX402Facilitator + "/settle"
30 +
31 + PathV1Sign = "/v1/sign"
32 +
33 + PathSDKPrefix = "/sdk"
34 + PathSDKDomain = PathSDKPrefix + "/domain"
35 + PathSDKRegisterChallenge = PathSDKPrefix + "/register/challenge"
36 + PathSDKRegister = PathSDKPrefix + "/register"
37 + PathSDKRenew = PathSDKPrefix + "/renew"
38 + PathSDKUnregister = PathSDKPrefix + "/unregister"
39 + PathSDKHop = PathSDKPrefix + "/hop"
40 + PathSDKConnect = PathSDKPrefix + "/connect"
41 +
42 + PathDiscovery = "/discovery"
43 + PathDiscoveryAnnounce = PathDiscovery + "/announce"
44 )
45
46 const (