Commit graph

87 commits

Author SHA1 Message Date
Mitchell R
0c74e26e42
feat: expand BF_DATA on first boot + wire update progress banner + partition reporting
- Add betterframe-expand-data systemd service: growpart + resize2fs on
  BF_DATA (last partition) so it fills the full SD card on first boot.
  Solves the "No space left on device" issue with OS update downloads.
- Change OS update staging dir from /var/tmp/betterframe to
  /var/lib/betterframe/tmp (on BF_DATA partition, not rootfs).
- Wire firmware and OS update progress callbacks into the GTK overlay
  banner — shows "OS Update v1.2.3: Downloading — 45%" etc.
- Add per-partition disk reporting in heartbeat (/, /boot/firmware,
  /var/lib/betterframe) with total/used/free/percent.
- Display partition table on kiosk detail page in admin UI.
- PG + SQLite migrations for partitions_json column on kiosks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 08:09:20 +02:00
Mitchell R
66653af360
feat: implement multi-tenant support with PG schema isolation
Adds tenant management for PostgreSQL deployments. Each tenant gets its
own PG schema (tenant_<slug>) with a full set of BetterFrame tables.
SQLite deployments stay single-tenant with no behavior change.

Key changes:
- Run PUBLIC_MIGRATIONS (tenants + global_admins tables) during PG init
- Auto-create "default" tenant (schema=public) on first boot
- createTenantSchema() runs TENANT_MIGRATIONS in a new PG schema
- DbAdapter.setSearchPath() for per-request schema switching (PG)
- Tenant CRUD in Repository (listTenants, create, update, delete)
- Middleware resolves bf_tenant cookie and sets search_path per request
- Admin UI: /admin/tenants with CRUD + tenant switching via cookie
- Tenant dropdown in topbar (Layout) when >1 tenant exists
- Tenant nav item in sidebar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 07:22:01 +02:00
Mitchell R
64f47a9a6b
refactor: migrate all auto-increment PKs to UUIDv7 text IDs
Replace SERIAL/AUTOINCREMENT integer primary keys with UUIDv7 text
IDs across all 15 entity tables (users, api_keys, displays, cameras,
camera_streams, layouts, layout_cells, entities, kiosks, labels,
kiosk_gpio_bindings, event_log, kiosk_logs, audit_log,
camera_event_subscriptions). SetupState keeps id=1 INTEGER singleton.

Changes:
- types.ts: all id fields number->string, all FK fields number->string
- mappers.ts: n(r["id"])->s(r["id"]) for PKs, nn()->sn() for nullable FKs
- repository.ts: import uuidv7, generate IDs before INSERT, remove
  RETURNING id, change all method signatures from number to string
- migrations-pg.ts: SERIAL->TEXT NOT NULL PRIMARY KEY, INTEGER FK->TEXT FK
- bundle.ts: all bundle interface IDs number->string
- pairing.ts, auth.ts: kioskId/userId types number->string
- coordinator-registry.ts: kioskId number->string
- audit.ts: actor_id number->string
- mqtt-bridge.ts: kioskId number->string in publish/subscribe
- All route handlers: Number(getRouterParam)->getRouterParam ?? ""
- admin-pages.tsx: template function params and Map types number->string
- kiosk/src/bundle.rs: flexible serde deserializer that accepts both
  u32 (old) and String (new) IDs for backward compatibility

Fresh PG database -- no data migration needed, just schema changes.
SQLite migrations unchanged (dev-only, recreate DB for UUIDv7).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 07:11:45 +02:00
Mitchell R
69e51197bf
refactor(streams): store RTSP components separately for ONVIF cameras
ONVIF-discovered camera streams now store rtsp_host, rtsp_port, and
rtsp_path as separate columns instead of baking credentials into a
pre-built URL. This fixes XML entity issues (&amp;), special character
password breakage, and credential duplication across streams.

Bundle generation builds the final playable URL at delivery time using
components + camera row credentials with proper URL encoding. Existing
RTSP-type cameras with only rtsp_uri continue to work unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 06:51:33 +02:00
Mitchell R
1cf77f55c9
fix: deliver encrypt_key in claim response
claimPairing returned kioskKey + clusterKey but NOT encryptKey.
Without it, kiosk cant decrypt ONVIF passwords in the bundle,
causing WSSE auth failure and HTTP 400 on all PullPoint
subscriptions. Now included in claim response and API output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 05:36:41 +02:00
Mitchell R
c51d971819
fix: add event_source/event_sink to CameraEventSubscription type
Mapper referenced these fields but type interface was missing them.
Caused tsc failure in Docker build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 05:31:59 +02:00
Mitchell R
24f9532adf
feat(events): mark subscriptions active on event receipt
When /api/kiosk/event receives an ONVIF event, call
markEventReceived(camera_id, topic) to flip subscription
status from pending → active (orange → green in admin UI).
Also added event_source/event_sink fields to subscription mapper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 05:04:11 +02:00
Mitchell R
01d9098af2
chore: gitignore doc files + remove from tracking
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 04:16:33 +02:00
Mitchell R
b0f42d29c2
feat: pre-boot firmware self-update + public endpoints
Kiosk checks for stable firmware update before pairing. If available,
downloads + verifies + swaps binary and restarts. No auth needed.

