lakehouse/mcp-server/biometric_intake.html
root 68d226c314 phase 1.6: BIPA withdrawal endpoint + UI + retention sweep timer
Closes the four production gaps that were live after the consent
endpoint shipped (76cb5ac):

(1) Withdrawal endpoint POST /biometric/subject/{id}/withdraw
    backs the BIPA right of withdrawal that consent template v1 §2
    explicitly promises. Without it, the only way to honor a
    candidate's withdrawal request was the heavier /erase, which
    destroys immediately rather than starting the 30-day SLA clock
    that the consent template commits to. Side-effects:
      - manifest.consent.biometric.status: Given → Withdrawn
      - manifest.consent.biometric.retention_until: 18mo → 30d
      - audit row kind=biometric_consent_withdrawal, captures
        reason + operator_of_record + evidence_path
      - DOES NOT touch general_pii or subject.status — biometric
        is independently revocable
    State machine: Given→Withdrawn (happy), NeverCollected/Pending→
    409 nothing_to_withdraw, Withdrawn→409 already_withdrawn (won't
    advance the destruction clock), Expired→409 already_expired,
    subject Erased/RetentionExpired→403 subject_inactive.
    12 new unit tests covering happy path + all guards + a full
    grant→withdraw cycle that asserts retention_until is correctly
    accelerated and the audit chain has 2 rows in correct order.

(2) Withdraw UI at /biometric/withdraw (mcp-server-served HTML).
    3-screen flow: operator auth (token + name in sessionStorage),
    withdrawal form (candidate_id + free-text reason ≥10 chars +
    optional evidence path), confirmation showing the audit row
    HMAC + the 30-day retention_until clock + a curl recipe for
    /audit/subject/{id} verification. Same neo-brutalist styling
    as biometric_intake.html. Mounted at
    http://localhost:3700/biometric/withdraw and externally at
    https://devop.live/lakehouse/biometric/withdraw.

