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

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

Stilte hoort niets te kosten: 304 op de guardian-wachtrijen (Barts punt, 9-8)

Bart wees op wat er al bestond: de inbox stuurt een 304 als er niets veranderd
is (since + wait), en de guardian-wachtrijen deden dat niet. Die stuurden bij
elke verversing de hele lijst terug -- bij honderd wards veertienhonderd
objecten. Over de lijn valt dat mee (2,8 KB gzip), maar het OPBOUWEN en parsen is
wat een telefoon merkt, en dat is precies de oude data die je elke keer weer
terugkrijgt.

En zijn punt over de waarschuwing klopte ook: de app ververst al voor de
guardian, tachtig keer per uur. Iemand waarschuwen voor een gewoonte die de app
zelf heeft is de verkeerde kant op redeneren.

EEN INHOUDS-ETAG, GEEN CURSOR. Een cursor vraagt een tweede beschrijving van
wanneer iets "veranderd" is, en die kan uit de pas lopen met wat er werkelijk in
het antwoord staat; een hash van het antwoord zelf kan dat per definitie niet.
De server bouwt het antwoord nog steeds -- wat we besparen is de overdracht en
het parsen.

NOOIT 304 OP EEN LEEG ANTWOORD, dezelfde les als de '0'-uitzondering bij de
inbox: gaat er bij het opbouwen iets mis en komt er een lege lijst uit, dan is
die hash ook stabiel en kijkt een client voor eeuwig naar niets. Daar staat een
toets op, en de mutatie maakt hem rood.

no-cache betekent niet "niet bewaren" maar "bewaar en vraag na" -- zonder dat
stuurt een browser geen If-None-Match en is de ETag decoratie.

Vijf toetsen, twee mutaties gecontroleerd. Suite 761/761.

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