BetterFrame/CLAUDE.md
Mitchell R 820e0a5945
fix(proxy): split Node-RED route surfaces
Route backend, kiosk ingest, kiosk dashboards, and public Node-RED HTTP-in separately. Keep Node-RED editor under admin auth and attach kiosk auth when kiosk loads protected dashboard URLs.
2026-05-11 10:44:45 +02:00

21 KiB
Raw Blame History

BetterFrame — handoff

Format: dense, structured. Update on every meaningful decision. A future Claude reading this fresh should be able to continue without re-deciding. Updated: 2026-05-08

product

  • name: BetterFrame · multi-camera display system, Pi-5-first
  • org: BetterCorp (user-affiliated). Prefer their packages where applicable.
  • env prefix BETTERFRAME_* · cookie betterframe_session · api-key prefix bf-
  • headers X-BetterFrame-* · totp issuer BetterFrame
  • db betterframe.db · paths /var/lib/betterframe, /etc/betterframe

goals

  • ≤32 cams/display
  • mixed cells: camera | web | html
  • layout swap = zero perceived latency (driving constraint → kiosk needs decoder pool)
  • camera config: raw rtsp OR onvif (auto-discover)
  • API-key admin API + username/pw+TOTP admin UI
  • single-host AND distributed installs from same artifact
  • offline-tolerant: kiosk runs cached bundle if server down

arch (current)

[cameras]─RTSP→[kiosk: rust+gtk4+gstreamer; cec/gpio/onvif-sub local]
                       ↑ws (port 18082) + bundle pull (port 18081)
