Index: src/routes/admin-push.js
===================================================================
--- src/routes/admin-push.js	(revision 053bf51d2047068ccfdc7ac5a76e3c8275bba1b5)
+++ src/routes/admin-push.js	(revision 053bf51d2047068ccfdc7ac5a76e3c8275bba1b5)
@@ -0,0 +1,22 @@
+/**
+ * Admin: Notificaties (web push) — the owner's per-device toggle page.
+ * Not premium-gated: notifications are infrastructure, not an extra.
+ */
+import express from 'express';
+import { renderPage } from '../middleware/render.js';
+import { requireSiteManager } from '../middleware/auth.js';
+import Push from '../services/PushService.js';
+
+const router = express.Router();
+
+router.get('/', requireSiteManager, async (req, res) => {
+  renderPage(req, res, 'pages/admin-push', {
+    pageTitle: 'Notificaties',
+    bodyClass: 'on-admin',
+    vapidKey: await Push.publicKey(),          // null → feature unavailable
+    subscriptions: Push.listSubscriptions(req.session.user.id),
+    defaultAlerts: Push.DEFAULT_ALERTS,
+  });
+});
+
+export default router;
Index: src/routes/push.js
===================================================================
--- src/routes/push.js	(revision 053bf51d2047068ccfdc7ac5a76e3c8275bba1b5)
+++ src/routes/push.js	(revision 053bf51d2047068ccfdc7ac5a76e3c8275bba1b5)
@@ -0,0 +1,64 @@
+/**
+ * Web Push (docs/webpush-design.md) slice 2: enable/disable + test.
+ * The public VAPID key is public by design (it only identifies this server to
+ * the browser's push service); everything that touches a subscription is a
+ * logged-in action. Web Push delivery itself is cookie-less.
+ */
+import express from 'express';
+import db from '../config/database.js';
+import { requireAuth } from '../middleware/auth.js';
+import Push from '../services/PushService.js';
+
+const router = express.Router();
+
+// A subscription row is personal: only its creator may touch it.
+function ownRow(endpoint, userId) {
+  if (!endpoint) return null;
+  const row = db.prepare('SELECT endpoint, user_id FROM push_subscriptions WHERE endpoint = ?').get(String(endpoint));
+  return row && row.user_id === userId ? row : null;
+}
+
+router.get('/vapid', async (req, res) => {
+  const key = await Push.publicKey();
+  if (!key) return res.status(503).json({ error: 'push_unavailable' });
+  res.json({ publicKey: key });
+});
+
+router.post('/subscribe', requireAuth, express.json({ limit: '16kb' }), async (req, res) => {
+  if (!(await Push.pushReady())) return res.status(503).json({ error: 'push_unavailable' });
+  const s = req.body && req.body.subscription;
+  const keys = s && s.keys;
+  const ok = Push.saveSubscription({
+    endpoint: s && s.endpoint, userId: req.session.user.id,
+    p256dh: keys && keys.p256dh, auth: keys && keys.auth,
+    alertTypes: req.body.alerts || null,
+    uaLabel: String(req.body.uaLabel || '').slice(0, 120) || null,
+  });
+  if (!ok) return res.status(400).json({ error: 'bad_subscription' });
+  res.json({ ok: true });
+});
+
+router.post('/unsubscribe', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
+  const row = ownRow(req.body && req.body.endpoint, req.session.user.id);
+  if (!row) return res.status(404).json({ error: 'not_found' });
+  Push.deleteSubscription(row.endpoint);
+  res.json({ ok: true });
+});
+
+router.post('/alerts', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
+  const row = ownRow(req.body && req.body.endpoint, req.session.user.id);
+  if (!row) return res.status(404).json({ error: 'not_found' });
+  Push.updateAlerts(row.endpoint, req.session.user.id, req.body.alerts || {});
+  res.json({ ok: true });
+});
+
+// A test ping to all of the caller's own devices (bypasses alert prefs).
+router.post('/test', requireAuth, async (req, res) => {
+  const sent = await Push.notifyUser(req.session.user.id, {
+    type: 'test', title: 'Klonkt-testnotificatie',
+    body: 'Werkt. Zo komen meldingen binnen op dit apparaat.', url: '/admin/push',
+  });
+  res.json({ ok: true, sent });
+});
+
+export default router;
Index: src/server.js
===================================================================
--- src/server.js	(revision ad107154f7df0224781cab7686f02aa6148d5a9e)
+++ src/server.js	(revision 053bf51d2047068ccfdc7ac5a76e3c8275bba1b5)
@@ -47,4 +47,6 @@
 import adminStatsRoutes from './routes/admin-stats.js';
 import adminPaidRoutes from './routes/admin-paid.js';
