diff --git a/packages/docs/docs/getting-started/configuration.md b/packages/docs/docs/getting-started/configuration.md index 34a809f..0c7f530 100644 --- a/packages/docs/docs/getting-started/configuration.md +++ b/packages/docs/docs/getting-started/configuration.md @@ -8,7 +8,8 @@ HappyView is configured via environment variables. A `.env` file in the project |----------|----------|---------|-------------| | `DATABASE_URL` | yes | --- | Database connection string. SQLite (`sqlite://path/to/db?mode=rwc`) or Postgres (`postgres://user:pass@host/db`) | | `DATABASE_BACKEND` | no | auto-detected | Force `sqlite` or `postgres`. Auto-detected from `DATABASE_URL` scheme if not set | -| `PUBLIC_URL` | yes | --- | Public-facing URL for HappyView (used for OAuth callbacks, e.g. `https://happyview.example.com`). **For local development, use `http://127.0.0.1:3000` — not `localhost`** (see note below) | +| `PUBLIC_URL` | yes | --- | Public-facing URL for HappyView (used for OAuth callbacks, e.g. `https://happyview.example.com`). **For local development, use `http://127.0.0.1:3000` — not `localhost`** (see note below). Do **not** include the base path — see `BASE_PATH` | +| `BASE_PATH` | no | _(none)_ | Subpath prefix for mounting HappyView behind a reverse proxy (e.g. `/hv`). Must start with `/` and have no trailing slash. When set, all routes are served under this prefix and the dashboard is accessible at `https://example.com/hv/`. See [Reverse proxy subpath](production-deployment.md#reverse-proxy-subpath) | | `SESSION_SECRET` | no | dev default | Secret key for signing session cookies (at least 64 characters). **Must be set in production** | | `HOST` | no | `0.0.0.0` | Bind host | | `PORT` | no | `3000` | Bind port | @@ -45,6 +46,7 @@ SESSION_SECRET=change-me-in-production # DATABASE_URL=postgres://happyview:happyview@localhost/happyview # Optional overrides +# BASE_PATH=/hv # HOST=0.0.0.0 # PORT=3000 # JETSTREAM_URL=wss://jetstream1.us-east.bsky.network diff --git a/packages/docs/docs/getting-started/production-deployment.md b/packages/docs/docs/getting-started/production-deployment.md index 6c07f4e..0a7b3c1 100644 --- a/packages/docs/docs/getting-started/production-deployment.md +++ b/packages/docs/docs/getting-started/production-deployment.md @@ -32,6 +32,54 @@ PUBLIC_URL=https://happyview.example.com `PUBLIC_URL` is used to construct OAuth redirect URIs, so it must exactly match the URL users hit — including scheme. A mismatch breaks OAuth login. +## Reverse proxy subpath + +If you need HappyView to share a domain with other services, set `BASE_PATH` to mount it at a subpath. For example, to serve the dashboard at `https://example.com/hv/`: + +```sh +PUBLIC_URL=https://example.com +BASE_PATH=/hv +``` + +`PUBLIC_URL` should **not** include the base path — HappyView appends it automatically when constructing OAuth callbacks and other external URLs. + +The base path is applied at container startup without rebuilding the image, so prebuilt Docker images (including Railway deployments) work with any subpath. + +### XRPC endpoints + +ATProto clients expect XRPC endpoints at `/xrpc/*` on the domain root. When using a base path, configure your reverse proxy to rewrite `/xrpc/*` requests so they reach HappyView under its base path. Here's an example using Caddy: + +``` +example.com { + # Dashboard and API under the base path + handle /hv/* { + reverse_proxy happyview:3000 + } + + # Redirect bare /hv to /hv/ for cleaner URLs + handle /hv { + redir /hv/ permanent + } + + # ATProto XRPC — rewrite to base path before proxying + handle /xrpc/* { + uri prefix /hv + reverse_proxy happyview:3000 + } + + # Everything else goes to another service + handle { + reverse_proxy other-app:3001 + } +} +``` + +The `uri prefix /hv` directive prepends `/hv` to the request path before proxying, so `/xrpc/com.atproto.sync.getRecord` becomes `/hv/xrpc/com.atproto.sync.getRecord` — which matches HappyView's nested routes. + +### Health checks with a base path + +`GET /health` is always served at the domain root, even when `BASE_PATH` is set. This keeps load balancer probes working without routing changes. + ## Database SQLite is fine for small to medium instances and is the default. Switch to Postgres if you need: diff --git a/src/auth/routes.rs b/src/auth/routes.rs index 0d08274..019d773 100644 --- a/src/auth/routes.rs +++ b/src/auth/routes.rs @@ -229,7 +229,13 @@ async fn callback( .map_err(|e| AppError::Internal(format!("user lookup failed: {e}")))?; if user_exists.is_none() { - return Ok((jar, Redirect::to("/login?error=not_authorized"))); + let login_url = state + .config + .base_path + .as_ref() + .map(|bp| format!("{}/login?error=not_authorized", bp)) + .unwrap_or_else(|| "/login?error=not_authorized".into()); + return Ok((jar, Redirect::to(&login_url))); } } @@ -250,8 +256,13 @@ async fn callback( None }; - // Use DB-stored redirect, or default to "/" - let redirect_url = redirect_url.unwrap_or_else(|| "/".into()); + let default_redirect = state + .config + .base_path + .as_ref() + .map(|bp| format!("{}/", bp)) + .unwrap_or_else(|| "/".into()); + let redirect_url = redirect_url.unwrap_or(default_redirect); tracing::debug!(redirect_url = %redirect_url, "redirecting after callback"); // Set the session cookie diff --git a/src/config.rs b/src/config.rs index a1e2f8e..cabd5d2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,6 +15,7 @@ pub struct Config { pub relay_url: String, pub plc_url: String, pub static_dir: String, + pub base_path: Option, pub event_log_retention_days: u32, pub app_name: Option, pub logo_uri: Option, @@ -49,6 +50,16 @@ impl Config { relay_url: env::var("RELAY_URL").unwrap_or_else(|_| "https://bsky.network".into()), plc_url: env::var("PLC_URL").unwrap_or_else(|_| "https://plc.directory".into()), static_dir: env::var("STATIC_DIR").unwrap_or_else(|_| "./web/out".into()), + base_path: env::var("BASE_PATH").ok().and_then(|s| { + let s = s.trim_end_matches('/').to_string(); + if s.is_empty() { + None + } else if !s.starts_with('/') { + panic!("BASE_PATH must start with '/' (got: {s})"); + } else { + Some(s) + } + }), event_log_retention_days: std::env::var("EVENT_LOG_RETENTION_DAYS") .ok() .and_then(|v| v.parse().ok()) @@ -80,6 +91,20 @@ impl Config { .parse() .expect("invalid HOST/PORT") } + + pub fn effective_public_url(&self) -> String { + match &self.base_path { + Some(bp) => format!("{}{}", self.public_url.trim_end_matches('/'), bp), + None => self.public_url.clone(), + } + } + + pub fn url_with_base_path(&self, domain_url: &str) -> String { + match &self.base_path { + Some(bp) => format!("{}{}", domain_url.trim_end_matches('/'), bp), + None => domain_url.to_string(), + } + } } #[cfg(test)] @@ -103,6 +128,7 @@ mod tests { "LOGO_URI", "TOS_URI", "POLICY_URI", + "BASE_PATH", ] { unsafe { env::remove_var(key); @@ -130,6 +156,7 @@ mod tests { relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, @@ -286,4 +313,211 @@ mod tests { let config = Config::from_env(); assert_eq!(config.database_backend, DatabaseBackend::Sqlite); } + + #[test] + #[serial] + fn from_env_base_path_none_by_default() { + unsafe { + clear_env(); + set_required_env(); + } + let config = Config::from_env(); + assert!(config.base_path.is_none()); + } + + #[test] + #[serial] + fn from_env_base_path_read_from_env() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", "/hv"); + } + let config = Config::from_env(); + assert_eq!(config.base_path.as_deref(), Some("/hv")); + } + + #[test] + #[serial] + fn from_env_base_path_strips_trailing_slash() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", "/hv/"); + } + let config = Config::from_env(); + assert_eq!(config.base_path.as_deref(), Some("/hv")); + } + + #[test] + #[serial] + fn from_env_base_path_empty_becomes_none() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", ""); + } + let config = Config::from_env(); + assert!(config.base_path.is_none()); + } + + #[test] + #[serial] + fn from_env_base_path_slash_only_becomes_none() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", "/"); + } + let config = Config::from_env(); + assert!(config.base_path.is_none()); + } + + #[test] + #[serial] + #[should_panic(expected = "BASE_PATH must start with '/'")] + fn from_env_base_path_without_leading_slash_panics() { + unsafe { + clear_env(); + set_required_env(); + env::set_var("BASE_PATH", "hv"); + } + Config::from_env(); + } + + #[test] + fn effective_public_url_without_base_path() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: "https://example.com".into(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: None, + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!(config.effective_public_url(), "https://example.com"); + } + + #[test] + fn effective_public_url_with_base_path() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: "https://example.com".into(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: Some("/hv".into()), + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!(config.effective_public_url(), "https://example.com/hv"); + } + + #[test] + fn effective_public_url_trims_trailing_slash() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: "https://example.com/".into(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: Some("/hv".into()), + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!(config.effective_public_url(), "https://example.com/hv"); + } + + #[test] + fn url_with_base_path_appends() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: String::new(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: Some("/hv".into()), + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!( + config.url_with_base_path("https://otherdomain.com"), + "https://otherdomain.com/hv" + ); + } + + #[test] + fn url_with_base_path_without_base_path() { + let config = Config { + host: String::new(), + port: 3000, + database_url: String::new(), + database_backend: DatabaseBackend::Postgres, + public_url: String::new(), + session_secret: String::new(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: None, + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + assert_eq!( + config.url_with_base_path("https://otherdomain.com"), + "https://otherdomain.com" + ); + } } diff --git a/src/external_auth/routes.rs b/src/external_auth/routes.rs index d5d9fcd..36f406c 100644 --- a/src/external_auth/routes.rs +++ b/src/external_auth/routes.rs @@ -130,8 +130,8 @@ async fn authorize( // Build the backend callback URL for OpenID/OAuth return_to // This ensures the auth provider redirects back to the backend, not the frontend let domain_url = domain - .map(|d| d.0.url.clone()) - .unwrap_or_else(|| app_state.config.public_url.clone()); + .map(|d| app_state.config.url_with_base_path(&d.0.url)) + .unwrap_or_else(|| app_state.config.effective_public_url()); let callback_url = format!( "{}/external-auth/{}/callback", diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs index bc1c614..bde9321 100644 --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -492,6 +492,7 @@ mod tests { relay_url: String::new(), plc_url: plc_url.to_string(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs index 41e68bf..e2fb031 100644 --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -632,6 +632,7 @@ mod tests { relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/lua/execute.rs b/src/lua/execute.rs index c1548ab..c2c0c0c 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -1089,6 +1089,7 @@ mod tests { relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/lua/http_api.rs b/src/lua/http_api.rs index 5e5b5d6..39b7a22 100644 --- a/src/lua/http_api.rs +++ b/src/lua/http_api.rs @@ -98,6 +98,7 @@ mod tests { relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/lua/xrpc_api.rs b/src/lua/xrpc_api.rs index 0b78ae6..308be59 100644 --- a/src/lua/xrpc_api.rs +++ b/src/lua/xrpc_api.rs @@ -202,6 +202,7 @@ mod tests { relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/src/main.rs b/src/main.rs index 079e61b..8145cf4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -411,7 +411,10 @@ async fn main() { // Build atrium-oauth client let dns = NativeDnsResolver::new(); - let callback_url = format!("{}/auth/callback", config.public_url.trim_end_matches('/')); + let callback_url = format!( + "{}/auth/callback", + config.effective_public_url().trim_end_matches('/') + ); let atrium_http = Arc::new(DefaultHttpClient::default()); let did_resolver = CommonDidResolver::new(CommonDidResolverConfig { @@ -459,9 +462,9 @@ async fn main() { client_metadata: AtprotoClientMetadata { client_id: format!( "{}/oauth-client-metadata.json", - config.public_url.trim_end_matches('/') + config.effective_public_url().trim_end_matches('/') ), - client_uri: Some(config.public_url.clone()), + client_uri: Some(config.effective_public_url()), redirect_uris: vec![callback_url], token_endpoint_auth_method: AuthMethod::None, grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], @@ -526,10 +529,12 @@ async fn main() { continue; // Already registered above } - let domain_callback_url = format!("{}/auth/callback", domain.url.trim_end_matches('/')); + let domain_base_url = config.url_with_base_path(&domain.url); + let domain_callback_url = + format!("{}/auth/callback", domain_base_url.trim_end_matches('/')); let domain_client_id = format!( "{}/oauth-client-metadata.json", - domain.url.trim_end_matches('/') + domain_base_url.trim_end_matches('/') ); let domain_http = Arc::new(DefaultHttpClient::default()); @@ -549,7 +554,7 @@ async fn main() { match atrium_oauth::OAuthClient::new(OAuthClientConfig { client_metadata: AtprotoClientMetadata { client_id: domain_client_id, - client_uri: Some(domain.url.clone()), + client_uri: Some(domain_base_url.clone()), redirect_uris: vec![domain_callback_url], token_endpoint_auth_method: AuthMethod::None, grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], diff --git a/src/server.rs b/src/server.rs index 11e470b..5699604 100644 --- a/src/server.rs +++ b/src/server.rs @@ -132,11 +132,22 @@ pub fn router(state: AppState) -> Router { resolve_domain, )); - Router::new() - .route("/health", get(health)) + let app_routes = Router::new() .nest("/admin", admin::admin_routes(state.clone())) .merge(domain_routes) - .fallback_service(serve_dir) + .fallback_service(serve_dir); + + let outer = if let Some(ref base_path) = state.config.base_path { + Router::new() + .route("/health", get(health)) + .nest(base_path, app_routes) + } else { + Router::new() + .route("/health", get(health)) + .merge(app_routes) + }; + + outer .layer(TraceLayer::new_for_http()) .layer( CorsLayer::new() @@ -166,8 +177,8 @@ async fn config_endpoint( req: axum::extract::Request, ) -> Json { let domain_url = crate::domain_middleware::extract_domain(&req) - .map(|d| d.url.clone()) - .unwrap_or_else(|| state.config.public_url.clone()); + .map(|d| state.config.url_with_base_path(&d.url)) + .unwrap_or_else(|| state.config.effective_public_url()); let pool = &state.db; let backend = state.db_backend; @@ -223,11 +234,12 @@ async fn client_metadata( State(state): State, req: axum::extract::Request, ) -> Json { - let domain_url = crate::domain_middleware::extract_domain(&req) + let raw_domain_url = crate::domain_middleware::extract_domain(&req) .map(|d| d.url.clone()) .unwrap_or_else(|| state.config.public_url.clone()); + let domain_url = state.config.url_with_base_path(&raw_domain_url); - let oauth_client = state.oauth.get_for_domain(&domain_url); + let oauth_client = state.oauth.get_for_domain(&raw_domain_url); let mut metadata = serde_json::to_value(&oauth_client.client_metadata).unwrap_or_default(); // The `client_id` field in the response must exactly match the URL the diff --git a/tests/common/app.rs b/tests/common/app.rs index c749a77..4b82744 100644 --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -54,6 +54,7 @@ impl TestApp { relay_url: mock_url.clone(), plc_url: mock_url.clone(), static_dir: "./web/out".into(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/tests/lua_atproto_api.rs b/tests/lua_atproto_api.rs index 3302244..94a5b64 100644 --- a/tests/lua_atproto_api.rs +++ b/tests/lua_atproto_api.rs @@ -27,6 +27,7 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs index ada3107..0e2ce49 100644 --- a/tests/lua_db_api.rs +++ b/tests/lua_db_api.rs @@ -30,6 +30,7 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), + base_path: None, event_log_retention_days: 30, app_name: None, logo_uri: None, diff --git a/web/next.config.ts b/web/next.config.ts index 93e2742..7e073de 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,11 +1,13 @@ import type { NextConfig } from "next"; const apiBase = process.env.API_URL || "http://localhost:3000"; +const basePath = process.env.NEXT_PUBLIC_BASE_PATH || undefined; const nextConfig: NextConfig = { reactCompiler: true, trailingSlash: true, images: { unoptimized: true }, + basePath, }; if (process.env.NODE_ENV === "production") { diff --git a/web/src/app/dashboard/about/page.tsx b/web/src/app/dashboard/about/page.tsx index daa4728..614253f 100644 --- a/web/src/app/dashboard/about/page.tsx +++ b/web/src/app/dashboard/about/page.tsx @@ -25,7 +25,7 @@ export default function AboutPage() { const [error, setError] = useState(null) useEffect(() => { - fetch("/config", { credentials: "same-origin" }) + fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/config`, { credentials: "same-origin" }) .then((r) => { if (!r.ok) throw new Error("Failed to load config") return r.json() diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a6ce249..f5fab6a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -57,6 +57,8 @@ export type { BulkActionResponse, } from "@/types/dead-letters" +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || "" + export class ApiError extends Error { status: number constructor(status: number, message: string) { @@ -78,7 +80,7 @@ async function apiFetch( headers["Content-Type"] = "application/json" } - const res = await fetch(path, { + const res = await fetch(`${BASE_PATH}${path}`, { ...options, headers: { ...headers, ...options?.headers }, credentials: "same-origin", @@ -239,7 +241,7 @@ export async function xrpcQuery( params?: Record ): Promise { const search = params ? `?${new URLSearchParams(params)}` : "" - const res = await fetch(`/xrpc/${encodeURIComponent(method)}${search}`) + const res = await fetch(`${BASE_PATH}/xrpc/${encodeURIComponent(method)}${search}`) if (!res.ok) { const text = await res.text().catch(() => res.statusText) throw new ApiError(res.status, text) @@ -323,7 +325,7 @@ export function deleteSetting(key: string) { export async function uploadLogo(file: File) { const formData = new FormData() formData.append("file", file) - const res = await fetch("/admin/settings/logo", { + const res = await fetch(`${BASE_PATH}/admin/settings/logo`, { method: "PUT", body: formData, credentials: "same-origin", diff --git a/web/src/lib/auth-context.tsx b/web/src/lib/auth-context.tsx index d8a1379..a817b45 100644 --- a/web/src/lib/auth-context.tsx +++ b/web/src/lib/auth-context.tsx @@ -41,7 +41,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { async function init() { try { // Check if the user has a valid session cookie - const resp = await fetch("/auth/me", { credentials: "same-origin" }) + const resp = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/auth/me`, { credentials: "same-origin" }) if (resp.ok) { const data = await resp.json() if (!cancelled && data.did) { @@ -67,7 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const login = useCallback(async (handle: string) => { setError(null) - const resp = await fetch(`/auth/login?handle=${encodeURIComponent(handle)}`, { + const resp = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/auth/login?handle=${encodeURIComponent(handle)}`, { credentials: "same-origin", }) @@ -83,7 +83,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const logout = useCallback(async () => { try { - await fetch("/auth/logout", { + await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/auth/logout`, { method: "POST", credentials: "same-origin", }) diff --git a/web/src/lib/config-context.tsx b/web/src/lib/config-context.tsx index db6a35b..875627e 100644 --- a/web/src/lib/config-context.tsx +++ b/web/src/lib/config-context.tsx @@ -23,7 +23,7 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { const [error, setError] = useState(null) useEffect(() => { - fetch("/config") + fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/config`) .then((res) => { if (!res.ok) throw new Error(`Config fetch failed: ${res.status}`) return res.json()