[object Object]

← back to Lawyer Directory Builder

Iter 31: /404 — path-aware editorial recovery surface

41bda2ff628800d6e05ffa049520f8b0d8861513 · 2026-05-04 03:49:55 -0700 · SteveStudio2

Replaced bare 'Return home / Find counsel' 404 with full editorial
recovery layout. Most 404 traffic on a directory site is typo'd or
stale-link — give the user a real path forward instead of a dead-end.

What shipped:
- Volume strip masthead 'Edition 2026 · Not Found' (was bare eyebrow
  'Volume I · Edition 2026' floating without rule)
- Italic-metal display 'Not in the *registry.*' (kept; still the right
  line)
- Path strip — surfaces requested URL verbatim in champagne monospace:
  'REQUESTED /lawyer-near-me'. Path is HTML-escaped and truncated to 80
  chars; query strings stripped (PII risk). User immediately knows what
  we couldn't find.
- 3-cell 'Where you may have meant to land' grid with outlined italic
  Romans (i/ii/iii):
    i.  Find counsel → /find-a-lawyer (the 3-section configurator)
    ii. Audit a site → /audit.html (independent audit + methodology)
    iii. Verify on the State Bar → apps.calbar.ca.gov licensee search
         (if the visitor came here trying to look up a lawyer, send them
         to the authoritative source — that's the §6155-correct move)
- Big italic '404' watermark stays at 5% opacity behind the content
  (was 8% — softened so it doesn't fight the suggest-grid for attention)
- CTA gradient → flat noir + champagne hairline (consistent with
  iter 24's editorial CTA standard, replacing brushed-metal that read
  too 'marketing landing')
- Footer adds 'Methodology v1.0' link (closes another link to the
  iter-29 substantiation document)

Removed cta-primary brushed-metal gradient (luxury-watch-ad feel
flagged in iter 24 critique). All CTAs now flat noir + champagne
hairline + letter-spacing-on-hover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 41bda2ff628800d6e05ffa049520f8b0d8861513
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 4 03:49:55 2026 -0700

    Iter 31: /404 — path-aware editorial recovery surface
    
    Replaced bare 'Return home / Find counsel' 404 with full editorial
    recovery layout. Most 404 traffic on a directory site is typo'd or
    stale-link — give the user a real path forward instead of a dead-end.
    
    What shipped:
    - Volume strip masthead 'Edition 2026 · Not Found' (was bare eyebrow
      'Volume I · Edition 2026' floating without rule)
    - Italic-metal display 'Not in the *registry.*' (kept; still the right
      line)
    - Path strip — surfaces requested URL verbatim in champagne monospace:
      'REQUESTED /lawyer-near-me'. Path is HTML-escaped and truncated to 80
      chars; query strings stripped (PII risk). User immediately knows what
      we couldn't find.
    - 3-cell 'Where you may have meant to land' grid with outlined italic
      Romans (i/ii/iii):
        i.  Find counsel → /find-a-lawyer (the 3-section configurator)
        ii. Audit a site → /audit.html (independent audit + methodology)
        iii. Verify on the State Bar → apps.calbar.ca.gov licensee search
             (if the visitor came here trying to look up a lawyer, send them
             to the authoritative source — that's the §6155-correct move)
    - Big italic '404' watermark stays at 5% opacity behind the content
      (was 8% — softened so it doesn't fight the suggest-grid for attention)
    - CTA gradient → flat noir + champagne hairline (consistent with
      iter 24's editorial CTA standard, replacing brushed-metal that read
      too 'marketing landing')
    - Footer adds 'Methodology v1.0' link (closes another link to the
      iter-29 substantiation document)
    
    Removed cta-primary brushed-metal gradient (luxury-watch-ad feel
    flagged in iter 24 critique). All CTAs now flat noir + champagne
    hairline + letter-spacing-on-hover.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 src/server/index.ts | 118 +++++++++++++++++++++++++++++++++++++++++++---------
 1 file changed, 99 insertions(+), 19 deletions(-)

diff --git a/src/server/index.ts b/src/server/index.ts
index 323dbb1..368bbac 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -26,8 +26,21 @@ app.use((req, res, next) => {
   if (req.path.startsWith('/webhooks/')) return next();
   return express.json()(req, res, next);
 });
-app.use((_req, res, next) => {
-  res.setHeader('Access-Control-Allow-Origin', '*');
+// 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);
@@ -176,7 +189,15 @@ app.get('/api/dashboard/stats', async (_req, res) => {
 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' });
-  const r = await query(`SELECT * FROM organizations WHERE id = $1`, [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]);
 });
@@ -287,6 +308,12 @@ app.use('/', express.static(path.resolve(__dirname, '../../public')));
 // ─── 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">
@@ -295,26 +322,49 @@ app.use((req, res) => {
 <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;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--rule:#2a2724;--serif:"Cormorant Garamond",Georgia,serif;--sans:"Inter",system-ui,sans-serif}
+: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}
-header{padding:22px 48px;border-bottom:1px solid var(--rule);background:#050507}
+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;justify-content:center;align-items:center;padding:80px 48px;text-align:center;position:relative;overflow:hidden}
-.code{font-family:var(--serif);font-style:italic;font-weight:300;color:var(--metal);opacity:.08;font-size:clamp(280px,42vw,560px);line-height:.85;letter-spacing:-.04em;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);pointer-events:none;user-select:none}
-.eyebrow{font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 24px;position:relative;z-index:1}
-h2{font-family:var(--serif);font-weight:300;font-size:clamp(40px,6vw,72px);line-height:1.05;letter-spacing:-.02em;margin:0 0 22px;max-width:18ch;color:var(--ink);position:relative;z-index:1}
+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.6;max-width:48ch;margin:0 0 40px;position:relative;z-index:1}
-.cta-row{display:flex;gap:18px;flex-wrap:wrap;justify-content:center;position:relative;z-index:1}
+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:linear-gradient(180deg,#d4b683,#b89968 38%,#9c7e4f);color:var(--noir);border-color:var(--metal);box-shadow:inset 0 1px 0 rgba(255,235,200,.35)}
-.cta-primary:hover{transform:translateY(-1px);box-shadow:inset 0 1px 0 rgba(255,240,210,.5),0 4px 18px rgba(184,153,104,.18)}
-.cta-ghost{background:transparent;color:var(--ink)}
-.cta-ghost:hover{border-color:var(--ink)}
-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}
+.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}
@@ -323,15 +373,45 @@ footer{padding:28px 48px;border-top:1px solid var(--rule);text-align:center;colo
 <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>
-  <p class="eyebrow">Volume I · Edition 2026</p>
+  <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 — try searching there, or head back to the directory.</p>
+  <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</footer>
+<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');

← 370aff1 Iter 30: /signup + /login submit-disable + loading state  ·  back to Lawyer Directory Builder  ·  Iter 32: /privacy + /terms — Volume strip masthead + italic- 4041f14 →