Commit graph

67 commits

Author SHA1 Message Date
Mitchell R
2e88d891e1
fix(db): clean config field names under db: object
Removed redundant pg prefix — fields already nested under db:.
pgHost→host, pgPort→port, pgDatabase→database, pgUser→user,
pgPassword→password, pgPoolMax→poolMax, pgUrl→url.

Updated all 3 plugin schemas, shared DbConfig type, init.ts,
and sec-config template.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 05:12:53 +02:00
Mitchell R
5c367203c6
fix(db): log failing PG migration index + SQL on error
Was crashing silently during migration. Now logs which migration
index failed and the first 200 chars of the SQL before rethrowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 05:05:31 +02:00
Mitchell R
0bb8fb68c9
feat(cloud-cameras): add EZVIZ, Reolink, Eagle Eye providers
EZVIZ: consumer Hikvision cameras via Open Platform API (appKey/appSecret).
Reolink: local HTTP API + RTSP (no cloud API available).
Eagle Eye Networks: OAuth2 cloud VMS with HLS relay URLs.
2026-05-24 02:54:49 +02:00
Mitchell R
0479cb7b4b
refactor(db): move service-store from BSB plugin to shared/db library
Each service plugin now independently initializes its own DB connection
via shared/db/init.ts instead of depending on a central service-store
plugin. This removes the inter-plugin dependency ordering and the
plugin-registry singleton, making each service self-contained.

- Move db-adapter, repository, mappers, migrations, adapters to shared/db/
- Create shared/db/config.ts (reusable dbConfigSchema) and init.ts
- Delete service-store plugin and plugin-registry
- Add db config block to each service's ConfigSchema + sec-config template
- Move event_log purge timer into service-admin-http
- Update all import paths across shared modules and plugins

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 02:48:32 +02:00
Mitchell R
238aa4f9af
fix: resolve merge conflict + align BSB workdir /home/bsb
Resolved coolify compose conflict — took remote bind mount pattern.
All paths now use /home/bsb (BSB container workdir, not /app).
Both compose files use bind mount for sec-config.yaml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 23:58:36 +02:00
Mitchell R
34331c9d0e
refactor: use BSB container + mount sec-config at runtime
Dockerfile.server now uses betterweb/service-base:node as runtime
base instead of node:24-trixie-slim + manual bsb-plugin-cli. BSB
container handles entrypoint, user, plugin loading.

sec-config.yaml removed from Docker image — must be bind-mounted
at /app/sec-config.yaml. Both compose files updated with :ro mount.
All BF_* env vars removed from compose server service.

deploy/docker/sec-config.yaml deleted (was baked in, now mounted).
version.ts path updated for new workdir /app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 23:56:44 +02:00
Mitchell R
49d730cf7f
refactor: remove all process.env and envStr() from server code
All runtime config now flows exclusively through BSB plugin config
(this.config.*) or shared module parameters. No more env var overrides.

Changes:
- Delete shared/env-overrides.ts (envStr/envBool/envInt helpers)
- version.ts: remove env var chain, keep only .bf-version file + "dev"
- firmware.ts: replace BF_FIRMWARE_SIGNING_KEY env with config.signingKeyPem
  parameter, remove tryParsePrivateKey helper
- secrets.ts: replace process.env.CREDENTIALS_DIRECTORY with
  config.systemdCredsDir
- mqtt-bridge.ts: accept MqttConfig object instead of reading process.env
- service-store: replace envStr calls with this.config.*, build pgUrl from
  config fields, add pgPoolMax config
- pg-adapter.ts: accept poolMax constructor param instead of env var
- service-admin-http: add firmwareSigningKey, firmwareImportApiKey,
  otaImportApiKey, systemdCredsDir config fields; pass to shared modules
- middleware.ts: replace tokenMatchesEnv with tokenMatchesExpected using
  deps.firmwareImportApiKey/otaImportApiKey
- service-api-http: add mqttUrl/mqttUsername/mqttPassword/mqttTopicPrefix
  config fields; pass to initMqttBridge
