diff --git a/.gitignore b/.gitignore
index 30b185d..e1eaba3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,12 @@ e2e/.env.test
/test-results/
/playwright-report/
+# Load-test sample media. The wedding sample set is ~8.7 GB of real photos and
+# videos; it is input to e2e/loadtest/event-sim.mjs, not source. Ignored by name
+# AND by extension so a stray archive can never be committed by accident.
+/wedding_sample_images.zip
+*.zip
+
# OS
.DS_Store
Thumbs.db
diff --git a/e2e/Caddyfile.sim b/e2e/Caddyfile.sim
new file mode 100644
index 0000000..02834f1
--- /dev/null
+++ b/e2e/Caddyfile.sim
@@ -0,0 +1,27 @@
+# Caddyfile for the EVENT SIMULATION stack (e2e/docker-compose.sim.yml).
+# Identical in behaviour to Caddyfile.test — same compression carve-out for SSE,
+# same security headers, same export framing rules — but on :3102 so the
+# simulation can run without colliding with the :3101 Playwright stack.
+
+:3102 {
+ # Mirror prod: exclude the SSE stream from compression so buffering doesn't
+ # delay real-time events.
+ @compressible not path /api/v1/stream
+ encode @compressible zstd gzip
+
+ header {
+ X-Content-Type-Options "nosniff"
+ Referrer-Policy "strict-origin-when-cross-origin"
+ }
+
+ @framable path /api/v1/export/zip /api/v1/export/html
+ @not_framable not path /api/v1/export/zip /api/v1/export/html
+ header @framable X-Frame-Options "SAMEORIGIN"
+ header @not_framable X-Frame-Options "DENY"
+
+ reverse_proxy /api/* app:3000
+ reverse_proxy /media/* app:3000
+ reverse_proxy /health app:3000
+
+ reverse_proxy frontend:3001
+}
diff --git a/e2e/docker-compose.sim.yml b/e2e/docker-compose.sim.yml
new file mode 100644
index 0000000..d7885ab
--- /dev/null
+++ b/e2e/docker-compose.sim.yml
@@ -0,0 +1,153 @@
+# EventSnap EVENT SIMULATION stack — models the real production box, not CI.
+#
+# Difference from docker-compose.test.yml (which is tuned for fast, unconstrained
+# Playwright runs): this file reproduces the CX22 the event actually runs on —
+# * 2 vCPU total, shared by all four services
+# * 4 GB RAM, split by the same per-service limits production ships
+# * a REAL 30 GB filesystem for media + exports (loopback ext4), so statvfs
+# inside the container returns true numbers and the disk gate / 507 path is
+# exercised for real rather than simulated.
+#
+# Why cpuset on every service: production's per-service `cpus` ceilings sum to
+# 1.5 + 1.2 + 0.6 + 0.5 = 3.8 on a box that has 2. That oversubscription is the
+# point — the ceilings only bind when something else is competing, and cpu_shares
+# decides who wins. Reproducing that on a 12-core workstation requires confining
+# every container to the SAME two cores; without cpuset each service would get its
+# ceiling simultaneously and the contention under test would never happen.
+#
+# Bring up: docker compose -f docker-compose.sim.yml up -d --build
+# Tear down: docker compose -f docker-compose.sim.yml down -v
+#
+# The 30 GB volume is created out-of-band (see e2e/loadtest/sim-disk.sh) because a
+# loopback device must be attached by root; it is declared `external` here.
+
+x-cpuset: &cpuset '0,1'
+
+services:
+ db:
+ image: postgres:16-alpine
+ cpuset: *cpuset
+ environment:
+ POSTGRES_USER: eventsnap_test
+ POSTGRES_PASSWORD: eventsnap_test
+ POSTGRES_DB: eventsnap_test
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U eventsnap_test -d eventsnap_test']
+ interval: 3s
+ timeout: 3s
+ retries: 30
+ ports:
+ - '55433:5432'
+ volumes:
+ # Named (not anonymous) so a `down -v` really wipes it and so its on-disk size
+ # can be measured against the 10 GB DISK_RESERVE that is meant to cover it.
+ - sim_pgdata:/var/lib/postgresql/data
+ # Production values, verbatim (docker-compose.yml db service).
+ deploy:
+ resources:
+ limits:
+ memory: 1G
+ cpus: '1.5'
+ reservations:
+ memory: 256M
+ cpu_shares: 2048
+ memswap_limit: 1152m
+
+ app:
+ # The SHIPPED release image, not a local build. Verified identical to HEAD:
+ # `git diff v0.17.5 HEAD -- backend/` is empty, and v0.17.5/v0.17.6 share one
+ # app digest on purpose (the v0.17.6 bump was frontend-only). Running the real
+ # artifact means the simulation tests what the event will actually run.
+ image: ${SIM_APP_IMAGE:-registry.mc02.dev/eventsnap/app:v0.17.5}
+ cpuset: *cpuset
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ DATABASE_URL: postgres://eventsnap_test:eventsnap_test@db:5432/eventsnap_test
+ JWT_SECRET: 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
+ # bcrypt("admin-test-pw"), cost 4. $ doubled to escape compose interpolation.
+ ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
+ EVENT_SLUG: sim-wedding
+ EVENT_NAME: Hochzeit Simulation
+ APP_PORT: '3000'
+ # Both live on the SAME 30 GB filesystem, as they do on the real VPS — but in
+ # sibling directories, because config.rs::validate requires EXPORT_PATH outside
+ # MEDIA_PATH (a keepsake archive contains every photo in the event).
+ MEDIA_PATH: /disk/media
+ EXPORT_PATH: /disk/exports
+ SESSION_EXPIRY_DAYS: '30'
+ # Production pins these; the CI stack leaves them at code defaults. Sized to 2 vCPU.
+ DATABASE_MAX_CONNECTIONS: '15'
+ COMPRESSION_WORKER_CONCURRENCY: '2'
+ # Production disables comments for this event (docker-compose.yml, product decision).
+ COMMENTS_ENABLED: 'false'
+ # The ONE deviation from production: enables /admin/__truncate so the harness can
+ # reset between runs. Never set on the real box.
+ EVENTSNAP_TEST_MODE: '1'
+ RUST_LOG: eventsnap_backend=info,tower_http=warn
+ volumes:
+ - sim_disk:/disk
+ deploy:
+ resources:
+ limits:
+ memory: 1G
+ cpus: '1.2'
+ reservations:
+ memory: 256M
+ cpu_shares: 512
+ memswap_limit: 1152m
+ expose:
+ - '3000'
+
+ frontend:
+ # Shipped release image; `git diff v0.17.6 HEAD -- frontend/` is empty.
+ image: registry.mc02.dev/eventsnap/frontend:v0.17.6
+ cpuset: *cpuset
+ depends_on:
+ - app
+ environment:
+ PORT: '3001'
+ HOST: '0.0.0.0'
+ ORIGIN: 'http://localhost:3102'
+ deploy:
+ resources:
+ limits:
+ memory: 256M
+ cpus: '0.6'
+ cpu_shares: 256
+ memswap_limit: 320m
+ expose:
+ - '3001'
+
+ caddy:
+ image: caddy:2-alpine
+ cpuset: *cpuset
+ depends_on:
+ - app
+ - frontend
+ volumes:
+ - ./Caddyfile.sim:/etc/caddy/Caddyfile:ro
+ deploy:
+ resources:
+ limits:
+ memory: 256M
+ cpus: '0.5'
+ cpu_shares: 1024
+ memswap_limit: 320m
+ ports:
+ - '3102:3102'
+
+volumes:
+ # 30 GB loopback ext4, created by e2e/loadtest/sim-disk.sh. Holds media AND exports,
+ # exactly as one VPS disk holds both.
+ sim_disk:
+ external: true
+ name: eventsnap_sim_media
+ # Postgres data stays on the host disk, NOT on the 30 GB volume. Scoping decision:
+ # the 30 GB budget under test is the one the APP manages and measures (statvfs on
+ # MEDIA_PATH drives the disk gate). Putting PG on the same volume would test
+ # filesystem exhaustion instead, and a full disk under Postgres risks ending the run
+ # for an infrastructural reason rather than an application one. Its growth is sampled
+ # separately and reported against DISK_RESERVE_BYTES (10 GB), which exists to cover it.
+ sim_pgdata:
diff --git a/e2e/loadtest/browser-check.mjs b/e2e/loadtest/browser-check.mjs
new file mode 100644
index 0000000..7e8634e
--- /dev/null
+++ b/e2e/loadtest/browser-check.mjs
@@ -0,0 +1,214 @@
+#!/usr/bin/env node
+/**
+ * Real-browser pass over the event AFTER the load simulation has filled it.
+ *
+ * `event-sim.mjs` drives the HTTP API directly — deliberately, because 150 real
+ * browsers would bottleneck the test box rather than the server. That leaves two
+ * things unmeasured, and both are guest-visible:
+ *
+ * 1. The SvelteKit frontend container, which the API driver never touches.
+ * 2. Whether the frontend ESCAPES the XSS caption the API stored verbatim. The
+ * backend stores captions raw by design (`upload.rs` length-checks only), so
+ * the entire defence is the renderer. The abuse suite proved the payload is
+ * in the database; only a browser can prove it is inert.
+ *
+ * Runs three real engines against the loaded gallery and reports load timings,
+ * console errors, and the XSS verdict.
+ *
+ * node e2e/loadtest/browser-check.mjs
+ */
+import { chromium, firefox, webkit, devices } from '@playwright/test';
+import { writeFile, mkdir } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const BASE = process.env.SIM_BASE ?? 'http://localhost:3102';
+const API = `${BASE}/api/v1`;
+const OUT = join(__dirname, 'results', 'browser');
+
+const XSS_CAPTION = '
';
+const XSS_MARKERS = ['