Resolve security audit findings

ober

7c4cdf6c1f89848a9034193e2a59b9ef87121077

diff --git a/.jerboa/security.json b/.jerboa/security.json
new file mode 100644
index 0000000..d9b7857
--- /dev/null
+++ b/.jerboa/security.json
@@ -0,0 +1,56 @@
+{
+  "version": 1,
+  "repo": "jerboa-imagesite",
+  "extends": ["jerboa:daemon", "jerboa:network-service", "jerboa:web-app"],
+  "paths": {
+    "production": ["src/**/*.ss", "public/**", "support/*.c", "Makefile", ".jerbuild"],
+    "tests": ["tests/**", "scripts/web-security-smoke.sh"],
+    "generated": ["build/**", "dist/**", ".jerboa/native/**", "var/**", "tools/__pycache__/**"],
+    "vendor": ["public/vendor/**"],
+    "docs": ["README.md", "SECURITY.md", "docs/**", "deploy/**"]
+  },
+  "policy": {
+    "failOn": ["critical", "high"],
+    "authentication": {
+      "urlCredentials": "deny",
+      "browserSession": "opaque-random-server-side",
+      "adminLifetimeSeconds": 3600,
+      "cookie": "Secure; HttpOnly; SameSite=Strict",
+      "failedAttemptLimit": "5 per peer per 300 seconds"
+    },
+    "csrf": {
+      "mutations": "POST only",
+      "browserControl": "session-bound synchronizer token plus Origin/Referer validation",
+      "headerBearerControl": "non-simple X-Imagesite-Admin-Token header"
+    },
+    "filesystem": {
+      "mediaTraversal": "descriptor-relative openat/fstatat with O_NOFOLLOW",
+      "symlinks": "reject",
+      "mountBoundary": "same st_dev as retained root",
+      "visitedInodes": true,
+      "maxDepth": 32,
+      "maxEntries": 100000,
+      "maxFileBytes": 8589934592,
+      "maxTotalBytes": 274877906944
+    },
+    "logging": {
+      "format": "bounded JSON lines",
+      "queryValues": "redacted",
+      "controlCharacters": "replaced",
+      "forwardingHeaders": "ignored unless transport peer is explicitly trusted"
+    },
+    "supplyChain": {
+      "standaloneManifest": ".jerbuild",
+      "nativeSymbols": "support/ffi-symbols.list",
+      "effectiveGraphEvidence": "make sbom",
+      "dirtyDependencyPolicy": "release blocked"
+    },
+    "releaseEvidence": {
+      "target": "make release-evidence",
+      "directory": "dist/release-evidence",
+      "requiredResult": "local_release_status=pass",
+      "productionPromotion": "requires a clean committed Imagesite/Sinatra/core snapshot and target-host proof"
+    }
+  },
+  "suppressions": []
+}
diff --git a/.jerbuild b/.jerbuild
new file mode 100644
index 0000000..b0b5257
--- /dev/null
+++ b/.jerbuild
@@ -0,0 +1,15 @@
+;; Reproducible standalone imagesite build. Project dependencies are staged by
+;; `make build`; the bundled Jerboa stdlib/native archive supplies the runtime.
+(entry "src/app.ss")
+(output "dist/jerboa-imagesite")
+(libdirs "build/lib"
+         "build/test-lib"
+         "../jerboa-sinatra/build/lib"
+         "../jerboa/vendor/jsqlite/src")
+(static-native #t)
+(extra-sources "support/imagesite-securefs.c")
+(ffi-symbols "support/ffi-symbols.list")
+(os-libs
+  (Darwin "-lm -lpthread -lncurses -liconv -lc++")
+  (Linux "-lm -ldl -lpthread -lncurses -lstdc++")
+  (FreeBSD "-lm -lpthread -lncurses -lstdc++"))
diff --git a/Makefile b/Makefile
index e40b506..ae4305d 100644
--- a/Makefile
+++ b/Makefile
@@ -8,26 +8,44 @@ JERBOA_HOME := $(HOME)/mine/jerboa
 endif
 
 SINATRA_DIR ?= $(abspath $(CURDIR)/../jerboa-sinatra)
-JSQLITE_DIR ?= $(abspath $(CURDIR)/../jerboa-sqlite)
+JSQLITE_SRC ?= $(JERBOA_SOURCE_HOME)/vendor/jsqlite/src
 BUILD_DIR ?= build
 SRC_STAGE := $(BUILD_DIR)/src
 TEST_STAGE := $(BUILD_DIR)/tests
 LIB_STAGE := $(BUILD_DIR)/lib
 TEST_LIB_STAGE := $(BUILD_DIR)/test-lib
-LIBDIRS := $(LIB_STAGE):$(TEST_LIB_STAGE):$(SINATRA_DIR)/build/lib:$(JSQLITE_DIR)/lib:$(JERBOA_HOME)/lib
-SQLITE_LIB_PATH := $(JSQLITE_DIR)
+LIBDIRS := $(LIB_STAGE):$(TEST_LIB_STAGE):$(SINATRA_DIR)/build/lib:$(JERBOA_HOME)/lib:$(JSQLITE_SRC)
 UNAME_S := $(shell uname -s)
-SQLITE_PREFIX ?= $(shell if [ "$(UNAME_S)" = "Darwin" ]; then brew --prefix sqlite 2>/dev/null || echo /opt/homebrew/opt/sqlite; fi)
-SQLITE_LIBDIR ?= $(if $(SQLITE_PREFIX),$(SQLITE_PREFIX)/lib,)
 ENTRY ?= src/app.ss
 BINARY_OUTPUT ?= dist/jerboa-imagesite
+BINARY_OBJ_DIR ?= $(abspath $(BUILD_DIR)/jerbuild-binary-imagesite)
+STATIC_BINARY_OUTPUT ?= dist/jerboa-imagesite-linux-amd64
+STATIC_OBJ_DIR ?= $(abspath $(BUILD_DIR)/jerbuild-binary-imagesite-static)
 BINARY_NATIVE_LDFLAGS ?= $(if $(filter Darwin,$(UNAME_S)),-lc++,)
+NATIVE_A := $(JERBOA_HOME)/jerboa-native-rs/target/release/libjerboa_native.a
+NATIVE_SOURCES := $(JERBOA_HOME)/jerboa-native-rs/Cargo.toml $(JERBOA_HOME)/jerboa-native-rs/Cargo.lock $(wildcard $(JERBOA_HOME)/jerboa-native-rs/src/*.rs)
+SECUREFS_DIR := $(CURDIR)/.jerboa/native
+SECUREFS_LIB := $(SECUREFS_DIR)/$(if $(filter Darwin,$(UNAME_S)),libimagesite_securefs.dylib,libimagesite_securefs.so)
+SECUREFS_ENV := IMAGESITE_DEV_NATIVE=1 IMAGESITE_SECUREFS_LIB="$(SECUREFS_LIB)"
+FACE_PYTHON ?= $(HOME)/.cache/jerboa-imagesite-face-ai-py312/bin/python
+FACE_MEDIA_ROOT ?= var/media
+FACE_OUTPUT_ROOT ?= var/metadata/faces
+FACE_LIMIT ?= 0
+FACE_LIMIT_ARG := $(if $(filter-out 0,$(FACE_LIMIT)),--limit $(FACE_LIMIT),)
+FACE_ARGS ?=
+STATIC_TARGET_MACHINE ?= ta6le
+STATIC_RUST_TARGET ?= x86_64-unknown-linux-musl
+STATIC_CHEZ_PREFIX ?= $(JERBOA_SOURCE_HOME)/.chez-cross-$(STATIC_TARGET_MACHINE)
+STATIC_CSV_DIR ?= $(firstword $(wildcard $(STATIC_CHEZ_PREFIX)/lib/csv*/$(STATIC_TARGET_MACHINE)))
+STATIC_XPATCH ?= $(JERBOA_SOURCE_HOME)/build/chez/xc-$(STATIC_TARGET_MACHINE)/s/xpatch
+STATIC_CC ?= x86_64-linux-musl-gcc
+STATIC_OS_LIBS ?= -lm -ldl -lpthread -static -no-pie -lstdc++
+STATIC_NATIVE_A ?= $(JERBOA_SOURCE_HOME)/jerboa-native-rs/target/$(STATIC_RUST_TARGET)/release/libjerboa_native.a
 
