Index: deploy/DEPLOY.md
===================================================================
--- deploy/DEPLOY.md	(revision 942028f9b430046c81feaea51547fb055a973d29)
+++ deploy/DEPLOY.md	(revision 942028f9b430046c81feaea51547fb055a973d29)
@@ -0,0 +1,224 @@
+# PrutCMS v10 — Deployment Guide
+
+End-to-end production install on a fresh Ubuntu 22.04 / Debian 12 VPS
+(TransIP, Hetzner, etc.). Assumes a non-root user with sudo.
+
+---
+
+## 0. Prerequisites
+
+- VPS with Ubuntu 22.04 LTS or Debian 12.
+- DNS A/AAAA records for your domain pointing at the server's IP. Wait for
+  propagation (`dig +short YOUR-DOMAIN` should return the right IP) before
+  running certbot.
+- SSH access as a non-root user (e.g. `robin`).
+
+---
+
+## 1. System packages
+
+```bash
+sudo apt update && sudo apt upgrade -y
+sudo apt install -y curl ca-certificates git nginx ufw sqlite3
+```
+
+Install Node.js 20 LTS via NodeSource (don't use the OS-default — it's old):
+
+```bash
+curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
+sudo apt install -y nodejs
+node --version    # should print v20.x.x
+```
+
+Install PM2 globally:
+
+```bash
+sudo npm install -g pm2
+```
+
+---
+
+## 2. Firewall
+
+```bash
+sudo ufw allow OpenSSH
+sudo ufw allow 'Nginx Full'   # opens 80 + 443
+sudo ufw enable
+```
+
+---
+
+## 3. App user + project clone
+
+```bash
+# As root or via sudo, create a deploy user if you don't already have one
+# (skip if you're already on a non-root user)
+
+# As your user:
+mkdir -p ~/prutcms
+cd ~/prutcms
+# Clone or rsync your code here. e.g. via git:
+# git clone <your-repo> .
+
+npm ci --omit=dev
+```
+
+`ensureLocalHtmx()` in `server.js` will copy the bundled HTMX from
+`node_modules/htmx.org/dist/htmx.min.js` to `src/assets/js/htmx.min.js` on
+first boot. You don't have to do that step manually.
+
+---
+
+## 4. Environment variables
+
+Create `.env` in the project root:
+
+```ini
+NODE_ENV=production
+PORT=3000
+
+# 32+ random hex chars — required, the app refuses to boot without it.
+# Generate: openssl rand -hex 32
+SESSION_SECRET=<paste-strong-random-string>
+
+# Optional: pin the audio HMAC secret instead of letting the app generate one
+# in storage/.audio-secret. Generate the same way as SESSION_SECRET.
+# AUDIO_SECRET=<paste-different-random-string>
+
+# Optional: override storage paths
+# DATABASE_PATH=/home/robin/prutcms/storage/database.sqlite
+# AUDIO_PATH=/home/robin/prutcms/storage/audio
+# AVATAR_PATH=/home/robin/prutcms/storage/media/avatars
+# COVER_PATH=/home/robin/prutcms/storage/media/audio-covers
+# MEDIA_PATH=/home/robin/prutcms/storage/media
+```
+
+`chmod 600 .env` — keep it readable only by your user.
+
+---
+
+## 5. First boot
+
+```bash
+npm run migrate    # creates storage/database.sqlite + tables
+```
+
+Then start with PM2:
+
+```bash
+pm2 start ecosystem.config.cjs --env production
+pm2 save
+pm2 startup        # follow the printed command to make PM2 survive reboots
+```
+
+Check it's up:
+
+```bash
+curl -I http://127.0.0.1:3000/   # expect 200 / 302
+pm2 logs prutcms                 # live logs; Ctrl-C to detach
+```
+
+Register your first user (becomes god) by visiting
+`http://YOUR-SERVER-IP:3000/auth/register` BEFORE you point nginx at it
+(or just wait until SSL is up — registration works the same).
+
+---
+
+## 6. nginx + SSL
+
+```bash
+sudo cp deploy/nginx.conf.example /etc/nginx/sites-available/prutcms
+sudo $EDITOR /etc/nginx/sites-available/prutcms     # replace <YOUR-DOMAIN>
+sudo ln -s /etc/nginx/sites-available/prutcms /etc/nginx/sites-enabled/
+sudo rm -f /etc/nginx/sites-enabled/default
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+Install certbot and provision certs:
+
+```bash
+sudo apt install -y certbot python3-certbot-nginx
+sudo mkdir -p /var/www/letsencrypt
+sudo certbot --nginx -d YOUR-DOMAIN -d www.YOUR-DOMAIN
+```
+
+Certbot edits the nginx config in place to wire in the certificate paths.
+Renewals run automatically via the certbot systemd timer; verify with:
+
+```bash
+sudo systemctl status certbot.timer
+```
+
+Now visit `https://YOUR-DOMAIN/`. You should see your site over HTTPS, with
+HSTS active and Prutter WebSocket working (browser dev-tools → Network →
+filter "WS" → see the `wss://YOUR-DOMAIN/ws/prutter` connection).
+
+---
+
+## 7. Backups
+
+```bash
+chmod +x deploy/backup.sh
+mkdir -p ~/backups/prutcms ~/prutcms/logs
+
+# Test it once
+./deploy/backup.sh
+
+# Schedule nightly at 03:00
+( crontab -l 2>/dev/null ; \
+  echo "0 3 * * * /home/$USER/prutcms/deploy/backup.sh >> /home/$USER/prutcms/logs/backup.log 2>&1" \
+) | crontab -
+
+crontab -l   # verify
+```
+
+Restore is a tar -xzf into a clean directory + `npm ci` + start.
+
+---
+
+## 8. Updating
+
+```bash
+cd ~/prutcms
+git pull
+npm ci --omit=dev
+pm2 reload prutcms     # zero-downtime within fork mode
+```
+
+The DB schema migrates automatically on boot (`ensureColumn` adds new columns
+idempotently). For destructive changes you'd need to write an explicit
+migration — not yet needed.
+
+---
+
+## 9. Multi-tenant setup
+
+After your first user/site is created (auto on first registration), use
+`/admin/sites` to create more sites. Each site gets its own URL prefix
+(`/sites/<slug>/`) and its own PWA scope, so installing the PWA from one
+site won't navigate into another.
+
+---
+
+## 10. Troubleshooting
+
+| Symptom | Check |
+|---|---|
+| `❌ FATAL: SESSION_SECRET is required` | `.env` missing or unreadable. `pm2 stop prutcms && pm2 start ecosystem.config.cjs --env production`. |
+| `❌ FATAL: SESSION_SECRET too weak for production` | Make it 32+ chars: `openssl rand -hex 32`. |
+| WS disconnects every minute | Check nginx `proxy_read_timeout` is ≥ 90s in `/ws/` block. |
+| Audio plays but seek stutters | nginx must have `proxy_buffering off` on `/audio/stream/`. |
+| 502 from nginx | `pm2 list` — is the app up? `pm2 logs prutcms --lines 100`. |
+| HTMX 404s on `/assets/js/htmx.min.js` | `ls node_modules/htmx.org/dist/htmx.min.js` — if missing, `npm install htmx.org`. The boot-copy step needs the package. |
+
+---
+
+## 11. What's NOT included
+
+- Email sending (password reset prints the URL to the console / page in
+  non-production). Hook up an SMTP or transactional service when needed.
+- Cluster mode / horizontal scaling. Single fork only — see comments in
+  `ecosystem.config.cjs` for what would have to change first.
+- Off-site backup replication. The local rotation keeps 14 days; copy the
+  tar.gz files off-server with rsync/restic/whatever you prefer.
Index: deploy/backup.sh
===================================================================
--- deploy/backup.sh	(revision 942028f9b430046c81feaea51547fb055a973d29)
+++ deploy/backup.sh	(revision 942028f9b430046c81feaea51547fb055a973d29)
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+#
+# PrutCMS v10 — nightly backup script.
+#
+# Snapshots:
+#   • SQLite database (uses .backup so it's safe while the app is running)
+#   • storage/audio/        (uploaded MP3s — files outside /media for security)
+#   • storage/media/        (avatars, audio covers — public files)
+#   • storage/.audio-secret (HMAC secret — needed to verify old signed URLs)
+#
+# Output: a single tar.gz per run in $BACKUP_DIR, named by timestamp.
+# Rotation: keeps the last $KEEP_DAYS dumps, prunes older.
+#
+# Recommended cron:
+#   0 3 * * * /home/robin/prutcms/deploy/backup.sh >> /home/robin/prutcms/logs/backup.log 2>&1
+
+set -euo pipefail
+
+# ── Config ────────────────────────────────────────────────────────
+APP_DIR="${APP_DIR:-/home/robin/prutcms}"
+BACKUP_DIR="${BACKUP_DIR:-/home/robin/backups/prutcms}"
+KEEP_DAYS="${KEEP_DAYS:-14}"
+TS="$(date +%Y%m%d-%H%M%S)"
+
+mkdir -p "$BACKUP_DIR"
+
+# ── DB snapshot via sqlite3 .backup (consistent under WAL) ────────
+DB_FILE="$APP_DIR/storage/database.sqlite"
+DB_SNAPSHOT="$BACKUP_DIR/db-$TS.sqlite"
+if [ -f "$DB_FILE" ]; then
+    sqlite3 "$DB_FILE" ".backup '$DB_SNAPSHOT'"
+else
+    echo "WARN: $DB_FILE not found, skipping DB backup" >&2
+fi
+
+# ── Tar the snapshot + storage subdirs ────────────────────────────
+ARCHIVE="$BACKUP_DIR/prutcms-$TS.tar.gz"
+tar -czf "$ARCHIVE" \
+    -C "$BACKUP_DIR" "$(basename "$DB_SNAPSHOT")" \
+    -C "$APP_DIR/storage" \
+        $( [ -d "$APP_DIR/storage/audio"        ] && echo audio        ) \
+        $( [ -d "$APP_DIR/storage/media"        ] && echo media        ) \
+        $( [ -f "$APP_DIR/storage/.audio-secret" ] && echo .audio-secret )
+
+# Remove the loose db-XXX.sqlite (it's inside the tarball now)
+rm -f "$DB_SNAPSHOT"
+
+# ── Rotation ───────────────────────────────────────────────────────
+find "$BACKUP_DIR" -type f -name 'prutcms-*.tar.gz' -mtime "+$KEEP_DAYS" -delete
+
+SIZE="$(du -h "$ARCHIVE" | cut -f1)"
+echo "[$TS] backup OK: $ARCHIVE ($SIZE)"
Index: deploy/nginx.conf.example
===================================================================
--- deploy/nginx.conf.example	(revision 942028f9b430046c81feaea51547fb055a973d29)
+++ deploy/nginx.conf.example	(revision 942028f9b430046c81feaea51547fb055a973d29)
@@ -0,0 +1,126 @@
+# ──────────────────────────────────────────────────────────────────
+# PrutCMS v10 — nginx reverse proxy
+#
+# Place this in /etc/nginx/sites-available/prutcms and symlink to
+# /etc/nginx/sites-enabled/. Replace <YOUR-DOMAIN> with the real host.
+#
+# Key points:
+#   • SSL termination here; Node listens on 127.0.0.1:3000 only.
+#   • WebSocket upgrade headers wired through (Prutter live messaging).
+#   • Static assets cached aggressively (Node gives them 1y maxAge anyway).
+#   • Audio streaming endpoint /audio/stream/* must NOT be cached by nginx —
+#     each request carries a different signed token and is byte-range based.
+#   • HSTS + security headers added at the proxy.
+#
+# After installing:
+#   sudo ln -s /etc/nginx/sites-available/prutcms /etc/nginx/sites-enabled/
+#   sudo nginx -t
+#   sudo systemctl reload nginx
+#
+# Then provision SSL:
+#   sudo certbot --nginx -d <YOUR-DOMAIN> -d www.<YOUR-DOMAIN>
+# certbot will edit this file in place to insert the cert paths.
+# ──────────────────────────────────────────────────────────────────
+
+# WebSocket connection upgrade map (let nginx set it once globally)
+map $http_upgrade $connection_upgrade {
+    default upgrade;
+    ''      close;
+}
+
+# Redirect HTTP → HTTPS
+server {
+    listen 80;
+    listen [::]:80;
+    server_name <YOUR-DOMAIN> www.<YOUR-DOMAIN>;
+
+    # Let's Encrypt http-01 challenge path
+    location /.well-known/acme-challenge/ {
+        root /var/www/letsencrypt;
+    }
+
+    location / {
+        return 301 https://$host$request_uri;
+    }
+}
+
+server {
+    listen 443 ssl http2;
+    listen [::]:443 ssl http2;
+    server_name <YOUR-DOMAIN> www.<YOUR-DOMAIN>;
+
+    # certbot will fill these in:
+    # ssl_certificate     /etc/letsencrypt/live/<YOUR-DOMAIN>/fullchain.pem;
+    # ssl_certificate_key /etc/letsencrypt/live/<YOUR-DOMAIN>/privkey.pem;
+    ssl_protocols       TLSv1.2 TLSv1.3;
+    ssl_ciphers         ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
+    ssl_prefer_server_ciphers off;
+    ssl_session_cache   shared:SSL:10m;
+    ssl_session_timeout 1d;
+    ssl_stapling on;
+    ssl_stapling_verify on;
+
+    # Security headers (Helmet adds them at the app too — these are belt+braces)
+    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+    add_header X-Frame-Options          "SAMEORIGIN" always;
+    add_header X-Content-Type-Options   "nosniff" always;
+    add_header Referrer-Policy          "no-referrer-when-downgrade" always;
+
+    # Reasonable body size — accommodates 50 MB MP3 uploads with headroom
+    client_max_body_size 60M;
+
+    # Compression
+    gzip on;
+    gzip_types text/plain text/css application/json application/javascript application/xml application/atom+xml application/rss+xml image/svg+xml;
+    gzip_min_length 256;
+
+    # ── Static assets: serve via Node, but tell upstream they're long-cached.
+    location /assets/ {
+        proxy_pass http://127.0.0.1:3000;
+        proxy_set_header Host $host;
+        proxy_cache_valid 200 1y;
+        add_header Cache-Control "public, max-age=31536000, immutable";
+    }
+
+    # ── Audio streaming: pass through, no caching, no buffering (HTML5 <audio>
+    #    needs to seek using byte-range; nginx must not slurp the whole file).
+    location /audio/stream/ {
+        proxy_pass http://127.0.0.1:3000;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_buffering off;
+        proxy_request_buffering off;
+        proxy_read_timeout 600s;
+        add_header Cache-Control "private, no-store" always;
+    }
+
+    # ── WebSocket: Prutter live chat. Long-lived connection.
+    location /ws/ {
+        proxy_pass http://127.0.0.1:3000;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection $connection_upgrade;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_read_timeout 3600s;     # let pings keep it alive an hour
+        proxy_send_timeout 3600s;
+    }
+
+    # ── Everything else: standard reverse proxy
+    location / {
+        proxy_pass http://127.0.0.1:3000;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Real-IP $remote_addr;
+
+        # Upgrade headers in case any other route ever uses WS
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection $connection_upgrade;
+
+        proxy_read_timeout 90s;
+    }
+}
Index: deploy/verify.ps1
===================================================================
--- deploy/verify.ps1	(revision 942028f9b430046c81feaea51547fb055a973d29)
+++ deploy/verify.ps1	(revision 942028f9b430046c81feaea51547fb055a973d29)
@@ -0,0 +1,135 @@
+# PrutCMS v10 — quick smoke test against a running dev server.
+# Usage:
+#   1. In one terminal: npm run dev
+#   2. In another:      .\deploy\verify.ps1
+#
+# Hits public + auth-gated endpoints, checks status codes + key strings in
+# response bodies. Doesn't try to upload — just verifies routing, rendering,
+# CSP, and signed-URL surface.
+
+param(
+    [string]$BaseUrl = 'http://localhost:3000'
+)
+
+$ErrorActionPreference = 'Stop'
+$script:pass = 0
+$script:fail = 0
+
+function Check {
+    param([string]$Name, [bool]$Ok, [string]$Detail = '')
+    $msg = if ($Detail) { "$Name -- $Detail" } else { $Name }
+    if ($Ok) {
+        Write-Host "[OK]   $msg" -ForegroundColor Green
+        $script:pass++
+    } else {
+        Write-Host "[FAIL] $msg" -ForegroundColor Red
+        $script:fail++
+    }
+}
+
+function Get-Url {
+    param([string]$Path)
+    try {
+        return Invoke-WebRequest -Uri ($BaseUrl + $Path) -UseBasicParsing -SkipHttpErrorCheck -MaximumRedirection 0 -ErrorAction SilentlyContinue
+    } catch {
+        Write-Host "Request failed: $Path -- $($_.Exception.Message)" -ForegroundColor Yellow
+        return $null
+    }
+}
+
+Write-Host "`n=== PrutCMS verify @ $BaseUrl ===`n" -ForegroundColor Cyan
+
+# ── Server up at all? ─────────────────────────────────────────────
+$home = Get-Url '/'
+$serverUp = ($home -ne $null) -and ($home.StatusCode -eq 200 -or $home.StatusCode -eq 302)
+Check 'Server responds on /' $serverUp
+
+if (-not $serverUp) {
+    Write-Host "`nServer not reachable. Is 'npm run dev' running?" -ForegroundColor Red
+    exit 1
+}
+
+# ── Manifest & feeds ──────────────────────────────────────────────
+$mf = Get-Url '/manifest.webmanifest'
+Check 'Manifest responds 200' ($mf -ne $null -and $mf.StatusCode -eq 200)
+$mfJson = $null
+if ($mf -and $mf.Content) {
+    try { $mfJson = $mf.Content | ConvertFrom-Json } catch {}
+}
+Check 'Manifest has scope field'   ($mfJson -ne $null -and $mfJson.scope -ne $null)
+Check 'Manifest id starts prutcms-' ($mfJson -ne $null -and $mfJson.id -like 'prutcms-*')
+Check 'Manifest scope ends with /' ($mfJson -ne $null -and $mfJson.scope.EndsWith('/'))
+
+$feed = Get-Url '/feed.xml'
+Check 'RSS /feed.xml: 200 + xml'   ($feed -ne $null -and $feed.StatusCode -eq 200 -and $feed.Content -like '*<rss*')
+$atom = Get-Url '/atom.xml'
+Check 'Atom /atom.xml: 200 + xml'  ($atom -ne $null -and $atom.StatusCode -eq 200 -and $atom.Content -like '*<feed*')
+$sm = Get-Url '/sitemap.xml'
+Check 'Sitemap (200 or 404)' ($sm -ne $null -and ($sm.StatusCode -eq 200 -or $sm.StatusCode -eq 404))
+
+# ── Search ─────────────────────────────────────────────────────────
+$s = Get-Url '/search?q=test'
+Check 'Search: 200'                ($s -ne $null -and $s.StatusCode -eq 200)
+Check 'Search has form input'      ($s -ne $null -and $s.Content -like '*name="q"*')
+
+# ── Audio streaming guards ────────────────────────────────────────
+$noToken = Get-Url '/audio/stream/foo.mp3'
+Check 'No token: 403' ($noToken -ne $null -and $noToken.StatusCode -eq 403)
+
+$badToken = Get-Url '/audio/stream/foo.mp3?t=deadbeef&exp=9999999999'
+Check 'Bad token: 403' ($badToken -ne $null -and $badToken.StatusCode -eq 403)
+
+$traverse = Get-Url '/audio/stream/..%2Fevil'
+Check 'Path traversal: 4xx' ($traverse -ne $null -and $traverse.StatusCode -ge 400 -and $traverse.StatusCode -lt 500)
+
+# ── Auth-gated routes preserve ?next= ──────────────────────────────
+$account = Get-Url '/account'
+Check '/account redirects (302)' ($account -ne $null -and $account.StatusCode -eq 302)
+$loc = if ($account) { $account.Headers.Location } else { $null }
+Check 'Redirect contains ?next=' ($loc -ne $null -and $loc -like '*/auth/login?next=*')
+
+$admin = Get-Url '/admin'
+Check '/admin requires auth' ($admin -ne $null -and ($admin.StatusCode -eq 302 -or $admin.StatusCode -eq 403))
+
+# ── Auth pages ─────────────────────────────────────────────────────
+$login = Get-Url '/auth/login'
+Check 'Login page: 200'              ($login -ne $null -and $login.StatusCode -eq 200)
+Check "Login has 'Forgot password?'" ($login -ne $null -and $login.Content -like '*Forgot password*')
+
+$reset = Get-Url '/auth/reset-request'
+Check 'Reset-request page: 200'      ($reset -ne $null -and $reset.StatusCode -eq 200)
+
+# ── Reserved slugs ─────────────────────────────────────────────────
+$tag = Get-Url '/tag/anything'
+Check '/tag/:tag responds' ($tag -ne $null -and ($tag.StatusCode -eq 200 -or $tag.StatusCode -eq 404))
+
+$users = Get-Url '/users/nonexistent'
+Check '/users/:username 404 when missing' ($users -ne $null -and $users.StatusCode -eq 404)
+
+# ── Prutter (route exists; status depends on enable_prutter + login) ──
+$prutter = Get-Url '/prutter'
+Check '/prutter route present' ($prutter -ne $null -and ($prutter.StatusCode -eq 302 -or $prutter.StatusCode -eq 404))
+
+# ── HTMX bundled locally ──────────────────────────────────────────
+$htmx = Get-Url '/assets/js/htmx.min.js'
+Check 'HTMX file served' ($htmx -ne $null -and $htmx.StatusCode -eq 200)
+Check 'HTMX is the real lib (>20 KB, not the loader stub)' `
+      ($htmx -ne $null -and $htmx.Content.Length -gt 20000)
+
+# ── CSP / security headers ────────────────────────────────────────
+$csp = if ($home) { $home.Headers.'Content-Security-Policy' } else { $null }
+Check 'CSP present' ($csp -ne $null)
+Check 'CSP no longer references unpkg.com' ($csp -ne $null -and $csp -notlike '*unpkg*')
+
+$nosniff = if ($home) { $home.Headers.'X-Content-Type-Options' } else { $null }
+Check 'Helmet active (X-Content-Type-Options: nosniff)' ($nosniff -eq 'nosniff')
+
+# ── Summary ───────────────────────────────────────────────────────
+Write-Host "`n────────────────────────────────────────"
+$summary = "$($script:pass) passed, $($script:fail) failed."
+if ($script:fail -eq 0) {
+    Write-Host $summary -ForegroundColor Green
+} else {
+    Write-Host $summary -ForegroundColor Red
+    exit 1
+}