- service-coordinator-ws: replace envStr calls with this.config.*
- sec-config.yaml: add all new config fields with sensible defaults
- docker-compose.coolify.yml: remove all BF_* env vars from server service

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 13:22:44 +02:00
Mitchell R
1421feb7b4
fix(hikconnect): rewrite to HikCentral Connect OpenAPI v2.15
Was using consumer api.hik-connect.com (wrong API). Rewritten to use
HikCentral Connect enterprise API per vendor docs:

- Auth: POST /api/hccgw/platform/v1/token/get with appKey + secretKey
- Cameras: POST /api/hccgw/resource/v1/areas/cameras/get (paginated)
- Live view: POST /api/hccgw/video/v1/live/address/get → RTMP URL
- Credential fields: app_key (AK), secret_key (SK), region
- Region-specific server addresses (eu/us/sg/sa/ru)
- Token response returns areaDomain for subsequent calls
- RTMP protocol=3, quality=1 (HD), expireTime=86400 (24h)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 12:16:42 +02:00
Mitchell R
a9484d1dd7
feat(cloud-cameras): type=cloud + bidirectional sync + PG default
Cloud cameras are now a distinct type ('cloud') managed entirely by
sync. Bidirectional: cameras added in vendor cloud appear automatically,
removed cameras get deleted. Cloud cameras and their entities are
read-only in admin UI — no manual editing.

- Camera type CHECK widened to include 'cloud'
- New columns: cloud_account_id, cloud_vendor_camera_id,
  cloud_stream_url, cloud_stream_type
- Repo: upsertCloudCamera, deleteCloudCamerasNotIn,
  listCloudCamerasByAccount
- Sync replaces import: full reconciliation per account
- Hik-Connect: fetch HLS preview URLs via previewURLs endpoint
- Tuya: fetch stream URLs during sync (not just on demand)
- Kiosk API: GET /api/kiosk/cameras/:id/stream returns fresh
  relay URL from vendor (session-based URLs expire)
- Cloud cameras show read-only detail page with cloud badge
- Coolify compose: postgres 18 as default, BF_DB=postgres,
  server depends_on postgres healthy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 11:36:49 +02:00
Mitchell R
851274d05d
fix: PG cloud_accounts migration + rollout-safe cleanup + setup cursor
- Add cloud_accounts table to PostgreSQL tenant migrations (was only
  in SQLite).
- Artifact cleanup now skips releases referenced by active/queued/paused
  rollouts (CASCADE would delete the rollout).
- Add invisible cursor theme install to setup-pi-kiosk.sh (was only
  in pi-gen image build).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 02:59:27 +02:00
Mitchell R
1a87c97479
fix(kiosk): piwiz + cursor + migration backfill + artifact cleanup
Cursor: install theme as index.theme (XCursor spec) not just
cursor.theme. Add WLR_XCURSOR_THEME env var for wlroots compat.

Piwiz: broader purge (rpi-first-boot-wizard, raspi-config triggers,
profile.d scripts, firstrun.sh). Mark first-boot done via userconf
marker file.

Migration: add encrypt_key_encrypted, cloud_accounts, and ONVIF event
columns to catch-all backfill so PRAGMA user_version skips can't miss
them.

Artifact cleanup: delete yanked firmware/OS files + prune to 5 most
recent per channel. Runs every 6h. Stops disk from filling up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 02:56:56 +02:00
Mitchell R
af639b4d46
feat(cloud-accounts): admin page with add/test/sync/import/delete 2026-05-23 02:34:03 +02:00
Mitchell R
f728b0002c
feat(cloud-cameras): Hik-Connect + Dahua + Tuya + Uniview + TP-Link integrations
Cloud camera platform integrations with provider interface pattern:

Framework (cloud-cameras/types.ts):
  - CloudCameraProvider interface: testCredentials, listCameras,
    getStreamUrl, credentialFields
  - CloudAccount model + vendor registry
  - Multiple accounts per vendor per tenant supported
  - All auth on server — kiosk only gets streaming URLs

