Index: package-lock.json
===================================================================
--- package-lock.json	(revision dc4aa08b9b004c449dc53ab40d77b4576e9360f4)
+++ package-lock.json	(revision ad107154f7df0224781cab7686f02aa6148d5a9e)
@@ -30,5 +30,6 @@
         "nodemailer": "^6.9.15",
         "sanitize-html": "^2.17.3",
-        "uuid": "^9.0.1"
+        "uuid": "^9.0.1",
+        "web-push": "^3.6.7"
       },
       "engines": {
@@ -551,4 +552,16 @@
       "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
       "license": "MIT"
+    },
+    "node_modules/asn1.js": {
+      "version": "5.4.1",
+      "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
+      "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
+      "license": "MIT",
+      "dependencies": {
+        "bn.js": "^4.0.0",
+        "inherits": "^2.0.1",
+        "minimalistic-assert": "^1.0.0",
+        "safer-buffer": "^2.1.0"
+      }
     },
     "node_modules/asn1js": {
@@ -635,4 +648,10 @@
       }
     },
+    "node_modules/bn.js": {
+      "version": "4.12.5",
+      "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
+      "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
+      "license": "MIT"
+    },
     "node_modules/body-parser": {
       "version": "1.20.5",
@@ -692,4 +711,10 @@
       }
     },
+    "node_modules/buffer-equal-constant-time": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+      "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+      "license": "BSD-3-Clause"
+    },
     "node_modules/buffer-from": {
       "version": "1.1.2",
@@ -1007,4 +1032,13 @@
       "engines": {
         "node": ">= 0.4"
+      }
+    },
+    "node_modules/ecdsa-sig-formatter": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+      "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
       }
     },
@@ -1448,4 +1482,13 @@
       "license": "0BSD"
     },
+    "node_modules/http_ece": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
+      "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16"
+      }
+    },
     "node_modules/http-errors": {
       "version": "2.0.1",
@@ -1604,4 +1647,25 @@
       }
     },
+    "node_modules/jwa": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+      "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer-equal-constant-time": "^1.0.1",
+        "ecdsa-sig-formatter": "1.0.11",
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "node_modules/jws": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+      "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+      "license": "MIT",
+      "dependencies": {
+        "jwa": "^2.0.1",
+        "safe-buffer": "^5.0.1"
+      }
+    },
     "node_modules/marked": {
       "version": "11.2.0",
@@ -1696,4 +1760,10 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
+    },
+    "node_modules/minimalistic-assert": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+      "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+      "license": "ISC"
     },
     "node_modules/minimatch": {
@@ -2512,4 +2582,68 @@
       }
     },
+    "node_modules/web-push": {
+      "version": "3.6.7",
+      "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
+      "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
+      "license": "MPL-2.0",
+      "dependencies": {
+        "asn1.js": "^5.3.0",
+        "http_ece": "1.2.0",
+        "https-proxy-agent": "^7.0.0",
+        "jws": "^4.0.0",
+        "minimist": "^1.2.5"
+      },
+      "bin": {
+        "web-push": "src/cli.js"
+      },
+      "engines": {
+        "node": ">= 16"
+      }
+    },
+    "node_modules/web-push/node_modules/agent-base": {
+      "version": "7.1.4",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/web-push/node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/web-push/node_modules/https-proxy-agent": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.2",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/web-push/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
     "node_modules/which": {
       "version": "1.3.1",
Index: package.json
===================================================================
--- package.json	(revision dc4aa08b9b004c449dc53ab40d77b4576e9360f4)
+++ package.json	(revision ad107154f7df0224781cab7686f02aa6148d5a9e)
@@ -35,5 +35,6 @@
     "nodemailer": "^6.9.15",
     "sanitize-html": "^2.17.3",
-    "uuid": "^9.0.1"
+    "uuid": "^9.0.1",
+    "web-push": "^3.6.7"
   },
   "engines": {
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision dc4aa08b9b004c449dc53ab40d77b4576e9360f4)
+++ src/config/database.js	(revision ad107154f7df0224781cab7686f02aa6148d5a9e)
@@ -335,4 +335,16 @@
       expires_at INTEGER NOT NULL,      -- unix seconds; re-link after
       created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+    );
