Index: src/routes/ward.js
===================================================================
--- src/routes/ward.js	(revision 303bfc4daeb122010e84e14370b612f2e7e286f4)
+++ src/routes/ward.js	(revision 303bfc4daeb122010e84e14370b612f2e7e286f4)
@@ -0,0 +1,192 @@
+/**
+ * De Ward PWA (FEP-633c): de tegenhanger van /guardian, maar dan vanuit het
+ * kind gezien. Shaer doet dit als native app; dit is dezelfde ward-kant in de
+ * browser, voor wie geen app kan of wil installeren.
+ *
+ * Plak 1 is bewust HET VANGNET en niet de sociale kant: wie over mij waakt,
+ * of dat vangnet ook echt heel is, wat er bij mijn guardians ligt te wachten,
+ * en de knop om hulp te vragen. De feed en de composer heeft Klonkt al op de
+ * gewone site; die dubbelen we hier niet zomaar.
+ *
+ * Alles is gescoped op een site die de ingelogde gebruiker BEZIT, precies als
+ * bij /guardian (?site=slug kiest er een als je er meerdere hebt). Views
+ * dragen geen inline scripts (CSP): de logica staat in /assets/js/ward.js.
+ */
+import express from 'express';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import db from '../config/database.js';
+import { requireAuth } from '../middleware/auth.js';
+import AP from '../services/ActivityPubService.js';
+import * as Guardianship from '../services/guardianship/index.js';
+// offers en carriesGuardians komen NIET als naamruimte uit index.js (die
+// exporteert er losse namen van), dus rechtstreeks uit hun eigen module.
+import * as Offers from '../services/guardianship/offers.js';
+import { carriesGuardians } from '../services/guardianship/context.js';
+import { t as i18nT, resolveLang } from '../services/i18n.js';
+import { injectCspNonce } from '../middleware/render.js';
+
+const router = express.Router();
+const __dir = path.dirname(fileURLToPath(import.meta.url));
+
+/** De handelende site: ?site=slug als je hem bezit, anders je eerste. */
+function siteForUser(req) {
+  const userId = req.session.user.id;
+  const want = String(req.query.site || req.body?.site || '').trim();
+  if (want) {
+    const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
+    if (s) return s;
+  }
+  return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
+}
+
+/**
+ * Alles wat het scherm in één keer nodig heeft.
+ *
+ * Bewust ZONDER netwerk: dit draait bij elke paginaweergave, en een trage
+ * guardian-server mag de eigen pagina van een kind niet ophouden. Wat een
+ * remote fetch vraagt (de §4-controle) zit in een eigen endpoint dat de app
+ * ná het tekenen ophaalt.
+ */
+function wardState(site, L) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const me = AP.actorId(base, site.slug);
+  const guardianRows = Guardianship.listGuardians(site.slug);
+  const uris = guardianRows.map((g) => g.other_uri).filter(Boolean);
+  const statuses = Guardianship.availability.statusesFor(site.slug, uris, Date.now());
+  const byUri = new Map(statuses.map((s) => [s.id || s.uri, s]));
+
+  return {
+    me,
+    slug: site.slug,
+    // Wie over mij waakt. De beschikbaarheid staat erbij omdat een guardian
+    // die weg is de echte maat van je vangnet verandert (§3.6).
+    guardians: guardianRows.map((g) => {
+      const st = byUri.get(g.other_uri) || {};
+      return {
+        uri: g.other_uri,
+        handle: g.other_handle || AP.deriveHandleFor?.(g.other_uri) || g.other_uri,
+        availability: st['shaer:availability'] || st.availability || 'active',
+        awayUntil: st['shaer:awayUntil'] || st.awayUntil || null,
+      };
+    }),
+    // Aanbiedingen waar IK een partij in ben: iemand wil mijn guardian worden,
+    // of er loopt een verzoek om er een te laten gaan.
+    offers: Offers.listForParty(site.slug, me).map((o) => Offers.queueItem(o, me)),
+    // Wat ik wilde volgen en wat bij mijn guardians ligt (§5.3 uitgaand).
+    // Dit is de derde uitkomst die Shaer ook toont: niet verstuurd, niet
+    // mislukt, maar wachtend op iemand die je kent.
+    waitingFollows: Guardianship.outgoing.listForWard(site.slug).map((o) => ({
+      id: o.id,
+      target: o.target_uri,
+      handle: o.target_handle || o.target_uri,
+      status: o.status,
+      createdAt: o.created_at,
+    })),
+    strings: {
+      title: i18nT(L, 'ward.app_name'),
+      guardians: i18nT(L, 'ward.guardians'),
+      noGuardians: i18nT(L, 'ward.no_guardians'),
+      offers: i18nT(L, 'ward.offers'),
+      waiting: i18nT(L, 'ward.waiting'),
+      help: i18nT(L, 'ward.help'),
+    },
+  };
+}
+
+// ── De PWA-pagina ────────────────────────────────────────────────────────
+router.get('/', requireAuth, (req, res) => {
+  const site = siteForUser(req);
+  const L = resolveLang(req);
+  if (!site) return res.status(404).send('No site for this account.');
+  // Losse PWA-pagina, dus NIET via renderPage: de CSP-nonce moet hier zelf
+  // geïnjecteerd worden, anders blokkeert strict-dynamic ward.js en doet geen
+  // enkele knop iets (zelfde valkuil als bij /guardian).
+  res.render('pages/ward', {
+    state: wardState(site, L),
+    lang: L,
+    t: (k, v) => i18nT(L, k, v),
+    cspNonce: res.locals.cspNonce,
+  }, (err, html) => {
+    if (err) { console.error('[ward] render error', err); return res.status(500).send('Internal Server Error'); }
+    res.send(injectCspNonce(html, res.locals.cspNonce));
+  });
+});
+
+// ── JSON voor verversen ──────────────────────────────────────────────────
+router.get('/api/state', requireAuth, (req, res) => {
+  const site = siteForUser(req);
+  if (!site) return res.status(404).json({ error: 'no_site' });
+  res.json(wardState(site, resolveLang(req)));
+});
+
+/**
+ * De §4-controle: is mijn vangnet wel heel?
+ *
+ * Een "guardian" met eigen guardians is er geen (§1), en een escalatie
+ * daarheen komt nergens aan. Dat is precies wat een ward moet kunnen zien.
+ * Apart endpoint omdat het per guardian een remote fetch kost; de pagina
+ * tekent eerst en vult dit daarna aan.
+ *
+ * Drie uitkomsten, net als in de apps: in orde, misvormd, of onbereikbaar.
+ * Onbereikbaar is géén goedkeuring, dus het krijgt een eigen waarde in plaats
+ * van te ontbreken.
+ */
+router.get('/api/guardian-checks', requireAuth, async (req, res) => {
+  const site = siteForUser(req);
+  if (!site) return res.status(404).json({ error: 'no_site' });
+  const uris = Guardianship.listGuardians(site.slug).map((g) => g.other_uri).filter(Boolean);
+  const out = {};
+  await Promise.all(uris.map(async (uri) => {
+    const doc = await AP.fetchActor(uri).catch(() => null);
+    if (!doc) { out[uri] = 'unreachable'; return; }
+    out[uri] = carriesGuardians(doc) ? 'malformed' : 'sound';
+  }));
+  res.json({ checks: out });
+});
+
+function pwaAsset(rel, type) {
+  return (req, res) => {
+    res.set('Cache-Control', 'no-cache');
+    res.type(type);
+    res.sendFile(path.join(__dir, '..', 'assets', rel));
+  };
+}
+router.get('/app.js', pwaAsset('js/ward.js', 'application/javascript'));
+router.get('/app.css', pwaAsset('css/ward.css', 'text/css'));
+
+router.get('/manifest.webmanifest', (req, res) => {
+  const site = res.locals.site;
+  res.set('Cache-Control', 'no-cache');
+  res.json({
+    id: `klonkt-ward-${site?.slug || 'ward'}`,
+    name: 'Klonkt Ward',
+    short_name: 'Ward',
+    description: 'Wie over je waakt, en wat er op hen wacht.',
+    scope: '/ward/',
+    start_url: '/ward?source=pwa',
+    display: 'standalone',
+    display_override: ['standalone', 'minimal-ui'],
+    orientation: 'any',
+    background_color: '#141a24',
+    theme_color: '#5a32e6',
+    lang: site?.language || 'nl',
+    icons: [
+      { src: '/ward/icon.svg', sizes: 'any', type: 'image/svg+xml' },
+    ],
+  });
+});
+
+// Het schild uit de guardianship-taal, in het ward-accent.
+router.get('/icon.svg', (req, res) => {
+  const svg = `<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
+  <rect width="64" height="64" rx="14" fill="#141a24"/>
+  <path d="M32 12l16 6v14c0 10-6.6 16.6-16 20-9.4-3.4-16-10-16-20V18z" fill="#5a32e6"/>
+  <path d="M24 32l6 6 12-12" stroke="#fff" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>`;
+  res.set('Cache-Control', 'public, max-age=86400');
+  res.type('image/svg+xml').send(svg);
+});
+
+export default router;
