source: Klonkt/src/routes/guardian.js@ 6fab3f3

main
Last change on this file since 6fab3f3 was 6fab3f3, checked in by roboburr <roboburr@โ€ฆ>, 4 weeks ago

Een lange poll voor alles wat de Guardian te verwerken krijgt (Barts opdracht, 9-8)

Het paneel tikte elke 45 seconden, ongeacht of er iets gebeurd was. Nu hangt er
een verzoek open tot er iets IS. Dat is niet alleen zuiniger -- het is vooral
sneller: een hulproep stond eerst tot drie kwart minuut te wachten op de klok.

WAKKER OP ALLES, en dat is de kern van de opdracht. De guardianship-module zendt
veertien soorten gebeurtenissen uit; die gingen tot nu toe alleen naar push, en
push kiest bewust een handvol. De tien soorten zonder pushtekst -- een stem op
een lapse, een uitkomst, een review -- zag je pas bij de volgende tik. Wie maak
je WAKKER en wat moet een openstaand scherm WETEN zijn twee verschillende
vragen, en ze hadden hetzelfde antwoord.

Naast die bus wekt ook de tijdlijn (onNews): de berichten van je wards staan in
ditzelfde scherm. En de twee paden die niet via notify() lopen -- een
hulpmarkering van een mede-guardian, en een poortverzoek van een kind -- wekken
nu zelf.

EERST KIJKEN, DAN WACHTEN. Veranderde er iets tussen het vorige antwoord en dit
verzoek, dan is de merksteen al anders en gaat het antwoord meteen de deur uit.
Zou je eerst gaan wachten, dan blijft nieuws dat net in dat gaatje viel 25
seconden liggen -- en bij een hulpvraag is dat de verkeerde vertraging.

Het tikje blijft als vangnet, maar op vijf minuten in plaats van 45 seconden:
valt er een wekker weg (een pad dat niet wekt, een herstart midden in een
verzoek), dan mag het scherm niet voorgoed stilstaan. Met de ETag kost zo'n tik
meestal een lege 304.

EEN NAAD DIE IK EERST NIET KON RAKEN. Het wekken zat verstopt in de deps-literal,
en de mutatie die het weghaalde bleef groen -- mijn toetsen dekten alleen de bus.
onGuardianshipEvent is nu een functie met een naam, precies zoals
guardianEventPush dat al was, en de mutatie is nu rood. Een lange poll die niet
wekt is niet te onderscheiden van een trage server, en dat is het soort fout dat
niemand meldt.

Tien toetsen. Suite 770/770.

  • Property mode set to 100644
