Index: src/server.js
===================================================================
--- src/server.js	(revision e2ea5c49d99947fd02bdda292a25027f868b7225)
+++ src/server.js	(revision 9a00f283dea4b5647c585540cdef7d52ced93298)
@@ -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);
+  }));
+});
   `);
 });
