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.
This commit is contained in:
Mitchell R 2026-05-14 07:49:57 +02:00
parent 17f8c7ce02
commit 150972a272
4 changed files with 54 additions and 9 deletions

View file

@ -1,5 +1,49 @@
# BetterFrame deployment # 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://<server-host>`).
## Recommended: Docker services + native kiosk ## Recommended: Docker services + native kiosk
Run server, Angie/nginx, and Node-RED in Docker Compose. Only Angie publishes a Run server, Angie/nginx, and Node-RED in Docker Compose. Only Angie publishes a

View file

@ -1429,6 +1429,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const id = Number(getRouterParam(event, "id")); const id = Number(getRouterParam(event, "id"));
getCoordinator().sendToKiosk(id, { type: "standby" }); getCoordinator().sendToKiosk(id, { type: "standby" });
emitDisplayPower(id, "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}` } }); 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")); const id = Number(getRouterParam(event, "id"));
getCoordinator().sendToKiosk(id, { type: "wake" }); getCoordinator().sendToKiosk(id, { type: "wake" });
emitDisplayPower(id, "on"); 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}` } }); return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
}); });

View file

@ -8,12 +8,12 @@ import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-page
import { audit } from "../../shared/audit.js"; import { audit } from "../../shared/audit.js";
import { createRateLimiter } from "../../shared/rate-limit.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 { 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 ---------------------------------------------------------------- // ---- Login ----------------------------------------------------------------
app.get("/auth/login", (event) => { app.get("/auth/login", (event) => {

View file

@ -26,11 +26,6 @@ import { envStr } from "../../shared/env-overrides.js";
import { createRateLimiter } from "../../shared/rate-limit.js"; import { createRateLimiter } from "../../shared/rate-limit.js";
import { initMqttBridge, type MqttBridge } from "../../shared/mqtt-bridge.js"; import { initMqttBridge, type MqttBridge } from "../../shared/mqtt-bridge.js";
import { createHash } from "node:crypto"; 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 { Repository } from "../service-store/repository.js";
import type { AuthApi } from "../../shared/auth.js"; import type { AuthApi } from "../../shared/auth.js";
import type { SecretsApi } from "../../shared/secrets.js"; import type { SecretsApi } from "../../shared/secrets.js";
@ -203,6 +198,10 @@ function registerPairingRoutes(
secrets: SecretsApi, secrets: SecretsApi,
codeTtl: number, codeTtl: number,
): void { ): 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 // Kiosk initiates pairing — no auth required
app.post("/api/pair/initiate", async (event) => { app.post("/api/pair/initiate", async (event) => {
const ip = getRequestHeader(event, "x-real-ip") const ip = getRequestHeader(event, "x-real-ip")