Explorar el Código

fix: drop CSP unsafe-inline/unsafe-eval via nonces + Alpine CSP build (SEC_REVIEW F24)

Per-request CSP nonce minted by CspMiddleware and stamped on the only
remaining inline <script> (FOUC handler in layout.twig); the static CSP
block moves out of Caddy because the nonce changes per response. Alpine
switches to @alpinejs/csp, which rules out Function() and so doesn't
need 'unsafe-eval'; every component is now registered via Alpine.data()
in app.js, and templates reference component/method names rather than
inline expressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa hace 4 días
padre
commit
193f6463a4

+ 3 - 9
ui/docker/Caddyfile

@@ -15,6 +15,9 @@
     encode zstd gzip
 
     # ── Security headers (M14) ──────────────────────────────────────────
+    # CSP is set per-response by `App\Http\CspMiddleware` so the
+    # `script-src 'nonce-…'` value can change per request, dropping
+    # `'unsafe-inline'` / `'unsafe-eval'` (SEC_REVIEW F24).
     header {
         -Server
         -X-Powered-By
@@ -22,15 +25,6 @@
         X-Frame-Options "DENY"
         Referrer-Policy "strict-origin-when-cross-origin"
         Permissions-Policy "geolocation=(), microphone=(), camera=()"
-        # CSP for the ui:
-        #   - script-src needs 'unsafe-eval' for Alpine.js v3's Function()
-        #     constructor. Migrating to @alpinejs/csp would let us drop it
-        #     but requires rewriting every x-data="..." inline expression.
-        #     Documented trade-off.
-        #   - style-src 'unsafe-inline' for inline style attrs that drive
-        #     score bars and dynamic widths.
-        #   - img-src data: for inline SVG icons.
-        Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
     }
 
     @prod expression `{env.APP_ENV} == "production"`

+ 11 - 10
ui/package-lock.json

@@ -7,8 +7,9 @@
     "": {
       "name": "irdb-ui",
       "version": "0.1.0",
+      "license": "Apache-2.0",
       "dependencies": {
-        "alpinejs": "^3.13.0",
+        "@alpinejs/csp": "^3.13.0",
         "chart.js": "^4.4.0",
         "htmx.org": "^1.9.0"
       },
@@ -32,6 +33,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/@alpinejs/csp": {
+      "version": "3.15.12",
+      "resolved": "https://registry.npmjs.org/@alpinejs/csp/-/csp-3.15.12.tgz",
+      "integrity": "sha512-9jielHzVPqMlO9zQ6K1WtdhSHVX5OZfq5ZDUSVZfY10VnXRoRBx8nOadWjsG1xev/kz4EPaNN9eDFWtZSvJHxA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "~3.1.1"
+      }
+    },
     "node_modules/@esbuild/aix-ppc64": {
       "version": "0.25.12",
       "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -572,15 +582,6 @@
       "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
       "license": "MIT"
     },
-    "node_modules/alpinejs": {
-      "version": "3.15.11",
-      "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.11.tgz",
-      "integrity": "sha512-m26gkTg/MId8O+F4jHKK3vB3SjbFxxk/JHP+qzmw1H6aQrZuPAg4CUoAefnASzzp/eNroBjrRQe7950bNeaBJw==",
-      "license": "MIT",
-      "dependencies": {
-        "@vue/reactivity": "~3.1.1"
-      }
-    },
     "node_modules/any-promise": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",

+ 1 - 1
ui/package.json

@@ -20,7 +20,7 @@
     "tailwindcss": "^3.4.0"
   },
   "dependencies": {
-    "alpinejs": "^3.13.0",
+    "@alpinejs/csp": "^3.13.0",
     "chart.js": "^4.4.0",
     "htmx.org": "^1.9.0"
   }

+ 571 - 56
ui/resources/js/app.js