Vendors:
  - Hik-Connect: token auth, device list via OpenAPI, local RTSP
    (cloud P2P relay requires native SDK — not supported yet)
  - Dahua: HTTP Basic/Digest against device ISAPI, channel enumeration,
    RTSP URL construction per channel
  - Tuya: OAuth2 + HMAC-SHA256, device list + stream allocation via
    IoT Cloud API, RTSP/HLS URL from allocate endpoint
  - Uniview: HTTP Basic against LightAPI, channel enumeration via
    /LAPI/V1.0/Channels, RTSP per channel
  - TP-Link: no cloud API, direct RTSP + TCP port probe for testing

DB: cloud_accounts table (SQLite migration) for storing encrypted
credentials per vendor per tenant.

Admin UI for account management TODO — provider framework + DB ready.
2026-05-23 02:25:44 +02:00
Mitchell R
a233b7d38b
feat(smart-url): automated login/navigation sequences for web cells
Smart URL actions: multi-step browser automation for web cells behind
login pages. Steps: navigate, fill (form fields), click, wait, wait_for
(element selector), javascript (raw eval). Passwords in fill steps
encrypted with per-kiosk key for transport.

Schema: server/src/schemas/wire/smart-url.ts defines step types.
Stored in layout_cells.options.smart_url (no migration needed).

Bundle: includes smart_url config per cell. Fill step values encrypted
at bundle generation time with per-kiosk key (or cluster key fallback).

Kiosk: execute_smart_url_steps() builds an async JS sequence from the
steps and injects via WebKit evaluate_javascript on LoadEvent::Finished.
Supports session expiry detection via login_detect_url.

Admin UI: step builder TODO (currently configure via cell options JSON).
Data model + kiosk execution + bundle transport are complete.
2026-05-23 02:21:27 +02:00
Mitchell R
ed2050cfd8
feat(db): full async Repository conversion for PostgreSQL support
Mechanical conversion of the entire data access layer from synchronous
node:sqlite API to async DbAdapter interface. Enables PostgreSQL
(PgAdapter) as a drop-in backend alongside SQLite (SqliteAdapter).

Repository (2208 lines):
  - Constructor accepts DbAdapter instead of DatabaseSync
  - Internal _run/_get/_all/_exec helpers wrap adapter calls
  - All 155 methods converted to async, return Promise<T>
  - transact() uses adapter.transaction() (supports PG savepoints)

14 caller files updated (327 call sites):
  - routes-admin.ts: 202 repo calls + 6 async helper functions
  - service-api-http: 40 repo calls + async getClusterKey
  - routes-firmware.ts, routes-os-updates.ts, routes-auth.ts,
    routes-setup.ts, middleware.ts: all handlers made async
  - shared/auth.ts: resolveSession + revokeSession now async
  - shared/bundle.ts: generateBundle now async, .map→for..of loops
  - shared/pairing.ts: all 3 functions async
  - shared/audit.ts: audit() now async
  - shared/camera-health.ts: checkAll repo calls awaited
  - service-coordinator-ws: session + kiosk lookups awaited
  - service-store/index.ts: creates SqliteAdapter.fromExisting()

SqliteAdapter gains static fromExisting(db) factory for wrapping an
already-opened DatabaseSync (migrations run on raw db, then adapter
wraps for Repository queries).

tsc --noEmit: zero errors.
2026-05-23 02:07:44 +02:00
Mitchell R
595521db88
feat(os-ota): resumable chunked download with Range header support
OS bundle download was buffering 1.2GB in RAM then writing → network
timeout or memory pressure killed it. Now:

Kiosk side:
  - Streams directly to /var/tmp/betterframe/ in 256KB chunks
  - On network error: resumes from last byte written (Range header)
  - Up to 5 retries with 10s backoff between attempts
  - Progress logged every ~50MB
  - sha256 verified on the complete file on disk (not in memory)

Server side:
  - /api/kiosk/os/download/:id supports Range: bytes=N- header
  - Returns 206 Partial Content with Content-Range for resume
  - streamBundle accepts start/end for partial reads via createReadStream
  - Advertises Accept-Ranges: bytes on all responses
