← back to Lawyer Directory Builder

src/server/index.ts

487 lines

/**
 * Lawyer Directory — read API + Leaflet heatmap dashboard on :9701.
 */
import 'dotenv/config';
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { pool, query } from '../db/pool.ts';
import { authMiddleware } from './auth.ts';
import portalRouter from './portal.ts';
import leadsRouter from './leads.ts';
import directoryRouter from './directory.ts';
import upgradeRouter from './upgrade.ts';
import dataMarketRouter from './data_market.ts';
import showcaseRouter from './showcase.ts';
import communityRouter from './community.ts';
import billingRouter from './billing.ts';
import adminRouter from './admin.ts';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT || '9701', 10);

const app = express();
// Security headers — added 2026-05-04 (overnight YOLO loop tick 26).
// CSP disabled because the lawyer-directory pages embed inline <style> blocks
// + Google Fonts + Stripe.js. Defer CSP hardening until each script source is
// catalogued. Other Helmet headers (HSTS, X-Frame-Options SAMEORIGIN, X-Content-
// Type-Options nosniff, Referrer-Policy, COOP/CORP, etc.) all apply.
import helmet from 'helmet';
app.use(helmet({ contentSecurityPolicy: false }));

// Stripe webhooks need the raw body for signature verification — skip JSON parsing for them.
app.use((req, res, next) => {
  if (req.path.startsWith('/webhooks/')) return next();
  return express.json()(req, res, next);
});
// SECURITY: was wildcard CORS, allowing any origin to make credentialed reads
// against session-authenticated routes (/api/billing/*, /api/me/*). Now an
// allowlist gated on PUBLIC_BASE_URL + localhost dev. Set PUBLIC_BASE_URL in
// pm2 env (e.g. https://lawyerdirectory.example.com).
const CORS_ALLOWED = new Set(
  [process.env.PUBLIC_BASE_URL, 'http://localhost:9701', 'http://127.0.0.1:9701']
    .filter(Boolean) as string[]
);
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && CORS_ALLOWED.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }
  next();
});
app.use(authMiddleware);
// adminRouter mounted FIRST so its new /admin overview wins over portal.ts's
// older /admin user-management page (which is still reachable at /portal/admin).
app.use(adminRouter);
app.use(portalRouter);
app.use(leadsRouter);
app.use(directoryRouter);
app.use(upgradeRouter);
app.use(dataMarketRouter);
app.use(showcaseRouter);
app.use(communityRouter);
app.use(billingRouter);

// ─── Read API ───────────────────────────────────────────────────────────────

app.get('/api/health', (_req, res) => res.json({ ok: true, port: PORT, ts: new Date().toISOString() }));

app.get('/api/stats', async (_req, res) => {
  const r = await query<{ key: string; value: string }>(`
    SELECT 'firms_total' AS key, COUNT(*)::text AS value FROM organizations WHERE type='law_firm'
    UNION ALL SELECT 'firms_with_geo',  COUNT(*)::text FROM organizations WHERE type='law_firm' AND lat IS NOT NULL
    UNION ALL SELECT 'firms_with_site', COUNT(*)::text FROM organizations WHERE type='law_firm' AND website IS NOT NULL
    UNION ALL SELECT 'firms_with_phone',COUNT(*)::text FROM organizations WHERE type='law_firm' AND phone IS NOT NULL
    UNION ALL SELECT 'cities',          COUNT(DISTINCT city)::text FROM organizations WHERE type='law_firm'
    UNION ALL SELECT 'multi_firm_buildings', COUNT(*)::text FROM (
      SELECT address FROM organizations WHERE type='law_firm' AND address IS NOT NULL
      GROUP BY address HAVING COUNT(*) > 1
    ) t
    UNION ALL SELECT 'sources_active', COUNT(*)::text FROM sources
    UNION ALL SELECT 'professionals_total', COUNT(*)::text FROM professionals
    UNION ALL SELECT 'phones_total', COUNT(*)::text FROM phones
    UNION ALL SELECT 'emails_total', COUNT(*)::text FROM emails
  `);
  const out: Record<string, number> = {};
  for (const row of r.rows) out[row.key] = parseInt(row.value, 10);
  res.json(out);
});

