phase 1.6: candidate intake UI — operator-driven consent + photo capture

Adds the Gate 2 frontend that operators use to capture candidate
biometric consent + photo at intake. Without this, the
/biometric/subject/{id}/consent endpoint shipped in 76cb5ac has
no caller — operators would have to build curl scripts by hand.

Surfaces:
- mcp-server serves /biometric/intake as static HTML (mounted at
  http://localhost:3700/biometric/intake; externally accessible via
  nginx at https://devop.live/lakehouse/biometric/intake)
- URL must include ?candidate_id=WORKER-XXX

Page flow (4 screens):
- Step 1 — operator authentication. Pastes legal-tier audit token
  into a password field; stored in sessionStorage (cleared on tab
  close), never localStorage, never cookies.
- Step 2 — consent. Renders the v1 consent template text inline
  (Disclosures 1/2/3 + plain-language summary, all matching
  docs/policies/consent/biometric_consent_template_v1.md as of
  this commit). Captures candidate printed name + checkbox accept;
  computes SHA-256 of the rendered consent block as
  consent_version_hash; computes SHA-256 of the click-acceptance
  evidence (method + name + ts + user_agent + page_origin) and
  records it as inline:sha256=<hash> evidence path. POSTs to
  gateway /biometric/subject/{id}/consent.
- Step 3 — photo. Two paths: file upload OR getUserMedia camera
  capture. Preview before submit. Skip-photo button for
  consent-only intake (e.g. consent collected before equipment
  available). POSTs to gateway /biometric/subject/{id}/photo.
- Step 4 — confirmation. Displays the audit row HMACs from both
  endpoint responses + the verify_biometric_erasure.sh command
  the operator can run for chain attestation.

Design choices:
- No framework, no build step. Single self-contained HTML file
  ~22KB. Matches the existing mcp-server precedent
  (onboard.html, console.html, etc.).
- Neo-brutalist dark style matching mcp-server's other pages
  (tracked dark surfaces, monospace technical metadata, sharp
  borders). Consistent with j's UI preferences.
- Server-authoritative timestamps. The page sends its own UA +
  click ts as evidence, but the canonical given_at on the
  manifest comes from the gateway's Utc::now() (per
  process_consent). Page displays whatever the gateway returns.
- Gateway URL configurable via ?gw= query param; defaults to
  http://localhost:3100 for the same-box workstation pattern.
  External-access deployment requires a separate nginx route
  for the gateway (out of scope for v1 to avoid touching the
  devop.live nginx config).

Verified live:
- GET http://localhost:3700/biometric/intake?candidate_id=WORKER-100
  returns 200 + 22KB HTML body
- GET https://devop.live/lakehouse/biometric/intake?candidate_id=WORKER-100
  returns 200 (nginx /lakehouse/ route proxies to :3700)
- Gateway CORS preflight for POST /biometric/subject/{id}/consent
  from origin http://localhost:3700: 200 with allow-methods/headers/origin: *

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-05-05 14:12:43 -05:00
parent 76cb5acb03
commit 7f0f500050
2 changed files with 478 additions and 0 deletions

View File

@ -0,0 +1,467 @@
<!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. Stored in this tab's session only; cleared on close. Never persists to disk.</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">
<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
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 from sessionStorage if previously set in this tab.
const saved = sessionStorage.getItem('lh_legal_token');
if (saved) {
state.token = saved;
document.getElementById('token-input').value = '••••••••';
}
// 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 ──────────────────────────────────────────────────
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;
if (!tok || tok.length < 32) {
err('token-error', 'Token must be ≥32 characters.');
return;
}
state.token = tok;
sessionStorage.setItem('lh_legal_token', tok);
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: 'intake_ui_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>

View File

@ -767,6 +767,17 @@ async function main() {
});
}
// Biometric intake — Phase 1.6 Gate 2 frontend. Operator-driven
// candidate consent + photo capture flow. POSTs to gateway's
// /biometric/subject/{id}/consent + /photo. URL must include
// ?candidate_id=WORKER-XXX. Operator's legal-tier audit token
// is captured into sessionStorage (cleared on tab close).
if (url.pathname === "/biometric/intake") {
return new Response(Bun.file(import.meta.dir + "/biometric_intake.html"), {
headers: { ...cors, "Content-Type": "text/html" },
});
}
// Workspaces — per-contract state (Phase 8.5). UI layer over the
// gateway's /workspaces/* routes: list, create, detail, handoff,
// save-search, shortlist, log-activity. All persisted on the