source: Klonkt/src/routes/guardian.js@ 23da947

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

Alle poorten zichtbaar, en de lijst ingeklapt (shaer-ahy.1, vervolg)

Barts wens: de negen geplande gates ook tonen, en de lijst compacter -- het liefst
ingeklapt met een uitklap.

DE NEGEN STAAN ERIN, MAAR NIET ALS DICHTE DEUR. Acht ervan bestaan nog niet:
featureColumn() kent de namen niet, dus een voorstel zou stranden op
unknown_feature. En ze als "uit" tonen zou ronduit onwaar zijn -- plaatjes werken
vandaag gewoon. Ze krijgen daarom een eigen stand, "nog niet beschikbaar", met een
gestippelde chip, geen drempel en geen knop. Een lege plek, geen gesloten poort.

Elke geplande rij draagt zijn bead, zodat het paneel meteen de weg wijst naar waar
die gate gebouwd wordt.

Het kind van de geplande gates is voorlopig en dat staat er ook bij: of
accountmigratie een stand is of een besluit per keer hoort bij het bouwen van
shaer-tge beslist te worden, niet hier. Independence is de enige die gezag
OVERDRAAGT en dus als enige onomkeerbaar (shaer-90v).

INGEKLAPT MET EEN SAMENVATTING. Met twaalf poorten duwt een open lijst alles wat
eronder staat -- guardians, volgverzoeken, hulpvragen -- van het scherm. De regel
erboven zegt wat je meestal wilt weten: hoeveel poorten er zijn, hoeveel er aan
staan en hoeveel er op je wachten. Uitklappen blijft over een verversing heen
staan, net als het wardpaneel zelf; anders valt hij elke 45 seconden dicht.

Negen labels in nl/en/de. Suite 611/611.

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