source: Klonkt/src/routes/guardian.js@ 9ce14b8

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

Een hulpvraag van een oud-ward gaat naar de geschiedenis (shaer-lgo, vervolg)

Bart heeft een hulpvraag staan van iemand die hij niet meer bewaakt, en kwam er
niet vanaf. Terecht: de knop stond er wel, maar de markeerroute eist dat het NOG
je ward is en antwoordt anders met 403. Klikken deed dus niets, zonder uitleg.

HET LOSLAAT-SCHERM BELOOFDE DIT AL LETTERLIJK -- "je krijgt geen hulpvragen meer
van ze". Nieuwe komen inderdaad niet meer binnen, maar wat er al lag bleef in de
lijst staan die om aandacht vraagt. Dit is dus geen nieuwe keuze maar een belofte
die de app al doet en niet nakwam.

NIET ALS AFGEHANDELD. Dat lag voor de hand en is fout: afhandelen is een claim
over een kind waar je niets meer over te zeggen hebt, en die claim wordt ook nog
rondgestuurd naar de guardians die er wel nog zijn. Er is een derde uitkomst en
die leest als wat hij is: niet meer van jou. De vraag zakt naar de geschiedenis,
zonder knoppen, met een regel die zegt dat hun andere guardians er nog zijn.

Dat laatste is geen troost maar een feit: een guardianship eindigt nooit bij de
LAATSTE guardian (3.4 -- dan is het emancipatie). Er blijft altijd iemand over
voor wie de vraag wel open staat, dus dit dooft geen reddingsboei.

Wat er al gebeurd is blijft leesbaar: was je erbij toen je nog guardian was, dan
blijft die oppik met naam staan. Er wordt niets herschreven.

Vier toetsen, drie talen. Suite 615/615.

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