@@ -1,4 +1,8 @@
-import Alpine from 'alpinejs';
+// CSP-friendly Alpine build: forbids `Function()` / arbitrary inline
+// expressions, so every component lives here as `Alpine.data(...)` and
+// templates reference component/method names only. Lets the UI ship a
+// CSP without `'unsafe-eval'` or `'unsafe-inline'` (SEC_REVIEW F24).
+import Alpine from '@alpinejs/csp';
 import 'htmx.org';
 import {
     Chart,
@@ -17,8 +21,6 @@ import {
     Filler,
 } from 'chart.js';
 
-// Dark mode toggle. Layout's inline <head> script handles the FOUC-free
-// initial paint; this just wires the toggle button.
 function applyTheme(theme) {
     if (theme === 'dark') {
         document.documentElement.classList.add('dark');
@@ -39,7 +41,6 @@ document.addEventListener('click', (e) => {
     applyTheme(next);
 });
 
-// htmx: send the per-session CSRF token on every state-changing request.
 document.body.addEventListener('htmx:configRequest', (e) => {
     const meta = document.querySelector('meta[name="csrf-token"]');
     if (meta && meta.content) {
@@ -47,9 +48,6 @@ document.body.addEventListener('htmx:configRequest', (e) => {
     }
 });
 
-// Dashboard charts. Each canvas carries its data in a data-attribute
-// (server-pre-bucketed; no AJAX). Chart.js is tree-shaken to just the
-// pieces we use so the bundle stays small.
 Chart.register(
     BarController,
     BarElement,
@@ -66,23 +64,17 @@ Chart.register(
     Filler,
 );
 
-// Expose Chart globally so per-page inline scripts (e.g. the policy
-// score-distribution component) can render charts without re-bundling.
-window.Chart = Chart;
-
-// Faded "glass" palette: Tailwind -400 tones for a powdery, translucent
-// feel. Emerald-400 (#34d399) matches the mid-stop of the logo's gradient.
 const PIE_COLORS = [
-    '#34d399', // emerald-400  (matches logo's mid-stop)
-    '#22d3ee', // cyan-400
-    '#818cf8', // indigo-400
-    '#a78bfa', // violet-400
-    '#f472b6', // pink-400
-    '#fb7185', // rose-400
-    '#fbbf24', // amber-400
-    '#a3e635', // lime-400
-    '#2dd4bf', // teal-400
-    '#60a5fa', // blue-400
+    '#34d399',
+    '#22d3ee',
+    '#818cf8',
+    '#a78bfa',
+    '#f472b6',
+    '#fb7185',
+    '#fbbf24',
+    '#a3e635',
+    '#2dd4bf',
+    '#60a5fa',
 ];
 
 function chartTheme() {
@@ -124,7 +116,7 @@ function renderReportsChart() {
             datasets: [{
                 label: 'reports',
                 data,
-                backgroundColor: 'rgba(52, 211, 153, 0.7)', // emerald-400 @ 70% — frosted bar
+                backgroundColor: 'rgba(52, 211, 153, 0.7)',
                 borderColor: 'rgba(52, 211, 153, 0.9)',
                 borderWidth: 1,
                 borderRadius: 4,
@@ -161,7 +153,7 @@ function renderPieChart(canvasId) {
             datasets: [{
                 data,
                 backgroundColor: colors,
-                borderColor: 'rgba(255,255,255,0.25)', // crisper glass-segment seam
+                borderColor: 'rgba(255,255,255,0.25)',
                 borderWidth: 1.5,
             }],
         },
@@ -200,8 +192,6 @@ function renderBlockedIpsChart() {
     const series = Array.isArray(payload.series) ? payload.series : [];
     if (days.length === 0 || series.length === 0) return;
 
-    // Day-of-month for axis ticks; the ISO date is preserved in the
-    // tooltip title via the closure on `days`.
     const labels = days.map((iso) => {
         const d = new Date(iso + 'T00:00:00Z');
         return isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
@@ -211,8 +201,8 @@ function renderBlockedIpsChart() {
         return {
             label: row.category || '—',
             data: Array.isArray(row.counts) ? row.counts.map((c) => c || 0) : [],
-            backgroundColor: hexToRgba(colour, 0.7), // frosted fill
-            borderColor: colour,                     // crisp glass edge
+            backgroundColor: hexToRgba(colour, 0.7),
+            borderColor: colour,
             borderWidth: 1,
         };
     });
@@ -255,14 +245,6 @@ document.addEventListener('DOMContentLoaded', () => {
     renderBlockedIpsChart();
 });
 
-// Locale-aware <time> rendering. Templates emit `<time class="irdb-dt"
-// datetime="<iso>">…</iso></time>`; the text content holds the raw ISO
-// string as a no-JS fallback. This pass replaces it with the user's
-// browser locale formatting, with an optional configured fallback (set
-// via UI_LOCALE on the html data attribute) appended so browser locale
-// wins but a deployment can still ensure something sensible if the
-// browser's preference isn't supported. `data-irdb-dt-format="date"`
-// switches to date-only output.
 function getDateLocales() {
     const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
     const locales = [];
@@ -312,12 +294,40 @@ function formatTimes(root) {
 document.addEventListener('DOMContentLoaded', () => formatTimes(document));
 document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
 
-// Sortable tables. Tables marked with `data-sortable-table="<id>"` get
-// click-to-sort headers. Sort state lives in sessionStorage under
-// `irdb-sort:<id>` and is wiped on the login page so logging out
-// forgets it. The comparator is selected by the th's `data-sort-type`
-// (`string` | `number` | `date`); cells may override the displayed
-// text via `data-sort-value`.
+// Audit page filter form: ISO ⇄ datetime-local round-tripping. Used to
+// live as an inline <script> in audit/index.twig; moved here so the page
+// has zero inline JS.
+function initAuditFilterForm() {
+    const form = document.getElementById('audit-filter-form');
+    if (!form) return;
+    function pad(n) { return String(n).padStart(2, '0'); }
+    function isoToLocalInput(iso) {
+        if (!iso) return '';
+        const d = new Date(iso);
+        if (isNaN(d.getTime())) return '';
+        return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+            + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
+    }
+    function localInputToIso(value) {
+        if (!value) return '';
+        const d = new Date(value);
+        if (isNaN(d.getTime())) return '';
+        return d.toISOString();
+    }
+    form.querySelectorAll('input[data-irdb-iso-filter]').forEach((el) => {
+        const iso = el.getAttribute('data-irdb-iso-filter');
+        if (iso) el.value = isoToLocalInput(iso);
+    });
+    form.addEventListener('submit', () => {
+        form.querySelectorAll('input[type="datetime-local"]').forEach((el) => {
+            if (el.value) el.value = localInputToIso(el.value);
+        });
+    });
+}
+document.addEventListener('DOMContentLoaded', initAuditFilterForm);
+
+// Sortable tables. Templates mark a table with `data-sortable-table="<id>"`
+// and headers with `data-sort-key`; sort state lives in sessionStorage.
 const SORT_KEY_PREFIX = 'irdb-sort:';
 
 function readSortState(tableId) {
@@ -357,7 +367,6 @@ function cellSortValue(cell, type) {
     if (!cell) return type === 'number' ? Number.NEGATIVE_INFINITY : '';
     const explicit = cell.getAttribute('data-sort-value');
     if (explicit !== null) return explicit;
-    // <time datetime="..."> is the canonical date carrier.
     const timeEl = cell.querySelector('time[datetime]');
     if (timeEl) return timeEl.getAttribute('datetime') || '';
     return (cell.textContent || '').trim();
@@ -370,7 +379,7 @@ function compareValues(a, b, type) {
         const aNaN = Number.isNaN(na);
         const bNaN = Number.isNaN(nb);
         if (aNaN && bNaN) return 0;
-        if (aNaN) return 1;   // empty/—  goes last on asc
+        if (aNaN) return 1;
         if (bNaN) return -1;
         return na - nb;
     }
@@ -384,13 +393,10 @@ function compareValues(a, b, type) {
         if (bNaN) return -1;
         return ta - tb;
     }
-    // string
     return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
 }
 
 function findSortableThs(table) {
-    // Only <th> in the *first* thead row (avoid scanning nested tables
-    // — the score-chart has its own SVG layout, not a sortable table).
     const head = table.tHead;
     if (!head || !head.rows.length) return [];
     return Array.from(head.rows[0].cells).filter((c) => c.hasAttribute('data-sort-key'));
@@ -406,10 +412,6 @@ function applySort(table, key, dir) {
     const tbody = table.tBodies[0];
     if (!tbody) return;
 
-    // Treat each contiguous run of rows with the same `data-sort-row-group`
-    // attribute, or pairs marked with `data-sort-row-detail`, as a unit so
-    // expandable rows (e.g. the audit log payload toggle) stay glued to
-    // their parent. By default, every row sorts independently.
     const allRows = Array.from(tbody.rows);
     const groups = [];
     let current = null;
@@ -433,7 +435,6 @@ function applySort(table, key, dir) {
     groups.forEach((g) => g.forEach((r) => frag.appendChild(r)));
     tbody.appendChild(frag);
 
-    // Update indicators.
     ths.forEach((t) => {
         const ind = t.querySelector('.sort-indicator');
         const isActive = t === th;
@@ -479,8 +480,6 @@ function initSortableTables(root) {
 }
 
 document.addEventListener('DOMContentLoaded', () => {
-    // The login page is the canonical "logged out" landing spot —
-    // wipe any sort state left over from the previous session.
     if (window.location && window.location.pathname === '/login') {
         clearAllSortState();
     }
@@ -488,5 +487,521 @@ document.addEventListener('DOMContentLoaded', () => {
 });
 document.body.addEventListener('htmx:afterSettle', (e) => initSortableTables(e.target));
 
-window.Alpine = Alpine;
+// ────────────────────────────────────────────────────────────────────
+// Alpine.js components — registered up-front because the CSP build only
+// resolves `x-data="name"` against names registered via Alpine.data().
+// ────────────────────────────────────────────────────────────────────
+
+// Generic open/close toggle (popovers, modals, side-panes). Optional
+// `data-initial-open="1"` on the root element starts in the open state.
+Alpine.data('toggle', () => ({
+    open: false,
+    init() {
+        if (this.$el.dataset.initialOpen === '1') {
+            this.open = true;
+        }
+    },
+    show() { this.open = true; },
+    hide() { this.open = false; },
+    flip() { this.open = !this.open; },
+}));
+
+// Single-row expander — multiple rows share one component, only one row
+// expanded at a time. Each expander button passes its own row id.
+Alpine.data('rowExpander', () => ({
+    open: null,
+    toggle(id) {
+        this.open = (this.open === id ? null : id);
+    },
+    isOpen(id) {
+        return this.open === id;
+    },
+}));
+
+// Form section switcher driven by a <select> bound via `x-model="kind"`.
+// `data-initial-kind="ip"` chooses the starting branch.
+Alpine.data('kindSwitcher', () => ({
+    kind: '',
+    init() {
+        this.kind = this.$el.dataset.initialKind || '';
+    },
+    isKind(value) {
+        return this.kind === value;
+    },
+}));
+
+// Submit-once guard: disables the submit button on form submission.
+Alpine.data('submitGuard', () => ({
+    submitting: false,
+    onSubmit() { this.submitting = true; },
+    get notSubmitting() { return !this.submitting; },
+}));
+
+// "Type the magic word to confirm" dangerous-action component used by
+// the seed-demo and purge buttons in the settings page.
+Alpine.data('dangerousAction', () => ({
+    open: false,
+    confirm: '',
+    submitting: false,
+    expectedConfirm: '',
+    init() {
+        this.expectedConfirm = this.$el.dataset.expectedConfirm || '';
+    },
+    show() { this.open = true; },
+    hide() {
+        this.open = false;
+        this.confirm = '';
+    },
+    onSubmit() { this.submitting = true; },
+    get blocked() {
+        return this.confirm !== this.expectedConfirm || this.submitting;
+    },
+    get notSubmitting() { return !this.submitting; },
+}));
+
+// Login page: initially-open if OIDC is disabled, hidden behind a
+// "Use local sign-in" toggle otherwise. The PHP page sets
+// `data-initial-open="1"` when there is no OIDC.
+Alpine.data('loginForm', () => ({
+    open: false,
+    init() {
+        if (this.$el.dataset.initialOpen === '1') {
+            this.open = true;
+        }
+        this.$watch('open', (v) => {
+            if (v) {
+                this.$nextTick(() => {
+                    if (this.$refs.usernameInput) {
+                        this.$refs.usernameInput.focus();
+                    }
+                });
+            }
+        });
+    },
+    flip() { this.open = !this.open; },
+    get toggleLabel() {
+        return this.open ? 'Hide local sign-in' : 'Use local sign-in';
+    },
+}));
+
+// Categories edit page: live preview of the decay function.
+Alpine.data('decayPreview', () => ({
+    fn: 'exponential',
+    param: 14,
+    init() {
+        const ds = this.$el.dataset;
+        this.fn = ds.decayFn === 'linear' ? 'linear' : 'exponential';
+        this.param = parseFloat(ds.decayParam) || 14;
+    },
+    decay(ageDays) {
+        const p = Math.max(0.1, Number(this.param) || 0.1);
+        if (this.fn === 'linear') {
+            return Math.max(0, 1 - ageDays / p);
+        }
+        return Math.pow(0.5, ageDays / p);
+    },
+    path() {
+        const x0 = 40, y0 = 10, w = 600, h = 200;
+        const points = [];
+        for (let i = 0; i <= 60; i++) {
+            const x = x0 + (i / 60) * w;
+            const y = y0 + (1 - this.decay(i)) * h;
+            points.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`);
+        }
+        return points.join(' ');
+    },
+    get paramLabel() {
+        return this.fn === 'linear' ? 'Days to zero' : 'Half-life days';
+    },
+}));
+
+// Policies edit page: preview of the rendered blocklist for a policy.
+Alpine.data('policyPreview', () => ({
+    loading: true,
+    count: 0,
+    sample: [],
+    policyId: 0,
+    init() {
+        this.policyId = parseInt(this.$el.dataset.policyId, 10) || 0;
+        this.load();
+    },
+    async load() {
+        this.loading = true;
+        try {
+            const res = await fetch('/app/policies/' + this.policyId + '/preview-proxy', { credentials: 'same-origin' });
+            if (!res.ok) throw new Error('preview ' + res.status);
+            const data = await res.json();
+            this.count = data.count || 0;
+            const items = Array.isArray(data.sample) ? data.sample : [];
+            this.sample = items.map((raw, idx) => this.shapeEntry(raw, idx));
+        } catch (e) {
+            this.count = 0;
+            this.sample = [{ key: 'err', label: '(preview unavailable)', expiry: '', tooltip: '' }];
+        } finally {
+            this.loading = false;
+        }
+    },
+    get notLoading() { return !this.loading; },
+    shapeEntry(raw, idx) {
+        if (typeof raw === 'string') {
+            return { key: 'r' + idx, label: raw, expiry: '', tooltip: '' };
+        }
+        const ip = raw.ip_or_cidr || '';
+        let expiry = '';
+        let tooltip = '';
+        if (raw.expires_at) {
+            const formatted = this.formatExpiry(raw.expires_at);
+            expiry = (raw.expires_estimated ? '~ ' : '') + formatted;
+            tooltip = (raw.expires_estimated
+                ? 'Estimated falls-off date (assumes no further reports). ISO: '
+                : 'Configured manual block expiry. ISO: ') + raw.expires_at;
+        } else if (raw.reason === 'manual') {
+            expiry = 'never';
+            tooltip = 'Manual block has no configured expiry';
+        } else {
+            expiry = '—';
+            tooltip = 'Score does not decay below threshold (threshold ≤ 0)';
+        }
+        return { key: 'r' + idx + ':' + ip, label: ip, expiry, tooltip };
+    },
+    formatExpiry(iso) {
+        if (!iso) return '';
+        const d = new Date(iso);
+        if (isNaN(d.getTime())) return iso;
+        try {
+            return new Intl.DateTimeFormat(getDateLocales(), {
+                year: 'numeric', month: '2-digit', day: '2-digit',
+                hour: '2-digit', minute: '2-digit',
+            }).format(d);
+        } catch (_) { return iso; }
+    },
+}));
+
+// Policies edit page: per-category histogram with shaded "blocked"
+// regions to the right of each threshold.
+Alpine.data('policyScoreDistribution', () => ({
+    empty: false,
+    chart: null,
+    policyId: 0,
+    init() {
+        this.policyId = parseInt(this.$el.dataset.policyId, 10) || 0;
+        this.load();
+    },
+    async load() {
+        try {
+            const res = await fetch('/app/policies/' + this.policyId + '/score-distribution-proxy', { credentials: 'same-origin' });
+            if (!res.ok) throw new Error('distribution ' + res.status);
+            const data = await res.json();
+            this.render(data);
+        } catch (e) {
+            this.empty = true;
+            if (this.chart) { this.chart.destroy(); this.chart = null; }
+        }
+    },
+    render(data) {
+        const CATEGORY_COLORS = [
+            '#f87171', '#fbbf24', '#facc15', '#4ade80',
+            '#38bdf8', '#a78bfa', '#f472b6', '#2dd4bf',
+        ];
+        const bucketSize = Number(data.bucket_size) || 5;
+        const thresholds = Array.isArray(data.thresholds) ? data.thresholds : [];
+        const categories = Array.isArray(data.categories) ? data.categories : [];
+        const overallMaxScore = Number(data.overall_max_score) || Number(data.max_score) || 0;
+
+        const thresholdByCat = {};
+        const thresholdBySlug = {};
+        for (const t of thresholds) {
+            if (typeof t.threshold !== 'number') continue;
+            if (t.category_id != null) thresholdByCat[t.category_id] = t.threshold;
+            if (t.category_slug) thresholdBySlug[t.category_slug] = t.threshold;
+        }
+
+        const categoriesById = {};
+        for (const c of categories) {
+            if (c.category_id != null) categoriesById[c.category_id] = c;
+        }
+
+        const onChart = [];
+        for (const t of thresholds) {
+            if (typeof t.threshold !== 'number') continue;
+            const cat = categoriesById[t.category_id];
+            onChart.push({
+                category_id: t.category_id,
+                category_slug: t.category_slug || ('#' + t.category_id),
+                threshold: t.threshold,
+                buckets: cat && Array.isArray(cat.buckets) ? cat.buckets : [],
+            });
+        }
+        this.empty = onChart.length === 0;
+
+        let upperX = overallMaxScore;
+        let yMax = 0;
+        for (const oc of onChart) {
+            if (oc.threshold > upperX) upperX = oc.threshold;
+            for (const b of oc.buckets) {
+                const start = Number(b.start) || 0;
+                const cnt = Number(b.count) || 0;
+                if (start + bucketSize > upperX) upperX = start + bucketSize;
+                if (cnt > yMax) yMax = cnt;
+            }
+        }
+        if (yMax <= 0) yMax = 1;
+        if (upperX <= 0) upperX = bucketSize;
+        upperX = upperX + bucketSize;
+
+        const datasets = [];
+        onChart.forEach((oc, i) => {
+            const color = CATEGORY_COLORS[i % CATEGORY_COLORS.length];
+            datasets.push({
+                label: oc.category_slug + ' ≥ ' + oc.threshold,
+                data: [
+                    { x: oc.threshold, y: yMax },
+                    { x: upperX, y: yMax },
+                ],
+                borderColor: hexToRgba(color, 0.45),
+                backgroundColor: hexToRgba(color, 0.10),
+                borderWidth: 1,
+                pointRadius: 0,
+                pointHoverRadius: 0,
+                fill: 'origin',
+                tension: 0,
+                stepped: false,
+                spanGaps: false,
+                order: 5 + i,
+                _isThresholdRegion: true,
+            });
+
+            const points = oc.buckets.map((b) => ({
+                x: Number(b.start) || 0,
+                y: Number(b.count) || 0,
+            }));
+            datasets.push({
+                label: oc.category_slug,
+                data: points,
+                borderColor: color,
+                backgroundColor: color,
+                borderWidth: 2,
+                tension: 0.25,
+                fill: false,
+                pointRadius: 3,
+                pointHoverRadius: 5,
+                pointBackgroundColor: color,
+                order: i,
+            });
+        });
+
+        const t = chartTheme();
+        const canvas = this.$refs.canvas;
+        if (!canvas) return;
+        if (this.chart) {
+            this.chart.destroy();
+        }
+        this.chart = new Chart(canvas, {
+            type: 'line',
+            data: { datasets },
+            options: {
+                responsive: true,
+                maintainAspectRatio: false,
+                parsing: false,
+                plugins: {
+                    legend: {
+                        position: 'bottom',
+                        labels: {
+                            color: t.legendColor,
+                            boxWidth: 12,
+                            font: { size: 11 },
+                            filter: (item, chartData) => {
+                                const ds = chartData.datasets[item.datasetIndex];
+                                return !ds || !ds._isThresholdRegion;
+                            },
+                        },
+                    },
+                    tooltip: {
+                        filter: (item) => !item.dataset || !item.dataset._isThresholdRegion,
+                        callbacks: {
+                            title: (items) => {
+                                if (!items.length) return '';
+                                const it = items[0];
+                                const start = it.parsed.x;
+                                return it.dataset.label + ' — score ' + start + '–' + (start + bucketSize);
+                            },
+                            label: (it) => it.parsed.y + ' IP' + (it.parsed.y === 1 ? '' : 's'),
+                        },
+                    },
+                },
+                scales: {
+                    x: {
+                        type: 'linear',
+                        min: 0,
+                        max: upperX,
+                        title: { display: true, text: 'score', color: t.tickColor },
+                        ticks: { color: t.tickColor, stepSize: bucketSize },
+                        grid: { color: t.gridColor },
+                    },
+                    y: {
+                        beginAtZero: true,
+                        title: { display: true, text: 'IPs', color: t.tickColor },
+                        ticks: { color: t.tickColor, precision: 0 },
+                        grid: { color: t.gridColor },
+                    },
+                },
+            },
+        });
+    },
+}));
+
+// IP detail page: rolling decay-aware score plotted across week / month
+// / all / future ranges. Reads its inputs from data attributes set by
+// the template.
+Alpine.data('scoreOverTime', () => ({
+    range: 'month',
+    ranges: [
+        { id: 'week',   label: 'Week' },
+        { id: 'month',  label: 'Month' },
+        { id: 'all',    label: 'All' },
+        { id: 'future', label: '+30d' },
+    ],
+    _now: 0,
+    _reports: [],
+    _categories: {},
+    init() {
+        const ds = this.$el.dataset;
+        let payload = { reports: [], categories: [], now: '' };
+        try {
+            if (ds.scoreChart) {
+                payload = JSON.parse(ds.scoreChart);
+            }
+        } catch (e) { /* fall through with defaults */ }
+        const DAY_MS = 24 * 3600 * 1000;
+        this._now = new Date(payload.now || Date.now()).getTime();
+        this._reports = (payload.reports || [])
+            .map(r => ({
+                t: new Date(r.at).getTime(),
+                category: r.category,
+                weight: Number(r.weight) || 1,
+            }))
+            .filter(r => Number.isFinite(r.t));
+        this._categories = (payload.categories || []).reduce((acc, c) => {
+            if (c && c.slug) {
+                acc[c.slug] = {
+                    fn: c.decay_function === 'linear' ? 'linear' : 'exponential',
+                    param: Math.max(0.1, Number(c.decay_param) || 14),
+                };
+            }
+            return acc;
+        }, {});
+        this.DAY_MS = DAY_MS;
+    },
+    setRange(id) { this.range = id; },
+    isRange(id) { return this.range === id; },
+    isFuture() { return this.range === 'future'; },
+    classForRange(id) {
+        return this.range === id
+            ? 'bg-indigo-600 text-white border-l border-slate-300 px-3 py-1 first:border-l-0 dark:border-slate-700'
+            : 'bg-white text-slate-600 hover:bg-slate-50 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 border-l border-slate-300 px-3 py-1 first:border-l-0 dark:border-slate-700';
+    },
+    decayFor(report, t) {
+        const DEFAULT = { fn: 'exponential', param: 14 };
+        const cat = this._categories[report.category] || DEFAULT;
+        const ageDays = (t - report.t) / this.DAY_MS;
+        if (ageDays < 0) return 0;
+        if (cat.fn === 'linear') return Math.max(0, 1 - ageDays / cat.param);
+        return Math.pow(0.5, ageDays / cat.param);
+    },
+    totalScoreAt(t) {
+        let total = 0;
+        for (const r of this._reports) total += r.weight * this.decayFor(r, t);
+        return total;
+    },
+    fmtDate(ts) {
+        const d = new Date(ts);
+        if (isNaN(d.getTime())) return '';
+        try {
+            return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+        } catch (e) {
+            return d.toISOString().slice(0, 10);
+        }
+    },
+    get hasReports() { return this._reports.length > 0; },
+    get hasNoReports() { return this._reports.length === 0; },
+    get bounds() {
+        const NOW = this._now;
+        const DAY = this.DAY_MS;
+        switch (this.range) {
+            case 'week':   return { start: NOW - 7 * DAY,  end: NOW };
+            case 'future': return { start: NOW,             end: NOW + 30 * DAY };
+            case 'all': {
+                const earliest = this._reports.length
+                    ? Math.min.apply(null, this._reports.map(r => r.t))
+                    : NOW - 30 * DAY;
+                return { start: Math.min(earliest, NOW - DAY), end: NOW };
+            }
+            case 'month':
+            default: return { start: NOW - 30 * DAY, end: NOW };
+        }
+    },
+    get points() {
+        const { start, end } = this.bounds;
+        const N = 120;
+        const span = end - start;
+        const out = [];
+        for (let i = 0; i <= N; i++) {
+            const t = start + (span * i) / N;
+            out.push({ t, v: this.totalScoreAt(t) });
+        }
+        return out;
+    },
+    get maxScoreDisplay() {
+        let max = 0;
+        for (const p of this.points) if (p.v > max) max = p.v;
+        return max;
+    },
+    get maxScoreLabel() {
+        return this.maxScoreDisplay.toFixed(2);
+    },
+    path() {
+        const pts = this.points;
+        if (pts.length === 0) return '';
+        const x0 = 50, y0 = 20, w = 590, h = 180;
+        const max = Math.max(this.maxScoreDisplay, 1e-6);
+        const out = [];
+        for (let i = 0; i < pts.length; i++) {
+            const x = x0 + (i / (pts.length - 1)) * w;
+            const y = y0 + (1 - pts[i].v / max) * h;
+            out.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`);
+        }
+        return out.join(' ');
+    },
+    yLabel(frac) {
+        return (this.maxScoreDisplay * frac).toFixed(2);
+    },
+    xLabel(frac) {
+        const { start, end } = this.bounds;
+        return this.fmtDate(start + (end - start) * frac);
+    },
+    xAxisCaption() {
+        switch (this.range) {
+            case 'week':   return 'last 7 days';
+            case 'month':  return 'last 30 days';
+            case 'all':    return 'all reported activity';
+            case 'future': return 'next 30 days (forecast)';
+            default:       return '';
+        }
+    },
+    rangeLabel() {
+        const opt = this.ranges.find(r => r.id === this.range);
+        return opt ? opt.label : '';
+    },
+}));
+
+// Tokens index page: copy-button for the just-issued raw token.
+Alpine.data('rawTokenCopy', () => ({
+    open: true,
+    hide() { this.open = false; },
+    copy() {
+        const el = document.getElementById('raw-token');
+        if (el) navigator.clipboard.writeText(el.value);
+    },
+}));
+
 Alpine.start();

+ 4 - 2
ui/resources/views/layout.twig

@@ -5,8 +5,10 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <meta name="csrf-token" content="{{ csrf_token|default('') }}">
     <title>{% block title %}IRDB{% endblock %}</title>
-    {# Dark-mode FOUC prevention: read localStorage before paint, set the class on <html>. #}
-    <script>
+    {# Dark-mode FOUC prevention: read localStorage before paint, set the class on <html>.
+       Has to stay inline because the bundled app.js is `defer`red — runs after layout.
+       The CSP nonce keeps this script eligible while `'unsafe-inline'` is dropped (F24). #}
+    <script nonce="{{ csp_nonce }}">
         (function () {
             try {
                 var stored = localStorage.getItem('irdb-theme');

+ 3 - 3
ui/resources/views/pages/allowlist/index.twig

@@ -26,7 +26,7 @@
     {% if can_write %}
         <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
             <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Add allowlist entry</h2>
-            <form method="post" action="/app/allowlist" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3 text-sm" x-data="{ kind: 'ip' }">
+            <form method="post" action="/app/allowlist" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3 text-sm" x-data="kindSwitcher" data-initial-kind="ip">
                 <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
                 <div>
                     <label for="al-kind" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Kind</label>
@@ -36,12 +36,12 @@
                         <option value="subnet">Subnet (CIDR)</option>
                     </select>
                 </div>
-                <div x-show="kind == 'ip'">
+                <div x-show="isKind('ip')">
                     <label for="al-ip" class="block text-xs font-medium text-slate-600 dark:text-slate-400">IP</label>
                     <input type="text" id="al-ip" name="ip" placeholder="203.0.113.5"
                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
                 </div>
-                <div x-show="kind == 'subnet'">
+                <div x-show="isKind('subnet')">
                     <label for="al-cidr" class="block text-xs font-medium text-slate-600 dark:text-slate-400">CIDR</label>
                     <input type="text" id="al-cidr" name="cidr" placeholder="10.0.0.0/8"
                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">

+ 5 - 33
ui/resources/views/pages/audit/index.twig

@@ -99,39 +99,11 @@
         </div>
     </form>
 
-    <script>
-    (function () {
-        function pad(n) { return String(n).padStart(2, '0'); }
-        function isoToLocalInput(iso) {
-            if (!iso) return '';
-            const d = new Date(iso);
-            if (isNaN(d.getTime())) return '';
-            return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
-                + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
-        }
-        function localInputToIso(value) {
-            if (!value) return '';
-            const d = new Date(value);
-            if (isNaN(d.getTime())) return '';
-            return d.toISOString();
-        }
-        document.querySelectorAll('#audit-filter-form input[data-irdb-iso-filter]').forEach((el) => {
-            const iso = el.getAttribute('data-irdb-iso-filter');
-            if (iso) el.value = isoToLocalInput(iso);
-        });
-        const form = document.getElementById('audit-filter-form');
-        if (form) {
-            form.addEventListener('submit', () => {
-                form.querySelectorAll('input[type="datetime-local"]').forEach((el) => {
-                    if (el.value) el.value = localInputToIso(el.value);
-                });
-            });
-        }
-    })();
-    </script>
+    {# Datetime-local ⇄ ISO round-tripping for the filter form lives in
+       app.js (initAuditFilterForm) so this page has no inline JS. #}
 
     {% if list %}
-        <div class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" x-data="{ open: null }">
+        <div class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" x-data="rowExpander">
             <table class="w-full text-sm" data-sortable-table="audit-index">
                 <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                     <tr>
@@ -164,14 +136,14 @@
                             <td class="px-4 py-2 align-top font-mono text-xs text-slate-500" data-sort-value="{{ ev.source_ip|default('') }}">{{ ev.source_ip|default('—') }}</td>
                             <td class="px-4 py-2 align-top text-right">
                                 {% if ev.details %}
-                                    <button type="button" x-on:click="open = (open === {{ ev.id }} ? null : {{ ev.id }})" class="rounded border border-slate-300 px-2 py-0.5 text-xs hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">View</button>
+                                    <button type="button" x-on:click="toggle({{ ev.id }})" class="rounded border border-slate-300 px-2 py-0.5 text-xs hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">View</button>
                                 {% else %}
                                     <span class="text-xs text-slate-400">—</span>
                                 {% endif %}
                             </td>
                         </tr>
                         {% if ev.details %}
-                            <tr x-show="open === {{ ev.id }}" x-cloak data-sort-row-detail>
+                            <tr x-show="isOpen({{ ev.id }})" x-cloak data-sort-row-detail>
                                 <td colspan="6" class="bg-slate-50 px-4 py-3 dark:bg-slate-950">
                                     {% if ev.details.changes is defined and ev.details.changes is iterable and ev.details.changes|length > 0 %}
                                         <div class="mb-3">

+ 4 - 31
ui/resources/views/pages/categories/edit.twig

@@ -4,10 +4,9 @@
 
 {% block content %}
 <div class="mx-auto max-w-3xl"
-     x-data="decayPreview({
-        fn: '{{ category.decay_function }}',
-        param: {{ category.decay_param }},
-     })">
+     x-data="decayPreview"
+     data-decay-fn="{{ category.decay_function }}"
+     data-decay-param="{{ category.decay_param }}">
     <a href="/app/categories" class="text-sm text-slate-500 hover:underline dark:text-slate-400">← Back to categories</a>
     <h1 class="mt-3 text-2xl font-semibold tracking-tight font-mono">{{ category.slug }}</h1>
 
@@ -44,7 +43,7 @@
             </div>
             <div>
                 <label for="cat-param" class="block text-xs font-medium text-slate-600 dark:text-slate-400">
-                    <span x-text="fn === 'linear' ? 'Days to zero' : 'Half-life days'"></span>
+                    <span x-text="paramLabel"></span>
                 </label>
                 <input type="number" id="cat-param" name="decay_param" step="0.1" min="0.1" x-model.number="param" {% if not can_write %}readonly{% endif %}
                        class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
@@ -107,30 +106,4 @@
         {% endif %}
     </form>
 </div>
-
-<script>
-window.decayPreview = function (initial) {
-    return {
-        fn: initial.fn,
-        param: initial.param,
-        decay(ageDays) {
-            const p = Math.max(0.1, Number(this.param) || 0.1);
-            if (this.fn === 'linear') {
-                return Math.max(0, 1 - ageDays / p);
-            }
-            return Math.pow(0.5, ageDays / p);
-        },
-        path() {
-            const x0 = 40, y0 = 10, w = 600, h = 200;
-            const points = [];
-            for (let i = 0; i <= 60; i++) {
-                const x = x0 + (i / 60) * w;
-                const y = y0 + (1 - this.decay(i)) * h;
-                points.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`);
-            }
-            return points.join(' ');
-        },
-    };
-};
-</script>
 {% endblock %}

+ 3 - 3
ui/resources/views/pages/consumers/edit.twig

@@ -64,7 +64,7 @@
             <a href="/app/audit?subject_kind=consumer&amp;subject_id={{ consumer.id }}" class="whitespace-nowrap text-xs font-medium text-indigo-600 hover:underline dark:text-indigo-400">View all in audit log →</a>
         </header>
         {% if audit_items|default([])|length > 0 %}
-            <div x-data="{ open: null }">
+            <div x-data="rowExpander">
                 <table class="w-full text-sm">
                     <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                         <tr>
@@ -87,14 +87,14 @@
                                 </td>
                                 <td class="px-5 py-2 align-top text-right">
                                     {% if ev.details %}
-                                        <button type="button" x-on:click="open = (open === {{ ev.id }} ? null : {{ ev.id }})" class="rounded border border-slate-300 px-2 py-0.5 text-xs hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">View</button>
+                                        <button type="button" x-on:click="toggle({{ ev.id }})" class="rounded border border-slate-300 px-2 py-0.5 text-xs hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">View</button>
                                     {% else %}
                                         <span class="text-xs text-slate-400">—</span>
                                     {% endif %}
                                 </td>
                             </tr>
                             {% if ev.details %}
-                                <tr x-show="open === {{ ev.id }}" x-cloak>
+                                <tr x-show="isOpen({{ ev.id }})" x-cloak>
                                     <td colspan="4" class="bg-slate-50 px-5 py-3 dark:bg-slate-950">
                                         {% if ev.details.changes is defined and ev.details.changes is iterable and ev.details.changes|length > 0 %}
                                             <div class="mb-2 text-[0.7rem] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Changes</div>

+ 16 - 141
ui/resources/views/pages/ips/detail.twig

@@ -41,10 +41,10 @@
                     <button type="submit" class="rounded-md border border-emerald-300 px-3 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:border-emerald-700 dark:text-emerald-300 dark:hover:bg-slate-800">Remove from allowlist</button>
                 </form>
             {% else %}
-                <div x-data="{ open: false }" class="inline">
-                    <button type="button" x-on:click="open = true" class="rounded-md border border-emerald-300 px-3 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:border-emerald-700 dark:text-emerald-300 dark:hover:bg-slate-800">Add to allowlist…</button>
+                <div x-data="toggle" class="inline">
+                    <button type="button" x-on:click="show()" class="rounded-md border border-emerald-300 px-3 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:border-emerald-700 dark:text-emerald-300 dark:hover:bg-slate-800">Add to allowlist…</button>
                     <div x-show="open" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4">
-                        <form method="post" action="/app/allowlist" x-on:click.outside="open = false" class="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900">
+                        <form method="post" action="/app/allowlist" x-on:click.outside="hide()" class="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900">
                             <h2 class="text-base font-semibold">Add {{ detail.ip }} to allowlist</h2>
                             <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
                             <input type="hidden" name="kind" value="ip">
@@ -52,7 +52,7 @@
                             <label class="mt-3 block text-xs font-medium text-slate-600 dark:text-slate-400">Reason (optional)</label>
                             <input type="text" name="reason" class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
                             <div class="mt-4 flex justify-end gap-2">
-                                <button type="button" x-on:click="open = false" class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
+                                <button type="button" x-on:click="hide()" class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
                                 <button type="submit" class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-500">Add</button>
                             </div>
                         </form>
@@ -67,10 +67,10 @@
                     <button type="submit" class="rounded-md border border-amber-300 px-3 py-1 text-xs font-medium text-amber-700 hover:bg-amber-50 dark:border-amber-700 dark:text-amber-300 dark:hover:bg-slate-800">Remove manual block</button>
                 </form>
             {% else %}
-                <div x-data="{ open: false }" class="inline">
-                    <button type="button" x-on:click="open = true" class="rounded-md border border-amber-300 px-3 py-1 text-xs font-medium text-amber-700 hover:bg-amber-50 dark:border-amber-700 dark:text-amber-300 dark:hover:bg-slate-800">Manually block…</button>
+                <div x-data="toggle" class="inline">
+                    <button type="button" x-on:click="show()" class="rounded-md border border-amber-300 px-3 py-1 text-xs font-medium text-amber-700 hover:bg-amber-50 dark:border-amber-700 dark:text-amber-300 dark:hover:bg-slate-800">Manually block…</button>
                     <div x-show="open" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4">
-                        <form method="post" action="/app/manual-blocks" x-on:click.outside="open = false" class="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900">
+                        <form method="post" action="/app/manual-blocks" x-on:click.outside="hide()" class="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900">
                             <h2 class="text-base font-semibold">Manually block {{ detail.ip }}</h2>
                             <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
                             <input type="hidden" name="kind" value="ip">
@@ -80,7 +80,7 @@
                             <label class="mt-3 block text-xs font-medium text-slate-600 dark:text-slate-400">Expires at (optional)</label>
                             <input type="datetime-local" name="expires_at" class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
                             <div class="mt-4 flex justify-end gap-2">
-                                <button type="button" x-on:click="open = false" class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
+                                <button type="button" x-on:click="hide()" class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
                                 <button type="submit" class="rounded-md bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-500">Block</button>
                             </div>
                         </form>
@@ -164,27 +164,21 @@
     </section>
 
     <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
-             x-data="scoreOverTime({
-                reports: {{ score_chart.reports|json_encode|e('html_attr') }},
-                categories: {{ score_chart.categories|json_encode|e('html_attr') }},
-                now: '{{ score_chart.now }}'
-             })">
+             x-data="scoreOverTime"
+             data-score-chart="{{ {reports: score_chart.reports, categories: score_chart.categories, now: score_chart.now}|json_encode|e('html_attr') }}">
         <div class="flex flex-wrap items-center justify-between gap-3">
             <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Score over time</h2>
             <div class="inline-flex overflow-hidden rounded-md border border-slate-300 text-xs dark:border-slate-700">
                 <template x-for="opt in ranges" :key="opt.id">
                     <button type="button"
-                            x-on:click="range = opt.id"
-                            :class="range === opt.id
-                                ? 'bg-indigo-600 text-white'
-                                : 'bg-white text-slate-600 hover:bg-slate-50 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800'"
-                            class="border-l border-slate-300 px-3 py-1 first:border-l-0 dark:border-slate-700"
+                            x-on:click="setRange(opt.id)"
+                            :class="classForRange(opt.id)"
                             x-text="opt.label"></button>
                 </template>
             </div>
         </div>
 
-        <template x-if="!hasReports">
+        <template x-if="hasNoReports">
             <p class="mt-3 text-sm text-slate-400">No reports yet — nothing to plot.</p>
         </template>
 
@@ -200,7 +194,7 @@
                     <line x1="50" y1="200" x2="640" y2="200" class="stroke-slate-300 dark:stroke-slate-700" stroke-width="1"/>
                     <line x1="50" y1="20"  x2="50"  y2="200" class="stroke-slate-300 dark:stroke-slate-700" stroke-width="1"/>
 
-                    <template x-if="range === 'future'">
+                    <template x-if="isFuture()">
                         <rect x="50" y="20" width="590" height="180" class="fill-amber-50 dark:fill-amber-900/20" />
                     </template>
 
@@ -224,8 +218,8 @@
                     <path :d="path()" stroke="currentColor" class="text-indigo-500" fill="none" stroke-width="2"/>
                 </svg>
                 <p class="mt-2 text-xs text-slate-400">
-                    <span x-text="rangeLabel()"></span> · max: <span x-text="maxScoreDisplay.toFixed(2)"></span>
-                    <template x-if="range === 'future'">
+                    <span x-text="rangeLabel()"></span> · max: <span x-text="maxScoreLabel"></span>
+                    <template x-if="isFuture()">
                         <span class="ml-2 text-amber-600 dark:text-amber-400">forecast assumes no new reports</span>
                     </template>
                 </p>
@@ -271,123 +265,4 @@
     </section>
 </div>
 
-<script>
-window.scoreOverTime = function (initial) {
-    const DAY_MS = 24 * 3600 * 1000;
-    const NOW = new Date(initial.now).getTime();
-    const REPORTS = (initial.reports || [])
-        .map(r => ({
-            t: new Date(r.at).getTime(),
-            category: r.category,
-            weight: Number(r.weight) || 1,
-        }))
-        .filter(r => Number.isFinite(r.t));
-    const CATEGORIES = (initial.categories || []).reduce((acc, c) => {
-        if (c && c.slug) {
-            acc[c.slug] = {
-                fn: c.decay_function === 'linear' ? 'linear' : 'exponential',
-                param: Math.max(0.1, Number(c.decay_param) || 14),
-            };
-        }
-        return acc;
-    }, {});
-    const DEFAULT = { fn: 'exponential', param: 14 };
-
-    function decayFor(report, t) {
-        const cat = CATEGORIES[report.category] || DEFAULT;
-        const ageDays = (t - report.t) / DAY_MS;
-        if (ageDays < 0) return 0;
-        if (cat.fn === 'linear') return Math.max(0, 1 - ageDays / cat.param);
-        return Math.pow(0.5, ageDays / cat.param);
-    }
-    function totalScoreAt(t) {
-        let total = 0;
-        for (const r of REPORTS) total += r.weight * decayFor(r, t);
-        return total;
-    }
-    function fmtDate(ts) {
-        const d = new Date(ts);
-        if (isNaN(d.getTime())) return '';
-        try {
-            return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
-        } catch (e) {
-            return d.toISOString().slice(0, 10);
-        }
-    }
-
-    return {
-        ranges: [
-            { id: 'week',   label: 'Week' },
-            { id: 'month',  label: 'Month' },
-            { id: 'all',    label: 'All' },
-            { id: 'future', label: '+30d' },
-        ],
-        range: 'month',
-        get hasReports() { return REPORTS.length > 0; },
-        get bounds() {
-            switch (this.range) {
-                case 'week':   return { start: NOW - 7 * DAY_MS,  end: NOW };
-                case 'future': return { start: NOW,                end: NOW + 30 * DAY_MS };
-                case 'all': {
-                    const earliest = REPORTS.length
-                        ? Math.min.apply(null, REPORTS.map(r => r.t))
-                        : NOW - 30 * DAY_MS;
-                    return { start: Math.min(earliest, NOW - DAY_MS), end: NOW };
-                }
-                case 'month':
-                default: return { start: NOW - 30 * DAY_MS, end: NOW };
-            }
-        },
-        get points() {
-            const { start, end } = this.bounds;
-            const N = 120;
-            const span = end - start;
-            const out = [];
-            for (let i = 0; i <= N; i++) {
-                const t = start + (span * i) / N;
-                out.push({ t, v: totalScoreAt(t) });
-            }
-            return out;
-        },
-        get maxScoreDisplay() {
-            let max = 0;
-            for (const p of this.points) if (p.v > max) max = p.v;
-            return max;
-        },
-        path() {
-            const pts = this.points;
-            if (pts.length === 0) return '';
-            const x0 = 50, y0 = 20, w = 590, h = 180;
-            const max = Math.max(this.maxScoreDisplay, 1e-6);
-            const out = [];
-            for (let i = 0; i < pts.length; i++) {
-                const x = x0 + (i / (pts.length - 1)) * w;
-                const y = y0 + (1 - pts[i].v / max) * h;
-                out.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`);
-            }
-            return out.join(' ');
-        },
-        yLabel(frac) {
-            return (this.maxScoreDisplay * frac).toFixed(2);
-        },
-        xLabel(frac) {
-            const { start, end } = this.bounds;
-            return fmtDate(start + (end - start) * frac);
-        },
-        xAxisCaption() {
-            switch (this.range) {
-                case 'week':   return 'last 7 days';
-                case 'month':  return 'last 30 days';
-                case 'all':    return 'all reported activity';
-                case 'future': return 'next 30 days (forecast)';
-                default:       return '';
-            }
-        },
-        rangeLabel() {
-            const opt = this.ranges.find(r => r.id === this.range);
-            return opt ? opt.label : '';
-        },
-    };
-};
-</script>
 {% endblock %}

+ 3 - 4
ui/resources/views/pages/login.twig

@@ -20,21 +20,20 @@
         {% endif %}
 
         {% if local_admin_enabled %}
-            <div class="mt-6" x-data="{ open: {{ oidc_enabled ? 'false' : 'true' }} }">
+            <div class="mt-6" x-data="loginForm" data-initial-open="{{ oidc_enabled ? '0' : '1' }}">
                 {% if oidc_enabled %}
                     <div class="relative my-4 text-center">
                         <span class="bg-white px-2 text-xs uppercase tracking-wider text-slate-400 dark:bg-slate-900">or</span>
                         <div class="absolute inset-x-0 top-1/2 -z-10 h-px bg-slate-200 dark:bg-slate-800"></div>
                     </div>
                     <button type="button"
-                            x-on:click="open = !open"
+                            x-on:click="flip()"
                             class="text-sm text-slate-500 underline hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">
-                        <span x-text="open ? 'Hide local sign-in' : 'Use local sign-in'"></span>
+                        <span x-text="toggleLabel"></span>
                     </button>
                 {% endif %}
                 <form x-show="open"
                       x-cloak
-                      x-init="$watch('open', (v) => { if (v) $nextTick(() => $refs.usernameInput && $refs.usernameInput.focus()); })"
                       method="post"
                       action="/login/local"
                       class="mt-4 space-y-3">

+ 3 - 3
ui/resources/views/pages/manual-blocks/index.twig

@@ -30,7 +30,7 @@
     {% if can_write %}
         <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
             <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Add manual block</h2>
-            <form method="post" action="/app/manual-blocks" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-4 text-sm" x-data="{ kind: '{{ kind|default('subnet') }}' }">
+            <form method="post" action="/app/manual-blocks" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-4 text-sm" x-data="kindSwitcher" data-initial-kind="{{ kind|default('subnet') }}">
                 <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
                 <div>
                     <label for="mb-kind" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Kind</label>
@@ -40,12 +40,12 @@
                         <option value="subnet">Subnet (CIDR)</option>
                     </select>
                 </div>
-                <div x-show="kind == 'ip'">
+                <div x-show="isKind('ip')">
                     <label for="mb-ip" class="block text-xs font-medium text-slate-600 dark:text-slate-400">IP</label>
                     <input type="text" id="mb-ip" name="ip" placeholder="203.0.113.5"
                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">
                 </div>
-                <div x-show="kind == 'subnet'">
+                <div x-show="isKind('subnet')">
                     <label for="mb-cidr" class="block text-xs font-medium text-slate-600 dark:text-slate-400">CIDR</label>
                     <input type="text" id="mb-cidr" name="cidr" placeholder="192.0.2.0/24"
                            class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono dark:border-slate-700 dark:bg-slate-950">

+ 3 - 287
ui/resources/views/pages/policies/edit.twig

@@ -94,7 +94,7 @@
     </form>
 
     <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
-             x-data="policyScoreDistribution({{ policy.id }})" x-init="load()">
+             x-data="policyScoreDistribution" data-policy-id="{{ policy.id }}">
         <div class="flex items-center justify-between">
             <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Score distribution</h2>
             <button type="button" x-on:click="load()" class="text-xs text-indigo-600 hover:underline dark:text-indigo-400">Refresh</button>
@@ -109,14 +109,14 @@
     </section>
 
     <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
-             x-data="policyPreview({{ policy.id }})" x-init="load()">
+             x-data="policyPreview" data-policy-id="{{ policy.id }}">
         <div class="flex items-center justify-between">
             <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Preview</h2>
             <button type="button" x-on:click="load()" class="text-xs text-indigo-600 hover:underline dark:text-indigo-400">Refresh</button>
         </div>
         <p class="mt-2 text-sm">
             <span x-show="loading">Loading…</span>
-            <template x-if="!loading">
+            <template x-if="notLoading">
                 <span><span class="font-mono" x-text="count"></span> entries</span>
             </template>
         </p>
@@ -136,288 +136,4 @@
     </section>
 </div>
 
-<script>
-window.policyPreview = function (id) {
-    function localeFallback() {
-        const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
-        const locales = [];
-        if (typeof navigator !== 'undefined' && navigator.language) {
-            locales.push(navigator.language);
-        }
-        if (fallback && fallback.trim()) {
-            locales.push(fallback.trim());
-        }
-        return locales.length > 0 ? locales : undefined;
-    }
-    let formatter;
-    try {
-        formatter = new Intl.DateTimeFormat(localeFallback(), {
-            year: 'numeric', month: '2-digit', day: '2-digit',
-            hour: '2-digit', minute: '2-digit',
-        });
-    } catch (e) {
-        formatter = null;
-    }
-    function formatExpiry(iso) {
-        if (!iso) return '';
-        const d = new Date(iso);
-        if (isNaN(d.getTime())) return iso;
-        if (!formatter) return iso;
-        try { return formatter.format(d); } catch (_) { return iso; }
-    }
-    function shapeEntry(raw, idx) {
-        if (typeof raw === 'string') {
-            return { key: 'r' + idx, label: raw, expiry: '', tooltip: '' };
-        }
-        const ip = raw.ip_or_cidr || '';
-        let expiry = '';
-        let tooltip = '';
-        if (raw.expires_at) {
-            const formatted = formatExpiry(raw.expires_at);
-            expiry = (raw.expires_estimated ? '~ ' : '') + formatted;
-            tooltip = (raw.expires_estimated
-                ? 'Estimated falls-off date (assumes no further reports). ISO: '
-                : 'Configured manual block expiry. ISO: ') + raw.expires_at;
-        } else if (raw.reason === 'manual') {
-            expiry = 'never';
-            tooltip = 'Manual block has no configured expiry';
-        } else {
-            expiry = '—';
-            tooltip = 'Score does not decay below threshold (threshold ≤ 0)';
-        }
-        return { key: 'r' + idx + ':' + ip, label: ip, expiry: expiry, tooltip: tooltip };
-    }
-    return {
-        loading: true,
-        count: 0,
-        sample: [],
-        async load() {
-            this.loading = true;
-            try {
-                const res = await fetch('/app/policies/' + id + '/preview-proxy', { credentials: 'same-origin' });
-                if (!res.ok) throw new Error('preview ' + res.status);
-                const data = await res.json();
-                this.count = data.count || 0;
-                const items = Array.isArray(data.sample) ? data.sample : [];
-                this.sample = items.map(shapeEntry);
-            } catch (e) {
-                this.count = 0;
-                this.sample = [{ key: 'err', label: '(preview unavailable)', expiry: '', tooltip: '' }];
-            } finally {
-                this.loading = false;
-            }
-        },
-    };
-};
-
-window.policyScoreDistribution = function (id) {
-    const CATEGORY_COLORS = [
-        '#f87171', // red-400
-        '#fbbf24', // amber-400
-        '#facc15', // yellow-400
-        '#4ade80', // green-400
-        '#38bdf8', // sky-400
-        '#a78bfa', // violet-400
-        '#f472b6', // pink-400
-        '#2dd4bf', // teal-400
-    ];
-    function chartTheme() {
-        const isDark = document.documentElement.classList.contains('dark');
-        return {
-            tickColor: isDark ? '#94a3b8' : '#475569',
-            gridColor: isDark ? 'rgba(148,163,184,0.15)' : 'rgba(148,163,184,0.3)',
-            legendColor: isDark ? '#cbd5e1' : '#334155',
-        };
-    }
-    function hexToRgba(hex, alpha) {
-        const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
-        if (!m) return hex;
-        return 'rgba(' + parseInt(m[1], 16) + ',' + parseInt(m[2], 16) + ',' + parseInt(m[3], 16) + ',' + alpha + ')';
-    }
-    return {
-        empty: false,
-        chart: null,
-        async load() {
-            try {
-                const res = await fetch('/app/policies/' + id + '/score-distribution-proxy', { credentials: 'same-origin' });
-                if (!res.ok) throw new Error('distribution ' + res.status);
-                const data = await res.json();
-                this.render(data);
-            } catch (e) {
-                this.empty = true;
-                if (this.chart) { this.chart.destroy(); this.chart = null; }
-            }
-        },
-        render(data) {
-            const bucketSize = Number(data.bucket_size) || 5;
-            const thresholds = Array.isArray(data.thresholds) ? data.thresholds : [];
-            const categories = Array.isArray(data.categories) ? data.categories : [];
-            const overallMaxScore = Number(data.overall_max_score) || Number(data.max_score) || 0;
-
-            // Index thresholds by category so we can look them up while
-            // iterating the per-category histograms. Only categories with
-            // a numeric threshold appear on the chart.
-            const thresholdByCat = {};
-            const thresholdBySlug = {};
-            for (const t of thresholds) {
-                if (typeof t.threshold !== 'number') continue;
-                if (t.category_id != null) thresholdByCat[t.category_id] = t.threshold;
-                if (t.category_slug) thresholdBySlug[t.category_slug] = t.threshold;
-            }
-
-            const categoriesById = {};
-            for (const c of categories) {
-                if (c.category_id != null) categoriesById[c.category_id] = c;
-            }
-
-            // Build the on-chart category list — only those with a threshold.
-            // Iterate over thresholds first so the legend ordering matches the
-            // policy's threshold list rather than database insertion order.
-            const onChart = [];
-            for (const t of thresholds) {
-                if (typeof t.threshold !== 'number') continue;
-                const cat = categoriesById[t.category_id];
-                onChart.push({
-                    category_id: t.category_id,
-                    category_slug: t.category_slug || ('#' + t.category_id),
-                    threshold: t.threshold,
-                    buckets: cat && Array.isArray(cat.buckets) ? cat.buckets : [],
-                });
-            }
-            this.empty = onChart.length === 0;
-
-            // Compute axis bounds: extend X to the largest threshold or score
-            // observed across the included categories. Y to the largest count.
-            let upperX = overallMaxScore;
-            let yMax = 0;
-            for (const oc of onChart) {
-                if (oc.threshold > upperX) upperX = oc.threshold;
-                for (const b of oc.buckets) {
-                    const start = Number(b.start) || 0;
-                    const cnt = Number(b.count) || 0;
-                    if (start + bucketSize > upperX) upperX = start + bucketSize;
-                    if (cnt > yMax) yMax = cnt;
-                }
-            }
-            if (yMax <= 0) yMax = 1;
-            if (upperX <= 0) upperX = bucketSize;
-            // Pad by one bucket so the rightmost shaded area has somewhere to
-            // extend into when a threshold sits exactly at the data maximum.
-            upperX = upperX + bucketSize;
-
-            const datasets = [];
-            onChart.forEach((oc, i) => {
-                const color = CATEGORY_COLORS[i % CATEGORY_COLORS.length];
-
-                // Shaded "blocked by this category" region. Drawn first
-                // (higher `order`) so the histogram lines render on top.
-                datasets.push({
-                    label: oc.category_slug + ' ≥ ' + oc.threshold,
-                    data: [
-                        { x: oc.threshold, y: yMax },
-                        { x: upperX, y: yMax },
-                    ],
-                    borderColor: hexToRgba(color, 0.45),
-                    backgroundColor: hexToRgba(color, 0.10),
-                    borderWidth: 1,
-                    pointRadius: 0,
-                    pointHoverRadius: 0,
-                    fill: 'origin',
-                    tension: 0,
-                    stepped: false,
-                    spanGaps: false,
-                    order: 5 + i,
-                    _isThresholdRegion: true,
-                });
-
-                // Per-category histogram line.
-                const points = oc.buckets.map((b) => ({
-                    x: Number(b.start) || 0,
-                    y: Number(b.count) || 0,
-                }));
-                datasets.push({
-                    label: oc.category_slug,
-                    data: points,
-                    borderColor: color,
-                    backgroundColor: color,
-                    borderWidth: 2,
-                    tension: 0.25,
-                    fill: false,
-                    pointRadius: 3,
-                    pointHoverRadius: 5,
-                    pointBackgroundColor: color,
-                    order: i,
-                });
-            });
-
-            const t = chartTheme();
-            const canvas = this.$refs.canvas;
-            if (!canvas) return;
-            if (this.chart) {
-                this.chart.destroy();
-            }
-            // Chart is registered globally by app.js; if it isn't yet
-            // (script ordering), bail silently.
-            if (typeof window.Chart === 'undefined' && typeof Chart === 'undefined') {
-                return;
-            }
-            const C = (typeof window.Chart !== 'undefined') ? window.Chart : Chart;
-            this.chart = new C(canvas, {
-                type: 'line',
-                data: { datasets },
-                options: {
-                    responsive: true,
-                    maintainAspectRatio: false,
-                    parsing: false,
-                    plugins: {
-                        legend: {
-                            position: 'bottom',
-                            labels: {
-                                color: t.legendColor,
-                                boxWidth: 12,
-                                font: { size: 11 },
-                                // Hide the synthetic threshold-region datasets;
-                                // their meaning is conveyed by the shading next
-                                // to the matching category line.
-                                filter: (item, chartData) => {
-                                    const ds = chartData.datasets[item.datasetIndex];
-                                    return !ds || !ds._isThresholdRegion;
-                                },
-                            },
-                        },
-                        tooltip: {
-                            filter: (item) => !item.dataset || !item.dataset._isThresholdRegion,
-                            callbacks: {
-                                title: (items) => {
-                                    if (!items.length) return '';
-                                    const it = items[0];
-                                    const start = it.parsed.x;
-                                    return it.dataset.label + ' — score ' + start + '–' + (start + bucketSize);
-                                },
-                                label: (it) => it.parsed.y + ' IP' + (it.parsed.y === 1 ? '' : 's'),
-                            },
-                        },
-                    },
-                    scales: {
-                        x: {
-                            type: 'linear',
-                            min: 0,
-                            max: upperX,
-                            title: { display: true, text: 'score', color: t.tickColor },
-                            ticks: { color: t.tickColor, stepSize: bucketSize },
-                            grid: { color: t.gridColor },
-                        },
-                        y: {
-                            beginAtZero: true,
-                            title: { display: true, text: 'IPs', color: t.tickColor },
-                            ticks: { color: t.tickColor, precision: 0 },
-                            grid: { color: t.gridColor },
-                        },
-                    },
-                },
-            });
-        },
-    };
-};
-</script>
 {% endblock %}

+ 3 - 3
ui/resources/views/pages/reporters/edit.twig

@@ -61,7 +61,7 @@
             <a href="/app/audit?subject_kind=reporter&amp;subject_id={{ reporter.id }}" class="whitespace-nowrap text-xs font-medium text-indigo-600 hover:underline dark:text-indigo-400">View all in audit log →</a>
         </header>
         {% if audit_items|default([])|length > 0 %}
-            <div x-data="{ open: null }">
+            <div x-data="rowExpander">
                 <table class="w-full text-sm">
                     <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
                         <tr>
@@ -84,14 +84,14 @@
                                 </td>
                                 <td class="px-5 py-2 align-top text-right">
                                     {% if ev.details %}
-                                        <button type="button" x-on:click="open = (open === {{ ev.id }} ? null : {{ ev.id }})" class="rounded border border-slate-300 px-2 py-0.5 text-xs hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">View</button>
+                                        <button type="button" x-on:click="toggle({{ ev.id }})" class="rounded border border-slate-300 px-2 py-0.5 text-xs hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">View</button>
                                     {% else %}
                                         <span class="text-xs text-slate-400">—</span>
                                     {% endif %}
                                 </td>
                             </tr>
                             {% if ev.details %}
-                                <tr x-show="open === {{ ev.id }}" x-cloak>
+                                <tr x-show="isOpen({{ ev.id }})" x-cloak>
                                     <td colspan="4" class="bg-slate-50 px-5 py-3 dark:bg-slate-950">
                                         {% if ev.details.changes is defined and ev.details.changes is iterable and ev.details.changes|length > 0 %}
                                             <div class="mb-2 text-[0.7rem] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Changes</div>

+ 14 - 14
ui/resources/views/pages/settings/index.twig

@@ -130,11 +130,11 @@
                                 </td>
                                 <td class="px-4 py-2 align-top text-right">
                                     {% if name != 'tick' %}
-                                        <form method="post" action="/app/settings/jobs/trigger/{{ name }}" class="inline" x-data="{ submitting: false }" x-on:submit="submitting = true">
+                                        <form method="post" action="/app/settings/jobs/trigger/{{ name }}" class="inline" x-data="submitGuard" x-on:submit="onSubmit()">
                                             <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
                                             <button type="submit" x-bind:disabled="submitting"
                                                     class="rounded-md border border-slate-300 px-2 py-1 text-xs hover:bg-slate-50 disabled:opacity-50 dark:border-slate-700 dark:hover:bg-slate-800">
-                                                <span x-show="!submitting">Run now</span>
+                                                <span x-show="notSubmitting">Run now</span>
                                                 <span x-show="submitting" x-cloak>Running…</span>
                                             </button>
                                         </form>
@@ -157,13 +157,13 @@
 
         <div class="mt-4 grid gap-4 md:grid-cols-2">
             <div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-900 dark:bg-emerald-950/40"
-                 x-data="{ open: false, confirm: '', submitting: false }">
+                 x-data="dangerousAction" data-expected-confirm="SEED">
                 <h3 class="text-sm font-semibold text-emerald-800 dark:text-emerald-200">Load demo data</h3>
                 <p class="mt-1 text-xs text-emerald-900/80 dark:text-emerald-200/80">
                     Inserts demo reporters, consumers, IPs, reports, manual blocks, allowlist entries, and synthetic GeoIP — then triggers a full score recompute. Returns "already seeded" if demo data is present.
                 </p>
                 <div class="mt-3">
-                    <button type="button" x-on:click="open = true"
+                    <button type="button" x-on:click="show()"
                             class="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500">
                         Load demo data…
                     </button>
@@ -180,17 +180,17 @@
                             Type <code class="rounded bg-slate-100 px-1 py-0.5 font-mono text-xs text-emerald-700 dark:bg-slate-800 dark:text-emerald-300">SEED</code> to confirm:
                         </p>
                         <form method="post" action="/app/settings/maintenance/seed-demo" class="mt-3"
-                              x-on:submit="submitting = true">
+                              x-on:submit="onSubmit()">
                             <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
                             <input type="text" name="confirm" autocomplete="off" x-model="confirm"
                                    class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-sm dark:border-slate-700 dark:bg-slate-950"
                                    placeholder="SEED">
                             <div class="mt-4 flex justify-end gap-2">
-                                <button type="button" x-on:click="open = false; confirm = ''"
+                                <button type="button" x-on:click="hide()"
                                         class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
-                                <button type="submit" x-bind:disabled="confirm !== 'SEED' || submitting"
+                                <button type="submit" x-bind:disabled="blocked"
                                         class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-40">
-                                    <span x-show="!submitting">Load demo data</span>
+                                    <span x-show="notSubmitting">Load demo data</span>
                                     <span x-show="submitting" x-cloak>Loading…</span>
                                 </button>
                             </div>
@@ -200,13 +200,13 @@
             </div>
 
             <div class="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40"
-                 x-data="{ open: false, confirm: '', submitting: false }">
+                 x-data="dangerousAction" data-expected-confirm="PURGE">
                 <h3 class="text-sm font-semibold text-red-800 dark:text-red-200">Purge operational data</h3>
                 <p class="mt-1 text-xs text-red-900/80 dark:text-red-200/80">
                     Deletes all reports, scores, manual blocks, allowlist, audit log, reporters, consumers, and non-service tokens. Users, OIDC mappings, and categories are preserved.
                 </p>
                 <div class="mt-3">
-                    <button type="button" x-on:click="open = true"
+                    <button type="button" x-on:click="show()"
                             class="rounded-md border border-red-400 bg-white px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:bg-slate-900 dark:text-red-300 dark:hover:bg-slate-800">
                         Purge data…
                     </button>
@@ -223,17 +223,17 @@
                             Type <code class="rounded bg-slate-100 px-1 py-0.5 font-mono text-xs text-red-700 dark:bg-slate-800 dark:text-red-300">PURGE</code> to confirm:
                         </p>
                         <form method="post" action="/app/settings/maintenance/purge" class="mt-3"
-                              x-on:submit="submitting = true">
+                              x-on:submit="onSubmit()">
                             <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
                             <input type="text" name="confirm" autocomplete="off" x-model="confirm"
                                    class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-sm dark:border-slate-700 dark:bg-slate-950"
                                    placeholder="PURGE">
                             <div class="mt-4 flex justify-end gap-2">
-                                <button type="button" x-on:click="open = false; confirm = ''"
+                                <button type="button" x-on:click="hide()"
                                         class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
-                                <button type="submit" x-bind:disabled="confirm !== 'PURGE' || submitting"
+                                <button type="submit" x-bind:disabled="blocked"
                                         class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-40">
-                                    <span x-show="!submitting">Purge data</span>
+                                    <span x-show="notSubmitting">Purge data</span>
                                     <span x-show="submitting" x-cloak>Purging…</span>
                                 </button>
                             </div>

+ 7 - 7
ui/resources/views/pages/tokens/index.twig

@@ -12,7 +12,7 @@
 
     {% if can_write %}
         <section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
-                 x-data="{ kind: 'admin' }">
+                 x-data="kindSwitcher" data-initial-kind="admin">
             <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Issue token</h2>
             <form method="post" action="/app/tokens" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3 text-sm">
                 <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
@@ -25,7 +25,7 @@
                         <option value="consumer">consumer</option>
                     </select>
                 </div>
-                <div x-show="kind == 'admin'">
+                <div x-show="isKind('admin')">
                     <label for="t-role" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Role</label>
                     <select id="t-role" name="role"
                             class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
@@ -34,7 +34,7 @@
                         <option value="admin">admin</option>
                     </select>
                 </div>
-                <div x-show="kind == 'reporter'">
+                <div x-show="isKind('reporter')">
                     <label for="t-rep" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Reporter</label>
                     <select id="t-rep" name="reporter_id"
                             class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
@@ -44,7 +44,7 @@
                         {% endfor %}
                     </select>
                 </div>
-                <div x-show="kind == 'consumer'">
+                <div x-show="isKind('consumer')">
                     <label for="t-con" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Consumer</label>
                     <select id="t-con" name="consumer_id"
                             class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 dark:border-slate-700 dark:bg-slate-950">
@@ -130,7 +130,7 @@
 </div>
 
 {% if just_created %}
-<div x-data="{ open: true }" x-show="open"
+<div x-data="rawTokenCopy" x-show="open"
      class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/70 px-4">
     <div class="w-full max-w-lg rounded-2xl border border-amber-300 bg-white p-6 shadow-2xl dark:border-amber-700 dark:bg-slate-900">
         <h2 class="text-lg font-semibold text-amber-700 dark:text-amber-300">Copy this token now</h2>
@@ -144,12 +144,12 @@
                 <input id="raw-token" type="text" readonly value="{{ just_created.raw_token }}"
                        class="w-full rounded-md border border-slate-300 bg-slate-50 px-3 py-2 font-mono text-xs dark:border-slate-700 dark:bg-slate-950">
                 <button type="button"
-                        x-on:click="navigator.clipboard.writeText(document.getElementById('raw-token').value)"
+                        x-on:click="copy()"
                         class="rounded-md border border-slate-300 px-3 py-2 text-xs hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Copy</button>
             </div>
         </div>
         <div class="mt-6 flex justify-end">
-            <button type="button" x-on:click="open = false"
+            <button type="button" x-on:click="hide()"
                     class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500">I have stored it safely</button>
         </div>
     </div>

+ 4 - 4
ui/resources/views/partials/confirm_form.twig

@@ -11,14 +11,14 @@
 
 {% set _btn_class = btn_class|default('rounded-md border border-red-300 bg-white px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:bg-slate-900 dark:text-red-300 dark:hover:bg-slate-800') %}
 
-<div x-data="{ open: false }" class="inline-block">
+<div x-data="toggle" class="inline-block">
     <button type="button"
-            x-on:click="open = true"
+            x-on:click="show()"
             class="{{ _btn_class }}">{{ label }}</button>
 
     <div x-show="open" x-cloak
          class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4">
-        <div x-on:click.outside="open = false"
+        <div x-on:click.outside="hide()"
              class="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900">
             <h2 class="text-base font-semibold">{{ label }}?</h2>
             <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">{{ description }}</p>
@@ -27,7 +27,7 @@
                 {% for k, v in extra_fields|default({}) %}
                     <input type="hidden" name="{{ k }}" value="{{ v }}">
                 {% endfor %}
-                <button type="button" x-on:click="open = false"
+                <button type="button" x-on:click="hide()"
                         class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
                 <button type="submit"
                         class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-500">Confirm</button>

+ 3 - 3
ui/resources/views/partials/topnav.twig

@@ -17,10 +17,10 @@
                        maxlength="64"
                        class="w-64 rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900" />
             </form>
-            <div x-data="{ open: false }" class="relative">
+            <div x-data="toggle" class="relative">
                 <button type="button"
-                        x-on:click="open = !open"
-                        x-on:click.outside="open = false"
+                        x-on:click="flip()"
+                        x-on:click.outside="hide()"
                         class="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:hover:bg-slate-800">
                     <span class="font-medium">{{ current_user.displayName }}</span>
                     <span class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs uppercase text-slate-600 dark:bg-slate-800 dark:text-slate-400">{{ current_user.role }}</span>

+ 11 - 4
ui/src/App/AppFactory.php

@@ -25,6 +25,7 @@ use App\Controllers\SettingsController;
 use App\Controllers\TokensController;
 use App\Controllers\UsersController;
 use App\Http\AuthRequiredMiddleware;
+use App\Http\CspMiddleware;
 use App\Http\CsrfMiddleware;
 use App\Http\JsonExceptionHandler;
 use App\Http\SessionMiddleware;
@@ -41,8 +42,10 @@ use Slim\Routing\RouteCollectorProxy;
  * Middleware order (Slim is LIFO; bottom = outermost):
  *   1. Session — always start the PHP session first.
  *   2. Csrf — needs a session to read/write the token.
- *   3. TwigGlobals — needs both above.
- *   4. Routing + body parsing.
+ *   3. Csp — mints the per-request nonce; needs to run before
+ *      TwigGlobals so the template global is populated.
+ *   4. TwigGlobals — needs all above.
+ *   5. Routing + body parsing.
  *
  * `/app/*` routes get an additional AuthRequiredMiddleware on the
  * route group so anonymous users bounce to /login.
@@ -73,15 +76,19 @@ final class AppFactory
         //   1. Session (needs to start before anything reads $_SESSION)
         //   2. BodyParsing (so CSRF can read form fields)
         //   3. CSRF (reads from session + parsed body)
-        //   4. TwigGlobals (after CSRF token is set on the request attr)
-        //   5. AuthRequired (per /app/* group)
+        //   4. CSP (mints the per-request nonce on the request attr)
+        //   5. TwigGlobals (after CSRF token + CSP nonce attrs are set)
+        //   6. AuthRequired (per /app/* group)
         /** @var TwigGlobalsMiddleware $globals */
         $globals = $container->get(TwigGlobalsMiddleware::class);
+        /** @var CspMiddleware $csp */
+        $csp = $container->get(CspMiddleware::class);
         /** @var CsrfMiddleware $csrf */
         $csrf = $container->get(CsrfMiddleware::class);
         /** @var SessionMiddleware $session */
         $session = $container->get(SessionMiddleware::class);
         $app->add($globals);
+        $app->add($csp);
         $app->add($csrf);
         $app->addBodyParsingMiddleware();
         $app->add($session);

+ 2 - 0
ui/src/App/Container.php

@@ -35,6 +35,7 @@ use App\Controllers\SettingsController;
 use App\Controllers\TokensController;
 use App\Controllers\UsersController;
 use App\Http\AuthRequiredMiddleware;
+use App\Http\CspMiddleware;
 use App\Http\CsrfMiddleware;
 use App\Http\JsonExceptionHandler;
 use App\Http\SessionMiddleware;
@@ -197,6 +198,7 @@ final class Container
             // Middlewares — autowire works directly.
             SessionMiddleware::class => autowire(),
             CsrfMiddleware::class => autowire(),
+            CspMiddleware::class => autowire(),
             AuthRequiredMiddleware::class => autowire(),
             TwigGlobalsMiddleware::class => factory(static function (ContainerInterface $c): TwigGlobalsMiddleware {
                 /** @var Twig $twig */

+ 54 - 0
ui/src/Http/CspMiddleware.php

@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Http;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Mints a per-request CSP nonce, exposes it on the request attribute
+ * for templates, and sets the `Content-Security-Policy` response
+ * header so `script-src` can drop `'unsafe-inline'` / `'unsafe-eval'`
+ * (closes SEC_REVIEW F24).
+ *
+ * The nonce is base64 (URL-safe alphabet trimmed of padding) so it
+ * fits HTML attributes verbatim. 16 random bytes ≈ 128 bits of entropy.
+ *
+ * Other security response headers are still set by Caddy (see
+ * `ui/docker/Caddyfile`). Only CSP needs to be set here because the
+ * nonce changes per request and Caddy can't see that.
+ */
+final class CspMiddleware implements MiddlewareInterface
+{
+    public const ATTR_NONCE = 'csp_nonce';
+
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        $nonce = self::generateNonce();
+        $response = $handler->handle($request->withAttribute(self::ATTR_NONCE, $nonce));
+
+        return $response->withHeader('Content-Security-Policy', self::policy($nonce));
+    }
+
+    public static function generateNonce(): string
+    {
+        return rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');
+    }
+
+    public static function policy(string $nonce): string
+    {
+        return "default-src 'self'; "
+            . "script-src 'self' 'nonce-{$nonce}'; "
+            . "style-src 'self' 'unsafe-inline'; "
+            . "img-src 'self' data:; "
+            . "font-src 'self' data:; "
+            . "connect-src 'self'; "
+            . "frame-ancestors 'none'; "
+            . "base-uri 'self'; "
+            . "form-action 'self'";
+    }
+}

+ 3 - 0
ui/src/Http/TwigGlobalsMiddleware.php

@@ -16,6 +16,8 @@ use Slim\Views\Twig;
  * without each controller building a context array:
  *
  *   - `csrf_token` — the current session's CSRF token (set by CsrfMiddleware).
+ *   - `csp_nonce` — per-request nonce (set by CspMiddleware) so templates
+ *     can stamp `nonce="…"` on inline `<script>` blocks.
  *   - `flash` — flash messages drained from the session.
  *   - `current_user` — the logged-in `UserContext` or null.
  *   - `oidc_enabled`, `local_admin_enabled` — login form toggles.
@@ -40,6 +42,7 @@ final class TwigGlobalsMiddleware implements MiddlewareInterface
     {
         $env = $this->twig->getEnvironment();
         $env->addGlobal('csrf_token', $request->getAttribute(CsrfMiddleware::ATTR_TOKEN));
+        $env->addGlobal('csp_nonce', $request->getAttribute(CspMiddleware::ATTR_NONCE, ''));
         $env->addGlobal('flash', $this->sessions->consumeFlash());
         $env->addGlobal('current_user', $this->sessions->getUser());
         foreach ($this->configFlags as $key => $value) {

+ 119 - 0
ui/tests/Integration/App/CspHeaderTest.php

@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\App;
+
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * Regression coverage for SEC_REVIEW F24: every rendered response must
+ * carry a strict Content-Security-Policy with a per-request nonce, and
+ * the only inline `<script>` block in the layout (the FOUC handler)
+ * must be stamped with that same nonce so it can execute under the
+ * stricter policy. `'unsafe-inline'` and `'unsafe-eval'` must not appear
+ * anywhere in `script-src`.
+ */
+final class CspHeaderTest extends AppTestCase
+{
+    protected function setUp(): void
+    {
+        $this->bootApp();
+    }
+
+    public function testLoginPageCarriesStrictCsp(): void
+    {
+        $response = $this->request('GET', '/login');
+
+        self::assertSame(200, $response->getStatusCode());
+        $csp = $response->getHeaderLine('Content-Security-Policy');
+
+        self::assertNotSame('', $csp, 'CSP header must be set');
+        self::assertStringNotContainsString("'unsafe-inline'", self::scriptSrc($csp));
+        self::assertStringNotContainsString("'unsafe-eval'", self::scriptSrc($csp));
+        self::assertMatchesRegularExpression(
+            "/script-src 'self' 'nonce-[A-Za-z0-9_-]+'/",
+            $csp,
+        );
+    }
+
+    public function testInlineScriptCarriesMatchingNonce(): void
+    {
+        $response = $this->request('GET', '/login');
+        $body = (string) $response->getBody();
+
+        self::assertMatchesRegularExpression(
+            '/<script nonce="([A-Za-z0-9_-]+)">/',
+            $body,
+            'layout FOUC <script> must be nonced',
+        );
+        preg_match('/<script nonce="([A-Za-z0-9_-]+)">/', $body, $m);
+        $bodyNonce = $m[1];
+        self::assertStringContainsString(
+            "'nonce-{$bodyNonce}'",
+            $response->getHeaderLine('Content-Security-Policy'),
+            'response CSP must contain the same nonce as the inline <script>',
+        );
+    }
+
+    public function testNoncesDifferAcrossRequests(): void
+    {
+        $first = $this->request('GET', '/login');
+        $second = $this->request('GET', '/login');
+
+        $firstNonce = self::extractScriptNonce($first->getHeaderLine('Content-Security-Policy'));
+        $secondNonce = self::extractScriptNonce($second->getHeaderLine('Content-Security-Policy'));
+
+        self::assertNotSame('', $firstNonce);
+        self::assertNotSame('', $secondNonce);
+        self::assertNotSame($firstNonce, $secondNonce, 'nonce must rotate per response');
+    }
+
+    public function testHealthzAlsoCarriesCsp(): void
+    {
+        $response = $this->request('GET', '/healthz');
+
+        self::assertSame(200, $response->getStatusCode());
+        self::assertStringContainsString(
+            "frame-ancestors 'none'",
+            $response->getHeaderLine('Content-Security-Policy'),
+        );
+    }
+
+    public function testNoBareInlineEventHandlersLeftInLoginTemplate(): void
+    {
+        $response = $this->request('GET', '/login');
+        $body = (string) $response->getBody();
+
+        // CSP build of Alpine cannot evaluate arbitrary inline expressions;
+        // the migration replaced object-literal x-data with named components.
+        self::assertStringNotContainsString('x-data="{', $body, 'no inline x-data object literals');
+        // Inline DOM event handlers were never used, but assert it.
+        self::assertDoesNotMatchRegularExpression(
+            '/\son(click|submit|change|input)\s*=\s*"/i',
+            $body,
+            'no inline DOM event handlers',
+        );
+    }
+
+    private static function scriptSrc(string $csp): string
+    {
+        foreach (explode(';', $csp) as $part) {
+            $part = trim($part);
+            if (str_starts_with($part, 'script-src')) {
+                return $part;
+            }
+        }
+
+        return '';
+    }
+
+    private static function extractScriptNonce(string $csp): string
+    {
+        if (preg_match("/'nonce-([A-Za-z0-9_-]+)'/", self::scriptSrc($csp), $m)) {
+            return $m[1];
+        }
+
+        return '';
+    }
+}

+ 90 - 0
ui/tests/Unit/Http/CspMiddlewareTest.php

@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Http;
+
+use App\Http\CspMiddleware;
+use PHPUnit\Framework\TestCase;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use Slim\Psr7\Factory\ResponseFactory;
+use Slim\Psr7\Factory\ServerRequestFactory;
+
+final class CspMiddlewareTest extends TestCase
+{
+    public function testGeneratedNoncesAreUniqueAndUrlSafe(): void
+    {
+        $seen = [];
+        for ($i = 0; $i < 50; $i++) {
+            $nonce = CspMiddleware::generateNonce();
+            self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce);
+            self::assertFalse(in_array($nonce, $seen, true), 'nonce should be unique');
+            $seen[] = $nonce;
+        }
+    }
+
+    public function testPolicyContainsNonceAndDropsUnsafeDirectives(): void
+    {
+        $nonce = 'TESTNONCE123';
+        $policy = CspMiddleware::policy($nonce);
+
+        self::assertStringContainsString("'nonce-{$nonce}'", $policy);
+        self::assertStringNotContainsString("'unsafe-eval'", $policy);
+        // `script-src` directive itself must not list `'unsafe-inline'`.
+        $scriptSrc = self::extractDirective($policy, 'script-src');
+        self::assertNotSame('', $scriptSrc, 'script-src directive missing');
+        self::assertStringNotContainsString("'unsafe-inline'", $scriptSrc);
+        self::assertStringContainsString("'self'", $scriptSrc);
+        self::assertStringContainsString("'nonce-{$nonce}'", $scriptSrc);
+        // Defence-in-depth: frame-ancestors / form-action / base-uri locked down.
+        self::assertStringContainsString("frame-ancestors 'none'", $policy);
+        self::assertStringContainsString("form-action 'self'", $policy);
+        self::assertStringContainsString("base-uri 'self'", $policy);
+    }
+
+    private static function extractDirective(string $csp, string $name): string
+    {
+        foreach (explode(';', $csp) as $part) {
+            $part = trim($part);
+            if (str_starts_with($part, $name . ' ')) {
+                return $part;
+            }
+        }
+
+        return '';
+    }
+
+    public function testProcessSetsHeaderAndExposesNonceOnRequestAttribute(): void
+    {
+        $middleware = new CspMiddleware();
+        $request = (new ServerRequestFactory())->createServerRequest('GET', '/');
+        $rf = new ResponseFactory();
+
+        $captured = null;
+        $handler = new class ($rf, $captured) implements RequestHandlerInterface {
+            public function __construct(
+                private readonly ResponseFactory $rf,
+                public ?string $captured,
+            ) {
+            }
+
+            public function handle(ServerRequestInterface $request): ResponseInterface
+            {
+                $this->captured = $request->getAttribute(CspMiddleware::ATTR_NONCE);
+
+                return $this->rf->createResponse(200);
+            }
+        };
+
+        $response = $middleware->process($request, $handler);
+
+        self::assertNotNull($handler->captured);
+        self::assertNotSame('', $handler->captured);
+        self::assertStringContainsString(
+            "'nonce-{$handler->captured}'",
+            $response->getHeaderLine('Content-Security-Policy'),
+        );
+    }
+}