Commit graph

348 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
2e8e783eed
feat(kiosk): update progress banner framework
Added WorkerMsg::UpdateProgress(Option<(label, percent)>) for
showing firmware/OS update progress as an overlay banner on the
display. Handler + label management in place. Actual progress
reporting from firmware.rs/os_update.rs to be wired next.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 07:51:42 +02:00
Mitchell R
bde26a7ed4
feat(kiosk): show FW+OS version on awaiting-layout screen too
Version label was only on pairing screen. Now also shown on the
idle/awaiting-layout logo screen (bottom-left overlay).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 07:38:34 +02:00
Mitchell R
de0a76e01d
fix(onvif): extract SOAP fault reason + log auth state + NTP setup
SOAP errors now extract fault Reason/Text/Code from XML instead of
dumping raw envelope. Logs whether ONVIF password was decrypted
(has_pass=true/false). Added NTP config to pi-gen (pool.ntp.org +
Google/Cloudflare fallback) — WSSE PasswordDigest fails with clock
skew.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 07:33:18 +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
b6e929d2ad
fix: decode XML entities in ONVIF RTSP URIs
ONVIF returns XML with &amp; in URIs. GStreamer rtspsrc cant parse
these. Now decoded before storing in camera_streams. Fixes RTSP
Unauthorized for ONVIF-discovered cameras with query params.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 06:44:25 +02:00
Mitchell R
2a21ababc0
fix(ui): add source/sink columns to event subscriptions + full width
Event subscriptions table now shows Source and Sink columns.
Camera detail page uses full width instead of max-width 700px.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 06:26:49 +02:00
Mitchell R
69e6ce1cbc
fix(kiosk): cleanup cameras removed from layouts
ONVIF: only subscribe to cameras actually in layout cells, not all
bundle cameras. Purge warm camera pool entries for cameras removed
from the bundle entirely — immediate stop, no cooling period.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 06:25:02 +02:00
Mitchell R
7b9ba13e3a
fix(kiosk): extract RTSP creds as rtspsrc properties for digest auth
Credentials embedded in RTSP URL can skip digest negotiation on
some cameras. Now extract user:pass from URL, set as user-id/user-pw
properties on rtspsrc, pass clean URL as location.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 06:23:38 +02:00
Mitchell R
36a787cc1e
feat(kiosk): show FW + OS version on pairing screen, remove spinner
Version label at bottom-left of pairing screen shows firmware
version (compile-time) and OS version (from /etc/betterframe/
os-version). Spinner removed from pairing screen per request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 05:46:08 +02:00
Mitchell R
26a9df6b11
fix(kiosk): return None after ignoring bf_kiosk_deleted
After _check confirms key still valid, code fell through to parse
the bf_kiosk_deleted JSON as a KioskBundle causing parse error.
Now returns None to skip bundle processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 05:44:32 +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
37cf03e37c
fix(kiosk): include response body in ONVIF SOAP error messages
HTTP 400 from camera gave no detail. Now includes first 500 chars
of response body in error message so Axiom shows the actual SOAP
fault reason.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 05:33:29 +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
6b31c09e9d
fix(kiosk): set Axiom kiosk_id from bundle on startup
kiosk_id only set during claim. After restart, loaded from disk,
kiosk_id stayed empty. Now set from bundle.kiosk_id after fetch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 05:31:15 +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
2592a18564
fix(ci): pass Axiom secrets to reusable build workflow
Secrets dont auto-propagate to reusable workflows. Added
BF_AXIOM_KEY + BF_AXIOM_DATASET to both release.yml secrets
block and build.yml workflow_call.secrets declaration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 04:26:35 +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
bc7b7695d8
fix(kiosk): accept integer kiosk_id in claim response
Server returns kiosk_id as integer (not yet migrated to UUIDv7).
ClaimResp.kiosk_id changed from Option<String> to Option<Value>
to handle both integer and string. This was causing a panic on
deserialization after successful pairing.

Also simplified Coolify version arg.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 04:06:00 +02:00
Mitchell R
cefd378b67
fix(docker): simplify version arg to COOLIFY_GIT_COMMIT
Nested dollar-brace defaults dont resolve in Docker Compose.
Use COOLIFY_GIT_COMMIT directly which Coolify always provides.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 03:36:09 +02:00
Mitchell R
a03a0d17de
fix(rauc): increase rootfs headroom from 25% to 50%
25% was too tight — firmware OTA writes to /opt/betterframe/kiosk/
on the rootfs and fills it. 50% headroom gives enough space for
the kiosk binary download + swap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 02:46: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
aa068a32f1
feat(kiosk): double-verified auto-wipe on server-side deletion
Server returns {bf_kiosk_deleted: true} (200) instead of 401 when
kiosk key not found on bundle/heartbeat. Kiosk then confirms via
GET /api/kiosk/_check — only wipes config if _check also returns
401. Prevents proxy glitches from nuking valid kiosks.

