diff --git a/.env.example b/.env.example index 7ff75ce..cf39896 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ DB_URL="postgres://postgres:postgres@localhost:5432/malfestio_dev?sslmode=disable" # OAuth Client Configuration (Optional - defaults shown) -APP_URL=http://localhost:3000 +APP_URL=http://127.0.0.1:3000 APP_NAME=Malfestio # Server Configuration (Optional - defaults shown) diff --git a/crates/server/src/api/oauth.rs b/crates/server/src/api/oauth.rs index 9be52dc..07ba755 100644 --- a/crates/server/src/api/oauth.rs +++ b/crates/server/src/api/oauth.rs @@ -59,6 +59,14 @@ impl OAuthRepository for MockOAuthRepository { Err(crate::repository::oauth::OAuthRepoError::NotFound(did.to_string())) } + async fn get_token_by_access_token( + &self, _access_token: &str, + ) -> Result { + Err(crate::repository::oauth::OAuthRepoError::NotFound( + "Mock impl".to_string(), + )) + } + async fn update_tokens( &self, _did: &str, _access_token: &str, _refresh_token: Option<&str>, _expires_at: Option>, @@ -90,7 +98,7 @@ pub struct AuthorizeResponse { /// Query parameters from OAuth callback. #[derive(Deserialize)] pub struct CallbackQuery { - pub code: String, + pub code: Option, pub state: String, #[serde(default)] pub error: Option, @@ -150,6 +158,14 @@ pub async fn callback(State(oauth): State>, Query(params): Query .into_response(); } + let code = match params.code { + Some(c) => c, + None => { + tracing::error!("OAuth callback missing authorization code"); + return Redirect::to("/login?error=missing_code").into_response(); + } + }; + tracing::debug!("Retrieving session for state: {}", params.state); let session = { let sessions = oauth.sessions.read().unwrap(); @@ -167,11 +183,7 @@ pub async fn callback(State(oauth): State>, Query(params): Query } }; - match oauth - .flow - .exchange_code(¶ms.code, ¶ms.state, &oauth.sessions) - .await - { + match oauth.flow.exchange_code(&code, ¶ms.state, &oauth.sessions).await { Ok(tokens) => { let did = session.did.clone().unwrap_or_default(); let pds_url = session.pds_url.unwrap_or_default(); @@ -180,6 +192,7 @@ pub async fn callback(State(oauth): State>, Query(params): Query .map(|secs| Utc::now() + Duration::seconds(secs as i64)); tracing::info!("Storing tokens for DID: {}", did); + if let Err(e) = oauth .repo .store_tokens(StoreTokensRequest { @@ -199,7 +212,23 @@ pub async fn callback(State(oauth): State>, Query(params): Query } tracing::info!("OAuth flow completed successfully for DID: {}", did); - Redirect::to(&format!("/login/success?did={}", urlencoding::encode(&did))).into_response() + + let handle = match oauth.flow.resolve_did(&did).await { + Ok(identity) => identity.handle.unwrap_or(did.clone()), + Err(e) => { + tracing::warn!("Failed to resolve handle for DID {}: {}", did, e); + did.clone() + } + }; + + let fragment = format!( + "accessJwt={}&refreshJwt={}&did={}&handle={}", + urlencoding::encode(&tokens.access_token), + urlencoding::encode(tokens.refresh_token.as_deref().unwrap_or("")), + urlencoding::encode(&did), + urlencoding::encode(&handle) + ); + Redirect::to(&format!("/login/success#{}", fragment)).into_response() } Err(e) => { tracing::error!("Token exchange failed: {}", e); @@ -228,7 +257,6 @@ pub struct RefreshResponse { pub async fn refresh(State(oauth): State>, Json(payload): Json) -> impl IntoResponse { tracing::info!("Token refresh request for DID: {}", payload.did); - // Get stored tokens from database tracing::debug!("Retrieving stored tokens from database for DID: {}", payload.did); let stored = match oauth.repo.get_tokens(&payload.did).await { Ok(t) => { @@ -241,7 +269,6 @@ pub async fn refresh(State(oauth): State>, Json(payload): Json kp, @@ -255,7 +282,6 @@ pub async fn refresh(State(oauth): State>, Json(payload): Json rt.clone(), None => { @@ -268,7 +294,6 @@ pub async fn refresh(State(oauth): State>, Json(payload): Json malfestio_core::Result<()> { let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); let config = state::AppConfig { pds_url }; let repos = state::Repositories::from(&pool); - let state = state::AppState::new(pool, repos, config); - let oauth_state = std::sync::Arc::new(api::oauth::OAuthState::new()); + let state = state::AppState::new(pool.clone(), repos, config); + let oauth_state = std::sync::Arc::new(api::oauth::OAuthState::with_pool(pool)); let auth_routes = Router::new() .route("/me", get(api::auth::me)) @@ -94,6 +94,7 @@ pub async fn start() -> malfestio_core::Result<()> { let oauth_routes = Router::new() .route("/authorize", post(api::oauth::authorize)) .route("/callback", get(api::oauth::callback)) + .route("/client-metadata.json", get(oauth::client_metadata_handler)) .route("/refresh", post(api::oauth::refresh)) .with_state(oauth_state.clone()); diff --git a/crates/server/src/middleware/auth.rs b/crates/server/src/middleware/auth.rs index a2be8ec..f2bbb7e 100644 --- a/crates/server/src/middleware/auth.rs +++ b/crates/server/src/middleware/auth.rs @@ -124,32 +124,113 @@ pub async fn auth_middleware(State(state): State, mut req: Request, let client = reqwest::Client::new(); let pds_url = &state.config.pds_url; - let resp = client - .get(format!("{}/xrpc/com.atproto.server.getSession", pds_url)) - .header("Authorization", format!("Bearer {}", token)) - .send() - .await; + let lookup_result = state.oauth_repo.get_token_by_access_token(&token).await; - match resp { - Ok(response) if response.status().is_success() => { - let body: serde_json::Value = response.json().await.unwrap_or_default(); - let did = body["did"].as_str().unwrap_or("").to_string(); - let handle = body["handle"].as_str().unwrap_or("").to_string(); - let user_ctx = UserContext { did, handle }; + if let Err(ref e) = lookup_result { + tracing::debug!("Token lookup failed: {}", e); + } - { - let mut cache = state.auth_cache.write().await; - cache.insert(token.to_string(), (user_ctx.clone(), Instant::now())); + let stored_token = lookup_result.ok(); + + let target_pds_url = stored_token + .as_ref() + .map(|t| t.pds_url.as_str()) + .unwrap_or(pds_url.as_str()); + + let endpoint_url = format!("{}/xrpc/com.atproto.server.getSession", target_pds_url); + let mut nonce: Option = None; + let mut attempt = 0; + + loop { + attempt += 1; + if attempt > 3 { + tracing::error!("Failed to verify token with PDS after multiple attempts"); + return ( + axum::http::StatusCode::UNAUTHORIZED, + axum::Json(json!({ "error": "Invalid session" })), + ) + .into_response(); + } + + let mut request_builder = client.get(&endpoint_url); + + if let Some(ref stored) = stored_token { + if attempt == 1 { + tracing::debug!("Found stored DPoP token for validation"); } + if let Some(dpop_keypair) = stored.dpop_keypair() { + if attempt == 1 { + tracing::debug!("Signing PDS request with DPoP Key"); + } + + let method = "GET"; + let proof = if let Some(ref n) = nonce { + dpop_keypair.generate_proof_with_nonce(method, &endpoint_url, Some(&token), Some(n)) + } else { + dpop_keypair.generate_proof(method, &endpoint_url, Some(&token)) + }; - req.extensions_mut().insert(user_ctx); - next.run(req).await + request_builder = request_builder + .header("Authorization", format!("DPoP {}", token)) + .header("DPoP", proof); + } else { + request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); + } + } else { + if attempt == 1 { + tracing::debug!("No stored DPoP token found, using standard Bearer auth"); + } + request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); + } + + let resp = request_builder.send().await; + + match resp { + Ok(response) if response.status().is_success() => { + let body: serde_json::Value = response.json().await.unwrap_or_default(); + let did = body["did"].as_str().unwrap_or("").to_string(); + let handle = body["handle"].as_str().unwrap_or("").to_string(); + let user_ctx = UserContext { did: did.clone(), handle }; + + tracing::debug!("PDS verification successful for DID: {}", did); + + { + let mut cache = state.auth_cache.write().await; + cache.insert(token.to_string(), (user_ctx.clone(), Instant::now())); + } + + req.extensions_mut().insert(user_ctx); + return next.run(req).await; + } + Ok(response) => { + let status = response.status(); + + if status == axum::http::StatusCode::UNAUTHORIZED + && let Some(new_nonce) = response.headers().get("DPoP-Nonce") + && let Ok(nonce_str) = new_nonce.to_str() + { + tracing::info!("Received DPoP nonce challenge from PDS, retrying verification..."); + nonce = Some(nonce_str.to_string()); + continue; + } + + let body = response.text().await.unwrap_or_default(); + tracing::error!("PDS Verification failed. Status: {}, Body: {}", status, body); + return ( + axum::http::StatusCode::UNAUTHORIZED, + axum::Json(json!({ "error": "Invalid session", "pds_error": body })), + ) + .into_response(); + } + Err(e) => { + tracing::error!("PDS Request failed: {}", e); + return ( + axum::http::StatusCode::UNAUTHORIZED, + axum::Json(json!({ "error": "Invalid session" })), + ) + .into_response(); + } } - _ => ( - axum::http::StatusCode::UNAUTHORIZED, - axum::Json(json!({ "error": "Invalid session" })), - ) - .into_response(), } } diff --git a/crates/server/src/oauth/client_metadata.rs b/crates/server/src/oauth/client_metadata.rs index d268fec..895a9c6 100644 --- a/crates/server/src/oauth/client_metadata.rs +++ b/crates/server/src/oauth/client_metadata.rs @@ -30,17 +30,18 @@ impl ClientMetadata { /// Create client metadata from environment variables. pub fn from_env() -> Self { let app_url = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); + let app_url = app_url.trim_end_matches('/'); let app_name = std::env::var("APP_NAME").unwrap_or_else(|_| "Malfestio".to_string()); Self { - client_id: format!("{}/oauth/client-metadata.json", app_url), + client_id: format!("{}/api/oauth/client-metadata.json", app_url), application_type: "web".to_string(), grant_types: vec!["authorization_code".to_string(), "refresh_token".to_string()], scope: "atproto transition:generic".to_string(), response_types: vec!["code".to_string()], - redirect_uris: vec![format!("{}/oauth/callback", app_url)], + redirect_uris: vec![format!("{}/api/oauth/callback", app_url)], client_name: app_name, - client_uri: app_url, + client_uri: app_url.to_string(), token_endpoint_auth_method: "none".to_string(), dpop_bound_access_tokens: true, } diff --git a/crates/server/src/oauth/flow.rs b/crates/server/src/oauth/flow.rs index dd370b5..547b249 100644 --- a/crates/server/src/oauth/flow.rs +++ b/crates/server/src/oauth/flow.rs @@ -58,15 +58,23 @@ impl OAuthFlow { /// Create a new OAuth flow manager. pub fn new() -> Self { let app_url = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); + let app_url = app_url.trim_end_matches('/'); Self { resolver: IdentityResolver::new(), client: reqwest::Client::new(), - client_id: format!("{}/oauth/client-metadata.json", app_url), - redirect_uri: format!("{}/oauth/callback", app_url), + client_id: format!("{}/api/oauth/client-metadata.json", app_url), + redirect_uri: format!("{}/api/oauth/callback", app_url), } } + /// Resolve a DID to an identity (including handle). + pub async fn resolve_did( + &self, did: &str, + ) -> Result { + self.resolver.resolve_did(did).await + } + /// Start the OAuth flow for a user handle or DID. /// /// Returns the authorization URL to redirect the user to. @@ -164,7 +172,7 @@ impl OAuthFlow { .generate_proof("POST", &auth_server.token_endpoint, None); tracing::info!("Sending token exchange request to: {}", auth_server.token_endpoint); - let response = self + let mut response = self .client .post(&auth_server.token_endpoint) .header("DPoP", dpop_proof) @@ -182,6 +190,38 @@ impl OAuthFlow { OAuthFlowError::NetworkError(e.to_string()) })?; + if (response.status().as_u16() == 400 || response.status().as_u16() == 401) + && let Some(nonce) = response + .headers() + .get("DPoP-Nonce") + .and_then(|h| h.to_str().ok().map(|s| s.to_string())) + { + tracing::info!("Received DPoP nonce, retrying token exchange"); + + let dpop_proof = + session + .dpop_keypair + .generate_proof_with_nonce("POST", &auth_server.token_endpoint, None, Some(&nonce)); + + response = self + .client + .post(&auth_server.token_endpoint) + .header("DPoP", dpop_proof) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", &self.redirect_uri), + ("client_id", &self.client_id), + ("code_verifier", &session.code_verifier), + ]) + .send() + .await + .map_err(|e| { + tracing::error!("Network error during retry token exchange: {}", e); + OAuthFlowError::NetworkError(e.to_string()) + })?; + } + let status = response.status(); if !status.is_success() { let error_body = response.text().await.unwrap_or_default(); diff --git a/crates/server/src/oauth/mod.rs b/crates/server/src/oauth/mod.rs index 3d00588..04e5592 100644 --- a/crates/server/src/oauth/mod.rs +++ b/crates/server/src/oauth/mod.rs @@ -13,3 +13,5 @@ pub mod dpop; pub mod flow; pub mod pkce; pub mod resolver; + +pub use client_metadata::client_metadata_handler; diff --git a/crates/server/src/repository/oauth.rs b/crates/server/src/repository/oauth.rs index d885b08..e931a5f 100644 --- a/crates/server/src/repository/oauth.rs +++ b/crates/server/src/repository/oauth.rs @@ -76,6 +76,9 @@ pub trait OAuthRepository: Send + Sync { /// Get stored tokens for a user. async fn get_tokens(&self, did: &str) -> Result; + /// Get stored tokens by access token. + async fn get_token_by_access_token(&self, access_token: &str) -> Result; + /// Update tokens after refresh. async fn update_tokens( &self, did: &str, access_token: &str, refresh_token: Option<&str>, expires_at: Option>, @@ -157,6 +160,36 @@ impl OAuthRepository for DbOAuthRepository { }) } + async fn get_token_by_access_token(&self, access_token: &str) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| OAuthRepoError::DatabaseError(e.to_string()))?; + + let row = client + .query_opt( + "SELECT did, pds_url, access_token, refresh_token, token_type, expires_at, dpop_private_key, created_at, updated_at + FROM oauth_tokens WHERE access_token = $1", + &[&access_token], + ) + .await + .map_err(|e| OAuthRepoError::DatabaseError(e.to_string()))? + .ok_or_else(|| OAuthRepoError::NotFound("Token not found".to_string()))?; + + Ok(StoredToken { + did: row.get("did"), + pds_url: row.get("pds_url"), + access_token: row.get("access_token"), + refresh_token: row.get("refresh_token"), + token_type: row.get("token_type"), + expires_at: row.get("expires_at"), + dpop_private_key: row.get("dpop_private_key"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) + } + async fn update_tokens( &self, did: &str, access_token: &str, refresh_token: Option<&str>, expires_at: Option>, ) -> Result<(), OAuthRepoError> { @@ -279,6 +312,19 @@ pub mod mock { .ok_or_else(|| OAuthRepoError::NotFound(format!("No tokens for DID: {}", did))) } + async fn get_token_by_access_token(&self, access_token: &str) -> Result { + if *self.should_fail.lock().unwrap() { + return Err(OAuthRepoError::DatabaseError("Mock failure".to_string())); + } + + let tokens = self.tokens.lock().unwrap(); + tokens + .iter() + .find(|t| t.access_token == access_token) + .cloned() + .ok_or_else(|| OAuthRepoError::NotFound("Token not found".to_string())) + } + async fn update_tokens( &self, did: &str, access_token: &str, refresh_token: Option<&str>, expires_at: Option>, ) -> Result<(), OAuthRepoError> { diff --git a/justfile b/justfile index b8e81be..fddaa55 100644 --- a/justfile +++ b/justfile @@ -72,3 +72,6 @@ verify HANDLE: clean: cargo clean cd web && rm -rf dist node_modules/.vite + +push: + git push origin main && git push alpha main diff --git a/web/src/App.tsx b/web/src/App.tsx index 1999100..dc4f8fb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,7 @@ import Landing from "$pages/Landing"; import LectureImport from "$pages/LectureImport"; import Library from "$pages/Library"; import Login from "$pages/Login"; +import LoginSuccess from "$pages/LoginSuccess"; import NoteNew from "$pages/NoteNew"; import Notes from "$pages/Notes"; import NoteView from "$pages/NoteView"; @@ -58,6 +59,7 @@ const App: Component = () => { return ( + diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index aabf337..1e7f61e 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -11,17 +11,19 @@ const Login: Component = () => ( export const Header: Component = () => { return ( -
+
}> diff --git a/web/src/index.css b/web/src/index.css index df5ba47..9a290ea 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -15,6 +15,8 @@ --font-body: "Figtree Variable", sans-serif; /* Spacing Tokens - 16px grid */ + /* + FIXME: These conflict with, and break existing usage of classes like max-w-4xl --spacing-xs: 4px; --spacing-sm: 8px; --spacing-md: 12px; @@ -23,7 +25,7 @@ --spacing-xl: 32px; --spacing-2xl: 48px; --spacing-3xl: 64px; - --spacing-4xl: 96px; + --spacing-4xl: 96px; */ /* Elevation Layers */ --layer-00: #161616; @@ -181,3 +183,7 @@ h6 { transform: scale(0.97); transition: transform var(--duration-instant) var(--easing-sharp); } + +button { + @apply cursor-pointer disabled:cursor-not-allowed disabled:opacity-50; +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index e0fce7c..a0cc449 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -1,9 +1,9 @@ import { AppLayout } from "$components/layout/AppLayout"; import { api } from "$lib/api"; import { authStore } from "$lib/store"; -import { useNavigate } from "@solidjs/router"; +import { useNavigate, useSearchParams } from "@solidjs/router"; import type { Component } from "solid-js"; -import { createSignal } from "solid-js"; +import { createEffect, createSignal } from "solid-js"; const Login: Component = () => { const [identifier, setIdentifier] = createSignal(""); @@ -11,6 +11,14 @@ const Login: Component = () => { const [error, setError] = createSignal(""); const [isLoading, setIsLoading] = createSignal(false); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + createEffect(() => { + if (searchParams.error) { + const desc = searchParams.description ? `: ${searchParams.description}` : ""; + setError(searchParams.error + desc); + } + }); const handleLogin = async (e: Event) => { e.preventDefault(); @@ -52,7 +60,7 @@ const Login: Component = () => { return ( -
+

