From f4c2c4151b717756b20b8911c55beeb36ff0ddd4 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 22 Jul 2026 09:23:37 +0200 Subject: [PATCH] hub v0.70.1: the ghost customer's Delete button must exist (Danger-zone render gate split) --- CLAUDE.md | 5 + CONTEXT.md | 12 ++ documentation/backlog/ROADMAP.md | 4 +- hub/CHANGELOG.md | 30 +++++ hub/internal/web/configs.go | 20 +++ .../web/customer_ghost_delete_render_test.go | 120 ++++++++++++++++++ .../web/templates/customer_unified.html | 12 +- 7 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 hub/internal/web/customer_ghost_delete_render_test.go diff --git a/CLAUDE.md b/CLAUDE.md index fdda5ca..19f4254 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,11 @@ UI. Package map, helpers, seams, extension points: **`REUSE.md`** (e.g. new even - Add debug capabilities (logging, verbose output). - If you need more input or troubleshooting output, **ask first — don't guess**. - Testing doctrine (non-hollow tests, red-proofs, seams): use the `felhom-testing` skill. +- **Seam-wiring rule — and it covers TEMPLATE GATES (fourth inert seam, hub v0.70.1):** a feature + is not shipped until its entry point is reachable. For UI, any conditional affordance + (`{{if .Flag}}` around a button/form/script) ships with a render test per branch of the gate — + handler tests that POST directly prove nothing about reachability. The v0.70.0 ghost-delete was + fully implemented server-side and fully dead UI because the button sat inside the wrong gate. - UI/design work (tokens, gates, copy rules): use the `felhom-ui-design` skill. - **Logging**: levels/English/no-secrets rules per `documentation/runbooks/logging-conventions.md` (DEBUG = flow detail, INFO = state change + duration; logs are operator-tier English; keys never diff --git a/CONTEXT.md b/CONTEXT.md index 58fb01c..f046214 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -3,6 +3,18 @@ > Created with the REUSE.md rollout (2026-07-03). Authoritative history: `hub/CHANGELOG.md` (hub), > `website/CHANGELOG.md`, `scripts/CHANGELOG.md`; end-of-task detail in `REPORT.md`. +- **2026-07-22 — hub v0.70.1: the ghost customer's Delete button exists now.** The v0.70.0 + ghost-delete path was fully implemented and fully unreachable — the **fourth inert-seam defect**, + this time a TEMPLATE GATE: the Danger-zone card (and the `customerDeleteOpen` script) sat inside + `{{if .HasConfig}}`, so a ghost rendered no Delete button (found live on `demo-vm-felhom`). + Fix: `Deletable` page flag = the exact negation of the delete preview's 404 predicate; RESET + stays HasConfig-gated, Block/Unblock config-only. Render tests per branch + 2 red-proofs. + **The seam-wiring rule now covers template gates** (see CLAUDE.md) — handler tests that POST + directly prove nothing about reachability. **Operator live leg PENDING: the demo-vm-felhom ghost + delete click** (doubles as v0.70.0+v0.70.1 live validation). Same day: **agent 0.93.0 published + + deployed fleet-wide** (`pilot/RUNBOOK-publish-agent-0.93-2026-07-22.md`), **R-17 CLOSED** + (u629193-sub1 deleted at the console). + - **2026-07-21 — THE FLEET IS TWO HOSTS.** A second Proxmox node exists: **`demo-hp-bb76ea`**, an **HP t740 Thin Client** (Ryzen V1756B, 30 GiB, PVE node `felhom-host`, customer `demo-hp`), installed from the armed universal ISO the same day. Both hosts run **agent 0.92.1**; the N100 is diff --git a/documentation/backlog/ROADMAP.md b/documentation/backlog/ROADMAP.md index f7efcb2..607d9b5 100644 --- a/documentation/backlog/ROADMAP.md +++ b/documentation/backlog/ROADMAP.md @@ -50,7 +50,7 @@ | R-11 | Tester-facing one-pager: what the box does, known limitations, how to report | S | **RULED 2026-07-21 (channel); doc is the architect's** | Pairs with R-3. ****operator ruling 2026-07-21**: the reporting channel is DIRECT MESSENGER to the operator** — no group, no tracker, no form. Rationale: the expected tester count at first invite is ~0–few, and a channel nobody is on is worse than a channel everyone already uses. **The one-pager and the tester agreement are the ARCHITECT's deliverable, not CC's** — CC does not write them; it may be asked to fact-check claims in them against the code. Revisit the channel only if tester count makes direct messages unmanageable | | R-16 | Operator hygiene: campaign6 autofs orphan (clears on host reboot) + tied-CreatedAt flash duplicates (audiobookshelf/komga/romm) | XS | open (doc-drift bit CLOSED) | Viktor's own action items from 6D/6E. **Doc-drift leftover CLOSED (host-install v1.17.0, 2026-07-17):** the R-20-noted stale "EMPTY by default" operator-key comment corrected (keys are PINNED). Remaining = the two operator items above | | R-22 | **PBS-DR pre-check self-grant (F4).** On a non-default storage id the token-auth `GET /storage/` pre-check 403s (no ACL yet) and used to abort before the root-run `grant` that creates it. | S | **SHIPPED + PROVEN-LIVE agent v0.89.0** (2026-07-17) | On a 403 the reconcile self-grants via the root wrapper + re-reads, then converges. Red-proof `TestSelfGrant_PreCheck403DoesNotAbortBeforeGrant`; live-reproduced on the demo (marker aside + ACLs revoked → self-grant → `converged state=adopted` in ~3 s, ACLs restored, offsite active). Origin `tests/VALIDATION-n100-baremetal-2026-07-16.md` F4. | -| R-17 | Old-box archive (u629193-sub1) retirement — 9/9 byte-identical restores verified | XS | **RULED 2026-07-21 — DELETE; execution is an operator console action** | **operator ruling 2026-07-21**: delete the archive now; the 9/9 byte-identical restores are the evidence it is no longer needed. **CC spike-lite (2026-07-21) — it CANNOT be deleted through existing tooling, and CC did not improvise credentialed API calls:** (a) `felhom-tenantsync.sh` does not touch Hetzner at all — it manages PBS namespaces/tokens only; (b) the hub CAN delete sub-accounts (`hetznerapi.DeleteSubaccount`), but its ONLY caller is `offsite.Deprovision`, which selects by the `felhom-customer=` label and runs as part of a customer RESET — a legacy archive sub-account from the 2026-07-09/10 spikes carries no such label, so no tooling verb targets it; (c) the only in-repo references are TEST FIXTURES (`hub/internal/hetznerapi/fake.go` generates the `u629193-sub%d` pattern; `felhom-controller/.../dr_recipe_test.go` uses it as a literal) — nothing live points at it. **Operator console steps:** Hetzner Storage Box console → box `u629193` → Sub-accounts → `u629193-sub1` → confirm the directory holds only the old-box archive → Delete. Deleting the sub-account destroys its data; the 9/9 verification is the sign-off | +| R-17 | Old-box archive (u629193-sub1) retirement — 9/9 byte-identical restores verified | XS | **CLOSED 2026-07-22 — archive deleted (operator console)** | **operator ruling 2026-07-21**: delete the archive now; the 9/9 byte-identical restores are the evidence it is no longer needed. **CC spike-lite (2026-07-21) — it CANNOT be deleted through existing tooling, and CC did not improvise credentialed API calls:** (a) `felhom-tenantsync.sh` does not touch Hetzner at all — it manages PBS namespaces/tokens only; (b) the hub CAN delete sub-accounts (`hetznerapi.DeleteSubaccount`), but its ONLY caller is `offsite.Deprovision`, which selects by the `felhom-customer=` label and runs as part of a customer RESET — a legacy archive sub-account from the 2026-07-09/10 spikes carries no such label, so no tooling verb targets it; (c) the only in-repo references are TEST FIXTURES (`hub/internal/hetznerapi/fake.go` generates the `u629193-sub%d` pattern; `felhom-controller/.../dr_recipe_test.go` uses it as a literal) — nothing live points at it. **Operator console steps:** Hetzner Storage Box console → box `u629193` → Sub-accounts → `u629193-sub1` → confirm the directory holds only the old-box archive → Delete. Deleting the sub-account destroys its data; the 9/9 verification is the sign-off. **CLOSED 2026-07-22: the operator executed the console action — `u629193-sub1` deleted, confirmed no sub-accounts remain on PBS-storage-1** | | R-19 | Internet-outage customer-experience drill: pull WAN on demo, verify lan_resolver path, document what the customer actually sees/does | S | idea | Flips map row E "LAN access" IMPLEMENTED→PROVEN-LIVE | | R-20 | ~~Verify operator-key pinning is fully in the day-0 install flow~~ | XS | **closed** (2026-07-16) | Confirmed against `scripts/felhom-host-install.sh` source (not changelog): keys resolve at L1181–1219 (script constants `OPERATOR_KEY_*`, populated, `--operator-pubkey-file` override), pinned automatically by `step_agent_config()` "STEP 6/8" (L2044; python builds `authz.signers` L2146–2156, reinstall preserves existing), verified at L2332–2337 ("authz signers: N … operator-signed self-update armed"). No interactive prompt or post-install hand-edit — fully automatic. Doc-drift note: the L193–197 "EMPTY by default" comment is stale vs the now-populated constants (→ R-16 hygiene) | | R-23 | **Immediate-sync Direction-2 follow-ups** (hub v0.58 / controller v0.140, 2026-07-16): **(a) — BANKED 2026-07-21 (both legs).** The operator-UI save->apply round trip is PROVEN: the STOP-2 global-floor save (hub `18:56:27 CEST`) released the controller's held wait in the **same second** (`16:56:27Z wait woke: generation=1 - firing out-of-cycle report`), with the report built 2 s later; the ring also shows `wait baseline generation=0` at startup (baseline recorded WITHOUT firing, as designed) then `generation=1`, so the generation advanced past 0. **RESTART LEG BANKED 2026-07-21 — the floor was moved to a version the box did NOT run, and the swap fired EXACTLY ONCE.** Operator saved global floor 0.153.0 → **v0.154.0** (a real version boundary, unlike the 2026-07-20 attempt which targeted an already-running version and therefore proved nothing). Timeline (guest UTC): `06:57:13` UpdateState `pending` written `initiated_by=auto-floor` → `06:57:17` agent `controller swap requested 0.153.0 -> 0.154.0` → `06:57:19` `image file written, restarting bootstrap` → `06:57:21` container StartedAt + UpdateState `completed_at` → `06:57:29` agent `new controller healthy`. **16 s end to end.** Assertions over the whole window (06:50 → 07:29, 39 min): `controller swap requested` = **1**, agent-driven bootstrap restarts = **1**, `new controller healthy` = **1**, rollback/swap-failed/unhealthy = **0**, container `RestartCount` = **0**. `VerifyStartup` banked it on the next boot (`Post-update startup: update successful (0.153.0 → 0.154.0)`) and the `06:57:52` periodic check logged `Current version 0.154.0 is up to date` — the at/above-floor branch correctly doing nothing. **No storm, no rollback, no second attempt.** *Caveat, disclosed: a hand-deploy of v0.155.0 at `07:17:10` sits inside the observation window and is what StartedAt shows after that point; it never touches `SwapController`, so the swap-count assertions above are uncontaminated across the full window. A second, unplanned confirmation of the at/above-floor branch came with it — after the hand-deploy the box ran 0.155.0 against a 0.154.0 floor and the updater logged `Current version 0.155.0 is up to date` and did nothing.* Evidence: `felhom-controller/REPORT.md` §6 (2026-07-21). *(Superseded note:)* **the self-restart single-fire leg was** - the floor was set to a version the box ALREADY ran, so there was no work and no restart. Finish by bumping the floor to a version the box does NOT run, debug ring open, asserting EXACTLY ONE restart. **Trap found while banking this: the wake is `logx.Debugf`, so it is INVISIBLE in `docker logs` at INFO** and lives only in the debug ring (`GET /api/debug/logs?level=DEBUG`) - a hunter looking at stdout wrongly concludes the box never woke; arguably (b) generalised. (b) cosmetic: the Waiter's "recovered" INFO logs on the next hold completion (`pollOnce` blocks ~240 s), not at reconnect | S | **(a) BANKED in full; only (b) cosmetic remains** | Map row "config/state change round-trips in seconds" flipped PARTIAL->PROVEN-LIVE 2026-07-21 on this evidence. Evidence: `felhom-controller/REPORT.md` 4f | @@ -84,7 +84,7 @@ | R-26 | **Guided old-history recovery via a retained superseded escrow + the recovery code.** Enabled by hub v0.60.0 (Part B) which now RETAINS superseded escrow blobs (`host_escrow_superseded`, `ListSupersededEscrow`). Build the flow that, given the customer's recovery code, unwraps a retained old blob → recovers the old repo passphrase → mounts/reads the moved-aside `.orphaned-` repo for restore. | M | idea (enabled by v0.60.0) | Turns "history recoverable in principle" into a real customer-drivable path; pairs with the controller v0.142.0 orphaned-repo move-aside. Origin `DIAGNOSE-offbox-repo-orphaned-2026-07-17` | | R-27 | **Customer-facing self-bind page (R-21 slice C follow-on).** Today an unclaimed appliance is bound by the OPERATOR on the Hosts page (hub v0.62.0). Build the customer-facing flow so a customer can claim/bind their own freshly-installed box. | M | **SHIPPED (slice 1, hub v0.66.0, 2026-07-17)** | **Slice 1 = the FIRST-box flow, DONE:** operator "Send self-bind link" → 7-day tokenized capability link over Hungarian email → public two-factor `/bind/` (console pairing code shown on the box screen + retrieval passphrase) → hub stages the bind via the same `BindAppliance`, no operator. Viktor's 3 rulings verbatim (console code not a list; operator-sent link; 5-attempt lockout→call support). No oracle; expiry falls back to operator-bind; THE TRAP `/bind/` exemption tight (single predicate, 4 red-proofs). GC verdict §3 (no appliance GC → the 7-day TTL stands alone). **Slice 1 PROVEN-LIVE 2026-07-18:** in the rehearsal the operator minted + emailed the link at 16:28:55 and **the customer bound their own box at 16:29:55 with zero failed attempts**, `appliance_bound` carrying source `customer_selfbind`, credential delivered 26 s later with no operator action (`tests/VALIDATION-n100-rehearsal-2026-07-18.md`). The new-ISO console banner rendered on the real box. **Slice 2 = console-passphrase bind → R-27c** (below). Reused the appliance_registrations + one-shot delivery machinery; the capability link is the whole customer-auth surface (no login system built). Controller/agent untouched. See hub v0.66.0 REPORT. **Multi-box per link is out of scope by design** (single-active, one-shot → binding several boxes = repeated operator sends) | | R-27b | **Customer self-bind, second-box flow (controller side).** For a customer who ALREADY has a bound box and installs another, the controller shows a dismissable "bind another box" prompt (and a bind-later entry under settings) that walks to the hub `/bind/` page — so a returning customer isn't emailed a fresh operator-sent link for every box. Mechanism sketched in the hub v0.66.0 REPORT; NOT built (R-27 slice 1 deliberately did not touch the controller). | M | idea (minted by hub v0.66.0) | Origin: hub v0.66.0 slice-1 ship (first-box only). Reuses the same `/bind/` public page + tokenized-link machinery; adds a controller-side entry point + the operator "mint a link for an existing customer" affordance | -| R-25b | **RULED: customer DELETE becomes a guided full-teardown cascade.** The middle-tier Customer RESET (hub v0.61.0) runs the full external teardown (Hetzner sub-account/box + PBS namespace/groups/token) and refuses while any host row exists. The Danger-zone DELETE still (a) leaves host rows and (b) does NOT run that teardown. | M (was S) | **SHIPPED hub v0.69.0 (2026-07-21)** | **operator ruling 2026-07-21**: **DELETE subsumes the whole cascade, behind explicit consent.** Three separate acknowledgements, each its own checkbox — (1) the host(s) will be deleted, (2) the customer will be RESET including external teardown and offsite data destruction, (3) the customer record and escrow will be purged — plus a **typed customer-name confirmation** before the button arms. Internal order is **host-delete → RESET → delete**, which preserves every existing invariant rather than relaxing any: RESET keeps its no-hosts precondition (hosts are already gone by then), and escrow keeps its demote-then-purge custody rule (host delete DEMOTES to retained custody, the final delete PURGES — the one true purge point). **Re-sized S → M: this is a multi-step destructive wizard with three acks and a typed confirmation, not a checkbox.** Implementation is explicitly NOT part of TASK-E; the row carries the ruling and awaits its own spec. **It no longer blocks R-3** — the model is decided, so the friend-alpha runbook can be written against it. **IMPLEMENTED per the ruling (TASK-I, hub v0.69.0):** `POST /configs/{id}/delete` now runs `hosts → RESET → purge`; three acks + typed customer-id + a stale-preview check + the ONLINE-host refusal, all gates before any write (zero side effects on refusal); custody purged exactly ONCE in leg 3 (leg 2 runs with `purgeEscrow=false`); ruling-3 preserved BY CONSTRUCTION and asserted from inside leg 2; failed legs retain the journal and the dialog offers Resume. Standalone RESET byte-identical. 5 red-proofs. Offboarding guidance: `runbooks/RUNBOOK-onboarding-draft-v4.md` §G. **v0.70.0 follow-up (same day, found validating against the live hub):** a completed delete still left the customer on the Customers list and still ALERTING, because `GetCustomers()` is report-derived and no tier ever deleted a report — new **residue** leg (reports/telemetry/log-tails/notif-prefs + the credential-bearing `appliance_registrations`/`selfbind_tokens`), and **ghost customers are now deletable** (404 = nothing here, not no-config-row) | +| R-25b | **RULED: customer DELETE becomes a guided full-teardown cascade.** The middle-tier Customer RESET (hub v0.61.0) runs the full external teardown (Hetzner sub-account/box + PBS namespace/groups/token) and refuses while any host row exists. The Danger-zone DELETE still (a) leaves host rows and (b) does NOT run that teardown. | M (was S) | **SHIPPED hub v0.69.0 (2026-07-21)** | **operator ruling 2026-07-21**: **DELETE subsumes the whole cascade, behind explicit consent.** Three separate acknowledgements, each its own checkbox — (1) the host(s) will be deleted, (2) the customer will be RESET including external teardown and offsite data destruction, (3) the customer record and escrow will be purged — plus a **typed customer-name confirmation** before the button arms. Internal order is **host-delete → RESET → delete**, which preserves every existing invariant rather than relaxing any: RESET keeps its no-hosts precondition (hosts are already gone by then), and escrow keeps its demote-then-purge custody rule (host delete DEMOTES to retained custody, the final delete PURGES — the one true purge point). **Re-sized S → M: this is a multi-step destructive wizard with three acks and a typed confirmation, not a checkbox.** Implementation is explicitly NOT part of TASK-E; the row carries the ruling and awaits its own spec. **It no longer blocks R-3** — the model is decided, so the friend-alpha runbook can be written against it. **IMPLEMENTED per the ruling (TASK-I, hub v0.69.0):** `POST /configs/{id}/delete` now runs `hosts → RESET → purge`; three acks + typed customer-id + a stale-preview check + the ONLINE-host refusal, all gates before any write (zero side effects on refusal); custody purged exactly ONCE in leg 3 (leg 2 runs with `purgeEscrow=false`); ruling-3 preserved BY CONSTRUCTION and asserted from inside leg 2; failed legs retain the journal and the dialog offers Resume. Standalone RESET byte-identical. 5 red-proofs. Offboarding guidance: `runbooks/RUNBOOK-onboarding-draft-v4.md` §G. **v0.70.0 follow-up (same day, found validating against the live hub):** a completed delete still left the customer on the Customers list and still ALERTING, because `GetCustomers()` is report-derived and no tier ever deleted a report — new **residue** leg (reports/telemetry/log-tails/notif-prefs + the credential-bearing `appliance_registrations`/`selfbind_tokens`), and **ghost customers are now deletable** (404 = nothing here, not no-config-row). **v0.70.1 (2026-07-22): the ghost delete was implemented but UNREACHABLE** — the Danger-zone card (and the `customerDeleteOpen` script) sat inside `{{if .HasConfig}}`, so a ghost rendered no Delete button at all (the fourth inert-seam defect; handler tests POST directly and proved nothing about reachability). Render gate split: RESET stays HasConfig-gated, Danger zone gates on `Deletable` (the exact negation of the preview's 404 predicate), Block/Unblock stay config-only; render tests per branch + 2 red-proofs. **Operator live leg: the demo-vm-felhom ghost delete click — PENDING** (doubles as the v0.70.0+v0.70.1 live validation; expect `residue=ok customer_delete=ok` with `skipped_no_config` Hetzner/descriptor legs, staleness emails stop) | | R-25 | **Device-node TOCTOU hardening (drive init).** Graduate the controller v0.141.0 Observation: the `format → resolveEnrollUUID(path) → AssignDisk(uuid)` sequence has a narrow /dev-re-enumeration window (agent-guarded on the destructive format via anti-retarget durable-id; benign fs-UUID mount). Bind resolve+assign to the format's durable-id so the mount can't target a moved node. | S | idea | From the v0.141.0 F6 commit's security-review finding (`felhom-controller` REPORT). Low real risk (single-operator, agent-guarded), but cheap to close | | R-24 | **Guest RAM resize (live) — SHIPPED (agent v0.90.0 + controller v0.143.0, 2026-07-17).** The customer right-sizes the guest's memory from the controller's Rendszer page; the agent enforces every bound fresh + applies via PVE `SetConfig` (live cgroup, no reboot, Phase-0 proven). **Framing note:** the original hub-desired-state framing is SUPERSEDED by Viktor's controller-direct ruling (2026-07-17) — the resize is controller→agent local-API, never through the hub. Memory only (cores stay observation). **COMPLETE — the apply is proven in BOTH directions on metal (2026-07-18):** customer zero shrank the guest **11675 → 8192 MB (16:50:22)** and grew it **8192 → 12288 MB (17:02:17)**, each a live cgroup apply with no reboot, the new total rippling into the deploy page's memory math. The nested demo had proven the refusals; the N100 proved the applies. `tests/VALIDATION-n100-rehearsal-2026-07-18.md` ledger 9. **Cores/live-resize as hub desired-state is NOT built** (deferred, low demand). | M | **SHIPPED + PROVEN-LIVE** | See felhom-agent + felhom-controller REPORTs; capability-map row "customer right-sizes guest RAM". | | R-12 | Cluster mode: agent-follows-guest, bind-mount reconciliation on HA migration | XL | idea | Scoped 07-15; interim = HA-group pin to one node. Driven by Peti's two-node cluster | diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 15f1ff0..272915a 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,35 @@ # Felhom Hub — Changelog +## v0.70.1 — the ghost customer's Delete button must exist (2026-07-22) + +**The fourth inert-seam defect: v0.70.0's ghost-delete path was fully implemented and fully +unreachable.** `handleCustomerDelete`/`handleCustomerDeletePreview` accepted `cfg == nil`, the +ghost dialog branch existed in the template JS (`customer_unified.html` `if (d.has_config === +false)`) — but the Danger-zone card containing the Delete button sat inside the `{{if .HasConfig}}` +block (old ~L736) that also wraps the RESET card. A ghost rendered no Danger zone → no button → +dead UI. Handler tests passed because they POST directly; nothing asserted the rendered page. +Observed live on `demo-vm-felhom` (2026-07-22): Edit tab showed Controller Update + Geo cards only. + +- **Handler** (`configs.go` `handleCustomerUnified`): new `Deletable` page flag — the **exact + negation of the delete preview's 404 predicate** (`customer_delete.go`: `cfg == nil && no hosts + && residue empty`), one truth, not a lookalike. Hosts were already fetched for the Host tab; + only the residue count is an extra read, and it runs solely on the ghost shape. A lookup error + logs and leaves `Deletable=false` — fail toward hiding a destructive control. +- **Template**: the old gate split. RESET card stays `{{if .HasConfig}}` (identity-preserving + re-onboarding — a ghost has no identity to preserve). Danger zone gates on `{{if .Deletable}}`; + inside it the Block/Unblock forms gain their own `{{if .HasConfig}}` (blocking gates dashboard + visibility of a configured customer — meaningless for a ghost). The `customerDeleteOpen/Submit` + script moved out with the card (it was inside the old gate). Ghost shape gets a one-sentence + intro prefix; the dialog already explains the rest. +- **Render tests** (`customer_ghost_delete_render_test.go`), asserting the delete form's `action` + and the `customerDeleteOpen(` call site: ghost-with-residue renders Delete only (no RESET, no + Block); configured customer keeps every affordance byte-for-byte (incl. the blocked→Unblock + branch); nothing-left 404s (there is no renderable `Deletable=false` state — `customer != nil` + implies a report row implies residue > 0). Two red-proofs run and recorded in `REPORT.md`. +- **The generalized seam-wiring rule now covers template gates:** any conditional UI affordance + ships with a render test per branch — handler tests that POST directly prove nothing about + reachability. + ## v0.70.0 — a deleted customer actually disappears (the ghost + its alerts) (2026-07-21) Found while validating v0.69.0 against the live hub, on the operator's report that `demo-vm-felhom` diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 95c1d99..34950ef 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -283,6 +283,12 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c Overrides map[string]interface{} IsBlocked bool + // Deletable (v0.70.1) gates the Danger-zone card. It is the exact negation of the delete + // preview's 404 predicate (customer_delete.go: cfg == nil && no hosts && residue empty) — + // one truth, not a lookalike. Before v0.70.1 the card sat inside {{if .HasConfig}}, so the + // entire v0.70.0 ghost-delete path was implemented but unreachable (dead UI). + Deletable bool + HasReports bool Customer *store.CustomerSummary Report map[string]interface{} @@ -397,6 +403,19 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c staleSinceReset = customer == nil || !customer.ReceivedAt.After(*cr.CompletedAt) } + // v0.70.1: the Danger-zone render gate. Hosts are already fetched above for the Host tab — + // only the residue count is an extra read, and it runs ONLY on the ghost shape (config-less, + // hostless), never on the hot normal path. A lookup error logs and leaves Deletable=false: + // fail toward HIDING a destructive control, never toward showing one on unknown state. + deletable := cfg != nil || len(hostViews) > 0 + if !deletable { + if residue, err := s.store.CustomerResidue(customerID); err != nil { + s.logger.Printf("[ERROR] CustomerResidue %s: %v", customerID, err) + } else { + deletable = residue.Total() > 0 + } + } + // R-36 interim (v0.67.0): enabled-but-unprovisioned is a real, stable state — the same predicate // the offsite re-issue handler already uses to refuse ("No provisioned offsite tier"). var offsiteView struct { @@ -420,6 +439,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c Config: cfg, Overrides: overrides, IsBlocked: cfg != nil && cfg.Status == "blocked", + Deletable: deletable, HasReports: customer != nil, Customer: customer, diff --git a/hub/internal/web/customer_ghost_delete_render_test.go b/hub/internal/web/customer_ghost_delete_render_test.go new file mode 100644 index 0000000..09001c1 --- /dev/null +++ b/hub/internal/web/customer_ghost_delete_render_test.go @@ -0,0 +1,120 @@ +package web + +// v0.70.1 — the ghost customer's Delete button must EXIST. v0.70.0 made ghost customers +// deletable server-side, but the Danger-zone card sat inside {{if .HasConfig}}, so the whole +// ghost path was implemented and unreachable (dead UI — the fourth inert-seam defect, found +// live on demo-vm-felhom). Handler tests that POST directly prove nothing about reachability: +// these tests assert the RENDERED page, per branch of the new gate. +// +// Assertions anchor on the delete form's action attribute and the customerDeleteOpen( call +// site — not free-text strings a copy edit would break. + +import ( + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +const ( + deleteFormMarker = `action="/configs/ghost/delete"` + deleteOpenMarker = `customerDeleteOpen('ghost')` + deleteFuncMarker = `function customerDeleteOpen(` + resetFormMarker = `action="/configs/ghost/reset"` + blockFormMarker = `action="/customers/ghost/block"` + unblockFormMkr = `action="/customers/ghost/unblock"` +) + +// Scenario A — ghost with residue (the demo-vm-felhom shape): no config row, 0 hosts, +// residue.Total() > 0 (report rows). The Danger zone must render — delete form, opener button, +// and its script — while RESET and Block (config-coupled affordances) must NOT. +func TestCustomerPage_GhostWithResidue_RendersDeleteOnly(t *testing.T) { + s, st := newTestServer(t) + // Reports only — NO SaveCustomerConfig. This is the ghost: config gone, residue keeps the + // customer on the list and raising staleness alerts. + if err := st.SaveReport("ghost", []byte(tabsTestReportJSON)); err != nil { + t.Fatal(err) + } + if residue, err := st.CustomerResidue("ghost"); err != nil || residue.Total() == 0 { + t.Fatalf("fixture must have residue (got total=%v err=%v)", residue, err) + } + + html := renderCustomerPage(t, s, "ghost") + + if !strings.Contains(html, deleteFormMarker) { + t.Errorf("ghost page must render the delete form (%s) — without it the entire v0.70.0 ghost-delete path is dead UI", deleteFormMarker) + } + if !strings.Contains(html, deleteOpenMarker) { + t.Errorf("ghost page must render the Delete button's %s opener", deleteOpenMarker) + } + if !strings.Contains(html, deleteFuncMarker) { + t.Errorf("the customerDeleteOpen script must exist whenever the button does (was inside the old HasConfig gate)") + } + // The WRONG outcome this split must not introduce: RESET rendering for a ghost. RESET is + // identity-preserving re-onboarding; a ghost has no identity to preserve. + if strings.Contains(html, resetFormMarker) { + t.Errorf("RESET card must NOT render for a ghost (found %s)", resetFormMarker) + } + // Blocking gates dashboard visibility of a CONFIGURED customer — meaningless for a ghost. + if strings.Contains(html, blockFormMarker) || strings.Contains(html, unblockFormMkr) { + t.Error("Block/Unblock forms must NOT render for a ghost") + } +} + +// Scenario B — normal configured customer: byte-for-byte the affordances of today — RESET card +// + full Danger zone incl. Block, and the delete machinery. +func TestCustomerPage_ConfiguredCustomer_KeepsAllAffordances(t *testing.T) { + s, st := newTestServer(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: "ghost", CustomerName: "Ghost", Domain: "ghost.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active", + }); err != nil { + t.Fatal(err) + } + if err := st.SaveReport("ghost", []byte(tabsTestReportJSON)); err != nil { + t.Fatal(err) + } + + html := renderCustomerPage(t, s, "ghost") + + for _, marker := range []string{resetFormMarker, blockFormMarker, deleteFormMarker, deleteOpenMarker, deleteFuncMarker} { + if !strings.Contains(html, marker) { + t.Errorf("configured customer lost an affordance: %s absent — the gate split must not change the normal shape", marker) + } + } +} + +// Scenario B variant — a BLOCKED configured customer renders Unblock (the IsBlocked branch +// survived the extra HasConfig wrapper around the block forms). +func TestCustomerPage_BlockedCustomer_RendersUnblock(t *testing.T) { + s, st := newTestServer(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: "ghost", CustomerName: "Ghost", Domain: "ghost.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active", + }); err != nil { + t.Fatal(err) + } + // SaveCustomerConfig never writes status — blocking goes through SetCustomerConfigStatus + // (the same call the /block handler makes). + if err := st.SetCustomerConfigStatus("ghost", "blocked"); err != nil { + t.Fatal(err) + } + html := renderCustomerPage(t, s, "ghost") + if !strings.Contains(html, unblockFormMkr) { + t.Errorf("blocked configured customer must render the Unblock form (%s)", unblockFormMkr) + } +} + +// Scenario C — nothing left: no config, 0 hosts, residue.Total() == 0. There is no renderable +// Deletable=false state with real store reads (customer != nil implies a report row implies +// residue > 0), so "no Danger zone" manifests as the page-level 404 — the same semantics as the +// delete preview's 404 ("there is nothing here"). +func TestCustomerPage_NothingLeft_404(t *testing.T) { + s, _ := newTestServer(t) + rr := httptest.NewRecorder() + s.handleCustomerUnified(rr, httptest.NewRequest("GET", "/customers/ghost", nil), "ghost") + if rr.Code != 404 { + t.Fatalf("a customer with no config, no hosts and no residue must 404 (mirrors the preview) — got %d", rr.Code) + } +} diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index d0c220a..506bbcd 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -811,13 +811,20 @@ document.getElementById('cust-reset-form-' + cid).submit(); } + {{end}} + Customer Info header — endpoints and confirm() handlers unchanged. + v0.70.1: gated on .Deletable, NOT .HasConfig — a GHOST customer (config gone, hosts or + report/telemetry residue remain) must render the Delete button, or the whole v0.70.0 + ghost-delete path is dead UI (found live on demo-vm-felhom). RESET stays HasConfig-gated + above (identity-preserving re-onboarding — a ghost has no identity to preserve). --> + {{if .Deletable}}

Danger zone

-

Blocking hides the customer from the Dashboard (reports are still accepted). Delete customer is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked, tunnel and zone removed), then purges the customer record and all escrow ciphertext — including the retained recovery-key custody for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use Ügyfél-visszaállítás (RESET) above instead.

+

{{if not .HasConfig}}Ghost customer — the configuration record is already gone; Delete is the applicable action. {{end}}Blocking hides the customer from the Dashboard (reports are still accepted). Delete customer is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked, tunnel and zone removed), then purges the customer record and all escrow ciphertext — including the retained recovery-key custody for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use Ügyfél-visszaállítás (RESET) above instead.

+ {{if .HasConfig}}{{/* v0.70.1: blocking gates dashboard visibility of a CONFIGURED customer — meaningless for a ghost */}} {{if .IsBlocked}}
{{.CSRFField}} @@ -829,6 +836,7 @@
{{end}} + {{end}}