app.get('/api/firms', async (req, res) => {
  const city = (req.query.city as string | undefined)?.trim() || null;
  const zip = (req.query.zip as string | undefined)?.trim() || null;
  const q = (req.query.q as string | undefined)?.trim() || null;
  const sizeBand = (req.query.firm_size_band as string | undefined)?.trim() || null;
  const prospect = (req.query.prospect as string | undefined)?.trim() || null;
  const sort = (req.query.sort as string | undefined)?.trim() || 'score';
  const limit = Math.min(parseInt((req.query.limit as string) || '200', 10), 1000);
  const offset = Math.max(parseInt((req.query.offset as string) || '0', 10), 0);

  const where: string[] = ["o.type='law_firm'"];
  const params: unknown[] = [];
  if (city) { params.push(city); where.push(`(LOWER(o.city) = LOWER($${params.length}) OR LOWER(o.neighborhood) = LOWER($${params.length}))`); }
  if (zip)  { params.push(zip);  where.push(`o.zip = $${params.length}`); }
  if (q)    { params.push('%' + q.toLowerCase() + '%'); where.push(`LOWER(o.name) LIKE $${params.length}`); }
  if (sizeBand) { params.push(sizeBand); where.push(`o.firm_size_band = $${params.length}`); }
  if (prospect === 'has_site')   where.push(`o.website ~ '^https?://'`);
  if (prospect === 'needs_site') where.push(`(o.website IS NULL OR o.website !~ '^https?://')`);
  if (prospect === 'has_phone')  where.push(`o.phone IS NOT NULL`);
  if (prospect === 'audited')    where.push(`EXISTS (SELECT 1 FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399)`);
  if (prospect === 'high_score') where.push(`EXISTS (SELECT 1 FROM site_audits a WHERE a.organization_id=o.id AND a.marketing_score >= 70)`);
  if (prospect === 'low_score')  where.push(`EXISTS (SELECT 1 FROM site_audits a WHERE a.organization_id=o.id AND a.marketing_score IS NOT NULL AND a.marketing_score < 50)`);

  let orderBy = 'attorney_count DESC NULLS LAST, name ASC';
  if (sort === 'name')        orderBy = 'o.name ASC';
  else if (sort === 'attorneys') orderBy = 'o.attorney_count DESC NULLS LAST, o.name ASC';
  else if (sort === 'zip')    orderBy = 'o.zip ASC NULLS LAST, o.name ASC';
  else if (sort === 'score')  orderBy = 'audit_score DESC NULLS LAST, o.attorney_count DESC NULLS LAST, o.name ASC';
  else if (sort === 'recent') orderBy = 'o.contact_crawled_at DESC NULLS LAST, o.id DESC';

  params.push(limit);
  params.push(offset);

  const r = await query(`
    SELECT
      o.id, o.name, o.address, o.city, o.neighborhood, o.zip, o.phone, o.website,
      o.lat, o.lng, o.firm_size_band, o.attorney_count, o.contact_crawled_at,
      (SELECT marketing_score FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS audit_score,
      (SELECT primary_color FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS audit_color,
      (SELECT COUNT(*)::int FROM phones WHERE organization_id=o.id) AS phone_count,
      (SELECT COUNT(*)::int FROM emails WHERE organization_id=o.id) AS email_count,
      (SELECT COUNT(*)::int FROM professional_locations pl WHERE pl.organization_id=o.id) AS lawyer_count
    FROM organizations o
    WHERE ${where.join(' AND ')}
    ORDER BY ${orderBy}
    LIMIT $${params.length - 1} OFFSET $${params.length}
  `, params);
  res.json({ count: r.rowCount, offset, limit, rows: r.rows });
});