+import adminPushRoutes from './routes/admin-push.js';
+import pushRoutes from './routes/push.js';
 import adminMediaRoutes from './routes/admin-media.js';
 import circleRoutes from './routes/circle.js';
@@ -381,4 +383,5 @@
 app.use('/admin/stats', adminStatsRoutes);
 app.use('/admin/paid', adminPaidRoutes);
+app.use('/admin/push', adminPushRoutes);
 app.use('/admin/newsletter', adminNewsletterRoutes);
 app.use('/admin/shows', adminShowsRoutes);
@@ -402,4 +405,5 @@
 app.use('/', langRoutes); // /lang/:code — interface-taal kiezen (vóór de catch-all)
 app.use('/paid', paidRoutes);   // paid-posts patron/passkey flow (before the /:slug catch-all)
+app.use('/push', pushRoutes);   // web-push subscribe/test (before the /:slug catch-all)
 app.use('/', postsRoutes);
 
@@ -478,5 +482,5 @@
   res.set('Cache-Control', 'no-cache');
   res.send(`
-const CACHE_VERSION = 'pcms-v18-' + new Date().toISOString().split('T')[0];
+const CACHE_VERSION = 'pcms-v19-' + new Date().toISOString().split('T')[0];
 self.addEventListener('install', e => {
   e.waitUntil(caches.open(CACHE_VERSION).then(c => c.addAll(['/'])));
@@ -522,4 +526,28 @@
   );
 });
+// Web push (docs/webpush-design.md): payload is JSON {type,title,body,url},
+// encrypted end-to-end to this browser (RFC 8291). Show it; click opens url.
+self.addEventListener('push', e => {
+  let d = {};
+  try { d = e.data ? e.data.json() : {}; } catch (err) { /* non-JSON push */ }
+  const title = d.title || 'Klonkt';
+  e.waitUntil(self.registration.showNotification(title, {
+    body: d.body || '',
+    icon: '/favicon.svg',
+    badge: '/favicon.svg',
+    tag: d.type ? ('klonkt-' + d.type) : undefined,   // collapse same-type bursts
+    data: { url: d.url || '/' },
+  }));
+});
+self.addEventListener('notificationclick', e => {
+  e.notification.close();
+  const url = (e.notification.data && e.notification.data.url) || '/';
+  e.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
+    for (const c of list) {
+      if (new URL(c.url).origin === self.location.origin && 'focus' in c) { c.navigate(url); return c.focus(); }
+    }
+    return clients.openWindow(url);
+  }));
+});
   `);
 });
