Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 47ca083edf66374a02b2ec144b35190119a785fd)
+++ src/routes/activitypub.js	(revision 2220c490286227e578a040a9e736bc2a3ed9d3ad)
@@ -57,9 +57,59 @@
 };
 
+// ── host-meta ─────────────────────────────────────────────────────
+// De klassieke eerste stap van WebFinger (RFC 6415): een client die het
+// webfinger-pad niet wil raden, vraagt hier de sjabloon op. Mastodon serveert
+// dit ook, en een client die ermee begint kreeg bij ons een 404 en gaf het dan
+// op -- terwijl de webfinger eronder gewoon werkte.
+//
+// Twee vormen, want beide worden in het wild gevraagd: XRD (het origineel) en
+// JRD (de JSON-variant, RFC 6415 §3).
+const lrddSjabloon = (req) => `${baseUrl(req)}/.well-known/webfinger?resource={uri}`;
+
+router.get('/.well-known/host-meta', (req, res) => {
+  res.type('application/xrd+xml; charset=utf-8');
+  res.set('Cache-Control', 'public, max-age=86400');
+  res.send(`<?xml version="1.0" encoding="UTF-8"?>
+<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
+  <Link rel="lrdd" template="${lrddSjabloon(req)}"/>
+</XRD>`);
+});
+
+router.get('/.well-known/host-meta.json', (req, res) => {
+  res.type('application/jrd+json; charset=utf-8');
+  res.set('Cache-Control', 'public, max-age=86400');
+  res.send(JSON.stringify({ links: [{ rel: 'lrdd', template: lrddSjabloon(req) }] }));
+});
+
 // ── WebFinger ─────────────────────────────────────────────────────
+/**
+ * De `resource` uitpakken tot de gebruiker die bedoeld wordt.
+ *
+ * RFC 7033 schrijft een URI voor, en `acct:` is de nette vorm -- maar in het
+ * wild komen er vier spellingen langs, en drie daarvan wezen we af met een 400
+ * terwijl we prima wisten wie er bedoeld werd:
+ *
+ *   acct:naam@host      de nette vorm (Mastodon stuurt altijd deze)
+ *   naam@host           zonder schema
+ *   @naam@host          met het apenstaartje dat mensen intypen
+ *
+ * Coulant zijn kost hier niets: het antwoord noemt altijd de canonieke
+ * `acct:`-vorm terug, dus een slordige vraag levert geen slordig antwoord.
+ *
+ * De ACTOR-URI als resource (die Mastodon ook accepteert) hoort hier NIET bij,
+ * bewust: test/webfinger-bare-host.test.js legt vast dat die een 400 geeft.
+ * Dat is een uitgesproken keuze van eerder en geen vergetelheid, dus die draai
+ * ik niet om als bijvangst van een coulance-fix.
+ */
+function webfingerGebruiker(resource) {
+  const r = String(resource || '').trim();
+  if (!r) return null;
+  const acct = r.match(/^(?:acct:)?@?([^@/]+)@(.+)$/i);
+  return acct ? acct[1] : null;
+}
+
 router.get('/.well-known/webfinger', (req, res) => {
-  const m = String(req.query.resource || '').match(/^acct:([^@]+)@(.+)$/i);
-  if (!m) return res.status(400).type('text/plain').send('bad resource');
-  const user = m[1];
+  const user = webfingerGebruiker(req.query.resource);
+  if (!user) return res.status(400).type('text/plain').send('bad resource');
   let site = publicSite(user);
   // `acct:<host>@<host>` asks for this server's primary actor — the convention
Index: test/webfinger-hostmeta.test.js
===================================================================
--- test/webfinger-hostmeta.test.js	(revision 2220c490286227e578a040a9e736bc2a3ed9d3ad)
+++ test/webfinger-hostmeta.test.js	(revision 2220c490286227e578a040a9e736bc2a3ed9d3ad)
@@ -0,0 +1,83 @@
+// Ontdekking: host-meta erbij, en WebFinger coulant in wat hij accepteert.
+//
+// Aanleiding (7/8 augustus): Funkwhale vond dev pas via zijn eigen "search via
+// the fediverse". Onze kant antwoordde correct, maar twee deuren stonden dicht
+// die een andere client wel gebruikt: host-meta gaf 404, en een resource zonder
+// `acct:` gaf 400 terwijl we prima wisten wie er bedoeld werd.
+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');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public, is_primary) VALUES (?,?,?,?,1,1)')
+  .run('s1', 'band', 'De Band', 'u1');
+
+const app = express(); app.use(routes);
+const server = app.listen(0);
+await new Promise((r) => server.once('listening', r));
+const base = `http://127.0.0.1:${server.address().port}`;
+const wf = async (resource) => {
+  const r = await fetch(`${base}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`);
+  return { status: r.status, type: r.headers.get('content-type'), body: r.status === 200 ? await r.json() : null };
+};
+
+test('de nette vorm blijft werken, en het antwoord is canoniek', async () => {
+  const { status, type, body } = await wf('acct:band@test.example');
+  assert.equal(status, 200);
+  assert.match(type, /application\/jrd\+json/);
+  assert.equal(body.subject, 'acct:band@test.example');
+  assert.equal(body.links.find((l) => l.rel === 'self').href, 'https://test.example/ap/users/band');
+});
+
+test('zonder acct: mag ook -- en levert hetzelfde canonieke antwoord', async () => {
+  const { status, body } = await wf('band@test.example');
+  assert.equal(status, 200);
+  assert.equal(body.subject, 'acct:band@test.example', 'een slordige vraag geeft geen slordig antwoord');
+});
+
+test('met het apenstaartje dat mensen intypen', async () => {
+  const { status, body } = await wf('@band@test.example');
+  assert.equal(status, 200);
+  assert.equal(body.subject, 'acct:band@test.example');
+});
+
+test('de actor-URI blijft een 400 -- vastgelegd in webfinger-bare-host', async () => {
+  // Niet vergeten maar bewust: die keuze staat elders vast en draaien we niet
+  // om als bijvangst van een coulance-fix.
+  assert.equal((await wf('https://test.example/ap/users/band')).status, 400);
+});
+
+test('echte onzin blijft een 400, een onbekende gebruiker een 404', async () => {
+  assert.equal((await wf('kaas')).status, 400);
+  assert.equal((await wf('')).status, 400);
+  assert.equal((await wf('acct:bestaatniet@test.example')).status, 404);
+});
+
+test('host-meta wijst naar de webfinger-sjabloon (XRD)', async () => {
+  const r = await fetch(`${base}/.well-known/host-meta`);
+  assert.equal(r.status, 200);
+  assert.match(r.headers.get('content-type'), /application\/xrd\+xml/);
+  const xml = await r.text();
+  assert.match(xml, /rel="lrdd"/);
+  assert.match(xml, /template="https:\/\/test\.example\/\.well-known\/webfinger\?resource=\{uri\}"/);
+});
+
+test('en in JSON, want beide vormen worden gevraagd', async () => {
+  const r = await fetch(`${base}/.well-known/host-meta.json`);
+  assert.equal(r.status, 200);
+  assert.match(r.headers.get('content-type'), /application\/jrd\+json/);
+  const j = await r.json();
+  assert.equal(j.links[0].rel, 'lrdd');
+  assert.match(j.links[0].template, /\{uri\}$/);
+});
+
+test.after(() => server.close());
