diff --git a/BSB_LEARNINGS.md b/BSB_LEARNINGS.md new file mode 100644 index 0000000..3094fb8 --- /dev/null +++ b/BSB_LEARNINGS.md @@ -0,0 +1,203 @@ +# BSB Framework — Practical Learnings + +Lessons learned building BetterFrame on BSB v9. Intended for updating +bsbcode.dev/llms.txt so future LLM sessions don't repeat these mistakes. + +--- + +## Container Runtime + +### Base Image +- Image: `betterweb/service-base:9` (Docker Hub) +- Alpine-based (use `apk` not `apt-get`) +- Entrypoint: `/root/entrypoint.sh` — runs as root, drops to unprivileged user +- **DO NOT** set `USER` in child Dockerfiles — BSB entrypoint handles privilege dropping +- **DO NOT** override `CMD` or `ENTRYPOINT` — BSB handles startup + +### Production Environment +- Set `ENV NODE_ENV=production` and `ENV BSB_LIVE=true` in Dockerfile +- Without `BSB_LIVE=true`, BSB warns about non-production and tries to write sec-config.yaml + +### Plugin Discovery +- BSB searches: `/home/bsb/node_modules//lib/plugins/-/index.js` +- Also searches: `/mnt/bsb-plugins/node_modules/...` but this is a VOLUME — build-time COPY gets shadowed by empty anonymous volume at runtime +- **USE `/home/bsb/node_modules/`** for build-time plugin installation +- Working directory is `/home/bsb` + +### Plugin Registration in Config +```yaml +services: + service-my-plugin: + package: my-npm-package-name # <-- REQUIRED for container mode + plugin: service-my-plugin + enabled: true + config: {} +``` +Without `package:`, BSB only finds built-in plugins (config-default, events-default, log-default). + +The `package` value must match the `name` field in the plugin's `package.json`. + +--- + +## Config Schema + +### Schema Extractor Limitations +- `bsb-plugin-cli build` extracts config schemas from TypeScript source **statically** +- **Cannot resolve cross-file imports** — imported schema variables show as "not defined" +- Workaround: inline anyvali schema definitions in each plugin's ConfigSchema +- Type-only imports (`import type { ... }`) are fine — only runtime value imports break extraction + +```typescript +// BAD — schema extractor can't resolve this: +import { dbConfigSchema } from "../../shared/db/config.js"; +const ConfigSchema = av.object({ db: dbConfigSchema, ... }); + +// GOOD — inline the schema: +const ConfigSchema = av.object({ + db: av.object({ + driver: av.enum_(["sqlite", "postgres"] as const).default("postgres"), + host: av.string().default("postgres"), + // ... + }, { unknownKeys: "strip" }), + // ... +}); +``` + +### Nested Config Objects +- Config supports nested `av.object()` — use for grouping related fields: +```yaml +config: + db: + driver: postgres + host: postgres + host: 0.0.0.0 + port: 18080 +``` +- Access via `this.config.db.driver`, `this.config.host` + +### Config Defaults +- BSB does NOT apply anyvali schema defaults for keys missing from sec-config.yaml +- Always declare config values explicitly in sec-config.yaml +- Defaults in the schema are documentation, not runtime fallbacks + +### No Environment Variable Access +- Application code should **never** read `process.env` +- All config comes from sec-config.yaml via `this.config.*` +- For Docker/Coolify: use build-time `envsubst` on a template to generate sec-config.yaml +- sec-config.yaml should be baked into the image or bind-mounted — not generated at runtime + +--- + +## Plugin Architecture + +### What Should Be a Plugin +- Services with own port, lifecycle, independent scaling +- Examples: HTTP server, WebSocket server, API server + +### What Should NOT Be a Plugin +- Database access layer (just a library — no port, no lifecycle) +- Shared utilities, crypto helpers, auth logic +- Anything that's just a function other plugins call + +### Cross-Plugin Communication +- Plugins should have **zero runtime imports** from other plugins +- Type-only imports (`import type { ... }`) are acceptable +- Communication between plugins: BSB event bus (emitBroadcast, emitReturnableEvent) +- Shared code goes in `src/shared/` — imported by multiple plugins as a library + +### Plugin Init Order +- `initAfterPlugins` / `initBeforePlugins` control order +- Not needed when plugins are independent (each inits own DB, etc.) + +--- + +## Build System + +### Build Command +```bash +cross-env NODE_OPTIONS="--import tsx" bsb-plugin-cli build +``` +- BSB build needs `tsx` for schema extraction from TypeScript source +- Build output: `lib/` directory with compiled JS + `bsb-plugin.json` + +### bsb-plugin.json +- Auto-generated by build, lists all discovered plugins +- Must match the plugins present in `src/plugins/` +- If a plugin is removed, this file must be regenerated + +### Dev Mode +```bash +cross-env NODE_OPTIONS="--import tsx" bsb-plugin-cli dev +``` +- Hot reload, runs TypeScript directly +- Creates `.bsbdevwatch` file for include/exclude patterns + +--- + +## Logging + +### Log Message Format +- `obs.log.info("text {tag}", { tag: value })` — structured logging +- Message strings **MUST be string literals** (BSB SmartLogMeta extracts placeholders from literal type) +- String concatenation (`"a " + "b"`) widens to `string` and breaks SmartLogMeta placeholder extraction +- Template literals in log messages work for the value, not the message key + +```typescript +// BAD: +obs.log.info("connecting to " + url, {}); + +// GOOD: +obs.log.info("connecting to {url}", { url }); +``` + +--- + +## Docker Deployment Pattern + +### Dockerfile Structure +```dockerfile +# Builder — compile TypeScript + native deps +FROM node:24-trixie-slim AS builder +WORKDIR /app +# ... npm ci, npm run build ... + +# Runtime — BSB container +FROM betterweb/service-base:9 + +# Install extras (Alpine) +RUN apk add --no-cache gettext ffmpeg + +# Copy built plugin into BSB's node_modules +COPY --from=builder /app/server/package.json /home/bsb/node_modules//package.json +COPY --from=builder /app/server/bsb-plugin.json /home/bsb/node_modules//bsb-plugin.json +COPY --from=builder /app/server/lib /home/bsb/node_modules//lib +COPY --from=builder /app/node_modules /home/bsb/node_modules//node_modules + +# Generate config from template +COPY sec-config.template.yaml /tmp/sec-config.template.yaml +RUN envsubst < /tmp/sec-config.template.yaml > /home/bsb/sec-config.yaml \ + && chmod 444 /home/bsb/sec-config.yaml \ + && rm /tmp/sec-config.template.yaml + +ENV NODE_ENV=production +ENV BSB_LIVE=true +``` + +### sec-config.template.yaml Pattern +- Template with `${VAR}` placeholders (NOT `${VAR:-default}` — envsubst doesn't support defaults) +- Defaults come from Dockerfile `ARG` declarations +- Secrets set as Coolify build args (not in git) +- Template committed to public repo (safe — no secrets) +- Generated sec-config.yaml baked into image at build time + +--- + +## Common Pitfalls + +1. **`betterweb/service-base:node` is v8, `:9` is v9** — use the correct tag +2. **VOLUME declarations in base image shadow build-time COPY** — don't write to `/mnt/bsb-plugins` +3. **`USER node` blocks BSB entrypoint** — entrypoint is at `/root/entrypoint.sh`, needs root access +4. **Schema extractor can't follow imports** — inline schemas in plugin ConfigSchema +5. **`package:` required in sec-config** — without it, BSB won't find external plugins +6. **Alpine = apk, not apt-get** — base image is Alpine Linux +7. **sec-config.yaml must be writable in dev, read-only in prod** — BSB_LIVE=true skips write attempts diff --git a/kiosk/src/firmware.rs b/kiosk/src/firmware.rs index 712a3b6..3f03d06 100644 --- a/kiosk/src/firmware.rs +++ b/kiosk/src/firmware.rs @@ -65,6 +65,73 @@ pub struct UpdateInfo { pub public_key_pem: String, } +/// Public pre-boot firmware check — no auth needed. Always checks stable +/// channel. Used before pairing to self-update to latest binary. +pub fn check_public(server: &str, current_version: &str) -> Option { + let url = format!( + "{server}/api/firmware/public/check?arch={arch}¤t={cur}", + arch = ARCH, + cur = current_version, + ); + let client = reqwest::blocking::Client::new(); + let resp = match client.get(&url).timeout(Duration::from_secs(10)).send() { + Ok(r) => r, + Err(err) => { warn!("preboot firmware check: {err}"); return None; } + }; + if !resp.status().is_success() { return None; } + match resp.json::() { + Ok(c) if !c.up_to_date => c.update, + _ => None, + } +} + +/// Public download + verify + swap — no auth. Used with check_public. +/// On success exits so systemd restarts with new binary. +pub fn apply_public(server: &str, info: &UpdateInfo) -> Result<(), String> { + info!("preboot firmware: applying {} ({} bytes)", info.version, info.size_bytes); + let download_url = format!("{server}{}", info.download_url); + let client = reqwest::blocking::Client::new(); + let resp = client.get(&download_url) + .timeout(Duration::from_secs(300)) + .send() + .map_err(|e| format!("download failed: {e}"))?; + if !resp.status().is_success() { + return Err(format!("download HTTP {}", resp.status())); + } + let bytes = resp.bytes().map_err(|e| format!("read failed: {e}"))?; + if bytes.len() as u64 != info.size_bytes { + return Err(format!("size mismatch: expected {}, got {}", info.size_bytes, bytes.len())); + } + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let got_sha = hex_lower(&hasher.finalize()); + if got_sha != info.sha256 { + return Err(format!("sha256 mismatch: expected {}, got {}", info.sha256, got_sha)); + } + verify_signature(&info.public_key_pem, &info.sha256, &info.signature) + .map_err(|e| format!("signature verify: {e}"))?; + + let bin = binary_path(); + let new_path = bin.with_extension("new"); + let prev_path = bin.with_extension("prev"); + { + use std::os::unix::fs::OpenOptionsExt; + let mut f = fs::OpenOptions::new() + .create(true).write(true).truncate(true).mode(0o755) + .open(&new_path) + .map_err(|e| format!("open {}: {e}", new_path.display()))?; + use std::io::Write; + f.write_all(&bytes).map_err(|e| format!("write: {e}"))?; + } + if bin.exists() { + let _ = fs::remove_file(&prev_path); + let _ = fs::rename(&bin, &prev_path); + } + fs::rename(&new_path, &bin).map_err(|e| format!("rename: {e}"))?; + info!("preboot firmware: updated to {}, exiting for restart", info.version); + std::process::exit(0); +} + /// Hit `/api/kiosk/firmware/check` and return the update info if one is /// available. Returns `None` on up-to-date / network error / unparsable /// response — never panics. diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index f74fe13..fb0d0fd 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -17,7 +17,7 @@ pub struct DisplayReport { pub power_state: String, } -fn kiosk_app_version() -> &'static str { +pub fn kiosk_app_version() -> &'static str { option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")) } diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 52c4e3a..ced0fb9 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -141,6 +141,19 @@ fn activate(app: &Application) { let server = server::discover_server(server_url.as_deref()); info!("server: {server}"); + // Pre-boot self-update: check for stable firmware before pairing. + // If an update is available, download + swap + exit. systemd restarts + // with the new binary which re-enters this flow. + if !server::is_paired() { + let current = crate::server::kiosk_app_version(); + if let Some(update) = crate::firmware::check_public(&server, current) { + info!("preboot update available: {} → {}", current, update.version); + if let Err(e) = crate::firmware::apply_public(&server, &update) { + tracing::warn!("preboot update failed: {e}"); + } + } + } + let key = if server::is_paired() { info!("already paired"); server::load_key() diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 8568e6c..2346638 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -223,7 +223,7 @@ export class Plugin extends BSBService, typeof Event }); }); - registerPairingRoutes(app, repo, auth, secrets, codeTtl); + registerPairingRoutes(app, repo, auth, secrets, codeTtl, firmware); registerKioskRoutes(app, repo, auth, secrets, nodered, firmware, osUpdates, mqtt); this.server = serve(app, { @@ -279,6 +279,7 @@ function registerPairingRoutes( auth: AuthApi, secrets: SecretsApi, codeTtl: number, + firmware: FirmwareApi, ): void { // Constructed in-function so the BSB schema extractor (which evaluates the // module statically) doesn't see a top-level createRateLimiter call. @@ -346,6 +347,60 @@ function registerPairingRoutes( bundle_url: result.bundleUrl, }; }); + + // Public firmware check — no auth. Used by kiosks on first boot before + // pairing to self-update to latest stable binary. Always stable channel. + app.get("/api/firmware/public/check", async (event) => { + const url = new URL(event.req.url); + const arch = url.searchParams.get("arch")?.trim(); + if (!arch) throw createError({ statusCode: 400, statusMessage: "arch required" }); + const current = url.searchParams.get("current")?.trim() ?? ""; + + const release = await repo.getLatestFirmwareRelease("stable", arch); + if (!release || release.version === current) { + return { up_to_date: true }; + } + + return { + up_to_date: false, + update: { + release_id: release.id, + version: release.version, + sha256: release.sha256, + signature: release.signature, + size_bytes: release.size_bytes, + download_url: `/api/firmware/public/download/${release.id}`, + public_key_pem: firmware.publicKeyPem(), + }, + }; + }); + + // Public firmware download — no auth. Rate-limited to prevent abuse. + const publicDlGuard = createRateLimiter({ windowMs: 60_000, max: 5 }); + app.get("/api/firmware/public/download/:id", async (event) => { + const ip = getRequestHeader(event, "x-real-ip") + ?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim() + ?? "anon"; + if (!publicDlGuard.take(`fwdl:${ip}`)) { + throw createError({ statusCode: 429, statusMessage: "rate limited" }); + } + + const id = getRouterParam(event, "id") ?? ""; + const release = await repo.getFirmwareRelease(id); + if (!release || release.yanked_at) { + throw createError({ statusCode: 404, statusMessage: "release not found" }); + } + + const buf = await firmware.readBlob(release.artifact_path, release.sha256); + return new Response(buf, { + headers: { + "content-type": "application/octet-stream", + "content-length": String(buf.length), + "x-bf-sha256": release.sha256, + "x-bf-signature": release.signature, + }, + }); + }); } // ---- Kiosk routes (require Bearer kiosk key) -------------------------------- diff --git a/server/src/shared/db/MULTI_TENANT.md b/server/src/shared/db/MULTI_TENANT.md new file mode 100644 index 0000000..878a687 --- /dev/null +++ b/server/src/shared/db/MULTI_TENANT.md @@ -0,0 +1,33 @@ +# Multi-Tenant Architecture + +## Current Design + +- **Single admin user** — global admin, full access to all tenants +- **No per-tenant logins** — one admin manages everything +- **Tenant = data isolation boundary** — each tenant gets its own PG schema +- **Admin switches tenants** via dropdown in topbar (session-stored) +- **User management deferred** — if/when we want per-tenant user logins, that's a separate feature + +## How It Works + +1. `PUBLIC_MIGRATIONS` create `tenants` + `global_admins` tables in `public` schema +2. Each tenant gets a PG schema: `tenant_` (e.g. `tenant_acme`) +3. `TENANT_MIGRATIONS` run inside each tenant schema (full table set per tenant) +4. Admin creates tenants from the admin UI +5. Middleware sets `search_path = tenant_` per request based on selected tenant +6. All repo queries automatically scope to the active tenant's schema + +## What's NOT Happening + +- No per-tenant admin users (single global admin for now) +- No tenant-specific auth (global session, tenant is just a context switch) +- No tenant billing/limits enforcement (max_kiosks/max_cameras columns exist but unenforced) +- No tenant API keys (all API keys are global) + +## Future: Per-Tenant Users + +When needed, add: +- Per-tenant `users` table (already in TENANT_MIGRATIONS) +- Login scoped to tenant (tenant slug in login URL or selection) +- Role-based access per tenant +- Separate from global admin