source: Klonkt/src/routes/guardian.js@ 54b027a

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

Een vreemde note mag de Krant niet meenemen

Robin meldde dat /news op beta niet meer werkte. Oorzaak:

_m.filter is not a function note-body.ejs:20

Drie posts van europeanpirates.eu dragen media_json = "[]": een JSON-STRING met
daarin [], niet een array. De try/catch eromheen vangt KAPOTTE json, maar niet
geldige json van het VERKEERDE TYPE. JSON.parse('"[]"') geeft netjes de string
"[]" terug, en een string heeft geen .filter.

Gevolg: één vreemde note nam de hele pagina mee. Niet de post werd overgeslagen,
de Krant lag plat. Wat er van een andere server binnenkomt is niet van ons, dus de
vorm moet op het punt van gebruik afgedwongen worden en niet aangenomen.

Zelfde wacht gezet in guardian.js, waar dezelfde parse stond. Dat is nog
vervelender: het guardian-paneel is precies het scherm dat het moet doen op het
moment dat er iets aan de hand is.

Changed files:
src/views/partials/note-body.ejs

  • Array.isArray-controle na de parse

src/routes/guardian.js

  • dezelfde controle rond media_json in het wardpaneel

New file:
test/note-body-media-shape.test.js

  • een string, een object, een getal en kapotte json slopen de pagina niet
  • en een normale lijst wordt nog steeds getoond, want een fix die alle media wegpoetst is geen fix

remarks: tegenproef gedaan. Zonder de fix vallen de string, het object en het getal
om; kapotte json en de normale lijst blijven groen. De test meet dus het gat en niet
zichzelf. Suite 937 in UTC en Europe/Amsterdam.

Onderweg twee keer mijn eigen testopzet moeten repareren (ontbrekende emojiHtml- en
thumb-helper); dat was de test, niet de code.

Nog te doen: de drie rijen op beta zelf opruimen, en uitzoeken welk pad ooit dubbel
gestringificeerd heeft. In de huidige code staat die dubbele stringify niet meer.

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

  • Property mode set to 100644
