8 Commits

Author SHA1 Message Date
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
50fdbb1b1d Merge pull request 'Insights: How to Read Your HOA's Reserve Fund Health Score' (#23) from feature/insights-hoa-reserve-fund-health-score-2026-07-15 into main
All checks were successful
Deploy to Production / deploy (push) Successful in 3s
Reviewed-on: #23
2026-07-15 19:29:57 -04:00
080be485fe Add Insights article: How to Read Your HOA's Reserve Fund Health Score (2026-07-15)
- New article: articles/hoa-reserve-fund-health-score.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-07-15 09:05:24 -04:00
9 changed files with 867 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,282 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>How to Read Your HOA's Reserve Fund Health Score | HOA LedgerIQ Insights</title>
<meta name="description" content="Your reserve study hands your board a percent-funded number and moves on. Here's what that score actually measures, why 100% isn't always the goal, and what to do at every level." />
<meta name="keywords" content="HOA reserve fund health score, percent funded HOA, reserve fund funding ratio, HOA reserve study, reserve fund benchmarks, HOA capital planning, underfunded reserve fund, community association reserves" />
<link rel="canonical" href="https://www.hoaledgeriq.com/articles/hoa-reserve-fund-health-score" />
<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="How to Read Your HOA's Reserve Fund Health Score" />
<meta property="og:description" content="A percent-funded number gets read aloud at every board meeting, but few boards know what actually moves it or what to do when it's low. Here's the real breakdown." />
<meta property="og:type" content="article" />
<meta property="og:url" content="https://www.hoaledgeriq.com/articles/hoa-reserve-fund-health-score" />
<meta property="article:published_time" content="2026-07-15" />
<!-- 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">
<nav class="article-breadcrumb">
<a href="../index.html">Home</a>
<span class="breadcrumb-separator">/</span>
<a href="index.html">Insights</a>
<span class="breadcrumb-separator">/</span>
<span>HOA Reserve Fund Health Score</span>
</nav>
<span class="article-tag">Reserve Funds</span>
<h1 class="article-title">How to Read Your HOA's Reserve Fund Health Score</h1>
<p class="article-subtitle">Your reserve study hands the board a single percentage and everyone nods like they understand it. Here's what that number actually measures, why 100% isn't always the right target, and exactly what to do at every level.</p>
<div class="article-meta">
<span>HOA LedgerIQ Team</span>
<span class="meta-separator"></span>
<span>July 15, 2026</span>
<span class="meta-separator"></span>
<span>9 min read</span>
</div>
</div>
</header>
<!-- Article Body -->
<section class="article-body-section">
<div class="container">
<div class="article-prose">
<p class="lead">The reserve study landed in the board's inbox three weeks before the annual meeting, forty-one pages long, and the only number anyone actually discussed was on page six: "Percent Funded: 58%." The treasurer read it aloud. A homeowner in the back asked if that was bad. Nobody on the board could say for certain—was 58% a crisis, a mild concern, or perfectly normal for a fifteen-year-old community? The meeting moved on without an answer, and the number went back into a drawer until next year's study.</p>
<p>This scene repeats in HOA boardrooms constantly, and it's not because boards are careless—it's because the percent-funded score is one of the least understood numbers in community association finance. It sounds simple: reserves as a percentage of what they should be. But the number that produces that percentage, the assumptions behind it, and the right response to it are almost never explained to the volunteers who are supposed to act on it.</p>
<p>Understanding your reserve fund health score isn't about becoming an actuary. It's about knowing what the number is built from, what range you're actually in, and what specific action—if any—that range calls for. Once a board understands that, the percent-funded line stops being a mystery number read aloud once a year and becomes one of the most useful signals a community has.</p>
<h2>What the Percent Funded Score Actually Measures</h2>
<p>At its core, the percent-funded score is a ratio of two numbers: what your reserve fund actually holds today, divided by what it would hold if every component—roof, pavement, pool equipment, elevators, siding—had been funded in perfect proportion to how much of its useful life it has consumed. A roof that's ten years into a twenty-year life should, in a perfectly funded community, have roughly half its replacement cost sitting in reserves. Add that logic up across every reserve component and you get the "fully funded balance." Your actual balance divided by that number is your percent funded.</p>
<p>The number that trips boards up is the fully funded balance itself, because it's not a fixed target—it moves every year as components age, get replaced, or get re-estimated for cost. A community that contributes exactly the same amount every year can still watch its percent-funded score decline, simply because the fully funded balance grew faster than the fund did. That's not mismanagement. It's math. But without understanding this, a board can look at a declining percentage and panic, or look at a flat percentage and wrongly assume nothing needs attention.</p>
<blockquote>
"For years I thought percent funded was like a bank statement—if it went down, we'd spent too much. It took a conversation with our reserve specialist to understand it's really a comparison against a moving target. That changed how I read the number completely." — HOA Treasurer, Fort Collins, CO
</blockquote>
<div class="highlight-box">
<strong>The core formula:</strong> Percent Funded = Actual Reserve Balance ÷ Fully Funded Balance. The fully funded balance is what your reserves would hold if every component were funded exactly in proportion to its consumed useful life—not a static number, but one that shifts as your components age and get re-appraised.
</div>
<h2>Why 100% Funded Isn't Always the Right Target</h2>
<p>Reserve specialists and community association institutes generally describe percent-funded ranges in three broad bands: below 30% is considered weak and carries meaningful special assessment risk; 30% to 70% is considered fair, adequate for many stable communities; and above 70% is considered strong. Very few well-run associations sit at 100%, and that's by design, not failure.</p>
<p>Chasing 100% funded means collecting assessments today for replacements that might be a decade away, which means homeowners are paying in advance for value they won't see for years—money that, in most cases, could be earning better returns elsewhere or easing the burden on current owners. A community sitting comfortably at 75% to 85% funded, with a reserve plan that shows contributions keeping pace with the fully funded balance over time, is often in a stronger practical position than a community rigidly targeting 100% at the cost of large annual increases.</p>
<p>What actually matters isn't hitting a specific percentage—it's whether the trajectory is stable or improving, and whether the fund can absorb the community's largest near-term expense without a special assessment. A 68% funded community with a clear, funded plan for its next major project is in far better shape than a 74% funded community whose roof replacement is due in eighteen months and isn't accounted for in the number at all.</p>
<div class="highlight-box">
<strong>What to ask instead of "are we at 100%?":</strong> "Given our largest upcoming project, does our current trajectory get us there without an assessment—and if not, what's the gap and the timeline to close it?"
</div>
<h2>The Three Numbers Hiding Behind Every Health Score</h2>
<p>A single percentage compresses a lot of information, and boards that only look at the top-line number miss the parts that actually predict trouble. Three numbers sit underneath every reserve fund health score, and each tells a different part of the story.</p>
<p>The first is the current balance—straightforward, the actual dollars sitting in the reserve account today. The second is the fully funded balance, the moving target described above. The third, and the one most boards never see broken out, is the funding threshold for the community's single largest near-term component. A community with $400,000 in reserves and a $380,000 roof replacement due in fourteen months has a very different risk profile than one with the same $400,000 balance and no major project for six years, even if their percent-funded scores land in the same range.</p>
<p>This is why a reserve study's appendix—the component-by-component funding table—often matters more than its summary page. It shows not just the aggregate percentage but which specific components are underfunded relative to their timeline. A board that only reads the cover page might feel reassured by a 65% overall score, unaware that the single component driving the community's next assessment risk is funded at 20%.</p>
<blockquote>
"We were sitting at 71% funded and felt fine. Then someone actually opened the component table and found our clubhouse HVAC system—due for replacement in two years—was funded at 12%. The overall number hid it completely." — HOA Board Secretary, Naples, FL
</blockquote>
<div class="highlight-box">
<strong>What to ask:</strong> "Beyond the overall percentage, which individual components are most underfunded relative to their replacement timeline—and what's our largest expense in the next 24 months?"
</div>
<h2>Reading the Trend, Not Just the Snapshot</h2>
<p>A reserve study is typically produced once every three to five years, sometimes updated annually with a simpler desktop review in between. That cadence means most boards experience their percent-funded score as a series of disconnected snapshots rather than a continuous line—and a single snapshot, without context for where the number has been or where it's heading, tells you far less than it seems to.</p>
<p>A community that moved from 82% funded to 74% funded over three years is telling a very different story than one that's been steady at 74% for a decade. The first suggests contributions aren't keeping pace with rising replacement costs or newly identified components—a trend that, left alone, compounds. The second may simply reflect a community that has calibrated its funding policy to a sustainable, intentional level and is holding it there.</p>
<p>The boards that manage reserves well don't wait for the next formal study to check their trajectory. They track contributions against the funding plan every month, they revisit cost estimates when a vendor quote comes in meaningfully different from the reserve study's projection, and they treat the percent-funded number as a running conversation rather than a once-every-few-years report card. That shift—from event to habit—is often the single biggest difference between communities that avoid special assessments and communities that get blindsided by them.</p>
<div class="highlight-box">
<strong>The bottom line:</strong> One percent-funded number tells you almost nothing. Three years of percent-funded numbers, read together with your upcoming project timeline, tell you almost everything.
</div>
<h2>What to Do When Your Score Is Low</h2>
<p>A weak percent-funded score—generally under 30%, or trending down toward it—isn't a reason to panic, but it is a reason to act deliberately rather than reactively. The instinct in a lot of boardrooms is to either ignore the number until a crisis forces a special assessment, or to overcorrect with a dramatic assessment increase that homeowners resist and resent.</p>
<p>The more sustainable path is usually a multi-year glide plan: a defined schedule of gradual contribution increases, timed to close the gap before the community's largest project comes due, communicated to homeowners well in advance and tied explicitly to specific components rather than a vague appeal for "more money in reserves." Homeowners tolerate predictable, explained increases far better than sudden ones, and a board that can point to "this increase closes the gap on the roof fund two years ahead of the replacement date" earns far more trust than one that simply says reserves are low.</p>
<p>In some cases, a modest, planned loan or line of credit against future assessments—paired with a funding plan to repay it—is a more homeowner-friendly option than a lump-sum special assessment, spreading the cost over time rather than demanding it all at once. The right answer depends on the community's specific timeline and risk tolerance, but the wrong answer, in almost every case, is doing nothing and hoping the gap closes itself.</p>
<h2>What This Looks Like in Practice</h2>
<p>Sienna Ridge is a 140-unit townhome community outside Denver that came in at 41% funded on its 2024 reserve study—solidly in the "fair, trending toward weak" range, with a shared roof and gutter system due for replacement in five years at an estimated $610,000. The board's first instinct was to raise assessments significantly and get to 70% funded as fast as possible, but a closer look at the component table showed that wasn't actually necessary: the roof project was the only major near-term expense, and a steady five-year ramp in contributions would fully fund it right on schedule without a dramatic single-year jump.</p>
<p>The board built a five-year contribution schedule with modest annual increases—about 6% per year rather than one large jump—and presented it to homeowners with a simple visual showing exactly how the increases mapped to the roof project timeline. They also began tracking contributions against that plan every quarter instead of waiting for the next formal study, catching a small shortfall in year two when a vendor's updated estimate came in $40,000 higher than the original study projected, and adjusting the fourth-year contribution slightly to compensate.</p>
<p>By the time the roof project came due, Sienna Ridge had the full $610,000 in reserves, no special assessment, and a board that had spent five years explaining a clear, credible plan to homeowners rather than five years hoping the number would somehow resolve itself. Their percent-funded score at year five: 68%—still not 100%, and not meant to be, but exactly aligned with what their upcoming project timeline required.</p>
<blockquote>
"We used to treat the reserve study like a report card we didn't want to get. Now we treat the percent-funded number like a dashboard gauge—something we check often, understand, and can actually steer." — Sienna Ridge HOA Treasurer
</blockquote>
<div class="highlight-box">
<strong>The bottom line:</strong> A reserve fund health score isn't a grade to fear or a target to chase blindly—it's a diagnostic tool. Boards that understand what drives it, track it continuously, and connect it to their actual project timeline make calmer, cheaper decisions than boards that only glance at it once a year.
</div>
<p>The percentage on page six of your reserve study will keep getting read aloud at board meetings whether or not anyone understands it. The communities that avoid special assessments and homeowner backlash are the ones that stop treating it as a mystery number and start treating it as what it actually is: a running measurement of whether today's decisions are keeping pace with tomorrow's bills.</p>
</div>
</div>
</section>
<!-- SCREENSHOT CAROUSEL -->
<section class="article-showcase">
<div class="container">
<div class="article-showcase-header">
<div class="section-label">See How Modern HOA Financial Management Works</div>
<h2>HOA LedgerIQ tracks your reserve fund health score continuously — not just once every few years</h2>
</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">Real-time fund health scores so your board never has to wait for the next reserve study</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">Forward cash flow forecasting that connects reserve contributions to real project timelines</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">Component-level capital planning that shows exactly which reserve items need attention</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>
<!-- CTA Section -->
<section class="article-cta">
<div class="container">
<h2>Ready to Track Your Reserve Fund Health Year-Round?</h2>
<p>HOA LedgerIQ gives your board a live, component-level view of reserve funding—so your percent-funded score is a number you check monthly, not a surprise you read once a year.</p>
<a href="https://app.hoaledgeriq.com/pricing" class="btn btn-primary btn-large" target="_blank" rel="noopener">Start Your Free 14-Day Trial</a>
<p class="cta-note">No credit card required · 14-day free trial · No contracts</p>
</div>
</section>
<!-- Article Navigation -->
<nav class="article-nav">
<div class="container">
<div class="article-nav-grid">
<a href="hoa-board-meeting-financial-questions.html" class="article-nav-prev">
<span class="nav-label">Previous Article</span>
<span class="nav-title">5 Questions Every HOA Board Should Ask at Every Meeting</span>
</a>
<a href="hoa-reserve-fund-cd-laddering.html" class="article-nav-next">
<span class="nav-label">More Reading</span>
<span class="nav-title">CD Laddering for HOA Reserve Funds: A Practical Guide</span>
</a>
</div>
</div>
</nav>
<!-- 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 10 — Newest first -->
<a href="hoa-reserve-fund-health-score.html" class="article-card" style="text-decoration:none;">
<span class="article-card-tag">Reserve Funds</span>
<h2 class="article-card-title">How to Read Your HOA's Reserve Fund Health Score</h2>
<p class="article-card-excerpt">Your reserve study hands the board a single percentage and everyone nods like they understand it. Here's what that number actually measures, why 100% funded isn't always the right target, and what to do at every level.</p>
<div class="article-card-meta">
<span>HOA LedgerIQ Team</span>
<span class="article-card-meta-dot"></span>
<span>July 15, 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 9 — Newest first --> <!-- Article 9 — Newest first -->
<a href="hoa-board-meeting-financial-questions.html" class="article-card" style="text-decoration:none;"> <a href="hoa-board-meeting-financial-questions.html" class="article-card" style="text-decoration:none;">
<span class="article-card-tag">Board Management</span> <span class="article-card-tag">Board Management</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-01</lastmod> <lastmod>2026-07-15</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-reserve-fund-health-score</loc>
<lastmod>2026-07-15</lastmod>
<changefreq>monthly</changefreq>
<priority>0.80</priority>
</url>
<url> <url>
<loc>https://www.hoaledgeriq.com/articles/hoa-board-meeting-financial-questions</loc> <loc>https://www.hoaledgeriq.com/articles/hoa-board-meeting-financial-questions</loc>
<lastmod>2026-07-01</lastmod> <lastmod>2026-07-01</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;