Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,7 @@ FORGE_RETENTION__LEASE_SECONDS=300
# ---------------------------------------------------------------------------
# Static web UI (SPA) serving
# ---------------------------------------------------------------------------
# Serve the built TanStack Start SPA from the Rust server (single origin).
FORGE_WEB__ENABLED=true
# The Rust server always serves the built TanStack Start SPA (single origin).
# Directory holding the built SPA (index.html + assets/). Defaults to web/dist.
FORGE_WEB__ASSETS_DIR=web/dist

Expand Down
1 change: 0 additions & 1 deletion .github/scripts/npm-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,6 @@ password = "$admin_password"
grace_seconds = 0
poll_interval_seconds = 2
[web]
enabled = true
assets_dir = "$work/web"
EOF
FORGE_CONFIG="$work/forge.toml" RUST_LOG=info "$server_bin" > "$work/server.log" 2>&1 &
Expand Down
2 changes: 0 additions & 2 deletions .github/scripts/oci-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,6 @@ password = "$admin_password"
[outbox]
grace_seconds = 0
poll_interval_seconds = 2
[web]
enabled = false
EOF
FORGE_CONFIG="$work/forge.toml" RUST_LOG=info "$server_bin" > "$work/server.log" 2>&1 &
server_pid=$!
Expand Down
2 changes: 0 additions & 2 deletions .github/scripts/raw-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,6 @@ password = "$admin_password"
[outbox]
grace_seconds = 0
poll_interval_seconds = 2
[web]
enabled = false
EOF
FORGE_CONFIG="$work/forge.toml" RUST_LOG=info "$server_bin" > "$work/server.log" 2>&1 &
server_pid=$!
Expand Down
13 changes: 5 additions & 8 deletions crates/server/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ pub fn principal_layer(state: &AppState) -> anyhow::Result<PrincipalLayer> {
/// read authz enforced inside the handlers).
/// * `/repository/{repo}/...` — repositories of every non-Docker format,
/// each served by its format's plugin ([`crate::plugins`]).
/// * everything else — the SPA (when `web.enabled`), whose admin screens live
/// under `/manage/*` (see [`crate::web_ui`]).
/// * everything else — the SPA, whose admin screens live under `/manage/*`
/// (see [`crate::web_ui`]).
///
/// All branches run behind the principal middleware so every handler sees a
/// resolved [`forge_core::auth::principal::Principal`] extension.
Expand Down Expand Up @@ -173,12 +173,9 @@ pub fn build_router(state: AppState, layer: PrincipalLayer) -> Router {
.route("/repository/{repo}/{*rest}", any(crate::plugins::dispatch));

// Static SPA fallback, installed LAST so the API routers above always win
// and only genuine UI paths fall through to `index.html`. Skipped for a
// headless deployment (`web.enabled = false`). The admin UI lives under
// `/manage/*`; `/admin/*` is the admin API's.
if state.config.web.enabled {
app = app.fallback_service(spa_service(&state.config.web));
}
// and only genuine UI paths fall through to `index.html`. The admin UI
// lives under `/manage/*`; `/admin/*` is the admin API's.
let app = app.fallback_service(spa_service(&state.config.web));

let hsts = state.cookie_secure;
let public_base_url = state.config.public_base_url();
Expand Down
10 changes: 1 addition & 9 deletions crates/server/src/cli_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,21 +162,13 @@ async fn password_login(
.await
}

/// Without the web UI there is no page to approve a web login on: 404,
/// and npm falls back to asking for a password.
/// Start a web login: the page that approves it and the secret to poll with.
async fn start_web_login(
state: &AppState,
repo: &Repository,
client_ip: IpAddr,
hostname: Option<String>,
) -> Result<AuthOutcome, Response> {
if !state.config.web.enabled {
return Err((
StatusCode::NOT_FOUND,
"logins approved in the browser need the web UI",
)
.into_response());
}
// Starting a login is free for anyone: bounded per client network, as
// OIDC logins are.
if !state.oidc_login_starts.allow(client_ip) {
Expand Down
79 changes: 63 additions & 16 deletions crates/server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1684,31 +1684,34 @@ fn default_nuget_max_nuspec_compression_ratio() -> u64 {

/// `web.*` — static SPA serving.
///
/// When `enabled`, the server serves the built TanStack Start SPA from
/// `assets_dir` (a directory containing `index.html` + `assets/*`), mounted as
/// the router's fallback **after** the `/admin`, `/repository`, and `/browse` API
/// routers so API paths always win. Unknown paths fall back to `index.html` so
/// The server always serves the built TanStack Start SPA from `assets_dir` (a
/// directory containing `index.html` + `assets/*`), mounted as the router's
/// fallback **after** the `/admin`, `/repository`, and `/browse` API routers so
/// API paths always win. Unknown paths fall back to `index.html` so
/// client-side (TanStack Router) deep links resolve.
///
/// `assets_dir` defaults to the relative path `web/dist`, matching the
/// repository layout (`web/` is built by Vite into `web/dist/`). Container
/// images set `FORGE_WEB__ASSETS_DIR` to the in-image asset path. A headless
/// deployment sets `FORGE_WEB__ENABLED=false`.
/// images set `FORGE_WEB__ASSETS_DIR` to the in-image asset path.
#[derive(Debug, Clone, Deserialize)]
pub struct WebConfig {
/// Serve the SPA. When false, only the API routers are mounted.
#[serde(default = "default_true")]
pub enabled: bool,
/// Directory holding the built SPA (`index.html` + `assets/`).
#[serde(default = "default_web_assets_dir")]
pub assets_dir: String,
/// The removed `web.enabled`, kept only to refuse `false`: the
/// administration screens, the invitation page and the page approving a
/// browser login of `npm login` are the web UI's, so Forge always serves
/// it. `WebConfig` accepts unknown keys, so without this a headless
/// deployment would quietly get a UI. A leftover `true` changes nothing.
#[serde(default, rename = "enabled")]
removed_enabled: Option<bool>,
}

impl Default for WebConfig {
fn default() -> Self {
WebConfig {
enabled: default_true(),
assets_dir: default_web_assets_dir(),
removed_enabled: None,
}
}
}
Expand Down Expand Up @@ -2420,6 +2423,15 @@ impl Config {
.into(),
));
}
if self.web.removed_enabled == Some(false) {
return Err(ConfigError::Invalid(
"web.enabled = false was removed: Forge always serves the web UI, which \
holds the administration screens, the invitation page and the page that \
approves an npm login in the browser. Remove web.enabled from the \
configuration (FORGE_WEB__ENABLED included)"
.into(),
));
}
if self.blob_cache.path.is_some()
&& self.blob_cache.max_bytes == BlobCacheMaxBytes::Bytes(0)
{
Expand Down Expand Up @@ -3278,20 +3290,57 @@ mod tests {
assert_eq!(cfg.outbox.processed_retention_seconds, 7 * 24 * 3600);
assert_eq!(cfg.retention.poll_interval_seconds, 30);
assert_eq!(cfg.retention.lease_seconds, 300);
// Web UI: enabled by default, served from `web/dist`.
assert!(cfg.web.enabled);
// Web UI: served from `web/dist`.
assert_eq!(cfg.web.assets_dir, "web/dist");
}

#[test]
fn web_block_overrides_defaults() {
let toml = MINIMAL.replace("[auth]", "[auth]\n[web]\nassets_dir = \"/srv/ui\"");
let cfg = Config::from_figment(fig_from_toml(&toml)).expect("valid");
assert_eq!(cfg.web.assets_dir, "/srv/ui");
}

/// The web UI cannot be turned off any more: `web.enabled = false` is
/// refused, naming the setting to remove, and a leftover `true` changes
/// nothing.
#[test]
fn web_enabled_false_is_refused() {
let toml = MINIMAL.replace("[auth]", "[auth]\n[web]\nenabled = false");
let err = Config::from_figment(fig_from_toml(&toml))
.unwrap_err()
.to_string();
assert!(
err.contains("web.enabled = false was removed")
&& err.contains("Remove web.enabled")
&& err.contains("FORGE_WEB__ENABLED"),
"{err}"
);
let toml = MINIMAL.replace(
"[auth]",
"[auth]\n[web]\nenabled = false\nassets_dir = \"/srv/ui\"",
"[auth]\n[web]\nenabled = true\nassets_dir = \"/srv/ui\"",
);
let cfg = Config::from_figment(fig_from_toml(&toml)).expect("valid");
assert!(!cfg.web.enabled);
assert_eq!(cfg.web.assets_dir, "/srv/ui");
// No `[web]` section at all.
Config::from_figment(fig_from_toml(MINIMAL)).expect("valid");
}

#[test]
#[allow(clippy::result_large_err)] // figment::Jail's closure returns figment::Error.
fn web_enabled_false_variable_is_refused() {
figment::Jail::expect_with(|jail| {
jail.create_file("forge.toml", MINIMAL)?;
jail.set_env("FORGE_WEB__ENABLED", "false");
let err = Config::load(Some(std::path::Path::new("forge.toml"))).unwrap_err();
assert!(
err.to_string().contains("web.enabled = false was removed"),
"{err}"
);
jail.set_env("FORGE_WEB__ENABLED", "true");
Config::load(Some(std::path::Path::new("forge.toml"))).expect("valid");
Ok(())
});
}

#[test]
Expand Down Expand Up @@ -3376,7 +3425,6 @@ mod tests {
jail.set_env("FORGE_OUTBOX__POLL_INTERVAL_SECONDS", "3");
jail.set_env("FORGE_RETENTION__POLL_INTERVAL_SECONDS", "11");
jail.set_env("FORGE_RETENTION__LEASE_SECONDS", "120");
jail.set_env("FORGE_WEB__ENABLED", "false");
jail.set_env("FORGE_WEB__ASSETS_DIR", "/srv/ui");
// OIDC section (redirect host must match public_base_url = https://forge.test).
jail.set_env("FORGE_OIDC__ISSUER", "https://idp.test/v2.0");
Expand Down Expand Up @@ -3424,7 +3472,6 @@ mod tests {
assert_eq!(cfg.outbox.poll_interval_seconds, 3);
assert_eq!(cfg.retention.poll_interval_seconds, 11);
assert_eq!(cfg.retention.lease_seconds, 120);
assert!(!cfg.web.enabled);
assert_eq!(cfg.web.assets_dir, "/srv/ui");
let oidc = cfg.oidc.as_ref().expect("oidc set via env");
assert_eq!(oidc.issuer, "https://idp.test/v2.0");
Expand Down
11 changes: 4 additions & 7 deletions crates/server/src/web_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,8 @@ struct Spa {
/// whereas `not_found_service` forces the status to `404` — wrong for an SPA
/// history fallback where unknown client routes must return `200 index.html`.
///
/// The caller is responsible for only installing this when `web.enabled` is
/// true. If `index.html` is missing (UI not built), the fallback yields a 404 —
/// the API still works.
/// If `index.html` is missing (UI not built), the fallback yields a 404 — the
/// API still works.
pub fn spa_service(cfg: &WebConfig) -> Router {
let assets_dir = PathBuf::from(&cfg.assets_dir);
let index = assets_dir.join("index.html");
Expand Down Expand Up @@ -231,10 +230,8 @@ mod tests {
fs::write(dir.path().join("index.html"), "INDEX").unwrap();
fs::create_dir_all(dir.path().join("assets")).unwrap();
fs::write(dir.path().join("assets").join("a.js"), "ASSET").unwrap();
let cfg = WebConfig {
enabled: true,
assets_dir: dir.path().to_string_lossy().to_string(),
};
let mut cfg = WebConfig::default();
cfg.assets_dir = dir.path().to_string_lossy().to_string();
let svc = spa_service(&cfg);
(dir, svc)
}
Expand Down
15 changes: 8 additions & 7 deletions crates/server/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,12 +251,13 @@ fn config_toml(db_url: &str, s3_endpoint: &str, opts: &ServerOpts) -> String {
Some((u, p)) => format!("[auth.bootstrap_admin]\nusername = \"{u}\"\npassword = \"{p}\"\n"),
None => String::new(),
};
// Default to a disabled web UI in tests so a missing `web/dist` never
// affects API behaviour; tests that exercise SPA serving pass an explicit
// assets dir.
// The web UI is always mounted. Without an explicit assets dir the
// default `web/dist` resolves against this crate's directory, which holds
// no built UI, so the fallback answers 404; tests that exercise SPA
// serving pass an assets dir.
let web = match web_assets_dir {
Some(dir) => format!("[web]\nenabled = true\nassets_dir = {dir:?}\n"),
None => "[web]\nenabled = false\n".to_string(),
Some(dir) => format!("[web]\nassets_dir = {dir:?}\n"),
None => String::new(),
};
format!(
r#"
Expand Down Expand Up @@ -374,8 +375,8 @@ pub async fn envelope(pool: &PgPool) -> std::sync::Arc<forge_crypto::EnvelopeSer
pub struct ServerOpts {
pub bootstrap_admin: Option<(String, String)>,
pub spawn_workers: bool,
/// When set, the static SPA is served from this directory; otherwise the
/// web UI is disabled for the test.
/// When set, the static SPA is served from this directory; otherwise
/// from the default `web/dist`, which holds no built UI in a test.
pub web_assets_dir: Option<String>,
/// When set, this raw TOML is appended as the `[oidc]` section so the server
/// builds an `OidcRuntime` + mounts the `/auth/oidc/*` routes.
Expand Down
3 changes: 0 additions & 3 deletions crates/server/tests/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -908,14 +908,11 @@ async fn a_selector_on_a_write_grant_scopes_publishes_and_reads() {

#[tokio::test]
async fn npm_login_legacy_and_web_and_logout() {
let assets = tempfile::tempdir().unwrap();
std::fs::write(assets.path().join("index.html"), "<html></html>").unwrap();
let admin_user = format!("admin{}@example.com", unique());
let srv = common::start_with(
common::pool().await,
common::ServerOpts {
bootstrap_admin: Some((admin_user.clone(), "adminpw".into())),
web_assets_dir: Some(assets.path().display().to_string()),
..Default::default()
},
)
Expand Down
2 changes: 1 addition & 1 deletion crates/server/tests/oidc_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1179,7 +1179,7 @@ async fn auth_routes_absent_when_oidc_disabled() {
.send()
.await
.unwrap();
// Web UI is disabled in the default harness, so an unmounted route 404s.
// An unmounted route under `/auth` is a 404, not the SPA's `index.html`.
assert_eq!(resp.status(), 404);
}

Expand Down
4 changes: 0 additions & 4 deletions crates/server/tests/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -923,12 +923,8 @@ async fn a_request_joins_the_callers_trace_down_to_the_upstream_and_s3() {
/// one that collects the user's token. No span holds either.
#[tokio::test]
async fn no_span_holds_the_secrets_of_a_web_login() {
// A web login is approved in the web UI: without it, none starts.
let assets = tempfile::tempdir().unwrap();
std::fs::write(assets.path().join("index.html"), "<html></html>").unwrap();
let srv = start_with(common::ServerOpts {
bootstrap_admin: Some((format!("admin{}@example.com", unique()), "adminpw".into())),
web_assets_dir: Some(assets.path().display().to_string()),
..Default::default()
})
.await;
Expand Down
4 changes: 2 additions & 2 deletions crates/server/tests/web_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ async fn spa_serving_and_api_precedence() {
assert!(!body.contains("forge-spa"), "{path}: {body}");
}
}
// Removed feed prefixes are no longer API routes: when the UI is enabled,
// they fall through to its SPA fallback rather than reaching a feed.
// Removed feed prefixes are no longer API routes: they fall through to
// the SPA fallback rather than reaching a feed.
for path in ["/v3/nuget/index.json", "/raw/binaries/file"] {
let res = client
.get(format!("{}{path}", server.base_url))
Expand Down
1 change: 0 additions & 1 deletion docker/forge.docker.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,4 @@ force_path_style = true
[web]
# The server serves the built SPA from here (also set via FORGE_WEB__ASSETS_DIR
# in the Dockerfile). Matches where stage 1's web/dist is copied in the image.
enabled = true
assets_dir = "/app/web/dist"
6 changes: 6 additions & 0 deletions docs/changes/2.2.0/652.upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Forge always serves the web UI: `web.enabled` was removed. A configuration
that sets `enabled = false` under `[web]`, or `FORGE_WEB__ENABLED=false`,
refuses to start: remove the setting. A leftover `enabled = true` changes
nothing. A deployment without the web UI lacked the administration screens,
the page on which an invited user chooses their password and the page that
approves an `npm login` in the browser.
3 changes: 1 addition & 2 deletions docs/npm.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,7 @@ npm whoami --registry https://forge.example.com/repository/npm-all/
Without a browser on the machine (`--browser=false`), npm prints the page's
URL to open elsewhere. A login waits for its approval for
`cli_login.web_login_ttl_seconds` (10 minutes), and the page answers only
the signed-in user who opens it. Logins in the browser need the web UI
(`[web] enabled`); without it npm falls back to asking for a password.
the signed-in user who opens it.

**A username and a password**: `yarn npm login` (Yarn 2+) and `npm login
--auth-type=legacy` send them to Forge, which checks them like the web
Expand Down
7 changes: 3 additions & 4 deletions forge.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,8 @@ password = "changeme"
# grace_seconds = 3600 # delay before a dereferenced blob is deleted from S3
# poll_interval_seconds = 10

# Static SPA serving. The Rust server serves the built TanStack Start UI from
# `assets_dir` (single origin, no Node proxy). Enabled by default; `assets_dir`
# defaults to `web/dist`. Set `enabled = false` for a headless deployment.
# Static SPA serving. The Rust server always serves the built TanStack Start UI
# from `assets_dir` (single origin, no Node proxy); `assets_dir` defaults to
# `web/dist`.
# [web]
# enabled = true
# assets_dir = "web/dist"
2 changes: 1 addition & 1 deletion forge.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ max_unpacked_bytes = 1073741824 # (default: 1 GiB) the most a published or prox

[cli_login]
# Logins of command-line clients (`npm login`, `yarn npm login`), with a
# password or approved in the browser (which needs the web UI).
# password or approved in the browser.
token_lifetime_days = 90 # (default: 90; 0: never expires) the API token a login mints
web_login_ttl_seconds = 600 # (default: 600; 60-3600) how long a login approved in the browser waits

Expand Down
5 changes: 2 additions & 3 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,8 @@ pnpm install --frozen-lockfile
pnpm run build # tsc --noEmit + vite build -> dist/
```

The Rust server serves `dist/` when `web.enabled = true` (config) with
`web.assets_dir` pointing at it (default `web/dist`). See
`crates/server/src/web_ui.rs`.
The Rust server serves `dist/` from `web.assets_dir` (config, default
`web/dist`). See `crates/server/src/web_ui.rs`.

## Test

Expand Down
1 change: 0 additions & 1 deletion web/e2e/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ function startServer() {
FORGE_AUTH__BOOTSTRAP_ADMIN__USERNAME: ADMIN_USER,
FORGE_AUTH__BOOTSTRAP_ADMIN__PASSWORD: ADMIN_PASS,
// web UI: serve the freshly built dist (single origin).
FORGE_WEB__ENABLED: 'true',
FORGE_WEB__ASSETS_DIR: path.join(webDir, 'dist'),
// outbox: drain quickly so a pushed package becomes visible fast.
FORGE_OUTBOX__POLL_INTERVAL_SECONDS: '1',
Expand Down
Loading