Index: test/ward-pwa.test.js
===================================================================
--- test/ward-pwa.test.js	(revision 303bfc4daeb122010e84e14370b612f2e7e286f4)
+++ test/ward-pwa.test.js	(revision 303bfc4daeb122010e84e14370b612f2e7e286f4)
@@ -0,0 +1,119 @@
+// De Ward PWA (/ward): de ward-kant van hetzelfde vangnet, in de browser.
+//
+// Wat hier bewaakt wordt is niet de opmaak maar de POORT en de INHOUD: een
+// vreemde mag deze pagina niet zien, en wat er wel doorheen komt moet de drie
+// dingen dragen waarvoor het scherm bestaat (wie waakt over mij, wat wacht er
+// op hen, en de §4-uitkomst die zegt of dat vangnet heel is).
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://kind.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const express = (await import('express')).default;
+const wardRoutes = (await import('../src/routes/ward.js')).default;
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'mee', 'mee@t', 'x', 'user');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)')
+  .run('s1', 'mee', 'Mee', 'u1');
+
+// Twee guardians: oma is in orde, en een tweede die zelf een ward is. Die
+// laatste is het geval uit §4: een guardian met eigen guardians is er geen.
+const OMA = 'https://elders.example/ap/users/oma';
+const KAPOT = 'https://elders.example/ap/users/kapot';
+for (const [uri, handle] of [[OMA, '@oma@elders.example'], [KAPOT, '@kapot@elders.example']]) {
+  db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, created_at)
+              VALUES (?, 'ward', ?, ?, 'accepted', datetime('now'))`).run('mee', uri, handle);
+}
+
+/// Een sessie die zegt dat je ingelogd bent als deze gebruiker.
+function appWith(session) {
+  const app = express();
+  // Zelfde view-opzet als de echte server, anders kan express het sjabloon
+  // niet vinden en faalt de render met "no default engine".
+  app.set('view engine', 'ejs');
+  app.set('views', new URL('../src/views', import.meta.url).pathname);
+  app.use((req, res, next) => { req.session = session; res.locals = {}; next(); });
+  app.use('/ward', wardRoutes);
+  return app;
+}
+
+async function listen(app) {
+  const server = app.listen(0);
+  await new Promise((r) => server.once('listening', r));
+  return { server, port: server.address().port };
+}
+
+test('een vreemde komt er niet in', async () => {
+  // Geen sessie: requireAuth hoort dit te weren. Zonder deze poort zou het
+  // vangnet van een kind voor iedereen te lezen zijn.
+  const { server, port } = await listen(appWith({}));
+  try {
+    const r = await fetch(`http://127.0.0.1:${port}/ward/api/state`, { redirect: 'manual' });
+    assert.notEqual(r.status, 200, 'een niet-ingelogde lezer kreeg de ward-toestand');
+  } finally { server.close(); }
+});
+
+test('de toestand draagt de guardians en wat er op hen wacht', async () => {
+  // Een follow die de guardians nog moeten goedkeuren (§5.3 uitgaand).
+  const G = await import('../src/services/guardianship/outgoing.js');
+  // De velden heten zoals recordPending ze leest: id, target, inbox, handle.
+  G.recordPending('mee', {
+    id: 'of-test-1',
+    target: 'https://elders.example/ap/users/tante',
+    handle: '@tante@elders.example',
+    inbox: 'https://elders.example/inbox',
+    quorum: 'any',
+  });
+
+  const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
+  try {
+    const r = await fetch(`http://127.0.0.1:${port}/ward/api/state`);
+    assert.equal(r.status, 200);
+    const s = await r.json();
+
+    assert.equal(s.slug, 'mee');
+    assert.deepEqual(s.guardians.map((g) => g.uri).sort(), [OMA, KAPOT].sort());
+    // Beschikbaarheid hoort erbij: een guardian die weg is verandert de echte
+    // maat van je vangnet.
+    assert.ok(s.guardians.every((g) => typeof g.availability === 'string'));
+
+    assert.equal(s.waitingFollows.length, 1);
+    assert.equal(s.waitingFollows[0].handle, '@tante@elders.example');
+    // Niet 'sent' en niet mislukt: dit is de derde uitkomst.
+    assert.equal(s.waitingFollows[0].status, 'pending');
+  } finally { server.close(); }
+});
+
+test('de pagina serveert zichzelf compleet, zonder inline script', async () => {
+  const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
+  try {
+    const r = await fetch(`http://127.0.0.1:${port}/ward/`);
+    assert.equal(r.status, 200);
+    const html = await r.text();
+    // De begintoestand reist mee, zodat het scherm niet leeg begint.
+    assert.match(html, /id="ward-state"/);
+    assert.match(html, /application\/json/);
+    // CSP: alle logica komt uit een bestand, nooit uit de pagina zelf.
+    assert.doesNotMatch(html, /<script(?![^>]*(src=|type="application\/json"))/);
+    assert.match(html, /\/ward\/app\.js/);
+    // En het is een installeerbare PWA, geen losse pagina.
+    assert.match(html, /rel="manifest" href="\/ward\/manifest\.webmanifest"/);
+  } finally { server.close(); }
+});
+
+test('het manifest wijst naar zijn eigen scope', async () => {
+  const { server, port } = await listen(appWith({ user: { id: 'u1' } }));
+  try {
+    const m = await (await fetch(`http://127.0.0.1:${port}/ward/manifest.webmanifest`)).json();
+    assert.equal(m.scope, '/ward/');
+    assert.equal(m.display, 'standalone');
+    // Eigen scope EN eigen start_url: anders opent de geïnstalleerde app in
+    // het guardian-dashboard, en dat is de andere kant van deze relatie.
+    assert.match(m.start_url, /^\/ward/);
+  } finally { server.close(); }
+});
