Index: test/openwebauth-http.test.js
===================================================================
--- test/openwebauth-http.test.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ test/openwebauth-http.test.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -0,0 +1,133 @@
+// OpenWebAuth over HTTP: de vindbaarheid, de poort van het token-endpoint, en
+// de impersonatie-verdediging.
+//
+// Die laatste is de belangrijkste toets in dit bestand. De FEP beschrijft hem
+// zo: Mallory maakt een link naar ONZE site met ?zid=bob@elders, klikt hem zelf,
+// en komt terug met een token dat MALLORY zegt. Wie `zid` gelooft in plaats van
+// het ingewisselde `owt`, laat Mallory als Bob binnen.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import express from 'express';
+
+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 OWA = await import('../src/services/OpenWebAuthService.js');
+const apRoutes = (await import('../src/routes/activitypub.js')).default;
+const owaMod = await import('../src/routes/openwebauth.js');
+
+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, created_at) VALUES (?,?,?,?,1,?)')
+  .run('s1', 'kid', 'kid', 'u1', '2026-01-01 00:00:00');
+
+const ACTOR = 'https://elders.example/users/mee';
+const SLACHTOFFER = 'https://elders.example/users/bob';
+
+// Eén app met een sessie-nep: we willen hier de middleware toetsen, niet
+// express-session.
+const app = express();
+app.use((req, _res, next) => { req.session = app.locals.sessie; next(); });
+app.use(apRoutes);
+app.use(owaMod.default);
+app.use(owaMod.owaMiddleware);
+app.get('/een-bericht', (req, res) => res.json({ owa: (req.session && req.session.owa) || null }));
+
+const server = app.listen(0);
+await new Promise((r) => server.once('listening', r));
+const port = server.address().port;
+test.after(() => server.close());
+
+const haal = (pad, opts) => fetch(`http://127.0.0.1:${port}${pad}`, { redirect: 'manual', ...opts });
+
+// ── vindbaarheid ──────────────────────────────────────────────────────────
+
+test('de wortel wijst een home instance naar ons token-endpoint', async () => {
+  const r = await haal('/.well-known/webfinger?resource=' + encodeURIComponent('https://test.example/'));
+  assert.equal(r.status, 200);
+  const jrd = await r.json();
+  const link = jrd.links.find((l) => l.rel === 'http://purl.org/openwebauth/v1');
+  assert.ok(link, 'de rel uit de FEP staat erin');
+  assert.equal(link.href, 'https://test.example/owa/token');
+});
+
+test('en een ACTOR-uri blijft een 400, zoals eerder besloten', async () => {
+  // Dit is geen detail: test/webfinger-bare-host.test.js legt het vast, en de
+  // wortel-uitzondering mocht die keuze niet stiekem omdraaien.
+  const r = await haal('/.well-known/webfinger?resource=' + encodeURIComponent('https://test.example/ap/users/kid'));
+  assert.equal(r.status, 400);
+});
+
+test('een wortel van een ANDERE host is niet van ons', async () => {
+  const r = await haal('/.well-known/webfinger?resource=' + encodeURIComponent('https://ergens-anders.example/'));
+  assert.equal(r.status, 400);
+});
+
+// ── het token-endpoint ────────────────────────────────────────────────────
+
+test('zonder handtekening geen token', async () => {
+  // De hele waarde van dit endpoint zit in de handtekening: wie hem niet zet,
+  // bewijst niets en krijgt niets.
+  for (const method of ['GET', 'POST']) {
+    const r = await haal('/owa/token', { method });
+    assert.equal(r.status, 401, method);
+    assert.deepEqual(await r.json(), { success: false });
+  }
+});
+
+test('een verzonnen handtekening ook niet', async () => {
+  const r = await haal('/owa/token', {
+    headers: { signature: 'keyId="https://elders.example/users/mee#main-key",signature="bm9wZQ=="' },
+  });
+  assert.equal(r.status, 401);
+});
+
+// ── impersonatie ──────────────────────────────────────────────────────────
+
+test('?owt= bepaalt wie je bent', async () => {
+  app.locals.sessie = {};
+  const t = OWA.issueToken(ACTOR);
+  const r = await haal('/een-bericht?owt=' + encodeURIComponent(t));
+  assert.equal(r.status, 302, 'het token wordt uit de URL gehaald');
+  assert.equal(r.headers.get('location'), '/een-bericht', 'en laat niets achter in de adresbalk');
+  assert.equal(app.locals.sessie.owa.actor, ACTOR);
+});
+
+test('?zid= bepaalt NIETS -- dit is de impersonatie-aanval uit de FEP', async () => {
+  app.locals.sessie = {};
+  // Mallory wijst met zid naar Bob. Er mag hier geen identiteit uit ontstaan.
+  const r = await haal('/een-bericht?zid=' + encodeURIComponent('bob@elders.example'));
+  assert.ok(r.status === 302 || r.status === 200, 'hooguit een doorverwijzing');
+  assert.equal(app.locals.sessie.owa, undefined, 'zid logt niemand in');
+});
+
+test('en zid naast een geldig owt verandert de uitkomst niet', async () => {
+  app.locals.sessie = {};
+  const t = OWA.issueToken(ACTOR);                       // dit is Mallory's eigen token
+  const r = await haal(`/een-bericht?owt=${encodeURIComponent(t)}&zid=${encodeURIComponent('bob@elders.example')}`);
+  assert.equal(r.status, 302);
+  assert.equal(app.locals.sessie.owa.actor, ACTOR, 'het token wint, niet de claim');
+  assert.notEqual(app.locals.sessie.owa.actor, SLACHTOFFER);
+  assert.equal(r.headers.get('location'), '/een-bericht', 'en zid gaat ook de URL uit');
+});
+
+test('een opgebruikt token logt niemand in', async () => {
+  app.locals.sessie = {};
+  const t = OWA.issueToken(ACTOR);
+  await haal('/een-bericht?owt=' + encodeURIComponent(t));
+  app.locals.sessie = {};
+  await haal('/een-bericht?owt=' + encodeURIComponent(t));
+  assert.equal(app.locals.sessie.owa, undefined, 'de tweede keer is hij niets waard');
+});
+
+// ── uitloggen ─────────────────────────────────────────────────────────────
+
+test('uitloggen laat een lokale sessie met rust', async () => {
+  app.locals.sessie = { owa: { actor: ACTOR }, user: { id: 'u1' } };
+  await haal('/owa/logout');
+  assert.equal(app.locals.sessie.owa, undefined, 'de gast is weg');
+  assert.deepEqual(app.locals.sessie.user, { id: 'u1' }, 'de lokale gebruiker niet');
+});
Index: test/openwebauth.test.js
===================================================================
--- test/openwebauth.test.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ test/openwebauth.test.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -0,0 +1,186 @@
+// OpenWebAuth (FEP-61cf), de target-kant.
+//
+// Dit is een AUTHENTICATIEPAD, dus de toetsen hier staan scherper dan elders:
+// niet alleen "werkt de gelukkige route", maar met name de drie aanvallen die de
+// FEP zelf beschrijft -- impersonatie via ?zid=, open redirect via het
+// ontdekte endpoint, en tokens die blijven liggen.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'crypto';
+
+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 OWA = await import('../src/services/OpenWebAuthService.js');
+
+const ACTOR = 'https://elders.example/users/mee';
+const ANDER = 'https://elders.example/users/iemand';
+
+// ── tokens ────────────────────────────────────────────────────────────────
+
+test('een token wijst naar de actor die hem kreeg', () => {
+  const t = OWA.issueToken(ACTOR);
+  assert.equal(OWA.redeemToken(t), ACTOR);
+});
+
+test('een token werkt precies ÉÉN keer', () => {
+  // Anders is een token dat ergens in een log of een history-item blijft hangen
+  // een sleutel die blijft werken.
+  const t = OWA.issueToken(ACTOR);
+  assert.equal(OWA.redeemToken(t), ACTOR);
+  assert.equal(OWA.redeemToken(t), null, 'de tweede keer is niets meer waard');
+});
+
+test('een verzonnen token levert niemand op', () => {
+  assert.equal(OWA.redeemToken('zomaar-wat'), null);
+  assert.equal(OWA.redeemToken(''), null);
+  assert.equal(OWA.redeemToken(null), null);
+});
+
+test('een token verloopt, en verlopen aanbieden verbrandt hem ook', () => {
+  const t = OWA.issueToken(ACTOR, Date.now() - (OWA.TOKEN_TTL_MS + 1000));
+  assert.equal(OWA.redeemToken(t), null, 'te oud');
+  // En hij is weg: opnieuw aanbieden binnen de tijd kan niet alsnog lukken.
+  const rij = db.prepare('SELECT 1 FROM owa_tokens WHERE token = ?').get(t);
+  assert.equal(rij, undefined, 'een aangeboden token blijft niet liggen');
+});
+
+test('oude tokens worden opgeruimd (de DoS uit de FEP)', () => {
+  db.prepare('DELETE FROM owa_tokens').run();
+  const oud = Date.now() - (OWA.TOKEN_TTL_MS + 60_000);
+  for (let i = 0; i < 5; i++) {
+    db.prepare('INSERT INTO owa_tokens (token, actor_uri, created_at) VALUES (?,?,?)')
+      .run('oud-' + i, ACTOR, oud);
+  }
+  OWA.issueToken(ACTOR);            // elke uitgifte veegt
+  const over = db.prepare('SELECT COUNT(*) AS n FROM owa_tokens').get().n;
+  assert.equal(over, 1, 'alleen de verse blijft staan');
+});
+
+// ── versleuteling ─────────────────────────────────────────────────────────
+
+test('het token is alleen leesbaar voor wie de privésleutel heeft', () => {
+  // De hele reden dat deze stap bestaat: de ondertekenaar bewijst dat hij de
+  // actor BEHEERT, niet alleen dat hij zijn document kent.
+  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+  const pem = publicKey.export({ type: 'spki', format: 'pem' });
+  const token = OWA.issueToken(ACTOR);
+  const versleuteld = OWA.encryptTokenFor(token, pem);
+
+  assert.ok(!versleuteld.includes('='), 'base64url zonder padding, zoals de FEP zegt');
+  assert.ok(!/[+/]/.test(versleuteld), 'en URL-veilig');
+
+  const terug = crypto.privateDecrypt(
+    { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
+    Buffer.from(versleuteld.replace(/-/g, '+').replace(/_/g, '/'), 'base64'),
+  ).toString('utf8');
+  assert.equal(terug, token);
+
+  // Een ANDERE sleutel komt er niet bij -- maar LET OP HOE dat eruitziet, want
+  // de voor de hand liggende toets (assert.throws) is hier fout.
+  //
+  // PKCS#1 v1.5 gooit bij een verkeerde sleutel geen fout: OpenSSL 3 doet aan
+  // "implicit rejection" en geeft afgeleide onzin terug in plaats van te falen,
+  // juist zodat een aanvaller niet aan het foutgedrag kan aflezen of zijn gok
+  // klopte (Bleichenbacher/Marvin). Gemeten: 200 vreemde sleutels, 0 fouten,
+  // 200 keer bytes -- en 0 keer het token.
+  //
+  // De eigenschap die telt is dus niet "het knalt" maar "er komt iets anders
+  // uit". Wat de home instance daarna terugstuurt matcht geen enkel opgeslagen
+  // token, en de inlog mislukt gewoon.
+  const ruw = Buffer.from(versleuteld.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
+  for (let i = 0; i < 5; i++) {
+    const vreemde = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey;
+    let uit = null;
+    try {
+      uit = crypto.privateDecrypt({ key: vreemde, padding: crypto.constants.RSA_PKCS1_PADDING }, ruw).toString('utf8');
+    } catch { uit = null; }                     // een fout mag, maar is niet de regel
+    assert.notEqual(uit, token, 'andermans sleutel levert nooit het token');
+  }
+});
+
+// ── adressen lezen ────────────────────────────────────────────────────────
+
+test('een adres mag op vier manieren geschreven worden', () => {
+  for (const vorm of ['mee@elders.example', '@mee@elders.example', 'acct:mee@elders.example', '  @mee@elders.example  ']) {
+    const h = OWA.parseHandle(vorm);
+    assert.ok(h, vorm);
+    assert.equal(h.acct, 'mee@elders.example');
+  }
+});
+
+test('en onzin is geen adres', () => {
+  for (const vorm of ['', null, 'geen-apenstaartje', 'twee@@apen', 'met/schuine@streep', 'a@b@c']) {
+    assert.equal(OWA.parseHandle(vorm), null, JSON.stringify(vorm));
+  }
+});
+
+// ── open redirect ─────────────────────────────────────────────────────────
+
+test('een endpoint op een ANDERE host wordt geweigerd', async () => {
+  // De aanval: een server antwoordt op webfinger met een redirect-endpoint dat
+  // ergens anders wijst, en ons inlogformulier wordt een doorgeefluik.
+  const nep = async () => ({
+    ok: true,
+    json: async () => ({ links: [{ rel: OWA.REL_REDIRECT, href: 'https://kwaadaardig.example/magic' }] }),
+  });
+  const r = await OWA.discoverRedirectEndpoint('mee@elders.example', { fetchImpl: nep });
+  assert.equal(r, null, 'andere host dan het ingetypte adres → niet doen');
+});
+
+test('een endpoint op de eigen host is prima', async () => {
+  const nep = async () => ({
+    ok: true,
+    json: async () => ({ links: [{ rel: OWA.REL_REDIRECT, href: 'https://elders.example/owa/hier' }] }),
+  });
+  const r = await OWA.discoverRedirectEndpoint('mee@elders.example', { fetchImpl: nep });
+  assert.equal(r.endpoint, 'https://elders.example/owa/hier');
+});
+
+test('zonder webfinger-link vallen we terug op /magic, op dezelfde host', async () => {
+  const nep = async () => ({ ok: false, json: async () => ({}) });
+  const r = await OWA.discoverRedirectEndpoint('mee@elders.example', { fetchImpl: nep });
+  assert.equal(r.endpoint, 'https://elders.example/magic', 'Hubzilla en (streams) doen het zo');
+});
+
+test('de redirect draagt owa=1 en een hex-bdest', () => {
+  const terug = 'https://test.example/een-bericht';
+  const url = new URL(OWA.buildRedirect('https://elders.example/magic', terug));
+  assert.equal(url.searchParams.get('owa'), '1');
+  const bdest = url.searchParams.get('bdest');
+  assert.match(bdest, /^[0-9a-f]+$/i, 'hexadecimaal, zoals de FEP zegt');
+  assert.equal(Buffer.from(bdest, 'hex').toString('utf8'), terug);
+});
+
+// ── wie is er binnen ──────────────────────────────────────────────────────
+
+test('volgerschap is de vraag die fan_only stelt', () => {
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run('u-owa', 'u-owa', 'u@t', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
+    .run('s-owa', 'kid', 'kid', 'u-owa');
+  db.prepare('INSERT INTO ap_followers (slug, actor_uri) VALUES (?,?)').run('kid', ACTOR);
+
+  assert.equal(OWA.isFollowerOf('kid', ACTOR), true);
+  assert.equal(OWA.isFollowerOf('kid', ANDER), false, 'een vreemde volgt niet');
+  assert.equal(OWA.isFollowerOf('kid', null), false, 'en niemand al helemaal niet');
+  assert.equal(OWA.isFollowerOf('andere-site', ACTOR), false, 'volgen doe je een SITE, niet de server');
+});
+
+test('een sessie zonder bewijs levert geen actor', () => {
+  assert.equal(OWA.guestActor(null), null);
+  assert.equal(OWA.guestActor({ session: {} }), null);
+  assert.equal(OWA.guestActor({ session: { owa: {} } }), null);
+  assert.equal(OWA.guestActor({ session: { owa: { actor: ACTOR } } }), ACTOR);
+});
+
+test('viewerFor zet het bewijs om in een besluitbare vorm', () => {
+  const req = { session: { owa: { actor: ACTOR } } };
+  const v = OWA.viewerFor(req, { slug: 'kid' });
+  assert.equal(v.fediActor, ACTOR);
+  assert.equal(v.isFollower, true);
+  assert.equal(v.user, null, 'een gast is geen lokale gebruiker');
+});
Index: test/post-access.test.js
===================================================================
--- test/post-access.test.js	(revision 8b65e67e700ea809fa6f1d5e556db13ed2a0696c)
+++ test/post-access.test.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -77,4 +77,28 @@
 });
 
+// ── de fanpoort stelt eindelijk de juiste vraag ───────────────────────────
+// fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort
+// vroeg om een lokaal account. isFollower komt uit OpenWebAuthService: een
+// bezoeker die via FEP-61cf bewees @iemand@ergens te zijn en die deze site volgt.
+
+test('een bewezen volger komt door de fanpoort', () => {
+  const f = post({ fan_only: 1 });
+  assert.equal(postAccess(f, { user: VREEMDE, site: SITE }), 'fan', 'zonder bewijs nog steeds dicht');
+  assert.equal(postAccess(f, { user: VREEMDE, site: SITE, isFollower: true }), 'full',
+    'mét bewijs open -- geen account, geen wachtwoord, wel een volger');
+});
+
+test('maar volgen koopt geen betaald bericht', () => {
+  // De volgorde blijft: betaald wint van fan-only, en volgerschap is geen geld.
+  const b = post({ paid: 1, fan_only: 1 });
+  assert.equal(postAccess(b, { user: VREEMDE, site: SITE, isFollower: true }), 'paid');
+  assert.equal(postAccess(b, { user: VREEMDE, site: SITE, isFollower: true, unlockedSlug: 'hallo' }), 'full',
+    'betaald EN volger komt er wel doorheen');
+});
+
+test('volgen doet niets bij een concept', () => {
+  assert.equal(postAccess(post({ status: 'draft' }), { user: VREEMDE, site: SITE, isFollower: true }), 'forbidden');
+});
+
 test('de eigenaar loopt niet tegen zijn eigen muur op', () => {
   assert.equal(postAccess(post({ paid: 1, fan_only: 1 }), { user: OWNER, site: SITE }), 'full');
