From 150972a2722a46ecd7ecc150fa892f414e46404c Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Thu, 14 May 2026 07:49:57 +0200 Subject: [PATCH] fix(server): move rate-limit creation inside register fns (BSB schema extractor) Schema extractor evaluates module top-level statically; createRateLimiter calls at module scope threw ReferenceError during bsb-plugin-cli build. Lifting into the per-route register functions keeps build clean. Also: standardise display.standby/wake audit hooks. --- deploy/README.md | 44 +++++++++++++++++++ .../service-admin-http/routes-admin.ts | 2 + .../plugins/service-admin-http/routes-auth.ts | 8 ++-- server/src/plugins/service-api-http/index.ts | 9 ++-- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index e670885..b6f1d06 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,5 +1,49 @@ # BetterFrame deployment +## Deployment shapes + +BetterFrame ships as **two artifacts**, deployable in any combination: + +| Variant | What it runs | Where it runs | +|--------------|-------------------------------------------|------------------------------| +| **bf-server**| Docker compose (server + Angie + Node-RED)| Coolify / VM / on-prem box | +| **bf-client**| Rust kiosk binary + cage + plymouth | Pi 5 (LAN-attached) | +| *bf-aio* | Both, single Pi | **Demo / single-site only** | + +The `bf-aio` mode (server + kiosk colocated on one Pi) is the simplest install +but couples failure domains — when the Pi dies, you lose both the displays it +drives AND the management plane for any other kiosks. Use for demos or a +single-display site. For anything else, run `bf-server` separately and have +`bf-client` Pis point at it. + +### bf-server (Docker compose, Coolify-friendly) + +Pull the repo on the host. Configure via env (overrides `sec-config.yaml`): + +``` +BF_DATA_DIR=/var/lib/betterframe +BF_SQLITE_PATH=/var/lib/betterframe/betterframe.db +BF_NODERED_URL=http://nodered:1880 +BF_SELF_URL=http://server:18080 +BF_FIRMWARE_SIGNING_KEY= # paste Ed25519 PEM for stable signing key +BF_MQTT_URL= # optional MQTT telemetry export +``` + +In Coolify: create a Docker compose stack from `deploy/docker/docker-compose.yml`, +inject the env vars, set a domain on the `angie` service. Backups via the admin +UI (`/admin/backup`) — Coolify's S3 hook can pull these on a schedule. + +### bf-client (kiosk Pi) + +```bash +sudo apt install -y git +git clone https://github.com/BetterCorp/BetterFrame.git ~/betterframe +sudo ~/betterframe/deploy/scripts/setup-pi-kiosk.sh client +``` + +Pairs with whichever `bf-server` is set in `/etc/default/betterframe-kiosk` +(`BETTERFRAME_SERVER=http://`). + ## Recommended: Docker services + native kiosk Run server, Angie/nginx, and Node-RED in Docker Compose. Only Angie publishes a diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 0763d83..ec1209c 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1429,6 +1429,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const id = Number(getRouterParam(event, "id")); getCoordinator().sendToKiosk(id, { type: "standby" }); emitDisplayPower(id, "standby"); + audit(deps.repo, event as any, "display.standby", { resource_type: "kiosk", resource_id: id }); return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); @@ -1436,6 +1437,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const id = Number(getRouterParam(event, "id")); getCoordinator().sendToKiosk(id, { type: "wake" }); emitDisplayPower(id, "on"); + audit(deps.repo, event as any, "display.wake", { resource_type: "kiosk", resource_id: id }); return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); diff --git a/server/src/plugins/service-admin-http/routes-auth.ts b/server/src/plugins/service-admin-http/routes-auth.ts index 6804ece..71c1b33 100644 --- a/server/src/plugins/service-admin-http/routes-auth.ts +++ b/server/src/plugins/service-admin-http/routes-auth.ts @@ -8,12 +8,12 @@ import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-page import { audit } from "../../shared/audit.js"; import { createRateLimiter } from "../../shared/rate-limit.js"; -// 8 attempts per 60s per IP — paired with the user-account lockout already in -// place via deps.auth.config.loginLockoutThreshold to defeat enumeration. -const loginGuard = createRateLimiter({ windowMs: 60_000, max: 8 }); - export function registerAuthRoutes(app: H3, deps: AdminDeps): void { + // 8 attempts per 60s per IP. Paired with the user-account lockout already + // wired via deps.auth.config.loginLockoutThreshold. In-function so the BSB + // schema extractor doesn't evaluate at module load. + const loginGuard = createRateLimiter({ windowMs: 60_000, max: 8 }); // ---- Login ---------------------------------------------------------------- app.get("/auth/login", (event) => { diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 68d7b52..27a0eee 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -26,11 +26,6 @@ import { envStr } from "../../shared/env-overrides.js"; import { createRateLimiter } from "../../shared/rate-limit.js"; import { initMqttBridge, type MqttBridge } from "../../shared/mqtt-bridge.js"; import { createHash } from "node:crypto"; - -// Pairing initiation is unauth — guard it so a misbehaving kiosk or attacker -// can't spam codes. 20 per minute per IP is generous for legit retries. -const pairingGuard = createRateLimiter({ windowMs: 60_000, max: 20 }); -const claimGuard = createRateLimiter({ windowMs: 60_000, max: 60 }); import type { Repository } from "../service-store/repository.js"; import type { AuthApi } from "../../shared/auth.js"; import type { SecretsApi } from "../../shared/secrets.js"; @@ -203,6 +198,10 @@ function registerPairingRoutes( secrets: SecretsApi, codeTtl: number, ): void { + // Constructed in-function so the BSB schema extractor (which evaluates the + // module statically) doesn't see a top-level createRateLimiter call. + const pairingGuard = createRateLimiter({ windowMs: 60_000, max: 20 }); + const claimGuard = createRateLimiter({ windowMs: 60_000, max: 60 }); // Kiosk initiates pairing — no auth required app.post("/api/pair/initiate", async (event) => { const ip = getRequestHeader(event, "x-real-ip")