-.PHONY: deps build test run binary import-check clean distclean
+.PHONY: deps build test run binary native-runtime static-binary securefs-shim web-security-smoke security audit sbom reproducibility-report target-evidence target-evidence-selftest verify release-evidence face-clusters import-check clean distclean
 
 deps:
 	$(MAKE) -C $(SINATRA_DIR) build
-	$(MAKE) -C $(JSQLITE_DIR) build
 
 build: deps
 	rm -rf $(SRC_STAGE) $(TEST_STAGE) $(LIB_STAGE) $(TEST_LIB_STAGE)
@@ -37,32 +55,77 @@ build: deps
 	$(JERBUILD) transpile $(SRC_STAGE) $(LIB_STAGE) --force
 	$(JERBUILD) transpile $(TEST_STAGE) $(TEST_LIB_STAGE) --force
 
-test: build
-	JERBOA_SQLITE_LIB="$(SQLITE_LIB_PATH)" \
-	DYLD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$DYLD_LIBRARY_PATH" \
-	LD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$LD_LIBRARY_PATH" \
-	$(JERBUILD) exec --libdirs "$(LIBDIRS)" tests/test-runner.ss
+securefs-shim:
+	@mkdir -p "$(SECUREFS_DIR)"
+	@case "$(UNAME_S)" in \
+	  Darwin) $(CC) -dynamiclib -fPIC -O2 -Wall -Wextra -Werror -o "$(SECUREFS_LIB)" support/imagesite-securefs.c ;; \
+	  *) $(CC) -shared -fPIC -O2 -Wall -Wextra -Werror -o "$(SECUREFS_LIB)" support/imagesite-securefs.c ;; \
+	esac
 
-run: build
-	JERBOA_SQLITE_LIB="$(SQLITE_LIB_PATH)" \
-	DYLD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$DYLD_LIBRARY_PATH" \
-	LD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$LD_LIBRARY_PATH" \
-	$(JERBUILD) exec --libdirs "$(LIBDIRS)" $(ENTRY)
+test: build securefs-shim
+	$(SECUREFS_ENV) $(JERBUILD) exec --libdirs "$(LIBDIRS)" tests/test-runner.ss
 
