From 316d916bd177d8dd0a8591a1f87cb1ac0c21214d Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:45:45 +0000 Subject: [PATCH 1/8] feat: add opt-in demo pairing and tenant display defaults --- client/Cargo.toml | 1 + .../betterportal/frame/ViewerSessionTest.kt | 25 ++++- .../cloud/betterportal/frame/MainActivity.kt | 14 +++ .../cloud/betterportal/frame/ViewerSession.kt | 21 ++++ client/core/src/protocol.rs | 21 ++++ client/core/src/state.rs | 4 + client/src/platform/linux/server.rs | 17 ++++ client/src/platform/linux/ui.rs | 34 ++++++- client/src/platform/windows/mod.rs | 54 ++++++---- client/src/platform/windows/renderer.rs | 43 ++++++++ compose.yaml | 1 + deploy/docker/server-entrypoint.sh | 1 + docker-compose.coolify.yml | 1 + docs/demo-mode.md | 20 ++++ sec-config.template.yaml | 2 + .../src/plugins/service-admin-http/index.ts | 3 + .../plugins/service-admin-http/middleware.ts | 4 +- .../service-admin-http/routes-admin.ts | 17 +++- .../service-admin-http/routes-tenants.ts | 16 +-- server/src/plugins/service-api-http/index.ts | 37 ++++++- server/src/schemas/wire/pairing.ts | 2 + server/src/shared/db/repository.ts | 55 +++++++---- server/src/shared/demo.ts | 70 +++++++++++++ server/src/shared/pairing.ts | 6 +- server/src/web-templates/admin-pages.tsx | 23 +++++ server/tests/demo-postgres.test.ts | 98 +++++++++++++++++++ 26 files changed, 535 insertions(+), 55 deletions(-) create mode 100644 docs/demo-mode.md create mode 100644 server/src/shared/demo.ts create mode 100644 server/tests/demo-postgres.test.ts diff --git a/client/Cargo.toml b/client/Cargo.toml index ac8063d0..aa28f614 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -60,6 +60,7 @@ windows-sys = { version = "0.52", features = [ "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", + "Win32_UI_Input_KeyboardAndMouse", ] } windows = { version = "0.61", features = [ "Win32_Media_Audio", diff --git a/client/android/app/src/androidTest/java/cloud/betterportal/frame/ViewerSessionTest.kt b/client/android/app/src/androidTest/java/cloud/betterportal/frame/ViewerSessionTest.kt index 08cdcba4..c7d13640 100644 --- a/client/android/app/src/androidTest/java/cloud/betterportal/frame/ViewerSessionTest.kt +++ b/client/android/app/src/androidTest/java/cloud/betterportal/frame/ViewerSessionTest.kt @@ -23,7 +23,10 @@ import java.util.concurrent.atomic.AtomicReference @RunWith(AndroidJUnit4::class) class ViewerSessionTest { - @Test fun secureEnrollmentDisplaysHtmlAndClearsUnassignedCache() { + @Test fun secureEnrollmentDisplaysHtmlAndClearsUnassignedCache() = exerciseEnrollment(false) + @Test fun demoUsesNormalPlaybackAndExitsWithoutServerDeletion() = exerciseEnrollment(true) + + private fun exerciseEnrollment(demo: Boolean) { val instrumentation = InstrumentationRegistry.getInstrumentation() val context = instrumentation.targetContext val directory = File(context.cacheDir, "session-test-${System.nanoTime()}").apply { mkdirs() } @@ -31,6 +34,8 @@ class ViewerSessionTest { override fun getNoBackupFilesDir(): File = directory override fun getApplicationContext(): Context = this } + val demoRequested = AtomicBoolean(false) + val demoConfirmed = AtomicBoolean(false) val unassigned = AtomicBoolean(false) val profile = AtomicReference(null) val profileRejected = LinkedBlockingQueue() @@ -64,12 +69,20 @@ class ViewerSessionTest { val body = JSONObject(request.body.readUtf8()) check(body.getBoolean("secure_claim")) { "Secure enrollment was not requested" } check(body.getJSONArray("capabilities").toString().contains("android-viewer")) { "Android capabilities missing" } - json("""{"code":"ABCD12","polling_secret":"test-poll-secret","poll_after_ms":1000}""") + json("""{"code":"ABCD12","polling_secret":"test-poll-secret","poll_after_ms":1000,"allowDemo":$demo}""") + } + "/api/pair/demo" -> { + check(demo) + val body = JSONObject(request.body.readUtf8()) + check(body.getString("code") == "ABCD12" && body.getString("polling_secret") == "test-poll-secret") + demoConfirmed.set(true) + json("{}") } "/api/pair/claim", "/api/pair/ack" -> { val body = JSONObject(request.body.readUtf8()) check(body.getString("code") == "ABCD12" && body.getString("polling_secret") == "test-poll-secret") { "Enrollment polling secret was not preserved" } - if (path.endsWith("claim")) json("""{"status":"claimed","kiosk_id":"1","kiosk_key":"test-device-key","encrypt_key":"0000000000000000000000000000000000000000000000000000000000000000"}""") + if (path.endsWith("claim") && demo && !demoConfirmed.get()) json("""{"status":"pending"}""", 202) + else if (path.endsWith("claim")) json("""{"status":"claimed","demo":$demo,"kiosk_id":"1","kiosk_key":"test-device-key","encrypt_key":"0000000000000000000000000000000000000000000000000000000000000000"}""") else { // The secure device identity must already be durable before acknowledging. check(ProtectedStore(isolated).read().getJSONObject("identity").getString("kiosk_key") == "test-device-key") { "Acknowledged before saving identity" } @@ -112,6 +125,7 @@ class ViewerSessionTest { if (message.contains("connection unavailable")) offlineRetained.countDown() } override fun onPairing(code: String) { + if (code.isNotBlank() && demo && session.get().allowDemo && demoRequested.compareAndSet(false, true)) session.get().enterDemo() if (code.isBlank() && clearing.get()) enrollmentCleared.countDown() } override fun onPlan(plan: JSONObject) { @@ -146,6 +160,9 @@ class ViewerSessionTest { assertTrue(ProtectedStore(isolated).read().has("bundle")) assertEquals("android-viewer-v1", ProtectedStore(isolated).read().getString("bundle_profile")) assertTrue(requests.contains("/api/pair/ack")) + assertEquals(demo, session.get().isDemo) + assertEquals(demo, ProtectedStore(isolated).read().getJSONObject("identity").optBoolean("demo")) + assertEquals(if (demo) 1 else 0, requests.count { it == "/api/pair/demo" }) val fetchedBeforeDowngrade = requests.count { it == "/api/kiosk/bundle" } interruptHeartbeat.set(true) instrumentation.runOnMainSync { session.get().refresh() } @@ -169,6 +186,8 @@ class ViewerSessionTest { instrumentation.runOnMainSync { session.get().unpair() } assertTrue("Unpair did not finish browser/cache cleanup", enrollmentCleared.await(10, TimeUnit.SECONDS)) assertEquals(0, ProtectedStore(isolated).read().length()) + assertFalse(session.get().isDemo) + assertFalse(requests.any { it.contains("delete") || it.contains("unpair") }) } finally { instrumentation.runOnMainSync { session.get()?.close() } server.shutdown() diff --git a/client/android/app/src/main/java/cloud/betterportal/frame/MainActivity.kt b/client/android/app/src/main/java/cloud/betterportal/frame/MainActivity.kt index 6943338e..35eb9988 100644 --- a/client/android/app/src/main/java/cloud/betterportal/frame/MainActivity.kt +++ b/client/android/app/src/main/java/cloud/betterportal/frame/MainActivity.kt @@ -150,7 +150,20 @@ class MainActivity : Activity(), ViewerSession.Listener { private fun kioskRoot() = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) } + private fun updateDemoButton() { + root.findViewWithTag("demo-control")?.let { root.removeView(it) } + val exit = session.isDemo + if (!exit && (!session.allowDemo || pairingCode.isBlank())) return + val control = button(if (exit) "Exit demo" else "Demo") { + if (exit) resetEnrollment(session.serverUrl) else session.enterDemo() + }.apply { tag = "demo-control" } + root.addView(control, FrameLayout.LayoutParams(-2, -2, Gravity.TOP or Gravity.START).apply { + topMargin = dp(8); marginStart = dp(8) + }) + } + private fun addMenu() { + updateDemoButton() val menu = button("⋮") { showKioskMenu() }.apply { contentDescription = getString(R.string.kiosk_menu) textSize = 24f @@ -420,6 +433,7 @@ class MainActivity : Activity(), ViewerSession.Listener { if (displayVisible || setupView == null) showSetup(code) else setupView?.showPairing(code) pairingCode = code + updateDemoButton() if (code.isBlank()) resetRequested = false } diff --git a/client/android/app/src/main/java/cloud/betterportal/frame/ViewerSession.kt b/client/android/app/src/main/java/cloud/betterportal/frame/ViewerSession.kt index c1d24680..6e3ccf0a 100644 --- a/client/android/app/src/main/java/cloud/betterportal/frame/ViewerSession.kt +++ b/client/android/app/src/main/java/cloud/betterportal/frame/ViewerSession.kt @@ -487,6 +487,7 @@ class ViewerSession internal constructor(context: Context, private val listener: } fun unpair(nextServer: String? = null) { + allowDemo = false if (closed || clearingEnrollment) return // Validate before changing enrollment; retain the choice through activity recreation. val target = try { nextServer?.let { ServerAddress.parse(it).toString().trimEnd('/') } } @@ -629,6 +630,24 @@ class ViewerSession internal constructor(context: Context, private val listener: return JSONObject(String(bytes, Charsets.UTF_8)) } + @Volatile var allowDemo = false + private set + val isDemo: Boolean get() = state.optJSONObject("identity")?.optBoolean("demo", false) == true + + fun enterDemo() = enqueue { + val pending = state.optJSONObject("pending") ?: return@enqueue + if (kioskKey.isNotBlank() || !allowDemo) return@enqueue + try { + request("/api/pair/demo", claimBody(pending), false).use { requireSuccessful(it) } + nextPairPoll = 0L + } catch (_: Exception) { + allowDemo = false + pending.put("allowDemo", false); persist() + ui(activeEpoch) { listener.onPairing(pending.getString("code")) } + status("Demo unavailable. Continue with normal pairing or reconnect to retry.") + } + } + private fun pair() { val now = System.currentTimeMillis() if (now < nextPairPoll) return @@ -644,6 +663,7 @@ class ViewerSession internal constructor(context: Context, private val listener: } } val session = pending!! + allowDemo = session.optBoolean("allowDemo", false) ui(activeEpoch) { listener.onPairing(session.getString("code")) } nextPairPoll = now + session.optLong("poll_after_ms", 2000).coerceIn(1000, 60_000) request("/api/pair/claim", claimBody(session), false).use { @@ -657,6 +677,7 @@ class ViewerSession internal constructor(context: Context, private val listener: state.put("identity", claim).put("blocked", false) persist() // Durable identity BEFORE acknowledgement or first bundle fetch. kioskKey = claim.getString("kiosk_key") + allowDemo = false nextSync = 0L ui(activeEpoch) { listener.onPairing("") } status("Paired — loading assigned display") diff --git a/client/core/src/protocol.rs b/client/core/src/protocol.rs index 634db2c7..0611e884 100644 --- a/client/core/src/protocol.rs +++ b/client/core/src/protocol.rs @@ -82,6 +82,8 @@ pub fn validate_discovery_redirect( #[derive(Clone, Deserialize, Serialize)] pub struct PairInitiateResponse { + #[serde(default, rename = "allowDemo")] + pub allow_demo: bool, pub code: String, pub expires_at: String, pub expires_in_seconds: Option, @@ -115,6 +117,8 @@ pub fn poll_delay(milliseconds: Option) -> Duration { #[derive(Clone, Deserialize, Serialize)] pub struct DeviceIdentity { + #[serde(default)] + pub demo: bool, pub version: u32, pub server_url: String, pub kiosk_id: String, @@ -134,6 +138,7 @@ impl DeviceIdentity { ) -> Result { let identity = Self { version: 1, + demo: claim.demo, server_url: server.to_string(), kiosk_id: match claim.kiosk_id { Some(Value::String(id)) => id, @@ -174,6 +179,8 @@ impl DeviceIdentity { #[derive(Deserialize)] pub struct PairClaimResponse { + #[serde(default)] + pub demo: bool, pub status: String, pub expires_in_seconds: Option, pub poll_after_ms: Option, @@ -227,6 +234,20 @@ mod tests { assert_eq!(modern.poll_delay(), Duration::from_secs(1)); } + #[test] + fn demo_availability_requires_explicit_true_and_identity_survives_restart() { + for (field, expected) in [("", false), (",\"allowDemo\":false", false), (",\"allowDemo\":true", true)] { + let session: PairInitiateResponse = serde_json::from_str(&format!(r#"{{"code":"ABC123","expires_at":"invalid"{field}}}"#)).unwrap(); + assert_eq!(session.allow_demo, expected); + let claim: PairClaimResponse = serde_json::from_str(r#"{"status":"claimed","demo":true,"kiosk_id":"demo-kiosk","kiosk_key":"unique-device-key","encrypt_key":"encryption-material"}"#).unwrap(); + let identity = DeviceIdentity::from_claim("https://example.com", &session, claim).unwrap(); + let restored: DeviceIdentity = serde_json::from_slice(&serde_json::to_vec(&identity).unwrap()).unwrap(); + assert!(restored.demo); + } + let old: crate::state::ClientState = serde_json::from_str(r#"{"server_url":"https://example.com"}"#).unwrap(); + assert!(!old.demo && !old.allow_demo); + } + #[test] fn incomplete_claim_cannot_be_saved_as_paired() { let session: PairInitiateResponse = diff --git a/client/core/src/state.rs b/client/core/src/state.rs index ffc77aa3..55256eeb 100644 --- a/client/core/src/state.rs +++ b/client/core/src/state.rs @@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct ClientState { + #[serde(default)] + pub demo: bool, + #[serde(default)] + pub allow_demo: bool, pub server_url: String, pub kiosk_key: Option, #[serde(default)] diff --git a/client/src/platform/linux/server.rs b/client/src/platform/linux/server.rs index c21ceb50..d5b5a2d3 100644 --- a/client/src/platform/linux/server.rs +++ b/client/src/platform/linux/server.rs @@ -500,6 +500,23 @@ fn remove_pairing_state_files(dir: &PathBuf) { /// Wipe all kiosk state and exit. Systemd restarts the service, /// kiosk boots fresh with a new pairing code. +pub fn demo_mode() -> bool { load_identity().map(|identity| identity.demo).unwrap_or(false) } + +pub fn demo_session() -> Option<(String, PairInitiateResponse)> { + let bytes = crate::at_rest::read_maybe_encrypted(&state_dir().join("pairing.json"))?; + let pending: (String, PairInitiateResponse) = serde_json::from_slice(&bytes).ok()?; + (pending.1.allow_demo && !is_paired()).then_some(pending) +} + +pub fn enter_demo() -> Result<(), String> { + let (origin, session) = demo_session().ok_or("Demo unavailable")?; + let response = pairing_client()?.post(format!("{origin}/api/pair/demo")) + .json(&crate::core::protocol::claim_body(&session.code, session.polling_secret.as_deref())) + .send().map_err(|error| error.to_string())?; + if !response.status().is_success() { return Err("Demo unavailable; continue normal pairing or restart to retry".into()); } + Ok(()) +} + pub fn reset_pairing_and_restart(reason: &str) -> ! { tracing::warn!("{reason}; wiping kiosk pairing state and restarting"); diff --git a/client/src/platform/linux/ui.rs b/client/src/platform/linux/ui.rs index f96aa477..d95b61d9 100644 --- a/client/src/platform/linux/ui.rs +++ b/client/src/platform/linux/ui.rs @@ -1317,6 +1317,32 @@ fn parse_drm_mode(mode: &str) -> Option<(u32, u32)> { (dimensions.0 > 0 && dimensions.1 > 0).then_some(dimensions) } +fn add_demo_control(overlay: >k::Overlay) { + let exit = server::demo_mode(); + if !exit && server::demo_session().is_none() { return; } + let button = gtk::Button::with_label(if exit { "Exit demo" } else { "Demo" }); + button.set_halign(gtk::Align::Start); + button.set_valign(gtk::Align::Start); + button.set_margin_top(12); + button.set_margin_start(12); + button.connect_clicked(move |button| { + if exit { server::reset_pairing_and_restart("Exit demo"); } + button.set_sensitive(false); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { let _ = tx.send(server::enter_demo()); }); + let button = button.clone(); + gtk::glib::timeout_add_local(Duration::from_millis(100), move || { + match rx.try_recv() { + Ok(Ok(())) => { button.set_label("Starting demo…"); gtk::glib::ControlFlow::Break } + Ok(Err(_)) => { button.set_label("Demo unavailable — retry"); button.set_sensitive(true); gtk::glib::ControlFlow::Break } + Err(std::sync::mpsc::TryRecvError::Empty) => gtk::glib::ControlFlow::Continue, + Err(_) => { button.set_sensitive(true); gtk::glib::ControlFlow::Break } + } + }); + }); + overlay.add_overlay(&button); +} + fn show_pairing_code(window: &ApplicationWindow, code: &str, status: &str) { let vbox = GtkBox::new(Orientation::Vertical, 20); vbox.set_valign(gtk::Align::Center); @@ -1362,6 +1388,7 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str, status: &str) { let overlay = gtk::Overlay::new(); overlay.set_child(Some(&vbox)); overlay.add_overlay(&ver_label); + add_demo_control(&overlay); window.set_child(Some(&overlay)); window.queue_resize(); window.queue_draw(); @@ -1470,7 +1497,10 @@ fn render_bundle( if displays.is_empty() { // A valid bundle without assignments is a settled configuration state, // including when the last display was removed from an active kiosk. - pairing_window.set_child(Some(&build_empty_display_reference(&bundle, None))); + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&build_empty_display_reference(&bundle, None))); + add_demo_control(&overlay); + pairing_window.set_child(Some(&overlay)); pairing_window.present(); recompute_global_state(); mark_kiosk_healthy(); @@ -1526,6 +1556,7 @@ fn render_bundle( content_overlay.add_overlay(&wl); wl }); + add_demo_control(&content_overlay); window.set_child(Some(&content_overlay)); new_state.insert( @@ -3317,6 +3348,7 @@ fn build_logo_content(action: &str) -> gtk::Widget { let overlay = gtk::Overlay::new(); overlay.set_child(Some(&vbox)); overlay.add_overlay(&ver_label); + add_demo_control(&overlay); overlay.upcast() } diff --git a/client/src/platform/windows/mod.rs b/client/src/platform/windows/mod.rs index cd492c2a..46180400 100644 --- a/client/src/platform/windows/mod.rs +++ b/client/src/platform/windows/mod.rs @@ -285,23 +285,34 @@ fn self_test() -> Result<(), String> { } fn run_agent_cli(args: &[String]) -> Result<(), String> { - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|e| format!("tokio runtime: {e}"))?; - let override_url = arg_value(args, "--server"); - rt.block_on(async { - let server = loop { - match discover_server(override_url.as_deref(), &load_agent_state()?).await { - Ok(server) => break server, - Err(error) => { - warn!("server discovery: {error}; retrying"); - tokio::time::sleep(Duration::from_secs(10)).await; + loop { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|e| format!("tokio runtime: {e}"))?; + let override_url = arg_value(args, "--server"); + let result = rt.block_on(async { + let server = loop { + match discover_server(override_url.as_deref(), &load_agent_state()?).await { + Ok(server) => break server, + Err(error) => { + warn!("server discovery: {error}; retrying"); + tokio::time::sleep(Duration::from_secs(10)).await; + } } - } - }; - run_agent(server).await - }) + }; + run_agent(server).await + }); + // Drop the runtime first: old heartbeat/bundle tasks must never restore cleared state. + drop(rt); + result?; + let origin = load_agent_state()?.server_url; + update_state(|latest| { *latest = unpaired_state(&origin); Ok(()) })?; + for path in [bundle_path(), state_dir().join("exit-demo")] { + if path.exists() { fs::remove_file(path).map_err(|e| e.to_string())?; } + } + if webview_data_dir().exists() { fs::remove_dir_all(webview_data_dir()).map_err(|e| e.to_string())?; } + } } async fn discover_server( @@ -342,7 +353,7 @@ async fn run_agent(server_url: String) -> Result<(), String> { let app = Arc::new(Mutex::new(None::)); // The renderer reads protected cached state independently. Start it before // regional discovery so an offline upgrade keeps showing the saved display. - if state.kiosk_key.is_some() { start_app(&app)?; } + start_app(&app)?; while crate::core::protocol::needs_regional_migration(&state.server_url) { match migrate_canonical_state(&mut state).await { Ok(()) => break, @@ -462,6 +473,12 @@ async fn run_agent(server_url: String) -> Result<(), String> { } } _ = tokio::time::sleep(Duration::from_secs(2)) => { + if state_dir().join("exit-demo").exists() && state.lock().unwrap().demo { + if let Some(mut child) = app.lock().unwrap().take() { + let _ = child.kill(); let _ = child.wait(); + } + return Ok(()); + } if last_supervise.elapsed() >= Duration::from_secs(10) { if let Err(err) = supervise_app(&app) { warn!("app supervision failed: {err}"); @@ -510,6 +527,7 @@ async fn pair(server_url: &str) -> Result { let saved = load_agent_state()?; let resume = if saved.server_url == server_url && saved.kiosk_key.is_none() { saved.pairing_code.map(|code| PairInitiateResponse { + allow_demo: saved.allow_demo, code, expires_at: saved.pairing_expires_at.unwrap_or_default(), polling_secret: saved.pairing_secret, @@ -544,6 +562,7 @@ async fn pair(server_url: &str) -> Result { }; println!("BetterFrame Windows pairing code: {}", init.code); let mut pending = unpaired_state(server_url); + pending.allow_demo = init.allow_demo; pending.pairing_code = Some(init.code.clone()); pending.pairing_expires_at = Some(init.expires_at.clone()); pending.pairing_secret = init.polling_secret.clone(); @@ -597,6 +616,7 @@ async fn pair(server_url: &str) -> Result { ) { Ok(identity) => { let mut state = ClientState { + demo: identity.demo, server_url: identity.server_url, kiosk_key: Some(identity.kiosk_key), encrypt_key: identity.encrypt_key.or(identity.cluster_key), diff --git a/client/src/platform/windows/renderer.rs b/client/src/platform/windows/renderer.rs index 5e190ffa..0a5ec85f 100644 --- a/client/src/platform/windows/renderer.rs +++ b/client/src/platform/windows/renderer.rs @@ -1,5 +1,7 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ DI_NORMAL, DrawIconEx, IMAGE_ICON, LR_SHARED, LoadImageW, + WM_COMMAND, WM_KEYDOWN, GetDlgItem, IsWindowVisible, SetWindowTextW, SetWindowPos, + HWND_TOP, SWP_NOACTIVATE, WS_CHILD, WS_VISIBLE, WS_TABSTOP, SW_SHOW, SW_HIDE, }; use windows_sys::Win32::Graphics::Gdi::DT_WORDBREAK; @@ -126,6 +128,34 @@ unsafe extern "system" fn window_proc( lparam: LPARAM, ) -> LRESULT { match msg { + WM_COMMAND if (wparam & 0xffff) == 1601 => { + let state = load_state(); + if state.demo { + if write_protected(&state_dir().join("exit-demo"), b"exit").is_ok() { PostQuitMessage(0); } + } else if state.allow_demo && state.kiosk_key.is_none() { + std::thread::spawn(move || { + let Ok(rt) = tokio::runtime::Runtime::new() else { return; }; + rt.block_on(async { + let Some(code) = state.pairing_code.as_deref() else { return; }; + let response = crate::network::client().post(format!("{}/api/pair/demo", state.server_url)) + .json(&crate::core::protocol::claim_body(code, state.pairing_secret.as_deref())).send().await; + if !response.is_ok_and(|r| r.status().is_success()) { + warn!("Demo unavailable; continue normal pairing or restart to retry"); + let _ = update_state(|latest| { + if latest.pairing_code == state.pairing_code { latest.allow_demo = false; } + Ok(()) + }); + } + }); + }); + } + 0 + } + WM_KEYDOWN if wparam == 0x09 => { + let control = GetDlgItem(hwnd, 1601); + if control != 0 && IsWindowVisible(control) != 0 { windows_sys::Win32::UI::Input::KeyboardAndMouse::SetFocus(control); } + 0 + } WM_PAINT => { paint_window(hwnd); 0 @@ -194,6 +224,19 @@ pub(super) fn paint_window(hwnd: HWND) { } else { draw_centered(hdc, ps.rcPaint, "BetterFrame Windows Kiosk"); } + let state = load_state(); + let show_demo = state.demo || (state.allow_demo && state.kiosk_key.is_none()); + let mut control = GetDlgItem(hwnd, 1601); + if control == 0 && show_demo { + control = CreateWindowExW(0, wide("BUTTON").as_ptr(), wide("Demo").as_ptr(), + WS_CHILD | WS_VISIBLE | WS_TABSTOP, 12, 12, 140, 44, hwnd, 1601 as HMENU, + GetModuleHandleW(null()), null_mut()); + } + if control != 0 { + SetWindowTextW(control, wide(if state.demo { "Exit demo" } else { "Demo" }).as_ptr()); + ShowWindow(control, if show_demo { SW_SHOW } else { SW_HIDE }); + if show_demo { SetWindowPos(control, HWND_TOP, 12, 12, 140, 44, SWP_NOACTIVATE); } + } EndPaint(hwnd, &ps); } } diff --git a/compose.yaml b/compose.yaml index 276c42c6..9c8f3bde 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,6 +14,7 @@ services: BF_PG_USER: ${BF_PG_USER:-betterframe} BF_PG_PASSWORD: ${BF_PG_PASSWORD:?set BF_PG_PASSWORD} BF_NODERED_URL: http://nodered:1880 + BF_ENABLE_DEMO_TENANT: ${BF_ENABLE_DEMO_TENANT:-false} BF_SELF_URL: http://server:18080 BF_CLIENT_FIRMWARE_PUBLIC_KEY: ${BF_CLIENT_FIRMWARE_PUBLIC_KEY:-} BF_NODERED_MANAGER_SECRET: ${BF_NODERED_MANAGER_SECRET:?set BF_NODERED_MANAGER_SECRET to at least 32 characters} diff --git a/deploy/docker/server-entrypoint.sh b/deploy/docker/server-entrypoint.sh index 36a6784d..2cc65c58 100644 --- a/deploy/docker/server-entrypoint.sh +++ b/deploy/docker/server-entrypoint.sh @@ -20,6 +20,7 @@ export BF_PG_DB="${BF_PG_DB:-betterframe}" export BF_PG_USER="${BF_PG_USER:-betterframe}" : "${BF_PG_PASSWORD:?BF_PG_PASSWORD is required}" export BF_PG_PASSWORD +export BF_ENABLE_DEMO_TENANT="${BF_ENABLE_DEMO_TENANT:-false}" export BF_KIOSK_LOG_RETENTION_HOURS="${BF_KIOSK_LOG_RETENTION_HOURS:-24}" export BF_PG_POOL_MAX="${BF_PG_POOL_MAX:-10}" export BF_NODERED_URL="${BF_NODERED_URL:-http://nodered:1880}" diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 3c340e01..dd582be0 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -26,6 +26,7 @@ services: - BF_PG_USER=${BF_PG_USER:-betterframe} - BF_PG_PASSWORD=${BF_PG_PASSWORD:?set BF_PG_PASSWORD} - BF_NODERED_URL=http://nodered:1880 + - BF_ENABLE_DEMO_TENANT=${BF_ENABLE_DEMO_TENANT:-false} - BF_SELF_URL=http://server:18080 - BF_CLIENT_FIRMWARE_PUBLIC_KEY=${BF_CLIENT_FIRMWARE_PUBLIC_KEY:-} - BF_NODERED_MANAGER_SECRET=${BF_NODERED_MANAGER_SECRET:?set BF_NODERED_MANAGER_SECRET to at least 32 characters} diff --git a/docs/demo-mode.md b/docs/demo-mode.md new file mode 100644 index 00000000..46b984d6 --- /dev/null +++ b/docs/demo-mode.md @@ -0,0 +1,20 @@ +# Demo mode and new-display defaults + +Demo is disabled by default. Set `BF_ENABLE_DEMO_TENANT=true` in the Docker deployment and restart the server. Native deployments set `enableDemoTenant: true` in both `service-api-http` and `service-admin-http` configuration in `sec-config.yaml`. + +On first enablement the server creates a reserved `demo` tenant in the root tenant registry, with a welcome layout and that layout selected as its display default. It never adopts an existing tenant with that slug. If provisioning fails, demo is not advertised. Subsequent starts preserve administrator edits. Platform administrators can switch into Demo through the normal tenant menu to edit content. Demo disappears from administration when disabled; existing kiosks remain subject to cleanup. + +Under **Settings → New display layouts**, each tenant can select layouts to automatically assign to newly created displays, and optionally select one of those layouts as the default active layout. This also applies to displays discovered later. Defaults do not modify existing displays or restore assignments removed by an administrator. Deleted layouts are ignored. + +## Client and reviewer path + +1. Start an unpaired Android/Android TV, Linux, or Windows kiosk against the enabled server. +2. Choose **Demo** at the top left of the pairing screen. No account or administrator approval is required. +3. Normal pairing completes and the assigned demo layout starts playing. +4. Choose **Exit demo** at the top left to clear local enrollment and content/session state and return to startup. Exit does not call a server deletion endpoint. On Linux this uses the existing supervised kiosk restart. + +The initial pairing response includes `allowDemo`; clients only show the entry control when it is explicitly true. `POST /api/pair/demo` accepts only `code` and `polling_secret` from that pairing session. The endpoint confirms normal pairing into the server-owned demo tenant; clients retrieve and acknowledge their individual credentials through the existing claim endpoints. It is disabled when demo is unavailable, requires the polling secret, rate limits requests, and respects the demo tenant kiosk limit (initially 1,000). A claimed response carries `demo: true` so clients retain their Exit demo control across restarts. + +A server job checks once per minute, deleting at most 100 demo kiosks per pass. A kiosk becomes eligible when **either** it reaches 24 hours since pairing **or** its last heartbeat was at least five minutes ago. Before its first heartbeat, pairing time is used. Normal kiosk deletion behavior applies; shared layouts and the demo tenant remain. The cleanup continues when new demo enrollment is disabled. + +Demo does not change application, firmware, OS, or store update policies. Only intentionally public content should be placed in the demo tenant. diff --git a/sec-config.template.yaml b/sec-config.template.yaml index 88f33f84..21a34696 100644 --- a/sec-config.template.yaml +++ b/sec-config.template.yaml @@ -31,6 +31,7 @@ default: poolMax: ${BF_PG_POOL_MAX} host: 0.0.0.0 port: 18080 + enableDemoTenant: ${BF_ENABLE_DEMO_TENANT} dataDir: /var/lib/betterframe kioskLogRetentionHours: ${BF_KIOSK_LOG_RETENTION_HOURS} sessionIdleSeconds: 43200 @@ -65,6 +66,7 @@ default: host: 0.0.0.0 port: 18081 codeTtlSeconds: 600 + enableDemoTenant: ${BF_ENABLE_DEMO_TENANT} dataDir: /var/lib/betterframe argon2Memory: 65536 argon2TimeCost: 3 diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 1e9441af..3946bbba 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -59,6 +59,7 @@ const ConfigSchema = av.object( ), host: av.string().default("127.0.0.1"), port: av.int().min(1).max(65535).default(18080), + enableDemoTenant: av.bool().default(false), dataDir: av.string().minLength(1).default("/var/lib/betterframe"), systemdCredsName: av.string().default("betterframe-secret"), kioskLogRetentionHours: av.int().min(1).max(8760).default(24), @@ -103,6 +104,7 @@ export const EventSchemas = createEventSchemas({ }); export interface AdminDeps { + enableDemoTenant?: boolean; repo: Repository; auth: AuthApi; secrets: SecretsApi; @@ -208,6 +210,7 @@ export class Plugin extends BSBService, typeof Event : undefined), firmwareImportApiKey: this.config.firmwareImportApiKey || undefined, otaImportApiKey: this.config.otaImportApiKey || undefined, + enableDemoTenant: this.config.enableDemoTenant, scheduleNoderedReconcile: () => { void this.scheduleNoderedReconcile(repo, secrets, auth, nodered, selfUrl, obs); }, diff --git a/server/src/plugins/service-admin-http/middleware.ts b/server/src/plugins/service-admin-http/middleware.ts index 289dd175..7c1f49ce 100644 --- a/server/src/plugins/service-admin-http/middleware.ts +++ b/server/src/plugins/service-admin-http/middleware.ts @@ -99,7 +99,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void { : (event.req.headers.get("x-betterframe-tenant") ?? "").trim().toLowerCase(); if (headerSlug) { const tenant = await deps.repo.getTenantBySlug(headerSlug); - if (tenant?.is_active) { + if (tenant?.is_active && (deps.enableDemoTenant || tenant.slug !== "demo")) { event.context.tenant = tenant; schema = tenant.schema_name; return deps.repo.adapter.withSearchPath(schema, next); @@ -109,7 +109,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void { const tenantSlug = loginRequest ? "default" : getCookie(event, "bf_tenant") || "default"; const tenant = await deps.repo.getTenantBySlug(tenantSlug); - if (tenant && tenant.is_active) { + if (tenant && tenant.is_active && (deps.enableDemoTenant || tenant.slug !== "demo")) { event.context.tenant = tenant; schema = tenant.schema_name; } else { diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 58b62892..fa1dc764 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -3269,7 +3269,20 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const cloudAccounts = await deps.repo.listCloudAccounts(); const ablesignAccounts = await deps.repo.listAbleSignAccounts(); const updateSchedule = normalizeUpdateSchedule(await deps.repo.getSetupExtra("update_schedule")); - return htmlPage(SettingsPage({ cloudAccounts, ablesignAccounts, updateSchedule })); + return htmlPage(SettingsPage({ cloudAccounts, ablesignAccounts, updateSchedule, layouts: await deps.repo.listLayouts(), displayDefaults: await deps.repo.getDisplayDefaults() })); + }); + + app.post("/admin/settings/display-defaults", async (event) => { + const body = await readBody>(event); + const raw = body?.["layout_ids"]; + const layoutIds = [...new Set((Array.isArray(raw) ? raw : raw ? [raw] : []).map(String))]; + const defaultLayoutId = String(body?.["default_layout_id"] ?? "") || null; + const existing = new Set((await deps.repo.listLayouts()).map(l => l.id)); + if (layoutIds.some(id => !existing.has(id)) || (defaultLayoutId && !layoutIds.includes(defaultLayoutId))) { + return new Response("Default layout must be selected from the assigned layouts", { status: 400 }); + } + await deps.repo.setSetupExtra("display_defaults", { layoutIds, defaultLayoutId }); + return new Response(null, { status: 302, headers: { location: "/admin/settings" } }); }); app.post("/admin/settings/update-schedule", async (event) => { @@ -3281,7 +3294,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Tenant switcher fragment (htmx) ---------------------------------------- app.get("/admin/_tenant_switcher", async (event) => { - const tenants = await deps.repo.listTenants(); + const tenants = (await deps.repo.listTenants()).filter(t => deps.enableDemoTenant || t.slug !== "demo"); if (tenants.length <= 1) return new Response("", { headers: { "content-type": "text/html" } }); const current = (event.context as any).tenant?.slug ?? "default"; const options = tenants.map((t: any) => diff --git a/server/src/plugins/service-admin-http/routes-tenants.ts b/server/src/plugins/service-admin-http/routes-tenants.ts index f4464144..46e2add2 100644 --- a/server/src/plugins/service-admin-http/routes-tenants.ts +++ b/server/src/plugins/service-admin-http/routes-tenants.ts @@ -20,7 +20,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/tenants", async (event) => { if (!isPg()) return new Response("multi-tenant requires postgres", { status: 404 }); const user = event.context.user!; - const tenants = await deps.repo.listTenants(); + const tenants = (await deps.repo.listTenants()).filter(t => deps.enableDemoTenant || t.slug !== "demo"); const currentTenant = event.context.tenant ?? null; return htmlPage(TenantsPage({ user: user.username, @@ -40,8 +40,8 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { const maxCameras = body?.["max_cameras"] ? parseInt(body["max_cameras"], 10) : null; const maxUsers = body?.["max_users"] ? parseInt(body["max_users"], 10) : null; - if (!name || !slug || !/^[a-z0-9][a-z0-9_-]{0,127}$/.test(slug)) { - const tenants = await deps.repo.listTenants(); + if (slug === "demo" || !name || !slug || !/^[a-z0-9][a-z0-9_-]{0,127}$/.test(slug)) { + const tenants = (await deps.repo.listTenants()).filter(t => deps.enableDemoTenant || t.slug !== "demo"); return htmlPage(TenantsPage({ user: event.context.user!.username, tenants, @@ -53,7 +53,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { // Check for duplicate slug. const existing = await deps.repo.getTenantBySlug(slug); if (existing) { - const tenants = await deps.repo.listTenants(); + const tenants = (await deps.repo.listTenants()).filter(t => deps.enableDemoTenant || t.slug !== "demo"); return htmlPage(TenantsPage({ user: event.context.user!.username, tenants, @@ -93,7 +93,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { if (!isPg()) return new Response("multi-tenant requires postgres", { status: 404 }); const id = getRouterParam(event, "id") ?? ""; const tenant = await deps.repo.getTenantById(id); - if (!tenant) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + if (!tenant || (!deps.enableDemoTenant && tenant.slug === "demo")) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); return htmlPage(TenantEditPage({ user: event.context.user!.username, tenant, @@ -114,7 +114,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { if (!name) { const tenant = await deps.repo.getTenantById(id); - if (!tenant) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + if (!tenant || (!deps.enableDemoTenant && tenant.slug === "demo")) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); return htmlPage(TenantEditPage({ user: event.context.user!.username, tenant, @@ -139,7 +139,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { if (!isPg()) return new Response("multi-tenant requires postgres", { status: 404 }); const id = getRouterParam(event, "id") ?? ""; const tenant = await deps.repo.getTenantById(id); - if (!tenant) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + if (!tenant || (!deps.enableDemoTenant && tenant.slug === "demo")) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); // Prevent deleting the default tenant. if (tenant.slug === "default") { return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); @@ -162,7 +162,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { // Validate the tenant exists and is active. const tenant = await deps.repo.getTenantBySlug(slug); - const targetSlug = tenant?.is_active ? tenant.slug : "default"; + const targetSlug = tenant?.is_active && (deps.enableDemoTenant || tenant.slug !== "demo") ? tenant.slug : "default"; // Set the bf_tenant cookie. MaxAge = 1 year (long-lived, session-like). return redirectWithCookie( diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 8ef36384..012a0624 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -51,6 +51,8 @@ import { validateBody, } from "../../shared/api-schemas.js"; +import { prepareDemo, enrollDemo, cleanupDemo } from "../../shared/demo.js"; + // ---- Config ----------------------------------------------------------------- const ConfigSchema = av.object( @@ -69,6 +71,7 @@ const ConfigSchema = av.object( ), host: av.string().default("127.0.0.1"), port: av.int().min(1).max(65535).default(18081), + enableDemoTenant: av.bool().default(false), codeTtlSeconds: av.int().min(60).max(3600).default(600), // Secrets + auth config (shared with admin-http for now) dataDir: av.string().minLength(1).default("/var/lib/betterframe"), @@ -126,6 +129,8 @@ export class Plugin extends BSBService, typeof Event private server?: Server; private dbClose?: () => Promise; + private demoTimer?: ReturnType; + private demoCleanup?: Promise; constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { super(cfg); @@ -279,7 +284,17 @@ export class Plugin extends BSBService, typeof Event const clientFirmwarePublicKey = this.config.clientFirmwarePublicKey || (this.config.clientFirmwarePublicKeyBase64 ? Buffer.from(this.config.clientFirmwarePublicKeyBase64, "base64").toString("utf8") : ""); - registerPairingRoutes(app, repo, auth, secrets, codeTtl, firmware, osUpdates, clientFirmwarePublicKey); + let demoReady = false; + try { demoReady = !!await prepareDemo(repo, this.config.enableDemoTenant); } + catch (error) { obs.log.warn("Demo provisioning failed: {error}", { error: String(error) }); } + this.demoTimer = setInterval(() => { + if (this.demoCleanup) return; + this.demoCleanup = cleanupDemo(repo).catch(error => { + obs.log.warn("Demo cleanup failed: {error}", { error: String(error) }); + }).finally(() => { this.demoCleanup = undefined; }); + }, 60_000); + this.demoTimer.unref(); + registerPairingRoutes(app, repo, auth, secrets, codeTtl, firmware, osUpdates, clientFirmwarePublicKey, demoReady); registerKioskRoutes(app, repo, auth, secrets, nodered, firmware, osUpdates, mqtt, clientFirmwarePublicKey); registerIoBoxRoutes(app, repo, auth, nodered, mqtt, firmware, secrets); @@ -300,6 +315,8 @@ export class Plugin extends BSBService, typeof Event if (this.server) { await this.server.close(); } + clearInterval(this.demoTimer); + await this.demoCleanup; await this.dbClose?.(); } } @@ -428,6 +445,7 @@ function registerPairingRoutes( firmware: FirmwareApi, osUpdates: OsUpdateApi, clientFirmwarePublicKey: string, + demoEnabled = false, ): void { // Constructed in-function so the BSB schema extractor (which evaluates the // module statically) doesn't see a top-level createRateLimiter call. @@ -455,7 +473,21 @@ function registerPairingRoutes( secureClaim: body.secure_claim, }); - return { code: result.code, expires_at: result.expiresAt, expires_in_seconds: result.expiresInSeconds, poll_after_ms: PAIR_POLL_AFTER_MS, polling_secret: result.pollingSecret }; + return { allowDemo: demoEnabled, code: result.code, expires_at: result.expiresAt, expires_in_seconds: result.expiresInSeconds, poll_after_ms: PAIR_POLL_AFTER_MS, polling_secret: result.pollingSecret }; + }); + + app.post("/api/pair/demo", async (event) => { + if (!demoEnabled) throw createError({ statusCode: 503, statusMessage: "Demo unavailable" }); + const ip = getRequestHeader(event, "x-real-ip") ?? "anon"; + if (!pairingGuard.take(`demo:${ip}`)) throw createError({ statusCode: 429, statusMessage: "rate limited" }); + const raw = await readBody>(event); + if (!raw || Object.keys(raw).some(key => key !== "code" && key !== "polling_secret")) { + throw createError({ statusCode: 400, statusMessage: "Only the pairing code and polling secret are accepted" }); + } + const body = validateBody(PairClaimBody, raw); + try { await enrollDemo(repo, auth, secrets, demoEnabled, body.code.trim().toUpperCase(), body.polling_secret); } + catch { throw createError({ statusCode: 409, statusMessage: "Demo enrollment unavailable; refresh pairing and retry" }); } + return { status: "confirmed" }; }); // Kiosk polls for claim result — no auth required @@ -479,6 +511,7 @@ function registerPairingRoutes( } return { status: "claimed", + demo: result.demo === true, expires_in_seconds: result.expiresInSeconds, kiosk_id: result.kioskId, kiosk_name: result.kioskName, diff --git a/server/src/schemas/wire/pairing.ts b/server/src/schemas/wire/pairing.ts index f557a5c9..b6f82be3 100644 --- a/server/src/schemas/wire/pairing.ts +++ b/server/src/schemas/wire/pairing.ts @@ -44,6 +44,7 @@ export const pairInitiateResponse = av.object( { code: av.string().pattern("^[A-HJ-NP-Z2-9]{8}$"), // 0/O/1/I excluded expires_at: av.string().format("date-time"), + allowDemo: av.optional(av.bool()), expires_in_seconds: av.optional(av.int().min(0)), poll_after_ms: av.optional(av.int().min(0)), polling_secret: av.optional(av.string().minLength(32)), @@ -72,6 +73,7 @@ export const pairClaimRequest = av.object( export const pairClaimResponse = av.object( { status: av.literal("claimed"), + demo: av.optional(av.bool()), kiosk_id: av.string().minLength(1), kiosk_name: av.string().minLength(1).maxLength(128), encrypt_key: av.optional(av.string().minLength(32)), diff --git a/server/src/shared/db/repository.ts b/server/src/shared/db/repository.ts index 86d8511a..f77748d2 100644 --- a/server/src/shared/db/repository.ts +++ b/server/src/shared/db/repository.ts @@ -605,25 +605,42 @@ export class Repository { width_px?: number; height_px?: number; }): Promise { - const idx = input.index ?? await this.nextDisplayIndexForKiosk(kioskId); - const id = uuidv7(); - await this._run( - `INSERT INTO displays (id, name, "index", is_primary, kiosk_id, width_px, height_px) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - id, - input.name, - idx, - false, - kioskId, - input.width_px ?? 1920, - input.height_px ?? 1080, - ], - ); - void this.notify("displays", "create", id); - const d = await this.getDisplayById(id); - if (!d) throw new Error("display vanished after insert"); - return d; + return this.transact(async () => { + const idx = input.index ?? await this.nextDisplayIndexForKiosk(kioskId); + const id = uuidv7(); + await this._run( + `INSERT INTO displays (id, name, "index", is_primary, kiosk_id, width_px, height_px) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + id, + input.name, + idx, + false, + kioskId, + input.width_px ?? 1920, + input.height_px ?? 1080, + ], + ); + const defaults = await this.getDisplayDefaults(); + for (const layoutId of defaults.layoutIds) await this.attachLayoutToDisplay(id, layoutId); + if (defaults.defaultLayoutId) await this.updateDisplay(id, { + default_layout_id: defaults.defaultLayoutId, active_layout_id: defaults.defaultLayoutId, + }); + void this.notify("displays", "create", id); + const d = await this.getDisplayById(id); + if (!d) throw new Error("display vanished after insert"); + return d; + }); + } + + /** Defaults apply once, when a display is created. Deleted layouts are ignored. */ + async getDisplayDefaults(): Promise<{ layoutIds: string[]; defaultLayoutId: string | null }> { + const raw = await this.getSetupExtra("display_defaults") as { layoutIds?: unknown; defaultLayoutId?: unknown } | null; + const existing = new Set((await this.listLayouts()).map(l => l.id)); + const layoutIds = Array.isArray(raw?.layoutIds) + ? [...new Set(raw.layoutIds.filter((id): id is string => typeof id === "string" && existing.has(id)))] : []; + const defaultLayoutId = typeof raw?.defaultLayoutId === "string" && layoutIds.includes(raw.defaultLayoutId) ? raw.defaultLayoutId : null; + return { layoutIds, defaultLayoutId }; } async listDisplaysForKiosk(kioskId: string): Promise { diff --git a/server/src/shared/demo.ts b/server/src/shared/demo.ts new file mode 100644 index 00000000..7721d700 --- /dev/null +++ b/server/src/shared/demo.ts @@ -0,0 +1,70 @@ +import type { Repository } from "./db/repository.js"; +import { createTenantSchema } from "./db/init.js"; +import type { Tenant } from "./tenant.js"; +import type { AuthApi } from "./auth.js"; +import type { SecretsApi } from "./secrets.js"; +import { confirmPairing, matchesSecret } from "./pairing.js"; + +export const DEMO_SLUG = "demo"; +const MARKER = "demo_tenant_id"; +export async function demoTenant(repo: Repository): Promise { + return repo.adapter.withSearchPath("public", async () => { + const id = await repo.getSetupExtra(MARKER); + return typeof id === "string" ? repo.getTenantById(id) : null; + }); +} + +export async function prepareDemo(repo: Repository, enabled: boolean): Promise { + if (!enabled) return null; + if (repo.adapter.dialect() !== "postgres") throw Error("Demo requires PostgreSQL tenant isolation"); + return repo.adapter.withSearchPath("public", () => repo.transact(async () => { + await repo.adapter.get("SELECT pg_advisory_xact_lock(1642060916)"); + const existing = await demoTenant(repo); + if (existing) return existing.is_active ? existing : null; + if (await repo.getTenantBySlug(DEMO_SLUG)) throw Error("Reserved demo tenant slug is already in use"); + const tenant = await repo.createTenant({ name: "Demo", slug: DEMO_SLUG, max_kiosks: 1000 }); + await createTenantSchema(repo.adapter, tenant.slug, { info: () => {}, warn: () => {} }); + await repo.setSetupExtra(MARKER, tenant.id); + await repo.adapter.withSearchPath(tenant.schema_name, async () => { + const layout = await repo.createLayout({ name: "Welcome to BetterFrame" }); + await repo.createLayoutCell({ layout_id: layout.id, row: 0, col: 0, content_type: "html", + html_content: '

BetterFrame

Your content. Every display.

Welcome to the demo

' }); + await repo.setSetupExtra("display_defaults", { layoutIds: [layout.id], defaultLayoutId: layout.id }); + }); + return tenant; + })); +} + +export async function enrollDemo(repo: Repository, auth: AuthApi, secrets: SecretsApi, + enabled: boolean, code: string, pollingSecret: string | undefined): Promise { + if (!enabled) throw Error("Demo unavailable"); + const tenant = await demoTenant(repo); + if (!tenant?.is_active) throw Error("Demo unavailable"); + await repo.adapter.withSearchPath(tenant.schema_name, () => repo.transact(async () => { + // Serialize capacity checks across API instances; pairing itself locks its code. + await repo.adapter.get("SELECT id FROM public.tenants WHERE id = ? FOR UPDATE", [tenant.id]); + const pc = await repo.getPairingCode(code, true); + if (!pc || !matchesSecret(pollingSecret, pc.extras["polling_secret_hash"])) throw Error("Invalid demo pairing session"); + if (!pc.consumed_at && tenant.max_kiosks != null) { + const count = await repo.adapter.get<{ count: string }>("SELECT count(*) FROM kiosks"); + if (Number(count?.count) >= tenant.max_kiosks) throw Error("Demo capacity reached"); + } + await confirmPairing(repo, auth, secrets, { + code, demo: true, tenant: { id: tenant.id, slug: tenant.slug, schemaName: tenant.schema_name }, + }); + })); +} + +/** Normal deletion owns credential invalidation and client recovery. */ +export async function cleanupDemo(repo: Repository, now = new Date()): Promise { + const tenant = await demoTenant(repo); + if (!tenant) return 0; + return repo.adapter.withSearchPath(tenant.schema_name, () => repo.transact(async () => { + const rows = await repo.adapter.all<{ id: string }>( + `SELECT id FROM kiosks WHERE paired_at <= ? OR COALESCE(last_seen_at, paired_at) <= ? + ORDER BY paired_at LIMIT 100 FOR UPDATE SKIP LOCKED`, + [new Date(now.getTime() - 86400000).toISOString(), new Date(now.getTime() - 300000).toISOString()]); + for (const row of rows) await repo.deleteKiosk(row.id); + return rows.length; + })); +} diff --git a/server/src/shared/pairing.ts b/server/src/shared/pairing.ts index 77565fe8..23223003 100644 --- a/server/src/shared/pairing.ts +++ b/server/src/shared/pairing.ts @@ -93,6 +93,7 @@ export interface PairingClaimResult { clusterKey?: string; encryptKey?: string; bundleUrl?: string; + demo?: boolean; } export async function claimPairing( @@ -157,10 +158,12 @@ export async function claimPairing( clusterKey: isAndroidViewer(kiosk) ? undefined : claim.clusterKey, encryptKey: claim.encryptKey, bundleUrl: "/api/kiosk/bundle", + demo: extras["demo"] === true, }; } export interface PairingConfirmInput { + demo?: boolean; code: string; nameOverride?: string; initialLabels?: string[]; @@ -189,7 +192,7 @@ export async function confirmPairing( if (pc.consumed_at) { // Retry only the same operation in the same tenant. Never leak another // tenant's claimed kiosk through an operator's repeated submission. - if (pc.extras["tenant_schema"] !== (input.tenant?.schemaName ?? "public") + if ((pc.extras["demo"] === true) !== (input.demo === true) || pc.extras["tenant_schema"] !== (input.tenant?.schemaName ?? "public") || pc.extras["confirmation_request"] !== JSON.stringify({ replaceKioskId: input.replaceKioskId ?? null, nameOverride: input.nameOverride ?? null, @@ -317,6 +320,7 @@ export async function confirmPairing( await repo.markPairingCodeClaimed(input.code, kioskId, { ...pc.extras, pairing_claim_encrypted: pairingClaimEncrypted, + demo: input.demo === true, claim_expires_at: new Date(Date.now() + DELIVERY_GRACE_MS).toISOString(), confirmation_request: JSON.stringify({ replaceKioskId: input.replaceKioskId ?? null, diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 143b63dc..4cc113f0 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -5371,6 +5371,8 @@ export function TenantEditPage(props: TenantEditPageProps) { // ---- Settings Page ---------------------------------------------------------- interface SettingsPageProps { + layouts?: Array<{ id: string; name: string }>; + displayDefaults?: { layoutIds: string[]; defaultLayoutId: string | null }; cloudAccounts: any[]; ablesignAccounts: any[]; updateSchedule: UpdateSchedule; @@ -5395,6 +5397,27 @@ export function SettingsPage(props: SettingsPageProps) { {props.error ?
{props.error}
: ""} +
+

New display layouts

+

Automatically assign these layouts when a display is created. Existing displays keep their assignments.

+
+ {(props.layouts ?? []).map(layout => ( + + ))} + +

Choose a default from the layouts selected above.

+ +
+

Automatic Updates

diff --git a/server/tests/demo-postgres.test.ts b/server/tests/demo-postgres.test.ts new file mode 100644 index 00000000..0f6537ea --- /dev/null +++ b/server/tests/demo-postgres.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { randomBytes } from "node:crypto"; +import { PgAdapter } from "../src/shared/db/pg-adapter.js"; +import { initDb } from "../src/shared/db/init.js"; +import { prepareDemo, demoTenant, enrollDemo, cleanupDemo } from "../src/shared/demo.js"; +import { initiatePairing, confirmPairing, claimPairing } from "../src/shared/pairing.js"; + +const url = process.env["BF_TEST_PG_URL"]; +const log = { info() {}, warn() {} }; +const secrets = { encryptString: (v: string) => `encrypted:${v}`, decryptString: (v: string) => v.slice(10) }; +const auth = { hashPassword: async (v: string) => `hash:${v}` }; + +test("demo enrollment, display defaults and bounded tenant-only cleanup", { skip: !url }, async () => { + const admin = new PgAdapter(url!); + const name = `bf_demo_${randomBytes(8).toString("hex")}`; + await admin.exec(`CREATE DATABASE "${name}"`); + const testUrl = new URL(url!); testUrl.pathname = `/${name}`; + const opened = await initDb({ url: testUrl.toString(), host: "", port: 5432, user: "", password: "", database: name, poolMax: 6 }, log); + const repo = opened.repo; + const initiate = () => initiatePairing(repo, { proposedName: "Demo kiosk", hardwareModel: null, capabilities: [], codeTtlSeconds: 600, secureClaim: true }); + try { + assert.equal(await prepareDemo(repo, false), null); + assert.equal(await demoTenant(repo), null); + const [tenant, again] = await Promise.all([prepareDemo(repo, true), prepareDemo(repo, true)]); + assert.ok(tenant); assert.equal(again?.id, tenant.id); + const settings = await repo.adapter.withSearchPath(tenant.schema_name, () => repo.getDisplayDefaults()); + assert.equal(settings.layoutIds.length, 1); + assert.equal(settings.defaultLayoutId, settings.layoutIds[0]); + await repo.adapter.withSearchPath(tenant.schema_name, async () => { + await repo.updateLayout(settings.layoutIds[0]!, { name: "Operator edited" }); + }); + await prepareDemo(repo, true); + assert.equal(await repo.adapter.withSearchPath(tenant.schema_name, async () => (await repo.listLayouts())[0]?.name), "Operator edited"); + + const session = await initiate(); + const enroll = (enabled = true, secret = session.pollingSecret) => enrollDemo(repo, auth as never, secrets as never, enabled, session.code, secret); + await assert.rejects(enroll(false), /unavailable/); + await assert.rejects(enroll(true, "forged-secret"), /Invalid/); + await Promise.all([enroll(), enroll()]); + const claim = await claimPairing(repo, session.code, secrets as never, undefined, session.pollingSecret); + assert.equal(claim.demo, true); assert.ok(claim.kioskId); + assert.equal((await claimPairing(repo, session.code, secrets as never)).status, "failed"); + await assert.rejects(confirmPairing(repo, auth as never, secrets as never, { code: session.code }), /already used/); + await repo.adapter.withSearchPath(tenant.schema_name, async () => { + assert.equal((await repo.listKiosks()).length, 1); + const display = (await repo.listDisplaysForKiosk(claim.kioskId!))[0]!; + assert.equal(display.active_layout_id, settings.defaultLayoutId); + assert.equal(display.default_layout_id, settings.defaultLayoutId); + assert.equal((await repo.listLayoutsForDisplay(display.id)).length, 1); + await repo.detachLayoutFromDisplay(display.id, settings.layoutIds[0]!); + const later = await repo.createDisplayForKiosk(claim.kioskId!, { name: "Second display" }); + assert.equal((await repo.listLayoutsForDisplay(later.id)).length, 1); + assert.equal((await repo.listLayoutsForDisplay(display.id)).length, 0); // No enforcement. + await repo.setSetupExtra("display_defaults", { layoutIds: ["deleted-id"], defaultLayoutId: "deleted-id" }); + const third = await repo.createDisplayForKiosk(claim.kioskId!, { name: "Third display" }); + assert.equal(third.default_layout_id, null); + await repo.setSetupExtra("display_defaults", settings); + }); + + // Ordinary tenants use exactly the same defaults and never enter demo cleanup. + const ordinary = await initiate(); + const normal = await confirmPairing(repo, auth as never, secrets as never, { code: ordinary.code }); + const normalLayout = await repo.createLayout({ name: "Marketing" }); + await repo.setSetupExtra("display_defaults", { layoutIds: [normalLayout.id], defaultLayoutId: normalLayout.id }); + const normalDisplay = await repo.createDisplayForKiosk(normal.kioskId, { name: "Marketing display" }); + assert.equal(normalDisplay.default_layout_id, normalLayout.id); + assert.equal((await claimPairing(repo, ordinary.code, secrets as never, undefined, ordinary.pollingSecret)).demo, false); + await assert.rejects(enrollDemo(repo, auth as never, secrets as never, true, ordinary.code, ordinary.pollingSecret), /already used/); + + const now = new Date("2030-01-02T12:00:00.000Z"); + const stamp = async (id: string, age: number, idle: number | null) => repo.adapter.withSearchPath(tenant.schema_name, () => + repo.adapter.run("UPDATE kiosks SET paired_at = ?, last_seen_at = ? WHERE id = ?", [new Date(+now - age).toISOString(), idle == null ? null : new Date(+now - idle).toISOString(), id])); + await stamp(claim.kioskId!, 299999, null); + assert.equal(await cleanupDemo(repo, now), 0); + await stamp(claim.kioskId!, 300000, null); + assert.equal(await cleanupDemo(repo, now), 1); + assert.equal((await claimPairing(repo, session.code, secrets as never, undefined, session.pollingSecret)).status, "revoked"); + assert.ok(await repo.getKioskById(normal.kioskId)); + await repo.adapter.withSearchPath(tenant.schema_name, async () => { + assert.equal((await repo.listDisplays()).length, 0); + assert.equal((await repo.listLayouts()).length, 1); + }); + const active = await initiate(); + await enrollDemo(repo, auth as never, secrets as never, true, active.code, active.pollingSecret); + const activeClaim = await claimPairing(repo, active.code, secrets as never, undefined, active.pollingSecret); + await stamp(activeClaim.kioskId!, 86399999, 0); + assert.equal(await cleanupDemo(repo, now), 0); + await stamp(activeClaim.kioskId!, 86400000, 0); + const counts = await Promise.all([cleanupDemo(repo, now), cleanupDemo(repo, now)]); + assert.equal(counts.reduce((a, b) => a + b, 0), 1); + assert.ok(await demoTenant(repo)); + } finally { + await opened.close(); + await admin.exec(`DROP DATABASE "${name}" WITH (FORCE)`); + await admin.close(); + } +}); From 586a91ff13b1a659f7c22e3dd2d1b383dce7b258 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:48:01 +0000 Subject: [PATCH 2/8] fix: keep demo browser sessions ephemeral on Linux --- client/src/platform/linux/ui.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/platform/linux/ui.rs b/client/src/platform/linux/ui.rs index d95b61d9..917dd907 100644 --- a/client/src/platform/linux/ui.rs +++ b/client/src/platform/linux/ui.rs @@ -3073,7 +3073,14 @@ fn ensure_web( return wv; } - let wv = webkit6::WebView::new(); + // Demo pages never retain browser cookies/storage after local exit or reboot. + let wv = if server::demo_mode() { + webkit6::WebView::builder() + .network_session(&webkit6::NetworkSession::new_ephemeral()) + .build() + } else { + webkit6::WebView::new() + }; wv.set_vexpand(true); wv.set_hexpand(true); #[cfg(all(target_arch = "x86_64", target_os = "linux"))] From c4ef9cc07e6867450321943a6e2ad49d757181d4 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:05:12 +0000 Subject: [PATCH 3/8] fix: identify managed demo tenant and refresh availability --- .../plugins/service-admin-http/middleware.ts | 5 +-- .../service-admin-http/routes-admin.ts | 3 +- .../service-admin-http/routes-tenants.ts | 19 +++++++---- server/src/plugins/service-api-http/index.ts | 11 +++---- server/src/shared/demo.ts | 25 +++++++++++++-- server/tests/demo-postgres.test.ts | 21 +++++++++++- server/tests/tenant-auth.test.ts | 32 +++++++++++++++++++ 7 files changed, 96 insertions(+), 20 deletions(-) diff --git a/server/src/plugins/service-admin-http/middleware.ts b/server/src/plugins/service-admin-http/middleware.ts index 7c1f49ce..78b2fd8b 100644 --- a/server/src/plugins/service-admin-http/middleware.ts +++ b/server/src/plugins/service-admin-http/middleware.ts @@ -1,3 +1,4 @@ +import { demoTenantHidden } from "../../shared/demo.js"; /** * Auth & setup gate middleware for admin-http. * @@ -99,7 +100,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void { : (event.req.headers.get("x-betterframe-tenant") ?? "").trim().toLowerCase(); if (headerSlug) { const tenant = await deps.repo.getTenantBySlug(headerSlug); - if (tenant?.is_active && (deps.enableDemoTenant || tenant.slug !== "demo")) { + if (tenant?.is_active && !await demoTenantHidden(deps.repo, deps.enableDemoTenant, tenant)) { event.context.tenant = tenant; schema = tenant.schema_name; return deps.repo.adapter.withSearchPath(schema, next); @@ -109,7 +110,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void { const tenantSlug = loginRequest ? "default" : getCookie(event, "bf_tenant") || "default"; const tenant = await deps.repo.getTenantBySlug(tenantSlug); - if (tenant && tenant.is_active && (deps.enableDemoTenant || tenant.slug !== "demo")) { + if (tenant && tenant.is_active && !await demoTenantHidden(deps.repo, deps.enableDemoTenant, tenant)) { event.context.tenant = tenant; schema = tenant.schema_name; } else { diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index fa1dc764..327f7fd5 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1,3 +1,4 @@ +import { visibleTenants } from "../../shared/demo.js"; import { kioskDebugEnabled, kioskDebugRequirement } from "../../shared/kiosk-channels.js"; /** * Admin page routes — overview, cameras, kiosks, labels, etc. @@ -3294,7 +3295,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Tenant switcher fragment (htmx) ---------------------------------------- app.get("/admin/_tenant_switcher", async (event) => { - const tenants = (await deps.repo.listTenants()).filter(t => deps.enableDemoTenant || t.slug !== "demo"); + const tenants = await visibleTenants(deps.repo, deps.enableDemoTenant); if (tenants.length <= 1) return new Response("", { headers: { "content-type": "text/html" } }); const current = (event.context as any).tenant?.slug ?? "default"; const options = tenants.map((t: any) => diff --git a/server/src/plugins/service-admin-http/routes-tenants.ts b/server/src/plugins/service-admin-http/routes-tenants.ts index 46e2add2..5b5d93f6 100644 --- a/server/src/plugins/service-admin-http/routes-tenants.ts +++ b/server/src/plugins/service-admin-http/routes-tenants.ts @@ -1,3 +1,4 @@ +import { demoTenantHidden, visibleTenants } from "../../shared/demo.js"; /** * Tenant management routes — CRUD for tenants + tenant switching. * PG-only feature. On SQLite these routes return 404. @@ -20,7 +21,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { app.get("/admin/tenants", async (event) => { if (!isPg()) return new Response("multi-tenant requires postgres", { status: 404 }); const user = event.context.user!; - const tenants = (await deps.repo.listTenants()).filter(t => deps.enableDemoTenant || t.slug !== "demo"); + const tenants = await visibleTenants(deps.repo, deps.enableDemoTenant); const currentTenant = event.context.tenant ?? null; return htmlPage(TenantsPage({ user: user.username, @@ -41,7 +42,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { const maxUsers = body?.["max_users"] ? parseInt(body["max_users"], 10) : null; if (slug === "demo" || !name || !slug || !/^[a-z0-9][a-z0-9_-]{0,127}$/.test(slug)) { - const tenants = (await deps.repo.listTenants()).filter(t => deps.enableDemoTenant || t.slug !== "demo"); + const tenants = await visibleTenants(deps.repo, deps.enableDemoTenant); return htmlPage(TenantsPage({ user: event.context.user!.username, tenants, @@ -53,7 +54,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { // Check for duplicate slug. const existing = await deps.repo.getTenantBySlug(slug); if (existing) { - const tenants = (await deps.repo.listTenants()).filter(t => deps.enableDemoTenant || t.slug !== "demo"); + const tenants = await visibleTenants(deps.repo, deps.enableDemoTenant); return htmlPage(TenantsPage({ user: event.context.user!.username, tenants, @@ -93,7 +94,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { if (!isPg()) return new Response("multi-tenant requires postgres", { status: 404 }); const id = getRouterParam(event, "id") ?? ""; const tenant = await deps.repo.getTenantById(id); - if (!tenant || (!deps.enableDemoTenant && tenant.slug === "demo")) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + if (!tenant || await demoTenantHidden(deps.repo, deps.enableDemoTenant, tenant)) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); return htmlPage(TenantEditPage({ user: event.context.user!.username, tenant, @@ -114,7 +115,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { if (!name) { const tenant = await deps.repo.getTenantById(id); - if (!tenant || (!deps.enableDemoTenant && tenant.slug === "demo")) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + if (!tenant || await demoTenantHidden(deps.repo, deps.enableDemoTenant, tenant)) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); return htmlPage(TenantEditPage({ user: event.context.user!.username, tenant, @@ -122,6 +123,10 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { })); } + const target = await deps.repo.getTenantById(id); + if (!target || await demoTenantHidden(deps.repo, deps.enableDemoTenant, target)) { + return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + } await deps.repo.updateTenant(id, { name, is_active: isActive, @@ -139,7 +144,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { if (!isPg()) return new Response("multi-tenant requires postgres", { status: 404 }); const id = getRouterParam(event, "id") ?? ""; const tenant = await deps.repo.getTenantById(id); - if (!tenant || (!deps.enableDemoTenant && tenant.slug === "demo")) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + if (!tenant || await demoTenantHidden(deps.repo, deps.enableDemoTenant, tenant)) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); // Prevent deleting the default tenant. if (tenant.slug === "default") { return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); @@ -162,7 +167,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { // Validate the tenant exists and is active. const tenant = await deps.repo.getTenantBySlug(slug); - const targetSlug = tenant?.is_active && (deps.enableDemoTenant || tenant.slug !== "demo") ? tenant.slug : "default"; + const targetSlug = tenant?.is_active && !await demoTenantHidden(deps.repo, deps.enableDemoTenant, tenant) ? tenant.slug : "default"; // Set the bf_tenant cookie. MaxAge = 1 year (long-lived, session-like). return redirectWithCookie( diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 012a0624..9a9c9e76 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -51,7 +51,7 @@ import { validateBody, } from "../../shared/api-schemas.js"; -import { prepareDemo, enrollDemo, cleanupDemo } from "../../shared/demo.js"; +import { prepareDemo, enrollDemo, cleanupDemo, demoAvailable } from "../../shared/demo.js"; // ---- Config ----------------------------------------------------------------- @@ -284,8 +284,7 @@ export class Plugin extends BSBService, typeof Event const clientFirmwarePublicKey = this.config.clientFirmwarePublicKey || (this.config.clientFirmwarePublicKeyBase64 ? Buffer.from(this.config.clientFirmwarePublicKeyBase64, "base64").toString("utf8") : ""); - let demoReady = false; - try { demoReady = !!await prepareDemo(repo, this.config.enableDemoTenant); } + try { await prepareDemo(repo, this.config.enableDemoTenant); } catch (error) { obs.log.warn("Demo provisioning failed: {error}", { error: String(error) }); } this.demoTimer = setInterval(() => { if (this.demoCleanup) return; @@ -294,7 +293,7 @@ export class Plugin extends BSBService, typeof Event }).finally(() => { this.demoCleanup = undefined; }); }, 60_000); this.demoTimer.unref(); - registerPairingRoutes(app, repo, auth, secrets, codeTtl, firmware, osUpdates, clientFirmwarePublicKey, demoReady); + registerPairingRoutes(app, repo, auth, secrets, codeTtl, firmware, osUpdates, clientFirmwarePublicKey, this.config.enableDemoTenant); registerKioskRoutes(app, repo, auth, secrets, nodered, firmware, osUpdates, mqtt, clientFirmwarePublicKey); registerIoBoxRoutes(app, repo, auth, nodered, mqtt, firmware, secrets); @@ -473,11 +472,11 @@ function registerPairingRoutes( secureClaim: body.secure_claim, }); - return { allowDemo: demoEnabled, code: result.code, expires_at: result.expiresAt, expires_in_seconds: result.expiresInSeconds, poll_after_ms: PAIR_POLL_AFTER_MS, polling_secret: result.pollingSecret }; + return { allowDemo: await demoAvailable(repo, demoEnabled), code: result.code, expires_at: result.expiresAt, expires_in_seconds: result.expiresInSeconds, poll_after_ms: PAIR_POLL_AFTER_MS, polling_secret: result.pollingSecret }; }); app.post("/api/pair/demo", async (event) => { - if (!demoEnabled) throw createError({ statusCode: 503, statusMessage: "Demo unavailable" }); + if (!await demoAvailable(repo, demoEnabled)) throw createError({ statusCode: 503, statusMessage: "Demo unavailable" }); const ip = getRequestHeader(event, "x-real-ip") ?? "anon"; if (!pairingGuard.take(`demo:${ip}`)) throw createError({ statusCode: 429, statusMessage: "rate limited" }); const raw = await readBody>(event); diff --git a/server/src/shared/demo.ts b/server/src/shared/demo.ts index 7721d700..69816bc0 100644 --- a/server/src/shared/demo.ts +++ b/server/src/shared/demo.ts @@ -14,6 +14,23 @@ export async function demoTenant(repo: Repository): Promise { }); } +/** Legacy customer tenants may also be named demo; only the stored ID is managed. */ +export async function demoTenantHidden(repo: Repository, enabled: boolean | undefined, tenant: Tenant): Promise { + if (enabled || tenant.slug !== DEMO_SLUG) return false; + return (await demoTenant(repo))?.id === tenant.id; +} + +export async function visibleTenants(repo: Repository, enabled: boolean | undefined): Promise { + const tenants = await repo.listTenants(); + if (enabled || !tenants.some(t => t.slug === DEMO_SLUG)) return tenants; + const hidden = await demoTenant(repo); + return tenants.filter(t => t.id !== hidden?.id); +} + +export async function demoAvailable(repo: Repository, enabled: boolean): Promise { + return enabled && (await demoTenant(repo))?.is_active === true; +} + export async function prepareDemo(repo: Repository, enabled: boolean): Promise { if (!enabled) return null; if (repo.adapter.dialect() !== "postgres") throw Error("Demo requires PostgreSQL tenant isolation"); @@ -42,12 +59,14 @@ export async function enrollDemo(repo: Repository, auth: AuthApi, secrets: Secre if (!tenant?.is_active) throw Error("Demo unavailable"); await repo.adapter.withSearchPath(tenant.schema_name, () => repo.transact(async () => { // Serialize capacity checks across API instances; pairing itself locks its code. - await repo.adapter.get("SELECT id FROM public.tenants WHERE id = ? FOR UPDATE", [tenant.id]); + const current = await repo.adapter.get<{ is_active: boolean; max_kiosks: number | null }>( + "SELECT is_active, max_kiosks FROM public.tenants WHERE id = ? FOR UPDATE", [tenant.id]); + if (!current?.is_active) throw Error("Demo unavailable"); const pc = await repo.getPairingCode(code, true); if (!pc || !matchesSecret(pollingSecret, pc.extras["polling_secret_hash"])) throw Error("Invalid demo pairing session"); - if (!pc.consumed_at && tenant.max_kiosks != null) { + if (!pc.consumed_at && current.max_kiosks != null) { const count = await repo.adapter.get<{ count: string }>("SELECT count(*) FROM kiosks"); - if (Number(count?.count) >= tenant.max_kiosks) throw Error("Demo capacity reached"); + if (Number(count?.count) >= current.max_kiosks) throw Error("Demo capacity reached"); } await confirmPairing(repo, auth, secrets, { code, demo: true, tenant: { id: tenant.id, slug: tenant.slug, schemaName: tenant.schema_name }, diff --git a/server/tests/demo-postgres.test.ts b/server/tests/demo-postgres.test.ts index 0f6537ea..00acadc9 100644 --- a/server/tests/demo-postgres.test.ts +++ b/server/tests/demo-postgres.test.ts @@ -3,7 +3,7 @@ import test from "node:test"; import { randomBytes } from "node:crypto"; import { PgAdapter } from "../src/shared/db/pg-adapter.js"; import { initDb } from "../src/shared/db/init.js"; -import { prepareDemo, demoTenant, enrollDemo, cleanupDemo } from "../src/shared/demo.js"; +import { prepareDemo, demoTenant, enrollDemo, cleanupDemo, demoAvailable, demoTenantHidden, visibleTenants } from "../src/shared/demo.js"; import { initiatePairing, confirmPairing, claimPairing } from "../src/shared/pairing.js"; const url = process.env["BF_TEST_PG_URL"]; @@ -22,8 +22,25 @@ test("demo enrollment, display defaults and bounded tenant-only cleanup", { skip try { assert.equal(await prepareDemo(repo, false), null); assert.equal(await demoTenant(repo), null); + const legacy = await repo.createTenant({ name: "Existing customer", slug: "demo" }); + assert.equal(await demoTenantHidden(repo, false, legacy), false); + assert.ok((await visibleTenants(repo, false)).some(t => t.id === legacy.id)); + await assert.rejects(prepareDemo(repo, true), /already in use/); + assert.equal(await demoAvailable(repo, true), false); + await repo.deleteTenant(legacy.id); const [tenant, again] = await Promise.all([prepareDemo(repo, true), prepareDemo(repo, true)]); assert.ok(tenant); assert.equal(again?.id, tenant.id); + assert.equal(await demoTenantHidden(repo, false, tenant), true); + assert.equal(await demoTenantHidden(repo, true, tenant), false); + assert.ok(!(await visibleTenants(repo, false)).some(t => t.id === tenant.id)); + assert.equal(await demoAvailable(repo, false), false); + assert.equal(await demoAvailable(repo, true), true); + await repo.updateTenant(tenant.id, { is_active: false }); + assert.equal(await demoAvailable(repo, true), false); + const disabled = await initiate(); + await assert.rejects(enrollDemo(repo, auth as never, secrets as never, true, disabled.code, disabled.pollingSecret), /unavailable/); + await repo.updateTenant(tenant.id, { is_active: true }); + assert.equal(await demoAvailable(repo, true), true); const settings = await repo.adapter.withSearchPath(tenant.schema_name, () => repo.getDisplayDefaults()); assert.equal(settings.layoutIds.length, 1); assert.equal(settings.defaultLayoutId, settings.layoutIds[0]); @@ -90,6 +107,8 @@ test("demo enrollment, display defaults and bounded tenant-only cleanup", { skip const counts = await Promise.all([cleanupDemo(repo, now), cleanupDemo(repo, now)]); assert.equal(counts.reduce((a, b) => a + b, 0), 1); assert.ok(await demoTenant(repo)); + await repo.deleteTenant(tenant.id); + assert.equal(await demoAvailable(repo, true), false); } finally { await opened.close(); await admin.exec(`DROP DATABASE "${name}" WITH (FORCE)`); diff --git a/server/tests/tenant-auth.test.ts b/server/tests/tenant-auth.test.ts index 6d2d263d..832d97a6 100644 --- a/server/tests/tenant-auth.test.ts +++ b/server/tests/tenant-auth.test.ts @@ -263,3 +263,35 @@ test("default-tenant queries use a scoped search path", async () => { assert.equal(await withDefaultTenant(repo as never, "tenant_site", async () => "ok"), "ok"); assert.deepEqual(calls, ["public"]); }); + +test("disabled demo hides only the managed tenant ID, including header and cookie selection", async () => { + const customer = { ...tenant, slug: "demo", name: "Existing customer" }; + let managedId: string | null = null; + const app = new H3(); + registerMiddleware(app, { + cookieName: "betterframe_session", + enableDemoTenant: false, + repo: { + adapter: { dialect: () => "postgres", withSearchPath: async (_schema: string, fn: () => unknown) => fn() }, + getTenantBySlug: async (slug: string) => slug === "demo" ? customer : defaultTenant, + getTenantById: async (id: string) => id === customer.id ? customer : null, + getSetupExtra: async () => managedId, + getUserByUsername: async () => admin, + isSetupComplete: async () => true, + }, + auth: { resolveSession: async () => ({ user: admin, session, tenant: defaultTenant }) }, + } as never); + app.get("/admin/demo-test", event => ({ tenantId: event.context.tenant?.id })); + for (const viaHeader of [false, true]) { + const headers: Record = { cookie: `betterframe_session=signed${viaHeader ? "" : "; bf_tenant=demo"}` }; + if (viaHeader) headers["x-betterframe-tenant"] = "demo"; + managedId = null; + const legacy = await app.request("http://betterframe.test/admin/demo-test", { headers }); + assert.equal(legacy.status, 200); + assert.deepEqual(await legacy.json(), { tenantId: customer.id }); + managedId = customer.id; + const managed = await app.request("http://betterframe.test/admin/demo-test", { headers }); + assert.equal(managed.status, 200); + assert.deepEqual(await managed.json(), { tenantId: defaultTenant.id }); + } +}); From 788a6e941bd7c9f6f308a2f6c7d09331e6df4a55 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:21:30 +0000 Subject: [PATCH 4/8] fix: reuse Linux demo control across content reloads --- client/src/platform/linux/ui.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/client/src/platform/linux/ui.rs b/client/src/platform/linux/ui.rs index 917dd907..eb23657e 100644 --- a/client/src/platform/linux/ui.rs +++ b/client/src/platform/linux/ui.rs @@ -1319,8 +1319,23 @@ fn parse_drm_mode(mode: &str) -> Option<(u32, u32)> { fn add_demo_control(overlay: >k::Overlay) { let exit = server::demo_mode(); - if !exit && server::demo_session().is_none() { return; } - let button = gtk::Button::with_label(if exit { "Exit demo" } else { "Demo" }); + let visible = exit || server::demo_session().is_some(); + let label = if exit { "Exit demo" } else { "Demo" }; + // Playback overlays survive bundle reloads. Keep a single control and its + // focus/signal handler instead of stacking another button on every refresh. + let mut child = overlay.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if widget.widget_name() != "betterframe-demo-control" { continue; } + if visible && widget.downcast_ref::() + .is_some_and(|button| button.label().as_deref() == Some(label)) { + return; + } + overlay.remove_overlay(&widget); + } + if !visible { return; } + let button = gtk::Button::with_label(label); + button.set_widget_name("betterframe-demo-control"); button.set_halign(gtk::Align::Start); button.set_valign(gtk::Align::Start); button.set_margin_top(12); From 7deaf001fb0ee2158132230ecdb77c31405a05d0 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:42:05 +0000 Subject: [PATCH 5/8] fix: advertise demo only when enrollment capacity remains --- server/src/plugins/service-api-http/index.ts | 4 +++- server/src/shared/demo.ts | 8 +++++++- server/tests/demo-postgres.test.ts | 11 +++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 9a9c9e76..631fc220 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -476,7 +476,9 @@ function registerPairingRoutes( }); app.post("/api/pair/demo", async (event) => { - if (!await demoAvailable(repo, demoEnabled)) throw createError({ statusCode: 503, statusMessage: "Demo unavailable" }); + // Capacity is enforced under the enrollment lock. An already confirmed + // session must remain retryable even if its kiosk filled the final slot. + if (!demoEnabled) throw createError({ statusCode: 503, statusMessage: "Demo unavailable" }); const ip = getRequestHeader(event, "x-real-ip") ?? "anon"; if (!pairingGuard.take(`demo:${ip}`)) throw createError({ statusCode: 429, statusMessage: "rate limited" }); const raw = await readBody>(event); diff --git a/server/src/shared/demo.ts b/server/src/shared/demo.ts index 69816bc0..bf943abb 100644 --- a/server/src/shared/demo.ts +++ b/server/src/shared/demo.ts @@ -28,7 +28,13 @@ export async function visibleTenants(repo: Repository, enabled: boolean | undefi } export async function demoAvailable(repo: Repository, enabled: boolean): Promise { - return enabled && (await demoTenant(repo))?.is_active === true; + if (!enabled) return false; + const tenant = await demoTenant(repo); + if (!tenant?.is_active) return false; + if (tenant.max_kiosks == null) return true; + const count = await repo.adapter.withSearchPath(tenant.schema_name, () => + repo.adapter.get<{ count: string }>("SELECT count(*) FROM kiosks")); + return Number(count?.count) < tenant.max_kiosks; } export async function prepareDemo(repo: Repository, enabled: boolean): Promise { diff --git a/server/tests/demo-postgres.test.ts b/server/tests/demo-postgres.test.ts index 00acadc9..06a2cedb 100644 --- a/server/tests/demo-postgres.test.ts +++ b/server/tests/demo-postgres.test.ts @@ -54,7 +54,15 @@ test("demo enrollment, display defaults and bounded tenant-only cleanup", { skip const enroll = (enabled = true, secret = session.pollingSecret) => enrollDemo(repo, auth as never, secrets as never, enabled, session.code, secret); await assert.rejects(enroll(false), /unavailable/); await assert.rejects(enroll(true, "forged-secret"), /Invalid/); + await repo.updateTenant(tenant.id, { max_kiosks: 1 }); + assert.equal(await demoAvailable(repo, true), true); await Promise.all([enroll(), enroll()]); + assert.equal(await demoAvailable(repo, true), false); + await enroll(); // A lost response can be retried even at capacity. + const overflow = await initiate(); + await assert.rejects(enrollDemo(repo, auth as never, secrets as never, true, overflow.code, overflow.pollingSecret), /capacity/); + await repo.updateTenant(tenant.id, { max_kiosks: null }); + assert.equal(await demoAvailable(repo, true), true); const claim = await claimPairing(repo, session.code, secrets as never, undefined, session.pollingSecret); assert.equal(claim.demo, true); assert.ok(claim.kioskId); assert.equal((await claimPairing(repo, session.code, secrets as never)).status, "failed"); @@ -91,7 +99,10 @@ test("demo enrollment, display defaults and bounded tenant-only cleanup", { skip await stamp(claim.kioskId!, 299999, null); assert.equal(await cleanupDemo(repo, now), 0); await stamp(claim.kioskId!, 300000, null); + await repo.updateTenant(tenant.id, { max_kiosks: 1 }); + assert.equal(await demoAvailable(repo, true), false); assert.equal(await cleanupDemo(repo, now), 1); + assert.equal(await demoAvailable(repo, true), true); assert.equal((await claimPairing(repo, session.code, secrets as never, undefined, session.pollingSecret)).status, "revoked"); assert.ok(await repo.getKioskById(normal.kioskId)); await repo.adapter.withSearchPath(tenant.schema_name, async () => { From 0ce6a02281a88b5fdd4ea0ae435ee1a7659a00be Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:59:41 +0000 Subject: [PATCH 6/8] fix: resume pending Windows demo exit before startup --- client/src/platform/windows/mod.rs | 12 ++--- client/src/platform/windows/renderer.rs | 2 + client/src/platform/windows/storage.rs | 61 +++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/client/src/platform/windows/mod.rs b/client/src/platform/windows/mod.rs index 46180400..bd000154 100644 --- a/client/src/platform/windows/mod.rs +++ b/client/src/platform/windows/mod.rs @@ -286,6 +286,9 @@ fn self_test() -> Result<(), String> { fn run_agent_cli(args: &[String]) -> Result<(), String> { loop { + // Finish a persisted local exit before discovery, pairing or rendering. + // The marker can outlive demo=true if a previous cleanup was interrupted. + complete_demo_exit()?; let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() @@ -306,12 +309,7 @@ fn run_agent_cli(args: &[String]) -> Result<(), String> { // Drop the runtime first: old heartbeat/bundle tasks must never restore cleared state. drop(rt); result?; - let origin = load_agent_state()?.server_url; - update_state(|latest| { *latest = unpaired_state(&origin); Ok(()) })?; - for path in [bundle_path(), state_dir().join("exit-demo")] { - if path.exists() { fs::remove_file(path).map_err(|e| e.to_string())?; } - } - if webview_data_dir().exists() { fs::remove_dir_all(webview_data_dir()).map_err(|e| e.to_string())?; } + } } @@ -473,7 +471,7 @@ async fn run_agent(server_url: String) -> Result<(), String> { } } _ = tokio::time::sleep(Duration::from_secs(2)) => { - if state_dir().join("exit-demo").exists() && state.lock().unwrap().demo { + if state_dir().join("exit-demo").exists() { if let Some(mut child) = app.lock().unwrap().take() { let _ = child.kill(); let _ = child.wait(); } diff --git a/client/src/platform/windows/renderer.rs b/client/src/platform/windows/renderer.rs index 0a5ec85f..b89ba81f 100644 --- a/client/src/platform/windows/renderer.rs +++ b/client/src/platform/windows/renderer.rs @@ -8,6 +8,8 @@ use windows_sys::Win32::Graphics::Gdi::DT_WORDBREAK; use super::*; pub(super) fn run_app() -> Result<(), String> { + // The agent owns cleanup; do not reopen the browser profile during recovery. + if state_dir().join("exit-demo").exists() { return Ok(()); } let Some(_instance) = acquire_app_instance()? else { info!("BetterFrame renderer is already running"); return Ok(()); diff --git a/client/src/platform/windows/storage.rs b/client/src/platform/windows/storage.rs index 79a96947..179f11ff 100644 --- a/client/src/platform/windows/storage.rs +++ b/client/src/platform/windows/storage.rs @@ -172,6 +172,26 @@ fn update_state_file( Ok(state) } +pub(super) fn complete_demo_exit() -> Result<(), String> { + ensure_secure_state_dir()?; + complete_demo_exit_at(&state_dir(), &webview_data_dir()) +} + +fn complete_demo_exit_at(directory: &std::path::Path, browser: &std::path::Path) -> Result<(), String> { + let marker = directory.join("exit-demo"); + if !marker.exists() { return Ok(()); } + update_state_file(&directory.join("state.json"), |latest| { + *latest = ClientState::unpaired(&latest.server_url); + Ok(()) + })?; + let bundle = directory.join("bundle.json"); + if bundle.exists() { fs::remove_file(bundle).map_err(|error| error.to_string())?; } + if browser.exists() { fs::remove_dir_all(browser).map_err(|error| error.to_string())?; } + // Commit the exit last. A crash or cleanup error leaves a retryable journal, + // regardless of whether the credentials have already been cleared. + fs::remove_file(marker).map_err(|error| error.to_string()) +} + pub(super) fn load_bundle() -> Option { ensure_secure_state_dir() .and_then(|()| read_protected_or_plain(&bundle_path())) @@ -729,6 +749,47 @@ mod tests { } } + #[test] + fn interrupted_demo_exit_finishes_before_a_fresh_enrollment() { + let directory = std::env::temp_dir().join(format!("bf-demo-exit-{}", std::process::id())); + fs::create_dir_all(&directory).unwrap(); + let state = directory.join("state.json"); + let marker = directory.join("exit-demo"); + let browser = directory.join("browser"); + update_state_file(&state, |latest| { + *latest = ClientState::unpaired("https://frame.example"); + latest.demo = true; + latest.kiosk_key = Some("old-demo-key".into()); + Ok(()) + }).unwrap(); + fs::write(&marker, b"exit").unwrap(); + fs::write(directory.join("bundle.json"), b"cached-demo-content").unwrap(); + // A non-directory browser path injects a cleanup error after state reset. + fs::write(&browser, b"blocked-browser-cleanup").unwrap(); + assert!(complete_demo_exit_at(&directory, &browser).is_err()); + assert!(marker.exists()); + let cleared = load_state_file(&state).unwrap(); + assert!(!cleared.demo); + assert!(cleared.kiosk_key.is_none()); + assert_eq!(cleared.server_url, "https://frame.example"); + fs::remove_file(&browser).unwrap(); + fs::create_dir(&browser).unwrap(); + fs::write(browser.join("cookies"), b"old-session").unwrap(); + // Restart resumes the journal even though demo is already false. + complete_demo_exit_at(&directory, &browser).unwrap(); + assert!(!marker.exists()); + assert!(!browser.exists()); + assert!(!directory.join("bundle.json").exists()); + update_state_file(&state, |latest| { + latest.demo = true; + latest.kiosk_key = Some("new-demo-key".into()); + Ok(()) + }).unwrap(); + complete_demo_exit_at(&directory, &browser).unwrap(); + assert_eq!(load_state_file(&state).unwrap().kiosk_key.as_deref(), Some("new-demo-key")); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn migrates_previous_windows_bundle_cache() { let bundle = deserialize_cached_bundle( From a54945d7cdc49fde9db6f9595e6e2faf491331bf Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sun, 20 Sep 2026 04:16:30 +0000 Subject: [PATCH 7/8] fix: preserve demo control focus during pairing polls --- .../cloud/betterportal/frame/MainActivity.kt | 11 ++++++--- client/src/platform/linux/ui.rs | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/client/android/app/src/main/java/cloud/betterportal/frame/MainActivity.kt b/client/android/app/src/main/java/cloud/betterportal/frame/MainActivity.kt index 35eb9988..8fbc42ca 100644 --- a/client/android/app/src/main/java/cloud/betterportal/frame/MainActivity.kt +++ b/client/android/app/src/main/java/cloud/betterportal/frame/MainActivity.kt @@ -151,10 +151,15 @@ class MainActivity : Activity(), ViewerSession.Listener { private fun kioskRoot() = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) } private fun updateDemoButton() { - root.findViewWithTag("demo-control")?.let { root.removeView(it) } + val existing = root.findViewWithTag