7 Commits

Author SHA1 Message Date
f665580270 Add Insights article: Rising HOA Insurance Costs: How Smart Boards Budget for the New Normal (2026-08-01)
- New article: articles/hoa-rising-insurance-costs-budgeting.html
- Updated articles/index.html with new card (newest first)
- Updated sitemap.xml with new URL entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 09:03:57 -04:00
614a0edfa1 Merge pull request 'Block example.com emails and homesites under 10' (#26) from feature/block-example-domain-and-min-homesites into main
All checks were successful
Deploy to Production / deploy (push) Successful in 3s
2026-07-24 09:30:44 -04:00
1219117adf Block example.com emails and homesites under 10
Spam submissions were still getting through with placeholder data. Two
more content filters on the public form endpoints:

- Reject emails from reserved documentation domains (example.com/.org/
  /.net/.edu and subdomains) and reserved TLDs (.test/.example/.invalid/
  localhost). testing@example.com and friends are never real leads.
- Reject a homesites count below 10. Real associations are larger; the
  junk uses 0/1/2.

Both are validated server-side in security.js (validateEmail gains a
domain blocklist, new validateHomesites) and mirrored client-side in
app.js for immediate feedback. The homesites input min attribute goes
from 1 to 10. Blocked submissions return 400 with a `field` hint and
store nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 09:30:15 -04:00
5f4af3886c Merge pull request 'Validate email addresses on both public form endpoints' (#25) from feature/strict-email-validation into main
All checks were successful
Deploy to Production / deploy (push) Successful in 6s
2026-07-23 08:25:55 -04:00
156adeab59 Validate email addresses on both public form endpoints
The ROI calculator accepted the email field with no validation at all and
stored whatever arrived, which is where the spam submissions were putting
shell-command payloads. Nothing was executable — there is no child_process
or eval in the codebase and all writes are parameterized — but the junk was
being persisted, and the field is the obvious place to stop it.

Adds security.validateEmail(), deliberately stricter than RFC 5322: the
local part is limited to the characters real addresses use, which excludes
every shell metacharacter (; | & ` $ ( ) < > \ " ' space) and CSV-injection
lead-ins. Also rejects control characters (including the CR/LF used for
mail-header injection), caps lengths at 254/64/253, rejects non-strings,
and normalizes to trimmed lowercase before storage.

Applied to /api/calculate (optional field — empty is fine, present must be
valid) and to /api/leads, replacing its much weaker regex. The client
mirrors the check for immediate feedback; the server remains authoritative.

Also hardens the /api/leads required-field checks, which called .trim() on
unvalidated input and returned a 500 rather than a 400 when a bot posted a
non-string.

Trade-off: RFC-legal but vanishingly rare addresses (foo!bar$baz@x.com, a
leading + in the local part) are rejected. Those characters are the
injection surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 08:25:22 -04:00
c6425887f1 Merge pull request 'Add abuse protection to the ROI calculator form' (#24) from feature/roi-calculator-captcha into main
All checks were successful
Deploy to Production / deploy (push) Successful in 3s
2026-07-23 07:13:58 -04:00
04ef642775 Add abuse protection to the ROI calculator form
The calculator endpoint was open to anyone, and it has been collecting
spam submissions. Add a layered guard that runs before anything is
written to the DB or sent to the AI provider:

- Cloudflare Turnstile CAPTCHA, verified server-side (active when
  TURNSTILE_* keys are configured; fails closed if unverifiable)
- Honeypot field that only bots fill in
- Signed, single-use form token enforcing a 4s minimum fill time
- Per-IP rate limiting (5 per 10 min, 25 per 24h)

Layers 2-4 need no configuration and work on their own, so submissions
are throttled immediately; adding Turnstile keys upgrades it to a full
challenge. Blocked submissions now stop the flow client-side instead of
silently showing a result.

Also sets `trust proxy` for correct client IPs behind nginx, caps the
JSON body at 32kb, and fixes a latent ReferenceError in the
"AI not configured" branch that called saveCalcSubmission() before the
variables it closes over were declared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 07:13:14 -04:00
9 changed files with 856 additions and 25 deletions

View File

@@ -8,3 +8,13 @@ AI_API_KEY=your_nvidia_api_key_here
AI_MODEL=qwen/qwen3.5-397b-a17b AI_MODEL=qwen/qwen3.5-397b-a17b
# Set to 'true' to enable detailed AI prompt/response logging # Set to 'true' to enable detailed AI prompt/response logging
AI_DEBUG=false AI_DEBUG=false
# Form abuse protection
# Cloudflare Turnstile (free): https://dash.cloudflare.com/?to=/:account/turnstile
# Leave blank to run without a visible CAPTCHA — the honeypot, signed form token
# and per-IP rate limits stay active either way.
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
# Optional: stable secret for signing form tokens. If unset, a random one is
# generated per process (tokens simply stop validating across restarts).
FORM_TOKEN_SECRET=

141
app.js
View File

@@ -34,10 +34,76 @@
if (!overlay) return; if (!overlay) return;
function open() { overlay.classList.add('open'); document.body.style.overflow = 'hidden'; } // ── Abuse protection ───────────────────────────────────
// A signed, single-use token is fetched when the form is opened; the server
// uses it to prove the form was really loaded and that a human took at least a
// few seconds to fill it in. When Turnstile keys are configured server-side, a
// CAPTCHA widget is rendered too.
const captchaSlot = document.getElementById('calcCaptcha');
let formToken = null;
let captchaWidget = null;
let siteKeyPromise = null;
async function fetchFormToken() {
try {
const res = await fetch('/api/form-token', { cache: 'no-store' });
formToken = (await res.json()).token || null;
} catch (_) { formToken = null; }
}
function loadTurnstileScript() {
return new Promise((resolve, reject) => {
if (window.turnstile) return resolve();
const s = document.createElement('script');
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
s.async = true;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
async function initCaptcha() {
if (!captchaSlot || captchaWidget !== null) return;
siteKeyPromise = siteKeyPromise || fetch('/api/form-config', { cache: 'no-store' })
.then(r => r.json())
.then(c => c.turnstileSiteKey)
.catch(() => null);
const siteKey = await siteKeyPromise;
if (!siteKey) return; // CAPTCHA not configured — other layers still apply
try {
await loadTurnstileScript();
captchaWidget = window.turnstile.render(captchaSlot, {
sitekey: siteKey,
theme: 'light',
action: 'roi_calculator',
});
} catch (_) { /* widget unavailable — server decides whether to allow */ }
}
function captchaResponse() {
if (captchaWidget === null || !window.turnstile) return '';
return window.turnstile.getResponse(captchaWidget) || '';
}
function resetCaptcha() {
if (captchaWidget !== null && window.turnstile) window.turnstile.reset(captchaWidget);
}
function open() {
overlay.classList.add('open');
document.body.style.overflow = 'hidden';
fetchFormToken(); // starts the minimum-fill-time clock
initCaptcha();
}
function close() { overlay.classList.remove('open'); document.body.style.overflow = ''; } function close() { overlay.classList.remove('open'); document.body.style.overflow = ''; }
openBtn?.addEventListener('click', open); openBtn?.addEventListener('click', open);
// Footer CTA also opens the modal (v2.js handles its analytics) — it needs the
// same form token and CAPTCHA set-up.
document.getElementById('openCalc2')?.addEventListener('click', open);
closeBtn?.addEventListener('click', close); closeBtn?.addEventListener('click', close);
overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
@@ -55,6 +121,34 @@
const calcBtnText = submitBtn?.querySelector('.calc-btn-text'); const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading'); const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading');
// Mirrors security.js validateEmail — the server is the authority, this just
// gives immediate feedback instead of a round-trip.
const EMAIL_RX = /^[A-Za-z0-9](?:[A-Za-z0-9._%+-]{0,62}[A-Za-z0-9])?@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,24}$/;
// Reserved documentation domains / TLDs — mirrors security.js. Real leads
// never come from these; spam bots use them.
const BLOCKED_EMAIL_DOMAINS = ['example.com', 'example.org', 'example.net', 'example.edu'];
const BLOCKED_EMAIL_TLDS = ['test', 'example', 'invalid', 'localhost'];
function isValidEmail(v) {
if (v.length > 254 || v.indexOf('@') > 64) return false;
if (v.includes('..')) return false;
if (!EMAIL_RX.test(v)) return false;
const domain = v.slice(v.indexOf('@') + 1).toLowerCase();
const tld = domain.slice(domain.lastIndexOf('.') + 1);
if (BLOCKED_EMAIL_TLDS.includes(tld)) return false;
if (BLOCKED_EMAIL_DOMAINS.some(d => domain === d || domain.endsWith('.' + d))) return false;
return true;
}
const MIN_HOMESITES = 10;
function showCalcError(msg) {
if (!calcErr) return;
calcErr.textContent = msg;
calcErr.classList.remove('hidden');
}
function setCalcLoading(on) { function setCalcLoading(on) {
if (!submitBtn) return; if (!submitBtn) return;
submitBtn.disabled = on; submitBtn.disabled = on;
@@ -73,12 +167,33 @@
const calcOptIn = document.getElementById('calcOptIn')?.checked ?? true; const calcOptIn = document.getElementById('calcOptIn')?.checked ?? true;
if (!homesites || !annualIncome) { if (!homesites || !annualIncome) {
calcErr.classList.remove('hidden'); showCalcError('Please fill in homesites and annual dues income to continue.');
return; return;
} }
if (homesites < MIN_HOMESITES) {
showCalcError(`Please enter the number of homesites in your community (minimum ${MIN_HOMESITES}).`);
document.getElementById('calcHomesites')?.focus();
return;
}
// Email is optional, but anything entered must be a real address.
if (calcEmail && !isValidEmail(calcEmail)) {
showCalcError('Please enter a valid email address.');
document.getElementById('calcEmail')?.focus();
return;
}
calcErr.classList.add('hidden'); calcErr.classList.add('hidden');
setCalcLoading(true); setCalcLoading(true);
// ── Abuse checks: server verifies the token, honeypot and CAPTCHA ──
const guardBody = {
formToken,
captchaToken: captchaResponse(),
hp_company_url: document.getElementById('hpCompanyUrl')?.value || '',
};
// ── Conservative investment assumptions ── // ── Conservative investment assumptions ──
// Operating cash: depending on payment frequency, portion investable in high-yield savings // Operating cash: depending on payment frequency, portion investable in high-yield savings
const opMultiplier = { monthly: 0.10, quarterly: 0.20, annually: 0.35 }[paymentFreq] || 0.10; const opMultiplier = { monthly: 0.10, quarterly: 0.20, annually: 0.35 }[paymentFreq] || 0.10;
@@ -131,17 +246,37 @@
// ── AI recommendation — call server to generate & save to DB (not displayed) ── // ── AI recommendation — call server to generate & save to DB (not displayed) ──
try { try {
await fetch('/api/calculate', { const res = await fetch('/api/calculate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
...guardBody,
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025, homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
email: calcEmail, optIn: calcOptIn, email: calcEmail, optIn: calcOptIn,
totalPotential, opInterest, resInterest, totalPotential, opInterest, resInterest,
}), }),
}); });
// A blocked submission stops here — the estimate is not shown and nothing
// is stored. AI/service errors (502/503) fall through to the local result.
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (data.blocked || data.field) {
setCalcLoading(false);
showCalcError(data.error || 'We could not verify this submission. Please try again.');
if (data.field === 'email') document.getElementById('calcEmail')?.focus();
if (data.field === 'homesites') document.getElementById('calcHomesites')?.focus();
resetCaptcha();
await fetchFormToken(); // tokens are single-use; issue a fresh one
return;
}
}
} catch (_) { /* best-effort — DB save failed silently */ } } catch (_) { /* best-effort — DB save failed silently */ }
// Token is spent on a successful submission; get another for a recalculation.
fetchFormToken();
resetCaptcha();
// ── Animate the main number ── // ── Animate the main number ──
animateValue(document.getElementById('resultAmount'), 0, totalPotential); animateValue(document.getElementById('resultAmount'), 0, totalPotential);

View File

@@ -0,0 +1,271 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rising HOA Insurance Costs: How Smart Boards Budget for the New Normal | HOA LedgerIQ Insights</title>
<meta name="description" content="HOA insurance premiums are climbing 20-50% a year in many states. Here's how boards can budget for rising coverage costs before renewal becomes a crisis." />
<meta name="keywords" content="HOA insurance costs, HOA insurance premiums, HOA master policy, community association insurance, HOA budgeting for insurance, rising HOA insurance rates, HOA property insurance, condo master insurance policy" />
<link rel="canonical" href="https://www.hoaledgeriq.com/articles/hoa-rising-insurance-costs-budgeting" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="../styles.css" />
<meta property="og:title" content="Rising HOA Insurance Costs: How Smart Boards Budget for the New Normal" />
<meta property="og:description" content="Premiums are climbing 20-50% a year in high-risk states. Here's how boards can budget for insurance the way the market actually behaves now — before renewal becomes a crisis." />
<meta property="og:type" content="article" />
<meta property="og:url" content="https://www.hoaledgeriq.com/articles/hoa-rising-insurance-costs-budgeting" />
<meta property="article:published_time" content="2026-08-01" />
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-RTWNVXPMRF"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-RTWNVXPMRF');
</script>
</head>
<body>
<!-- NAV -->
<nav class="nav">
<div class="nav-inner">
<a href="../index.html" class="nav-logo">
<img src="../logo_house_transparent.svg" alt="HOA LedgerIQ" class="logo-img" />
</a>
<ul class="nav-links">
<li><a href="../index.html">Home</a></li>
<li><a href="../index.html#features">Features</a></li>
<li><a href="../index.html#pricing">Pricing</a></li>
<li><a href="index.html" class="nav-active">Insights</a></li>
</ul>
<a href="https://app.hoaledgeriq.com/pricing" class="btn btn-primary nav-btn" target="_blank" rel="noopener">Start Free Trial</a>
<a href="https://app.hoaledgeriq.com" class="btn btn-outline nav-btn nav-login" target="_blank" rel="noopener">Login</a>
</div>
</nav>
<!-- ARTICLE HEADER -->
<header class="article-header">
<div class="container">
<div class="article-breadcrumb">
<a href="index.html">← Back to Insights</a>
</div>
<div class="article-tag">Financial Planning</div>
<h1 class="article-title">Rising HOA Insurance Costs:<br /><span class="gradient-text">How Smart Boards Budget for the New Normal</span></h1>
<p class="article-subtitle">Premiums are climbing 20 to 50 percent a year in high-risk states, and the boards caught flat-footed are the ones scrambling for a special assessment. Here's how to budget for insurance the way the market actually behaves now.</p>
<div class="article-meta">
<span class="article-meta-author">HOA LedgerIQ Team</span>
<span class="article-meta-separator"></span>
<span>August 1, 2026</span>
<span class="article-meta-separator"></span>
<span>9 min read</span>
</div>
</div>
</header>
<!-- ARTICLE BODY: INTRO + SECTIONS 1-2 -->
<section class="article-body-section">
<div class="container">
<div class="article-prose">
<p>The renewal notice used to be a formality. The treasurer would glance at it, note that the premium ticked up a few percentage points in line with inflation, and move on to the next line item. That version of insurance renewal season is gone for a growing number of HOAs, and it isn't coming back.</p>
<p>In coastal states, wildfire-prone regions, and plenty of places that don't fit either category, boards are opening renewal letters and finding premium increases of 25%, 40%, sometimes more than 60% in a single year — with no claims history, no lapses in coverage, and no obvious reason beyond "the market has changed." For a community that budgeted a modest 5% increase and got hit with 45%, the gap has to come from somewhere: a mid-year special assessment, a raid on reserves that were earmarked for something else, or a scramble to cut coverage just to make the number fit.</p>
<p>None of those are good options, and none of them are necessary if the budget was built to anticipate this in the first place. This isn't about predicting exactly what your premium will be next year — nobody can do that reliably in the current market. It's about building a budget process that treats insurance as the volatile, consequential line item it has become, instead of a rounding error that gets a flat 3% bump and no further thought.</p>
<h2>Why HOA Insurance Premiums Are Rising So Fast</h2>
<p>Understanding why this is happening changes how a board plans for it. Three forces are compounding at once, and none of them are temporary blips.</p>
<p>The first is reinsurance. Insurance carriers don't hold catastrophic risk on their own books — they lay it off to reinsurers, and the reinsurance market has hardened significantly after several consecutive years of outsized wildfire, hurricane, and severe convective storm losses. When reinsurance gets more expensive, that cost flows directly into the premiums carriers charge community associations, regardless of any individual property's claims history.</p>
<p>The second is replacement cost. Construction material and labor costs have risen substantially over the past several years, which means the insured value required to fully rebuild a structure has risen with them. Many HOA master policies were carrying replacement cost estimates that hadn't been updated in years — and insurers, having watched underinsured claims play out disastrously elsewhere, are now pushing hard for updated appraisals that push insured values, and therefore premiums, up.</p>
<p>The third is scrutiny. In the wake of high-profile structural failures at underinsured and underreserved communities, carriers and, in many states, regulators are paying closer attention to how HOAs are funding both their insurance and their reserves. Associations that can't demonstrate adequate reserve funding or a credible capital maintenance plan are increasingly seeing that reflected in underwriting — either through higher premiums, coverage exclusions, or carriers declining to renew altogether.</p>
<div class="highlight-box">
<p><strong>The takeaway for budgeting purposes:</strong> these forces are structural, not cyclical. A board that budgets for insurance the way it used to — a small percentage bump off last year's number — is planning for a market that no longer exists. Insurance now needs to be modeled as a genuinely variable cost with a wide range of outcomes.</p>
</div>
<h2>The Budgeting Mistake That Turns a Bad Renewal Into a Crisis</h2>
<p>The single most common mistake boards make with insurance budgeting is treating the current premium as a stable baseline and applying a modest inflation factor — the same approach that works reasonably well for a landscaping contract or a management fee. Insurance doesn't behave like those categories anymore, and budgeting as though it does creates a specific, predictable failure mode: the gap between the budgeted number and the actual renewal quote shows up right when the policy is due, with no time to plan a response and no funding source lined up to absorb it.</p>
<p>This is made worse by a timing mismatch that catches many boards off guard. Insurance renewal dates rarely align neatly with the budget cycle. A board that finalizes its annual budget in November, with a policy that renews the following June, is essentially guessing at a number seven months in advance — in a market where premiums have moved by double digits within a single renewal cycle. If that guess is wrong, there's no clean mechanism to absorb the difference until the next full budget cycle, which is often another six to twelve months away.</p>
<blockquote><p>"We budgeted a 10% increase because that's what we'd seen the last two years. The actual renewal came in at 38%. We ended up pulling the difference out of the reserve fund because there was nowhere else for it to come from — and then spent the next board meeting explaining to homeowners why the roof replacement timeline had slipped."</p></blockquote>
<p>That kind of story is becoming common enough that it shouldn't be treated as bad luck. It's the predictable result of budgeting for insurance the old way in a market that has fundamentally repriced risk. The fix isn't to guess better — it's to build a process that doesn't depend on guessing correctly.</p>
</div>
</div>
</section>
<!-- ARTICLE BODY: SECTIONS 3-4 -->
<section class="article-body-section" style="padding-top: 0;">
<div class="container">
<div class="article-prose">
<h2>Building a Real Insurance Contingency Into the Operating Budget</h2>
<p>The boards handling this well share a common approach: they stop treating the renewal quote as a known number to plug into a spreadsheet, and start treating it as a range that needs a funding plan attached to every point within that range.</p>
<p>The process starts earlier than most boards are used to. Rather than waiting for the official renewal quote to arrive 30 days before the policy expires, request a preliminary indication from your broker three to four months out. Brokers dealing with this market daily can usually give a reasonable range — "expect somewhere between 15% and 35%, depending on how the wind/hail deductible shakes out" — well before the binding quote is finalized. That range is enough to start planning around, even without a final number.</p>
<p>From there, build the operating budget around three scenarios rather than one: a low-end increase, a moderate increase in line with what similar communities in your region have reported, and a high-end increase that reflects the worst plausible outcome your broker has flagged. Fund the budget to the moderate scenario, but explicitly document what the board's plan is if the actual renewal lands at the high end — whether that's a modest contingency line, a temporary draw against a specific reserve category with a defined repayment plan, or an assessment true-up communicated to homeowners in advance rather than sprung on them after the fact.</p>
<div class="highlight-box">
<p><strong>A practical contingency structure:</strong> Budget the operating line for insurance at your moderate-scenario estimate. Separately, hold a contingency line equal to the gap between your moderate and high-end scenarios — even 3-5% of total operating expenses is often enough to absorb the difference without disrupting other line items. If the renewal comes in at or below the moderate estimate, that contingency rolls into next year's planning instead of getting spent.</p>
</div>
<h2>Why Cutting Coverage to Protect the Premium Is a False Economy</h2>
<p>When a renewal quote comes in well above budget, the fastest lever available to a board is reducing coverage — raising deductibles, trimming coverage limits, or dropping endorsements that seem optional. It's an understandable instinct: the premium number is the thing that needs to shrink, and cutting coverage shrinks it. But this is the point in the process where boards most often trade a manageable, predictable cost for an unmanageable, unpredictable one.</p>
<p>A higher deductible looks like savings on the declarations page. In practice, it means the community is self-insuring for that gap — and for many associations, the amount sitting in reserves specifically earmarked to absorb an insurance deductible is thin to nonexistent. A wind or hail event that triggers a $50,000 deductible instead of a $10,000 one doesn't disappear the cost; it just moves it from the insurance company's balance sheet to the association's, usually at the worst possible moment, right after a damaging storm when contractors are backlogged and costs are elevated.</p>
<p>Coverage cuts deserve the same scrutiny as any other major financial decision — a specific board vote, informed by a clear-eyed look at what the community's reserves could actually absorb if that gap gets triggered, not a reflexive move to make a difficult number smaller. If a coverage reduction is genuinely the right call for your community's risk profile and financial position, that's a legitimate decision. If it's a way to avoid an uncomfortable conversation with homeowners about a premium increase, it's a decision that tends to get much more expensive later.</p>
</div>
</div>
</section>
<!-- SCREENSHOT CAROUSEL -->
<section class="article-showcase">
<div class="container">
<div class="article-showcase-header">
<div class="section-label">See It in Action</div>
<h2>The Financial Tools Behind a Confident Renewal</h2>
<p>Live cash flow forecasting, reserve fund health scoring, and budget-vs.-actual tracking — the data every board needs to plan for a volatile insurance renewal instead of reacting to one.</p>
</div>
<div class="screenshot-carousel" id="screenshotCarousel">
<div class="carousel-frame">
<div class="carousel-slides">
<div class="carousel-slide active">
<img src="../img/screenshot-dashboard.png" alt="HOA LedgerIQ Dashboard — Fund health scores, operating and reserve balances" />
<div class="slide-caption">Dashboard — Fund Health &amp; At-a-Glance Metrics</div>
</div>
<div class="carousel-slide">
<img src="../img/screenshot-cashflow.png" alt="HOA LedgerIQ Cash Flow — Projected balances with forward forecasting chart" />
<div class="slide-caption">Cash Flow — Actuals &amp; Forward Projections</div>
</div>
<div class="carousel-slide">
<img src="../img/screenshot-capital.png" alt="HOA LedgerIQ Capital Planning — Multi-year project timeline and budget view" />
<div class="slide-caption">Capital Planning — 5-Year Project Pipeline</div>
</div>
</div>
</div>
<div class="carousel-controls">
<button class="carousel-btn carousel-prev" aria-label="Previous screenshot">&#8592;</button>
<div class="carousel-dots">
<span class="carousel-dot active" data-index="0"></span>
<span class="carousel-dot" data-index="1"></span>
<span class="carousel-dot" data-index="2"></span>
</div>
<button class="carousel-btn carousel-next" aria-label="Next screenshot">&#8594;</button>
</div>
</div>
</div>
</section>
<!-- ARTICLE BODY: PRACTICAL SCENARIO + CONCLUSION -->
<section class="article-body-section">
<div class="container">
<div class="article-prose">
<h2>What This Looks Like in Practice</h2>
<p>Consider a 140-unit coastal condo association whose treasurer, a board member named Marcus, started tracking the insurance market seriously after a neighboring community's premium jumped 52% the previous year. Rather than waiting for the official quote, he asked the association's broker in February for an early read on the June renewal — four months out. The broker's range was wide: 20% at the low end if the carrier's wildfire model came back favorably, up to 45% if it didn't.</p>
<p>Marcus built the operating budget around a 30% increase — roughly the midpoint, weighted slightly toward the higher end given the carrier's public commentary on coastal risk. He also set aside a contingency line equal to an additional 15%, funded from a modest across-the-board reduction in discretionary operating categories rather than touching reserves. He presented both numbers to the board in March, along with a one-page summary explaining the reinsurance market dynamics driving the increase — so that if the final number came in high, it wouldn't be the first time the board had heard the explanation.</p>
<p>The renewal quote arrived in May at a 38% increase. Because the moderate-scenario budget had already absorbed 30% and the contingency line covered the remaining 8%, the association didn't need an emergency vote, a reserve draw, or a special assessment. The board approved paying the premium from already-budgeted funds at its regular May meeting. When a homeowner asked about it at the annual meeting, Marcus pulled up the same one-page summary from March and walked through it in under five minutes.</p>
<p>The difference wasn't that Marcus predicted the number correctly — his midpoint estimate was actually 8 points low. The difference was that the budget was built to survive being wrong within a reasonable range, and the board had already had the hard conversation with homeowners before the number was final rather than after.</p>
<h2>Insurance Is a Budget Line, Not a Surprise</h2>
<p>The underlying shift boards need to make isn't really about insurance specifically — it's about recognizing which line items in the budget have moved from stable and predictable to genuinely volatile, and adjusting the planning process accordingly. Insurance has moved firmly into that second category for most communities, particularly in regions with elevated catastrophe risk, and treating it with the same light-touch approach as a landscaping contract is where the trouble starts.</p>
<p>None of this requires predicting the market correctly. It requires getting information earlier, building a budget that can flex across a realistic range of outcomes, and being honest with homeowners about the pressure the community is under before the number is locked in. Boards that do this consistently turn what could be a crisis into a line item — uncomfortable, sometimes expensive, but manageable. That's a much better place to be standing when the renewal notice actually arrives.</p>
</div>
</div>
</section>
<!-- ARTICLE CTA -->
<section class="article-cta">
<div class="container">
<h2>Plan for Volatile Costs Before They Land on Your Desk.</h2>
<p>HOA LedgerIQ gives your board live cash flow forecasting, budget-vs.-actual tracking, and reserve fund health scoring — the visibility you need to build a budget that holds up when a renewal comes in high.</p>
<a href="https://app.hoaledgeriq.com/pricing" class="btn btn-primary btn-lg" target="_blank" rel="noopener">Start Your Free 14-Day Trial</a>
<p class="article-cta-note">No credit card required &nbsp;·&nbsp; 14-day free trial &nbsp;·&nbsp; No contracts</p>
</div>
</section>
<!-- ARTICLE BOTTOM NAV -->
<div class="insights-grid-section" style="padding: 2.5rem 0 3rem;">
<div class="container">
<div style="border-top: 1px solid rgba(255,255,255,0.07); padding-top: 2rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
<a href="hoa-reserve-fund-health-score.html" style="color: var(--blue); font-size: 0.875rem; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; gap: 0.4rem; transition: opacity 0.2s;">← Previous: Reserve Fund Health Score</a>
<a href="index.html" style="color: var(--gray-400); font-size: 0.875rem; text-decoration: none; display: inline-flex; align-items: center; gap: 0.4rem; transition: color 0.2s;">All Insights →</a>
</div>
</div>
</div>
<!-- FOOTER -->
<footer class="footer">
<div class="container footer-inner">
<div class="footer-logo">
<img src="../logo_house.svg" alt="HOA LedgerIQ" class="logo-img logo-img--footer" />
<p>AI-powered HOA finance management.</p>
</div>
<div class="footer-links">
<div class="footer-col">
<div class="footer-col-title">Product</div>
<a href="../index.html#features">Features</a>
<a href="../index.html#pricing">Pricing</a>
<a href="https://app.hoaledgeriq.com/pricing" target="_blank" rel="noopener">Start Free Trial</a>
</div>
<div class="footer-col">
<div class="footer-col-title">Pages</div>
<a href="../investment-management.html">Investment Management</a>
<a href="../reserve-study-software.html">Reserve Studies</a>
<a href="index.html">Insights</a>
</div>
<div class="footer-col">
<div class="footer-col-title">Legal</div>
<a href="../privacy.html">Privacy Policy</a>
<a href="../terms.html">Terms of Service</a>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="container">
<span>&copy; 2026 HOA LedgerIQ. All rights reserved.</span>
</div>
</div>
</footer>
<script src="../app.js"></script>
<!-- Support Chat Widget -->
<script>
(function(d,t) {
var BASE_URL="https://chat.hoaledgeriq.com";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/packs/js/sdk.js";
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken: '1QMW1fycL5xHvd6XMfg4Dbb4',
baseUrl: BASE_URL
})
}
})(document,"script");
</script>
</body>
</html>

View File

@@ -54,6 +54,21 @@
<div class="article-grid"> <div class="article-grid">
<!-- Article 11 — Newest first -->
<a href="hoa-rising-insurance-costs-budgeting.html" class="article-card" style="text-decoration:none;">
<span class="article-card-tag">Financial Planning</span>
<h2 class="article-card-title">Rising HOA Insurance Costs: How Smart Boards Budget for the New Normal</h2>
<p class="article-card-excerpt">Premiums are climbing 20 to 50 percent a year in high-risk states, and the boards caught flat-footed are the ones scrambling for a special assessment. Here's how to budget for insurance the way the market actually behaves now.</p>
<div class="article-card-meta">
<span>HOA LedgerIQ Team</span>
<span class="article-card-meta-dot"></span>
<span>August 1, 2026</span>
<span class="article-card-meta-dot"></span>
<span>9 min read</span>
</div>
<span class="article-card-read-more">Read article →</span>
</a>
<!-- Article 10 — Newest first --> <!-- Article 10 — Newest first -->
<a href="hoa-reserve-fund-health-score.html" class="article-card" style="text-decoration:none;"> <a href="hoa-reserve-fund-health-score.html" class="article-card" style="text-decoration:none;">
<span class="article-card-tag">Reserve Funds</span> <span class="article-card-tag">Reserve Funds</span>

View File

@@ -469,7 +469,7 @@
<div class="calc-grid"> <div class="calc-grid">
<div class="calc-field"> <div class="calc-field">
<label for="calcHomesites">Number of homesites</label> <label for="calcHomesites">Number of homesites</label>
<input type="number" id="calcHomesites" placeholder="e.g. 150" min="1" /> <input type="number" id="calcHomesites" placeholder="e.g. 150" min="10" />
</div> </div>
<div class="calc-field"> <div class="calc-field">
<label for="calcPropertyType">Property type</label> <label for="calcPropertyType">Property type</label>
@@ -509,8 +509,17 @@
</div> </div>
</div> </div>
<!-- Honeypot: hidden from humans, irresistible to bots. Leave it empty. -->
<div class="calc-hp" aria-hidden="true">
<label for="hpCompanyUrl">Company website</label>
<input type="text" id="hpCompanyUrl" name="hp_company_url" tabindex="-1" autocomplete="off" />
</div>
<p class="calc-error hidden" id="calcError">Please fill in homesites and annual dues income to continue.</p> <p class="calc-error hidden" id="calcError">Please fill in homesites and annual dues income to continue.</p>
<!-- CAPTCHA widget — rendered only when Turnstile keys are configured -->
<div class="calc-captcha" id="calcCaptcha"></div>
<div class="calc-email-row"> <div class="calc-email-row">
<div class="calc-field calc-field--full"> <div class="calc-field calc-field--full">
<label for="calcEmail">Your email address <span class="calc-optional">(recommended)</span></label> <label for="calcEmail">Your email address <span class="calc-optional">(recommended)</span></label>

324
security.js Normal file
View File

@@ -0,0 +1,324 @@
/**
* HOA LedgerIQ — Form abuse protection
*
* Layered defence for public form endpoints (ROI calculator, lead capture):
*
* 1. Cloudflare Turnstile — real CAPTCHA, active when TURNSTILE_* keys are set.
* 2. Honeypot field — a hidden input humans never fill in.
* 3. Signed form token — proves the form was actually loaded, and enforces a
* minimum fill time (bots submit instantly).
* 4. Per-IP rate limiting — caps bursts and daily volume from one source.
*
* Layers 24 need no configuration and work on their own; adding Turnstile keys
* upgrades the protection to a full CAPTCHA challenge.
*/
'use strict';
const crypto = require('crypto');
// ── Config ───────────────────────────────────────────────
const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || '';
const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET_KEY || '';
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
// If no explicit secret is configured, generate one per process. Tokens then stop
// validating across restarts — harmless, the client just fetches a fresh one.
const FORM_SECRET = process.env.FORM_TOKEN_SECRET || crypto.randomBytes(32).toString('hex');
const MIN_FILL_MS = 4 * 1000; // faster than this is not a human
const MAX_TOKEN_AGE_MS = 2 * 60 * 60 * 1000; // tokens expire after 2 hours
// Sliding-window caps per IP: short burst window + daily ceiling.
const RATE_WINDOWS = [
{ windowMs: 10 * 60 * 1000, max: 5, label: '10 minutes' },
{ windowMs: 24 * 60 * 60 * 1000, max: 25, label: '24 hours' },
];
const turnstileEnabled = Boolean(TURNSTILE_SITE_KEY && TURNSTILE_SECRET);
// ── Signed, single-use form tokens ───────────────────────
const usedTokens = new Map(); // token -> expiry ms
const hits = new Map(); // ip -> [timestamps]
function sign(payload) {
return crypto.createHmac('sha256', FORM_SECRET).update(payload).digest('hex').slice(0, 32);
}
function issueFormToken() {
const payload = `${Date.now()}.${crypto.randomBytes(9).toString('base64url')}`;
return `${payload}.${sign(payload)}`;
}
function verifyFormToken(token) {
if (typeof token !== 'string' || token.length > 200) {
return { ok: false, reason: 'missing_token' };
}
const parts = token.split('.');
if (parts.length !== 3) return { ok: false, reason: 'bad_token' };
const [tsRaw, nonce, sig] = parts;
const expected = sign(`${tsRaw}.${nonce}`);
if (sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return { ok: false, reason: 'bad_token' };
}
const issuedAt = Number(tsRaw);
if (!Number.isFinite(issuedAt)) return { ok: false, reason: 'bad_token' };
const age = Date.now() - issuedAt;
if (age > MAX_TOKEN_AGE_MS || age < -60_000) return { ok: false, reason: 'expired_token' };
if (age < MIN_FILL_MS) return { ok: false, reason: 'too_fast' };
if (usedTokens.has(token)) return { ok: false, reason: 'replayed_token' };
usedTokens.set(token, Date.now() + MAX_TOKEN_AGE_MS);
return { ok: true };
}
// ── Rate limiting ────────────────────────────────────────
function clientIp(req) {
// Requires `app.set('trust proxy', ...)` when running behind nginx.
return req.ip || req.socket?.remoteAddress || 'unknown';
}
/** Check the caps without consuming a slot. */
function checkRateLimit(ip) {
const now = Date.now();
const list = hits.get(ip) || [];
for (const { windowMs, max, label } of RATE_WINDOWS) {
const recent = list.filter(t => now - t < windowMs).length;
if (recent >= max) return { ok: false, reason: 'rate_limited', label };
}
return { ok: true };
}
/** Record a successful submission against the caller's IP. */
function recordSubmission(ip) {
const now = Date.now();
const widest = Math.max(...RATE_WINDOWS.map(w => w.windowMs));
const list = (hits.get(ip) || []).filter(t => now - t < widest);
list.push(now);
hits.set(ip, list);
}
// ── Turnstile ────────────────────────────────────────────
async function verifyTurnstile(token, ip) {
if (!turnstileEnabled) return { ok: true, skipped: true };
if (typeof token !== 'string' || !token) return { ok: false, reason: 'captcha_missing' };
try {
const body = new URLSearchParams({ secret: TURNSTILE_SECRET, response: token });
if (ip && ip !== 'unknown') body.set('remoteip', ip);
const resp = await fetch(TURNSTILE_VERIFY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
signal: AbortSignal.timeout(8000),
});
const data = await resp.json();
if (!data.success) {
return { ok: false, reason: 'captcha_failed', codes: data['error-codes'] };
}
return { ok: true };
} catch (err) {
console.error('Turnstile verify error:', err.message);
// Fail closed: an unverifiable challenge is not a passed challenge.
return { ok: false, reason: 'captcha_unavailable' };
}
}
// ── Combined guard ───────────────────────────────────────
const MESSAGES = {
honeypot: 'Submission rejected.',
missing_token: 'Your session expired. Please reload the page and try again.',
bad_token: 'Your session expired. Please reload the page and try again.',
expired_token: 'Your session expired. Please reload the page and try again.',
replayed_token: 'This form was already submitted. Please reload the page to run another estimate.',
too_fast: 'That was a little too quick — please take a moment and try again.',
rate_limited: 'Too many submissions from this network. Please try again later.',
captcha_missing: 'Please complete the verification challenge.',
captcha_failed: 'Verification failed. Please try the challenge again.',
captcha_unavailable: 'Verification is temporarily unavailable. Please try again in a moment.',
email_required: 'Please enter your email address.',
email_invalid: 'Please enter a valid email address.',
email_too_long: 'That email address is too long.',
email_blocked: 'Please use a valid work or personal email address.',
homesites_too_low: 'Please enter the number of homesites in your community (minimum 10).',
};
const STATUS = { rate_limited: 429, honeypot: 400 };
/** User-facing message for a validation/abuse reason code. */
function messageFor(reason) {
return MESSAGES[reason] || 'Submission rejected.';
}
// ── Email validation ─────────────────────────────────────
// Deliberately stricter than RFC 5322. The local part is limited to the
// characters real-world addresses actually use, which excludes every shell
// metacharacter (; | & ` $ ( ) < > \ " ' space) and every CSV-injection lead-in
// (= + @ at position 0). RFC-legal oddities like `foo!bar$baz@x.com` are
// rejected — an acceptable trade for a marketing form.
const EMAIL_RX = /^[A-Za-z0-9](?:[A-Za-z0-9._%+-]{0,62}[A-Za-z0-9])?@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,24}$/;
// C0/C1 control characters and DEL — includes the CR/LF used for header injection.
const CONTROL_CHARS_RX = /[\x00-\x1F\x7F-\x9F]/;
// Domains that are never a real lead. These are the IANA reserved documentation
// domains (RFC 2606) plus reserved TLDs, which is what spam bots reach for.
// A submitted domain is blocked when it equals one of these or is a subdomain
// of one (e.g. `mail.example.com`).
const BLOCKED_EMAIL_DOMAINS = new Set([
'example.com', 'example.org', 'example.net', 'example.edu',
]);
const BLOCKED_EMAIL_TLDS = new Set(['test', 'example', 'invalid', 'localhost']);
function isBlockedDomain(domain) {
const d = domain.toLowerCase();
const tld = d.slice(d.lastIndexOf('.') + 1);
if (BLOCKED_EMAIL_TLDS.has(tld)) return true;
for (const blocked of BLOCKED_EMAIL_DOMAINS) {
if (d === blocked || d.endsWith('.' + blocked)) return true;
}
return false;
}
/**
* Validate an email address.
* Returns { ok: true, email } with the normalised (trimmed, lower-cased) value,
* or { ok: false, reason }.
*
* Pass { required: false } to accept an empty value (the calculator's email
* field is optional) — an empty result comes back as { ok: true, email: null }.
*/
function validateEmail(raw, { required = true } = {}) {
if (raw === undefined || raw === null || raw === '') {
return required ? { ok: false, reason: 'email_required' } : { ok: true, email: null };
}
// Anything that isn't a plain string is a structured-injection attempt
// (arrays and objects can survive into places a string wouldn't).
if (typeof raw !== 'string') return { ok: false, reason: 'email_invalid' };
const email = raw.trim();
if (email === '') {
return required ? { ok: false, reason: 'email_required' } : { ok: true, email: null };
}
// Length caps first — bounds every check that follows.
if (email.length > 254) return { ok: false, reason: 'email_too_long' };
// Control characters, including the newlines used for header injection.
if (CONTROL_CHARS_RX.test(email)) return { ok: false, reason: 'email_invalid' };
const at = email.indexOf('@');
if (at < 1 || at !== email.lastIndexOf('@')) return { ok: false, reason: 'email_invalid' };
const local = email.slice(0, at);
const domain = email.slice(at + 1);
if (local.length > 64 || domain.length > 253) return { ok: false, reason: 'email_too_long' };
if (email.includes('..')) return { ok: false, reason: 'email_invalid' };
if (!EMAIL_RX.test(email)) return { ok: false, reason: 'email_invalid' };
if (isBlockedDomain(domain)) return { ok: false, reason: 'email_blocked' };
return { ok: true, email: email.toLowerCase() };
}
// ── Homesites validation ─────────────────────────────────
// Real associations have at least this many units; smaller values are the
// placeholder junk (0, 1, 2…) the spam submissions use.
const MIN_HOMESITES = 10;
/**
* Validate a homesites count.
* Returns { ok: true, homesites } (a finite number) or { ok: false, reason }.
*/
function validateHomesites(raw) {
const n = typeof raw === 'number' ? raw : parseFloat(raw);
if (!Number.isFinite(n) || n < MIN_HOMESITES) {
return { ok: false, reason: 'homesites_too_low' };
}
return { ok: true, homesites: n };
}
/**
* Run every protection layer for a public form POST.
* Returns { ok: true, ip } or { ok: false, status, error, reason }.
*/
async function guardSubmission(req, { honeypotField = 'hp_company_url' } = {}) {
const ip = clientIp(req);
const body = req.body ?? {};
const fail = ({ reason, label }) => ({
ok: false,
reason,
status: STATUS[reason] ?? 403,
error: label ? `${MESSAGES[reason]} (limit: ${label})` : MESSAGES[reason],
});
// 1. Honeypot — any value at all means a bot filled every field it found.
if (typeof body[honeypotField] === 'string' && body[honeypotField].trim() !== '') {
console.warn(`[abuse] honeypot tripped from ${ip}`);
return fail({ reason: 'honeypot' });
}
// 2. Rate limit (checked before the outbound Turnstile call).
const rate = checkRateLimit(ip);
if (!rate.ok) {
console.warn(`[abuse] rate limit hit by ${ip}`);
return fail(rate);
}
// 3. Signed single-use token + minimum fill time.
const tok = verifyFormToken(body.formToken);
if (!tok.ok) {
console.warn(`[abuse] form token rejected (${tok.reason}) from ${ip}`);
return fail(tok);
}
// 4. CAPTCHA.
const captcha = await verifyTurnstile(body.captchaToken, ip);
if (!captcha.ok) {
console.warn(`[abuse] captcha rejected (${captcha.reason}) from ${ip}`);
return fail(captcha);
}
recordSubmission(ip);
return { ok: true, ip };
}
function publicConfig() {
return { turnstileSiteKey: turnstileEnabled ? TURNSTILE_SITE_KEY : null };
}
// ── Housekeeping ─────────────────────────────────────────
const sweep = setInterval(() => {
const now = Date.now();
for (const [token, exp] of usedTokens) if (exp < now) usedTokens.delete(token);
const widest = Math.max(...RATE_WINDOWS.map(w => w.windowMs));
for (const [ip, list] of hits) {
const kept = list.filter(t => now - t < widest);
if (kept.length) hits.set(ip, kept); else hits.delete(ip);
}
}, 10 * 60 * 1000);
sweep.unref?.();
module.exports = {
turnstileEnabled,
issueFormToken,
guardSubmission,
validateEmail,
validateHomesites,
MIN_HOMESITES,
messageFor,
publicConfig,
clientIp,
};

View File

@@ -16,6 +16,8 @@ const express = require('express');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const OpenAI = require('openai'); const OpenAI = require('openai');
const security = require('./security');
// ── Config ────────────────────────────────────────────── // ── Config ──────────────────────────────────────────────
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -107,43 +109,61 @@ const getAllLeads = db.prepare(`
// ── App ─────────────────────────────────────────────────── // ── App ───────────────────────────────────────────────────
const app = express(); const app = express();
app.use(express.json()); app.set('trust proxy', 1); // behind nginx — needed for correct client IPs
app.use(express.json({ limit: '32kb' }));
app.use(express.static(__dirname)); // serve the marketing site app.use(express.static(__dirname)); // serve the marketing site
// GET /api/form-config — public config the forms need (CAPTCHA site key)
app.get('/api/form-config', (_req, res) => {
res.set('Cache-Control', 'no-store');
res.json(security.publicConfig());
});
// GET /api/form-token — single-use, signed token proving the form was loaded
app.get('/api/form-token', (_req, res) => {
res.set('Cache-Control', 'no-store');
res.json({ token: security.issueFormToken() });
});
// POST /api/leads — capture a new preview sign-up // POST /api/leads — capture a new preview sign-up
app.post('/api/leads', (req, res) => { app.post('/api/leads', (req, res) => {
const { firstName, lastName, email, orgName, state, role, unitCount, betaInterest, source } = req.body ?? {}; const { firstName, lastName, email, orgName, state, role, unitCount, betaInterest, source } = req.body ?? {};
// Coerce defensively: a non-string (array, object, number) would otherwise
// blow up on .trim() and surface as a 500 instead of a 400.
const str = v => (typeof v === 'string' ? v.trim() : '');
// Validate required fields // Validate required fields
if (!firstName?.trim() || !lastName?.trim() || !email?.trim()) { if (!str(firstName) || !str(lastName) || !str(email)) {
return res.status(400).json({ error: 'firstName, lastName, and email are required.' }); return res.status(400).json({ error: 'firstName, lastName, and email are required.' });
} }
if (!orgName?.trim()) { if (!str(orgName)) {
return res.status(400).json({ error: 'Organization name is required.' }); return res.status(400).json({ error: 'Organization name is required.' });
} }
if (!state?.trim()) { if (!str(state)) {
return res.status(400).json({ error: 'State is required.' }); return res.status(400).json({ error: 'State is required.' });
} }
// Simple email format check // Strict email format check — see security.validateEmail
const emailRx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailCheck = security.validateEmail(email);
if (!emailRx.test(email.trim())) { if (!emailCheck.ok) {
return res.status(400).json({ error: 'Invalid email address.' }); return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
} }
const cleanEmail = emailCheck.email;
// Check for duplicate // Check for duplicate
const existing = findByEmail.get(email.trim().toLowerCase()); const existing = findByEmail.get(cleanEmail);
if (existing) { if (existing) {
return res.status(409).json({ error: 'This email is already on the list.', id: existing.id }); return res.status(409).json({ error: 'This email is already on the list.', id: existing.id });
} }
try { try {
const info = insertLead.run({ const info = insertLead.run({
firstName: firstName.trim(), firstName: str(firstName),
lastName: lastName.trim(), lastName: str(lastName),
email: email.trim().toLowerCase(), email: cleanEmail,
orgName: orgName?.trim() ?? null, orgName: str(orgName) || null,
state: state?.trim() ?? null, state: str(state) || null,
role: role ?? null, role: role ?? null,
unitCount: unitCount ?? null, unitCount: unitCount ?? null,
betaInterest: betaInterest ? 1 : 0, betaInterest: betaInterest ? 1 : 0,
@@ -175,7 +195,7 @@ app.post('/api/calculate', async (req, res) => {
function saveCalcSubmission(aiRecommendation) { function saveCalcSubmission(aiRecommendation) {
try { try {
insertCalcSubmission.run({ insertCalcSubmission.run({
email: email?.trim() || null, email: cleanEmail,
optIn: optIn ? 1 : 0, optIn: optIn ? 1 : 0,
homesites: homesites || null, homesites: homesites || null,
propertyType: propertyType || null, propertyType: propertyType || null,
@@ -193,9 +213,11 @@ app.post('/api/calculate', async (req, res) => {
} }
} }
if (!aiClient) { // ── Abuse protection: CAPTCHA, honeypot, form token, rate limit ──
saveCalcSubmission(null); // Runs before anything is written to the DB or sent to the AI provider.
return res.status(503).json({ error: 'AI service not configured.' }); const guard = await security.guardSubmission(req);
if (!guard.ok) {
return res.status(guard.status).json({ error: guard.error, blocked: true });
} }
const { const {
@@ -203,10 +225,31 @@ app.post('/api/calculate', async (req, res) => {
email, optIn, totalPotential, opInterest, resInterest, email, optIn, totalPotential, opInterest, resInterest,
} = req.body ?? {}; } = req.body ?? {};
if (!homesites || !annualIncome) { // Email is optional here, but if one is supplied it must be a real address.
// Nothing is stored or sent onward until it passes.
const emailCheck = security.validateEmail(email, { required: false });
if (!emailCheck.ok) {
console.warn(`[abuse] rejected email from ${guard.ip}: ${JSON.stringify(String(email).slice(0, 120))}`);
return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
}
const cleanEmail = emailCheck.email;
if (!annualIncome) {
return res.status(400).json({ error: 'homesites and annualIncome are required.' }); return res.status(400).json({ error: 'homesites and annualIncome are required.' });
} }
// Homesites must be a real community size; tiny/placeholder values are spam.
const homesitesCheck = security.validateHomesites(homesites);
if (!homesitesCheck.ok) {
console.warn(`[abuse] rejected homesites from ${guard.ip}: ${JSON.stringify(homesites)}`);
return res.status(400).json({ error: security.messageFor(homesitesCheck.reason), field: 'homesites' });
}
if (!aiClient) {
saveCalcSubmission(null);
return res.status(503).json({ error: 'AI service not configured.' });
}
const fmt = n => '$' + Math.round(n).toLocaleString(); const fmt = n => '$' + Math.round(n).toLocaleString();
const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || ''; const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || '';
const freqDivisor = { monthly: 12, quarterly: 4, annually: 1 }[paymentFreq] || 12; const freqDivisor = { monthly: 12, quarterly: 4, annually: 1 }[paymentFreq] || 12;

View File

@@ -37,11 +37,18 @@
<!-- Insights / Blog --> <!-- Insights / Blog -->
<url> <url>
<loc>https://www.hoaledgeriq.com/articles/</loc> <loc>https://www.hoaledgeriq.com/articles/</loc>
<lastmod>2026-07-15</lastmod> <lastmod>2026-08-01</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.85</priority> <priority>0.85</priority>
</url> </url>
<url>
<loc>https://www.hoaledgeriq.com/articles/hoa-rising-insurance-costs-budgeting</loc>
<lastmod>2026-08-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.80</priority>
</url>
<url> <url>
<loc>https://www.hoaledgeriq.com/articles/hoa-reserve-fund-health-score</loc> <loc>https://www.hoaledgeriq.com/articles/hoa-reserve-fund-health-score</loc>
<lastmod>2026-07-15</lastmod> <lastmod>2026-07-15</lastmod>

View File

@@ -821,6 +821,23 @@ a.feature-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg)
} }
.input-prefix-wrap input { padding-left: 28px; } .input-prefix-wrap input { padding-left: 28px; }
.calc-error { color: var(--red); font-size: 13px; margin: 8px 0; font-weight: 500; } .calc-error { color: var(--red); font-size: 13px; margin: 8px 0; font-weight: 500; }
/* Honeypot — visually and semantically hidden, but still fillable by bots.
Deliberately not `display:none`, which the better bots skip. */
.calc-hp {
position: absolute !important;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
/* CAPTCHA widget slot — collapsed until a challenge is actually rendered */
.calc-captcha:empty { display: none; }
.calc-captcha { margin: 12px 0 0; display: flex; justify-content: center; }
.calc-submit-btn { width: 100%; justify-content: center; margin-top: 16px; } .calc-submit-btn { width: 100%; justify-content: center; margin-top: 16px; }
.calc-fine { .calc-fine {
font-size: 11px; font-size: 11px;