-binary: build
+run: build securefs-shim
+	$(SECUREFS_ENV) $(JERBUILD) exec --libdirs "$(LIBDIRS)" $(ENTRY)
+
+native-runtime: $(NATIVE_A)
+
+$(NATIVE_A): $(NATIVE_SOURCES)
+	@command -v cargo >/dev/null 2>&1 || { echo "ERROR: cargo is required for the standalone TLS runtime" >&2; exit 1; }
+	cargo build --locked --release --manifest-path "$(JERBOA_HOME)/jerboa-native-rs/Cargo.toml" --no-default-features --features tls
+
+binary: build native-runtime
 	mkdir -p $(dir $(BINARY_OUTPUT))
+	JERBOA_HOME="$(JERBOA_HOME)" JERBUILD_BINARY_OBJ_DIR="$(BINARY_OBJ_DIR)" $(JERBUILD) build --config .jerbuild
+
+web-security-smoke: binary
+	@sh scripts/web-security-smoke.sh
+
+security:
+	@sh scripts/security-check.sh
+
+audit:
+	@command -v cargo-audit >/dev/null 2>&1 || command -v cargo >/dev/null 2>&1 || { echo "ERROR: cargo-audit is required" >&2; exit 1; }
+	cargo audit --file "$(JERBOA_HOME)/jerboa-native-rs/Cargo.lock" --deny warnings
+
+sbom: binary
+	@JERBUILD="$(JERBUILD)" JERBUILD_TOOL="$(JERBOA)" SINATRA_DIR="$(SINATRA_DIR)" JSQLITE_SRC="$(JSQLITE_SRC)" sh scripts/sbom.sh
+
+reproducibility-report:
+	@sh scripts/reproducibility-report.sh
+
+target-evidence:
+	@sh scripts/target-evidence.sh
+
+target-evidence-selftest:
+	@sh scripts/test-target-evidence.sh
+
+verify: security audit test binary web-security-smoke reproducibility-report sbom target-evidence-selftest target-evidence
+
+release-evidence:
+	@sh scripts/release-evidence.sh
+
+static-binary: build
+	@test -n "$(STATIC_CSV_DIR)" && test -f "$(STATIC_CSV_DIR)/libkernel.a" || { echo "ERROR: static runtime libkernel not found under $(STATIC_CHEZ_PREFIX); override STATIC_CHEZ_PREFIX or build the cross runtime first" >&2; exit 1; }
+	@test -f "$(STATIC_XPATCH)" || { echo "ERROR: xpatch not found at $(STATIC_XPATCH); override STATIC_XPATCH or build the cross Chez first" >&2; exit 1; }
+	@command -v "$(STATIC_CC)" >/dev/null 2>&1 || { echo "ERROR: $(STATIC_CC) not on PATH" >&2; exit 1; }
+	@test -f "$(STATIC_NATIVE_A)" || { echo "ERROR: libjerboa_native.a not found at $(STATIC_NATIVE_A); run make -C $(JERBOA_SOURCE_HOME) native-cross for $(STATIC_RUST_TARGET)" >&2; exit 1; }
+	mkdir -p $(dir $(STATIC_BINARY_OUTPUT))
 	JERBOA_HOME="$(JERBOA_HOME)" \
-	JERBOA_SQLITE_LIB="$(SQLITE_LIB_PATH)" \
-	$(JERBUILD) binary --static-native $(foreach flag,$(BINARY_NATIVE_LDFLAGS),--extra-ldflag $(flag)) --libdirs "$(LIBDIRS)" $(ENTRY) $(BINARY_OUTPUT)
+	JERBOA_NATIVE_A="$(STATIC_NATIVE_A)" \
+	JERBUILD_BINARY_OBJ_DIR="$(STATIC_OBJ_DIR)" \
+	$(JERBUILD) binary --static-native --extra-source support/imagesite-securefs.c --main-c support/imagesite-main.c --cc "$(STATIC_CC)" --rust-target "$(STATIC_RUST_TARGET)" --csv-dir "$(STATIC_CSV_DIR)" --xpatch "$(STATIC_XPATCH)" --os-libs "$(STATIC_OS_LIBS)" --libdirs "$(LIBDIRS)" $(ENTRY) $(STATIC_BINARY_OUTPUT)
+
+face-clusters:
+	@test -x "$(FACE_PYTHON)" || { echo "ERROR: FACE_PYTHON not executable: $(FACE_PYTHON)" >&2; exit 1; }
+	"$(FACE_PYTHON)" tools/face_cluster.py --media-root "$(FACE_MEDIA_ROOT)" --output-root "$(FACE_OUTPUT_ROOT)" $(FACE_LIMIT_ARG) $(FACE_ARGS)
 
-import-check: build
-	JERBOA_SQLITE_LIB="$(SQLITE_LIB_PATH)" \
-	DYLD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$DYLD_LIBRARY_PATH" \
-	LD_LIBRARY_PATH="$(SQLITE_LIBDIR):$(SQLITE_LIB_PATH):$$LD_LIBRARY_PATH" \
-	$(JERBUILD) exec --libdirs "$(LIBDIRS)" support/import-check.ss
+import-check: build securefs-shim
+	$(SECUREFS_ENV) $(JERBUILD) exec --libdirs "$(LIBDIRS)" support/import-check.ss
 
 clean:
 	rm -rf $(BUILD_DIR)
