docs: customer-claim arc — REPORT/CONTEXT, G9 supersede, tester-agreement claim step, day0 D.3/D.4 gate, drill F-4/F-5 RESOLVED

Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
This commit is contained in:
2026-07-12 18:56:46 +02:00
parent 9a14f04819
commit 8ded485a58
6 changed files with 64 additions and 67 deletions
+12
View File
@@ -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-12 — HUB v0.50.0 SHIPPED + LIVE: customer-claim password arc (closes DRILL-day0-vm
F-4/F-5).** The customer OWNS the dashboard password via a one-time claim code the hub emails to
the registered address (`bcrypt(code)` stored, plaintext only in the send). `internal/claim`
engine (EnsureIssued idempotent Day-0/live-box issue+email, Resend, RequestReset 3/day,
MarkClaimed set-only) + `store.customer_claims` (RotateClaimCode bumps generation + preserves
claimed_at) + delivery (config bake `web.claim_code_*`, report-ACK `claim` object, `POST
/api/v1/claim/reset-request`) + Setup-tab chip/resend + 3 Hungarian emails. Pairs with controller
**v0.122.0** (the gate). **LIVE-proven on drill box demo-vm-felhom: gate ON via the real edge (/ →
302 claim page, /api → 401); code emailed to the registered address.** Deployed felhom-hub:0.50.0
(ArgoCD synced/healthy). Floor raise 0.120→0.122 = operator's supervised LAST step. Observed: the
hub issues a claim code for EVERY reporting customer on first report (benign for pre-0.122 boxes —
no gate, ACK ignored). Report: documentation/audits/DRILL-day0-vm-2026-07-12.md (F-4/F-5 RESOLVED).
- **2026-07-12 — HUB v0.49.0 SHIPPED: Edit tab merge (edit-a) + scoped auto-refresh + riders**
(CHANGELOG v0.49.0; commits `e740147``2e03de1``1d94b1a` + docs/manifest). The standalone
customer edit page merged into the customer page's **Edit** tab (renamed from Settings): shared
+27 -56
View File
@@ -2,66 +2,37 @@
> **Overwrite** this file with a summary of the most recent task only (uniform with the other repos; not cumulative). The cumulative hub history lives in [hub/CHANGELOG.md](hub/CHANGELOG.md); the scripts history lives in [scripts/CHANGELOG.md](scripts/CHANGELOG.md).
## Hub v0.49.0 — Edit tab merge (edit-a), scoped auto-refresh, + two riders — 2026-07-12
## Hub v0.50.0 — customer-claim password arc (closes DRILL-day0-vm F-4/F-5) — 2026-07-12
**⚠ VERSION NOTE (deviation from the spec):** the task targeted **v0.48.0**, but hub v0.48.0
(`app_start_failed` allowlist, CAMPAIGN-3 Task D) had **already shipped and was the live manifest
tag** when this train started. A published tag is never re-pointed (pinned-version discipline), so
this train shipped as **v0.49.0**. Everything else is per spec.
Hub half of the customer-owned dashboard-password gate (controller v0.122.0 is the other half).
The customer sets + owns the password via a one-time claim code the hub emails to the registered
address; the hub stores only `bcrypt(code)`. **DEPLOYED: `felhom-hub:0.50.0` live on k3s (ArgoCD
synced/healthy, `Listening on :8080` at 18:47 CEST).**
### Baseline / commits
### What shipped
- Baseline: `3e949bc` (spec said `0ed87f5`; the one extra commit was docs-only, `git diff 0ed87f5..3e949bc -- hub/` empty — no hub code drift).
- `e740147` — Part 1: `config_form_body` sub-template + `configFormData` builder (behavior-neutral).
- `2e03de1` — Part 2: Edit tab merge, danger zone, header cleanup, 302, redirect anchors, server-side required fields, Group A/B tests.
- `1d94b1a` — Parts 3+4: scoped auto-refresh, style.css cache-bust (all 11 templates), CLAUDE.md `git add -A` ban, Group C/D tests.
- `b1ab0ea` — Part 5: CHANGELOG v0.49.0, REUSE.md rows, CONTEXT.md, `manifests/hub.yaml` → 0.49.0.
- **`internal/claim`** — the code engine: `EnsureIssued` (idempotent Day-0/live-box issue+email),
`Resend` (operator rotate), `RequestReset` (controller-forwarded, 3/day cap), `MarkClaimed`
(set-only + one confirmation email). Plaintext code exists ONLY inside the email send.
- **`store.customer_claims`** — `RotateClaimCode` (generation bump = single active code; PRESERVES
`claimed_at`), `GetClaim`, `MarkClaimed` (set-only), `BumpResetCount`.
- **Delivery**: `GET /api/v1/config/{id}` issues the first code + bakes `web.claim_code_*` into the
generated controller.yaml; the report ACK serves `claim {code_hash, generation, issued_at}`
(allowlisted) + ingests `claimed` (set-only); `POST /api/v1/claim/reset-request` (self-scoped).
- **UI**: customer-page Setup tab claim chip + "Kód újraküldése" (`POST /configs/{id}/claim-resend`).
- **Emails**: three Hungarian templates (claim / reset / claimed) via the notify dispatcher.
- 15 tests (engine, ACK/config, UI); red-proofs for the generation bump + reset-non-DoS proven.
### Files touched
### Live validation
`hub/internal/web/{configs.go, pbsdr.go}`, `hub/internal/web/templates/{config_form.html, config_form_body.html(new), customer_unified.html, +9 templates (cache-bust line only)}`, tests `{customer_edit_tab_test.go(new), refresh_cachebust_test.go(new), customer_tabs_test.go, pbsdr_test.go}`, docs `{hub/CHANGELOG.md, REUSE.md, CONTEXT.md, CLAUDE.md, REPORT.md}`, `manifests/hub.yaml`.
Drill box demo-vm-felhom: on the first report after hub 0.50.0 + controller 0.122.0, the hub issued
+ emailed the claim code to the registered address and the ACK delivered the hash — the controller
gate flipped ON (proven via the real Cloudflare edge: `/` → 302 claim page, `/api` → 401). Observed:
the hub issues a code for EVERY reporting managed customer on first report (peti + demo also received
emails) — benign, since a pre-v0.122 controller ignores the ACK claim field and has no gate.
### Test results (all green: `go build ./... && go vet ./... && go test ./...` per commit)
### Version note
- web package: **54 tests** (43 before → +11 new: Group A ×2, Group B ×3 + 7-case anchor table, Group C ×1, Group D ×1); full repo 13 packages ok.
- **Group A** — Edit panel renders form (`action=/configs/c1/edit`) + Controller Update + Geo + Danger zone; forms are siblings (no `<form` inside the config form's extent); stored overrides populate inputs; header has no edit link; Block/Delete **count == 1** page-wide; blocked customer → Unblock.
- **Group B** — B1: GET `/configs/{id}/edit` → 302 `/customers/{id}#tab=edit` (unknown id still 404). B2: `/configs/new` chrome + create round-trip unchanged. B3: invalid update → 200 standalone re-render, error shown, **submitted values preserved, stored values absent, nothing saved**. B4/B5: anchor table — update/block/unblock/offsite-reissue/offsite-freeze → `#tab=edit`, regen-password → `#tab=setup`, delete → `/configs?flash=deleted` (pbsdr-reissue also anchored → `#tab=edit`; same surface, noted as a spec+1).
- **Group C**`data-live-tabs="overview,applications,events,host"` on the nav; script pins: `getAttribute('data-live-tabs')`, delegated `input`/`change` dirty listeners, `settings→edit` alias ×2 (tabs + refresh scripts), `(paused)` hint element, 60000/`hub_auto_refresh`/toggle survivors.
- **Group D** — six rendered pages carry `/style.css?v=test` and no bare `/style.css"`.
### Red-proofs (all three ran mutate → FAIL → revert → green)
1. **B3 typed-values**: error path switched to `nil` overrides → "SUBMITTED value lost / stored value rendered" → FAIL ✓.
2. **Header count**: old header Block/Delete restored → count = 2 → FAIL ✓ (Contains alone would have passed).
3. **Cache-bust**: `offsite.html` reverted to bare link → both assertions FAIL ✓.
### Deploy (GitOps)
- Image `gitea.dooplex.hu/admin/felhom-hub:0.49.0` built+pushed on 180.
- ArgoCD hard-refresh → sync: `sync=Synced`, rollout ok, pod image = `felhom-hub:0.49.0`, clean startup log (checkers seeded, `Listening on :8080`) at 17:40:50 CEST.
### Live validation — DONE (Viktor logged in; claude-in-chrome on hub.felhom.eu, demo-felhom)
1. **Edit tab renders the merged surface** — populated form (ID readonly, name/domain/email, CF tokens), Edit active in the nav, `(paused)` hint shown (Edit is non-live). ✓
2. **Legacy alias**`#tab=settings` activates the Edit tab (panel + nav highlight). ✓
3. **Hint logic**`(paused)` inline on Edit and while dirty; `display:none` on Overview. ✓
4. **Dirty blocks reload** — typed one char into Display Name (15:49:03Z, hint on); at 15:50:46Z (103 s later) the JS window-marker and the typed char were both still present → **no reload**. Field restored via Backspace afterwards. ✓
5. **Live tab reloads** — after a fresh load on `#tab=overview` (toggle on, not dirty), the window-marker was gone within ~90 s → the 60 s auto-reload fired; the hash kept Overview active. ✓
6. **No-op save** — Save Changes with zero edits → 303 → `?flash=updated#tab=edit`, "Configuration updated." flash, Edit tab active, values intact (offsite/PBS re-apply idempotent as designed). ✓
7. **Cache-bust** — hard reload: `GET /style.css?v=0.49.0` → 200 in the network log; the DOM link href matches. ✓
**Spec-wording caveat:** §13's "clear it → reload resumes next tick" is not how the shipped (and
spec-Part-3-mandated) never-reset dirty flag behaves — once dirty, auto-refresh stays paused until
the next full page load (manual reload, navigation, or the save redirect), which then re-arms it
(validated as probe 5). Clearing the typed character alone does not resume.
**NOT live-validated** (unit tests only, by design/STOP): Block/Delete flows, the validation-error
re-render (no live invalid submit against demo-felhom).
### Observations (candidates, not done)
- Customer delete still uses `confirm()` (relocated as-is) — **type-to-confirm retrofit** is the known follow-up.
- The customer-keyed report lookup → cluster-mode arc (pre-existing note) unchanged.
- `handleConfigUpdate` had NO validation-error path before this task (the spec described it as existing); the required-fields check (Display Name + Domain, the form's `required` twins) was added to give B3 its trigger — `pbsdr_test.postUpdate` amended to supply them.
- Workspace-root `E:\git\CLAUDE.md` and the other repos' CLAUDE.md files still carry `git add -A` snippets — flag for their own sessions (out of scope here, per spec).
- The hub operator password gate means browser validation always needs a Viktor-logged-in session; consider a read-only CC credential if live UI validation should become routine.
Hub was at v0.49.0; this is v0.50.0. Controller v0.122.0 committed + image built. The **floor raise
to 0.122.0 is the operator's supervised LAST step** (after the drill/demo claim validation) — a
gated controller must never reach a box before its hub can deliver the code hash.
@@ -132,8 +132,8 @@ tier (ep0)** — which converted the drill into a full rehearsal of Peti's pendi
| F-1 | LOW | 3 | Version-string mismatches: `-h` v1.14.0 vs run banner v1.13.0 vs hub Setup-tab copy "1.12.0" | fix strings (installer + hub template) |
| F-2 | COSMETIC | 3 | dry-run prints `curl -u <git>` on the anonymous-fetch branch | fix placeholder |
| F-3 | MEDIUM | 4 | Root-run provision leaves `/var/lib/felhom-agent/guests{,/9201}` root:root 0700 inside the agent-owned state dir → non-root agent lanresolver "permission denied". LIVE-FIXED (chown the two parent dirs; the guest-root-owned bootstrap subtree untouched) | agent/installer: create parents agent-owned at provision; **check demo/felhom-pve for the same latent state** |
| **F-4** | **HIGH** | 5 | **No operator-set dashboard password path exists anywhere** (hub has no UI/API for per-customer `password_hash`; controller open-state page defers to the operator; Day-0 preseeded path skips the wizard's password form) → **G10 unclosable; every fresh box's dashboard stays OPEN on the internet** | hub feature task (operator set → config-delivered hash → controller re-pull); blocks tester onboarding gate |
| F-5 | MEDIUM | 5 | Geo-restriction APPLY is customer-dashboard-side; hub only disables. Runbook premise stale; also: the open dashboard (F-4) exposes the geo toggle unauthenticated | doc/design decision; folded into F-4's arc |
| **F-4** | **HIGH** | 5 | **No operator-set dashboard password path exists anywhere** (hub has no UI/API for per-customer `password_hash`; controller open-state page defers to the operator; Day-0 preseeded path skips the wizard's password form) → **G10 unclosable; every fresh box's dashboard stays OPEN on the internet** | **RESOLVED 2026-07-12 — customer-claim arc (controller v0.122.0 + hub v0.50.0).** The customer OWNS the password, set via a one-time claim code the hub emails to the registered address; an unclaimed box serves only the claim page (no open window). The operator-set premise was correctly abandoned. Live-proven on the drill box (gate ON via the real edge). |
| F-5 | MEDIUM | 5 | Geo-restriction APPLY is customer-dashboard-side; hub only disables. Runbook premise stale; also: the open dashboard (F-4) exposes the geo toggle unauthenticated | **RESOLVED with F-4** — the geo toggle (and every other route) is now behind the claim gate on an unclaimed box. The apply-is-customer-side design is accepted (documented). |
| **F-6** | **HIGH** | 6 | **Identity-only escrow ceremony was never implemented** (`escrow-create` hard-requires PBS storage + key; the ≥0.80.0 runbook claim was false) → on a no-PBS box (the documented appliance standard!) the offsite escrow chain can never complete | agent feature task (identity-only mode); ceremony runbook corrected in this commit |
| F-7 | MEDIUM | 6 | host-install ships the FELHOM_PBSDR sudoers alias but NOT the `felhom-pbs-apply` binary → pbsdr capabilities born DEGRADED on every fresh box | installer: ship the wrapper (like mkfs/selfupdate wrappers) |
| F-8 | LOW/UX | 3 | Step 4b rotates root@pam + vaults silently — operator surprised by 401 at the PVE GUI (live: Viktor) | installer: print "root@pam rotated + vaulted — retrieve at hub → host page" |
+1 -1
View File
@@ -69,7 +69,7 @@ Status legend: ✔ DONE · ◐ PARTIAL · ○ OPEN · ⚠ BLOCKER
| **G6** | **Uninstaller gap-closure (customer offboarding).** SHIPPED in host-install v1.11.0 (GL-4, `f7cc6a72`): 4b4 self-update-artifact removal (wrapper, .prev/.new.* slots, rollback unit, limits drop-in — derived from the guarded script itself), per-drive unmounts under `/mnt/felhom-drives` (plain umount ONLY, busy = warn+guidance, root-bind guarded), the **KEPT-vs-WIPED statement** in BOTH modes (drives/PBS/hub record/escrow/vaulted recovery credential named as living on), guest-only drive note from the bind store. **LIVE-VALIDATED (GL-6): two real uninstalls** — busy-drive prop handled (warn+guidance, no `-l/-f`, statement=retry), KEPT/WIPED statement verbatim, host audit clean, **drive data intact ×3** (witness hash matched every time). Findings F1 (config `.bak` residue) + F2 (mount-unit residue) filed. | ✔ | DRILL-GL6 §Phase 1/5 |
| **G7** | **Demo-box drill****DONE 2026-07-08.** Two full destroy/rebuild cycles (byo + appliance) + two uninstalls on felhom-pve, Viktor gating every phase; F4 found+fixed+proven mid-drill (v1.11.3), C7 + armed-key chain live-validated, data intact ×3. | ✔ | DRILL-GL6-2026-07-08.md |
| **G8** | **DR bring-up bind-override fix** (agent). SHIPPED as **v0.75.0** (GL-5, `b3446213`, LIVE on felhom-pve) + **scratch-DR live-validated end-to-end** (9310 from a real 9201 archive: mp0 200G + mp1 50G restored with content, real mp8/mp9 binds, zero unusedN, clean auto-teardown; the same op failed outright on v0.74.0). **TWO live-discovered PVE rules beyond the spike:** explicit-params restore requires an explicit rootfs AND silently drops unlisted mountpoints — the full param set now derives from the archive's embedded config (`ExtractArchiveConfig`, 200 under the scoped token; bind LAYOUT stays the known constants). | ◐ | agent v0.75.0 @ `b3446213`; REPORT.md. **Remaining: the full customer-data DR drill (GL-6/S5). (The publish follow-up landed as 0.76.0 — GL-5b/G12.)** |
| **G9** | **Auth-on onboarding.** Auth + CSRF proven in campaign 2; the dashboard password is **operator-set via the hub config** (anti-F9 pipeline), NOT a customer-dashboard field — until set the public dashboard is OPEN (**G10 exposure**). **DONE for GL-7:** the password-set + geo-restriction are now an explicit onboarding step in the tester agreement (§7 step 3), gated as "onboarding not complete until set". The action itself is performed with Peti at his day-0. | ✔ | PETI-tester-agreement.md §7 |
| **G9** | **Auth-on onboarding — SUPERSEDED by the customer-claim model (2026-07-12, controller v0.122.0 + hub v0.50.0).** The operator-set-password premise was found NEVER to exist in shipped code (DRILL-day0-vm F-4). Replaced by a customer-owned password set via a **one-time claim code the hub emails to the registered address**: an unclaimed box serves ONLY the claim page (no open window), which closes the CT-log first-visit race that motivated G9. Reset rides the same code engine. Live-proven on the drill box 2026-07-12 (gate ON via the real edge; claim page / 401 on API). | ✔ | DRILL-day0-vm-2026-07-12.md; controller/hub CHANGELOGs |
| **G10** | **Local-API posture documented + spot-verified.** Token→vmid binding is structural (server.go:23, :5355, :153). **DONE:** written into the tester agreement (§2 table + §3 — vmbr0:8443 LAN surface, TLS + per-guest bearer token, cross-guest refusal). | ✔ | PETI-tester-agreement.md §2/§3 |
| **G12** | **Restore-test full-fidelity verification** (GL-5b, agent **v0.76.0**). The restore-test had GL-5 finding #2's mirror image: its live-source-config override path tripped PVE's drop-unlisted-mountpoints rule, so it boot-verified scratch guests WITHOUT their storage mpN. Now: params derive from the ARCHIVE's embedded config (`drRestoreOverrides`, same as DR) + a **mount-parity assert** (restored mpN set vs the archive's; missing/mispathed/undersized/extra = FAIL naming the delta) so the rule can never regress into a green light. `MountParity`+`MountInventory` ride the hub wire record (additive). Honest cost, measured: **3m4s** on the local tier (the extraction adds ~2min over data-less; cheaper than the DR-derived ~7m estimate; PBS tier will run longer). Live-proven on felhom-pve: parity ok, inventory mp0 200G + mp1 50G + 2 throwaways; a rotated-out archive volid refuses up front. | ◐ | agent v0.76.0 (published `9828c5f7…f50b`); agent REPORT.md. Remaining: parity-on-real-drift (GL-6 family) + PBS-tier runtime |
| **G11** | **Pilot backup statement.** **DONE:** the tester agreement states it plainly (§4 — *no offsite copy yet; a dead/stolen box or dead drive without a second local target loses data*; §7 step 5 asserts local backups green at onboarding). **⚠ carries the one open pilot question:** Peti's local backup TARGET (does his box have a second disk/pool?) — resolved into `--acl-storages` at onboarding (agreement §6). | ✔ | PETI-tester-agreement.md §4/§6/§7 |
+7 -3
View File
@@ -153,9 +153,13 @@ inspected, never piped straight into a shell).
1. Run pre-flight first: same command with `--preflight-only` — prints PASS/FAIL, writes no state.
Confirms 9.x, `:53` free, ACL storages exist, pool membership.
2. Run the install. Peti sees the host-mutation disclosure and types his hostname to acknowledge.
3. **Set the dashboard password (G9).** The customer dashboard is open until the operator sets the
password via the hub config; set it at onboarding, along with the geo-restriction. Until then the
dashboard is publicly reachable — do not consider onboarding complete without it.
3. **Claim the dashboard (customer-claim model, controller v0.122.0 + hub v0.50.0 — supersedes the
old operator-set G9).** The dashboard is NOT open before the customer claims it: an unclaimed box
serves only the claim page. On the box's first report the hub emails a one-time **claim code** to
Peti's registered address; he opens `felhom.sajatfelhom.hu`, enters the code, and sets his OWN
password. The operator never sets or sees it. If the code didn't arrive, the "Új kód kérése"
button (or the operator's "Kód újraküldése" on the hub customer page) emails a fresh one. Set the
geo-restriction at onboarding as before. Onboarding is complete once Peti has claimed + logged in.
4. **Re-point the `sajatfelhom.hu` Cloudflare tunnel** at the new guest.
5. **Verify local backups are green** on his box (§4 — the honesty gate). Confirm a backup completes
to `<PETI-BACKUP-STORAGE>` and a restore-test passes mount-parity.
+15 -5
View File
@@ -341,14 +341,24 @@ golden older than 0.86.0? See Part F troubleshooting.)
### D.3 Customer-visible
- `https://felhom.<customer-domain>` loads the dashboard through the tunnel (cloudflared up
in-guest: `pct exec <VMID> -- docker ps --filter name=cloudflared`).
- `https://felhom.<customer-domain>` answers through the tunnel (cloudflared up in-guest:
`pct exec <VMID> -- docker ps --filter name=cloudflared`).
- **Customer-claim gate (controller ≥ v0.122.0):** a fresh box is CLAIM-GATED, not open — the URL
serves the claim page ("A szerver beállítása") and every other route returns the claim page (302)
or 401. This is correct and expected (closes the DRILL-day0-vm F-4 exposure). The hub emails a
one-time claim code to the customer's registered address on the box's first report; the customer
claims by entering the code + choosing their own password. Verify the gate mechanically without
claiming: `curl --resolve felhom.<domain>:443:<CF-edge-IP>` on `/` → 302, on `/claim` → 200, on
`/api/host-metrics` → 401. Do NOT claim on the operator's behalf — that is the customer's step
(onboarding). (An older <0.122 controller has no gate and is open until claimed post-update — the
transitional legacy-open red banner shows until the hub delivers a code hash.)
### D.4 First app deploy (smoke)
In the dashboard, deploy one small catalog app end-to-end (pick something dependency-free). The app
reaches "running" and its URL answers. This proves the full customer-visible chain: catalog →
controller → Docker → tunnel → browser.
Deploy one small dependency-free catalog app end-to-end — **after the customer has claimed the
dashboard** (the gate blocks deploys pre-claim). The app reaches "running" and its URL answers,
proving the full chain: catalog → controller → Docker → tunnel → browser. (For an operator-only
smoke before claim, drive the in-guest Host-header probe instead — the public path is gated.)
---