source: Klonkt/src/routes/guardian.js@ 1d76e0e

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

Je hoort te weten dat je de doorslag geeft (shaer-8vt)

De telling is een race naar de drempel: zodra het aantal gehaald is, is het
besluit gevallen. Bij 2 van 3 is de tweede ja meteen de beslissing -- en sinds
Barts meerderheidsbesluit van vandaag is bij een volgverzoek met twee guardians
de EERSTE ja dat al. Wie antwoordde wist dat niet, en het scherm zei het nergens.

Dat kon niet, en waarom niet was de vondst bij shaer-nf9: gatedProgress werkt op
een LOKALE slug en er zijn geen lokale wards. De telling loopt op de server van
het kind, en die stuurde hem niet mee. Nu wel: shaer:decisive reist mee met de
doorgestuurde Offer, voor gate-voorstellen en voor volgverzoeken in beide
richtingen.

EEN JA/NEE, GEEN TELLING, en dat is een besluit. Een getal ("1 van 2") reist mee,
veroudert onderweg en leest daarna als een feit; de beschikbare set schuift met
3.6 bovendien mee. En hoeveel guardians een kind heeft, en wie er al gestemd
heeft, is niet vanzelf iets dat elke mede-guardian hoort te zien. Een
waarschuwing veroudert ook, maar hij CLAIMT niets -- en dat scheelt.

BIJ TWIJFEL WAARSCHUWEN. Ontbreekt het veld (een oudere server), dan zeggen we
dat je beslist. De twee fouten zijn niet gelijk: zeggen dat je beslist terwijl
dat niet zo is maakt iemand voorzichtiger dan nodig; niets zeggen terwijl hij wel
beslist laat hem het onwetend doen. Daar staat een toets op, en de mutatie die
ertoe doet -- onbekend als "je beslist niets" lezen -- maakt hem rood.

Eerlijk over de mutatietest: mijn eerste poging (de Number.isFinite-guards
weghalen) gaf nul fouten, want die geven voor undefined dezelfde uitkomst. De
regel bijt wel tegen de realistische verkeerde versie.

Zes toetsen, drie talen. Suite 747/747.

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