app.get('/api/attorneys', async (req, res) => {
  const q = (req.query.q as string | undefined)?.trim() || null;
  const status = (req.query.status as string | undefined)?.trim() || null;
  const sort = (req.query.sort as string | undefined)?.trim() || 'name';
  const limit = Math.min(parseInt((req.query.limit as string) || '200', 10), 1000);
  const offset = Math.max(parseInt((req.query.offset as string) || '0', 10), 0);

  const where: string[] = ['p.bar_number IS NOT NULL'];
  const params: unknown[] = [];
  if (q) {
    params.push('%' + q.toLowerCase() + '%');
    where.push(`(LOWER(p.full_name) LIKE $${params.length} OR LOWER(p.last_name) LIKE $${params.length})`);
  }
  if (status === 'active') where.push(`p.license_status = 'Active'`);
  if (status === 'inactive') where.push(`p.license_status <> 'Active'`);

  let orderBy = 'p.full_name ASC';
  if (sort === 'admission') orderBy = 'p.admission_date DESC NULLS LAST';
  else if (sort === 'bar') orderBy = '(p.bar_number)::int ASC';

  params.push(limit);
  params.push(offset);

  const r = await query(`
    SELECT
      p.id, p.full_name, p.first_name, p.last_name, p.bar_number, p.license_status, p.admission_date,
      p.law_school, p.primary_practice_area,
      pl.organization_id AS firm_id,
      o.name AS firm_name, o.city, o.zip, o.phone AS firm_phone, o.website AS firm_website
    FROM professionals p
    LEFT JOIN professional_locations pl ON pl.professional_id = p.id AND pl.is_primary = true
    LEFT JOIN organizations o ON o.id = pl.organization_id
    WHERE ${where.join(' AND ')}
    ORDER BY ${orderBy}
    LIMIT $${params.length - 1} OFFSET $${params.length}
  `, params);
  res.json({ count: r.rowCount, offset, limit, rows: r.rows });
});

app.get('/api/dashboard/stats', async (_req, res) => {
  const r = await query<{ key: string; value: string }>(`
    SELECT 'firms'      AS key, COUNT(*)::text AS value FROM organizations WHERE type='law_firm'
    UNION ALL SELECT 'attorneys',  COUNT(*)::text FROM professionals
    UNION ALL SELECT 'with_site',  COUNT(*)::text FROM organizations WHERE type='law_firm' AND website ~ '^https?://'
    UNION ALL SELECT 'with_phone', COUNT(*)::text FROM organizations WHERE type='law_firm' AND phone IS NOT NULL
    UNION ALL SELECT 'phones',     COUNT(*)::text FROM phones
    UNION ALL SELECT 'emails',     COUNT(*)::text FROM emails
    UNION ALL SELECT 'audits',     COUNT(*)::text FROM site_audits WHERE status_code BETWEEN 200 AND 399
    UNION ALL SELECT 'cities',     COUNT(DISTINCT city)::text FROM organizations WHERE type='law_firm'
  `);
  const out: Record<string, number> = {};
  for (const row of r.rows) out[row.key] = parseInt(row.value, 10);
  res.json(out);
});

app.get('/api/firms/:id', async (req, res) => {
  const id = parseInt(req.params.id, 10);
  if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
  // SECURITY: explicit allow-list. Do NOT use SELECT * — would leak future
  // internal columns (contact_crawled_at, site_intel_body, internal notes, etc.)
  // to any unauthenticated caller. Add new public columns here intentionally.
  const r = await query(`
    SELECT id, name, type, address, city, state, zip, county, neighborhood,
           phone, website, lat, lng, building_name, attorney_count,
           practice_areas, firm_size_band, rating, review_count,
           created_at, updated_at
      FROM organizations WHERE id = $1`, [id]);
  if (!r.rowCount) return res.status(404).json({ error: 'not found' });
  res.json(r.rows[0]);
});

app.get('/api/heatmap', async (_req, res) => {
  const r = await query<{ lat: number; lng: number; weight: number }>(`
    SELECT lat, lng, GREATEST(1, attorney_count) AS weight
    FROM organizations
    WHERE type='law_firm' AND lat IS NOT NULL AND lng IS NOT NULL
  `);
  res.json({ count: r.rowCount, points: r.rows });
});

app.get('/api/buildings', async (_req, res) => {
  const r = await query(`
    SELECT address, city, COUNT(*)::int AS firms, AVG(lat) AS lat, AVG(lng) AS lng,
           ARRAY_AGG(name ORDER BY name) AS names
    FROM organizations
    WHERE type='law_firm' AND address IS NOT NULL AND lat IS NOT NULL
    GROUP BY address, city
    HAVING COUNT(*) > 1
    ORDER BY firms DESC
  `);
  res.json({ count: r.rowCount, rows: r.rows });
});

