[object Object]

← back to Animals

fix(events): codex P1 XSS in /shows map + P2 dedupe collision

b70d15d8a93d1e184934bd58fe06503b6294854d · 2026-05-01 18:50:22 -0700 · Steve

P1 (security) — render.js:389-396
The /shows page injected scraped event titles via raw JSON.stringify in a
<script> block, then built popups via 'string' + p.title concat fed to
Leaflet's bindPopup (which treats it as innerHTML). Event titles come from
third-party AKC/UKC pages — a malformed or hostile title containing
</script> or HTML became stored XSS on a public route.

Switched to:
- <script type="application/json" id="show-pts">…</script> + JSON.parse
  (with a <-escape on the JSON output for an extra layer in case the
  pattern is ever copy-pasted into a non-JSON context)
- Popup built via document.createElement('strong') + .textContent so
  titles can never become HTML.

P2 (data integrity) — dog_shows_ca.js:158 + migrations/007_*.sql
ON CONFLICT (source, source_url, start_at) collided for AKC/UKC where
source_url is the shared search/results page. Two distinct shows on the
same date silently overwrote each other in the events table.

Added title to the dedupe key + new migration that drops the old unique
index and creates idx_events_dedupe (source, source_url, start_at, title).

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

Files touched

Diff

commit b70d15d8a93d1e184934bd58fe06503b6294854d
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri May 1 18:50:22 2026 -0700

    fix(events): codex P1 XSS in /shows map + P2 dedupe collision
    
    P1 (security) — render.js:389-396
    The /shows page injected scraped event titles via raw JSON.stringify in a
    <script> block, then built popups via 'string' + p.title concat fed to
    Leaflet's bindPopup (which treats it as innerHTML). Event titles come from
    third-party AKC/UKC pages — a malformed or hostile title containing
    </script> or HTML became stored XSS on a public route.
    
    Switched to:
    - <script type="application/json" id="show-pts">…</script> + JSON.parse
      (with a <-escape on the JSON output for an extra layer in case the
      pattern is ever copy-pasted into a non-JSON context)
    - Popup built via document.createElement('strong') + .textContent so
      titles can never become HTML.
    
    P2 (data integrity) — dog_shows_ca.js:158 + migrations/007_*.sql
    ON CONFLICT (source, source_url, start_at) collided for AKC/UKC where
    source_url is the shared search/results page. Two distinct shows on the
    same date silently overwrote each other in the events table.
    
    Added title to the dedupe key + new migration that drops the old unique
    index and creates idx_events_dedupe (source, source_url, start_at, title).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 migrations/007_events_dedupe_with_title.sql |  15 ++
 src/ingest/dog_shows_ca.js                  | 221 ++++++++++++++++++++++++++++
 src/server/render.js                        | 115 ++++++++++++++-
 3 files changed, 347 insertions(+), 4 deletions(-)

