- Sidebar: NavGroup component (details/summary) for AbleSign dropdown
with Accounts, Screens, Content, Playlists sub-items
- Global screens page (/admin/ablesign/screens) — all screens across
accounts with Internal/External badge
- Content page — aggregates media files + web apps from all accounts
- Playlists page — shows per-screen playlist items
- Auto-sync screens on account creation
- Internal/External: Internal = created via "Create & Pair" (has
screenToken, gets entity). External = synced from AbleSign (no token,
no entity, management-only). Only internal screens become entities.
- Entity creation only on Create & Pair path — not on sync or assign
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server-side:
- Bundle gen: when camera event_source is "auto", first kiosk to fetch
bundle claims ownership → writes "kiosk:<id>" to camera row. Other
kiosks see assigned owner and skip ONVIF subscription.
- Kiosk deletion resets event_source back to "auto" so next kiosk
takes over.
- repo.getActiveOnvifOwners() for future use.
Kiosk-side:
- Only subscribe when event_source is "auto" or "kiosk:<MY_ID>".
Skips "kiosk:<other_id>", "server", "none", "disabled".
- Poll interval: 3s → 10s (cameras were getting overwhelmed)
- CreatePullPoint backoff: exponential 30s→60s→120s...→600s max
- Pull errors: exponential 15s→30s→45s...→300s, resubscribe after 5
consecutive failures instead of immediately.
- load_kiosk_id() helper reads from cached bundle.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server already ran past the indices where AbleSign tables were inserted.
Moving to end ensures they get new, unrun version numbers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
validateBody now extracts per-field error messages from anyvali issues.
Event endpoint logs the raw body (first 500 chars) on validation failure
so we can see exactly what the kiosk sends.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SET CONSTRAINTS ALL DEFERRED only works on DEFERRABLE constraints.
Ours aren't. Instead: save all FK definitions to jsonb array, drop
them all, do the id replacements unconstrained, re-add from saved
definitions. Same pattern as the type-conversion migration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds migration that finds all rows where id matches ^[0-9]+$ (legacy
integer IDs converted to text strings), generates a UUID for each,
updates all FK references dynamically via information_schema, then
updates the PK. Existing data (cameras, layouts, kiosks, etc.) gets
proper UUID IDs. New rows already use UUIDv7 from repository.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dynamic column detection only matched 'id' and '*_id' patterns.
firmware_releases.uploaded_by and similar FK columns use '_by' suffix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous migration hardcoded ALTER TABLE ADD CONSTRAINT for FK re-add,
but production DB may have different columns than CREATE TABLE schema
(api_keys had no user_id). Now uses _bf_add_fk() helper that checks
both source column and target column exist before adding FK. Skips
silently if either is missing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
setup_state is a singleton (INTEGER PK CHECK(id=1)), not an entity.
Converting its id to TEXT breaks the CHECK constraint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous migration hard-coded FK constraint names and missed
firmware_releases.uploaded_by, firmware_rollouts.created_by/release_id,
os_update_releases.uploaded_by, os_update_rollouts.created_by/release_id,
pairing_codes.consumed_by_kiosk_id, entities.camera_id.
Now uses information_schema to dynamically drop ALL FK constraints
before type conversion, and dynamically finds ALL integer id/*_id
columns to convert. No more missed FKs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Delete sqlite-adapter.ts and migrations.ts (SQLite path removed)
- Remove driver/sqlitePath from all config schemas + sec-config template
- init.ts now PG-only, no SQLite branch
- db-adapter.ts dialect narrowed to "postgres" only
- Add in-place UUIDv7 migration: detects INTEGER PKs in existing PG
databases, drops FK constraints, ALTER COLUMN TYPE to TEXT for all
15 entity tables + their FK columns, re-adds FK constraints. Idempotent
(skips if already TEXT). Existing integer IDs become string "1", "2"
etc — new inserts use proper UUIDv7 from repository.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 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>
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>
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 (&), 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
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>
- 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>
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>
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.
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.