Onboarding wizard — ingest any staffing CSV in 3 steps

New /onboard page. Client-facing wizard for getting real data into
the system without engineering help.

Flow:
1. Drop a CSV (or click 'Use the sample as my data' — ships a 25-row
   realistic staffing roster under /samples/staffing_roster_sample.csv)
2. Browser parses client-side. Columns auto-typed (text/int/decimal/
   date). PII flagged by name hint AND content regex (emails, phones).
   First rows previewed. Read-only — nothing written yet.
3. Name the dataset (lowercase+underscores). Commit.
4. Post-commit: dataset is live. Shows 4 next steps the operator can
   take (SQL query, vector index, dashboard search, playbook training).

Backend:
- /onboard serves onboard.html
- /samples/*.csv serves CSV files from mcp-server/samples/ with
  filename validation (only [a-zA-Z0-9_-.]+.csv, prevents path traversal)
- /onboard/ingest forwards multipart/form-data to gateway /ingest/file
  preserving the boundary. The generic /api/* passthrough breaks
  multipart because it reads as text and forwards as JSON; this route
  uses arrayBuffer + original Content-Type.

Verified end-to-end: upload sample roster (25 rows, 12 columns) →
parse in browser → show columns + PII flags + preview → commit →
gateway writes Parquet, registers in catalog → immediately queryable:
  SELECT * FROM onboard_demo2 LIMIT 3
  → Sarah Johnson, Forklift Operator, Chicago, IL, 0.92
Round-trip <1 second.

Nav updated on all pages to link Onboard. Shipped with a sample CSV
so the full flow is demonstrable without real client data.

When a real client shows up, same path — they upload their CSV.
No engineering ticket, no code change, no schema pre-definition.

Security: sample filename regex prevents path traversal. CSV parse
is client-side pure JS (no DOM injection). Commit uses existing
/ingest/file validation (schema fingerprint, PII server-side,
content-hash dedup).
This commit is contained in:
root
2026-04-20 18:13:56 -05:00
parent 468798c9ac
commit 23eb04a145
7 changed files with 490 additions and 0 deletions
+48
View File
@@ -650,6 +650,54 @@ async function main() {
});
}
// Onboard — client-facing ingest wizard. Upload any CSV, preview
// columns + PII + sample rows, commit via /ingest/file. Works
// with a shipped sample roster so anyone can trial the flow
// without real client data.
if (url.pathname === "/onboard") {
return new Response(Bun.file(import.meta.dir + "/onboard.html"), {
headers: { ...cors, "Content-Type": "text/html" },
});
}
// Onboard ingest — forwards multipart/form-data correctly to
// the Rust gateway /ingest/file. The generic /api/* passthrough
// can't handle multipart because it reads as text and forwards
// as JSON, losing the boundary. This route preserves the body
// and Content-Type.
if (url.pathname === "/onboard/ingest" && req.method === "POST") {
const name = url.searchParams.get("name");
if (!name || !/^[a-z][a-z0-9_]*$/.test(name)) {
return err("dataset name required (lowercase+underscores)", 400);
}
const contentType = req.headers.get("content-type") || "";
const upstream = await fetch(`${BASE}/ingest/file?name=${encodeURIComponent(name)}`, {
method: "POST",
headers: { "Content-Type": contentType },
body: await req.arrayBuffer(),
});
const body = await upstream.text();
return new Response(body, {
status: upstream.status,
headers: { ...cors, "Content-Type": upstream.headers.get("content-type") || "application/json" },
});
}
// Sample files (downloadable + fetchable from the onboard wizard)
if (url.pathname.startsWith("/samples/")) {
const name = url.pathname.slice("/samples/".length);
if (!/^[a-zA-Z0-9_\-\.]+\.csv$/.test(name)) {
return err("invalid sample filename", 400);
}
const path = `${import.meta.dir}/samples/${name}`;
const file = Bun.file(path);
if (!(await file.exists())) return err("sample not found", 404);
return new Response(file, {
headers: { ...cors, "Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="${name}"` },
});
}
// Proof JSON API (same data, no HTML)
if (url.pathname === "/proof.json") {
const ds = await api("GET", "/catalog/datasets") as any[];