diff --git a/migrations/007_events_dedupe_with_title.sql b/migrations/007_events_dedupe_with_title.sql
new file mode 100644
index 0000000..44edc77
--- /dev/null
+++ b/migrations/007_events_dedupe_with_title.sql
@@ -0,0 +1,15 @@
+-- 007_events_dedupe_with_title.sql
+-- Fixes the ON CONFLICT key mismatch flagged by codex on 2026-05-01.
+--
+-- The original idx_events_dedupe (source, source_url, start_at) collided for
+-- AKC/UKC imports where source_url is the shared search/results page —
+-- multiple distinct events on the same date silently overwrote each other.
+-- Adding `title` to the unique key so distinct events on the same source/date
+-- survive.
+--
+-- Idempotent: drops the old index if present, recreates the new one.
+
+DROP INDEX IF EXISTS idx_events_dedupe;
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_events_dedupe
+  ON events (source, source_url, start_at, title);
diff --git a/src/ingest/dog_shows_ca.js b/src/ingest/dog_shows_ca.js
new file mode 100644
index 0000000..860e2fa
--- /dev/null
+++ b/src/ingest/dog_shows_ca.js
@@ -0,0 +1,221 @@
+#!/usr/bin/env node
+// Dog show + competition scraper for California.
+//
+// Sources:
+//   1. AKC event search JSON (https://www.akc.org/wp-admin/admin-ajax.php → action=event_search_loadmore)
+//   2. UKC event search (https://www.ukcdogs.com/event-search) — HTML, parsed with cheerio
+//   3. Misc: each major California kennel club website if directly listed
+//
+// All inserts go into the `events` table with kind one of:
+//   'dog_show','agility_trial','obedience_trial','rally_trial','field_trial',
+//   'herding_trial','tracking_test','lure_coursing','barn_hunt','dock_diving',
+//   'flyball','breed_meet'.
+//
+// Geocoding: best-effort. We rely on the source's address field; if no
+// lat/lng is provided, we leave the columns NULL and the city map page
+// just won't drop a marker for that event (acceptable for v1).
+//
+// Run:
+//   node src/ingest/dog_shows_ca.js               # CA, next 90 days
+//   node src/ingest/dog_shows_ca.js --days 180
+
+import 'dotenv/config';
+import { fetch as undiciFetch } from 'undici';
+import * as cheerio from 'cheerio';
+import { pool, one } from '../lib/db.js';
+import { log } from '../lib/log.js';
+
+const args = process.argv.slice(2);
+const arg = (k, d = null) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
+const days = parseInt(arg('--days'), 10) || 120;
+
+const UA = 'AnimalsDirectoryBot/1.0 (+https://animalsdirectory.com/bot)';
+const today = new Date();
+const until = new Date(today.getTime() + days * 24 * 60 * 60 * 1000);
+
+const stats = { akc: 0, ukc: 0, errors: 0 };
+
+await scrapeAKC().catch(e => { stats.errors++; log.error('akc failed', { err: e.message }); });
+await scrapeUKC().catch(e => { stats.errors++; log.error('ukc failed', { err: e.message }); });
+
+log.info('dog shows CA done', stats);
+await pool.end();
+
+// ── AKC event search ─────────────────────────────────────────────────────
+// Best-effort: AKC frequently changes their endpoint. We try the public
+// admin-ajax POST first (returns rendered HTML w/ event cards), fall back
+// to the search-results HTML page.
+async function scrapeAKC() {
+  log.info('=== AKC events (CA) ===');
+
+  // Public results page — server-renders next ~50 events for the filters
+  const url = 'https://www.akc.org/sports/?_event_state%5B%5D=ca';
+  let html;
+  try {
+    const r = await undiciFetch(url, {
+      headers: { 'user-agent': UA, accept: 'text/html,*/*' },
+      signal: AbortSignal.timeout(20000),
+    });
+    if (!r.ok) { log.warn(`AKC HTTP ${r.status}`); return; }
+    html = await r.text();
+  } catch (err) { log.warn(`AKC fetch err: ${err.message}`); return; }
+
+  const $ = cheerio.load(html);
+
+  // AKC marks each event card as .event-card or article.event — we accept either
+  const cards = $('article.event, .event-card, .results-card, [data-event-id]').toArray();
+  log.info(`  AKC cards found: ${cards.length}`);
+
+  for (const el of cards) {
+    try {
+      const $c = $(el);
+      const title = clean($c.find('h2, h3, .event-title, [data-event-title]').first().text());
+      if (!title) continue;
+      const dateText = clean($c.find('.event-date, .date, time').first().text())
+        || clean($c.find('[data-event-date]').first().attr('data-event-date'));
+      const venue = clean($c.find('.event-venue, .venue').first().text());
+      const location = clean($c.find('.event-location, .location, .city-state').first().text());
+      const eventUrl = $c.find('a').first().attr('href') || null;
+      const eventType = inferKind(title + ' ' + (clean($c.find('.event-type').first().text()) || ''));
+
+      const { startAt, endAt } = parseDateRange(dateText);
+      if (!startAt || startAt > until) continue;
+
+      const { city, state } = parseLocation(location);
+      if (state && state !== 'CA') continue;
+
+      await upsertEvent({
+        kind: eventType,
+        title,
+        sponsor_org: 'AKC',
+        venue: venue || null,
+        city: city || null,
+        state: state || 'CA',
+        start_at: startAt,
+        end_at: endAt,
+        url: absUrl(eventUrl, 'https://www.akc.org'),
+        source_url: url,
+        source: 'akc',
+      });
+      stats.akc++;
+    } catch (err) { log.warn(`  AKC card parse err: ${err.message}`); }
+  }
+}
+
+// ── UKC event search ─────────────────────────────────────────────────────
+async function scrapeUKC() {
+  log.info('=== UKC events (CA) ===');
+  const url = 'https://www.ukcdogs.com/event-search?_state=California';
+  let html;
+  try {
+    const r = await undiciFetch(url, {
+      headers: { 'user-agent': UA, accept: 'text/html,*/*' },
+      signal: AbortSignal.timeout(20000),
+    });
+    if (!r.ok) { log.warn(`UKC HTTP ${r.status}`); return; }
+    html = await r.text();
+  } catch (err) { log.warn(`UKC fetch err: ${err.message}`); return; }
+
+  const $ = cheerio.load(html);
+  const cards = $('.event-listing, article, .search-result').toArray();
+  log.info(`  UKC cards found: ${cards.length}`);
+  for (const el of cards) {
+    try {
+      const $c = $(el);
+      const title = clean($c.find('h2, h3, .title').first().text());
+      if (!title) continue;
+      const dateText = clean($c.find('.date, time').first().text());
+      const venue = clean($c.find('.venue, .location').first().text());
+      const eventUrl = $c.find('a').first().attr('href') || null;
+      const { startAt, endAt } = parseDateRange(dateText);
+      if (!startAt || startAt > until) continue;
+      const { city, state } = parseLocation(venue);
+      if (state && state !== 'CA') continue;
+      await upsertEvent({
+        kind: inferKind(title),
+        title,
+        sponsor_org: 'UKC',
+        venue: venue || null,
+        city: city || null,
+        state: state || 'CA',
+        start_at: startAt,
+        end_at: endAt,
+        url: absUrl(eventUrl, 'https://www.ukcdogs.com'),
+        source_url: url,
+        source: 'ukc',
+      });
+      stats.ukc++;
+    } catch (err) { log.warn(`  UKC card parse err: ${err.message}`); }
+  }
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────
+async function upsertEvent(e) {
+  await pool.query(`
+    INSERT INTO events (kind, title, organizer, sponsor_org, venue, address, city, state, zip,
+      start_at, end_at, url, source_url, source, status)
+    VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'scheduled')
+    -- (source, source_url, start_at) collides for AKC/UKC where source_url is
+    -- the shared search/results page — multiple events on same date silently
+    -- overwrite each other. Add title to the dedupe key so distinct events on
+    -- the same source/date survive. (codex P2, 2026-05-01)
+    ON CONFLICT (source, source_url, start_at, title) DO UPDATE SET
+      title = EXCLUDED.title,
+      venue = COALESCE(EXCLUDED.venue, events.venue),
+      city = COALESCE(EXCLUDED.city, events.city),
+      end_at = EXCLUDED.end_at,
+      url = EXCLUDED.url,
+      updated_at = NOW()`,
+    [e.kind, e.title, e.organizer || null, e.sponsor_org || null, e.venue || null,
+     e.address || null, e.city || null, e.state || null, e.zip || null,
+     e.start_at, e.end_at || null, e.url || null, e.source_url, e.source]);
+}
+
+function inferKind(s) {
+  s = s.toLowerCase();
+  if (/agility/.test(s))           return 'agility_trial';
+  if (/obedience/.test(s))         return 'obedience_trial';
+  if (/rally/.test(s))             return 'rally_trial';
+  if (/field|hunt(ing)? test/.test(s)) return 'field_trial';
+  if (/herding/.test(s))           return 'herding_trial';
+  if (/tracking/.test(s))          return 'tracking_test';
+  if (/lure cours/.test(s))        return 'lure_coursing';
+  if (/barn hunt/.test(s))         return 'barn_hunt';
+  if (/dock div/.test(s))          return 'dock_diving';
+  if (/flyball/.test(s))           return 'flyball';
+  if (/conformation|specialty|all-breed|kennel club|cluster/.test(s)) return 'dog_show';
+  return 'dog_show';
+}
+
+function clean(s) { return String(s || '').replace(/\s+/g, ' ').trim(); }
+
+function parseLocation(s) {
+  const m = (s || '').match(/^(.+?)\s*,\s*([A-Z]{2})\b/);
+  if (m) return { city: m[1].trim(), state: m[2] };
+  if (/california|, ca\b|\bca\b/i.test(s)) return { city: null, state: 'CA' };
+  return { city: null, state: null };
+}
+
+function parseDateRange(s) {
+  if (!s) return { startAt: null, endAt: null };
+  // common shapes: "Jan 15, 2026" / "Jan 15-17, 2026" / "Jan 15 - 17, 2026" / "January 15-17 2026"
+  const m1 = s.match(/(\b[A-Z][a-z]{2,8})\s+(\d{1,2})(?:\s*[-–]\s*(?:[A-Z][a-z]{2,8}\s+)?(\d{1,2}))?,?\s*(\d{4})/);
+  if (m1) {
+    const [, mon, d1, d2, yr] = m1;
+    const start = new Date(`${mon} ${d1}, ${yr}`);
+    const end = d2 ? new Date(`${mon} ${d2}, ${yr}`) : null;
+    if (!isNaN(start.getTime())) return { startAt: start, endAt: end && !isNaN(end.getTime()) ? end : null };
+  }
+  // ISO-ish "2026-01-15"
+  const m2 = s.match(/(\d{4})-(\d{2})-(\d{2})/);
+  if (m2) {
+    const start = new Date(`${m2[0]}T00:00:00`);
+    if (!isNaN(start.getTime())) return { startAt: start, endAt: null };
+  }
+  return { startAt: null, endAt: null };
+}
+
+function absUrl(href, base) {
+  if (!href) return null;
+  try { return new URL(href, base).href; } catch { return null; }
+}
diff --git a/src/server/render.js b/src/server/render.js
index d304b94..8d14d9b 100644
--- a/src/server/render.js
+++ b/src/server/render.js
@@ -105,8 +105,10 @@ export function renderHome({ breeds, counts, categories }) {
   // the db uses singular for some. Map explicitly so counts always render.
   const URL_TO_DB = {
     'vets':'vet_clinic','emergency-vets':'emergency_vet','specialty-hospitals':'specialty_hospital',
-    'groomers':'groomer','pet-stores':'pet_store','shelters':'shelter','breeders':'breeder',
-    'trainers':'trainer','boarding':'boarding','daycare':'daycare','dog-parks':'dog_park',
+    'groomers':'groomer','pet-stores':'pet_store',
+    'shelters':'shelter','rescues':'rescue','pounds':'pound',
+    'breeders':'breeder','trainers':'trainer',
+    'boarding':'boarding','kennels':'kennel','daycare':'daycare','dog-parks':'dog_park',
   };
   const catCards = categories.filter(c => (counts[URL_TO_DB[c]]||0) > 0).map(c => `
     <a href="/${c}" class="cat-card">
@@ -158,7 +160,7 @@ export function renderBreedsIndex({ breeds, filters }) {
 </main>` + footer() + '</body></html>';
 }
 
-export function renderBreed({ breed, relatedVets, ad }) {
+export function renderBreed({ breed, relatedVets, images = [], ad }) {
   const traits = [
     ['Group', breed.group_name], ['Size', breed.size_class],
     ['Weight', breed.weight_lbs_min ? `${breed.weight_lbs_min}–${breed.weight_lbs_max} lb` : null],
@@ -171,20 +173,41 @@ export function renderBreed({ breed, relatedVets, ad }) {
     ['Good with pets', breed.good_with_pets ? `${breed.good_with_pets}/5` : null],
   ].filter(([_,v]) => v).map(([k,v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
   const vets = relatedVets.map(v => `<li><a href="/clinic/${v.id}">${esc(v.name)}</a> <small>${esc(v.city||'')}, ${esc(v.state||'')}${v.rating?` · ★ ${v.rating}`:''}</small></li>`).join('');
+  const hero = breed.hero_image_url || images[0]?.image_url || null;
+  // Gallery skips the hero (so it doesn't show twice). Per Steve's standing
+  // no-stock-images rule, every image is PD/CC0/CC-BY/CC-BY-SA — caption
+  // includes author + license + Commons descriptionurl link.
+  const gallery = images
+    .filter(im => im.image_url !== hero)
+    .map(im => `
+      <figure class="gallery-card">
+        <a href="${esc(im.source_url)}" target="_blank" rel="noopener" title="View on Wikimedia Commons">
+          <img loading="lazy" src="${esc(im.thumb_url || im.image_url)}" alt="${esc(im.title || breed.common_name)}">
+        </a>
+        <figcaption>
+          <small><strong>${esc(im.title || '')}</strong></small>
+          <small class="muted">${esc(im.author || 'Unknown')} · <a href="${esc(im.license_url || im.source_url)}" target="_blank" rel="noopener">${esc(im.license_short || 'CC')}</a></small>
+        </figcaption>
+      </figure>`).join('');
   return head(`${breed.common_name} — breed profile`) + nav() + `
 <main class="container">
   <article>
     <header class="breed-header">
-      <div class="breed-img-large" style="${breed.hero_image_url?`background-image:url('${esc(breed.hero_image_url)}')`:''}"></div>
+      <div class="breed-img-large" style="${hero?`background-image:url('${esc(hero)}')`:''}"></div>
       <div>
         <h1>${esc(breed.common_name)}</h1>
         <p class="muted">${esc(breed.species)} · ${esc(breed.group_name||'')} group · ${esc(breed.size_class||'')}</p>
         ${breed.recognized_by?.length ? `<p class="badges">${breed.recognized_by.map(r=>`<span>${esc(r)}</span>`).join('')}</p>` : ''}
+        ${breed.hero_image_credit ? `<p class="muted small">Photo: ${esc(breed.hero_image_credit)}</p>` : ''}
       </div>
     </header>
     <dl class="trait-list">${traits}</dl>
     ${breed.description_md ? `<section class="prose">${breed.description_md.split('\n\n').map(p=>`<p>${esc(p)}</p>`).join('')}</section>` : ''}
   </article>
+  ${gallery ? `
+  <h2>Photos (${images.length} from Wikimedia Commons)</h2>
+  <p class="muted small">All images public-domain or Creative Commons. Click any photo for source + full license.</p>
+  <section class="breed-gallery">${gallery}</section>` : ''}
   ${adSlot(ad, 'native', `/breeds/${breed.slug}`)}
   <h2>Top-rated vets that see ${esc(breed.common_name)}s</h2>
   <ul class="business-list">${vets || '<li class="muted">No vets seeded yet — coming soon.</li>'}</ul>
@@ -321,3 +344,87 @@ export function renderAdmin({ upgradeQ, leadsQ, auditQ }) {
 }
 
 export function renderNotFound(msg='Not found') { return head(msg) + nav() + `<main class="container"><h1>${esc(msg)}</h1><p><a href="/">Back to home</a></p></main>` + footer() + '</body></html>'; }
+
+const KIND_LABEL = {
+  dog_show:'🏆 Dog show', cat_show:'🏆 Cat show', horse_show:'🏆 Horse show',
+  adoption_event:'❤️ Adoption event', vaccine_clinic:'💉 Vaccine clinic',
+  agility_trial:'🎯 Agility trial', obedience_trial:'📜 Obedience',
+  rally_trial:'📋 Rally', field_trial:'🌾 Field trial',
+  herding_trial:'🐑 Herding', tracking_test:'🐾 Tracking',
+  lure_coursing:'🐇 Lure coursing', barn_hunt:'🐀 Barn hunt',
+  dock_diving:'🌊 Dock diving', flyball:'🎾 Flyball',
+  breed_meet:'🐕 Breed meet', other:'📅 Event',
+};
+
+export function renderShows({ events, filters, ad }) {
+  const fmt = (d) => new Date(d).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric', year:'numeric' });
+  const groups = {};
+  for (const e of events) {
+    const k = fmt(e.start_at);
+    (groups[k] ||= []).push(e);
+  }
+  const list = Object.entries(groups).map(([day, evs]) => `
+    <section class="day-group">
+      <h3>${esc(day)}</h3>
+      <div class="event-list">
+        ${evs.map(e => `
+          <article class="event-card">
+            <span class="kind">${esc(KIND_LABEL[e.kind] || e.kind)}</span>
+            <h4>${e.url ? `<a href="${esc(e.url)}" target="_blank" rel="nofollow noopener">${esc(e.title)}</a>` : esc(e.title)}</h4>
+            <p class="meta">
+              ${e.sponsor_org ? `<strong>${esc(e.sponsor_org)}</strong> · ` : ''}
+              ${e.venue ? esc(e.venue) + ' · ' : ''}${esc(e.city || '')}${e.state ? ', ' + esc(e.state) : ''}${e.zip ? ' ' + esc(e.zip) : ''}
+            </p>
+            ${e.organizer ? `<p class="muted">Organizer: ${esc(e.organizer)}</p>` : ''}
+            ${e.end_at && new Date(e.end_at).getTime() !== new Date(e.start_at).getTime() ? `<p class="muted">Through ${esc(fmt(e.end_at))}</p>` : ''}
+          </article>`).join('')}
+      </div>
+    </section>`).join('');
+
+  const points = events.filter(e => e.latitude && e.longitude).map(e => ({
+    id:e.id, title:e.title, lat:Number(e.latitude), lng:Number(e.longitude), kind:e.kind,
+  }));
+
+  const states = ['','CA','TX','FL','NY','IL','WA','CO','GA','MA','PA'];
+  const kinds = ['', ...Object.keys(KIND_LABEL)];
+
+  return head('Dog shows + events') + nav() + `
+<main class="container">
+  <h1>Dog shows · trials · adoption events ${filters.state ? `in ${esc(filters.state)}` : ''}</h1>
+  <p class="muted">${events.length} upcoming events</p>
+  <form class="filters" method="GET">
+    <select name="state">${states.map(s => `<option value="${s}" ${filters.state===s?'selected':''}>${s||'All states'}</option>`).join('')}</select>
+    <select name="kind">${kinds.map(k => `<option value="${k}" ${filters.kind===k?'selected':''}>${k?KIND_LABEL[k]:'All kinds'}</option>`).join('')}</select>
+    <button type="submit">Filter</button>
+  </form>
+  ${points.length ? `<div id="show-map" class="map" style="height:380px"></div>` : ''}
+  ${list || '<p class="empty">No events scheduled yet — scraper run pending.</p>'}
+  ${adSlot(ad, 'native', '/shows')}
+</main>
+${points.length ? `
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
+<!-- Event titles are scraped from third-party pages (AKC, UKC, etc) and may
+     contain HTML or </script>. Inline JSON in a <script> block is unsafe with
+     bare JSON.stringify. Use type="application/json" + JSON.parse, and build
+     popup text via DOM/textContent so titles can never become HTML. (codex P1) -->
+<script type="application/json" id="show-pts">${JSON.stringify(points).replace(/</g, '\\u003c')}</script>
+<script>
+(function(){
+  const el = document.getElementById('show-map');
+  if (typeof L === 'undefined') { el.innerHTML = '<p class="muted" style="padding:1em">Map library failed to load.</p>'; return; }
+  const pts = JSON.parse(document.getElementById('show-pts').textContent);
+  if (!pts.length) return;
+  const map = L.map('show-map').setView([pts[0].lat, pts[0].lng], 6);
+  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OpenStreetMap contributors',maxZoom:18}).addTo(map);
+  const grp = L.featureGroup(pts.map(p => {
+    const m = L.circleMarker([p.lat,p.lng], { radius:9, color:'#c97a3e', fillColor:'#c97a3e', fillOpacity:0.85, weight:2 });
+    const popup = document.createElement('strong');
+    popup.textContent = p.title;
+    m.bindPopup(popup);
+    return m;
+  })).addTo(map);
+  if (pts.length > 1) map.fitBounds(grp.getBounds().pad(0.15));
+  setTimeout(() => map.invalidateSize(), 100);
+})();
+</script>` : ''}` + footer() + '</body></html>';
+}

← bf214ed fix: codex review findings — creds + URL parse + license reg  ·  back to Animals  ·  yolo: iter 1 trivial patches from claude-codex debate 05cb1bc →