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 (