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