From 22067fb87600d4e687686187e800539106604806 Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:33:11 +0800 Subject: [PATCH 1/5] feat(reborn): serve the WebUI from root paths --- CHANGELOG.md | 1 + FEATURE_PARITY.md | 2 +- crates/ironclaw_reborn_composition/CLAUDE.md | 2 +- crates/ironclaw_reborn_composition/Cargo.toml | 4 +- .../src/llm_admin/nearai_login_serve.rs | 114 +++++++- .../src/webui/webui_serve.rs | 13 +- .../tests/webui_v2_serve.rs | 158 +++++++++-- .../ironclaw_reborn_webui_ingress/CLAUDE.md | 4 +- .../src/auth/pending.rs | 13 +- .../src/auth/routes.rs | 4 +- .../tests/github_oauth_routes.rs | 25 +- .../tests/google_oauth_routes.rs | 103 +++++-- .../tests/network_limits_contract.rs | 2 +- .../tests/session_round_trip.rs | 2 +- .../tests/signed_session_multi_user.rs | 2 +- crates/ironclaw_webui_v2/build.rs | 2 +- crates/ironclaw_webui_v2/frontend/index.html | 12 +- .../frontend/public/assets/site.webmanifest | 9 +- .../frontend/public/wallet-connect.html | 2 +- .../frontend/src/app/app.tsx | 4 +- .../frontend/src/app/auth.ts | 4 +- .../frontend/src/app/root-paths.test.ts | 67 +++++ .../frontend/src/components/sidebar.tsx | 2 +- .../frontend/src/pages/login/login-page.tsx | 2 +- .../src/pages/logs/lib/logs-data.test.ts | 5 +- .../frontend/src/pages/logs/lib/logs-data.ts | 3 +- .../frontend/src/pages/logs/logs-page.tsx | 2 +- .../components/provider-components.test.ts | 5 + .../pages/settings/hooks/useProviderLogin.ts | 2 +- .../ironclaw_webui_v2/frontend/vite.config.ts | 10 +- crates/ironclaw_webui_v2/src/lib.rs | 4 +- .../src/static_assets/assets.rs | 4 +- .../src/static_assets/mod.rs | 2 +- .../src/static_assets/router.rs | 255 ++++++++++++------ docs/reborn-binary.md | 9 +- .../security-parity/03-headers-errors.md | 26 +- scripts/run-reborn-webui.sh | 4 +- 37 files changed, 663 insertions(+), 221 deletions(-) create mode 100644 crates/ironclaw_webui_v2/frontend/src/app/root-paths.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ea59a13689..1ae9b573408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- *(webui-v2)* serve the Reborn WebUI from root-level browser routes, with temporary `/v2` compatibility redirects that preserve deep links and login query parameters; `/api/webchat/v2/*` remains unchanged ([#6142](https://github.com/nearai/ironclaw/issues/6142)). - *(reborn)* raise the default agent-loop runaway backstop from 256 to 1,024 iterations and the subagent ceiling from 16 to 256 ([#5959](https://github.com/nearai/ironclaw/pull/5959)). - *(reborn-cli)* document the standalone `config init` atomic-write dependency on `tempfile` and call out the default runner cadence change to 5s heartbeats / 200ms polling (down from 10s / 2s). - *(reborn)* expose runtime poll settings and document the standalone turn-runner cadence change for callers using `TurnRunnerSettings::default()`. diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index ae4a4c7ac75..4e30d233c61 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -649,7 +649,7 @@ Trace Commons issuer/TenantCtx note: the server-side `zmanian/tracedao-server` s | Model selection | ✅ | ✅ | - | TUI only | | Config editing | ✅ | ❌ | P3 | Raw config pending-changes diff panel with redacted reveal | | Debug/logs viewer | ✅ | ✅ | - | Real-time log streaming with level/target filters | -| WebChat interface | ✅ | ✅ | - | Web gateway chat with SSE/WebSocket | +| WebChat interface | ✅ | ✅ | - | Web gateway chat with SSE/WebSocket; Reborn serves canonical SPA routes at `/chat`, `/settings`, and `/extensions`, while legacy `/v2/*` browser URLs temporarily redirect to root equivalents and `/api/webchat/v2/*` stays unchanged | | Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI, improved asset resolution; macOS canvas hosts pushed A2UI without auto-reload | | Control UI i18n | ✅ | ❌ | P3 | English, Chinese, Portuguese; expanded with Persian (fa), Dutch (nl), Vietnamese (vi), Italian (it), Arabic (ar), Thai (th), Traditional Chinese (zh-TW) | | WebChat theme sync | ✅ | ❌ | P3 | Sync with system dark/light mode | diff --git a/crates/ironclaw_reborn_composition/CLAUDE.md b/crates/ironclaw_reborn_composition/CLAUDE.md index 0aa2aaab46a..990498b98c1 100644 --- a/crates/ironclaw_reborn_composition/CLAUDE.md +++ b/crates/ironclaw_reborn_composition/CLAUDE.md @@ -247,7 +247,7 @@ Reborn-native auth router. v1 gateway code remains untouched — ### Session transport decision (#4116) The OAuth callback returns a short-lived, one-time login ticket to -the SPA via the URL query (`/v2?login_ticket=`), not the +the SPA via the URL query (`/?login_ticket=`), not the session bearer itself and not an `HttpOnly` cookie. The SPA immediately POSTs that ticket to `/auth/session/exchange` and stores the returned bearer in `sessionStorage`. diff --git a/crates/ironclaw_reborn_composition/Cargo.toml b/crates/ironclaw_reborn_composition/Cargo.toml index 5c9038eb271..11622972872 100644 --- a/crates/ironclaw_reborn_composition/Cargo.toml +++ b/crates/ironclaw_reborn_composition/Cargo.toml @@ -162,9 +162,9 @@ ironclaw_product_context = { path = "../ironclaw_product_context", version = "0. nix = { version = "0.30", default-features = false, features = ["process"] } # Unpacking uploaded WASM-tool bundles for the WebUI "Install Tool" import flow. zip = { version = "8", default-features = false, features = ["deflate"] } -# WebUI v2 route surface — feature-gated; only used by `webui_serve`. +# WebUI v2 JSON route surface and root-mounted static SPA — feature-gated; +# only used by `webui_serve`. ironclaw_webui_v2 = { path = "../ironclaw_webui_v2", optional = true } -# WebUI v2 static SPA bundle — feature-gated; mounted under `/v2`. ironclaw_wasm_product_adapters = { path = "../ironclaw_wasm_product_adapters", optional = true } libsql = { version = "0.9", optional = true, default-features = false, features = ["core", "replication", "remote", "tls"] } libc = "0.2" diff --git a/crates/ironclaw_reborn_composition/src/llm_admin/nearai_login_serve.rs b/crates/ironclaw_reborn_composition/src/llm_admin/nearai_login_serve.rs index 6c87eafae76..4ef0198c8ce 100644 --- a/crates/ironclaw_reborn_composition/src/llm_admin/nearai_login_serve.rs +++ b/crates/ironclaw_reborn_composition/src/llm_admin/nearai_login_serve.rs @@ -40,6 +40,8 @@ const NEARAI_CALLBACK_RATE_MAX: NonZeroU32 = match NonZeroU32::new(60) { // SAFETY: 60 is a non-zero literal rate limit. None => unreachable!(), }; +const NEARAI_LOGIN_SUCCESS_REDIRECT: &str = "/chat"; +const NEARAI_LOGIN_ERROR_REDIRECT: &str = "/settings/inference?nearai_login=error"; #[derive(Clone)] struct NearAiCallbackState { @@ -61,16 +63,16 @@ async fn nearai_callback( Query(query): Query, ) -> Redirect { if !state.states.consume(&login_state).await { - return Redirect::to("/v2/settings/inference?nearai_login=error"); + return Redirect::to(NEARAI_LOGIN_ERROR_REDIRECT); } let Some(token) = query.token.filter(|token| !token.trim().is_empty()) else { - return Redirect::to("/v2/settings/inference?nearai_login=error"); + return Redirect::to(NEARAI_LOGIN_ERROR_REDIRECT); }; match apply_nearai_login(&state.session, &state.boot, state.reload.as_ref(), &token).await { - Ok(()) => Redirect::to("/v2/chat"), + Ok(()) => Redirect::to(NEARAI_LOGIN_SUCCESS_REDIRECT), Err(error) => { tracing::warn!(%error, "NEAR AI login callback failed"); - Redirect::to("/v2/settings/inference?nearai_login=error") + Redirect::to(NEARAI_LOGIN_ERROR_REDIRECT) } } } @@ -125,8 +127,50 @@ fn nearai_callback_descriptor() -> IngressRouteDescriptor { #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use async_trait::async_trait; + use axum::body::Body; + use axum::http::{Request, StatusCode, header}; + use ironclaw_llm::{SessionConfig, SessionManager}; + use ironclaw_reborn_config::{RebornHome, RebornProfile}; + use tower::ServiceExt; + use super::*; + #[derive(Default)] + struct RecordingReload { + calls: AtomicUsize, + } + + #[async_trait] + impl LlmReloadTrigger for RecordingReload { + async fn reload(&self) -> Result<(), String> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + fn callback_mount( + root: &std::path::Path, + states: Arc, + reload: Arc, + ) -> PublicRouteMount { + let home = + RebornHome::resolve_from_env_parts(Some(root.as_os_str().to_os_string()), None, None) + .expect("temporary Reborn home is valid"); + let session = Arc::new(SessionManager::new(SessionConfig { + auth_base_url: "https://private.near.ai".to_string(), + session_path: root.join("nearai-session.json"), + })); + nearai_login_callback_mount( + session, + reload, + RebornBootConfig::new(home, RebornProfile::LocalDev), + states, + ) + } + #[test] fn nearai_callback_descriptor_records_state_guarded_effectful_workflow() { let descriptor = nearai_callback_descriptor(); @@ -141,4 +185,66 @@ mod tests { assert_eq!(policy.scope_source(), IngressScopeSource::HostResolved); assert_eq!(policy.effect_path(), &AllowedEffectPath::ProductWorkflow); } + + #[tokio::test] + async fn nearai_callback_redirects_success_to_root_chat() { + let temp = tempfile::tempdir().expect("temporary directory"); + let states = Arc::new(NearAiLoginStateStore::new()); + let login_state = states.issue().await; + let reload = Arc::new(RecordingReload::default()); + let mount = callback_mount(temp.path(), states, Arc::clone(&reload)); + + let response = mount + .router + .oneshot( + Request::builder() + .uri(format!( + "/api/webchat/v2/llm/nearai/{login_state}/auth/callback?token=session-token" + )) + .body(Body::empty()) + .expect("callback request"), + ) + .await + .expect("callback response"); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get(header::LOCATION), + Some(&NEARAI_LOGIN_SUCCESS_REDIRECT.parse().expect("location")) + ); + assert_eq!(reload.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn nearai_callback_redirects_invalid_or_incomplete_login_to_root_settings() { + let temp = tempfile::tempdir().expect("temporary directory"); + let states = Arc::new(NearAiLoginStateStore::new()); + let valid_state = states.issue().await; + let reload = Arc::new(RecordingReload::default()); + let mount = callback_mount(temp.path(), states, Arc::clone(&reload)); + + for uri in [ + "/api/webchat/v2/llm/nearai/unknown/auth/callback?token=session-token".to_string(), + format!("/api/webchat/v2/llm/nearai/{valid_state}/auth/callback"), + ] { + let response = mount + .router + .clone() + .oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("callback request"), + ) + .await + .expect("callback response"); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get(header::LOCATION), + Some(&NEARAI_LOGIN_ERROR_REDIRECT.parse().expect("location")) + ); + } + assert_eq!(reload.calls.load(Ordering::SeqCst), 0); + } } diff --git a/crates/ironclaw_reborn_composition/src/webui/webui_serve.rs b/crates/ironclaw_reborn_composition/src/webui/webui_serve.rs index d2bd0970b27..f5536983f25 100644 --- a/crates/ironclaw_reborn_composition/src/webui/webui_serve.rs +++ b/crates/ironclaw_reborn_composition/src/webui/webui_serve.rs @@ -767,14 +767,11 @@ pub fn webui_v2_app_with_lifecycle( // CORS, panic boundary, and the global body-limit // (`.layer(...)` calls below) still apply, defense in depth. // - // The static crate's `mount_at_prefix` factory owns the - // routing surface (root, trailing-slash, wildcard, and any - // future routes it adds) so the composition layer never - // enumerates individual handlers. `merge` (not `nest`) is - // used because the factory already returns fully prefixed - // routes — `nest` in axum 0.8 has quirky dispatch for the - // exact prefix with/without trailing slash. - .merge(ironclaw_webui_v2::mount_at_prefix("/v2")) + // The static crate owns the complete browser surface: root SPA + // routes, assets, the isolated wallet popup, and compatibility + // redirects from the former `/v2` mount. Composition merges that + // surface as a unit so it never re-implements route policy. + .merge(ironclaw_webui_v2::static_router()) // Outer global cap: applies to unmatched paths (e.g. 404 fallback) // as defense in depth. v2 routes are tighter via the per-route // body-limit middleware above. diff --git a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs index 457374396ba..2b639b6745d 100644 --- a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs +++ b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs @@ -14,6 +14,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; +// arch-exempt: large_file, root WebUI caller regressions stay with the shared composed-router fixture, plan #5905 use async_trait::async_trait; use axum::body::{Body, to_bytes}; use axum::http::{HeaderValue, Method, Request, StatusCode, header}; @@ -1064,7 +1065,7 @@ async fn served_app_stylesheet() -> String { } async fn served_app_vite_asset(suffix: &str) -> String { - let shell = served_static_text("/v2/").await; + let shell = served_static_text("/").await; let asset_path = shell_vite_asset_path(&shell, suffix); served_static_text(&asset_path).await } @@ -1072,7 +1073,7 @@ async fn served_app_vite_asset(suffix: &str) -> String { fn shell_vite_asset_path(shell: &str, suffix: &str) -> String { shell .split(['"', '\'']) - .find(|part| part.starts_with("/v2/assets/app-") && part.ends_with(suffix)) + .find(|part| part.starts_with("/assets/app-") && part.ends_with(suffix)) .expect("shell should reference requested Vite asset") .to_string() } @@ -2168,11 +2169,11 @@ fn expand_route_pattern(pattern: &str) -> String { // ─── static SPA mount (`ironclaw_webui_v2`) ──────────────────── // -// The composition mounts the embedded SPA bundle under `/v2`. These -// tests drive that mount through the same composed router production -// uses, so a regression that drops the `.nest("/v2", ...)` call (or -// that accidentally routes the SPA through the bearer-auth middleware) -// fails here. Per `.claude/rules/testing.md` ("Test Through the +// The composition mounts the embedded SPA bundle at the gateway root. These +// tests drive that mount through the same composed router production uses, so +// a regression that drops the static router (or accidentally routes the SPA +// through the bearer-auth middleware) fails here. Per +// `.claude/rules/testing.md` ("Test Through the // Caller") — the standalone router test in `ironclaw_webui_v2` // does not exercise the composition seam, so this layer needs its // own coverage. @@ -2184,7 +2185,7 @@ async fn static_root_serves_index_with_substituted_csp_nonce() { .oneshot( Request::builder() .method(Method::GET) - .uri("/v2/") + .uri("/") .body(Body::empty()) .expect("request"), ) @@ -2214,13 +2215,13 @@ async fn static_root_does_not_require_bearer_auth() { let (app, _) = build_app(); // No Authorization header at all — anonymous fetch of the SPA shell // must succeed. The bearer-auth middleware is only attached to the - // v2 JSON routes via `route_layer`, so the static `.nest("/v2", …)` - // mount escapes it by design. + // v2 JSON routes via `route_layer`, so the root static router escapes it + // by design. let response = app .oneshot( Request::builder() .method(Method::GET) - .uri("/v2/") + .uri("/") .body(Body::empty()) .expect("request"), ) @@ -2229,6 +2230,125 @@ async fn static_root_does_not_require_bearer_auth() { assert_eq!(response.status(), StatusCode::OK); } +#[tokio::test] +async fn legacy_v2_urls_redirect_to_root_without_losing_query_data() { + let (app, _) = build_app(); + for (source, target) in [ + ("/v2", "/"), + ("/v2/", "/"), + ( + "/v2/settings/skills?token=old%2Btoken&tab=installed", + "/settings/skills?token=old%2Btoken&tab=installed", + ), + ("/v2?login_ticket=ticket%2B1", "/?login_ticket=ticket%2B1"), + ("/v2//evil.example?keep=1", "/evil.example?keep=1"), + (r"/v2/\evil.example", "/evil.example"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(source) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + assert_eq!( + response.status(), + StatusCode::TEMPORARY_REDIRECT, + "GET {source}", + ); + assert_eq!( + response + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()), + Some(target), + "GET {source}", + ); + } +} + +#[tokio::test] +async fn root_static_mount_keeps_server_namespaces_fail_closed() { + let (app, _) = build_app(); + for (method, path) in [ + (Method::GET, "/api/not-a-route"), + (Method::POST, "/api/not-a-route"), + (Method::GET, "/auth/not-a-route"), + (Method::POST, "/auth/not-a-route"), + (Method::GET, "/v1/not-a-route"), + (Method::POST, "/v1/not-a-route"), + (Method::GET, "/webhooks/not-a-route"), + (Method::POST, "/webhooks/not-a-route"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method.clone()) + .uri(path) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method} {path}"); + } +} + +#[tokio::test] +async fn root_manifest_and_wallet_popup_are_served_with_owned_contracts() { + let (app, _) = build_app(); + let manifest_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/assets/site.webmanifest") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + assert_eq!(manifest_response.status(), StatusCode::OK); + assert_eq!( + manifest_response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/manifest+json"), + ); + let manifest = read_body_string(manifest_response).await; + let manifest: serde_json::Value = + serde_json::from_str(&manifest).expect("embedded manifest must be valid JSON"); + assert_eq!(manifest["id"], "/v2/"); + assert_eq!(manifest["start_url"], "/"); + assert_eq!(manifest["scope"], "/"); + + let wallet_response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/wallet/connect") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + assert_eq!(wallet_response.status(), StatusCode::OK); + let wallet_csp = wallet_response + .headers() + .get(header::CONTENT_SECURITY_POLICY) + .and_then(|value| value.to_str().ok()) + .expect("wallet popup CSP"); + assert!(wallet_csp.contains("script-src 'self' 'unsafe-inline' https:")); + let wallet_body = read_body_string(wallet_response).await; + assert!(wallet_body.contains("src=\"/wallet-connect.js\"")); +} + #[tokio::test] async fn static_js_asset_returns_javascript_content_type() { let (app, _) = build_app(); @@ -2237,7 +2357,7 @@ async fn static_js_asset_returns_javascript_content_type() { .oneshot( Request::builder() .method(Method::GET) - .uri("/v2/") + .uri("/") .body(Body::empty()) .expect("request"), ) @@ -2362,7 +2482,7 @@ async fn static_css_asset_returns_text_css_content_type() { .oneshot( Request::builder() .method(Method::GET) - .uri("/v2/") + .uri("/") .body(Body::empty()) .expect("request"), ) @@ -2469,7 +2589,7 @@ async fn static_unknown_extension_path_returns_404() { .oneshot( Request::builder() .method(Method::GET) - .uri("/v2/missing-asset.bin") + .uri("/missing-asset.bin") .body(Body::empty()) .expect("request"), ) @@ -2480,16 +2600,16 @@ async fn static_unknown_extension_path_returns_404() { #[tokio::test] async fn static_client_side_route_falls_back_to_spa_shell() { - // Any `/v2/` path that does not match an asset + // Any root-level no-dot path that does not match an asset // returns the SPA shell so react-router can render the right - // view. Without this, a hard refresh on `/v2/chat/` would + // view. Without this, a hard refresh on `/chat/` would // 404 instead of resuming the chat view. let (app, _) = build_app(); let response = app .oneshot( Request::builder() .method(Method::GET) - .uri("/v2/chat/some-thread-id") + .uri("/chat/some-thread-id") .body(Body::empty()) .expect("request"), ) @@ -2516,7 +2636,7 @@ async fn static_root_emits_a_fresh_nonce_per_request() { .oneshot( Request::builder() .method(Method::GET) - .uri("/v2/") + .uri("/") .body(Body::empty()) .expect("request"), ) @@ -2528,7 +2648,7 @@ async fn static_root_emits_a_fresh_nonce_per_request() { app.oneshot( Request::builder() .method(Method::GET) - .uri("/v2/") + .uri("/") .body(Body::empty()) .expect("request"), ) diff --git a/crates/ironclaw_reborn_webui_ingress/CLAUDE.md b/crates/ironclaw_reborn_webui_ingress/CLAUDE.md index 2f7fd46b94b..602e3ef6bf3 100644 --- a/crates/ironclaw_reborn_webui_ingress/CLAUDE.md +++ b/crates/ironclaw_reborn_webui_ingress/CLAUDE.md @@ -65,7 +65,7 @@ Routes mounted by `webui_v2_auth_router`: cross-provider replay guard, code exchange via the matching `OAuthProvider`, user resolution via `UserDirectory`, session mint via `SessionStore`, and redirect to - `{redirect_after}?login_ticket=` (default `/v2`). The + `{redirect_after}?login_ticket=` (default `/`). The ticket is short-lived and single-use; the SPA redeems it over same-origin JSON so the bearer never appears in a redirect `Location` header. @@ -136,7 +136,7 @@ pub trait OAuthProvider: Send + Sync + 'static { - **Hosted-domain restriction** is enforced server-side from the ID token's `hd` claim, not from the `hd=` URL hint. - **Error mapping**: every failure path redirects to - `/v2?login_error=` where `` is an opaque enum + `/?login_error=` where `` is an opaque enum (`invalid_state`, `provider_mismatch`, `denied`, `unauthorized`, `exchange_failed`, `server_error`, `invalid_request`). Provider error bodies, JWT decode messages, diff --git a/crates/ironclaw_reborn_webui_ingress/src/auth/pending.rs b/crates/ironclaw_reborn_webui_ingress/src/auth/pending.rs index 250a2a711d3..f61e1850516 100644 --- a/crates/ironclaw_reborn_webui_ingress/src/auth/pending.rs +++ b/crates/ironclaw_reborn_webui_ingress/src/auth/pending.rs @@ -283,7 +283,7 @@ mod tests { #[test] fn insert_then_take_returns_same_flow() { let store = PendingFlowStore::new(); - let (state, flow) = store.insert(google(), Some("/v2".to_string())); + let (state, flow) = store.insert(google(), Some("/".to_string())); assert!(!state.is_empty()); let taken = store.take(&state).expect("flow present"); assert_eq!(taken.provider, google()); @@ -292,7 +292,7 @@ mod tests { flow.code_verifier.expose_secret() ); assert_eq!(taken.code_challenge, flow.code_challenge); - assert_eq!(taken.redirect_after.as_deref(), Some("/v2")); + assert_eq!(taken.redirect_after.as_deref(), Some("/")); } // Regression: the challenge stored on the flow MUST equal the @@ -330,7 +330,7 @@ mod tests { provider: google(), code_verifier: SecretString::from("expired-verifier".to_string()), code_challenge: "expired-challenge".to_string(), - redirect_after: Some("/v2".to_string()), + redirect_after: Some("/".to_string()), created_at: Instant::now() - STATE_TTL - Duration::from_secs(1), }, ); @@ -419,6 +419,9 @@ mod tests { #[test] fn safe_redirects_pass_validation() { assert!(is_safe_redirect("/")); + // Cached pre-root-migration clients may still request the legacy SPA + // mount. Keep accepting it so the compatibility redirect can preserve + // the one-time login ticket on the way to `/`. assert!(is_safe_redirect("/v2")); assert!(is_safe_redirect("/v2/threads/abc")); assert!(is_safe_redirect("/v2?tab=settings")); @@ -458,8 +461,8 @@ mod tests { #[test] fn sanitize_redirect_strips_unsafe_inputs() { assert_eq!( - sanitize_redirect(Some("/v2".to_string())), - Some("/v2".to_string()) + sanitize_redirect(Some("/".to_string())), + Some("/".to_string()) ); assert_eq!(sanitize_redirect(Some("//attacker".to_string())), None); assert_eq!( diff --git a/crates/ironclaw_reborn_webui_ingress/src/auth/routes.rs b/crates/ironclaw_reborn_webui_ingress/src/auth/routes.rs index ed2b5e0a904..71cfebae0a2 100644 --- a/crates/ironclaw_reborn_webui_ingress/src/auth/routes.rs +++ b/crates/ironclaw_reborn_webui_ingress/src/auth/routes.rs @@ -43,7 +43,7 @@ use crate::session::SessionStore; /// Default landing page after a successful OAuth callback. The SPA /// reads `?login_ticket=` and exchanges it for a bearer. -const DEFAULT_REDIRECT_AFTER: &str = "/v2"; +const DEFAULT_REDIRECT_AFTER: &str = "/"; /// Default session lifetime (30 days). Matches the v1 gateway's /// `SESSION_LIFETIME_SECS`; production deployments can override via @@ -645,7 +645,7 @@ fn build_success_redirect(redirect_after: &str, ticket: &str) -> String { /// code in the query string. The SPA maps the code to a localized /// error banner. fn spa_error_redirect(code: &str) -> Redirect { - let target = format!("/v2?login_error={}", urlencoding::encode(code)); + let target = format!("/?login_error={}", urlencoding::encode(code)); Redirect::to(&target) } diff --git a/crates/ironclaw_reborn_webui_ingress/tests/github_oauth_routes.rs b/crates/ironclaw_reborn_webui_ingress/tests/github_oauth_routes.rs index 31686d3ff1d..747a640da10 100644 --- a/crates/ironclaw_reborn_webui_ingress/tests/github_oauth_routes.rs +++ b/crates/ironclaw_reborn_webui_ingress/tests/github_oauth_routes.rs @@ -202,7 +202,7 @@ async fn login_and_callback(router: &axum::Router) -> String { .oneshot( Request::builder() .method("GET") - .uri("/auth/login/github?redirect_after=%2Fv2") + .uri("/auth/login/github?redirect_after=%2F") .body(Body::empty()) .expect("request"), ) @@ -286,7 +286,7 @@ async fn login_redirects_to_github_with_state_and_scope_and_no_pkce() { .oneshot( Request::builder() .method("GET") - .uri("/auth/login/github?redirect_after=%2Fv2") + .uri("/auth/login/github?redirect_after=%2F") .body(Body::empty()) .expect("request"), ) @@ -318,7 +318,7 @@ async fn callback_success_mints_session_for_primary_verified_email() { let router = build_router(vec![github_provider(addr)], session_store); let landing = login_and_callback(&router).await; - assert!(landing.starts_with("/v2?login_ticket="), "got {landing}"); + assert!(landing.starts_with("/?login_ticket="), "got {landing}"); assert!( !landing.contains("#token="), "callback Location must not carry the bearer: {landing}", @@ -391,7 +391,7 @@ async fn callback_with_unverified_emails_mints_session_for_provider_sub() { let router = build_router(vec![github_provider(addr)], session_store); let landing = login_and_callback(&router).await; - assert!(landing.starts_with("/v2?login_ticket="), "got {landing}"); + assert!(landing.starts_with("/?login_ticket="), "got {landing}"); let ticket = ticket_from_landing(&landing); let bearer = redeem_ticket(&router, &ticket).await; let session = store_inner @@ -428,10 +428,7 @@ async fn callback_with_provider_error_redirects_with_denied() { .await .expect("oneshot"); assert_eq!(resp.status(), StatusCode::SEE_OTHER); - assert_eq!( - header_str(&resp, header::LOCATION), - "/v2?login_error=denied" - ); + assert_eq!(header_str(&resp, header::LOCATION), "/?login_error=denied"); } #[tokio::test] @@ -491,7 +488,7 @@ async fn callback_exchange_failure_redirects_with_exchange_failed() { assert_eq!(callback.status(), StatusCode::SEE_OTHER); assert_eq!( header_str(&callback, header::LOCATION), - "/v2?login_error=exchange_failed", + "/?login_error=exchange_failed", ); } @@ -517,7 +514,7 @@ async fn callback_with_unknown_state_redirects_with_invalid_state_error() { assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!( header_str(&resp, header::LOCATION), - "/v2?login_error=invalid_state" + "/?login_error=invalid_state" ); } @@ -560,7 +557,7 @@ async fn callback_with_state_replay_fails_closed() { .await .expect("oneshot"); assert_eq!(first.status(), StatusCode::SEE_OTHER); - assert!(header_str(&first, header::LOCATION).starts_with("/v2?login_ticket=")); + assert!(header_str(&first, header::LOCATION).starts_with("/?login_ticket=")); assert_eq!(store_inner.len(), 1); // Replaying the SAME state must fail closed — no second session. @@ -580,7 +577,7 @@ async fn callback_with_state_replay_fails_closed() { assert_eq!(replay.status(), StatusCode::SEE_OTHER); assert_eq!( header_str(&replay, header::LOCATION), - "/v2?login_error=invalid_state" + "/?login_error=invalid_state" ); assert_eq!( store_inner.len(), @@ -655,7 +652,7 @@ async fn callback_profile_fetch_failure_redirects_with_exchange_failed() { assert_eq!(callback.status(), StatusCode::SEE_OTHER); assert_eq!( header_str(&callback, header::LOCATION), - "/v2?login_error=exchange_failed", + "/?login_error=exchange_failed", ); } @@ -722,7 +719,7 @@ async fn callback_exchange_timeout_redirects_with_exchange_failed() { assert_eq!(callback.status(), StatusCode::SEE_OTHER); assert_eq!( header_str(&callback, header::LOCATION), - "/v2?login_error=exchange_failed", + "/?login_error=exchange_failed", ); } diff --git a/crates/ironclaw_reborn_webui_ingress/tests/google_oauth_routes.rs b/crates/ironclaw_reborn_webui_ingress/tests/google_oauth_routes.rs index e80a8189d0f..5fe573a09c2 100644 --- a/crates/ironclaw_reborn_webui_ingress/tests/google_oauth_routes.rs +++ b/crates/ironclaw_reborn_webui_ingress/tests/google_oauth_routes.rs @@ -226,7 +226,7 @@ async fn login_redirects_to_provider_with_state_and_callback_url() { .oneshot( Request::builder() .method("GET") - .uri("/auth/login/google?redirect_after=%2Fv2") + .uri("/auth/login/google?redirect_after=%2F") .body(Body::empty()) .expect("request"), ) @@ -259,7 +259,7 @@ async fn login_from_non_canonical_host_redirects_before_state_creation() { .oneshot( Request::builder() .method("GET") - .uri("/auth/login/google?redirect_after=%2Fv2") + .uri("/auth/login/google?redirect_after=%2F") .header(header::HOST, "preview.example") .body(Body::empty()) .expect("request"), @@ -275,7 +275,7 @@ async fn login_from_non_canonical_host_redirects_before_state_creation() { .expect("utf-8"); assert_eq!( location, - "https://gateway.example/auth/login/google?redirect_after=%2Fv2" + "https://gateway.example/auth/login/google?redirect_after=%2F" ); } @@ -374,7 +374,7 @@ async fn callback_success_creates_session_and_redirects_with_login_ticket() { .oneshot( Request::builder() .method("GET") - .uri("/auth/login/google?redirect_after=%2Fv2") + .uri("/auth/login/google?redirect_after=%2F") .body(Body::empty()) .expect("request"), ) @@ -414,7 +414,7 @@ async fn callback_success_creates_session_and_redirects_with_login_ticket() { .to_str() .expect("utf-8") .to_string(); - assert!(landing.starts_with("/v2?login_ticket="), "got {landing}",); + assert!(landing.starts_with("/?login_ticket="), "got {landing}",); assert!( !landing.contains("#token="), "callback Location must not carry the bearer: {landing}", @@ -459,6 +459,71 @@ async fn callback_success_creates_session_and_redirects_with_login_ticket() { assert_eq!(session.user_id.as_str(), "alice@example.com"); } +#[tokio::test] +async fn callback_preserves_legacy_v2_redirect_after_through_ticket_exchange() { + // A cached pre-migration SPA can still start OAuth with `/v2` as its + // redirect target. The callback must preserve that safe same-origin path; + // the root router's compatibility redirect then carries the ticket query + // to `/`. This crate owns the callback half of that rolling-upgrade seam. + let store_inner = Arc::new(InMemorySessionStore::new()); + let session_store: Arc = store_inner.clone(); + let provider = StubProvider::google_with_profile(alice_profile()); + let router = build_router(vec![provider as Arc], session_store); + + let login = router + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/auth/login/google?redirect_after=%2Fv2") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + let state = state_from_location( + login + .headers() + .get(header::LOCATION) + .expect("Location") + .to_str() + .expect("utf-8"), + ); + + let callback = router + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/auth/callback/google?code=legacy-code&state={}", + urlencoding::encode(&state) + )) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + assert_eq!(callback.status(), StatusCode::SEE_OTHER); + let landing = callback + .headers() + .get(header::LOCATION) + .expect("Location") + .to_str() + .expect("utf-8"); + assert!( + landing.starts_with("/v2?login_ticket="), + "legacy redirect_after must be preserved; got {landing}", + ); + + let ticket = ticket_from_landing(landing); + let bearer = redeem_ticket(router, &ticket).await; + assert!( + store_inner.lookup(&bearer).await.expect("lookup").is_some(), + "legacy landing ticket must still exchange for a live session", + ); +} + fn ticket_from_landing(landing: &str) -> String { let query = landing.split_once('?').expect("query").1; let query = query.split_once('#').map(|(q, _)| q).unwrap_or(query); @@ -520,7 +585,7 @@ async fn callback_with_unknown_state_redirects_with_error_code() { .expect("Location") .to_str() .expect("utf-8"); - assert_eq!(location, "/v2?login_error=invalid_state"); + assert_eq!(location, "/?login_error=invalid_state"); } #[tokio::test] @@ -583,7 +648,7 @@ async fn callback_with_state_replay_fails_closed() { .to_str() .unwrap(); assert!( - first_location.starts_with("/v2?login_ticket="), + first_location.starts_with("/?login_ticket="), "first callback must succeed; got {first_location}" ); assert_eq!(store_inner.len(), 1, "first callback must mint a session"); @@ -612,7 +677,7 @@ async fn callback_with_state_replay_fails_closed() { .unwrap() .to_str() .unwrap(); - assert_eq!(replay_location, "/v2?login_error=invalid_state"); + assert_eq!(replay_location, "/?login_error=invalid_state"); assert_eq!( store_inner.len(), 1, @@ -643,7 +708,7 @@ async fn callback_with_provider_error_param_redirects_with_denied() { .unwrap() .to_str() .unwrap(); - assert_eq!(location, "/v2?login_error=denied"); + assert_eq!(location, "/?login_error=denied"); } #[tokio::test] @@ -693,7 +758,7 @@ async fn callback_when_provider_rejects_hosted_domain_yields_unauthorized() { .unwrap() .to_str() .unwrap(); - assert_eq!(location, "/v2?login_error=unauthorized"); + assert_eq!(location, "/?login_error=unauthorized"); assert_eq!(store_inner.len(), 0, "no session must be created"); } @@ -708,7 +773,7 @@ async fn login_open_redirect_attempt_falls_back_to_default() { ); // Protocol-relative redirect target: sanitize_redirect must - // strip it, and the callback must land on the default `/v2`. + // strip it, and the callback must land on the default `/`. let login = router .clone() .oneshot( @@ -749,7 +814,7 @@ async fn login_open_redirect_attempt_falls_back_to_default() { .unwrap() .to_str() .unwrap(); - assert!(location.starts_with("/v2?login_ticket=")); + assert!(location.starts_with("/?login_ticket=")); } // ─── logout ─────────────────────────────────────────────────────────── @@ -890,7 +955,7 @@ async fn callback_missing_code_or_state_redirects_invalid_request() { .unwrap() .to_str() .unwrap(); - assert_eq!(location, "/v2?login_error=invalid_request", "uri={uri}"); + assert_eq!(location, "/?login_error=invalid_request", "uri={uri}"); } } @@ -955,7 +1020,7 @@ async fn callback_with_state_for_different_provider_redirects_provider_mismatch( .unwrap() .to_str() .unwrap(); - assert_eq!(location, "/v2?login_error=provider_mismatch"); + assert_eq!(location, "/?login_error=provider_mismatch"); assert_eq!( store_inner.len(), 0, @@ -1017,7 +1082,7 @@ async fn callback_when_provider_exchange_fails_redirects_exchange_failed() { .unwrap() .to_str() .unwrap(); - assert_eq!(location, "/v2?login_error=exchange_failed"); + assert_eq!(location, "/?login_error=exchange_failed"); assert_eq!(store_inner.len(), 0); } @@ -1071,7 +1136,7 @@ async fn callback_when_profile_fetch_fails_redirects_exchange_failed() { .unwrap() .to_str() .unwrap(), - "/v2?login_error=exchange_failed", + "/?login_error=exchange_failed", ); } @@ -1174,7 +1239,7 @@ mod user_directory_branches { async fn unknown_user_redirects_unauthorized() { let (router, store) = build_router_with_directory(Arc::new(AlwaysUnknown)); let location = drive_callback(router).await; - assert_eq!(location, "/v2?login_error=unauthorized"); + assert_eq!(location, "/?login_error=unauthorized"); assert_eq!(store.len(), 0); } @@ -1182,7 +1247,7 @@ mod user_directory_branches { async fn backend_failure_redirects_server_error() { let (router, store) = build_router_with_directory(Arc::new(AlwaysBackendFail)); let location = drive_callback(router).await; - assert_eq!(location, "/v2?login_error=server_error"); + assert_eq!(location, "/?login_error=server_error"); assert_eq!(store.len(), 0); } } @@ -1283,7 +1348,7 @@ mod session_store_failure { .unwrap() .to_str() .unwrap(), - "/v2?login_error=server_error", + "/?login_error=server_error", ); } } diff --git a/crates/ironclaw_reborn_webui_ingress/tests/network_limits_contract.rs b/crates/ironclaw_reborn_webui_ingress/tests/network_limits_contract.rs index a99122636b2..94e0e6c0550 100644 --- a/crates/ironclaw_reborn_webui_ingress/tests/network_limits_contract.rs +++ b/crates/ironclaw_reborn_webui_ingress/tests/network_limits_contract.rs @@ -140,7 +140,7 @@ fn default_origins() -> Vec { fn login_builder() -> Request { Request::builder() .method(Method::GET) - .uri(format!("/auth/login/{PROVIDER}?redirect_after=%2Fv2")) + .uri(format!("/auth/login/{PROVIDER}?redirect_after=%2F")) .body(Body::empty()) .expect("request") } diff --git a/crates/ironclaw_reborn_webui_ingress/tests/session_round_trip.rs b/crates/ironclaw_reborn_webui_ingress/tests/session_round_trip.rs index 90a24e165eb..26bb15b1171 100644 --- a/crates/ironclaw_reborn_webui_ingress/tests/session_round_trip.rs +++ b/crates/ironclaw_reborn_webui_ingress/tests/session_round_trip.rs @@ -435,7 +435,7 @@ async fn session_minted_via_oauth_callback_authenticates_protected_v2_route() { .oneshot(with_peer( Request::builder() .method(Method::GET) - .uri("/auth/login/google?redirect_after=%2Fv2") + .uri("/auth/login/google?redirect_after=%2F") .body(Body::empty()) .expect("request"), )) diff --git a/crates/ironclaw_reborn_webui_ingress/tests/signed_session_multi_user.rs b/crates/ironclaw_reborn_webui_ingress/tests/signed_session_multi_user.rs index bf2b9c6d818..a421204d2a2 100644 --- a/crates/ironclaw_reborn_webui_ingress/tests/signed_session_multi_user.rs +++ b/crates/ironclaw_reborn_webui_ingress/tests/signed_session_multi_user.rs @@ -422,7 +422,7 @@ async fn login(app: &axum::Router) -> String { .oneshot(with_peer( Request::builder() .method(Method::GET) - .uri("/auth/login/google?redirect_after=%2Fv2") + .uri("/auth/login/google?redirect_after=%2F") .body(Body::empty()) .expect("request"), )) diff --git a/crates/ironclaw_webui_v2/build.rs b/crates/ironclaw_webui_v2/build.rs index 43357ac6115..c7cd5ab4047 100644 --- a/crates/ironclaw_webui_v2/build.rs +++ b/crates/ironclaw_webui_v2/build.rs @@ -2,7 +2,7 @@ //! //! Builds the Vite frontend into Cargo's `OUT_DIR` when `webui-v2-beta` is //! enabled, then emits Rust source that declares one `ASSETS` slice keyed by -//! URL path (relative to the mount prefix). Each entry pairs an +//! URL path (relative to the gateway root). Each entry pairs an //! `include_bytes!` reference with a content-type string picked from the file //! extension. //! diff --git a/crates/ironclaw_webui_v2/frontend/index.html b/crates/ironclaw_webui_v2/frontend/index.html index 5e354acd1b1..efb1884597b 100644 --- a/crates/ironclaw_webui_v2/frontend/index.html +++ b/crates/ironclaw_webui_v2/frontend/index.html @@ -10,19 +10,19 @@ - - + + - - + + + diff --git a/crates/ironclaw_webui_v2/frontend/src/app/app.tsx b/crates/ironclaw_webui_v2/frontend/src/app/app.tsx index 9f48ba16a5e..f870a653fd4 100644 --- a/crates/ironclaw_webui_v2/frontend/src/app/app.tsx +++ b/crates/ironclaw_webui_v2/frontend/src/app/app.tsx @@ -34,7 +34,7 @@ function LoginPage({ auth }) { const from = fromLocation ? `${fromLocation.pathname || defaultRoute}${fromLocation.search || ""}${fromLocation.hash || ""}` : defaultRoute; - const redirectAfter = `/v2${from === "/" ? "" : from}`; + const redirectAfter = from; const handleSubmit = React.useCallback( (token) => { @@ -101,7 +101,7 @@ export function App() { const auth = useAuthSession(); return ( - + )} /> )}> diff --git a/crates/ironclaw_webui_v2/frontend/src/app/auth.ts b/crates/ironclaw_webui_v2/frontend/src/app/auth.ts index 51a6ffde24c..126f79fa84b 100644 --- a/crates/ironclaw_webui_v2/frontend/src/app/auth.ts +++ b/crates/ironclaw_webui_v2/frontend/src/app/auth.ts @@ -23,13 +23,13 @@ import { clearAllDrafts } from "../pages/chat/lib/draft-store"; // the login page). // `login_ticket=` — OAuth callback transport. The host's // `/auth/callback/{provider}` redirects to -// `/v2?login_ticket=`. The ticket is short-lived +// `/?login_ticket=`. The ticket is short-lived // and single-use; the SPA POSTs it to // `/auth/session/exchange` for the real bearer so the // bearer never appears in a redirect `Location` header. // // Raw bearer URL tokens are honored ONLY when sessionStorage has no -// token yet. Without this guard a crafted `/v2/#token=INVALID` link +// token yet. Without this guard a crafted `/#token=INVALID` link // could replace a user's working bearer with garbage and lock them // out until they re-auth. OAuth `login_ticket` redirects are different: // they come from an intentional provider callback and must replace a diff --git a/crates/ironclaw_webui_v2/frontend/src/app/root-paths.test.ts b/crates/ironclaw_webui_v2/frontend/src/app/root-paths.test.ts new file mode 100644 index 00000000000..3e081df05ae --- /dev/null +++ b/crates/ironclaw_webui_v2/frontend/src/app/root-paths.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; + +const FRONTEND_ROOT = new URL("../../", import.meta.url); + +function frontendFile(path: string) { + return readFileSync(new URL(path, FRONTEND_ROOT), "utf8"); +} + +test("Vite and the SPA shell load browser assets from the root path", () => { + const viteConfig = frontendFile("vite.config.ts"); + assert.match(viteConfig, /base:\s*"\/"/); + for (const proxyPath of [ + "/assets", + "/vendor", + "/wallet/connect", + "/wallet-connect.js", + ]) { + assert.ok(viteConfig.includes(`"${proxyPath}"`), `missing root proxy ${proxyPath}`); + } + + const index = frontendFile("index.html"); + for (const assetPath of [ + "/assets/favicon-96x96.png", + "/assets/favicon.svg", + "/assets/favicon.ico", + "/assets/apple-touch-icon.png", + "/assets/site.webmanifest", + "/vendor/fonts/fonts.css", + ]) { + assert.ok(index.includes(`"${assetPath}"`), `missing root asset ${assetPath}`); + } + assert.ok(!index.includes('="/v2/'), "SPA shell must not load assets from /v2"); +}); + +test("PWA and wallet entrypoints use root-scoped URLs", () => { + const manifest = JSON.parse(frontendFile("public/assets/site.webmanifest")); + // Keep the identity of PWA installs created before the root-path move. + assert.equal(manifest.id, "/v2/"); + assert.equal(manifest.start_url, "/"); + assert.equal(manifest.scope, "/"); + assert.deepEqual( + manifest.icons.map((icon: { src: string }) => icon.src), + [ + "/assets/web-app-manifest-192x192.png", + "/assets/web-app-manifest-512x512.png", + ], + ); + + const wallet = frontendFile("public/wallet-connect.html"); + assert.ok(wallet.includes('src="/wallet-connect.js"')); + assert.ok(!wallet.includes('src="/v2/')); +}); + +test("router and login defaults no longer add the legacy mount prefix", () => { + const app = frontendFile("src/app/app.tsx"); + assert.ok(app.includes("const redirectAfter = from;")); + assert.match(app, //); + assert.ok(!app.includes("basename=")); + + const login = frontendFile("src/pages/login/login-page.tsx"); + assert.ok(login.includes('oauthRedirectAfter = "/"')); + + const sidebar = frontendFile("src/components/sidebar.tsx"); + assert.ok(sidebar.includes('src="/assets/logo.jpg"')); +}); diff --git a/crates/ironclaw_webui_v2/frontend/src/components/sidebar.tsx b/crates/ironclaw_webui_v2/frontend/src/components/sidebar.tsx index dc8a172a07f..05732a8c535 100644 --- a/crates/ironclaw_webui_v2/frontend/src/components/sidebar.tsx +++ b/crates/ironclaw_webui_v2/frontend/src/components/sidebar.tsx @@ -30,7 +30,7 @@ export function Sidebar({ className="flex items-center gap-2.5 opacity-90 hover:opacity-100" aria-label="IronClaw" > - IronClaw + IronClaw diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/login/login-page.tsx b/crates/ironclaw_webui_v2/frontend/src/pages/login/login-page.tsx index eb4e084b8fd..3305ecb3ab4 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/login/login-page.tsx +++ b/crates/ironclaw_webui_v2/frontend/src/pages/login/login-page.tsx @@ -9,7 +9,7 @@ import { cn } from "../../utils/cn"; import { OAuthProviderButtons } from "./components/oauth-provider-buttons"; import { useOAuthProviders } from "./hooks/useOAuthProviders"; -export function LoginPage({ initialToken, error, oauthRedirectAfter = "/v2", onSubmit }) { +export function LoginPage({ initialToken, error, oauthRedirectAfter = "/", onSubmit }) { const t = useT(); const { theme, toggleTheme } = useInterfaceTheme(); const oauthProviders = useOAuthProviders(); diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/logs/lib/logs-data.test.ts b/crates/ironclaw_webui_v2/frontend/src/pages/logs/lib/logs-data.test.ts index ce53ba99e1c..c9a73780693 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/logs/lib/logs-data.test.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/logs/lib/logs-data.test.ts @@ -97,8 +97,5 @@ test("buildScopedLogsPath encodes structured log filters", () => { }), "/logs?thread_id=thread+a&run_id=run-a&tool_call_id=tool%2Fa&source=slack", ); - assert.equal( - buildScopedLogsPath({ threadId: "thread-a" }, { absolute: true }), - "/v2/logs?thread_id=thread-a", - ); + assert.equal(buildScopedLogsPath({ threadId: "thread-a" }), "/logs?thread_id=thread-a"); }); diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/logs/lib/logs-data.ts b/crates/ironclaw_webui_v2/frontend/src/pages/logs/lib/logs-data.ts index 40ce399405b..a0855f90d0b 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/logs/lib/logs-data.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/logs/lib/logs-data.ts @@ -17,7 +17,6 @@ export function normalizeLogEntry(entry) { export function buildScopedLogsPath( { threadId, runId, turnId, toolCallId, toolName, source } = {}, - { absolute = false } = {}, ) { const params = new URLSearchParams(); if (threadId) params.set("thread_id", threadId); @@ -28,7 +27,7 @@ export function buildScopedLogsPath( if (source) params.set("source", source); const suffix = params.toString(); const path = `/logs${suffix ? `?${suffix}` : ""}`; - return absolute ? `/v2${path}` : path; + return path; } export function normalizeOperatorLogsResponse(response) { diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/logs/logs-page.tsx b/crates/ironclaw_webui_v2/frontend/src/pages/logs/logs-page.tsx index 0ed1bf03449..cbcefb97dc5 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/logs/logs-page.tsx +++ b/crates/ironclaw_webui_v2/frontend/src/pages/logs/logs-page.tsx @@ -246,7 +246,7 @@ export function LogsPage() { (item) => () )} {t("logs.clearScope")} diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/provider-components.test.ts b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/provider-components.test.ts index 1cfdcafd78e..c00336e569e 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/provider-components.test.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/provider-components.test.ts @@ -742,6 +742,11 @@ test("startNearaiWallet proceeds on a loopback origin (wallet is not hosted SSO) const run = runProviderLogin({ hostname: "127.0.0.1" }); await run.hook.startNearaiWallet(); assert.ok(run.httpCalls.includes("open"), "wallet popup opens on localhost"); + assert.equal( + run.openedUrls[0], + "/wallet/connect?channel=nearai-wallet-login%3Auuid", + "wallet popup uses the root-path route", + ); assert.ok( !run.nearaiErrors().includes("onboarding.nearaiLocalSso"), "no hosted-SSO local block for the wallet path" diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/settings/hooks/useProviderLogin.ts b/crates/ironclaw_webui_v2/frontend/src/pages/settings/hooks/useProviderLogin.ts index 08a9ca412e9..592d3122733 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/settings/hooks/useProviderLogin.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/settings/hooks/useProviderLogin.ts @@ -220,7 +220,7 @@ export function useProviderLogin({ onSuccess } = {}) { // user closing the popup instead of waiting out the full timeout. The // popup is a same-origin route we control, so the handle is safe. const popup = window.open( - `/v2/wallet/connect?channel=${encodeURIComponent(channelName)}`, + `/wallet/connect?channel=${encodeURIComponent(channelName)}`, "_blank", "width=460,height=640" ); diff --git a/crates/ironclaw_webui_v2/frontend/vite.config.ts b/crates/ironclaw_webui_v2/frontend/vite.config.ts index 1756cd17ef3..09a1b597c63 100644 --- a/crates/ironclaw_webui_v2/frontend/vite.config.ts +++ b/crates/ironclaw_webui_v2/frontend/vite.config.ts @@ -10,7 +10,7 @@ const rustStaticDir = resolve(here, "..", "static"); const require = createRequire(import.meta.url); export default defineConfig({ - base: "/v2/", + base: "/", plugins: [tailwindcss(), react()], publicDir: "public", resolve: { @@ -34,10 +34,10 @@ export default defineConfig({ "/api/webchat/v2": "http://127.0.0.1:3000", "/api/reborn": "http://127.0.0.1:3000", "/auth": "http://127.0.0.1:3000", - "/v2/assets": "http://127.0.0.1:3000", - "/v2/js": "http://127.0.0.1:3000", - "/v2/vendor": "http://127.0.0.1:3000", - "/v2/wallet/connect": "http://127.0.0.1:3000", + "/assets": "http://127.0.0.1:3000", + "/vendor": "http://127.0.0.1:3000", + "/wallet/connect": "http://127.0.0.1:3000", + "/wallet-connect.js": "http://127.0.0.1:3000", }, }, build: { diff --git a/crates/ironclaw_webui_v2/src/lib.rs b/crates/ironclaw_webui_v2/src/lib.rs index a9866806390..4755083890f 100644 --- a/crates/ironclaw_webui_v2/src/lib.rs +++ b/crates/ironclaw_webui_v2/src/lib.rs @@ -135,8 +135,8 @@ pub use router::{ #[cfg(feature = "webui-v2-beta")] pub use schema::{WebChatV2Event, WebChatV2EventFrame}; // Re-export the static-bundle router factory at the crate root so host -// composition keeps calling `ironclaw_webui_v2::mount_at_prefix(...)`. +// composition can mount the canonical root surface as one owned unit. #[cfg(feature = "webui-v2-beta")] pub use sse_capacity::DEFAULT_SSE_MAX_CONCURRENT_PER_CALLER; #[cfg(feature = "webui-v2-beta")] -pub use static_assets::{mount_at_prefix, serve_root, serve_wildcard, static_router}; +pub use static_assets::{serve_root, serve_wildcard, static_router}; diff --git a/crates/ironclaw_webui_v2/src/static_assets/assets.rs b/crates/ironclaw_webui_v2/src/static_assets/assets.rs index 4ead615cf6b..b25659f2008 100644 --- a/crates/ironclaw_webui_v2/src/static_assets/assets.rs +++ b/crates/ironclaw_webui_v2/src/static_assets/assets.rs @@ -2,8 +2,8 @@ //! //! Populated at compile time by `build.rs` from the crate-owned WebUI bundle //! and committed public assets. Each file becomes one -//! `Asset` row keyed by its URL path (relative to the `/v2` mount -//! prefix). `index.html` is handled separately — see +//! `Asset` row keyed by its URL path (relative to the gateway root). +//! `index.html` is handled separately — see //! [`INDEX_HTML_TEMPLATE`]. pub(crate) struct Asset { diff --git a/crates/ironclaw_webui_v2/src/static_assets/mod.rs b/crates/ironclaw_webui_v2/src/static_assets/mod.rs index e4beb6d1ff0..d99eef348d5 100644 --- a/crates/ironclaw_webui_v2/src/static_assets/mod.rs +++ b/crates/ironclaw_webui_v2/src/static_assets/mod.rs @@ -17,4 +17,4 @@ mod assets; mod router; -pub use router::{mount_at_prefix, serve_root, serve_wildcard, static_router}; +pub use router::{serve_root, serve_wildcard, static_router}; diff --git a/crates/ironclaw_webui_v2/src/static_assets/router.rs b/crates/ironclaw_webui_v2/src/static_assets/router.rs index 5a9adc533af..3c36ca710ba 100644 --- a/crates/ironclaw_webui_v2/src/static_assets/router.rs +++ b/crates/ironclaw_webui_v2/src/static_assets/router.rs @@ -12,8 +12,8 @@ use axum::Router; use axum::body::Body; use axum::extract::Path as AxumPath; -use axum::http::{HeaderValue, StatusCode, header}; -use axum::response::{IntoResponse, Response}; +use axum::http::{HeaderValue, StatusCode, Uri, header}; +use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::get; use rand::RngExt as _; @@ -27,70 +27,63 @@ const NONCE_PLACEHOLDER: &str = "__IRONCLAW_CSP_NONCE__"; /// characters, well above the CSP-3 recommendation of 128 bits. const NONCE_BYTES: usize = 16; -/// Build the SPA static-asset router with no path prefix. +/// Build the SPA static-asset router at the gateway root. /// -/// Standalone consumers (the crate's own tests) mount this at `/`. -/// Host composition should call [`mount_at_prefix`] instead so the -/// SPA lives under a stable URL prefix without dragging the SPA -/// shell handler onto the gateway root. +/// The canonical browser routes live at `/chat`, `/settings`, and the other +/// root-level SPA paths. The legacy `/v2` surface redirects to the matching +/// root path while preserving its query string so bookmarked URLs and OAuth +/// login tickets keep working during the migration. Host-owned namespaces +/// such as `/api`, `/auth`, `/v1`, and `/webhooks` remain fail-closed. /// /// The router owns no per-instance state; each request generates a /// fresh nonce. pub fn static_router() -> Router { - // Three explicit routes keep `axum::Router::nest` out of the - // picture — nest in 0.8 has quirky dispatch for the exact prefix - // with/without trailing slash. The wildcard handler reads the - // matched suffix via `Path` so the path passed downstream is - // already prefix-stripped, no matter what mount the caller used. + // Explicit routes keep `axum::Router::nest` out of the picture — nest in + // 0.8 has quirky dispatch for exact prefixes with/without trailing slash. + // The wildcard handler receives the root-relative path via `Path`. Router::new() .route("/", get(serve_root)) - .route("/{*path}", get(serve_wildcard)) + // Keep the isolated wallet page ahead of the SPA wildcard. It carries + // a deliberately different CSP and must never render the app shell. + .route("/wallet/connect", get(serve_wallet_connect)) + .route("/v2", get(redirect_legacy_v2)) + .route("/v2/", get(redirect_legacy_v2)) + .route("/v2/{*path}", get(redirect_legacy_v2)) + // A custom method fallback keeps unmounted POST/PUT API paths at 404. + // Without it, this root wildcard would turn them into 405 responses + // merely because the SPA owns GET for the same catch-all path. + .route( + "/{*path}", + get(serve_wildcard).fallback(|| async { StatusCode::NOT_FOUND }), + ) } -/// Build the SPA static-asset router wired under `prefix`. -/// -/// This is the factory host composition should use — owning the -/// prefixed route shape inside the static crate means a future -/// fourth route is picked up automatically by every mount site. -/// Composition merges the returned `Router` into the gateway's main -/// router; it must not also enumerate individual handlers from this -/// crate. -/// -/// `prefix` must begin with `/` and must not end with `/`. Passing -/// `"/v2"` mounts the SPA at `/v2`, `/v2/`, and `/v2/`. -/// -/// # Panics +/// Redirect a legacy `/v2` URL to its root-mounted equivalent. /// -/// Panics if `prefix` is empty, doesn't start with `/`, or ends with -/// `/`. The factory is called at composition-startup, so failing loud -/// there is preferable to silently building broken routes that only -/// surface as request-time 404s in production. -pub fn mount_at_prefix(prefix: &str) -> Router { - let valid = !prefix.is_empty() && prefix.starts_with('/') && !prefix.ends_with('/'); - if !valid { - panic!("mount_at_prefix expects a path like \"/v2\" — got {prefix:?}"); // safety: composition-startup factory — failing loud on bad prefix is preferable to silently building broken routes +/// A temporary redirect is intentional: it keeps compatibility links working +/// without leaving a browser-cached permanent redirect behind if the root mount +/// ever needs to be rolled back. Leading slashes in the suffix are collapsed +/// so an input such as `/v2//example.com` cannot become a protocol-relative +/// redirect target. Backslashes are normalized as path separators as well: +/// WHATWG URL parsing treats them like slashes for HTTP(S), so forwarding a +/// raw backslash could otherwise turn a same-origin `Location` into an +/// external navigation. +async fn redirect_legacy_v2(uri: Uri) -> Redirect { + Redirect::temporary(&legacy_v2_target(&uri)) +} + +fn legacy_v2_target(uri: &Uri) -> String { + let suffix = match uri.path().strip_prefix("/v2") { + Some(suffix) => suffix.trim_start_matches(['/', '\\']), + None => "", + }; + let mut target = String::from("/"); + target.push_str(&suffix.replace('\\', "/")); + if let Some(query) = uri.query() { + target.push('?'); + target.push_str(query); } - // Three explicit routes (no `nest`) for the same reason - // `static_router` keeps `nest` out of the picture: axum 0.8's - // nest dispatch for the exact prefix with/without trailing - // slash is quirky and was the source of regressions in the - // earlier inline wiring this factory replaces. - Router::new() - .route(prefix, get(serve_root)) - .route(&format!("{prefix}/"), get(serve_root)) - // Isolated NEAR-wallet connect popup. It needs a far looser CSP than the - // hardened SPA shell (the wallet connector loads remote executor code in - // sandboxed iframes and talks to wallet relays / NEAR RPC), so it gets a - // dedicated route and a scoped policy instead of widening the app CSP. - // The page takes only a random BroadcastChannel name and posts the - // resulting signature back on that same-origin channel, so the blast - // radius of the looser policy is this one popup page. Registered before - // the wildcard so it wins. - .route( - &format!("{prefix}/wallet/connect"), - get(serve_wallet_connect), - ) - .route(&format!("{prefix}/{{*path}}"), get(serve_wildcard)) + target } /// Serve the isolated NEAR-wallet connect popup with its own relaxed CSP. @@ -155,6 +148,16 @@ fn serve_for_path(path: &str) -> Response { return StatusCode::NOT_FOUND.into_response(); } + // Root-mounting the SPA must not turn unknown host/API requests into a + // successful HTML response. Exact registered routes still win in axum; + // unmatched requests in these server-owned namespaces fail closed here. + if matches!( + path.split('/').next(), + Some("api" | "auth" | "v1" | "webhooks") + ) { + return StatusCode::NOT_FOUND.into_response(); + } + // Empty path (only reachable through unusual routings) → SPA shell. if path.is_empty() { return render_index_with_nonce(); @@ -201,13 +204,13 @@ fn render_index_with_nonce() -> Response { // header we set here instead of overwriting it. // // Every sub-resource the shell loads is same-origin: Vite emits the app - // bundle and CSS under `/v2/assets/`, and the web fonts are vendored under - // `/v2/vendor/` (see `frontend/public`). So `script-src` / `style-src` / + // bundle and CSS under `/assets/`, and the web fonts are vendored under + // `/vendor/` (see `frontend/public`). So `script-src` / `style-src` / // `font-src` collapse to `'self'` — no CDN origins, no third-party fetches. // `'unsafe-inline'` stays on `style-src` only: the Tailwind browser // runtime injects a generated `