File size: 38.8 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_decides', 'warn_not_last', '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 // Maakt JOUW antwoord dit af (shaer-8vt)? De telling loopt op de server van
152 // het kind, dus dit is het enige wat we erover weten -- en zonder dat
153 // weet niemand dat hij de doorslag geeft.
154 decisive: r.decisive !== 0,
155 })),
156 help: helpItems,
157 strings: uiStrings(L),
158 };
159}
160
161
162// โ”€โ”€ The PWA page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
163router.get('/', requireAuth, (req, res) => {
164 const site = siteForUser(req);
165 const L = resolveLang(req);
166 if (!site) return res.status(404).send('No site for this account.');
167 const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
168 // This standalone PWA page is rendered directly (not through renderPage), so
169 // the CSP nonce must be injected here โ€” otherwise strict-dynamic blocks
170 // guardian.js and the whole dashboard is dead (buttons do nothing).
171 res.render('pages/guardian', {
172 state: dashboardState(site, L),
173 sites,
174 lang: L,
175 t: (k, v) => i18nT(L, k, v),
176 cspNonce: res.locals.cspNonce,
177 }, (err, html) => {
178 if (err) { console.error('[guardian] render error', err); return res.status(500).send('Internal Server Error'); }
179 res.send(injectCspNonce(html, res.locals.cspNonce));
180 });
181});
182
183// โ”€โ”€ JSON state for refreshes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
184/**
185 * De staat van het paneel, desgewenst als LANGE POLL (Barts opdracht, 9-8).
186 *
187 * Zonder `wait` gedraagt de route zich exact zoals altijd. Met `wait` blijft het
188 * antwoord hangen tot er iets gebeurt dat de guardian moet verwerken, of tot de
189 * tijd om is -- dan een lege 304.
190 *
191 * EERST KIJKEN, DAN WACHTEN. Veranderde er iets tussen het vorige antwoord en
192 * dit verzoek, dan is de merksteen nu al anders en gaat het antwoord METEEN de
193 * deur uit. Zou je eerst gaan wachten, dan blijft nieuws dat net in dat gaatje
194 * viel vijfentwintig seconden liggen -- en juist bij een hulpvraag is dat de
195 * verkeerde vertraging.
196 *
197 * WAKKER OP ALLES. De guardianship-module zendt veertien soorten gebeurtenissen
198 * uit en die wekken allemaal (wakeGuardian); daarnaast wekt de tijdlijn (onNews),
199 * want de berichten van je wards staan in ditzelfde scherm.
200 */
201router.get('/api/state', requireAuth, async (req, res) => {
202 const site = siteForUser(req);
203 if (!site) return res.status(404).json({ error: 'no_site' });
204 const stuur = () => AP.sendMaybe304(req, res, dashboardState(site, resolveLang(req)), { contentType: 'application/json' });
205
206 const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
207 const merk = req.headers['if-none-match'];
208 if (!wachtS || !merk) return stuur();
209
210 // Is er nu al iets anders? Dan niet wachten.
211 const nu = AP.etagFor(JSON.stringify(dashboardState(site, resolveLang(req))));
212 if (nu !== merk) return stuur();
213
214 await new Promise((klaar) => {
215 let af = false;
216 const eind = () => { if (af) return; af = true; clearTimeout(t); offG(); offN(); klaar(); };
217 const offG = AP.onGuardian(site.slug, eind);
218 const offN = AP.onNews(site.slug, eind);
219 const t = setTimeout(eind, wachtS * 1000);
220 // Hing de client op, dan houdt niemand dit antwoord meer vast.
221 res.on('close', eind);
222 });
223 if (res.writableEnded) return undefined;
224 return stuur();
225});
226
227// โ”€โ”€ Meekijken (FEP-633c ยง5, interop-hoofdroute): a committed guardian FOLLOWS
228// its wards, so their posts (incl. followers-only) are DELIVERED to the
229// guardian's inbox โ†’ timeline. The follow is the mechanism; no new fetch.
230// First contact also backfills the ward's recent PUBLIC posts as a cold
231// start so the corner is not empty before delivery catches up.
232function ensureWardConnections(site) {
233 let wards;
234 try { wards = Guardianship.listWards(site.slug); } catch { return; }
235 for (const w of wards) {
236 const already = db.prepare('SELECT 1 FROM ap_following WHERE slug = ? AND actor_uri = ?')
237 .get(site.slug, w.other_uri);
238 if (already) continue;
239 // Follow (guardian's server auto-accepts today; ยง5.3 gating is a later fase).
240 AP.followActor(site, w.other_uri).catch(() => { /* retried by the queue */ });
241 // Cold start: pull recent public posts now so oma sees something at once.
242 AP.backfillFromOutbox(site.slug, w.other_uri).catch(() => { /* best-effort */ });
243 }
244}
245
246// โ”€โ”€ The wards' corner: your wards' posts, read-only. No reply, no share; a
247// guardian watches, it does not publish (Robins besluit).
248router.get('/api/feed', requireAuth, (req, res) => {
249 const site = siteForUser(req);
250 if (!site) return res.status(404).json({ error: 'no_site' });
251 const L = resolveLang(req);
252 ensureWardConnections(site);
253 const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
254 // Only show the wards you actually guard (the timeline can hold more).
255 const items = AP.getTimeline(site.slug, 60, 0)
256 .filter((p) => wardUris.has(p.author_uri))
257 .map((p) => ({
258 id: p.id,
259 author: p.author_handle || p.author_name || p.author_uri,
260 authorUri: p.author_uri, // the grouping key: which child's panel this belongs in
261 authorName: p.author_name,
262 authorIcon: p.author_icon,
263 content: p.content,
264 url: p.url,
265 published: p.published || p.created_at,
266 when_text: formatDateTime(p.published || p.created_at),
267 cw: p.cw || null,
268 media: p.media_json ? JSON.parse(p.media_json) : [],
269 // Een post van je ward hoort er hetzelfde uit te zien als in de Krant en
270 // in Berichten: dezelfde partial, dus opmaak, media, quote-kaart en
271 // embed. Tot nu toe kreeg de PWA alleen kale content -- een guardian zag
272 // een lege regel waar een foto stond. `content` blijft ernaast staan voor
273 // een client die nog uit de cache draait.
274 body_html: renderNoteBody(p, L),
275 }));
276 res.json({ items, following: wardUris.size });
277});
278
279// โ”€โ”€ Follow-gating (FEP-633c ยง5.3): pending follows on MY wards, for me to
280// approve. Ward and guardian are co-located on the family Klonkt here, so
281// the guardian reads its wards' pending follows locally.
282function wardSlugsOf(site) {
283 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
284 return Guardianship.listWards(site.slug)
285 .map((w) => (w.other_uri.startsWith(base) ? { slug: w.other_uri.split('/').pop(), uri: w.other_uri } : null))
286 .filter(Boolean);
287}
288
289router.get('/api/follow-requests', requireAuth, (req, res) => {
290 const site = siteForUser(req);
291 if (!site) return res.status(404).json({ error: 'no_site' });
292 const items = [];
293 const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
294 // wardUri is the grouping key for the per-ward panel: the handle is for
295 // reading, the URI is what identifies the child across both cases below.
296 // Local wards (guardian co-located): read the pending follows directly.
297 for (const w of wardSlugsOf(site)) {
298 for (const f of Guardianship.follows.listForWard(w.slug)) {
299 items.push({ id: f.id, ward: `@${w.slug}@${host}`, wardUri: w.uri, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, remote: false, created: f.created_at });
300 }
301 }
302 // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
303 for (const rev of Guardianship.follows.listReviews(site.slug)) {
304 const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
305 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 });
306 }
307 res.json({ items });
308});
309
310router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
311 const site = siteForUser(req);
312 if (!site) return res.status(404).json({ error: 'no_site' });
313 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
314 const me = AP.actorId(base, site.slug);
315 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
316
317 // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
318 // which tallies quorum and returns the Accept(Follow) to the follower.
319 const review = Guardianship.follows.getReview(site.slug, req.params.id);
320 if (review) {
321 try { await AP.sendFollowDecision(site, review, decision); }
322 catch { return res.status(502).json({ error: 'delivery' }); }
323 Guardianship.follows.removeReview(site.slug, req.params.id);
324 return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
325 }
326
327 // Local ward: decide directly (quorum on this instance).
328 const pending = Guardianship.follows.getPending(req.params.id);
329 if (!pending) return res.status(404).json({ error: 'gone' });
330 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
331 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
332 // Acting from the dashboard is an answer (3.6), and the quorum runs over
333 // the available set (3.5): both applied here, the same as over the wire.
334 Guardianship.availability.oneAnswer(me, Date.now());
335 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
336 const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
337 try {
338 if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
339 else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
340 } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
341 res.json({ ok: true, outcome: r.outcome });
342});
343
344// โ”€โ”€ ยง5.3, the other direction (shaer-p729): the ward wants to follow SOMEONE,
345// and the guardians decide. Same quorum arithmetic and the same availability
346// rules as the inbound gate above; only the question is turned around, which
347// is why it gets its own endpoint rather than a flag on that one.
348router.post('/api/outgoing-follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
349 const site = siteForUser(req);
350 if (!site) return res.status(404).json({ error: 'no_site' });
351 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
352 const me = AP.actorId(base, site.slug);
353 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
354
355 const pending = Guardianship.outgoing.getPending(req.params.id);
356 if (!pending) return res.status(404).json({ error: 'gone' });
357 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
358 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
359 Guardianship.availability.oneAnswer(me, Date.now());
360 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
361 const r = Guardianship.outgoing.decide(pending.id, me, decision, guardians);
362 try {
363 // Only on approval does anything leave the building. A refusal is a local
364 // fact: the follow was never sent, so there is nothing out there to undo
365 // and nobody to inform that a child asked about them.
366 if (r.outcome === 'approved') await AP.performApprovedFollow(r.follow);
367 } catch { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
368 res.json({ ok: true, outcome: r.outcome });
369});
370
371// โ”€โ”€ Wave (FEP-633c ยง5, shaer:wave): a gentle "thinking of you" from a
372// guardian to a ward. A private direct note, never a feed post. Warmth
373// without publishing (Robins besluit).
374router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
375 const site = siteForUser(req);
376 if (!site) return res.status(404).json({ error: 'no_site' });
377 const wardUri = String(req.body?.ward || '').trim();
378 // Only wave at a ward you actually guard.
379 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
380 if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
381 const text = String(req.body?.text || '').trim().slice(0, 200) || '๐Ÿ‘‹ thinking of you';
382 const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
383 if (!r) return res.status(502).json({ error: 'delivery' });
384 res.json({ ok: true, delivered: r.delivered });
385});
386
387// โ”€โ”€ Een hulpvraag oppikken of afsluiten (shaer-lgo) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
388// Gaat naar de WARD en naar de MEDE-GUARDIANS. De ward hoort te weten dat er
389// iemand komt -- dat is de helft van de gerustheid -- en de anderen dat het
390// loopt, zodat niemand denkt dat de ander het al doet.
391//
392// OPPIKKEN mag stapelen: twee mensen die tegelijk reageren is geen probleem.
393// AFSLUITEN kent geen terugdraai; leeft de vraag nog, dan wordt hij opnieuw
394// gesteld. De stevige bevestiging zit in de client, net als bij het loslaten van
395// een ward: nooit een window.confirm.
396router.post('/api/help/:kind', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
397 const site = siteForUser(req);
398 if (!site) return res.status(404).json({ error: 'no_site' });
399 const kind = req.params.kind === 'handled' ? 'handled' : 'pickup';
400 const noteUri = String(req.body?.note || '').trim();
401 const wardUri = String(req.body?.ward || '').trim();
402 if (!noteUri || !/^https?:\/\//i.test(noteUri)) return res.status(400).json({ error: 'no_note' });
403 // Alleen over een hulpvraag van een kind dat je echt bewaakt.
404 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
405 if (!isWard) return res.status(403).json({ error: 'not_your_ward' });
406
407 const me = AP.actorId((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site.slug);
408 // Onze eigen kopie meteen, zonder op bezorging te wachten: het scherm van
409 // degene die klikt hoort niet te liegen omdat een andere server traag is.
410 Guardianship.help.record(noteUri, me, kind, null);
411
412 const anderen = Guardianship.listGuardians(wardUri.replace(/.*\/ap\/users\//, '')) || [];
413 const ontvangers = [wardUri, ...anderen.map((g) => g.other_uri)].filter((u) => u && u !== me);
414 const r = await AP.deliverDirectNote(site, {
415 recipients: ontvangers,
416 text: kind === 'handled' ? 'Deze hulpvraag is afgehandeld.' : 'Ik kijk hiernaar.',
417 helpMark: { kind, noteUri },
418 }).catch(() => null);
419 // Bezorging kan mislukken; de eigen staat staat er dan toch. Dat melden we,
420 // want "verstuurd" zeggen terwijl het niet aankwam is hier het ergste soort
421 // stilte.
422 res.json({ ok: true, delivered: r ? r.delivered : 0, recipients: ontvangers.length });
423});
424
425// โ”€โ”€ Adopt a ward: handle โ†’ resolve โ†’ C2S Offer through the same pipeline
426// the Shaer apps use (one path, one behavior).
427router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
428 const site = siteForUser(req);
429 if (!site) return res.status(404).json({ error: 'no_site' });
430 const handle = String(req.body?.handle || '').trim();
431 if (!handle) return res.status(400).json({ error: 'empty_handle' });
432 const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
433 if (!wardUri) return res.status(404).json({ error: 'not_found' }); // the handle does not resolve to an account
434 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
435 const me = AP.actorId(base, site.slug);
436 const r = await AP.ingestOutboxActivity(site, req.session.user, {
437 type: 'Offer',
438 object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
439 });
440 // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
441 // offer is recorded and delivery is retried in the background.
442 if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
443 res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
444});
445
446// โ”€โ”€ Answer an offer (co-guardian accept/reject, or the candidate's final
447// "complete"). All three are a C2S Accept/Reject on the offer id; the
448// handshake module decides when it commits (ยง3.1).
449// โ”€โ”€ Step away (FEP-633c 3.6.1): the guardian declares itself unavailable โ”€โ”€
450// One direct note with shaer:away and an endTime to every ward, the same path
451// Shaer takes over C2S, and the only path: a ward on this instance receives
452// that note through the loopback and applies the absence in its own inbox
453// handler, exactly as a ward elsewhere does. This route used to write the
454// local wards itself as well, which meant the wire version could break without
455// anyone here noticing.
456router.post('/api/away', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
457 const site = siteForUser(req);
458 if (!site) return res.status(404).json({ error: 'no_site' });
459 const days = Math.min(365, Math.max(1, parseInt(req.body?.days, 10) || 0));
460 if (!days) return res.status(400).json({ error: 'away_needs_an_end' });
461 const wards = Guardianship.listWards(site.slug).map((w) => w.other_uri);
462 if (!wards.length) return res.status(409).json({ error: 'no_wards' });
463 const until = Date.now() + days * 24 * 3600 * 1000;
464 const L = resolveLang(req);
465 const text = i18nT(L, 'guardian.away_msg', { date: new Date(until).toLocaleDateString('nl-NL') });
466 const r = await AP.deliverDirectNote(site, { recipients: wards, text, awayUntil: until }).catch(() => null);
467 if (!(r && r.id)) return res.status(502).json({ error: 'away_failed' });
468 res.json({ ok: true, until });
469});
470
471// โ”€โ”€ Propose a lapse (FEP-633c 3.6.3) against a dormant co-guardian โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
472// The same C2S pipeline the Shaer apps would use: an Offer of shaer:Lapse.
473// A local ward opens directly; a remote ward gets the proposal delivered,
474// because the ward's server is the one that tallies and enforces.
475router.post('/api/lapse', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
476 const site = siteForUser(req);
477 if (!site) return res.status(404).json({ error: 'no_site' });
478 const ward = String(req.body?.ward || '').trim();
479 const target = String(req.body?.target || '').trim();
480 if (!ward || !target) return res.status(400).json({ error: 'missing_ward_or_target' });
481 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === ward)) {
482 return res.status(403).json({ error: 'not_my_ward' });
483 }
484 const r = await AP.ingestOutboxActivity(site, req.session.user, {
485 type: 'Offer', object: { type: 'shaer:Lapse', 'shaer:ward': ward, object: target },
486 });
487 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'lapse_failed' });
488 res.json({ ok: true, lapse: r.id });
489});
490
491// โ”€โ”€ Answer a forwarded gated-setting proposal (FEP-633c 5.6) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
492// The decision belongs to the ward's server, so the answer travels there as an
493// Accept/Reject on the offer id, exactly like a gated follow's decision.
494router.post('/api/gated/:id', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
495 const site = siteForUser(req);
496 if (!site) return res.status(404).json({ error: 'no_site' });
497 const review = Guardianship.gated.getGatedReview(site.slug, req.params.id);
498 if (!review) return res.status(404).json({ error: 'gone' });
499 const agree = req.body?.answer !== 'reject';
500 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
501 const me = AP.actorId(base, site.slug);
502 const activity = {
503 id: `${me}#gated-${Date.now().toString(36)}`,
504 type: agree ? 'Accept' : 'Reject', actor: me, to: [review.ward_uri], object: review.id,
505 };
506 try { await AP.deliverToActor(site, review.ward_uri, activity); }
507 catch { return res.status(502).json({ error: 'delivery' }); }
508 Guardianship.gated.removeGatedReview(site.slug, review.id);
509 res.json({ ok: true, answer: agree ? 'accept' : 'reject' });
510});
511
512router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
513 const site = siteForUser(req);
514 if (!site) return res.status(404).json({ error: 'no_site' });
515 const offerId = String(req.body?.offer || '').trim();
516 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
517 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
518 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
519 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
520 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
521});
522
523// โ”€โ”€ PWA assets served no-cache, so an update is never masked by the 1-year
524// /assets cache or a stuck install (that was the whole "nothing works after
525// a deploy" bug). Small files; the browser revalidates and gets a 304 when
526// unchanged, the fresh file when changed.
527function pwaAsset(rel, type) {
528 return (req, res) => {
529 res.set('Cache-Control', 'no-cache');
530 res.type(type);
531 res.sendFile(path.join(__dir, '..', 'assets', rel));
532 };
533}
534router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
535router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
536
537// โ”€โ”€ Manage: release a committed ward (local Undo; federation is Fase 4). โ”€โ”€
538/**
539 * What actually happens if this guardian releases this ward?
540 *
541 * Releasing is not one action but two very different ones, and the difference
542 * is the number of guardians the child has left (FEP-633c):
543 * - more than one โ†’ ยง3.3, you step down and the child stays a ward;
544 * - you are the last โ†’ ยง3.4, that is emancipation, and the FEP is explicit
545 * that no single guardian decides it alone (three consenting adults, or a
546 * majority plus two witnesses).
547 * On top of that, today's release is LOCAL: the Undo is not federated yet
548 * (relations.js, fase 4), so the ward's server keeps listing this guardian.
549 * A guardian pressing the button would otherwise believe the child is released.
550 *
551 * Answered on demand rather than in the dashboard state: for a ward we do not
552 * host this reaches out to that ward's server, and nobody should pay for that
553 * on every refresh.
554 */
555router.get('/wards/release-check', requireAuth, async (req, res) => {
556 const site = siteForUser(req);
557 if (!site) return res.status(404).json({ error: 'no_site' });
558 const uri = String(req.query.uri || '').trim();
559 if (!uri) return res.status(400).json({ error: 'empty_uri' });
560 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
561 return res.status(403).json({ error: 'not_my_ward' });
562 }
563 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
564 const local = !!base && uri.startsWith(`${base}/`);
565 let guardians = null; // null = we could not find out; say so rather than guess
566 if (local) {
567 const slug = uri.replace(/\/+$/, '').split('/').pop();
568 try { guardians = Guardianship.listGuardians(slug).length; } catch { /* stays null */ }
569 } else {
570 const doc = await AP.fetchActor(uri).catch(() => null);
571 const g = doc && doc['shaer:guardians'];
572 if (Array.isArray(g)) guardians = g.length;
573 else if (typeof g === 'string') guardians = 1;
574 else if (g && Array.isArray(g.items)) guardians = g.items.length;
575 else if (doc) guardians = 0; // the actor answered and names no guardians
576 }
577 res.json({
578 guardians,
579 last: guardians === null ? null : guardians <= 1,
580 local,
581 });
582});
583
584// โ”€โ”€ The fellow guardians of a ward, wherever it lives โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
585// A guardian looking at a ward's panel should see who else holds a seat: that
586// is the child's safety net, and "dit kind woont op een andere server" is not
587// an answer. For a local ward the availability rides along (we do that
588// bookkeeping). For a remote ward we read the PUBLIC membership from its
589// actor document (shaer:guardians, ยง2.1) and nothing more: availability is
590// the ward's server's private ledger (ยง3.6.1) and stays there. Fetched on
591// panel-open rather than into the dashboard, so one slow remote server does
592// not hold the whole screen hostage.
593router.get('/wards/guardians', requireAuth, async (req, res) => {
594 const site = siteForUser(req);
595 if (!site) return res.status(404).json({ error: 'no_site' });
596 const uri = String(req.query.uri || '').trim();
597 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
598 return res.status(403).json({ error: 'not_my_ward' });
599 }
600 const local = Guardianship.queues.wardGuardianStatuses(uri);
601 if (local) return res.json({ local: true, guardians: local });
602 const doc = await AP.fetchActor(uri).catch(() => null);
603 let g = doc && doc['shaer:guardians'];
604 if (g && Array.isArray(g.items)) g = g.items; // a Collection
605 const guardians = (Array.isArray(g) ? g : (typeof g === 'string' ? [g] : []))
606 .filter((x) => typeof x === 'string')
607 .map((u) => {
608 try { const p = new URL(u); return { uri: u, handle: `@${p.pathname.replace(/\/+$/, '').split('/').pop()}@${p.host}` }; }
609 catch { return { uri: u, handle: u }; }
610 });
611 res.json({ local: false, guardians });
612});
613
614router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
615 const site = siteForUser(req);
616 if (!site) return res.status(404).json({ error: 'no_site' });
617 const uri = String(req.body?.uri || '').trim();
618 if (!uri) return res.status(400).json({ error: 'empty_uri' });
619 // Ending a guardianship is an Undo of the Relationship that travels to the
620 // ward and the other guardians (ยง3.2), not a local delete. Same call the
621 // Guardian apps reach over C2S, so the two cannot drift apart.
622 const r = await Guardianship.endGuardianship(site, uri);
623 if (r.status >= 400) return res.status(r.status).json({ error: r.error });
624 res.json({ ok: true, delivered: r.delivered, guardiansLeft: r.guardiansLeft });
625});
626
627/**
628 * The external-embeds setting of a ward we host: true/false when a guardian has
629 * decided, null when it is still on auto (which means off for a ward) or when
630 * the ward lives elsewhere and the setting is not ours to show.
631 */
632function wardEmbedSetting(uri) { return wardGateSetting(uri, 'external_embeds'); }
633/** The playback gate of a ward we host (5.6): the heavier sibling. */
634function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
635
636function wardGateSetting(uri, column) {
637 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
638 if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
639 const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
640 const row = slug ? db.prepare(`SELECT ${column === 'external_playback' ? 'external_playback' : 'external_embeds'} AS v FROM sites WHERE slug = ?`).get(slug) : null;
641 if (!row) return null;
642 return row.v === null || row.v === undefined ? false : row.v === 1;
643}
644
645// โ”€โ”€ Gated feature: may this ward see external (non-fediverse) embeds? โ”€โ”€
646// The first real gated setting (FEP-633c ยง5-style). The gate itself is applied
647// server-side when the feed is serialised, so this endpoint is the only way it
648// can move, and only a committed guardian of THAT ward may move it.
649router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
650 // Niet meer alleen embeds/playback: elke gate uit de catalogus met een kolom
651 // is voorstelbaar (8-8, "maak ze allemaal functioneel"). De oude regel
652 // HERSCHREEF een onbekende feature stilletjes naar externalEmbeds -- een
653 // voorstel voor de ene poort dat op de andere landt is precies het soort
654 // fout dat een guardian nooit mag overkomen. Onbekend wordt nu geweigerd.
655 const feature = String(req.body?.feature || 'shaer:externalEmbeds');
656 if (!Guardianship.gated.featureColumn(feature)) return res.status(400).json({ error: 'unknown_feature' });
657 req.body = { ...req.body, feature };
658 return proposeGated(req, res);
659});
660function proposeGated(req, res) {
661 const site = siteForUser(req);
662 if (!site) return res.status(404).json({ error: 'no_site' });
663 // De hele afweging staat in AP.proposeGate, zodat de apps langs dezelfde weg
664 // kunnen voorstellen (shaer-8ru). Deze route is nog maar de PWA-deur ernaartoe.
665 const uit = AP.proposeGate(site, req.body?.uri, req.body?.feature, req.body?.allow === true);
666 const { status, ...rest } = uit;
667 return res.status(status === 200 ? 200 : status).json(rest);
668}
669
670// โ”€โ”€ The installable identity: own scope so the Guardian corner installs as
671// its own app next to the site PWA.
672router.get('/manifest.webmanifest', (req, res) => {
673 const site = res.locals.site;
674 res.set('Cache-Control', 'no-cache');
675 res.json({
676 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
677 name: 'Klonkt Guardian',
678 short_name: 'Guardian',
679 description: 'Ward management and help requests for guardians.',
680 scope: '/guardian/',
681 start_url: '/guardian?source=pwa',
682 display: 'standalone',
683 display_override: ['standalone', 'minimal-ui'],
684 orientation: 'any',
685 background_color: '#141a24',
686 theme_color: '#ff6b35',
687 lang: site?.language || 'nl',
688 icons: [
689 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
690 ],
691 });
692});
693
694// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
695router.get('/icon.svg', (req, res) => {
696 const svg = `<?xml version="1.0" encoding="UTF-8"?>
697<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
698 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
699 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
700</svg>`;
701 res.set('Content-Type', 'image/svg+xml');
702 res.set('Cache-Control', 'public, max-age=86400');
703 res.send(svg);
704});
705
706// Losse guardian-accounts (guardian-lite: /invite + /join, user + site met
707// guardian_only=1) zijn verwijderd op 31-7-2026. Een instance is een eigenaar;
708// zo'n account was de laatste multi-user-rest en zette bovendien andermans
709// wachtwoordhash, sessie en PRIVATE actor-sleutel in jouw database, wat een
710// verhuizing (shaer-qw6q) onmogelijk netjes maakte. Een guardian hoort een
711// eigen Klonkt te hebben; de adoptie loopt dan gewoon over de federatie.
712
713export default router;
Note: See TracBrowser for help on using the repository browser.