app.get('/api/cities', async (_req, res) => {
  const r = await query(`
    SELECT COALESCE(neighborhood, city) AS area,
           COUNT(*)::int AS firms,
           AVG(lat) AS lat, AVG(lng) AS lng
    FROM organizations
    WHERE type='law_firm' AND COALESCE(neighborhood, city) IS NOT NULL
    GROUP BY area
    ORDER BY firms DESC
  `);
  res.json({ count: r.rowCount, rows: r.rows });
});

app.get('/api/jobs', async (_req, res) => {
  const r = await query(`
    SELECT j.id, s.source_name, j.job_label, j.status, j.started_at, j.finished_at,
           j.records_found, j.records_inserted, j.records_skipped, j.error_message
    FROM scrape_jobs j
    JOIN sources s ON s.id = j.source_id
    ORDER BY j.id DESC
    LIMIT 30
  `);
  res.json({ count: r.rowCount, rows: r.rows });
});

app.get('/api/professionals/search', async (req, res) => {
  const q = ((req.query.q as string) || '').trim();
  if (q.length < 3) return res.json({ rows: [] });
  // Match against full_name (loose ILIKE), prefer last_name prefix matches
  // and active CA-bar attorneys with phone/firm.
  const r = await query(`
    SELECT
      p.id, p.full_name, p.bar_number, p.license_status,
      pl.organization_id AS firm_id,
      o.name AS firm_name, o.city, o.zip, o.website AS firm_website,
      o.phone AS firm_phone
    FROM professionals p
    LEFT JOIN professional_locations pl ON pl.professional_id = p.id AND pl.is_primary = true
    LEFT JOIN organizations o ON o.id = pl.organization_id
    WHERE p.bar_number IS NOT NULL
      AND (
        LOWER(p.full_name) LIKE LOWER($1) || '%'
        OR LOWER(p.last_name) LIKE LOWER($1) || '%'
        OR LOWER(p.full_name) LIKE '%' || LOWER($1) || '%'
      )
    ORDER BY
      (LOWER(p.last_name) LIKE LOWER($1) || '%') DESC,
      (LOWER(p.full_name) LIKE LOWER($1) || '%') DESC,
      (p.license_status = 'Active') DESC,
      p.full_name ASC
    LIMIT 8
  `, [q]);
  res.json({ rows: r.rows });
});

app.get('/api/audits', async (req, res) => {
  const minScore = parseInt((req.query.min_score as string) || '0', 10);
  const limit = Math.min(parseInt((req.query.limit as string) || '500', 10), 2000);
  const r = await query(`
    SELECT
      a.id, a.organization_id AS firm_id, o.name, a.url, a.final_url,
      a.status_code, a.screenshot_path, a.primary_color, a.palette,
      a.has_https, a.has_favicon, a.has_og_image, a.has_meta_viewport,
      a.has_analytics, a.has_h1, a.has_schema_org, a.has_phone_visible,
      a.page_size_bytes, a.load_time_ms, a.font_count, a.image_count, a.link_count,
      a.marketing_score, a.suggestions, a.audited_at,
      o.city, o.neighborhood, o.zip
    FROM site_audits a
    JOIN organizations o ON o.id = a.organization_id
    WHERE a.status_code BETWEEN 200 AND 399
      AND a.marketing_score >= $1
    ORDER BY a.marketing_score DESC NULLS LAST, a.audited_at DESC
    LIMIT $2
  `, [minScore, limit]);
  res.json({ count: r.rowCount, rows: r.rows });
});