+	rm -rf $(SECUREFS_DIR)
 
 distclean: clean
 	rm -rf dist var
diff --git a/README.md b/README.md
index 13fc1d6..e7c67c7 100644
--- a/README.md
+++ b/README.md
@@ -7,12 +7,12 @@ The design keeps the durable archive simple:
 
 - originals and videos stay on disk under a media root;
 - generated thumbnails/previews are optional files under a derivative root;
-- `jerboa-sqlite` stores albums, media rows, tags, captions, visibility, and
-  sync history;
+- Jerboa's `(std db sqlite)` API, backed by vendored jsqlite, stores albums,
+  media rows, tags, captions, visibility, and sync history;
 - Jerboa-Sinatra serves the catalog UI, search, JSON endpoints, family access
   gate, and admin sync hooks;
-- nginx should serve large `/media/` and `/derivatives/` files directly in
-  production.
+- the app can terminate TLS directly and serves gated media with byte-range
+  support for video playback.
 
 This is intentionally not a PHP plugin system. It takes the useful Piwigo ideas
 that mattered in the existing deployment, physical albums, tags, search,
@@ -25,27 +25,53 @@ keeping the runtime native Scheme plus SQLite.
 make build
 ```
 
-The Makefile builds sibling dependencies from `../jerboa-sinatra` and
-`../jerboa-sqlite`, then transpiles this repository into `build/lib`.
+The Makefile builds sibling dependencies from `../jerboa-sinatra`, uses the
+configured Jerboa tree's vendored `vendor/jsqlite/src`, then transpiles this
+repository into `build/lib`.
+
+For the FreeBSD jail deployment, build a single target binary with the
+cross-build variables used by the Ansible playbook:
+
+```sh
+make static-binary \
+  STATIC_TARGET_MACHINE=ta6fb \
+  STATIC_RUST_TARGET=x86_64-unknown-freebsd \
+  STATIC_BINARY_OUTPUT=dist/jerboa-imagesite-freebsd-amd64
+```
 
 ## Test
 
 ```sh
 make test
+make web-security-smoke
 ```
 
+The hostile smoke starts the standalone server on loopback and proves that
+credentials are POST-only, cross-origin and missing-CSRF requests fail, GET
+mutations are inert, opaque sessions rotate at login, and a media symlink swap
+cannot escape the configured root.
+
 ## Run Locally
 
 ```sh
 export JERBOA_IMAGESITE_SESSION_SECRET="$(openssl rand -hex 32)"
-export JERBOA_IMAGESITE_ACCESS_ANSWER="beth"
-export JERBOA_IMAGESITE_MEDIA_ROOT="/srv/wendyfourniersandborn/photos/published"
+export JERBOA_IMAGESITE_ACCESS_ANSWER="<family-answer>"
+export JERBOA_IMAGESITE_MEDIA_ROOT="/srv/wendyfourniersanborn/photos/published"
 export JERBOA_IMAGESITE_DB="/var/db/jerboa-imagesite/imagesite.db"
 make run
 ```
 
 Then visit `http://127.0.0.1:4580/`.
 
+For direct TLS, set cert and key paths:
+
+```sh
+export JERBOA_IMAGESITE_PORT=8443
+export JERBOA_IMAGESITE_TLS_CERT=/path/fullchain.cer
+export JERBOA_IMAGESITE_TLS_KEY=/path/privkey.key
+make run
+```
+
 For a first local smoke test without production media:
 
 ```sh
@@ -53,7 +79,7 @@ mkdir -p var/media/family var/db
 cp some-photo.jpg var/media/family/
 JERBOA_IMAGESITE_SESSION_SECRET=dev-secret \
 JERBOA_IMAGESITE_SESSION_SECURE=0 \
-JERBOA_IMAGESITE_ACCESS_ANSWER=beth \
+JERBOA_IMAGESITE_ACCESS_ANSWER=dev-answer \
 JERBOA_IMAGESITE_MEDIA_ROOT="$PWD/var/media" \
 JERBOA_IMAGESITE_DB="$PWD/var/db/imagesite.db" \
 make run
@@ -62,22 +88,29 @@ make run
 In another shell:
 
 ```sh
-curl -H "X-Imagesite-Admin-Token: dev-admin" \
+curl -X POST -H "X-Imagesite-Admin-Token: dev-admin" \
   http://127.0.0.1:4580/admin/sync
 ```
 
 Set `JERBOA_IMAGESITE_ADMIN_TOKEN=dev-admin` before starting the server if you
 want the admin sync endpoint enabled.
 
+Browser gate/admin credentials are accepted only in form-encoded POST bodies.
+They are never accepted from query parameters. Browser mutations require a
+random, session-bound CSRF value and same-origin validation. The cookie holds
+only an opaque random session identifier; it is `HttpOnly`, `SameSite=Strict`,
+and `Secure` by default. The administrator grant expires server-side after one
+hour and login failures are rate-limited and audited without logging answers.
+
 ## Filesystem Layout
 
-The current Ansible/Piwigo deployment stages media like this:
+The Ansible deployment stages media like this:
 
 ```text