Flow: bf_kiosk_deleted signal → confirm via _check → 401 = wipe,
200 = ignore (false alarm).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 02:17:50 +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
85f8456cf0
fix: onError uses init obs when request trace missing
If event.context.obs not set, fall back to init-level obs and
flag no request trace in error message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 01:34:14 +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
5768e325b1
feat(kiosk): include kiosk_id in Axiom log entries after pairing
After successful claim, kiosk_id from server response is stored
globally and included in all subsequent Axiom log entries for
kiosk identification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 01:24:52 +02:00
Mitchell R
05853473a1
feat(kiosk): Axiom log forwarding via tracing layer
Kiosk binary now forwards all tracing logs to Axiom when
BF_AXIOM_KEY + BF_AXIOM_DATASET are set at compile time via
option_env!(). Batches up to 50 entries or flushes every 10s.
No-op when keys not baked in (local dev builds).

CI build.yml passes secrets as env vars for cargo build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 01:23:13 +02:00
Mitchell R
35d184a6dd
fix: use pluginCwd for static files + info log on request
Static file path now uses BSB pluginCwd instead of import.meta.dirname.
Added info log with method+path on every request via per-request trace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 01:20:17 +02:00
Mitchell R
57348b14ab
feat: per-request tracing via BSB createTrace
Each HTTP request gets a fresh BSB trace (not a child span of init).
onRequest creates trace, stores on event.context.obs. onError logs
with trace context. onResponse ends the trace. 4xx logged as warn,
5xx as error. H3EventContext typed with obs field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 01:02:26 +02:00
Mitchell R
0113e4e54a
feat: log 5xx errors to BSB observable via h3 onError
Both admin-http and api-http now log HTTP 500+ errors with status,
path, and error message to BSB observable (warn level). Makes
server-side errors visible in Coolify/container logs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 00:55:17 +02:00
Mitchell R
e189d67faa
fix(rauc): lowercase PARTUUID + format BF_DATA + remove resize
- PARTUUID in cmdline.txt and fstab must be lowercase (initramfs
  does case-sensitive match)
- BF_DATA partition now formatted as ext4 with label (was zeroed)
- Remove resize flag from cmdline.txt (breaks GPT layout)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 00:22:58 +02:00
Mitchell R
e7cf1d1e4f
fix(rauc+kiosk): use PARTUUID not LABEL, add XDG_RUNTIME_DIR
Repartition: reverted auto_initramfs=0 (Pi 5 needs initramfs).
Now creates GPT first, reads PARTUUIDs via sfdisk -J, patches
cmdline.txt with real PARTUUID. Initramfs resolves PARTUUID
natively.

Kiosk service: added XDG_RUNTIME_DIR=/run/user/1000 — cage
needs this for Wayland socket creation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 22:24:22 +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
649c64728a
fix(pi-gen+rauc): stop purge from removing cage + fix boot
1. Desktop purge was using wildcard lx* which removed libwlroots
   and cage as dependency. Now uses specific package names +
   apt-mark manual cage to protect it from autoremove.
2. Per-user cursor theme for bfkiosk (~/.icons/default/index.theme).
3. Repartition disables auto_initramfs in config.txt (initramfs
   cant resolve LABEL= roots). Also handles root=/dev/* format
   in cmdline.txt sed replacement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 23:54:21 +02:00
Mitchell R
1ff0f9928c
fix(rauc): remove BF_BOOTSEL, boot from partition 1
Pi 5 bootloader needs config.txt on partition 1. Old layout had
BF_BOOTSEL there with only autoboot.txt. Now 5 partitions:
BF_BOOT_A(1), BF_BOOT_B(2), BF_ROOT_A(3), BF_ROOT_B(4), BF_DATA(5).
autoboot.txt on each boot partition for A/B tryboot switching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 06:06:52 +02:00
Mitchell R
d7f3b12488
fix(pi-gen): create bfadmin in chroot, remove pi-gen username/password
Pi-gen username/password config triggers firstboot wizard AFTER
custom stages — reinstalls userconf and undoes our purge. Removed
those params from pi-gen-action config. Now create bfadmin user
directly in chroot script with password expiry on first login.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 06:00:58 +02:00
Mitchell R
9d27fe9323
fix(kiosk): nuclear piwiz kill — purge entire desktop + force cmdline
Piwiz still appeared after package purge alone. Now:
1. Purge ALL desktop packages (lxde, labwc, wayfire, lightdm, xorg)
2. autoremove orphaned deps
3. Force systemd.unit=multi-user.target in kernel cmdline

No desktop = no piwiz. Period.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 05:22:54 +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
6ec2dcbec4
fix(docker): align BF_PG_DATABASE to BF_PG_DB
Compose postgres uses POSTGRES_DB from BF_PG_DB. Dockerfile ARG
was BF_PG_DATABASE causing mismatch. PG error 3D000 db not found.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 05:06:56 +02:00