// ─── Landing template — injects the Last-verified date from data/last-verified.json
// so the colophon "Last verified" claim can never silently go stale on deploy.
// The JSON manifest is the source of truth; bump it after each State Bar re-verification.
import { readFileSync } from 'node:fs';
const LANDING_HTML_PATH = path.resolve(__dirname, '../../public/index.html');
const LAST_VERIFIED_PATH = path.resolve(__dirname, '../../data/last-verified.json');
function renderLanding(): string {
  let html = readFileSync(LANDING_HTML_PATH, 'utf8');
  let display = '2026 · 05 · 04';
  let isoDate = '2026-05-04';
  try {
    const manifest = JSON.parse(readFileSync(LAST_VERIFIED_PATH, 'utf8'));
    if (manifest.verified_at_display) display = String(manifest.verified_at_display);
    if (manifest.verified_at_iso) isoDate = String(manifest.verified_at_iso).slice(0, 10);
  } catch {
    // If the manifest is missing/unreadable, fall through with the static fallback above.
    // The page still renders; only the date won't update on next verification.
  }
  // Token replaces any hardcoded date already present in the static file.
  // Pattern matches "20YY · MM · DD" inside the colophon stack only.
  html = html.replace(/<span class="colophon-stack-val sans">20\d{2} · \d{2} · \d{2}<\/span>/g,
    `<span class="colophon-stack-val sans" data-iso="${isoDate}">${display}</span>`);
  return html;
}
app.get(['/', '/index.html'], (_req, res) => {
  res.set('Content-Type', 'text/html; charset=utf-8');
  res.send(renderLanding());
});

// ─── Static dashboard + audit viewer ────────────────────────────────────────

app.use('/', express.static(path.resolve(__dirname, '../../public')));

// ─── Error handler — convert send() errors to proper HTTP statuses ──────────
// Caught crash loop 2026-05-04: a client sent a malformed Range header at a
// static asset; express.static's `send` module threw RangeNotSatisfiableError
// which propagated past route handlers, crashing the process. Express's
// default error handler returns 500 *and* logs to stderr; without an explicit
// handler bound here, the throw bubbled to pm2 → restart loop.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use((err: any, _req: import('express').Request, res: import('express').Response, _next: import('express').NextFunction) => {
  const status = typeof err?.status === 'number' ? err.status : 500;
  const code = err?.code || err?.name || 'Error';
  // Don't leak full stack to clients in 4xx; do log to stderr for ops visibility.
  if (status >= 500) {
    console.error('[err]', status, code, err?.message);
    if (err?.stack) console.error(err.stack);
  }
  if (res.headersSent) return;
  res.status(status).type('text/plain').send(`${status} ${code}`);
});

