source: Klonkt/src/routes/guardian.js@ b2646cc

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

Waarschuwen voor wat er blijft hangen (shaer-nf9)

Barts opdracht: waarschuw bij het openen en het accepteren van een gate-voorstel.
Zijn zin was "Een geopende poort gaat niet meer dicht!"

DIE ZIN STAAT ER NIET, EN DAT IS MET OPZET. Als uitspraak over de INSTELLING is
hij onwaar: shaer-ahy eist het tegendeel en de code doet het -- een voorstel
draagt true of false. Een waarschuwing die aantoonbaar niet klopt neemt de rest
van het scherm mee in zijn val zodra iemand het merkt.

Wat wel onomkeerbaar is, is het GEVOLG, en die versie is zwaarder:

Wat hier doorheen komt, komt niet meer terug. Je kunt deze instelling later
weer dichtzetten -- wat je kind gezien heeft niet.

Independence krijgt zijn eigen tekst, want dat draagt gezag over en is wel echt
onomkeerbaar (shaer-90v). Welke tekst je krijgt volgt uit de catalogus, dus een
nieuwe gate hoeft niets te bedenken.

ONBEKEND KRIJGT DE ZWAARSTE. Een guardian elders kan iets voorstellen dat onze
catalogus niet kent. Bij twijfel waarschuwen we zwaarder, niet lichter -- de
faalstand die pijn doet is een guardian die iets doorlaat omdat het scherm er
licht over deed.

ALLEEN BIJ OPENZETTEN. Dichtzetten laat niets nieuws door, en een waarschuwing
die overal staat wordt nergens gelezen.

BIJ ACCEPTEREN KON HET GEVRAAGDE NIET. De bead stelde voor te tonen of jouw
antwoord de drempel haalt, want gatedProgress bestaat al. Maar die werkt op een
LOKALE slug, en er zijn geen lokale wards -- voor deze kaart komt hij altijd op
null uit. Een verzonnen "1 van 2" zou hier het gevaarlijkste getal op het scherm
zijn: het leest als "er kan nog iemand na mij". Er staat nu wat waar is: wij zien
de telling niet, die loopt op de server van het kind, en jouw ja kan de doorslag
geven -- want de tally settelt op het moment dat de drempel gehaald is.

Geen window.confirm, zoals bij het loslaten van een ward: de uitleg staat er, en
dan pas de knop. Drie talen. Vier toetsen, twee vallen om bij mutatie. 619/619.

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