-/srv/wendyfourniersandborn/photos/inbox
-/srv/wendyfourniersandborn/photos/metadata
-/srv/wendyfourniersandborn/photos/classified
-/srv/wendyfourniersandborn/photos/published
+/srv/wendyfourniersanborn/photos/inbox
+/srv/wendyfourniersanborn/photos/metadata
+/srv/wendyfourniersanborn/photos/classified
+/srv/wendyfourniersanborn/photos/published
 ```
 
 `jerboa-imagesite` indexes only the configured published media root. Directory
@@ -93,6 +126,13 @@ Rows are marked missing during sync when a previously indexed file is absent.
 They are not deleted automatically, so a bad mount or partial rsync does not
 destroy catalog work.
 
+The media root is opened as a no-follow directory descriptor. Walks use
+descriptor-relative `openat`/`fstatat`, reject links and inode swaps, stay on the
+root filesystem, remember visited device/inode pairs, and enforce depth (32),
+entry (100,000), per-file (8 GiB), and aggregate (256 GiB) limits. Direct media
+serving is development-only and disabled by default; production deployments
+should use an internal nginx `X-Accel-Redirect` location with symlinks disabled.
+
 ## Metadata Sidecars
 
 Sidecar import is deliberately simple and append-only friendly. For a media file
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..bd6ebda
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,33 @@
+# Security
+
+Report vulnerabilities privately to the repository maintainers. Do not include
+family answers, administrator tokens, session cookies, private media, access
+logs, or target-host configuration in a public issue.
+
+The family answer and browser administrator answer are accepted only in
+form-encoded POST bodies. Administrator automation uses the
+`X-Imagesite-Admin-Token` request header. No credential is accepted from a URL.
+Rotate any credential that was historically placed in a query string.
+
+Every browser mutation is POST-only and requires a random synchronizer token
+bound to the opaque server-side session. Origin and Referer, when present, must
+match `JERBOA_IMAGESITE_ORIGIN`. Cookies use `Secure`, `HttpOnly`, and
+`SameSite=Strict` in production. The administrator grant expires after one hour;
+five failed logins in five minutes rate-limit the transport peer.
+
+The media and face roots must be real directories, not symlinks. Enumeration
+and serving are descriptor-relative and no-follow. Links are skipped, directory
+and file inode identity is checked across opens, filesystem crossings and inode
+cycles are rejected, and traversal has fixed depth, entry, per-file, and total
+byte budgets. Production direct serving is disabled; configure the documented
+internal nginx location with `disable_symlinks on`.
+
+Access logs are bounded JSON objects. Query values are replaced by a marker and
+C0/C1 controls are replaced before encoding. Forwarding headers are never a
+source of client identity unless the accepted transport peer can be matched to
+an exact configured proxy.
+
+Run `make verify` before review. A local pass is not production certification.
+Release remains blocked while this repository or its effective Jerboa/Sinatra
+dependencies are dirty, while target TLS/proxy/filesystem proof is absent, or
+while the built dependency graph does not match reviewed source hashes.
diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example
index 1797c74..867fdf2 100644
--- a/deploy/nginx.conf.example
+++ b/deploy/nginx.conf.example
@@ -1,6 +1,6 @@
 server {
     listen 80;
-    server_name wendyfourniersandborn.com www.wendyfourniersandborn.com;
+    server_name wendyfourniersanborn.com www.wendyfourniersanborn.com;
 
     location /.well-known/acme-challenge/ {
         root /usr/local/www/nginx;
@@ -13,18 +13,26 @@ server {
 
 server {
     listen 443 ssl;
-    server_name wendyfourniersandborn.com www.wendyfourniersandborn.com;
+    server_name wendyfourniersanborn.com www.wendyfourniersanborn.com;
 
-    ssl_certificate     /etc/ssl/wendyfourniersandborn.com/wendyfourniersandborn.com.fullchain.pem;
-    ssl_certificate_key /etc/ssl/wendyfourniersandborn.com/wendyfourniersandborn.com.key;
+    ssl_certificate     /etc/ssl/wendyfourniersanborn.com/wendyfourniersanborn.com.fullchain.pem;
+    ssl_certificate_key /etc/ssl/wendyfourniersanborn.com/wendyfourniersanborn.com.key;
     ssl_protocols TLSv1.2 TLSv1.3;
     ssl_prefer_server_ciphers off;
 
-    client_max_body_size 512M;
+    client_max_body_size 4M;
 
     add_header X-Content-Type-Options nosniff always;
     add_header Referrer-Policy no-referrer always;
 
+    # Must match JERBOA_IMAGESITE_MEDIA_ACCEL_PREFIX. This location is never
+    # reachable directly and refuses every symlink in the archive path.
+    location /_protected_media/ {
+        internal;
+        alias /srv/<archive>/photos/published/;
+        disable_symlinks on;
+    }
+
     location / {
         proxy_pass http://127.0.1.20:4580;
         proxy_set_header Host $host;
@@ -35,4 +43,3 @@ server {
         proxy_http_version 1.1;
     }
 }
-
diff --git a/docs/deployment.md b/docs/deployment.md
index 9e54621..d635f5d 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -1,8 +1,8 @@
 # Deployment
 
-The existing Piwigo deployment runs in a FreeBSD jail behind host nginx. The
-replacement keeps the same public front door, TLS certificate, and published
-media tree, but removes PHP, MariaDB, and the Piwigo plugin runtime.
+The existing Piwigo deployment ran in a FreeBSD jail behind host nginx. The
+replacement keeps the dedicated jail, TLS certificate, and published media
+tree, but removes nginx, PHP, MariaDB, and the Piwigo plugin runtime.
 
 ## Runtime Inputs
 
@@ -12,11 +12,17 @@ JERBOA_IMAGESITE_MEDIA_ROOT=/srv/<archive>/photos/published
 JERBOA_IMAGESITE_METADATA_ROOT=/srv/<archive>/photos/metadata
 JERBOA_IMAGESITE_DERIVATIVE_ROOT=/srv/<archive>/photos/derivatives
 JERBOA_IMAGESITE_BIND=127.0.1.20
-JERBOA_IMAGESITE_PORT=4580
+JERBOA_IMAGESITE_PORT=443
+JERBOA_IMAGESITE_ORIGIN=https://<domain>
+JERBOA_IMAGESITE_TLS_CERT=/etc/ssl/<domain>/fullchain.cer
+JERBOA_IMAGESITE_TLS_KEY=/etc/ssl/<domain>/<domain>.key
 JERBOA_IMAGESITE_SESSION_SECRET=<random 32+ bytes>
 JERBOA_IMAGESITE_ACCESS_QUESTION="What was Wendy's middle name?"
 JERBOA_IMAGESITE_ACCESS_ANSWER=<answer>
 JERBOA_IMAGESITE_ADMIN_TOKEN=<random admin token>
+JERBOA_IMAGESITE_ADMIN_ANSWER=<separate high-entropy browser password>
+JERBOA_IMAGESITE_DIRECT_MEDIA=0
+JERBOA_IMAGESITE_MEDIA_ACCEL_PREFIX=/_protected_media/
 ```
 
 ## Sync
