From dba06b63dbc8694591c193ac13dad42cd54aa580 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Tue, 26 May 2026 02:40:46 +0200 Subject: [PATCH] fix: require() to dynamic import() in ESM + event subscriptions routes-os-updates.ts used require() which fails in ESM. Changed to dynamic import(). Also includes persistent event topic subscriptions with status tracking (inactive/pending/active/failed), merge-only refresh, and colored status dots in camera detail UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- BSB_LEARNINGS.md | 203 ++++++++++++++++++ .../service-admin-http/routes-os-updates.ts | 4 +- server/src/shared/db/MULTI_TENANT.md | 33 +++ 3 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 BSB_LEARNINGS.md create mode 100644 server/src/shared/db/MULTI_TENANT.md 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/server/src/plugins/service-admin-http/routes-os-updates.ts b/server/src/plugins/service-admin-http/routes-os-updates.ts index db3b2a8..ca60bc7 100644 --- a/server/src/plugins/service-admin-http/routes-os-updates.ts +++ b/server/src/plugins/service-admin-http/routes-os-updates.ts @@ -64,9 +64,9 @@ export function registerOsUpdateRoutes(app: H3, deps: AdminDeps): void { }); // Push OS update now: server pings the kiosk via WS coordinator. - app.post("/admin/kiosks/:id/os-update/push", (event) => { + app.post("/admin/kiosks/:id/os-update/push", async (event) => { const id = Number(getRouterParam(event, "id")); - const { getCoordinator } = require("../../shared/coordinator-registry.js"); + const { getCoordinator } = await import("../../shared/coordinator-registry.js"); const dispatched = getCoordinator().sendToKiosk(id, { type: "os_check" }); return { ok: true, dispatched }; }); 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