# Felhom Hub — Changelog ## v0.52.0 — polish batch: F-15 instant reset codes + F-16 inline confirms (2026-07-13) Hub half of the polish batch (take-two findings F-15/F-16). Companion: controller v0.123.0. - **F-15 instant reset codes**: `POST /api/v1/claim/reset-request` now returns the ACTIVE code state in the response — `{claim: {code_hash, generation, issued_at}}`, the exact shape and bcrypt-only guarantee of the report ACK — so the box applies the rotated hash in the same request cycle and the emailed code works immediately (previously the box learned it only on its next report ACK, ~15 min — Viktor's take-two live failure). Served on every authorized outcome (a cap-reached refusal returns the unrotated row = controller-side no-op by generation). Never a plaintext code. Live-proven: apply 1 s after the request; code accepted on first try. The operator "Kód újraküldése" (no box round-trip) still has the ACK lag — its flash + data-confirm copy now say so ("A kód a doboz következő jelentésekor (~15 percen belül) aktiválódik."); the previously unmapped `claim-resent`/`claim-resend-failed` flashes render. - **F-16 inline confirms**: every native `confirm()` in the hub UI (offsite re-issue, freeze/ unfreeze, PBS re-issue, telemetry reset, dismiss-all-issues, regen-password, claim-resend, block/delete, geo-disable) replaced by the LIGHT inline two-step — the shared `inline_confirm.html` partial (`felhomConfirm` + `data-confirm` delegation, `requestSubmit` so formaction survives). Native confirms are OS-modals that froze CDP browser automation (F-16; the drill F-11 siblings). NOT the danger-zone typed-confirm — that heavyweight cascade flow is untouched. New gate `scripts/hub_confirm_gate.py` (zero native confirm/prompt; red-proven). Live-proven: the offsite re-issue completed under automation without freezing. ## v0.51.0 — DR-tier-by-default: per-customer flag + hands-free cascade + offsite coupling + capability chips (2026-07-12) Hub half of the DR-tier-by-default batch (DRILL-day0-vm-2026-07-12; operator decisions 1–5: capability BAKED on every install, activation is THIS flag, DR defaults ON for new customers, identity-only escrow PARKED by policy, WG is base infrastructure). Companion: installer v1.15.0 + agent v0.86.0 (capability `inactive` state). - **Per-customer `dr_tier` flag** (customer_configs column + form checkbox in the renamed "DR tier (PBS, ep0)" section, replacing the old `pbsdr_enabled` form field). NEW customers default ON; legacy rows were initialized FROM REALITY by a one-time migration backfill (host carries an enabled pbs_dr descriptor → ON, else OFF — never auto-cascade a legacy box; backfill runs only on the ALTER that adds the column, so later operator opt-outs survive). - **Cascade semantics** (scenario D): an UNMET precondition (no host / no WG peer / no tenantsync) is no longer a save-blocking error — the flag stores the intent and the edit form shows per-stage status (host enrolled → WG peer → descriptor provisioned → escrow present), reusing the fail-closed guard wording. REAL provisioning failures stay fail-closed (tenantsync error, token-exists → Re-issue). - **Hands-free auto-provision** (scenario A): a host's FIRST WG peer registration fires `PBSDRAutoProvision` (api `SetWGRegisteredHook`, wired when tenantsync is enabled) — a DR-ON customer's descriptor provisions with ZERO operator steps and applies on the agent's next desired-state tick. Detached goroutine; never delays/fails the registration response. - **Offsite requires the DR tier** (scenario C, drill F-6 CLOSED BY POLICY): `applyOffsite` refuses without the flag — exact message "Offsite backup requires the DR tier — enable it first (the escrow ceremony depends on the PBS key)". No more provisioning into the EscrowState-pending-forever dead end. - **Capability chips on the host page** (NEW render surface): the agent's privileged-capability self-check is now visible — ok (blue), degraded (warn / error when critical), and the agent v0.86.0 `inactive` state as a NEUTRAL chip (disabled ≠ degraded). The pre-v1.15.0 pbsdr "binary not found" signature surfaces the migration one-liner (never silently pretend). `.badge-ok` finally defined in style.css (was referenced, fell back to bare `.badge`). - Setup-tab installer copy: `hostInstallVersion` 1.12.0 → **1.15.0** (drill F-1), now gated against the installer's SCRIPT_VERSION by `scripts/hostinstall_gates.py`. - Tests + red-proofs (all four mutations proven red): coupling gate (guard removed → refused case fails), flag default (default flipped → form test fails), backfill (enabled:false ignored → disabled case fails), auto-provision (hook unhooked → scenario A test fails); plus cascade-wait, chips render (inactive-neutral / degraded-stays / migration hint), and the one-time-backfill-survives-reopen case. ## v0.50.0 — customer-claim password arc: code engine + email + ACK/config delivery (2026-07-12) Hub half of the customer-claim password gate (closes DRILL-day0-vm F-4/F-5; needs controller v0.122.0). The customer OWNS the dashboard password — the hub generates a one-time claim code, emails it (Hungarian) to the REGISTERED address, and stores only `bcrypt(code)`. No operator-set path; the plaintext code exists solely inside the email send (the retrieval-passphrase custody rule). - **`internal/claim`** — the code engine. `EnsureIssued` (idempotent — issue+email at the FIRST real config retrieve = Day-0, and at a live box's first report; repeated pulls/reports never rotate or re-send), `Resend` (operator button; rotates generation — unclaimed gets the claim template, claimed gets the reset template), `RequestReset` (controller-forwarded "Elfelejtett jelszó", rate-limited 3/day/customer), `MarkClaimed` (set-only; one confirmation email on the unclaimed→claimed transition). - **`store.customer_claims`** — per-customer `{code_hash, generation, issued_at, emailed_at, claimed_at, reset_day, reset_count}`. `RotateClaimCode` bumps the generation (single active code) and PRESERVES `claimed_at` (a reset never un-claims); `MarkClaimed` is set-only. - **Delivery**: `GET /api/v1/config/{id}` bakes `web.claim_code_{hash,generation,issued_at}` into the generated controller.yaml (gate-from-first-boot) and issues the first code; the report ACK serves the active `claim {code_hash, generation, issued_at}` (allowlisted) and ingests the controller's `claimed` flag (set-only). `POST /api/v1/claim/reset-request` (self-scoped by the box's report key). New emails via the notify dispatcher; `claim_lockout` event allowlisted. - **UI**: the customer page Setup tab shows a claim status chip (Nyitott — kód kiküldve / Claimed) + a "Kód újraküldése" button (`POST /configs/{id}/claim-resend`) — no plaintext code ever rendered (there is none to render). - 15 tests (engine, ACK/config, UI); the arc's red-proofs live in the controller repo (gate) + here (generation bump, reset non-DoS). ## v0.49.0 — Edit tab merge (edit-a), scoped auto-refresh, style.css cache-bust (2026-07-12) > The task spec targeted "v0.48.0", but v0.48.0 (app_start_failed, below) had already shipped + > deployed by the time this train ran — a published tag is never re-pointed, so this is v0.49.0. > Baseline `3e949bc`; commits `e740147` → `2e03de1` → `1d94b1a` → docs/manifest. - **Edit tab merge (edit-a)** (`templates/customer_unified.html`, `templates/config_form.html`, new `templates/config_form_body.html`, `web/configs.go`, `web/pbsdr.go`): the standalone customer edit page merged into the customer page's Settings tab, renamed **Edit**. The form body is a shared `{{define "config_form_body"}}` sub-template (the `host_detail_body` pattern) built by the one `configFormData` view-model builder; the standalone chrome keeps rendering it for the create flow (`/configs/new`) and the validation-error re-render. The Edit tab renders: config form, Controller Update card, Geo card, and a **Danger zone** card holding the Block/Unblock/Delete forms relocated verbatim from the Customer Info header (endpoints + `confirm()` unchanged) — all SIBLINGS after `` (nested forms are invalid HTML and would break the offsite/PBS `formaction` sub-buttons). The header keeps only the config-less Create Config action. `GET /configs/{id}/edit` → 302 `/customers/{id}#tab=edit`; tabs JS gains the `settings`→`edit` legacy-hash alias. - **Server-side required fields on update** (`web/configs.go` `handleConfigUpdate`): the twin of the form's `required` attributes (Display Name + Domain), checked BEFORE provisioning; the error path re-renders the standalone page with the SUBMITTED overrides so typed values are never lost (red-proofed: nil overrides → values reset → test fails). - **Redirect anchors**: update/block/unblock/offsite-reissue/offsite-freeze/pbsdr-reissue → `?flash=…#tab=edit`; regen-password → `#tab=setup` (its card lives there); delete unchanged (`/configs?flash=deleted`). - **Scoped auto-refresh** (`templates/customer_unified.html`): the 60s reload fires only while a live tab (`data-live-tabs="overview,applications,events,host"` on the nav) is active AND no form is dirty (delegated document-level input/change listener, never reset — a reload clears it). Skipped ticks reschedule; a muted `(paused)` hint shows next to the toggle on non-live tabs / dirty forms. Toggle, `hub_auto_refresh` localStorage key, cadence, default-on: unchanged. - **style.css cache-bust** (all `templates/*.html`): every stylesheet link is now `/style.css?v={{hubVersion}}` — closes the v0.47.0 gotcha (`max-age=3600` served stale styling for up to an hour after each deploy). Red-proofed (bare link fails the render test). - **Repo staging rule** (`CLAUDE.md`): never `git add -A` in this repo (the v0.47.0 `146d165` sweep incident) — explicit paths, pull-rebase, one writing session per clone. - Tests: +11 (Group A panel surface / sibling-form / header-count, Group B redirect + create + typed-values-preservation table, Group C refresh structural pins, Group D cache-bust sweep). Amended pins: `customer_tabs_test.go` (settings→edit), `pbsdr_test.go` (postUpdate supplies the now-required fields; FormRendersState asserts the embedded Edit-tab render). ## v0.48.0 — accept the app_start_failed event (controller fix-3, CAMPAIGN-3) (2026-07-12) - `app_start_failed` added to `allowedEventTypes` (`internal/api/handler.go`) + `customerMessages` (`internal/notify/templates.go`). Without the allowlist entry the controller's fix-3 event (a DEPLOYED app found not running — controller v0.120.0) would 400 at ingest and never reach the operator. No other hub change; pairs with controller v0.120.0 which closes the CAMPAIGN-3 finding set. ## v0.47.0 — UI reorganization: customer tabs, Host tab, stale-host removal, offsite multi-endpoint UI, button contrast (2026-07-11) Five hub-side deliverables; no agent/controller/protocol changes. Baseline `8e1a3f0` (v0.46.0); commits `9f29bf3` → `ae950e5` → `146d165`(swept WIP) → `068427a` → `0daddcd`. - **CSS button contrast** (`templates/style.css`): `.data-table td a` → `:not(.btn)` (base + hover) — `` inside data-table cells (host-detail Diagnostics View/Download, customer log-tail buttons) rendered blue-bright on blue-bright, i.e. invisible. Plain table links keep the bright-link style; `.btn` itself untouched, no `!important`. - **Customer page tabs** (`templates/customer_unified.html`, `style.css`): the ~18 stacked sections split into 8 client-side hash tabs (`#tab=` overview / applications / setup / settings / backup / events / notifications / host) + a sticky summary strip (name, status, controller version, last report, containers chip). Graceful degradation is load-bearing: panels hide only under a JS-added `body.js-tabs` class — no JS = every section visible, all existing render tests pass unmodified. Events tab carries a red error-count badge (reuses the already-fetched `CountEventsBySeverity` data — no new query). The auto-refresh reload preserves the hash → the active tab survives. No handler/data-model change for the tabs. - **Host tab + shared sub-template** (`templates/host_detail_body.html`, `web/hosts.go`, `web/configs.go`, `store.ListHostsByCustomer`): the host-detail body extracted into a `{{define "host_detail_body"}}` rendered by BOTH `/hosts/{id}` (chrome + call) and the new per-customer Host tab (a LIST by design — 1 host today, N for a later HA cluster; empty state otherwise). `handleHostDetail`'s data assembly extracted into `hostDetailData`. - **Stale host removal** (`store.CountHostArtifacts`/`DeleteHost`, `web/hosts.go` handlers, routes above the `/hosts/` catch-all): `GET /hosts/{id}/delete-impact` (counts/booleans ONLY) + `POST /hosts/{id}/delete` behind a type-to-confirm dialog (global-floor pattern). Gates: ONLINE host → 409 always (no override — a live agent would 401 forever; enroll is passphrase-gated mint-once); confirm mismatch → 400; escrow present without the explicit checkbox → 409 with the tx never started (`ErrHostEscrowPresent`, fail-safe-to-refuse). One transaction cascades guests, host_reports, signed_jobs, host_recovery, host_pbs_secrets, host-scoped log bundles (`scope_id == host_id` ONLY — customer-scoped bundles survive), the bound wg peer (inside the tx — no stranded peer on crash), escrow (only when acked), then the host row. The wgsync 5-min declarative push converges the endpoint afterwards — no reconciler change. Danger-zone card renders only when deletable, so the hosts-list zero-`