source: Klonkt/src/routes/ward.js@ 303bfc4

Last change on this file since 303bfc4 was 303bfc4, checked in by Robin <roboburr@…>, 3 months ago

De Ward PWA: het vangnet vanuit het kind gezien

Klonkt had /guardian, de kant van de volwassene. Dit is dezelfde relatie
vanaf de andere kant: wie waakt er over mij, is dat vangnet heel, en wat
ligt er bij hen te wachten. Shaer doet dit als native app; dit is het in
de browser, voor wie geen app kan of wil installeren.

Plak 1 is bewust HET VANGNET en niet de sociale kant. De feed en de
composer staan al op de gewone Klonkt-site; die dubbelen we niet zomaar.
Wat Shaer uniek toevoegt voor een ward is juist het guardianship-deel.

Drie keuzes die er toe doen. De hulpknop staat bovenaan en vol breed: als
je hem nodig hebt moet je niet hoeven zoeken. De par. 4-controle zit in
een eigen endpoint omdat hij per guardian een remote fetch kost, dus de
pagina tekent eerst en vult daarna aan; een trage guardian-server mag het
scherm van een kind niet ophouden. En dezelfde drieslag als in de apps:
misvormd is oranje, onbereikbaar is grijs, in orde is stil. Onbereikbaar
is geen goedkeuring, maar ook geen alarm.

New file:
src/routes/ward.js

  • pagina, /api/state, /api/guardian-checks, assets, manifest, icoon

src/views/pages/ward.ejs

  • schil; begintoestand als JSON mee, geen inline script (CSP)

src/assets/js/ward.js

  • tekent guardians, aanvragen en wachtende follows

src/assets/css/ward.css

  • donker, grote raakzones

test/ward-pwa.test.js

  • de poort (een vreemde komt er niet in) en de inhoud

Changed files:
src/server.js

  • /ward gemonteerd

src/services/i18n.js

  • ward-sleutels in nl, en, de

