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
- **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.
-`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.
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.
## 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.
## 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.