+    -- Web Push (docs/webpush-design.md): one row per browser/device the owner
+    -- enabled notifications on. Payloads are encrypted to p256dh/auth (RFC 8291).
+    CREATE TABLE IF NOT EXISTS push_subscriptions (
+      endpoint TEXT PRIMARY KEY,       -- push-service URL for this device
+      user_id TEXT NOT NULL,
+      p256dh TEXT NOT NULL,            -- client public key
+      auth TEXT NOT NULL,              -- client auth secret
+      alert_types TEXT,                -- JSON {follow,reply,like,boost,dm}
+      ua_label TEXT,
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+      last_ok_at DATETIME
     );
     CREATE TABLE IF NOT EXISTS ap_outbox (
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision dc4aa08b9b004c449dc53ab40d77b4576e9360f4)
+++ src/routes/posts.js	(revision ad107154f7df0224781cab7686f02aa6148d5a9e)
@@ -152,4 +152,5 @@
   'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
   'authorize_interaction', 'fediverse', 'news', 'following', 'notifications', 'blocking',
+  'paid', 'push',
 ]);
 
Index: src/services/PushService.js
===================================================================
--- src/services/PushService.js	(revision ad107154f7df0224781cab7686f02aa6148d5a9e)
+++ src/services/PushService.js	(revision ad107154f7df0224781cab7686f02aa6148d5a9e)
@@ -0,0 +1,148 @@
+// Web Push (VAPID) — background notifications to the owner's browser/PWA
+// (docs/webpush-design.md). RFC 8030 delivery + RFC 8291 payload encryption via
+// the `web-push` dependency (approved); the push service only ever sees
+// ciphertext. No cookies anywhere: only enabling/disabling is a logged-in action.
+import crypto from 'crypto';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import db from '../config/database.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+// Lazy so a not-yet-installed dependency can never crash app boot; only push
+// fails until `npm ci` has run (same pattern as @simplewebauthn/server).
+let _lib = null;
+async function lib() {
+  if (!_lib) { const m = await import('web-push'); _lib = m.default || m; }  // CJS: API on default
+  return _lib;
+}
+
+// ── VAPID keys ──────────────────────────────────────────────────────
+// env wins; otherwise a persisted key file next to the database, generated on
+// first use. NEVER regenerated while the file exists: new keys invalidate every
+// existing subscription. Back up storage/ as a whole (README).
+
+function keyFilePath() {
+  const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../storage/database.sqlite');
+  const dir = dbPath === ':memory:' ? path.join(__dirname, '../../storage') : path.dirname(dbPath);
+  return path.join(dir, '.vapid');
+}
+
+// The VAPID subject: an https URL (PUBLIC_BASE_URL) or a mailto.
+function subject() {
+  if (process.env.VAPID_SUBJECT) return process.env.VAPID_SUBJECT;
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (/^https:\/\//.test(base)) return base;
+  const from = (process.env.SMTP_FROM || '').replace(/^.*</, '').replace(/>.*$/, '').trim();
+  return from.includes('@') ? `mailto:${from}` : 'mailto:webpush@invalid.local';
+}
+
+let _keys = null;
+async function vapidKeys() {
+  if (_keys) return _keys;
+  const envPub = process.env.VAPID_PUBLIC_KEY, envPriv = process.env.VAPID_PRIVATE_KEY;
+  if (envPub && envPriv) { _keys = { publicKey: envPub, privateKey: envPriv }; return _keys; }
+  const file = keyFilePath();
+  try {
+    const j = JSON.parse(fs.readFileSync(file, 'utf8'));
+    if (j && j.publicKey && j.privateKey) { _keys = j; return _keys; }
+  } catch { /* not created yet */ }
+  const { generateVAPIDKeys } = await lib();
+  const fresh = generateVAPIDKeys();
+  fs.mkdirSync(path.dirname(file), { recursive: true });
+  fs.writeFileSync(file, JSON.stringify(fresh), { mode: 0o600 });
+  try { fs.chmodSync(file, 0o600); } catch { /* non-POSIX fs */ }
+  _keys = fresh;
+  return _keys;
+}
+
+// The public key for the client (pushManager.subscribe). Null when the
+// dependency is missing or the key can't be persisted → feature stays gated.
+export async function publicKey() {
+  try { return (await vapidKeys()).publicKey; } catch { return null; }
+}
+
+export async function pushReady() { return (await publicKey()) !== null; }
+
+// ── Subscriptions ───────────────────────────────────────────────────
+
+export const DEFAULT_ALERTS = { follow: 1, reply: 1, like: 0, boost: 0, dm: 1 };
+
+export function saveSubscription({ endpoint, userId, p256dh, auth, alertTypes, uaLabel }) {
+  if (!endpoint || !userId || !p256dh || !auth) return false;
+  const alerts = JSON.stringify({ ...DEFAULT_ALERTS, ...(alertTypes || {}) });
+  db.prepare(`INSERT INTO push_subscriptions (endpoint, user_id, p256dh, auth, alert_types, ua_label, created_at)
+      VALUES (?,?,?,?,?,?,CURRENT_TIMESTAMP)
+    ON CONFLICT(endpoint) DO UPDATE SET
+      user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth,
+      alert_types=excluded.alert_types, ua_label=excluded.ua_label`)
+    .run(endpoint, userId, p256dh, auth, alerts, uaLabel || null);
+  return true;
+}
+
+export function deleteSubscription(endpoint) {
+  return db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint).changes > 0;
+}
+
+export function listSubscriptions(userId) {
+  return db.prepare('SELECT endpoint, alert_types, ua_label, created_at, last_ok_at FROM push_subscriptions WHERE user_id = ? ORDER BY created_at').all(userId);
+}
+
+export function updateAlerts(endpoint, userId, alertTypes) {
+  const alerts = JSON.stringify({ ...DEFAULT_ALERTS, ...(alertTypes || {}) });
+  return db.prepare('UPDATE push_subscriptions SET alert_types = ? WHERE endpoint = ? AND user_id = ?').run(alerts, endpoint, userId).changes > 0;
+}
+
+// ── Sending ─────────────────────────────────────────────────────────
+
+// Send one payload to one stored subscription row. 404/410 → the device is
+// gone or permission was revoked → delete the row (self-pruning).
+async function sendTo(row, payload) {
+  const wp = await lib();
+  const keys = await vapidKeys();
+  wp.setVapidDetails(subject(), keys.publicKey, keys.privateKey);
+  try {
+    await wp.sendNotification(
+      { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
+      JSON.stringify(payload),
+      { TTL: 3600 },
+    );
+    db.prepare('UPDATE push_subscriptions SET last_ok_at = CURRENT_TIMESTAMP WHERE endpoint = ?').run(row.endpoint);
+    return true;
+  } catch (e) {
+    if (e && (e.statusCode === 404 || e.statusCode === 410)) deleteSubscription(row.endpoint);
+    else console.warn('[push] send failed:', e && (e.statusCode || e.message));
+    return false;
+  }
+}
+
+// Notify one user on all their devices, honouring per-type preferences.
+// type ∈ {follow, reply, like, boost, dm, test}. Fire-and-forget at call sites.
+export async function notifyUser(userId, { type, title, body, url }) {
+  if (!(await pushReady())) return 0;
+  const rows = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
+  let sent = 0;
+  for (const row of rows) {
+    if (type !== 'test') {
+      let alerts = DEFAULT_ALERTS;
+      try { alerts = { ...DEFAULT_ALERTS, ...JSON.parse(row.alert_types || '{}') }; } catch { /* keep defaults */ }
+      if (!alerts[type]) continue;
+    }
+    if (await sendTo(row, { type, title: String(title || '').slice(0, 120), body: String(body || '').slice(0, 240), url: url || '/' })) sent++;
+  }
+  return sent;
+}
+
+// Notify the owner of a site (the usual entry point from the S2S inbox).
+export async function notifySite(slug, event) {
+  const row = db.prepare('SELECT owner_id FROM sites WHERE slug = ?').get(slug);
+  if (!row || !row.owner_id) return 0;
+  return notifyUser(row.owner_id, event);
+}
+
+export default {
+  publicKey, pushReady, DEFAULT_ALERTS,
+  saveSubscription, deleteSubscription, listSubscriptions, updateAlerts,
+  notifyUser, notifySite,
+};
Index: test/push.test.js
===================================================================
--- test/push.test.js	(revision ad107154f7df0224781cab7686f02aa6148d5a9e)
+++ test/push.test.js	(revision ad107154f7df0224781cab7686f02aa6148d5a9e)
@@ -0,0 +1,70 @@
+// Web Push slice 1: VAPID key management + the subscription store. Actual
+// delivery needs a real push service, so tests cover the pure parts: key
+// auto-generation/persistence (no env editing) and the subscription CRUD with
+// per-type alert preferences. Own process (node --test), so env tweaks here
+// don't leak into other test files.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+delete process.env.VAPID_PUBLIC_KEY;
+delete process.env.VAPID_PRIVATE_KEY;
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pushkey-'));
+process.env.DATABASE_PATH = path.join(dir, 'database.sqlite');
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const Push = (await import('../src/services/PushService.js')).default;
+const keyFile = path.join(dir, '.vapid');
+
+test('VAPID keys auto-generate to a 0600 file and persist', async () => {
+  const pub = await Push.publicKey();
+  assert.ok(pub && typeof pub === 'string' && pub.length > 20, 'public key exists');
+  assert.ok(fs.existsSync(keyFile), 'key file written next to the database');
+  if (process.platform !== 'win32') {
+    assert.equal(fs.statSync(keyFile).mode & 0o777, 0o600, 'key file is 0600');
+  }
+  const onDisk = JSON.parse(fs.readFileSync(keyFile, 'utf8'));
+  assert.equal(onDisk.publicKey, pub, 'served key matches the persisted one');
+  assert.equal(await Push.pushReady(), true);
+});
+
+test('subscription store: save, list, update alerts, delete', () => {
+  const ok = Push.saveSubscription({
+    endpoint: 'https://push.example/ep1', userId: 'u1',
+    p256dh: 'PK', auth: 'AUTH', uaLabel: 'Firefox op laptop',
+  });
+  assert.equal(ok, true);
+  const list = Push.listSubscriptions('u1');
+  assert.equal(list.length, 1);
+  const alerts = JSON.parse(list[0].alert_types);
+  assert.equal(alerts.follow, 1);   // defaults applied
+  assert.equal(alerts.like, 0);
+  // update preferences
+  assert.equal(Push.updateAlerts('https://push.example/ep1', 'u1', { like: 1, follow: 0 }), true);
+  const upd = JSON.parse(Push.listSubscriptions('u1')[0].alert_types);
+  assert.equal(upd.like, 1);
+  assert.equal(upd.follow, 0);
+  assert.equal(upd.dm, 1);          // untouched default survives
+  // wrong user can't update
+  assert.equal(Push.updateAlerts('https://push.example/ep1', 'u2', { like: 0 }), false);
+  // delete
+  assert.equal(Push.deleteSubscription('https://push.example/ep1'), true);
+  assert.equal(Push.listSubscriptions('u1').length, 0);
+});
+
+test('re-subscribing the same endpoint upserts instead of duplicating', () => {
+  Push.saveSubscription({ endpoint: 'https://push.example/ep2', userId: 'u1', p256dh: 'A', auth: 'B' });
+  Push.saveSubscription({ endpoint: 'https://push.example/ep2', userId: 'u1', p256dh: 'C', auth: 'D' });
+  const rows = db.prepare('SELECT * FROM push_subscriptions WHERE endpoint = ?').all('https://push.example/ep2');
+  assert.equal(rows.length, 1);
+  assert.equal(rows[0].p256dh, 'C');
+});
+
+test('incomplete subscription payloads are refused', () => {
+  assert.equal(Push.saveSubscription({ endpoint: '', userId: 'u1', p256dh: 'x', auth: 'y' }), false);
+  assert.equal(Push.saveSubscription({ endpoint: 'https://e', userId: 'u1', p256dh: '', auth: 'y' }), false);
+});
