source: Klonkt/src/routes/guardian.js@ 6259305

main
Last change on this file since 6259305 was 6259305, checked in by Robin <roboburr@…>, 4 weeks ago

Eén weg naar de hulpvragen: het paneel had een eigen afkap (shaer-6wt, staart)

Gevonden bij de limietinventarisatie na Barts 429-jacht. De fix van
vanochtend haalt de afkap van OPEN hulpvragen weg in helpItemsFor -- maar
de guardian-PWA had een eigen kopie van die query staan, mét LIMIT 50.
Precies de gebruiker uit die bead (de jeugdzorgmedewerker met een
caseload) zag in het PANEEL dus nog steeds open vragen wegvallen zodra er
meer dan vijftig waren.

Twee wegen naar dezelfde staat, en de fix nam er maar één -- hetzelfde
patroon dat eerder bij de reply-gate misging.

De reden dat die kopie bestond was echt: het paneel toont kaarten en had
note_url, media, quote en emoji nodig, die de queue-query niet selecteerde.
Dus draagt de bron ze nu; de queue leest ze simpelweg niet, en er is geen
aanleiding meer om opnieuw te splitsen.

Drie toetsen met een caseload van 80: alle open vragen komen door, de
bron draagt de kaartvelden, en de geschiedenis mag nog steeds afgekapt
worden. De testopstelling ving onderweg zijn eigen les: zonder echte
ward-relaties telt elke vraag als van een oud-ward en dus niet-open --
withWardship doet dat met opzet, mijn eerste opzet toetste per ongeluk
dat pad. Alle 785 groen.

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

  • Property mode set to 100644
