This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/new-site/onboarding-owner.jsx
mawkone 6b8862ef2b feat(api): comprehensive QA hardening — security gates, chat improvements, beta scaffolds
Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07,
B-01..B-07, R-01..R-02, O-03.

Security (28 deletions + 10 auth gates):
- Delete 28 unauthenticated debug/cursor/firebase/test routes
- Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth
- Add HMAC-SHA256 signature verification to webhooks/coolify
- Switch all admin secret comparisons to timingSafeStringEq

Foundations (lib/server/*):
- api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit
- logger.ts: structured request-scoped logging with turnId
- audit-log.ts: writeAuditLog helper + audit_log table
- rate-limit.ts: Postgres sliding window rate limiter
- coolify-webhook.ts: verifyCoolifySignature
- timing-safe.ts: timingSafeStringEq

Chat hardening (chat/route.ts):
- MAX_TOOL_ROUNDS 15 → 8 (C-01)
- Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02)
- Add 6-consecutive-tool-call hard-break (C-02)
- Mode: respond first, act second prompt block (C-03)
- SSE heartbeat every 25s via setInterval (C-04)
- Per-tool 45s timeout via Promise.race (C-05)
- turnId per-turn UUID for log correlation (C-06)
- Recovery fires when roundsSinceText >= 4 (C-07)
- SSE plan event on plan_task_add/edit (B-05)

Beta features:
- invites table + GET/POST /api/invites (P4.8)
- invites/[token] validate + redeem (P4.8)
- fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1)
- fs_project_secrets table + CRUD routes (P6.D2)
- lib/integrations/brief-extract.ts (P3.7)

Documentation:
- app/api/ROUTES.md: full route map with auth + tenant
2026-05-17 19:17:22 -07:00

263 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Owner path — 4 steps for small-business owners replacing their stack.
const OWNER_TOTAL = 4;
const OWNER_STEP_NAMES = ["Business", "Stack", "First fix", "Scale"];
const BIZ_KINDS = [
{ id: "service", icon: "🛠", label: "Trades / home services", desc: "Plumbing, HVAC, landscaping, cleaning" },
{ id: "retail", icon: "🛍", label: "Retail / shop", desc: "Vintage, boutique, market, online" },
{ id: "food", icon: "🥐", label: "Food & drink", desc: "Café, bakery, food truck, catering" },
{ id: "appointments", icon: "💈", label: "Appointment-based", desc: "Salon, studio, clinic, tutoring" },
{ id: "events", icon: "🎟", label: "Events / hospitality", desc: "Venue, rental, planning" },
{ id: "other", icon: "✦", label: "Something else", desc: "We'll learn from your answers" },
];
function OwnerBiz({ value, name, onChange, onNameChange }) {
return (
<>
<WizardQ
title="What does your business do?"
sub="Roughly. We tailor the next screens to match."
/>
<PresetGroup
options={BIZ_KINDS.map((b) => ({
id: b.id, label: b.label, desc: b.desc,
icon: <span style={{ fontSize: 14 }}>{b.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
<Field label="Business name">
<input
className="wiz-input"
placeholder="Sunrise Plumbing, Pearl Lane Bakery…"
value={name}
onChange={(e) => onNameChange(e.target.value)}
/>
</Field>
</>
);
}
const STACK_TOOLS = [
"Square / POS",
"Stripe",
"Calendly",
"Acuity",
"Shopify",
"QuickBooks",
"Mailchimp",
"Instagram",
"Google Sheets",
"Notion / Airtable",
"Wix / Squarespace",
"WhatsApp / Slack",
"A printed binder",
"Head + notepad",
];
function OwnerStack({ tools, spend, onToolsChange, onSpendChange }) {
return (
<>
<WizardQ
title="What are you renting right now?"
sub="Tap everything you pay for. Approximate is fine."
/>
<Field label="Tools & subscriptions">
<ChipGroup
options={STACK_TOOLS}
values={tools || []}
onChange={onToolsChange}
allowOther
/>
</Field>
<Field label="About how much per month?" hint="Across all your software, ballpark.">
<Slider
min={0} max={1500} step={25}
value={spend ?? 250}
onChange={onSpendChange}
format={(v) => v === 0 ? "$0" : v === 1500 ? "$1.5k+" : `$${v}`}
/>
</Field>
{(tools || []).length > 0 && (
<div
style={{
padding: "12px 14px",
borderRadius: 10,
border: "1px solid var(--hairline)",
background: "oklch(0.18 0.009 60 / 0.6)",
fontSize: 13.5,
lineHeight: 1.5,
color: "var(--fg-dim)",
display: "flex", gap: 12, alignItems: "flex-start",
}}
>
<span style={{
width: 6, height: 6, borderRadius: "50%",
background: "var(--accent)", boxShadow: "0 0 10px var(--accent-glow)",
marginTop: 7, flexShrink: 0,
}} />
<span>
<b style={{ color: "var(--fg)", fontWeight: 500 }}>{tools.length} tool{tools.length === 1 ? "" : "s"}</b>
{spend ? <> · ~<b style={{ color: "var(--fg)", fontWeight: 500 }}>${spend}/mo</b></> : null}.
Replaced by one workspace, owned by you.
</span>
</div>
)}
</>
);
}
const OWNER_FIRST_THINGS = [
{ id: "booking", icon: "📅", label: "Bookings & scheduling", desc: "Customers book themselves." },
{ id: "invoicing", icon: "🧾", label: "Quotes, invoices, payments", desc: "Send a quote, get paid, no chasing." },
{ id: "customers", icon: "👥", label: "Customer history & portal", desc: "One place per customer." },
{ id: "inventory", icon: "📦", label: "Inventory & orders", desc: "Track stock, sales, suppliers." },
{ id: "team", icon: "🪪", label: "Team & job dispatch", desc: "Assign jobs, log hours." },
{ id: "marketing", icon: "📣", label: "Website + email + reviews", desc: "A site that converts, list that follows up." },
];
function OwnerFirstThing({ value, onChange }) {
return (
<>
<WizardQ
title="What's burning first?"
sub="The one workflow you wish was already replaced. Vibn builds it on day one."
/>
<PresetGroup
options={OWNER_FIRST_THINGS.map((f) => ({
id: f.id, label: f.label, desc: f.desc,
icon: <span style={{ fontSize: 14 }}>{f.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
</>
);
}
const OWNER_HOW_LONG = [
{ id: "starting", label: "Just starting" },
{ id: "1_3", label: "13 years" },
{ id: "3_10", label: "310 years" },
{ id: "10_plus", label: "10+ years" },
];
function OwnerScale({ customers, team, howLong, onCustomers, onTeam, onHowLong }) {
return (
<>
<WizardQ
title="A little about scale."
sub="Sensible defaults — table view vs. cards, daily vs. monthly reports."
/>
<Field label="Customers per month">
<Slider
min={0} max={2000} step={10}
value={customers ?? 50}
onChange={onCustomers}
format={(v) => v === 0 ? "0" : v >= 2000 ? "2k+" : v.toLocaleString()}
/>
</Field>
<Field label="Team size (incl. you)">
<Slider
min={1} max={50} step={1}
value={team ?? 1}
onChange={onTeam}
format={(v) => v >= 50 ? "50+" : `${v}`}
/>
</Field>
<Field label="How long have you been at this?">
<div className="chips">
{OWNER_HOW_LONG.map((h) => (
<button
key={h.id}
type="button"
className={"chip" + (howLong === h.id ? " active" : "")}
onClick={() => onHowLong(h.id)}
>
{h.label}
</button>
))}
</div>
</Field>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
function OwnerPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < OWNER_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext;
if (step === 0) {
body = (
<OwnerBiz
value={data.biz}
name={data.bizName || ""}
onChange={(v) => onUpdate({ biz: v })}
onNameChange={(v) => onUpdate({ bizName: v })}
/>
);
canNext = !!data.biz && (data.bizName || "").trim().length >= 2;
} else if (step === 1) {
body = (
<OwnerStack
tools={data.tools || []}
spend={data.spend}
onToolsChange={(v) => onUpdate({ tools: v })}
onSpendChange={(v) => onUpdate({ spend: v })}
/>
);
canNext = (data.tools || []).length >= 1;
} else if (step === 2) {
body = <OwnerFirstThing value={data.firstThing} onChange={(v) => onUpdate({ firstThing: v })} />;
canNext = !!data.firstThing;
} else {
body = (
<OwnerScale
customers={data.customers}
team={data.team}
howLong={data.howLong}
onCustomers={(v) => onUpdate({ customers: v })}
onTeam={(v) => onUpdate({ team: v })}
onHowLong={(v) => onUpdate({ howLong: v })}
/>
);
canNext = !!data.howLong;
}
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.owner}
stepText={OWNER_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 0 || step === 2 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === OWNER_TOTAL - 1 ? "Build my workspace →" : "Continue"}
hint={canNext ? "⌘↵" : null}
/>
</WizardBody>
</>
);
}
Object.assign(window, { OwnerPath, OWNER_TOTAL });