source: Klonkt/src/routes/guardian.js@ 8064da1

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

De Guardian PWA kende maar één richting, en de knop wist het ook niet

/api/follow-requests bouwde zijn lijst uit listReviews() zonder richting, terwijl
ap_follow_reviews.direction er juist voor bestaat. Bij een uitgaand verzoek IS de
follower de ward, dus de kaart zette het kind zelf onder "Volgverzoeken" -- "je
kind wil je kind volgen" -- en degene om wie het ging viel weg.

Erger was de knop. Accept ging naar /api/follow/:id, dat in ap_pending_follows
zoekt en een uitgaand verzoek daar nooit vindt. Een guardian kon dus op Toestaan
drukken en denken dat het gebeurd was. Nu twee eindpunten, gekozen op richting.

En lokale wards ontbraken helemaal: alleen listForWard() van de INKOMENDE tabel
stond erin, dus wie zijn ward op dezelfde instance heeft zag uitgaande verzoeken
nergens. Die wachtten op iemand die er nooit naar keek.

Zelfde fout als in het paneel en in de melding: de richting is bekend en wordt op
het laatste moment weggegooid. Dit is de derde plek.

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

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