Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision f3a58a46c10937e41cf62bd07c117c8d06bf8c66)
+++ src/routes/activitypub.js	(revision 04d5aebb050cd1c96020a822d03370dbf5b13399)
@@ -585,8 +585,28 @@
 
 // ── Note ──────────────────────────────────────────────────────────
-router.get('/ap/notes/:id', (req, res) => {
+router.get('/ap/notes/:id', async (req, res) => {
+  // No fan_only filter in the SELECT anymore: a friends-only post is not
+  // absent, it is GATED. The old route hid it from EVERYONE, also from the
+  // follower whose friendship earns it — so the signed resolution the reply
+  // path performs knocked on a door that could never open, and every reply
+  // to a friends-only post (Shaer's default!) died in
+  // cannot_resolve_inReplyTo. Strangers still get the exact same 404, so a
+  // note's existence stays as private as before.
   const post = db.prepare(
-    "SELECT * FROM posts WHERE id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)"
+    "SELECT * FROM posts WHERE id = ? AND status = 'published'"
   ).get(req.params.id);
+  if (post && AP.noteAudience(post) !== 'public') {
+    // The whole gate in a try: this is the only async route in this file,
+    // and Express 4 does not catch an async rejection — the request would
+    // hang forever instead of failing (which is exactly how the missing
+    // default-export entry manifested while building this). Any error here
+    // reads as "not authorized", never as silence.
+    try {
+      if (AP.noteAudience(post) === 'direct') return res.status(404).end();
+      const gsite = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
+      const actor = await AP.verifyRequest(req).catch(() => null);
+      if (!actor || !AP.mayReadNote(gsite, post, actor.id)) return res.status(404).end();
+    } catch { return res.status(404).end(); }
+  }
   if (!post) {
     // Could be one of OUR outbound replies (ap_outbox), not a post.
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision f3a58a46c10937e41cf62bd07c117c8d06bf8c66)
+++ src/services/ActivityPubService.js	(revision 04d5aebb050cd1c96020a822d03370dbf5b13399)
@@ -947,7 +947,22 @@
 const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
 // Extract our local post id from a note URL, but only if it's ours (base match).
+// One host, two spellings (Barts WebFinger-les, 2-8): a URL the client hands
+// back may carry the punycoded host (every URL parser silently punycodes)
+// while PUBLIC_BASE_URL carries the typed one. WHATWG URL does the IDNA, so
+// compare origins in ASCII and never the bytes the client happened to send.
+function asciiOrigin(u) {
+  try { const x = new URL(String(u)); return `${x.protocol}//${x.host}`.toLowerCase(); } catch { return null; }
+}
+function isOwnUrl(u) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base) return false;
+  const a = asciiOrigin(u);
+  return !!a && a === asciiOrigin(base);
+}
 function postIdFromNoteUrl(url, base) {
   const s = String(url || '');
-  if (base && !s.startsWith(base)) return null;
+  // ASCII origins, not startsWith: xn--zz9h.example IS 🩵.example, and a
+  // byte comparison read our own note as a stranger's.
+  if (base) { const a = asciiOrigin(s); if (!a || a !== asciiOrigin(base)) return null; }
   const m = s.match(/\/ap\/notes\/([^/?#]+)/);
   return m ? decodeURIComponent(m[1]) : null;
@@ -1262,4 +1277,33 @@
   }
   return ok ? actor : null;
+}
+
+// ── Authorized fetch for a single Note (2-8) ─────────────────────
+// Who may read this post's Note over AP GET? 'public' needs nobody;
+// friends-only (fan_only, Shaer's DEFAULT) needs a verified follower;
+// 'direct' is addressed to people and is never served over a GET at all.
+export function noteAudience(post) {
+  if (!post) return 'direct';
+  if (post.ap_visibility === 'direct') return 'direct';
+  if (post.fan_only || post.ap_visibility === 'friends') return 'followers';
+  return 'public';
+}
+// A follower earns the friends-only Note; a blocked actor gets the same
+// nothing as a stranger (the standing rule: a blocked actor's signed fetch
+// earns the empty set, gated server-side at serialisation).
+export function mayReadNote(site, post, actorUri) {
+  const aud = noteAudience(post);
+  if (aud === 'public') return true;
+  if (aud === 'direct' || !site || !actorUri) return false;
+  try {
+    const blocked = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'actor' AND target = ?").get(site.slug, actorUri);
+    if (blocked) return false;
+    let host = null; try { host = new URL(actorUri).host; } catch { /* geen host, geen domein-block */ }
+    if (host) {
+      const dom = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'domain' AND target = ?").get(site.slug, host);
+      if (dom) return false;
+    }
+    return !!db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(site.slug, actorUri);
+  } catch { return false; }
 }
 
@@ -2722,4 +2766,36 @@
 // Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
 // Returns a parent-shaped object usable by deliverReply(), or null.
+// The server's own note, built straight from the DB. resolveRemoteNote used
+// to fetch EVERYTHING over HTTPS, including notes living right here: a
+// hairpin fetch fails on home setups (a Klonkt on a Mac behind a tunnel), the
+// /ap/notes route rightly hides friends-only posts, and a punycode-spelled
+// own URL read as remote on a byte comparison. For the authenticated C2S
+// caller none of those walls apply; the DB is one prepare() away.
+// `forSlug` is that caller: only the post's own site gets its non-public
+// notes on this shortcut (public ones anyone, same as the route serves).
+function localNoteObject(url, forSlug) {
+  if (!isOwnUrl(url)) return null;
+  const m = String(url).match(/\/ap\/notes\/([^/?#]+)/);
+  if (!m) return null;
+  const id = decodeURIComponent(m[1]);
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const post = db.prepare("SELECT * FROM posts WHERE id = ? AND status = 'published'").get(id);
+  if (post) {
+    const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
+    if (!site) return null;
+    const nonPublic = post.fan_only || post.ap_visibility === 'friends' || post.ap_visibility === 'direct';
+    if (nonPublic && (!forSlug || forSlug !== site.slug)) return null;
+    return buildNote(base, site, post);
+  }
+  return getOutboxNote(base, id);   // our own outbound replies
+}
+// The own actor document, same shortcut, same reason.
+function localActorObject(uri) {
+  if (!isOwnUrl(uri)) return null;
+  const m = String(uri).match(/\/ap\/users\/([^/?#]+)/);
+  const site = m ? db.prepare('SELECT * FROM sites WHERE slug = ?').get(decodeURIComponent(m[1])) : null;
+  return site ? buildActor((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site) : null;
+}
+
 export async function resolveRemoteNote(url, opts = {}) {
   if (!/^https?:\/\//i.test(String(url || ''))) return null;
@@ -2730,10 +2806,10 @@
   // the other server sees WHO asks and serves what the friendship earns.
   const get = (u) => (opts.asSlug ? signedGetJson(opts.asSlug, u) : fetchActor(u).catch(() => null));
-  const note = await get(url); // AP GET (content-negotiates)
+  const note = localNoteObject(url, opts.asSlug) || await get(url); // own DB first, then AP GET
   if (!note || !note.id) return null;
   const att = note.attributedTo;
   const actorUri = actorUriOf(att);
   if (!actorUri) return null;
-  const actor = await get(actorUri);
+  const actor = localActorObject(actorUri) || await get(actorUri);
   const ai = actorInfo(actor, actorUri);
   // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
@@ -2749,5 +2825,5 @@
     const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
     if (!url) break;
-    const pn = await get(url);
+    const pn = localNoteObject(url, opts.asSlug) || await get(url);
     if (!pn) break;
     const pa = actorUriOf(pn.attributedTo);
@@ -4432,5 +4508,5 @@
   buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
   followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
-  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote,
+  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
Index: test/reply-friends-only.test.js
===================================================================
--- test/reply-friends-only.test.js	(revision 04d5aebb050cd1c96020a822d03370dbf5b13399)
+++ test/reply-friends-only.test.js	(revision 04d5aebb050cd1c96020a822d03370dbf5b13399)
@@ -0,0 +1,147 @@
+// A reply to a friends-only post, however its address is spelled.
+//
+// The chain that broke on Shaer (Robins schermafdruk, 2-8: "Server said 502:
+// cannot_resolve_inReplyTo"): a friends-visibility post stores fan_only = 1,
+// the /ap/notes route hid every fan_only post from EVERYONE without ever
+// reading the Signature header, and resolveRemoteNote fetched even the
+// server's OWN notes over public HTTPS. So the signed resolution the reply
+// path performs knocked on a door that could never open, and every reply to
+// a friends-only post (Shaer's default!) died before delivery. Public posts
+// resolved fine, which made it look intermittent.
+//
+// Two fixes, two test groups. One: a note living on this server resolves
+// from the DB, no HTTP, in any spelling of our own host (🩵.example IS
+// xn--zz9h.example, Barts WebFinger-les). Two: the /ap/notes route now does
+// authorized fetch: a verified follower earns the friends-only Note, a
+// stranger keeps getting the exact same 404 as before, and 'direct' is never
+// served over GET at all.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://xn--zz9h.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+const express = (await import('express')).default;
+const routes = (await import('../src/routes/activitypub.js')).default;
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)')
+  .run('s1', 'kid', 'kid', 'u1');
+const site = db.prepare("SELECT * FROM sites WHERE slug = 'kid'").get();
+const user = db.prepare("SELECT * FROM users WHERE id = 'u1'").get();
+
+const insertPost = db.prepare(`INSERT INTO posts
+  (id, site_id, slug, author_id, title, content, excerpt, status, type, language, fan_only, ap_visibility, created_at, updated_at, published_at)
+  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now'),datetime('now'))`);
+insertPost.run('p-friends', 's1', 'n-friends', 'u1', '', '<p>alleen vrienden</p>', '', 'published', 'post', 'nl', 1, 'friends');
+insertPost.run('p-public', 's1', 'n-public', 'u1', '', '<p>iedereen</p>', '', 'published', 'post', 'nl', 0, 'public');
+insertPost.run('p-direct', 's1', 'n-direct', 'u1', '', '<p>persoonlijk</p>', '', 'published', 'post', 'nl', 1, 'direct');
+
+const app = express();
+app.use(routes);
+const server = app.listen(0);
+await new Promise((r) => server.once('listening', r));
+const port = server.address().port;
+test.after(() => server.close());
+
+const apGet = (path, headers = {}) =>
+  fetch(`http://127.0.0.1:${port}${path}`, { headers: { Accept: 'application/activity+json', ...headers } });
+
+// ── Eén: de eigen note resolven zonder HTTP ──────────────────────────────
+
+test('a C2S reply to the own friends-only post resolves its parent locally', async () => {
+  // There is no server behind xn--zz9h.example: if the parent resolution
+  // still went over HTTP this would 502. It resolves from the DB instead.
+  const r = await AP.ingestOutboxActivity(site, user, {
+    type: 'Create',
+    object: {
+      type: 'Note', content: '<p>hoi</p>', source: { content: 'hoi' },
+      inReplyTo: 'https://xn--zz9h.example/ap/notes/p-friends',
+      to: ['https://xn--zz9h.example/ap/users/kid/followers'], cc: [],
+    },
+  });
+  assert.equal(r.status, 201, JSON.stringify(r));
+  // And it threads under the post: findThreadTarget recognized the URL as ours.
+  const row = db.prepare('SELECT post_id FROM ap_outbox WHERE id = ?').get(r.id);
+  assert.equal(row && row.post_id, 'p-friends');
+});
+
+test('the unicode spelling of our own host is still our own host', async () => {
+  // Foundation, Node and every browser silently punycode a URL; a typed one
+  // arrives verbatim. Both spellings must reach the same parent (the same
+  // lesson WebFinger learned on 2-8, now on the reply path).
+  const r = await AP.ingestOutboxActivity(site, user, {
+    type: 'Create',
+    object: {
+      type: 'Note', content: '<p>nogmaals</p>', source: { content: 'nogmaals' },
+      inReplyTo: 'https://\u{1FA75}.example/ap/notes/p-friends',   // 🩵.example ⇒ xn--zz9h.example
+      to: ['https://xn--zz9h.example/ap/users/kid/followers'], cc: [],
+    },
+  });
+  assert.equal(r.status, 201, JSON.stringify(r));
+  const row = db.prepare('SELECT post_id FROM ap_outbox WHERE id = ?').get(r.id);
+  assert.equal(row && row.post_id, 'p-friends');
+});
+
+test('a reply to a nonexistent own note still fails, loudly', async () => {
+  const r = await AP.ingestOutboxActivity(site, user, {
+    type: 'Create',
+    object: {
+      type: 'Note', content: '<p>niks</p>', source: { content: 'niks' },
+      inReplyTo: 'https://xn--zz9h.example/ap/notes/bestaat-niet',
+      to: ['https://xn--zz9h.example/ap/users/kid/followers'], cc: [],
+    },
+  });
+  assert.equal(r.status, 502);
+  assert.equal(r.error, 'cannot_resolve_inReplyTo');
+});
+
+// ── Twee: de leespoort (authorized fetch) ────────────────────────────────
+
+test('mayReadNote: the full matrix', () => {
+  const friends = { fan_only: 1, ap_visibility: 'friends' };
+  const direct = { fan_only: 1, ap_visibility: 'direct' };
+  const pub = { fan_only: 0, ap_visibility: 'public' };
+  const oma = 'https://elders.example/ap/users/oma';
+  db.prepare('INSERT INTO ap_followers (slug, actor_uri, inbox) VALUES (?,?,?)')
+    .run('kid', oma, 'https://elders.example/inbox');
+
+  assert.equal(AP.mayReadNote(site, pub, null), true, 'public needs nobody');
+  assert.equal(AP.mayReadNote(site, friends, oma), true, 'a follower earns the friends-only note');
+  assert.equal(AP.mayReadNote(site, friends, 'https://elders.example/ap/users/vreemde'), false, 'a stranger does not');
+  assert.equal(AP.mayReadNote(site, friends, null), false, 'no verified actor, no note');
+  assert.equal(AP.mayReadNote(site, direct, oma), false, 'direct is never served over GET');
+
+  // A blocked actor's signed fetch earns the empty set (the standing rule),
+  // follower row or not.
+  db.prepare("INSERT INTO ap_blocks (slug, target, kind) VALUES ('kid', ?, 'actor')").run(oma);
+  assert.equal(AP.mayReadNote(site, friends, oma), false, 'actor block wins from the follower row');
+  db.prepare('DELETE FROM ap_blocks').run();
+  db.prepare("INSERT INTO ap_blocks (slug, target, kind) VALUES ('kid', 'elders.example', 'domain')").run();
+  assert.equal(AP.mayReadNote(site, friends, oma), false, 'domain block covers its actors');
+  db.prepare('DELETE FROM ap_blocks').run();
+});
+
+test('the route: a stranger keeps the exact same 404, public stays public', async () => {
+  // Unsigned: the friends-only note does not exist for you.
+  assert.equal((await apGet('/ap/notes/p-friends')).status, 404);
+  // A signature that cannot be verified is no signature. The keyId points at
+  // a blocked IP so the SSRF guard refuses it instantly: same null outcome as
+  // an unreachable host, without a DNS lookup that can hang the test.
+  assert.equal((await apGet('/ap/notes/p-friends', {
+    Signature: 'keyId="https://127.0.0.1:1/u/x#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="aGVsbG8="',
+    Date: new Date().toUTCString(),
+  })).status, 404);
+  // Direct is addressed to people: never served over GET, signed or not.
+  assert.equal((await apGet('/ap/notes/p-direct')).status, 404);
+  // And the public post is untouched by the gate.
+  const pub = await apGet('/ap/notes/p-public');
+  assert.equal(pub.status, 200);
+  const note = await pub.json();
+  assert.equal(note.id, 'https://xn--zz9h.example/ap/notes/p-public');
+});
