source: Klonkt/src/routes/guardian.js@ 7d7b36d

main
Last change on this file since 7d7b36d was 7d7b36d, checked in by roboburr <roboburr@โ€ฆ>, 5 weeks ago

De geschiedenis apart, en onderaan het wardpaneel (shaer-lgo, vervolg)

Barts wens: afgehandelde hulpvragen los van de openstaande, en helemaal onderaan.

De hulpvraag-sectie in een wardpaneel toont nu alleen wat nog WACHT. Sorteren was
niet genoeg: dooreen gezet lees je niet meer of er iets van je gevraagd wordt, en
dat is de enige vraag die die sectie hoort te beantwoorden.

De afgehandelde vragen staan als eigen sectie onder de recente berichten, met een
lijn erboven en het aantal in de kop. Weg gaan ze niet -- er wordt in dit systeem
niets herschreven -- maar ze staan wel achteraan, waar geschiedenis hoort.

Geen lege sectie als er nog niets is afgehandeld: dat is geen informatie.

nl/en/de. Suite 576/576, al raakt die dit niet: client-JS.

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