File size: 41.7 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 // Zelfde valkuil als in note-body.ejs: kapotte json gooit, maar geldige json
265 // van het verkeerde type niet. Zonder deze wacht neemt één vreemde note van
266 // een remote server het hele guardian-paneel mee, en dat is precies het
267 // scherm dat het moet doen als er iets aan de hand is.
268 media: (() => { try { const m = JSON.parse(p.media_json || '[]'); return Array.isArray(m) ? m : []; } catch { return []; } })(),
269 // Een post van je ward hoort er hetzelfde uit te zien als in de Krant en
270 // in Berichten: dezelfde partial, dus opmaak, media, quote-kaart en
271 // embed. Tot nu toe kreeg de PWA alleen kale content -- een guardian zag
272 // een lege regel waar een foto stond. `content` blijft ernaast staan voor
273 // een client die nog uit de cache draait.
274 body_html: renderNoteBody(p, L),
275 }));
276 res.json({ items, following: wardUris.size });
277});
278
279// ── Follow-gating (FEP-633c §5.3): pending follows on MY wards, for me to
280// approve. Ward and guardian are co-located on the family Klonkt here, so
281// the guardian reads its wards' pending follows locally.
282function wardSlugsOf(site) {
283 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
284 return Guardianship.listWards(site.slug)
285 .map((w) => (w.other_uri.startsWith(base) ? { slug: w.other_uri.split('/').pop(), uri: w.other_uri } : null))
286 .filter(Boolean);
287}
288
289router.get('/api/follow-requests', requireAuth, (req, res) => {
290 const site = siteForUser(req);
291 if (!site) return res.status(404).json({ error: 'no_site' });
292 const items = [];
293 const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
294 // wardUri is the grouping key for the per-ward panel: the handle is for
295 // reading, the URI is what identifies the child across both cases below.
296 // Local wards (guardian co-located): read the pending follows directly.
297 for (const w of wardSlugsOf(site)) {
298 for (const f of Guardianship.follows.listForWard(w.slug)) {
299 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 });
300 }
301 // §5.3 andersom (shaer-p729): wat dit kind zelf heeft gevraagd. Stond hier
302 // niet, dus een guardian met een LOKALE ward zag uitgaande verzoeken in de
303 // PWA helemaal niet -- ze wachtten op iemand die er nooit naar keek.
304 for (const o of Guardianship.outgoing.listForWard(w.slug)) {
305 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 });
306 }
307 }
308 // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
309 for (const rev of Guardianship.follows.listReviews(site.slug)) {
310 const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
311 // De richting stond in de tabel en werd hier weggelaten. Zonder haar leest
312 // een uitgaand verzoek als een inkomend: de follower IS dan de ward, dus de
313 // kaart zei "je kind wil je kind volgen" en het doel viel weg.
314 const uitgaand = rev.direction === 'outgoing';
315 items.push({
316 id: rev.id, direction: uitgaand ? 'outgoing' : 'incoming',
317 ward: wardName, wardUri: rev.ward_uri,
318 follower: uitgaand ? undefined : (rev.follower_handle || rev.follower_uri),
319 target: uitgaand ? (rev.target_handle || rev.target_uri) : undefined,
320 followerIcon: uitgaand ? undefined : rev.follower_icon,
321 remote: true, created: rev.created_at,
322 });
323 }
324 res.json({ items });
325});
326
327// Het logboek (§4.2): wat er is gebeurd, met de reden erbij. GEEN wachtrij --
328// hier staat niets dat om een antwoord vraagt, en daarom hoort het ingeklapt.
329// Het bestaat omdat een weigering anders alleen te merken was doordat er iets
330// uit een lijst verdween, en "het is weg" vertelt een ward niet waarom.
331router.get('/api/events', requireAuth, (req, res) => {
332 const site = siteForUser(req);
333 if (!site) return res.status(404).json({ error: 'no_site' });
334 res.json({ items: AP.listGuardianEvents(site.slug, 50) });
335});
336
337router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
338 const site = siteForUser(req);
339 if (!site) return res.status(404).json({ error: 'no_site' });
340 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
341 const me = AP.actorId(base, site.slug);
342 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
343
344 // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
345 // which tallies quorum and returns the Accept(Follow) to the follower.
346 const review = Guardianship.follows.getReview(site.slug, req.params.id);
347 if (review) {
348 try { await AP.sendFollowDecision(site, review, decision); }
349 catch { return res.status(502).json({ error: 'delivery' }); }
350 Guardianship.follows.removeReview(site.slug, req.params.id);
351 return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
352 }
353
354 // Local ward: decide directly (quorum on this instance).
355 const pending = Guardianship.follows.getPending(req.params.id);
356 if (!pending) return res.status(404).json({ error: 'gone' });
357 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
358 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
359 // Acting from the dashboard is an answer (3.6), and the quorum runs over
360 // the available set (3.5): both applied here, the same as over the wire.
361 Guardianship.availability.oneAnswer(me, Date.now());
362 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
363 const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
364 try {
365 if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
366 else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
367 } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
368 res.json({ ok: true, outcome: r.outcome });
369});
370
371// ── §5.3, the other direction (shaer-p729): the ward wants to follow SOMEONE,
372// and the guardians decide. Same quorum arithmetic and the same availability
373// rules as the inbound gate above; only the question is turned around, which
374// is why it gets its own endpoint rather than a flag on that one.
375router.post('/api/outgoing-follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
376 const site = siteForUser(req);
377 if (!site) return res.status(404).json({ error: 'no_site' });
378 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
379 const me = AP.actorId(base, site.slug);
380 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
381
382 const pending = Guardianship.outgoing.getPending(req.params.id);
383 if (!pending) return res.status(404).json({ error: 'gone' });
384 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
385 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
386 Guardianship.availability.oneAnswer(me, Date.now());
387 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
388 const r = Guardianship.outgoing.decide(pending.id, me, decision, guardians);
389 try {
390 // Only on approval does anything leave the building. A refusal is a local
391 // fact: the follow was never sent, so there is nothing out there to undo
392 // and nobody to inform that a child asked about them.
393 if (r.outcome === 'approved') await AP.performApprovedFollow(r.follow);
394 } catch { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
395 res.json({ ok: true, outcome: r.outcome });
396});
397
398// ── Wave (FEP-633c §5, shaer:wave): a gentle "thinking of you" from a
399// guardian to a ward. A private direct note, never a feed post. Warmth
400// without publishing (Robins besluit).
401router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
402 const site = siteForUser(req);
403 if (!site) return res.status(404).json({ error: 'no_site' });
404 const wardUri = String(req.body?.ward || '').trim();
405 // Only wave at a ward you actually guard.
406 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
407 if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
408 const text = String(req.body?.text || '').trim().slice(0, 200) || '👋 thinking of you';
409 const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
410 if (!r) return res.status(502).json({ error: 'delivery' });
411 res.json({ ok: true, delivered: r.delivered });
412});
413
414// ── Een hulpvraag oppikken of afsluiten (shaer-lgo) ───────────────
415// Gaat naar de WARD en naar de MEDE-GUARDIANS. De ward hoort te weten dat er
416// iemand komt -- dat is de helft van de gerustheid -- en de anderen dat het
417// loopt, zodat niemand denkt dat de ander het al doet.
418//
419// OPPIKKEN mag stapelen: twee mensen die tegelijk reageren is geen probleem.
420// AFSLUITEN kent geen terugdraai; leeft de vraag nog, dan wordt hij opnieuw
421// gesteld. De stevige bevestiging zit in de client, net als bij het loslaten van
422// een ward: nooit een window.confirm.
423router.post('/api/help/:kind', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
424 const site = siteForUser(req);
425 if (!site) return res.status(404).json({ error: 'no_site' });
426 const kind = req.params.kind === 'handled' ? 'handled' : 'pickup';
427 const noteUri = String(req.body?.note || '').trim();
428 const wardUri = String(req.body?.ward || '').trim();
429 if (!noteUri || !/^https?:\/\//i.test(noteUri)) return res.status(400).json({ error: 'no_note' });
430 // Alleen over een hulpvraag van een kind dat je echt bewaakt.
431 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
432 if (!isWard) return res.status(403).json({ error: 'not_your_ward' });
433
434 const me = AP.actorId((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site.slug);
435 // Onze eigen kopie meteen, zonder op bezorging te wachten: het scherm van
436 // degene die klikt hoort niet te liegen omdat een andere server traag is.
437 // MET onze eigen handle. Die stond hier op null, en "door wie" was juist de
438 // hele vraag van deze bead: een binnengekomen markering draagt de handle van
439 // de afzender wel, dus onze EIGEN rij was de enige zonder naam. Op het scherm
440 // viel dat terug op de kale URI.
441 Guardianship.help.record(noteUri, me, kind, AP.deriveHandle(me));
442
443 // DE MEDE-GUARDIANS, en dit ging mis (shaer-lgo, gevonden 11-8 met @mee).
444 //
445 // Hier stond listGuardians(wardUri.replace(/.*\/ap\/users\//, '')): de staart
446 // van de URI als slug. listGuardians kent alleen relaties van LOKALE sites,
447 // dus voor een ward elders leverde dat altijd een lege lijst -- en juist die
448 // ward is het hele punt, want een ward op je eigen instance heeft geen
449 // federatie nodig. De markering ging dus alleen naar het kind en nooit naar
450 // de andere guardian. Precies de faalstand waar deze bead voor bestaat:
451 // iedereen denkt dat de ander het oppakt.
452 //
453 // Erger nog: had er toevallig een lokale site met die naam bestaan, dan
454 // waren het DIENS guardians geweest.
455 //
456 // existingGuardiansOf kende de goede weg al -- lokaal opzoeken, en anders
457 // shaer:guardians uit de actor van de ward. Die stond alleen niet aan deze
458 // route vast.
459 const anderen = await Guardianship.existingGuardiansOf(wardUri).catch(() => []);
460 const ontvangers = [wardUri, ...anderen].filter((u) => u && u !== me);
461 const r = await AP.deliverDirectNote(site, {
462 recipients: ontvangers,
463 text: kind === 'handled' ? 'Deze hulpvraag is afgehandeld.' : 'Ik kijk hiernaar.',
464 helpMark: { kind, noteUri },
465 }).catch(() => null);
466 // Bezorging kan mislukken; de eigen staat staat er dan toch. Dat melden we,
467 // want "verstuurd" zeggen terwijl het niet aankwam is hier het ergste soort
468 // stilte.
469 res.json({ ok: true, delivered: r ? r.delivered : 0, recipients: ontvangers.length });
470});
471
472// ── Adopt a ward: handle → resolve → C2S Offer through the same pipeline
473// the Shaer apps use (one path, one behavior).
474router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
475 const site = siteForUser(req);
476 if (!site) return res.status(404).json({ error: 'no_site' });
477 const handle = String(req.body?.handle || '').trim();
478 if (!handle) return res.status(400).json({ error: 'empty_handle' });
479 const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
480 if (!wardUri) return res.status(404).json({ error: 'not_found' }); // the handle does not resolve to an account
481 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
482 const me = AP.actorId(base, site.slug);
483 const r = await AP.ingestOutboxActivity(site, req.session.user, {
484 type: 'Offer',
485 object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
486 });
487 // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
488 // offer is recorded and delivery is retried in the background.
489 if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
490 res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
491});
492
493// ── Answer an offer (co-guardian accept/reject, or the candidate's final
494// "complete"). All three are a C2S Accept/Reject on the offer id; the
495// handshake module decides when it commits (§3.1).
496// ── Step away (FEP-633c 3.6.1): the guardian declares itself unavailable ──
497// One direct note with shaer:away and an endTime to every ward, the same path
498// Shaer takes over C2S, and the only path: a ward on this instance receives
499// that note through the loopback and applies the absence in its own inbox
500// handler, exactly as a ward elsewhere does. This route used to write the
501// local wards itself as well, which meant the wire version could break without
502// anyone here noticing.
503router.post('/api/away', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
504 const site = siteForUser(req);
505 if (!site) return res.status(404).json({ error: 'no_site' });
506 const days = Math.min(365, Math.max(1, parseInt(req.body?.days, 10) || 0));
507 if (!days) return res.status(400).json({ error: 'away_needs_an_end' });
508 const wards = Guardianship.listWards(site.slug).map((w) => w.other_uri);
509 if (!wards.length) return res.status(409).json({ error: 'no_wards' });
510 const until = Date.now() + days * 24 * 3600 * 1000;
511 const L = resolveLang(req);
512 const text = i18nT(L, 'guardian.away_msg', { date: new Date(until).toLocaleDateString('nl-NL') });
513 const r = await AP.deliverDirectNote(site, { recipients: wards, text, awayUntil: until }).catch(() => null);
514 if (!(r && r.id)) return res.status(502).json({ error: 'away_failed' });
515 res.json({ ok: true, until });
516});
517
518// ── Propose a lapse (FEP-633c 3.6.3) against a dormant co-guardian ────────
519// The same C2S pipeline the Shaer apps would use: an Offer of shaer:Lapse.
520// A local ward opens directly; a remote ward gets the proposal delivered,
521// because the ward's server is the one that tallies and enforces.
522router.post('/api/lapse', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
523 const site = siteForUser(req);
524 if (!site) return res.status(404).json({ error: 'no_site' });
525 const ward = String(req.body?.ward || '').trim();
526 const target = String(req.body?.target || '').trim();
527 if (!ward || !target) return res.status(400).json({ error: 'missing_ward_or_target' });
528 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === ward)) {
529 return res.status(403).json({ error: 'not_my_ward' });
530 }
531 const r = await AP.ingestOutboxActivity(site, req.session.user, {
532 type: 'Offer', object: { type: 'shaer:Lapse', 'shaer:ward': ward, object: target },
533 });
534 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'lapse_failed' });
535 res.json({ ok: true, lapse: r.id });
536});
537
538// ── Answer a forwarded gated-setting proposal (FEP-633c 5.6) ─────────────
539// The decision belongs to the ward's server, so the answer travels there as an
540// Accept/Reject on the offer id, exactly like a gated follow's decision.
541router.post('/api/gated/:id', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
542 const site = siteForUser(req);
543 if (!site) return res.status(404).json({ error: 'no_site' });
544 const review = Guardianship.gated.getGatedReview(site.slug, req.params.id);
545 if (!review) return res.status(404).json({ error: 'gone' });
546 const agree = req.body?.answer !== 'reject';
547 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
548 const me = AP.actorId(base, site.slug);
549 const activity = {
550 id: `${me}#gated-${Date.now().toString(36)}`,
551 type: agree ? 'Accept' : 'Reject', actor: me, to: [review.ward_uri], object: review.id,
552 };
553 try { await AP.deliverToActor(site, review.ward_uri, activity); }
554 catch { return res.status(502).json({ error: 'delivery' }); }
555 Guardianship.gated.removeGatedReview(site.slug, review.id);
556 res.json({ ok: true, answer: agree ? 'accept' : 'reject' });
557});
558
559router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
560 const site = siteForUser(req);
561 if (!site) return res.status(404).json({ error: 'no_site' });
562 const offerId = String(req.body?.offer || '').trim();
563 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
564 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
565 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
566 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
567 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
568});
569
570// ── PWA assets served no-cache, so an update is never masked by the 1-year
571// /assets cache or a stuck install (that was the whole "nothing works after
572// a deploy" bug). Small files; the browser revalidates and gets a 304 when
573// unchanged, the fresh file when changed.
574function pwaAsset(rel, type) {
575 return (req, res) => {
576 res.set('Cache-Control', 'no-cache');
577 res.type(type);
578 res.sendFile(path.join(__dir, '..', 'assets', rel));
579 };
580}
581router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
582router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
583
584// ── Manage: release a committed ward (local Undo; federation is Fase 4). ──
585/**
586 * What actually happens if this guardian releases this ward?
587 *
588 * Releasing is not one action but two very different ones, and the difference
589 * is the number of guardians the child has left (FEP-633c):
590 * - more than one → §3.3, you step down and the child stays a ward;
591 * - you are the last → §3.4, that is emancipation, and the FEP is explicit
592 * that no single guardian decides it alone (three consenting adults, or a
593 * majority plus two witnesses).
594 * On top of that, today's release is LOCAL: the Undo is not federated yet
595 * (relations.js, fase 4), so the ward's server keeps listing this guardian.
596 * A guardian pressing the button would otherwise believe the child is released.
597 *
598 * Answered on demand rather than in the dashboard state: for a ward we do not
599 * host this reaches out to that ward's server, and nobody should pay for that
600 * on every refresh.
601 */
602router.get('/wards/release-check', requireAuth, async (req, res) => {
603 const site = siteForUser(req);
604 if (!site) return res.status(404).json({ error: 'no_site' });
605 const uri = String(req.query.uri || '').trim();
606 if (!uri) return res.status(400).json({ error: 'empty_uri' });
607 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
608 return res.status(403).json({ error: 'not_my_ward' });
609 }
610 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
611 const local = !!base && uri.startsWith(`${base}/`);
612 let guardians = null; // null = we could not find out; say so rather than guess
613 if (local) {
614 const slug = uri.replace(/\/+$/, '').split('/').pop();
615 try { guardians = Guardianship.listGuardians(slug).length; } catch { /* stays null */ }
616 } else {
617 const doc = await AP.fetchActor(uri).catch(() => null);
618 const g = doc && doc['shaer:guardians'];
619 if (Array.isArray(g)) guardians = g.length;
620 else if (typeof g === 'string') guardians = 1;
621 else if (g && Array.isArray(g.items)) guardians = g.items.length;
622 else if (doc) guardians = 0; // the actor answered and names no guardians
623 }
624 res.json({
625 guardians,
626 last: guardians === null ? null : guardians <= 1,
627 local,
628 });
629});
630
631// ── The fellow guardians of a ward, wherever it lives ─────────────────────
632// A guardian looking at a ward's panel should see who else holds a seat: that
633// is the child's safety net, and "dit kind woont op een andere server" is not
634// an answer. For a local ward the availability rides along (we do that
635// bookkeeping). For a remote ward we read the PUBLIC membership from its
636// actor document (shaer:guardians, §2.1) and nothing more: availability is
637// the ward's server's private ledger (§3.6.1) and stays there. Fetched on
638// panel-open rather than into the dashboard, so one slow remote server does
639// not hold the whole screen hostage.
640router.get('/wards/guardians', requireAuth, async (req, res) => {
641 const site = siteForUser(req);
642 if (!site) return res.status(404).json({ error: 'no_site' });
643 const uri = String(req.query.uri || '').trim();
644 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
645 return res.status(403).json({ error: 'not_my_ward' });
646 }
647 const local = Guardianship.queues.wardGuardianStatuses(uri);
648 if (local) return res.json({ local: true, guardians: local });
649 const doc = await AP.fetchActor(uri).catch(() => null);
650 let g = doc && doc['shaer:guardians'];
651 if (g && Array.isArray(g.items)) g = g.items; // a Collection
652 const guardians = (Array.isArray(g) ? g : (typeof g === 'string' ? [g] : []))
653 .filter((x) => typeof x === 'string')
654 .map((u) => {
655 try { const p = new URL(u); return { uri: u, handle: `@${p.pathname.replace(/\/+$/, '').split('/').pop()}@${p.host}` }; }
656 catch { return { uri: u, handle: u }; }
657 });
658 res.json({ local: false, guardians });
659});
660
661router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
662 const site = siteForUser(req);
663 if (!site) return res.status(404).json({ error: 'no_site' });
664 const uri = String(req.body?.uri || '').trim();
665 if (!uri) return res.status(400).json({ error: 'empty_uri' });
666 // Ending a guardianship is an Undo of the Relationship that travels to the
667 // ward and the other guardians (§3.2), not a local delete. Same call the
668 // Guardian apps reach over C2S, so the two cannot drift apart.
669 const r = await Guardianship.endGuardianship(site, uri);
670 if (r.status >= 400) return res.status(r.status).json({ error: r.error });
671 res.json({ ok: true, delivered: r.delivered, guardiansLeft: r.guardiansLeft });
672});
673
674/**
675 * The external-embeds setting of a ward we host: true/false when a guardian has
676 * decided, null when it is still on auto (which means off for a ward) or when
677 * the ward lives elsewhere and the setting is not ours to show.
678 */
679function wardEmbedSetting(uri) { return wardGateSetting(uri, 'external_embeds'); }
680/** The playback gate of a ward we host (5.6): the heavier sibling. */
681function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
682
683function wardGateSetting(uri, column) {
684 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
685 if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
686 const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
687 const row = slug ? db.prepare(`SELECT ${column === 'external_playback' ? 'external_playback' : 'external_embeds'} AS v FROM sites WHERE slug = ?`).get(slug) : null;
688 if (!row) return null;
689 return row.v === null || row.v === undefined ? false : row.v === 1;
690}
691
692// ── Gated feature: may this ward see external (non-fediverse) embeds? ──
693// The first real gated setting (FEP-633c §5-style). The gate itself is applied
694// server-side when the feed is serialised, so this endpoint is the only way it
695// can move, and only a committed guardian of THAT ward may move it.
696router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
697 // Niet meer alleen embeds/playback: elke gate uit de catalogus met een kolom
698 // is voorstelbaar (8-8, "maak ze allemaal functioneel"). De oude regel
699 // HERSCHREEF een onbekende feature stilletjes naar externalEmbeds -- een
700 // voorstel voor de ene poort dat op de andere landt is precies het soort
701 // fout dat een guardian nooit mag overkomen. Onbekend wordt nu geweigerd.
702 const feature = String(req.body?.feature || 'shaer:externalEmbeds');
703 if (!Guardianship.gated.featureColumn(feature)) return res.status(400).json({ error: 'unknown_feature' });
704 req.body = { ...req.body, feature };
705 return proposeGated(req, res);
706});
707function proposeGated(req, res) {
708 const site = siteForUser(req);
709 if (!site) return res.status(404).json({ error: 'no_site' });
710 // De hele afweging staat in AP.proposeGate, zodat de apps langs dezelfde weg
711 // kunnen voorstellen (shaer-8ru). Deze route is nog maar de PWA-deur ernaartoe.
712 const uit = AP.proposeGate(site, req.body?.uri, req.body?.feature, req.body?.allow === true);
713 const { status, ...rest } = uit;
714 return res.status(status === 200 ? 200 : status).json(rest);
715}
716
717// ── The installable identity: own scope so the Guardian corner installs as
718// its own app next to the site PWA.
719router.get('/manifest.webmanifest', (req, res) => {
720 const site = res.locals.site;
721 res.set('Cache-Control', 'no-cache');
722 res.json({
723 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
724 name: 'Klonkt Guardian',
725 short_name: 'Guardian',
726 description: 'Ward management and help requests for guardians.',
727 scope: '/guardian/',
728 start_url: '/guardian?source=pwa',
729 display: 'standalone',
730 display_override: ['standalone', 'minimal-ui'],
731 orientation: 'any',
732 background_color: '#141a24',
733 theme_color: '#ff6b35',
734 lang: site?.language || 'nl',
735 icons: [
736 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
737 ],
738 });
739});
740
741// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
742router.get('/icon.svg', (req, res) => {
743 const svg = `<?xml version="1.0" encoding="UTF-8"?>
744<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
745 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
746 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
747</svg>`;
748 res.set('Content-Type', 'image/svg+xml');
749 res.set('Cache-Control', 'public, max-age=86400');
750 res.send(svg);
751});
752
753// Losse guardian-accounts (guardian-lite: /invite + /join, user + site met
754// guardian_only=1) zijn verwijderd op 31-7-2026. Een instance is een eigenaar;
755// zo'n account was de laatste multi-user-rest en zette bovendien andermans
756// wachtwoordhash, sessie en PRIVATE actor-sleutel in jouw database, wat een
757// verhuizing (shaer-qw6q) onmogelijk netjes maakte. Een guardian hoort een
758// eigen Klonkt te hebben; de adoptie loopt dan gewoon over de federatie.
759
760export default router;
Note: See TracBrowser for help on using the repository browser.