Index: src/views/pages/admin-push.ejs
===================================================================
--- src/views/pages/admin-push.ejs	(revision 053bf51d2047068ccfdc7ac5a76e3c8275bba1b5)
+++ src/views/pages/admin-push.ejs	(revision 053bf51d2047068ccfdc7ac5a76e3c8275bba1b5)
@@ -0,0 +1,165 @@
+<div class="container" style="max-width:640px;margin:1.5rem auto 3rem;padding:0 1rem">
+  <h1 style="margin:0 0 .3rem">Notificaties</h1>
+  <p style="color:var(--ink-soft,#888);margin:0 0 1.2rem">
+    Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten,
+    ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo
+    min mogelijk inhoud mee.
+  </p>
+
+  <% if (!vapidKey) { %>
+    <p style="color:#c0392b">Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).</p>
+  <% } else { %>
+
+  <div id="np-unsupported" hidden style="color:#c0392b;margin:0 0 1rem">
+    Deze browser ondersteunt geen push-notificaties.
+  </div>
+  <div id="np-ios-hint" hidden style="border:1px solid var(--border,#333);border-radius:10px;padding:.8rem 1rem;margin:0 0 1rem;font-size:.92rem;color:var(--ink-soft,#888)">
+    Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat:
+    deel-knop → <em>Zet op beginscherm</em>, en open 'm daarna vanaf daar.
+  </div>
+
+  <section style="border:1px solid var(--border,#333);border-radius:12px;padding:1rem 1.1rem;margin:0 0 1.2rem">
+    <p style="margin:0 0 .6rem"><strong>Dit apparaat:</strong> <span id="np-state">controleren…</span></p>
+    <div style="display:flex;gap:.6rem;flex-wrap:wrap">
+      <button type="button" id="np-on" class="btn btn-primary" hidden>Zet aan op dit apparaat</button>
+      <button type="button" id="np-off" class="btn" hidden>Zet uit</button>
+      <button type="button" id="np-test" class="btn" hidden>Stuur testmelding</button>
+    </div>
+    <fieldset id="np-alerts" hidden style="border:none;padding:0;margin:1rem 0 0">
+      <legend style="font-weight:600;margin-bottom:.4rem">Waarvoor wil je een melding?</legend>
+      <label style="display:block"><input type="checkbox" data-alert="follow"> Nieuwe volger</label>
+      <label style="display:block"><input type="checkbox" data-alert="reply"> Reactie of vermelding</label>
+      <label style="display:block"><input type="checkbox" data-alert="like"> Waardering (ster)</label>
+      <label style="display:block"><input type="checkbox" data-alert="boost"> Boost</label>
+      <label style="display:block"><input type="checkbox" data-alert="dm"> Priv&eacute;bericht</label>
+      <p id="np-saved" hidden style="color:var(--accent);margin:.4rem 0 0">Opgeslagen.</p>
+    </fieldset>
+  </section>
+
+  <% if (subscriptions.length) { %>
+    <h2 style="font-size:1.05rem;margin:0 0 .5rem">Gekoppelde apparaten</h2>
+    <ul style="list-style:none;padding:0;margin:0 0 1.2rem">
+      <% subscriptions.forEach(function (s) { %>
+        <li data-endpoint="<%= s.endpoint %>" style="display:flex;justify-content:space-between;gap:.8rem;align-items:center;padding:.45rem 0;border-bottom:1px solid var(--border,#2a2a2a)">
+          <span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><%= s.ua_label || 'Apparaat' %> <small style="color:var(--ink-soft,#888)">sinds <%= String(s.created_at || '').slice(0, 10) %></small></span>
+          <button type="button" class="btn np-remove" data-endpoint="<%= s.endpoint %>">Verwijder</button>
+        </li>
+      <% }); %>
+    </ul>
+  <% } %>
+
+  <% } %>
+
+  <p style="margin-top:1.5rem"><a href="/admin">&larr; Terug naar Beheer</a></p>
+</div>
+
+<% if (vapidKey) { %>
+<script id="np-data" type="application/json"><%- JSON.stringify({
+  vapid: vapidKey,
+  alerts: defaultAlerts,
+  saved: subscriptions.reduce(function (m, s) { try { m[s.endpoint] = JSON.parse(s.alert_types || '{}'); } catch (e) { m[s.endpoint] = {}; } return m; }, {}),
+}).replace(/</g, '\\u003c') %></script>
+<script>
+(function () {
+  var cfg = JSON.parse(document.getElementById('np-data').textContent);
+  var elState = document.getElementById('np-state');
+  var btnOn = document.getElementById('np-on'), btnOff = document.getElementById('np-off'), btnTest = document.getElementById('np-test');
+  var alertsBox = document.getElementById('np-alerts'), savedMsg = document.getElementById('np-saved');
+  var currentEndpoint = null;
+
+  var isIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
+  var standalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
+  if (isIos && !standalone) document.getElementById('np-ios-hint').hidden = false;
+
+  if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
+    document.getElementById('np-unsupported').hidden = false;
+    elState.textContent = 'niet ondersteund';
+    return;
+  }
+
+  function b64ToU8(s) {
+    var pad = '='.repeat((4 - (s.length % 4)) % 4);
+    var raw = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/'));
+    var out = new Uint8Array(raw.length);
+    for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
+    return out;
+  }
+  function post(url, body) {
+    return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
+  }
+  function deviceLabel() {
+    var ua = navigator.userAgent;
+    var browser = /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
+    var os = /Android/.test(ua) ? 'Android' : /iPad|iPhone|iPod/.test(ua) ? 'iOS' : /Mac/.test(ua) ? 'macOS' : /Win/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : '';
+    return (browser + (os ? ' op ' + os : ''));
+  }
+  function readAlertBoxes() {
+    var out = {};
+    alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { out[cb.getAttribute('data-alert')] = cb.checked ? 1 : 0; });
+    return out;
+  }
+  function setAlertBoxes(alerts) {
+    alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) {
+      cb.checked = !!alerts[cb.getAttribute('data-alert')];
+    });
+  }
+
+  function render(sub) {
+    currentEndpoint = sub ? sub.endpoint : null;
+    elState.textContent = sub ? 'meldingen staan aan' : (Notification.permission === 'denied' ? 'geblokkeerd in de browserinstellingen' : 'meldingen staan uit');
+    btnOn.hidden = !!sub || Notification.permission === 'denied';
+    btnOff.hidden = !sub;
+    btnTest.hidden = !sub;
+    alertsBox.hidden = !sub;
+  }
+
+  navigator.serviceWorker.ready.then(function (reg) {
+    return reg.pushManager.getSubscription();
+  }).then(function (sub) {
+    // Show this device's SAVED prefs when we know them, defaults otherwise.
+    setAlertBoxes((sub && cfg.saved[sub.endpoint]) ? Object.assign({}, cfg.alerts, cfg.saved[sub.endpoint]) : cfg.alerts);
+    render(sub);
+  }).catch(function () { elState.textContent = 'status onbekend'; });
+
+  btnOn.addEventListener('click', function () {
+    btnOn.disabled = true;
+    Notification.requestPermission().then(function (perm) {
+      if (perm !== 'granted') { btnOn.disabled = false; render(null); return; }
+      navigator.serviceWorker.ready.then(function (reg) {
+        return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(cfg.vapid) });
+      }).then(function (sub) {
+        return post('/push/subscribe', { subscription: sub.toJSON(), alerts: readAlertBoxes(), uaLabel: deviceLabel() })
+          .then(function (r) { if (!r.ok) throw new Error('subscribe failed'); render(sub); location.reload(); });
+      }).catch(function () { btnOn.disabled = false; elState.textContent = 'aanzetten mislukt'; });
+    });
+  });
+
+  btnOff.addEventListener('click', function () {
+    navigator.serviceWorker.ready.then(function (reg) { return reg.pushManager.getSubscription(); }).then(function (sub) {
+      if (!sub) { render(null); return; }
+      var ep = sub.endpoint;
+      sub.unsubscribe().then(function () { return post('/push/unsubscribe', { endpoint: ep }); })
+        .then(function () { location.reload(); });
+    });
+  });
+
+  btnTest.addEventListener('click', function () {
+    btnTest.disabled = true;
+    post('/push/test').then(function () { setTimeout(function () { btnTest.disabled = false; }, 2000); });
+  });
+
+  alertsBox.addEventListener('change', function () {
+    if (!currentEndpoint) return;
+    post('/push/alerts', { endpoint: currentEndpoint, alerts: readAlertBoxes() }).then(function (r) {
+      if (r.ok) { savedMsg.hidden = false; setTimeout(function () { savedMsg.hidden = true; }, 1500); }
+    });
+  });
+
+  document.querySelectorAll('.np-remove').forEach(function (btn) {
+    btn.addEventListener('click', function () {
+      post('/push/unsubscribe', { endpoint: btn.getAttribute('data-endpoint') }).then(function () { location.reload(); });
+    });
+  });
+})();
+</script>
+<% } %>
Index: src/views/pages/admin.ejs
===================================================================
--- src/views/pages/admin.ejs	(revision ad107154f7df0224781cab7686f02aa6148d5a9e)
+++ src/views/pages/admin.ejs	(revision 053bf51d2047068ccfdc7ac5a76e3c8275bba1b5)
@@ -55,4 +55,5 @@
     <%# Fediverse pages (News / Notifications / My replies / Blocking) are reached via the
         "Fediverse" nav button + the bell + the tabbed section — not duplicated here. %>
+    <a href="/admin/push" class="btn">Notificaties</a>
     <a href="/admin/updates" class="btn"><%= t('admin.b_updates') %></a>
     <a href="/admin/handleiding" class="btn"><%= t('admin.b_help') %></a>
