source: Klonkt/src/routes/guardian.js@ 0b0cd54

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

De vraag van het kind staat apart van de wachtrij (shaer-8ru, vervolg)

Ik gooide het verzoek van de ward in dezelfde waiting-teller als de wachtende
volgverzoeken. Dat is fout: drie onbekenden die je kind willen volgen is iets
heel anders dan je kind dat een keer vraagt of muziek aan mag, en een gedeeld
getal maakt daar hetzelfde van -- dan leest de ene urgentie als de andere.

Nu een eigen veld requested op de poortrij, en in het paneel een eigen regel
onder de schakelaar in plaats van een getal in het statusveld: dit is het enige
op die rij dat van het KIND komt, en het hoort niet weg te vallen tussen de
drempels en de soorten. Drie talen.

Suite 714/714.

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