@@ -25,29 +31,49 @@ Run sync after rsyncing approved media:
 
 ```sh
 curl -fsS \
+  -k \
+  -X POST \
   -H "X-Imagesite-Admin-Token: $JERBOA_IMAGESITE_ADMIN_TOKEN" \
-  http://127.0.1.20:4580/admin/sync
+  https://127.0.1.20:443/admin/sync
 ```
 
 The syncer marks all existing media rows missing at the start of a transaction,
 then clears that marker as it sees files. If the sync fails, the transaction is
 rolled back.
 
-## nginx
-
-Use nginx for TLS and large static files:
-
-- `/` proxies to the Jerboa app;
-- `/app.css` can proxy or be served from the app public directory;
-- `/media/` should alias the published media root after the family gate has
-  been passed by the app, or proxy to the app for simpler first deployment;
-- `/derivatives/` can alias generated previews/thumbs when available.
-
-The simple first deployment is to proxy everything to Jerboa. The scalable
-deployment uses nginx `auth_request` against a future `/auth/media` endpoint
-before serving media aliases directly.
-
-See `deploy/nginx.conf.example` for the initial proxy configuration.
+## TLS And Media
+
+The FreeBSD deployment follows the `jerboa.sh` shape: Jerboa terminates TLS
+directly with cert paths supplied through the environment. The host uses a
+small `pf` redirect from public TCP/443 into the jail, so host DNS services can
+keep the public address while the web app remains jailed.
+
+`/media/` remains behind the family gate. In production the application first
+opens the requested path relative to a retained, no-follow media-root
+descriptor and then emits an internal `X-Accel-Redirect`. Configure nginx's
+internal location with `disable_symlinks on`; do not expose that location or
+the archive alias directly. Direct application media serving is an explicit
+development mode, caps non-range responses at 16 MiB, and is disabled by
+default.
+
+The gate and administrator login accept secrets only in POST bodies. Never put
+an answer or token in a URL, bookmark, monitoring probe, or redirect. Session
+cookies contain random opaque identifiers and use `Secure`, `HttpOnly`, and
+`SameSite=Strict`; set `JERBOA_IMAGESITE_ORIGIN` to the single canonical HTTPS
+origin so Origin/Referer validation fails closed. Rotate any answer or token
+that was previously used in a URL.
+
+Forwarding headers are not trusted by default. If a future HTTP transport
+provides the accepted peer address to the logger, list exact proxy addresses in
+`JERBOA_IMAGESITE_TRUSTED_PROXIES`; never use a wildcard or client-controlled
+subnet. The current logger emits one bounded JSON object per request, omits
+query values, escapes controls, and records the transport peer rather than
+blindly trusting `X-Forwarded-For`.
+
+Synchronization rejects symlinks and cross-device directories, verifies inode
+identity between enumeration and open, and caps traversal to 32 levels,
+100,000 entries, 8 GiB per media file, and 256 GiB total. Treat a limit failure
+as an ingestion error to investigate, not as a reason to disable the bounds.
 
 ## Replacing Piwigo
 
@@ -61,6 +87,6 @@ The pieces that stay:
 
 - host-side archive workspace;
 - rsync staging flow;
-- TLS certificate and host nginx front door;
+- TLS certificate;
 - family access question;
 - published-only media policy.
