Index: src/middleware/rate-limit.js
===================================================================
--- src/middleware/rate-limit.js	(revision 49edc72f102927812ac0294fdf7816d624a56cde)
+++ src/middleware/rate-limit.js	(revision 75ab393b61dbacf266594c3d44c01d8ad60781a1)
@@ -21,5 +21,18 @@
 function clientKey(req) {
   let ip = req.ip || req.socket?.remoteAddress || '';
+  // Strip a trailing IPv4 port (1.2.3.4:11046 -> 1.2.3.4)
   if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(ip)) ip = ip.split(':')[0];
+  // IPv6-mapped IPv4 (::ffff:1.2.3.4) -> the plain IPv4
+  const mapped = ip.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
+  if (mapped) return mapped[1];
+  // Real IPv6: key on the /64 network prefix, not the full address. A single
+  // user/allocation is usually a whole /64, so this stops an attacker from
+  // getting a fresh budget by rotating addresses within their own range.
+  if (ip.includes(':')) {
+    const left = ip.includes('::') ? ip.split('::')[0] : ip;
+    const groups = left.split(':').filter(Boolean);
+    while (groups.length < 4) groups.push('0');
+    return groups.slice(0, 4).join(':') + '::/64';
+  }
   return ip || 'unknown';
 }
@@ -70,2 +83,31 @@
   handler: blockedHandler('pages/auth-register', 'on-special', 'Too many signup attempts.'),
 });
+
+// ─── Fediverse (/ap/*) ────────────────────────────────────────────
+// These endpoints are hit by REMOTE SERVERS, not browsers, so the default
+// plain-text 429 is the right response (no HTML page). Deliberately generous:
+// legitimate federation from one instance never comes close, but a flood from
+// a single IP is capped. Per-IP via the same /64-aware clientKey.
+
+// Baseline read cap across all /ap/* (actor, outbox, notes, webfinger, …).
+// 5 req/sec per IP — far above any real Mastodon polling.
+export const apReadLimiter = rateLimit({
+  windowMs: 60 * 1000,
+  max: 300,
+  standardHeaders: true,
+  legacyHeaders: false,
+  keyGenerator: clientKey,
+  validate: { ip: false },
+});
+
+// Inbox POSTs each trigger an outbound actor fetch (signature verify) → cap the
+// amplification/queue-inflation a single source can drive. 120/min/IP is still
+// generous for a small site's inbound federation; bump if a busy instance trips it.
+export const apInboxLimiter = rateLimit({
+  windowMs: 60 * 1000,
+  max: 120,
+  standardHeaders: true,
+  legacyHeaders: false,
+  keyGenerator: clientKey,
+  validate: { ip: false },
+});
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 49edc72f102927812ac0294fdf7816d624a56cde)
+++ src/routes/activitypub.js	(revision 75ab393b61dbacf266594c3d44c01d8ad60781a1)
@@ -16,6 +16,10 @@
 import db from '../config/database.js';
 import AP from '../services/ActivityPubService.js';
+import { apReadLimiter, apInboxLimiter } from '../middleware/rate-limit.js';
 
 const router = express.Router();
+// Generous per-IP baseline over all /ap/* (reads). The inbox POST gets an
+// additional, tighter cap inline (it triggers outbound fetches).
+router.use(apReadLimiter);
 let _ver = '1.0.0';
 try { _ver = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url))).version || _ver; } catch { /* keep default */ }
@@ -160,5 +164,5 @@
   verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
 });
-router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apJson, async (req, res) => {
+router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
   try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
   catch (e) { console.warn('[AP inbox] error:', e.message); return res.status(202).end(); }