File size: 38.1 KB
Line 
1/**
2 * The Guardian PWA (FEP-633c): a separate, installable corner of Klonkt for
3 * guardians. One place to add and manage wards, a message centre for
4 * incoming help requests and adoption traffic, and its own push channel
5 * (alert types 'help' and 'guardian', web-push slice reused).
6 *
7 * Everything is scoped to a site the logged-in user OWNS: the guardian acts
8 * as one of their own actors (?site=slug picks one when they own several).
9 * Views carry no inline scripts (CSP): logic lives in /assets/js/guardian.js.
10 */
11import express from 'express';
12import path from 'path';
13import { fileURLToPath } from 'url';
14import db from '../config/database.js';
15import { requireAuth } from '../middleware/auth.js';
16import AP from '../services/ActivityPubService.js';
17import * as Guardianship from '../services/guardianship/index.js';
18import { t as i18nT, resolveLang } from '../services/i18n.js';
19import { injectCspNonce, renderNoteBody, formatDateTime } from '../middleware/render.js';
20import { emojiName } from '../services/NoteRender.js';
21
22const router = express.Router();
23const __dir = path.dirname(fileURLToPath(import.meta.url));
24
25/** The acting site: ?site=slug when owned, else the user's first site. */
26function siteForUser(req) {
27 const userId = req.session.user.id;
28 const want = String(req.query.site || req.body?.site || '').trim();
29 if (want) {
30 const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
31 if (s) return s;
32 }
33 return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
34}
35
36/** Everything the dashboard shows, one shape for page and API. */
37function uiStrings(L) {
38 const keys = ['sent', 'sent_retry', 'sending', 'not_found', 'failed', 'network',
39 'pending', 'active', 'retract', 'release', 'release_confirm', 'open', 'push_unavailable',
40 'embeds_on', 'embeds_off', 'embeds_propose', 'embeds_waiting',
41 'accept', 'reject', 'complete', 'awaiting_others', 'coguard',
42 // The per-ward panel: everything about one child in one place.
43 'settings_title', 'panel_open', 'panel_close', 'panel_help', 'panel_help_empty',
44 'panel_follow', 'panel_follow_empty', 'panel_posts', 'panel_posts_empty',
45 'panel_actions', 'badge_help', 'badge_follow', 'badge_follow_one', 'help_empty',
46 // Releasing a ward: a deliberate two-step answer, never one click.
47 'release_title', 'release_effect', 'release_local', 'release_step_down',
48 'release_last', 'release_unknown', 'release_yes', 'release_no',
49 // Availability (FEP-633c 3.6): the dots, the step-away, the lapse.
50 'avail_available', 'avail_away', 'avail_dormant', 'panel_guards', 'panel_guards_remote',
51 'lapse_propose', 'lapse_line', 'lapse_tally', 'lapse_note', 'lapse_agree', 'lapse_disagree', 'voted',
52 'away_title', 'away_sub', 'away_week', 'away_month', 'away_done',
53 // A gated-setting proposal from a fellow guardian (5.6).
54 'gated_title', 'gated_line_on', 'gated_line_off', 'gated_agree', 'gated_disagree',
55 'play_propose', 'play_on', 'play_off',
56 // The status of a proposal this guardian sent (5.6).
57 'prop_line', 'prop_embeds', 'prop_play', 'prop_on', 'prop_off',
58 'prop_st_open', 'prop_st_accepted', 'prop_st_rejected', 'prop_st_expired',
59 'panel_guards_far',
60 // Het gate-paneel per ward (shaer-ahy.1): een rij per gate, met het soort en
61 // de drempel erbij. De namen volgen de catalogus in gated.js.
62 'gate_externalEmbeds', 'gate_externalPlayback', 'gate_externalThreads', 'gate_follows',
63 'gate_kind_setting', 'gate_kind_perRequest', 'gate_kind_handover',
64 'gate_unknown', 'gate_threshold', 'gate_threshold_unknown',
65 'gate_irreversible', 'gate_waiting', 'gate_blocked', 'gate_propose',
66 // Oppikken en afhandelen van een hulpvraag (shaer-lgo).
67 'help_pick', 'help_close', 'help_picked_by', 'help_handled_by', 'help_handled_note',
68 'help_close_ask', 'help_close_yes', 'help_just_now', 'help_hours', 'help_days', 'help_former_ward',
69 'warn_reversible', 'warn_irreversible', 'warn_unknown', 'warn_tally_elsewhere', 'warn_decides', 'warn_not_last', 'warn_go', 'warn_back',
70 'help_archive', 'help_archive_hide', 'panel_history',
71 'gate_propose_open', 'gate_propose_close', 'gate_default_off',
72 'gate_images', 'gate_messages', 'gate_compose', 'gate_replies', 'gate_music', 'gate_quoteCards', 'gate_asked',
73 'gate_customEmoji', 'gate_publicProfile', 'gate_accountMove', 'gate_independence',
74 'gate_unavailable', 'gate_planned_note', 'gates_summary', 'gates_show', 'gates_hide'];
75 const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
76 s.wave = i18nT(L, 'guardian.wave');
77 s.waved = i18nT(L, 'guardian.waved');
78 return s;
79}
80
81function dashboardState(site, L) {
82 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
83 const me = AP.actorId(base, site.slug);
84 // EEN weg naar de hulpvragen (Barts 429-jacht, 9-8): dit scherm had een
85 // eigen kopie van de queue-query, met een afkap op 50 -- dus de fix die open
86 // vragen nooit meer afkapt (shaer-6wt) ging aan het paneel voorbij, en juist
87 // de guardian met een caseload zag oude open vragen wegvallen. Nu dezelfde
88 // bron als de apps: open vragen volledig, geschiedenis afgekapt.
89 const helpItems = Guardianship.queues.helpItemsFor(site.slug).map((h) => ({
90 ...h,
91 // The dashboard is built in the browser, so it gets the body finished: the
92 // same partial de Krant and Berichten use. A 🛟 often carries a screenshot
93 // and a link to the post it is about; both belong in the card.
94 body_html: renderNoteBody(h, L),
95 name_html: emojiName(h.actor_name || '', h.actor_emoji_json),
96 // In the site's own timezone, the same as everywhere else in Klonkt. The
97 // PWA used to slice the raw UTC string, so a 20:20 call for help read 18:20.
98 when_text: formatDateTime(h.published || h.created_at),
99 }));
100 return {
101 site: site.slug,
102 me,
103 // Committed wards, each carrying the gated settings a guardian may change.
104 // `embeds` is null for a ward we do not host: that setting lives on the
105 // ward's own server, so we show it as not-adjustable rather than lying.
106 // `guardians` (FEP-633c 3.6): the fellow guardians of a LOCAL ward with
107 // their availability; null for a remote ward, whose server tracks it.
108 wards: Guardianship.listWards(site.slug).map((w) => ({
109 ...w,
110 embeds: wardEmbedSetting(w.other_uri),
111 playback: wardPlaybackSetting(w.other_uri),
112 guardians: Guardianship.queues.wardGuardianStatuses(w.other_uri),
113 // What THIS guardian proposed for this ward and how it stands (5.6):
114 // open, accepted, rejected, or expired when the window ran out and the
115 // ward's server had nothing to write home. The answer is a real
116 // Accept/Reject from the ward's server, not a guess from here.
117 proposals: Guardianship.gated.listSent(site.slug, w.other_uri).map((p) => ({
118 feature: p.feature, value: !!p.value, created: p.created_at,
119 status: Guardianship.gated.sentStatus(p, Date.now()),
120 })),
121 // Alles wat voor dit kind gated is op EEN plek, met per gate het soort en
122 // de drempel (shaer-ahy.1). Losse knoppen lieten een guardian zelf
123 // uitzoeken wat er allemaal geldt; wat niet verstelbaar is stond nergens.
124 gates: Guardianship.queues.wardGates(site.slug, w.other_uri),
125 })),
126 offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
127 // Running lapses (3.6.3) this guardian or its local wards are party to.
128 lapses: Guardianship.availability.lapseQueueItems(site.slug, me, Date.now()),
129 // Gated-setting proposals another guardian opened on a ward we share
130 // (5.6), forwarded here by the ward's server. Without answering these the
131 // threshold is never met and the proposal simply expires.
132 gatedReviews: Guardianship.gated.listGatedReviews(site.slug).map((r) => ({
133 id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value,
134 // Wat er blijft hangen als dit doorgaat (shaer-nf9). Alleen bij OPENZETTEN:
135 // dichtzetten laat niets nieuws door en hoeft dus niet gewaarschuwd te
136 // worden -- een waarschuwing die overal staat wordt nergens gelezen.
137 consequence: r.value ? Guardianship.gated.gateConsequence(r.feature) : null,
138 // Maakt JOUW antwoord dit af (shaer-8vt)? De telling loopt op de server van
139 // het kind, dus dit is het enige wat we erover weten -- en zonder dat
140 // weet niemand dat hij de doorslag geeft.
141 decisive: r.decisive !== 0,
142 })),
143 help: helpItems,
144 strings: uiStrings(L),
145 };
146}
147
148
149// ── The PWA page ─────────────────────────────────────────────────────────
150router.get('/', requireAuth, (req, res) => {
151 const site = siteForUser(req);
152 const L = resolveLang(req);
153 if (!site) return res.status(404).send('No site for this account.');
154 const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
155 // This standalone PWA page is rendered directly (not through renderPage), so
156 // the CSP nonce must be injected here — otherwise strict-dynamic blocks
157 // guardian.js and the whole dashboard is dead (buttons do nothing).
158 res.render('pages/guardian', {
159 state: dashboardState(site, L),
160 sites,
161 lang: L,
162 t: (k, v) => i18nT(L, k, v),
163 cspNonce: res.locals.cspNonce,
164 }, (err, html) => {
165 if (err) { console.error('[guardian] render error', err); return res.status(500).send('Internal Server Error'); }
166 res.send(injectCspNonce(html, res.locals.cspNonce));
167 });
168});
169
170// ── JSON state for refreshes ─────────────────────────────────────────────
171/**
172 * De staat van het paneel, desgewenst als LANGE POLL (Barts opdracht, 9-8).
173 *
174 * Zonder `wait` gedraagt de route zich exact zoals altijd. Met `wait` blijft het
175 * antwoord hangen tot er iets gebeurt dat de guardian moet verwerken, of tot de
176 * tijd om is -- dan een lege 304.
177 *
178 * EERST KIJKEN, DAN WACHTEN. Veranderde er iets tussen het vorige antwoord en
179 * dit verzoek, dan is de merksteen nu al anders en gaat het antwoord METEEN de
180 * deur uit. Zou je eerst gaan wachten, dan blijft nieuws dat net in dat gaatje
181 * viel vijfentwintig seconden liggen -- en juist bij een hulpvraag is dat de
182 * verkeerde vertraging.
183 *
184 * WAKKER OP ALLES. De guardianship-module zendt veertien soorten gebeurtenissen
185 * uit en die wekken allemaal (wakeGuardian); daarnaast wekt de tijdlijn (onNews),
186 * want de berichten van je wards staan in ditzelfde scherm.
187 */
188router.get('/api/state', requireAuth, async (req, res) => {
189 const site = siteForUser(req);
190 if (!site) return res.status(404).json({ error: 'no_site' });
191 const stuur = () => AP.sendMaybe304(req, res, dashboardState(site, resolveLang(req)), { contentType: 'application/json' });
192
193 const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
194 const merk = req.headers['if-none-match'];
195 if (!wachtS || !merk) return stuur();
196
197 // Is er nu al iets anders? Dan niet wachten.
198 const nu = AP.etagFor(JSON.stringify(dashboardState(site, resolveLang(req))));
199 if (nu !== merk) return stuur();
200
201 await new Promise((klaar) => {
202 let af = false;
203 const eind = () => { if (af) return; af = true; clearTimeout(t); offG(); offN(); klaar(); };
204 const offG = AP.onGuardian(site.slug, eind);
205 const offN = AP.onNews(site.slug, eind);
206 const t = setTimeout(eind, wachtS * 1000);
207 // Hing de client op, dan houdt niemand dit antwoord meer vast.
208 res.on('close', eind);
209 });
210 if (res.writableEnded) return undefined;
211 return stuur();
212});
213
214// ── Meekijken (FEP-633c §5, interop-hoofdroute): a committed guardian FOLLOWS
215// its wards, so their posts (incl. followers-only) are DELIVERED to the
216// guardian's inbox → timeline. The follow is the mechanism; no new fetch.
217// First contact also backfills the ward's recent PUBLIC posts as a cold
218// start so the corner is not empty before delivery catches up.
219function ensureWardConnections(site) {
220 let wards;
221 try { wards = Guardianship.listWards(site.slug); } catch { return; }
222 for (const w of wards) {
223 const already = db.prepare('SELECT 1 FROM ap_following WHERE slug = ? AND actor_uri = ?')
224 .get(site.slug, w.other_uri);
225 if (already) continue;
226 // Follow (guardian's server auto-accepts today; §5.3 gating is a later fase).
227 AP.followActor(site, w.other_uri).catch(() => { /* retried by the queue */ });
228 // Cold start: pull recent public posts now so oma sees something at once.
229 AP.backfillFromOutbox(site.slug, w.other_uri).catch(() => { /* best-effort */ });
230 }
231}
232
233// ── The wards' corner: your wards' posts, read-only. No reply, no share; a
234// guardian watches, it does not publish (Robins besluit).
235router.get('/api/feed', requireAuth, (req, res) => {
236 const site = siteForUser(req);
237 if (!site) return res.status(404).json({ error: 'no_site' });
238 const L = resolveLang(req);
239 ensureWardConnections(site);
240 const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
241 // Only show the wards you actually guard (the timeline can hold more).
242 const items = AP.getTimeline(site.slug, 60, 0)
243 .filter((p) => wardUris.has(p.author_uri))
244 .map((p) => ({
245 id: p.id,
246 author: p.author_handle || p.author_name || p.author_uri,
247 authorUri: p.author_uri, // the grouping key: which child's panel this belongs in
248 authorName: p.author_name,
249 authorIcon: p.author_icon,
250 content: p.content,
251 url: p.url,
252 published: p.published || p.created_at,
253 when_text: formatDateTime(p.published || p.created_at),
254 cw: p.cw || null,
255 media: p.media_json ? JSON.parse(p.media_json) : [],
256 // Een post van je ward hoort er hetzelfde uit te zien als in de Krant en
257 // in Berichten: dezelfde partial, dus opmaak, media, quote-kaart en
258 // embed. Tot nu toe kreeg de PWA alleen kale content -- een guardian zag
259 // een lege regel waar een foto stond. `content` blijft ernaast staan voor
260 // een client die nog uit de cache draait.
261 body_html: renderNoteBody(p, L),
262 }));
263 res.json({ items, following: wardUris.size });
264});
265
266// ── Follow-gating (FEP-633c §5.3): pending follows on MY wards, for me to
267// approve. Ward and guardian are co-located on the family Klonkt here, so
268// the guardian reads its wards' pending follows locally.
269function wardSlugsOf(site) {
270 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
271 return Guardianship.listWards(site.slug)
272 .map((w) => (w.other_uri.startsWith(base) ? { slug: w.other_uri.split('/').pop(), uri: w.other_uri } : null))
273 .filter(Boolean);
274}
275
276router.get('/api/follow-requests', requireAuth, (req, res) => {
277 const site = siteForUser(req);
278 if (!site) return res.status(404).json({ error: 'no_site' });
279 const items = [];
280 const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
281 // wardUri is the grouping key for the per-ward panel: the handle is for
282 // reading, the URI is what identifies the child across both cases below.
283 // Local wards (guardian co-located): read the pending follows directly.
284 for (const w of wardSlugsOf(site)) {
285 for (const f of Guardianship.follows.listForWard(w.slug)) {
286 items.push({ id: f.id, ward: `@${w.slug}@${host}`, wardUri: w.uri, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, remote: false, created: f.created_at });
287 }
288 }
289 // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
290 for (const rev of Guardianship.follows.listReviews(site.slug)) {
291 const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
292 items.push({ id: rev.id, ward: wardName, wardUri: rev.ward_uri, follower: rev.follower_handle || rev.follower_uri, followerIcon: rev.follower_icon, remote: true, created: rev.created_at });
293 }
294 res.json({ items });
295});
296
297router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
298 const site = siteForUser(req);
299 if (!site) return res.status(404).json({ error: 'no_site' });
300 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
301 const me = AP.actorId(base, site.slug);
302 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
303
304 // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
305 // which tallies quorum and returns the Accept(Follow) to the follower.
306 const review = Guardianship.follows.getReview(site.slug, req.params.id);
307 if (review) {
308 try { await AP.sendFollowDecision(site, review, decision); }
309 catch { return res.status(502).json({ error: 'delivery' }); }
310 Guardianship.follows.removeReview(site.slug, req.params.id);
311 return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
312 }
313
314 // Local ward: decide directly (quorum on this instance).
315 const pending = Guardianship.follows.getPending(req.params.id);
316 if (!pending) return res.status(404).json({ error: 'gone' });
317 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
318 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
319 // Acting from the dashboard is an answer (3.6), and the quorum runs over
320 // the available set (3.5): both applied here, the same as over the wire.
321 Guardianship.availability.oneAnswer(me, Date.now());
322 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
323 const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
324 try {
325 if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
326 else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
327 } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
328 res.json({ ok: true, outcome: r.outcome });
329});
330
331// ── §5.3, the other direction (shaer-p729): the ward wants to follow SOMEONE,
332// and the guardians decide. Same quorum arithmetic and the same availability
333// rules as the inbound gate above; only the question is turned around, which
334// is why it gets its own endpoint rather than a flag on that one.
335router.post('/api/outgoing-follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
336 const site = siteForUser(req);
337 if (!site) return res.status(404).json({ error: 'no_site' });
338 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
339 const me = AP.actorId(base, site.slug);
340 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
341
342 const pending = Guardianship.outgoing.getPending(req.params.id);
343 if (!pending) return res.status(404).json({ error: 'gone' });
344 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
345 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
346 Guardianship.availability.oneAnswer(me, Date.now());
347 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
348 const r = Guardianship.outgoing.decide(pending.id, me, decision, guardians);
349 try {
350 // Only on approval does anything leave the building. A refusal is a local
351 // fact: the follow was never sent, so there is nothing out there to undo
352 // and nobody to inform that a child asked about them.
353 if (r.outcome === 'approved') await AP.performApprovedFollow(r.follow);
354 } catch { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
355 res.json({ ok: true, outcome: r.outcome });
356});
357
358// ── Wave (FEP-633c §5, shaer:wave): a gentle "thinking of you" from a
359// guardian to a ward. A private direct note, never a feed post. Warmth
360// without publishing (Robins besluit).
361router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
362 const site = siteForUser(req);
363 if (!site) return res.status(404).json({ error: 'no_site' });
364 const wardUri = String(req.body?.ward || '').trim();
365 // Only wave at a ward you actually guard.
366 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
367 if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
368 const text = String(req.body?.text || '').trim().slice(0, 200) || '👋 thinking of you';
369 const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
370 if (!r) return res.status(502).json({ error: 'delivery' });
371 res.json({ ok: true, delivered: r.delivered });
372});
373
374// ── Een hulpvraag oppikken of afsluiten (shaer-lgo) ───────────────
375// Gaat naar de WARD en naar de MEDE-GUARDIANS. De ward hoort te weten dat er
376// iemand komt -- dat is de helft van de gerustheid -- en de anderen dat het
377// loopt, zodat niemand denkt dat de ander het al doet.
378//
379// OPPIKKEN mag stapelen: twee mensen die tegelijk reageren is geen probleem.
380// AFSLUITEN kent geen terugdraai; leeft de vraag nog, dan wordt hij opnieuw
381// gesteld. De stevige bevestiging zit in de client, net als bij het loslaten van
382// een ward: nooit een window.confirm.
383router.post('/api/help/:kind', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
384 const site = siteForUser(req);
385 if (!site) return res.status(404).json({ error: 'no_site' });
386 const kind = req.params.kind === 'handled' ? 'handled' : 'pickup';
387 const noteUri = String(req.body?.note || '').trim();
388 const wardUri = String(req.body?.ward || '').trim();
389 if (!noteUri || !/^https?:\/\//i.test(noteUri)) return res.status(400).json({ error: 'no_note' });
390 // Alleen over een hulpvraag van een kind dat je echt bewaakt.
391 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
392 if (!isWard) return res.status(403).json({ error: 'not_your_ward' });
393
394 const me = AP.actorId((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site.slug);
395 // Onze eigen kopie meteen, zonder op bezorging te wachten: het scherm van
396 // degene die klikt hoort niet te liegen omdat een andere server traag is.
397 Guardianship.help.record(noteUri, me, kind, null);
398
399 const anderen = Guardianship.listGuardians(wardUri.replace(/.*\/ap\/users\//, '')) || [];
400 const ontvangers = [wardUri, ...anderen.map((g) => g.other_uri)].filter((u) => u && u !== me);
401 const r = await AP.deliverDirectNote(site, {
402 recipients: ontvangers,
403 text: kind === 'handled' ? 'Deze hulpvraag is afgehandeld.' : 'Ik kijk hiernaar.',
404 helpMark: { kind, noteUri },
405 }).catch(() => null);
406 // Bezorging kan mislukken; de eigen staat staat er dan toch. Dat melden we,
407 // want "verstuurd" zeggen terwijl het niet aankwam is hier het ergste soort
408 // stilte.
409 res.json({ ok: true, delivered: r ? r.delivered : 0, recipients: ontvangers.length });
410});
411
412// ── Adopt a ward: handle → resolve → C2S Offer through the same pipeline
413// the Shaer apps use (one path, one behavior).
414router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
415 const site = siteForUser(req);
416 if (!site) return res.status(404).json({ error: 'no_site' });
417 const handle = String(req.body?.handle || '').trim();
418 if (!handle) return res.status(400).json({ error: 'empty_handle' });
419 const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
420 if (!wardUri) return res.status(404).json({ error: 'not_found' }); // the handle does not resolve to an account
421 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
422 const me = AP.actorId(base, site.slug);
423 const r = await AP.ingestOutboxActivity(site, req.session.user, {
424 type: 'Offer',
425 object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
426 });
427 // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
428 // offer is recorded and delivery is retried in the background.
429 if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
430 res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
431});
432
433// ── Answer an offer (co-guardian accept/reject, or the candidate's final
434// "complete"). All three are a C2S Accept/Reject on the offer id; the
435// handshake module decides when it commits (§3.1).
436// ── Step away (FEP-633c 3.6.1): the guardian declares itself unavailable ──
437// One direct note with shaer:away and an endTime to every ward, the same path
438// Shaer takes over C2S, and the only path: a ward on this instance receives
439// that note through the loopback and applies the absence in its own inbox
440// handler, exactly as a ward elsewhere does. This route used to write the
441// local wards itself as well, which meant the wire version could break without
442// anyone here noticing.
443router.post('/api/away', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
444 const site = siteForUser(req);
445 if (!site) return res.status(404).json({ error: 'no_site' });
446 const days = Math.min(365, Math.max(1, parseInt(req.body?.days, 10) || 0));
447 if (!days) return res.status(400).json({ error: 'away_needs_an_end' });
448 const wards = Guardianship.listWards(site.slug).map((w) => w.other_uri);
449 if (!wards.length) return res.status(409).json({ error: 'no_wards' });
450 const until = Date.now() + days * 24 * 3600 * 1000;
451 const L = resolveLang(req);
452 const text = i18nT(L, 'guardian.away_msg', { date: new Date(until).toLocaleDateString('nl-NL') });
453 const r = await AP.deliverDirectNote(site, { recipients: wards, text, awayUntil: until }).catch(() => null);
454 if (!(r && r.id)) return res.status(502).json({ error: 'away_failed' });
455 res.json({ ok: true, until });
456});
457
458// ── Propose a lapse (FEP-633c 3.6.3) against a dormant co-guardian ────────
459// The same C2S pipeline the Shaer apps would use: an Offer of shaer:Lapse.
460// A local ward opens directly; a remote ward gets the proposal delivered,
461// because the ward's server is the one that tallies and enforces.
462router.post('/api/lapse', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
463 const site = siteForUser(req);
464 if (!site) return res.status(404).json({ error: 'no_site' });
465 const ward = String(req.body?.ward || '').trim();
466 const target = String(req.body?.target || '').trim();
467 if (!ward || !target) return res.status(400).json({ error: 'missing_ward_or_target' });
468 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === ward)) {
469 return res.status(403).json({ error: 'not_my_ward' });
470 }
471 const r = await AP.ingestOutboxActivity(site, req.session.user, {
472 type: 'Offer', object: { type: 'shaer:Lapse', 'shaer:ward': ward, object: target },
473 });
474 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'lapse_failed' });
475 res.json({ ok: true, lapse: r.id });
476});
477
478// ── Answer a forwarded gated-setting proposal (FEP-633c 5.6) ─────────────
479// The decision belongs to the ward's server, so the answer travels there as an
480// Accept/Reject on the offer id, exactly like a gated follow's decision.
481router.post('/api/gated/:id', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
482 const site = siteForUser(req);
483 if (!site) return res.status(404).json({ error: 'no_site' });
484 const review = Guardianship.gated.getGatedReview(site.slug, req.params.id);
485 if (!review) return res.status(404).json({ error: 'gone' });
486 const agree = req.body?.answer !== 'reject';
487 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
488 const me = AP.actorId(base, site.slug);
489 const activity = {
490 id: `${me}#gated-${Date.now().toString(36)}`,
491 type: agree ? 'Accept' : 'Reject', actor: me, to: [review.ward_uri], object: review.id,
492 };
493 try { await AP.deliverToActor(site, review.ward_uri, activity); }
494 catch { return res.status(502).json({ error: 'delivery' }); }
495 Guardianship.gated.removeGatedReview(site.slug, review.id);
496 res.json({ ok: true, answer: agree ? 'accept' : 'reject' });
497});
498
499router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
500 const site = siteForUser(req);
501 if (!site) return res.status(404).json({ error: 'no_site' });
502 const offerId = String(req.body?.offer || '').trim();
503 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
504 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
505 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
506 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
507 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
508});
509
510// ── PWA assets served no-cache, so an update is never masked by the 1-year
511// /assets cache or a stuck install (that was the whole "nothing works after
512// a deploy" bug). Small files; the browser revalidates and gets a 304 when
513// unchanged, the fresh file when changed.
514function pwaAsset(rel, type) {
515 return (req, res) => {
516 res.set('Cache-Control', 'no-cache');
517 res.type(type);
518 res.sendFile(path.join(__dir, '..', 'assets', rel));
519 };
520}
521router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
522router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
523
524// ── Manage: release a committed ward (local Undo; federation is Fase 4). ──
525/**
526 * What actually happens if this guardian releases this ward?
527 *
528 * Releasing is not one action but two very different ones, and the difference
529 * is the number of guardians the child has left (FEP-633c):
530 * - more than one → §3.3, you step down and the child stays a ward;
531 * - you are the last → §3.4, that is emancipation, and the FEP is explicit
532 * that no single guardian decides it alone (three consenting adults, or a
533 * majority plus two witnesses).
534 * On top of that, today's release is LOCAL: the Undo is not federated yet
535 * (relations.js, fase 4), so the ward's server keeps listing this guardian.
536 * A guardian pressing the button would otherwise believe the child is released.
537 *
538 * Answered on demand rather than in the dashboard state: for a ward we do not
539 * host this reaches out to that ward's server, and nobody should pay for that
540 * on every refresh.
541 */
542router.get('/wards/release-check', requireAuth, async (req, res) => {
543 const site = siteForUser(req);
544 if (!site) return res.status(404).json({ error: 'no_site' });
545 const uri = String(req.query.uri || '').trim();
546 if (!uri) return res.status(400).json({ error: 'empty_uri' });
547 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
548 return res.status(403).json({ error: 'not_my_ward' });
549 }
550 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
551 const local = !!base && uri.startsWith(`${base}/`);
552 let guardians = null; // null = we could not find out; say so rather than guess
553 if (local) {
554 const slug = uri.replace(/\/+$/, '').split('/').pop();
555 try { guardians = Guardianship.listGuardians(slug).length; } catch { /* stays null */ }
556 } else {
557 const doc = await AP.fetchActor(uri).catch(() => null);
558 const g = doc && doc['shaer:guardians'];
559 if (Array.isArray(g)) guardians = g.length;
560 else if (typeof g === 'string') guardians = 1;
561 else if (g && Array.isArray(g.items)) guardians = g.items.length;
562 else if (doc) guardians = 0; // the actor answered and names no guardians
563 }
564 res.json({
565 guardians,
566 last: guardians === null ? null : guardians <= 1,
567 local,
568 });
569});
570
571// ── The fellow guardians of a ward, wherever it lives ─────────────────────
572// A guardian looking at a ward's panel should see who else holds a seat: that
573// is the child's safety net, and "dit kind woont op een andere server" is not
574// an answer. For a local ward the availability rides along (we do that
575// bookkeeping). For a remote ward we read the PUBLIC membership from its
576// actor document (shaer:guardians, §2.1) and nothing more: availability is
577// the ward's server's private ledger (§3.6.1) and stays there. Fetched on
578// panel-open rather than into the dashboard, so one slow remote server does
579// not hold the whole screen hostage.
580router.get('/wards/guardians', requireAuth, async (req, res) => {
581 const site = siteForUser(req);
582 if (!site) return res.status(404).json({ error: 'no_site' });
583 const uri = String(req.query.uri || '').trim();
584 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
585 return res.status(403).json({ error: 'not_my_ward' });
586 }
587 const local = Guardianship.queues.wardGuardianStatuses(uri);
588 if (local) return res.json({ local: true, guardians: local });
589 const doc = await AP.fetchActor(uri).catch(() => null);
590 let g = doc && doc['shaer:guardians'];
591 if (g && Array.isArray(g.items)) g = g.items; // a Collection
592 const guardians = (Array.isArray(g) ? g : (typeof g === 'string' ? [g] : []))
593 .filter((x) => typeof x === 'string')
594 .map((u) => {
595 try { const p = new URL(u); return { uri: u, handle: `@${p.pathname.replace(/\/+$/, '').split('/').pop()}@${p.host}` }; }
596 catch { return { uri: u, handle: u }; }
597 });
598 res.json({ local: false, guardians });
599});
600
601router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
602 const site = siteForUser(req);
603 if (!site) return res.status(404).json({ error: 'no_site' });
604 const uri = String(req.body?.uri || '').trim();
605 if (!uri) return res.status(400).json({ error: 'empty_uri' });
606 // Ending a guardianship is an Undo of the Relationship that travels to the
607 // ward and the other guardians (§3.2), not a local delete. Same call the
608 // Guardian apps reach over C2S, so the two cannot drift apart.
609 const r = await Guardianship.endGuardianship(site, uri);
610 if (r.status >= 400) return res.status(r.status).json({ error: r.error });
611 res.json({ ok: true, delivered: r.delivered, guardiansLeft: r.guardiansLeft });
612});
613
614/**
615 * The external-embeds setting of a ward we host: true/false when a guardian has
616 * decided, null when it is still on auto (which means off for a ward) or when
617 * the ward lives elsewhere and the setting is not ours to show.
618 */
619function wardEmbedSetting(uri) { return wardGateSetting(uri, 'external_embeds'); }
620/** The playback gate of a ward we host (5.6): the heavier sibling. */
621function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
622
623function wardGateSetting(uri, column) {
624 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
625 if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
626 const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
627 const row = slug ? db.prepare(`SELECT ${column === 'external_playback' ? 'external_playback' : 'external_embeds'} AS v FROM sites WHERE slug = ?`).get(slug) : null;
628 if (!row) return null;
629 return row.v === null || row.v === undefined ? false : row.v === 1;
630}
631
632// ── Gated feature: may this ward see external (non-fediverse) embeds? ──
633// The first real gated setting (FEP-633c §5-style). The gate itself is applied
634// server-side when the feed is serialised, so this endpoint is the only way it
635// can move, and only a committed guardian of THAT ward may move it.
636router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
637 // Niet meer alleen embeds/playback: elke gate uit de catalogus met een kolom
638 // is voorstelbaar (8-8, "maak ze allemaal functioneel"). De oude regel
639 // HERSCHREEF een onbekende feature stilletjes naar externalEmbeds -- een
640 // voorstel voor de ene poort dat op de andere landt is precies het soort
641 // fout dat een guardian nooit mag overkomen. Onbekend wordt nu geweigerd.
642 const feature = String(req.body?.feature || 'shaer:externalEmbeds');
643 if (!Guardianship.gated.featureColumn(feature)) return res.status(400).json({ error: 'unknown_feature' });
644 req.body = { ...req.body, feature };
645 return proposeGated(req, res);
646});
647function proposeGated(req, res) {
648 const site = siteForUser(req);
649 if (!site) return res.status(404).json({ error: 'no_site' });
650 // De hele afweging staat in AP.proposeGate, zodat de apps langs dezelfde weg
651 // kunnen voorstellen (shaer-8ru). Deze route is nog maar de PWA-deur ernaartoe.
652 const uit = AP.proposeGate(site, req.body?.uri, req.body?.feature, req.body?.allow === true);
653 const { status, ...rest } = uit;
654 return res.status(status === 200 ? 200 : status).json(rest);
655}
656
657// ── The installable identity: own scope so the Guardian corner installs as
658// its own app next to the site PWA.
659router.get('/manifest.webmanifest', (req, res) => {
660 const site = res.locals.site;
661 res.set('Cache-Control', 'no-cache');
662 res.json({
663 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
664 name: 'Klonkt Guardian',
665 short_name: 'Guardian',
666 description: 'Ward management and help requests for guardians.',
667 scope: '/guardian/',
668 start_url: '/guardian?source=pwa',
669 display: 'standalone',
670 display_override: ['standalone', 'minimal-ui'],
671 orientation: 'any',
672 background_color: '#141a24',
673 theme_color: '#ff6b35',
674 lang: site?.language || 'nl',
675 icons: [
676 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
677 ],
678 });
679});
680
681// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
682router.get('/icon.svg', (req, res) => {
683 const svg = `<?xml version="1.0" encoding="UTF-8"?>
684<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
685 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
686 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
687</svg>`;
688 res.set('Content-Type', 'image/svg+xml');
689 res.set('Cache-Control', 'public, max-age=86400');
690 res.send(svg);
691});
692
693// Losse guardian-accounts (guardian-lite: /invite + /join, user + site met
694// guardian_only=1) zijn verwijderd op 31-7-2026. Een instance is een eigenaar;
695// zo'n account was de laatste multi-user-rest en zette bovendien andermans
696// wachtwoordhash, sessie en PRIVATE actor-sleutel in jouw database, wat een
697// verhuizing (shaer-qw6q) onmogelijk netjes maakte. Een guardian hoort een
698// eigen Klonkt te hebben; de adoptie loopt dan gewoon over de federatie.
699
700export default router;
Note: See TracBrowser for help on using the repository browser.