[admin]─https→[angie proxy]
                ├→/admin/* /api/* /s/*  → service-admin-http (h3, port 18080)
                ├→/api/kiosk/* /api/pair/* → service-api-http (h3, port 18081)
                ├→/ws/kiosk           → service-coordinator-ws (port 18082)
                ├→/nrdp/* /dash/*      → node-red (1880)
                ├→/in/public/*         → node-red (rate-limited)
                └→/in/kiosk/*          → node-red (kiosk-key gated)
              ↓
        [BSB runtime] (Node.js ≥23) — sec-config.yaml drives everything
        Plugins:
          service-store         (sqlite, single writer; node:sqlite built-in)
          service-secrets       (fernet-equivalent + cluster key)
          service-auth          (passwords/totp/sessions/api-keys)
          service-admin-http    (h3 listener, htmx admin UI via jsx-htmx)
          service-api-http      (h3 listener, kiosk-facing REST)
          service-coordinator-ws (ws server for live kiosk channel)
          service-pairing       (8-char code state machine)
          service-bundle        (label-aware bundle generation)
          service-nodered-bridge (HTTP forwarder both ways)
          service-cec-relay     (commands → authoritative kiosk)
        ↕ BSB event bus (events-default in-memory; swap to RabbitMQ later if needed)

Route correction: /nrdp/* is the admin-auth Node-RED editor, /dash/* is kiosk-auth Node-RED dashboard content, /in/kiosk/* is kiosk-auth ingest (ONVIF/GPIO/etc.), and otherwise-unmatched root paths are public Node-RED HTTP-in endpoints for user webhooks/actions.

stack & languages

  • TS / Node.js ≥23 — server. BSB v9 framework. h3 v2 for HTTP. node:sqlite built-in. argon2 + otpauth for crypto/totp. jsx-htmx for SSR.
  • TS — Node-RED custom nodes (bf-*).
  • Browser JS (ESM) — htmx + tiny anyvali-driven form helper. Vendored, no build step.
  • Rust — kiosk app. GTK4 + GStreamer. Imports anyvali schemas exported from server.
  • Bash — vendor scripts, eventually a bf CLI.

key decisions (chronological + WHY)

  1. kiosk = native rust+gtk4+gstreamer. pi5 chromium has no hw h264 decode; we own decoder pool. zero-latency swap needs warm pipelines.
  2. stream warmth: hot/warm/cooling/cold per (cam,stream-type). layouts declare needed+preload. cooling timeout per layout AND cell. priority:hot always warm.
  3. stream selection: cells ≥20% display→main, else sub. per-cam stream_policy: auto|always_main|always_sub. per-cell selector overrides.
  4. layout templates = 12×12 named regions; layouts bind cells→region names.
  5. two timeouts: idle (revert to default layout) vs sleep (CEC standby). per-layout resets_idle_timer:bool.
  6. CEC via cec-ctl (v4l-utils), NOT libcec. async subprocess. kiosk side (kiosk owns hdmi).
  7. multi-display ready but v1 = index 0. all api carries display_id.
  8. node-red owns ALL rules. dropped the py engine plan. event_log table kept.
  9. shortlinks live in node-red, not our db.
  10. Proxy route surfaces:
    • public /in/public/* /s/* — rate-limited
    • kiosk-key /api/kiosk/* /in/kiosk/* /dash/* (kiosks)
    • admin session+TOTP /admin/* /api/admin/* /nrdp/* (humans) BetterFrame web/API stays on backend routes; kiosk-specific ingest/control uses /api/kiosk/*, /ws/kiosk, /in/kiosk/*, and /dash/*; dashboards are kiosk-only; otherwise-unmatched root paths are public Node-RED HTTP-in URLs for user webhooks/actions. /api/* and /ws/* must not fall through to Node-RED. Angie strips /in/public and /in/kiosk before proxying, so a Node-RED route /test1 is callable as /test1, /in/public/test1, or /in/kiosk/test1 depending on the desired auth surface.
  11. labels = routing primitive. cams+layouts+kiosks carry labels. 2 binding kinds:
    • consume: any kiosk w/label may render
    • operate: exactly ONE kiosk authoritative (composite PK incl role)
  12. cluster key (single shared symmetric) for cam-pw delivery to kiosks.
  13. kiosk pairing: kiosk shows 8-char code (no 0/O/1/I), admin enters in UI, server delivers kiosk_key+cluster_key+bundle_url via one-shot poll.
  14. server never touches RTSP. server = coordinator. kiosk = puller + decoder + actuator.
  15. single binary, role at runtime. systemd: betterframe-server, -kiosk, -nodered.
  16. proxy = Angie (nginx fork). drop-in nginx config.
  17. deploy: deb pkg + docker-compose first class.
  18. secrets: systemd-creds prod, dev fallback data_dir/secret.key (0600+warn).
  19. no DB on kiosk — JSON files for cached bundle+state. server = sqlite always.
  20. gpio: kiosks only. server's node-red nodes fan out via ws.
  21. validation = anyvali (BetterCorp) — single source of truth for cross-language wire schemas. Schemas authored TS-side, exported to /schemas/*.av.json, imported by Rust kiosk + Node-RED TS nodes + browser via vendored @anyvali/js. Forms also use anyvali (one schema → server validation + browser HTML5 hints via initForm from @anyvali/js/forms).
  22. server = TS, not Python. Pivoted from FastAPI/sqlmodel to BSB v9 + h3. Reasons:
    • Node-RED is already on the box (TS native there)
    • admin UI ships JS to browser anyway
    • same anyvali schemas serve both ends
    • single tsconfig spans server+browser+node-red types
    • BSB gives plugin architecture, observability, multi-instance scaling
    • prefer raw @anyvali/js over bsb.* wrappers, per user (wrappers being cleaned up)
  23. server framework = BSB v9 (@bsb/base@9.1.11). createConfigSchema() + createEventSchemas() patterns. PLUGIN_DEVELOPMENT.md is in v9 branch on github. The published llms.txt service example still shows import { z } from "zod"STALE; v9 has no zod. The actual @bsb/base@9.1.11 example plugins (node_modules/@bsb/base/lib/plugins/service-default0) use raw av.* from @anyvali/js. Match that pattern.
  24. server templating = jsx-htmx (BetterCorp, by user). tsconfig.compilerOptions.jsxImportSource = "jsx-htmx". JSX returns string (server-side rendered), type-safe htmx attrs (hx-get, hx-on:*), css(...) and js(...) helpers for inline. License AGPL-3.0-only OR Commercial — same as BSB.
  25. HTTP framework = h3 v2 (h3@^2.0.1-rc.22). Web-standard Request/Response/URL. v1 is legacy. One h3 instance per http-facing service (admin, api). Proxy fronts all. Multiple ports is intentional — separate scaling, separate auth posture, separate code surface.
  26. sqlite = node:sqlite built into Node ≥22.5. Sync API (DatabaseSync). No native compile, no platform-specific binary. Drops a dep vs better-sqlite3. Single-writer: only service-store opens the DB; everyone else asks via returnable events.
  27. runtime = pure Node.js (not Bun/Deno). BSB targets Node ≥23. Pi5 has Node 23 readily available.
  28. service decomposition (10 services):
    • foundations: service-store, service-secrets, service-auth
    • HTTP: service-admin-http, service-api-http
    • live: service-coordinator-ws, service-pairing, service-bundle
    • bridges: service-nodered-bridge, service-cec-relay rationale: scale coordinator-ws independently per kiosk count, keep admin minimal, isolate stateful pieces, independent test surfaces.

models (sqlite — translated from old-python)

  • users (argon2id pw, totp_secret_encrypted, recovery_codes_hashed JSON, role: admin/operator, must_change_password, locked_until, failed_login_count)
  • sessions (id=hex32, csrf_token, totp_pending, sliding 12h + abs 30d expiry)
  • api_keys (key_hash, key_prefix indexed, scopes JSON: read/control/admin, expires_at)
  • setup_state (singleton id=1, is_complete, cluster_key_provisioned, nodered_flows_deployed)
  • displays (index unique, w/h px, default_layout_id, idle_timeout_s, sleep_timeout_s, cec_*, desired_power_state)
  • cameras (type: rtsp/onvif, rtsp_url OR onvif host+port+user+pw_encrypted, capabilities, stream_policy)
  • camera_streams (role: main/sub/other, profile_token, rtsp_uri, w/h/fps/encoding/bitrate, is_discovered)
  • layout_templates (regions JSON, grid_cols/rows, is_builtin)
  • layouts (template_id, display_id, priority, cooling_timeout_s, preload_camera_ids JSON, is_default, resets_idle_timer)
  • layout_cells (region_name, content_type, camera_id+stream_selector | web_url | html_content, options JSON)
  • kiosks (key_hash+prefix, capabilities JSON, hardware_model, paired_at, last_seen_at, last_bundle_version, display_id)
  • labels (name [a-z0-9][a-z0-9_-]*, color)
  • kiosk_labels (composite PK: kiosk_id+label_id+role; role IN consume/operate)
  • camera_labels (cam_id+label_id) · layout_labels (layout_id+label_id)
  • pairing_codes (PK=code, kiosk_proposed_name, capabilities, expires_at, consumed_at, extras JSON)
  • event_log (kiosk_id+camera_id, source_type, topic, property_op, payload JSON, forwarded_to_nodered)

DROPPED—do NOT recreate: event_rules (node-red), shortlinks (node-red).

file layout (current)

betterframe/
  package.json                 # workspaces: server, nodered
  tsconfig.base.json
  sec-config.yaml              # BSB runtime config (all services declared)
  CLAUDE.md, ARCHITECTURE.md
  
  server/                      # BSB v9 server
    package.json
    tsconfig.json              # extends ../tsconfig.base.json; jsxImportSource=jsx-htmx
    src/
      plugins/
        service-smoke/         # ✅ canonical v9 pattern, compiles clean
        service-store/         # TODO
        service-secrets/       # TODO
        service-auth/          # TODO
        service-admin-http/    # TODO
        service-api-http/      # TODO
        service-coordinator-ws/ # TODO
        service-pairing/       # TODO
        service-bundle/        # TODO
        service-nodered-bridge/ # TODO
        service-cec-relay/     # TODO
      shared/                  # cross-plugin types/utils
      schemas/                 # anyvali schema authoring
        forms/                 # admin form schemas
        wire/                  # cross-language schemas (kiosk + nodered consume these)
        events/                # BSB event input/output schemas
      web-templates/           # jsx-htmx components (.tsx)
      web-static/              # served at /static/
        anyvali/               # vendored @anyvali/js
        htmx.min.js            # vendored
        app.css
  
  schemas/                     # exported .av.json — committed, source of truth
  
  kiosk/                       # rust app (placeholder, future)
  
  nodered/                     # custom nodes (placeholder, future)
  
  scripts/
    vendor-anyvali-js.sh       # ✅ working
    vendor-htmx.sh             # TODO
  
  deploy/                      # angie + systemd + docker-compose (TODO)
  
  old-python/                  # archived FastAPI prototype (reference only)
    backend/                   # full Python implementation
    README.md                  # what was working, what wasn't

conventions

  • TS strict mode (no implicit any, noUncheckedIndexedAccess on)
  • ESM only: "type": "module", module: "NodeNext", import paths use .js even when source is .ts
  • Plugin folder = service-<name> exactly; index.ts exports Config, EventSchemas, Plugin
  • Plugin pattern: extends BSBService<InstanceType<typeof Config>, typeof EventSchemas>, static Config, static EventSchemas, four optional *Plugins fields, constructor calls super(cfg)
  • Schemas always anyvali raw (av.object, av.string, etc.). Skip bsb.* wrappers — being cleaned up.
  • Object schemas usually unknownKeys: "strip" for forms (browser sends extras), "reject" for wire (drift should fail loud)
  • Build: npm run buildbsb-plugin-cli build writes lib/ + generates bsb-plugin.json
  • Dev: npm run devbsb-plugin-cli dev runs .ts directly with hot reload
  • Logging: obs.log.info("text {tag}", { tag: value }) — structured

current build status

  • workspace skeleton, deps, tsconfig hierarchy
  • @anyvali/js + htmx.min.js vendored
  • all wire + form schemas authored + export script
  • service-store — full Repository with CRUD for all entities + display-chain bundle queries
  • shared/secrets — AES-256-GCM + HKDF + cluster key (was plugin, now shared module)
  • shared/auth — argon2id + TOTP + sessions + API keys + kiosk-key (was plugin, now shared module)
  • shared/pairing — 8-char code state machine
  • shared/bundle — display-chain bundle generation (kiosk→display→layouts→cells→cameras)
  • service-admin-http — h3 on :18080, full admin UI (setup, login, TOTP, cameras CRUD, kiosks CRUD, labels CRUD, templates CRUD, layouts CRUD with cell management, display editing)
  • service-api-http — h3 on :18081, kiosk REST API (pairing, bundle, heartbeat, events)
  • service-coordinator-ws — HTTP on :18082 (WS upgrade stub, needs crossws)
  • tsc --noEmit clean, npm run build passes (4 plugins, 0 errors)
  • end-to-end tested on Raspberry Pi 5 (setup → camera → layout → pair → bundle → display)
  • Rust kiosk app — GTK4 + GStreamer, pairing flow, bundle-driven layout rendering
  • Shell kiosk prototype — auto-detect codec (H264/H265/fallback)
  • coordinator-ws: full WebSocket upgrade (needs crossws or ws package)
  • Node-RED custom nodes + bridge
  • CEC relay
  • Angie proxy config + systemd units + Docker
  • Visual template grid editor (currently preset-based + form)
  • Display auto-discovery from kiosk capabilities

architecture note: plugins vs shared modules

BSB plugins = actual services (own port, lifecycle, resource ownership). Everything else is a shared module (plain TS, no BSB lifecycle).

4 BSB plugins:

  • service-store — DB lifecycle, migrations, repository. Registers repo in shared/plugin-registry.ts
  • service-admin-http — h3 :18080, admin UI. Inits secrets + auth as shared modules in init()
  • service-api-http — h3 :18081, kiosk REST API. Same shared module pattern
  • service-coordinator-ws — :18082, live kiosk channel

Shared modules (server/src/shared/):

  • secrets.ts — initSecrets(config, log) → SecretsApi
  • auth.ts — createAuth(repo, secrets, config) → AuthApi
  • pairing.ts — initiatePairing, claimPairing, confirmPairing
  • bundle.ts — generateBundle (display-chain routing)
  • plugin-registry.ts — store repo singleton for cross-plugin access
  • types.ts — all domain interfaces
  • Stubs: nodered-bridge.ts, cec-relay.ts

Why not plugins? Secrets/auth/pairing/bundle have no ports, no lifecycle. Making them plugins created unnecessary inter-plugin wiring complexity. Shared modules = direct imports, no event bus overhead.

active TODO

  1. Visual template grid editor — dynamic grid: starts 1x1, expands as content added, resize blocks
  2. RTSP camera field split done: separate host/port/path/user/pass fields
  3. Display auto-discovery — kiosk reports connected HDMI displays during pairing/heartbeat
  4. coordinator-ws WebSocket — install crossws, implement kiosk connections + layout-switch commands
  5. Rust kiosk polish — multi-camera compositor, H264/H265 auto-detect, web cells via WebKit
  6. Node-RED bridge — outbound HTTP forwarder + inbound callbacks
  7. Display power relay — kiosk handles CEC first, then monitor DPMS fallback (wlr-randr, then xset). Server/admin should keep using generic wake/standby commands, not CEC-only naming.
  8. Angie config + systemd units + Dockerfile — baseline native + Docker deployment files exist; Angie now uses auth_request for admin/kiosk-gated Node-RED routes, and Docker uses container upstreams.
  9. Auth-check endpoints admin session/API-key, kiosk key, and API-key checks added for proxy auth_request

conventions (additions discovered while building)

  • BSB build needs tsx: cross-env NODE_OPTIONS="--import tsx" bsb-plugin-cli build in package.json scripts. Required because BSB's schema extractor doesn't resolve .js→.ts imports in multi-file plugins
  • h3 v2 html() is tagged template only — can't pass string. Use htmlPage() helper that returns new Response(String(markup), { headers: { "content-type": "text/html" } })
  • h3 v2 setCookie doesn't work with raw Response returns — set Set-Cookie header on Response directly via redirectWithCookie() helper
  • BSB config doesn't apply schema defaults for keys missing from sec-config.yaml. Always declare config values explicitly
  • Cookie signing uses HKDF-derived key (deterministic). NOT encryptString (random IV = non-deterministic = broken)
  • RTSP URLs with special chars in password: URL-encode user/pass components. Camera form splits into host/port/path/user/pass fields, builds URL server-side
  • ONVIF discovery import: ONVIF profiles are streams, not cameras. Group profiles by VideoSourceConfiguration/SourceToken (fallback to channel-ish URI/name), assign largest stream main, next sub, rest other, and import one camera with multiple camera_streams. If RTSP URIs omit userinfo, inject the ONVIF username/password before storing so kiosk playback avoids RTSP 401.
  • Display power: TVs may support CEC, monitors usually won't. Kiosk power commands should try cec-ctl, then standard output sleep/wake (wlr-randr, then xset dpms) so monitor installs still work.
  • GStreamer on Pi5: hw H265 decoder rejects non-standard resolutions (960x1080). Use avdec_h265 (sw) as fallback
  • Log message strings MUST be string literals (BSB SmartLogMeta extracts placeholders from literal type)
  • Datetimes are ISO-8601 strings stored as TEXT
  • node:sqlite API: DatabaseSync, .exec(), .prepare().run/all/get(). Synchronous

file layout (current)

betterframe/
  package.json                 # workspaces: server, nodered
  tsconfig.base.json
  sec-config.yaml              # 4 services: store, admin-http, api-http, coordinator-ws
  CLAUDE.md
  
  server/
    package.json               # tsx in devDeps for BSB build
    tsconfig.json              # jsxImportSource=jsx-htmx
    src/
      plugins/
        service-store/         ✅ DB lifecycle + full repository
        service-admin-http/    ✅ h3 + jsx-htmx admin UI
          index.ts, middleware.ts, html-response.ts
          routes-setup.ts, routes-auth.ts, routes-admin.ts, routes-account.ts, routes-static.ts
        service-api-http/      ✅ kiosk REST API (pairing, bundle, heartbeat)
        service-coordinator-ws/ ⚠️ stub (HTTP health only, WS upgrade TODO)
      shared/
        types.ts               ✅ all domain interfaces
        secrets.ts             ✅ AES-256-GCM + HKDF + cluster key
        auth.ts                ✅ argon2id + TOTP + sessions + API keys
        pairing.ts             ✅ 8-char code state machine
        bundle.ts              ✅ display-chain bundle generation
        plugin-registry.ts     ✅ store repo singleton
        nodered-bridge.ts      stub
        cec-relay.ts           stub
      schemas/                 ✅ anyvali schemas (forms, wire, events)
      web-templates/           ✅ jsx-htmx components (layout, auth pages, admin pages)
      web-static/              ✅ vendored htmx.min.js + @anyvali/js
  
  kiosk/
    Cargo.toml                 ✅ Rust GTK4 + GStreamer
    src/
      main.rs, ui.rs, bundle.rs, server.rs, pipeline.rs
    prototype.sh               ✅ shell prototype for quick testing
  
  schemas/                     exported .av.json
  nodered/                     custom nodes (future)
  scripts/                     vendor scripts
  deploy/                      angie + systemd + docker (TODO)

things the docs got wrong (so future-Claude doesn't re-find them)

  • BSB public docs (bsbcode.dev/llms/nodejs-plugin-service.txt) show import { z } from "zod" — STALE. v9 ships zero zod, uses anyvali everywhere.
  • Same docs use BSBService<typeof Config, ...> — should be InstanceType<typeof Config>. The actual canonical pattern is in node_modules/@bsb/base/lib/plugins/service-default0/.
  • @anyvali/js Python SDK uses object_()/int_()/bool_() (underscores avoid keyword conflicts), but the JS SDK uses object()/int()/bool() without underscores. Only enum_() and null_() keep the underscore in JS.
  • BSB log methods (obs.log.info etc.) require literal-typed message strings. String concatenation ("a " + "b") widens to string and breaks the SmartLogMeta<T> placeholder extraction.