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

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

Een logboek, zodat de reden ergens blijft staan

Guardianship-events waren vluchtig: onGuardianshipEvent wekte de long-poll,
zocht in een tabel van zeven soorten of er een push bij hoorde, en liet de rest
vallen. Elf van de achttien soorten verdwenen spoorloos, met hun inhoud. Een
weigering droeg reason: 'not_a_teapot' tot precies daar en niet verder --
terwijl §4.2 eist dat de ward en zijn guardians die reden te HOREN krijgen, en
niet dat ze hem afleiden uit een aanbod dat opeens weg is.

Vastleggen en melden zijn nu twee dingen. Alles komt in ap_guardian_events;
welke gebeurtenis een mens wakker maakt blijft de aparte, korte lijst die het
altijd al was.

Ingeklapt en onderaan, want hier vraagt niets om een antwoord. Zou dit tussen
de wachtrijen staan, dan wordt "moet ik iets doen" onleesbaar -- dezelfde reden
waarom de afgehandelde hulpvragen daar al staan.

200 per account, jongste eerst. Een logboek dat oneindig groeit wordt er een
die niemand opent, en afkappen aan de verkeerde kant zou hem onbruikbaar maken
op het moment dat er juist iets gebeurt. Schrijffouten worden geslikt: het
logboek is bijzaak en mag de commit of de weigering zelf nooit meesleuren.

Onbekende soorten vallen in de PWA terug op hun ruwe naam. Zichtbaar en lelijk
is beter dan netjes en afwezig.

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

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