2026-05-23 01:44:34 +02:00
Mitchell R
a92e927b3b
feat(cameras): periodic offline detection via TCP probe + camera.offline events 2026-05-23 01:38:23 +02:00
Mitchell R
caf6095b6e
feat(security): per-kiosk encryption keys for camera passwords
Replaces shared cluster_key for bundle encryption. Each kiosk gets a
unique 32-byte AES key generated at pairing time:

Server:
  - confirmPairing generates randomBytes(32), stores encrypted with
    server secret on kiosks.encrypt_key_encrypted column
  - Delivers plaintext encrypt_key to kiosk in claim response (one-time)
  - generateBundle prefers per-kiosk key over cluster_key for
    encryptForCluster (same AES-256-GCM format, different key per kiosk)

Kiosk:
  - ClaimResp gains encrypt_key field, stored encrypted at rest
  - onvif_events prefers encrypt_key over cluster_key for decryption
  - Backward compatible: old kiosks without encrypt_key still use
    cluster_key (both delivered at pairing)

Security improvement: compromised SD card only exposes camera passwords
encrypted for THAT specific kiosk, not the entire fleet. Rotate by
deleting + re-pairing the compromised kiosk.
2026-05-23 01:36:43 +02:00
Mitchell R
864e66fbc8
feat(multi-tenant): schema-per-tenant model + PostgreSQL migration DDL
Prep for multi-tenant PostgreSQL:

shared/tenant.ts: tenant model, schema name derivation, search_path
SQL helper. Schema-per-tenant: each tenant gets tenant_<uuid> schema,
public schema holds tenant registry + global admins.

migrations-pg.ts: two migration sets:
  - PUBLIC_MIGRATIONS: tenants + global_admins + schema_migrations tables
  - TENANT_MIGRATIONS: full BetterFrame table set in PG-native types
    (SERIAL, TIMESTAMPTZ, JSONB, native BOOLEAN). Mirrors SQLite schema
    1:1 but with PG conventions.

DbAdapter + SqliteAdapter + PgAdapter already existed. Next steps:
  1. Repository async conversion (155 sync calls → await adapter.*)
  2. Tenant provisioning endpoint (create schema + run migrations)
  3. Request middleware: session → tenant_id → SET search_path
  4. Global admin UI for tenant management
2026-05-23 01:15:49 +02:00
Mitchell R
b1e8e00eb1
feat(onvif): event routing config + GetEventProperties + subscription status
Full ONVIF event management overhaul:

DB: cameras gain event_source (auto|server|kiosk:<id>), event_sink
(auto|server|kiosk:<id>), and supported_event_topics (JSON array).

Server:
  - GetEventProperties SOAP call in onvif.ts — queries camera for all
    supported event topics (motion, ANPR, line crossing, etc.)
  - POST /admin/cameras/:id/refresh-events route — runs GetEventProperties
    via designated event source (kiosk WS relay or server direct)
  - Camera edit form: event_source + event_sink dropdowns
  - Camera detail: supported event topics table with refresh button
  - Bundle includes event_source + event_sink so kiosk knows its role

Kiosk:
  - onvif_events.rs respects event_source: only subscribes when "auto"
    or "kiosk:<this_id>", skips when "server"
  - Subscription status tracking: state (subscribing/active/failed),
    last_event_at, error — reported in heartbeat for admin visibility
  - BundleCamera gains event_source + event_sink fields

Auto logic for source: camera in kiosk's bundle → kiosk subscribes.
Auto logic for sink: TODO — same-subnet detection for WSBaseNotification.
Currently PullPoint only; push model is the next step.
2026-05-23 00:38:54 +02:00
Mitchell R
7d81891b0e
fix(version): derive server version from git at Docker build time
Coolify pulls from GitHub and runs docker compose build — no guaranteed
env vars like SOURCE_COMMIT. Previous approach relied on ARG/ENV
passthrough that silently defaulted to "dev".

Fix: install git in the builder stage, COPY .git into context, run
git describe --tags --always to derive the version, write it to
/app/server/.bf-version. version.ts reads this file as a fallback
between env vars and the "dev" literal.

Chain: BF_SERVER_VERSION env → BF_BUILD_VERSION env → .bf-version file
→ COOLIFY_GIT_COMMIT env → SOURCE_COMMIT env → "dev".