// ─── Branded 404 fallback ───────────────────────────────────────────────────
app.use((req, res) => {
  if (req.accepts('html')) {
    // Surface the requested path verbatim so the user knows what we couldn't
    // find. Truncate to 80 chars + escape; never echo query strings (PII risk).
    const rawPath = String(req.originalUrl || req.url || '/').split('?')[0];
    const safePath = rawPath
      .replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!))
      .slice(0, 80);
    res.status(404).type('html').send(`<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Not found · Counsel &amp; Bar</title>
<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=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root{--noir:#0a0a0c;--noir-rise:#131316;--noir-deep:#050507;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--metal-glow:#d4b683;--rule:#2a2724;--rule-faint:#1a1815;--serif:"Cormorant Garamond",Georgia,serif;--sans:"Inter",system-ui,sans-serif}
*{box-sizing:border-box;margin:0;padding:0}
body{margin:0;background:var(--noir);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased;min-height:100vh;display:flex;flex-direction:column}
a{color:var(--metal);text-decoration:none;transition:color 180ms ease}
a:hover{color:var(--metal-glow)}
header{padding:22px 48px;border-bottom:1px solid var(--rule);background:var(--noir-deep)}
header a{display:inline-flex;align-items:center;gap:14px;text-decoration:none}
header h1{font-family:var(--serif);font-size:22px;font-weight:400;color:var(--ink)}
header h1 .amp{color:var(--metal);font-style:italic;padding:0 4px}
main{flex:1;display:flex;flex-direction:column;align-items:center;padding:96px 48px 64px;position:relative;overflow:hidden;max-width:980px;margin:0 auto;width:100%}
.code{font-family:var(--serif);font-style:italic;font-weight:300;color:var(--metal);opacity:.05;font-size:clamp(260px,38vw,520px);line-height:.85;letter-spacing:-.04em;position:absolute;top:38%;left:50%;transform:translate(-50%,-50%);pointer-events:none;user-select:none;z-index:0}
.nf-vol{display:flex;align-items:center;gap:18px;margin:0 0 26px;color:var(--metal);position:relative;z-index:1;align-self:flex-start}
.nf-vol .v-rule{flex:0 0 56px;height:1px;background:linear-gradient(90deg,transparent,var(--metal) 50%,transparent)}
.nf-vol .v-text{font-family:var(--serif);font-style:italic;font-weight:400;font-size:14px;letter-spacing:.02em}
.nf-vol .v-meta{font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
h2{font-family:var(--serif);font-weight:300;font-size:clamp(40px,6vw,72px);line-height:1.04;letter-spacing:-.02em;margin:0 0 22px;max-width:18ch;color:var(--ink);position:relative;z-index:1;align-self:flex-start}
h2 em{font-style:italic;color:var(--metal);font-weight:400}
p.lede{color:var(--ink-soft);font-size:17px;line-height:1.65;max-width:58ch;margin:0 0 16px;position:relative;z-index:1;align-self:flex-start;font-weight:300}
p.lede strong{color:var(--ink);font-weight:500}
.path-strip{display:inline-flex;align-items:center;gap:10px;padding:10px 16px;border:1px solid var(--rule);background:linear-gradient(180deg,rgba(184,153,104,.025),transparent);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;color:var(--ink-soft);letter-spacing:0;margin:0 0 40px;position:relative;z-index:1;align-self:flex-start;max-width:100%;overflow-x:auto;white-space:nowrap}
.path-strip .pl{font-family:var(--sans);font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
.path-strip .pp{color:var(--metal-glow)}
.cta-row{display:flex;gap:18px;flex-wrap:wrap;align-self:flex-start;position:relative;z-index:1;margin:0 0 56px}
.cta{display:inline-flex;align-items:center;gap:10px;padding:18px 32px;font-size:12px;font-weight:500;letter-spacing:.18em;text-transform:uppercase;text-decoration:none;border:1px solid var(--rule);transition:all 240ms ease}
.cta-primary{background:transparent;color:var(--metal-glow);border-color:var(--metal)}
.cta-primary:hover{background:var(--metal);color:var(--noir);letter-spacing:.22em}
.cta-ghost{background:transparent;color:var(--ink-mute)}
.cta-ghost:hover{color:var(--ink);border-color:var(--ink)}
.suggest{position:relative;z-index:1;align-self:stretch;margin:0 0 32px;padding:48px 0 0;border-top:1px solid var(--rule)}
.suggest h3{font-family:var(--serif);font-weight:400;font-size:22px;color:var(--ink);margin:0 0 24px;letter-spacing:-.005em}
.suggest-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
@media (max-width:780px){.suggest-grid{grid-template-columns:1fr}}
.suggest-cell{padding:28px 24px;border-right:1px solid var(--rule);display:flex;flex-direction:column;gap:10px}
.suggest-cell:last-child{border-right:none}
@media (max-width:780px){.suggest-cell{border-right:none;border-bottom:1px solid var(--rule);padding:24px 20px}.suggest-cell:last-child{border-bottom:none}}
.suggest-num{font-family:var(--serif);font-style:italic;font-weight:300;color:transparent;-webkit-text-stroke:1px var(--metal);font-size:24px;line-height:1;letter-spacing:-.02em}
.suggest-cell h4{font-family:var(--serif);font-weight:400;font-size:18px;line-height:1.25;color:var(--ink);margin:0;letter-spacing:-.005em}
.suggest-cell p{font-size:13px;line-height:1.6;color:var(--ink-mute);margin:0;flex:1}
.suggest-link{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--metal);font-weight:500;border-bottom:1px solid var(--rule);padding-bottom:3px;align-self:flex-start;transition:border-color 200ms ease,color 200ms ease;text-decoration:none}
.suggest-link:hover{color:var(--metal-glow);border-bottom-color:var(--metal)}
footer{padding:28px 48px;border-top:1px solid var(--rule);text-align:center;color:var(--ink-mute);font-size:11px;letter-spacing:.16em;text-transform:uppercase;background:var(--noir-deep)}
footer a{color:var(--ink-mute)}
footer a:hover{color:var(--metal)}

*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(184,153,104,.45);border-radius:1px}
*:focus:not(:focus-visible){outline:none}

</style></head><body>
<header><a href="/"><svg viewBox="0 0 28 28" width="28" height="28" aria-hidden="true"><defs><linearGradient id="cb-404" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#d4b683"/><stop offset="50%" stop-color="#b89968"/><stop offset="100%" stop-color="#8a7044"/></linearGradient></defs><circle cx="14" cy="14" r="13" fill="none" stroke="url(#cb-404)" stroke-width="1"/><path fill="url(#cb-404)" d="M9.6 9.5c-1.6 0-2.7 1.2-2.7 3v3c0 1.8 1.1 3 2.7 3 1.4 0 2.4-.8 2.6-2.1h-1.3c-.1.6-.6 1-1.3 1-.9 0-1.4-.6-1.4-1.7v-2.4c0-1.1.5-1.7 1.4-1.7.7 0 1.2.4 1.3 1h1.3c-.2-1.3-1.2-2.1-2.6-2.1zm5.5.1v8.7h2.7c1.6 0 2.6-.9 2.6-2.4 0-1-.5-1.7-1.3-2 .7-.3 1.1-1 1.1-1.8 0-1.5-1-2.5-2.5-2.5h-2.6zm1.3 1.1h1.2c.8 0 1.4.5 1.4 1.4 0 .9-.5 1.4-1.4 1.4h-1.2v-2.8zm0 3.9h1.4c.9 0 1.4.6 1.4 1.5s-.5 1.5-1.4 1.5h-1.4V14.6z"/></svg><h1>Counsel <span class="amp">&amp;</span> Bar</h1></a></header>
<main>
  <div class="code" aria-hidden="true">404</div>
  <div class="nf-vol">
    <span class="v-rule"></span>
    <span class="v-text">Volume I</span>
    <span class="v-meta">Edition 2026 · Not Found</span>
  </div>
  <h2>Not in the <em>registry.</em></h2>
  <p class="lede">The page you're looking for isn't on this site. The California State Bar's public roll is &mdash; try searching there if you're verifying a license, or pick a path forward below.</p>
  <div class="path-strip"><span class="pl">Requested</span> <span class="pp">${safePath}</span></div>

  <div class="cta-row">
    <a class="cta cta-primary" href="/">Return home <span style="font-family:var(--serif);font-style:italic;font-size:18px;line-height:1">→</span></a>
    <a class="cta cta-ghost" href="/find-a-lawyer">Find counsel</a>
  </div>

  <section class="suggest">
    <h3>Where you may have meant to land</h3>
    <div class="suggest-grid">
      <div class="suggest-cell">
        <span class="suggest-num">i.</span>
        <h4>Find counsel</h4>
        <p>Three-section configurator. Tell us your matter, where you are, and what's going on. We list California-licensed firms ranked by proximity.</p>
        <a class="suggest-link" href="/find-a-lawyer">Open configurator →</a>
      </div>
      <div class="suggest-cell">
        <span class="suggest-num">ii.</span>
        <h4>Audit a site</h4>
        <p>Browse our independent audit of California-licensed firms' websites against twelve published signals. Methodology v1.0 documented in full.</p>
        <a class="suggest-link" href="/audit.html">Open audit →</a>
      </div>
      <div class="suggest-cell">
        <span class="suggest-num">iii.</span>
        <h4>Verify on the State Bar</h4>
        <p>For license verification, the State Bar's public roll at calbar.ca.gov is the authoritative source. Search by name or bar number.</p>
        <a class="suggest-link" href="https://apps.calbar.ca.gov/attorney/Licensee/Search" target="_blank" rel="noopener">State Bar lookup →</a>
      </div>
    </div>
  </section>
</main>
<footer>Counsel &amp; Bar · A directory and software platform · Not a law firm, not a lawyer referral service · <a href="/methodology.html">Methodology v1.0</a></footer>
</body></html>`);
  } else {
    res.status(404).type('text').send('Not found');
  }
});

app.listen(PORT, () => {
  console.log(`[lawyer-directory] listening on http://localhost:${PORT}`);
  console.log(`  open               http://localhost:${PORT}/`);
  console.log(`  api stats          http://localhost:${PORT}/api/stats`);
  console.log(`  api heatmap        http://localhost:${PORT}/api/heatmap`);
  console.log(`  audit viewer       http://localhost:${PORT}/audit.html`);
});

process.on('SIGINT',  async () => { await pool.end(); process.exit(0); });
process.on('SIGTERM', async () => { await pool.end(); process.exit(0); });