Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 17546afbe88ae2f5e44da391ca5d632030be956e)
+++ src/routes/activitypub.js	(revision 26c5f71eb4c92718f426c99fffd1b73b3d5a9c9a)
@@ -44,4 +44,10 @@
 const publicSite = (slug) => db.prepare('SELECT * FROM sites WHERE slug = ? AND (is_public IS NULL OR is_public = 1)').get(slug);
 const primarySlug = () => { const r = db.prepare('SELECT slug FROM sites WHERE is_primary = 1').get(); return r && r.slug; };
+// A hostname as a human types it and as DNS stores it are the same host:
+// `🩵.is.wildenvrij.nl` IS `xn--zz9h.is.wildenvrij.nl`. WHATWG URL does the IDNA,
+// so compare the ASCII form and never the bytes the client happened to send.
+const asciiHost = (h) => {
+  try { return new URL(`https://${h}`).host.toLowerCase(); } catch { return String(h).trim().toLowerCase(); }
+};
 
 // ── WebFinger ─────────────────────────────────────────────────────
@@ -49,5 +55,16 @@
   const m = String(req.query.resource || '').match(/^acct:([^@]+)@(.+)$/i);
   if (!m) return res.status(400).type('text/plain').send('bad resource');
-  const site = publicSite(m[1]);
+  const user = m[1];
+  let site = publicSite(user);
+  // `acct:<host>@<host>` asks for this server's primary actor — the convention
+  // Shaer's Handle relies on so a Ward is reachable without knowing anyone's
+  // slug. Typing `🩵.is.wildenvrij.nl`, pasting `https://🩵.is.wildenvrij.nl`
+  // (which the client's URL parser silently punycodes) and sending the xn--
+  // form by hand are three spellings of one address; all arrive here with the
+  // host sitting in the user position, and all must find the same actor.
+  if (!site && asciiHost(user) === asciiHost(hostOf(req))) {
+    const slug = primarySlug();
+    if (slug) site = publicSite(slug);
+  }
   if (!site) return res.status(404).end();
   res.type('application/jrd+json; charset=utf-8');
Index: test/webfinger-bare-host.test.js
===================================================================
--- test/webfinger-bare-host.test.js	(revision 26c5f71eb4c92718f426c99fffd1b73b3d5a9c9a)
+++ test/webfinger-bare-host.test.js	(revision 26c5f71eb4c92718f426c99fffd1b73b3d5a9c9a)
@@ -0,0 +1,103 @@
+// One Ward, however you spell its address.
+//
+// Shaer never asks for a URL. You type a handle, and its `Handle` parser turns a
+// bare host into `acct:<host>@<host>` — the WebFinger convention for "give me
+// this server's primary actor". WebFinger here only ever looked the user part up
+// as a site slug, so that resource 404'd and the app could not find a Ward it was
+// pointed straight at.
+//
+// The emoji host makes the second half of the problem visible. Foundation's URL
+// (and Node's, and every browser's) silently punycodes a host, so a pasted
+// `https://🩵.is.wildenvrij.nl` arrives as `xn--zz9h.is.wildenvrij.nl` while a
+// typed `🩵.is.wildenvrij.nl` arrives verbatim. Same Ward, two spellings, and a
+// byte comparison says they are strangers.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+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');
+// `kid` is the primary site; `oma` is a second public site that must NOT be
+// what a bare host resolves to.
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,0)').run('s2', 'oma', 'oma', 'u1');
+
+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());
+
+/// Ask WebFinger for a resource while the server believes it is served at `base`.
+async function finger(resource, base = 'https://test.example') {
+  const previous = process.env.PUBLIC_BASE_URL;
+  process.env.PUBLIC_BASE_URL = base;
+  try {
+    const url = `http://127.0.0.1:${port}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`;
+    const res = await fetch(url);
+    return { status: res.status, body: res.status === 200 ? await res.json() : null };
+  } finally {
+    process.env.PUBLIC_BASE_URL = previous;
+  }
+}
+
+/// The `self` link is the actor the client will actually fetch next.
+const actorOf = (body) => body.links.find((l) => l.rel === 'self').href;
+
+test('a normal handle still resolves (the case that already worked)', async () => {
+  const { status, body } = await finger('acct:oma@test.example');
+  assert.equal(status, 200);
+  assert.equal(actorOf(body), 'https://test.example/ap/users/oma', 'a named slug wins over the primary fallback');
+});
+
+test('a bare host resolves the primary actor', async () => {
+  // The whole bug: this is what Shaer sends when you type `test.example`.
+  const { status, body } = await finger('acct:test.example@test.example');
+  assert.equal(status, 200, 'the bare host is a valid address, not a 404');
+  assert.equal(actorOf(body), 'https://test.example/ap/users/kid', 'and it means the PRIMARY site, not just any site');
+});
+
+test('unicode and punycode spellings of one host find one Ward', async () => {
+  const unicode = 'https://🩵.is.wildenvrij.nl';
+  const punycode = 'https://xn--zz9h.is.wildenvrij.nl';
+
+  const typed = await finger('acct:🩵.is.wildenvrij.nl@🩵.is.wildenvrij.nl', unicode);
+  const pasted = await finger('acct:xn--zz9h.is.wildenvrij.nl@xn--zz9h.is.wildenvrij.nl', unicode);
+
+  assert.equal(typed.status, 200, 'typed by hand: the emoji host');
+  assert.equal(pasted.status, 200, 'pasted as a URL: the client already punycoded it');
+  assert.deepEqual(actorOf(typed.body), actorOf(pasted.body), 'both spellings are the same Ward');
+
+  // And it does not matter which spelling the server itself is configured with.
+  const configuredAscii = await finger('acct:🩵.is.wildenvrij.nl@🩵.is.wildenvrij.nl', punycode);
+  assert.equal(configuredAscii.status, 200, 'PUBLIC_BASE_URL may be written either way too');
+
+  assert.equal(typed.body.subject, 'acct:kid@xn--zz9h.is.wildenvrij.nl',
+    'the subject we answer with is the canonical one, never the alias that was asked for');
+});
+
+test('an unknown user is still a 404', async () => {
+  // The fallback must not turn every miss into the primary actor, or a typo
+  // silently connects a child to the wrong account.
+  const { status } = await finger('acct:nobody@test.example');
+  assert.equal(status, 404);
+});
+
+test('a bare host that is not ours is still a 404', async () => {
+  const { status } = await finger('acct:elders.example@elders.example');
+  assert.equal(status, 404, 'we only answer for the host we are actually serving');
+});
+
+test('a malformed resource is a 400', async () => {
+  const { status } = await finger('https://test.example/ap/users/kid');
+  assert.equal(status, 400);
+});
