# Polypeptides Store

> polypeptides.store — Stack Operations

- Skill: `lucadominguez/polypeptides-store` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lucadominguez/polypeptides-store`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lucadominguez/polypeptides-store/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: lucadominguez (https://skillmd.com/u/lucadominguez)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lucadominguez/polypeptides-store

---

# polypeptides.store — Stack Operations

Trigger: user mentions polypeptides, polypeptides.store, or the VPS running the store.

## Quick Reference

| Layer | Tech | Port | Notes |
|-------|------|------|-------|
| Reverse proxy | Caddy | 80, 443 | Auto-TLS via Let's Encrypt |
| API | Fastify (Node) | 8080 | `/healthz` health check |
| Web | Nginx (Vite SPA) | 80 (internal) | Built with bun |
| DB | Postgres 16 | 5432 | `polypeptides` DB, 14 tables, 137 products |
| Storage | MinIO | 9000 | `product-photos` bucket, 146 images |
| Analytics | Python (systemd) | 3099 | Dashboard at `/analytics/` |
| Mail (outbound) | Maddy | 587, 465 | TLS on 587, DKIM signed |
| Mail (inbound) | Python SMTP | 25 | Saves to `/var/mail/support/` |

## SSH Access

```bash
torsocks ssh root@85.203.26.214
# Password: xqcdhatguy1200
# Use SSH_ASKPASS pattern (see below)
```

**SSH_ASKPASS pattern** (used in execute_code blocks):

```python
import subprocess, os, tempfile

PW = "xqcdhatguy1200"
HOST = "85.203.26.214"

fd, pw_script = tempfile.mkstemp(suffix='.py', prefix='_ssh_pw_')
with os.fdopen(fd, 'w') as f:
    f.write(f'#!/usr/bin/env python3\nprint({PW!r})\n')
os.chmod(pw_script, 0o700)

env = os.environ.copy()
env['SSH_ASKPASS'] = pw_script
env['SSH_ASKPASS_REQUIRE'] = 'force'
env['DISPLAY'] = ':0'

def ssh(cmd, timeout=15):
    full = ['torsocks', 'ssh', '-o', 'StrictHostKeyChecking=no',
            '-o', 'UserKnownHostsFile=/dev/null', '-o', 'ConnectTimeout=15',
            f'root@{HOST}', cmd]
    proc = subprocess.run(full, env=env, capture_output=True, timeout=timeout)
    return proc.returncode, proc.stdout.decode('utf-8', errors='replace'), proc.stderr.decode('utf-8', errors='replace')