diff --git a/docs/release-evidence.md b/docs/release-evidence.md
new file mode 100644
index 0000000..6422c7a
--- /dev/null
+++ b/docs/release-evidence.md
@@ -0,0 +1,21 @@
+# Release evidence
+
+`make release-evidence` records local test, hostile HTTP smoke, source scan,
+standalone build, effective dependency/SBOM, and two-build reproducibility
+results under `dist/release-evidence/`.
+
+A local pass must contain `local_release_status=pass`. It deliberately does not
+claim production readiness. Promotion additionally requires:
+
+- a clean, committed Imagesite snapshot (the audit started from a heavily dirty
+  mixed tree);
+- clean, reviewed Jerboa and Sinatra source inputs matching the recorded hashes;
+- target-host proof of TLS, exact bind address, internal nginx media location,
+  `disable_symlinks on`, jail/sandbox policy, file ownership, and backup restore;
+- an external dependency/advisory scan of the effective linked artifact;
+- a reviewed credential rotation confirming no historical URL answer/token
+  remains valid; and
+- sustained load and filesystem-race testing on the deployment OS.
+
+The local SBOM captures both Git revisions and current source manifests so a
+dirty adjacent dependency cannot be mistaken for its HEAD commit.
diff --git a/docs/threat-model.md b/docs/threat-model.md
new file mode 100644
index 0000000..76c5ea1
--- /dev/null
+++ b/docs/threat-model.md
@@ -0,0 +1,31 @@
+# Threat model
+
+The application protects a private media catalog from unauthenticated web
+clients and treats media/staging directory entries as hostile. Browser history,
+reverse-proxy logs, referrers, cross-site navigation, crawlers, malformed URL
+normalization, symlinks, mount aliases, concurrent path replacement, oversized
+trees, and control characters in request metadata are in scope.
+
+The trusted computing base is the reviewed Imagesite source, the exact Jerboa
+and Sinatra sources used for the build, the native secure-filesystem shim, the
+kernel, TLS endpoint, and a correctly configured reverse proxy/jail. A host
+administrator, kernel compromise, malicious replacement of the built binary,
+or direct access to the underlying media volume is outside the web application's
+boundary.
+
+Security invariants:
+
+- GET and HEAD never modify authentication or gallery state.
+- Answers and bearer tokens never appear in request targets, redirects, HTML,
+  cookies, or logs.
+- Browser state changes require both a live opaque session and its CSRF value.
+- Redirect destinations have no scheme, authority, backslash, control, encoded
+  separator, double-decode marker, or unknown application prefix.
+- A directory entry can be indexed or served only through an opened descriptor
+  descended from the retained root without following a link.
+- One request contributes at most one bounded JSON log record and no query
+  value.
+
+The hostile module and HTTP smoke suites cover these invariants. Production
+promotion additionally requires target-host TLS/proxy, jail policy, filesystem,
+load, backup/restore, and operator credential-rotation evidence.
diff --git a/public/app.css b/public/app.css
index 4e883d9..a5b2c86 100644
--- a/public/app.css
+++ b/public/app.css
@@ -1,23 +1,32 @@
 :root {
   color-scheme: light;
-  --ink: #222;
-  --muted: #666;
-  --line: #d9ddd3;
-  --paper: #fbfbf8;
+  --ink: #1d211d;
+  --muted: #68706a;
+  --soft: #f4f1eb;
+  --paper: #fbfaf7;
   --panel: #ffffff;
-  --accent: #2d6a4f;
-  --accent-2: #7a4e2d;
+  --line: #d8d7cf;
+  --accent: #23614f;
+  --accent-strong: #16483b;
+  --warm: #8a5a34;
+  --rose: #9f4c55;
+  --shadow: 0 12px 28px rgba(35, 39, 33, 0.12);
 }
 
 * {
   box-sizing: border-box;
 }
 
