feat(hub): v0.69.0 — customer DELETE is the guided full-teardown cascade (R-25b)

POST /configs/{id}/delete now runs hosts -> RESET -> purge behind three
acknowledgements, a typed customer-id, a stale-preview check and the
ONLINE-host refusal (every gate before any write, so a refusal has zero
side effects). The shallow handleConfigDelete is gone.

Two invariants are asserted, not just commented: ruling 3 is preserved by
construction (leg 2 never sees a host row) and retained escrow custody is
purged exactly once, in leg 3 (leg 2 runs with purgeEscrow=false).

handleCustomerReset's committed half was extracted as commitCustomerReset;
the standalone RESET path is byte-identical to v0.68.1 and its suite is
untouched. Five red-proofs run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J55BQE1gE2V4ffud5jweGS
This commit is contained in:
2026-07-21 19:31:48 +02:00
parent f59aa97d0c
commit 61dbd870c3
13 changed files with 1087 additions and 208 deletions
@@ -816,7 +816,7 @@
Customer Info header — endpoints and confirm() handlers unchanged. -->
<section class="card">
<h2>Danger zone</h2>
<p class="text-muted">Blocking hides the customer from the Dashboard (reports are still accepted); deleting removes the managed configuration permanently — and permanently removes the retained recovery-key custody (escrow blobs) for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it.</p>
<p class="text-muted">Blocking hides the customer from the Dashboard (reports are still accepted). <strong>Delete customer</strong> 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 <strong>retained recovery-key custody</strong> 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 <em>Ügyfél-visszaállítás (RESET)</em> above instead.</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
{{if .IsBlocked}}
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
@@ -829,12 +829,109 @@
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Block this customer? They will be hidden from the Dashboard.">Block</button>
</form>
{{end}}
<form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline">
<button type="button" class="btn btn-danger btn-sm" onclick="customerDeleteOpen('{{.CustomerID}}')">Delete customer&hellip;</button>
</div>
<!-- Guided full-teardown cascade (v0.69.0, R-25b). The inventory panel is fetched from
GET /configs/{id}/delete; the three acknowledgements + typed customer-id are ALSO
enforced server-side (this is convenience, never the gate). -->
<div id="cust-del-box-{{.CustomerID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--crit); border-radius: var(--radius); max-width: 52em;">
<p id="cust-del-inv-{{.CustomerID}}" style="margin: 0 0 0.75rem; font-size: 0.9em;">&hellip;</p>
<div id="cust-del-journal-{{.CustomerID}}" style="display: none; margin: 0 0 0.75rem; padding: 0.5rem; border: 1px solid var(--warn); font-size: 0.85em;"></div>
<label style="display: block; margin: 0 0 0.5rem; font-size: 0.85em;">
<input type="checkbox" id="cust-del-ack1-{{.CustomerID}}">
<strong>1.</strong> <span id="cust-del-ack1-text-{{.CustomerID}}">The host(s) will be deleted</span> — recovery-key custody is <strong>demoted</strong> to retained custody, not destroyed.
</label>
<label style="display: block; margin: 0 0 0.5rem; font-size: 0.85em;">
<input type="checkbox" id="cust-del-ack2-{{.CustomerID}}">
<strong>2.</strong> The customer will be <strong>RESET</strong> — the offsite repository is <strong>DESTROYED</strong>, PBS credentials are revoked, tunnel and zone are removed.
</label>
<label style="display: block; margin: 0 0 0.75rem; font-size: 0.85em; color: var(--crit);">
<input type="checkbox" id="cust-del-ack3-{{.CustomerID}}">
<strong>3.</strong> The customer record and <strong>ALL escrow ciphertext</strong> are <strong>PURGED</strong> — unrecoverable.
</label>
<p style="margin: 0 0 0.4rem; font-size: 0.85em; color: var(--text-2);">Type the customer-id to confirm:</p>
<form method="POST" action="/configs/{{.CustomerID}}/delete" id="cust-del-form-{{.CustomerID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
{{.CSRFField}}
<button type="submit" class="btn btn-danger btn-sm" data-confirm="Delete configuration for {{.CustomerID}}? This cannot be undone.">Delete</button>
<input type="hidden" name="confirm_id" id="cust-del-cid-{{.CustomerID}}" value="">
<input type="hidden" name="ack_hosts" id="cust-del-h1-{{.CustomerID}}" value="">
<input type="hidden" name="ack_reset" id="cust-del-h2-{{.CustomerID}}" value="">
<input type="hidden" name="ack_purge" id="cust-del-h3-{{.CustomerID}}" value="">
<input type="hidden" name="expect_hosts" id="cust-del-exp-{{.CustomerID}}" value="">
<input type="text" id="cust-del-input-{{.CustomerID}}" placeholder="customer-id&hellip;" style="padding: 0.3em 0.5em; width: 16em;">
<button type="button" class="btn btn-danger btn-sm" id="cust-del-go-{{.CustomerID}}" onclick="customerDeleteSubmit('{{.CustomerID}}')">Confirm &amp; delete everything</button>
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('cust-del-box-{{.CustomerID}}').style.display='none';">Cancel</button>
</form>
<p id="cust-del-err-{{.CustomerID}}" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
</div>
</section>
<script>
function customerDeleteOpen(cid) {
var box = document.getElementById('cust-del-box-' + cid);
var inv = document.getElementById('cust-del-inv-' + cid);
var go = document.getElementById('cust-del-go-' + cid);
var jr = document.getElementById('cust-del-journal-' + cid);
box.style.display = 'block';
inv.textContent = 'Loading inventory…';
jr.style.display = 'none';
document.getElementById('cust-del-input-' + cid).value = '';
document.getElementById('cust-del-err-' + cid).textContent = '';
document.getElementById('cust-del-exp-' + cid).value = '';
['ack1', 'ack2', 'ack3'].forEach(function(a) { document.getElementById('cust-del-' + a + '-' + cid).checked = false; });
go.disabled = false;
fetch('/configs/' + encodeURIComponent(cid) + '/delete')
.then(function(r){ return r.json(); })
.then(function(d){
document.getElementById('cust-del-exp-' + cid).value = String(d.host_count);
var hostNames = (d.hosts || []).map(function(h){ return h.host_id + ' (' + h.status + ')'; });
document.getElementById('cust-del-ack1-text-' + cid).textContent =
d.host_count + ' host(s) will be deleted' + (hostNames.length ? ': ' + hostNames.join(', ') : '');
var dies = [];
if (d.host_count) dies.push(d.host_count + ' host row(s)');
if (d.offsite_enabled) dies.push('offsite repository' + (d.offsite_identifier ? ' (' + d.offsite_identifier + ')' : ''));
if (d.pbs_tenancy_configured) dies.push('PBS namespace + backups + token');
if (d.dr_recipe_present) dies.push('DR recipe');
if (d.one_time_secret) dies.push('one-time password');
if (d.claim_present) dies.push('claim state');
dies.push('customer record');
var custody = d.superseded_blobs > 0
? d.superseded_blobs + ' retained escrow blob(s) + every current host escrow'
: 'every current host escrow';
inv.innerHTML = '<strong>Will be destroyed:</strong> ' + dies.join(', ') +
'. <strong>Custody:</strong> ' + custody + ' (purged in the final leg). ' +
'<strong>Survives:</strong> the audit event stream and the deletion provenance.';
if (d.online_host_present) {
inv.innerHTML += '<br><strong style="color: var(--crit)">Refused:</strong> a host is ONLINE. ' +
'Decommission the box first — the cascade never deletes a live host.';
go.disabled = true;
}
if (d.pending_journal) {
var legs = d.pending_journal.legs || {};
var parts = Object.keys(legs).map(function(k){ return k + '=' + legs[k]; });
jr.innerHTML = '<strong>An earlier cascade stopped mid-way</strong> (journal #' + d.pending_journal.id +
', started ' + d.pending_journal.started_at + '). Legs: ' + (parts.length ? parts.join(', ') : 'none recorded') +
'. Confirming again RESUMES it — completed legs are no-ops.';
jr.style.display = 'block';
document.getElementById('cust-del-go-' + cid).textContent = 'Confirm & resume teardown';
}
})
.catch(function(){ inv.textContent = 'Inventory unavailable — the server enforces every gate regardless.'; });
}
function customerDeleteSubmit(cid) {
var err = document.getElementById('cust-del-err-' + cid);
var a1 = document.getElementById('cust-del-ack1-' + cid).checked;
var a2 = document.getElementById('cust-del-ack2-' + cid).checked;
var a3 = document.getElementById('cust-del-ack3-' + cid).checked;
if (!a1 || !a2 || !a3) { err.textContent = 'All three acknowledgements are required.'; return; }
var typed = document.getElementById('cust-del-input-' + cid).value.trim();
if (typed !== cid) { err.textContent = 'The typed customer-id does not match.'; return; }
document.getElementById('cust-del-cid-' + cid).value = typed;
document.getElementById('cust-del-h1-' + cid).value = '1';
document.getElementById('cust-del-h2-' + cid).value = '1';
document.getElementById('cust-del-h3-' + cid).value = '1';
document.getElementById('cust-del-form-' + cid).submit();
}
</script>
{{end}}
</div>