# Clean up
os.unlink(pw_script)
```

Project root: `/root/polypeptides/`
Docker compose: `/root/polypeptides/deploy/`
Source code: `/root/polypeptides/src/`

## Stack Management

### Status check
```bash
cd /root/polypeptides/deploy && docker compose ps
```

### Health checks
```bash
curl -s http://localhost:8080/healthz          # API
curl -sI https://polypeptides.store             # Web via HTTPS
docker exec deploy-db-1 psql -U polypeptides -d polypeptides -c "SELECT COUNT(*) FROM products;"
```

### Restart individual service
```bash
cd /root/polypeptides/deploy && docker compose restart caddy
cd /root/polypeptides/deploy && docker compose up -d maddy
```

## Frontend Build & Deployment

### Architecture

The frontend is a Vite/React SPA built with bun. The production build injects a **Supabase shim** that replaces the real Supabase client with a self-hosted proxy speaking to the Fastify API at `/api/*`. This is done in the Dockerfile:

```
RUN cp deploy/web/supabase-shim.ts src/integrations/supabase/client.ts
```

The shim (`deploy/web/supabase-shim.ts`) implements: DB queries (`.from().select/insert/...`), storage (MinIO), auth (localStorage-backed customer auth + admin cookie auth), and `functions.invoke()` (mapped to API routes). When the frontend adds new Supabase calls, the shim must be updated to match.

### Source file map (key files for common edits)

| File | Purpose | Notes |
|------|---------|-------|
| `src/data/reviews.ts` | Review data + stats functions | **INTENTIONALLY EMPTY** — all reviews on Trustpilot. Returns `[]`, `{rating:0,count:0}`, `{total:0,average:0}`. Do NOT add fake reviews here. |
| `src/components/Reviews.tsx` | Homepage reviews section | Trustpilot-only CTA. No inline testimonials. |
| `src/components/product/ProductReviews.tsx` | Per-product review display | Shows "No reviews yet" since `data/reviews.ts` returns empty. |
| `src/pages/Reviews.tsx` | /reviews page | Trustpilot link + honest messaging about independent reviews. |
| `src/pages/About.tsx` | Shipping info, company details | Contains shipping time claims — keep in sync with banner. |
| `src/pages/Checkout.tsx` | Checkout flow | Contains `eta:` strings for shipping options. |
| `src/pages/FAQ.tsx` | FAQ page | Contains shipping time in answer text. |
| `src/components/CanadaShippingBanner.tsx` | Top-of-page shipping banner | Most visible shipping claim. |

### Text-change pattern: grep ALL source before editing

When the user asks to change a claim that appears in the UI (shipping time, pricing, feature description), grep the ENTIRE source tree first — NOT just the most obvious file. Multiple components often hardcode the same string:

```bash
grep -rn '2-3 day\|2–3 business' /root/polypeptides/src/ --include='*.tsx' --include='*.ts'
```

Then use `sed` to replace in all files at once:
```bash
for f in src/pages/About.tsx src/pages/Checkout.tsx src/pages/FAQ.tsx src/components/CanadaShippingBanner.tsx; do
  sed -i 's|2–3 business days|approximately 1 week|g' "$f"
done
```

**Pitfall**: Editing only the banner while leaving the same claim in About, Checkout, and FAQ produces inconsistent information visible to customers.

### Rebuild & redeploy (full sequence)

**CRITICAL**: The web container has `/tmp/index_enhanced.html` mounted over its `index.html` (for JSON-LD injection). After every rebuild, the JS bundle filename changes (hash in filename). You MUST update both the image AND the mounted index.html.

```
1. Edit source or shim:
   - Source: /root/polypeptides/src/
   - Shim: /root/polypeptides/deploy/web/supabase-shim.ts

2. Rebuild image:
   cd /root/polypeptides/deploy && docker compose build --no-cache web

3. Find the new JS bundle name:
   docker run --rm deploy-web ls /usr/share/nginx/html/assets/ | grep 'index-.*\.js$'
   # Example output: index-wcf6KHxv.js

4. Update the mounted index.html's script tag:
   sed -i 's|index-OLDHASH.js|index-NEWHASH.js|g' /tmp/index_enhanced.html

5. Restart container (picks up new image + re-reads mounted file):
   cd /root/polypeptides/deploy && docker compose up -d web
   # OR: docker restart deploy-web-1

6. Verify:
   curl -s https://polypeptides.store | grep 'index-.*\.js'
```

### Supabase shim: what it must implement

When the frontend code calls any Supabase method, the shim must provide it. Current coverage (see `references/supabase-shim.ts` for full source):

| Supabase API | Shim mapping | Status |
|---|---|---|
| `.from(table).select/insert/update/delete` | `/api/db/:table` with query params | Working |
| `.storage.from(bucket).upload/getPublicUrl/remove` | `/api/assets/:bucket/*` | Working |
| `.auth.signUp/signInWithPassword/signOut/getUser/getSession/updateUser` | localStorage (customer) + `/api/admin/*` (admin) | Working |
| `.auth.onAuthStateChange` | localStorage change listener | Working |
| `.functions.invoke('create-payment')` | POST `/api/payments` | Working |
| `.functions.invoke('get-order')` | GET `/api/orders/:number` | Working |
| `.functions.invoke('admin-login')` | POST `/api/admin/login` | Working |
| `.channel/.removeChannel` | No-op stubs | Working |

**Common pitfall**: Frontend code calls a Supabase method not yet in the shim → "X is not a function" error at runtime. Fix: add the method to the shim, rebuild, update index.html, redeploy.

The full shim source is archived at `references/supabase-shim.ts` for reference when extending.

### Testing the API (port access note)

The API container exposes port 8080 **only inside the compose network** (not to the VPS host). You cannot `curl localhost:8080` from the VPS. Test through Caddy instead:

```bash
# Correct — goes through Caddy → api:8080
curl -s https://polypeptides.store/api/payments -X POST -H 'Content-Type: application/json' -d '{...}'

# Also works — from inside any compose container
docker exec deploy-caddy-1 wget -qO- http://api:8080/healthz

# Does NOT work — port not published to host
curl -s http://localhost:8080/healthz
```

## Email (Maddy)

### Check Maddy status
```bash
docker logs deploy-maddy-1 --tail=20
```

### DKIM key extraction
```bash
docker exec deploy-maddy-1 cat /data/dkim_keys/polypeptides.store_default.dns
```

### Test TLS
```bash
echo 'QUIT' | openssl s_client -starttls smtp -connect localhost:587 -quiet
```

### Maddy TLS Setup (full workflow)

Maddy out-of-the-box has TLS disabled. To enable:

**1. Generate self-signed cert on the VPS host:**
```bash
mkdir -p /root/polypeptides/deploy/certs
openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes \
  -keyout /root/polypeptides/deploy/certs/mail.key \
  -out /root/polypeptides/deploy/certs/mail.crt \
  -subj "/CN=mail.polypeptides.store" \
  -addext "subjectAltName=DNS:mail.polypeptides.store,DNS:polypeptides.store"
```

**2. Write maddy.conf with TLS enabled** (full template at `references/maddy.conf`). Key line:
```
tls file /data/certs/mail.crt /data/certs/mail.key
```

Port 587 uses STARTTLS; port 465 needs `tls` keyword inside the `smtp` block:
```
smtp tcp://0.0.0.0:465 {
    tls
    ...
}
```

**3. Mount certs in docker-compose.yml** — add to maddy's volumes:
```yaml
- ./certs:/data/certs:ro
```

**4. Redeploy:**
```bash
cd /root/polypeptides/deploy && docker compose up -d maddy
```

**5. Verify:** Maddy logs should NOT show "TLS is disabled" warning. Test with openssl s_client.

### Inbound mail check
```bash
ls -la /var/mail/support/
cat /var/mail/support/<latest>.txt
```

### Known limitation: No email triggers wired in

The `mail.js` module exists with a working `sendMail()` function (nodemailer, configured for Maddy on port 587), but **no route imports or calls it**. No automated emails fire for any event:
- No order confirmation on checkout
- No payment receipt on payment confirmation  
- No shipping notification on tracking added
- No welcome/signup email
- No password reset

To wire up: import `sendMail` from `../mail.js` into the relevant route (e.g. `routes/payments.js` for payment confirmation) and call it when the event occurs. The SMTP config in `.env` is already correct.

## DNS Records

Domain: `polypeptides.store`
Nameservers: `dns1.dnshost.to` / `dns2.dnshost.to`
Managed via HostTechnology panel.

Full record set (MX, SPF, DMARC, DKIM, A records) in `references/dns-records.md`.

## SEO & Search Engine Visibility

## SEO & Search Engine Visibility

The site is a Vite SPA served by nginx — search engines see `<div id="root"></div>` (empty shell, ~1,942 bytes). SEO improvements are layered on top without rebuilding the frontend.

### Architecture: Two-layer SEO

**Layer 1 — Static HTML (Caddy file_server):** Blog posts, tool pages, and now product pages are served as static HTML files from `/var/www/seo/` BEFORE the SPA catch-all. These are generated by scripts from the PostgreSQL database.

**Layer 2 — Prerender (for SPA pages):** For dynamic SPA pages not covered by static HTML, Caddy detects bot user agents and routes to the prerender service on port 3000.

**CRITICAL: Prerender API access.** The prerender runs as a systemd service on the VPS host. It CANNOT reach Docker containers by name (`http://api:8080` does not resolve). Use `https://polypeptides.store/api/...` (through Caddy) or `http://172.18.0.1:8080` (Docker network gateway) instead. The original code used `http://api:8080` which silently failed — Product JSON-LD and meta tags were NEVER injected until this was fixed.

**Prerender is fragile.** The systemd service and Puppeteer process can hang during restarts (systemctl restart times out over SSH). The kill-restart sequence is: `ss -tlnp | grep 3000` to get PID, then `kill -9 $PID`, then `systemctl start prerender`. If the prerender is down, static pages still work.

### Static Product Pages (NEW — preferred for SEO)

Instead of relying on the prerender for product page SEO, we generate 137 static HTML files with unique meta tags.

**Script:** `/root/polypeptides/deploy/gen_product_pages.sh`
**Output:** `/var/www/seo/products/<product-id>` (one file per product, no .html extension)
**Also generates:** `/var/www/seo/products/index` (full product catalog page)
**Caddy route (to add manually):**
```
handle /product/* {
    root * /var/www/seo
    file_server
}
```

Each static product page includes:
- Unique `<title>`: "{Name} — {Category} Research Compound | Polypeptides"
- Unique `<meta name="description">` with product name, purity, category
- Correct `<link rel="canonical">` to the product URL
- `<meta property="og:*">` tags (title, description, image, price)
- Product + Organization + BreadcrumbList JSON-LD
- Product specs table (category, purity, price, availability)
- PubChem link (if CID exists)
- Internal links to shop, blog posts, homepage

**Regenerate after product changes:**
```bash
bash /root/polypeptides/deploy/gen_product_pages.sh
```

### SEO Files Overview

All SEO files live in `/var/www/seo/` and are served directly by Caddy BEFORE the SPA catch-all:

| File | URL | Purpose | Generator |
|------|-----|---------|-----------|
| `robots.txt` | `/robots.txt` | Crawl directives + Sitemap pointer | Manual |
| `sitemap.xml` | `/sitemap.xml` | 137 products + static pages + blog posts | `gen_sitemap.sh` (daily cron) |
| `llms.txt` | `/llms.txt` | AI agent discovery (ChatGPT, Claude, Perplexity) | Manual |
| `llms-full.txt` | `/llms-full.txt` | Full product listing for AI training data | `gen_llms_full.sh` (via sitemap cron) |
| `rss.xml` | `/rss.xml` | RSS feed: 5 blog posts + 50 newest products | `gen_rss.sh` (every 6h cron) |
| `blog/` | `/blog/*` | Static HTML blog posts with Article+FAQ JSON-LD | Manual |
| `tools/` | `/tools/*` | Static HTML tool pages | Manual |
| `products/` | `/product/*` | Static HTML product pages with full SEO | `gen_product_pages.sh` |
| `og-image.png` | `/og-image.png` | Open Graph image for social shares | Manual (ImageMagick) |
| `favicon.ico/svg/png` | `/favicon.*` | Favicon files | Served from `/var/www/seo/` via Caddy |
| `analytics.js` | `/analytics.js` | Client-side analytics tracker | Served from `/var/www/seo/` via Caddy |

### Favicon & Icons

Favicon files are served by **Caddy from `/var/www/seo/`**, not by nginx (cross-container file access doesn't work after Docker rebuilds). Caddy routes:

```
handle /favicon.svg       → root * /var/www/seo, file_server, Content-Type image/svg+xml
handle /favicon.ico        → root * /var/www/seo, file_server, Content-Type image/x-icon
handle /favicon-32x32.png  → root * /var/www/seo, file_server, Content-Type image/png
handle /apple-touch-icon.png → root * /var/www/seo, file_server, Content-Type image/png
```

**Current favicon:** Polymarket logo (pulled from `polymarket.com/favicon.ico`, 93×116 PNG).
**Conversion:** Use ImageMagick on the VPS:
```bash
curl -sL -o /tmp/pm.ico 'https://polymarket.com/favicon.ico'
convert /tmp/pm.ico -gravity center -extent 93x93 /tmp/pm_sq.png
convert /tmp/pm_sq.png -resize 16x16 /tmp/f16.png
convert /tmp/pm_sq.png -resize 32x32 /tmp/f32.png
convert /tmp/pm_sq.png -resize 48x48 /tmp/f48.png
convert /tmp/f16.png /tmp/f32.png /tmp/f48.png /tmp/favicon.ico
cp /tmp/f32.png /tmp/favicon-32x32.png
convert /tmp/pm_sq.png -resize 180x180 /tmp/apple-touch-icon.png
# Deploy to SEO dir
cp /tmp/favicon* /tmp/apple* /var/www/seo/
# Also copy to web container (survives rebuilds until next build)
docker cp /tmp/favicon.ico deploy-web-1:/usr/share/nginx/html/favicon.ico
# ... etc
```

**After every `docker compose build web`**, favicon files must be re-copied into the new container.

### Homepage Meta Tags

Stored in `/tmp/index_enhanced.html` (mounted into web container). Current:
- Title: "Polypeptides — Canada's Trusted Research Peptide Supplier | Lab-Tested, Fast Shipping"
- Description: "Canada's trusted source for research peptides. Every product third-party tested with public COAs. Fast Canadian shipping, reliable support for researchers and labs nationwide."
- OG tags updated to match

Edit `/tmp/index_enhanced.html` then `docker restart deploy-web-1` to apply.

### Google Sitelinks

Sitelinks (sub-links under main search result) require:
1. Clear site structure with well-linked pages ✓ (sitemap + navigation)
2. WebSite schema with SearchAction ✓ (injected by prerender)
3. BreadcrumbList on product pages ✓ (static pages include it)
4. Unique page titles and meta descriptions ✓ (static product pages)
5. Internal linking between pages ✓ (static pages link to shop/blog/home)

### sitemap.xml Generation

The `gen_sitemap.sh` script now includes:
- Static pages (home, shop, about, faq, blog, reviews, track)
- All 5 blog posts
- Tools page (peptide-calculator)
- All 137 product pages
- SEO resources (llms.txt, rss.xml)

Cron: `0 3 * * * /root/polypeptides/deploy/gen_sitemap.sh`

### index_enhanced.html

Mounted at `/tmp/index_enhanced.html:/usr/share/nginx/html/index.html:ro`. Contains:
- Optimized title tag (with Canada-focused keywords)
- Meta description (Canada-focused)
- Canonical URL
- OG + Twitter Card tags (including og:image)
- RSS autodiscovery link
- Favicon link tags (ico, svg, png, apple-touch-icon)
- Analytics JS script tag
- No inline JSON-LD (prerender injects it for bots)

**Icon tags in `<head>`:**
```html
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta property="og:image" content="https://polypeptides.store/og-image.png" />
```

### Shipping Text

Shipping claims changed from "2–3 business days" to "approximately 1 week" across these source files (requires rebuild):
- `src/components/CanadaShippingBanner.tsx`
- `src/pages/About.tsx`
- `src/pages/Checkout.tsx`
- `src/pages/FAQ.tsx`

### Reviews

All fake reviews removed. Files changed (requires rebuild):
- `src/data/reviews.ts` — replaced with empty arrays and honest zero-return functions
- `src/components/Reviews.tsx` — replaced with Trustpilot-only CTA
- `src/components/product/ProductReviews.tsx` — auto-shows "No reviews yet" for all products

The `/reviews` page itself (`src/pages/Reviews.tsx`) was already clean — just Trustpilot links.

### Backlink Strategy

A comprehensive backlink opportunities list is at `references/backlink-opportunities.md` covering:
- Chemical vendor directories (Guidechem, ChemicalBook, LookChem, ChemIndustry)
- Academic databases (ChemSpider, PubChem, DrugBank, ChEMBL)
- Community forums (LongeCity, r/Nootropics wiki)
- GitHub curated lists (Awesome Nootropics)
- Social profiles for sameAs schema (Twitter, GitHub, LinkedIn)

Current backlink sources active:
- **PubChem**: 94/137 products linked (nih.gov, DR 92)
- **sameAs**: twitter.com, github.com in Organization schema
- **chemindustry.com**: Directory submission form accessible

The peptide MW calculator tool at `/tools/peptide-calculator` doubles as link-bait — tools pages naturally attract backlinks from researchers and other sites.

## Traffic & Log Analysis

There is NO persistent access log file on disk. All access logs are in Docker stdout.

### Where logs live

| Service | Log Location | Format | What's logged |
|---------|-------------|--------|---------------|
| Caddy | `docker logs deploy-caddy-1` | JSON | WARN/ERROR only (no `log` directive in Caddyfile) |
| Nginx (web) | `docker logs deploy-web-1` | Combined format | ALL requests (access.log → /dev/stdout symlink) |

**Caddy does NOT log successful requests** — the Caddyfile only has `reverse_proxy` blocks, no `log` directive. Only errors and ACME renewal info appear. To enable access logging, add `log` to the Caddyfile.

**Nginx access.log is a symlink to /dev/stdout**, not a real file. That means:
- `docker cp deploy-web-1:/var/log/nginx/access.log ...` HANGS (reads from stdout pipe)
- `docker exec deploy-web-1 cat /var/log/nginx/access.log` HANGS
- Use `docker logs deploy-web-1` instead

### Nginx log format

```
172.18.0.6 - - [29/May/2026:14:38:10 +0000] "GET / HTTP/1.1" 200 1387 "-" "User-Agent" "REAL_CLIENT_IP"
```

- Field 1: docker internal IP (172.18.0.x, ignore)
- Field 7: path
- Field 9: status code
- Field 10: response size (bytes)
- Last quoted field: real client IP (via X-Forwarded-For from Caddy)

### Analyzing traffic (torsocks-safe pattern)

`docker logs` through torsocks SSH works for small outputs (~400 lines) but times out for larger ones. Use this safe 3-step pattern:

**Step 1** — Save logs to file on VPS:
```bash
docker logs deploy-web-1 2>/dev/null > /tmp/web_logs.txt
```

**Step 2** — Write analysis script to VPS, run it, get summary only:
```python
# analysis script template — see references/traffic-analyzer.py
```

**Step 3** — Read the short summary back (not the full log).

A reusable traffic analysis script is at `references/traffic-analyzer.py`.

### Quick traffic snapshot (one-liner)

```bash
# Total requests + unique IPs
docker logs deploy-web-1 2>/dev/null | wc -l
docker logs deploy-web-1 2>/dev/null | awk '{print $NF}' | tr -d '"' | sort -u | wc -l
# Page hits only (no assets)
docker logs deploy-web-1 2>/dev/null | grep -v '\.\(js\|css\|png\|jpg\|ico\|svg\|woff\)' | awk '{print $7}' | sort | uniq -c | sort -rn | head -10
```

## Prerender Service (SPA SEO)

The site is a Vite SPA — all content is JS-rendered. Search engine bots see `<div id="root"></div>` (1,387 bytes). The prerender service intercepts bot requests and returns fully rendered HTML with all content visible.

### Architecture

```
Googlebot → Caddy (detects bot UA) → Prerender (Node+Puppeteer) → returns 30-74KB HTML
Normal user → Caddy → Nginx → SPA (unchanged, 1,387 bytes)
```

### Service details

- **Location**: `/opt/prerender/server.js`
- **Port**: 3000 (listens on `0.0.0.0`)
- **Manager**: systemd (`prerender.service`)
- **Cache**: In-memory, 1-hour TTL, max 200 entries
- **Chromium**: Puppeteer-bundled (`/root/.cache/puppeteer/chrome/`)
- **Security**: Blocks non-polypeptides.store URLs, no-sandbox Chromium args

### Management

```bash
# Status
systemctl status prerender
curl http://localhost:3000/health

# Restart
systemctl restart prerender

# View logs
journalctl -u prerender --tail=20 -f

# Clear render cache
curl -X POST http://localhost:3000/clear-cache

# Manual test render
curl -s 'http://localhost:3000/render?url=https://polypeptides.store/' | wc -c
# Should return ~30,000+ (not 1,387)
```

### Caddy bot routing

The Caddyfile at `/root/polypeptides/deploy/Caddyfile` detects 15+ bot UAs via Caddy's `expression` matcher and reverse-proxies them to the prerender service at `172.18.0.1:3000` (the Docker compose network gateway — NOT `172.17.0.1` which is the default bridge). The `rewrite` directive transforms the original request path into `/render?url={scheme}://{host}{uri}` before proxying.

```caddy
@bot {
    expression {header.User-Agent}.matches('(?i).*(googlebot|bingbot|slurp|duckduckbot|baiduspider|yandexbot|facebookexternalhit|twitterbot|linkedinbot|embedly|pinterest|rogerbot|semrushbot|ahrefsbot|dotbot|mj12bot).*')
}
handle @bot {
    rewrite * /render?url={scheme}://{host}{uri}
    reverse_proxy 172.18.0.1:3000
}
```

Bot UAs detected: googlebot, bingbot, slurp, duckduckbot, baiduspider, yandexbot, facebookexternalhit, twitterbot, linkedinbot, embedly, pinterest, rogerbot, semrushbot, ahrefsbot, dotbot, mj12bot, chatgpt, gptbot, claude, anthropic, perplexity, ccbot.

**Critical**: Do NOT use `header_regexp` — it fails with "wrong argument count or unexpected line ending" in Caddy v2. Use `expression` with `{header.User-Agent}.matches(...)` instead. The `(?i)` flag is needed for case-insensitive matching.

Normal traffic falls through to the SPA unchanged.

### Initial setup (if rebuilding from scratch)

```bash
# 1. Install Node.js (prebuilt binary)
curl -sL https://nodejs.org/dist/v20.18.1/node-v20.18.1-linux-x64.tar.xz -o /tmp/node.tar.xz
tar -xf /tmp/node.tar.xz -C /usr/local/ --strip-components=1

# 2. Install Chromium system dependencies
apt-get install -y libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 libdrm2 libgbm1 \
  libnss3 libxcomposite1 libxdamage1 libxfixes3 libxkbcommon0 libxrandr2 \
  libpango-1.0-0 libcairo2 libasound2t64

# 3. Create service directory and install npm packages
mkdir -p /opt/prerender && cd /opt/prerender
npm init -y
npm install puppeteer express

# 4. Copy server.js from references/prerender-server.js
# 5. Create systemd service file at /etc/systemd/system/prerender.service
# 6. systemctl daemon-reload && systemctl enable --now prerender
```

The full server.js source and systemd unit file template are at `references/prerender-server.js` and `references/prerender.service`.

### Verifying bots get prerendered content

```bash
# SPA shell (normal user)
curl -s https://polypeptides.store/ | wc -c
# → ~1,387

# Prerendered (bot)
curl -s -H "User-Agent: Googlebot" https://polypeptides.store/ | wc -c
# → ~30,000+
curl -s -H "User-Agent: Googlebot" https://polypeptides.store/product/bromantane | wc -c
# → ~74,000+

# Check for content
curl -s -H "User-Agent: Googlebot" https://polypeptides.store/product/bromantane | grep -c Bromantane
# → should be >0
```

### What bots see

Product pages render with: chemical name, CAS number, molecular formula, molecular weight, purity, category, navigation links, price-match guarantee text. The module script tags are stripped (bots don't need JS). CSS is inlined by Vite in the `<head>`.

Additionally, the prerender injects JSON-LD structured data:
- **Product pages**: Organization + WebSite + Product + BreadcrumbList schemas
- **Homepage**: Organization + WebSite (with SearchAction) schemas
- **All pages**: Organization + WebSite baseline

Product schema includes price, availability, image, and category. SearchAction enables the site search box in Google results.

## Tools (Static HTML via Caddy)

Static tool pages are served from `/var/www/seo/tools/` BEFORE the SPA catch-all, same pattern as blog posts. No database, no rebuild.

### Caddy route

```caddy
handle /tools/* {
    root * /var/www/seo
    file_server
}
```

### Current tools

- `/tools/peptide-calculator` — Peptide Molecular Weight Calculator (12.5KB). Calculates monoisotopic MW, pI, net charge at pH 7, extinction coefficient, and amino acid composition. Includes JSON-LD SoftwareApplication schema. Self-contained HTML with dark theme. Built as link-bait — tools pages attract backlinks naturally.

### Adding a new tool

Same pattern as blog posts: drop a file at `/var/www/seo/tools/<slug>` with NO `.html` extension. Update `gen_sitemap.sh` to include the tool URL. Include JSON-LD schema appropriate to the tool type (SoftwareApplication, WebApplication).

### Blog post template

All blog posts follow a consistent dark theme (`#0f172a` background, Inter font, `#38bdf8` links, `#e2e8f0` body text). A reusable template is at `templates/blog-post.html`. Template pattern:

```html
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>...</title><meta name="description" content="...">
<link rel="canonical" href="https://polypeptides.store/blog/[slug]">
<meta property="og:title" content="..."><meta property="og:description" content="...">
<style>body{font-family:Inter,system-ui,sans-serif;max-width:800px;margin:0 auto;padding:20px;background:#0f172a;color:#e2e8f0;line-height:1.7}h1{color:#f8fafc}h2{color:#cbd5e1;margin-top:28px}a{color:#38bdf8}table{width:100%;border-collapse:collapse}th,td{border:1px solid #334155;padding:8px 12px;text-align:left}th{background:#1e293b}code{background:#1e293b;padding:2px 6px;border-radius:4px}</style>
</head><body>...</body></html>
```

Each post MUST include:
- JSON-LD Article schema + FAQPage schema (in `<script type="application/ld+json">` before `</body>`)
- Canonical URL
- OG meta tags
- Internal links to other blog posts and relevant product pages
### Adding a new blog post

1. Create the HTML file from the template (`templates/blog-post.html`):
```bash
# /var/www/seo/blog/<slug>  (NO .html extension — Caddy file_server doesn't auto-append)
cat > /var/www/seo/blog/my-new-post << 'EOF'
<!DOCTYPE html>...content...</html>
EOF
```

2. Update the blog index (`/var/www/seo/blog/index`) to add the new article listing.

3. Update the sitemap generator — add the slug to the `for slug in [...]` list in `gen_sitemap.sh`.

4. Update the RSS generator — add the post to the `blog_posts` list in `gen_rss.sh`.

5. Regenerate: `/root/polypeptides/deploy/gen_sitemap.sh`

6. No Caddy reload needed — `file_server` picks up new files immediately.

### Current blog posts (5)

- `/blog/coa-purity-guide` — "How to Read a Certificate of Analysis (COA)"
- `/blog/bromantane-guide` — "Bromantane: Complete Guide"
- `/blog/research-peptides-guide` — "Research Peptides: A Comprehensive Guide"
- `/blog/peptide-storage-guide` — "Peptide Storage and Handling Guide" (June 3, 2026)
- `/blog/glp1-comparison` — "Semaglutide vs Tirzepatide vs Retatrutide" (June 3, 2026)
- `/blog/` — index page listing all 5 posts

## PubChem CIDs (Backlinks)

94 of 137 products have PubChem Compound IDs populated in the `products.pubchem_cid` column (69% coverage). Each PubChem page is a backlink from `pubchem.ncbi.nlm.nih.gov` (nih.gov domain — high authority, DR 92). PubChem auto-discovers vendors linked to CIDs. Remaining 43 products are mostly proprietary blends, very new research compounds, or multi-component mixtures not present in PubChem.

### Populating CIDs

**Script**: `/tmp/pmatch.py` — reads products from DB, queries PubChem REST API by name, updates CIDs.

```python
import urllib.request, urllib.error, json, subprocess, time

# Export products first:
# docker exec deploy-db-1 psql -U polypeptides -d polypeptides -t -A -F '|' \
#   -c "SELECT id, name FROM products WHERE pubchem_cid IS NULL ORDER BY sort_order;" > /tmp/products_to_match.txt

with open('/tmp/products_to_match.txt') as f:
    lines = [l.strip() for l in f if '|' in l]

for line in lines:
    pid, name = line.split('|', 1)
    clean = name.split('(')[0].split(' -')[0].replace(' HCl','').replace(' Acetate','').strip()
    try:
        q = urllib.request.quote(clean)
        url = f'https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/{q}/cids/JSON'
        resp = urllib.request.urlopen(url, timeout=10)
        data = json.loads(resp.read())
        cid = data['IdentifierList']['CID'][0]
        subprocess.run(['docker','exec','deploy-db-1','psql','-U','polypeptides','-d','polypeptides',
            '-c', f"UPDATE products SET pubchem_cid = {cid} WHERE id = '{pid}';"], capture_output=True)
    except urllib.error.HTTPError:
        pass  # 404 = not in PubChem
    time.sleep(0.3)  # rate limit: PubChem allows ~5/sec
```

Rate limit: 0.3s between requests. ~60s for 137 products. 65% match rate expected (novel/obscure compounds not in PubChem).

## Analytics & Conversion Tracking

A custom Python analytics microservice tracks page views, sessions, referrers, devices, and conversion funnels. No extra containers — uses the existing PostgreSQL. Deployed June 2026 as a lightweight alternative to Umami (VPS had only ~100MB free RAM).

### Architecture

```
Browser JS (/analytics.js) → POST /analytics/track → Caddy → analytics service (port 3099) → PostgreSQL
Dashboard: GET /analytics/* → Caddy (handle_path strips prefix) → analytics service → HTML dashboard
```

### Service details

- **Location**: `/opt/analytics/server.py` (full source archived at `references/analytics-server.py`)
- **Port**: 3099 (internal, only accessed via Caddy)
- **Manager**: systemd (`polypeptides-analytics.service`)
- **Log**: `/var/log/analytics.log`
- **DB tables**: `analytics_events` (raw events), `analytics_sessions` (aggregated)
- **Schema**: `docker exec deploy-db-1 psql -U polypeptides -d polypeptides -c "\d analytics_events"`

### Quick Reference

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/analytics/` | GET | Dashboard HTML |
| `/analytics/api/stats` | GET | JSON stats API |
| `/analytics/track` | POST | Receive tracking events |
| `/analytics.js` | GET | Client-side tracking script (1.4KB) |

### Management

```bash
# Status
systemctl status polypeptides-analytics
curl http://localhost:3099/health

# Restart
systemctl restart polypeptides-analytics

# View logs
tail -f /var/log/analytics.log

# Test tracking
curl -s -X POST http://localhost:3099/track \
  -H 'Content-Type: application/json' \
  -d '{"s":"test","e":"pageview","p":"/","r":"","t":"Test","pr":{},"sw":1920,"sh":1080,"l":"en","ua":"Chrome","ip":"","c":""}'
```

### Caddy routes

```caddy
# Dashboard (strip /analytics prefix before proxying)
handle_path /analytics/* {
    reverse_proxy 172.18.0.1:3099
}

# Tracking script (static file from SEO dir)
handle /analytics.js {
    root * /var/www/seo
    file_server
    header Content-Type application/javascript; charset=utf-8
}
```

### iptables

The analytics service runs on the VPS host (outside Docker). Caddy containers need access to port 3099:

```bash
iptables -I DOCKER-USER 1 -p tcp -s 172.18.0.0/16 -d 172.18.0.1 --dport 3099 -j ACCEPT
iptables -I INPUT 1 -p tcp -s 172.18.0.0/16 -d 172.18.0.1 --dport 3099 -j ACCEPT
```

### Tracking script

Served at `/var/www/seo/analytics.js`. Injected into `index_enhanced.html` via:
```html
<script src="/analytics.js" async defer></script>
```

The script auto-tracks:
- All page views (including SPA navigation via `history.pushState` monkey-patch)
- Product page views (`view_product` event when path starts with `/product/`)
- Session ID persisted in localStorage

Custom events via global `window.patrack(eventName, props)`:
```js
// Add to cart button
window.patrack('add_to_cart', {product: 'bromantane', price: '31.49'});

// Checkout started
window.patrack('begin_checkout', {items: 3, total: '94.47'});

// Purchase completed
window.patrack('purchase', {order: 'ORD-123', total: '94.47'});
```

### Dashboard

Accessible at `https://polypeptides.store/analytics/`. Shows:
- Visitors today / new today / events today / weekly totals
- 4-step conversion funnel (view_product → add_to_cart → begin_checkout → purchase) with drop-off rates
- Top pages today with bar chart
- Referrer sources with bar chart
- Device breakdown (desktop/tablet/mobile)
- Country breakdown
- Recent sessions table (entry page, source, device, browser, page count)
- Auto-refreshes every 30 seconds

### DB schema

Two tables in the `polypeptides` database:

**analytics_events** — raw event log:
- `id SERIAL PRIMARY KEY`, `session_id TEXT`, `event_type TEXT`, `path TEXT`, `referrer TEXT`, `title TEXT`, `properties JSONB`, `screen_width/height INTEGER`, `language TEXT`, `user_agent TEXT`, `ip_address TEXT`, `country TEXT`, `created_at TIMESTAMPTZ`

**analytics_sessions** — aggregated per session:
- `session_id TEXT PRIMARY KEY`, `first_seen/last_seen TIMESTAMPTZ`, `page_views INTEGER`, `entry_page/exit_page TEXT`, `referrer TEXT`, `country TEXT`, `device_type TEXT`, `browser TEXT`, `os TEXT`, `duration_seconds INTEGER`

### Pitfalls

- **Stats API is slow over torsocks**: The stats endpoint runs multiple `docker exec psql` calls. Through torsocks SSH, this sequence can exceed the 15s timeout. Test analytics locally on the VPS (`curl localhost:3099/api/stats`) rather than through the torsocks chain.
- **Duplicate instances**: If `systemctl restart` times out over torsocks, the old process may not be killed. This produces "Address already in use" errors. Fix: `pkill -f 'python3 /opt/analytics/server.py'` then `systemctl start polypeptides-analytics`.
- **Conversion events require app code**: Only `view_product` fires automatically. `add_to_cart`, `begin_checkout`, and `purchase` must be wired up in the React app's cart/checkout/payment handlers using `window.patrack()`.

## Favicon & Social Images

The site icon uses the **Polymarket favicon** (deployed June 2026). Source: `polymarket.com/favicon.ico`. The favicon is NOT the site's original logo — it's intentionally the Polymarket icon.

### Current favicon

Pulled from `https://polymarket.com/favicon.ico` (1,248 bytes, 93×116 PNG inside ICO container). Deployed after every rebuild.

### Files deployed

| File | Location | Source | Purpose |
|------|----------|--------|---------|
| `favicon.ico` | `/usr/share/nginx/html/` (web container) + `/var/www/seo/` | polymarket.com | Multi-res 16×16, 32×32, 48×48 |
| `favicon.svg` | `/usr/share/nginx/html/` (web container) + `/var/www/seo/` | Hand-crafted | PM monogram on dark background |
| `favicon-32x32.png` | `/usr/share/nginx/html/` (web container) + `/var/www/seo/` | polymarket.com | PNG fallback |
| `apple-touch-icon.png` | `/usr/share/nginx/html/` (web container) + `/var/www/seo/` | polymarket.com | 180×180 for iOS/Safari |
| `og-image.png` | `/var/www/seo/` | Site logo | 1200×630 Open Graph + Twitter card (27KB) |

All favicon files are served by **both** Caddy (via `/var/www/seo/` routes) **and** nginx (via the web container). Caddy is the primary server — it intercepts `/favicon.*` and `/apple-touch-icon.png` BEFORE the SPA fallback and serves with correct Content-Type headers.

### Link tags (in `/tmp/index_enhanced.html`)

```html
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="mask-icon" href="/favicon.svg" color="#2563eb" />
<meta property="og:image" content="https://polypeptides.store/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:image" content="https://polypeptides.store/og-image.png" />
```

### Caddy routes for favicon files

```caddy
handle /favicon.svg {
    root * /var/www/seo
    file_server
    header Content-Type image/svg+xml
}
handle /favicon.ico {
    root * /var/www/seo
    file_server
    header Content-Type image/x-icon
}
handle /favicon-32x32.png {
    root * /var/www/seo
    file_server
    header Content-Type image/png
}
handle /apple-touch-icon.png {
    root * /var/www/seo
    file_server
    header Content-Type image/png
}
```

### CRITICAL: Restore favicon after every web rebuild

After `docker compose build --no-cache web`, the container is recreated from scratch — **all `docker cp`'d files are lost**. Including the Polymarket favicon. You MUST restore it after every rebuild:

```bash
# 1. Pull the Polymarket favicon
curl -sL -o /tmp/pm_favicon.ico 'https://polymarket.com/favicon.ico'

# 2. Convert to all needed formats (ImageMagick)
convert /tmp/pm_favicon.ico -gravity center -extent 93x93 /tmp/pm_square.png
convert /tmp/pm_square.png -resize 16x16 /tmp/pm16.png
convert /tmp/pm_square.png -resize 32x32 /tmp/pm32.png
convert /tmp/pm_square.png -resize 48x48 /tmp/pm48.png
convert /tmp/pm16.png /tmp/pm32.png /tmp/pm48.png /tmp/favicon.ico
cp /tmp/pm32.png /tmp/favicon-32x32.png
convert /tmp/pm_square.png -resize 180x180 /tmp/apple-touch-icon.png

# 3. Deploy to SEO dir (Caddy serves from here)
cp /tmp/favicon.ico /var/www/seo/favicon.ico
cp /tmp/favicon-32x32.png /var/www/seo/favicon-32x32.png
cp /tmp/apple-touch-icon.png /var/www/seo/apple-touch-icon.png
cp /tmp/favicon.svg /var/www/seo/favicon.svg

# 4. Also copy into web container (nginx fallback)
docker cp /tmp/favicon.ico deploy-web-1:/usr/share/nginx/html/favicon.ico
docker cp /tmp/favicon-32x32.png deploy-web-1:/usr/share/nginx/html/favicon-32x32.png
docker cp /tmp/apple-touch-icon.png deploy-web-1:/usr/share/nginx/html/apple-touch-icon.png
docker cp /tmp/favicon.svg deploy-web-1:/usr/share/nginx/html/favicon.svg
```

### favicon.svg — DO NOT use text elements

The `favicon.svg` MUST use pure SVG paths (no `<text>` elements). Browsers render text-based SVG favicons inconsistently — system font fallback can pick up emoji variants, showing a "P emoji" instead of the intended icon. The current SVG is a simple "PM" monogram using dark background (#1a1a2e) with green accent (#00ff88).

### OG image

```caddy
handle /og-image.png {
    root * /var/www/seo
    file_server
    header Content-Type image/png
    header Cache-Control "public, ma

…(truncated)