remarks: 886 tests groen. De hulpknop en het accepteren van aanvragen
doen nog niets; dat is de volgende plak.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 8.0 KB
Line 
1/**
2 * De Ward PWA (FEP-633c): de tegenhanger van /guardian, maar dan vanuit het
3 * kind gezien. Shaer doet dit als native app; dit is dezelfde ward-kant in de
4 * browser, voor wie geen app kan of wil installeren.
5 *
6 * Plak 1 is bewust HET VANGNET en niet de sociale kant: wie over mij waakt,
7 * of dat vangnet ook echt heel is, wat er bij mijn guardians ligt te wachten,
8 * en de knop om hulp te vragen. De feed en de composer heeft Klonkt al op de
9 * gewone site; die dubbelen we hier niet zomaar.
10 *
11 * Alles is gescoped op een site die de ingelogde gebruiker BEZIT, precies als
12 * bij /guardian (?site=slug kiest er een als je er meerdere hebt). Views
13 * dragen geen inline scripts (CSP): de logica staat in /assets/js/ward.js.
14 */
15import express from 'express';
16import path from 'path';
17import { fileURLToPath } from 'url';
18import db from '../config/database.js';
19import { requireAuth } from '../middleware/auth.js';
20import AP from '../services/ActivityPubService.js';
21import * as Guardianship from '../services/guardianship/index.js';
22// offers en carriesGuardians komen NIET als naamruimte uit index.js (die
23// exporteert er losse namen van), dus rechtstreeks uit hun eigen module.
24import * as Offers from '../services/guardianship/offers.js';
25import { carriesGuardians } from '../services/guardianship/context.js';
26import { t as i18nT, resolveLang } from '../services/i18n.js';
27import { injectCspNonce } from '../middleware/render.js';
28
29const router = express.Router();
30const __dir = path.dirname(fileURLToPath(import.meta.url));
31
32/** De handelende site: ?site=slug als je hem bezit, anders je eerste. */
33function siteForUser(req) {
34 const userId = req.session.user.id;
35 const want = String(req.query.site || req.body?.site || '').trim();
36 if (want) {
37 const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
38 if (s) return s;
39 }
40 return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
41}
42
43/**
44 * Alles wat het scherm in één keer nodig heeft.
45 *
46 * Bewust ZONDER netwerk: dit draait bij elke paginaweergave, en een trage
47 * guardian-server mag de eigen pagina van een kind niet ophouden. Wat een
48 * remote fetch vraagt (de §4-controle) zit in een eigen endpoint dat de app
49 * ná het tekenen ophaalt.
50 */
51function wardState(site, L) {
52 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
53 const me = AP.actorId(base, site.slug);
54 const guardianRows = Guardianship.listGuardians(site.slug);
55 const uris = guardianRows.map((g) => g.other_uri).filter(Boolean);
56 const statuses = Guardianship.availability.statusesFor(site.slug, uris, Date.now());
57 const byUri = new Map(statuses.map((s) => [s.id || s.uri, s]));
58
59 return {
60 me,
61 slug: site.slug,
62 // Wie over mij waakt. De beschikbaarheid staat erbij omdat een guardian
63 // die weg is de echte maat van je vangnet verandert (§3.6).
64 guardians: guardianRows.map((g) => {
65 const st = byUri.get(g.other_uri) || {};
66 return {
67 uri: g.other_uri,
68 handle: g.other_handle || AP.deriveHandleFor?.(g.other_uri) || g.other_uri,
69 availability: st['shaer:availability'] || st.availability || 'active',
70 awayUntil: st['shaer:awayUntil'] || st.awayUntil || null,
71 };
72 }),
73 // Aanbiedingen waar IK een partij in ben: iemand wil mijn guardian worden,
74 // of er loopt een verzoek om er een te laten gaan.
75 offers: Offers.listForParty(site.slug, me).map((o) => Offers.queueItem(o, me)),
76 // Wat ik wilde volgen en wat bij mijn guardians ligt (§5.3 uitgaand).
77 // Dit is de derde uitkomst die Shaer ook toont: niet verstuurd, niet
78 // mislukt, maar wachtend op iemand die je kent.
79 waitingFollows: Guardianship.outgoing.listForWard(site.slug).map((o) => ({
80 id: o.id,
81 target: o.target_uri,
82 handle: o.target_handle || o.target_uri,
83 status: o.status,
84 createdAt: o.created_at,
85 })),
86 strings: {
87 title: i18nT(L, 'ward.app_name'),
88 guardians: i18nT(L, 'ward.guardians'),
89 noGuardians: i18nT(L, 'ward.no_guardians'),
90 offers: i18nT(L, 'ward.offers'),
91 waiting: i18nT(L, 'ward.waiting'),
92 help: i18nT(L, 'ward.help'),
93 },
94 };
95}
96
97// ── De PWA-pagina ────────────────────────────────────────────────────────
98router.get('/', requireAuth, (req, res) => {
99 const site = siteForUser(req);
100 const L = resolveLang(req);
101 if (!site) return res.status(404).send('No site for this account.');
102 // Losse PWA-pagina, dus NIET via renderPage: de CSP-nonce moet hier zelf
103 // geïnjecteerd worden, anders blokkeert strict-dynamic ward.js en doet geen
104 // enkele knop iets (zelfde valkuil als bij /guardian).
105 res.render('pages/ward', {
106 state: wardState(site, L),
107 lang: L,
108 t: (k, v) => i18nT(L, k, v),
109 cspNonce: res.locals.cspNonce,
110 }, (err, html) => {
111 if (err) { console.error('[ward] render error', err); return res.status(500).send('Internal Server Error'); }
112 res.send(injectCspNonce(html, res.locals.cspNonce));
113 });
114});
115
116// ── JSON voor verversen ──────────────────────────────────────────────────
117router.get('/api/state', requireAuth, (req, res) => {
118 const site = siteForUser(req);
119 if (!site) return res.status(404).json({ error: 'no_site' });
120 res.json(wardState(site, resolveLang(req)));
121});
122
123/**
124 * De §4-controle: is mijn vangnet wel heel?
125 *
126 * Een "guardian" met eigen guardians is er geen (§1), en een escalatie
127 * daarheen komt nergens aan. Dat is precies wat een ward moet kunnen zien.
128 * Apart endpoint omdat het per guardian een remote fetch kost; de pagina
129 * tekent eerst en vult dit daarna aan.
130 *
131 * Drie uitkomsten, net als in de apps: in orde, misvormd, of onbereikbaar.
132 * Onbereikbaar is géén goedkeuring, dus het krijgt een eigen waarde in plaats
133 * van te ontbreken.
134 */
135router.get('/api/guardian-checks', requireAuth, async (req, res) => {
136 const site = siteForUser(req);
137 if (!site) return res.status(404).json({ error: 'no_site' });
138 const uris = Guardianship.listGuardians(site.slug).map((g) => g.other_uri).filter(Boolean);
139 const out = {};
140 await Promise.all(uris.map(async (uri) => {
141 const doc = await AP.fetchActor(uri).catch(() => null);
142 if (!doc) { out[uri] = 'unreachable'; return; }
143 out[uri] = carriesGuardians(doc) ? 'malformed' : 'sound';
144 }));
145 res.json({ checks: out });
146});
147
148function pwaAsset(rel, type) {
149 return (req, res) => {
150 res.set('Cache-Control', 'no-cache');
151 res.type(type);
152 res.sendFile(path.join(__dir, '..', 'assets', rel));
153 };
154}
155router.get('/app.js', pwaAsset('js/ward.js', 'application/javascript'));
156router.get('/app.css', pwaAsset('css/ward.css', 'text/css'));
157
158router.get('/manifest.webmanifest', (req, res) => {
159 const site = res.locals.site;
160 res.set('Cache-Control', 'no-cache');
161 res.json({
162 id: `klonkt-ward-${site?.slug || 'ward'}`,
163 name: 'Klonkt Ward',
164 short_name: 'Ward',
165 description: 'Wie over je waakt, en wat er op hen wacht.',
166 scope: '/ward/',
167 start_url: '/ward?source=pwa',
168 display: 'standalone',
169 display_override: ['standalone', 'minimal-ui'],
170 orientation: 'any',
171 background_color: '#141a24',
172 theme_color: '#5a32e6',
173 lang: site?.language || 'nl',
174 icons: [
175 { src: '/ward/icon.svg', sizes: 'any', type: 'image/svg+xml' },
176 ],
177 });
178});
179
180// Het schild uit de guardianship-taal, in het ward-accent.
181router.get('/icon.svg', (req, res) => {
182 const svg = `<?xml version="1.0" encoding="UTF-8"?>
183<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
184 <rect width="64" height="64" rx="14" fill="#141a24"/>
185 <path d="M32 12l16 6v14c0 10-6.6 16.6-16 20-9.4-3.4-16-10-16-20V18z" fill="#5a32e6"/>
186 <path d="M24 32l6 6 12-12" stroke="#fff" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
187</svg>`;
188 res.set('Cache-Control', 'public, max-age=86400');
189 res.type('image/svg+xml').send(svg);
190});
191
192export default router;
Note: See TracBrowser for help on using the repository browser.