Also: fix .gitignore for rauc-signing/ (was under wrong path).
2026-05-21 16:02:21 +02:00
Mitchell R
e770e48f76
fix(layout): resolve cell overlaps on resize/expand
Replace naive per-cell shift with iterative overlap resolver. When any
cell grows (direction expand, dim/delta resize, or cell edit with new
spans), all overlapping cells get pushed along the expansion axis, with
cascading to prevent pushed cells from overlapping each other.

Fixes: expanding left block goes under right block instead of pushing it.
2026-05-21 12:07:32 +02:00
Mitchell R
9129613920
feat(cameras): sync entity name on rename + ONVIF device name from GetDeviceInformation
Two fixes:

1. When admin renames a camera, the linked entity's name now syncs
   automatically so the entity list doesn't drift from the camera list.

2. ONVIF discovery now calls GetDeviceInformation before GetProfiles
   (best-effort, catches auth-gated devices). Pulls Manufacturer + Model
   and uses the combined string as the camera's proposed name instead of
   the raw IP. E.g. "Hikvision DS-2CD2146G2" instead of "192.168.74.8".
   Falls back to host IP when the device omits the info.
2026-05-21 11:57:38 +02:00
Mitchell R
90346f4efd
feat(os-ota-ui): admin pages for OS releases + rollouts + per-kiosk panel
Mirrors the kiosk-firmware admin shape one-for-one against OS RAUC
bundles:

  /admin/os-updates                   release list, yank
  /admin/os-updates/rollouts          rollout list + create
  /admin/os-updates/rollouts/:id/state pause/resume/complete
  /admin/kiosks/:id/os-update         per-kiosk channel + pin

Templates: OsUpdatePage, OsUpdateRolloutsPage, KioskOsUpdatePanel.
KioskOsUpdatePanel is rendered next to the existing KioskFirmwarePanel
on the kiosk detail page so OS + app state sit side-by-side. The
"how bundles get here" sidebar on the list page documents the four
GitHub secrets needed (signing cert/key + autoimport URL/key) so a
new operator doesn't have to dig through scripts/ to find them.

Nav gains an OS Updates entry between Firmware and Labels. Activates
on activeNav="os-updates".

Repo + import endpoint already existed (audit confirmed earlier). All
admin routes use them as-is.
2026-05-21 11:30:33 +02:00
Mitchell R
334ee8fb93
feat(preview): pull entity snapshot from active kiosk first
When admin opens an entity preview, find a kiosk whose active layout
references the camera (new repo.listKiosksRenderingCamera). Probe each
candidate's LAN snapshot endpoint with a 4s timeout. On success, stream
the bytes back with x-bf-snapshot-source: kiosk:<id>. Falls through to
the existing server-direct ffmpeg/gst pull only when no kiosk is reachable
or has the camera in its active layout.

Kiosk side adds /local/snapshot/:camera_id?key=<local_key>. Spawns a
one-shot gst-launch (rtspsrc → decodebin → jpegenc ! filesink
num-buffers=1) on a blocking worker so axum's reactor stays free.
Prefers sub stream for snapshots to keep bandwidth low. Single-frame
pipeline tears down after the first JPEG.

LAN IP picking extracted to shared/kiosk-lan.ts so route handler and
KioskLocalPanel agree on which interface to talk to (the previously-
duplicated logic in admin-pages stays for now since it also renders the
interface list).

