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

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

Een hulpvraag oppikken en afsluiten, zichtbaar voor alle guardians (shaer-lgo)

Een hulpvraag (5.2.1) gaat naar ALLE guardians van een kind, op verschillende
servers. Zonder gedeelde staat denken er twee dat de ander het oppakt -- precies
het scenario waar de reddingsboei voor bestaat. Tot nu toe droeg een hulpvraag
geen enkele staat: een vlag op ap_mentions, verder niets.

DE FAALSTAND IS HIER NIET VEILIG, en dat stuurt het hele ontwerp. Bij een gate is
"dicht" het veilige antwoord. Hier is de faalstand "iedereen denkt dat het
geregeld is", en dat is gevaarlijker dan geen markering. Daarom staat een
hulpvraag bij twijfel OPEN: een lege lijst, een rij die we niet kunnen lezen, een
soort die we niet kennen -- alles wat geen expliciete afsluiting is telt als
"er wacht nog iemand".

Twee besluiten van Bart zitten in de vorm.

OPGEPIKT mag stapelen en vervalt niet, maar veroudert zichtbaar. Twee mensen die
tegelijk reageren op een kind is geen probleem; twee die allebei niets doen omdat
de ander het "geclaimd" had, wel. En een signaal dat vanzelf verdwijnt laat een
hulpvraag er onaangeroerd uitzien terwijl er iemand mee bezig is -- dus het blijft
staan en toont hoe oud het is.

AFGEHANDELD kent GEEN terugdraai. Sluiten gaat met een stevige bevestiging (geen
window.confirm, zelfde lijn als het loslaten van een ward), en leeft de vraag
daarna nog, dan wordt hij opnieuw gesteld -- een nieuwe hulpvraag. Er wordt niets
herschreven, er wordt toegevoegd. Een test bewaakt dat er geen undo() of
reopen() bestaat.

Geen nieuw protocol: de markering is een gewone directe note met een
shaer:-eigenschap, net als de zwaai en de afwezigheidsmelding. Daardoor reist hij
over de bestaande bezorging, ziet de WARD hem als bericht ("er komt iemand") en
houden de mede-guardians er staat aan over. Naar de ward en naar de andere
guardians tegelijk.

De eigen kopie wordt meteen weggeschreven, voordat er bezorgd is: het scherm van
degene die klikt hoort niet te liegen omdat een andere server traag is.

11 tests op het pure stuk. Gecontroleerd dat ze bijten: laat helpStatus altijd
"afgehandeld" zeggen en er vallen er vijf om. nl/en/de. Suite 576/576.

Niet gedekt: de weergave zelf (client-JS), en er is geen guardianship op dev om
het end-to-end te zien lopen.

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