source: Klonkt/src/routes/guardian.js@ 89d3c06

main
Last change on this file since 89d3c06 was 89d3c06, checked in by Bart <bart@…>, 4 weeks ago

Woorden bij de tweede poort, en de app weet het vooraf

gateLabel() valt terug op de kale naam, dus shaer:following stond in het paneel
als "following" naast "Volgverzoeken" -- één letter verschil voor twee
tegengestelde vragen. Nu een eigen label in alle drie de talen: "Zelf iemand
volgen" / "Following others" / "Anderen folgen", zodat de rijen niet op elkaar
lijken zodra ze naast elkaar staan.

En shaer:following hoort in shaer:capabilities, om de reden die daar al staat:
de app hoort vooraf te weten wat hij mag aanbieden in plaats van het bij de
eerste weigering te ontdekken. Bij deze poort telt dat dubbel, want dicht
betekent hier niet "kan niet" maar "moet eerst gevraagd worden". Zonder die
capability ziet een volgknop eruit als elke andere en wordt hij pas bij het
antwoord een wachtkamer.

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

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