Server: GET /api/firmware/public/check (stable channel, no auth)
        GET /api/firmware/public/download/:id (rate-limited, no auth)

Kiosk: check_public() + apply_public() in firmware.rs. Called from
ui.rs worker thread before entering pairing loop. kiosk_app_version
made pub for access from ui.rs.

Also includes kiosk_id deserialization fix (Value instead of String).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 04:16:17 +02:00
Mitchell R
6a74b96570
chore: remove accidentally committed doc files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 02:41:01 +02:00
Mitchell R
dba06b63db
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) <noreply@anthropic.com>
2026-05-26 02:40:46 +02:00
Mitchell R
cce9b51887
feat(events): add persistent ONVIF event topic subscriptions with status tracking
Add camera_event_subscriptions table to track per-camera per-topic
subscription state (inactive/pending/active/failed). Refresh-events
handler now merges discovered topics instead of replacing, so topics
are never lost when a camera goes temporarily offline. Admin UI shows
colored status dots and last-event timestamps per topic, with a
"subscribe all inactive" button to queue subscriptions for kiosk pickup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 02:38:43 +02:00
Mitchell R
3ee79b9e83
fix(db): replace enabled = 1 with enabled = true in WHERE clauses
PG BOOLEAN columns cant compare with integer literals. Five
queries used enabled = 1 in WHERE, causing boolean = integer
operator error on kiosk auth, bundle fetch, and heartbeat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 02:08:09 +02:00
Mitchell R
edf3c2e2eb
fix(db): handle PG JSONB as native objects in j() mapper
PG returns JSONB columns as native JS objects, not strings.
j() helper only handled strings via JSON.parse, returning the
fallback for objects. Now passes through objects/arrays directly.
Fixes pairing extras (kiosk_key_plaintext), capabilities, scopes,
and all other JSONB fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 01:58:06 +02:00
Mitchell R
f4be3ee901
fix(db): handle PG Date objects in string mappers
PG driver returns TIMESTAMPTZ as JS Date objects. The s() and sn()
mapper helpers only checked for typeof string, returning null/empty
for Date objects. This broke consumed_at check in pairing (always
null), expires_at comparisons (Invalid Date), and all other
timestamp fields.

Now: Date instances are converted to ISO strings via toISOString().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 01:54:08 +02:00
Mitchell R
c91f9cb450
feat(obs): add observability tracing throughout server
Repository _run/_get/_all now create child spans with db.statement
when an Observable is set via withObs(). Bundle generation and pairing
confirmation accept optional obs for span-based tracing. Key admin
route handlers (camera/layout/kiosk CRUD, cloud sync) log structured
info lines with actor and resource id. Kiosk API routes (heartbeat,
bundle, event, firmware check, OS check) log kiosk_id on entry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 01:47:24 +02:00
Mitchell R
4880dc32fc
feat: onError always logs, onResponse logs status+time, fix debug
onError: always log.error regardless of status code.
onResponse: log info with response status + duration in ms.
claimPairing: debug changed to info (debug not working in BSB).
Timestamps tracked via _startMs on event context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 01:41:16 +02:00
Mitchell R
925d9fd6dc
feat: pass request obs into claimPairing for traced logging
claimPairing now receives the request Observable and logs the
specific reason for pending (not_found/expired/not_consumed/
missing_key). Success logged at info level with kiosk_id.
All logs correlated via request trace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 01:32:28 +02:00
Mitchell R
e3254ed46b
fix(db): rewrite INSERT OR IGNORE to ON CONFLICT DO NOTHING for PG
SQLite INSERT OR IGNORE syntax not valid in PG. PG adapter now
auto-rewrites to INSERT INTO ... ON CONFLICT DO NOTHING. Fixes
attach layout, label assignments, and join table inserts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 00:12:25 +02:00
Mitchell R
41e9991891
fix(db): replace remaining hardcoded 0/1 with boolean params
setup_state.is_complete, cluster_key_provisioned, display.is_primary,
event_log.forwarded_to_nodered all had literal 0/1 in SQL strings.
PG rejects integer for BOOLEAN columns. Changed to ? params with
true/false values — SQLite adapter coerces to 1/0 automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 05:18:43 +02:00
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