Why a parallel pipeline instead of teeing the warm one: cross-thread
gtk4paintablesink → appsink sample extraction is non-trivial. A 1-frame
parallel pull is cheap when the kiosk's RTSP session to that camera is
already known to work (precondition: it's in the active layout).
2026-05-21 10:35:27 +02:00
Mitchell R
7df048c195
feat(display): persist + surface active layout
Kiosk's layout.changed events now bump displays.active_layout_id on the
server side. Display edit page and kiosk edit page render the currently-
active layout, and the "Switch Layout" dropdowns pre-select it (with
"(active)" suffix) instead of defaulting to first-in-list. Stops the
operator from accidentally re-switching to the layout already showing.

Migration is idempotent + tail-positioned so existing DBs pick up the
column without breaking PRAGMA user_version semantics.
2026-05-21 10:19:39 +02:00
Mitchell R
d51e01ff0e
feat(pairing): validate replace-target matches existing kiosk
Replacing a kiosk now sanity-checks the incoming device:
- hardware_model must match (Pi 5 swapping in for Pi 5, not Pi 3)
- managed_image flag must match (don't silently switch BYO-OS ↔ image)
- capabilities can narrow legitimately but a "lost capabilities" diff is
  surfaced anyway so the operator notices.

Mismatch raises an error listing what changed; "Force replace" checkbox
on the pair form bypasses for legitimate hardware upgrades. Pending codes
panel also now renders proposed_name / hw_model / capabilities /
managed-image badge so the operator can eyeball the inbound device
before picking a replace target.
2026-05-21 10:16:55 +02:00
Mitchell R
28ff450d35
revert(bundle): restore type==='rtsp' gate on stream fallback
ONVIF cams legitimately have multiple streams (main+sub) stored in
camera_streams. Dropping the type gate synthesized a single "main"
row for ONVIF cams that lacked rows, hiding the multi-stream design
the kiosk's pick_stream relies on (area >= 0.2 → main, else sub).
The "(no stream)" symptom is a partial-import bug to chase separately;
the bundle fallback is for single-URL RTSP cams only. Also drop the
backfill migration that did the same insert at the DB layer.
2026-05-21 09:39:54 +02:00
Mitchell R
281c0adf44
fix(bundle): synthesize stream for any camera with rtsp_url
ONVIF-imported cameras with rtsp_url but no camera_streams rows showed
"(no stream)" in the kiosk because the bundle fallback was gated to
type=rtsp only. Drop the type check + backfill existing rows so old
imports get a main stream row created.

feat(kiosk-mgmt): report hostname + all network interfaces

Behind Docker/Angie the server only saw the proxy bridge IP (172.31.0.2).
Kiosk now shells `ip -j addr show`, reports every non-loopback IPv4/v6
with CIDR, MAC, and operstate. Plus `hostname` for verifying that
managed-config applies landed. Admin UI renders interface list with
LAN IPs preferred for the copy-paste local-LAN endpoint.

feat(managed-config): auto-sync hostname from kiosk name

When admin renames a managed-image kiosk, slugify the name → DNS-safe
hostname and bump managed_config_version so the kiosk applies it on
next heartbeat. Empty form hostname now falls back to slug too, so
DHCP shows the friendly name.

feat(events): forward firmware + OS update outcomes as kiosk.log

Kiosk POSTs `/api/kiosk/event` with topic=kiosk.log on firmware-apply
attempts. Server-side firmware/os-update endpoints also insert into
event_log so admins can audit upgrades without correlating per-source.
Wire schema heartbeat gains reported_hostname + network_interfaces for
Rust import parity.
2026-05-21 09:23:50 +02:00
Mitchell R
49e420dea5
feat(display): report and control power state 2026-05-21 09:10:30 +02:00
Mitchell R
3d5e27bdfb
fix(release): surface build versions 2026-05-21 08:51:41 +02:00
Mitchell R
3ffaf780e3
feat(kiosk): improve display controls and health 2026-05-21 02:03:12 +02:00
Mitchell R
96f5e6a330
feat(ota): add OS update release endpoints 2026-05-20 06:19:46 +02:00
Mitchell R
9942957bcf
feat(onvif): run discovery from selected kiosk 2026-05-20 06:16:27 +02:00
Mitchell R
dae5d0ce88
feat(managed-config): server-side scaffold for Pi-image device config
Kiosks running our pre-built image (managed_image=true at pairing) can
have their hostname, timezone, network (DHCP/static + VLAN), and Wi-Fi
configured from the admin UI. Pull-model: server stores desired-state
JSON, kiosk heartbeat returns pending_config when version exceeds
applied_version, kiosk echoes applied_version back. Wi-Fi PSK encrypted
with the cluster key so ciphertext at rest is shipped to the kiosk
without per-kiosk re-encryption.

Server side only — kiosk Rust applier (betterframe-apply-config helper
+ rollback timer) and pair-initiate marker file are next.

ci(pi-gen): use action's image-path output for asset upload

pi-gen writes the .img.xz into pi-gen-action's own working dir, not our
repo deploy/. Glob never matched. Use steps.pigen.outputs.image-path
directly — no glob needed.
2026-05-20 03:18:11 +02:00
Mitchell R
6473f0fc95
fix(firmware): diagnostic dump + smart-quote / BOM / multi-quote handling
Adds aggressive normalisation to tryParsePrivateKey:
- Strip UTF-8 BOM
- Replace smart quotes (" " ' ') with ASCII
- Strip multiple layers of wrapping quotes
- Combine escape-unfold with quote-strip (env vars that quote AND escape)
- Strip whitespace inside base64 candidate before decode

On parse failure, dumps length + head/tail samples + first-byte hex so
the operator can spot exactly what shape the env var arrived in.
2026-05-18 22:52:35 +02:00
Mitchell R
8082571b03
fix(firmware): tolerate mangled PEM in BF_FIRMWARE_SIGNING_KEY env
Coolify / docker compose env injection routinely strips real newlines or
wraps in quotes, causing createPrivateKey to throw ERR_OSSL_UNSUPPORTED
and crashing the server before it can even start.

tryParsePrivateKey now attempts: literal, \n→LF, CRLF→LF, quote-stripped,
base64-decoded, and single-line PEM re-wrapped to 64-col. On total
failure, logs a clear warning and falls back to on-disk / generated key
instead of crashing.
2026-05-18 22:47:07 +02:00
Mitchell R
17f8c7ce02 feat(server): generic MQTT telemetry bridge (off by default) 2026-05-14 07:46:56 +02:00
Mitchell R
aa4e91491b feat(server): backup + restore (AES-256-GCM, PBKDF2, admin UI) 2026-05-14 07:44:01 +02:00
Mitchell R
a6c1fb4d8d feat(server): rate limit (login + pair) + CSP/security headers 2026-05-14 07:40:22 +02:00
Mitchell R
3ec2f3bf85 feat(server): audit log — schema, helper, admin UI, hooks for login/pair/firmware 2026-05-14 07:38:18 +02:00
Mitchell R
d1fd128ea0 feat(server): env-var overrides for sec-config keys + docker healthchecks 2026-05-14 07:33:10 +02:00
Mitchell R
6a8f6d76af feat(kiosk): LAN-side local HTTP server (GET layout API + admin proxy)
Kiosk now exposes :18090 with two surfaces:

- GET /local/layout/:id?key=<kiosk_local_key>
  Bookmark-friendly layout switch on this kiosk. Auth = kiosk-generated
  local key (32 random bytes, hex, stored at <state_dir>/local.key).

- ANY /proxy/* — forwards to BF server with the request's Authorization
  header preserved. Lets LAN clients reach a cloud-hosted BF server via
  the kiosk's local socket; kiosk adds no auth of its own.

Heartbeat reports {local_key, local_port}; kiosks table grows
local_key/local_port/local_last_ip columns. Admin kiosk edit page now
shows the local URLs as a copy-paste block.

Override port: BF_KIOSK_LOCAL_PORT. Disable: BF_KIOSK_LOCAL_DISABLE=1.
2026-05-14 07:24:21 +02:00
Mitchell R
e5009fdd14 feat(ota): replacement pairing + firmware OTA (admin UI, kiosk client, CI) 2026-05-13 20:56:42 +02:00
Mitchell R
b10958def7 fix(nodered): kiosk-side layout.changed events + provisioning retries
Three related fixes:

1. Idle reverts (and any other kiosk-initiated layout switch) now POST
   layout.changed to /api/kiosk/event. Previously the server only emitted
   on admin-initiated switches, so Node-RED never saw the idle revert.

2. Server's /api/kiosk/event splays the payload to the top level when
   the topic has a dedicated trigger node (layout.changed, kiosk.changed,
   kiosk.status, display.power.changed, camera.changed). The trigger
   nodes expect flat shapes matching the admin emit; the old wrapped
   shape left every field undefined.

3. Auto-provisioning of bf-server-config in Node-RED: extend retry
   window to ~5 min, log per attempt, force v2 API + full-deploy header
   so credentials inline get accepted, surface response body on failure.
2026-05-13 13:03:51 +02:00
Mitchell R
122509de0d feat(nodered): auto-provision bf-server-config on boot
Server mints a dedicated admin API key on first boot (persisted plaintext
encrypted in setup_state.extras) and POSTs a bf-server-config node into
Node-RED's flow graph via /nrdp/flows. Idempotent — skips if any
bf-server-config already exists, so user-owned configs win.

New admin-http config 'selfUrl' (defaults to http://127.0.0.1:18080)
tells Node-RED how to reach the BF server. Docker compose sets it to
http://server:18080 so requests stay inside the compose network.
2026-05-13 03:09:25 +02:00
Mitchell R
faaa2cef39 feat(display): admin enable/disable toggle
is_enabled column on displays (default 1). Disabled displays are filtered
from the kiosk bundle so the kiosk never opens a window on them. Admin
edit page exposes a checkbox; list page shows a "disabled" badge.
2026-05-13 02:59:28 +02:00
Mitchell R
887db013ef fix: trigger nodes self-register + move to angie-blocked path
Trigger nodes now self-contained inputs (inputs:0):
- Each registers POST /api/internal/<topic> on RED.httpNode
- Angie returns 404 for any /api/* not whitelisted (kiosk/pair/admin)
  so external requests cannot trigger BF nodes
- Server bridge POSTs direct to nodered container (bypasses Angie)
- nodered-bridge.ts updated to use /api/internal/<topic>
- 6 trigger nodes converted: display-power, layout-changed,
  kiosk-changed, camera-changed, status, kiosk-camera-event
- Optional per-node filters (display_id, kiosk_id, camera_id)
- close handler removes only this node's route layer
2026-05-13 02:42:37 +02:00
Mitchell R
bd48c853e6 feat: restructure Node-RED nodes + server event emission
Renames:
- bf-config → bf-server-config (config node clarity)
- bf-event-in → bf-kiosk-camera-event (specific camera trigger)

New trigger nodes (input-only, under "BetterFrame Triggers"):
- bf-trigger-display-power, bf-trigger-layout-changed,
  bf-trigger-kiosk-changed, bf-trigger-camera-changed

New flow nodes:
- bf-config-get: query state by type (displays/kiosks/cameras/layouts/
  entities, or by-id)
- bf-config-set: mutate via typed setters (default-layout, enabled,
  priority, name)

Server-side event emission:
- shared/strip-secrets.ts: recursive password scrub
- New JSON admin endpoints: GET/POST /api/admin/{displays,kiosks,
  layouts,entities}[/:id]
- Coordinator-ws fires kiosk.changed on connect/disconnect/heartbeat
- Layout/power/camera routes call nodered.forward() on state change
2026-05-13 02:26:08 +02:00
Mitchell R
b83782b8e0 feat: Node-RED custom nodes + dashboard entity type
Node-RED nodes (nodered/):
- bf-config: shared server URL + admin API key
- bf-event-in: filter kiosk events by topic glob
- bf-layout-switch: POST display layout-switch
- bf-power: kiosk wake/standby
- bf-fan: kiosk fan control
- bf-cameras: query camera list
- Drag-droppable from Node-RED palette

Server:
- Admin Bearer API key auth on /admin/* (NodeRED can call admin API)
- GET /api/admin/cameras for bf-cameras node
- Dashboard entity type:
  - entities.type CHECK adds 'dashboard'
  - entities.dashboard_id column
  - shared/nodered-bridge.ts listDashboards() polls /nrdp/flows
  - Bundle resolves dashboard entity → web cell at /dash/<id>
  - POST /admin/entities/sync-dashboards mirrors Node-RED tabs
  - EntitiesPage shows Dashboards section + Sync button
  - EntityEditPage for dashboard: read-only + "Open in Node-RED"
  - No create/delete from BF UI — managed in Node-RED
- sec-config: noderedUrl on admin-http (was already on api-http)
2026-05-13 01:47:53 +02:00