(3) Retention sweep systemd timer. crates/catalogd/bin/retention_sweep
    binary already existed; this commit schedules it. Daily 03:00 UTC,
    Persistent=true so a missed boot triggers on next start. Service
    runs as oneshot with --apply (writes a date-stamped JSONL to
    data/_catalog/subjects/_retention_sweep_<date>.jsonl ONLY when
    overdue subjects exist, per the binary's existing semantics).
    install.sh updated to handle .timer + paired .service correctly:
    enables the timer, skips direct start of the oneshot service
    (the timer pulls it in). One-shot manual test confirmed clean:
    100 subjects scanned, 0 overdue (all backfill subjects within
    their 4-year general retention window).

(4) operator_of_record bug fix in intake UI. Previously the page
    hardcoded the literal string 'intake_ui_operator' as the
    operator_of_record sent to /consent — meaning every audit row
    captured the same useless placeholder, defeating the whole
    point of operator traceability. Fixed by adding an operator
    name field to the token-paste step (sessionStorage-backed),
    passed through to consent + photo POSTs as the actual operator.

Verified live post-restart:
- gateway /audit/health + /biometric/health both 200
- mcp-server /biometric/intake + /biometric/withdraw both 200
- Live withdraw probes: 401 (no token), 400 (empty body), 404
  (ghost subject), 409 nothing_to_withdraw on WORKER-1 (which
  is NeverCollected per backfill default) — all expected
- Binary strings contain: process_withdraw, withdraw_consent,
  biometric_consent_withdrawal, biometric_withdraw_response.v1,
  nothing_to_withdraw, already_withdrawn, already_expired,
  /subject/{candidate_id}/withdraw route
- systemd: lakehouse-retention-sweep.timer active+enabled,
  next fire Tue 2026-05-05 22:00 CDT (= 03:00 UTC May 6)
- Manual one-shot of retention sweep service: exit 0/SUCCESS,
  100 subjects loaded, 0 overdue

83/83 catalogd lib tests + 46/46 biometric_endpoint tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:09:32 -05:00

483 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lakehouse — Biometric Consent Intake</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',-apple-system,system-ui,sans-serif;background:#090c10;color:#b0b8c4;font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
a{color:#58a6ff;text-decoration:none}
a:hover{color:#79c0ff}
.bar{background:#0d1117;padding:0 24px;height:56px;border-bottom:1px solid #171d27;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
.bar h1{font-size:14px;font-weight:600;color:#e6edf3;letter-spacing:-0.2px}
.bar .cid{font-size:12px;color:#545d68;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
.bar .step{font-size:11px;color:#7d8590;text-transform:uppercase;letter-spacing:1px}
.wrap{max-width:760px;margin:0 auto;padding:28px 20px 60px}
.screen{display:none}
.screen.active{display:block}
h2{font-size:20px;color:#e6edf3;font-weight:600;margin-bottom:8px;letter-spacing:-0.3px}
h3{font-size:14px;color:#e6edf3;font-weight:600;margin:16px 0 8px}
.lede{color:#7d8590;font-size:13px;margin-bottom:24px}
.card{background:#0d1117;border:1px solid #171d27;padding:20px;margin-bottom:16px}
.card.warn{border-left:3px solid #d29922}
.card.bad{border-left:3px solid #f85149}
.card.good{border-left:3px solid #3fb950}
label{display:block;margin-bottom:12px;color:#7d8590;font-size:12px;text-transform:uppercase;letter-spacing:0.5px}
input[type=text],input[type=password]{width:100%;background:#0d1117;border:1px solid #2d333b;color:#e6edf3;padding:10px 12px;font-family:inherit;font-size:14px;border-radius:0;margin-top:6px;transition:border-color .15s}
input[type=text]:focus,input[type=password]:focus{outline:none;border-color:#58a6ff}
input[type=file]{color:#7d8590;font-size:12px;margin-top:8px}
input[type=checkbox]{margin-right:8px;transform:scale(1.2)}
.checkbox-row{display:flex;align-items:flex-start;color:#e6edf3;font-size:14px;margin:16px 0;cursor:pointer;text-transform:none;letter-spacing:0}
.checkbox-row input{margin-top:3px}
button{background:#1f6feb;color:#fff;border:none;padding:10px 20px;font-family:inherit;font-size:14px;font-weight:500;cursor:pointer;border-radius:0;transition:background .15s;margin-right:8px}
button:hover:not(:disabled){background:#388bfd}
button:disabled{background:#21262d;color:#545d68;cursor:not-allowed}
button.secondary{background:#21262d;color:#c9d1d9}
button.secondary:hover:not(:disabled){background:#30363d}
button.danger{background:#da3633}
button.danger:hover:not(:disabled){background:#f85149}
.consent-text{background:#0a0c10;border:1px solid #1f242c;padding:20px;max-height:340px;overflow-y:auto;font-size:13px;line-height:1.7;margin-bottom:16px}
.consent-text h4{color:#e6edf3;font-size:14px;font-weight:600;margin:14px 0 6px}
.consent-text h4:first-child{margin-top:0}
.consent-text p{margin-bottom:8px;color:#b0b8c4}
.consent-text ul{margin:6px 0 8px 20px;color:#b0b8c4}
.row{display:flex;gap:12px;align-items:center}
.row > *{flex:1}
.row > button{flex:0 0 auto}
.preview{max-width:280px;max-height:280px;display:block;margin:12px 0;border:2px solid #30363d}
video{max-width:280px;max-height:280px;display:block;margin:12px 0;border:2px solid #30363d;background:#000}
canvas{display:none}
.hash{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:11px;color:#7ee787;word-break:break-all;background:#0a0c10;padding:8px 12px;border:1px solid #1f242c;margin:6px 0}
.kv{display:grid;grid-template-columns:140px 1fr;gap:8px;margin:6px 0;font-size:13px}
.kv .k{color:#7d8590;text-transform:uppercase;font-size:11px;letter-spacing:0.5px;padding-top:2px}
.kv .v{color:#e6edf3;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px}
.error{background:#3a0f0f;border:1px solid #f85149;color:#ffa198;padding:12px 16px;margin:12px 0;font-size:13px;display:none}
.error.show{display:block}
.error code{color:#fff}
.foot{margin-top:32px;padding-top:16px;border-top:1px solid #171d27;font-size:11px;color:#545d68;text-transform:uppercase;letter-spacing:0.5px}
.spec-link{font-size:11px;color:#545d68;letter-spacing:0.4px;text-transform:uppercase}
.spec-link a{color:#58a6ff}
</style>
</head>
<body>
<div class="bar">
<h1>⚡ Biometric Consent Intake</h1>
<span class="step" id="step-label">step 1 of 4</span>
<span class="cid" id="cid-display"></span>
</div>
<div class="wrap">
<!-- Step 1: operator auth -->
<div id="screen-token" class="screen active">
<h2>Operator authentication</h2>
<p class="lede">Paste the legal-tier audit token + your name. Both stored in this tab's session only; cleared on close. Never persists to disk. Your name is recorded as <code>operator_of_record</code> in the audit row for legal traceability.</p>
<div class="card">
<label for="token-input">Legal audit token</label>
<input type="password" id="token-input" placeholder="44-char token from /etc/lakehouse/legal_audit.token" autocomplete="off">
<label for="op-name" style="margin-top:14px">Your name (operator of record)</label>
<input type="text" id="op-name" placeholder="First Last" autocomplete="off">
<div style="margin-top:14px">
<button id="token-submit">Continue →</button>
</div>
</div>
<div class="error" id="token-error"></div>
</div>
<!-- Step 2: consent -->
<div id="screen-consent" class="screen">
<h2>Biometric Information Consent — v1</h2>
<p class="lede">Please read the disclosures below. Sign by checking the box and entering your printed name. The candidate-facing surface required by BIPA §15(b)(1)-(3).</p>
<div class="consent-text">
<h4>Disclosure 1 — Notice of collection (§15(b)(1))</h4>
<p>Lakehouse will collect and store your <strong>biometric identifier</strong> (a photograph of you from which facial geometry is implicit). The photograph itself is the data we keep — we do <strong>NOT</strong> run automated facial-classification (gender / race / age inference) against it in v1. If at a later date we add automated classification, we will re-collect consent under a superseding template before doing so.</p>
<h4>Disclosure 2 — Specific purpose and length of term (§15(b)(2))</h4>
<p>The biometric data will be used for:</p>
<ul>
<li>Identity verification at staffing job sites</li>
<li>Internal record-keeping so coordinators can recognize you across placements</li>
</ul>
<p>Retained for a maximum of <strong>18 months</strong> from your most recent interaction, then permanently destroyed per the <a href="#" onclick="alert('See docs/policies/consent/biometric_retention_schedule_v1.md')">Biometric Retention Schedule v1</a>.</p>
<p>You may withdraw at any time by contacting the operator. Withdrawal triggers permanent destruction.</p>
<h4>Disclosure 3 — Written release (§15(b)(3))</h4>
<p>By checking the box below and providing your printed name, you provide a written release authorizing Lakehouse to collect, store, and use your biometric identifier and biometric information for the purposes and term stated above.</p>
<h4>What you're agreeing to (plain language)</h4>
<p>If you upload a photo of yourself, we'll keep that photo so your staffing coordinator can recognize you when you arrive at job sites. We don't run automated guesses about your age, gender, or race against the photo. We don't sell it. We don't share it outside the staffing operation unless legally compelled. You can withdraw consent at any time.</p>
</div>
<div class="card">
<label for="cand-name">Candidate printed name</label>
<input type="text" id="cand-name" placeholder="First Last" autocomplete="off">
<label class="checkbox-row" style="margin-top:16px">
<input type="checkbox" id="cand-agree">
<span>I have read and understood the disclosures above. I am providing this consent voluntarily and free of coercion.</span>
</label>
<div class="kv" style="margin-top:14px">
<span class="k">Method</span><span class="v">click_acceptance</span>
<span class="k">Version hash</span><span class="v" id="version-hash-display">computing…</span>
<span class="k">Timestamp</span><span class="v" id="timestamp-display">on submit</span>
</div>
<div style="margin-top:16px">
<button id="grant-btn" disabled>Grant consent + continue →</button>
</div>
</div>
<div class="error" id="consent-error"></div>
</div>
<!-- Step 3: photo -->
<div id="screen-photo" class="screen">
<h2>Photo capture</h2>
<p class="lede">Take or upload a clear photo. Stored quarantined under <code>data/biometric/uploads/</code> with mode 0700/0600 + SHA-256 integrity hash. Audit chain records the upload event.</p>
<div class="card">
<h3>Option A — File upload</h3>
<input type="file" id="photo-file" accept="image/jpeg,image/png">
<h3 style="margin-top:24px">Option B — Camera capture</h3>
<button id="cam-start" class="secondary">Start camera</button>
<button id="cam-capture" class="secondary" style="display:none">📸 Capture</button>
<button id="cam-stop" class="secondary" style="display:none">Stop</button>
<video id="cam" autoplay playsinline muted></video>
<canvas id="cam-canvas"></canvas>
<h3 style="margin-top:24px">Preview</h3>
<img id="preview" class="preview" style="display:none">
<p id="no-preview" class="lede" style="margin:12px 0">No photo selected yet.</p>
<div style="margin-top:16px">
<button id="upload-btn" disabled>Upload photo + finish →</button>
<button id="skip-btn" class="secondary">Skip photo (consent only)</button>
</div>
</div>
<div class="error" id="photo-error"></div>
</div>
<!-- Step 4: done -->
<div id="screen-done" class="screen">
<div class="card good">
<h2>✓ Intake complete</h2>
<p class="lede" style="margin:8px 0 0">Audit chain rows (HMAC-SHA256, persisted to <code>data/_catalog/subjects/&lt;id&gt;.audit.jsonl</code>):</p>
</div>
<div class="card">
<h3>Consent grant</h3>
<div class="kv">
<span class="k">Status</span><span class="v" id="done-status">Given</span>
<span class="k">Given at</span><span class="v" id="done-given-at"></span>
<span class="k">Retention until</span><span class="v" id="done-retention"></span>
</div>
<h3>Audit hmac</h3>
<div class="hash" id="done-consent-hmac"></div>
</div>
<div class="card" id="photo-card" style="display:none">
<h3>Photo upload</h3>
<div class="kv">
<span class="k">Data path</span><span class="v" id="done-data-path"></span>
<span class="k">Template SHA-256</span><span class="v" id="done-template-hash"></span>
</div>
<h3>Audit hmac</h3>
<div class="hash" id="done-photo-hmac"></div>
</div>
<div class="card warn">
<h3>Verification</h3>
<p>Operator: confirm the audit chain by running:</p>
<div class="hash" id="verify-cmd">./scripts/staffing/verify_biometric_erasure.sh &lt;candidate_id&gt;</div>
<p style="margin-top:8px">Or hit <code>GET /audit/subject/&lt;id&gt;</code> with legal-tier auth to read the full chain.</p>
</div>
<div style="margin-top:16px">
<button onclick="location.reload()" class="secondary">Start another intake</button>
</div>
</div>
</div>
<div class="foot">
<span class="spec-link">
<a href="https://git.agentview.dev/profit/lakehouse/src/branch/main/docs/PHASE_1_6_BIPA_GATES.md">Phase 1.6 BIPA Gates</a>
· <a href="https://git.agentview.dev/profit/lakehouse/src/branch/main/docs/policies/consent/biometric_consent_template_v1.md">Consent template v1</a>
· <a href="https://git.agentview.dev/profit/lakehouse/src/branch/main/docs/runbooks/BIPA_DESTRUCTION_RUNBOOK.md">Destruction runbook</a>
</span>
</div>
<script>
// ── Config ────────────────────────────────────────────────────────
// Gateway base URL. Same-origin if the page is served behind a reverse
// proxy; otherwise the operator's local gateway.
const GATEWAY = (function(){
const p = new URLSearchParams(location.search);
return p.get('gw') || 'http://localhost:3100';
})();
// Candidate ID from the URL. Required.
const CANDIDATE_ID = (function(){
const p = new URLSearchParams(location.search);
return p.get('candidate_id') || p.get('cid') || '';
})();
// ── State ─────────────────────────────────────────────────────────
const state = {
token: null, // sessionStorage-backed
operator: null, // operator_of_record — captured at token paste
consentVersionHash: null, // SHA-256 of the rendered consent template
consentResp: null, // server response from /consent
photoBlob: null, // captured/selected photo
photoResp: null, // server response from /photo
};
// ── Helpers ───────────────────────────────────────────────────────
function show(screen) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(screen).classList.add('active');
const stepMap = {'screen-token':'step 1 of 4', 'screen-consent':'step 2 of 4', 'screen-photo':'step 3 of 4', 'screen-done':'step 4 of 4'};
document.getElementById('step-label').textContent = stepMap[screen] || '';
}
function err(elId, msg) {
const e = document.getElementById(elId);
e.textContent = msg;
e.classList.add('show');
}
function clearErr(elId) {
document.getElementById(elId).classList.remove('show');
}
async function sha256Hex(text) {
const enc = new TextEncoder().encode(text);
const buf = await crypto.subtle.digest('SHA-256', enc);
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('');
}
// ── Init ──────────────────────────────────────────────────────────
(async function init(){
if (!CANDIDATE_ID) {
err('token-error', 'Missing candidate_id in URL. Append ?candidate_id=WORKER-XXX');
document.getElementById('token-submit').disabled = true;
return;
}
document.getElementById('cid-display').textContent = CANDIDATE_ID;
// Restore token + operator from sessionStorage if previously set in this tab.
const saved = sessionStorage.getItem('lh_legal_token');
const savedOp = sessionStorage.getItem('lh_operator_name');
if (saved) {
state.token = saved;
document.getElementById('token-input').value = '••••••••';
}
if (savedOp) {
state.operator = savedOp;
document.getElementById('op-name').value = savedOp;
}
// Compute consent template hash (SHA-256 of the rendered consent block).
// This is what we'll send as consent_version_hash. Counsel later
// attests against the canonical signed template hash; operator
// discipline keeps these aligned.
const consentText = document.querySelector('.consent-text').innerText;
state.consentVersionHash = await sha256Hex(consentText);
document.getElementById('version-hash-display').textContent = state.consentVersionHash.substring(0,16) + '…';
})();
// ── Step 1: token + operator ───────────────────────────────────────
document.getElementById('token-submit').addEventListener('click', () => {
clearErr('token-error');
const v = document.getElementById('token-input').value.trim();
// If user kept the masked placeholder, use the saved token.
const tok = v === '••••••••' ? state.token : v;
const op = document.getElementById('op-name').value.trim();
if (!tok || tok.length < 32) {
err('token-error', 'Token must be ≥32 characters.');
return;
}
if (!op || op.length < 2) {
err('token-error', 'Operator name is required (recorded as operator_of_record in audit row).');
return;
}
state.token = tok;
state.operator = op;
sessionStorage.setItem('lh_legal_token', tok);
sessionStorage.setItem('lh_operator_name', op);
show('screen-consent');
});
// ── Step 2: consent ────────────────────────────────────────────────
function refreshGrantBtn() {
const ok = document.getElementById('cand-agree').checked
&& document.getElementById('cand-name').value.trim().length >= 2;
document.getElementById('grant-btn').disabled = !ok;
}
document.getElementById('cand-agree').addEventListener('change', refreshGrantBtn);
document.getElementById('cand-name').addEventListener('input', refreshGrantBtn);
document.getElementById('grant-btn').addEventListener('click', async () => {
clearErr('consent-error');
document.getElementById('grant-btn').disabled = true;
try {
const candName = document.getElementById('cand-name').value.trim();
const ts = new Date().toISOString();
document.getElementById('timestamp-display').textContent = ts;
const evidence = JSON.stringify({
method: 'click_acceptance',
candidate_printed_name: candName,
acceptance_ts: ts,
user_agent: navigator.userAgent,
page_origin: location.origin,
});
const evidenceHash = await sha256Hex(evidence);
const body = {
consent_version_hash: state.consentVersionHash,
consent_collection_method: 'click_acceptance',
consent_collection_evidence_path: 'inline:sha256=' + evidenceHash,
operator_of_record: state.operator,
};
const r = await fetch(`${GATEWAY}/biometric/subject/${encodeURIComponent(CANDIDATE_ID)}/consent`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Lakehouse-Legal-Token': state.token,
'X-Lakehouse-Trace-Id': 'intake-' + Date.now(),
},
body: JSON.stringify(body),
});
const respBody = await r.json();
if (!r.ok) {
err('consent-error', `${r.status} ${respBody.error || 'unknown'}${respBody.detail || ''}`);
document.getElementById('grant-btn').disabled = false;
return;
}
state.consentResp = respBody;
show('screen-photo');
} catch (e) {
err('consent-error', `Network error: ${e.message}`);
document.getElementById('grant-btn').disabled = false;
}
});
// ── Step 3: photo ──────────────────────────────────────────────────
const previewEl = document.getElementById('preview');
const noPreviewEl = document.getElementById('no-preview');
const fileInput = document.getElementById('photo-file');
fileInput.addEventListener('change', () => {
const f = fileInput.files[0];
if (!f) return;
state.photoBlob = f;
previewEl.src = URL.createObjectURL(f);
previewEl.style.display = 'block';
noPreviewEl.style.display = 'none';
document.getElementById('upload-btn').disabled = false;
});
let camStream = null;
document.getElementById('cam-start').addEventListener('click', async () => {
clearErr('photo-error');
try {
camStream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: 'user' }, audio: false });
document.getElementById('cam').srcObject = camStream;
document.getElementById('cam').style.display = 'block';
document.getElementById('cam-start').style.display = 'none';
document.getElementById('cam-capture').style.display = 'inline-block';
document.getElementById('cam-stop').style.display = 'inline-block';
} catch (e) {
err('photo-error', `Camera unavailable: ${e.message}`);
}
});
document.getElementById('cam-capture').addEventListener('click', () => {
const v = document.getElementById('cam');
const c = document.getElementById('cam-canvas');
c.width = v.videoWidth;
c.height = v.videoHeight;
c.getContext('2d').drawImage(v, 0, 0);
c.toBlob(b => {
state.photoBlob = b;
previewEl.src = URL.createObjectURL(b);
previewEl.style.display = 'block';
noPreviewEl.style.display = 'none';
document.getElementById('upload-btn').disabled = false;
}, 'image/jpeg', 0.92);
});
document.getElementById('cam-stop').addEventListener('click', () => {
if (camStream) { camStream.getTracks().forEach(t => t.stop()); camStream = null; }
document.getElementById('cam').style.display = 'none';
document.getElementById('cam-start').style.display = 'inline-block';
document.getElementById('cam-capture').style.display = 'none';
document.getElementById('cam-stop').style.display = 'none';
});
document.getElementById('upload-btn').addEventListener('click', async () => {
clearErr('photo-error');
document.getElementById('upload-btn').disabled = true;
try {
const ct = state.photoBlob.type || 'image/jpeg';
const r = await fetch(`${GATEWAY}/biometric/subject/${encodeURIComponent(CANDIDATE_ID)}/photo`, {
method: 'POST',
headers: {
'Content-Type': ct,
'X-Lakehouse-Legal-Token': state.token,
'X-Lakehouse-Consent-Version-Hash': state.consentVersionHash,
'X-Lakehouse-Trace-Id': 'intake-' + Date.now(),
},
body: state.photoBlob,
});
const respBody = await r.json();
if (!r.ok) {
err('photo-error', `${r.status} ${respBody.error || 'unknown'}${respBody.detail || ''}`);
document.getElementById('upload-btn').disabled = false;
return;
}
state.photoResp = respBody;
if (camStream) camStream.getTracks().forEach(t => t.stop());
showDone();
} catch (e) {
err('photo-error', `Network error: ${e.message}`);
document.getElementById('upload-btn').disabled = false;
}
});
document.getElementById('skip-btn').addEventListener('click', () => {
if (camStream) camStream.getTracks().forEach(t => t.stop());
showDone();
});
// ── Step 4: done ───────────────────────────────────────────────────
function showDone() {
document.getElementById('done-given-at').textContent = state.consentResp.given_at;
document.getElementById('done-retention').textContent = state.consentResp.retention_until;
document.getElementById('done-consent-hmac').textContent = state.consentResp.audit_row_hmac;
if (state.photoResp) {
document.getElementById('photo-card').style.display = 'block';
document.getElementById('done-data-path').textContent = state.photoResp.data_path;
document.getElementById('done-template-hash').textContent = state.photoResp.template_hash;
document.getElementById('done-photo-hmac').textContent = state.photoResp.audit_row_hmac;
}
document.getElementById('verify-cmd').textContent =
`./scripts/staffing/verify_biometric_erasure.sh ${CANDIDATE_ID}`;
show('screen-done');
}
</script>
</body>
</html>