Log in

Continue to Malfestio

diff --git a/web/src/pages/LoginSuccess.tsx b/web/src/pages/LoginSuccess.tsx new file mode 100644 index 0000000..3bc1e3c --- /dev/null +++ b/web/src/pages/LoginSuccess.tsx @@ -0,0 +1,37 @@ +import { authStore } from "$lib/store"; +import { useNavigate } from "@solidjs/router"; +import { type Component, onMount } from "solid-js"; + +const LoginSuccess: Component = () => { + const navigate = useNavigate(); + + onMount(() => { + const hash = window.location.hash.substring(1); + const params = new URLSearchParams(hash); + + const accessJwt = params.get("accessJwt"); + const refreshJwt = params.get("refreshJwt"); + const did = params.get("did"); + const handle = params.get("handle"); + + if (accessJwt && did) { + authStore.login({ accessJwt, refreshJwt: refreshJwt || "", did, handle: handle || did }); + window.history.replaceState(null, "", "/"); + navigate("/"); + } else { + console.error("Missing tokens in login success"); + navigate("/login?error=missing_tokens"); + } + }); + + return ( +
+
+
+

Finalizing login...

+
+
+ ); +}; + +export default LoginSuccess; diff --git a/web/src/pages/tests/Login.test.tsx b/web/src/pages/tests/Login.test.tsx index 2a13262..c3a71b8 100644 --- a/web/src/pages/tests/Login.test.tsx +++ b/web/src/pages/tests/Login.test.tsx @@ -11,7 +11,7 @@ vi.mock("$lib/api", () => ({ api: { startOAuth: vi.fn(), post: vi.fn() } })); vi.mock("$lib/store", () => ({ authStore: { login: vi.fn() } })); -vi.mock("@solidjs/router", () => ({ useNavigate: () => mockNavigate })); +vi.mock("@solidjs/router", () => ({ useNavigate: () => mockNavigate, useSearchParams: () => [{}, vi.fn()] })); vi.mock( "$components/layout/AppLayout", diff --git a/web/src/pages/tests/LoginSuccess.test.tsx b/web/src/pages/tests/LoginSuccess.test.tsx new file mode 100644 index 0000000..f5ae658 --- /dev/null +++ b/web/src/pages/tests/LoginSuccess.test.tsx @@ -0,0 +1,73 @@ +import { authStore } from "$lib/store"; +import { cleanup, render, waitFor } from "@solidjs/testing-library"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import LoginSuccess from "../LoginSuccess"; + +const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() })); + +vi.mock("$lib/store", () => ({ authStore: { login: vi.fn() } })); +vi.mock("@solidjs/router", () => ({ useNavigate: () => mockNavigate })); + +describe("LoginSuccess Page", () => { + const originalLocation = window.location; + + beforeEach(() => { + vi.stubGlobal("location", { + configurable: true, + enumerable: true, + value: { hash: "", href: "http://localhost/login/success", assign: vi.fn(), replace: vi.fn() }, + }); + vi.spyOn(window.history, "replaceState"); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.stubGlobal("location", originalLocation); + }); + + it("logs in and redirects on valid tokens", async () => { + window.location.hash = "#accessJwt=access123&refreshJwt=refresh123&did=did:plc:123&handle=alice.bsky.social"; + + render(() => ); + + await waitFor(() => { + expect(authStore.login).toHaveBeenCalledWith({ + accessJwt: "access123", + refreshJwt: "refresh123", + did: "did:plc:123", + handle: "alice.bsky.social", + }); + + expect(window.history.replaceState).toHaveBeenCalledWith(null, "", "/"); + expect(mockNavigate).toHaveBeenCalledWith("/"); + }); + }); + + it("handles missing optional parameters (handle fallback)", async () => { + window.location.hash = "#accessJwt=access123&refreshJwt=&did=did:plc:123"; + + render(() => ); + + await waitFor(() => { + expect(authStore.login).toHaveBeenCalledWith({ + accessJwt: "access123", + refreshJwt: "", + did: "did:plc:123", + handle: "did:plc:123", + }); + expect(mockNavigate).toHaveBeenCalledWith("/"); + }); + }); + + it("redirects to error on missing required tokens", async () => { + window.location.hash = "#did=did:plc:123"; + + render(() => ); + + await waitFor(() => { + expect(authStore.login).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith("/login?error=missing_tokens"); + }); + }); +}); diff --git a/web/vite.config.ts b/web/vite.config.ts index 94e051d..4498064 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,23 +1,41 @@ import tailwindcss from "@tailwindcss/vite"; import path from "path"; +import { loadEnv } from "vite"; import solid from "vite-plugin-solid"; import { defineConfig } from "vitest/config"; -export default defineConfig({ - plugins: [solid(), tailwindcss()], - resolve: { - alias: { - $lib: path.resolve(__dirname, "src/lib"), - $pages: path.resolve(__dirname, "src/pages"), - $components: path.resolve(__dirname, "src/components"), - $ui: path.resolve(__dirname, "src/components/ui"), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const appUrl = env.APP_URL || "http://localhost:3000"; + let host = "localhost"; + try { + const url = new URL(appUrl); + host = url.hostname; + } catch { + console.warn("Invalid APP_URL in .env, defaulting to localhost"); + } + + return { + plugins: [solid(), tailwindcss()], + resolve: { + alias: { + $lib: path.resolve(__dirname, "src/lib"), + $pages: path.resolve(__dirname, "src/pages"), + $components: path.resolve(__dirname, "src/components"), + $ui: path.resolve(__dirname, "src/components/ui"), + }, + }, + server: { + host: "0.0.0.0", + allowedHosts: [host, "localhost", "127.0.0.1", ".ts.net", ".ngrok-free.app"], + proxy: { "/api": { target: "http://localhost:8080", changeOrigin: true } }, + port: 3000, + }, + test: { + environment: "jsdom", + ui: false, + watch: false, + server: { deps: { inline: [/@solidjs/, /solid-js/, /solid-motionone/, /motion/] } }, }, - }, - server: { proxy: { "/api": { target: "http://localhost:8080", changeOrigin: true } } }, - test: { - environment: "jsdom", - ui: false, - watch: false, - server: { deps: { inline: [/@solidjs/, /solid-js/, /solid-motionone/, /motion/] } }, - }, + }; });