refactor: rename API_PATHS to BROWSER_API_PATHS for clarity and consistency across the codebase
Kim committed
Jun 1, 2026 at 12:29 UTC
8db18fca7b861d13a9cf5c498e1a48229194d097
14 files changed
+61
-47
frontend/AGENTS.md
+1
-1
@@ -22,7 +22,7 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
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.
25
+ Leave it empty for same-origin development/proxying, or set it at build/dev time to the public edge origin/base path. Do not point it at `/api`; `/api`, `/ui`, `/sdk`, and `/discovery` are sibling paths.
26
- Why: runtime-generated config files couple the static frontend bundle back to deployment state.
27
28
6. **Presentation policy state reads are aggregated through `/ui/policy/state`.**
frontend/README.md
+2
-2
@@ -22,7 +22,7 @@ data.
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 `/api/admin/auth/login`.
25
+- `VITE_PORTAL_API_BASE_URL` points the frontend at the public edge origin or deployment base path, not at `/api`; `/api`, `/ui`, `/sdk`, and `/discovery` are sibling paths. Admin auth uses a bearer token returned by `/api/admin/auth/login`.
26
27
## Project Structure
28
@@ -76,7 +76,7 @@ npm run dev
76
77
Default dev URL: `http://localhost:5173`.
78
79
-To run against another origin, build or run the frontend with the public frontend/API URL:
79
+To run against another origin, build or run the frontend with the public edge origin:
80
81
```bash
82
VITE_PORTAL_API_BASE_URL=https://portal.example.com npm run dev
frontend/src/components/Header.tsx
+2
-2
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
4
import { ThemeToggleButton } from "@/components/ThemeToggleButton";
5
import { useAuth } from "@/hooks/useAuth";
6
import { apiClient } from "@/lib/apiClient";
7
-import { API_PATHS } from "@/lib/apiPaths";
7
+import { BROWSER_API_PATHS } from "@/lib/apiPaths";
8
import type { DomainResponse, X402FacilitatorInfo } from "@/types/api";
9
import {
10
Tooltip,
@@ -81,7 +81,7 @@ export function Header({
81
void (async () => {
82
try {
83
const status = await apiClient.get<DomainResponse>(
84
- API_PATHS.sdk.domain
84
+ BROWSER_API_PATHS.sdk.domain
85
);
86
if (!cancelled) {
87
setReleaseVersion(
frontend/src/components/ServerListView.tsx
+3
-3
@@ -15,7 +15,7 @@ import { ApprovalModeToggle } from "@/components/button/ApprovalModeToggle";
15
import { FloatingActionBar } from "@/components/FloatingActionBar";
16
import { readCurrentOrigin } from "@/hooks/useTunnelCommand";
17
import { apiClient } from "@/lib/apiClient";
18
-import { API_PATHS, ROUTE_PATHS } from "@/lib/apiPaths";
18
+import { BROWSER_API_PATHS, ROUTE_PATHS } from "@/lib/apiPaths";
19
import type { DiscoveryResponse, DomainResponse, RelayDescriptor } from "@/types/api";
20
import {
21
Dialog,
@@ -41,7 +41,7 @@ async function loadRelayReleaseVersion(
41
relayURL: string,
42
timeoutMs: number = 5000
43
): Promise<string> {
44
- const domainURL = new URL(API_PATHS.sdk.domain, relayURL).toString();
44
+ const domainURL = new URL(BROWSER_API_PATHS.sdk.domain, relayURL).toString();
45
46
const timeoutPromise = new Promise<never>((_, reject) => {
47
setTimeout(() => reject(new Error("timeout")), timeoutMs);
@@ -301,7 +301,7 @@ export function ServerListView({
301
302
try {
303
const discovery =
304
- await apiClient.get<DiscoveryResponse>(API_PATHS.discovery);
304
+ await apiClient.get<DiscoveryResponse>(BROWSER_API_PATHS.discovery);
305
nextKnownRelays = normalizeKnownRelays(
306
discovery?.relays,
307
currentRelayURL
frontend/src/components/TunnelCommandForm.tsx
+2
-2
@@ -8,7 +8,7 @@ import {
8
import { Check, Copy, RefreshCw, X } from "lucide-react";
9
import { Input } from "@/components/ui/input";
10
import { apiClient } from "@/lib/apiClient";
11
-import { API_PATHS } from "@/lib/apiPaths";
11
+import { BROWSER_API_PATHS } from "@/lib/apiPaths";
12
import type { ServiceStatusResponse } from "@/types/api";
13
import { cn } from "@/lib/utils";
14
import {
@@ -88,7 +88,7 @@ function HeroTunnelCommandForm({
88
try {
89
const params = new URLSearchParams({ hostname: statusHostname });
90
const statusResponse = await apiClient.get<ServiceStatusResponse>(
91
- `${API_PATHS.service.status}?${params.toString()}`
91
+ `${BROWSER_API_PATHS.service.status}?${params.toString()}`
92
);
93
if (cancelled) {
94
return;
frontend/src/hooks/useAdmin.test.ts
+11
-11
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
3
4
import type { PolicyLease, PolicySettings } from "@/types/api";
5
import { useAdmin } from "@/hooks/useAdmin";
6
-import { API_PATHS } from "@/lib/apiPaths";
6
+import { BROWSER_API_PATHS } from "@/lib/apiPaths";
7
import { APIClientError, apiClient } from "@/lib/apiClient";
8
9
type DeferredPolicyState = {
@@ -91,7 +91,7 @@ describe("useAdmin", () => {
91
vi.clearAllMocks();
92
93
mockGet.mockImplementation(async (path: string) => {
94
- if (path === API_PATHS.policy.state) {
94
+ if (path === BROWSER_API_PATHS.policy.state) {
95
return {
96
leases: [buildLease("0x00000000000000000000000000000000000000A1")],
97
policy: { ...buildSettings(), approval_mode: "not-a-mode" },
@@ -101,7 +101,7 @@ describe("useAdmin", () => {
101
});
102
103
mockPost.mockImplementation(async <T,>(path: string, body?: unknown): Promise<T> => {
104
- if (path === API_PATHS.policy.root) {
104
+ if (path === BROWSER_API_PATHS.policy.root) {
105
return body as T;
106
}
107
return {} as T;
@@ -122,7 +122,7 @@ describe("useAdmin", () => {
122
123
it("surfaces fetchData API errors", async () => {
124
mockGet.mockImplementation(async (path: string) => {
125
- if (path === API_PATHS.policy.state) {
125
+ if (path === BROWSER_API_PATHS.policy.state) {
126
throw new APIClientError("failed to load leases", 500, "server_error");
127
}
128
throw new Error(`Unexpected GET path: ${path}`);
@@ -180,8 +180,8 @@ describe("useAdmin", () => {
180
});
181
182
const calledPaths = mockPost.mock.calls.map(([path]) => path as string);
183
- expect(calledPaths).toContain(API_PATHS.policy.leases);
184
- expect(mockPost).toHaveBeenCalledWith(API_PATHS.policy.leases, {
183
+ expect(calledPaths).toContain(BROWSER_API_PATHS.policy.leases);
184
+ expect(mockPost).toHaveBeenCalledWith(BROWSER_API_PATHS.policy.leases, {
185
identity_key: identityKey,
186
is_approved: true,
187
});
@@ -199,7 +199,7 @@ describe("useAdmin", () => {
199
});
200
201
expect(mockPost).toHaveBeenCalledWith(
202
- API_PATHS.policy.leases,
202
+ BROWSER_API_PATHS.policy.leases,
203
{
204
identity_key: "relay-1:0x00000000000000000000000000000000000000a1",
205
bps: 4096,
@@ -214,7 +214,7 @@ describe("useAdmin", () => {
214
| undefined;
215
216
mockGet.mockImplementation((path: string) => {
217
- if (path !== API_PATHS.policy.state) {
217
+ if (path !== BROWSER_API_PATHS.policy.state) {
218
throw new Error(`Unexpected GET path: ${path}`);
219
}
220
getCalls++;
@@ -252,7 +252,7 @@ describe("useAdmin", () => {
252
253
it("bulk deny posts deduped identity keys in lease policy bodies", async () => {
254
mockGet.mockImplementation(async (path: string) => {
255
- if (path === API_PATHS.policy.state) {
255
+ if (path === BROWSER_API_PATHS.policy.state) {
256
return {
257
leases: [
258
buildLease("0x00000000000000000000000000000000000000A1", "relay-1"),
@@ -283,8 +283,8 @@ describe("useAdmin", () => {
283
expect(denyCalls).toHaveLength(2);
284
expect(denyCalls).toEqual(
285
expect.arrayContaining([
286
- [API_PATHS.policy.leases, { identity_key: identityKeyA, is_denied: true }],
287
- [API_PATHS.policy.leases, { identity_key: identityKeyB, is_denied: true }],
286
+ [BROWSER_API_PATHS.policy.leases, { identity_key: identityKeyA, is_denied: true }],
287
+ [BROWSER_API_PATHS.policy.leases, { identity_key: identityKeyB, is_denied: true }],
288
]),
289
);
290
});
frontend/src/hooks/useAdmin.ts
+6
-6
@@ -1,7 +1,7 @@
1
import { useEffect, useMemo, useState } from "react";
2
import { useList, type BaseServer } from "@/hooks/useList";
3
import type { BanFilter } from "@/types/filters";
4
-import { API_PATHS } from "@/lib/apiPaths";
4
+import { BROWSER_API_PATHS } from "@/lib/apiPaths";
5
import { APIClientError, apiClient } from "@/lib/apiClient";
6
import { parseLeaseMetadata, resolveLeaseThumbnail } from "@/lib/metadata";
7
import type {
@@ -141,7 +141,7 @@ interface PolicyViewState {
141
}
142
143
async function loadPolicyState(): Promise<PolicyViewState> {
144
- const state = await apiClient.get<PolicyStateResponse>(API_PATHS.policy.state);
144
+ const state = await apiClient.get<PolicyStateResponse>(BROWSER_API_PATHS.policy.state);
145
const normalizedLeases = Array.isArray(state?.leases) ? state.leases : [];
146
147
return {
@@ -245,7 +245,7 @@ export function useAdmin(enabled = true) {
245
};
246
247
const postPolicySettings = async (settings: PolicySettings) => {
248
- const response = await apiClient.post<PolicySettings>(API_PATHS.policy.root, settings);
248
+ const response = await apiClient.post<PolicySettings>(BROWSER_API_PATHS.policy.root, settings);
249
setPolicySettings(normalizePolicySettings(response));
250
};
251
@@ -261,7 +261,7 @@ export function useAdmin(enabled = true) {
261
if (!identityKey) {
262
throw new Error("Missing lease identity");
263
}
264
- await apiClient.post<unknown>(API_PATHS.policy.leases, {
264
+ await apiClient.post<unknown>(BROWSER_API_PATHS.policy.leases, {
265
identity_key: identityKey,
266
...policy,
267
} satisfies LeasePolicyUpdate);
@@ -353,7 +353,7 @@ export function useAdmin(enabled = true) {
353
if (!normalizedIP) {
354
throw new Error("Missing IP address");
355
}
356
- await apiClient.post<unknown>(API_PATHS.policy.ips, {
356
+ await apiClient.post<unknown>(BROWSER_API_PATHS.policy.ips, {
357
ip: normalizedIP,
358
is_banned: isBan,
359
} satisfies IPPolicyUpdate);
@@ -375,7 +375,7 @@ export function useAdmin(enabled = true) {
375
: action === "deny"
376
? { identity_key: identityKey, is_denied: true }
377
: { identity_key: identityKey, is_banned: true };
378
- return apiClient.post<unknown>(API_PATHS.policy.leases, policy);
378
+ return apiClient.post<unknown>(BROWSER_API_PATHS.policy.leases, policy);
379
})
380
);
381
frontend/src/hooks/useAuth.ts
+5
-5
@@ -6,7 +6,7 @@ import {
6
useDisconnect,
7
useSignMessage,
8
} from "wagmi";
9
-import { API_PATHS } from "@/lib/apiPaths";
9
+import { BROWSER_API_PATHS } from "@/lib/apiPaths";
10
import { APIClientError, apiClient } from "@/lib/apiClient";
11
import { writeAdminAuthToken } from "@/lib/adminAuthToken";
12
import type {
@@ -37,7 +37,7 @@ function emptyAuthState(): AuthState {
37
async function fetchAuthState(): Promise<AuthState> {
38
try {
39
const data = await apiClient.get<WalletAuthStatusResponse>(
40
- API_PATHS.admin.authStatus
40
+ BROWSER_API_PATHS.admin.authStatus
41
);
42
return {
43
isAuthenticated: data.authenticated,
@@ -86,7 +86,7 @@ export function useAuth() {
86
return { success: false, error: "Wallet provider is unavailable." };
87
}
88
const challenge = await apiClient.post<WalletAuthChallengeResponse>(
89
- API_PATHS.admin.authChallenge,
89
+ BROWSER_API_PATHS.admin.authChallenge,
90
{ address }
91
);
92
const signature = await signMessageAsync({
@@ -94,7 +94,7 @@ export function useAuth() {
94
message: challenge.siwe_message,
95
});
96
const data = await apiClient.post<WalletAuthLoginResponse>(
97
- API_PATHS.admin.authLogin,
97
+ BROWSER_API_PATHS.admin.authLogin,
98
{
99
challenge_id: challenge.challenge_id,
100
siwe_message: challenge.siwe_message,
@@ -130,7 +130,7 @@ export function useAuth() {
130
131
const logout = async () => {
132
try {
133
- await apiClient.post<unknown>(API_PATHS.admin.logout);
133
+ await apiClient.post<unknown>(BROWSER_API_PATHS.admin.logout);
134
} catch {
135
// Logging out should clear local state even if the remote token is stale.
136
} finally {
frontend/src/hooks/useServerList.ts
+2
-2
@@ -1,7 +1,7 @@
1
import { useEffect, useMemo, useState } from "react";
2
import { useList, type BaseServer } from "@/hooks/useList";
3
import { apiClient } from "@/lib/apiClient";
4
-import { API_PATHS } from "@/lib/apiPaths";
4
+import { BROWSER_API_PATHS } from "@/lib/apiPaths";
5
import { parseLeaseMetadata, resolveLeaseThumbnail } from "@/lib/metadata";
6
import type { Lease, PublicStateResponse } from "@/types/api";
7
@@ -44,7 +44,7 @@ export function useServerList() {
44
void (async () => {
45
try {
46
const data = await apiClient.get<PublicStateResponse>(
47
- API_PATHS.public.state
47
+ BROWSER_API_PATHS.public.state
48
);
49
if (cancelled) {
50
return;
frontend/src/lib/apiClient.test.ts
+13
-2
@@ -34,7 +34,18 @@ describe("apiClient", () => {
34
expect(init.headers).toEqual({ Accept: "application/json" });
35
});
36
37
- it("preserves API base URL subpaths", async () => {
37
+ it("preserves non-API base URL subpaths", async () => {
38
+ vi.stubEnv("VITE_PORTAL_API_BASE_URL", "https://portal.example.com/custom");
39
+ fetchMock.mockResolvedValueOnce(
40
+ jsonResponse({ ok: true, data: { status: "ok" } }),
41
+ );
42
+
43
+ await apiClient.get("/ui/state");
44
+
45
+ expect(fetchMock.mock.calls[0]?.[0]).toBe("https://portal.example.com/custom/ui/state");
46
+ });
47
+
48
+ it("treats an API base URL suffix as the public edge root", async () => {
49
vi.stubEnv("VITE_PORTAL_API_BASE_URL", "https://portal.example.com/api");
50
fetchMock.mockResolvedValueOnce(
51
jsonResponse({ ok: true, data: { status: "ok" } }),
@@ -42,7 +53,7 @@ describe("apiClient", () => {
53
54
await apiClient.get("/ui/state");
55
45
- expect(fetchMock.mock.calls[0]?.[0]).toBe("https://portal.example.com/api/ui/state");
56
+ expect(fetchMock.mock.calls[0]?.[0]).toBe("https://portal.example.com/ui/state");
57
});
58
59
it("rejects successful non-envelope JSON payloads", async () => {
frontend/src/lib/apiClient.ts
+8
-5
@@ -1,5 +1,5 @@
1
import { readAdminAuthToken } from "@/lib/adminAuthToken";
2
-import { API_PATHS, RELAY_API_PATHS } from "@/lib/apiPaths";
2
+import { BROWSER_API_PATHS, RELAY_API_PATHS } from "@/lib/apiPaths";
3
import type { APIEnvelope } from "@/types/api";
4
5
export class APIClientError extends Error {
@@ -31,7 +31,10 @@ function resolveAPIURL(path: string): string {
31
}
32
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
33
const parsedBase = new URL(baseURL);
34
- const basePath = parsedBase.pathname.replace(/\/$/, "");
34
+ const rawBasePath = parsedBase.pathname.replace(/\/$/, "");
35
+ const basePath = rawBasePath.endsWith("/api")
36
+ ? rawBasePath.slice(0, -"/api".length)
37
+ : rawBasePath;
38
if (
39
basePath !== "" &&
40
(normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`))
@@ -112,13 +115,13 @@ async function request<T>(path: string, init: RequestInit): Promise<T> {
115
};
116
const pathname = new URL(path, window.location.origin).pathname;
117
const requiresAdminAuth =
115
- isPathOrChild(pathname, API_PATHS.policy.root) ||
118
+ isPathOrChild(pathname, BROWSER_API_PATHS.policy.root) ||
119
isPathOrChild(pathname, RELAY_API_PATHS.policy.root) ||
120
isPathOrChild(pathname, RELAY_API_PATHS.admin.root);
121
if (
122
requiresAdminAuth &&
120
- pathname !== API_PATHS.admin.authChallenge &&
121
- pathname !== API_PATHS.admin.authLogin
123
+ pathname !== BROWSER_API_PATHS.admin.authChallenge &&
124
+ pathname !== BROWSER_API_PATHS.admin.authLogin
125
) {
126
const token = readAdminAuthToken();
127
if (token) {
frontend/src/lib/apiPaths.ts
+1
-1
@@ -43,7 +43,7 @@ export const PRESENTATION_API_PATHS = {
43
},
44
} as const;
45
46
-export const API_PATHS = {
46
+export const BROWSER_API_PATHS = {
47
...RELAY_API_PATHS,
48
public: PRESENTATION_API_PATHS.public,
49
policy: PRESENTATION_API_PATHS.policy,
frontend/src/lib/metadata.ts
+2
-2
@@ -1,4 +1,4 @@
1
-import { API_PATHS } from "./apiPaths.js";
1
+import { BROWSER_API_PATHS } from "./apiPaths.js";
2
3
interface Metadata {
4
description: string;
@@ -66,5 +66,5 @@ export function resolveLeaseThumbnail(metadata: Metadata, hostname: string): str
66
if (normalizedHostname === "" || normalizedHostname.startsWith("*.")) {
67
return "";
68
}
69
- return `${API_PATHS.thumbnail.prefix}${encodeURIComponent(normalizedHostname)}`;
69
+ return `${BROWSER_API_PATHS.thumbnail.prefix}${encodeURIComponent(normalizedHostname)}`;
70
}
frontend/src/lib/tunnelCommand.ts
+3
-3
@@ -1,4 +1,4 @@
1
-import { API_PATHS } from "@/lib/apiPaths";
1
+import { BROWSER_API_PATHS } from "@/lib/apiPaths";
2
import { resolveExposeName } from "@/lib/exposeName";
3
4
export type TunnelCommandOS = "unix" | "windows";
@@ -94,11 +94,11 @@ function buildTunnelCommandParts({
94
const relayURLValue =
95
relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin;
96
const installScriptURL = new URL(
97
- API_PATHS.install.shell,
97
+ BROWSER_API_PATHS.install.shell,
98
currentOrigin
99
).toString();
100
const installPowerShellURL = new URL(
101
- API_PATHS.install.powershell,
101
+ BROWSER_API_PATHS.install.powershell,
102
currentOrigin
103
).toString();
104