+html {
+  background: var(--paper);
+}
+
 body {
   margin: 0;
   font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
   color: var(--ink);
-  background: var(--paper);
+  background:
+    linear-gradient(180deg, #f7f2ea 0, var(--paper) 240px);
 }
 
 a {
@@ -27,37 +36,55 @@ a {
 .topbar {
   position: sticky;
   top: 0;
-  z-index: 2;
+  z-index: 5;
   display: grid;
-  grid-template-columns: minmax(190px, 1fr) minmax(240px, 520px) auto;
-  gap: 16px;
+  grid-template-columns: minmax(220px, 1fr) minmax(260px, 560px) auto;
+  gap: 18px;
   align-items: center;
-  padding: 12px 20px;
-  border-bottom: 1px solid var(--line);
-  background: rgba(251, 251, 248, 0.96);
+  padding: 12px 22px;
+  border-bottom: 1px solid rgba(216, 215, 207, 0.92);
+  background: rgba(251, 250, 247, 0.96);
+  backdrop-filter: blur(16px);
 }
 
 .brand {
-  font-weight: 700;
+  font-weight: 800;
   text-decoration: none;
 }
 
 nav {
   display: flex;
-  gap: 12px;
+  gap: 14px;
+  justify-content: flex-end;
   font-size: 14px;
 }
 
 nav a,
 .back {
   color: var(--accent);
+  font-weight: 650;
   text-decoration: none;
 }
 
+.nav-action {
+  display: inline;
+  margin: 0;
+}
+
+.nav-action button {
+  min-height: 0;
+  padding: 0;
+  border: 0;
+  color: var(--accent);
+  background: transparent;
+  font-weight: 650;
+  cursor: pointer;
+}
+
 main {
-  width: min(1180px, calc(100vw - 32px));
+  width: min(1380px, calc(100vw - 32px));
   margin: 0 auto;
-  padding: 28px 0 56px;
+  padding: 30px 0 64px;
 }
 
 section {
@@ -65,19 +92,22 @@ section {
 }
 
 .hero {
-  padding: 24px 0 10px;
+  display: grid;
+  gap: 8px;
+  padding: 18px 0 4px;
 }
 
 h1 {
   margin: 0 0 8px;
-  font-size: clamp(30px, 5vw, 58px);
-  line-height: 1;
+  font-size: 44px;
+  line-height: 1.04;
   letter-spacing: 0;
 }
 
 h2 {
   margin: 0 0 14px;
-  font-size: 22px;
+  font-size: 21px;
+  letter-spacing: 0;
 }
 
 p {
@@ -97,6 +127,8 @@ p {
 }
 
 input,
+textarea,
+select,
 button {
   min-height: 40px;
   border: 1px solid var(--line);
@@ -104,28 +136,350 @@ button {
   font: inherit;
 }
 
-input {
+input,
+textarea,
+select {
   width: 100%;
   padding: 0 12px;
   background: white;
 }
 
+select {
+  min-width: 0;
+}
+
+textarea {
+  min-height: 112px;
+  padding-top: 10px;
+  resize: vertical;
+}
+
 button {
-  padding: 0 14px;
+  padding: 0 15px;
   border-color: var(--accent);
   color: white;
   background: var(--accent);
   cursor: pointer;
 }
 
+button:hover {
+  background: var(--accent-strong);
+}
+
+button.danger {
+  border-color: #9d1c1c;
+  background: #9d1c1c;
+}
+
+button.danger:hover {
+  background: #7b1515;
+}
+
+.admin-actions,
+.admin-panel,
+.contribute {
+  padding: 16px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--panel);
+}
+
+.admin-mode {
+  position: sticky;
+  top: 65px;
+  z-index: 4;
+  display: flex;
+  gap: 14px;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 14px;
+  border: 2px solid #9d1c1c;
+  border-radius: 8px;
+  color: #7b1515;
+  background: #fff3f3;
+  box-shadow: 0 10px 24px rgba(157, 28, 28, 0.16);
+}
+
+.admin-mode strong {
+  letter-spacing: 0;
+}
+
+.admin-mode a,
+.admin-mode .nav-action button {
+  color: #7b1515;
+  font-weight: 750;
+}
+
+.admin-actions form {
+  display: flex;
+  gap: 10px;
+}
+
+.media-admin-primary {
+  border-color: rgba(157, 28, 28, 0.38);
+  background: #fff7f7;
+}
+
+.media-admin-primary form {
+  display: block;
+}
+
+button.hide-current {
+  display: inline-flex;
+  width: 100%;
+  min-height: 54px;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  font-size: 17px;
+  font-weight: 850;
+}
+
+button.hide-current span {
+  font-size: 32px;
+  line-height: 1;
+}
+
+.contribute form {
+  display: grid;
+  gap: 12px;
+}
+
+.contribute label {
+  display: grid;
+  gap: 7px;
+  color: var(--muted);
+  font-weight: 650;
+}
+
+.hidden-list {
+  display: grid;
+  gap: 10px;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.hidden-list li {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 12px;
+  align-items: center;
+  padding: 10px 12px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: #fff;
+}
+
+.hidden-list code {
+  overflow-wrap: anywhere;
+}
+
+.duplicate-groups {
+  display: grid;
+  gap: 14px;
+}
+
+.duplicate-group {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 12px 18px;
+  align-items: start;
+  padding: 16px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--panel);
+}
+
+.duplicate-group h2 {
+  margin-bottom: 4px;
+}
+
+.duplicate-list {
+  grid-column: 1 / -1;
+  display: grid;
+  gap: 6px;
+  margin: 0;
+  padding-left: 20px;
+}
+
+.duplicate-list a {
+  overflow-wrap: anywhere;
+}
+
+.people-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+  gap: 16px;
+}
+
+.person-card {
+  display: grid;
+  gap: 14px;
+  padding: 16px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: white;
+  box-shadow: var(--shadow);
+}
+
+.person-card-saved {
+  opacity: 0;
+  transform: translateY(-6px);
+  transition: opacity 160ms ease, transform 160ms ease;
+}
+
+.person-card-head {
+  display: flex;
+  gap: 12px;
+  align-items: start;
+  justify-content: space-between;
+}
+
+.person-card-head h2 {
+  margin: 0 0 4px;
+  font-size: 20px;
+}
+
+.person-card-head code {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.person-job {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  align-items: center;
+  margin: 0;
+  padding: 8px 10px;
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  background: #f8faf8;
+  font-size: 13px;
+}
+
+.person-job strong {
+  color: var(--accent);
+}
+
+.person-job span {
+  color: var(--muted);
+}
+
+.person-job-running,
+.person-job-queued {
+  border-color: rgba(35, 97, 79, 0.32);
+  background: #eef7f3;
+}
+
+.person-job-done {
+  border-color: rgba(35, 97, 79, 0.22);
+}
+
+.person-job-error {
+  border-color: rgba(157, 28, 28, 0.36);
+  background: #fff3f3;
+}
+
+.person-job-error strong {
+  color: #7b1515;
+}